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.

Table 1. The Node.js options at a glance
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, demandCommand, completion

Behaviour is configured through many chained calls; make .strict() and .fail() explicit.

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.

docsctl in commander (docsctl.mjs)
#!/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 a CommanderError instead of calling process.exit(1). The catch block 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 --help after an error, and commander writes the error to stderr.

  • process.exitCode = 5 sets the code without cutting off pending output, so it is preferable to calling process.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.

The search command in yargs
#!/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:

A search command in oclif (TypeScript)
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.

browse.mjs — search results as an Ink component (no build step)
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:

package.json
{
  "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

"type": "module" in package.json, or a .mjs file

The default for .js files without "type": "module", or a .cjs file

Syntax

import { Command } from 'commander', top-level await

const { Command } = require('commander')

commander, yargs

Both are usable from ESM

Both remain usable from CommonJS

Ink

ESM-only

Needs a dynamic import() or a migration to ESM

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.