Files
hooks/hooks/skill-name.sh
T
jiantw83 0e9308f60e feat(hooks): 技能名解析與阻擋輸出共用化
What:
- 新增 hooks/skill-name.sh。五支 CLI 各一個子命令,從各自的負載解析出這一次要用哪一支 jsc 技能,印一行「{domain}<TAB>{技能名}」。解不出來就印空字串並回 0。
- 新增 hooks/deny.sh。依當前 CLI 產出四種阻擋形態,訊息從參數或標準輸入進。
- version-guard.sh 與 restart-gate.sh 改用這兩支,各自那份工具名判定與技能名取值一併移除。

Why:
- 兩道閘門原本都拿 Claude 的工具名 Skill 當通用條件。另外四支 CLI 的工具名分別是 Bash、skill、view_file,一律被擋在判定之外,兩道閘門在那四支上長期完全失效,而且一聲都不吭。
- 阻擋形態每支 CLI 都不一樣,判定卻是同一件事。各自留一份輸出邏輯,改了一支忘了另一支,就會做出「判定擋下、CLI 照樣放行」的無聲失效。

How:
- 技能名取值規則只留 skill-name.sh 這一份,環境變數 JSC_SKILL、SKILL 優先。claude 讀 skill 欄位、codex 認 tool_input.command 裡那條 SKILL.md 路徑、copilot 先剝一層字串化 JSON 再讀 toolArgs、antigravity 讀 toolCall.args.AbsolutePath 並另收提示字串、kiro 取提示開頭那個斜線指令。
- 閘門端用 awk 判 NF == 2 才取值。少一欄就當成解析不出來,免得沒有定位字元時 cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。
- deny.sh 定形態:claude、codex、copilot 走 stderr 加 exit 2;antigravity 印 stdout 的單行 deny JSON 並固定回 0,因為那支 CLI 的結束碼語意兩邊文件都沒寫、絕不可靠;kiro 擋不下技能叫用,改印警告後回 0;認不得的代號走 stderr 加 2 這個保守預設。
- 訊息整段走同一條管線送進 deny.sh。分次呼叫會做出好幾份 deny JSON,antigravity 只認第一份,後面幾段使用者永遠看不到。
- 豁免清單與 fail-open 原則不變。這兩支刻意以子行程呼叫、不用 source 載入:讀不到只會讓技能名解不出來而安靜放行,不會反過來擋掉每一次呼叫。

Who:codex、copilot、antigravity、kiro 四支 CLI 的 pre-tool hook 接線修正。
2026-08-31 19:05:48 +08:00

107 lines
5.2 KiB
Bash
Executable File

#!/usr/bin/env sh
# skill-name.sh — 從各 CLI 的 hook 負載解析出「這一次要用哪一支 jsc 技能」。
#
# 用途:五支 CLI 的負載形態各不相同,但「從負載取出 domain 與技能名」是同一件事。
# 規則只留這一份:寫在每支閘門裡就會漂移,CLI 換了負載形態也只要改這一個地方。
#
# 用法:skill-name.sh {claude|codex|copilot|antigravity|kiro}
# 從標準輸入讀該 CLI 的 hook 負載,印出一行「{domain}<TAB>{技能名}」,例如「sdlc<TAB>implement」。
# 解析不出來就印空字串,由呼叫端安靜放行。
#
# 各 CLI 的取值來源:
# claude stdin JSON 的 skill 欄位(PreToolUse matcher Skill 才會有)
# codex stdin JSON 的 tool_input.command 裡那條 SKILL.md 路徑。Codex 沒有 Skill 工具,
# 技能是模型自己用 Bash 讀 SKILL.md 載入的,所以要從指令字串裡認路徑
# copilot stdin JSON 的 toolArgs。那個欄位是**字串化的 JSON**,要先剝一層跳脫才讀得到裡面的值
# antigravity stdin JSON 的 toolCall.args.AbsolutePath;另外收 PreInvocation 那一輪的提示字串,
# 因為斜線指令會把 SKILL.md 全文直接注入訊息,一個工具呼叫都不產生,PreToolUse 攔不到
# kiro stdin JSON 的 prompt,取開頭那個「/{技能名}」
# 五支都先看環境變數 JSC_SKILL、SKILL:接線時用環境變數餵資料的 CLI 要收得到,冒煙測試也走這條。
#
# 為什麼只認 jsc 技能:呼叫這支腳本的是 jsc 自己的閘門,別人的技能不歸它們管。解不出 jsc-{domain}
# 這個形狀就等同「這一次不是 jsc 技能」,印空字串比印半個結果安全——呼叫端只要判空就好,不必再
# 自己補一次「這是不是我們的技能」的判斷,那正是會漂移的那一段。
#
# 結束碼:
# 0 永遠是 0,含「解析不出來」與「CLI 代號不認得」兩種。這支腳本只解析、不判定:
# 閘門那一端一律 fail-open,解析失敗回非零只會讓呼叫端多一條沒必要的錯誤分支。
# copilot 的 command hook 是 fail-closed 的(非零結束碼等於拒絕),更不能回非零。
# 本檔沒有其他結束碼。
set -u
CLI="${1:-}"
PAYLOAD=""
[ -t 0 ] || PAYLOAD=$(cat 2>/dev/null || true)
# 把整份負載併成一行再取「某個欄位之後的內容」。不切逗號:命令字串裡本來就有逗號,
# 切了會把路徑攔腰砍斷。貪婪比對取的是最後一次出現的那個欄位,巢狀負載也指得到裡層那一個。
after_field() { # $1=欄位名
printf '%s' "$PAYLOAD" | tr -d '\n' \
| sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*//p"
}
# 從一段文字取第一條 SKILL.md 路徑。刻意不去解那個 JSON 字串的值:值裡的引號是跳脫過的,
# 照欄位邊界取會在第一個 \" 就被截斷,反而讀不到路徑。認路徑本身的形狀最穩。
md_path() { # $1=文字
printf '%s' "$1" | grep -o '/[A-Za-z0-9_./-]*SKILL\.md' | head -n1
}
# 從一段文字取第一個 jsc-{domain}:{技能名} 字樣
token_skill() { # $1=文字
printf '%s' "$1" | grep -o 'jsc-[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*' | head -n1
}
# jsc-{domain}:{技能名} → 兩欄輸出
emit_token() { # $1=技能名字樣
[ -n "$1" ] || return 0
_d=${1#jsc-}; _d=${_d%%:*}
_n=${1#*:}
[ -n "$_d" ] && [ -n "$_n" ] || return 0
printf '%s\t%s\n' "$_d" "$_n"
}
# SKILL.md 路徑 → 兩欄輸出。domain 取路徑裡最後一段 jsc-{domain},技能名取 SKILL.md 的上一層目錄,
# 所以 {前綴}/jsc-sdlc/skills/implement/SKILL.md 與 {前綴}/jsc-sdlc/implement/SKILL.md 都解得出來。
emit_path() { # $1=路徑
[ -n "$1" ] || return 0
_d=$(printf '%s' "$1" | sed -n 's#.*/jsc-\([a-z0-9][a-z0-9-]*\)/.*#\1#p')
_n=$(printf '%s' "$1" | sed -n 's#.*/\([^/][^/]*\)/SKILL\.md$#\1#p')
[ -n "$_d" ] && [ -n "$_n" ] || return 0
printf '%s\t%s\n' "$_d" "$_n"
}
# 環境變數優先。接線時用環境變數餵資料的 CLI 只有這一條路,負載再怎麼解也解不出東西。
env_skill="${JSC_SKILL:-${SKILL:-}}"
if [ -n "$env_skill" ]; then
emit_token "$(token_skill "$env_skill")"
exit 0
fi
case "$CLI" in
claude)
emit_token "$(token_skill "$(after_field skill)")" ;;
codex)
emit_path "$(md_path "$(after_field command)")" ;;
copilot)
# 剝一層字串化 JSON:把 \" 還原成 "、\\ 還原成 \,裡面的技能名才認得出來。
_args=$(after_field toolArgs | sed 's/\\"/"/g; s/\\\\/\\/g')
_tok=$(token_skill "$_args")
if [ -n "$_tok" ]; then emit_token "$_tok"; else emit_path "$(md_path "$_args")"; fi ;;
antigravity)
_p=$(md_path "$(after_field AbsolutePath)")
if [ -n "$_p" ]; then
emit_path "$_p"
else
# PreInvocation 那一輪沒有工具呼叫,只有提示字串。斜線指令走的就是這條路。
emit_token "$(token_skill "$(after_field prompt)")"
fi ;;
kiro)
# 提示開頭那個斜線指令。kiro 的技能不走工具管線,userPromptSubmit 是唯一看得到技能名的時點。
emit_token "$(token_skill "$(after_field prompt)")" ;;
*)
: ;; # 認不得的代號印空字串,理由見檔頭結束碼那一段
esac
exit 0