Node.js CLI Frameworks
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against Node.js 24 LTS, commander 15.0.0, oclif 6.0.2, Ink 7.1.1, yargs as documented on yargs.js.org and the docsctl
running example of this sub-section, as checked on 2026-09-30. The commander and yargs snippets were run on Node.js 24.
The Ink snippet was run on Node.js 24; the oclif snippet was checked against its documentation but not run. The fictional docsctl exists only on these pages,
and its behaviour here is illustrative.
Node.js is a natural home for a CLI that will later be wrapped as an MCP server, because the MCP TypeScript SDK runs on the same runtime. The language
references are JavaScript and TypeScript;
this page covers only what changes when the program is a command-line tool. Node’s start-up is faster than the JVM’s, and slower than a native binary, so the
advice on cheap start-up in Agent Ergonomics still applies: import lazily and keep --help free of work.
| Library | What it is | Good for | Watch out for |
|---|---|---|---|
commander |
Small, popular command parser |
Single-file tools with a few subcommands; choices, env-bound options, help text |
Usage errors exit with 1 by default, not the contract’s 2 (see below). |
yargs |
Parser with a fluent, chainable API |
Strict parsing, |
Behaviour is configured through many chained calls; make |
oclif |
Framework for multi-command CLIs and plugin systems |
Large tools, generated project, JSON flag, generated docs and manifest |
More scaffolding than a small tool needs. |
Ink |
React renderer for the terminal |
Interactive views built from components |
A TUI library (see TUIs vs. CLIs), not a parser. |
commander
commander is a compact parser: a Command object, .command() for subcommands, .argument() and .option() for their inputs, .action() for the handler, and
.parseAsync() to run. It supports -- to end option parsing, choices through new Option(…).choices([…]), and binding an option to an environment variable with
.env(…). Below is the short docsctl version, covering search and publish.
#!/usr/bin/env node
import { Command, Option } from 'commander';
const EXIT_USAGE = 2;
const EXIT_CONFIRMATION_REQUIRED = 5;
const program = new Command();
program
.name('docsctl')
.description("Search and publish this site's documentation.")
.version('0.1.0')
.addOption(new Option('--format <format>', 'output format').choices(['text', 'json', 'jsonl']).env('DOCSCTL_FORMAT').default('text'))
.option('--json', 'alias for --format json')
.showHelpAfterError('(run docsctl --help for usage)')
.exitOverride(); // throw CommanderError instead of calling process.exit(1)
program
.command('search')
.description('Search the documentation.')
.argument('<query>', 'words to look for')
.option('--component <name>', 'restrict to one component')
.addHelpText('after', '\nExample: docsctl search kalman --component irurueta-statistics --json')
.action((query, options, command) => {
const globals = command.optsWithGlobals();
const payload = { schemaVersion: 1, query, total: 0, nextCursor: null, truncated: false, results: [] };
const json = globals.json || globals.format !== 'text';
process.stdout.write(json ? JSON.stringify(payload) + '\n' : `0 result(s) for ${query}\n`);
});
program
.command('publish')
.description('Publish a component (destructive unless --dry-run).')
.requiredOption('--component <name>', 'component to publish')
.option('--dry-run', 'show the plan only')
.option('-y, --yes', 'assume yes for confirmations')
.action((options) => {
if (options.dryRun) {
process.stdout.write(JSON.stringify({ schemaVersion: 1, dryRun: true, component: options.component }) + '\n');
return;
}
if (!options.yes) {
process.stderr.write('docsctl: publish needs confirmation; re-run with --yes or use --dry-run.\n');
process.exitCode = EXIT_CONFIRMATION_REQUIRED;
}
});
try {
await program.parseAsync();
} catch (err) {
// commander has already printed its message to stderr
const informational = ['commander.helpDisplayed', 'commander.version', 'commander.help'];
process.exit(informational.includes(err.code) ? 0 : EXIT_USAGE);
}
Notes on the choices made:
-
.exitOverride()makes commander throw aCommanderErrorinstead of callingprocess.exit(1). Thecatchblock at the bottom turns every usage error into exit code 2, as the contract requires, and lets help and version display exit with 0. The library default of 1 is the most common surprise when adopting commander for an agent-facing tool. -
.showHelpAfterError(…)adds a pointer to--helpafter an error, and commander writes the error to stderr. -
process.exitCode = 5sets the code without cutting off pending output, so it is preferable to callingprocess.exit(5)in the middle of a handler. -
Data goes to stdout with
process.stdout.write; the confirmation message goes to stderr.
Run against Node.js 24, docsctl --json search kalman prints the JSON and exits with 0, docsctl publish --component irurueta-algebra exits with 5 and names --yes,
and a missing argument, a missing required option, an invalid --format value and an unknown command all exit with 2. Reference:
commander.js.
yargs
yargs builds the command line with a fluent API. .command('search <query>', …) declares a subcommand and its positional, .strict() rejects unknown flags and commands, .demandCommand(1)
requires a subcommand, and .fail(…) replaces the default failure handler, which is where the exit code is chosen. hideBin strips node and the script
name from process.argv.
#!/usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
await yargs(hideBin(process.argv))
.scriptName('docsctl')
.option('json', { type: 'boolean', describe: 'Emit JSON on stdout.', global: true })
.command(
'search <query>',
'Search the documentation.',
(y) => y.positional('query', { type: 'string' }).option('component', { type: 'string' }),
(argv) => {
const payload = { schemaVersion: 1, query: argv.query, total: 0, results: [] };
process.stdout.write(argv.json ? JSON.stringify(payload) + '\n' : `0 result(s) for ${argv.query}\n`);
},
)
.demandCommand(1)
.strict()
.fail((message, error, y) => {
process.stderr.write(`docsctl: ${message ?? error.message}\n`);
process.exit(2);
})
.parseAsync();
Everything after -- is treated as operands rather than flags. yargs supports both ESM and CommonJS, and can generate shell completion with .completion().
Reference: yargs.
oclif
oclif ("Open CLI Framework") is a generator plus a runtime for larger tools. oclif generate mynewcli scaffolds a TypeScript project with a
bin/run.js entry point (and bin/dev.js for development), one class per command, oclif generate command foo:bar for adding commands,
and hooks and plugins for extension. The documentation lists JSON output, manifest generation and README generation among its features. Each command is a class:
import {Args, Command, Flags} from '@oclif/core'
export default class Search extends Command {
static description = 'Search the documentation.'
static enableJsonFlag = true // adds --json; with --json the object returned by run() is printed as JSON and this.log() output is suppressed
static examples = ['<%= config.bin %> <%= command.id %> kalman --component irurueta-statistics --json']
static args = {
query: Args.string({required: true, description: 'Words to look for.'}),
}
static flags = {
component: Flags.string({description: 'Restrict to one component.'}),
}
async run(): Promise<{schemaVersion: number; query: string; total: number; results: unknown[]}> {
const {args} = await this.parse(Search)
return {schemaVersion: 1, query: args.query, total: 0, results: []}
}
}
static enableJsonFlag = true is the documented way to give a command a --json flag, and this.error('message', {exit: 5}) ends a command with a chosen code.
The <%= config.bin %> placeholders in examples are expanded by oclif. Because oclif’s exit codes and JSON envelope are its own,
check them against the contract and map differences in a single base class, and use oclif when you need its plugin system and generated documentation, not for a two-command tool.
Reference: oclif.
Ink
Ink is a React renderer for the terminal: components such as <Text> and <Box> (a flexbox container) are rendered with render(…), and hooks such as useInput,
useApp, useStdin and useWindowSize handle keys, exit, whether stdin supports raw mode (isRawModeSupported) and terminal size. It is the right tool for an interactive picker or dashboard.
Its documentation notes that it works in TTY environments and behaves differently outside them (such as in CI), and Ink 7 is published as an ESM-only package. Use it only behind
an explicit subcommand or flag, for example docsctl browse, and keep every operation reachable non-interactively. The comparison with the other TUI libraries is on
TUIs vs. CLIs.
import React, {useState} from 'react';
import {render, Box, Text, useApp, useInput} from 'ink';
const h = React.createElement; // JSX would need a build step (TypeScript, Babel or tsx)
// Rows as returned in "results" by `docsctl search --json -- kalman`.
const results = [
{id: 'irurueta-statistics::kalman.adoc', title: 'Kalman filter'},
{id: 'irurueta-algebra::index.adoc', title: 'Irurueta Algebra'},
];
function Browse() {
const [cursor, setCursor] = useState(0);
const {exit} = useApp();
useInput((input, key) => {
if (input === 'q') exit();
if (key.upArrow) setCursor(c => Math.max(0, c - 1));
if (key.downArrow) setCursor(c => Math.min(results.length - 1, c + 1));
});
return h(Box, {flexDirection: 'column'},
...results.map((r, i) =>
h(Text, {key: r.id, inverse: i === cursor}, `${r.title} (${r.id})`)),
h(Text, {dimColor: true}, 'Up/down to move, q to quit'));
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
process.stderr.write('docsctl: browse needs a terminal; use `docsctl search --json` instead.\n');
process.exitCode = 2;
} else {
await render(h(Browse)).waitUntilExit();
}
The file is an ES module (.mjs, so top-level await works) and uses React.createElement instead of JSX, so Node.js runs it
directly; written as <Box> and <Text> JSX, it needs a compile step (TypeScript, Babel or a runner such as tsx) first. Install
ink and react (Ink 7.1.1 declares React 19.2 or later as a peer dependency). render returns an instance whose
waitUntilExit() resolves after exit() from useApp unmounts the app. The terminal check comes first, so a pipe or an agent
gets exit code 2 and never an interactive screen. On Node.js 24 with Ink 7.1.1 and React 19.2 this printed the message and set exit
code 2 with standard input redirected, and under a pseudo-terminal it drew the list and quit on q.
Reference: Ink.
The bin field and the shebang
npm turns a script into a command through the bin field of package.json. It is a string when the package has one executable named like the package, and an object mapping command names to
files when it has several:
{
"name": "docsctl",
"version": "0.1.0",
"type": "module",
"bin": { "docsctl": "./docsctl.mjs" },
"engines": { "node": ">=22.12" }
}
The npm documentation requires the file to start with #!/usr/bin/env node; without it the script is started without the node executable. On Unix-like systems a global install
symlinks the file into a directory on the PATH, and on Windows npm creates a .cmd wrapper. Set the executable bit in the repository for local runs, and
run the real, installed command in a smoke test rather than only node docsctl.mjs, so a broken bin entry is caught before release. Reference:
npm — package.json bin.
ESM versus CommonJS
Node.js has two module systems, and a CLI author meets both:
| Topic | ESM | CommonJS |
|---|---|---|
Enabled by |
|
The default for |
Syntax |
|
|
commander, yargs |
Both are usable from ESM |
Both remain usable from CommonJS |
Ink |
ESM-only |
Needs a dynamic |
oclif |
Supported |
Supported |
New tools should default to ESM: top-level await makes await program.parseAsync() natural, and libraries such as Ink require it. When a dependency forces you
to load ESM from CommonJS, use a dynamic import(); recent Node.js LTS lines also let require() load ES modules in some cases, so check the
Node.js documentation for your release. Remember also the TypeScript side: compile with a matching module setting and keep the shebang on the built output. Reference:
Node.js — ECMAScript modules.
Wrapping and testing
Testing follows the same pattern as elsewhere: test the parser in-process where the library allows it (commander’s exitOverride and configureOutput exist to make this possible), and run a few subprocess tests
against the installed binary to check the streams and exit codes; see Testing CLIs for what to assert. Distribution through npm bin is covered on Packaging & Distribution. The MCP wrapper for a Node tool is covered on Wrapping a CLI as an MCP Server.