Building CLIs with Commander & Yargs
Command-line tools are a staple of the Node.js ecosystem — from scaffolding generators to deployment scripts and developer utilities. Parsing process.argv by hand quickly turns into a mess of string-splitting and if statements, so the community settled on two mature libraries: Commander and Yargs. Both turn raw arguments into structured commands, options, and flags, and both generate polished --help output for free. This page shows how to define commands, parse arguments, build subcommands, and ship a professional CLI.
Why a parsing library
A CLI needs to understand three kinds of input: commands (the verb, like git commit), arguments (positional values), and options/flags (named modifiers like --verbose or -o file.txt). A parser maps these to a clean JavaScript object, validates required values, applies defaults, coerces types, and prints usage text when the user makes a mistake. Commander leans toward a fluent, declarative API; Yargs favors a chainable builder with rich validation and middleware. Both are battle-tested and ship with TypeScript types.
| Feature | Commander | Yargs |
|---|---|---|
| API style | Fluent / declarative | Chainable builder |
| Subcommands | .command() | .command() with builder/handler |
| Auto help | Yes | Yes |
| Type coercion | Manual / custom | Built-in (number, boolean, array) |
| Validation | Via custom code | .demandOption, .choices, .check |
| Bundle size | Smaller | Larger (more features) |
Setting up the executable
Both libraries are installed from npm and run from a file marked as executable. The shebang line lets the OS run the script directly, and the bin field in package.json maps a command name to it.
npm install commander yargs
{
"name": "taskcli",
"version": "1.0.0",
"type": "module",
"bin": { "task": "./bin/task.js" }
}
After npm link (or installing globally), the task command is available on your PATH.
Commander
Commander uses a program object that you configure with a name, version, and a series of commands. Options are declared with short and long flags, and the parsed values are read from program.opts() or passed directly to action handlers.
#!/usr/bin/env node
import { Command } from "commander";
const program = new Command();
program
.name("task")
.description("A tiny task manager CLI")
.version("1.0.0");
program
.command("add")
.description("Add a new task")
.argument("<title>", "task title")
.option("-p, --priority <level>", "priority level", "normal")
.option("--done", "mark as already completed", false)
.action((title, options) => {
console.log(`Added "${title}" [priority=${options.priority}]`);
if (options.done) console.log("Marked as done.");
});
program
.command("list")
.description("List tasks")
.option("-a, --all", "include completed tasks")
.action((options) => {
console.log(options.all ? "Listing all tasks" : "Listing open tasks");
});
program.parse();
Running the tool produces clean, predictable output.
task add "Write docs" --priority high --done
Output:
Added "Write docs" [priority=high]
Marked as done.
In the option spec, <level> means a value is required, while [level] would make it optional. Commander auto-generates help when you pass --help or use an unknown command:
task --help
Output:
Usage: task [options] [command]
A tiny task manager CLI
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
add <title> Add a new task
list List tasks
help [command] display help for command
Tip: Commander parses options after the command by default. Use
program.enablePositionalOptions()if you need global options to coexist with subcommand options that share a name.
Yargs
Yargs builds the parser through chained calls. Each command() takes a name string (with positional placeholders), a description, a builder that declares options, and a handler that runs the logic. Yargs coerces types automatically and offers declarative validation.
#!/usr/bin/env node
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
yargs(hideBin(process.argv))
.scriptName("task")
.command(
"add <title>",
"Add a new task",
(y) =>
y
.positional("title", { type: "string", describe: "task title" })
.option("priority", {
alias: "p",
choices: ["low", "normal", "high"],
default: "normal",
})
.option("done", { type: "boolean", default: false }),
(argv) => {
console.log(`Added "${argv.title}" [priority=${argv.priority}]`);
if (argv.done) console.log("Marked as done.");
}
)
.command(
"list",
"List tasks",
(y) => y.option("all", { alias: "a", type: "boolean" }),
(argv) => {
console.log(argv.all ? "Listing all tasks" : "Listing open tasks");
}
)
.demandCommand(1, "You must provide a command.")
.strict()
.help()
.parse();
The hideBin helper strips node and the script path from process.argv. .demandCommand(1) forces at least one command, .strict() rejects unknown flags, and choices restricts a value to a fixed set.
task add "Ship release" -p urgent
Output:
Invalid values:
Argument: priority, Given: "urgent", Choices: "low", "normal", "high"
Yargs caught the invalid value before the handler ran — no manual checks needed. With CommonJS, replace the imports with const yargs = require("yargs/yargs") and const { hideBin } = require("yargs/helpers").
Subcommands and nesting
Real tools group related actions (task config set, task config get). Commander nests commands by adding child command() calls to a parent, while Yargs nests by calling .command() inside a builder.
// Commander nested subcommands
const config = program.command("config").description("Manage settings");
config
.command("set <key> <value>")
.action((key, value) => console.log(`Set ${key} = ${value}`));
config
.command("get <key>")
.action((key) => console.log(`Value of ${key}`));
Polishing the experience
A professional CLI does more than parse. Pair these libraries with chalk for colored output, ora for spinners, and @inquirer/prompts for interactive questions. Always set a non-zero exit code on failure so scripts and CI pipelines can detect errors.
program
.command("deploy")
.action(async () => {
try {
await runDeploy();
console.log("Deploy succeeded.");
} catch (err) {
console.error(`Deploy failed: ${err.message}`);
process.exitCode = 1;
}
});
Best practices
- Add a shebang (
#!/usr/bin/env node) and abinentry inpackage.jsonso the tool installs as a real command. - Use required (
<value>) versus optional ([value]) placeholders deliberately, and provide sensible defaults for options. - Validate input early — use Yargs
choices/demandOptionor Commander custom argument parsers rather than checking inside handlers. - Set
process.exitCode = 1(or callprocess.exit(1)) on errors so CI and shell scripts react correctly. - Keep handlers thin: parse arguments in the CLI layer, then call plain functions that are easy to unit test.
- Write descriptions for every command and option — they power the auto-generated
--help, which is your tool’s documentation. - Enable
.strict()in Yargs (or validate unknown options in Commander) to catch typos instead of silently ignoring them.