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.

bash
npm install commander
# or
yarn add commander
pnpm add commander
bun add commander

Output: (none — exits 0 on success)

Verify version:

bash
node -e "console.log(require('commander/package.json').version)"

Output:

text
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.

javascript
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

MethodPurpose
.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:

javascript
#!/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();
bash
node mycli.js alice

Output:

text
Hello, alice!

With the option:

bash
node mycli.js alice --upper

Output:

text
HELLO, ALICE!

Built-in help (auto-generated):

bash
node mycli.js --help

Output:

text
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.

javascript
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();
bash
node cli.js input.txt /tmp/out a.txt b.txt c.txt

Output:

text
{ source: 'input.txt', dest: '/tmp/out', files: [ 'a.txt', 'b.txt', 'c.txt' ] }

Typed/parsed arguments — pass a parser function to .argument():

javascript
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();
bash
node cli.js 3000

Output:

text
number 3000
bash
node cli.js abc

Output:

text
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.

javascript
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]);
}
bash
node cli.js --port 8080 --no-cache -D NODE_ENV=prod -D LOG=info

Output:

text
{
  port: '8080',
  cache: false,
  tag: 'latest',
  retry: 3,
  define: [ 'NODE_ENV=prod', 'LOG=info' ],
  debug: undefined,
  config: undefined
}
FormMeaning
-xboolean short flag
--nameboolean long flag
-p, --port <number>required value
-t, --tag [value]optional value
--no-colornegates 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.

javascript
program
  .requiredOption('-u, --url <url>', 'target URL')
  .parse();
bash
node cli.js

Output:

text
error: required option '-u, --url <url>' not specified

Choices

Restrict the accepted values with .choices() on the option:

javascript
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();
bash
node cli.js --log-level verbose

Output:

text
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.

javascript
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();
bash
API_KEY=sk_live_abc123 node cli.js

Output:

text
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().

javascript
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();
bash
node todo.js add "Write docs" --priority high

Output:

text
Added "Write docs" (priority: high)
bash
node todo.js ls --done

Output:

text
Completed tasks…
bash
node todo.js --help

Output:

text
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.

javascript
// 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();
javascript
// mycli-add.js
#!/usr/bin/env node
import { program } from 'commander';

program
  .argument('<item>')
  .action((item) => console.log(`added: ${item}`))
  .parse(process.argv);
bash
node mycli.js add hello

Output:

text
added: hello

Nested subcommands

Deep hierarchies (docker container ls) work via nested .command():

javascript
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}`));
bash
node cli.js container ls

Output:

text
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.

javascript
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();
bash
node cli.js build

Output:

text
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.

javascript
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);
}
bash
node cli.js fetch https://api.example.com/health

Output:

text
200 {"status":"ok"}

Use .exitOverride() to throw instead of process.exit() (useful in tests):

javascript
program.exitOverride();
try {
  program.parse(['--bogus'], { from: 'user' });
} catch (err) {
  console.error('Caught:', err.code);
}

Output:

text
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).

javascript
program
  .addHelpText('after', `
Examples:
  $ mycli add "Buy milk" --priority high
  $ mycli ls --done
  $ mycli done 3

Docs: https://example.com/docs
`);
bash
node cli.js --help

Output:

text
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.

typescript
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);
bash
node --import tsx src/cli.ts build --format cjs --watch

Output:

text
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).

json
{
  "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:

javascript
#!/usr/bin/env node
// dist/cli.js
import { program } from 'commander';
// … rest of the program
program.parse();
bash
chmod +x dist/cli.js

Output: (none — exits 0 on success)

Test it locally before publishing:

bash
npm link
mycli --help

Output:

text
Usage: mycli [options] [command]
…

Unlink to clean up:

bash
npm unlink -g mycli

Output: (none — exits 0 on success)

Commander vs yargs vs oclif vs cac

AspectCommanderyargsoclifcac
API styleFluent chainingObject-basedClass-based, OOPFluent, very small
Bundle size~120 KB~400 KB~3 MB~40 KB
TypeScriptBuilt-in .d.tsBuilt-inFirst-classBuilt-in
Plugin systemNoMiddlewareFull (hot-load)No
Auto-generated helpYesYesYesYes
Async actionsparseAsyncNativeNative (async hooks)Native
Subcommand filesConvention-basedModules dirConventions + pluginsNone
Best forMost CLIsMiddleware-heavy CLIsBig 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

  1. Forgetting program.parse() — without it, nothing runs. Always call parse() (or parseAsync() for async actions) at the very end.
  2. 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.
  3. Camel-case option lookup--out-dir becomes options.outDir, not options['out-dir']. Commander camel-cases automatically.
  4. No subcommand vs unknown subcommand — by default unknown commands fall through silently. Call program.showHelpAfterError() or program.action(() => program.help()) to print help.
  5. Parsing numbersprogram.option('-p, --port <n>') returns a string. Pass parseInt as a custom parser, or coerce in your action.
  6. Top-level await in CJSprogram.parseAsync() requires either ESM or (async () => { … })() wrapping in CJS.
  7. 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/.
  8. Forgetting the shebang#!/usr/bin/env node plus chmod +x is required for npm link and 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.

text
file-tools/
├── package.json
├── tsconfig.json
├── src/
│   ├── cli.ts          # entry — wires commands
│   ├── commands/
│   │   ├── count.ts
│   │   ├── dedupe.ts
│   │   └── rename.ts
└── dist/               # compiled output (in .gitignore)
typescript
// 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);
typescript
// 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}`);
  });
bash
npm run build && npm link
ft count /home/alice/Documents -e pdf

Output:

text
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.

javascript
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();
bash
node cli.js check https://example.com/health
echo "Exit: $?"

Output:

text
OK
Exit: 0
bash
node cli.js check https://example.com/down
echo "Exit: $?"

Output:

text
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).

javascript
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();
bash
node cli.js init

Output:

text
? 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.

javascript
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();
bash
echo "hello, world" | node cli.js -

Output:

text
Read 13 bytes

Testing a Commander program

Unit-test by parsing a custom argv and capturing output. exitOverride() makes errors throwable.

typescript
// __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');
  });
});
bash
npx vitest run

Output:

text
 ✓ __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.

javascript
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();
bash
node cli.js deploy

Output:

text
error: API token required (set MYAPP_TOKEN or pass --token)
bash
MYAPP_TOKEN=abc node cli.js deploy

Output:

text
Deploying…