Node.js 83 🟢 Building Custom Command-Line Tools (CLIs) in Node.js
A command-line tool is a program that a user invokes by name from a terminal, passes arguments and options to, and receives output from. Building one in Node.js is deceptively straightforward: create a JavaScript file with a shebang, map it to a command name in package.json, and install it globally. The difficulty is not in getting the tool to run—it is in making it behave like a tool that people want to use. That means argument parsing that respects conventions, output that separates data from diagnostics, exit codes that CI systems can read, and a --help screen that actually helps.
The ecosystem has converged on a small set of libraries that handle the argument parsing and help generation, and a separate set that handle interactive prompts for cases where flags are not enough. The choice between them is a trade-off between dependency weight and the amount of boilerplate you are willing to write yourself. Node.js also ships a built-in argument parser, parseArgs from node:util, which has been stable since Node 20 and requires no dependency at all. It handles flags, types, and short aliases, but it does not generate help screens or route subcommands—those remain your code . For a small tool, that is a reasonable trade. For a tool with nested subcommands and dozens of flags, Commander or yargs pays for itself.
Key point: A Node.js CLI needs a shebang, a bin entry in package.json, and an argument parser; the tool must send primary output to stdout and diagnostics to stderr, use exit code 0 for success and non-zero for failure, and respect NO_COLOR and TTY detection to degrade gracefully in CI and pipes.
Why the CLI contract matters more than the parser
The stdout/stderr separation. A CLI that prints progress messages and JSON results to the same stream cannot be piped into jq. The rule is: primary output goes to stdout, everything else—progress, warnings, debug logs, prompts—goes to stderr . This is not a stylistic preference; it is the contract that makes the tool composable with other Unix tools. A tool that violates it feels unfinished the moment someone tries to script against it.
The exit code contract. process.exit(0) signals success. A non-zero code signals failure. A CI pipeline reads the exit code and nothing else. Calling process.exit() with no argument or with a string is a bug; the code must be an integer. The recommended pattern is to set process.exitCode and let the process end naturally, because calling process.exit() can truncate a write that is still queued when stdout is a pipe . The exception is when the tool must exit immediately, in which case the write should be flushed first.
The TTY and color problem. ANSI color codes look sharp in a terminal and become garbage in a log file. The convention is to check process.stdout.isTTY and disable color when it is false. The NO_COLOR environment variable is a separate signal: if it is present and not an empty string, color must be disabled regardless of the TTY state . A tool that ignores NO_COLOR is a tool that pollutes the output of every user who sets it.
The EPIPE problem. Piping a CLI’s output into head or less causes the reader to close the pipe early. The next write fails with EPIPE, and an unhandled error dumps a stack trace where a clean exit belongs. The fix is to listen for the error event on process.stdout and exit with code 0 when the error code is EPIPE . This is a two-line addition that separates a polished tool from a fragile one.
a. The project structure and argument parsing
The entry point is a JavaScript file with a shebang. The #!/usr/bin/env node line tells the operating system to use the Node.js interpreter found in the environment, rather than hardcoding a path like /usr/local/bin/node, which may not exist on every machine . On Windows, the shebang is ignored by the OS but read by npm during installation to generate a .cmd wrapper.
The package.json maps the command name to the entry file through the bin field. The key is the command the user types, and the value is the path to the script.
{
"name": "my-cli",
"version": "1.0.0",
"type": "module",
"bin": {
"mycli": "./bin/cli.js"
},
"engines": {
"node": ">=20.0.0"
}
}
The engines field declares the Node.js version requirement. The tool should check this at runtime and exit with a clear message if the version is unsupported, rather than failing with a cryptic syntax error .
The argument parser is the next decision. Commander is the most widely used framework and provides the most complete feature set: subcommands, automatic help generation, and option parsing with defaults . A minimal Commander setup defines the program name, version, description, and the commands.
#!/usr/bin/env node
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)));
const program = new Command();
program
.name('mycli')
.description('A sample CLI tool')
.version(pkg.version);
program
.command('deploy <environment>')
.description('Deploy to an environment')
.option('--dry-run', 'Preview without executing')
.action(async (environment, options) => {
if (options.dryRun) {
console.error(`Would deploy to: ${environment}`);
return;
}
await deploy(environment);
});
program.parse();
The <environment> in the command definition marks a required positional argument. The --dry-run option is a boolean flag. Commander generates the --help screen from these declarations, which means the help output stays in sync with the code without manual maintenance.
yargs is the alternative for projects that need middleware or more complex validation. It provides a chainable API and built-in support for shell completion . The choice between them is largely stylistic; both are mature and well-documented. citty is a lighter ESM-first option from the UnJS ecosystem, but it is less established than the other two .
For a zero-dependency tool, parseArgs from node:util is the starting point. It handles flags with types and short aliases, but it does not generate help output or route subcommands. The trade-off is explicit: fewer dependencies, more boilerplate .
b. Interactive prompts and the arg + fallback pattern
Flags are the right interface for values that are always known upfront. They are the wrong interface for values that require discovery—choosing from a list, browsing a directory, confirming a destructive action. For those, interactive prompts are the better experience. The @clack/prompts library provides a minimal, styled set of prompt components: text input, select, multiselect, confirm, and spinner . It is smaller than Inquirer and produces a cleaner terminal UI.
The critical pattern is not “prompt instead of flags.” It is “prompt when the flag is missing and the session is interactive.” A CLI that prompts unconditionally will hang in CI, in a cron job, or when its stdin is a pipe . The check is process.stdin.isTTY && process.stdout.isTTY. If the session is not interactive, the tool should throw an error that tells the user which flag to pass.
import { text, isCancel, cancel } from '@clack/prompts';
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
async function getProjectName(flagValue) {
if (flagValue) return flagValue;
if (!isInteractive) {
throw new Error('Missing --project-name and session is not interactive');
}
const name = await text({ message: 'Project name:' });
if (isCancel(name)) {
cancel('Cancelled');
process.exit(0);
}
return name;
}
The isCancel check is the other half of the pattern. Every Clack prompt returns a cancel symbol when the user presses Ctrl+C. If the tool does not check for it and exit cleanly, the cancel symbol flows into the rest of the logic and produces a confusing error . The cancel() function prints a clean message, and process.exit(0) exits with success, because the user’s cancellation is not a failure.
c. Packaging, testing, and distribution
The tool is tested before it is published. npm link inside the package directory creates a global symlink, so edits take effect immediately without reinstalling. npm pack followed by installing the tarball in a different directory is closer to what a user experiences: it exercises the files field and catches whatever was forgotten from the published package . npm publish --dry-run reports what would be sent without actually publishing.
The files field in package.json limits what is included in the tarball. Without it, the entire project directory—tests, source maps, CI configuration—is published. The field should list only bin, lib, and any other directories the runtime needs.
Testing a CLI is black-box testing. The test invokes the compiled binary as a subprocess and asserts on stdout, stderr, and the exit code. node:test with execSync is sufficient for this; no external test framework is required . The tests should cover the --version and --help output, the happy path for each command, and the error paths that produce non-zero exit codes.
import { test } from 'node:test';
import assert from 'node:assert';
import { execSync } from 'node:child_process';
test('--version prints a version', () => {
const out = execSync('node bin/cli.js --version', { encoding: 'utf8' });
assert.match(out, /\d+\.\d+\.\d+/);
});
test('unknown command exits non-zero', () => {
assert.throws(() => execSync('node bin/cli.js nonexistent', { encoding: 'utf8' }));
});
Distribution has two paths. npm install -g installs the package globally and puts the command on the PATH. npx runs the package without a permanent installation, which is the pattern many modern tools use for one-off executions . For tools that need to run without Node.js installed, the Single Executable Application mechanism from the previous chapter is the native path, though it produces a large binary.
Complete Example Session
The following session builds a minimal CLI with Commander, adds the TTY and exit-code handling, and tests it with node:test.
// ============================================
// PART 1: THE CLI ENTRY POINT
// ============================================
// bin/cli.js
#!/usr/bin/env node
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)));
const program = new Command();
program
.name('mycli')
.description('Manage projects')
.version(pkg.version);
// ============================================
// PART 2: A COMMAND WITH AN OPTION
// ============================================
program
.command('init <name>')
.description('Initialize a project')
.option('-t, --template <type>', 'Template to use', 'default')
.action(async (name, options) => {
try {
await initProject(name, options.template);
process.stdout.write(`Created ${name}\n`);
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exitCode = 1;
}
});
// ============================================
// PART 3: THE TTY AND NO_COLOR CHECK
// ============================================
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
const log = {
info: (msg) => process.stderr.write(`${useColor ? '\x1b[36m' : ''}${msg}${useColor ? '\x1b[0m' : ''}\n`),
error: (msg) => process.stderr.write(`${msg}\n`),
};
// ============================================
// PART 4: THE EPIPE HANDLER
// ============================================
process.stdout.on('error', (err) => {
if (err.code === 'EPIPE') process.exit(0);
throw err;
});
// ============================================
// PART 5: PARSE AND RUN
// ============================================
program.parse();
// ============================================
// PART 6: THE TEST FILE
// ============================================
// test/cli.test.js
import { test } from 'node:test';
import assert from 'node:assert';
import { execSync } from 'node:child_process';
const CLI = 'node bin/cli.js';
test('--version prints a version', () => {
const out = execSync(`${CLI} --version`, { encoding: 'utf8' });
assert.match(out, /\d+\.\d+\.\d+/);
});
test('init creates a project', () => {
const out = execSync(`${CLI} init demo`, { encoding: 'utf8' });
assert.match(out, /Created demo/);
});
test('unknown command exits non-zero', () => {
assert.throws(() => execSync(`${CLI} unknown`, { encoding: 'utf8' }));
});
The six parts cover the entry point with Commander, a command with an option, the TTY and NO_COLOR check, the EPIPE handler, the parse call, and the black-box tests. What the session demonstrates is that the parsing library handles the argument structure, while the tool itself is responsible for the output streams, the exit codes, and the edge cases that determine whether the tool behaves correctly in a pipeline.
Quick Reference
Essential package.json Fields
| Field | Purpose |
|---|---|
bin | Maps command name to script |
engines | Declares Node.js version |
files | Limits published files |
type | "module" for ESM |
Argument Parsing Libraries
| Library | Best For |
|---|---|
parseArgs | Zero-dependency, simple tools |
commander | Subcommands, help generation |
yargs | Middleware, complex validation |
citty | ESM-first, async loading |
Output Contract
| Stream | Contents |
|---|---|
stdout | Primary data (JSON, tables) |
stderr | Progress, warnings, errors, prompts |
| Exit code 0 | Success |
| Exit code non-zero | Failure |
Edge Cases
| Condition | Behavior |
|---|---|
NO_COLOR set | No ANSI color |
stdout not TTY | No color, no prompts |
EPIPE on stdout | Exit 0 |
Ctrl+C during prompt | Cancel, exit 0 |
Testing Commands
| Command | Purpose |
|---|---|
npm link | Install locally for development |
npm pack | Create tarball for testing |
npm publish --dry-run | Preview publish |
node --test | Run tests |
Best Practices
✅ Do This:
#!/usr/bin/env node // ✅ env shebang
process.stderr.write(`Info: ${msg}\n`) // ✅ diagnostics to stderr
process.exitCode = 1; process.exit() // ✅ set code, not exit(1)
process.stdout.on('error', (e) => e.code === 'EPIPE' && process.exit(0)) // ✅ EPIPE
if (process.stdout.isTTY && !process.env.NO_COLOR) // ✅ color check
❌ Don’t Do This:
#!/usr/local/bin/node // ❌ hardcoded path
process.stdout.write(`Warning: ${msg}\n`) // ❌ diagnostics to stdout
process.exit(); // ❌ no exit code
console.log(json) // when stdout is piped // ❌ mixing data and logs
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Piped output breaks jq | Logs on stdout | Send logs to stderr |
| CI step cannot detect failure | No non-zero exit code | process.exitCode = 1 |
Stack trace on head | Unhandled EPIPE | Listen for stdout error |
| Color in log files | No TTY check | Check isTTY and NO_COLOR |
| Global command not found | bin not in package.json | Add bin field |
Real-World Examples
1. Shebang
#!/usr/bin/env node
2. bin field
{ "bin": { "mycli": "./bin/cli.js" } }
3. Commander command
program.command('deploy <env>').option('--dry-run').action(...)
4. parseArgs option
const { values } = parseArgs({ options: { name: { type: 'string' } } });
5. stdout for data
process.stdout.write(JSON.stringify(result) + '\n');
6. stderr for progress
process.stderr.write('Processing...\n');
7. Exit code
process.exitCode = 1;
8. EPIPE handler
process.stdout.on('error', (e) => { if (e.code === 'EPIPE') process.exit(0); });
9. TTY check
const interactive = process.stdin.isTTY && process.stdout.isTTY;
10. Test with execSync
execSync('node bin/cli.js --version', { encoding: 'utf8' });
Visual
The CLI Contract
┌──────────────────────────────────────────────┐
│ USER │
│ mycli deploy prod --dry-run │
│ │ │
│ ▼ │
│ PARSER (Commander/yargs/parseArgs) │
│ Routes to deploy action │
│ │ │
│ ▼ │
│ ACTION │
│ - data → stdout │
│ - logs → stderr │
│ - exit code 0 or 1 │
│ │ │
│ ▼ │
│ PIPELINE │
│ mycli list --json | jq '.items' │
│ (only data on stdout, logs on stderr) │
└──────────────────────────────────────────────┘
Prompt Fallback Decision
┌──────────────────────────────────────────────┐
│ Is the flag provided? │
│ ├─ YES → use it │
│ └─ NO → Is session interactive? │
│ ├─ YES → prompt │
│ └─ NO → throw error with flag hint │
└──────────────────────────────────────────────┘
EPIPE Flow
┌──────────────────────────────────────────────┐
│ mycli list | head -1 │
│ │ │
│ ▼ │
│ head reads first line, closes pipe │
│ │ │
│ ▼ │
│ Next stdout write fails with EPIPE │
│ │ │
│ ▼ │
│ Handler: process.exit(0) │
│ (clean exit, no stack trace) │
└──────────────────────────────────────────────┘
Packaging Flow
┌──────────────────────────────────────────────┐
│ npm link │
│ → global command for development │
│ │ │
│ ▼ │
│ npm pack │
│ → tarball for install testing │
│ │ │
│ ▼ │
│ npm publish --dry-run │
│ → preview published contents │
│ │ │
│ ▼ │
│ npm publish │
│ → available via npx or npm i -g │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Entry point | Shebang #!/usr/bin/env node |
| Command mapping | bin field in package.json |
| Parsing (simple) | parseArgs from node:util |
| Parsing (complex) | Commander or yargs |
| Prompts | @clack/prompts |
| Output contract | Data to stdout, diagnostics to stderr |
| Success code | 0 |
| Failure code | Non-zero |
| Color check | isTTY and NO_COLOR |
| EPIPE | Exit 0 on stdout error |
| Testing | node:test + execSync |
Key takeaways:
- The CLI is a contract, not just a script. The contract is: data on stdout, diagnostics on stderr, exit codes for success and failure, and graceful degradation when the session is not a terminal. A tool that violates the contract is difficult to script against .
- The parser choice is a trade-off.
parseArgsis zero-dependency but requires manual help and subcommand routing. Commander and yargs generate help and route subcommands but add dependencies. Choose based on the complexity of the command surface . - Prompts require a TTY check. A CLI that prompts unconditionally will hang in CI or when piped. Check
process.stdin.isTTYand fall back to an error that names the missing flag . NO_COLORis a standard. If the variable is present and non-empty, disable color regardless of the TTY state. This is a convention that users expect and tools that ignore it feel broken .EPIPEis not an error. When a downstream reader closes the pipe, the tool should exit 0, not dump a stack trace. The handler is two lines .- Testing is black-box. Invoke the binary as a subprocess and assert on stdout, stderr, and exit codes. This tests the actual interface that users interact with, not the internal functions .
npm linkis for development;npm packis for verification.linkmakes the command global with live edits.packexercises the published tarball and catches missing files .
Remember: A CLI is the thinnest interface between a human and a program. It is also the interface that is most visible when it is wrong. A tool that prints progress to stdout cannot be piped. A tool that exits with no code cannot fail a CI step. A tool that prompts in a non-interactive session hangs. A tool that ignores NO_COLOR pollutes logs. These are not edge cases—they are the common cases. The parser library handles the argument structure. The tool itself is responsible for the contract: the streams, the codes, the TTY checks, and the EPIPE handler. Those are the lines that separate a script that works on the developer’s machine from a tool that works everywhere.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!