8 min read
AI assisted

autocorrect.zsh — 167 lines of zsh that fix failed commands

Gemini Flash structured output and zsh preexec/precmd hooks — failed commands fixed in place, no new terminal app

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

#──────────────────────────────────────────────────────────────────────────────
# 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_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:

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

# 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