PostToolUse shell-form hook not executed: treats shell variable $f as missing env var

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 0 comments · opened Jul 31, 2026

Summary

A shell-form PostToolUse command hook that uses a normal shell loop variable $f fails with a message along the lines of "hook not executed: couldn't find env var f". The hook never runs, so auto-formatting after Edit/Write is skipped.

Claude Code appears to be interpreting a shell-local variable ($f from while read -r f) as a required environment variable that must exist before the hook is spawned, instead of leaving $f for the shell to expand at runtime.

Environment

  • Claude Code: 2.1.220
  • OS: macOS 26.5.1 (Darwin 25.5.0, arm64)
  • Shell: zsh / bash (hook shell form)

Minimal reproduction

In ~/.claude/settings.json (or project settings):

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path // empty' | while read -r f; do\n  [[ -z \"$f\" || ! -f \"$f\" ]] && continue;\n  case \"$f\" in\n    *.go) command -v gofmt &>/dev/null && gofmt -w \"$f\";;\n    *.py) command -v ruff &>/dev/null && ruff format -q \"$f\";;\n    *.js|*.ts|*.tsx|*.jsx|*.css) command -v prettier &>/dev/null && prettier --write --log-level silent \"$f\";;\n    *.sh|*.bash) command -v shfmt &>/dev/null && shfmt -w \"$f\";;\n  esac;\ndone; true"
          }
        ]
      }
    ]
  }
}

Steps:

  1. Start Claude Code in any project with jq available.
  2. Ask Claude to create or edit a file (e.g. write a trivial .go or .py file).
  3. Observe the PostToolUse hook result after Edit/Write.

Actual behavior

  • UI reports something like: hook not executed: couldn't find env var f (or equivalent wording about missing env var f).
  • The hook command is not executed.
  • Formatters (gofmt / ruff / prettier / shfmt) never run.

Expected behavior

  • The command string is passed to the shell as shell-form (sh -c / bash).
  • $f is a shell loop variable set by while read -r f, not a process environment variable that must be pre-set.
  • The hook runs successfully whenever Edit/Write produces a tool_input.file_path.

Analysis / suspected cause

  1. The hook is shell form (no args), so per docs the full command string should be executed by a shell. Shell variables like $f are valid and common.
  2. Claude Code already has env-var interpolation paths for hooks (e.g. HTTP hooks log Hooks: env var $… not in allowedEnvVars, skipping interpolation, and path placeholders like ${CLAUDE_PROJECT_DIR} / ${CLAUDE_PLUGIN_ROOT}).
  3. It looks like bare $f (or "$f") in a command hook is being treated as an environment-variable reference that must resolve before spawn, rather than being left for the shell.
  4. Note: HTTP-header interpolation only matches uppercase $VAR / ${VAR} ([A-Z_][A-Z0-9_]*), so this may be a separate, stricter code path for command hooks that incorrectly scans for $ + identifier including lowercase shell locals.

Workarounds

Any of:

  1. Avoid short shell variables; use a name that is not expanded as an env placeholder (if the scanner only matches certain patterns).
  2. Move the script to a file and run it via exec form:

``json
{
"type": "command",
"command": "bash",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/format-edited-file.sh"]
}
``

  1. Avoid $… in the inline command entirely (e.g. xargs -I{} gofmt -w {} patterns — though xargs may be permission-restricted in some setups).

Why this matters

The official hooks guide recommends shell one-liners that pipe JSON through jq and process paths. Using while read -r f / "$f" is idiomatic shell. If any $word that is not a pre-defined env var aborts the hook, a large class of documented shell-form hooks becomes unusable.

Related

Requested fix

  • Do not require shell-local variables (e.g. $f from read/for) to exist as environment variables before spawning a shell-form command hook.
  • Only pre-expand known placeholders (${CLAUDE_PROJECT_DIR}, ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}, and documented plugin user_config / allowed env lists).
  • Leave ordinary $var / "$var" for the shell to expand at runtime.

View original on GitHub ↗