{"slug": "when-hook-scripts-die-silently-three-macos-traps", "title": "When Hook Scripts Die Silently — Three macOS Traps", "summary": "A developer documented three silent failure traps in Claude Code hook scripts on macOS, including zsh's read-only 'status' variable, missing PATH when launched from the GUI, and the absence of the GNU 'timeout' command. The post recommends using alternative variable names, setting env.PATH in settings.json, and avoiding timeout in hooks.", "body_md": "This is part of the \"Claude Code environment\" series, following on from [Auto-syncing frontmatter and the index for Claude Code agents](https://zenn.dev/bokuwalily/articles/agents-frontmatter-index).\n\nYou 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**.\n\nOn 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`\n\nshows that the branches never satisfy their conditions at all. Digging into the cause turned up three separate traps, all hit at once.\n\n`status`\n\nis a read-only reserved variable\n\n```\n# ❌ zsh では status は read-only 予約変数\nstatus=$?\nif [[ $status -ne 0 ]]; then\n  notify_failure   # ← 永遠に呼ばれない\nfi\n```\n\n`status=$?`\n\nis **silently ignored, with no error**. In zsh, `status`\n\nis 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`\n\n.\n\n```\necho ${(t)status}\n# → \"integer-readonly-special\"  ← special を含めば代入禁止\n\necho ${(t)rc}\n# → \"\"  ← 未定義 = 安全に使える\n\n# zsh の read-only 変数一覧\ntypeset -r | grep '='\n# ✅ rc / exit_code / _rc など予約されていない名前を使う\nrc=$?\nif [[ $rc -ne 0 ]]; then\n  notify_failure\nfi\n\n# 関数化するならこの形が安全\nrun_and_check() {\n  \"$@\"\n  local rc=$?\n  [[ $rc -ne 0 ]] && echo \"ERROR: $* returned $rc\"\n  return $rc\n}\n```\n\nNote\n\nThe main non-assignable variables in zsh:`status`\n\n/`signals`\n\n/`commands`\n\n/`options`\n\n/`aliases`\n\n/`functions`\n\n/`modules`\n\n/`history`\n\n. When porting a script from bash, scan it for these first.\n\n```\ngrep -n '\\bstatus=' your_script.zsh\ngrep -n '\\bsignals=&#124;\\bcommands=&#124;\\boptions=&#124;\\baliases=' your_script.zsh\n```\n\nWhen Claude Code (.app) is launched from the GUI and a hook runs, the process does not read `~/.zshrc`\n\n. The PATH handed down to child processes is just the bare minimum.\n\n```\n/usr/bin:/bin:/usr/sbin:/sbin\n```\n\nNeither `node`\n\nunder nvm nor any of the Homebrew tools are on this minimal PATH. A hook that calls `node \"...mjs\"`\n\nterminates silently with `node: command not found`\n\nevery time.\n\nReproduce how things look to a child process launched from the GUI:\n\n```\nenv -i HOME=\"$HOME\" /bin/sh -c 'PATH=\"/usr/bin:/bin\"; node --version'\n# → sh: node: command not found\n```\n\nAdd `env.PATH`\n\nat the top level of `~/.claude/settings.json`\n\nso it's inherited by hooks:\n\n```\n{\n  \"env\": {\n    \"PATH\": \"~/.nvm/versions/node/<ver>/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin\"\n  }\n}\n```\n\nThe 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`\n\nacts as a fallback, so it won't break.\n\nVerification:\n\n```\n# 設定後に env.PATH の値で再現テスト\nenv -i HOME=\"$HOME\" /bin/sh -c 'PATH=\"<settings.jsonのenv.PATHの値>\"; node --version && command -v node'\n# → v24.x.x\n# → ~/.nvm/versions/node/.../bin/node\n\njq -e . ~/.claude/settings.json >/dev/null && echo \"settings.json VALID\"\n```\n\nNote\n\nKeep`env.PATH`\n\nneither 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`\n\n. Plugins sometimes rewrite settings.json live, so always re-Read it before modifying it with the Edit tool.\n\n`timeout`\n\ncommand\nIf you write `timeout 60 some_command`\n\nin a hook script, on macOS you get\n\n```\ntimeout: command not found\n```\n\nGNU coreutils' `timeout`\n\ndoesn't ship with macOS by default. Even if other docs assume it, it won't just work. Only the lines that call `timeout`\n\nget silently skipped, and the script keeps running with zero timeout protection.\n\n```\ncommand -v timeout   # → 何も出ない（macOS標準には存在しない）\ncommand -v gtimeout  # → coreutilsが入っていれば /opt/homebrew/bin/gtimeout\nbrew install coreutils   # gtimeout が /opt/homebrew/bin に入る\n```\n\nWrite the script so it detects `gtimeout`\n\nfirst:\n\n```\nTIMEOUT_CMD=\"\"\ncommand -v gtimeout >/dev/null && TIMEOUT_CMD=\"gtimeout 60\"\n\n# ...\n$TIMEOUT_CMD some_command\n```\n\nIf `$TIMEOUT_CMD`\n\nis empty, only `some_command`\n\nruns. It keeps running without a timeout, but at least it doesn't error out. If you want full protection, die explicitly:\n\n```\ncommand -v gtimeout >/dev/null || { echo \"ERROR: coreutils not installed (brew install coreutils)\"; exit 1; }\n```\n\nNote\n\nScripts that use`gtimeout`\n\n(`post_tsc_check.sh`\n\n/`skills-auto-update.sh`\n\n, etc.) are written with the`command -v gtimeout >/dev/null && TIMEOUT_CMD=\"gtimeout 60\"`\n\npattern. As long as`brew install coreutils`\n\ngoes through, they start up as designed.\n\n`status=$?`\n\nis a no-op, not an assignment`echo ${(t)status}`\n\nat the top of a zsh script to check the type`#!/bin/sh`\n\nshebang still in place are especially dangerous. Run a syntax check first with `#!/bin/zsh`\n\n+ `zsh -n`\n\n`exit 0`\n\nat the end of try/except blocks (when embedding Python) and conditional branches`env.PATH`\n\nAll three traps share the same pattern: they **fail silently, with no error**.\n\n`status`\n\nis read-only → rename to `rc=$?`\n\n. Diagnose it in one shot with `echo ${(t)status}`\n\n`env.PATH`\n\nto `~/.claude/settings.json`\n\n`timeout`\n\n→ use `gtimeout`\n\nvia `brew install coreutils`\n\nAll 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**.\n\nNext 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**].\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/when-hook-scripts-die-silently-three-macos-traps", "canonical_source": "https://dev.to/bokuwalily/when-hook-scripts-die-silently-three-macos-traps-3cgh", "published_at": "2026-07-31 05:00:02+00:00", "updated_at": "2026-07-31 05:32:03.144449+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Claude Code", "zsh", "macOS", "nvm", "Homebrew"], "alternates": {"html": "https://wpnews.pro/news/when-hook-scripts-die-silently-three-macos-traps", "markdown": "https://wpnews.pro/news/when-hook-scripts-die-silently-three-macos-traps.md", "text": "https://wpnews.pro/news/when-hook-scripts-die-silently-three-macos-traps.txt", "jsonld": "https://wpnews.pro/news/when-hook-scripts-die-silently-three-macos-traps.jsonld"}}