cheat sheet
yargs
Package-level reference for yargs on npm — command builders, middleware, prototype-pollution history, and alternatives like commander.
yargs
What it is
yargs is the long-running, declarative CLI argument parser for Node.js. You chain .command(), .option(), .middleware(), and .parse() to build a CLI that auto-generates --help, validates types, supports sub-commands, and reads from environment variables.
It powers some of the most-downloaded CLIs in the ecosystem — Mocha, Jest (historically), Webpack CLI, ESLint (historically), and many smaller tools. Its closest competitor is Commander; yargs is more declarative and feature-rich, Commander is leaner and more imperative. Both are excellent; the choice is style.
The 2020-2022 era was rough — multiple prototype-pollution CVEs flowed through yargs-parser (yargs' internal arg parser). The current 17.x line is patched and considered safe; older versions are not.
Install
# npm / pnpm / yarn / bun
npm install yargs
pnpm add yargs
yarn add yargs
bun add yargs
Output: runtime dep. ~50 KB gzipped — heavyweight relative to Commander (~10 KB).
# TypeScript declarations are bundled in 17+ — no separate package needed
# (for older versions, install @types/yargs)
npm install --save-dev @types/yargs # legacy
Output: types bundled since v17. For 16 and earlier, install @types/yargs.
# yargs-parser — the lower-level arg parser, used standalone
npm install yargs-parser
Output: if you only need flag parsing without help text and subcommands, yargs-parser alone is ~5 KB and faster.
# yargs-interactive — interactive prompt extension
npm install yargs-interactive
Output: if a flag is missing, prompt the user (like Yeoman).
Versioning & Node support
- Current major line is
17.x(stable since 2021 — long-lived, security-maintained). API surface unchanged for years; new minors fix bugs and refresh dependencies. 17.xis ESM-first but ships dual ESM + CJS. Bothimport yargs from "yargs"andconst yargs = require("yargs")work.- Pure JS; runs on Node 18+ (yargs 17.7+ dropped Node 12/14 support). Other runtimes (Bun, Deno via npm specifier) work but are not officially tested.
- Always a runtime dependency — your CLI binary imports it.
- Strict semver — major bumps remove deprecated subcommands and tighten parser behaviour.
Package metadata
- Maintainer: Benjamin Coe (
@bcoe), Maciej Małecki, and the yargs-js org - Project home: github.com/yargs/yargs
- Docs: yargs.js.org
- npm: npmjs.com/package/yargs
- License: MIT
- First released: 2010 (originally as
optimist; renamedyargsin 2013) - Downloads: ~80 million per week
Peer dependencies & extras
| Package | Purpose |
|---|---|
yargs-parser | Lower-level arg parser (yargs depends on this internally; useful standalone). |
@types/yargs | TS declarations for yargs ≤16 (bundled in 17+). |
yargs-interactive | Adds prompt-style input for missing required flags. |
cliui | yargs' table-rendering layer for help output. Indirect dependency. |
cosmiconfig | Layered config loader — often paired with yargs to merge file-based config with CLI flags. |
chalk | Color the help/error output. Yargs doesn't color by default. |
inquirer / prompts / enquirer | Heavier interactive prompt libraries — pair with yargs for init subcommands. |
Alternatives
| Library | Trade-off |
|---|---|
| commander | Smaller (~10 KB), more imperative API (.option() then .parse(process.argv)). Slightly less feature-rich but more popular in newer CLIs (vite, esbuild, …). |
| clipanion | Class-based, TypeScript-first. Built by the Yarn team. Pick for very large CLIs with many subcommands and rich TS typing. |
| citty | Lightweight CLI by Nuxt team. Modern, ESM-first, smaller. Pick for tiny single-purpose CLIs. |
| cmd-ts | Functional, TypeScript-first, type-safe argument parsing. Pick for FP-style codebases. |
| oclif | Salesforce's heavyweight framework. Full plugin system, autoupdater, manifest generation. Pick for very large multi-binary CLI suites. |
| mri / minimist | ~1 KB raw argument parsers. No help text, no subcommands. Pick for shell-script-replacement scripts. |
Common gotchas
yargs(hideBin(process.argv))is the canonical entry. ForgettinghideBinincludesnode/scriptpaths in argv. The helper strips them on Node and on Windows-shim variants..demandCommand(1)is your friend. Without it, calling the binary with no subcommand silently exits 0 — confusing UX. Always require at least one command.- Type coercion is opt-in.
--port 3000is the string"3000"unless you declare.option("port", { type: "number" }). Forgetting this is the #1 footgun. .strict()is opt-in but should be default. Without.strict(), unknown flags are silently accepted intoargv..strict()makes them errors.- ESM +
require.main === moduledoesn't work. In ESM, useimport.meta.url === pathToFileURL(process.argv[1]).hrefto detect "ran as main". Or just always invoke.parse()at the top of the file. .middleware()runs before each command's handler, including help. A middleware that callsconsole.log(...)will pollute--helpoutput. Gate onargv._.length.
Real-world recipes
Basic CLI
// bin/cli.ts
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
yargs(hideBin(process.argv))
.scriptName("greet")
.option("name", { type: "string", demandOption: true, describe: "Who to greet" })
.option("loud", { type: "boolean", default: false, alias: "l" })
.command("$0", "Say hello", () => {}, (argv) => {
let msg = `Hello, ${argv.name}!`;
if (argv.loud) msg = msg.toUpperCase();
console.log(msg);
})
.strict()
.help()
.parse();
Output:
$ greet --name Alice
Hello, Alice!
$ greet --name Alice --loud
HELLO, ALICE!
$ greet
Missing required argument: name
$0 is the default command (no subcommand). .strict() + .help() are the safety net every CLI should have.
Subcommands
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
yargs(hideBin(process.argv))
.command(
"init <path>",
"Initialize a new project",
(y) => y
.positional("path", { type: "string", demandOption: true })
.option("template", { type: "string", default: "default" }),
(argv) => {
console.log(`init at ${argv.path} from ${argv.template}`);
}
)
.command(
"build [target]",
"Build the project",
(y) => y
.positional("target", { type: "string", default: "production" })
.option("watch", { type: "boolean", alias: "w" }),
(argv) => {
console.log(`build ${argv.target} watch=${argv.watch}`);
}
)
.demandCommand(1)
.strict()
.help()
.parse();
Output:
$ mycli init ./myapp --template react
init at ./myapp from react
$ mycli build dev --watch
build dev watch=true
$ mycli
Not enough non-option arguments: got 0, need at least 1
<path> = required positional. [target] = optional positional.
Aliases + types + defaults
const argv = yargs(hideBin(process.argv))
.option("port", { type: "number", alias: "p", default: 3000, describe: "Port to listen on" })
.option("host", { type: "string", alias: "h", default: "localhost" })
.option("verbose", { type: "count", alias: "v", describe: "Verbosity (-v, -vv, -vvv)" })
.option("tag", { type: "string", array: true, describe: "Tags to apply" })
.option("env", { type: "string", choices: ["dev", "staging", "prod"] as const, default: "dev" })
.strict()
.parseSync();
console.log(argv);
Output:
$ mycli -vvv --port 8080 --tag a --tag b --env prod
{ verbose: 3, port: 8080, host: "localhost", tag: ["a", "b"], env: "prod", _: [], $0: "mycli" }
type: "count" increments per flag (-v = 1, -vv = 2, …). array: true makes a flag repeatable. choices validates against an enum.
Environment-var config
const argv = yargs(hideBin(process.argv))
.env("MYAPP") // MYAPP_PORT → --port
.option("port", { type: "number", default: 3000 })
.option("api-key", { type: "string", demandOption: true }) // MYAPP_API_KEY
.strict()
.parseSync();
Output:
$ MYAPP_PORT=8080 MYAPP_API_KEY=abc mycli
# argv.port = 8080, argv.apiKey = "abc"
Environment vars are auto-mapped: MYAPP_API_KEY becomes --api-key, hyphens map to underscores. This is the canonical "twelve-factor" config layer.
Middleware — load config / authenticate / inject ctx
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { readFileSync } from "node:fs";
interface Config { apiUrl: string; token: string }
const loadConfig = (argv: any) => {
const cfg: Config = JSON.parse(readFileSync(argv.config ?? "./config.json", "utf8"));
return { ...argv, config: cfg };
};
const checkAuth = (argv: any) => {
if (!argv.config.token) throw new Error("No API token in config");
return argv;
};
yargs(hideBin(process.argv))
.middleware([loadConfig, checkAuth])
.command("deploy", "Deploy the app", () => {}, (argv) => {
console.log(`Deploying to ${argv.config.apiUrl}`);
})
.strict()
.parse();
Output: middleware functions run before each command handler, in order. Use for config loading, auth, dependency injection.
Reading config files (cosmiconfig + yargs)
import { cosmiconfigSync } from "cosmiconfig";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
const cfg = cosmiconfigSync("mycli").search()?.config ?? {};
const argv = yargs(hideBin(process.argv))
.config(cfg) // seed defaults from cosmiconfig
.option("port", { type: "number", default: 3000 })
.strict()
.parseSync();
Output: layered config — .myclirc.json → env vars (via .env(...)) → CLI flags. Each layer overrides the previous.
Production deployment
Make the binary self-aware
// package.json
{
"name": "mycli",
"bin": { "mycli": "./dist/cli.js" },
"scripts": {
"build": "tsc",
"prepublish": "npm run build"
}
}
// dist/cli.js (compiled from src/cli.ts)
#!/usr/bin/env node
import yargs from "yargs";
// ...
The shebang lets npm install -g mycli (or npx mycli) work.
ESM + CJS dual publishing
Yargs ships dual ESM/CJS. Your CLI can match:
{
"type": "module",
"exports": {
".": {
"import": "./dist/esm/cli.js",
"require": "./dist/cjs/cli.cjs"
}
}
}
Or pick one — most modern CLIs are ESM-only now.
Strip yargs' source maps for size
In production, yargs is ~50 KB gzipped uncompressed. If you bundle (esbuild --bundle), tree-shaking trims further. Many CLIs prefer Commander specifically for the smaller bundle.
Performance tuning
parseSync vs parse
yargs(...).parse() returns a Promise (or argv, depending on middleware async-ness). parseSync() is sync and faster for purely-sync handlers. Most CLIs should use parseSync unless they have async middleware.
Avoid .middleware() for tiny CLIs
Each middleware adds a function call to every command. For tiny CLIs with one command, just call the helper inside the handler.
Lazy-load subcommand handlers
For huge multi-command CLIs:
yargs(...)
.command(
"expensive",
"Run expensive thing",
() => {},
async (argv) => {
const { runExpensive } = await import("./expensive.js");
await runExpensive(argv);
}
);
This keeps startup time low — expensive.js is only loaded if that subcommand is invoked.
Version migration guide
v16 → v17
- Node ≥ 12 required (17.7+ raised to 18). Drop older runtimes.
yargs/yargsimport path retired. Useyargsdirectly.- ESM + CJS dual published. No code changes needed; bundlers pick the right one.
- TypeScript types bundled. Uninstall
@types/yargsafter upgrade. yargs-parser21+ patched all known prototype-pollution paths. Confirm vianpm ls yargs-parser.
# Migration
npm install yargs@^17
npm uninstall @types/yargs
npm ls yargs-parser # confirm ^21
Output:
added 1 package in 2s
removed 1 package in 1s
my-app@1.0.0
└─┬ yargs@17.7.2
└── yargs-parser@21.1.1
Common migration breaks
// v16 — import path
import yargs from "yargs/yargs";
// v17
import yargs from "yargs";
// v16 — manually strip argv
yargs(process.argv.slice(2))
// v17 — use helper (also handles Windows shim paths)
import { hideBin } from "yargs/helpers";
yargs(hideBin(process.argv))
v17.x stability
The 17.x line has been stable for ~5 years. There's no v18 announced. Treat 17 as the LTS-style line; just stay current within it (^17).
Security considerations
Yargs' security history is real — almost every CVE has been in yargs-parser.
| CVE | Component | Year | Fixed in |
|---|---|---|---|
| CVE-2020-7608 | yargs-parser | 2020 | yargs-parser 18.1.2 |
| Various PP | yargs-parser | 2020-2022 | yargs-parser 21+ |
| CVE-2021-23337 | (indirect via lodash) | 2021 | lodash 4.17.21 |
Rules:
- Pin
yargsto^17.7or newer. This pullsyargs-parser≥21 — all known prototype-pollution paths patched. npm ls yargs-parser— confirm no v18 or older copy is in your tree (some old packages pin it).- Don't feed
argvinto deep-set or deep-merge helpers._.set(target, argv.path, argv.value)is a classic prototype-pollution sink. Filter argv keys against an allow-list before such calls. - Sensitive flags should never echo into the shell history. Read tokens/passwords via env vars or stdin prompt, not
--token. Useinquirerorpromptsfor interactive secret input.
Testing & CI integration
import { describe, it, expect } from "vitest";
import yargs from "yargs";
const buildCli = () =>
yargs()
.option("port", { type: "number", default: 3000 })
.option("host", { type: "string", default: "localhost" })
.strict();
describe("cli", () => {
it("parses port + host", () => {
const argv = buildCli().parseSync(["--port", "8080", "--host", "0.0.0.0"]);
expect(argv.port).toBe(8080);
expect(argv.host).toBe("0.0.0.0");
});
it("rejects unknown flags in strict mode", () => {
expect(() => buildCli().parseSync(["--unknown"])).toThrow();
});
});
Output: yargs accepts an explicit argv array — test without spawning a subprocess.
For integration tests with real binaries, use execa:
import { execa } from "execa";
const { stdout } = await execa("node", ["./dist/cli.js", "--port", "8080"]);
Ecosystem integrations
| Tool | Integration |
|---|---|
cosmiconfig | Layered config — .myclirc.json, package.json field, env var, CLI flag |
dotenv | .env file → process.env → yargs.env(...) |
chalk | Color help output via yargs.usage(chalk.blue(...)) |
cliui | yargs' table renderer — exported standalone for custom output |
pino / winston | Logger initialized from argv.verbose count |
inquirer / prompts | Interactive prompts after yargs argv parse for missing inputs |
update-notifier | Notify users when a newer version of the CLI is available |
enquirer | Alternative interactive prompts |
Troubleshooting common errors
Missing required argument: <name>—demandOption: trueflag not supplied. Either supply it, setdefault:, or removedemandOption.Unknown argument: <name>in.strict()mode — flag not declared. Add an.option()call or remove.strict().argv.portis"3000"(string) instead of 3000 (number) — forgottype: "number".process.argvincludesnodeand the script path — usehideBin(process.argv), notprocess.argvdirectly.Cannot find module 'yargs'under tsc — ensure"moduleResolution": "node16"or"bundler"so the conditionalexportsmap resolves.yargs is not a function— usuallyimport * as yargs from "yargs"instead of default. Useimport yargs from "yargs".- Sub-command help isn't shown — missing
.help()at the top level, or you're calling the handler before.parse().
When NOT to use this
- You only need flag parsing, no help/subcommands. Use
minimistormri— ~1 KB, ten lines of code. - You prefer imperative chaining. Use commander — smaller, more popular in newer tools, slightly easier to write.
- You want type-safe argv at compile time. Use cmd-ts or clipanion — typed all the way through.
- You're building a massive plugin-driven CLI suite. Use oclif — has plugin manifest, autoupdater, manifest generation built in.
- You're shell-scripting in Node. Use
zxorexeca— they handle args differently (you're not building a CLI for others; you're scripting).
See also
- Packages: npm-commander — leaner alternative
- Concept: JSON — env + config + CLI flag merging
- JavaScript: node runtime —
process.argvandimport.metasemantics