Skip to content

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.

4 min read
A terminal window showing a CLI tool with colorful output, progress bars, and structured help text

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.

jsonjson
// 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"
  }
}
tstypescript
// 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.

tstypescript
// ❌ 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.

tstypescript
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.

tstypescript
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.

tstypescript
// 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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX