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.
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.
status
is a read-only reserved variable
status=$?
if [[ $status -ne 0 ]]; then
notify_failure # ← 永遠に呼ばれない
fi
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
.
echo ${(t)status}
echo ${(t)rc}
typeset -r | grep '='
rc=$?
if [[ $rc -ne 0 ]]; then
notify_failure
fi
run_and_check() {
"$@"
local rc=$?
[[ $rc -ne 0 ]] && echo "ERROR: $* returned $rc"
return $rc
}
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
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
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.
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'
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"
}
}
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 -i HOME="$HOME" /bin/sh -c 'PATH="<settings.jsonのenv.PATHの値>"; node --version && command -v node'
jq -e . ~/.claude/settings.json >/dev/null && echo "settings.json VALID"
Note
Keepenv.PATH
neither 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.
timeout
command
If you write timeout 60 some_command
in a hook script, on macOS you get
timeout: command not found
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.
command -v timeout # → 何も出ない(macOS標準には存在しない)
command -v gtimeout # → coreutilsが入っていれば /opt/homebrew/bin/gtimeout
brew install coreutils # gtimeout が /opt/homebrew/bin に入る
Write the script so it detects gtimeout
first:
TIMEOUT_CMD=""
command -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60"
$TIMEOUT_CMD some_command
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; }
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 coreutils
goes through, they start up as designed.
status=$?
is a no-op, not an assignmentecho ${(t)status}
at the top of a zsh script to check the type#!/bin/sh
shebang still in place are especially dangerous. Run a syntax check first with #!/bin/zsh
zsh -n
exit 0
at the end of try/except blocks (when embedding Python) and conditional branchesenv.PATH
All three traps share the same pattern: they fail silently, with no error.
status
is read-only → rename to rc=$?
. Diagnose it in one shot with echo ${(t)status}
env.PATH
to ~/.claude/settings.json
timeout
→ use gtimeout
via brew 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*