Error Handling Patterns Every CLI Tool Needs
Error handling in CLI tools is fundamentally different from error handling in web applications. There's no retry button. No error boundary component. No loading spinner to buy time. When your CLI fails, the user sees a stack trace, a cryptic message, or nothing at all — and they lose trust in your tool.
Good error handling in CLI tools means three things: the user understands what went wrong, they know how to fix it, and your CI pipeline gets the right exit code. Let's build all three.
The Three Audiences for Errors
Every CLI error serves three audiences simultaneously:
- The human in a terminal — needs a clear, actionable message
- The CI pipeline — needs a non-zero exit code
-
The developer debugging — needs details when
--verboseis on
class CliError extends Error {
constructor(message, { code = 1, suggestion, details } = {}) {
super(message);
this.code = code;
this.suggestion = suggestion;
this.details = details;
}
}
function handleError(error, options = {}) {
if (error instanceof CliError) {
// Human-readable message
console.error(chalk.red(`Error: ${error.message}`));
// Actionable suggestion
if (error.suggestion) {
console.error(chalk.yellow(` Suggestion: ${error.suggestion}`));
}
// Verbose details for debugging
if (options.verbose && error.details) {
console.error(chalk.gray(`\n Details: ${error.details}`));
}
process.exit(error.code);
}
// Unexpected errors: show stack trace
console.error(chalk.red('Unexpected error:'));
console.error(error.stack);
process.exit(99);
}
Pattern 1: Validate Before You Execute
Don't let your tool get halfway through a task before discovering the input was bad:
function validateInputs(options) {
const errors = [];
if (!options.url) {
errors.push(new CliError('URL is required', {
suggestion: 'Usage: mytool audit <url>'
}));
} else if (!isValidUrl(options.url)) {
errors.push(new CliError(`Invalid URL: "${options.url}"`, {
suggestion: 'URLs must start with http:// or https://'
}));
}
if (options.threshold !== undefined) {
if (options.threshold < 0 || options.threshold > 100) {
errors.push(new CliError(`Threshold must be 0-100, got ${options.threshold}`, {
suggestion: 'Example: --threshold 85'
}));
}
}
if (options.output) {
const dir = path.dirname(options.output);
if (!fs.existsSync(dir)) {
errors.push(new CliError(`Output directory doesn't exist: ${dir}`, {
suggestion: `Run: mkdir -p "${dir}"`
}));
}
}
if (errors.length > 0) {
errors.forEach(e => console.error(chalk.red(` ✗ ${e.message}`)));
if (errors[0].suggestion) {
console.error(chalk.yellow(`\n ${errors[0].suggestion}`));
}
process.exit(2);
}
}
Pattern 2: Network Error Recovery
Network errors are the most common failure mode for CLI tools that talk to APIs:
async function fetchWithRetry(url, { retries = 3, timeout = 10000 } = {}) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!response.ok) {
throw new CliError(`HTTP ${response.status}: ${response.statusText}`, {
code: 3,
suggestion: response.status === 404
? 'Check that the URL is correct'
: response.status === 429
? 'Rate limited — try again in a few minutes'
: response.status >= 500
? 'Server error — try again later'
: 'Check your authentication and permissions',
details: `URL: ${url}, Status: ${response.status}`
});
}
return response;
} catch (error) {
if (error instanceof CliError) throw error;
if (error.name === 'AbortError') {
if (attempt === retries) {
throw new CliError(`Request timed out after ${timeout}ms`, {
code: 4,
suggestion: 'Check your network connection or increase timeout with --timeout',
details: `URL: ${url}, Attempts: ${retries}`
});
}
// Retry with exponential backoff
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
process.stderr.write(chalk.gray(` Timeout, retrying in ${delay}ms...\n`));
await new Promise(r => setTimeout(r, delay));
continue;
}
if (error.code === 'ENOTFOUND') {
throw new CliError(`Cannot resolve hostname: ${new URL(url).hostname}`, {
code: 3,
suggestion: 'Check your internet connection and DNS settings'
});
}
throw error;
}
}
}
Pattern 3: File System Errors
async function readInputFile(filePath) {
try {
const content = await readFile(filePath, 'utf-8');
return content;
} catch (error) {
if (error.code === 'ENOENT') {
throw new CliError(`File not found: ${filePath}`, {
code: 2,
suggestion: `Check the path and try again. Current directory: ${process.cwd()}`
});
}
if (error.code === 'EACCES') {
throw new CliError(`Permission denied: ${filePath}`, {
code: 2,
suggestion: `Check file permissions: ls -la "${filePath}"`
});
}
if (error.code === 'EISDIR') {
throw new CliError(`Expected a file, got a directory: ${filePath}`, {
code: 2,
suggestion: 'Provide a file path, not a directory'
});
}
throw error;
}
}
Pattern 4: Graceful Shutdown
Handle SIGINT (Ctrl+C) and SIGTERM properly:
let isShuttingDown = false;
function setupGracefulShutdown(cleanup) {
const shutdown = async (signal) => {
if (isShuttingDown) return;
isShuttingDown = true;
console.error(chalk.gray(`\n Received ${signal}, cleaning up...`));
try {
await cleanup();
} catch (error) {
console.error(chalk.red(` Cleanup failed: ${error.message}`));
}
process.exit(signal === 'SIGINT' ? 130 : 143);
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
// Usage
setupGracefulShutdown(async () => {
await chrome?.kill(); // Kill headless Chrome
await db?.close(); // Close database connections
tempDir && await rm(tempDir, { recursive: true }); // Clean temp files
});
Pattern 5: The --verbose Flag
Give users a debugging escape hatch:
let verboseMode = false;
export function setVerbose(enabled) {
verboseMode = enabled;
}
export function debug(message) {
if (verboseMode) {
process.stderr.write(chalk.gray(` [debug] ${message}\n`));
}
}
// Usage throughout your code
debug(`Fetching ${url}`);
debug(`Response: ${response.status} in ${elapsed}ms`);
debug(`Parsed ${items.length} items`);
debug(`Writing output to ${outputPath}`);
Pattern 6: Catch-All Error Boundary
Wrap your entire CLI in a top-level error handler:
#!/usr/bin/env node
async function main() {
// All your CLI logic here
program.parse();
}
main().catch((error) => {
handleError(error, {
verbose: process.argv.includes('--verbose') || process.argv.includes('-v')
});
});
// Also catch unhandled rejections
process.on('unhandledRejection', (error) => {
console.error(chalk.red('Unhandled error:'));
console.error(error);
process.exit(99);
});
Error Message Checklist
Good CLI error messages:
- [ ] Say what went wrong — not just "Error" or a stack trace
- [ ] Say why — "File not found" is better than "ENOENT"
- [ ] Say how to fix it — include the command or action needed
- [ ] Use stderr — never pollute stdout with error messages
- [ ] Use color sparingly — red for errors, yellow for suggestions
- [ ] Include context — which file, which URL, which flag
- [ ] Respect
--quiet— suppress non-essential error details
Conclusion
Error handling isn't the exciting part of building CLI tools. But it's the difference between a tool developers trust and one they abandon after the first confusing failure. Every error is an opportunity to help your user succeed — or to lose them forever.
Wilson Xu publishes developer CLI tools on npm and writes about developer experience at dev.to/chengyixu.
Top comments (0)