Making Your First CLI with Commander
This lab walks you through building a modern CLI with commander. You'll scaffold a project, parse flags and subcommands, add rich output, handle errors, and package it so npx
and global installs work cleanly.
What you'll build
- A
hello
CLI with flags (--name
,--json
), subcommands (greet
,init
), and config file support. - Auto-generated help, version, and shell-friendly exit codes.
- Ready-to-publish package with
bin
mapping and ESM/CJS notes.
Prereqs
- Node.js 18+
- npm or pnpm (examples use
npm
)
node -v
npm -v
1) Scaffold the project
mkdir hello-cli && cd hello-cli
npm init -y
npm i commander
# optional niceties
npm i chalk@5 prompts@2
npm i -D typescript @types/node ts-node eslint
npx tsc --init --rootDir src --outDir dist --module ES2022 --moduleResolution bundler --target ES2022 --resolveJsonModule true
mkdir src
package.json
minimal fields:
{
"name": "hello-cli",
"version": "0.1.0",
"type": "module",
"bin": {
"hello": "dist/index.js"
},
"exports": {
".": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
}
}
If you prefer JavaScript, use
src/index.js
and skip TypeScript. Replace build with a copier step if needed.
2) Create the CLI entry
// src/index.ts
#!/usr/bin/env node
import { Command, Option } from "commander";
import * as fs from "node:fs";
import * as path from "node:path";
import chalk from "chalk";
const pkg = { version: "0.1.0", name: "hello-cli" }; // or read from package.json if bundling allows
type Config = { defaultName?: string };
function loadConfig(cwd = process.cwd()): Config {
const candidates = ["hello.config.json", "hello.config.mjs", "hello.config.js"];
for (const file of candidates) {
const p = path.join(cwd, file);
if (fs.existsSync(p)) {
if (p.endsWith(".json")) return JSON.parse(fs.readFileSync(p, "utf8"));
return (await import(pathToFileURL(p).href)).default ?? {};
}
}
return {};
}
const program = new Command()
.name("hello")
.description("A friendly demo CLI built with commander")
.version(pkg.version);
program
.addOption(new Option("-n, --name <name>", "name to greet").env("HELLO_NAME"))
.addOption(new Option("--json", "output JSON"))
.action(async (opts) => {
const cfg = await loadConfig();
const name = opts.name ?? cfg.defaultName ?? "world";
const message = `Hello, ${name}!`;
if (opts.json) {
console.log(JSON.stringify({ message }, null, 2));
return;
}
console.log(chalk.bold.green(message));
});
program
.command("greet [name]")
.description("Greet someone explicitly")
.option("--upper", "UPPERCASE the output")
.action((name = "world", options) => {
let msg = `Hello, ${name}!`;
if (options.upper) msg = msg.toUpperCase();
console.log(msg);
});
program
.command("init")
.description("Create a starter config file")
.option("-f, --force", "overwrite if exists", false)
.action(async (opts) => {
const file = path.join(process.cwd(), "hello.config.json");
if (fs.existsSync(file) && !opts.force) {
console.error("Config exists. Use --force to overwrite.");
process.exitCode = 2;
return;
}
fs.writeFileSync(file, JSON.stringify({ defaultName: "world" }, null, 2));
console.log(`Wrote ${file}`);
});
program.showHelpAfterError();
program.exitOverride((err) => {
// Convert commander errors to proper exit codes without stack noise in CI
if (err.code === "commander.helpDisplayed") process.exit(0);
if (err.code === "commander.version") process.exit(0);
console.error(err.message);
process.exit(2);
});
program.parseAsync();
On Unix/macOS, ensure the file is executable after build: the shebang (
#!/usr/bin/env node
) must be at the top of the compiled JS. TypeScript preserves it by default. If not, considerrollup
ortsup
withbanner
settings.
3) Try it locally
npm run build
node dist/index.js
node dist/index.js --name Jon
node dist/index.js --json
node dist/index.js greet Alice --upper
node dist/index.js init
For a global-feel test without publishing:
npm link # symlinks "hello" into your PATH
hello --help
hello greet Bob
npm unlink -g hello-cli
4) Polish the UX
Helpful behaviors
program.showHelpAfterError()
shows help when a user makes a mistake.- Use
Option.env("PREFIX_NAME")
to map environment variables. - Colorized output with
chalk
and prompt flows withprompts
(e.g., interactiveinit
).
import prompts from "prompts";
program
.command("init")
.description("Interactive init")
.action(async () => {
const { defaultName } = await prompts({
type: "text",
name: "defaultName",
message: "Default name?",
initial: "world"
});
// write config...
});
Consistent exits
process.exitCode = 1
for generic failures,2
for usage errors.- Never throw raw errors; print a clear message + tip.
5) Package & publish
Add a clean files
allowlist to your package.json
:
{
"files": ["dist", "README.md", "LICENSE"],
"publishConfig": { "access": "public" }
}
Build and test:
npm run build
node dist/index.js --help
Publish:
npm login
npm publish --access public
Usage via npx / global:
npx hello-cli --name Ada
npm i -g hello-cli && hello greet Bob
6) ESM vs CJS quick notes
- Setting
"type": "module"
gives you ESM; if you need CJS, remove it and userequire()
. - Some environments (older Node) prefer CJS. Commander works with both.
- For tighter single-file binaries, consider
tsx
for dev andtsup
/rollup
for build.
7) Bonus: tests
npm i -D vitest
// test/basic.test.ts
import { execSync } from "node:child_process";
import { describe, it, expect } from "vitest";
describe("hello cli", () => {
it("prints default greeting", () => {
const out = execSync("node dist/index.js").toString();
expect(out).toMatch(/Hello, world!/);
});
});
Run tests:
npx vitest run
Cheat sheet
program.option("-f, --flag", "desc", defaultValue)
.command("name [optional] <required>")
showHelpAfterError()
,addHelpText()
,exitOverride()
- Map env vars with
new Option(...).env("PREFIX_KEY")
You now have a fully working CLI that's easy to extend. Add more subcommands for your workflows (e.g., transcode
, sync
, deploy
) and keep the UX crisp.