公式ドキュメントを読んでも、何から書けばいいのか手が止まった経験はないだろうか。Hooksの仕様は理解できた。でもいざ自分のシェルスクリプトを書こうとすると、最初の1行でつまずく。そういう「理解はしているが動かせていない」状態は、実際の稼働例が手元にないまま書き始めようとするときに起きやすい。
この記事では私が79日間実際に動かし続けている3本のHookを、シェルスクリプトの全文ごと公開する。コピペして動かしながら改造する形で使ってほしい。
Hooksの基本仕様をおさらいする
Hooksは ~/.claude/settings.json に登録する。発火タイミングをEventで指定し、実行するシェルコマンドを command で渡すだけだ。
主要なEventは以下の通り。
| Event | 発火タイミング | ツール実行をブロックできるか |
|---|---|---|
PreToolUse | ツール実行前 | Yes(exit 2で拒否) |
PostToolUse | ツール実行後 | No |
Notification | Claude Code が通知を出す時 | No |
Stop | Claudeが応答を終了する時 | Yes |
SessionStart | セッション開始・再開時 | No |
Hookに渡されるのはstdinのJSON。tool_name や tool_input などの情報が含まれ、jq でパースして使う。
exit codeの意味はシンプルだ。0 で成功、2 でエラー(ブロック)、それ以外はノンブロッキングエラー。PreToolUseで exit 2 を返すとツール呼び出しを止められる。Stopで exit 2 を返すとClaudeが終了できなくなるのでループに注意。
設定の最小例はこうなる。
{
"hooks": {
"PreToolUse": [{
"hooks": [{
"type": "command",
"command": "~/.claude/scripts/permission-logger.sh"
}]
}]
}
}実例1: permission-logger.sh — 全ツール呼び出しを日次ログに記録する
Claude Codeがどのツールをどういう順番で呼び出しているかを把握するには、ログを蓄積するしかない。この permission-logger.sh はPreToolUseで毎回発火し、ツール名と引数のサマリーを日付別のJSONLファイルに書き込む。
#!/bin/bash
LOG_DIR="$HOME/.claude/logs/permissions"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/$(date +%Y-%m-%d).jsonl"
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
SUMMARY=$(echo "$INPUT" | jq -c '.tool_input // {} | {
command: .command,
file_path: .file_path,
url: .url,
skill: .skill
} | with_entries(select(.value != null))' 2>/dev/null || echo "{}")
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"tool\":\"$TOOL_NAME\",\"summary\":$SUMMARY}" >> "$LOG_FILE"
exit 0設計で気をつけた点が2つある。
tool_input を全フィールド記録するとJSONが肥大化する。私の場合は command・file_path・url・skill の4フィールドだけに絞り、with_entries(select(.value != null)) でnull値を除外している。79日間で溜まったJSONLは管理しやすいサイズに収まっている。
もうひとつは最後に必ず exit 0 を書くこと。ログ取得のHookでツール呼び出しをブロックしてしまうと、Claude Codeの動作全体が止まる。PreToolUseに登録するHookは、明示的にブロックしたい場合を除いてexit 0で終わらせる。
なお、このスクリプトはPreToolUse全てに対して発火するため、settings.jsonにマッチャー(matcher)を指定していない。特定のツールだけ記録したい場合は "matcher": "Bash" のように絞ることもできる。
実例2: permission-review-check.sh — ログが溜まったらStopで知らせる
ログを蓄積するだけでは、いつまでも見直さないまま積み上がる。permission-review-check.sh はClaudeが応答を終了するたびに発火し、前回レビュー以降の新規ログが閾値(20件)を超えていたらstdoutにメッセージを出す。
#!/bin/bash
LOG_DIR="$HOME/.claude/logs/permissions"
THRESHOLD=20
MARKER="$LOG_DIR/.last_review_ts"
if [ ! -d "$LOG_DIR" ]; then
exit 0
fi
if [ -f "$MARKER" ]; then
COUNT=$(find "$LOG_DIR" -name "*.jsonl" -newer "$MARKER" -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
else
COUNT=$(cat "$LOG_DIR"/*.jsonl 2>/dev/null | wc -l | tr -d ' ')
fi
if [ "$COUNT" -ge "$THRESHOLD" ]; then
echo "📋 パーミッションログが${COUNT}件溜まっています。/permission-review を実行してください。"
fiマーカーファイル .last_review_ts がポイントだ。前回のレビュー完了時にこのファイルのタイムスタンプを更新することで、「前回レビュー以降に追加されたログ件数」だけをカウントできる。全体のログをカウントしてしまうと、古いレビュー済みのエントリも毎回引っかかってしまう。
このHookとCLAUDE.mdを組み合わせると面白い連鎖が起きる。私のCLAUDE.mdには「Stopフックから『📋 パーミッションログが〇件溜まっています』というメッセージが届いたら、自動的に /permission-review スキルを実行する」と書いてある。Hookがメッセージを出す→Claude CodeがCLAUDE.mdのルールを読む→Skill実行、という連鎖だ。Hookそのものに全ての処理を詰め込まなくても、メッセージを橋渡しにしてSkillに委譲できる。
Hookの権限制御の設計についてはClaude Code セキュリティ運用の実態で詳しく書いている。Hookで記録した内容をどう活かすかに興味があればあわせて読んでほしい。
実例3: cmux-notify.sh — NotificationとStopをデスクトップ通知に転送する
cmuxは複数ターミナルを一元管理するツールで、ソケット経由でデスクトップ通知を送る機能がある。cmux-notify.sh はNotificationイベントとStopイベントの両方に登録し、Claude Codeの通知をデスクトップに転送する。同じスクリプトを複数のイベントで使い回している例でもある。
#!/bin/bash
command -v cmux >/dev/null 2>&1 || exit 0
CMUX_SOCK="${CMUX_SOCKET:-/tmp/cmux.sock}"
[ -S "$CMUX_SOCK" ] || exit 0
INPUT=$(cat)
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"')
case "$EVENT" in
"Notification")
TITLE=$(echo "$INPUT" | jq -r '.title // "Claude Code"')
BODY=$(echo "$INPUT" | jq -r '.message // "Notification"')
cmux --socket "$CMUX_SOCK" notify --title "$TITLE" --body "$BODY"
;;
"Stop")
MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // "Session complete"' | head -c 80)
cmux --socket "$CMUX_SOCK" notify --title "Claude Code - Complete" --body "$MSG"
;;
esac冒頭2行の環境依存ガードがこのスクリプトの核だ。cmuxがインストールされていないマシン、ソケットが起動していないマシンでは即 exit 0 で終了する。これがないと、cmuxを使っていない環境でHookを有効にしたとたんに毎ターン失敗通知がClaudeに届く。
settings.jsonへの登録はこうなる。
"Notification": [{
"hooks": [{ "type": "command", "command": "~/.claude/scripts/cmux-notify.sh" }]
}],
"Stop": [
{ "hooks": [{ "type": "command", "command": "~/.claude/scripts/permission-review-check.sh" }] },
{ "hooks": [{ "type": "command", "command": "~/.claude/scripts/cmux-notify.sh" }] }
]Stopには2つのHookをチェーンさせている。ログ確認とデスクトップ通知は独立したスクリプトに分けることで、片方を修正しても他方に影響が出ない。
Hook設計で意識すること
実際に79日間運用してきた中で、繰り返し気になる設計上のポイントがある。
exit 0 をデフォルトにする
PreToolUseに登録するHookで意図せず exit 2 を返すと、そのターンでツール実行が全てブロックされる。ログ目的・通知目的のHookは最後に必ず exit 0 を書く。exit codeを明示しないとシェルの戻り値が予期せぬ値になることもある。必ず明記する。
環境依存を冒頭でガードする
cmux-notify.shのように、外部コマンドの存在を command -v でチェックし、なければ即 exit 0 で終わらせる。Hookは全プロジェクト共通で発火するため、ある環境で動いても別の環境で壊れることがある。ガードを冒頭に書くだけで、環境を移したときの障害が大幅に減る。
Hookに責務を詰め込まない
permission-logger(記録)とpermission-review-check(閾値判定)とpermission-review Skill(実際の整理)を分けているのはこの原則の実践だ。1本のHookに全てを詰め込まず、シンプルなHookを連鎖させる構造にしておくと、個別にテスト・修正できる。SkillとHookの設計原則の対比についてはClaude Code Skill設計論も参照してほしい。
セキュリティについて
Hooksは任意のシェルコマンドを実行する。プロジェクトの .claude/settings.json に含まれるHookは、リポジトリをクローンしたメンバー全員の環境で実行されることになる。公式ドキュメントでも「Only use hooks you trust」と明記されている。
チームで共有するHookは必ず内容を読んでから使う。組織での展開時は allowManagedHooksOnly オプションを設定することで、管理者が承認したHookだけに制限できる。
自分のホームディレクトリ(~/.claude/settings.json)に置く個人用Hookは、自分の管理下なので比較的リスクは低い。ただし、外部から持ってきたHookをそのまま登録するのは避けること。
まとめ
3本のHookを入れてから、私のClaude Code環境ではツール呼び出しの記録が自動で溜まり、ログが積み上がったタイミングで通知が届いてSkillが走るようになった。手元で何も意識しなくてもサイクルが回っている感覚がある。
まずpermission-logger.shだけ動かしてみてほしい。ログが溜まり始めたらpermission-review-check.shを追加する。1本追加するたびに動作が確認できるので、怖くない。
公式リファレンスは Claude Code Hooks ドキュメント に詳しい。settings.jsonのフォーマットや全Eventの仕様を確認したいときはここを見てほしい。

コメント