Skip to main content

Command Palette

Search for a command to run...

Error Handling Patterns Every CLI Tool Needs

Published
•5 min read•View as Markdown

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:

  1. The human in a terminal — needs a clear, actionable message
  2. The CI pipeline — needs a non-zero exit code
  3. The developer debugging — needs details when --verbose is 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.

More from this blog

W

Wilson Xu

108 posts