Build a CLI with Node.js: Commander vs yargs vs oclif
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
binfield 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
| Package | Weekly Downloads | Bundle Size | TypeScript | Last Release | License |
|---|---|---|---|---|---|
| commander | ~35M | 6 KB | Native | 2025 | MIT |
| yargs | ~30M | 8 KB | @types/yargs | 2025 | MIT |
| @oclif/core | ~200K | 180 KB | Native | 2025 | MIT |
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
| Scenario | Best Pick |
|---|---|
| Simple utility with < 10 commands | Commander |
| Script with complex option validation | yargs |
| Need middleware / config file loading | yargs |
| Built-in shell completion | yargs |
| 20+ commands across multiple contributors | oclif |
| Plugin architecture for end-user extensibility | oclif |
| CLI that ships as a platform (like Heroku CLI) | oclif |
| Fastest dev setup, minimal dependencies | Commander + tsx + esbuild |
| Migrating from Java/Spring CLI patterns | oclif (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.
Related Resources
See the live comparison
View commander vs. yargs on PkgPulse →