| |

Node.js 83 🟢 Building Custom Command-Line Tools (CLIs) in Node.js

⏱️ Estimated read: 14 min | |

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

FieldPurpose
binMaps command name to script
enginesDeclares Node.js version
filesLimits published files
type"module" for ESM

Argument Parsing Libraries

LibraryBest For
parseArgsZero-dependency, simple tools
commanderSubcommands, help generation
yargsMiddleware, complex validation
cittyESM-first, async loading

Output Contract

StreamContents
stdoutPrimary data (JSON, tables)
stderrProgress, warnings, errors, prompts
Exit code 0Success
Exit code non-zeroFailure

Edge Cases

ConditionBehavior
NO_COLOR setNo ANSI color
stdout not TTYNo color, no prompts
EPIPE on stdoutExit 0
Ctrl+C during promptCancel, exit 0

Testing Commands

CommandPurpose
npm linkInstall locally for development
npm packCreate tarball for testing
npm publish --dry-runPreview publish
node --testRun 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

PitfallWhy It HappensFix
Piped output breaks jqLogs on stdoutSend logs to stderr
CI step cannot detect failureNo non-zero exit codeprocess.exitCode = 1
Stack trace on headUnhandled EPIPEListen for stdout error
Color in log filesNo TTY checkCheck isTTY and NO_COLOR
Global command not foundbin not in package.jsonAdd 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

ItemValue
Entry pointShebang #!/usr/bin/env node
Command mappingbin field in package.json
Parsing (simple)parseArgs from node:util
Parsing (complex)Commander or yargs
Prompts@clack/prompts
Output contractData to stdout, diagnostics to stderr
Success code0
Failure codeNon-zero
Color checkisTTY and NO_COLOR
EPIPEExit 0 on stdout error
Testingnode: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. parseArgs is 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.isTTY and fall back to an error that names the missing flag .
  • NO_COLOR is 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 .
  • EPIPE is 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 link is for development; npm pack is for verification. link makes the command global with live edits. pack exercises 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!