0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

hookスクリプトが黙って死ぬ ― macOSで踏んだ3つの罠

0
Posted at

前作「Claude Codeエージェントのfrontmatterとインデックスを自動同期する」に続く「Claude Code環境」シリーズです。

hookスクリプトを書いたのに通知が来ない。エラーも出ない。ログにも何も残らない。macOSでClaude CodeのhookをzshスクリプトとしてLLMに書かせると、こういうことが起きる。理由は毎回違う3つの罠のどれかで、いずれも無音で失敗するのが共通点だ。

困りごと:エラーゼロのまま通知が止まった

2026-07-18、note-autolikeプロジェクトのStopフックから失敗通知が届かなくなった。スクリプトは exit 0 で終わっている。Claude Codeはエラーを吐いていない。bash -x でトレースを取ると、分岐が全く条件を満たしていない。原因を掘ると3つ別々の罠に同時に引っかかっていた。

罠1:zshの status は読み取り専用予約変数

何が起きるか

# ❌ zsh では status は read-only 予約変数
status=$?
if [[ $status -ne 0 ]]; then
  notify_failure   # ← 永遠に呼ばれない
fi

status=$?エラーなしに無視される。zshでは status が最後のコマンド終了コードの別名として予約されており、代入できない。bashでは普通に動くため、bashで書いたスクリプトを .zsh に移植すると気づかない。

診断

echo ${(t)status}
# → "integer-readonly-special"  ← special を含めば代入禁止

echo ${(t)rc}
# → ""  ← 未定義 = 安全に使える

# zsh の read-only 変数一覧
typeset -r | grep '='

修正

# ✅ 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
}

zshの主な代入禁止変数: status / signals / commands / options / aliases / functions / modules / history。bashから移植するスクリプトはこれらを使っていないか先に走査する。

grep -n '\bstatus=' your_script.zsh
grep -n '\bsignals=\|\bcommands=\|\boptions=\|\baliases=' your_script.zsh

罠2:GUIから起動したhookは最小PATHしか持たない

何が起きるか

Claude Code (.app) をGUIで起動してhookが動くとき、プロセスは ~/.zshrc を読まない。子プロセスに渡るPATHは最小値だけだ。

/usr/bin:/bin:/usr/sbin:/sbin

nvm下の node もHomebrewの各ツールも、この最小PATHには入っていない。node "...mjs" を呼ぶhookは毎回 node: command not found で無音終了する。

診断

GUIから起動した子プロセスの見え方を再現する:

env -i HOME="$HOME" /bin/sh -c 'PATH="/usr/bin:/bin"; node --version'
# → sh: node: command not found

修正

~/.claude/settings.json のトップレベルに env.PATH を追加してhookに継承させる:

{
  "env": {
    "PATH": "~/.nvm/versions/node/<ver>/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin"
  }
}

Bashツール側はプロファイルを再読み込みするので、この設定はhookなどプロファイル非読み込みの子プロセスにだけ効く。nvmのバージョンパスが将来staleになっても /opt/homebrew/bin/node がフォールバックするので壊れない。

確認:

# 設定後に 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"

env.PATH は短すぎず長すぎず、ログインPATHの主要ディレクトリを網羅したsupersetにする。設定前に cp ~/.claude/settings.json ~/.claude/settings.json.bak でバックアップ。settings.jsonはプラグインがライブ書き換えすることがあるので、Editツールで変更する前に必ず再Readする。

罠3:macOSに timeout コマンドがない

何が起きるか

hookスクリプトで timeout 60 some_command と書くと、macOSでは

timeout: command not found

GNU coreutilsの timeout はmacOSに標準搭載されない。他のドキュメントが前提にしていても、そのまま動かない。timeout 呼び出しの行だけが無音でスキップされ、タイムアウト保護がゼロのままスクリプトが走り続ける。

診断

command -v timeout   # → 何も出ない(macOS標準には存在しない)
command -v gtimeout  # → coreutilsが入っていれば /opt/homebrew/bin/gtimeout

修正

brew install coreutils   # gtimeout が /opt/homebrew/bin に入る

スクリプトは gtimeout を優先検出するパターンにする:

TIMEOUT_CMD=""
command -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60"

# ...
$TIMEOUT_CMD some_command

$TIMEOUT_CMD が空なら some_command だけが実行される。タイムアウトなしで動き続けるが少なくともエラーにはならない。完全に保護したい場合は明示的に死ぬ:

command -v gtimeout >/dev/null || { echo "ERROR: coreutils not installed (brew install coreutils)"; exit 1; }

gtimeout を使うスクリプト(post_tsc_check.sh / skills-auto-update.sh など)は command -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60" のパターンで書いてある。brew install coreutils さえ通れば設計通りに起動する。

踏んだ落とし穴

  • status=$? は代入ではなくno-op: 分岐が全部スルーされるので「ロジックが間違っている」と勘違いして時間を溶かす。zshスクリプトは冒頭で echo ${(t)status} を打って型を確認する習慣をつける
  • bashで動く ≠ zshで動く: shebangが #!/bin/sh のままzshに持ち込んだスクリプトは特に危険。#!/bin/zsh + zsh -n でシンタックスチェックを先に通す
  • hookの終了コードはClaude Codeへのシグナル: 非0で終わると操作がブロックされる。try/except(Python埋め込みの場合)や条件分岐の末尾は必ず exit 0 で返す
  • env.PATH の長さ: 実在しないディレクトリを大量に列挙すると起動に影響する場合がある。nvmのバージョンパスは1つに絞り、Homebrew fallbackで安全網を張る
  • settings.jsonのライブ書き換え: agentmemoryなどプラグインが設定を更新することがある。Editツールで書き換える前に再Readして衝突を避ける

まとめ

3つの罠はいずれもエラーなしに黙って失敗するという共通パターンを持つ。

  • zshの status は読み取り専用 → rc=$? に改名。診断は echo ${(t)status} で一発
  • GUIアプリから起動されるhookは最小PATH → ~/.claude/settings.jsonenv.PATH を足す
  • macOSに timeout は存在しない → brew install coreutilsgtimeout を使う

3つとも「bashでは動く」「CIのLinuxでは通る」状態になりやすい。zshスクリプトを書いたら ${(t)変数名} 確認・env -i 再現・command -v timeout 確認の3点セットを先に打つのが最速の診断フローだった。

次回は、hookが蓄積したJSONLログから何をどう読み出しているか ―― [Claude Code hookのJSONLログ設計と集計パターン]を書きます。


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?