Building a Command Line Tool

A command line tool (CLI) is a program you run by typing its name in a terminal instead of clicking a button — think of tools like npm, git, or eslint, all of which are Node.js scripts under the hood. Node.js is a natural fit for building CLIs because it starts quickly, ships a rich standard library for working with files and the network, and npm gives you a built-in mechanism for installing and distributing your tool as a global command.

In this lesson you’ll learn how Node turns a plain JavaScript file into an executable command, how to read and parse the arguments a user passes in, how to tell normal output apart from error output, and how to package a script so it can be installed and run just like any other terminal command.

Overview / How it works

At its core, a Node.js CLI is just a script executed with node file.js arg1 arg2. Node populates a global array, process.argv, with every token from that command line: index 0 is the path to the node binary itself, index 1 is the path to your script, and everything from index 2 onward is what the user actually typed. Almost every CLI starts with process.argv.slice(2) to drop the first two entries and keep only the real arguments.

To make a script runnable without typing node in front of it, you add a shebang line as the very first line of the file: #!/usr/bin/env node. On Linux and macOS, the operating system reads this line and uses it to pick the interpreter — so once the file is marked executable (chmod +x) and the shebang is present, running ./file.js works directly. npm builds on this: the "bin" field in package.json maps a command name to a script file, and when the package is installed globally (or linked with npm link), npm creates a symlink to that file inside a directory that is already on the shell’s PATH. Typing the command name then resolves through that symlink, the shebang tells the shell to hand the file to Node, and your script runs — this is exactly how npm, tsc, and thousands of other CLIs work.

Execution order matters too. All top-level synchronous code in your script runs immediately, in order. Any awaited call — reading a file, making a request — suspends that function and lets the event loop keep spinning; Node stays alive as long as there is pending work (a promise not yet settled, a timer, an open stream) or until the script sets an exit code. This is why a CLI that reads a config file and then queries an API doesn’t need any special “wait for it” logic — async/await and the event loop already provide that ordering.

Two other conventions matter for well-behaved CLIs: separate stdout from stderr, and prefer process.exitCode over process.exit(). Use console.log (stdout) only for the actual output a user might want to pipe into another command (your-tool | grep foo); use console.error (stderr) for diagnostics, progress messages, and errors, so piping stdout doesn’t pick up noise. Setting process.exitCode = 1 tells Node to exit with a failure status once all pending I/O has flushed, whereas calling process.exit() forces an immediate shutdown that can cut off output that hasn’t been written yet.

Property Type Purpose
process.argv array Full argument list; index 0 is the node binary, index 1 the script, rest are user arguments
process.exitCode number Sets the exit status without forcing an immediate stop
process.exit(code) function Forces the process to end right away — can truncate pending output
process.stdin stream Readable stream for piped or typed input
process.stdout stream Writable stream backing console.log
process.stderr stream Writable stream backing console.error
process.env object Environment variables available to the script

Syntax

A minimal CLI script looks like this, using Node’s built-in parseArgs helper from node:util (stable since Node 20) to turn raw arguments into named flags and positional values:

#!/usr/bin/env node
import { parseArgs } from "node:util";

const { values, positionals } = parseArgs({
  args: process.argv.slice(2),
  options: {
    // define flags here, e.g. json: { type: "boolean", short: "j" }
  },
  allowPositionals: true,
});
  • Shebang line — the first line, #!/usr/bin/env node, tells the OS which interpreter to run the file with once it’s executable.
  • process.argv.slice(2) — strips the node binary path and script path, leaving only what the user typed.
  • parseArgs — declaratively describes the flags your tool accepts and returns { values, positionals }.
  • options.<name>.type — either "string" or "boolean".
  • options.<name>.short — an optional single-letter alias, e.g. -j for --json.
  • allowPositionals — lets bare arguments (like a filename) sit alongside flags.

Examples

Example 1: reading raw arguments

The simplest possible CLI just reads process.argv directly — no library needed for a single, unlabelled argument:

#!/usr/bin/env node
const name = process.argv[2] ?? "World";
console.log(`Hello, ${name}!`);

Output:

Hello, Alice!

Running node hello.js Alice puts "Alice" at process.argv[2]. The nullish coalescing operator falls back to "World" when no argument is supplied, so the script never crashes on missing input.

Example 2: named flags with parseArgs

Real tools usually need more than one bare argument — this one reports file information and can switch its output format with a --json flag:

#!/usr/bin/env node
import { parseArgs } from "node:util";
import { stat } from "node:fs/promises";

const { values, positionals } = parseArgs({
  args: process.argv.slice(2),
  options: {
    json: { type: "boolean", short: "j", default: false },
  },
  allowPositionals: true,
});

const [filePath] = positionals;

if (!filePath) {
  console.error("Usage: fileinfo  [--json]");
  process.exitCode = 1;
} else {
  try {
    const stats = await stat(filePath);
    const info = {
      path: filePath,
      size: stats.size,
      isDirectory: stats.isDirectory(),
      modified: stats.mtime.toISOString(),
    };
    if (values.json) {
      console.log(JSON.stringify(info, null, 2));
    } else {
      console.log(`${info.path} - ${info.size} bytes, modified ${info.modified}`);
    }
  } catch (err) {
    console.error(`Error: ${err.message}`);
    process.exitCode = 1;
  }
}

Output of node fileinfo.js package.json --json:

{
  "path": "package.json",
  "size": 312,
  "isDirectory": false,
  "modified": "2026-07-30T10:15:00.000Z"
}

Because the file has top-level import statements, this is an ES module, which is exactly what makes the top-level await stat(filePath) legal — no wrapping async function is required. Missing arguments and failed lookups both set process.exitCode = 1 instead of throwing, so the process exits cleanly with a non-zero status a calling script or CI job can check.

Example 3: a packaged, installable CLI

A CLI is only really useful once it can be run as a plain command. Here’s a small word-counter, followed by the packaging that turns it into the wordcount command:

#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { parseArgs } from "node:util";

async function main() {
  const { values, positionals } = parseArgs({
    args: process.argv.slice(2),
    options: {
      upper: { type: "boolean", default: false },
    },
    allowPositionals: true,
  });

  const [filePath] = positionals;
  if (!filePath) {
    console.error("Usage: wordcount  [--upper]");
    process.exitCode = 1;
    return;
  }

  try {
    const text = await readFile(filePath, "utf8");
    const words = text.trim().split(/\s+/).filter(Boolean);
    const summary = `${words.length} words in ${filePath}`;
    console.log(values.upper ? summary.toUpperCase() : summary);
  } catch (err) {
    console.error(`Could not read ${filePath}: ${err.message}`);
    process.exitCode = 1;
  }
}

main();

The matching package.json declares the executable via the "bin" field:

{
  "name": "wordcount-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "wordcount": "./wordcount.js"
  }
}

Then, from the project folder:

chmod +x wordcount.js
npm link
wordcount notes.txt --upper

Output:

42 WORDS IN NOTES.TXT

chmod +x marks the file executable on POSIX systems (required even with a correct shebang). npm link installs the package globally as a symlink pointing back at your working directory, which is the standard way to test a CLI locally before publishing it with npm publish.

Under the hood / step by step

When someone runs an installed CLI like wordcount notes.txt --upper, this is the actual sequence of events:

  1. The shell looks up wordcount on PATH and finds the symlink npm created, which points at wordcount.js.
  2. The OS reads the first line of that file, sees #!/usr/bin/env node, and launches node with the script’s path as an argument.
  3. Node loads the file as an ES module (because package.json has "type": "module") and populates process.argv with [nodePath, scriptPath, "notes.txt", "--upper"].
  4. Top-level synchronous code runs first — here, that’s just the call to main().
  5. parseArgs runs synchronously and immediately returns { values: { upper: true }, positionals: ["notes.txt"] }.
  6. await readFile(...) hands the actual disk read off to libuv’s thread pool (since regular file I/O has no native async syscall on most platforms) and suspends main(); the event loop is free to do other work while it waits.
  7. When the read completes, the callback resumes main() on the microtask queue, the word count is computed, and console.log writes to process.stdout.
  8. With no more pending timers, I/O, or open handles, the event loop has nothing left to do and Node exits — with status 0 unless process.exitCode was set to something else.

Common Mistakes

Mistake 1: calling process.exit() before async work finishes.

import { writeFile } from "node:fs/promises";

writeFile("out.txt", "report generated").then(() => {
  console.log("Saved!");
});

process.exit(0);

This looks harmless, but process.exit(0) runs immediately, on the very next line, while the write is still pending — Node can shut down before the file is written and before "Saved!" ever prints. The fix is to await the operation (or let the event loop drain naturally) instead of forcing an exit:

import { writeFile } from "node:fs/promises";

async function main() {
  await writeFile("out.txt", "report generated");
  console.log("Saved!");
}

main();

Mistake 2: using __dirname in an ES module.

import { readFile } from "node:fs/promises";

const config = await readFile(`${__dirname}/config.json`, "utf8");
console.log(config);

__dirname and __filename only exist in CommonJS — in an ES module (any file using import, or any .js file inside a "type": "module" package) referencing __dirname throws a ReferenceError. Since Node 20.11, the fix is import.meta.dirname:

import { readFile } from "node:fs/promises";
import path from "node:path";

const configPath = path.join(import.meta.dirname, "config.json");
const config = await readFile(configPath, "utf8");
console.log(config);

Other common pitfalls worth watching for: forgetting chmod +x on the entry file (the shell reports “permission denied” even though the shebang is correct), mixing require and import in the same file, reading an entire large file into memory with readFileSync inside a hot path instead of streaming it, and leaving an awaited promise’s rejection uncaught, which crashes the whole process with an “Unhandled promise rejection” message instead of a clean error and exit code.

Best Practices

  • Print a short usage message and set process.exitCode = 1 when required arguments are missing — don’t just throw a raw stack trace at the user.
  • Send normal output to stdout (console.log) and diagnostics or errors to stderr (console.error) so your tool composes correctly in shell pipelines.
  • Prefer process.exitCode over process.exit() so buffered output and pending I/O finish flushing before the process ends.
  • Use node:util‘s parseArgs for a handful of flags; reach for a package like commander once you need subcommands, nested help text, or complex validation.
  • Keep the file referenced by "bin" small and fast — every millisecond of startup cost is paid on every invocation.
  • Set an "engines" field in package.json so users know the minimum Node version your CLI requires.
  • Test locally with npm link before publishing, so you catch packaging mistakes (missing shebang, wrong bin path) before they reach users.
  • Never build shell commands or file paths from unsanitized user arguments if you pass them to child_process — treat CLI arguments as untrusted input.

Practice Exercises

Exercise 1: Build a script called wc-lite.js that reports line, word, and character counts for a file passed as an argument, similar to the Unix wc command. Add a --lines-only boolean flag using parseArgs that, when set, prints only the line count.

Exercise 2: Extend the word-counter example from this lesson so that when no file argument is given, it reads text from standard input instead of failing. Hint: process.stdin is a readable stream — you can collect it with for await (const chunk of process.stdin) { ... }.

Exercise 3: Turn any script you’ve written in this lesson into an installable command: add a "bin" field to a package.json, make the file executable with chmod +x, run npm link, and confirm you can call it by name from a different directory.

Summary

  • A Node.js CLI is a regular script; a shebang line (#!/usr/bin/env node) plus chmod +x lets the OS run it directly without typing node first.
  • process.argv holds every argument; process.argv.slice(2) gives you just the user-supplied ones.
  • node:util‘s parseArgs turns raw arguments into named flags and positionals without any external dependency.
  • Send real output to stdout and diagnostics to stderr, and prefer process.exitCode over process.exit() so pending I/O isn’t cut off.
  • The "bin" field in package.json, combined with npm link or a global install, is what turns a script into a command available on PATH.
  • Classic mistakes — exiting before async work finishes and using __dirname in an ES module — are both easy to avoid once you know the underlying cause.