zsh-autofix

July 6, 2026 · View on GitHub

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 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:

    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):

VariableDefaultPurpose
ZSH_AUTOFIX_MODELdeepseek-v4-flash:cloudOllama model. A fast model matters — it runs after every failed command.
ZSH_AUTOFIX_ON_ERROR_ONLYtrueOnly suggest after a non-zero exit. Set false to suggest after every command.
ZSH_AUTOFIX_TIMEOUT6Seconds before the model call is abandoned.
ZSH_AUTOFIX_THINKfalseWhether the model may use extended "thinking"/reasoning before answering. Slower when true. Ignored by models that don't support it.
ZSH_AUTOFIX_CAPTURE_ENABLEDtrueMaster switch for the stderr-capture machinery.
ZSH_AUTOFIX_OUTPUT_MAX_LINES50How 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_ENABLEDtrueMaster switch for suggestions.
ZSH_AUTOFIX_DEBUGfalseAppend 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):

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:

ollama list

To pull a new local model, or authenticate a :cloud one:

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:

    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:

TermMeaning
stdout / stderrEvery 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 codeA 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 / preexecTwo 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 / POSTDISPLAYThe 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 / ptyShort for "teletype" / "pseudo-teletype" — technical names for "the terminal window itself."
FIFO / pipeA 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 substitutionexec 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 argumentsvim, ssh, top, and critically ipython — it silently collapses to character-indexing instead of array-indexing, returning just the first character (ipythoni). 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:

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:

SituationWhat actually happensWhat you see
Ollama not installed / not runningcurl can't connect (exit code 7), writes nothing to the response fifoNothing
ZSH_AUTOFIX_MODEL is misspelled or not pulled/availableOllama 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 stringNothing
curl or jq isn't installedCaught explicitly in _zsh_autofix_suggest via command -vNothing
Request/model call takes longer than ZSH_AUTOFIX_TIMEOUTcurl --max-time kills it, empty responseNothing

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:

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 stderrgit 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

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:

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.