Writing command-line tools

Intermediate55 min

Build a real command-line tool with util.parseArgs, stdin, stdout and stderr, exit codes and a shebang line, and run another program from JavaScript with node:child_process.

What you will learn
  • Parse flags, options and positional arguments with util.parseArgs
  • Send data to stdout and messages to stderr, and report success or failure with exit codes
  • Read lines from stdin
  • Build a small grep that uses a regular expression and follows the exit-code conventions
  • Run another program with spawnSync, and make a script directly executable with a shebang line
On this page

Much of the software you use every day is a command-line tool: git, npm, node itself. They share conventions that make them work together in pipelines and scripts, and a JavaScript program can follow them. A well-behaved tool does four things:

  • it takes arguments: flags such as -i, options such as --max=5, and plain values such as file names;
  • it writes its results to standard output (stdout) and its complaints to standard error (stderr), so the two can be separated;
  • it may read standard input (stdin), so that other programs can feed it;
  • it ends with an exit code: 0 for success, anything else for failure. The shell, CI systems and other programs depend on it.

The runtime lesson introduced process.argv and process.exitCode; this one adds the helpers that make tools pleasant to write. Terminal listings below are real runs on Node 26; $ marks what you type.

Arguments with util.parseArgs

Parsing process.argv.slice(2) by hand turns into a pile of if statements. util.parseArgs does it from a description of the options you accept. It reads process.argv by default; these examples pass an args array so that the result is the same every time.

ct_parseargs.mjs
import { parseArgs } from "node:util";
const options = {
verbose: { type: "boolean", short: "v" },
max: { type: "string", short: "m", default: "10" },
tag: { type: "string", multiple: true },
};
const argv = ["-v", "--max=5", "build", "--tag", "a", "--tag", "b", "src"];
const { values, positionals } = parseArgs({
args: argv,
options,
allowPositionals: true,
});
console.log(values.verbose, values.max, values.tag);
console.log(positionals);
Output
true 5 [ 'a', 'b' ]
[ 'build', 'src' ]
  • Every option has a type: "boolean" (a switch) or "string" (takes a value, as --max=5 or --max 5). short adds a one-letter alias, so -v means --verbose.
  • default supplies a value when the option is absent. parseArgs never converts: values.max is "5", and turning it into a number is your job.
  • multiple: true collects every occurrence into an array.
  • Anything that is not an option is a positional argument, accepted only with allowPositionals: true. A lone -- ends the options.

Parsing is strict: unknown options, a missing value or an unexpected positional all throw an error with a code and a readable message, which is what you show the user instead of a stack trace.

ct_parseargs.mjs (continued)
for (const args of [["--nope"], ["--max"], ["extra"]]) {
try {
parseArgs({ args, options });
} catch (error) {
console.log(error.code, "|", error.message);
}
}
Output
ERR_PARSE_ARGS_UNKNOWN_OPTION | Unknown option '--nope'
ERR_PARSE_ARGS_INVALID_OPTION_VALUE | Option '-m, --max <value>' argument missing
ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL | Unexpected argument 'extra'. This command does not take positional arguments

The values object has no prototype, so console.log(values) prints a [Object: null prototype] label. Read the properties you need.

Exit codes

A program’s exit code is how the world learns whether it worked. This script ends in a different way depending on its first argument.

ct_status.mjs
const how = process.argv[2];
if (how === "code") process.exitCode = 5;
if (how === "exit") process.exit(4);
if (how === "throw") throw new Error("boom");
if (how === "reject") Promise.reject(new Error("late"));
console.log("reached the end");

In a terminal, $? holds the exit code of the last command, and 2>/dev/null throws away standard error, where the message of a crash goes.

Terminal window
$ node ct_status.mjs; echo $?
reached the end
0
$ node ct_status.mjs code; echo $?
reached the end
5
$ node ct_status.mjs exit; echo $?
4
$ node ct_status.mjs throw 2>/dev/null; echo $?
1
$ node ct_status.mjs reject 2>/dev/null; echo $?
reached the end
1
  • Reaching the end means 0.
  • process.exitCode = n records the code and lets the program finish its work, so nothing is lost. This is the right way to report failure.
  • process.exit(n) ends the program on the spot. Output still being written may be lost.
  • An uncaught exception and an unhandled promise rejection both end the program with 1. The last run still printed its line, because the rejection is noticed only after the synchronous code is done (the async errors lesson).

By convention 0 is success, 1 a general failure, and many tools use 2 for “you used me wrongly”. grep uses 0 for “found something”, 1 for “found nothing” and 2 for an error, and the tool below follows that.

Reading standard input

In a pipeline such as cat notes.txt | node shout.mjs, the input arrives on process.stdin. The readline module turns that stream into lines that for await can read. The loop ends when the input ends.

ct_stdin.mjs
import { createInterface } from "node:readline/promises";
const lines = createInterface({ input: process.stdin });
let count = 0;
for await (const line of lines) {
count++;
console.log(`${count}: ${line.toUpperCase()}`);
}
console.error(`${count} lines read`);

printf writes two lines and the pipe | feeds them to the program. In the second run the output is redirected into a file with >, and only stdout goes there: the summary on stderr still reaches the screen.

Terminal window
$ printf 'hello\nnode world\n' | node ct_stdin.mjs
1: HELLO
2: NODE WORLD
2 lines read
$ printf 'hello\nnode world\n' | node ct_stdin.mjs > upper.txt
2 lines read
$ cat upper.txt
1: HELLO
2: NODE WORLD

The rule of thumb: data goes to stdout; progress, warnings and errors go to stderr. console.log writes to stdout and console.error to stderr. With no pipe, the program waits for you to type; end the input with Ctrl+D.

Running another program

A tool sometimes runs another program. spawnSync from node:child_process runs it, waits, and returns the exit status and the captured output. It takes the program and an array of arguments, so no shell reinterprets them. Here the program is process.execPath, the very node that is running us, and -e runs the text after it as a script.

ct_child.mjs
import { spawnSync } from "node:child_process";
const script = "console.log('out'); console.error('err'); process.exitCode = 2";
const args = ["-e", script];
const result = spawnSync(process.execPath, args, { encoding: "utf8" });
console.log(result.status, result.signal);
console.log(JSON.stringify(result.stdout), JSON.stringify(result.stderr));
Output
2 null
"out\n" "err\n"

spawnSync blocks, which suits a short script and not a server. execFile (with util.promisify it gives a promise that rejects on a non-zero status) and spawn (a child whose stdout is a stream, and which is an event emitter) do not block. Avoid exec with user input: it hands a whole string to a shell, where a file name such as a; rm -rf ~ becomes two commands.

Putting it together: a tiny grep

grep prints the lines of files that match a pattern. This version takes a regular expression (regex basics), supports -i (ignore case) and -n (line numbers), and exits with 0, 1 or 2 as grep does. The file starts with a shebang line, which comes back at the end. First, the arguments: parse turns a bad command line into a clean message and null.

ct_grep.mjs
#!/usr/bin/env node
import { parseArgs } from "node:util";
import { readFile } from "node:fs/promises";
const options = {
"ignore-case": { type: "boolean", short: "i" },
"line-numbers": { type: "boolean", short: "n" },
};
function parse(argv) {
try {
const parsed = parseArgs({ args: argv, options, allowPositionals: true });
if (parsed.positionals.length >= 2) return parsed;
console.error("usage: ct_grep [-i] [-n] pattern file...");
} catch (error) {
console.error(`grep: ${error.message}`);
}
return null;
}

A pattern can be invalid, so building the regular expression is guarded too.

ct_grep.mjs (continued)
function compile(pattern, ignoreCase) {
try {
return new RegExp(pattern, ignoreCase ? "i" : "");
} catch (error) {
console.error(`grep: ${error.message}`);
return null;
}
}

The search of one file splits it into lines, tests each one and prints the matches. It reports whether anything matched.

ct_grep.mjs (continued)
async function grepFile(file, regex, flags) {
const lines = (await readFile(file, "utf8")).split("\n");
if (lines.at(-1) === "") lines.pop();
let found = false;
lines.forEach((line, index) => {
if (!regex.test(line)) return;
found = true;
const where = flags.prefix ? `${file}:` : "";
const number = flags.numbers ? `${index + 1}:` : "";
console.log(`${where}${number}${line}`);
});
return found;
}

grepAll runs it over every file and chooses the exit code: 2 as soon as a file cannot be read, otherwise 0 if something matched and 1 if not.

ct_grep.mjs (continued)
async function grepAll(files, regex, flags) {
let found = false;
for (const file of files) {
try {
found = (await grepFile(file, regex, flags)) || found;
} catch (error) {
console.error(`grep: ${file}: ${error.code}`);
return 2;
}
}
return found ? 0 : 1;
}

The last part ties it together. main returns the exit code, and the final line hands it to process.exitCode.

ct_grep.mjs (continued)
async function main(argv) {
const parsed = parse(argv);
if (parsed === null) return 2;
const [pattern, ...files] = parsed.positionals;
const regex = compile(pattern, parsed.values["ignore-case"]);
if (regex === null) return 2;
const numbers = parsed.values["line-numbers"];
return grepAll(files, regex, { prefix: files.length > 1, numbers });
}
process.exitCode = await main(process.argv.slice(2));

Two small files to search:

fruit.txt
apple
Banana
cherry
apple pie
more.txt
pineapple
plum

Now use it as a user would. Each case shows one behaviour: bare lines for one file; -i and -n; regular expressions (^a anchors to the start of a line, e$ to the end); a file: prefix for several files; exit 1 for no match; exit 2 and a message on stderr for a missing file, an invalid pattern and missing arguments.

Terminal window
$ node ct_grep.mjs apple fruit.txt
apple
apple pie
$ node ct_grep.mjs -in BANANA fruit.txt
2:Banana
$ node ct_grep.mjs '^a' fruit.txt more.txt
fruit.txt:apple
fruit.txt:apple pie
$ node ct_grep.mjs 'e$' fruit.txt more.txt
fruit.txt:apple
fruit.txt:apple pie
more.txt:pineapple
$ node ct_grep.mjs zzz fruit.txt; echo $?
1
$ node ct_grep.mjs apple missing.txt; echo $?
grep: missing.txt: ENOENT
2
$ node ct_grep.mjs '(' fruit.txt; echo $?
grep: Invalid regular expression: /(/: Unterminated group
2
$ node ct_grep.mjs apple; echo $?
usage: ct_grep [-i] [-n] pattern file...
2

Because the pattern is a regular expression, a.b and ( are special. To search for literal text, escape it first with RegExp.escape (Node 24 and later, see advanced regular expressions); on Node 22 you escape the characters yourself.

Making it executable

The first line of ct_grep.mjs, #!/usr/bin/env node, is a shebang line. On Linux and macOS, when the file is marked executable, the system reads that line to find out which program should run it, so you can leave out node. JavaScript accepts the line as a comment at the very start of a file (part of the language since ES2023).

Terminal window
$ chmod +x ct_grep.mjs
$ ./ct_grep.mjs -n apple fruit.txt
1:apple
4:apple pie

To install a tool under a command name, a package lists it in the "bin" field of its package.json. When someone installs the package, npm links that name onto their command path (on Windows it creates a small wrapper, since there is no shebang support). During development, npm link in the package folder does the same on your machine. The npm lesson covers package.json further.

{
"name": "ct-grep",
"version": "1.0.0",
"type": "module",
"bin": { "ct-grep": "./ct_grep.mjs" }
}

Common pitfalls

  • Printing errors with console.log. They end up in the output of a pipeline. Use console.error.
  • Always exiting 0. A tool that reports failure only in text cannot be used in a script.
  • Calling process.exit() right after printing a lot. Set process.exitCode and return.
  • Forgetting that option values are strings. --max=5 gives "5".
  • Passing user input to exec. Use execFile or spawn with an argument array.
  • Showing a stack trace to the user for a mistake in the command line. Catch the error and print one clear line.

Exercises

ExerciseSum of the numbers on stdin

Write a program that reads lines from stdin and prints sum: N. Blank lines are ignored. A line that is not a number is reported on stderr as skipped: <line> and does not count. The input for this exercise is the four lines 3, 4, x and 10, followed by a blank line.

Expected output on stdout:

sum: 17
Show solution

Number("x") is NaN, which Number.isNaN detects. The skipped message goes to stderr, so it is not part of the output.

ct_ex_sum.mjs
import { createInterface } from "node:readline/promises";
let sum = 0;
for await (const line of createInterface({ input: process.stdin })) {
if (line.trim() === "") continue;
const value = Number(line);
if (Number.isNaN(value)) {
console.error(`skipped: ${line}`);
} else {
sum += value;
}
}
console.log(`sum: ${sum}`);
Output
sum: 17
ExerciseA port option

Write parseCli(argv) that accepts --port N (short -p, default "3000"), the switch --open and any number of file names. It returns an object { port, open, files } where port is a number. If the port is not a whole number from 1 to 65535, it throws an Error with the message invalid port: <text>. Print the result for ["--open", "-p", "8080", "a.txt", "b.txt"] and the message for ["--port=abc"].

Expected output:

{ port: 8080, open: true, files: [ 'a.txt', 'b.txt' ] }
invalid port: abc
Show solution

parseArgs supplies strings, so the function converts and validates. Building a fresh object also avoids handing the caller the prototype-less values.

ct_ex_port.mjs
import { parseArgs } from "node:util";
function parseCli(argv) {
const { values, positionals } = parseArgs({
args: argv,
options: {
port: { type: "string", short: "p", default: "3000" },
open: { type: "boolean" },
},
allowPositionals: true,
});
const port = Number(values.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`invalid port: ${values.port}`);
}
return { port, open: values.open ?? false, files: positionals };
}
console.log(parseCli(["--open", "-p", "8080", "a.txt", "b.txt"]));
try {
parseCli(["--port=abc"]);
} catch (error) {
console.log(error.message);
}
Output
{ port: 8080, open: true, files: [ 'a.txt', 'b.txt' ] }
invalid port: abc
ExerciseHelp and a polite failure

Write ct_ex_help.mjs, a program that reads its own command line with parseArgs. --name NAME (default world) prints hello, NAME. --help or -h prints usage: node ct_ex_help.mjs [--name NAME] instead. Any other option must print the error message from parseArgs on stderr and end with exit code 2, with no stack trace. Run it the four ways below.

Expected terminal session:

Terminal window
$ node ct_ex_help.mjs
hello, world
$ node ct_ex_help.mjs --name Ada
hello, Ada
$ node ct_ex_help.mjs -h
usage: node ct_ex_help.mjs [--name NAME]
$ node ct_ex_help.mjs --colour; echo $?
Unknown option '--colour'
2
Show solution

parseArgs reads process.argv when no args are given. The try catches its error for an unknown option, and process.exitCode records the failure without cutting the program short.

ct_ex_help.mjs
import { parseArgs } from "node:util";
const options = {
help: { type: "boolean", short: "h" },
name: { type: "string", default: "world" },
};
try {
const { values } = parseArgs({ options });
if (values.help) {
console.log("usage: node ct_ex_help.mjs [--name NAME]");
} else {
console.log(`hello, ${values.name}`);
}
} catch (error) {
console.error(error.message);
process.exitCode = 2;
}

Summary

  • A command-line tool takes arguments, reads stdin, writes results to stdout and problems to stderr, and ends with an exit code.
  • util.parseArgs parses flags, options and positionals from a description. Values stay strings, and strict mode throws on bad input.
  • Report failure with process.exitCode: 0 success, 1 failure, 2 misuse. Exceptions and unhandled rejections give 1. In a terminal, echo $? shows the code.
  • readline over process.stdin reads input line by line with for await.
  • spawnSync runs another program with an argument array and returns its status and output. Avoid exec with user input.
  • A #!/usr/bin/env node line and a "bin" entry turn a script into an installable command.

Next: streams, the tool for data that is too big to hold in memory at once.