A well-built CLI is one of the highest-leverage developer tools you can create. Unlike a web app, a CLI can be scripted, piped, automated in CI, and distributed as a single binary. In 2026, the Node.js ecosystem has matured CLI tooling to the point where the hard parts — argument parsing, interactive prompts, colorized output, binary distribution — have solved, ergonomic libraries. Here is how to use them.
What changed in 2026
clack by Svelte team became the standard for interactive prompts, replacing older inquirer for new projects with a cleaner, accessible API.
- Bun as a CLI runtime —
bun build --compile produces a single standalone binary with no Node.js dependency.
commander v12 added TypeScript-first generics for strongly-typed options.
effect-ts CLIs — for complex CLI tools, Effect's typed error channels and dependency injection are gaining traction.
- AI-assisted CLIs — tools like Claude CLI and GitHub Copilot CLI normalized natural-language commands as a UX layer.
Minimal setup
mkdir my-cli && cd my-cli
npm init -y
npm install commander chalk
npm install -D typescript @types/node tsx
npx tsc --init --strict
// package.json
{
"bin": { "my-cli": "./dist/index.js" },
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
}
Basic CLI with commander
// src/index.ts
#!/usr/bin/env node
import { Command } from "commander";
import chalk from "chalk";
const program = new Command();
program
.name("my-cli")
.description("A useful CLI tool")
.version("1.0.0");
program
.command("greet <name>")
.description("Greet someone")
.option("-l, --loud", "Use uppercase", false)
.action((name: string, options: { loud: boolean }) => {
const msg = `Hello, ${name}!`;
console.log(chalk.green(options.loud ? msg.toUpperCase() : msg));
});
program.parse(process.argv);
The shebang #!/usr/bin/env node is required for the binary to be executable directly.
Interactive prompts with clack
import * as p from "@clack/prompts";
async function setup() {
p.intro("Project setup");
const config = await p.group({
name: () =>
p.text({ message: "Project name", placeholder: "my-app" }),
framework: () =>
p.select({
message: "Framework",
options: [
{ value: "next", label: "Next.js" },
{ value: "sveltekit", label: "SvelteKit" },
{ value: "nuxt", label: "Nuxt" },
],
}),
typescript: () =>
p.confirm({ message: "Use TypeScript?", initialValue: true }),
});
if (p.isCancel(config)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
p.outro(`Creating ${config.name} with ${config.framework}`);
}
setup();
Subcommand architecture for larger CLIs
src/
index.ts # root program, registers subcommands
commands/
init.ts
deploy.ts
config.ts
lib/
config.ts # config file read/write
output.ts # shared chalk helpers
// src/index.ts
import { Command } from "commander";
import { initCommand } from "./commands/init";
import { deployCommand } from "./commands/deploy";
const program = new Command()
.name("my-cli")
.version("1.0.0");
program.addCommand(initCommand);
program.addCommand(deployCommand);
program.parse();
Config file support
Good CLIs support both flags and a config file so CI pipelines can call them without interactive prompts.
import { readFileSync, existsSync } from "fs";
import { join } from "path";
function loadConfig(): Partial<Config> {
const configPath = join(process.cwd(), "my-cli.config.json");
if (existsSync(configPath)) {
return JSON.parse(readFileSync(configPath, "utf8"));
}
return {};
}
Use the XDG Base Directory spec (~/.config/my-cli/config.json) for global user config.
Comparison: CLI frameworks
| Library |
Best for |
TypeScript |
Prompts |
| commander |
Most CLIs |
Excellent |
No (add clack) |
| yargs |
Arg-heavy CLIs |
Good |
No |
| oclif |
Enterprise CLIs, plugins |
Excellent |
Built-in |
| Bun Shell |
Shell-replacement scripts |
Good |
No |
Commander + clack covers ~80% of use cases cleanly.
Output and error handling
import chalk from "chalk";
import ora from "ora";
const spinner = ora("Deploying...").start();
try {
await deploy();
spinner.succeed(chalk.green("Deployed successfully"));
} catch (err) {
spinner.fail(chalk.red(`Deploy failed: ${(err as Error).message}`));
process.exit(1);
}
Always process.exit(1) on errors — scripts that wrap your CLI check the exit code.
Testing CLIs
// tests/greet.test.ts
import { describe, it, expect } from "vitest";
import { execSync } from "child_process";
describe("greet command", () => {
it("outputs hello message", () => {
const output = execSync("tsx src/index.ts greet Alice").toString();
expect(output).toContain("Hello, Alice!");
});
it("uppercases with --loud flag", () => {
const output = execSync("tsx src/index.ts greet Alice --loud").toString();
expect(output).toContain("HELLO, ALICE!");
});
});
How to distribute
| Method |
Use case |
npm publish |
Developer tools; users have Node.js |
bun build --compile |
Single binary for any OS/arch |
| Homebrew tap |
macOS developer tools |
| GitHub Releases + install script |
curl-pipe install pattern |
For most developer tools, npm publish with a clear bin field in package.json is sufficient.
Common mistakes
Not handling -- argument terminator. my-cli run -- --verbose should pass --verbose to the child process, not parse it as a flag. Commander handles this if you call program.allowUnknownOption() or use --.
Printing to stdout instead of stderr for errors. Error messages and logs should go to stderr (console.error); only machine-readable output goes to stdout. This allows piping: my-cli list | jq '.[].name'.
No graceful Ctrl+C handling. Listen for SIGINT and clean up temp files or running child processes before exiting.
Ignoring terminals that do not support color. Use chalk — it automatically disables color when NO_COLOR is set or stdout is not a TTY.
What to skip
- Manual
process.argv parsing — edge cases with quoting and -- are not worth solving from scratch.
- Prompts in CI pipelines — always check
!process.stdout.isTTY and skip prompts (use flag defaults) in non-interactive mode.
- Bundling your CLI to a single JS file with webpack unless you also need to run on Bun — the Node.js ecosystem resolution works fine; bundling is premature optimization.
FAQ
Should I build a CLI in Python or Node.js?
Node.js if your users are in the JavaScript ecosystem and the project lives in an npm monorepo. Python if your CLI is data-science-adjacent or your users are more comfortable with pip. The language matters less than the distribution story.
How do I read environment variables in a CLI?
process.env.MY_VAR. Document all expected env vars in your README. Use dotenv to auto-load .env in development but never in production.
Can I build a TUI (terminal UI) in Node.js?
Yes — ink (React for the terminal) is the most capable library. For simpler interactive output, clack is lower friction.
How do I handle config that spans multiple command invocations?
Write to ~/.config/my-cli/config.json (XDG spec). Use conf npm package — it handles the path, JSON parsing, and atomic writes.
Where to go next