UserPromptExpansion hookでスラッシュコマンド展開に処理を挟む
UserPromptExpansionは、/skillコマンドの直接入力がPreToolUseを素通りする経路を捕まえるフックです。発火条件・入力スキーマ・block判定の使い方を解説します。
UserPromptExpansionはいつ発火するか
UserPromptExpansionは、ユーザーが入力したコマンドがプロンプトへ展開される瞬間に走るフックです。対象はスキル・カスタムコマンドのスラッシュコマンドと、MCPサーバーが提供するプロンプトの両方で、展開後の本文がClaudeに届く前に割り込みます。展開をブロックする、あるいは追加のコンテキストを差し込むことができます。
典型的な使い道は3つです。特定のコマンドを承認ファイルの有無で制御する、レビュー系スキルが呼ばれるたびにチェックリストをadditionalContextとして渡す、そしてどのコマンドがどれだけ使われたかをログに残す。いずれも「コマンド名で分岐する」処理で、tool呼び出しの中身ではなく入力そのものを見る点が特徴です。
PreToolUseで止められない経路はどれか
スキルを止めたいとき、まず思い浮かぶのはPreToolUseをSkillツールにマッチさせる方法です。ところがこの方法が効くのは、Claudeがツールを呼び出す経路だけです。ユーザーが/skillnameと直接タイプした場合はツール呼び出しを経由しないため、PreToolUseは素通りされます。UserPromptExpansionは、この直接入力の経路を受け持つイベントです。
スキルが動き出す2つの経路
Claudeがスキルを呼ぶ
Skillツールの呼び出しとして届くので、PreToolUseのmatcherにSkillを指定すれば止められます。
ユーザーが/skillnameと打つ
ツール呼び出しを経由せずに展開されます。止める手段として使えるのは、展開時に走るUserPromptExpansionです。
UserPromptSubmitとの違いは、コマンド名で絞れるかどうかです。UserPromptSubmitはmatcherに対応しておらず、プロンプトを送るたびに必ず発火します。UserPromptExpansionはcommand_nameをmatcherの照合対象にできるので、/deployだけを狙い撃ちにできます。
| 観点 | UserPromptExpansion | UserPromptSubmit | PreToolUse(matcher: Skill) |
|---|---|---|---|
| 絞り込みの単位 | UserPromptExpansionコマンド名(command_name) | UserPromptSubmitなし(毎回発火) | PreToolUse(matcher: Skill)ツール名 |
| 直接入力への到達 | UserPromptExpansionする | UserPromptSubmitする | PreToolUse(matcher: Skill)しない(Claude経由のみ) |
| コマンドタイムアウト既定値 | UserPromptExpansion600秒 | UserPromptSubmit30秒 | PreToolUse(matcher: Skill)600秒 |
| exit 2の効果 | UserPromptExpansion展開をブロック | UserPromptSubmitプロンプトをブロックしClaudeに届けない | PreToolUse(matcher: Skill)ツール呼び出しをブロック |
タイムアウトの違いは見落としやすい箇所です。UserPromptSubmitはセッションを止めるリスクを避けるため既定30秒に短縮されていますが、UserPromptExpansionはこの短縮対象に含まれません。command・http・mcp_toolいずれのハンドラも既定600秒のままです。
公式Hooksリファレンス冒頭のライフサイクル図では、ターンごとのループの中で、UserPromptSubmitの後にUserPromptExpansionが置かれ、その先にPreToolUseなどのエージェントループが続きます。CLAUDE.mdなどの読み込みを記録するSetup/InstructionsLoadedフックは、このループとは別に独立して発火するイベントです。
特定のコマンドだけに効かせるmatcherの書き方
matcherはコマンド名を基準に絞り込みます。空文字や*にすると全てのプロンプト型コマンドで発火し、deployのように書けば/deployだけに絞れます。評価のされ方は、matcherに含まれる文字で決まります。
| matcherの書き方 | 評価のされ方 | 例 |
|---|---|---|
空文字・*・省略 | 評価のされ方全コマンドに一致 | 例すべてのコマンドをログに残す |
英数字・_・-・空白・,・|だけ | 評価のされ方完全一致(複数なら|か,区切り) | 例deploy、deploy|release |
| それ以外の文字を含む | 評価のされ方JavaScriptの正規表現(前後の固定なし) | 例^reviewならreview-で始まる名前すべて |
カンマ区切りが使えるのはv2.1.191以降です。ハイフンを含む識別子を完全一致で扱うのはv2.1.195以降で、それより前はcode-reviewのような名前が部分一致で別の名前にも反応していました。
入力フィールドは、session_idやcwdなどの共通フィールドに加えて次の5つが渡されます。
| フィールド | 内容 |
|---|---|
expansion_type | 内容slash_command(スキル・カスタムコマンド)またはmcp_prompt(MCPサーバーのプロンプト) |
command_name | 内容展開されたコマンドの名前(matcherの照合対象) |
command_args | 内容コマンドに渡された引数の文字列 |
command_source | 内容コマンドの提供元(プラグイン由来ならpluginなど) |
prompt | 内容ユーザーが入力した元の文字列(/example-skill arg1 arg2の形) |
サブエージェント内で発火した場合に増えるフィールド
--agent付きで起動したセッションやサブエージェントの内部でこのフックが発火すると、共通入力フィールドにagent_typeが加わります。サブエージェント内で発火した場合はagent_idも付きます。agent_idはそのサブエージェント呼び出しを一意に識別する値で、agent_typeはExploreやsecurity-reviewerのようなエージェント名です。メインスレッドとサブエージェントのフック呼び出しを区別する必要があるときに使います。
承認ファイルが無い/deployをブロックする
decision controlで使えるフィールドはdecision・reason・additionalContextの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 # ブロックしないこのスクリプトはbashとjqだけで動くので、Claude Codeを起動しなくても、手元で3通りの入力を流して終了コードを見られます。保存したらchmod +xで実行権限を付けてから試します。
J='{"hook_event_name":"UserPromptExpansion","command_name":"deploy","command_args":"","prompt":"/deploy"}'
# 承認ファイルが無い状態の /deploy
echo "$J" | .claude/hooks/check-deploy-approval.sh; echo "exit=$?"
# 承認ファイルを置いた後の /deploy
touch .deploy-approved
echo "$J" | .claude/hooks/check-deploy-approval.sh; echo "exit=$?"
# 別のコマンド(review)
echo '{"command_name":"review","command_args":""}' | .claude/hooks/check-deploy-approval.sh; echo "exit=$?"出力は次の順になります。
承認ファイル(.deploy-approved)が無いため /deploy を拒否しました
exit=2
exit=0
exit=0stderrに出たメッセージとexit=2の組が、そのまま「ブロックして理由を見せる」動作になります。2つ目と3つ目はメッセージなしでexit=0となり、展開は通常どおり進みます。matcherをdeployにしている場合、reviewはそもそもこのスクリプトを呼びません。それでも3つ目を流してexit=0になることは、matcherを*に広げたときの保険になります。
チェックリストを差し込みたいだけなら、exit 2の代わりにJSONでadditionalContextを返します。プレーンテキストのstdoutをそのままコンテキストに追加できる点はUserPromptSubmitと同じ扱いで、JSON出力か素のテキストかを自動判別します。承認判定と違ってdecisionは省略してよく、その場合は展開がそのまま進みます。
ブロックできても書き換えはできない
出力フィールドがdecision・reason・additionalContextの3つだけという点は、できることの境界線にもなります。展開後のプロンプト本文を別の文字列に置き換える項目はなく、できるのは「止める」か「文脈を足す」かのどちらかです。
UserPromptSubmitにも同じ制約があります。プロンプトの置き換えを求める要望はGitHubでopenのままです(anthropics/claude-code #95763)。コマンドの引数を補正したい場合は、フックで書き換えるのではなく、additionalContextで「引数はこう解釈すること」と添える形になります。
どこで定義でき、どのハンドラ型が使えるか
UserPromptExpansionは、他のフックイベントと同じ場所すべてに書けます。置き場所は、効かせたい範囲で選びます。
置き場所と効く範囲
個人の設定
~/.claude/settings.jsonに書くと、自分のすべてのプロジェクトで効きます。チームには共有されません。プロジェクトの設定
.claude/settings.jsonに書けばリポジトリにコミットでき、メンバー全員で同じ制御を共有できます。組織の強制
managed policy settingsに置きます。
allowManagedHooksOnlyが有効な環境ではユーザー・プロジェクト・ローカル・プラグインのフックが無効になり、managed側のフックは動き続けます。
プラグインのhooks/hooks.jsonと、スキル・サブエージェントのfrontmatterにも書けます。「特定コマンドは承認ファイルが無ければ組織全体で拒否する」といった強制力が必要なときに、managed policy settingsを選ぶことになります。
ハンドラの型もcommand・http・mcp_tool・prompt・agentの5種類がすべて使えます。「どのコマンドがどれだけ使われたかをログに残す」という用途なら、http型でチーム共通のログ収集サーバーにcommand_nameとcommand_argsをPOSTできます。判定にコマンド名のパターンマッチ以上の判断が必要なら、prompt型でClaudeのモデルにcommand_argsの内容を評価させる選択肢もあります。
Skillのfrontmatterに書くとどう変わるか
frontmatterに書いたフックのうち、スキルのものは、そのスキルが一度呼び出されて登録された後、セッションの残り全体で効き続ける点に注意が必要です。呼び出された直後のターンに限らず、以降の別コマンドの展開にも反応します。
1回動いたら外したい場合はonce: trueを付けます。成功した最初の実行のあとで外れる設定で、実行が失敗したときやexit 2でブロックしたとき、タイムアウトしたときはフックが残り、次の対象イベントでもう一度動きます。スキルのfrontmatterに書いたフックだけが対応し、settings.jsonやサブエージェントのfrontmatterでは無視されます。
プロジェクトのスキルにfrontmatterでフックを書いた場合、settings.jsonのフックと同じワークスペース信頼のルールが適用されます。未信頼のフォルダでも、-p実行でスキルを呼び出すと登録・実行されるため、「信頼していないフォルダでは何も走らない」とは考えない方が安全です。サブエージェントのfrontmatterに書いたフックは逆に、そのフォルダのワークスペース信頼ダイアログを承認するまで動きません。
プラグイン経由でスキルやフックを配布している場合、フックの移行手順はClaude Codeプラグイン化の移行手順にまとめています。フック定義自体のフォーマットはsettings.jsonと共通なので、既存のUserPromptExpansion設定をそのままfrontmatterへ移せます。
prompt hookで判定すると差し戻しにならない
判定ロジックをtype: "prompt"のLLM評価に任せる場合は、"ok": falseを返したときの扱いがイベントで分かれます。
prompt hookが"ok": falseを返したとき
StopとSubagentStop
reasonがClaudeへ次の指示として渡され、ターンは続きます。「直して」と差し戻す設計に使えます。
UserPromptExpansion
ターンはそこで終了し、reasonは警告行としてユーザーに表示されるだけです。Claudeにフィードバックとして戻ることはありません。
承認待ちのように「差し戻して直させる」設計には、UserPromptExpansionのprompt hookは向きません。同じ分岐はUserPromptSubmitとPostToolBatchにも当てはまります。
運用で見落としやすい注意点
matcherが空のときは全てのプロンプト型コマンドに発火するため、意図せず全コマンドをログ対象にしてしまう設定ミスが起きやすい箇所です。コマンド名を絞るなら、deployのように完全一致で書くのが確実です。
展開をブロックしたコマンドは、Claudeに届きません。したがって、承認前に判定を挟むPermissionRequest hookの段階にも進みません。直接入力の入口をUserPromptExpansionで、Claudeによるツール実行の手前をPreToolUseやPermissionRequestで、と役割を分けて組むことになります。
まとめ
/skillnameと直接タイプされるコマンドを止めたいなら、PreToolUseではなくUserPromptExpansionを選びます。直接入力の入口を握るのはUserPromptExpansion、Claudeによるツール実行の手前を握るのはPreToolUseとPermissionRequestです。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。