Building CLI Tools

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on Node.js 26, 24 and 22 unless the text says otherwise. A command-line tool is the smallest useful Node.js program that still has to behave like a citizen of the shell: it takes its instructions from arguments, standard input and the environment, writes results to standard output and complaints to standard error, tells its caller how it went through an exit status, and stops cleanly when it is interrupted. Node.js ships the pieces for all of that, so a tool such as the Bookshelf catalogue manager built here needs no runtime dependency: util.parseArgs() parses flags, util.styleText() colours text, node:readline/promises asks questions and reads lines, and the process object carries the exit code and the signals. This page assembles those pieces into one complete bookshelf command and then ships it, first through npm and then as a single executable file. The individual APIs are documented in depth elsewhere in this section: see The util Module, Console, readline & Terminal I/O and Process, Signals & Exit Codes; the flags of the node binary itself are the subject of Running Scripts, the CLI & the REPL. What a CLI should look like to an automated caller, such as an AI agent, is covered in CLI for Agents, and its Node.js page Node.js CLI Frameworks compares commander, yargs, oclif and Ink.

The stability and version map

Table 1. APIs and features used on this page
API or feature Stability (Node.js 26 docs) Version notes

util.parseArgs()

2 Stable

Stable since v20.0.0. allowNegative (--no-color) was added in v22.4.0 and v20.16.0, so it works on all three lines.

util.styleText()

2 Stable

Stable since v23.5.0 and v22.13.0. It checks the stream and NO_COLOR since v22.8.0. Hexadecimal colours arrived in v26.1.0 (observed on 24.21.0, rejected by 22.23.3).

node:readline/promises

2 Stable

question() takes { signal }; createInterface() takes a signal option. Both ran on 26, 24 and 22.

process.exitCode, process.on('SIGINT'), process.once('SIGTERM')

2 Stable

Unchanged across the three lines. SIGTERM and SIGINT have default handlers on non-Windows platforms; installing a listener replaces the default exit.

util.convertProcessSignalToExitCode()

Stable (module level)

Added in v25.4.0 and v24.14.0. typeof util.convertProcessSignalToExitCode is 'undefined' on 22.23.3 (observed), so the code below computes 128 + n itself.

process.loadEnvFile()

Stable (module level)

No longer experimental since v24.10.0 and v22.21.0. Present on all three lines.

import pkg from './package.json' with { type: 'json' }

Language feature

Import attributes; ran without a warning on 26, 24 and 22.23.3.

node --build-sea config.json

1.1 Active development

Added in v25.5.0. Node.js 24 and 22 only have the older --experimental-sea-config blob plus an injection tool.

SEA virtual file system ("useVfs")

1.0 Early development

Added in v26.9.0; documented but not run for this page.

A command is a package with a bin entry

An executable command in the npm world is a file that a package names in its bin field. When someone installs the package, npm links that file into a bin directory that is on the PATH (globally) or into node_modules/.bin (locally, where npx and npm run find it). The file must start with a shebang, the line #!/usr/bin/env node: the operating system reads it, looks node up on the PATH (so a version manager that swaps the active Node.js keeps working) and runs the file with it. Node.js itself skips a leading shebang line, so the same file also runs as node bin/bookshelf.js. The thin-entry layout below is the one used by most real tools: the file under bin/ only wires the process to the program (arguments, signals, exit code) and everything testable lives in modules under src/.

package.json of the Bookshelf CLI
{
  "name": "bookshelf-cli",
  "version": "0.1.0",
  "description": "Manage the Bookshelf catalogue from a terminal",
  "type": "module",
  "bin": {
    "bookshelf": "./bin/bookshelf.js"
  },
  "files": [
    "bin",
    "src"
  ],
  "engines": {
    "node": ">=22"
  },
  "license": "MIT"
}

"type": "module" makes the .js files ES modules (see ECMAScript Modules); bin maps the command name to the file (a plain string is shorthand for a command named after the package); files is an allow-list for what the published tarball contains; and engines documents the supported Node.js range, which npm only enforces as a warning unless engine-strict is set.

The entry file: wiring only, with a shebang
#!/usr/bin/env node
import { constants } from 'node:os';
import { stdout } from 'node:process';
import { main } from '../src/main.js';

const controller = new AbortController();
let caught;
for (const name of ['SIGINT', 'SIGTERM']) {
  process.once(name, () => {
    caught = name;
    controller.abort();
  });
}

// A closed pipe (`bookshelf list | head -n 1`) is not a failure.
stdout.on('error', (err) => {
  if (err.code === 'EPIPE') process.exit(0);
  throw err;
});

const code = await main(process.argv.slice(2), { signal: controller.signal });
// Shell convention: a process ended by signal N reports 128 + N.
process.exitCode = caught ? 128 + constants.signals[caught] : code;

A few facts about this layout were observed on macOS with Node.js 26.10.0 and npm 11.19.1, and they are the ones that bite first.

Table 2. Observed behaviour of bin files (macOS, Node.js 26.10.0, npm 11.19.1)
Situation What happened

Source file without the execute bit, packed with npm pack and installed into another project

node_modules/bookshelf-cli/bin/bookshelf.js came out as -rwxr-xr-x and node_modules/.bin/bookshelf was a symlink to it, so the command ran. npm sets the bit on bin targets at install time; set it in your working tree anyway (chmod +x bin/bookshelf.js) so ./bin/bookshelf.js works while developing.

Shebang line saved with Windows line endings (\r\n)

./crlf.js failed with env: node\r: No such file or directory and status 127. Keep the file LF-only (a .gitattributes rule *.js text eol=lf does it).

Windows

The npm documentation says a global install either links the file or creates a .cmd (Windows Command File) that runs it, so the command is available by name or as name.cmd in PowerShell; this was not run here.

Parsing arguments with util.parseArgs

util.parseArgs() turns an argument array into { values, positionals } and covers what a small tool needs: long and short flags, booleans and strings, --name=value, grouped short flags, -- handling and negation. In strict mode (the default) an unknown flag or a missing value throws an error whose code starts with ERR_PARSE_ARGS_; the tool catches it, prints a one-line message and a hint on stderr, and exits with status 2, the conventional "usage error" code. The API reference of parseArgs() is in The util Module; what matters when building a tool is the decisions around it.

Excerpt of src/main.js: parse, then dispatch on the first positional
import { parseArgs, styleText } from 'node:util';
// ...
export async function main(argv, { signal } = {}) {
  try {
    const { values, positionals } = parseArgs({
      args: argv,
      allowPositionals: true,
      allowNegative: true,
      options: {
        help: { type: 'boolean', short: 'h' },
        version: { type: 'boolean', short: 'v' },
        json: { type: 'boolean' },
        color: { type: 'boolean' },
        file: { type: 'string', short: 'f' },
        year: { type: 'string', short: 'y' },
      },
    });
    const [command, ...rest] = positionals;
    if (values.help) { console.log(HELP); return EXIT.OK; }
    if (values.version) { console.log(pkg.version); return EXIT.OK; }
    if (!command) throw new UsageError('missing command');
    // ...
  } catch (err) {
    if (err instanceof UsageError || String(err.code).startsWith('ERR_PARSE_ARGS_')) {
      stderr.write(`bookshelf: ${err.message}\nTry 'bookshelf --help'.\n`);
      return EXIT.USAGE;
    }
    // ...
  }
}

Four design choices in that excerpt are worth stating, because each one avoids a bug that is easy to ship.

  • main() takes the arguments and returns an exit code. It never calls process.exit() and never reads process.argv itself, so a test can call it directly (or spawn the real binary, see Testing a CLI) and the entry file stays in charge of the process.

  • Options that take part in configuration layering have no default. parseArgs() applies a default whenever the flag is absent, which would make the flag win over the config file and the environment. Leaving it out keeps values.file undefined unless the user typed it, which is what the precedence code in Configuration relies on.

  • allowNegative: true gives --no-color for free. It sets the boolean color to false; with no flag, color stays undefined.

  • Subcommands are your job. parseArgs() has no command tree: the first positional is the command and the rest are its arguments. For a handful of commands a switch is enough. When each subcommand needs its own flags, the tokens option (an ordered list of options and positionals) lets you split the arguments yourself, and at that point a framework starts to pay off (see When a framework pays off).

It also does not generate --help, convert numbers or validate ranges; the tool above keeps its help text in one template string and validates the year with a regular expression. Running the parsing paths gives:

Usage errors and help (identical on Node.js 26, 24 and 22)
$ bookshelf frobnicate; echo "exit $?"
bookshelf: unknown command 'frobnicate'
Try 'bookshelf --help'.
exit 2
$ bookshelf list --bogus 2>&1 | head -n 1
bookshelf: Unknown option '--bogus'. To specify a positional argument starting with a '-', place it at the end of the command after '--', as in '-- "--bogus"
$ bookshelf --version
0.1.0

Output: stdout for results, stderr for talk, colour only for people

Two conventions carry most of the weight of a well-behaved tool. Results go to standard output, where they can be redirected or piped; everything else (prompts, progress, confirmations, errors) goes to standard error, so that bookshelf list > books.txt captures data and nothing else. And colour is for people: it is emitted only when the stream is a terminal and the user has not opted out. util.styleText() already makes that decision for you. It checks whether the target stream (process.stdout by default, { stream: process.stderr } for messages on stderr) supports colours and honours NO_COLOR, NODE_DISABLE_COLORS and FORCE_COLOR; with { validateStream: false } it always emits the escape codes. A --no-color flag is still worth offering, because not every user can set environment variables, and the tool below combines the three signals.

Excerpt of src/main.js: one paint helper, and a list that changes shape when piped
const config = await loadConfig({ file: values.file, color: values.color });
const paint = (format, text) => (config.color ? styleText(format, text, { stream: stdout }) : text);
// ...
case 'list': {
  const lines = values.json || !stdout.isTTY;
  for (const b of books) {
    console.log(lines ? JSON.stringify(b) : `${paint('bold', b.title)} ${paint('dim', `(${b.year})`)}`);
  }
  return EXIT.OK;
}

On a terminal list prints decorated text and when stdout is a pipe or a file it prints one JSON object per line (NDJSON), which jq, grep and other programs can consume. A --json flag forces that form on a terminal as well. Observed on 26, 24 and 22 with a pseudo-terminal (the escape sequences are shown as Python-style escapes):

Output of bookshelf list under a pseudo-terminal with TERM=xterm-256color
$ bookshelf list                      # TTY, colours on
'\x1b[1mDune\x1b[22m \x1b[2m(1965)\x1b[22m\r\n\x1b[1mHyperion\x1b[22m ...'
$ bookshelf list --no-color           # TTY, flag
'Dune (1965)\r\nHyperion (1989)\r\n...'
$ NO_COLOR=1 bookshelf list           # TTY, environment
'Dune (1965)\r\nHyperion (1989)\r\n...'
$ bookshelf list | cat                # not a TTY
{"title":"Dune","year":1965}
{"title":"Hyperion","year":1989}

One detail of the observation: without a TERM variable in the pseudo-terminal environment the decorated output had no escape codes either, so styleText() also depends on the terminal advertising colour support. See Console, readline & Terminal I/O for isTTY, hasColors() and getColorDepth(), and TTY, Colour & Interactivity for the same rules seen from an agent’s side.

Prompts with readline/promises

A tool should ask a question only when a person can answer it. The check is stdin.isTTY: when standard input is a file or a pipe, the missing value is a usage error and the tool exits with 2 instead of waiting for input that will never arrive. When it is a terminal, readline/promises prints the query on stderr (so stdout stays clean) and question() resolves with the typed line.

src/prompt.js: asking only the missing questions, cancelable
import { createInterface } from 'node:readline/promises';
import { stdin, stderr } from 'node:process';

// Interactive only: the caller checks stdin.isTTY first. Prompts go to stderr so stdout stays clean.
export async function ask(questions, { signal } = {}) {
  const rl = createInterface({ input: stdin, output: stderr });
  // On a terminal Ctrl+C is a keystroke (raw mode), not a signal: readline reports it as 'SIGINT'.
  const cancelled = new AbortController();
  rl.once('SIGINT', () => cancelled.abort());
  const abort = AbortSignal.any([cancelled.signal, ...(signal ? [signal] : [])]);
  try {
    const answers = {};
    for (const [key, query] of Object.entries(questions)) {
      answers[key] = (await rl.question(`${query}: `, { signal: abort })).trim();
    }
    return answers;
  } catch (err) {
    // Ctrl+D also rejects with an AbortError, but nobody aborted our signal: report it as plain failure.
    if (err.name === 'AbortError' && !abort.aborted) throw new Error('input ended before every question was answered');
    throw err;
  } finally {
    rl.close();
  }
}

The special cases around a prompt are where hand-written tools go wrong, so each was tried under a pseudo-terminal (identical on Node.js 26, 24 and 22).

Table 3. Observed behaviour of the prompt on Node.js 26, 24 and 22
Input Result

Title and Year typed, Enter after each

added Neuromancer, exit status 0.

Ctrl+C at the first prompt

With the 'SIGINT' listener above, question() rejects with an AbortError; the tool prints a newline and exits with 130. Terminal input is in raw mode, so no operating-system signal reaches the process: the process-level SIGINT handler in the entry file did not run, which is why readline’s own event is needed.

Ctrl+D (end of input) at a prompt

question() rejects with an AbortError whose message is Aborted with Ctrl+D, even though nobody aborted a signal. The abort.aborted check tells the two apart, and the tool exits with 1.

Without the 'SIGINT' listener

A first version of this tool had none: Ctrl+C closed the interface, the pending promise never settled, and the process ended with status 0 and no message, a silent success for a cancelled command.

stdin at end of file, query pending

A small script that called rl.question() with stdin redirected from /dev/null ended with the warning Detected unsettled top-level await and exit status 13 on all three lines. This is why the tool never prompts when stdin.isTTY is false.

Password-style (masked) input, history and completion are described in Console, readline & Terminal I/O. Never take a secret as a command-line argument: arguments are visible to other users in the process list and end up in shell history; read it from a prompt with echo disabled, from an environment variable or from standard input.

Piping standard input and output

A Unix-style tool reads from standard input when there is no file argument and writes one record per line, so it composes with other tools. The import command below reads NDJSON from stdin line by line with readline; memory use stays constant however large the input is, because the interface is an async iterable over the stream. Passing the signal option to createInterface() closes the interface when the signal aborts, which ends the loop, so a SIGTERM interrupts a command that is blocked waiting for input.

Excerpt of src/main.js: the import command
case 'import': {
  let count = 0;
  // The signal option closes the interface (ending the loop) when the caller aborts.
  for await (const line of createInterface({ input: stdin, crlfDelay: Infinity, signal })) {
    if (line.trim() === '') continue;
    books.push(JSON.parse(line));
    count++;
  }
  signal?.throwIfAborted();                          // interrupted: save nothing
  await writeBooks(config.file, books);
  console.error(`imported ${count} books`);
  return EXIT.OK;
}
Importing from a pipe, then listing through another one
$ printf '{"title":"Hyperion","year":1989}\n{"title":"Solaris","year":1961}\n' | bookshelf import
imported 2 books
$ bookshelf list | head -n 1
{"title":"Dune","year":1965}

When the input is a single small document rather than a stream of lines, node:stream/consumers collects a stream in one call:

Reading all of stdin at once (small inputs only)
import { text } from 'node:stream/consumers';
import { stdin } from 'node:process';

// Fine for small inputs; for large ones read line by line instead.
const input = await text(stdin);
const books = JSON.parse(input);
console.log(`${books.length} books, first: ${books[0]?.title}`);
Run with a JSON array piped in (Node.js 26, 24 and 22)
2 books, first: Dune

When the reader goes away: EPIPE

bookshelf list | head -n 1 shows the other half of piping: head exits after one line and closes the pipe, and the next write fails with EPIPE. Whether that matters depends on how you write. console.log() swallows write errors, so the tool above never notices; but a program that writes with process.stdout.write() gets an unhandled 'error' event on the stdout stream, and the shell sees a stack trace and a non-zero status. Both variants were run with 200,000 lines piped into head -n 1:

Writing with stdout.write(): crashes when the reader closes the pipe
for (let i = 0; i < 200000; i++) process.stdout.write(`Book ${i}\n`);
The fix, as in the entry file: treat EPIPE as a normal end
import { stdout } from 'node:process';

stdout.on('error', (err) => {
  if (err.code === 'EPIPE') process.exit(0);
  throw err;
});
for (let i = 0; i < 200000; i++) stdout.write(`Book ${i}\n`);
Observed on Node.js 26, 24 and 22
$ node epipe.mjs | head -n 1; echo "statuses: ${pipestatus[@]}"
Book 0
node:events:505
    throw er; // Unhandled 'error' event
Error: write EPIPE
    ...
statuses: 1 0
$ node epipe2.mjs | head -n 1; echo "statuses: ${pipestatus[@]}"
Book 0
statuses: 0 0

The pipestatus array is zsh syntax (Bash calls it PIPESTATUS). The handler calls process.exit(0) because the reader has already gone and there is nothing left to flush; a tool that must clean up should call its own shutdown function there instead.

Exit codes and signals

The exit status is the tool’s return value for every caller: a shell if, a Makefile, a CI job, an npm script, an agent. The convention the tool follows is small and stable. It uses process.exitCode rather than process.exit(), so that pending writes to stdout and stderr are flushed; the full reasoning and the codes Node.js uses itself are in Process, Signals & Exit Codes.

Table 4. Exit codes used by the Bookshelf CLI
Code Meaning Where it comes from

0

Success

The command finished; --help and --version also succeed.

1

Runtime failure

An error that is not the user’s typing: an unreadable catalogue, bad JSON in the input, a config file that does not parse, end of input at a prompt.

2

Usage error

Unknown command or flag, missing value, invalid year. The same code Bash uses for builtin misuse and most tools use for bad invocations.

130

Interrupted by SIGINT (Ctrl+C)

128 + 2. The shell convention for "killed by signal N", which a process that handles the signal itself must reproduce by hand.

143

Terminated by SIGTERM

128 + 15: what kill, a container runtime or a process manager sends first.

Signals are delivered to the process, not to a function: the entry file registers a listener with process.once() for SIGINT and SIGTERM that records which signal arrived and aborts an AbortController. Everything that waits (a prompt, the import loop, a fetch() call) takes the controller’s signal, so one mechanism cancels all of it, and main() returns normally with the work abandoned. The entry file then sets process.exitCode to 128 + the signal number, taken from os.constants.signals. Node.js 25.4 and 24.14 added util.convertProcessSignalToExitCode('SIGTERM'), which returns 143; it does not exist on 22, hence the arithmetic. (The signal listeners on process apply to non-interactive runs, such as a kill from another terminal or a CI timeout. At a prompt in a terminal, Ctrl+C never becomes a signal, as the table in Prompts shows.)

Behaviour of the two signal paths, observed with a long-lived bookshelf import whose stdin stays open:

Signals delivered to a running import (Node.js 26)
SIGTERM  ->  exit status 143, nothing written to the catalogue
SIGINT   ->  exit status 130, nothing written to the catalogue

Configuration: flags, environment, config file

Tools usually accept the same setting in several places, and users expect a fixed precedence: built-in defaults, then a configuration file, then environment variables, then command-line flags, the most specific source winning. loadConfig() implements exactly that with object spread, where later spreads overwrite earlier ones. A missing config file is normal (ENOENT is swallowed); a file that exists but does not parse is an error the user must see, with its path in the message.

src/config.js: defaults, config file, environment, flags
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';

const defaults = {
  file: join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'bookshelf', 'books.json'),
  color: true,
};

async function readConfigFile() {
  const dir = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
  const path = join(dir, 'bookshelf', 'config.json');
  try {
    return JSON.parse(await readFile(path, 'utf8'));
  } catch (err) {
    if (err.code === 'ENOENT') return {};            // no config file is fine
    throw new Error(`cannot use ${path}: ${err.message}`);
  }
}

// Precedence, lowest to highest: defaults < config file < environment < command-line flags.
export async function loadConfig(flags) {
  const fromEnv = {};
  if (process.env.BOOKSHELF_FILE) fromEnv.file = process.env.BOOKSHELF_FILE;
  if (process.env.NO_COLOR) fromEnv.color = false;
  const fromFlags = Object.fromEntries(Object.entries(flags).filter(([, v]) => v !== undefined));
  return { ...defaults, ...(await readConfigFile()), ...fromEnv, ...fromFlags };
}

Running the same command with the setting in each place shows the order (the same on 26, 24 and 22):

Precedence, observed: the catalogue file chosen by each source
$ cat $XDG_CONFIG_HOME/bookshelf/config.json
{ "file": "/work/from-config.json" }
$ bookshelf add A -y 2000                                    # config file  -> /work/from-config.json
$ BOOKSHELF_FILE=/work/from-env.json bookshelf add B -y 2001   # environment   -> /work/from-env.json
$ BOOKSHELF_FILE=/work/from-env.json bookshelf add C -y 2002 --file /work/from-flag.json   # flag -> /work/from-flag.json

Each of the three commands created exactly the file named in the comment. With the config file containing { nope, bookshelf list printed bookshelf: cannot use …​/bookshelf/config.json: Expected property name or '}' in JSON at position 2 (line 1 column 3) and exited with 1.

Where the files live is a convention, not an API: on Linux and macOS $XDG_CONFIG_HOME (default ~/.config) holds configuration and $XDG_DATA_HOME (default ~/.local/share) holds data; Windows tools typically use %APPDATA%. Node.js has no helper for this, and the Windows paths were not tried here.

Three related points:

  • .env files. A tool that wants a project-local .env can call process.loadEnvFile() itself. A run under all three lines gave the same results: the file’s value is used when the variable is not already set, a value from the shell wins, and a missing file throws an error with code ENOENT that the tool must tolerate. The --env-file flag exists for running scripts, but the flag belongs to the node binary and a user of your installed command cannot pass it, so the tool loads the file itself.

  • node.config.json is not your tool’s config file. It configures the node process (see CLI Options, Environment & Config File); a tool defines and reads its own file as above.

  • Secrets stay out of the config file and out of the arguments. Read tokens from the environment or a prompt, and if the tool stores credentials, restrict the file permissions; see Configuration & Auth for the agent-facing side.

Loading an optional .env file (src/env-demo.mjs)
import { loadEnvFile } from 'node:process';

// Load ./.env if it exists; variables already in the environment win over the file.
try {
  loadEnvFile('.env');
} catch (err) {
  if (err.code !== 'ENOENT') throw err;
}
console.log(process.env.BOOKSHELF_FILE ?? '(BOOKSHELF_FILE not set)');
Output with a .env file containing BOOKSHELF_FILE=/tmp/from-dotenv.json, run three ways (Node.js 26, 24 and 22)
$ node env-demo.mjs
/tmp/from-dotenv.json
$ BOOKSHELF_FILE=/tmp/from-shell.json node env-demo.mjs
/tmp/from-shell.json
$ cd .. && node envdemo/env-demo.mjs
(BOOKSHELF_FILE not set)

The last line shows that '.env' is resolved against the current directory, not the script’s directory.

Testing a CLI

The entry file and the real binary are part of the product, so test them as a user would: spawn the program, pass an argument array (never a shell command string), and assert on the exit status, standard output and standard error. node:test (see Test Runner) and child_process.spawnSync() are enough; process.execPath is the running node binary, so the test uses the same Node.js version that runs the suite.

test/cli.test.js: exit codes, piped output and a signal
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const bin = new URL('../bin/bookshelf.js', import.meta.url).pathname;
const env = { ...process.env, BOOKSHELF_FILE: join(mkdtempSync(join(tmpdir(), 'shelf-')), 'books.json'), XDG_CONFIG_HOME: tmpdir() };
const run = (args, input) => spawnSync(process.execPath, [bin, ...args], { env, input, encoding: 'utf8' });

test('a usage error exits with 2 and explains itself on stderr', () => {
  const { status, stdout, stderr } = run(['frobnicate']);
  assert.equal(status, 2);
  assert.equal(stdout, '');
  assert.match(stderr, /unknown command 'frobnicate'/);
});

test('imported books come back as NDJSON when stdout is a pipe', () => {
  assert.equal(run(['import'], '{"title":"Dune","year":1965}\n').status, 0);
  const { status, stdout } = run(['list']);
  assert.equal(status, 0);
  assert.deepEqual(JSON.parse(stdout.trim()), { title: 'Dune', year: 1965 });
});

test('SIGTERM during import exits with 143 and saves nothing', async () => {
  const child = spawn(process.execPath, [bin, 'import'], { env, stdio: ['pipe', 'ignore', 'ignore'] });
  await new Promise((resolve) => setTimeout(resolve, 500));   // let it start reading stdin
  child.kill('SIGTERM');
  const [code, signal] = await new Promise((resolve) => child.on('close', (c, s) => resolve([c, s])));
  assert.deepEqual([code, signal], [143, null]);
});
Result with node --test (3 of 3 tests passed on Node.js 26.10.0, 24.21.0 and 22.23.3)
✔ a usage error exits with 2 and explains itself on stderr
✔ imported books come back as NDJSON when stdout is a pipe
✔ SIGTERM during import exits with 143 and saves nothing
ℹ pass 3
ℹ fail 0

The reporter prints ✔ marks on 26 and 24 and TAP lines (ok 1 - …​) on 22 when output is not a terminal; the totals are the same. The code and signal pair [143, null] is the proof that the tool exited by itself with status 143 instead of being killed by the signal. Interactive prompts need a pseudo-terminal, which the standard library does not provide; test the prompt logic by passing it streams, and keep the check that the tool refuses to prompt without a TTY (as in add </dev/null, exit 2) as a plain spawn test. More on the child-process options in Child Processes, and on testing agent-facing CLIs in Testing CLIs.

Packaging and publishing with npm

Before publishing, look at what would be shipped. npm pack --dry-run lists the tarball contents without writing it; the files allow-list in the package above leaves out tests and local data, and npm always adds package.json (plus README and LICENSE when present).

Previewing the package
$ npm pack --dry-run
npm notice package: bookshelf-cli@0.1.0
npm notice Tarball Contents
npm notice 687B bin/bookshelf.js
npm notice 291B package.json
npm notice 1.1kB src/config.js
npm notice 4.0kB src/main.js
npm notice 1.1kB src/prompt.js
npm notice 615B src/store.js
npm notice package size: 3.2 kB
npm notice unpacked size: 7.8 kB
npm notice total files: 6

npm pack then writes bookshelf-cli-0.1.0.tgz. Installing that tarball into a throw-away project is the closest thing to what a user gets, and it exercises the bin link, the shebang and the files list at once:

Installing the tarball into another project and running it (npm 11.19.1, Node.js 26)
$ npm pack --pack-destination ..
$ cd ../consumer && npm install ../bookshelf-cli-0.1.0.tgz
added 1 package, and audited 2 packages in 353ms
$ ls -l node_modules/.bin
bookshelf -> ../bookshelf-cli/bin/bookshelf.js
$ npx bookshelf --version
0.1.0
$ npm exec -- bookshelf --help
usage: bookshelf <command> [options]

For a tool that is developed and used on the same machine, npm link creates a global symlink to the working tree and npm install --global . installs a copy; neither was run here, to keep the global npm prefix untouched. Users who do not want a global install can run npx bookshelf-cli (npx fetches the package and runs its bin; a package with several bin entries needs one that matches the unscoped package name, otherwise npm exec fails and you must name the binary with --package), or install it as a project dependency and call it from an npm script.

Choices that matter once the package is public:

  • Name the supported Node.js versions in engines and test on them; a CLI is often run with whatever node the user has. The scratch project above declares >=22 and its tests passed on 22, 24 and 26.

  • Keep dependencies at zero or few. Every dependency is installed on every user’s machine and is part of the supply chain; the built-ins used here replace what used to need a parser, a colour library and a prompt library. See npm & Package Management.

  • Publish with provenance from CI. npm can attach a signed statement of where and how the package was built, and trusted publishing removes the need for a long-lived publish token. The mechanics, workspaces and the release checklist are in Workspaces & Publishing.

  • Version the output. Once scripts depend on the NDJSON lines or on the exit codes, changing them is a breaking change; document them next to the flags. See Packaging & Distribution and Machine-Readable Output.

A single executable application

An npm package needs Node.js on the user’s machine. A single executable application (SEA) copies the node binary, injects your bundled script into it and produces one file that runs without Node.js installed. The feature is Stability 1.1 (Active development) and its documentation lists tested platforms: Windows, macOS on arm64 only, and Linux (all distributions except Alpine). The script must be a single file, so a multi-file ESM project is bundled first; the steps below used esbuild (a third-party bundler, installed in a scratch directory) and were run on Node.js 26.10.0 on macOS arm64.

Bundling and building the executable (Node.js 26)
npx esbuild bin/bookshelf.js --bundle --platform=node --format=esm --outfile=bookshelf.mjs
cat > sea-config.json <<'JSON'
{
  "main": "bookshelf.mjs",
  "mainFormat": "module",
  "output": "bookshelf-sea",
  "disableExperimentalSEAWarning": true
}
JSON
node --build-sea sea-config.json
codesign --sign - bookshelf-sea      # macOS only; see below
./bookshelf-sea --version

Two details of the bundling step: the entry file uses top-level await, which esbuild cannot emit as CommonJS (the first attempt with --format=cjs failed with an error pointing at the await), so the build uses ESM output and "mainFormat": "module"; and --format=esm keeps the node: imports and the JSON import attribute working, because esbuild inlines the package.json version.

Observed result (Node.js 26.10.0, macOS arm64)
Generated single executable /Users/.../v26.10.0/bin/node + sea-config.json -> bookshelf-sea
$ ls -l bookshelf-sea
-rwxr-xr-x  1 user  wheel  144953248 ...  bookshelf-sea
$ ./bookshelf-sea --version
0.1.0
$ BOOKSHELF_FILE=./s.json ./bookshelf-sea add Z -y 2020 && BOOKSHELF_FILE=./s.json ./bookshelf-sea list
added Z
{"title":"Z","year":2020}
$ ./bookshelf-sea frob; echo $?
bookshelf: unknown command 'frob'
Try 'bookshelf --help'.
2
$ ./bookshelf-sea --help | head -n 1
usage: bookshelf <command> [options]

Things the run showed, labelled as observed on one platform and not as a promise:

  • The binary is a copy of node: about 145 MB in this build. Distribution size is the price of not requiring a runtime.

  • Arguments pass through unchanged. process.argv.slice(2) held the user’s arguments, and --help reached the tool’s own parser instead of being interpreted as a Node.js option. The documentation describes execArgvExtension ("env" by default, "cli" to accept --node-options=…​, "none") for controlling which extra Node.js flags the executable accepts, and execArgv for fixed ones.

  • On macOS the binary must be re-signed. Run straight after the build, without codesign, the unsigned file was killed (exit status 137); after codesign --sign - (an ad-hoc signature) it ran. The documentation lists the same step for macOS and an optional signtool step for Windows.

  • Assets (templates, data files) can be added with the assets map and read through node:sea; the useVfs option that exposes them as a read-only virtual file system was added in v26.9.0 as Stability 1.0 and was not run here.

  • Cross-building needs useCodeCache and useSnapshot set to false, because those caches only load on the platform that produced them.

On the older lines the workflow differs, because --build-sea is new in v25.5.0. Node.js 24.21.0 and 22.23.3 reject the flag (node: bad option: --build-sea). On 24 the older path worked as far as it was tried: node --experimental-sea-config wrote a preparation blob, and the third-party postject tool injected it into a copy of the node binary (after codesign --remove-signature, with --macho-segment-name NODE_SEA on macOS and the documented sentinel fuse), but the resulting program failed to start with Cannot use import statement outside a module for the ESM bundle, so on 24 and 22 the main script has to be CommonJS (the documentation of those lines has no mainFormat field). A SEA is therefore a Node.js 26 option for an ESM tool, and a bundle to a single CommonJS file for older lines, which this page did not complete.

Other ways to ship a self-contained tool are a container image with Node.js and the tool (see Docker) and the standalone binaries of other tool-chains; a SEA is attractive when the target machines have no container runtime and no Node.js.

When a framework pays off

util.parseArgs() stops where tools start to grow: it does not generate help from option descriptions, does not build subcommand trees with their own flags, does not coerce or validate values, and has no shell completion. When a tool reaches ten commands, nested subcommands, plugin loading or interactive terminal interfaces, a framework removes code you would otherwise write. Node.js CLI Frameworks works through commander, yargs, oclif (a plugin-based framework) and Ink (React for terminals) using a different running example, including package.json bin, shebang, ESM and CommonJS notes and exit codes that match a machine-readable contract. A reasonable rule: start with the built-ins, as here; adopt a framework when you find yourself writing a help generator, a validator or a command tree. The design rules for output that other programs consume are in CLI Design Fundamentals, and a finished CLI can be exposed to language-model clients as a tool; see Wrapping as an Agent Tool.

The complete src/main.js

The pieces above come from one file. This is its complete text, as it was run for every claim on this page.

src/main.js
import { parseArgs, styleText } from 'node:util';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout, stderr } from 'node:process';
import { loadConfig } from './config.js';
import { readBooks, writeBooks } from './store.js';
import { ask } from './prompt.js';
import pkg from '../package.json' with { type: 'json' };

export const EXIT = { OK: 0, FAILURE: 1, USAGE: 2, SIGINT: 130 };

const HELP = `usage: bookshelf <command> [options]

commands:
  list                     print the catalogue (NDJSON when piped)
  add [title] [-y year]    add a book, prompting for what is missing
  import                   read NDJSON books from standard input

options:
  -f, --file <path>        catalogue file
  -h, --help               show this help
  -v, --version            print the version
      --json               force NDJSON output
      --no-color           disable colours`;

class UsageError extends Error {}

export async function main(argv, { signal } = {}) {
  try {
    const { values, positionals } = parseArgs({
      args: argv,
      allowPositionals: true,
      allowNegative: true,
      options: {
        help: { type: 'boolean', short: 'h' },
        version: { type: 'boolean', short: 'v' },
        json: { type: 'boolean' },
        color: { type: 'boolean' },
        file: { type: 'string', short: 'f' },
        year: { type: 'string', short: 'y' },
      },
    });
    const [command, ...rest] = positionals;
    if (values.help) { console.log(HELP); return EXIT.OK; }
    if (values.version) { console.log(pkg.version); return EXIT.OK; }
    if (!command) throw new UsageError('missing command');

    const config = await loadConfig({ file: values.file, color: values.color });
    const paint = (format, text) => (config.color ? styleText(format, text, { stream: stdout }) : text);
    const books = await readBooks(config.file);

    switch (command) {
      case 'list': {
        const lines = values.json || !stdout.isTTY;
        for (const b of books) {
          console.log(lines ? JSON.stringify(b) : `${paint('bold', b.title)} ${paint('dim', `(${b.year})`)}`);
        }
        return EXIT.OK;
      }
      case 'add': {
        let title = rest.join(' ');
        let year = values.year;
        if (!title || !year) {
          if (!stdin.isTTY) throw new UsageError('title and --year are required when stdin is not a terminal');
          const answers = await ask({ ...(title ? {} : { title: 'Title' }), ...(year ? {} : { year: 'Year' }) }, { signal });
          title ||= answers.title;
          year ||= answers.year;
        }
        if (!title || !/^\d{4}$/.test(year)) throw new UsageError('a title and a four-digit year are required');
        books.push({ title, year: Number(year) });
        await writeBooks(config.file, books);
        console.error(`added ${title}`);
        return EXIT.OK;
      }
      case 'import': {
        let count = 0;
        // The signal option closes the interface (ending the loop) when the caller aborts.
        for await (const line of createInterface({ input: stdin, crlfDelay: Infinity, signal })) {
          if (line.trim() === '') continue;
          books.push(JSON.parse(line));
          count++;
        }
        signal?.throwIfAborted();                          // interrupted: save nothing
        await writeBooks(config.file, books);
        console.error(`imported ${count} books`);
        return EXIT.OK;
      }
      default:
        throw new UsageError(`unknown command '${command}'`);
    }
  } catch (err) {
    if (err instanceof UsageError || String(err.code).startsWith('ERR_PARSE_ARGS_')) {
      stderr.write(`bookshelf: ${err.message}\nTry 'bookshelf --help'.\n`);
      return EXIT.USAGE;
    }
    if (err.name === 'AbortError') {
      stderr.write('\n');
      return EXIT.SIGINT;                                 // cancelled: 128 + SIGINT
    }
    stderr.write(`bookshelf: ${err.message}\n`);
    return EXIT.FAILURE;
  }
}

src/store.js is the other missing piece. It writes the catalogue through a temporary file and a rename, so a reader (or a crash) never sees a half-written JSON file:

src/store.js
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';

export async function readBooks(file) {
  try {
    return JSON.parse(await readFile(file, 'utf8'));
  } catch (err) {
    if (err.code === 'ENOENT') return [];
    throw err;
  }
}

// Write to a temporary file and rename it: readers never see a half-written catalogue.
export async function writeBooks(file, books) {
  await mkdir(dirname(file), { recursive: true });
  const tmp = `${file}.${process.pid}.tmp`;
  await writeFile(tmp, JSON.stringify(books, null, 2) + '\n');
  await rename(tmp, file);
}

One note on the design: main() catches errors and maps them to codes, but the String(err.code) in the parse-error check is deliberate. A DOMException such as the AbortError raised by signal.throwIfAborted() has a numeric code (20), and calling .startsWith() on it threw a TypeError in the first version of the tool.

What changed recently

Table 5. Changes that matter to CLI authors
Version Change

v26.9.0

Single executable applications can expose bundled assets as a read-only virtual file system with "useVfs": true (Stability 1.0, Early development).

v26.1.0

util.styleText() accepts hexadecimal colours (also observed on 24.21.0, rejected on 22.23.3).

v25.5.0

node --build-sea config.json builds a single executable in one step; before it, a blob was written with --experimental-sea-config and injected with an external tool. The feature is still Stability 1.1.

v25.4.0 and v24.14.0

util.convertProcessSignalToExitCode() returns the conventional 128 + n exit code for a signal name.

v24.10.0 and v22.21.0

process.loadEnvFile(), --env-file and util.parseEnv() are no longer experimental.

v23.5.0 and v22.13.0

util.styleText() became stable.

v22.8.0 and v20.18.0

styleText() respects isTTY, NO_COLOR and the other colour variables instead of always emitting escape codes.

v22.4.0 and v20.16.0

util.parseArgs() gained allowNegative for --no-name flags.

v20.0.0

util.parseArgs() became stable.

Where the books are outdated

Both books that this section consults build command-line tools with the packages of their time. Node.js Projects by Jonathan Wexler (O’Reilly, 2025) starts its projects with readline plus process.stdin and util.promisify, and later builds a menu-driven password manager around synchronous terminal prompts. Today node:readline/promises returns promises directly, so the promisify step is unnecessary; the incomplete promisify example in that chapter, where the readline interface is not defined, is repaired by the ask() function above. Synchronous prompt packages block the event loop while they wait, which is acceptable in a one-shot script but not in a tool that also handles signals or timers. Efficient Node.js by Samer Buna (O’Reilly, 2025) teaches the REPL and the node command line well and is a good place to read about process.argv; it predates the habit, now possible with built-ins, of replacing argument parsers and colour libraries by util.parseArgs() and util.styleText(). Neither book covers the parts of a CLI that are about being a good citizen of the shell: the stderr/stdout split, exit code conventions beyond 0 and 1, handling EPIPE and signals, and shipping a single executable file, so use the sections above for those. Both books also install npm packages for environment-file loading and terminal prompts that process.loadEnvFile() and readline/promises now cover.

Pitfalls

  • A shebang with CRLF line endings fails with env: node\r: No such file or directory. Normalise line endings for files under bin/.

  • Forgetting "type": "module" (or the .mjs extension) can make import in the entry file fail to load. Recent releases detect ES module syntax in an untyped .js file (see Running Scripts, the CLI & the REPL), but a published package should be explicit.

  • Calling process.exit() after writing a lot to stdout can cut the output when stdout is a pipe: the documentation says pipes are asynchronous on POSIX and synchronous on Windows, and that process.exit() does not wait for pending I/O. Set process.exitCode and return, as the entry file does.

  • A prompt with a piped stdin that never answers leaves an unsettled promise; Node.js ends such a program with exit status 13 and a warning on all three lines. Check stdin.isTTY first.

  • Treating Ctrl+C at a prompt as a signal. In a terminal it arrives as a keystroke that readline reports as 'SIGINT'; with no listener the process can end with status 0.

  • Ignoring EPIPE. Writing with process.stdout.write() into a closed pipe throws an unhandled 'error' event; console.log() hides it.

  • Colour codes in piped output. Use styleText() with the right stream option, or the tool pollutes files and breaks grep.

  • Defaults in parseArgs() that outrank the config file. Keep layered settings out of default.

  • Putting a secret in an argument or in the config file exposes it in ps output, shell history and backups.

  • Shipping tests, fixtures or .env files in the tarball. Use files and check npm pack --dry-run before every publish.

  • Treating an SEA as portable. It is built for one operating system and CPU architecture, a macOS build must be signed, and it is a copy of the exact Node.js version that built it.

References