🔔

Claude Code にはもっとぴろぴろしてほしい(hookで通知を表示する)

に公開

Claude Codeを使用していると、コマンド実行やファイル操作などの処理が完了したタイミングが分かりにくく、次のプロンプトを入力する適切なタイミングを見逃してしまうことがありました。特に長時間の処理や複数のツール実行時に、いちいち画面を監視し続けるのは非効率的です。

そこで、Claude Codeのhooks機能を活用して、すべての操作に対してmacOS通知とサウンドで処理状況を知らせるシステムを構築することにしました。これにより、他の作業をしながらでもClaude Codeの処理完了を即座に把握できるようになります。

成果物は日本語で読みやすくするように多少の工夫が入っています。
ついでなので他にもあるhook全部入れてみました。不要なやつは適宜オフにしてください。
完了のhookはstophookです。

成果物

terminal-notifier が必要です。
https://github.com/julienXX/terminal-notifier
brew でインストール可能です。

作らせたファイルは以下で、セットアップはこの記事をclaudeに読ませてセットアップしてって言えばやってくれるんじゃないかと思います。

#!/bin/bash

# Read JSON input from stdin
input=$(cat)

# Parse notification message from JSON
notification_message=$(echo "$input" | jq -r '.message // "System notification"')

# Truncate long messages
display_message="$notification_message"
if [ ${#display_message} -gt 60 ]; then
    display_message="${display_message:0:57}..."
fi

# Create message
message="🔔 システム通知: $display_message
(Notification hookがトリガーされました)"

# Send notification and play sound
terminal-notifier -title "Claude Code Hook" -message "$message" && afplay /System/Library/Sounds/Purr.aiff
#!/bin/bash

# Read JSON input from stdin
input=$(cat)

# Parse tool name and response from JSON
tool_name=$(echo "$input" | jq -r '.tool_name // "Unknown"')
tool_input=$(echo "$input" | jq -r '.tool_input // {}')
tool_response=$(echo "$input" | jq -r '.tool_response // {}')

# Create specific message based on tool type and response
case "$tool_name" in
    "Bash")
        command=$(echo "$tool_input" | jq -r '.command // "command"')

        # Check if command was interrupted or had stderr
        interrupted=$(echo "$tool_response" | jq -r '.interrupted // false')
        stderr=$(echo "$tool_response" | jq -r '.stderr // ""')

        # Since Claude Code doesn't always populate stderr, check the global error context
        # Error is indicated when the Bash tool itself returns an error response
        if [ "$interrupted" = "true" ] || [ -n "$stderr" ] || echo "$input" | grep -q '"type":"error"'; then
            status="❌"
        else
            status="✅"
        fi
        # Truncate long commands
        if [ ${#command} -gt 40 ]; then
            command="${command:0:37}..."
        fi
        message="✅ 実行完了:
$command $status
(PostToolUse hookがトリガーされました)"
        ;;
    "Write")
        file_path=$(echo "$tool_input" | jq -r '.file_path // "file"')
        filename=$(basename "$file_path")
        content_length=$(echo "$tool_input" | jq -r '.content | length // 0')
        message="✅ 書き込み完了:
$filename (${content_length} 文字)
(PostToolUse hookがトリガーされました)"
                ;;
    "Read")
        file_path=$(echo "$tool_input" | jq -r '.file_path // "file"')
        filename=$(basename "$file_path")

        # Get line count from file.totalLines or file.numLines
        line_count=$(echo "$tool_response" | jq -r '.file.totalLines // .file.numLines // "?"')
        message="✅ 読み込み完了:
$filename ($line_count 行)
(PostToolUse hookがトリガーされました)"
        ;;
    "Edit"|"MultiEdit")
        file_path=$(echo "$tool_input" | jq -r '.file_path // "file"')
        filename=$(basename "$file_path")
        # Count edits for MultiEdit
        if [ "$tool_name" = "MultiEdit" ]; then
            edit_count=$(echo "$tool_input" | jq -r '.edits | length // 1')
            message="✅ 一括編集完了:
$filename (${edit_count} 箇所)
(PostToolUse hookがトリガーされました)"
        else
            message="✅ 編集完了:
$filename
(PostToolUse hookがトリガーされました)"
        fi
        ;;
    "Glob")
        pattern=$(echo "$tool_input" | jq -r '.pattern // "pattern"')
        search_path=$(echo "$tool_input" | jq -r '.path // "."')

        # Show path relative to home or absolute
        if [[ "$search_path" == */Users/riin/* ]]; then
            display_path="~$(echo "$search_path" | sed 's|/Users/riin||')"
        else
            display_path="$search_path"
        fi

        # Count results by counting lines in tool_response
        result_count=$(echo "$tool_response" | wc -l | tr -d ' ')
        message="✅ ファイル検索完了:
$pattern in $display_path ($result_count 件)
(PostToolUse hookがトリガーされました)"
        ;;
    "Grep")
        pattern=$(echo "$tool_input" | jq -r '.pattern // "pattern"')
        search_path=$(echo "$tool_input" | jq -r '.path // "."')

        # Show path relative to home or absolute
        if [[ "$search_path" == */Users/riin/* ]]; then
            display_path="~$(echo "$search_path" | sed 's|/Users/riin||')"
        else
            display_path="$search_path"
        fi

        # Count results by counting lines in tool_response
        result_count=$(echo "$tool_response" | wc -l | tr -d ' ')
        message="✅ 文字列検索完了:
$pattern in $display_path ($result_count 件)
(PostToolUse hookがトリガーされました)"
        ;;
    "LS")
        path=$(echo "$tool_input" | jq -r '.path // "."')

        # Show path relative to home or absolute
        if [[ "$path" == */Users/riin/* ]]; then
            display_path="~$(echo "$path" | sed 's|/Users/riin||')"
        else
            display_path="$path"
        fi

        # Count items by counting non-empty lines in tool_response
        item_count=$(echo "$tool_response" | grep -v '^[[:space:]]*$' | wc -l | tr -d ' ')
        message="✅ 一覧完了:
$display_path ($item_count 項目)
(PostToolUse hookがトリガーされました)"
        ;;
    "Task")
        description=$(echo "$tool_input" | jq -r '.description // "task"')
        message="✅ タスク完了:
$description
(PostToolUse hookがトリガーされました)"
        ;;
    "WebFetch")
        url=$(echo "$tool_input" | jq -r '.url // "URL"')
        domain=$(echo "$url" | sed 's|^https\?://||' | cut -d'/' -f1)
        # Try to get response size
        response_size=$(echo "$tool_response" | jq -r '. | length // "?"')
        message="✅ Webアクセス完了:
$domain ($response_size 文字)
(PostToolUse hookがトリガーされました)"
        ;;
    *)
        message="✅ 実行完了:
$tool_name
(PostToolUse hookがトリガーされました)"
        ;;
esac

# Send notification and play sound
terminal-notifier -title "Claude Code Hook" -message "$message" && afplay /System/Library/Sounds/Glass.aiff

#!/bin/bash

# Read JSON input from stdin
input=$(cat)

# Parse tool name from JSON
tool_name=$(echo "$input" | jq -r '.tool_name // "Unknown"')

# Parse tool_input to get more specific information
tool_input=$(echo "$input" | jq -r '.tool_input // {}')

# Create specific message based on tool type
case "$tool_name" in
    "Bash")
        command=$(echo "$tool_input" | jq -r '.command // "command"')
        # Truncate long commands
        if [ ${#command} -gt 50 ]; then
            command="${command:0:47}..."
        fi
        message="🔧 実行中:
$command
(PreToolUse hookがトリガーされました)"
        ;;
    "Write")
        file_path=$(echo "$tool_input" | jq -r '.file_path // "file"')
        filename=$(basename "$file_path")
        message="🔧 ファイル書き込み中:
$filename
(PreToolUse hookがトリガーされました)"
        ;;
    "Read")
        file_path=$(echo "$tool_input" | jq -r '.file_path // "file"')
        filename=$(basename "$file_path")
        message="🔧 ファイル読み込み中:
$filename
(PreToolUse hookがトリガーされました)"
        ;;
    "Edit")
        file_path=$(echo "$tool_input" | jq -r '.file_path // "file"')
        filename=$(basename "$file_path")
        message="🔧 ファイル編集中:
$filename
(PreToolUse hookがトリガーされました)"
        ;;
    "MultiEdit")
        file_path=$(echo "$tool_input" | jq -r '.file_path // "file"')
        filename=$(basename "$file_path")
        message="🔧 ファイル一括編集中:
$filename
(PreToolUse hookがトリガーされました)"
        ;;
    "Glob")
        pattern=$(echo "$tool_input" | jq -r '.pattern // "pattern"')
        search_path=$(echo "$tool_input" | jq -r '.path // "."')
        
        # Show path relative to home or absolute
        if [[ "$search_path" == */Users/riin/* ]]; then
            display_path="~$(echo "$search_path" | sed 's|/Users/riin||')"
        else
            display_path="$search_path"
        fi
        
        message="🔧 ファイル検索中:
$pattern in $display_path
(PreToolUse hookがトリガーされました)"
        ;;
    "Grep")
        pattern=$(echo "$tool_input" | jq -r '.pattern // "pattern"')
        search_path=$(echo "$tool_input" | jq -r '.path // "."')
        
        # Show path relative to home or absolute
        if [[ "$search_path" == */Users/riin/* ]]; then
            display_path="~$(echo "$search_path" | sed 's|/Users/riin||')"
        else
            display_path="$search_path"
        fi
        
        message="🔧 文字列検索中:
$pattern in $display_path
(PreToolUse hookがトリガーされました)"
        ;;
    "LS")
        path=$(echo "$tool_input" | jq -r '.path // "."')
        
        # Show path relative to home or absolute
        if [[ "$path" == */Users/riin/* ]]; then
            display_path="~$(echo "$path" | sed 's|/Users/riin||')"
        else
            display_path="$path"
        fi
        
        message="🔧 ディレクトリ一覧中:
$display_path
(PreToolUse hookがトリガーされました)"
        ;;
    "Task")
        description=$(echo "$tool_input" | jq -r '.description // "task"')
        message="🔧 タスク実行中:
$description
(PreToolUse hookがトリガーされました)"
        ;;
    "WebFetch")
        url=$(echo "$tool_input" | jq -r '.url // "URL"')
        # Extract domain from URL
        domain=$(echo "$url" | sed 's|^https\?://||' | cut -d'/' -f1)
        message="🔧 Webアクセス中:
$domain
(PreToolUse hookがトリガーされました)"
        ;;
    *)
        message="🔧 ツール実行中:
$tool_name
(PreToolUse hookがトリガーされました)"
        ;;
esac

# Send notification and play sound
terminal-notifier -title "Claude Code Hook" -message "$message" && afplay /System/Library/Sounds/Blow.aiff
#!/bin/bash

# Read JSON input from stdin
input=$(cat)

# Parse session information
session_id=$(echo "$input" | jq -r '.session_id // "unknown"')
source=$(echo "$input" | jq -r '.source // "unknown"')
cwd=$(echo "$input" | jq -r '.cwd // "."')

# Get current working directory name
current_dir=$(basename "$cwd")

# Create message based on source
case "$source" in
    "startup")
        message="🚀 セッション開始: 新規セッション → $current_dir
(SessionStart hookがトリガーされました)"
        ;;
    "resume")
        message="🚀 セッション開始: セッション復元 → $current_dir
(SessionStart hookがトリガーされました)"
        ;;
    "clear")
        message="🚀 セッション開始: セッションクリア → $current_dir
(SessionStart hookがトリガーされました)"
        ;;
    *)
        message="🚀 セッション開始: ($source) → $current_dir
(SessionStart hookがトリガーされました)"
        ;;
esac

# Send notification and play sound
terminal-notifier -title "Claude Code Hook" -message "$message" && afplay /System/Library/Sounds/Hero.aiff
#!/bin/bash

# Read JSON input from stdin
input=$(cat)

# Parse session information
session_id=$(echo "$input" | jq -r '.session_id // "unknown"')
stop_hook_active=$(echo "$input" | jq -r '.stop_hook_active // false')

# Get current timestamp for response duration calculation
current_time=$(date +%s)

# Try to extract response information from transcript if available
transcript_path=$(echo "$input" | jq -r '.transcript_path // ""')

response_info=""
if [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then
    # Get the last assistant message to estimate response size
    last_response=$(tail -1 "$transcript_path" 2>/dev/null | jq -r '.message.content // ""' 2>/dev/null)
    if [ -n "$last_response" ] && [ "$last_response" != "null" ]; then
        response_length=${#last_response}
        if [ $response_length -gt 0 ]; then
            response_info=" (${response_length} 文字)"
        fi
    fi
fi

# Prevent infinite loops
if [ "$stop_hook_active" = "true" ]; then
    message="🛑 応答完了: ループ防止
(Stop hookがトリガーされました)"
else
    message="🚀 応答完了${response_info}
(Stop hookがトリガーされました)"
fi

# Send notification and play sound
terminal-notifier -title "Claude Code Hook" -message "$message" && afplay /System/Library/Sounds/Funk.aiff
#!/bin/bash

# Read JSON input from stdin
input=$(cat)

# Parse subagent information
session_id=$(echo "$input" | jq -r '.session_id // "unknown"')
stop_hook_active=$(echo "$input" | jq -r '.stop_hook_active // false')

# Try to get subagent information from transcript
transcript_path=$(echo "$input" | jq -r '.transcript_path // ""')

subagent_info=""
if [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then
    # Look for Task tool usage in recent entries to identify subagent type
    subagent_type=$(tail -10 "$transcript_path" 2>/dev/null | grep -o '"subagent_type":"[^"]*"' | tail -1 | cut -d'"' -f4 2>/dev/null)
    if [ -n "$subagent_type" ] && [ "$subagent_type" != "null" ]; then
        subagent_info=" ($subagent_type)"
    fi
fi

# Prevent infinite loops
if [ "$stop_hook_active" = "true" ]; then
    message="🤖 エージェント完了: ループ防止
(SubagentStop hookがトリガーされました)"
else
    message="🤖 エージェント完了${subagent_info}
(SubagentStop hookがトリガーされました)"
fi

# Send notification and play sound
terminal-notifier -title "Claude Code Hook" -message "$message" && afplay /System/Library/Sounds/Tink.aiff
#!/bin/bash

# Read JSON input from stdin
input=$(cat)

# Parse prompt from JSON
prompt=$(echo "$input" | jq -r '.prompt // "No prompt"')

# Calculate prompt statistics
char_count=${#prompt}
word_count=$(echo "$prompt" | wc -w | tr -d ' ')
line_count=$(echo "$prompt" | wc -l | tr -d ' ')

# Truncate prompt for display (first 40 chars)
display_prompt="$prompt"
if [ ${#display_prompt} -gt 40 ]; then
    display_prompt="${display_prompt:0:37}..."
fi

# Escape quotes for display
display_prompt=$(echo "$display_prompt" | sed 's/"/\\"/g')

# Create message with statistics
message="📝 プロンプト送信: \"$display_prompt\" (${char_count} 文字, ${word_count} 単語)
(UserPromptSubmit hookがトリガーされました)"

# Send notification and play sound
terminal-notifier -title "Claude Code Hook" -message "$message" && afplay /System/Library/Sounds/Ping.aiff

オマケ:今回どのようにプロンプトしたか

0. 仕様をWebから取得する

claudeに仕様を訪ねたところ自分で公式ページからhookのページをfetchして調べてきてくれました。
「じゃあそれをもとに設定してね」としたところ最小の動作確認ができました。

1. 段階的なアプローチ

まず既存のhooks設定(PostToolUse: Bash)から始めて、徐々に全hookイベントに拡張する段階的アプローチを採用しました。これにより、一つずつ動作確認をしながら安全に構築できました。

実際のプロンプト例:

  • 「claude code のすべての hook について、以下のように設定する 1. サウンドを再生する(音はhookごとに異なるものにする) 2. なんのhookによってトリガーされたか表示する」
  • 「良さそうです PostToolUse や他のhookにも追加の情報を盛り込み余地があるか考察せよ」

2. デバッグファーストの開発手法

hookが期待通りに動作しない場合(特にBashエラー時の通知問題)、すぐにデバッグログを仕込んで原因を特定しました。JSON構造の解析やhookの発火タイミングを詳細に調査することで、Claude Codeの内部仕様を理解できました。

問題発生時のプロンプト例:

  • 「PostToolUse の READの行数のカウントがうまくいっていないようです」←ここだけだと実際の挙動を確認しない
  • 「? 行と表示されました 要素を分解してどのステップまで行数を識別できているかを検証
    しながら修正を試みてください」←これによってログを吐かせて実行結果から自律的に修正を試みるようになりました

デバッグ結果を受けた修正指示:

  • 「デバッグログから分かった情報をもとに、正しくエラー状態を判定できるように修正してください」
  • 「tool_responseの構造が異なることが分かったので、ツールごとに適切な情報抽出方法に変更してください」

デバッグのためにhook-debug.logファイルを作成し、すべてのPostToolUse eventをログに出力することで、Claude CodeのJSON構造とhookトリガー条件を詳細に解析できました。これにより、Bashエラー時にPostToolUse hookがトリガーされない仕様や、tool_responseの構造がツールごとに異なることが判明し、適切な対処法を見つけることができました。

主要な発見:

  • Bashツールがエラー時にPostToolUse hookがトリガーされない仕様
  • tool_responseの構造がツールごとに異なる(Read: object, LS: string, Bash: object等)
  • hook間での情報の受け渡し方法

3. 自律的な動作確認・修正サイクル

最も効果的だったのは、AIに「動作確認→問題発見→修正→再確認」のサイクルを自律的に実行させるアプローチでした。これにより、人間が細かく指示しなくても品質の高いシステムが構築できました。

期待動作の明確化プロンプト例:

  • 「Pre Tool Use で なんのコマンドを実行したかのような追加のメッセージを盛り込むことが可能か検討して」
  • 「良さそうだ PostToolUse や他のhookにも追加の情報を盛り込み余地があるか考察せよ」
  • 「LSのときに件数の取得と表示がうまくいっていません また、 Grep,Glob, LS は プロジェクトルートからのパスがわかると良いと思います」

問題発見時の自律的対応:
AIは動作確認中に期待と異なる結果を発見すると、自動的に:

  1. 問題の詳細分析
  2. デバッグ情報の追加
  3. 修正案の実装
  4. 再度の動作確認

を繰り返し実行しました。

自律サイクルの成功要因:

  1. 明確な期待値の設定: 「通知が表示される」「音が出る」など具体的な成功条件
  2. 問題の具体的報告: 「音は出るけど画面に表示されない」など現象の正確な記述
  3. 段階的な改善委任: 「同じような思想で」という抽象的な指示で応用を促す
  4. 継続的な動作確認: 一つの変更ごとに必ず確認を求める

このアプローチにより、人間は大きな方向性を示すだけで、AIが詳細な実装と品質保証を自律的に実行し、非常に効率的な開発が実現できました。

まとめ

とくになし。勝手にぐるぐる直してくれるとめっちゃ楽でいいですね。

各種hookについては必要なものと不要なものを決めるにしろ一回全部出すつもりで出してるので各自オフにしてください。

Discussion