TypeScript CLI Development
Overview
TypeScript is an excellent choice for building CLI tools thanks to its type safety, rich ecosystem of argument-parsing and terminal-UI libraries, and multiple packaging options. This skill covers the major frameworks for building CLIs, terminal styling and interaction libraries, and strategies for testing and distributing CLI applications.
Tool Comparison
| Framework | Approach | Subcommands | Plugins | TypeScript DX | Best For |
|---|---|---|---|---|---|
| Commander | Fluent API | Yes (nested) | No | Good (built-in types) | Simple to mid-complexity CLIs |
| Yargs | Builder/chaining | Yes | No (middleware) | Good (@types/yargs) | Complex option parsing, interactive |
| oclif | Class-based (OOP) | Yes (topics) | Yes (first-class) | Excellent (decorators) | Large, extensible CLIs (Heroku, Salesforce) |
| Clipanion | Class-based | Yes | No | Excellent (Yarn Berry's parser) | Type-safe, validation-heavy CLIs |
| Citty | Functional | Yes (nested) | No | Excellent (unjs ecosystem) | Lightweight, modern CLIs |
Commander Deep Dive
Commander is the most popular Node.js CLI framework, with a fluent chainable API.
Installation
npm install commander
Basic Structure
import { Command } from "commander";
const program = new Command();
program
.name("my-cli")
.description("A CLI tool built with Commander")
.version("1.0.0");
// Simple command with options
program
.command("greet")
.description("Greet a user")
.argument("<name>", "Name to greet")
.option("-l, --loud", "Shout the greeting")
.option("-t, --times <count>", "Number of times to greet", "1")
.action((name: string, options: { loud?: boolean; times: string }) => {
const greeting = options.loud ? `HELLO ${name.toUpperCase()}!` : `Hello, ${name}!`;
const count = parseInt(options.times, 10);
for (let i = 0; i < count; i++) {
console.log(greeting);
}
});
program.parse();
Subcommands
// Nested subcommands
const deploy = program
.command("deploy")
.description("Deployment commands");
deploy
.command("staging")
.description("Deploy to staging")
.option("--dry-run", "Preview without deploying")
.action((options) => {
console.log(options.dryRun ? "Dry run..." : "Deploying to staging...");
});
deploy
.command("production")
.description("Deploy to production")
.option("--force", "Skip confirmation")
.action((options) => {
if (!options.force) {
console.log("Use --force to deploy to production");
return;
}
console.log("Deploying to production...");
});
Option Types
program
.option("-d, --debug", "Enable debug mode") // boolean
.option("-p, --port <number>", "Port number", "3000") // string with default
.option("-c, --config <path>", "Config file path") // required value
.option("-e, --env <values...>", "Environment variables") // variadic
.option("--no-color", "Disable color output") // negatable boolean
.option("-v, --verbose", "Increase verbosity", (_, prev) => prev + 1, 0); // incremental
Help and Version
program
.version("1.0.0", "-v, --version", "Show version number")
.helpOption("-h, --help", "Show help")
.addHelpText("after", "\nExamples:\n $ my-cli greet Alice\n $ my-cli deploy staging --dry-run");
Yargs Deep Dive
Yargs provides a powerful builder pattern for complex CLIs with middleware and auto-completion.
Installation
npm install yargs npm install -D @types/yargs
Basic Structure
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
const argv = yargs(hideBin(process.argv))
.scriptName("my-cli")
.usage("$0 <command> [options]")
.command(
"greet <name>",
"Greet a user",
(yargs) => {
return yargs
.positional("name", {
describe: "Name to greet",
type: "string",
demandOption: true,
})
.option("loud", {
alias: "l",
type: "boolean",
describe: "Shout the greeting",
});
},
(argv) => {
const greeting = argv.loud
? `HELLO ${argv.name.toUpperCase()}!`
: `Hello, ${argv.name}!`;
console.log(greeting);
}
)
.strict() // fail on unknown options
.demandCommand(1) // require at least one command
.help()
.alias("h", "help")
.version()
.alias("v", "version")
.parse();
Middleware
yargs(hideBin(process.argv))
.middleware([
// Load config file before any command runs
async (argv) => {
if (argv.config) {
const config = JSON.parse(await fs.readFile(argv.config, "utf-8"));
Object.assign(argv, config);
}
},
// Set up logging
(argv) => {
if (argv.verbose) {
console.log("Verbose mode enabled");
}
},
])
.option("config", { type: "string", describe: "Path to config file" })
.option("verbose", { type: "boolean", default: false })
.command(/* ... */)
.parse();
Shell Completion
yargs(hideBin(process.argv))
.completion("completion", "Generate shell completion script")
.parse();
# Generate and install completion my-cli completion >> ~/.bashrc my-cli completion >> ~/.zshrc
Strict Mode
yargs(hideBin(process.argv)) .strict() // error on unknown options .strictCommands() // error on unknown commands .strictOptions() // error on unknown options (even in commands) .parse();
oclif Deep Dive
oclif (Open CLI Framework) is Salesforce's framework for building large, production CLIs with a class-based, plugin-extensible architecture.
Installation
npx oclif generate my-cli cd my-cli npm install
Class-Based Commands
import { Command, Flags, Args } from "@oclif/core";
export default class Greet extends Command {
static description = "Greet a user";
static examples = [
"<%= config.bin %> greet Alice",
"<%= config.bin %> greet Alice --loud",
];
static args = {
name: Args.string({
description: "Name to greet",
required: true,
}),
};
static flags = {
loud: Flags.boolean({
char: "l",
description: "Shout the greeting",
default: false,
}),
times: Flags.integer({
char: "t",
description: "Number of times to greet",
default: 1,
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(Greet);
const greeting = flags.loud
? `HELLO ${args.name.toUpperCase()}!`
: `Hello, ${args.name}!`;
for (let i = 0; i < flags.times; i++) {
this.log(greeting);
}
}
}
Plugins and Hooks
// package.json
{
"oclif": {
"plugins": [
"@oclif/plugin-help",
"@oclif/plugin-autocomplete",
"@oclif/plugin-not-found",
"@oclif/plugin-warn-if-update-available"
],
"hooks": {
"init": "./dist/hooks/init"
},
"topics": {
"deploy": {
"description": "Deployment commands"
}
}
}
}
Hook Implementation
import { Hook } from "@oclif/core";
const hook: Hook<"init"> = async function (options) {
// Runs before every command
this.log(`Running ${options.id}`);
};
export default hook;
Topics (Subcommand Groups)
oclif uses directory structure for subcommand grouping:
src/commands/ ├── deploy/ │ ├── staging.ts # my-cli deploy staging │ └── production.ts # my-cli deploy production ├── config/ │ ├── get.ts # my-cli config get │ └── set.ts # my-cli config set └── greet.ts # my-cli greet
Ink for React-Based TUI
Ink lets you build terminal UIs using React components.
Installation
npm install ink react npm install -D @types/react
Basic App
import React, { useState, useEffect } from "react";
import { render, Box, Text, useInput, useApp } from "ink";
function App() {
const [counter, setCounter] = useState(0);
const { exit } = useApp();
useInput((input, key) => {
if (input === "q") exit();
if (key.upArrow) setCounter((c) => c + 1);
if (key.downArrow) setCounter((c) => Math.max(0, c - 1));
});
return (
<Box flexDirection="column" padding={1}>
<Text bold color="green">
Counter: {counter}
</Text>
<Text dimColor>
Press Up/Down arrows to change, q to quit
</Text>
</Box>
);
}
render(<App />);
Ink Components
| Component | Purpose |
|---|---|
<Box> | Flexbox container (like div). Supports flexDirection, padding, margin, borderStyle. |
<Text> | Styled text. Supports bold, italic, underline, color, backgroundColor, dimColor. |
<Newline> | Renders a blank line. |
<Spacer> | Flexible spacer that fills available space (like flex: 1). |
<Static> | Renders items that should not be re-rendered (log output). |
Ink Hooks
| Hook | Purpose |
|---|---|
useInput(handler) | Listen for keyboard input. Handler receives (input, key). |
useApp() | Access { exit } to quit the app programmatically. |
useStdin() | Read raw stdin data. |
useStdout() | Access stdout dimensions and write(). |
useFocus() | Manage focus for interactive elements. |
useFocusManager() | Control focus programmatically. |
Ink Spinner
import Spinner from "ink-spinner";
function Loading() {
return (
<Text>
<Text color="green"><Spinner type="dots" /></Text>
{" Loading..."}
</Text>
);
}
Chalk for Terminal Styling
Chalk provides chainable terminal color and styling.
Installation
npm install chalk
Usage
import chalk from "chalk";
// Colors
console.log(chalk.red("Error!"));
console.log(chalk.green("Success!"));
console.log(chalk.blue.bold("Info"));
console.log(chalk.hex("#FFA500")("Orange text"));
console.log(chalk.rgb(255, 165, 0)("Also orange"));
// Backgrounds
console.log(chalk.bgRed.white(" ERROR "));
console.log(chalk.bgGreen.black(" PASS "));
// Modifiers
console.log(chalk.bold("Bold"));
console.log(chalk.dim("Dimmed"));
console.log(chalk.italic("Italic"));
console.log(chalk.underline("Underlined"));
console.log(chalk.strikethrough("Struck through"));
// Template literals (tagged template)
console.log(chalk`{red Error}: {green.bold Fixed} in {blue ${file}}`);
// Nesting
console.log(chalk.red("Red", chalk.bold("and bold"), "and red again"));
// Level detection
// chalk.level: 0 = no color, 1 = basic, 2 = 256 colors, 3 = truecolor
Ora for Spinners
import ora from "ora";
const spinner = ora("Loading...").start();
try {
await doSomething();
spinner.succeed("Done!");
} catch (error) {
spinner.fail("Failed!");
}
// Spinner variants
spinner.text = "Still working..."; // update text
spinner.color = "yellow"; // change color
spinner.spinner = "dots"; // change style (dots, line, arc, etc.)
spinner.warn("Warning"); // yellow warning
spinner.info("Information"); // blue info
Listr2 for Task Lists
import { Listr } from "listr2";
const tasks = new Listr([
{
title: "Installing dependencies",
task: async () => {
await execAsync("npm install");
},
},
{
title: "Building project",
task: async (ctx, task) => {
task.output = "Compiling TypeScript...";
await execAsync("npm run build");
},
},
{
title: "Running tests",
task: async () => {
await execAsync("npm test");
},
skip: (ctx) => ctx.skipTests && "Tests skipped by user",
},
{
title: "Deploying",
task: () => {
return new Listr([
{ title: "Upload assets", task: async () => { /* ... */ } },
{ title: "Invalidate cache", task: async () => { /* ... */ } },
], { concurrent: true }); // nested tasks run in parallel
},
},
]);
await tasks.run({ skipTests: false });
Inquirer / Prompts for Interactive Input
@inquirer/prompts (modern, modular)
import { input, select, confirm, checkbox, password } from "@inquirer/prompts";
const name = await input({ message: "What is your name?" });
const framework = await select({
message: "Pick a framework",
choices: [
{ name: "React", value: "react" },
{ name: "Vue", value: "vue" },
{ name: "Svelte", value: "svelte" },
],
});
const features = await checkbox({
message: "Select features",
choices: [
{ name: "TypeScript", value: "ts", checked: true },
{ name: "ESLint", value: "eslint" },
{ name: "Prettier", value: "prettier" },
],
});
const proceed = await confirm({ message: "Continue?" });
const secret = await password({ message: "Enter API key:", mask: "*" });
prompts (lightweight alternative)
import prompts from "prompts";
const response = await prompts([
{ type: "text", name: "name", message: "Project name?" },
{ type: "select", name: "lang", message: "Language?",
choices: [
{ title: "TypeScript", value: "ts" },
{ title: "JavaScript", value: "js" },
],
},
{ type: "confirm", name: "git", message: "Initialize git?" },
]);
console.log(response); // { name: "my-app", lang: "ts", git: true }
Output Formatting
JSON Mode
Provide a --json flag for machine-readable output:
interface Result {
status: string;
items: string[];
}
function output(result: Result, json: boolean): void {
if (json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`Status: ${result.status}`);
result.items.forEach((item) => console.log(` - ${item}`));
}
}
Table Output with cli-table3
import Table from "cli-table3";
const table = new Table({
head: ["Name", "Version", "Status"],
colWidths: [20, 10, 15],
style: { head: ["cyan"] },
});
table.push(
["express", "4.18.2", "up to date"],
["typescript", "5.4.5", "outdated"],
["vitest", "1.6.0", "up to date"]
);
console.log(table.toString());
Progress Bars with cli-progress
import cliProgress from "cli-progress";
const bar = new cliProgress.SingleBar({
format: "Progress |{bar}| {percentage}% | {value}/{total}",
barCompleteChar: "\u2588",
barIncompleteChar: "\u2591",
});
bar.start(100, 0);
for (let i = 0; i <= 100; i++) {
bar.update(i);
await sleep(50);
}
bar.stop();
Exit Codes and Error Handling
Standard Exit Codes
| Code | Meaning |
|---|---|
0 | Success |
1 | General error |
2 | Misuse of command (invalid arguments) |
126 | Command found but not executable |
127 | Command not found |
130 | Terminated by Ctrl+C (SIGINT) |
Error Handling Pattern
class CLIError extends Error {
constructor(
message: string,
public exitCode: number = 1,
public showHelp: boolean = false
) {
super(message);
this.name = "CLIError";
}
}
async function main(): Promise<void> {
try {
program.parse();
} catch (error) {
if (error instanceof CLIError) {
console.error(chalk.red(`Error: ${error.message}`));
if (error.showHelp) program.help();
process.exit(error.exitCode);
}
// Unexpected errors
console.error(chalk.red("Unexpected error:"), error);
process.exit(1);
}
}
// Handle SIGINT gracefully
process.on("SIGINT", () => {
console.log(chalk.yellow("\nInterrupted. Cleaning up..."));
// Perform cleanup
process.exit(130);
});
// Handle unhandled rejections
process.on("unhandledRejection", (reason) => {
console.error(chalk.red("Unhandled rejection:"), reason);
process.exit(1);
});
main();
CLI Testing Strategies
Mock stdin/stdout
import { describe, it, expect, vi } from "vitest";
describe("CLI", () => {
it("should output greeting", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
// Simulate CLI invocation
process.argv = ["node", "cli", "greet", "Alice"];
await import("./cli.js");
expect(consoleSpy).toHaveBeenCalledWith("Hello, Alice!");
consoleSpy.mockRestore();
});
});
Snapshot Testing with execa
import { describe, it, expect } from "vitest";
import { execa } from "execa";
describe("CLI integration", () => {
it("should show help", async () => {
const result = await execa("node", ["./dist/cli.js", "--help"]);
expect(result.stdout).toMatchSnapshot();
expect(result.exitCode).toBe(0);
});
it("should fail on invalid command", async () => {
try {
await execa("node", ["./dist/cli.js", "invalid"]);
} catch (error: any) {
expect(error.exitCode).toBe(1);
expect(error.stderr).toContain("Unknown command");
}
});
it("should greet user", async () => {
const result = await execa("node", ["./dist/cli.js", "greet", "Alice"]);
expect(result.stdout).toBe("Hello, Alice!");
});
});
Testing oclif Commands
import { expect, test } from "@oclif/test";
describe("greet", () => {
test
.stdout()
.command(["greet", "Alice"])
.it("greets Alice", (ctx) => {
expect(ctx.stdout).toContain("Hello, Alice!");
});
test
.stdout()
.command(["greet", "Alice", "--loud"])
.it("greets Alice loudly", (ctx) => {
expect(ctx.stdout).toContain("HELLO ALICE!");
});
});
Testing Ink Components
import { describe, it, expect } from "vitest";
import { render } from "ink-testing-library";
import React from "react";
import { App } from "./App.js";
describe("App", () => {
it("should render counter", () => {
const { lastFrame } = render(<App />);
expect(lastFrame()).toContain("Counter: 0");
});
it("should increment on up arrow", () => {
const { lastFrame, stdin } = render(<App />);
stdin.write("\u001B[A"); // up arrow
expect(lastFrame()).toContain("Counter: 1");
});
});
Packaging CLIs
npm bin (Standard)
The simplest distribution method. Add a "bin" field to package.json:
{
"name": "my-cli",
"bin": {
"my-cli": "./dist/cli.js"
},
"files": ["dist"]
}
Ensure the entry file has a shebang:
#!/usr/bin/env node
// dist/cli.js
import { program } from "./program.js";
program.parse();
Users install globally: npm install -g my-cli.
Bun Compile (Single Binary)
# Compile to a standalone executable bun build ./src/cli.ts --compile --outfile my-cli # Cross-compile for other platforms bun build ./src/cli.ts --compile --target=bun-linux-x64 --outfile my-cli-linux bun build ./src/cli.ts --compile --target=bun-darwin-arm64 --outfile my-cli-macos bun build ./src/cli.ts --compile --target=bun-windows-x64 --outfile my-cli.exe
pkg (Node.js to Binary)
npm install -D @yao-pkg/pkg # Compile for all platforms npx pkg dist/cli.js --targets node18-linux-x64,node18-macos-arm64,node18-win-x64
nexe (Node.js to Binary)
npm install -g nexe nexe dist/cli.js -o my-cli --target linux-x64-18.0.0
Docker Distribution
FROM node:20-slim WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --production COPY dist/ ./dist/ ENTRYPOINT ["node", "dist/cli.js"]
docker build -t my-cli . docker run --rm my-cli greet Alice
Best Practices
- •
Always provide
--helpand--versionflags. Users expect them and they cost nothing to implement. - •
Use exit codes correctly. Return
0for success,1for general errors, and2for usage errors. Neverprocess.exit(0)on failure. - •
Support
--jsonoutput for machine-readable output alongside human-friendly defaults. - •
Handle SIGINT gracefully. Clean up temporary files and in-progress operations when the user presses Ctrl+C.
- •
Use stderr for errors and diagnostics, stdout for data. This allows piping output without mixing in error messages:
my-cli list 2>/dev/null | jq .. - •
Respect
NO_COLORenvironment variable. Checkprocess.env.NO_COLORand disable colors when set (chalk does this automatically). - •
Validate input early. Fail fast with clear error messages rather than crashing deep in business logic.
- •
Provide shell completion. Use yargs
completion()or oclif's@oclif/plugin-autocompleteto generate completion scripts. - •
Test your CLI as a black box. Use
execaor similar to invoke the compiled binary and assert on stdout, stderr, and exit codes. - •
Use
commanderfor simple CLIs,yargsfor complex option parsing, andocliffor large extensible tools. Match the framework to the complexity of your project.