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
binfield with executable shebang
Commander.js (Recommended for Most)
#!/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.
See the live comparison
View commander vs. yargs on PkgPulse →