Posted on Jul 31 • Originally published at dev.to
This is part of the "Claude Code environment" series, following on from Auto-syncing frontmatter and the index for Claude Code agents.
You wrote a hook script, but no notification ever arrives. No error. Nothing in the logs either. When you have an LLM write a Claude Code hook as a zsh script on macOS, this is what happens. The cause is one of three traps, different every time, and what they all share is that they fail silently.
The problem: notifications stopped, with zero errors
On 2026-07-18, failure notifications stopped arriving from the Stop hook in the note-autolike project. The script exits with exit 0. Claude Code isn't emitting any errors. Tracing with bash -x shows that the branches never satisfy their conditions at all. Digging into the cause turned up three separate traps, all hit at once.
Trap 1: in zsh, status is a read-only reserved variable
What happens
# ❌ zsh では status は read-only 予約変数
status=$?
if [[ $status -ne 0 ]]; then
notify_failure # ← 永遠に呼ばれない
fi
Enter fullscreen mode Exit fullscreen mode
status=$? is silently ignored, with no error. In zsh, status is reserved as an alias for the exit code of the last command, and cannot be assigned to. Since it works fine in bash, you won't notice when you port a script written for bash over to .zsh.
Diagnosis
echo ${(t)status}
# → "integer-readonly-special" ← special を含めば代入禁止
echo ${(t)rc}
# → "" ← 未定義 = 安全に使える
# zsh の read-only 変数一覧
typeset -r | grep '='
Enter fullscreen mode Exit fullscreen mode
The fix
# ✅ rc / exit_code / _rc など予約されていない名前を使う
rc=$?
if [[ $rc -ne 0 ]]; then
notify_failure
fi
# 関数化するならこの形が安全
run_and_check() {
"$@"
local rc=$?
[[ $rc -ne 0 ]] && echo "ERROR: $* returned $rc"
return $rc
}
Enter fullscreen mode Exit fullscreen mode
Note
The main non-assignable variables in zsh:status/signals/commands/options/aliases/functions/modules/history. When porting a script from bash, scan it for these first.grep -n '\bstatus=' your_script.zsh grep -n '\bsignals=|\bcommands=|\boptions=|\baliases=' your_script.zsh
Trap 2: hooks launched from the GUI get only a minimal PATH
What happens
When Claude Code (.app) is launched from the GUI and a hook runs, the process does not read ~/.zshrc. The PATH handed down to child processes is just the bare minimum.
/usr/bin:/bin:/usr/sbin:/sbin
Enter fullscreen mode Exit fullscreen mode
Neither node under nvm nor any of the Homebrew tools are on this minimal PATH. A hook that calls node "...mjs" terminates silently with node: command not found every time.
Diagnosis
Reproduce how things look to a child process launched from the GUI:
env -i HOME="$HOME" /bin/sh -c 'PATH="/usr/bin:/bin"; node --version'
# → sh: node: command not found
Enter fullscreen mode Exit fullscreen mode
The fix
Add env.PATH at the top level of ~/.claude/settings.json so it's inherited by hooks:
{
"env": {
"PATH": "~/.nvm/versions/node/<ver>/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin"
}
}
Enter fullscreen mode Exit fullscreen mode
The Bash tool reloads the profile, so this setting only takes effect for child processes that don't read the profile, such as hooks. Even if the nvm version path goes stale in the future, /opt/homebrew/bin/node acts as a fallback, so it won't break.
Verification:
# 設定後に env.PATH の値で再現テスト
env -i HOME="$HOME" /bin/sh -c 'PATH="<settings.jsonのenv.PATHの値>"; node --version && command -v node'
# → v24.x.x
# → ~/.nvm/versions/node/.../bin/node
jq -e . ~/.claude/settings.json >/dev/null && echo "settings.json VALID"
Enter fullscreen mode Exit fullscreen mode
Note
Keepenv.PATHneither too short nor too long — make it a superset that covers the main directories of your login PATH. Back it up before changing anything withcp ~/.claude/settings.json ~/.claude/settings.json.bak. Plugins sometimes rewrite settings.json live, so always re-Read it before modifying it with the Edit tool.
Trap 3: macOS has no timeout command
What happens
If you write timeout 60 some_command in a hook script, on macOS you get
timeout: command not found
Enter fullscreen mode Exit fullscreen mode
GNU coreutils' timeout doesn't ship with macOS by default. Even if other docs assume it, it won't just work. Only the lines that call timeout get silently skipped, and the script keeps running with zero timeout protection.
Diagnosis
command -v timeout # → 何も出ない(macOS標準には存在しない)
command -v gtimeout # → coreutilsが入っていれば /opt/homebrew/bin/gtimeout
Enter fullscreen mode Exit fullscreen mode
The fix
brew install coreutils # gtimeout が /opt/homebrew/bin に入る
Enter fullscreen mode Exit fullscreen mode
Write the script so it detects gtimeout first:
TIMEOUT_CMD=""
command -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60"
# ...
$TIMEOUT_CMD some_command
Enter fullscreen mode Exit fullscreen mode
If $TIMEOUT_CMD is empty, only some_command runs. It keeps running without a timeout, but at least it doesn't error out. If you want full protection, die explicitly:
command -v gtimeout >/dev/null || { echo "ERROR: coreutils not installed (brew install coreutils)"; exit 1; }
Enter fullscreen mode Exit fullscreen mode
Note
Scripts that usegtimeout(post_tsc_check.sh/skills-auto-update.sh, etc.) are written with thecommand -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60"pattern. As long asbrew install coreutilsgoes through, they start up as designed.
Pitfalls I hit
-
status=$?is a no-op, not an assignment: every branch gets skipped, so you burn time thinking "my logic must be wrong." Get in the habit of runningecho ${(t)status}at the top of a zsh script to check the type -
Works in bash ≠ works in zsh: scripts carried over to zsh with a
#!/bin/shshebang still in place are especially dangerous. Run a syntax check first with#!/bin/zsh+zsh -n -
A hook's exit code is a signal to Claude Code: exiting non-zero blocks the operation. Always return with
exit 0at the end of try/except blocks (when embedding Python) and conditional branches -
The length of
env.PATH: listing a large number of non-existent directories can affect startup in some cases. Narrow the nvm version paths down to one and use the Homebrew fallback as a safety net - Live rewrites of settings.json: plugins such as agentmemory sometimes update the settings. Re-Read before rewriting with the Edit tool to avoid conflicts
Summary
All three traps share the same pattern: they fail silently, with no error.
- zsh's
statusis read-only → rename torc=$?. Diagnose it in one shot withecho ${(t)status} - Hooks launched from a GUI app get a minimal PATH → add
env.PATHto~/.claude/settings.json - macOS has no
timeout→ usegtimeoutviabrew install coreutils
All three tend to end up in the state of "it works in bash" and "it passes on CI's Linux." The fastest diagnostic flow was to run the three-part set first whenever you write a zsh script: check ${(t)variable_name}, reproduce with env -i, and check command -v timeout.
Next time I'll write about what the hooks read out of the JSONL logs they accumulate, and how — [JSONL log design and aggregation patterns for Claude Code hooks].
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.