πŸ“ Tutorials
Β· 3 min read

Build a CLI That Explains Error Messages With AI


You see ECONNREFUSED 127.0.0.1:5432. You know it means PostgreSQL isn’t running. But your junior developer doesn’t. And neither does the you from 3 years ago who spent 45 minutes googling it.

In this tutorial, we’ll build wtf β€” a CLI tool that explains any error message in plain English and suggests a fix. Pipe errors directly from your terminal.

What we’re building

$ wtf "ECONNREFUSED 127.0.0.1:5432"

πŸ” Error: Connection Refused (ECONNREFUSED)

Your app tried to connect to PostgreSQL on localhost:5432, 
but nothing is listening on that port.

Likely causes:
1. PostgreSQL isn't running
2. It's running on a different port
3. A firewall is blocking the connection

Fix:
$ sudo systemctl start postgresql    # Linux
$ brew services start postgresql     # macOS
$ docker start my-postgres           # Docker

Verify: $ pg_isready -h localhost -p 5432

Or pipe errors directly:

$ npm run build 2>&1 | wtf
$ python app.py 2>&1 | wtf
$ docker compose up 2>&1 | tail -5 | wtf

Prerequisites

  • Node.js 20+
  • An Anthropic or OpenAI API key

Step 1: Set up the project

mkdir wtf-cli && cd wtf-cli
npm init -y
npm install @anthropic-ai/sdk

Add to package.json:

{
  "bin": { "wtf": "./index.js" },
  "type": "module"
}

Step 2: Build the CLI

#!/usr/bin/env node
// index.js
import Anthropic from '@anthropic-ai/sdk';
import { readFileSync, existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';

// Load API key from ~/.wtf-config or environment
const configPath = join(homedir(), '.wtf-config');
const apiKey = process.env.ANTHROPIC_API_KEY || 
  (existsSync(configPath) ? readFileSync(configPath, 'utf-8').trim() : null);

if (!apiKey) {
  console.error('Set ANTHROPIC_API_KEY or create ~/.wtf-config with your key');
  process.exit(1);
}

const anthropic = new Anthropic({ apiKey });

// Get error from argument or stdin
let errorText = process.argv.slice(2).join(' ');

if (!errorText) {
  // Read from stdin (piped input)
  errorText = readFileSync('/dev/stdin', 'utf-8').trim();
}

if (!errorText) {
  console.log('Usage: wtf "error message" or command 2>&1 | wtf');
  process.exit(0);
}

// Truncate very long errors
if (errorText.length > 3000) {
  errorText = errorText.slice(-3000); // Keep the last 3000 chars (most relevant)
}

const response = await anthropic.messages.create({
  model: 'claude-sonnet-4-6',
  max_tokens: 512,
  messages: [{
    role: 'user',
    content: `You are a senior developer helping debug an error. Explain this error in plain English:

${errorText}

Format your response as:
πŸ” Error: [brief name]

[1-2 sentence explanation of what went wrong]

Likely causes:
[numbered list, most common first]

Fix:
[specific commands or code to fix it, with comments]

Keep it concise. No preamble. Assume the developer is intermediate level.`
  }],
});

console.log(response.content[0].text);

Step 3: Make it executable

chmod +x index.js
npm link  # Makes 'wtf' available globally

Step 4: Configure your API key

echo "sk-ant-your-key-here" > ~/.wtf-config
chmod 600 ~/.wtf-config  # Restrict permissions

Step 5: Test it

# Direct error
wtf "TypeError: Cannot read properties of undefined (reading 'map')"

# Piped from a failing command
npm run build 2>&1 | wtf

# Last 10 lines of Docker logs
docker logs myapp --tail 10 2>&1 | wtf

Making it better

Add language detection

Detect the programming language from the error to give more specific advice:

function detectLanguage(error) {
  if (error.includes('Traceback') || error.includes('IndentationError')) return 'Python';
  if (error.includes('NullPointerException') || error.includes('java.')) return 'Java';
  if (error.includes('TypeError') || error.includes('node_modules')) return 'JavaScript/Node.js';
  if (error.includes('panic:') || error.includes('goroutine')) return 'Go';
  if (error.includes('NullReferenceException')) return 'C#';
  return 'Unknown';
}

// Add to prompt: "The error is from a ${detectLanguage(errorText)} application."

Cache common errors

Save explanations locally to avoid API calls for errors you’ve seen before:

import { createHash } from 'crypto';

const cacheDir = join(homedir(), '.wtf-cache');
const hash = createHash('md5').update(errorText).digest('hex');
const cachePath = join(cacheDir, hash);

if (existsSync(cachePath)) {
  console.log(readFileSync(cachePath, 'utf-8'));
  process.exit(0);
}

// After getting response:
mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, response.content[0].text);

Use a cheaper model for simple errors

Route simple errors (connection refused, file not found) to a cheaper model and complex errors to Sonnet:

const isSimple = /ECONNREFUSED|ENOENT|EACCES|EADDRINUSE/.test(errorText);
const model = isSimple ? 'claude-haiku-3.5' : 'claude-sonnet-4-6';

Deploy as an npm package

# Update package.json
{
  "name": "wtf-error",
  "version": "1.0.0",
  "description": "AI-powered error explainer",
  "bin": { "wtf": "./index.js" }
}

npm publish
# Now anyone can: npx wtf-error "their error"

Total build time: ~30 minutes. API cost: ~$0.002 per error explanation.

Previous: Build a Slack Bot That Summarizes Channels Next: Build a Local AI Chatbot for Your Docs

Related: Build Code Snippet Manager

πŸ“˜