Run this yourself. The complete CLI skeleton from this guide, ready to clone and run:
cli-tool-nodejs-typescript/in the Coding Dunia code-examples repo.
A Node.js CLI tool is a command-line program, written and typed in TypeScript, that parses its own arguments, exits with a meaningful status code, and installs as a real command via npm's bin field rather than staying a node script.js invocation forever. Building one properly in 2026 takes less setup than it used to, since Node's native TypeScript support and built-in argument parser remove two dependencies that used to be assumed defaults.
The script that prompted this was a data-migration tool that had grown three undocumented flags, no help text, and a habit of exiting with code 0 even when it failed halfway through. None of that was intentional, it just accumulated because the script never got the fifteen minutes of structure a real CLI needs from the start.
Quick take: A real Node.js CLI tool needs four things: argument parsing (
util.parseArgs()for simple cases, Commander for subcommands), meaningful exit codes (0 for success, non-zero on every failure), abinentry with a shebang so it installs as a real command, and tests that don't require spawning the whole process to run.
How Do You Parse Arguments With util.parseArgs()?
#!/usr/bin/env node
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
options: {
output: { type: 'string', short: 'o', default: './dist' },
verbose: { type: 'boolean', short: 'v', default: false },
force: { type: 'boolean', default: false },
},
allowPositionals: true,
});
const [inputFile] = positionals;
if (!inputFile) {
console.error('Usage: mycli <input-file> [--output <dir>] [--verbose] [--force]');
process.exit(1);
}
if (values.verbose) {
console.log(`Processing ${inputFile} -> ${values.output}`);
}
parseArgs() handles short and long flags, typed values (string/boolean), and defaults, all without a dependency. For a CLI with a flat set of flags and no subcommands, this covers the whole surface a heavier library like Commander would otherwise be pulled in for. It's been stable since Node 20.0.0 and lives directly under node:util, so there's no version-pinning risk the way a third-party package on a fast-moving major version can carry.
Three details trip people up the first time. Unknown flags throw by default (strict: true is the default, not opt-in), so a typo like --verbse fails loudly instead of silently getting ignored, which is the opposite of what most hand-rolled process.argv parsers do. Positional arguments require allowPositionals: true explicitly, without it, anything not matching a defined flag throws. And boolean flags default to false only if you set default: false yourself, an unset boolean option is undefined, not false, which matters if your code does a plain truthy check on it.
Node's built-in util.parseArgs(), stable since Node 20.0.0, handles short and long flags, typed values, and defaults without a dependency, and it throws on unknown flags by default. For a flat CLI with no subcommands, it covers the whole surface a heavier library like Commander or yargs would otherwise be pulled in for.
What Exit Codes Should a CLI Tool Use?
async function run(): Promise<void> {
try {
const inputFile = positionals[0];
if (!inputFile) {
console.error('Error: input file required');
process.exit(1);
}
await validateFile(inputFile); // throws if the file doesn't exist or is malformed
await processFile(inputFile, values.output as string);
console.log('Done.');
process.exit(0);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
run();
The try/catch around the whole run function is what stops an uncaught exception from producing Node's default stack trace dump with an ambiguous exit code, or worse, exiting 0 despite the failure. A CI pipeline or shell script calling this tool needs $? (the exit code) to be trustworthy, if my-cli input.json; then echo ok; fi only works correctly if a real failure always produces a non-zero code.
Pick a small, documented set of codes and stick to it. A common split: 1 for "the tool ran but the operation failed" (bad input file, validation error), 2 for "the tool was invoked wrong" (missing required argument, unknown flag), and 130 reserved by convention for SIGINT (Ctrl+C), which Node already sets automatically if you don't intercept the signal yourself. Reusing 1 for every failure category is fine for a small internal tool; a CLI meant for CI pipelines benefits from the split because a caller can branch on "fix your input" versus "fix your invocation" without parsing stderr text.
- Wrap the whole
run()body in one top-leveltry/catch, never scatter exits across nested functions. - Log to
console.error, notconsole.log, so failure output doesn't get mixed into piped stdout that another tool might parse. - Call
process.exit()only after the catch block finishes logging, an early exit inside afinallycan swallow the real error.
How Do You Run TypeScript Without a Build Step?
// package.json
{
"name": "mycli",
"version": "1.0.0",
"type": "module",
"bin": {
"mycli": "./src/index.ts"
},
"engines": {
"node": ">=23.6.0"
}
}
// src/index.ts
#!/usr/bin/env node
// ... the CLI code above
As of Node 23.6+, Node runs .ts files directly via native type stripping, no ts-node, no tsx, no build step, as long as the TypeScript doesn't rely on features requiring actual transformation (enums, namespaces, without the extra --experimental-transform-types flag). For a CLI targeting a Node version that new, this removes an entire category of "works on my machine" build tooling drift. For broader compatibility with older Node versions, compile with tsc to plain .js and point bin at the compiled output instead.
Native type stripping is the term for what Node 23.6+ does here: it erases type annotations at parse time without a full TypeScript compiler pass, so the runtime cost is close to zero and there's no tsc step to forget before publishing. The tradeoff is that stripping doesn't type-check, a CLI that runs fine with a stripped type error still misbehaves exactly like plain JavaScript would. Run tsc --noEmit in CI separately if you want that safety net without giving up the no-build-step local workflow.
How Do You Publish It?
npm link # test locally: creates a global symlink to your local package
mycli --help # now runs as a real command
npm publish # ships it to the npm registry, once bin/shebang/package.json are set
npm link is the step worth doing before a real publish, it exercises the exact bin-to-PATH symlink mechanism a real install will use, catching a missing shebang or incorrect file permissions before they become a bug report from an actual user. Per the npm CLI docs for npm v10, Windows additionally gets a .cmd shim alongside the symlink, so test on Windows specifically if any published users run it there, a shebang line alone doesn't do anything on that platform.
Semantic versioning is the convention that ties a version bump's size to how much a release can break for callers: patch for fixes that don't change behavior, minor for additive changes, major for anything that changes existing behavior. A CLI's bin name and flag surface are effectively a public API the moment a script or CI pipeline depends on it, renaming a flag in a patch release breaks callers silently. Version deliberately once the package is public.
How Do You Test a CLI Without Running It End-to-End?
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { validateFile } from '../src/validate.js';
describe('validateFile', () => {
it('rejects a missing file', async () => {
await assert.rejects(() => validateFile('/nonexistent.json'));
});
});
The parsing and business logic, validateFile, processFile, live in their own exported functions, tested directly with node --test, separate from the parseArgs()/process.exit() wiring in index.ts. Testing the wiring itself (does --force actually get read correctly) is worth a handful of integration tests that spawn the CLI as a subprocess and check its exit code and output, but the bulk of the logic should be testable without spawning a process at all.
Node's built-in test runner, stable since Node 20, is enough here, there's no need to pull in Jest or Vitest just to cover a CLI's business logic. For the subprocess-level tests specifically, use node:child_process's execFileSync wrapped in a try/catch, since a non-zero exit code makes it throw rather than returning a status you'd otherwise have to check manually. Asserting on both the exit code and stderr content in the same test catches the case where a command exits 1 for the wrong reason, a real bug that a code-only assertion would miss entirely.
The Four Pieces at a Glance
| What it does | Tool/API | |
|---|---|---|
| Argument parsing | Flags, defaults, required-value validation | util.parseArgs() (simple), Commander (subcommands) |
| Exit codes | 0 success, non-zero on every failure path | process.exit() inside a top-level try/catch |
| Runnable command | Symlinks the compiled entry into PATH | bin field + #!/usr/bin/env node shebang |
| Running TS directly | No build step, native type stripping | Node 23.6+, or compile with tsc for older Node |
Conclusion
A CLI tool earns real structure the moment more than one person, including future you, has to run it without reading the source first. Argument parsing, meaningful exit codes, and a proper bin entry are the three pieces that turn a node script.js habit into something that behaves like every other command a user already trusts.