| name | cli-author |
| description | Write Node.js CLI tools with zero dependencies. Use when creating command-line tools, argument parsing, colored output, or interactive prompts. |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
CLI Authoring Skill
Write professional Node.js command-line tools using zero-dependency patterns.
Core Principles
| Principle | Description |
|---|---|
| Zero Dependencies | Use Node.js built-ins only (util.parseArgs, readline, fs) |
| Fail Fast | Validate arguments early, exit with clear errors |
| Exit Codes | 0 = success, 1 = user error, 2 = system error |
| Respect Environment | NO_COLOR, TERM, CI detection |
| Unix Philosophy | Single purpose, composable with pipes |
Argument Parsing with util.parseArgs
Node.js 18+ includes native argument parsing:
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
output: { type: 'string', short: 'o' },
verbose: { type: 'boolean', short: 'v' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
});
// values.output, values.verbose, values.help
// positionals = ['file1.js', 'file2.js']
Option Types
| Type | Example | Notes |
|---|---|---|
boolean |
--verbose, -v |
Flag, no value needed |
string |
--output file.txt, -o file.txt |
Requires value |
string (multiple) |
--tag a --tag b |
Use multiple: true |
Error Handling
try {
const { values } = parseArgs({ args, options, strict: true });
} catch (error) {
if (error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
console.error(`Unknown option: ${error.message}`);
printHelp();
process.exit(1);
}
throw error;
}
CLI Structure Patterns
Simple (Single Action)
#!/usr/bin/env node
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({...});
if (values.help) {
printHelp();
process.exit(0);
}
if (positionals.length === 0) {
console.error('Error: No files specified');
process.exit(1);
}
await processFiles(positionals, values);
Multi-Command (Git-style)
#!/usr/bin/env node
const [command, ...rest] = process.argv.slice(2);
const commands = {
init: () => import('./commands/init.js'),
build: () => import('./commands/build.js'),
help: () => import('./commands/help.js'),
};
if (!command || command === 'help' || command === '--help') {
showHelp(commands);
process.exit(0);
}
if (!commands[command]) {
console.error(`Unknown command: ${command}`);
console.error(`Run 'toolname help' for available commands`);
process.exit(1);
}
const { run } = await commands[command]();
await run(rest);
Interactive (Wizard)
#!/usr/bin/env node
import { createInterface } from 'node:readline';
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
const question = (q) => new Promise((resolve) => rl.question(q, resolve));
async function wizard() {
const name = await question('Project name: ');
const type = await question('Type (lib/app): ');
rl.close();
return { name, type };
}
const config = await wizard();
await scaffold(config);
Help Text Convention
Usage: toolname [options] <command> [arguments]
Commands:
init Initialize a new project
build Build the project
help Show this help message
Options:
-o, --output <path> Output directory
-v, --verbose Enable verbose output
-h, --help Show help
--version Show version
Examples:
toolname init my-project
toolname build --output dist/
Colored Output
// ANSI color codes (respect NO_COLOR)
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
const colors = {
red: useColor ? '\x1b[31m' : '',
green: useColor ? '\x1b[32m' : '',
yellow: useColor ? '\x1b[33m' : '',
blue: useColor ? '\x1b[34m' : '',
dim: useColor ? '\x1b[2m' : '',
reset: useColor ? '\x1b[0m' : '',
};
// Status indicators
const success = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
const error = (msg) => console.error(`${colors.red}✗${colors.reset} ${msg}`);
const warn = (msg) => console.warn(`${colors.yellow}⚠${colors.reset} ${msg}`);
Exit Codes
| Code | Meaning | When to Use |
|---|---|---|
| 0 | Success | Normal completion |
| 1 | User Error | Invalid args, file not found, validation failed |
| 2 | System Error | Unexpected exception, crash |
| 130 | SIGINT | Ctrl+C (convention) |
process.on('uncaughtException', (error) => {
console.error('Unexpected error:', error.message);
process.exit(2);
});
process.on('SIGINT', () => {
console.log('\nCancelled');
process.exit(130);
});
Config File Loading
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
function findConfig(startDir, filename) {
let dir = resolve(startDir);
while (dir !== dirname(dir)) {
const configPath = resolve(dir, filename);
if (existsSync(configPath)) {
return JSON.parse(readFileSync(configPath, 'utf-8'));
}
dir = dirname(dir);
}
return null;
}
// Load from .toolrc or package.json
const config = findConfig(process.cwd(), '.mytoolrc')
|| loadPackageJsonConfig('mytool')
|| {};
Progress Indicators
Spinner (Simple)
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let i = 0;
const spinner = setInterval(() => {
process.stdout.write(`\r${frames[i++ % frames.length]} Processing...`);
}, 80);
await longOperation();
clearInterval(spinner);
process.stdout.write('\r✓ Done\n');
Progress Bar
function progressBar(current, total, width = 30) {
const percent = current / total;
const filled = Math.round(width * percent);
const empty = width - filled;
const bar = '█'.repeat(filled) + '░'.repeat(empty);
return `[${bar}] ${Math.round(percent * 100)}%`;
}
// Usage
for (let i = 0; i <= 100; i++) {
process.stdout.write(`\r${progressBar(i, 100)}`);
await delay(50);
}
console.log();
Testing CLI Tools
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { execSync } from 'node:child_process';
describe('mytool CLI', () => {
it('should show help with --help', () => {
const output = execSync('node bin/mytool.js --help', {
encoding: 'utf-8',
});
assert.match(output, /Usage:/);
});
it('should exit 1 on invalid args', () => {
try {
execSync('node bin/mytool.js --invalid', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
assert.fail('Should have exited with error');
} catch (error) {
assert.strictEqual(error.status, 1);
}
});
it('should process files correctly', () => {
const output = execSync('node bin/mytool.js test.txt', {
encoding: 'utf-8',
});
assert.match(output, /Processed/);
});
});
Stdin/Stdout Piping
import { createInterface } from 'node:readline';
// Read from stdin if no files provided
if (positionals.length === 0 && !process.stdin.isTTY) {
const rl = createInterface({ input: process.stdin });
for await (const line of rl) {
process.stdout.write(processLine(line) + '\n');
}
} else if (positionals.length === 0) {
console.error('Error: No input. Pipe data or provide files.');
process.exit(1);
}
Checklist
When writing CLI tools:
Structure
- Shebang line:
#!/usr/bin/env node - Entry point in
bin/directory - Main logic in
src/(importable as library) - JSDoc documentation for main functions
Arguments
-
--helpand-hshow usage -
--versionshows version from package.json - Unknown options produce helpful error
- Required arguments validated early
Output
- Respect NO_COLOR environment variable
- Use stderr for errors, stdout for results
- Exit 0 on success, 1 on user error, 2 on system error
- Support
--quietor--jsonfor scripting
Robustness
- Handle SIGINT (Ctrl+C) gracefully
- Catch uncaught exceptions
- Validate file paths before use
- Work correctly when piped
Related Skills
- javascript-author - JavaScript coding patterns
- unit-testing - Testing with Node.js test runner
- error-handling - Error management patterns
- nodejs-backend - Server-side Node.js patterns