From 0e9308f60edc03338bd069174b525ab8d3f7ca82 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 31 Aug 2026 19:04:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20=E6=8A=80=E8=83=BD=E5=90=8D?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E8=88=87=E9=98=BB=E6=93=8B=E8=BC=B8=E5=87=BA?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: - 新增 hooks/skill-name.sh。五支 CLI 各一個子命令,從各自的負載解析出這一次要用哪一支 jsc 技能,印一行「{domain}{技能名}」。解不出來就印空字串並回 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 接線修正。 --- hooks/deny.sh | 57 ++++++++++++++++++++++ hooks/restart-gate.sh | 55 ++++++++++++--------- hooks/skill-name.sh | 106 +++++++++++++++++++++++++++++++++++++++++ hooks/version-guard.sh | 73 +++++++++++++++------------- 4 files changed, 234 insertions(+), 57 deletions(-) create mode 100755 hooks/deny.sh create mode 100755 hooks/skill-name.sh diff --git a/hooks/deny.sh b/hooks/deny.sh new file mode 100755 index 0000000..0b79984 --- /dev/null +++ b/hooks/deny.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env sh +# deny.sh — 產出各 CLI 認得的「擋下這一次呼叫」輸出。 +# +# 用途:阻擋形態每支 CLI 都不一樣,判定卻是同一件事。形態留這一份,閘門只管判定: +# 各自留一份輸出邏輯,改了一支忘了另一支,就會出現「判定擋下、CLI 卻沒收到拒絕」的無聲失效。 +# +# 用法:deny.sh {cli} [訊息...] +# 訊息從參數來,沒給參數就讀標準輸入。兩者都空就送一句預設訊息,不會產出空的拒絕。 +# +# 各 CLI 的阻擋形態: +# claude、codex、copilot 訊息寫 stderr,結束碼 2 就是拒絕 +# antigravity stdout 印 {"decision":"deny","reason":"..."}。 +# 結束碼語意兩邊文件都沒寫,**絕對不可靠**,所以固定回 0, +# 拒絕整個靠那一行 JSON。也因此 stdout 只准有那一行, +# 呼叫端要給人看的字一律走 stderr。 +# kiro 擋不下來。技能叫用是 agent 內部請求,preToolUse 攔不到, +# userPromptSubmit 的非零結束碼也不會擋下那一輪。 +# 唯一可用的介入是 stdout 注入,所以改印警告並回 0。 +# +# 結束碼: +# 2 拒絕已經送出(claude、codex、copilot,以及認不得的 CLI 代號)。呼叫端直接把它當自己的結束碼。 +# 0 拒絕已經送出,但形態不靠結束碼(antigravity 的 stdout JSON),或這支 CLI 根本擋不下來(kiro)。 +# 本檔沒有其他結束碼。認不得的代號不另立一種:五支裡有三支是 stderr 加 2,未知代號走這個保守預設, +# 比靜靜放行安全。 +set -u + +CLI="${1:-}" +shift 2>/dev/null || true + +if [ "$#" -gt 0 ]; then + MSG="$*" +else + MSG="" + [ -t 0 ] || MSG=$(cat 2>/dev/null || true) +fi +[ -n "$MSG" ] || MSG='[jsc]:這次呼叫已被 jsc 閘門擋下。' + +# 把訊息壓成一個合法的 JSON 字串值:反斜線與雙引號先跳脫,換行與定位字元改成跳脫序列。 +# 直接把原文塞進 JSON 會做出解不開的負載,antigravity 收到壞 JSON 等於沒收到拒絕。 +json_reason() { + printf '%s' "$MSG" \ + | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' \ + | awk '{ printf "%s%s", sep, $0; sep = "\\n" } END { printf "\n" }' +} + +case "$CLI" in + antigravity) + printf '{"decision":"deny","reason":"%s"}\n' "$(json_reason)" + exit 0 ;; + kiro) + printf '%s\n' "$MSG" + printf '[jsc]:kiro 擋不下技能叫用(CLI 限制),上面這段只是警告,請自己先處理完再繼續。\n' + exit 0 ;; + *) + printf '%s\n' "$MSG" >&2 + exit 2 ;; +esac diff --git a/hooks/restart-gate.sh b/hooks/restart-gate.sh index 7f6632e..4264ed5 100755 --- a/hooks/restart-gate.sh +++ b/hooks/restart-gate.sh @@ -4,16 +4,26 @@ # 技能組更新後,正在跑的 CLI 行程載入的還是舊版:SKILL.md、hook 腳本與 tools 都在啟動當下 # 讀進記憶體。所以部署收尾要求重新啟動,這道閘門負責讓「還沒重啟就繼續用技能」擋在門外。 # -# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫,訊息走 stderr。 -# 安靜放行(exit 0)的情況:逃生門 JSC_RESTART_GATE=off、工具名取得到但不是 Skill、 -# 取不到技能名、技能名不是 jsc-{domain}:{name}、命中下方豁免清單那 10 支、取不到 CLI 代號、 +# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫。 +# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容: +# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼 +# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。 +# 所以「exit 0」在這支腳本有兩種意思:放行,或已經以不靠結束碼的形態擋下。 +# 安靜放行(exit 0)的情況:逃生門 JSC_RESTART_GATE=off、負載裡解不出技能名、 +# 解出來的不是 jsc 技能、命中下方豁免清單那 10 支、取不到 CLI 代號、 # 當前 CLI 那份狀態檔與舊格式狀態檔都不在。 -# 只有「當前 CLI 那份狀態檔存在」或「退回讀到的舊格式狀態檔存在」會 exit 2。 +# 只有「當前 CLI 那份狀態檔存在」或「退回讀到的舊格式狀態檔存在」會走 deny.sh。 # 結束碼(require):0=閘門已掛上 2=取不到 CLI 代號或寫不進狀態檔,兩種都等於沒掛上。 # 結束碼(clear、report):0=永遠成功。clear 檔案不存在也算成功,report 一份都沒有就不印。 # 結束碼(不認得的子命令):0=安靜放行,不中斷宿主 CLI。 # 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地 # 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫,上面那些放行路徑一條都跑不到。 +# hooks/skill-name.sh 與 hooks/deny.sh 同理要一起裝上,但那兩支是以子行程呼叫,讀不到只會 +# 讓技能名解不出來而安靜放行,不會反過來擋人——所以那兩支刻意不用 source 載入。 +# +# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先, +# 規則與 version-guard.sh 共用同一份。不再另外篩工具名:工具名每支 CLI 都不一樣 +# (Skill、Bash、skill、view_file),拿 Claude 的那一個當通用條件會把另外四支整批擋在判定之外。 # # 用法: # restart-gate.sh hook 模式:當前 CLI 那份狀態檔存在就擋下該次技能 @@ -184,19 +194,14 @@ read_stdin [ "${JSC_RESTART_GATE:-}" = "off" ] && exit 0 -# 輸入相容:stdin JSON(Claude 格式)與環境變數(其他四支 CLI 接線時設定)都要收, -# 取法比照 version-guard.sh。工具名取不到就當成沒篩,繼續判技能名。 -tool="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}" -[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0 - -skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}" -[ -n "$skill" ] || exit 0 - -# 只管本技能組(jsc-{domain}:{name})。別人的技能不受這道閘門影響。 -case "$skill" in - jsc-*:*) ;; - *) exit 0 ;; -esac +# 技能名解析:交給 skill-name.sh,規則與 version-guard.sh 共用同一份。輸出固定是 +# 「{domain}{技能名}」;用 awk 判 NF==2 才取值,少一欄就當成解析不出來,免得沒有定位字元時 +# cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。 +sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null) +sn_domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }') +sn_name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }') +[ -n "$sn_domain" ] && [ -n "$sn_name" ] || exit 0 +skill="jsc-$sn_domain:$sn_name" # 豁免清單(理由見檔頭) case "$skill" in @@ -236,9 +241,13 @@ info="" # 系統認定就是這一支剛部署過。 [ "$legacy" = yes ] && info="${info}${info:+,}舊格式紀錄,分不出是哪一支 CLI 部署的" -printf '[jsc][重啟閘門][ERR]:技能組已更新%s,%s 還在跑舊版,新版要重新啟動才會載入。本次技能呼叫已擋下。\n' \ - "${info:+($info)}" "$bin" >&2 -printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \ - "$bin" "$state" >&2 -printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-hooks:repair、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完,hook 壞掉也要修得回來) | 確定要略過閘門:JSC_RESTART_GATE=off\n' >&2 -exit 2 +# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。三段訊息整段走同一條管線送過去, +# antigravity 那一支才有辦法把它們壓成同一個 reason 字串;分次呼叫會做出好幾份 deny JSON, +# 那支 CLI 只認第一份,後面兩段使用者永遠看不到。 +{ printf '[jsc][重啟閘門][ERR]:技能組已更新%s,%s 還在跑舊版,新版要重新啟動才會載入。本次技能呼叫已擋下。\n' \ + "${info:+($info)}" "$bin" + printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \ + "$bin" "$state" + printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-hooks:repair、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完,hook 壞掉也要修得回來) | 確定要略過閘門:JSC_RESTART_GATE=off\n' +} | sh "$HERE/deny.sh" "$(cli_name)" +exit $? diff --git a/hooks/skill-name.sh b/hooks/skill-name.sh new file mode 100755 index 0000000..fa544de --- /dev/null +++ b/hooks/skill-name.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env sh +# skill-name.sh — 從各 CLI 的 hook 負載解析出「這一次要用哪一支 jsc 技能」。 +# +# 用途:五支 CLI 的負載形態各不相同,但「從負載取出 domain 與技能名」是同一件事。 +# 規則只留這一份:寫在每支閘門裡就會漂移,CLI 換了負載形態也只要改這一個地方。 +# +# 用法:skill-name.sh {claude|codex|copilot|antigravity|kiro} +# 從標準輸入讀該 CLI 的 hook 負載,印出一行「{domain}{技能名}」,例如「sdlcimplement」。 +# 解析不出來就印空字串,由呼叫端安靜放行。 +# +# 各 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 diff --git a/hooks/version-guard.sh b/hooks/version-guard.sh index 17105e1..54896d4 100755 --- a/hooks/version-guard.sh +++ b/hooks/version-guard.sh @@ -6,23 +6,32 @@ # 二、技能所屬 plugin 宣告的相依 plugin 版本落後(manifest 的 jsc.requires)。 # 兩種都會提示更新指令。 # -# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫,訊息走 stderr。 +# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫。 +# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容: +# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼 +# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。 +# 所以「exit 0」在這支腳本有兩種意思:放行,或已經以不靠結束碼的形態擋下。 # 安靜放行(exit 0)的情況要記清楚,這道閘門絕大多數時候走的是這幾條:逃生門 -# JSC_VERSION_GUARD=off、工具名取得到但不是 Skill、取不到技能名、技能名不是 -# jsc-{domain}:{name}、拆不出 domain、命中下方豁免清單那 7 支、解不出安裝路徑、 +# JSC_VERSION_GUARD=off、負載裡解不出技能名、解出來的不是 jsc 技能、 +# 命中下方豁免清單那 7 支、解不出安裝路徑、 # 讀不到 manifest、manifest 沒有 jsc.requires、讀不到相依 plugin 的本機載入版本、 # 讀不到自己的本機實際載入版本、推導不出遠端站台、查不到遠端版本、 # 本機版本等於或超前遠端。 -# 只有「相依確定落後」與「本機落後遠端」這兩條會 exit 2。 +# 只有「相依確定落後」與「本機落後遠端」這兩條會走 deny.sh。 # 結束碼(report、recommend):0=永遠成功,只讀不擋。結論看 stdout,不看結束碼。 # 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地 # 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫,上面那些放行路徑一條都跑不到 # (write-guard.sh 踩過這個坑)。部署時要確認 hooks/lib.sh 跟這支腳本一起裝上。 +# hooks/skill-name.sh 與 hooks/deny.sh 同理要一起裝上,但那兩支是以子行程呼叫,讀不到只會 +# 讓技能名解不出來而安靜放行,不會反過來擋人——所以那兩支刻意不用 source 載入。 # -# 輸入:stdin JSON(Claude 格式)或環境變數,兩者都收。 -# 工具名 JSC_TOOL_NAME、TOOL_NAME、stdin 的 tool_name -# 技能名 JSC_SKILL、SKILL、stdin 的 skill -# 兩邊都拿不到就安靜降級 exit 0。 +# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先。 +# 五支 CLI 的負載形態不同(claude 有 skill 欄位、codex 是 Bash 指令裡的 SKILL.md 路徑、 +# copilot 是字串化的 toolArgs、antigravity 是 AbsolutePath、kiro 是提示開頭的斜線指令), +# 取值規則只留 skill-name.sh 那一份,本檔不重寫第二套。解不出來就安靜降級 exit 0。 +# 不再另外篩工具名:工具名每支 CLI 都不一樣(Skill、Bash、skill、view_file), +# 拿 Claude 的那一個當通用條件,等於把另外四支整批擋在判定之外——這正是先前失效的原因。 +# 接線那一端已經用各自的 matcher 篩過一輪,解得出技能名就是該判的那一次。 # # 判準與取值: # - 比對對象是「遠端發佈版本」與「本機**實際載入**的版本」。 @@ -306,24 +315,14 @@ read_stdin [ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0 -# 輸入相容:stdin JSON(Claude 格式)與環境變數(其他四支 CLI 接線時設定)都要收。 -# 只讀 stdin 的話,用環境變數餵資料的 CLI 一律拿到空值,檢查會整支靜靜放行。 -# 兩者都缺才是真的沒資料,那時照舊安靜降級 exit 0。 -tool="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}" -[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0 - -skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}" -[ -n "$skill" ] || exit 0 - -# 只管本技能組(jsc-{domain}:{name}) -case "$skill" in - jsc-*:*) ;; - *) exit 0 ;; -esac - -domain=${skill#jsc-} -domain=${domain%%:*} -[ -n "$domain" ] || exit 0 +# 技能名解析:交給 skill-name.sh,它一支 CLI 一個子命令,規則只有那一份。 +# 輸出固定是「{domain}{技能名}」;用 awk 判 NF==2 才取值,少一欄就當成解析不出來, +# 免得沒有定位字元時 cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。 +sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null) +domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }') +name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }') +[ -n "$domain" ] && [ -n "$name" ] || exit 0 +skill="jsc-$domain:$name" # 豁免清單 case "$skill" in @@ -348,11 +347,15 @@ update_cmd() { # $1=domain esac } +# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。訊息整段走管線送過去, +# antigravity 那一支才有辦法把多行訊息壓成同一個 reason 字串;分成好幾次呼叫會做出好幾份 +# deny JSON,那支 CLI 只認第一份,後面幾段訊息使用者永遠看不到。 deny() { # $1=訊息 - printf '[jsc][版本檢查][ERR]:%s\n' "$1" >&2 - printf '更新指令:%s\n' "$(update_cmd "$domain")" >&2 - printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2 - exit 2 + { printf '[jsc][版本檢查][ERR]:%s\n' "$1" + printf '更新指令:%s\n' "$(update_cmd "$domain")" + printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' + } | sh "$HERE/deny.sh" "$(cli_name)" + exit $? } # ── 相依版本檢查:讀技能所屬 plugin 的 manifest,逐項比對相依 plugin 的本機載入版本。 @@ -374,10 +377,12 @@ if [ -n "$plugin_dir" ] && [ -f "$plugin_dir/plugin.json" ]; then printf ' - %s 需要 %s,目前 %s,更新指令:%s\n' "$dep" "$cond" "$cur" "$(update_cmd "$dep_domain")" done) if [ -n "$behind_list" ]; then - printf '[jsc][版本檢查][ERR]:%s 宣告的相依 plugin 版本落後,本次技能呼叫已擋下\n' "$skill" >&2 - printf '%s\n' "$behind_list" >&2 - printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2 - exit 2 + # 逐項清單與結語一起送進 deny.sh,理由同上:一次呼叫、一份拒絕。 + { printf '[jsc][版本檢查][ERR]:%s 宣告的相依 plugin 版本落後,本次技能呼叫已擋下\n' "$skill" + printf '%s\n' "$behind_list" + printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' + } | sh "$HERE/deny.sh" "$(cli_name)" + exit $? fi fi