
Building a CLI Tool That People Actually Enjoy Using
I've built maybe a dozen internal CLI tools over the past few years. Database seeders, deployment scripts, code generators, data migration utilities. The ones people actually use have something in common: they're pleasant to run. The ones that get ignored in favor of manual processes don't have that.
"Pleasant to run" sounds vague, but it breaks down into concrete decisions. Here's what I've learned.
The Setup
bashmkdir my-cli && cd my-cli npm init -y npm install commander chalk ora inquirer npm install -D @types/node typescript tsx
json// package.json additions { "bin": { "mycli": "./bin/cli.ts" }, "scripts": { "dev": "tsx bin/cli.ts", "build": "tsc", "start": "node dist/bin/cli.js" } }
typescript// bin/cli.ts #!/usr/bin/env tsx import { program } from 'commander' import { version } from '../package.json' program .name('mycli') .description('My internal tooling CLI') .version(version) // Commands are registered in separate files import './commands/db' import './commands/generate' import './commands/deploy' program.parse()
Commander gives you argument parsing, subcommands, help generation, and --version. It handles the boilerplate so you can focus on what the command actually does.
Command Structure
Each command in its own file, registered on the program:
typescript// commands/db.ts import { program } from 'commander' import chalk from 'chalk' import ora from 'ora' const db = program.command('db').description('Database utilities') db.command('seed') .description('Seed the database with development data') .option('--reset', 'Drop all tables before seeding', false) .option('--count <number>', 'Number of records to seed', '100') .action(async (options) => { const count = parseInt(options.count, 10) if (options.reset) { const confirmed = await confirm('This will drop all data. Are you sure?') if (!confirmed) { console.log(chalk.yellow('Aborted.')) process.exit(0) } } const spinner = ora('Seeding database...').start() try { await seedDatabase({ reset: options.reset, count }) spinner.succeed(chalk.green(`Seeded ${count} records successfully`)) } catch (err) { spinner.fail(chalk.red('Seeding failed')) console.error(err instanceof Error ? err.message : err) process.exit(1) } }) db.command('migrate') .description('Run pending database migrations') .option('--dry-run', 'Show pending migrations without running them') .action(async (options) => { // ... })
Output That Tells You What's Happening
The thing that makes CLIs feel broken: running a command and staring at a blank terminal for 30 seconds not knowing if anything is happening.
ora gives you spinners. Use them for anything that takes more than half a second:
typescriptimport ora from 'ora' const spinner = ora('Connecting to database').start() try { await connectToDb() spinner.succeed('Connected') spinner.start('Running migrations') const count = await runMigrations() spinner.succeed(`Applied ${count} migrations`) spinner.start('Seeding data') await seedData() spinner.succeed('Data seeded') } catch (err) { spinner.fail(`Failed: ${err instanceof Error ? err.message : 'Unknown error'}`) process.exit(1) }
The pattern: start the spinner with what you're about to do. On success, succeed with what happened. On failure, fail with why.
For multi-step processes, use a progress bar:
typescriptimport { SingleBar, Presets } from 'cli-progress' async function processItems(items: Item[]) { const bar = new SingleBar( { format: 'Processing |{bar}| {percentage}% | {value}/{total} items | ETA: {eta}s', clearOnComplete: false, }, Presets.shades_classic ) bar.start(items.length, 0) for (const item of items) { await processItem(item) bar.increment() } bar.stop() console.log(chalk.green(`\n✓ Processed ${items.length} items`)) }
Colors That Mean Something
The convention that users already understand from other CLIs:
typescriptimport chalk from 'chalk' // Success console.log(chalk.green('✓ Migration applied')) // Warning (something to note, but not a failure) console.log(chalk.yellow('⚠ No pending migrations found')) // Error console.log(chalk.red('✗ Connection failed')) // Dimmed info (secondary information, less important) console.log(chalk.dim(' Using config from: .env.local')) // Highlighted data console.log(`Running as: ${chalk.cyan(username)}`) console.log(`Target: ${chalk.bold(environment)}`)
Don't use colors for decoration. Use them to communicate: green means good, yellow means note, red means bad. This is already what users expect.
Interactive Prompts
For destructive operations or operations that need context, inquirer handles interactive input:
typescriptimport inquirer from 'inquirer' async function confirm(message: string): Promise<boolean> { const { confirmed } = await inquirer.prompt([ { type: 'confirm', name: 'confirmed', message, default: false, // Default to No for destructive operations }, ]) return confirmed } async function selectEnvironment(): Promise<string> { const { environment } = await inquirer.prompt([ { type: 'list', name: 'environment', message: 'Select target environment:', choices: [ { name: 'Development', value: 'dev' }, { name: 'Staging', value: 'staging' }, { name: chalk.red('Production'), value: 'production' }, ], }, ]) return environment } async function promptForSecrets() { return inquirer.prompt([ { type: 'password', name: 'apiKey', message: 'Enter your API key:', validate: (input) => input.length > 0 || 'API key is required', }, ]) }
The important UX detail: default destructive prompts to No. The user should have to explicitly choose the dangerous option.
Error Messages That Actually Help
The difference between a good error message and a bad one is whether the user knows what to do next.
Bad:
Error: ENOENT: no such file or directory, open 'config.json'
Good:
✗ Config file not found
Expected config at: /home/user/project/config.json
Run "mycli init" to generate a default config, or create
config.json manually. See the docs at: https://docs.example.com/config
typescriptclass ConfigNotFoundError extends Error { constructor(configPath: string) { super() this.name = 'ConfigNotFoundError' this.message = [ chalk.red('✗ Config file not found'), '', ` Expected config at: ${chalk.dim(configPath)}`, '', ` Run ${chalk.cyan('mycli init')} to generate a default config, or create`, ` ${chalk.cyan('config.json')} manually.`, ].join('\n') } } // In your error handler process.on('uncaughtException', (err) => { if (err instanceof ConfigNotFoundError) { console.error(err.message) } else { console.error(chalk.red('An unexpected error occurred:')) console.error(chalk.dim(err.message)) if (process.env.DEBUG) console.error(err.stack) } process.exit(1) })
The DEBUG flag pattern is worth establishing: normally show clean error messages, but if the user sets DEBUG=1, show the full stack trace. Power users can get the details they need without cluttering normal output.
Config Files Done Right
Most CLIs need configuration. The convention that works:
typescript// lib/config.ts import { cosmiconfig } from 'cosmiconfig' import { z } from 'zod' const ConfigSchema = z.object({ database: z.object({ url: z.string().url(), }), api: z.object({ baseUrl: z.string().url().default('https://api.example.com'), timeout: z.number().default(30000), }), }) type Config = z.infer<typeof ConfigSchema> export async function loadConfig(): Promise<Config> { const explorer = cosmiconfig('mycli') const result = await explorer.search() if (!result) { throw new ConfigNotFoundError(process.cwd() + '/mycli.config.js') } const parsed = ConfigSchema.safeParse(result.config) if (!parsed.success) { const issues = parsed.error.issues .map((i) => ` ${i.path.join('.')}: ${i.message}`) .join('\n') throw new Error(`Invalid config:\n${issues}`) } return parsed.data }
cosmiconfig searches for mycli.config.js, mycli.config.json, .myclirc, etc. — following the convention that every popular CLI uses. Users know where to put config.
Making It Installable
json// package.json { "bin": { "mycli": "./dist/bin/cli.js" }, "files": [ "dist/" ] }
bash# Local development npm link # Install globally (when published) npm install -g mycli # Or without publishing: install directly from the repo npm install -g .
The test: does mycli --help show clean, useful output? Does mycli <command> --help show the specific options for that command? Commander generates this for you if you've written good .description() calls on your commands and options.
That's the bar. If someone can figure out what to do from the help output alone, the CLI is good.