Web Engineering ·

Building a CLI Tool in TypeScript: Argument Parsing, Interactive Prompts, and Cross-Platform Distribution

A practical guide to building production-quality CLI tools with TypeScript. Covers project setup with tsup, argument parsing with Commander.js, interactive prompts with @clack/prompts, configuration management with cosmiconfig, output formatting, error handling, testing, and distribution via npm and Homebrew.

Building a CLI Tool in TypeScript: Argument Parsing, Interactive Prompts, and Cross-Platform Distribution

Most CLI tools start the same way: a quick script you needed once, then grew into something a team depends on. The gap between “it works on my machine” and “it’s installable by anyone, works on Windows and macOS, and doesn’t crash in unexpected ways” is wider than it looks. TypeScript closes some of that gap through type checking, but there’s real engineering work in the build setup, the argument parser choice, and the distribution story.

This guide builds a complete CLI tool from scratch. The example is depcheck-audit, a tool that checks a project’s dependencies against a known-vulnerabilities list, generates a formatted report, and supports both interactive and CI modes. The code is realistic enough to show the real tradeoffs.

Project Setup and Build with tsup

The two meaningful choices for bundling a CLI are esbuild directly, or tsup, which wraps esbuild with sensible defaults. For most CLI tools, tsup wins: it handles the CommonJS/ESM dual output, strips types, and produces a single file without configuration overhead.

npm init -y
npm install --save-dev tsup typescript @types/node
npm install commander @clack/prompts chalk ora cosmiconfig

The tsconfig.json should target Node.js 18+ and be strict:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "esModuleInterop": true
  },
  "include": ["src"]
}

The tsup.config.ts handles the actual bundle:

import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["cjs"],
  target: "node18",
  clean: true,
  banner: {
    js: "#!/usr/bin/env node",
  },
  minify: false,
  sourcemap: true,
});

The banner option injects the shebang line directly into the bundle output. This matters because if you write the shebang in your source file, TypeScript will reject it as invalid syntax. tsup’s banner injection is the correct approach. Do not put the shebang in src/index.ts.

In package.json, wire up the binary and the build:

{
  "name": "depcheck-audit",
  "version": "1.0.0",
  "bin": {
    "depcheck-audit": "./dist/index.js"
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "prepublishOnly": "npm run build"
  },
  "engines": {
    "node": ">=18"
  }
}

The files field is important. Without it, npm publish ships your entire repo including src, node_modules, test files, and everything else. Always whitelist explicitly.

During development, npm link installs the CLI globally from your working directory. Run npm run build first, then npm link, and the command becomes available in your shell pointing at ./dist/index.js.

Argument Parsing: Commander, Yargs, and Clipanion

Picking an argument parser is one of the few decisions that’s hard to reverse later. Here’s the real comparison:

LibraryBundle sizeType safetySubcommandsAsync handlersBest for
Commander.js~50KBManual typing neededYes, first-classYesMost CLIs, good balance
Yargs~100KBTypeScript support, verboseYesYesComplex option schemas
Clipanion~30KBNative TypeScript, class-basedYesYesYarn-style multi-command CLIs
Oclif~200KB+Strong, class-basedYesYesEnterprise CLIs, plugin system
Meow~15KBMinimal typingNoNoSimple single-command tools

Commander.js is the default choice for most CLIs. It handles the common cases cleanly, the TypeScript types are good enough, and the API is familiar to most engineers. Clipanion is worth considering if you’re building a multi-command tool where each command is a class with its own state. Yargs is powerful but its configuration API is verbose and the TypeScript experience is inconsistent.

Here is the core command setup using Commander:

import { Command } from "commander";
import { readFileSync } from "fs";
import { join } from "path";

const pkg = JSON.parse(
  readFileSync(join(__dirname, "../package.json"), "utf-8")
);

export const program = new Command();

program
  .name("depcheck-audit")
  .description("Audit project dependencies against known vulnerabilities")
  .version(pkg.version, "-v, --version");

program
  .command("scan")
  .description("Scan dependencies in the current project")
  .option("-p, --path <directory>", "Path to project root", process.cwd())
  .option("--json", "Output results as JSON")
  .option("--fail-on <severity>", "Exit with code 1 if issues found at this level", "high")
  .option("--config <file>", "Path to config file")
  .action(async (options: ScanOptions) => {
    await runScan(options);
  });

program
  .command("fix")
  .description("Interactively fix flagged dependencies")
  .option("-p, --path <directory>", "Path to project root", process.cwd())
  .action(async (options) => {
    await runFix(options);
  });

program.parseAsync(process.argv);

One detail worth noting: use parseAsync when any of your handlers are async. Using parse with async handlers means unhandled promise rejections. Commander will not catch those for you.

Interactive Prompts with @clack/prompts

Inquirer.js was the standard for years. @clack/prompts is the better choice today: smaller API surface, first-class TypeScript, built-in spinners, and output that doesn’t look like it was designed in 2015.

The interactive flow for depcheck-audit fix looks like this:

import * as p from "@clack/prompts";

async function runFix(options: { path: string }): Promise<void> {
  p.intro("depcheck-audit");

  const vulnerabilities = await loadVulnerabilities(options.path);

  if (vulnerabilities.length === 0) {
    p.outro("No vulnerabilities found.");
    return;
  }

  const selected = await p.multiselect({
    message: "Select packages to update:",
    options: vulnerabilities.map((v) => ({
      value: v.name,
      label: `${v.name}@${v.current}`,
      hint: `${v.severity} — fix: ${v.recommendation}`,
    })),
  });

  if (p.isCancel(selected)) {
    p.cancel("Cancelled.");
    process.exit(0);
  }

  const confirmed = await p.confirm({
    message: `Update ${(selected as string[]).length} packages?`,
  });

  if (p.isCancel(confirmed) || !confirmed) {
    p.cancel("Aborted.");
    process.exit(0);
  }

  const spinner = p.spinner();
  spinner.start("Updating packages");

  for (const pkg of selected as string[]) {
    await updatePackage(pkg, options.path);
  }

  spinner.stop("Packages updated");
  p.outro("Done. Run your tests.");
}

The p.isCancel() check is not optional. When a user presses Ctrl+C during a prompt, @clack/prompts returns a special cancel symbol rather than throwing. If you skip the check and pass the cancel symbol downstream, you’ll get confusing type errors at runtime. Always check before using the value.

For CI environments where stdin is not a TTY, detect and skip interactive prompts:

const isCI = !process.stdin.isTTY || process.env.CI === "true";

if (isCI) {
  await runScanNonInteractive(options);
} else {
  await runFix(options);
}

Configuration Management with cosmiconfig

Hard-coding behavior in flags is fine for simple tools. Once you have more than five options that users set regularly, a config file is the right move. cosmiconfig handles the lookup chain: package.json field, .depcheck-auditrc, .depcheck-auditrc.json, .depcheck-auditrc.yaml, depcheck-audit.config.js, etc.

import { cosmiconfig } from "cosmiconfig";
import { z } from "zod";

const ConfigSchema = z.object({
  failOn: z.enum(["critical", "high", "medium", "low"]).default("high"),
  ignore: z.array(z.string()).default([]),
  registries: z.array(z.string()).default(["https://registry.npmjs.org"]),
  outputFormat: z.enum(["table", "json", "summary"]).default("table"),
});

type Config = z.infer<typeof ConfigSchema>;

export async function loadConfig(searchFrom: string): Promise<Config> {
  const explorer = cosmiconfig("depcheck-audit");
  const result = await explorer.search(searchFrom);

  if (!result) {
    return ConfigSchema.parse({});
  }

  const parsed = ConfigSchema.safeParse(result.config);

  if (!parsed.success) {
    console.error("Invalid config file:", parsed.error.format());
    process.exit(1);
  }

  return parsed.data;
}

Pairing cosmiconfig with Zod gives you validated, typed config with defaults. The error message on parse failure will point users at exactly what’s wrong. Without the Zod validation step, you’ll be debugging mysterious runtime failures from malformed config files.

Output Formatting: Chalk, Ora, and Tables

Terminal output has two audiences: humans running interactively, and CI systems consuming stdout. Design for both.

For colors, chalk remains the standard. Check chalk.level before writing colored output in scripts that might be consumed by other tools:

import chalk from "chalk";

function formatSeverity(severity: "critical" | "high" | "medium" | "low"): string {
  const colors = {
    critical: chalk.bgRed.white,
    high: chalk.red,
    medium: chalk.yellow,
    low: chalk.gray,
  };
  return colors[severity](severity.toUpperCase());
}

function printVulnerabilityTable(vulns: Vulnerability[]): void {
  if (vulns.length === 0) {
    console.log(chalk.green("No vulnerabilities found."));
    return;
  }

  const rows = vulns.map((v) => [
    v.name,
    v.current,
    v.recommendation ?? "no fix available",
    formatSeverity(v.severity),
    v.cve ?? "—",
  ]);

  const header = ["Package", "Version", "Fix", "Severity", "CVE"].map((h) =>
    chalk.bold(h)
  );

  // Use the 'cli-table3' package for aligned columns
  import { Table } from "cli-table3";
  const table = new Table({ head: header });
  rows.forEach((row) => table.push(row));
  console.log(table.toString());
}

For progress indication during long operations, use ora rather than manual spinners:

import ora from "ora";

async function fetchAdvisories(packages: string[]): Promise<Advisory[]> {
  const spinner = ora(`Checking ${packages.length} packages...`).start();

  try {
    const results = await Promise.all(packages.map(checkPackage));
    spinner.succeed(`Checked ${packages.length} packages`);
    return results.flat();
  } catch (err) {
    spinner.fail("Failed to fetch advisory data");
    throw err;
  }
}

One hard rule: never write spinner output to stdout. Both ora and @clack/prompts write to stderr by default. If a user pipes your CLI output into another tool, the spinner frames should not appear in the pipe. Verify this by running depcheck-audit scan | cat and confirming only the actual data comes through.

Error Handling and Exit Codes

Exit codes are a contract with the shell. Tools that always exit with 0 are useless in scripts. The conventions worth following:

  • 0: success
  • 1: general error (caught, expected failure)
  • 2: misuse (bad arguments, missing required flags)
  • 3+: tool-specific codes (document them)

Wrap your main entry point with a top-level error boundary:

import { program } from "./commands";

async function main(): Promise<void> {
  try {
    await program.parseAsync(process.argv);
  } catch (err) {
    if (err instanceof UserError) {
      console.error(chalk.red("Error:"), err.message);
      process.exit(1);
    }

    if (err instanceof ConfigError) {
      console.error(chalk.red("Config error:"), err.message);
      process.exit(2);
    }

    // Unexpected error: print the full stack in debug mode
    if (process.env.DEBUG) {
      console.error(err);
    } else {
      console.error(chalk.red("Unexpected error:"), (err as Error).message);
      console.error("Run with DEBUG=1 for full stack trace.");
    }
    process.exit(1);
  }
}

main();

Define specific error classes for expected failure modes. Catching Error generically and always exiting with 1 makes it impossible to distinguish a config mistake from a network failure in a script. The exit code is the only reliable signal for calling processes.

Testing CLI Tools

The testing surface for a CLI has three layers: unit tests on the logic, integration tests on the command parsing, and end-to-end tests on the full binary.

For unit tests, keep business logic in pure functions that take explicit arguments and return values. The command handlers should be thin wrappers.

For integration tests, test the command object directly without spawning a subprocess:

import { describe, it, expect, vi } from "vitest";
import { program } from "../src/commands";

describe("scan command", () => {
  it("defaults to current directory when --path is not specified", async () => {
    const scanSpy = vi.fn().mockResolvedValue([]);
    vi.mock("../src/scanner", () => ({ runScan: scanSpy }));

    await program.parseAsync(["node", "depcheck-audit", "scan"]);

    expect(scanSpy).toHaveBeenCalledWith(
      expect.objectContaining({ path: process.cwd() })
    );
  });

  it("exits with code 1 when vulnerabilities exceed --fail-on threshold", async () => {
    vi.mock("../src/scanner", () => ({
      runScan: vi.fn().mockResolvedValue([
        { name: "lodash", severity: "high", current: "4.17.11" },
      ]),
    }));

    const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
      throw new Error("process.exit called");
    });

    await expect(
      program.parseAsync(["node", "depcheck-audit", "scan", "--fail-on", "high"])
    ).rejects.toThrow("process.exit called");

    expect(exitSpy).toHaveBeenCalledWith(1);
  });
});

For end-to-end tests, spawn the built binary using Node’s child_process.execFile. This catches shebang issues, PATH resolution problems, and any runtime differences from the bundled output:

import { execFile } from "child_process";
import { promisify } from "util";
import { join } from "path";

const execFileAsync = promisify(execFile);
const BIN = join(__dirname, "../dist/index.js");

describe("CLI binary", () => {
  it("prints version with -v flag", async () => {
    const { stdout } = await execFileAsync("node", [BIN, "-v"]);
    expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
  });

  it("exits cleanly with no arguments and shows help", async () => {
    const { stdout, stderr } = await execFileAsync("node", [BIN, "--help"]);
    expect(stdout).toContain("scan");
    expect(stdout).toContain("fix");
  });
});

For testing interactive prompts, @clack/prompts does not provide a test helper, but you can mock process.stdin with a PassThrough stream and write input sequences into it. This is brittle. A cleaner approach is to test the underlying command logic directly and treat the prompt layer as a thin integration point that you test manually or with end-to-end snapshot tests.

Distribution via npm

Publishing is straightforward if you’ve set files correctly in package.json. The lifecycle script prepublishOnly ensures the build always runs before publish:

npm version patch    # bumps version, creates git tag
npm publish          # runs build, publishes to registry

For scoped packages (@your-org/depcheck-audit), add "publishConfig": { "access": "public" } to package.json to avoid needing --access public on every publish.

For teams that want global install without npm, provide an npx-compatible experience. If your binary is named depcheck-audit, npx depcheck-audit scan works immediately after publish with no extra configuration.

Distribution via Homebrew

Homebrew is the standard install path for CLI tools targeting macOS developers. The flow: build a standalone binary with pkg or nexe, publish a release with it on GitHub, then create a Homebrew formula.

Building a self-contained binary with pkg:

npm install --save-dev pkg
npx pkg dist/index.js --targets node18-macos-x64,node18-linux-x64,node18-win-x64 --output bin/depcheck-audit

This produces platform-specific binaries with Node.js embedded. Upload them to a GitHub release, then write the formula in a Homebrew tap repository (homebrew-tap by convention):

class DepcheckAudit < Formula
  desc "Audit project dependencies against known vulnerabilities"
  homepage "https://github.com/your-org/depcheck-audit"
  version "1.2.0"

  on_macos do
    on_arm do
      url "https://github.com/your-org/depcheck-audit/releases/download/v1.2.0/depcheck-audit-macos-arm64"
      sha256 "abc123..."
    end
    on_intel do
      url "https://github.com/your-org/depcheck-audit/releases/download/v1.2.0/depcheck-audit-macos-x64"
      sha256 "def456..."
    end
  end

  on_linux do
    url "https://github.com/your-org/depcheck-audit/releases/download/v1.2.0/depcheck-audit-linux-x64"
    sha256 "ghi789..."
  end

  def install
    bin.install "depcheck-audit-#{OS.mac? ? "macos" : "linux"}-#{Hardware::CPU.arm? ? "arm64" : "x64"}" => "depcheck-audit"
  end

  test do
    assert_match version.to_s, shell_output("#{bin}/depcheck-audit -v")
  end
end

Users install via:

brew tap your-org/tap
brew install depcheck-audit

The sha256 field is non-optional. Homebrew verifies it against the downloaded binary. Compute it with shasum -a 256 <binary> and update the formula with each release. Automate this with a GitHub Actions workflow that triggers on release creation, builds the binaries, uploads them, computes hashes, and opens a PR against your tap repository.

Windows Compatibility

A few things break on Windows that work fine elsewhere:

#!/usr/bin/env node has no effect on Windows. npm handles this by generating wrapper .cmd and .ps1 files when you run npm install -g. As long as you’ve declared your binary in package.json’s bin field, this is automatic.

Path separators: never construct paths by string concatenation. Use path.join() or path.resolve() everywhere. This is a TypeScript antipattern that’s easy to miss if you only test on macOS.

ANSI escape codes: Windows Terminal and modern PowerShell support color. The legacy cmd.exe does not reliably. chalk detects color support via the COLORTERM and NO_COLOR environment variables and the TERM level. Do not force chalk’s level manually, let it detect.

File permissions: chmod 755 on the binary is a no-op on Windows, but npm handles making the binary executable during global install. You don’t need to set permissions in your install script.

A Note on Binary Size

A self-contained binary via pkg for a moderately complex CLI weighs around 40-60MB on macOS because Node.js itself is embedded. For tools distributed via npm, this doesn’t matter. For Homebrew distribution where download size matters to users, consider whether the self-contained binary is worth it versus requiring Node.js as a prerequisite. Most developer-facing CLI tools are fine with a Node.js prerequisite. pkg is primarily worth it for tools targeting non-Node audiences.

The TypeScript compilation and tsup bundling step itself produces output around 50-200KB depending on dependencies. That’s the relevant size for the npm-distributed version.


The surface area of a production CLI is larger than it looks. The argument parser choice, the shebang setup, the config file resolution, the CI-vs-interactive detection, the exit codes, and the distribution mechanism are all independent decisions that compound. Getting any one of them wrong produces a tool that works for you but fails for the next person. The patterns above are the ones that hold up across real usage.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.