Skip to main content

How to Build a CLI with Node.js: Commander vs yargs vs oclif

·PkgPulse Team

TL;DR

Commander.js for simple CLIs; oclif for multi-command tools with plugins. Commander (~35M weekly downloads) is the most popular — minimal API, excellent TypeScript support. yargs (~30M) has richer middleware and configuration. oclif (~200K) is Salesforce's enterprise framework — generates multi-command CLIs with plugin architecture. For most CLIs: Commander + esbuild + tsx for development.

Key Takeaways

  • Commander: ~35M downloads — simple, composable, TypeScript-native
  • yargs: ~30M downloads — builder pattern, middleware, yargs-parser
  • oclif: ~200K downloads — Heroku/Salesforce standard, plugin system, command classes
  • Use esbuild to bundle your CLI into a single portable binary
  • Ship as both ESM and CJS or use the bin field with executable shebang

#!/usr/bin/env node
// src/cli.ts — Commander-based CLI
import { Command } from 'commander';
import { readFileSync } from 'fs';
import { resolve } from 'path';

const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));

const program = new Command();

program
  .name('mytool')
  .description('My awesome CLI tool')
  .version(pkg.version);

// Simple command
program
  .command('hello <name>')
  .description('Say hello to someone')
  .option('-l, --loud', 'Loud mode (uppercase)')
  .option('-t, --times <number>', 'How many times', '1')
  .action((name: string, options: { loud: boolean; times: string }) => {
    const times = parseInt(options.times);
    const message = options.loud ? `HELLO, ${name.toUpperCase()}!` : `Hello, ${name}!`;
    for (let i = 0; i < times; i++) {
      console.log(message);
    }
  });

// Command with subcommands
const db = program.command('db').description('Database operations');

db
  .command('migrate')
  .description('Run database migrations')
  .option('--dry-run', 'Show what would run without executing')
  .action(async (options: { dryRun: boolean }) => {
    console.log('Running migrations...', options.dryRun ? '(dry run)' : '');
    // Run migrations
  });

db
  .command('seed')
  .description('Seed the database')
  .option('-e, --env <env>', 'Environment', 'development')
  .action(async (options: { env: string }) => {
    console.log(`Seeding database for ${options.env}`);
  });

// Global error handler
program.configureOutput({
  writeErr: (str) => process.stderr.write(`Error: ${str}`),
  outputError: (str, write) => write(`\n${str}\n`),
});

program.exitOverride();  // Throw instead of calling process.exit (testable)

program.parse();

yargs (Rich Middleware Support)

#!/usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

yargs(hideBin(process.argv))
  .scriptName('mytool')
  .usage('$0 <command> [options]')

  .command(
    'deploy <environment>',
    'Deploy to an environment',
    (yargs) =>
      yargs
        .positional('environment', {
          describe: 'Target environment',
          choices: ['staging', 'production'] as const,
        })
        .option('tag', {
          type: 'string',
          description: 'Docker image tag',
          default: 'latest',
        })
        .option('dry-run', {
          type: 'boolean',
          default: false,
        }),
    async (argv) => {
      const { environment, tag, dryRun } = argv;
      console.log(`Deploying ${tag} to ${environment}${dryRun ? ' (dry run)' : ''}`);
    }
  )

  .command(
    'config <action> [key] [value]',
    'Manage configuration',
    (yargs) =>
      yargs
        .positional('action', {
          choices: ['get', 'set', 'list'] as const,
        })
        .positional('key', { type: 'string' })
        .positional('value', { type: 'string' }),
    (argv) => {
      switch (argv.action) {
        case 'list': listConfig(); break;
        case 'get': getConfig(argv.key!); break;
        case 'set': setConfig(argv.key!, argv.value!); break;
      }
    }
  )

  // Global middleware (runs before every command)
  .middleware(async (argv) => {
    if (argv.verbose) console.log('Verbose mode on');
  })

  .option('verbose', { type: 'boolean', alias: 'v', global: true })
  .help()
  .strict()
  .parse();

oclif (Enterprise Multi-Command)

# Scaffold an oclif CLI
npx oclif generate mycli
cd mycli

# Directory structure:
# src/commands/         ← One file per command
# src/commands/hello.ts ← hello command
# src/commands/db/      ← db:migrate, db:seed (subcommands)
# src/hooks/            ← Lifecycle hooks
// src/commands/deploy.ts — oclif command
import { Command, Flags, Args } from '@oclif/core';

export default class Deploy extends Command {
  static description = 'Deploy to an environment';

  static examples = [
    '<%= config.bin %> deploy staging',
    '<%= config.bin %> deploy production --tag v1.2.0',
  ];

  static args = {
    environment: Args.string({
      description: 'Target environment',
      required: true,
      options: ['staging', 'production'],
    }),
  };

  static flags = {
    tag: Flags.string({
      char: 't',
      description: 'Docker image tag',
      default: 'latest',
    }),
    'dry-run': Flags.boolean({
      description: 'Simulate deployment without executing',
      default: false,
    }),
  };

  async run() {
    const { args, flags } = await this.parse(Deploy);

    this.log(`Deploying ${flags.tag} to ${args.environment}...`);

    if (flags['dry-run']) {
      this.warn('Dry run — no changes will be made');
      return;
    }

    // Deployment logic
    this.log('✅ Deployment complete');
  }
}

TypeScript + Build Setup

// package.json — CLI package setup
{
  "name": "mycli",
  "version": "1.0.0",
  "bin": {
    "mycli": "./dist/cli.js"
  },
  "scripts": {
    "dev": "tsx src/cli.ts",
    "build": "esbuild src/cli.ts --bundle --platform=node --target=node20 --outfile=dist/cli.js",
    "prepublishOnly": "npm run build"
  },
  "dependencies": {
    "commander": "^12.0.0"
  },
  "devDependencies": {
    "esbuild": "^0.23.0",
    "tsx": "^4.0.0",
    "typescript": "^5.3.0"
  }
}
// dist/cli.js — must have shebang
// Add to top of built file or entry point:
#!/usr/bin/env node

// esbuild doesn't add shebang automatically — use banner option:
// esbuild --banner:js='#!/usr/bin/env node'

Publishing to npm

# 1. Test locally
npm link        # Creates global symlink to your CLI
mycli --help    # Should work from anywhere

# 2. Publish
npm publish --access public  # For scoped: @yourname/mycli

# 3. Install globally
npm install -g mycli
# or: npx mycli --help (no install needed)

Compare CLI library package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.