Writing command-line tools
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:
0for 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.
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);true 5 [ 'a', 'b' ][ 'build', 'src' ]- Every option has a
type:"boolean"(a switch) or"string"(takes a value, as--max=5or--max 5).shortadds a one-letter alias, so-vmeans--verbose. defaultsupplies a value when the option is absent.parseArgsnever converts:values.maxis"5", and turning it into a number is your job.multiple: truecollects 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.
for (const args of [["--nope"], ["--max"], ["extra"]]) { try { parseArgs({ args, options }); } catch (error) { console.log(error.code, "|", error.message); }}ERR_PARSE_ARGS_UNKNOWN_OPTION | Unknown option '--nope'ERR_PARSE_ARGS_INVALID_OPTION_VALUE | Option '-m, --max <value>' argument missingERR_PARSE_ARGS_UNEXPECTED_POSITIONAL | Unexpected argument 'extra'. This command does not take positional argumentsThe 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.
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.
$ node ct_status.mjs; echo $?reached the end0$ node ct_status.mjs code; echo $?reached the end5$ 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 end1- Reaching the end means
0. process.exitCode = nrecords 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.
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.
$ printf 'hello\nnode world\n' | node ct_stdin.mjs1: HELLO2: NODE WORLD2 lines read$ printf 'hello\nnode world\n' | node ct_stdin.mjs > upper.txt2 lines read$ cat upper.txt1: HELLO2: NODE WORLDThe 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.
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));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.
#!/usr/bin/env nodeimport { 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.
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.
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.
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.
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:
appleBananacherryapple piepineappleplumNow 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.
$ node ct_grep.mjs apple fruit.txtappleapple pie$ node ct_grep.mjs -in BANANA fruit.txt2:Banana$ node ct_grep.mjs '^a' fruit.txt more.txtfruit.txt:applefruit.txt:apple pie$ node ct_grep.mjs 'e$' fruit.txt more.txtfruit.txt:applefruit.txt:apple piemore.txt:pineapple$ node ct_grep.mjs zzz fruit.txt; echo $?1$ node ct_grep.mjs apple missing.txt; echo $?grep: missing.txt: ENOENT2$ node ct_grep.mjs '(' fruit.txt; echo $?grep: Invalid regular expression: /(/: Unterminated group2$ node ct_grep.mjs apple; echo $?usage: ct_grep [-i] [-n] pattern file...2Because 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).
$ chmod +x ct_grep.mjs$ ./ct_grep.mjs -n apple fruit.txt1:apple4:apple pieTo 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. Useconsole.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. Setprocess.exitCodeand return. - Forgetting that option values are strings.
--max=5gives"5". - Passing user input to
exec. UseexecFileorspawnwith 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
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: 17Show solution
Number("x") is NaN, which Number.isNaN detects. The skipped message goes to stderr, so it is not part of the output.
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}`);sum: 17Write 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: abcShow solution
parseArgs supplies strings, so the function converts and validates. Building a fresh object also avoids handing the caller the prototype-less values.
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);}{ port: 8080, open: true, files: [ 'a.txt', 'b.txt' ] }invalid port: abcWrite 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:
$ node ct_ex_help.mjshello, world$ node ct_ex_help.mjs --name Adahello, Ada$ node ct_ex_help.mjs -husage: node ct_ex_help.mjs [--name NAME]$ node ct_ex_help.mjs --colour; echo $?Unknown option '--colour'2Show 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.
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.parseArgsparses flags, options and positionals from a description. Values stay strings, and strict mode throws on bad input.- Report failure with
process.exitCode:0success,1failure,2misuse. Exceptions and unhandled rejections give1. In a terminal,echo $?shows the code. readlineoverprocess.stdinreads input line by line withfor await.spawnSyncruns another program with an argument array and returns its status and output. Avoidexecwith user input.- A
#!/usr/bin/env nodeline 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.