DEV Community

Lily
Lily

Posted on • Originally published at dev.to

When Hook Scripts Die Silently — Three macOS Traps

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
Keep env.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 with cp ~/.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 use gtimeout (post_tsc_check.sh / skills-auto-update.sh, etc.) are written with the command -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60" pattern. As long as brew install coreutils goes 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 running echo ${(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/sh shebang 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 0 at 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 status is read-only → rename to rc=$?. Diagnose it in one shot with echo ${(t)status}
  • Hooks launched from a GUI app get a minimal PATH → add env.PATH to ~/.claude/settings.json
  • macOS has no 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*

Top comments (0)