PostToolUse shell-form hook not executed: treats shell variable $f as missing env var
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:
- Start Claude Code in any project with
jqavailable. - Ask Claude to create or edit a file (e.g. write a trivial
.goor.pyfile). - Observe the
PostToolUsehook result afterEdit/Write.
Actual behavior
- UI reports something like:
hook not executed: couldn't find env var f(or equivalent wording about missing env varf). - 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). $fis a shell loop variable set bywhile read -r f, not a process environment variable that must be pre-set.- The hook runs successfully whenever
Edit/Writeproduces atool_input.file_path.
Analysis / suspected cause
- The hook is shell form (no
args), so per docs the fullcommandstring should be executed by a shell. Shell variables like$fare valid and common. - 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}). - 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. - 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:
- Avoid short shell variables; use a name that is not expanded as an env placeholder (if the scanner only matches certain patterns).
- 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"]
}
- Avoid
$…in the inline command entirely (e.g.xargs -I{} gofmt -w {}patterns — thoughxargsmay 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
- Hooks reference (shell form vs exec form, path placeholders): https://code.claude.com/docs/en/hooks
- Hooks guide examples using shell pipelines and
jq: https://code.claude.com/docs/en/hooks-guide
Requested fix
- Do not require shell-local variables (e.g.
$ffromread/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 pluginuser_config/ allowed env lists). - Leave ordinary
$var/"$var"for the shell to expand at runtime.