What Makes a Good CLI?
Most developer tools are CLIs. git, docker, npm, gh—all of them. A well-built CLI is invisible: it does what you expect, fails clearly, and stays out of the way.
Here's how to build one that meets that bar.
Setup with TypeScript
mkdir my-cli && cd my-cli
npm init -y
npm install commander chalk ora execa
npm install -D typescript @types/node ts-node tsup
// package.json additions
{
"bin": {
"mycli": "./dist/index.js"
},
"scripts": {
"build": "tsup src/index.ts --format cjs --dts",
"dev": "ts-node src/index.ts"
}
}
Commander: Argument Parsing
#!/usr/bin/env node
import { Command } from 'commander';
import { deploy } from './commands/deploy';
import { init } from './commands/init';
const program = new Command();
program
.name('mycli')
.description('Deploy and manage your applications')
.version('1.0.0');
program
.command('init')
.description('Initialize a new project')
.option('-t, --template <template>', 'project template', 'default')
.option('--no-git', 'skip git initialization')
.action(init);
program
.command('deploy')
.description('Deploy to production')
.argument('<environment>', 'target environment (staging|production)')
.option('-f, --force', 'skip confirmation prompts')
.option('--dry-run', 'preview what would be deployed')
.action(deploy);
program.parseAsync(process.argv);
Output That Doesn't Suck
import chalk from 'chalk';
export const log = {
info: (msg: string) => console.log(chalk.blue('ℹ'), msg),
success: (msg: string) => console.log(chalk.green('✓'), msg),
warning: (msg: string) => console.log(chalk.yellow('⚠'), msg),
error: (msg: string) => console.error(chalk.red('✗'), msg),
// Structured output for scripts
json: (data: unknown) => {
if (process.env.CI || !process.stdout.isTTY) {
console.log(JSON.stringify(data));
}
},
};
Spinners for Long Operations
import ora from 'ora';
export async function deploy(environment: string, options: { force: boolean }) {
const spinner = ora('Connecting to deployment service...').start();
try {
spinner.text = 'Building application...';
await buildApp();
spinner.text = `Deploying to ${environment}...`;
const result = await deployToEnvironment(environment);
spinner.succeed(`Deployed successfully → ${result.url}`);
} catch (error) {
spinner.fail(`Deployment failed: ${error.message}`);
process.exit(1);
}
}
Interactive Prompts
import { input, confirm, select } from '@inquirer/prompts';
export async function init(options: { template: string; git: boolean }) {
const projectName = await input({
message: 'Project name:',
default: 'my-project',
validate: (value) => {
if (!value.match(/^[a-z0-9-]+$/)) return 'Use lowercase letters, numbers, and hyphens only';
return true;
},
});
const template = await select({
message: 'Select template:',
choices: [
{ name: 'SaaS Starter (Next.js + Stripe + Auth)', value: 'saas' },
{ name: 'API Server (Express + Prisma)', value: 'api' },
{ name: 'CLI Tool (Commander + TypeScript)', value: 'cli' },
],
});
if (!options.force) {
const confirmed = await confirm({
message: `Create ${projectName} with ${template} template?`,
});
if (!confirmed) process.exit(0);
}
// ... create project
}
Running Shell Commands
import { execa } from 'execa';
async function buildApp() {
const { stdout, stderr, exitCode } = await execa('npm', ['run', 'build'], {
cwd: process.cwd(),
stderr: 'pipe',
stdout: 'pipe',
});
if (exitCode !== 0) {
throw new Error(`Build failed:\n${stderr}`);
}
return stdout;
}
// Stream output in real-time
async function streamBuild() {
const proc = execa('npm', ['run', 'build']);
proc.stdout?.pipe(process.stdout);
proc.stderr?.pipe(process.stderr);
await proc;
}
Config Files
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
interface Config {
apiUrl: string;
token?: string;
defaultEnvironment: string;
}
const CONFIG_PATH = join(process.env.HOME!, '.myclirc');
export function readConfig(): Partial<Config> {
if (!existsSync(CONFIG_PATH)) return {};
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
}
export function writeConfig(config: Partial<Config>) {
const existing = readConfig();
writeFileSync(CONFIG_PATH, JSON.stringify({ ...existing, ...config }, null, 2));
}
// Usage
mycli config set token sk-abc123
mycli deploy production // uses token from config
Error Handling
// Top-level error handler
process.on('unhandledRejection', (error: Error) => {
log.error(error.message);
if (process.env.DEBUG) console.error(error.stack);
process.exit(1);
});
// User-friendly errors
class CLIError extends Error {
constructor(message: string, public exitCode = 1) {
super(message);
}
}
throw new CLIError('Authentication failed. Run `mycli login` first.');
Publishing to npm
# Build
npm run build
# Test locally
npm link
mycli --version
# Publish
npm publish --access public
# Users install with:
npm install -g mycli
The difference between a CLI people use and one they ignore is usually three things: fast startup, clear errors, and good help text. Nail those and you're 80% of the way there.
Need a complete CLI + SaaS backend? The Whoff Agents Ship Fast Skill Pack includes CLI patterns, API templates, and deployment scripts.
Top comments (0)