NodeJSBuilding Command-Line Tools

Building Command-Line Tools

Node isn't only for servers — it's an excellent platform for command-line tools. npm, eslint, prettier, vite, tsc, and most of the tooling you use daily are Node CLIs. A CLI is just a script you can run from the terminal that reads arguments, does work, prints output, and exits with a status code. This page covers what makes a script a command — the shebang line, the executable bit, and the bin field — plus the building blocks every CLI uses: process.argv, exit codes, stdin/stdout/stderr, and the conventions (--help, --version, piping) that make a tool feel native to the shell.

From script to command

greet.js

JS
#!/usr/bin/env node
// ^ the SHEBANG: tells the OS to run this file with node, so you can call it directly.

const name = process.argv[2] || 'world'    // argv[0]=node, argv[1]=script, argv[2]=first arg
console.log(`Hello, ${name}!`)
process.exit(0)                            // 0 = success

Bash
chmod +x greet.js        # make it executable (Unix)
./greet.js Ada           # run it directly — no "node" prefix needed
node greet.js Ada        # or run it explicitly with node
Hello, Ada!
A shebang (`#!/usr/bin/env node`) + the executable bit turns a `.js` file into a runnable command
Three things turn an ordinary script into a *command* you can type. First, the **shebang** line `#!/usr/bin/env node` as the very first line tells a Unix shell to execute the file with `node` — `env` finds node on the `PATH` rather than hard-coding its location. Second, the **executable bit** (`chmod +x`) lets the OS run the file directly (`./greet.js`) instead of `node greet.js`. Third — covered in [publishing](/nodejs/publishing-cli) — a package's `bin` field maps a command name to the file, and npm creates the right launcher (including on Windows, which ignores the shebang and relies on npm's generated shim). Get these right and your tool behaves like any other terminal command.
Reading arguments — process.argv

JS
// node app.js build --watch --out=dist file.txt
console.log(process.argv)
// [
//   '/usr/bin/node',        // [0] the node binary
//   '/path/to/app.js',      // [1] the script
//   'build',                // [2..] everything the user typed
//   '--watch',
//   '--out=dist',
//   'file.txt',
// ]
const args = process.argv.slice(2)   // the part you actually care about
`process.argv` is the raw argument array — slice off the first two (node + script) to get user input
Node hands you the raw command line as **`process.argv`**, an array of strings. The first two entries are always the **node executable** and the **script path**; the user's actual arguments start at index 2, so `process.argv.slice(2)` is the idiom for "what the user typed." From there it's just string parsing — but parsing flags by hand (`--watch`, `-o value`, `--out=dist`, combined short flags like `-rf`, the `--` separator) gets fiddly fast. Node 18+ ships a small built-in helper, `util.parseArgs`, and the ecosystem offers fuller libraries — the next pages cover [parsing arguments](/nodejs/parsing-args) properly and [Commander](/nodejs/commander) for full command structures. For a trivial tool, `slice(2)` and a few `if`s are perfectly fine.
Exit codes — how the shell knows success or failure

JS
try {
  doWork()
  process.exit(0)              // 0 = success (also the default if you don't call exit)
} catch (err) {
  console.error('Error:', err.message)   // errors go to STDERR, not stdout
  process.exit(1)              // non-zero = failure; pick codes for distinct error types
}

Bash
mytool build && echo "ok"     # the && only runs echo if mytool exited 0
echo $?                        # $? holds the exit code of the last command
Exit codes are your tool's API to scripts — `0` means success, non-zero means failure; set them deliberately
Every process returns an **exit code**, and in the shell world `0` means success and any non-zero value means failure. This isn't cosmetic: it's how scripts, CI pipelines, and shell operators (`&&`, `||`, `if`) decide whether to continue. A CLI that always exits `0` — even when it failed — silently breaks automation, because `mytool && deploy` will deploy after a failed build. Call `process.exit(1)` (or a specific non-zero code per error category) when something goes wrong, and let success exit `0` (the default). One subtlety: `process.exit()` terminates *immediately* and can cut off buffered `stdout`/async writes — prefer setting `process.exitCode = 1` and letting the event loop drain naturally when you have pending output.
Standard streams — stdout, stderr, stdin

Stream

Purpose

In Node

stdout

The program's actual output / result — meant to be piped

console.log, process.stdout.write

stderr

Diagnostics: errors, logs, progress — kept out of the data

console.error, process.stderr.write

stdin

Input piped or typed into the program

process.stdin

Bash
# Because results go to stdout and logs go to stderr, this works cleanly:
mytool extract data.json > result.txt    # only the RESULT lands in the file
                                          # progress/errors still show on screen (stderr)
cat data.json | mytool transform | wc -l  # tools compose via pipes
Put real output on stdout and everything else (logs, progress, errors) on stderr — or you'll corrupt pipes
Unix tooling composes through **pipes**, and that only works if you respect the separation of streams. **`stdout`** carries the tool's *actual output* — the data another command or a redirect (`> file`) will consume. **`stderr`** carries *everything else*: error messages, warnings, progress bars, debug logs. The classic mistake is `console.log`-ing progress or status: `console.log` writes to stdout, so your "Processing..." message gets mixed into the piped data and corrupts `mytool > result.txt`. Send diagnostics to `stderr` (`console.error`) so a user can still redirect clean output while watching progress on screen. Reading **`stdin`** lets your tool act as a filter in a pipeline (`cat file | mytool`). Honoring these conventions is what makes a CLI a good citizen of the shell.
Conventions that make a CLI feel native
  • --help / -h — print usage, options, and examples; users expect it and so does every CLI framework.

  • --version / -v — print the version (usually read from package.json).

  • Sensible exit codes0 success, non-zero failure; document distinct codes if you have them.

  • Read from stdin when no file is given — lets your tool work in pipes as a filter.

  • Respect NO_COLOR and detect a TTY — only emit colors/spinners when output is an interactive terminal (process.stdout.isTTY), not when piped.

  • Fail loudly to stderr — clear, actionable error messages; never swallow errors and exit 0.

  • Be quiet on success — print only what's asked for; offer --verbose for more and --quiet for less.

Follow shell conventions — `--help`, `--version`, TTY-aware color, stdin filtering — and your tool feels familiar instantly
Users bring strong expectations from every other command-line tool, and meeting them makes yours feel trustworthy and learnable. Provide **`--help`** and **`--version`** (frameworks like [Commander](/nodejs/commander) generate these for free). Detect whether output is an interactive terminal with **`process.stdout.isTTY`** and disable colors, spinners, and progress bars when it's *not* (i.e. when piped or redirected) — and honor the `NO_COLOR` environment variable. Default to **quiet** output, adding `--verbose`/`--quiet` flags for control. Read **`stdin`** when no input file is provided so you slot into pipelines. None of this is hard, but together it's the difference between a script and a tool people enjoy using.
Next
Parse those flags and arguments robustly: [Parsing Arguments](/nodejs/parsing-args).