Claude Codeのプラグイン推奨をSessionStartフックで自動化する
Claude CodeのSessionStartフックは起動ディレクトリを読み取り、そのエリアの担当チームが管理するプラグインを最初の返信で案内できます。モノレポで規約集約が効かなくなったときの入り口です。
Claude Codeのプラグイン推奨をSessionStartフックで自動化する仕組みとは
Claude CodeのSessionStartフックとは、セッション開始時に発火し、標準出力に書いたプレーンテキストをClaudeの会話に最初のプロンプトより前のコンテキストとして追加できる仕組みです。この仕組みを使うと、Claudeを起動したディレクトリを読み取り、そのエリアを管理するチームが用意したプラグインを最初の返信でチームメイトに案内させられます。
対象になるのは、規約やスキルをプラグインに集約している大規模リポジトリです。プラグインとして配布された規約は、そのプラグインを知らない人には見えません。初めて触るパッケージでclaudeを起動したとき、そのエリアのオーナーがどのプラグインを前提にしているかを知る手がかりが何もない、という状態が起きます。
なぜ規約をCLAUDE.mdからプラグインへ移すとこの問題が起きるか
ディレクトリごとにCLAUDE.mdを置く方式は、コードベースが育つにつれて統治が難しくなります。規約が古くなる、ルートのCLAUDE.mdを誰も更新しなくなる、といった劣化が起きやすいためです。解決の方向は、常時ロードされるCLAUDE.mdから、必要なときだけ読み込まれる仕組みへ規約を移すことです。選択肢は3つです。Claudeがタスクに関連すると判断したときだけ読み込まれるSkills、プラットフォームチームが一元管理できるPlugins、既存のコード検索やRAGインデックスをツールとして公開するMCPサーバーです。
規約をプラグインへ移した瞬間に新しい問題が生まれます。オンボーディング時点で全員が把握していた「このパッケージではこのCLAUDE.mdを読む」という前提が、「このパッケージではこのプラグインを有効化する」という前提に変わり、しかもその前提はプラグインを知っている人にしか見えません。SessionStartフックは、この見えなくなった前提を起動時に可視化する役割を担います。
SessionStartフックで起動ディレクトリからプラグインを引き当てる
SessionStartフックは、共通の入力フィールドに加えてsource(startup / resume / clear / compact / forkのいずれか)を受け取ります。フックのスクリプトは標準入力からこのJSONを読み、cwd(起動時のカレントディレクトリ)を取り出します。
フックがやること
- 1
cwdを読む
標準入力のJSONから、起動時のディレクトリ
cwdを取り出します。 - 2
対応表を引く
リポジトリにコミットしたパスとプラグイン名の対応表を、
cwdと突き合わせます。 - 3
案内を返す
一致したプラグイン名を
additionalContextとしてClaudeに渡します。一致しなければ何も出力しません。
対応表はJSONで持たせておくと、パッケージが増えたときの追記が楽になります。
{
"packages/api": "api-conventions@internal-tools",
"packages/web": "web-conventions@internal-tools",
"packages/mobile": "mobile-conventions@internal-tools"
}このJSONを.claude/plugin-map.jsonのようなパスでリポジトリに置き、フックのスクリプトから読みます。
#!/bin/bash
INPUT=$(cat)
CWD=$(echo "$INPUT" | jq -r '.cwd')
MAP_FILE="$(git rev-parse --show-toplevel 2>/dev/null)/.claude/plugin-map.json"
if [ -f "$MAP_FILE" ]; then
MATCH=$(jq -r --arg cwd "$CWD" '
to_entries[] | select(.key as $k | $cwd | contains($k)) | .value
' "$MAP_FILE" | head -n1)
if [ -n "$MATCH" ]; then
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"このディレクトリの担当チームは ${MATCH} プラグインを前提にしています。未導入の場合は最初の返信でその旨を伝えます。\"}}"
fi
fi手元で動かして見つかった2つの落とし穴
このスクリプトは、Claude Codeを起動せずに標準入力へJSONを流すだけで試せます。一時ディレクトリにリポジトリを作り、対応表とスクリプトを置いて、cwdを変えながら実行しました。
最初に書いたselect($cwd | contains(.key))は、jqがエラーで止まります。selectの中の.は対応表のエントリではなく$cwdの文字列になるため、.keyを引けないからです。
jq: error: Cannot index string with string "key".keyを変数$kに受けてからcontainsに渡す形(上のスクリプトの書き方)に直すと、対応表のパスを含むcwdで案内のJSONが出て、載っていないパスでは何も出力されませんでした。
もう1つは部分一致です。containsはpackages/api-oldのような別パッケージにも一致するので、packages/apiの案内がそのまま出ました。パッケージ名が前方一致で重なる構成では、対応表のキーの長い順に判定するか、cwdをパス区切りで比較する書き方に変えます。
どの設定ファイルに登録するか
スクリプトは.claude/hooks/recommend-plugin.shに保存し、実行権限を付けます。実行権限はgitでコミットされます。
chmod +x .claude/hooks/recommend-plugin.sh下の登録例のようにスクリプトを直接実行する形では、実行権限がないとPermission deniedでフックが動かず、案内も出ません。
このフックはリポジトリ全員に効かせたいので、プロジェクトの.claude/settings.jsonに登録してコミットします。個人の好みで別の案内を足したい場合は.claude/settings.local.jsonに、組織として絶対に外させたくない案内なら管理設定に置くという使い分けです。プロジェクト設定は共有され、ローカル設定は各自のマシンに閉じる、という基本原則はこのフックにもそのまま当てはまります。
フックのコマンドは、claudeを起動したディレクトリから実行されます。packages/apiなどで起動すると、bash .claude/hooks/recommend-plugin.shのような相対パスは見つからず、案内が出ないままフックエラーになります。スクリプトのパスは、プロジェクトのルートを指す${CLAUDE_PROJECT_DIR}で書きます。
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/recommend-plugin.sh"
}
]
}
]
}
}フックそのものをプラグインとして配布する
ここまでの例は.claude/settings.jsonに直接フックを書く方法でしたが、フックはPluginsのhooks/hooks.jsonにも同じ形式で書けます。社内の共通ツールキットをプラグインとして配布している組織なら、プラグイン推奨のロジック自体をそのプラグインへ同梱するという選択肢が生まれます。プラグインを更新すれば対応表も一緒に更新される形になり、フックの配布をリポジトリのコミット履歴とは別のライフサイクルで管理できます。
どちらの置き場所を選ぶかは、更新の主体で決まります。リポジトリのコミットで管理したいなら.claude/settings.json、複数リポジトリにまたがる共通ロジックとしてバージョン管理したいならプラグイン、という切り分けが基本です。
効かせる範囲を絞る — matcherの使い分け
matcherにはstartup(新規セッション)、resume(--resume・--continue・/resume)、clear(/clear後)、compact(要約後)、fork(セッションの分岐)の5種類があります。プラグイン推奨は「初めてそのディレクトリで作業を始めた瞬間」に効かせたい情報なので、startupだけに絞るのが基本です。resumeまで含めると、同じセッションを再開するたびに同じ案内が繰り返し流れ、会話のノイズになります。
additionalContextで返した文字列は、セッションのトランスクリプトに保存されます。会話の途中で読み返されることはあっても、Claudeが毎回それを読者に見せるわけではない点は理解しておきます。案内文は、命令形のシステム指示ではなく事実文で書きます。命令口調の文面はプロンプトインジェクションの疑いとして扱われ、コンテキストとして使われずに利用者へ表示されることがあるためです。
起動の待ち時間にも影響します。対話セッションでは、SessionStartフックはバックグラウンドで走るので、すぐに入力できます。ただしClaudeの最初の返信はフックの完了を待ち、フックが終わる前に送ったプロンプトもClaudeには届きません。案内を出すだけのフックに時間のかかる処理を入れると、そのまま最初の返信の遅れになります。
セッション内で/resumeにより会話を切り替えるときは、バックグラウンドではなく、切り替え自体がフックの完了を待ちます。バックグラウンドのフックが走っている間に/clearや別の会話への切り替えをすると、フックが返した内容はセッションに適用されません。どちらの待ち時間でもEscでプロンプトを入力欄に戻せて、フックは動き続けます。
SessionStartフックはsourceのほかに、任意で3つのフィールドを受け取ります。model(有効なモデル名)と、agent_type(claude --agent <name>で起動した場合のエージェント名)です。もう1つのsession_titleは、--nameや/renameで付けたカスタムタイトルがある場合だけ入ります。自動生成されたタイトルはカスタムタイトルではなく、session_titleには入りません。特定のカスタムエージェントで起動したときだけ案内を出したい、といった条件分岐が必要な場合はこれらのフィールドで絞り込めます。フックはtype: "command"かtype: "mcp_tool"のみ対応です。
起動時のSessionStartではセッションのMCPサーバーがまだ使えず、mcp_toolのフックは呼び出されずに飛ばされるので、起動時に必要な処理はcommandで書きます。--continueや--resumeでの起動でもmcp_toolは飛ばされます。デバッグログにはmcp_tool hooks are not available for the 'SessionStart' hook eventで始まる行が出ます。/clearや要約の後にSessionStartが再び発火したときは、mcp_toolのフックも実行されます。
関連する自動化との使い分け早見表
同じ「起動時に情報を渡す」目的でも、動的な案内はCLAUDE.mdやSkillsでは作れません。CLAUDE.mdは静的な規約向けで、起動ディレクトリごとに変わる案内には向きません。Skillsはタスクに関連したときに読み込まれるので、「このプラグインを使ってください」という導線そのものには使えません。残る分担は次のとおりです。
案内するか、強制するか
SessionStartフック
起動ディレクトリに応じた案内や環境変数の設定に向きます。返すのは案内だけで、プラグインは有効になりません。
enabledPlugins(managed設定)
組織として必ず有効化したいプラグインを指定します。個々の使い方を都度説明する場面には向きません。
よくあるつまずき
フックが発火しない場合、matcherをstartupに絞っていると/clearや--resumeでは動きません。意図したsourceでフックが呼ばれているかを、まずclaude --debug-file <path>で書き出したログで確認します。ログにはマッチしたフック名・終了コード・標準出力と標準エラーが残るため、対応表の読み込みに失敗しているのか、JSON出力の形式が誤っているのかを切り分けられます。より詳細なマッチング情報が要る場合はCLAUDE_CODE_DEBUG_LOG_LEVEL=verboseを設定します。
案内にインストールコマンドを書きたい場合は、claude plugin install --helpの出力が手がかりになります。v2.1.287では、claude plugin install <plugin>@<marketplace>の形で、--scopeにuser・project・localを取り、既定はuserです。-yは、マーケットプレイスが宣言したコマンドの確認を省く指定で、標準入出力がTTYでない環境では必須とされています。案内文に--scope projectまで書いておくと、個人の設定ではなく、プロジェクトの.claude/settings.jsonのenabledPluginsに書き込まれます。このファイルはコミットする前提です。localなら.claude/settings.local.json、userなら~/.claude/settings.jsonが書き込み先です。
案内文がClaudeの返信に出てこない場合、additionalContextはコンテキストに追加されるだけで、Claudeが必ずそれを最初の発言に含めるとは限りません。案内文を事実文にする理由は、前の節で触れたとおりです。
対応表のパスがマッチしない場合、cwdは起動時のディレクトリであり、その後cdした先までは追跡しません。サブディレクトリへ移動してから作業する運用が多いなら、CwdChangedイベントと組み合わせて再評価する構成を検討します。
フックが重くてセッション起動が遅い場合、SessionStartフックは毎セッション必ず実行されるため、外部APIへの問い合わせのような重い処理は避け、リポジトリ内のファイル参照だけで完結させます。
よくある質問
この仕組みはプラグインのインストールまで自動化しますか
しません。SessionStartフックが返すのは案内テキストだけです。導入は利用者がシェルでclaude plugin install <plugin>@<marketplace> --scope projectを実行するか、enabledPluginsに追加します。.claude/settings.jsonへの記載をコミットすると共同作業者のプラグインは有効になりますが、各自のマシンにはダウンロードされません。そのため各自が同じコマンドを一度実行します。
複数のSessionStartフックを同時に登録できますか
できます。マッチしたフックはすべて並列に実行されるので、プラグイン推奨のフックと、たとえば直近のgitログを表示するフックを別々に登録しても構いません。同じハンドラを複数の設定ファイルに重複して書いた場合は1回だけ実行されます。ただしプラグインやスキルが持つ同じハンドラの写しは別扱いです。前の節のようにプラグインへ同梱したロジックを.claude/settings.jsonにも書くと、二重に走ります。複数のフックが同じイベントでadditionalContextを返したときは、Claudeがすべての値を受け取ります。
対応表が古くなって案内が間違っていたらどうなりますか
フックは案内を出すだけなので、間違った案内が出ても実害は「不要なプラグインをすすめられる」程度です。ただし信頼を失うと読まれなくなるため、対応表の更新はプラグイン追加のレビュープロセスに組み込んでおくのが安全です。
案内文を長くしすぎると何が起きますか
additionalContextは、会話の開始時にClaudeのコンテキストへ追加される文字列です。10,000字を超えると、ファイルへの参照と先頭2,000字のプレビューに置き換わります。案内は「プラグイン名+一言」程度に留め、規約の詳細そのものはプラグインが読み込むスキルやドキュメント側に持たせるほうが、起動のたびに払うコストを小さく保てます。
まとめ
Claude Codeでフックを書くときは、本番に登録する前に、cwdを変えた標準入力で案内の出力と無出力の両方を試すと、jqの式やキーの重なりといった不具合を先に見つけられます。案内で足りるのか強制まで要るのかは、プラグインが必須かどうかで決まります。