Tools: Building a CLI Tool in Node.js That People Actually Want to Use

Tools: Building a CLI Tool in Node.js That People Actually Want to Use

What Makes a Good CLI?

Setup with TypeScript

Commander: Argument Parsing

Output That Doesn't Suck

Spinners for Long Operations

Interactive Prompts

Running Shell Commands

Config Files

Error Handling

Publishing to npm 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ mkdir my-cli && cd my-cli -weight: 500;">npm init -y -weight: 500;">npm -weight: 500;">install commander chalk ora execa -weight: 500;">npm -weight: 500;">install -D typescript @types/node ts-node tsup mkdir my-cli && cd my-cli -weight: 500;">npm init -y -weight: 500;">npm -weight: 500;">install commander chalk ora execa -weight: 500;">npm -weight: 500;">install -D typescript @types/node ts-node tsup mkdir my-cli && cd my-cli -weight: 500;">npm init -y -weight: 500;">npm -weight: 500;">install commander chalk ora execa -weight: 500;">npm -weight: 500;">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" } } // package.json additions { "bin": { "mycli": "./dist/index.js" }, "scripts": { "build": "tsup src/index.ts --format cjs --dts", "dev": "ts-node src/index.ts" } } // package.json additions { "bin": { "mycli": "./dist/index.js" }, "scripts": { "build": "tsup src/index.ts --format cjs --dts", "dev": "ts-node src/index.ts" } } #!/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--weight: 500;">git', 'skip -weight: 500;">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); #!/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--weight: 500;">git', 'skip -weight: 500;">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); #!/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--weight: 500;">git', 'skip -weight: 500;">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); 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)); } }, }; 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)); } }, }; 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)); } }, }; import ora from 'ora'; export async function deploy(environment: string, options: { force: boolean }) { const spinner = ora('Connecting to deployment -weight: 500;">service...').-weight: 500;">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); } } import ora from 'ora'; export async function deploy(environment: string, options: { force: boolean }) { const spinner = ora('Connecting to deployment -weight: 500;">service...').-weight: 500;">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); } } import ora from 'ora'; export async function deploy(environment: string, options: { force: boolean }) { const spinner = ora('Connecting to deployment -weight: 500;">service...').-weight: 500;">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); } } import { input, confirm, select } from '@inquirer/prompts'; export async function init(options: { template: string; -weight: 500;">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 } import { input, confirm, select } from '@inquirer/prompts'; export async function init(options: { template: string; -weight: 500;">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 } import { input, confirm, select } from '@inquirer/prompts'; export async function init(options: { template: string; -weight: 500;">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 } import { execa } from 'execa'; async function buildApp() { const { stdout, stderr, exitCode } = await execa('-weight: 500;">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('-weight: 500;">npm', ['run', 'build']); proc.stdout?.pipe(process.stdout); proc.stderr?.pipe(process.stderr); await proc; } import { execa } from 'execa'; async function buildApp() { const { stdout, stderr, exitCode } = await execa('-weight: 500;">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('-weight: 500;">npm', ['run', 'build']); proc.stdout?.pipe(process.stdout); proc.stderr?.pipe(process.stderr); await proc; } import { execa } from 'execa'; async function buildApp() { const { stdout, stderr, exitCode } = await execa('-weight: 500;">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('-weight: 500;">npm', ['run', 'build']); proc.stdout?.pipe(process.stdout); proc.stderr?.pipe(process.stderr); await proc; } 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 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 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 // 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.'); // 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.'); // 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.'); # Build -weight: 500;">npm run build # Test locally -weight: 500;">npm link mycli --version # Publish -weight: 500;">npm publish --access public # Users -weight: 500;">install with: -weight: 500;">npm -weight: 500;">install -g mycli # Build -weight: 500;">npm run build # Test locally -weight: 500;">npm link mycli --version # Publish -weight: 500;">npm publish --access public # Users -weight: 500;">install with: -weight: 500;">npm -weight: 500;">install -g mycli # Build -weight: 500;">npm run build # Test locally -weight: 500;">npm link mycli --version # Publish -weight: 500;">npm publish --access public # Users -weight: 500;">install with: -weight: 500;">npm -weight: 500;">install -g mycli