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 jqon 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)
-
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.zshmust match — this is an oh-my-zsh requirement, not specific to this plugin). -
Open
~/.zshrcand find the line that starts withplugins=(. Addzsh-autofixinside the parentheses, alongside whatever's already there:plugins=( git zsh-autosuggestions zsh-autofix ) -
Close your terminal and open a brand new one (see Never re-source further down for why — in short, typing
source ~/.zshrcinto 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):
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:cloudis 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;:cloudmodels 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(default6seconds) so it isn't cut off mid-response. -
ZSH_AUTOFIX_THINKcontrols "thinking"/reasoning (defaultfalse, see lesson #7 below) — hybrid-reasoning models (like the defaultdeepseek-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=trueSafe 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:
-
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. -
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. -
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:
-
teevia process substitution —exec 1> >(tee f) 2> >(tee f >&2). No job-control noise, but theteeruns async and gets killed/raced on the fd restore, so fast commands (notablycommand not found, which returns almost instantly) had their output dropped. -
teevia explicit background jobs +wait— captured everything reliably, but zsh's job control printed[1] 12345 done tee ...after every command.disown,&!, subshell double-fork( … & ), andsetopt no_monitorall failed to suppress those reports in an interactive shell.waititself re-reports the job. -
One persistent reader (a single long-lived
teefed 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:
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:
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 toZSH_AUTOFIX_OUTPUT_DENYLISTif 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/sshwrite 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.
-
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
.errorfield or a connection failure, print a single dimmed note likezsh-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. -
Harden exit-code capture.
_zsh_autofix_suggestreads$?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 (alongsiderestore_fds) and stash it in a variable the suggest function reads. -
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.
-
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.
-
Cost/privacy of
:cloudmodels. 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. -
Temp-file hygiene.
stderrcapture and the request FIFO live in/tmpkeyed by$$. Add anEXIT/zshexittrap to clean them up, and considerTMPDIRinstead of hard-coded/tmp. -
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/perlescape-stripper first. -
Portability. Assumes GNU-ish
tail/sed/jqbehavior and zsh ≥ 5. Verify on Linux; the tty-reopen trick andzle -F -ware zsh-specific by design. -
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.
-
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.