cheat sheet
Commander.js
Build full-featured Node.js CLIs with positional arguments, typed options, subcommands, auto-generated help, lifecycle hooks, and a distributable bin in package.json.
Commander.js — Node.js Command-Line Interfaces
What it is
Commander.js is the most widely used CLI framework on npm — ~250M weekly downloads — maintained by TJ Holowaychuk and contributors since 2011. It models a command-line program as a tree of commands with positional arguments, options, and subcommands, and generates a --help screen automatically from your declarations. Reach for it when building any non-trivial Node CLI; consider yargs for richer middleware, oclif for plugin architectures, cac for a smaller bundle, or clipanion for class-based typed commands.
Install
Install as a regular dependency — Commander has zero deps and works in CJS, ESM, and TypeScript.
npm install commander
# or
yarn add commander
pnpm add commander
bun add commander
Output: (none — exits 0 on success)
Verify version:
node -e "console.log(require('commander/package.json').version)"
Output:
12.1.0
Syntax
A Commander program is built around a single Command instance — typically the default export program — that you call .argument(), .option(), .action(), and .parse() on.
import { program } from 'commander';
program
.name('mycli')
.description('What the CLI does')
.version('1.0.0')
.argument('<file>', 'input file')
.option('-v, --verbose', 'verbose output')
.action((file, options) => { /* ... */ })
.parse();
Output: (none — exits 0 on success)
Essential methods
| Method | Purpose |
|---|---|
.name(str) | Sets the program name (used in help and errors) |
.description(str) | Top-line summary in help |
.version(str, flags?) | Adds -V, --version (or custom) |
.argument(name, desc, default?) | Declares a positional argument |
.option(flags, desc, default?) | Declares an option/flag |
.requiredOption(flags, desc) | Same but errors when missing |
.command(name, desc?) | Declares a subcommand |
.action(handler) | Runs when the command matches |
.parse(argv?) | Parses process.argv (or a passed array) |
.parseAsync(argv?) | Async version (use when your action returns a Promise) |
.hook(event, fn) | preAction, postAction, preSubcommand |
.helpOption(flags, desc) | Customise the --help flag |
.addHelpText(pos, text) | Add custom text before/after help |
Hello, CLI
A minimal program with one argument and one option. Save as mycli.js, run with node mycli.js:
#!/usr/bin/env node
// mycli.js
import { program } from 'commander';
program
.name('greet')
.description('Say hello to someone')
.version('1.0.0')
.argument('<name>', 'person to greet')
.option('-u, --upper', 'shout the greeting')
.action((name, options) => {
const msg = `Hello, ${name}!`;
console.log(options.upper ? msg.toUpperCase() : msg);
});
program.parse();
node mycli.js alice
Output:
Hello, alice!
With the option:
node mycli.js alice --upper
Output:
HELLO, ALICE!
Built-in help (auto-generated):
node mycli.js --help
Output:
Usage: greet [options] <name>
Say hello to someone
Arguments:
name person to greet
Options:
-V, --version output the version number
-u, --upper shout the greeting
-h, --help display help for command
Arguments
Positional arguments are declared with .argument() (or .arguments() for several at once). Angle brackets <x> mean required; square brackets [x] mean optional; <x...> collects the rest into an array.
import { program } from 'commander';
program
.argument('<source>', 'source path')
.argument('[dest]', 'destination', './out')
.argument('<files...>', 'additional files (one or more)')
.action((source, dest, files) => {
console.log({ source, dest, files });
})
.parse();
node cli.js input.txt /tmp/out a.txt b.txt c.txt
Output:
{ source: 'input.txt', dest: '/tmp/out', files: [ 'a.txt', 'b.txt', 'c.txt' ] }
Typed/parsed arguments — pass a parser function to .argument():
import { program, InvalidArgumentError } from 'commander';
function parseIntStrict(value) {
const n = parseInt(value, 10);
if (Number.isNaN(n)) throw new InvalidArgumentError('Not a number.');
return n;
}
program
.argument('<port>', 'server port', parseIntStrict)
.action((port) => console.log(typeof port, port))
.parse();
node cli.js 3000
Output:
number 3000
node cli.js abc
Output:
error: command-argument value 'abc' is invalid for argument 'port'. Not a number.
Options
Options are declared with .option(flags, description, default?). Flags accept short and long forms, plus a value placeholder.
program
.option('-d, --debug', 'enable debug output') // boolean
.option('-p, --port <number>', 'server port', '3000') // string with default
.option('-c, --config <path>', 'config file path') // string, no default
.option('--no-cache', 'disable cache') // negated boolean
.option('-t, --tag [name]', 'optional tag', 'latest') // optional value
.option('--retry <n>', 'retries (number)', parseInt, 3) // custom parser
.option('-D, --define <key=value...>', 'repeat flag', collect, [])
.action((options) => console.log(options))
.parse();
function collect(value, previous) {
return previous.concat([value]);
}
node cli.js --port 8080 --no-cache -D NODE_ENV=prod -D LOG=info
Output:
{
port: '8080',
cache: false,
tag: 'latest',
retry: 3,
define: [ 'NODE_ENV=prod', 'LOG=info' ],
debug: undefined,
config: undefined
}
| Form | Meaning |
|---|---|
-x | boolean short flag |
--name | boolean long flag |
-p, --port <number> | required value |
-t, --tag [value] | optional value |
--no-color | negates a default-true boolean |
-D, --define <kv...> | variadic — collects values |
.requiredOption()
Marks an option as mandatory — the program exits with an error if the user omits it.
program
.requiredOption('-u, --url <url>', 'target URL')
.parse();
node cli.js
Output:
error: required option '-u, --url <url>' not specified
Choices
Restrict the accepted values with .choices() on the option:
import { Option } from 'commander';
program
.addOption(
new Option('-l, --log-level <level>', 'log level')
.choices(['debug', 'info', 'warn', 'error'])
.default('info')
)
.action((opts) => console.log(opts.logLevel))
.parse();
node cli.js --log-level verbose
Output:
error: option '-l, --log-level <level>' argument 'verbose' is invalid. Allowed choices are debug, info, warn, error.
Environment-variable fallback
Option.env(name) falls back to a given env var when the flag isn't supplied.
import { Option } from 'commander';
program
.addOption(
new Option('--api-key <key>', 'API key').env('API_KEY').makeOptionMandatory()
)
.action((opts) => console.log('Using key:', opts.apiKey.slice(0, 4) + '…'))
.parse();
API_KEY=sk_live_abc123 node cli.js
Output:
Using key: sk_l…
Subcommands
A CLI with multiple commands (git add, git commit, …) is built by attaching .command() calls to the root program. Each subcommand can have its own arguments, options, and .action().
import { program } from 'commander';
program
.name('todo')
.description('Minimal todo CLI')
.version('1.0.0');
program
.command('add <task>')
.description('add a task')
.option('-p, --priority <level>', 'priority', 'medium')
.action((task, options) => {
console.log(`Added "${task}" (priority: ${options.priority})`);
});
program
.command('list')
.alias('ls')
.description('list all tasks')
.option('--done', 'show only completed')
.action((options) => {
console.log(options.done ? 'Completed tasks…' : 'All tasks…');
});
program
.command('done <id>')
.description('mark task complete')
.action((id) => console.log(`Marked task ${id} as done`));
program.parse();
node todo.js add "Write docs" --priority high
Output:
Added "Write docs" (priority: high)
node todo.js ls --done
Output:
Completed tasks…
node todo.js --help
Output:
Usage: todo [options] [command]
Minimal todo CLI
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
add [options] <task> add a task
list|ls [options] list all tasks
done <id> mark task complete
help [command] display help for command
Stand-alone executable subcommands
For larger CLIs, split each subcommand into its own file. Commander finds them by file-name convention: mycli-add.js, mycli-list.js, etc. The main file just declares the command names.
// mycli.js
import { program } from 'commander';
program
.name('mycli')
.version('1.0.0');
program.command('add', 'add an item'); // looks for mycli-add.js
program.command('list', 'list items'); // looks for mycli-list.js
program.parse();
// mycli-add.js
#!/usr/bin/env node
import { program } from 'commander';
program
.argument('<item>')
.action((item) => console.log(`added: ${item}`))
.parse(process.argv);
node mycli.js add hello
Output:
added: hello
Nested subcommands
Deep hierarchies (docker container ls) work via nested .command():
const container = program.command('container').description('manage containers');
container
.command('ls')
.description('list running containers')
.action(() => console.log('ID IMAGE STATUS'));
container
.command('rm <id>')
.description('remove a container')
.action((id) => console.log(`removed ${id}`));
node cli.js container ls
Output:
ID IMAGE STATUS
Lifecycle hooks
.hook(event, fn) runs code before/after actions across the program. Use preAction for cross-cutting concerns (auth, logging, telemetry), postAction for cleanup.
program
.hook('preAction', (thisCommand, actionCommand) => {
console.log(`> ${actionCommand.name()} starting…`);
})
.hook('postAction', (thisCommand, actionCommand) => {
console.log(`> ${actionCommand.name()} done.`);
})
.hook('preSubcommand', (thisCommand, subcommand) => {
console.log(`Dispatching to ${subcommand.name()}`);
});
program.command('build').action(() => console.log('building…'));
program.parse();
node cli.js build
Output:
Dispatching to build
> build starting…
building…
> build done.
Async actions and error handling
When the action returns a Promise, use parseAsync so Node waits for it before exiting. Errors surface via try/catch or Commander's own error display.
import { program } from 'commander';
program
.command('fetch <url>')
.action(async (url) => {
const res = await fetch(url);
console.log(res.status, await res.text());
});
try {
await program.parseAsync();
} catch (err) {
console.error('Error:', err.message);
process.exit(1);
}
node cli.js fetch https://api.example.com/health
Output:
200 {"status":"ok"}
Use .exitOverride() to throw instead of process.exit() (useful in tests):
program.exitOverride();
try {
program.parse(['--bogus'], { from: 'user' });
} catch (err) {
console.error('Caught:', err.code);
}
Output:
Caught: commander.unknownOption
Custom help
Commander auto-generates help, but you can extend it with extra examples or sections via addHelpText('before' | 'after' | 'beforeAll' | 'afterAll', text).
program
.addHelpText('after', `
Examples:
$ mycli add "Buy milk" --priority high
$ mycli ls --done
$ mycli done 3
Docs: https://example.com/docs
`);
node cli.js --help
Output:
Usage: mycli [options] [command]
…
Examples:
$ mycli add "Buy milk" --priority high
$ mycli ls --done
$ mycli done 3
Docs: https://example.com/docs
Customise the help layout entirely with configureHelp({ helpWidth, sortSubcommands, … }).
TypeScript
Commander ships type definitions in the package. With ESM + TypeScript, options is typed OptionValues = Record<string, any> by default — narrow it with an interface or generic action.
import { Command, Option } from 'commander';
interface BuildOptions {
watch: boolean;
format: 'esm' | 'cjs' | 'iife';
outDir: string;
}
const program = new Command();
program
.command('build')
.option('-w, --watch', 'watch mode', false)
.addOption(
new Option('-f, --format <fmt>')
.choices(['esm', 'cjs', 'iife'])
.default('esm')
)
.option('-o, --out-dir <dir>', 'output directory', 'dist')
.action((options: BuildOptions) => {
console.log(options.format, options.outDir, options.watch);
});
await program.parseAsync(process.argv);
node --import tsx src/cli.ts build --format cjs --watch
Output:
cjs dist true
Distributing as a bin
Publishing a CLI means setting the "bin" field in package.json. Each entry installs as node_modules/.bin/<name> (and globally with npm install -g).
{
"name": "mycli",
"version": "1.0.0",
"type": "module",
"bin": {
"mycli": "./dist/cli.js"
},
"files": ["dist"],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"dependencies": {
"commander": "^12.1.0"
}
}
The executable must start with a shebang and be marked executable on POSIX:
#!/usr/bin/env node
// dist/cli.js
import { program } from 'commander';
// … rest of the program
program.parse();
chmod +x dist/cli.js
Output: (none — exits 0 on success)
Test it locally before publishing:
npm link
mycli --help
Output:
Usage: mycli [options] [command]
…
Unlink to clean up:
npm unlink -g mycli
Output: (none — exits 0 on success)
Commander vs yargs vs oclif vs cac
| Aspect | Commander | yargs | oclif | cac |
|---|---|---|---|---|
| API style | Fluent chaining | Object-based | Class-based, OOP | Fluent, very small |
| Bundle size | ~120 KB | ~400 KB | ~3 MB | ~40 KB |
| TypeScript | Built-in .d.ts | Built-in | First-class | Built-in |
| Plugin system | No | Middleware | Full (hot-load) | No |
| Auto-generated help | Yes | Yes | Yes | Yes |
| Async actions | parseAsync | Native | Native (async hooks) | Native |
| Subcommand files | Convention-based | Modules dir | Conventions + plugins | None |
| Best for | Most CLIs | Middleware-heavy CLIs | Big CLIs (Heroku, Salesforce) | Minimal CLIs |
Commander is the default safe choice. Switch when you have a specific reason (plugin host, ultra-small bundle, declarative test fixtures).
Common pitfalls
- Forgetting
program.parse()— without it, nothing runs. Always callparse()(orparseAsync()for async actions) at the very end. - Mixing
program.action()and subcommands — adding both a root.action()and subcommands makes the root action run only when no subcommand matches. Pick one model. - Camel-case option lookup —
--out-dirbecomesoptions.outDir, notoptions['out-dir']. Commander camel-cases automatically. - No subcommand vs unknown subcommand — by default unknown commands fall through silently. Call
program.showHelpAfterError()orprogram.action(() => program.help())to print help. - Parsing numbers —
program.option('-p, --port <n>')returns a string. PassparseIntas a custom parser, or coerce in your action. - Top-level await in CJS —
program.parseAsync()requires either ESM or(async () => { … })()wrapping in CJS. - Variadic args swallowing options —
<files...>after an option list eats everything that follows. Put options before the variadic argument or use--to terminate options:mycli copy --verbose -- src/*.js dst/. - Forgetting the shebang —
#!/usr/bin/env nodepluschmod +xis required fornpm linkand global installs to work.
Real-world recipes
A multi-command CLI shipped via bin
End-to-end: build, link, run, publish. The project lives in ~/projects/file-tools/ and ships a CLI called ft.
file-tools/
├── package.json
├── tsconfig.json
├── src/
│ ├── cli.ts # entry — wires commands
│ ├── commands/
│ │ ├── count.ts
│ │ ├── dedupe.ts
│ │ └── rename.ts
└── dist/ # compiled output (in .gitignore)
// src/cli.ts
#!/usr/bin/env node
import { Command } from 'commander';
import count from './commands/count.js';
import dedupe from './commands/dedupe.js';
import rename from './commands/rename.js';
const program = new Command()
.name('ft')
.description('File-tools CLI')
.version('1.0.0');
program.addCommand(count);
program.addCommand(dedupe);
program.addCommand(rename);
await program.parseAsync(process.argv);
// src/commands/count.ts
import { Command } from 'commander';
import { readdir } from 'node:fs/promises';
export default new Command('count')
.description('count files in a directory')
.argument('<dir>', 'directory to scan')
.option('-e, --ext <extension>', 'only count this extension')
.action(async (dir, opts) => {
const files = await readdir(dir);
const filtered = opts.ext
? files.filter((f) => f.endsWith(`.${opts.ext}`))
: files;
console.log(`${filtered.length} file(s) in ${dir}`);
});
npm run build && npm link
ft count /home/alice/Documents -e pdf
Output:
12 file(s) in /home/alice/Documents
CI-friendly CLI with exit codes
CLIs talk to scripts via exit codes. Set them explicitly so callers can branch on success/failure.
import { program } from 'commander';
program
.command('check <url>')
.action(async (url) => {
try {
const res = await fetch(url);
if (!res.ok) {
console.error(`Bad status: ${res.status}`);
process.exitCode = 2; // non-zero, but lets pending I/O flush
return;
}
console.log('OK');
} catch (err) {
console.error('Network error:', err.message);
process.exitCode = 3;
}
});
await program.parseAsync();
node cli.js check https://example.com/health
echo "Exit: $?"
Output:
OK
Exit: 0
node cli.js check https://example.com/down
echo "Exit: $?"
Output:
Bad status: 503
Exit: 2
Pair with chalk, ora, and @inquirer/prompts
A typical interactive CLI combines Commander (parsing) + chalk (colour) + ora (spinners) + inquirer (prompts).
import { program } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import { input, confirm } from '@inquirer/prompts';
program
.command('init')
.description('scaffold a new project')
.action(async () => {
const name = await input({ message: 'Project name?' });
const useTS = await confirm({ message: 'Use TypeScript?', default: true });
const spinner = ora('Generating files…').start();
await new Promise((r) => setTimeout(r, 800));
spinner.succeed(chalk.green(`Created ${name}/`));
console.log(chalk.dim(`TypeScript: ${useTS ? 'yes' : 'no'}`));
});
await program.parseAsync();
node cli.js init
Output:
? Project name? my-app
? Use TypeScript? Yes
✔ Created my-app/
TypeScript: yes
Reading from stdin (-)
A common Unix convention is treating - as "read from stdin." Commander gives you the raw string; you handle the rest.
import { program } from 'commander';
import { readFile } from 'node:fs/promises';
program
.argument('<file>', 'file path or `-` for stdin')
.action(async (file) => {
let content;
if (file === '-') {
const chunks = [];
for await (const c of process.stdin) chunks.push(c);
content = Buffer.concat(chunks).toString('utf8');
} else {
content = await readFile(file, 'utf8');
}
console.log(`Read ${content.length} bytes`);
});
await program.parseAsync();
echo "hello, world" | node cli.js -
Output:
Read 13 bytes
Testing a Commander program
Unit-test by parsing a custom argv and capturing output. exitOverride() makes errors throwable.
// __tests__/cli.test.ts
import { describe, it, expect, vi } from 'vitest';
import { Command } from 'commander';
function buildProgram() {
const program = new Command();
program.exitOverride();
program
.command('greet <name>')
.option('--upper')
.action((name, opts) => {
const msg = `Hi, ${name}`;
console.log(opts.upper ? msg.toUpperCase() : msg);
});
return program;
}
describe('greet', () => {
it('prints lowercase by default', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await buildProgram().parseAsync(['greet', 'alice'], { from: 'user' });
expect(log).toHaveBeenCalledWith('Hi, alice');
});
it('prints uppercase with --upper', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await buildProgram().parseAsync(['greet', 'alice', '--upper'], { from: 'user' });
expect(log).toHaveBeenCalledWith('HI, ALICE');
});
});
npx vitest run
Output:
✓ __tests__/cli.test.ts (2)
✓ greet
✓ prints lowercase by default
✓ prints uppercase with --upper
Test Files 1 passed (1)
Tests 2 passed (2)
Cross-cutting auth via preAction hook
A real CLI often needs an API token for most commands. A preAction hook centralises the check.
import { program } from 'commander';
import { Option } from 'commander';
program
.addOption(new Option('--token <t>', 'API token').env('MYAPP_TOKEN'))
.hook('preAction', (thisCommand) => {
if (!thisCommand.opts().token) {
console.error('error: API token required (set MYAPP_TOKEN or pass --token)');
process.exit(1);
}
});
program.command('deploy').action(() => console.log('Deploying…'));
program.command('status').action(() => console.log('Status: ok'));
await program.parseAsync();
node cli.js deploy
Output:
error: API token required (set MYAPP_TOKEN or pass --token)
MYAPP_TOKEN=abc node cli.js deploy
Output:
Deploying…