Building CLI Tools with TypeScript and Commander.js
Create professional command-line tools in TypeScript with Commander.js: argument parsing, interactive prompts, progress and structured output.

Why Build Custom CLI Tools
Every mature engineering team accumulates scripts—deployment helpers, data migration runners, environment setup commands. These scripts start as bash one-liners, grow into unreadable monsters, and eventually need proper argument parsing, error handling, and documentation. TypeScript CLI tools give you type safety, testability, and a professional interface for your team's internal tooling.
Project Setup
Start with a focused package structure. The CLI entry point should be minimal, delegating to command handlers that contain the actual logic.
// package.json
{
"name": "@company/deploy-tool",
"version": "1.0.0",
"type": "module",
"bin": {
"deploy": "./dist/cli.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts",
"lint": "eslint src/"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^5.3.0",
"ora": "^8.0.0",
"inquirer": "^9.0.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"tsx": "^4.0.0",
"@types/node": "^20.0.0"
}
}// src/cli.ts
import { Command } from "commander";
import { deployCommand } from "./commands/deploy.js";
import { statusCommand } from "./commands/status.js";
import { rollbackCommand } from "./commands/rollback.js";
const program = new Command()
.name("deploy")
.description("Deployment management CLI")
.version("1.0.0");
program.addCommand(deployCommand);
program.addCommand(statusCommand);
program.addCommand(rollbackCommand);
program.parse();Building Commands with Options and Validation
Each command is a self-contained module with its own options, validation, and handler. Commander parses arguments; your handler validates business logic.
// ❌ No validation, unclear errors, raw process.argv parsing
const env = process.argv[2]; // "staging" hopefully
const tag = process.argv[3]; // who knows
runDeploy(env, tag);
// ✅ Typed options, validation, clear error messages
// src/commands/deploy.ts
import { Command, Option } from "commander";
import chalk from "chalk";
interface DeployOptions {
environment: "staging" | "production";
tag: string;
dryRun: boolean;
force: boolean;
notify: boolean;
}
export const deployCommand = new Command("run")
.description("Deploy a tagged release to an environment")
.requiredOption(
"-e, --environment <env>",
"Target environment"
)
.requiredOption(
"-t, --tag <tag>",
"Docker image tag to deploy"
)
.option("--dry-run", "Preview changes without deploying", false)
.option("--force", "Skip confirmation prompts", false)
.option("--notify", "Send Slack notification on completion", true)
.addOption(
new Option("-e, --environment <env>", "Target environment")
.choices(["staging", "production"])
.makeOptionMandatory()
)
.action(async (options: DeployOptions) => {
try {
await handleDeploy(options);
} catch (error) {
console.error(chalk.red(`Deploy failed: ${(error as Error).message}`));
process.exit(1);
}
});Interactive Prompts for Dangerous Operations
Some commands need confirmation. Production deployments, data deletions, and rollbacks should prompt the user unless explicitly overridden with a --force flag.
import inquirer from "inquirer";
import chalk from "chalk";
async function handleDeploy(options: DeployOptions): Promise<void> {
// Validate tag format
if (!/^v\d+\.\d+\.\d+(-[\w.]+)?$/.test(options.tag)) {
throw new Error(
`Invalid tag format: "${options.tag}". Expected semver like v1.2.3`
);
}
console.log(chalk.bold("\nDeployment Plan:"));
console.log(` Environment: ${chalk.cyan(options.environment)}`);
console.log(` Tag: ${chalk.cyan(options.tag)}`);
console.log(` Dry run: ${options.dryRun ? chalk.yellow("yes") : "no"}`);
if (options.environment === "production" && !options.force) {
const { confirmed } = await inquirer.prompt([
{
type: "confirm",
name: "confirmed",
message: chalk.red(
"You are deploying to PRODUCTION. Continue?"
),
default: false,
},
]);
if (!confirmed) {
console.log(chalk.yellow("Deployment cancelled."));
return;
}
}
if (options.dryRun) {
console.log(chalk.yellow("\n[DRY RUN] Would execute the following:"));
console.log(` kubectl set image deployment/app app=${options.tag}`);
return;
}
await executeDeploy(options);
}Progress Indicators and Structured Output
Long-running commands need feedback. Use spinners for indeterminate operations and tables for structured results.
import ora from "ora";
import chalk from "chalk";
async function executeDeploy(options: DeployOptions): Promise<void> {
const steps: Array<{ label: string; fn: () => Promise<void> }> = [
{
label: "Pulling image",
fn: () => pullImage(options.tag),
},
{
label: "Running pre-deploy checks",
fn: () => runHealthChecks(options.environment),
},
{
label: "Updating deployment",
fn: () => updateDeployment(options.environment, options.tag),
},
{
label: "Waiting for rollout",
fn: () => waitForRollout(options.environment),
},
{
label: "Running post-deploy verification",
fn: () => verifyDeployment(options.environment),
},
];
console.log("");
for (const step of steps) {
const spinner = ora(step.label).start();
try {
await step.fn();
spinner.succeed();
} catch (error) {
spinner.fail();
throw error;
}
}
console.log(chalk.green("\n✓ Deployment complete"));
if (options.notify) {
await sendSlackNotification({
environment: options.environment,
tag: options.tag,
status: "success",
});
}
}Error Handling and Exit Codes
CLI tools communicate success and failure through exit codes. Use them correctly so scripts and CI pipelines can react appropriately.
// src/errors.ts
class CLIError extends Error {
constructor(
message: string,
public readonly exitCode: number = 1,
public readonly hint?: string
) {
super(message);
this.name = "CLIError";
}
}
class ValidationError extends CLIError {
constructor(message: string) {
super(message, 2, "Run with --help for usage information");
}
}
class NetworkError extends CLIError {
constructor(message: string) {
super(message, 3, "Check your network connection and VPN status");
}
}
// Global error handler in cli.ts
function handleError(error: unknown): never {
if (error instanceof CLIError) {
console.error(chalk.red(`Error: ${error.message}`));
if (error.hint) {
console.error(chalk.dim(`Hint: ${error.hint}`));
}
process.exit(error.exitCode);
}
// Unexpected errors
console.error(chalk.red("Unexpected error:"));
console.error(error);
process.exit(1);
}
process.on("uncaughtException", handleError);
process.on("unhandledRejection", handleError);Testing CLI Commands
Test command handlers as pure functions, separate from the Commander parsing layer. This lets you verify logic without spawning child processes.
import { describe, it, expect, vi } from "vitest";
describe("deploy command", () => {
it("rejects invalid tag format", async () => {
await expect(
handleDeploy({
environment: "staging",
tag: "not-a-semver",
dryRun: false,
force: true,
notify: false,
})
).rejects.toThrow('Invalid tag format: "not-a-semver"');
});
it("dry run does not execute deployment", async () => {
const deploySpy = vi.spyOn(deployModule, "updateDeployment");
await handleDeploy({
environment: "staging",
tag: "v1.2.3",
dryRun: true,
force: true,
notify: false,
});
expect(deploySpy).not.toHaveBeenCalled();
});
});Key Takeaways
TypeScript CLI tools replace fragile bash scripts with typed, testable, documented command interfaces. Use Commander.js for argument parsing, Inquirer for interactive prompts on dangerous operations, and Ora for progress feedback. Validate inputs early with clear error messages. Return meaningful exit codes so scripts and CI can depend on your tool.
Test command handlers as pure functions—pass options in, assert on behavior. Structure the CLI as entry point plus command modules, keeping each command focused on one operation. The investment in a proper CLI tool pays back every time a teammate runs it without reading the source code to understand the arguments.


