cheat sheet

commander

Package-level reference for the commander CLI framework on npm — install, version policy, TypeScript extras, and alternatives.

commander

What it is

commander is a Node.js library for building command-line interfaces with positional arguments, typed options, subcommands, and auto-generated --help. It is the longest-running and most-used CLI builder on npm — originally a port of Ruby's commander gem by TJ Holowaychuk and now maintained by a community group under tj/commander.js.

Reach for commander when you want a mature, stable API with strong TypeScript support and a vast amount of existing Stack Overflow / GitHub coverage. Reach for yargs when you want auto-prompting for missing args, or for cac / citty when bundle size matters.

Install

Commander is a library, not a CLI. Most projects depend on it directly and ship their own bin entry that uses commander internally.

bash
npm install commander

Output: added commander to dependencies

bash
pnpm add commander

Output: added 1 package, linked from store

bash
yarn add commander

Output: added commander

bash
bun add commander

Output: installed commander

bash
deno add npm:commander

Output: added npm:commander to import map

Invoke a project that uses commander via npx:

bash
npx your-cli --help

Output: auto-generated help text from commander

Versioning & Node support

Current line is commander@13.x. Strict semver — major bumps signal breaking changes (typed-option defaults, action-handler arity, deprecation removals).

  • commander@13 — Node 20+. Dual ESM/CJS via conditional exports.
  • commander@12 — Node 18+. Still receives bug-fix releases.
  • commander@9 and below — legacy CJS. Avoid for new projects.
  • TypeScript types ship in-tree (no @types/commander needed since v3).

Package metadata

  • Maintainer: commander-js org on GitHub, originally authored by TJ Holowaychuk
  • Project home: github.com/tj/commander.js
  • Docs: README on GitHub + extensive /examples directory
  • npm: npmjs.com/package/commander
  • License: MIT
  • First released: 2011
  • Downloads: ~150 million weekly downloads — pulled transitively by vue-cli, create-react-app, lerna, jest's CLI plumbing, and most modern Node CLIs.

Peer dependencies & extras

commander has zero runtime dependencies. No peer-deps. No extras flag.

Companion packages:

  • @commander-js/extra-typings — stricter TypeScript inference for .option()/.argument() chains, so the action callback knows the option types without manual generics. Strongly recommended for TypeScript projects.
  • commander-completion (third-party) — generate shell-completion scripts. Less polished than yargs's built-in completion.

Alternatives

PackageTrade-off
yargsOlder and equally mature. Built-in shell completion, prompt-on-missing-arg, and i18n. Bigger surface area; slower startup.
cac"Command and Conquer" — small, fast, modern API. Used by Vite, Vitest, unbuild. ESM-first.
cittyUnJS-team modern alternative used by Nuxt CLI. TypeScript-first with great inference.
sadeTiny, ~3 kB. Good for small scripts where commander's surface is overkill.
clipanionUsed by Yarn. Class-based, supports composition. Steepest learning curve.
Node parseArgs (stdlib)Native node:util. Parses flags only; no subcommands, no auto-help. Best for tiny scripts.

Common gotchas

  1. Action handlers vs option-only commands. .action(fn) is required if the command does work — without it, commander exits silently after parsing. If your command only displays help/version, omitting .action is fine.
  2. Subcommand option inheritance. Global options declared on the root program are NOT auto-passed to subcommand action handlers. Use program.opts() inside the subcommand, or declare with .passThroughOptions() on the subcommand to forward unknown flags downstream.
  3. .option("--foo <val>") vs .requiredOption(...). A plain option with a default of undefined will not error if missing. Use requiredOption to fail-fast at parse time.
  4. Action handler arity. Older docs show .action((cmd, options) => ...). Modern commander passes parsed args first, options last, and the Command instance via this. Read the v13 migration notes before copying older snippets.
  5. ESM await at top level. A common bin file pattern is program.parseAsync(process.argv), NOT parse(). With async actions, parse() returns before they finish; parseAsync resolves when the chain is done. Use it inside an async IIFE or with top-level await in ESM.
  6. process.exit() swallows async work. Commander does not call process.exit on success, but does on parse error. If your action handler is async and the process ends early, the next-tick microtasks (unflushed logs, stream writes) get dropped — await everything before returning.
  7. extra-typings only works if option declarations are chained inline. Splitting .option() calls into a builder function loses the type inference because TS cannot trace the chain. Inline the chain or accept manual generics.

Real-world recipes

These recipes target package-level patterns: multi-file command discovery, plugin loading, configurable defaults, and shell-completion publishing. The companion article covers single-file CLI building blocks.

Nested subcommands with shared options

Multi-level CLIs (docker container ls, kubectl get pods) work by attaching subcommands to subcommands. Shared options propagate via the parent reference.

typescript
import { Command, Option } from "commander";

const program = new Command()
  .name("infra")
  .version("1.0.0")
  .addOption(new Option("--env <name>", "target environment").default("dev").choices(["dev", "staging", "prod"]));

const db = program.command("db").description("database operations");

db.command("migrate")
  .description("run pending migrations")
  .option("--dry-run", "print SQL without executing")
  .action((opts, cmd) => {
    const { env } = cmd.optsWithGlobals();
    console.log(`migrating ${env} (dry-run=${opts.dryRun ?? false})`);
  });

db.command("backup")
  .description("snapshot the database")
  .argument("<name>", "snapshot name")
  .action((name, _opts, cmd) => {
    const { env } = cmd.optsWithGlobals();
    console.log(`backing up ${env}${name}`);
  });

await program.parseAsync();
bash
infra --env=prod db migrate --dry-run

Output: migrating prod (dry-run=true)

cmd.optsWithGlobals() (commander 11+) is the canonical way to read parent options inside a subcommand action without manually walking the parent chain.

Plugin loader pattern (filesystem discovery)

Larger CLIs often discover commands at runtime — each plugin is a module that exports a Command instance. Commander has no built-in loader, but a small fs walk works.

typescript
import { Command } from "commander";
import { readdir } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const program = new Command().name("mycli").version("1.0.0");

const pluginDir = join(__dirname, "commands");
for (const file of await readdir(pluginDir)) {
  if (!file.endsWith(".js")) continue;
  const mod = await import(pathToFileURL(join(pluginDir, file)).href);
  if (mod.default instanceof Command) program.addCommand(mod.default);
}

await program.parseAsync();

Output: every .js file in commands/ that default-exports a Command is auto-registered.

This pattern lets contributors drop a new file into the directory without touching the entry point. Pair with a --plugin-dir flag to load user-supplied plugins outside the package tree.

Configurable defaults via a config file

Commander has no built-in config loader, but the preAction hook is the integration seam. Read a TOML / JSON / YAML config and overlay it before action handlers run.

typescript
import { Command, Option } from "commander";
import { readFile } from "node:fs/promises";
import { join } from "node:path";

const program = new Command()
  .name("mycli")
  .addOption(new Option("--config <path>", "path to config").default(join(process.env.HOME ?? ".", ".myclirc")))
  .hook("preAction", async (root) => {
    try {
      const cfg = JSON.parse(await readFile(root.opts().config, "utf8"));
      for (const [k, v] of Object.entries(cfg)) {
        if (root.opts()[k] === undefined) root.setOptionValue(k, v);
      }
    } catch {
      // silent — config is optional
    }
  });

program
  .command("ping")
  .option("--host <h>", "host to ping")
  .action((opts) => console.log(`pinging ${opts.host}`));

await program.parseAsync();

Output: ~/.myclirc provides host if the user omits --host.

Shell-completion scripts

Commander doesn't ship completion natively, but you can emit zsh / bash completion declaratively. The pattern: expose a hidden completion command that prints a script for the user to source.

typescript
import { Command } from "commander";

const program = new Command().name("mycli");

program.command("build");
program.command("test");
program.command("deploy");

program.command("completion", { hidden: true })
  .description("emit bash completion script")
  .action(() => {
    console.log(`# mycli completion
_mycli_complete() {
  local cur="\${COMP_WORDS[COMP_CWORD]}"
  local cmds="build test deploy"
  COMPREPLY=( $(compgen -W "$cmds" -- "$cur") )
}
complete -F _mycli_complete mycli`);
  });

await program.parseAsync();
bash
mycli completion > ~/.local/share/bash-completion/completions/mycli

Output: tab-completion for mycli <Tab> now suggests build/test/deploy.

For richer completions (per-subcommand option suggestions), the third-party commander-completion package generates everything from the registered tree, but it's noticeably less polished than yargs's built-in support.

Distributing a bin cross-platform

Commander CLIs ship via the bin field in package.json. The shebang + executable bit only matter on POSIX; on Windows, npm generates a .cmd shim automatically.

json
{
  "name": "mycli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "mycli": "./dist/cli.js"
  },
  "files": ["dist"],
  "engines": {
    "node": ">=20.0.0"
  },
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build"
  }
}

The first line of dist/cli.js MUST be #!/usr/bin/env node. The TypeScript compiler preserves it if it's in src/cli.ts and compilerOptions.removeComments is false (the default). Verify after build:

bash
head -n1 dist/cli.js

Output: #!/usr/bin/env node

Version migration guide

Commander has had several large API shifts. The 7 → 8, 8 → 9, and 11 → 12 jumps are the painful ones.

FromToKey change
commander@6commander@7.parse() no longer returns program; chain via the returned Command value.
commander@7commander@8Action handler signature: parsed args come first, options last, this/cmd last. Older (cmd, options) order broken.
commander@8commander@9.option(...) returns Command, not Option. Use addOption(new Option(...)) for .choices(), .env(), .makeOptionMandatory().
commander@9commander@10.opts() exposes only options of the current command; optsWithGlobals() added for parent merge.
commander@10commander@11parseAsync is the recommended async path. parse() no longer awaits async actions even by accident.
commander@11commander@12TypeScript types tightened; many anys became unknown. Custom parsers need explicit return types.
commander@12commander@13Node 20+ floor. Removed deprecated command() string-form aliases.

Migration checklist (8 → 13):

  1. Replace (cmd, options) => ... action handlers with (arg1, ..., options, cmd) => ....
  2. Move .choices(...) and .env(...) calls onto Option objects via addOption(new Option(...)).
  3. Replace program.opts() from within subcommands with cmd.optsWithGlobals() where parent options are expected.
  4. Switch program.parse() to await program.parseAsync() for async actions.
  5. Update Node engine in package.json to match the floor of your chosen commander major.
  6. If using @commander-js/extra-typings, upgrade it in lockstep — its types are pinned to a commander major.

ESM/CJS interop & bundling

Commander dual-publishes ESM and CJS via "exports", so it works seamlessly under both module systems. The interop notes are about how you wire it, not what the library does.

SetupPattern
ESM (Node, "type": "module")import { program, Command, Option } from "commander";
CJS (default)const { program, Command, Option } = require("commander");
TypeScript ESMSame as ESM. Set "module": "Node16" (or higher) in tsconfig so "exports" is honoured.
TypeScript CJSSame as CJS. Set "module": "commonjs".
Bundled CLI (esbuild)esbuild src/cli.ts --bundle --platform=node --target=node20 --outfile=dist/cli.js — bundles commander into a single file.
Bundled CLI (tsup)tsup src/cli.ts --format esm --target node20 --shims — handles shebang preservation via --shims.
pkg / nexe (single-binary)Works — commander has no native code. The bundler embeds it in the binary.
Bun runtimeWorks in both ESM and CJS. Bun's import resolver honours commander's exports map.
Deno (npm:commander)ESM only. import { Command } from "npm:commander";

The bundle cost is small (~120 KB minified, ~30 KB gzipped) and tree-shaking removes unused features. For an all-in-one bin that ships to global install, prefer bundling — it eliminates the node_modules install step for end users on npm install -g.

Plugin & ecosystem coverage

PackageRole
@commander-js/extra-typingsStricter TypeScript inference — option / argument types flow into the action handler automatically. Required for typed CLIs.
commander-completionThird-party shell completion (bash, zsh, fish). Generates scripts from the registered command tree.
pastelClass+decorator wrapper around commander. Used by some CLIs that prefer OOP style.
inkReact for CLI rendering — pairs well with commander when building TUIs. The commander action launches an Ink app.
enquirer / @inquirer/promptsInteractive prompts for missing arguments. Hook into commander via preAction.
cosmiconfigConfig-file discovery (.myclirc, mycli.config.js, package.json#mycli). Load in a preAction hook.
update-notifierNotifies users when a newer version of your CLI exists on npm. Call once at startup.
chalk, ora, boxen, figletOutput styling. See npm-chalk.
execaSubprocess execution with promises. Used inside action handlers that shell out.
whichCross-platform which for locating sibling binaries.
package-up / read-pkg-upFind the nearest package.json from the CLI's invocation point.
signal-exitRun cleanup on Ctrl-C / process exit.

These compose into the canonical "interactive Node CLI" stack: commander parses, enquirer prompts, ora spins, chalk colours, execa shells out, update-notifier nudges.

Testing & CI integration

Commander's .exitOverride() and .parseAsync(argv, { from: "user" }) are the keys to testability.

Unit test a command tree

typescript
import { describe, it, expect } from "vitest";
import { Command } from "commander";
import stripAnsi from "strip-ansi";

function buildProgram() {
  const program = new Command();
  program.exitOverride();
  program.configureOutput({
    writeOut: (s) => out.push(s),
    writeErr: (s) => err.push(s),
  });
  const out: string[] = [];
  const err: string[] = [];
  program.command("add <a> <b>").action((a, b) => console.log(Number(a) + Number(b)));
  return { program, out, err };
}

describe("add", () => {
  it("sums two integers", async () => {
    const { program } = buildProgram();
    const log: string[] = [];
    const orig = console.log;
    console.log = (s: string) => log.push(s);
    try {
      await program.parseAsync(["add", "2", "3"], { from: "user" });
    } finally {
      console.log = orig;
    }
    expect(log[0]).toBe("5");
  });

  it("errors on unknown option", async () => {
    const { program } = buildProgram();
    await expect(program.parseAsync(["--bogus"], { from: "user" }))
      .rejects.toMatchObject({ code: "commander.unknownOption" });
  });
});

Output: 2 passed.

CI: install, build, smoke-test

yaml
# .github/workflows/cli.yml
jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npm run build
      - run: node dist/cli.js --help
      - run: node dist/cli.js --version
      - run: npm test

Output: validates the CLI loads, parses --help, and the unit suite passes.

Snapshot the auto-generated --help

--help output is your CLI's docs. Snapshot it to catch unintended changes.

typescript
import { test, expect } from "vitest";
import { execSync } from "node:child_process";

test("help is stable", () => {
  const out = execSync("node dist/cli.js --help", { encoding: "utf8" });
  expect(out).toMatchSnapshot();
});

Output: snapshot saved on first run; subsequent runs fail if the help text drifts.

Troubleshooting common errors

error: missing required argument 'foo' — a required positional was omitted. Either supply it or change <foo> to [foo] (optional).

error: required option '-x, --thing <val>' not specified.requiredOption flagged a missing flag. Supply via flag, env (Option.env(...)), or remove the requirement.

error: unknown option '--xyz' — typo or stray flag. To allow pass-through to a subcommand or external tool, use .allowUnknownOption() or .passThroughOptions() on the relevant command.

Action handler never runsparse() was never called, OR a subcommand was matched but no action attached. Confirm program.parse() (or parseAsync) at file-end and that the matched subcommand has .action(...).

TypeError: program.command is not a function — wrong import. Use import { Command, program } from "commander", NOT import program from "commander" (no default export on modern versions).

Async action exits before completion — used parse() instead of parseAsync(). The process closes once event-loop has no pending work; sync parse() doesn't await your Promise.

TypeScript: "Property 'foo' does not exist on type 'OptionValues'" — without @commander-js/extra-typings, .opts() returns Record<string, any>. Cast: (opts as { foo: string }).foo, or install extra-typings.

Variadic argument eats following flags<files...> collects everything after. Place options first or use -- to terminate: mycli copy --verbose -- a.txt b.txt.

Cannot find module 'commander' in a packaged CLI — dependencies vs devDependencies mismatch. Commander must be in dependencies (or bundled), not devDependencies, for downstream installs.

Security considerations

  • Shell injection in action handlers. If your handler shells out (execSync(\grep ${opts.term} file`)), user input is concatenated into a command string. Use execaorspawnwith array-form args:execa("grep", [opts.term, "file"])`.
  • Option.env("API_KEY"). Reading secrets from env is fine; printing them in --help is not. Commander only shows the flag, never the env value — but custom defaultValueDescription strings can leak. Audit them.
  • Untrusted plugin loading. A --plugin-dir <path> flag that imports arbitrary .js files lets a co-located malicious file execute on launch. Limit discovery to a known directory and consider checksum-verifying plugin modules.
  • process.exit() after error. Commander exits the process on parse errors. Inside a long-running service that hosts a CLI subcommand (rare but real), call .exitOverride() to throw instead — otherwise a malformed argv could DoS the host.
  • Help-text injection. If you build help text from external input (addHelpText("after", userSupplied)), arbitrary ANSI escape sequences land in stdout. Strip first.

When NOT to use this

Skip commander when:

  • The CLI takes no arguments and no flags. A 5-line script reading process.argv[2] is simpler than declaring a Command.
  • You're targeting an edge runtime. Workers, Deno Deploy, and Bun's edge build don't expose a meaningful process.argv. Use the framework's request handler instead.
  • You need declarative class-based commands. clipanion (used by Yarn) is built for that style. Commander's fluent API can be wrapped, but it fights the model.
  • You want middleware composition. yargs has a built-in middleware chain that fits API-call CLIs better. Commander has hooks but no first-class middleware.
  • Bundle size is extreme. A 40 kB CLI library is still a lot when the use-case is a single --foo bar parser. cac or sade are 3-10 kB. Node's stdlib parseArgs is 0 kB.
  • You're building a long-lived TUI. Commander parses argv once at startup. For continuous interaction (a REPL or interactive shell), use a Node REPL + readline directly.

See also