CLI Tool Development
Project Structure
src/
index.ts # Entry point with shebang
cli.ts # Commander setup
commands/ # Command handlers
ui/ # Ink components (if interactive)
utils/ # Helpers
types.ts # Type definitions
Commander.js Setup
#!/usr/bin/env node
import { Command } from 'commander';
import packageJson from '../package.json' with { type: 'json' };
const program = new Command();
program
.name('mytool')
.description('My awesome CLI tool')
.version(packageJson.version);
program
.command('init')
.description('Initialize a new project')
.option('-t, --template <name>', 'Template to use', 'default')
.option('-f, --force', 'Overwrite existing files', false)
.action(async (options) => {
await initCommand(options);
});
program.parseAsync();
User Feedback with Chalk & Ora
import chalk from 'chalk';
import ora from 'ora';
// Status messages
console.log(chalk.green('✓') + ' Operation complete');
console.log(chalk.red('✗') + ' Operation failed');
console.log(chalk.yellow('⚠') + ' Warning message');
// Progress spinner
const spinner = ora('Loading...').start();
try {
await longOperation();
spinner.succeed('Done!');
} catch (error) {
spinner.fail('Failed');
}
Interactive Prompts with Enquirer
import enquirer from 'enquirer';
const { name } = await enquirer.prompt<{ name: string }>({
type: 'input',
name: 'name',
message: 'Project name:',
validate: (v) => v.length > 0 || 'Name required',
});
const { confirm } = await enquirer.prompt<{ confirm: boolean }>({
type: 'confirm',
name: 'confirm',
message: 'Continue?',
initial: true,
});
Ink for Rich TUI
import React, { useState } from 'react';
import { render, Box, Text, useInput } from 'ink';
function App() {
const [selected, setSelected] = useState(0);
const items = ['Option 1', 'Option 2', 'Option 3'];
useInput((input, key) => {
if (key.downArrow) setSelected(s => Math.min(s + 1, items.length - 1));
if (key.upArrow) setSelected(s => Math.max(s - 1, 0));
if (key.return) process.exit(0);
});
return (
<Box flexDirection="column">
{items.map((item, i) => (
<Text key={i} color={i === selected ? 'cyan' : undefined}>
{i === selected ? '>' : ' '} {item}
</Text>
))}
</Box>
);
}
render(<App />);
Configuration Management
import fs from 'fs-extra';
import path from 'node:path';
import os from 'node:os';
const CONFIG_DIR = path.join(os.homedir(), '.mytool');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
async function loadConfig(): Promise<Config> {
await fs.ensureDir(CONFIG_DIR);
if (await fs.pathExists(CONFIG_FILE)) {
return fs.readJson(CONFIG_FILE);
}
return getDefaultConfig();
}
async function saveConfig(config: Config): Promise<void> {
await fs.writeJson(CONFIG_FILE, config, { spaces: 2 });
}
Error Handling
// Graceful exit handling
process.on('SIGINT', () => {
console.log('\nCancelled');
process.exit(0);
});
// Top-level error handler
try {
await program.parseAsync();
} catch (error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
Package.json Setup
{
"bin": {
"mytool": "./dist/index.js"
},
"files": ["dist"],
"type": "module",
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsx src/index.ts"
}
}
Best Practices
- Add
#!/usr/bin/env node shebang to entry file
- Support both flags (
-f) and options (--force)
- Provide helpful error messages with suggestions
- Support
--help and --version flags
- Use exit codes: 0 for success, 1 for error
- Support piping and stdin when appropriate
- Respect
NO_COLOR environment variable