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
$ 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