---
ai_assisted: true
title: "autocorrect.zsh — 167 lines of zsh that fix failed commands"
description: "Gemini Flash structured output and zsh preexec/precmd hooks — failed commands fixed in place, no new terminal app"
pubDate: 2026-05-20T16:29:17+09:00
category: "Development"
tags: ["zsh", "Gemini", "Terminal", "AI", "Shell", "Productivity", "Hooks", "structured-output"]
heroImage: ""
serial: 8
draft: false
published: true
---

`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    ./GitHub
```

**Natural 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-project
```

The 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

```zsh
#──────────────────────────────────────────────────────────────────────────────
# 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:

```json
"generationConfig": {
  "responseMimeType": "application/json",
  "responseJsonSchema": {
    "type": "object",
    "properties": {
      "cmd": {"type": "string"}
    },
    "required": ["cmd"]
  }
}
```

Every response arrives as `{"cmd": "..."}`. Extraction is one `jq` call:

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

```sh
_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_fix
```

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

```sh
if [[ -z "$retry" ]]; then
  _gemini_cmd_fixer "$suggested_cmd"
elif [[ "$retry" != [nNqQ] ]]; then
  _gemini_cmd_fixer "$suggested_cmd ($retry)"
fi
```

Feedback 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

```sh
# 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' >> ~/.zshrc
```

The hooks register on shell start. The next command that fails will prompt for a correction.

Source: [github.com/jaesolshin/autocorrect](https://github.com/jaesolshin/autocorrect)
