🔔

Dev Container で起動した Claude Code から Hooks でホストの Mac に通知する

に公開

masaki です。
弊社では生成 AI を業務に積極的に取り入れています。
これまでにも以下のような記事を公開してきました。

https://zenn.dev/socialplus/articles/d92f1296c8403a
https://zenn.dev/socialplus/articles/618bc1016bc51c

最近はコードアシストにとどまらず、エージェント型 AI ツールを活用する動きが活発になっています。
今回は、よく話題にもなり弊社でも利用している Claude Code について、少し便利になる通知設定のテクニックをご紹介します。

はじめに

Claude Code を使っていると、長いタスクを実行している間に他の作業を始めてしまい、処理が終わっているのに長時間放置していた...なんてことありませんか?
そんな時に便利なのが、最近追加された Hooks 機能を使った通知です。
色々な記事で紹介されているように、私も Mac で terminal-notifier というツールを使って通知するようにしました。

ただ、ここで一つ問題があります。弊社では開発環境に Dev Container を採用しているのですが、コンテナ環境からはホストの Mac の通知機能に直接アクセスできないため、そのままでは Claude Code の Hooks を使った通知ができません。
とはいえ、Dev Container のようなコンテナ環境でコーディングエージェントを動かすことには、セキュリティ面で大きなメリットがあるので、できれば Dev Container 上で Claude Code を利用したいです。

※ Claude Code の公式ドキュメントでも、コンテナ上でコーディングエージェントを実行することについて言及されています

そこで今回は、Dev Container 内の Claude Code から、ホストの Mac に通知を送る仕組みを作ってみました。

概要

以下に全体図を示します。

仕組みとしては以下のとおりです。

  1. Mac 側で簡易的な HTTP サーバーを立ち上げる
  2. Docker コンテナから host.docker.internal 経由で 1 にアクセスする
  3. 1 の HTTP サーバーが Mac の通知システムを通じて通知する

host.docker.internal とは?

host.docker.internal は、Docker Desktop が提供する特殊な DNS 名で、コンテナ内からホストマシンの IP アドレスを参照できます。
これにより、コンテナ内のアプリケーションがホストで動作しているサービスにアクセスすることが可能になります。
詳しくは以下の公式ドキュメントを参照してください。
Networking | Docker Docs

それではステップバイステップで実装していきます。

環境

使用したツールの各バージョンは以下のとおりです。

ツール バージョン インストール環境
macOS Sequoia Mac
Docker Desktop 4.43.1 Mac
Dev Container 0.422.0 Mac
terminal-notifier 2.0.0 Mac
Claude Code 1.0.51 コンテナ
Ruby 3.3.8 Mac

最小構成を作る

まずは最小限の実装で動作確認をしていきます。

Step 1: terminal-notifier のインストール(Mac)

brew install terminal-notifier

インストール後、動作確認をしておきましょう。

terminal-notifier -message "テスト通知"

通知が表示されれば準備完了です。

Step 2: HTTP サーバーを作る

ホストで立ち上げる通知サーバーを作成します。
HTTP サーバーであれば何でも構いませんが、本記事では手軽に実装できる Ruby と Sinatra を使います。

notification_server.rb を作成します。

require 'sinatra'

# HostAuthorization を明示的に許可ホストに追加
# これがないと host.docker.internal からのアクセスが拒否される
set :host_authorization, {
  permitted_hosts: [
    'host.docker.internal',  # Docker Desktop のホスト名
    'localhost'              # ローカルからのテスト用
  ]
}

# 通知エンドポイント
get '/notify' do
  system("terminal-notifier -message \"#{params[:message]}\" -title \"Claude Code\"")
end

必要な Ruby gem をインストールします。

gem install sinatra

サーバーを起動します:

ruby notification_server.rb

別のターミナルで動作確認:

# 通知テスト
curl "http://localhost:4567/notify?message=サーバーが動作しています"

Step 3: Hooks の設定(コンテナ)

次に Claude Code の Hooks を設定します。

コンテナ内でプロジェクトのルートディレクトリに移動し、.claude/settings.local.json に以下の設定を追加します。

{
  "hooks": {
    "Notification": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "curl 'http://host.docker.internal:4567/notify?message=ユーザーのアクションが必要です'"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "curl 'http://host.docker.internal:4567/notify?message=タスクが完了しました'"
          }
        ]
      }
    ]
  }
}

設定ファイルについて

Claude Code では 2 種類の設定ファイルを使用できます。

  • .claude/settings.json: プロジェクト全体で共有される設定
  • .claude/settings.local.json: 個人用の設定(gitignore に追加推奨)

今回は個人的な通知設定なので、.claude/settings.local.json を使用しています。

詳細は公式ドキュメントを参照してください。

Step 4: 動作確認

準備が整ったので、実際に動作確認してみましょう。

  1. Mac 側で notification_server.rb を起動
  2. Dev Container を起動
  3. コンテナ内で Claude Code を実行
# Dev Container 内で
claude -p "pwd コマンドを実行してください"

タスク完了時、以下のように通知が表示されたら成功です 🎉

トラブルシューティング

通知が届かない場合

  1. コンテナから接続できるか確認
# コンテナ内から
curl "http://host.docker.internal:4567/notify?message=サーバーが動作しています"
  1. macOS の通知設定を確認
  • システム設定 > 通知 > terminal-notifier が許可されているか
  1. ファイアウォールの設定
  • macOS のファイアウォールが 4567 ポートをブロックしていないか

改善編:より実用的な通知システムへ

基本的な実装ができたので、実際の開発で使いやすくするための改善を行いました。
大きく変更したところは、「Ruby スクリプトを実行するためのラッパースクリプト作成」です。
ただし、2つのスクリプトを管理することは煩雑になるため、ラッパースクリプト内に Ruby スクリプトを含めました。
またスクリプトにはサーバー起動や停止などのコマンドも準備しました。
これにより、設定や通知を簡単なコマンドで実行できるようにしました。

以下が改善したスクリプトです。

#!/usr/bin/env bash

case "$1" in
    start)
        # Ruby 一時ファイルを作成
        temp_ruby_file=$(mktemp -t any_notifier_server.xxxxxx)

        # bin/any_notifier_server の内容を一時ファイルに書き込み
        cat > "$temp_ruby_file" << 'EOF'
#!/usr/bin/env ruby

begin
  require 'sinatra'
rescue LoadError
  system('gem install sinatra')
  Gem.clear_paths
  require 'sinatra'
end

# HostAuthorization を明示的に許可ホストに追加
set :host_authorization, {
  permitted_hosts: [
    'host.docker.internal', # Docker Desktop のホスト名
    'localhost' # ローカル用
  ]
}

get '/notify' do
  title = params[:title]
  subtitle = params[:subtitle]
  message = params[:message]
  app = params[:app]
  sound = params[:sound]

  system("terminal-notifier -message '#{message}' -title '#{title}' -subtitle '#{subtitle}' -execute 'open -a \"#{app}\"' -sound '#{sound}'")
end

# プロセス終了時のクリーンアップ
Signal.trap('INT') do
  File.delete(__FILE__) if File.exist?(__FILE__)
  exit
end
Signal.trap('TERM') do
  File.delete(__FILE__) if File.exist?(__FILE__)
  exit
end
EOF

        # 一時ファイルを実行してサーバー起動
        chmod +x "$temp_ruby_file"

        nohup ruby "$temp_ruby_file" -p 5678 > /dev/null 2>&1 &
        echo "Any notifier server started"
        ;;
    stop)
        pids=$(lsof -t -i:5678 2>/dev/null)
        if [ -n "$pids" ]; then
            echo "$pids" | xargs kill
            echo "Any notifier server stopped"
        else
            echo "No any notifier server running on port 5678"
        fi
        ;;
    send)
        # パラメータを解析
        message=""
        title="Claude Code"
        subtitle=$(basename $(pwd))
        app="iTerm"
        sound="Default"

        shift  # "send" を削除

        while [[ $# -gt 0 ]]; do
            case $1 in
                -m|--message)
                    message="$2"
                    shift 2
                    ;;
                -t|--title)
                    title="$2"
                    shift 2
                    ;;
                -s|--subtitle)
                    subtitle="$2"
                    shift 2
                    ;;
                -a|--app)
                    app="$2"
                    shift 2
                    ;;
                --sound)
                    sound="$2"
                    shift 2
                    ;;
                *)
                    if [ -z "$message" ]; then
                        message="$1"
                    fi
                    shift
                    ;;
            esac
        done

        if [ -z "$message" ]; then
            echo "Error: No message provided for send command"
            echo "Usage: $0 send -m <message> [-t <title>] [-s <subtitle>] [-a <app>]"
            exit 1
        fi

        # URLエンコード
        message=$(printf '%s' "$message" | jq -sRr @uri)
        title=$(printf '%s' "$title" | jq -sRr @uri)
        subtitle=$(printf '%s' "$subtitle" | jq -sRr @uri)
        app=$(printf '%s' "$app" | jq -sRr @uri)
        sound=$(printf '%s' "$sound" | jq -sRr @uri)

        if [ -f "/.dockerenv" ]; then
            host="host.docker.internal"
        else
            host="localhost"
            # コンテナ環境でない場合、サーバーが起動しているかチェック
            if ! lsof -i:5678 >/dev/null 2>&1; then
                echo "Any notifier server not running. Starting server..."
                if "$0" start; then
                    echo "Any notifier server started"
                    # サーバー起動を少し待つ
                    sleep 3
                else
                    echo "Error: Failed to start any notifier server"
                    exit 1
                fi
            fi
        fi

        curl -s "http://$host:5678/notify?message=$message&title=$title&subtitle=$subtitle&app=$app&sound=$sound"
        ;;
    setup-container)
        # container-id の検証
        if [ -z "$2" ]; then
            echo "Error: Container ID required"
            echo "Usage: $0 setup-container <container-id>"
            exit 1
        fi

        container_id="$2"

        # コンテナの存在確認
        if ! docker ps -q --filter "id=$container_id" | grep -q .; then
            echo "Error: Container $container_id not found or not running"
            exit 1
        fi

        # 一時ファイルを作成して any-notifier スクリプトの内容を書き込み
        temp_script=$(mktemp -t any_notifier_setup.XXXXXX)

        # 現在のスクリプト内容を一時ファイルに出力
        cat "$0" > "$temp_script"

        # パーミッションを付与
        chmod +x "$temp_script"

        # コンテナ内に配置
        if docker cp "$temp_script" "$container_id:/tmp/any-notifier" && \
           docker exec "$container_id" mv /tmp/any-notifier /usr/local/bin/any-notifier && \
           docker exec "$container_id" chmod +x /usr/local/bin/any-notifier; then
            echo "Any notifier script installed in container $container_id"

            # ホスト側でany-notifierサーバーを起動
            echo "Starting any notifier server on host..."
            if "$0" start; then
                echo "Any notifier server started on host"
            else
                echo "Warning: Failed to start any notifier server on host"
            fi
        else
            echo "Error: Failed to install any notifier script in container $container_id"
            rm -f "$temp_script"
            exit 1
        fi

        # 一時ファイルをクリーンアップ
        rm -f "$temp_script"
        ;;
    *)
        echo "Usage: $0 {start|stop|send -m <message> [-t <title>] [-s <subtitle>] [-a <app>] [--sound] |setup-container <container-id>}"
        exit 1
        ;;
esac

設定方法

上記コードをコピーし、 Mac 上の usr/local/bin 配下などに any-notifier として配置し、スクリプトとして実行できるよう実行権限を変更してください。

chmod +x any-notifier

Dev Container を使用しているプロジェクトの .claude/settings.local.json を変更します。

{
  "hooks": {
    "Notification": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "any-notifier send 'ユーザーのアクションが必要です'"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "any-notifier send 'タスクが完了しました'"
          }
        ]
      }
    ]
  }
}

Dev Container を立ち上げたら、コンテナ内にスクリプトをセットアップ(コピー)する以下のコマンドを実行します。(コマンド実行時、Mac 側で Ruby サーバーが起動します)

any-notifier setup-container {コンテナID}

本スクリプトでは URL クエリに空白文字が入っていることによるエラーを避けるため、jq コマンドで URL エンコーディングを行っています。
そのため、コンテナ環境に jq をインストールする必要があります。

Dev Container 環境に jq をインストールし、Claude Code を立ち上げ、タスク完了時に通知が表示されたら成功です 🎉

その他、タイトルやサブタイトルや起動するアプリをオプションによって変更可能にしているので、必要に応じてカスタマイズしてください。

any-notifier send --message "タスクが完了しました" \
                  --title "Claude Code" \
                  --subtitle "コンテナ環境" \
                  --sound Ping \
                  --app "iTerm" # 通知をクリックしたときに起動するアプリ

まとめ

本記事では、Claude Code の Hooks を使い、Mac + Dev Container 環境で通知を実現する方法を紹介しました。

ポイント:

  • host.docker.internalを使用してコンテナからホストへアクセス
  • 簡易HTTPサーバーで通知を中継
  • 単一スクリプトで管理を簡素化

セキュリティと利便性のバランスを取りながら、快適な開発環境を構築していきましょう!
ご質問やフィードバックがありましたら、ぜひコメントでお知らせください。

Social PLUS Tech Blog

Discussion