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.
npm install commander
Output: added commander to dependencies
pnpm add commander
Output: added 1 package, linked from store
yarn add commander
Output: added commander
bun add commander
Output: installed commander
deno add npm:commander
Output: added npm:commander to import map
Invoke a project that uses commander via npx:
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@9and below — legacy CJS. Avoid for new projects.- TypeScript types ship in-tree (no
@types/commanderneeded since v3).
Package metadata
- Maintainer:
commander-jsorg on GitHub, originally authored by TJ Holowaychuk - Project home: github.com/tj/commander.js
- Docs: README on GitHub + extensive
/examplesdirectory - 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
| Package | Trade-off |
|---|---|
yargs | Older 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. |
citty | UnJS-team modern alternative used by Nuxt CLI. TypeScript-first with great inference. |
sade | Tiny, ~3 kB. Good for small scripts where commander's surface is overkill. |
clipanion | Used 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
- Action handlers vs option-only commands.
.action(fn)is required if the command does work — without it,commanderexits silently after parsing. If your command only displays help/version, omitting.actionis fine. - 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. .option("--foo <val>")vs.requiredOption(...). A plainoptionwith a default ofundefinedwill not error if missing. UserequiredOptionto fail-fast at parse time.- Action handler arity. Older docs show
.action((cmd, options) => ...). Modern commander passes parsed args first, options last, and the Command instance viathis. Read the v13 migration notes before copying older snippets. - ESM
awaitat top level. A common bin file pattern isprogram.parseAsync(process.argv), NOTparse(). With async actions,parse()returns before they finish;parseAsyncresolves when the chain is done. Use it inside an async IIFE or with top-level await in ESM. process.exit()swallows async work. Commander does not callprocess.exiton 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 —awaiteverything before returning.extra-typingsonly 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.
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();
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.
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.
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.
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();
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.
{
"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:
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.
| From | To | Key change |
|---|---|---|
commander@6 | commander@7 | .parse() no longer returns program; chain via the returned Command value. |
commander@7 | commander@8 | Action handler signature: parsed args come first, options last, this/cmd last. Older (cmd, options) order broken. |
commander@8 | commander@9 | .option(...) returns Command, not Option. Use addOption(new Option(...)) for .choices(), .env(), .makeOptionMandatory(). |
commander@9 | commander@10 | .opts() exposes only options of the current command; optsWithGlobals() added for parent merge. |
commander@10 | commander@11 | parseAsync is the recommended async path. parse() no longer awaits async actions even by accident. |
commander@11 | commander@12 | TypeScript types tightened; many anys became unknown. Custom parsers need explicit return types. |
commander@12 | commander@13 | Node 20+ floor. Removed deprecated command() string-form aliases. |
Migration checklist (8 → 13):
- Replace
(cmd, options) => ...action handlers with(arg1, ..., options, cmd) => .... - Move
.choices(...)and.env(...)calls ontoOptionobjects viaaddOption(new Option(...)). - Replace
program.opts()from within subcommands withcmd.optsWithGlobals()where parent options are expected. - Switch
program.parse()toawait program.parseAsync()for async actions. - Update Node engine in
package.jsonto match the floor of your chosen commander major. - 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.
| Setup | Pattern |
|---|---|
ESM (Node, "type": "module") | import { program, Command, Option } from "commander"; |
| CJS (default) | const { program, Command, Option } = require("commander"); |
| TypeScript ESM | Same as ESM. Set "module": "Node16" (or higher) in tsconfig so "exports" is honoured. |
| TypeScript CJS | Same 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 runtime | Works 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
| Package | Role |
|---|---|
@commander-js/extra-typings | Stricter TypeScript inference — option / argument types flow into the action handler automatically. Required for typed CLIs. |
commander-completion | Third-party shell completion (bash, zsh, fish). Generates scripts from the registered command tree. |
pastel | Class+decorator wrapper around commander. Used by some CLIs that prefer OOP style. |
ink | React for CLI rendering — pairs well with commander when building TUIs. The commander action launches an Ink app. |
enquirer / @inquirer/prompts | Interactive prompts for missing arguments. Hook into commander via preAction. |
cosmiconfig | Config-file discovery (.myclirc, mycli.config.js, package.json#mycli). Load in a preAction hook. |
update-notifier | Notifies users when a newer version of your CLI exists on npm. Call once at startup. |
chalk, ora, boxen, figlet | Output styling. See npm-chalk. |
execa | Subprocess execution with promises. Used inside action handlers that shell out. |
which | Cross-platform which for locating sibling binaries. |
package-up / read-pkg-up | Find the nearest package.json from the CLI's invocation point. |
signal-exit | Run 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
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
# .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.
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 runs — parse() 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. Useexecaorspawnwith array-form args:execa("grep", [opts.term, "file"])`. Option.env("API_KEY"). Reading secrets from env is fine; printing them in--helpis not. Commander only shows the flag, never the env value — but customdefaultValueDescriptionstrings can leak. Audit them.- Untrusted plugin loading. A
--plugin-dir <path>flag that imports arbitrary.jsfiles 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 aCommand. - 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.
yargshas 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 barparser.cacorsadeare 3-10 kB. Node's stdlibparseArgsis 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
- JavaScript: commander — full API tutorial, subcommands, hooks
- JavaScript: package.json —
binentries for publishing a CLI - Concept: pipes — composing CLIs via stdin/stdout