Skip to main content

Build a CLI with Node.js: Commander vs yargs vs 2026

·PkgPulse Team
0

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

Why CLI Framework Choice Matters

Before the JavaScript ecosystem matured, most Node.js CLI tools were built with raw process.argv parsing or the built-in readline module. That works for simple cases but quickly becomes unmaintainable as you add subcommands, option validation, help text generation, and shell completion.

Modern CLI frameworks handle all of that scaffolding so you can focus on what your tool actually does. The choice of framework shapes how your project scales: a single-purpose build tool has very different requirements from a developer platform CLI with dozens of subcommands and a plugin ecosystem.

The three most widely adopted options in 2026 each occupy a distinct niche. Commander is the minimalist choice — do one thing well, no opinions. yargs adds a middleware layer that makes complex validation and configuration loading elegant. oclif is a complete platform for CLIs that need to grow.


Commander.js: The Minimalist Standard

Commander (~35M weekly downloads) has been the default choice for Node.js CLIs for years, and its staying power comes from its API design. The library does exactly what you need and nothing more: parse arguments, map them to commands, run handlers.

#!/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 with positional argument and options
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);
    }
  });

// Subcommand group — build and deploy under one namespace
const deploy = program.command('deploy').description('Deployment operations');

deploy
  .command('build')
  .description('Build the project for deployment')
  .option('--env <env>', 'Target environment', 'production')
  .option('--no-cache', 'Skip build cache')
  .action(async (options: { env: string; cache: boolean }) => {
    console.log(`Building for ${options.env}${options.cache ? '' : ' (no cache)'}...`);
    // Build logic here
  });

deploy
  .command('push <target>')
  .description('Push build artifacts to target')
  .option('--tag <tag>', 'Image tag to push', 'latest')
  .option('--dry-run', 'Print what would happen without executing')
  .action(async (target: string, options: { tag: string; dryRun: boolean }) => {
    if (options.dryRun) {
      console.log(`Would push tag ${options.tag} to ${target}`);
      return;
    }
    console.log(`Pushing ${options.tag} to ${target}...`);
  });

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

// exitOverride makes Commander throw instead of calling process.exit
// This makes your CLI testable without process mocking
program.exitOverride();

program.parse();

Commander's exitOverride() is worth noting: by default, Commander calls process.exit(1) on errors. Calling exitOverride() makes it throw a CommanderError instead, which is essential for unit testing your CLI without mocking process.exit.

The program.command('deploy') pattern creates a command group. Subcommands (build, push) are added to deploy rather than program, giving you mytool deploy build and mytool deploy push — clean namespacing without any special configuration.


yargs: Middleware and Rich Validation

yargs (~30M weekly downloads) differentiates itself with a declarative builder API and middleware support. Where Commander is imperative (do this, then this), yargs is more functional — you describe the shape of your CLI and yargs figures out the parsing.

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

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

  .command(
    'build',
    'Build the project',
    (yargs) =>
      yargs
        .option('env', {
          type: 'string',
          description: 'Target environment',
          choices: ['development', 'staging', 'production'] as const,
          default: 'production',
        })
        .option('no-cache', {
          type: 'boolean',
          default: false,
          description: 'Skip build cache',
        }),
    async (argv) => {
      console.log(`Building for ${argv.env}...`);
    }
  )

  .command(
    'deploy <target>',
    'Deploy to an environment',
    (yargs) =>
      yargs
        .positional('target', {
          describe: 'Deployment target',
          choices: ['staging', 'production'] as const,
        })
        .option('tag', {
          type: 'string',
          description: 'Docker image tag',
          default: 'latest',
        })
        .option('dry-run', {
          type: 'boolean',
          default: false,
        })
        // yargs .check() — custom validation with helpful error messages
        .check((argv) => {
          if (argv.target === 'production' && argv.tag === 'latest') {
            throw new Error('Deploying "latest" to production is not allowed — use a specific tag');
          }
          return true;
        }),
    async (argv) => {
      const { target, tag, dryRun } = argv;
      console.log(`Deploying ${tag} to ${target}${dryRun ? ' (dry run)' : ''}`);
    }
  )

  // Global middleware — runs before every command handler
  // Useful for: loading config, auth checks, telemetry
  .middleware(async (argv) => {
    if (process.env.CONFIG_PATH) {
      // Load config file and merge into argv
      const config = JSON.parse(await fs.readFile(process.env.CONFIG_PATH, 'utf8'));
      Object.assign(argv, config);
    }
    if (argv.verbose) {
      console.log('[verbose] Parsed args:', JSON.stringify(argv, null, 2));
    }
  })

  .option('verbose', {
    type: 'boolean',
    alias: 'v',
    global: true,
    description: 'Enable verbose logging',
  })
  .help()
  .strict()   // Error on unknown options
  .parse();

The .check() function is one of yargs's most useful features. It runs after argument parsing but before your command handler, with access to all parsed values. This is where you validate cross-option dependencies — "if deploying to production, require a non-latest tag" — things that can't be expressed with simple type or choice constraints.

The .middleware() chain is yargs's answer to Express-style middleware. Each function receives the parsed argv and can mutate it, load config files, verify authentication, or log telemetry before passing control to the command handler. Commander has no equivalent — you'd need to implement this yourself in each command.

yargs also ships with built-in shell completion via .completion(), which generates a completion script you can source in your shell. Commander requires a separate package like omelette for the same functionality.


oclif: Enterprise Multi-Command Architecture

oclif (~200K weekly downloads) takes a fundamentally different approach. Instead of a programmatic API you configure in one file, oclif is a full framework: you scaffold a project, generate command files, and the framework discovers and wires them automatically.

This is the approach used by the Heroku CLI, Salesforce CLI, and several other large developer tools. When your CLI has 20+ commands, a plugin ecosystem, and multiple teams contributing, oclif's file-based structure becomes an asset rather than overhead.

# Scaffold an oclif CLI project
npx oclif generate mycli
cd mycli

# Project structure:
# src/commands/           ← Each file = one command
# src/commands/build.ts   ← mytool build
# src/commands/deploy.ts  ← mytool deploy
# src/commands/deploy/    ← Subcommand directory
# src/hooks/              ← Lifecycle hooks (prerun, postrun, init)
# src/base-command.ts     ← Optional base class for shared behavior
// src/commands/build.ts — oclif command as a class
import { Command, Flags } from '@oclif/core';

export default class Build extends Command {
  static description = 'Build the project for deployment';

  static examples = [
    '<%= config.bin %> build',
    '<%= config.bin %> build --env staging --no-cache',
  ];

  static flags = {
    env: Flags.string({
      char: 'e',
      description: 'Target environment',
      options: ['development', 'staging', 'production'],
      default: 'production',
    }),
    'no-cache': Flags.boolean({
      description: 'Skip build cache',
      default: false,
    }),
  };

  async run() {
    const { flags } = await this.parse(Build);
    this.log(`Building for ${flags.env}...`);

    if (flags['no-cache']) {
      this.warn('Cache disabled — build will be slower');
    }

    // Build logic here
    this.log('Build complete');
  }
}
// src/commands/deploy.ts — oclif deploy command with args
import { Command, Flags, Args } from '@oclif/core';

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

  static args = {
    target: Args.string({
      description: 'Deployment target',
      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);

    if (args.target === 'production' && flags.tag === 'latest') {
      this.error('Cannot deploy "latest" to production — specify a tag with --tag', { exit: 1 });
    }

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

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

    this.log('Deployment complete');
  }
}

oclif's plugin architecture is its defining feature for large CLIs. Plugins are npm packages that export additional commands. Users can install them with mycli plugins install @mycli/analytics, and the commands appear in mycli automatically — no code changes required. This is exactly how the Salesforce CLI ships hundreds of commands across dozens of packages.


TypeScript and Build Setup

All three frameworks work well with TypeScript. The recommended setup differs:

{
  "name": "mycli",
  "version": "1.0.0",
  "type": "module",
  "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 --banner:js='#!/usr/bin/env node'",
    "prepublishOnly": "npm run build"
  },
  "dependencies": {
    "commander": "^12.0.0"
  },
  "devDependencies": {
    "esbuild": "^0.23.0",
    "tsx": "^4.0.0",
    "typescript": "^5.3.0",
    "@types/node": "^20.0.0"
  }
}

The --banner:js='#!/usr/bin/env node' flag in the esbuild command adds the shebang line to the output — esbuild doesn't do this automatically. Without it, users would need to invoke your CLI with node mycli rather than just mycli.

The tsx devDependency enables npm run dev which runs TypeScript directly without a compile step, dramatically speeding up the development loop. You only run esbuild when you need to publish.

For oclif, the scaffolded project uses tsc directly rather than esbuild, since oclif's plugin loading system depends on the file structure that TypeScript compilation preserves.


Distribution: npm, npx, and Standalone Binaries

# Development: test locally before publishing
npm link          # Creates a global symlink to your current build
mycli --help      # Works from anywhere on your machine
npm unlink mycli  # Remove when done

# Publishing to npm
npm publish --access public            # Public package
npm publish --access restricted        # Private (requires paid account)
# For scoped packages: @yourname/mycli
# npm publish --access public for scoped

# Users install globally
npm install -g mycli
# or run without installing
npx mycli --help

For CLIs that need to run on machines without Node.js installed (enterprise tools, DevOps utilities), you can bundle into standalone binaries using pkg or caxa:

# pkg — creates binaries for macOS, Linux, Windows
npm install -g pkg
pkg dist/cli.js --targets node20-macos-arm64,node20-linux-x64,node20-win-x64 --output dist/mycli

# Result:
# dist/mycli-macos-arm64  (~80MB — includes Node.js runtime)
# dist/mycli-linux-x64
# dist/mycli-win-x64.exe

The size (~80MB) is the tradeoff — you're bundling the entire Node.js runtime. For most developer tools, that's acceptable if it means zero-dependency installation for end users.


Shell Completion

Shell completion is an often-overlooked but significant quality-of-life feature. Users who can tab-complete your commands and flags adopt tools faster.

yargs has the cleanest built-in solution:

yargs(hideBin(process.argv))
  // ... commands ...
  .completion('completion', 'Generate shell completion script')
  .parse();

Users install it once:

mycli completion >> ~/.zshrc && source ~/.zshrc
# Now: mycli de<TAB> → mycli deploy

Commander requires a third-party package like omelette:

import omelette from 'omelette';

const completion = omelette('mycli <command>');
completion.on('command', ({ reply }) => {
  reply(['build', 'deploy', 'config']);
});
completion.init();

oclif generates completion scripts automatically via @oclif/plugin-autocomplete — one of its official plugins. Add the plugin to your package.json and users get mycli autocomplete setup to install completions for bash, zsh, or fish.


Package Health

PackageWeekly DownloadsBundle SizeTypeScriptLast ReleaseLicense
commander~35M6 KBNative2025MIT
yargs~30M8 KB@types/yargs2025MIT
@oclif/core~200K180 KBNative2025MIT

Commander and yargs have comparable download counts reflecting their use as transitive dependencies in millions of packages. oclif's lower count reflects its more targeted use case — but 200K downloads for an enterprise CLI framework is substantial.


When to Choose Each

ScenarioBest Pick
Simple utility with < 10 commandsCommander
Script with complex option validationyargs
Need middleware / config file loadingyargs
Built-in shell completionyargs
20+ commands across multiple contributorsoclif
Plugin architecture for end-user extensibilityoclif
CLI that ships as a platform (like Heroku CLI)oclif
Fastest dev setup, minimal dependenciesCommander + tsx + esbuild
Migrating from Java/Spring CLI patternsoclif (class-based)

The default recommendation for 2026: Start with Commander. Its minimal API means you can always add what you need (custom middleware, manual completion) without fighting the framework. If you find yourself writing the same validation logic in ten commands or needing plugin support, migrate to oclif — the class-based structure makes the refactor straightforward.


Testing CLI Applications

Testing is the area where CLI development differs most from library or application development, and it is frequently skipped because the patterns are less obvious. A CLI has three testable surfaces: the argument parsing logic, the command handler behavior, and the program's stdout/stderr output. Each requires a different approach.

Commander's exitOverride() is the foundation for unit testing argument parsing. Without it, any Commander error calls process.exit(1), which terminates your test process. With exitOverride(), Commander throws a CommanderError that your tests can catch:

// test/cli.test.ts
import { describe, it, expect } from 'vitest';
import { program } from '../src/cli';

describe('CLI argument parsing', () => {
  it('rejects unknown options', () => {
    expect(() => program.parse(['node', 'mycli', '--unknown-flag'])).toThrow();
  });

  it('parses --times as integer', () => {
    let capturedOptions: { times: string } | undefined;
    program.command('hello <name>').action((name, options) => {
      capturedOptions = options;
    });
    program.parse(['node', 'mycli', 'hello', 'Alice', '--times', '3']);
    expect(capturedOptions?.times).toBe('3');
  });
});

For testing stdout and stderr output, capture the streams rather than mocking console.log. The standard pattern is to replace process.stdout.write and process.stderr.write temporarily for each test:

function captureOutput(fn: () => void): { stdout: string; stderr: string } {
  const stdoutChunks: string[] = [];
  const stderrChunks: string[] = [];
  const originalStdout = process.stdout.write.bind(process.stdout);
  const originalStderr = process.stderr.write.bind(process.stderr);

  process.stdout.write = (chunk: string) => { stdoutChunks.push(chunk); return true; };
  process.stderr.write = (chunk: string) => { stderrChunks.push(chunk); return true; };

  try {
    fn();
  } finally {
    process.stdout.write = originalStdout;
    process.stderr.write = originalStderr;
  }

  return { stdout: stdoutChunks.join(''), stderr: stderrChunks.join('') };
}

Integration testing — running your actual compiled binary and checking what it produces — is the most realistic test level for CLIs. Use Node.js's child_process.execSync or execFileSync for synchronous CLIs, and execFile with a callback for async behavior:

import { execFileSync } from 'child_process';
import { resolve } from 'path';

const CLI_PATH = resolve(__dirname, '../dist/cli.js');

describe('CLI integration', () => {
  it('outputs help text when called with --help', () => {
    const result = execFileSync('node', [CLI_PATH, '--help'], {
      encoding: 'utf8',
      env: { ...process.env, NODE_ENV: 'test' },
    });
    expect(result).toContain('Usage:');
    expect(result).toContain('Commands:');
  });

  it('exits with code 1 on invalid arguments', () => {
    expect(() =>
      execFileSync('node', [CLI_PATH, '--invalid-flag'], { encoding: 'utf8' })
    ).toThrow(/Command failed/);
  });
});

Snapshot testing is valuable for CLI output that changes infrequently but needs to remain consistent. Testing that your --help output looks exactly right across versions catches accidental formatting regressions. Vitest's toMatchSnapshot() works well for this: the first run creates the snapshot, subsequent runs compare against it. Update snapshots deliberately with vitest --update-snapshots when you intentionally change output formatting.


Distribution and Publishing Strategy

Getting your CLI from a local build to something users can install requires decisions that affect user experience significantly.

The bin field in package.json is the entry point for npm-distributed CLIs. When a user runs npm install -g mycli, npm reads the bin field and creates a symlink from a directory on the user's PATH to your CLI's entry file. The entry file must have the #!/usr/bin/env node shebang as its first line — without it, the file is not executable as a command.

Publishing to npm with npm publish makes your CLI installable with both npm install -g mycli (global persistent install) and npx mycli (ephemeral run). npx downloads and runs the package in a temporary directory, which is increasingly the preferred distribution model because users do not need to manage globally installed packages. For CLIs that users run infrequently or just to bootstrap a project, npx is the expected install-free invocation pattern.

Scoped packages (@yourorg/mycli) require npm publish --access public for the first publish. Scoped packages are private by default and the --access public flag is easy to forget. Add it to your prepublishOnly check or use a .npmrc file with access=public scoped to your org.

For CLIs that need to run on machines without Node.js — enterprise deployment scripts, DevOps tools that need to work across different server configurations — standalone binaries via pkg or caxa package the Node.js runtime alongside your compiled code. The ~80MB binary includes everything needed to run. GitHub Releases is the right distribution channel for these: create a release, attach platform-specific binaries (mycli-macos-arm64, mycli-linux-x64, mycli-win-x64.exe), and let users download the correct binary for their system. A shell install script that detects the platform and downloads the right binary completes the experience.

For CLIs where automatic updates matter (you want users to always run the latest version), update-notifier is the standard package. It checks the npm registry for a newer version than what's installed and prints a message. The check runs in the background via a detached child process so it never adds latency to the CLI's actual execution. Combine this with a clear --version flag (which Commander and yargs both wire automatically from your package.json version) so users can always report what version they're running when filing bugs.


Interactive Prompts and Rich Terminal Output

Most production CLIs need more than argument parsing. Interactive prompts — asking users for confirmation, letting them select from a list, collecting sensitive input like passwords — are table stakes for deployment scripts and setup wizards. Rich output formatting — spinners during long operations, colored status messages, progress bars for file operations — significantly improves usability.

The @inquirer/prompts package (the modular successor to inquirer) is the standard for interactive prompts. It provides input, password, confirm, select, multi-select, and search prompts with accessible keyboard navigation:

import { input, password, select, confirm } from '@inquirer/prompts';

const projectName = await input({ message: 'Project name:' });
const dbPassword = await password({ message: 'Database password:' });

const environment = await select({
  message: 'Deploy to which environment?',
  choices: [
    { name: 'Staging', value: 'staging' },
    { name: 'Production', value: 'production', disabled: !hasProductionAccess },
  ],
});

const confirmed = await confirm({
  message: `Deploy ${projectName} to ${environment}?`,
  default: false,
});

if (!confirmed) {
  console.log('Deployment cancelled');
  process.exit(0);
}

For terminal output formatting, chalk handles colors and ora handles spinners:

import chalk from 'chalk';
import ora from 'ora';

// Semantic color usage — not decoration
console.log(chalk.green('✓'), 'Build completed');
console.log(chalk.yellow('⚠'), 'Using cached dependencies');
console.log(chalk.red('✗'), 'Deployment failed');

// Spinner for operations with unknown duration
const spinner = ora('Deploying to production...').start();
try {
  await deploy();
  spinner.succeed('Deployment complete');
} catch (err) {
  spinner.fail(`Deployment failed: ${err.message}`);
  process.exit(1);
}

The combination of a spinner (indicating something is happening) with a clear success/failure terminal state is a minimum viable UX for any CLI operation that takes more than a second. Users who run your CLI in CI systems often pipe output to log files — verify that your interactive prompts detect non-TTY environments (process.stdout.isTTY) and fall back to non-interactive behavior or throw a helpful error asking for the flag equivalent.


The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.