autocorrect.zsh — 167 lines of zsh that fix failed commands
autocorrect.zsh is a 167-line zsh script that catches failed commands and asks Gemini Flash to suggest a correction in place. With jq and a Gemini API key, it runs inside the existing shell — no new terminal application required. This post covers how it works, the key design choices, usage, and limits.
What it is
When a command exits non-zero, the script intercepts it, sends the command text and environment context to the Gemini Flash API, and prints a corrected command. The user can execute it, cancel, or provide feedback to refine the suggestion. If the suggested command also fails, the script offers a retry.
The two dependencies are jq (for JSON handling) and a Gemini API key sourced from ~/.oh-my-zsh/custom/apikey.env. Nothing else is installed. The hooks register at shell start and are otherwise invisible.
Warp offers a comparable feature as part of a full terminal replacement. autocorrect.zsh covers the same narrow use case — catch failed commands, suggest a fix — without replacing the shell, the prompt, or the keybindings. The per-call cost on Gemini Flash is effectively zero.
What it looks like
Wrong syntax, auto-corrected with feedback:
$ du -h depth 1 top 10
du: depth: No such file or directory
[AUTO] Failed (exit 1). Fix with AI? (ENTER/n):
[WAIT] ...
[INFO] du -hd 1 | sort -hr | head -n 10
ENTER to execute / f to give feedback / any key to cancel: 최신순
[WAIT] ...
[INFO] du -h -d 1 . | sort -hr | head -n 10
ENTER to execute / f to give feedback / any key to cancel:
213G .
195G ./GitHubNatural language — typed directly into the shell:
$ 이름에 'cc'가 포함된 폴더를 찾아줘. 하위 폴더는 뒤지지 마.
zsh: command not found: 이름에
[AUTO] Failed (exit 127). Fix with AI? (ENTER/n):
[WAIT] ...
[INFO] ls -d *cc*
ENTER to execute / f to give feedback / any key to cancel:
analyze-cc-prompts
cc_teammates_test
cc-projectThe Korean query works because Gemini speaks Korean. The core use case is wrong flags and misremembered syntax, not multilingual input — but zsh: command not found is a valid failure exit code, and the hook does not distinguish why the command failed.
Full source
#──────────────────────────────────────────────────────────────────────────────
# Command Auto Correction System (Gemini Flash)
#──────────────────────────────────────────────────────────────────────────────
# Two modes: auto-detect on failure, and manual invocation via `fix`
# Backup: claude_autocorrect.zsh.bak (Claude Haiku version)
# Environment info — collected once at shell start
_FIX_OS="$(uname -s)"
[[ -f /proc/version ]] && grep -qi microsoft /proc/version 2>/dev/null && _FIX_OS="WSL"
_FIX_SHELL_NAME="${SHELL##*/}"
if [[ -n "$ZSH_VERSION" ]]; then
_FIX_SHELL="${_FIX_SHELL_NAME} ${ZSH_VERSION}"
elif [[ -n "$BASH_VERSION" ]]; then
_FIX_SHELL="${_FIX_SHELL_NAME} ${BASH_VERSION}"
else
_FIX_SHELL="$_FIX_SHELL_NAME"
fi
_FIX_ARCH="$(uname -m)"
_FIX_ENV="${_FIX_OS}/${_FIX_ARCH} | ${_FIX_SHELL}"
_FIX_MODEL="${GEMINI_MODEL:-gemini-3.5-flash}"
# Call Gemini Flash and offer the suggested command interactively
_gemini_cmd_fixer() {
local user_input="$*"
source ~/.oh-my-zsh/custom/apikey.env 2>/dev/null
if [[ -z "$GEMINI_API_KEY" ]]; then
echo "[ERROR] GEMINI_API_KEY not found"
return 1
fi
local prompt="[env: $_FIX_ENV] [cwd: $(pwd)]
$user_input
-> One correct command that matches the user's intent. Rules: 1) achieve exactly what the user wants 2) use only options valid for the above env (watch for macOS vs GNU coreutils differences, BSD sort has no -h, zsh vs bash syntax) 3) do not add heavy operations the user did not request. Respond only as JSON: {\"cmd\": \"command\"}"
local json_prompt=$(printf '%s' "$prompt" | jq -Rs '.')
echo "[WAIT] ..."
local raw=$(curl -s --connect-timeout 5 \
"https://generativelanguage.googleapis.com/v1beta/models/${_FIX_MODEL}:generateContent?key=${GEMINI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"contents": [{"parts": [{"text": '"$json_prompt"'}]}],
"generationConfig": {
"maxOutputTokens": 100,
"thinkingConfig": {"thinkingLevel": "minimal"},
"responseMimeType": "application/json",
"responseJsonSchema": {
"type": "object",
"properties": {
"cmd": {"type": "string"}
},
"required": ["cmd"]
}
}
}' 2>/dev/null)
if [[ -z "$raw" ]]; then
echo "[ERROR] No response (network/timeout)"
return 1
fi
if printf '%s' "$raw" | grep -q '"error"'; then
echo "[ERROR] $(printf '%s' "$raw" | jq -r '.error.message // .error.status // "unknown"' 2>/dev/null)"
return 1
fi
# structured output → parse JSON {"cmd": "..."}
# NOTE: use printf, not echo — zsh echo converts \\ → \
local suggested_cmd=$(printf '%s' "$raw" | jq -r '.candidates[0].content.parts[-1].text' 2>/dev/null | jq -r '.cmd // empty' 2>/dev/null)
if [[ -z "$suggested_cmd" ]]; then
echo "[ERROR] Parse failed"
return 1
fi
echo ""
echo "[INFO] $suggested_cmd"
echo ""
read -r "?ENTER to execute / f to give feedback / any key to cancel: " confirm
if [[ -z "$confirm" ]]; then
local output
output=$(eval "$suggested_cmd" 2>&1)
local eval_status=$?
local pipe_failed=0
for s in ${pipestatus[@]}; do
[[ $s -ne 0 ]] && pipe_failed=1
done
printf '%s\n' "$output"
echo ""
# retry if exit code, pipe failure, or error pattern detected
if [[ $eval_status -ne 0 || $pipe_failed -eq 1 ]] || printf '%s' "$output" | grep -qiE '^usage:|: (command not found|not found|no such file|invalid option|illegal option)'; then
echo ""
read -r "?[RETRY] Failed. ENTER to retry / feedback to guide / any key to cancel: " retry
if [[ -z "$retry" ]]; then
_gemini_cmd_fixer "$suggested_cmd (failed: exit $eval_status, error: $output)"
elif [[ "$retry" != [nNqQ] ]]; then
_gemini_cmd_fixer "$suggested_cmd ($retry)"
fi
fi
elif [[ "$confirm" == [fF] || ${#confirm} -gt 1 ]]; then
local feedback="$confirm"
[[ "$confirm" == [fF] ]] && read -r "?Feedback: " feedback
_gemini_cmd_fixer "$user_input ($feedback)"
else
echo "[CANCEL]"
return 1
fi
}
# ========== Auto-detect mode ==========
_auto_fix_last_command() {
local exit_status=$?
local last_cmd="$1"
[[ $exit_status -eq 0 || $exit_status -eq 130 ]] && return 0
local cmd_first="${last_cmd%% *}"
[[ "$cmd_first" =~ ^(vim|nano|subl|code|less|man|fix|cmd_fix)$ ]] && return 0
echo ""
read -r -k 1 "?[AUTO] Failed (exit $exit_status). Fix with AI? (ENTER/n): " response
echo ""
if [[ "$response" == "y" || "$response" == "Y" || "$response" == "" || "$response" == $'\n' ]]; then
_gemini_cmd_fixer "$last_cmd"
fi
}
# Register precmd hook (auto-detect)
if [[ -o interactive ]]; then
autoload -U add-zsh-hook 2>/dev/null
_store_last_command() {
LAST_COMMAND="$1"
}
_check_and_fix() {
_auto_fix_last_command "$LAST_COMMAND"
}
add-zsh-hook preexec _store_last_command 2>/dev/null
add-zsh-hook precmd _check_and_fix 2>/dev/null
fi
# ========== Manual invocation mode ==========
cmd_fix() {
local user_input
if [[ $# -eq 0 ]]; then
read -r -p "Enter command or natural language: " user_input
else
user_input="$*"
fi
if [[ -z "$user_input" ]]; then
echo "No input provided"
return 1
fi
_gemini_cmd_fixer "$user_input"
}
alias fix='cmd_fix'Structured output for reliable parsing
Gemini Flash supports responseJsonSchema in generationConfig. Passing a schema causes the API to enforce the response shape directly:
"generationConfig": {
"responseMimeType": "application/json",
"responseJsonSchema": {
"type": "object",
"properties": {
"cmd": {"type": "string"}
},
"required": ["cmd"]
}
}Every response arrives as {"cmd": "..."}. Extraction is one jq call:
local suggested_cmd=$(printf '%s' "$raw" \
| jq -r '.candidates[0].content.parts[-1].text' \
| jq -r '.cmd // empty')printf is used instead of echo because zsh's echo converts \\ to \, which corrupts backslash sequences before jq can parse them. The schema constraint replaces prompt-engineering-based parsing — no regex, no fragile instruction-following.
preexec and precmd hooks
Getting the command text and exit code into the same hook is not straightforward in zsh. $? is available in precmd, but by then the command text is gone. preexec receives the command string as $1 but fires before the command runs, so it cannot see the exit code.
The solution is two hooks working together:
_store_last_command() {
LAST_COMMAND="$1"
}
_check_and_fix() {
_auto_fix_last_command "$LAST_COMMAND"
}
add-zsh-hook preexec _store_last_command
add-zsh-hook precmd _check_and_fixpreexec stores the command text in LAST_COMMAND. precmd reads it back alongside $?. No PROMPT_COMMAND hacks, no readline interception.
Retry loop
When the suggested command exits non-zero, or its output matches patterns like usage: or command not found, the script surfaces a retry prompt:
[RETRY] Failed. ENTER to retry / feedback to guide / any key to cancel:The retry call passes the failed command — plus any user feedback — back into _gemini_cmd_fixer recursively:
if [[ -z "$retry" ]]; then
_gemini_cmd_fixer "$suggested_cmd"
elif [[ "$retry" != [nNqQ] ]]; then
_gemini_cmd_fixer "$suggested_cmd ($retry)"
fiFeedback typed inline — 최신순 in the example above — is appended to the next prompt without breaking the flow.
Limits
Latency is around 1 second per call. The [WAIT] ... line reflects a real HTTPS round trip to the Gemini API. Gemini Flash is fast for an LLM, but the delay is noticeable for a quick typo in a tight loop.
The API key is stored in a plaintext dotfile. The setup sources GEMINI_API_KEY from ~/.oh-my-zsh/custom/apikey.env on each call. On shared machines, tighter file permissions or a secrets manager are worth considering.
BSD/GNU flag disambiguation relies on LLM judgment. The script injects OS, architecture, and shell version into every prompt — [env: Darwin/arm64 | zsh 5.9] — so Gemini knows not to suggest GNU-only flags on macOS. If Gemini hallucinates a flag that does not exist on the platform, the retry loop catches it, but the correction takes an additional round trip.
Setup
# 1. Put your key in ~/.oh-my-zsh/custom/apikey.env
echo 'GEMINI_API_KEY="your-key-here"' >> ~/.oh-my-zsh/custom/apikey.env
# 2. Source the script
echo 'source /path/to/autocorrect.zsh' >> ~/.zshrcThe hooks register on shell start. The next command that fails will prompt for a correction.