# errexplain

`ee` runs a command (or reads piped output), and when it fails it pulls out the error, reads only the source lines that matter from your disk, and asks Claude Haiku 5.5 for a short root cause and a concrete fix, as a unified diff when the model is confident. Most of the work (parsing, context selection, budgeting, redaction) is deterministic Python, so a request is typically a few hundred to ~3k input tokens, well under a cent.

## Features

- Runs `ee -- CMD ...` with live output (stdout and stderr merged), keeps the last 400 lines and the exit code, and exits with the wrapped command's exit code. Also works as a filter: `cmd 2>&1 | ee`.
- Deterministic error parsers: Python tracebacks (including chained "During handling..."), pytest failure and collection-error sections with assertion rewrites, Node/TS stack traces, `tsc` errors (plain and pretty), Go panics, compile errors and test failures, Rust `error[E0xxx]` with `-->` locations and panics, Java/Kotlin stack traces (root cause first), GCC/Clang errors, plus a generic "last error-looking block" fallback.
- Context builder: +-12 lines around each frame and the enclosing function (Python via `ast`, other languages by an indentation heuristic), overlapping ranges merged, vendor frames (`site-packages`, `node_modules`, `.venv`) kept only when they are the top frame, budgeted to ~3k tokens with the top frames first. Adds OS, language version and declared dependency versions (`requirements*.txt`, `pyproject.toml`, `package.json`) for names imported in the snippets.
- Safety: files outside the repo root are never read or sent, `.eeignore` blocks paths, and obvious secrets (API keys, tokens, JWTs, private keys, `password=...`, URL credentials) are redacted before anything leaves your machine.
- Structured answer (summary, root cause, fix steps, optional patch, confidence, docs links) rendered with rich, with a syntax-highlighted diff.
- `--apply` validates the patch with `git apply --check` and asks before applying.
- `--dry-run` shows exactly what would be sent; responses are cached on disk by sha256 of (model, prompt); `--verbose` shows tokens and estimated cost.

## Install

```bash
cd errexplain
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
export PATH="$PWD/.venv/bin:$PATH"     # provides `ee` and `errexplain`
export ANTHROPIC_API_KEY=sk-ant-...    # or log in with `ant auth login`
```

## Quickstart

```bash
ee -- pytest -x                 # run, and explain if it fails
make 2>&1 | ee                  # explain piped output
ee --dry-run -- python3 examples/pyapp/report.py   # no key needed
ee --apply -- go build ./...    # offer to apply the suggested patch
```

## Usage

```
ee [OPTIONS] [COMMAND]...

  --model TEXT      Model id (env EE_MODEL)  [default: claude-haiku-5-5]
  --dry-run         Print what would be sent and the token estimate; no API call.
  -v, --verbose     Show token usage and estimated cost.
  --apply           Apply the suggested patch (git apply --check first, asks to confirm).
  -y, --yes         Do not ask for confirmation with --apply.
  --no-cache        Ignore and do not write the response cache.
  --root DIRECTORY  Repo root; files outside it are never sent.  [default: .]
  --budget INTEGER  Approximate token budget for source context.  [default: 3000]
```

Flags that belong to the wrapped command are passed through (`ee pytest -x -k foo`); use `--` if it clashes with an `ee` flag. A command that exits 0 is never analysed and never costs anything.

### Example: dry run on a real failure

`examples/pyapp/report.py` looks up a SKU that is missing from `prices.json`. This is real output (system prompt omitted for brevity):

```
$ cd examples/pyapp && ee --dry-run -- python3 report.py
Traceback (most recent call last):
  ...
KeyError: 'C300'

ee dry run: model=claude-haiku-5-5, model not called
--- user ---
Command: python3 report.py

Exit code: 1

Detected: python / KeyError

Environment:
- OS: Linux 6.8.0-138-generic
- python: Python 3.12.3

Error output:
...
Source inventory.py lines 1-13:
 9| def total_cost(items, prices):
10|     total = 0
11|     for item in items:
12|         total += prices[item["sku"]] * item["qty"]
13|     return total

Source report.py lines 1-12:
...
---
estimated input tokens: ~519 (~$0.000052)
```

The other samples in `examples/` (Go panic, Node `JSON.parse`, Rust lifetime error, C undeclared identifier, a failing pytest; `tsapp` needs `tsc`, which was not available here) all work the same way, e.g. `cd examples/goapp && ee --dry-run -- go run .`.

### Example: rendered answer (requires a real key)

The answer layout below was rendered from a stub response (the tests use a fake client); with a key the content comes from Haiku.

```
╭───────────────────────────────── errexplain ─────────────────────────────────╮
│ KeyError: no price for SKU C300.                                             │
│                                                                              │
│ Root cause                                                                   │
│ ITEMS in report.py contains C300, but prices.json only defines A100 and      │
│ B200, so the lookup in total_cost raises KeyError.                           │
│                                                                              │
│ Fix                                                                          │
│ 1. Add C300 to prices.json, or                                               │
│ 2. Validate SKUs in total_cost and fail with a clear message.                │
╰────────────────────────────── confidence: high ──────────────────────────────╯
--- a/inventory.py
...
tokens: 1000 in / 200 out, est. cost $0.000200
```

## Configuration

| Setting | Purpose |
| --- | --- |
| `ANTHROPIC_API_KEY` | Credentials (never logged; there is no `--api-key` flag). |
| `EE_MODEL` | Default model id (`claude-haiku-5-5`). |
| `EE_CACHE_DIR` | Cache location (default `~/.cache/errexplain`). |
| `.eeignore` | In the repo root; one glob or directory per line, `#` comments. Matching files are never read or sent. |

## Architecture

| Module | Role |
| --- | --- |
| `runner.py` | Subprocess runner with tee, 400-line tail, exit codes (126/127 for launch failures); stdin reader. |
| `parsers.py` | Per-language error extraction into `ErrorInfo` (message, excerpt, frames ordered most relevant first). |
| `context.py` | Frame resolution, root and `.eeignore` enforcement, function spans, merging, token budget, environment facts. |
| `redact.py` | Secret masking applied to the whole prompt. |
| `llm.py` | Prompt builder, `Explanation` schema, `messages.parse` call, disk cache, error mapping, cost. |
| `patch.py` | `git apply --check` / apply with path-escape protection. |
| `render.py` | rich panel and diff. |
| `cli.py` | click command wiring. |

See [docs/design.md](docs/design.md) for the algorithms.

## Tests

```bash
.venv/bin/python -m pytest -q     # 88 tests, no network or API key needed
```

Parsers run against real captured outputs in `tests/fixtures/`; the model is replaced by a fake client.

## Limitations

- Live API calls were not exercised in development (no key available); the request shape follows the SDK's `messages.parse` structured-output API and is covered by fake-client tests.
- The language version comes from the `python3`/`node`/... on `PATH`, which may differ from the interpreter the wrapped command used.
- Only frames that appear in the error output are read: a pytest assertion failure in a test shows the test file but not the function under test unless it appears in a traceback.
- The non-Python function-span detection is an indentation heuristic and can miss unusual formatting; the +-12 line window is always included.
- Output is read through pipes, not a PTY, so programs that colourise only on a TTY print plain text (which suits parsing).
- Patches are model-generated; `--apply` only guarantees they apply cleanly, not that they are correct.
