meow
meow turns a Node script into a command-line tool in about ten lines. You hand it a block of help text and a flags object, and it parses process.argv, converts --kebab-case flags to camelCase keys, applies types and defaults, validates required flags and choices, negates flags written as --no-thing, wires up --help and --version from your package.json, and sets the process title. What comes back is a small object with input (the non-flag arguments), flags, command, pkg, and helpers like showHelp() and showVersion(). It deliberately does not build a subcommand framework for you: version 14.1 added a flat commands list that stops parsing at the first non-flag argument, and nesting deeper means calling meow again with the leftover arguments.
For a one-verb CLI on modern Node, meow is the least ceremony you can get away with and the zero-dependency install is a genuine advantage in a tool people install globally. The moment you need real subcommands or want help text you do not maintain by hand, move to commander.
Use it if
- You are writing a small single-purpose CLI on Node 20+ in ESM and want argv parsing, --help, and --version handled in a dozen lines
- You want your CLI's dependency tree to stay empty: since v12.1 meow bundles its own parser, so installing it adds exactly one node_modules entry
- You want basic validation without a schema library: type, default, choices, isRequired (which can be a function of the other flags), and isMultiple cover most flag rules
- You want to hand-write the help output and control its exact wording and layout rather than accept whatever a framework generates
- You need a couple of top-level verbs like run and list, which the commands option handles by parsing the verb and leaving the rest in input for you to route
- You publish CommonJS or support older runtimes: meow has been ESM-only since v10 and v14 requires Node 20, so there is no require() path and no way back short of bundling
- Your CLI has real subcommands with their own flags and help: commands is a flat list, per-command help and nesting are your problem, and commander or yargs generate all of that from one declaration
- You want help text generated from your flag definitions: meow never does this, so every flag exists twice (once in the flags object, once in the hand-written help string) and the two drift apart the first time someone is in a hurry
- You want strict parsing by default: allowUnknownFlags defaults to true, so a typo like --verbsoe is silently accepted and ignored until you explicitly set it to false
- You expect numbers to arrive as numbers: inferType defaults to false, so --count 42 is the string '42' unless you declare type: 'number' on that flag
- You need shell completion, prompts, coloured output, or spinners: meow parses arguments and nothing else, and you assemble the rest of the CLI experience yourself
Setup reality
npm install meow, add a #!/usr/bin/env node shebang, and point the bin field at your entry file. The two requirements that bite are structural rather than fiddly: the package is ESM-only with "type": "module" and engines node >=20, and importMeta: import.meta is a mandatory option because meow uses it to walk upwards and find your package.json for the version and description. That last part is the classic production failure. If you bundle the CLI with esbuild, ncc, or rollup, import.meta.url resolves inside your build output, meow finds the wrong package.json or none at all, and --version prints something unexpected; the fix is to pass the pkg option explicitly, which also stops the filesystem lookup. TypeScript users need "module" and "moduleResolution" set to node16 or later, otherwise the types will not resolve at all. Everything else is optional: types are bundled and there are no peer dependencies.
Patterns
A minimal CLI entry pointbasic-cli
#!/usr/bin/env node
import meow from 'meow';
const cli = meow(`
Usage
$ resize <file>
Options
--width, -w Output width in pixels
Examples
$ resize photo.png --width 800
`, {
importMeta: import.meta,
flags: {
width: {type: 'number', shortFlag: 'w', default: 640},
},
});
console.log(cli.input.at(0), cli.flags.width);importMeta is required, not optional: meow uses it to locate your package.json for the version and description. The help string is reindented and trimmed, so a tab-indented template literal comes out looking correct.
Types, short flags, defaults, and choicesdefine-flags
flags: {
format: {
type: 'string',
shortFlag: 'f',
choices: ['json', 'yaml', 'table'],
default: 'table',
},
dryRun: {type: 'boolean', default: false},
retries: {type: 'number', default: 3},
}Flag keys are camelCase but match kebab-case on the command line, so dryRun is passed as --dry-run and read as cli.flags.dryRun. shortFlag is the single-letter form; the separate aliases array is for longer synonyms. An invalid choice exits with a parse error rather than falling back to the default.
Require a flag, conditionallyrequired-flags
flags: {
apiKey: {type: 'string', isRequired: true},
region: {
type: 'string',
isRequired: (flags, input) => flags.deploy === true,
},
}isRequired accepts a function of the parsed flags and the non-flag input, which covers most 'this flag only matters with that flag' rules without a validation library. A missing required flag prints an error and exits before your code runs, so there is no hook to customise the message.
Accept a flag more than oncerepeatable-flags
flags: {
include: {type: 'string', shortFlag: 'i', isMultiple: true},
}
// $ mytool -i src -i tests
// cli.flags.include === ['src', 'tests']Values must be supplied by repeating the flag. Comma-separated and space-separated lists are explicitly not supported (upstream issue 164), so --include src,tests gives you the single string 'src,tests' and you split it yourself. With isMultiple the default value is an empty array when the flag is absent.
Support --no-somethingnegated-flags
const cli = meow(`
Options
--no-color Disable coloured output
`, {
importMeta: import.meta,
flags: {
color: {type: 'boolean', default: true},
},
});
// $ mytool --no-color -> cli.flags.color === falseDeclare the positive flag; the --no- prefix is handled for you. Do not also declare a noColor flag, because both would then appear in flags and the two states can disagree.
Route a flat set of commandssubcommands
const cli = meow({
importMeta: import.meta,
commands: ['run', 'list'],
flags: {verbose: {type: 'boolean', shortFlag: 'v'}},
});
if (!cli.command) {
cli.showHelp(0);
}
if (cli.command === 'run') {
const runCli = meow({importMeta: import.meta, argv: cli.input});
// parse the subcommand's own flags here
}Added in 14.1.0. Parsing stops at the first non-flag argument, so parent flags must appear before the command: mytool -v run works, mytool run -v leaves -v in input for the child parser. An unknown command prints help and exits 2; no command at all leaves cli.command undefined and meow does nothing, so the check above is on you.
Reject unknown flagsstrict-flags
const cli = meow(helpText, {
importMeta: import.meta,
allowUnknownFlags: false,
flags: {
verbose: {type: 'boolean', shortFlag: 'v'},
},
});The default is true, which means a typo such as --verbsoe is quietly accepted and dropped. Turning this on is almost always what you want; camelCase-declared flags still accept both --camelCase and --camel-case in strict mode.
Require and type the positional argumentsrequire-input
const cli = meow(helpText, {
importMeta: import.meta,
input: {
type: 'string-array',
isRequired: true,
},
});
for (const file of cli.input) {
process(file);
}input covers the non-flag arguments and accepts string, boolean, number, array, and the typed array forms. isRequired only checks that at least one argument is present, so 'exactly two arguments' is still a manual check.
Get numbers instead of stringsnumber-flags
// explicit, and what you should do:
flags: {port: {type: 'number', default: 3000}}
// blanket coercion for everything, including positional input:
const cli = meow(helpText, {importMeta: import.meta, inferType: true});Without a declared type, --port 3000 arrives as the string '3000' and the positional argument 5 arrives as '5'. inferType: true flips that globally, but it also turns a version-looking argument or a zero-padded id into something you did not intend, so per-flag types are the safer choice.
Tell 'not passed' apart from 'passed as false'tri-state-booleans
const cli = meow(helpText, {
importMeta: import.meta,
booleanDefault: undefined,
flags: {
cache: {type: 'boolean'},
},
});
const useCache = cli.flags.cache ?? configFile.cache ?? true;booleanDefault is false by default, which makes an omitted boolean indistinguishable from --no-cache and quietly overrides config file values. Setting it to undefined excludes unpassed booleans from flags entirely, which is what you want when the CLI layers over a config file. A per-flag default still wins over booleanDefault.
Show help or version yourselfhelp-and-exit
if (cli.input.length === 0) {
cli.showHelp(); // prints help and exits with code 2
}
if (someCondition) {
cli.showHelp(0); // treat it as success
}
cli.showVersion();showHelp defaults to exit code 2, which is correct for a usage error but wrong when the user asked for help on purpose, so pass 0 in that case. Automatic --help and --version only fire when that flag is the only argument, which is why a wrapper CLI passing arguments through can set autoHelp and autoVersion to false.
Parse a fixed argv in teststest-a-cli
import meow from 'meow';
import {readFileSync} from 'node:fs';
function parse(argv) {
return meow(helpText, {
importMeta: import.meta,
argv,
pkg: JSON.parse(readFileSync('./package.json', 'utf8')),
flags: {width: {type: 'number', default: 640}},
});
}
const cli = parse(['image.png', '--width', '800']);argv replaces process.argv.slice(2) so tests never touch global state. Passing pkg explicitly stops meow searching the filesystem for a package.json, which is also the fix when your CLI is bundled by esbuild or ncc and import.meta.url no longer points at your source tree.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| commander | npm | Nested subcommands, per-command options, and help text generated from your declarations |
| yargs | npm | Bigger tools that want command modules, middleware, config file merging, and shell completion built in |
| cac | npm | Similarly small and fast, but it does generate help from your option definitions and supports subcommands |