# zsh-autofix **In plain terms:** when a command you type fails (like a typo), this plugin asks a small AI model "what did they probably mean?" and shows the fix right on your next command line — greyed-out, like autocomplete. If it guessed right, press `→` (right arrow) or `Tab` to accept it, then hit Enter. If it guessed wrong, just keep typing and ignore it — nothing is forced on you. ``` ➜ ~ gti status zsh: command not found: gti ➜ ~ git status ← suggested, shown as greyed-out "ghost text" ``` It only appears after commands that **fail** — a successful command produces no suggestion, so it stays out of your way most of the time. Under the hood: it captures the error message (`stderr`), sends it to a locally-running [Ollama](https://ollama.com) model along with a bit of context (what directory you're in, your git branch), and displays whatever the model suggests. Everything after this point in the README gets progressively more technical — read as far as you need and stop whenever it stops being useful. --- ## Requirements - **zsh** as your shell, with **oh-my-zsh** installed (this plugin is an oh-my-zsh plugin) and the **zsh-autosuggestions** plugin already enabled (this plugin reuses its "greyed-out text" rendering, so it must be present) - **curl** and **jq** — two small command-line tools (`brew install curl jq` on macOS if you don't have them; both usually already exist) - **Ollama** installed and running (`ollama serve`, or just having the Ollama app open), with the model you want to use already available (see *Configuring the model* below) ## Install (oh-my-zsh) 1. Copy this whole plugin folder into your oh-my-zsh custom plugins directory so the path looks like: `~/.oh-my-zsh/custom/plugins/zsh-autofix/zsh-autofix.plugin.zsh` (the **folder name** and the **file name before `.plugin.zsh`** must match — this is an oh-my-zsh requirement, not specific to this plugin). 2. Open `~/.zshrc` and find the line that starts with `plugins=(`. Add `zsh-autofix` inside the parentheses, alongside whatever's already there: ```zsh plugins=( git zsh-autosuggestions zsh-autofix ) ``` 3. Close your terminal and open a **brand new one** (see *Never re-source* further down for why — in short, typing `source ~/.zshrc` into an already-open terminal doesn't cleanly pick up this plugin; you need a fresh window/tab). That's it — try typing a command with a typo and see if a suggestion appears. ## Quick config You probably don't need to change anything to get started — the defaults work out of the box as long as Ollama is running. Come back to this table later if you want to tweak behavior. Set any of these in `~/.zshrc` **before** the plugin loads (i.e. above the `plugins=(...)` line, or at least above `source $ZSH/oh-my-zsh.sh`): | Variable | Default | Purpose | |---|---|---| | `ZSH_AUTOFIX_MODEL` | `deepseek-v4-flash:cloud` | Ollama model. A **fast** model matters — it runs after every failed command. | | `ZSH_AUTOFIX_ON_ERROR_ONLY` | `true` | Only suggest after a non-zero exit. Set `false` to suggest after every command. | | `ZSH_AUTOFIX_TIMEOUT` | `6` | Seconds before the model call is abandoned. | | `ZSH_AUTOFIX_THINK` | `false` | Whether the model may use extended "thinking"/reasoning before answering. Slower when `true`. Ignored by models that don't support it. | | `ZSH_AUTOFIX_CAPTURE_ENABLED` | `true` | Master switch for the stderr-capture machinery. | | `ZSH_AUTOFIX_OUTPUT_MAX_LINES` | `50` | How many trailing stderr lines to send to the model. | | `ZSH_AUTOFIX_OUTPUT_DENYLIST` | *(editors, pagers, ssh, sudo, REPLs, …)* | Commands whose stderr is **never** captured. | | `ZSH_AUTOFIX_ENABLED` | `true` | Master switch for suggestions. | | `ZSH_AUTOFIX_DEBUG` | `false` | Append a trace to `/tmp/zsh-autofix.log`. | --- ## Configuring the model Set `ZSH_AUTOFIX_MODEL` in `~/.zshrc`, **before** the plugin loads (i.e. before the `plugins=(...)` / `source $ZSH/oh-my-zsh.sh` lines): ```zsh export ZSH_AUTOFIX_MODEL="qwen3-coder:480b-cloud" ``` Then open a **fresh terminal** — see *Never re-source* below for why `source ~/.zshrc` into a live session isn't a safe way to pick this up. **Any model name `ollama list` shows works** — local or `:cloud`. To see what you have: ```zsh ollama list ``` To pull a new local model, or authenticate a `:cloud` one: ```zsh ollama pull qwen3:8b # local - runs on your machine, uses RAM ollama pull deepseek-v4-flash:cloud # cloud - runs on Ollama's servers ``` **Picking a model — what actually matters here:** - **Latency over quality.** This fires synchronously-ish after *every failed command* (async so it doesn't block, but you're still waiting to see if a suggestion shows up). A slow model makes the plugin feel laggy far more than a slightly-worse-but-fast model feels wrong. `deepseek-v4-flash:cloud` is the default specifically because "flash" models are optimized for this. - **Local vs. `:cloud`:** local models cost RAM and CPU/GPU but have no network round-trip or privacy exposure; `:cloud` models are free of local resource cost but send your failing command + stderr + cwd + git status to Ollama's servers on every failure (see the privacy note in Known issues). - **If you switch to a bigger/slower model**, raise `ZSH_AUTOFIX_TIMEOUT` (default `6` seconds) so it isn't cut off mid-response. - **`ZSH_AUTOFIX_THINK` controls "thinking"/reasoning** (default `false`, see lesson #7 below) — hybrid-reasoning models (like the default `deepseek-v4-flash`) are noticeably slower with this on, so it's off unless you explicitly want deeper reasoning at the cost of speed: ```zsh export ZSH_AUTOFIX_THINK=true ``` Safe to leave at the default for any model — one that doesn't support thinking simply ignores the field. --- ## Glossary A few terms used in the rest of this README, if you're newer to shell scripting: | Term | Meaning | |---|---| | **stdout** / **stderr** | Every command has two output streams: `stdout` for normal output, `stderr` for error messages. They usually both just print to your screen, but a program can tell them apart, which is how this plugin captures *only* error text. | | **exit code** | A number every command produces when it finishes: `0` means success, anything else means some kind of failure. This is how the plugin decides "did that fail?" | | **fd (file descriptor)** | A number the shell uses internally to refer to an open input/output stream — `1` is stdout, `2` is stderr. "Redirecting fd 2" means "send error messages somewhere else instead of the screen." | | **`precmd` / `preexec`** | Two moments zsh lets a plugin "hook into": `preexec` runs right *before* your command executes, `precmd` runs right *before* the next prompt is drawn (i.e. right after your command finishes). | | **`zle`** (Zsh Line Editor) | The part of zsh that lets you type, move the cursor, and edit your command before pressing Enter. | | **ghost text / `POSTDISPLAY`** | The greyed-out suggested text you see after your cursor (the same thing `zsh-autosuggestions` shows from your history). `POSTDISPLAY` is the internal variable that holds it. | | **tty / pty** | Short for "teletype" / "pseudo-teletype" — technical names for "the terminal window itself." | | **FIFO / pipe** | A special file that lets one program stream data to another without a real file on disk. | --- ## How it works *(You don't need to understand this to use the plugin — it's here for when something breaks and you want to know why.)* Three zsh hooks cooperate: 1. **`preexec`** (`_zsh_autofix_preexec`) — right before your command runs, temporarily send its **stderr only** to a temp file instead of the screen: `exec 2>"$errfile"`. Normal output (`stdout`) is left completely alone. Certain commands (editors, `ssh`, `sudo`, etc. — see the denylist) are skipped entirely so nothing about them is touched. 2. **`precmd`** (`_zsh_autofix_restore_fds`, forced to run **first**) — right after your command finishes, point stderr back at the real screen and print out whatever was captured in the temp file — so you still **see** the error message, just a moment later than normal. 3. **`precmd`** (`_zsh_autofix_suggest`) — if the command failed, package up what happened (the command, its exit code, the captured error text, your current folder, your git branch) and send it to Ollama **in the background**, so your terminal is never frozen waiting. When the answer comes back — usually under a second — it's displayed as ghost text. --- ## Design decisions & hard-won lessons *(This is the technical deep-dive — genuinely optional reading. It exists so that if this plugin ever needs fixing again, the same dead ends aren't re-discovered from scratch. Skip straight to Debugging below if you just hit a problem and want the fastest fix.)* ### 1. Capturing command output in pure zsh is genuinely hard The core difficulty: in zsh/bash/fish, commands write **directly** to the terminal file descriptor. The shell never sees the bytes, so "capture what a command printed" means intercepting fd 1/2 — and every interception method has a sharp edge. We tried, in order: - **`tee` via process substitution** — `exec 1> >(tee f) 2> >(tee f >&2)`. No job-control noise, but the `tee` runs async and gets **killed/raced on the fd restore**, so fast commands (notably `command not found`, which returns almost instantly) had their output **dropped**. - **`tee` via explicit background jobs + `wait`** — captured everything reliably, but zsh's job control printed `[1] 12345 done tee ...` after every command. `disown`, `&!`, subshell double-fork `( … & )`, and `setopt no_monitor` **all failed** to suppress those reports in an interactive shell. `wait` itself re-reports the job. - **One persistent reader** (a single long-lived `tee` fed by a FIFO) — killed the job noise (a process that never exits is never reported "done"), but the FIFO keep-alive is finicky: a second reader on the FIFO silently swallows data and **hangs the shell**; getting the open ordering right is fragile. **The winning approach:** don't tee at all. Since we only act on **failures**, and errors go to **stderr**, redirect *only stderr* to a plain file (synchronous, no process, no job) and re-print it ourselves at `precmd`. This is what the code does now. It trades *live streaming of stderr* for enormous simplicity — see Limitations. > The general lesson: reliable **live** output capture in a pure shell plugin > is a losing battle. Tools that truly do it (Warp, Fig, iTerm shell > integration) use a pty/terminal-emulator layer, not shell hooks. The pty > route (running the session under `script`) is the only clean way to capture > *all* output live — but that's a session-launch change, not a plugin. ### 2. `zle -F` needs `-w` (and a pre-registered widget) To render ghost-text from an async fd callback you must use `zle -F -w` **and** register the handler with `zle -N` first. Without `-w`, the callback runs outside zle's widget context and `POSTDISPLAY` / `zle -R` **silently do nothing** — the suggestion is computed but never appears. This cost hours; it's a one-line flag. ### 3. `POSTDISPLAY` only sticks once the line editor is live `zsh-autosuggestions`' `_zsh_autosuggest_suggest` only sets `POSTDISPLAY` when `BUFFER` is non-empty (it's built for completing what you've typed). For a suggestion on an *empty* prompt we set `POSTDISPLAY` directly. Setting it during `precmd` itself doesn't work — it's reset before the prompt draws; it must be set from the live editing context (the async handler, which fires while the line editor is active). ### 4. Saved file descriptors collide with oh-my-zsh's async worker The nastiest bug. The obvious "save stderr, restore it later" pattern — `exec {SAVED}>&2` then `exec 2>&${SAVED}` — **breaks intermittently** under oh-my-zsh. Its async git prompt (`_omz_async_request`) opens its own fds *between* our `preexec` and `precmd`, and the auto-allocated numbers (`{SAVED}` picks 12, 13, …) **collide** with omz's. Our "saved terminal" fd gets clobbered, so restoring stderr points it at omz's async pipe (or a closed fd) — and **errors silently vanish after the first git-repo prompt**. It presents as "works only once." **Fix:** never save an fd number. Capture the **tty device path** once at load (`$(tty)`, while fd 0 is still the pristine terminal) and reopen stderr straight to it: `exec 2>"$_ZSH_AUTOFIX_TTY"`. Nothing for omz to collide with. ### 5. `precmd` hook ordering matters `_zsh_autofix_restore_fds` is **prepended** to `precmd_functions` so it runs before oh-my-zsh's own precmd hooks (git prompt, etc.) — those must see a restored stderr, not our temp file. ### 6. `status` is a read-only variable in zsh `local status=...` throws `read-only variable: status`. Use another name (`git_status`). Same class: `path`, `pipestatus`, etc. are special too. ### 6b. `${${(z)cmd}[1]}` silently breaks on single-word commands The denylist check extracted the first word of a command with `${${(z)cmd}[1]}`. This works correctly for multi-word commands (`"vim foo.txt"` → `vim`), but for a command with **no arguments** — `vim`, `ssh`, `top`, and critically `ipython` — it silently collapses to **character-indexing** instead of array-indexing, returning just the first *character* (`ipython` → `i`). That never matches anything in the denylist, so every single-word denylisted command had its stderr captured (redirected to a file) for its **entire session** instead of being skipped. This is what actually broke `ipython`: with stderr silently pointed at a file instead of the terminal, `sys.stderr.isatty()` returns `False`, which is exactly the kind of thing an interactive REPL's terminal/signal handling (`prompt_toolkit`, in ipython's case) can choke on - manifesting as broken Ctrl-C handling (`KeyboardInterrupt escaped interact()` looping). It's deterministic, not timing-dependent, which is why it reproduced on a completely fresh session with `ipython` as the very first command - a real in-flight-request race (see lesson 4) was a red herring investigated first and ruled out by testing. **Fix:** force array context explicitly before indexing, rather than relying on the nested substitution to infer it: ```zsh local -a _words _words=("${(z)cmd}") local first_word="${_words[1]}" ``` **Lesson:** don't trust a nested parameter expansion's array-vs-scalar behavior implicitly - assign to an explicit array variable first if you need guaranteed array semantics, especially for something like a denylist check where a silent wrong answer (rather than an error) is the failure mode. This also argues for testing denylist/allowlist logic against single-word inputs specifically, not just the multi-word examples that happen to look right. ### 7. Model "thinking" trades latency for depth — made configurable `deepseek-v4-flash` (and other hybrid-reasoning models) emit a verbose `"thinking"` field when reasoning is on, which ~3-4x'd response time and caused timeouts. Passing `"think": false` in the request body dropped a typical call from ~2s to ~0.6s. This is now exposed as `ZSH_AUTOFIX_THINK` (default `false`) rather than hardcoded, in case a future model or use case genuinely benefits from deeper reasoning despite the latency cost. Always test the raw model latency before assuming a setting is safe either way. ### 8. Never re-source this plugin into a live shell Re-running `source ~/.zshrc` (or re-sourcing the plugin) into an existing session is **not** a clean reset: - The `precmd_functions=(... $precmd_functions)` prepend is **not idempotent** — restore gets registered multiple times. - A prior version may have left stderr redirected (`exec 2>file`) with no matching restore, so your errors silently go to a dead file. Always test in a **fresh terminal window**. Debugging this plugin in a re-sourced shell produced hours of phantom "bugs" that were just stale state. ### 9. Test with a real pty, not `zsh -i -c` `zsh -i -c '…'` has no live line editor, so `zle -F` callbacks and `POSTDISPLAY` can't be observed, and job-control messages differ. Drive a real pseudo-terminal (`expect` spawning `zsh -i`, or Python `pty`) and inspect the **raw bytes** — escape codes and prompt redraws hide or reveal what actually reached the screen. Several "it works" / "it doesn't" flip-flops were purely test-harness artifacts. --- ## Failure modes are silent by design **If you're not seeing any suggestions at all**, the most common cause is simply that Ollama isn't running, or the model name is wrong — and this plugin currently gives you **no error message** when that happens (see below for why). Start by checking: is Ollama open/running, and does `ollama list` show the model you configured? More precisely — if Ollama isn't installed, isn't running, or the configured model doesn't exist, **nothing appears on your terminal** — no error, no warning, just no suggestion. This was verified directly, not assumed: | Situation | What actually happens | What you see | |---|---|---| | Ollama not installed / not running | `curl` can't connect (exit code 7), writes nothing to the response fifo | Nothing | | `ZSH_AUTOFIX_MODEL` is misspelled or not pulled/available | Ollama responds `HTTP 404` with `{"error":"model '...' not found"}` — `curl --silent` doesn't check HTTP status, so this reaches the handler as "success"; `jq -r '.response // empty'` finds no `.response` key in an error body, so it extracts an empty string | Nothing | | `curl` or `jq` isn't installed | Caught explicitly in `_zsh_autofix_suggest` via `command -v` | Nothing | | Request/model call takes longer than `ZSH_AUTOFIX_TIMEOUT` | `curl --max-time` kills it, empty response | Nothing | Every one of these degrades to the same outcome: **no ghost-text, no error message.** This is a real design gap, not a considered fallback — it just means "the model didn't answer" and "everything is misconfigured" look identical. The only way to tell them apart today is: ```zsh export ZSH_AUTOFIX_DEBUG=true tail -f /tmp/zsh-autofix.log ``` ...and look for `skip: empty async response (timeout or curl error)` (Ollama unreachable / timed out) vs. `skip: empty/unparsed suggestion` (Ollama responded, but with an error body — check the model name). See *Known issues* below for surfacing this more visibly. --- ## Limitations (accepted trade-offs) - **stderr is shown at command *end*, not streamed live.** For a failing command this is imperceptible (the error prints right before it exits). But tools that **animate progress on stderr** — `git clone`, `npm install`, `wget`, `ffmpeg` — will batch that progress to the end. Add such commands to `ZSH_AUTOFIX_OUTPUT_DENYLIST` if it bothers you. - **stdout is not captured.** Some tools print failure detail to stdout (e.g. test runners); the model won't see it. stderr-only was chosen because it's where errors live and because leaving stdout untouched keeps interactive programs 100% safe. - **Interactive/prompt commands must be denylisted.** `sudo`/`ssh` write password prompts to stderr; capturing those would hide the prompt. The default denylist covers the common ones — extend it as needed. - **Exit-code gating reads `$?` after oh-my-zsh's precmd hooks** (see Known issues) — usually correct, but structurally fragile. --- ## Known issues / TODO Ordered roughly by value-to-effort. 1. **Surface config errors instead of failing silently.** Right now "Ollama unreachable" and "model not found" and "everything working, no fix exists" all look identical (nothing happens). A one-time warning per session — e.g. the first time a request comes back with an `.error` field or a connection failure, print a single dimmed note like `zsh-autofix: can't reach Ollama / model not found (see ZSH_AUTOFIX_DEBUG)` — would save a lot of "is this even working?" confusion without being noisy on every failure. 2. **Harden exit-code capture.** `_zsh_autofix_suggest` reads `$?` at the top, but it runs *after* oh-my-zsh's precmd functions, which can overwrite `$?`. It works in practice but is fragile. Capture the true exit code in a dedicated hook that runs **first** (alongside `restore_fds`) and stash it in a variable the suggest function reads. 3. **Capture stdout too (optionally).** For failures where the useful text is on stdout (test runners, compilers). Would need a second temp file and the same interactive-command care. Consider a combined-capture toggle. 4. **Debounce / rate-limit.** Every failure fires a model call. Add a minimum interval, or skip if the identical (command, stderr) was just queried, to avoid hammering the backend on repeated failures. 5. **Cost/privacy of `:cloud` models.** The default model sends command text + stderr + cwd + git status to Ollama's cloud. Document/hint this, and make it easy to point at a purely local model. Consider redacting obvious secrets from stderr before sending. 6. **Temp-file hygiene.** `stderr` capture and the request FIFO live in `/tmp` keyed by `$$`. Add an `EXIT`/`zshexit` trap to clean them up, and consider `TMPDIR` instead of hard-coded `/tmp`. 7. **Strip ANSI from captured stderr before sending.** Colored error output includes escape codes that waste tokens and can confuse the model. Run the captured text through a `sed`/`perl` escape-stripper first. 8. **Portability.** Assumes GNU-ish `tail`/`sed`/`jq` behavior and zsh ≥ 5. Verify on Linux; the tty-reopen trick and `zle -F -w` are zsh-specific by design. 9. **A proper "accept and run" affordance.** Currently you accept ghost-text then press Enter. A dedicated keybinding to accept-and-execute (or accept-and-edit) would be nicer. 10. **Consider xonsh for a v2.** If output capture ever needs to be comprehensive and live, xonsh captures every command's stdout/stderr natively (`$XONSH_CAPTURE_ALWAYS`, `$XONSH_STORE_STDOUT`) via its own pty — no fd juggling. A far smaller, more robust plugin could be written there. --- ## Debugging ```zsh export ZSH_AUTOFIX_DEBUG=true tail -f /tmp/zsh-autofix.log ``` The log records: capture-skip reasons, the exact request JSON, the raw model response, the extracted suggestion, and whether it was applied or dropped (e.g. because you started typing before it arrived). If errors stop showing entirely, check whether stderr is stuck redirected: ```zsh readlink /dev/fd/2 # should be your tty (or empty on macOS), NOT a /tmp/*.log ``` If it points at a temp file, you have stale state — open a **fresh terminal**.