Claude Media
UserPromptExpansion hookでスラッシュコマンド展開に処理を挟む

UserPromptExpansion hookでスラッシュコマンド展開に処理を挟む

UserPromptExpansionは、/skillコマンドの直接入力がPreToolUseを素通りする経路を捕まえるフックです。発火条件・入力スキーマ・block判定の使い方を解説します。

UserPromptExpansionはいつ発火するか

UserPromptExpansionは、ユーザーが入力したコマンドがプロンプトへ展開される瞬間に走るフックです。対象はスキル・カスタムコマンドのスラッシュコマンドと、MCPサーバーが提供するプロンプトの両方で、展開後の本文がClaudeに届く前に割り込みます。展開をブロックする、あるいは追加のコンテキストを差し込むことができます。

典型的な使い道は3つです。特定のコマンドを承認ファイルの有無で制御する、レビュー系スキルが呼ばれるたびにチェックリストをadditionalContextとして渡す、そしてどのコマンドが誰にどれだけ使われたかをログに残す。いずれも「コマンド名で分岐する」処理で、tool呼び出しの中身ではなく入力そのものを見る点が特徴です。

PreToolUseやUserPromptSubmitと何が違うか

Hooksのリファレンスは、UserPromptExpansionを「PreToolUseが塞がない経路」として説明しています。PreToolUseをSkillツールにマッチさせても、Claudeがそのツールを呼び出したときにしか発火しません。ユーザーが/skillnameと直接タイプしたときはツール呼び出しを経由しないため、PreToolUseは素通りされます。UserPromptExpansionはこの直接入力の経路を捕まえる唯一のフックです。

UserPromptSubmitとも役割が違います。UserPromptSubmitはユーザーが送ったプロンプトそのものを見るため、スラッシュコマンドが展開された後の本文まで含めて判定できます。一方UserPromptExpansionは展開が起きた事実そのものを捕まえるので、コマンド名や引数といった展開前の構造化データに直接アクセスできます。この違いは、単に発火順序が前後するという以上の意味を持ちます。

観点UserPromptExpansionUserPromptSubmitPreToolUse(matcher: Skill)
見る対象UserPromptExpansionコマンド名・引数(展開前)UserPromptSubmit送信されたプロンプト全文PreToolUse(matcher: Skill)Claudeが呼び出したツールの入力
直接入力への到達UserPromptExpansionするUserPromptSubmitするPreToolUse(matcher: Skill)しない(Claude経由のみ)
コマンドタイムアウト既定値UserPromptExpansion600秒UserPromptSubmit30秒PreToolUse(matcher: Skill)600秒
exit 2の効果UserPromptExpansion展開をブロックUserPromptSubmitプロンプト処理をブロックし消去PreToolUse(matcher: Skill)ツール呼び出しをブロック

タイムアウトの違いは見落としやすい箇所です。UserPromptSubmitはセッションを止めるリスクを避けるため既定30秒に短縮されていますが、UserPromptExpansionはこの短縮対象に含まれず、command・http・mcp_toolいずれのハンドラも既定600秒のままになります。重い判定ロジックを書いてもタイムアウトで打ち切られることはまずありませんが、逆に言えば遅いフックがセッションの体感速度を落とすことがあります。

matcherと入力スキーマ

matcherはコマンド名を基準に絞り込みます。空文字や*にすると全てのプロンプト型コマンドで発火し、deployのように書けば/deployだけに絞れます。複数指定は|または,区切りで並べられます(カンマ区切りと前後の空白許容はv2.1.191以降)。ハイフンを含む名前を完全一致として扱うのはv2.1.195以降で、それより前のバージョンではハイフン入りの名前が正規表現として評価されるため、code-reviewのようなコマンド名は意図せずsenior-code-reviewのような別名にもマッチします。

入力フィールドは、session_idcwdなどの共通フィールドに加えて次の4つが渡されます。

フィールド内容
expansion_type内容slash_command(スキル・カスタムコマンド)またはmcp_prompt(MCPサーバーのプロンプト)
command_name内容展開されたコマンドの名前(matcherの照合対象)
command_args内容コマンドに渡された引数の文字列
command_source内容コマンドの提供元(プラグイン由来ならpluginなど)
サブエージェント内で発火した場合に増えるフィールド

--agent付きで起動したセッションやサブエージェントの内部でこのフックが発火すると、共通入力フィールドにagent_idagent_typeが追加されます。agent_idはそのサブエージェント呼び出しを一意に識別する値で、agent_typeExploresecurity-reviewerのようなエージェント名です。メインスレッドとサブエージェントのフック呼び出しを区別する必要があるときに使います。

decision controlで承認ファイルなしのコマンドをブロックする

decision controlで使えるフィールドはdecisionreasonadditionalContextの3つです。decision"block"にすると展開そのものが止まり、reasonはユーザーへ表示されます。ブロックしない場合はadditionalContextに入れた文字列を展開後のプロンプトへ追加できます。exit 2で終了した場合も同じ効果になり、その場合はstderrのテキストがブロック理由として扱われます。

Hooksリファレンスが挙げる典型例は、承認ファイルが無い限り/deployの直接実行を止めるパターンです。settings.jsonでは次のようにmatcherへコマンド名を指定します。

{
  "hooks": {
    "UserPromptExpansion": [
      {
        "matcher": "deploy",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-deploy-approval.sh"
          }
        ]
      }
    ]
  }
}

ハンドラ側のスクリプトはstdinからJSONを受け取り、承認ファイルの有無だけを見て終了コードを返します。

#!/bin/bash
# .claude/hooks/check-deploy-approval.sh
input=$(cat)
command_name=$(jq -r '.command_name' <<<"$input")
 
if [[ "$command_name" == "deploy" && ! -f ".deploy-approved" ]]; then
  echo "承認ファイル(.deploy-approved)が無いため /deploy を拒否しました" >&2
  exit 2  # 展開をブロック
fi
 
exit 0  # ブロックしない

このスクリプトを保存したらchmod +xで実行権限を付け、動作を単体で確認できます。

echo '{"command_name":"deploy","command_args":""}' | .claude/hooks/check-deploy-approval.sh

チェックリストを差し込みたいだけなら、exit 2の代わりにJSONでadditionalContextを返します。プレーンテキストのstdoutをそのままコンテキストに追加できる点はUserPromptSubmitと同じ扱いで、JSON出力か素のテキストかを自動判別します。承認判定と違ってdecisionは省略してよく、その場合は展開がそのまま進みます。

どこで定義でき、どのハンドラ型が使えるか

UserPromptExpansionは、ユーザー個人の~/.claude/settings.jsonからプロジェクトの.claude/settings.json、組織のmanaged policy settings、プラグインのhooks/hooks.json、そしてスキル・サブエージェントのfrontmatterまで、他のフックイベントと同じ場所すべてに書けます。個人の作業効率化ならプロジェクト設定で十分ですが、「特定コマンドは承認ファイルが無ければ組織全体で拒否する」といった強制力を持たせたい場合は、managed policy settingsに置くとallowManagedHooksOnlyが有効な環境でもユーザー・プロジェクト・プラグインのフックだけが無効化され、managed側のフックは生き残ります。

ハンドラの型もcommand・http・mcp_tool・prompt・agentの5種類がすべて使えます。「どのコマンドが誰にどれだけ使われたかをログに残す」という用途では、ローカルのシェルスクリプトより、http型でチーム共通のログ収集サーバーにcommand_namecommand_argsをPOSTする構成の方が集計しやすくなります。判定にコマンド名のパターンマッチ以上の判断が必要なら、prompt型でHaiku相当のモデルにcommand_argsの内容を評価させる選択肢もあります。

Skillのfrontmatterに書くとどう変わるか

UserPromptExpansionはsettings.jsonだけでなく、スキルやサブエージェントのfrontmatterにも直接書けます。ただしスキルに書いたフックは、そのスキルが一度呼び出されて登録された後、セッションの残り全体で効き続ける点に注意が必要です。呼び出された直後のターンに限らず、以降の別コマンドの展開にも反応します。1回動いたら外したい場合はonce: trueを付けます。これはスキルのfrontmatterに書いたフックだけが対応する挙動で、settings.jsonやサブエージェントのfrontmatterでは無視されます。

プロジェクトのスキルにfrontmatterでフックを書いた場合、settings.jsonのフックと同じワークスペース信頼のルールが適用されます。フォルダをまだ信頼していない状態でも-p実行やClaude自身によるスキル呼び出しでは登録・実行されるため、「信頼していないフォルダでは何も走らない」とは考えない方が安全です。サブエージェントのfrontmatterに書いたフックは逆に、そのフォルダのワークスペース信頼ダイアログを承認するまで動きません。

プラグイン経由でスキルやフックを配布している場合、フックの移行手順はClaude Codeプラグイン化の移行手順にまとめています。フック定義自体のフォーマットはsettings.jsonと共通なので、既存のUserPromptExpansion設定をそのままfrontmatterへ移せます。

運用で見落としやすい注意点

判定ロジックをtype: "prompt"のLLM評価に任せる場合は挙動が他のイベントと違います。StopSubagentStop宛てのprompt hookが"ok": falseを返すと、そのreasonはClaudeへ次の指示として渡され作業が続きますが、UserPromptExpansion宛ての場合はそうなりません。ターンはそこで終了し、reasonは警告行としてユーザーに表示されるだけで、Claude側へフィードバックとして戻ることはありません。承認待ちのような「差し戻して直させる」設計には使えません。

発火位置も押さえておきたい点です。ライフサイクル図では、セッション起動直後のSessionStartSetup/InstructionsLoadedフックより後、ターンごとのループの中でUserPromptSubmitに続けて発生する位置に置かれています。CLAUDE.mdの読み込みやセットアップが済んだ後でなければ、コマンド展開時のコンテキストは正しく積み上がりません。

matcherが空のときは全てのプロンプト型コマンドに発火するため、意図せず全コマンドをログ対象にしてしまう設定ミスも起きやすい箇所です。似た仕組みで承認前に判定を挟むPermissionRequest hookと組み合わせて多重チェックにすると、片方だけmatcherを絞り忘れて二重にブロックが走ることがあるため、設定を変更したら/hooksメニューでどのイベントにどのハンドラが登録されているかを確認しておくと安全です。

まとめ

UserPromptExpansionは、/skillnameのようにユーザーが直接タイプしたスラッシュコマンドやMCPプロンプトの展開を、Claudeが処理する前に捕まえるフックです。PreToolUseのSkillマッチャーでは拾えない直接入力の経路を埋める役割を持ち、command_nameをmatcherにして特定コマンドだけをブロックしたり、additionalContextでスキル呼び出しごとに固定情報を差し込んだりする用途に向きます。タイムアウトが既定600秒のまま短縮されない点、prompt hookの"ok": falseがフィードバックにならず即座にターンを終える点、スキルのfrontmatterに書いたフックはセッション終了まで残り続ける点は、他のフックイベントとの違いとして押さえておくとよい挙動です。

この記事を共有:XはてブLinkedIn