skip to content

Commander.js — Node.js Command-Line Interfaces

Build full-featured Node.js CLIs with positional arguments, typed options, subcommands, auto-generated help, lifecycle hooks, and a distributable bin in package.json.

14 min read 89 snippets deep dive

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#

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:

#!/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
}
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.

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#

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.

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…