Files
hooks/hooks/version-guard.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

405 lines
22 KiB
Bash
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env sh
# version-guard.sh — 技能使用前的版本前置檢查(PreToolUse,matcher: Skill)。
#
# 這道閘門擋兩種情況,兩種都會 exit 2:
# 一、本機版本落後遠端發佈版本。
# 二、技能所屬 plugin 宣告的相依 plugin 版本落後(manifest 的 jsc.requires)。
# 兩種都會提示更新指令。
#
# 結束碼(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、負載裡解不出技能名、解出來的不是 jsc 技能、
# 命中下方豁免清單那 7 支、解不出安裝路徑、
# 讀不到 manifest、manifest 沒有 jsc.requires、讀不到相依 plugin 的本機載入版本、
# 讀不到自己的本機實際載入版本、推導不出遠端站台、查不到遠端版本、
# 本機版本等於或超前遠端。
# 只有「相依確定落後」與「本機落後遠端」這兩條會走 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 載入。
#
# 輸入:技能名一律由 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 篩過一輪,解得出技能名就是該判的那一次。
#
# 判準與取值:
# - 比對對象是「遠端發佈版本」與「本機**實際載入**的版本」。
# 實際載入版本只認 installed_plugins.json 的 installPath 底下那份 plugin.json,
# 不看註冊在 installed_plugins.json 的版本欄位——那兩者可能不同,註冊值比較新時
# 會放過真正被載入的舊版。讀不到那份檔案就當查不到,安靜放行。
# - 只擋落後。本機版本等於或超前遠端一律放行:開發技能組時本機本來就會
# 超前預設分支,擋下去會讓維護者自己動不了。
# - 查不到資料一律放行(exit 0):本機版本、Gitea 站台、遠端版本全部來自
# Claude 的 plugin 檔案與 Gitea API,沒裝 Claude 或離線的機器一筆都讀不到。
# 那種情況擋下去,等於在沒有任何版本證據時停掉每一次技能呼叫,護欄變成故障點。
#
# 相依版本檢查(第二種擋人情況):
# - 取值來源是技能所屬 plugin 的 manifest,也就是 installPath 底下那份 plugin.json,
# 讀它的 jsc.requires,一項是一個「相依 plugin: 最低版本」。
# - 相依 plugin 的現況一律取「本機實際載入版本」,規則與上面同一條:只認
# installed_plugins.json 的 installPath 底下那份 plugin.json,不拿註冊欄位當備援。
# - 只要有一項落後宣告的最低版本就 exit 2,訊息逐項講明哪一個 plugin、需要哪一版、
# 目前哪一版、怎麼補。
# - 判定同樣 fail-open:解不出安裝路徑、讀不到 manifest、manifest 沒有 jsc.requires、
# 讀不到某一項相依的本機載入版本,四種都安靜放行。五支 CLI 只有 claude 讀得到
# 本機載入版本,fail-closed 會把另外四支整批鎖死。
# - 這段邏輯自己實作,不去呼叫 jsc-cli 的 check-requires.sh。兩個理由:技能準則要求
# 所有 hook 專屬存放於 jsc-hooks,不可散落到別的 domain;而且 jsc-cli 已經宣告相依
# jsc-hooks,反向呼叫會做出循環相依。部署那端照樣更新、只回報,阻擋落在這支 hook。
# - 檢查順序刻意排在遠端比對之前:相依檢查全部讀本機檔案,不必連網,離線機器也判得動。
#
# 豁免(這些技能永遠放行,兩種擋人情況一起豁免):
# 共 7 項,jsc-meta:* 算一項。相依版本落後不另立一份短清單:這幾支同樣是修復與更新的
# 唯一路徑,用哪一個理由擋都是死鎖。這張表的唯一真實來源就是這段檔頭與下方豁免清單。
# jsc-cli:deploy 更新整組技能的入口,擋了就沒有任何方法更新,會死鎖
# jsc-hooks:hooks-install 更新後要重新接線,擋了會讓更新做一半卡住
# jsc-cli:models SDLC 閘門依賴它產生 model-tags.tsv
# jsc-meta:* 開發技能組本身的工具,擋了就修不了技能組
# jsc-ask:ask deploy 問「install/update/uninstall」一定會呼叫它;
# 它本身落後版本被擋下,訊息又指回 /jsc-cli:deploy,
# deploy 卡在問不出模式那一步,跟直接擋 deploy 是同一種死鎖
# jsc-gitea:wiki jsc-ask:ask 問完一定寫回 wiki 才算完成,理由同上一條,
# 擋在這一步一樣是 deploy 做不完
# jsc-hooks:repair hook 壞掉時唯一的修復路徑。修 hook 的技能被 hook 擋下,
# 就沒有任何方法把 hook 修回來,跟直接擋 deploy 是同一種死鎖
#
# 逃生門:JSC_VERSION_GUARD=off 完全略過檢查(離線工作時用)。
#
# 快取:$JSC_HOME/version-cache/{CLI 代號}/{domain},單行「{版本} {epoch}」,
# 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。hook 與 report 在同一支 CLI 內共用。
# 舊路徑 $JSC_HOME/version-cache/{domain} 會在第一次讀取時複製到當前 CLI 的新路徑。
#
# 另有兩個非 hook 的子指令:
# version-guard.sh report 把每個已安裝 jsc-* plugin 的版本比對印成 TSV,每行
# 「{domain}<TAB>{本機}<TAB>{遠端}<TAB>{落後|最新|超前|查詢失敗}」,
# 最後一行「behind<TAB>{落後個數}」。供 jsc-cli:deploy 判斷要不要
# 把「更新」設成推薦選項。report 只讀不擋,永遠 exit 0。
# 本機沒有 Claude 的 plugin 註冊檔時改印「noregistry<TAB>{路徑}」
# 再接 behind 0:那代表這台機器無法做版本檢查,跟「全部最新」是兩件事。
#
# version-guard.sh recommend
# 把 report 那張表收斂成一個結論,只印一行、只有這一種格式:
# recommend<TAB>update|none|unverifiable
# 判定規則(呼叫端不必自己再判一次):
# update 任何一個 plugin 落後就是 update,一個就夠,不等多數
# unverifiable 查不到註冊資訊(noregistry),或一列 domain 都沒有,
# 或每一列都是查詢失敗——沒有任何一項查得到的證據
# none 至少有一列查得到結果,而且沒有任何一項落後
# 查詢失敗的那幾列不計入:查不到不等於最新,也不等於落後,只是沒有證據。
# 輸出格式必須穩定,別的 domain 的技能直接讀第二欄;證據表要另外看的話
# 再呼叫一次 report,這裡刻意不混印,免得 cut 取值被表格內容打亂。
# recommend 只讀不擋,永遠 exit 0:判定結果只看那一行的第二欄。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
REG="$HOME/.claude/plugins/installed_plugins.json"
MK="$HOME/.claude/plugins/known_marketplaces.json"
# 從檔案取 JSON 字串欄位。與 lib.sh 的 json_str 同一種 naive 解析,只是來源是檔案:
# 先把換行換成空白、再以逗號斷行,這樣每行最多一個欄位,取值不會被貪婪比對吃掉。
file_json_str() { # $1=檔案 $2=欄位名
[ -f "$1" ] || return 0
tr '\n' ' ' < "$1" | tr ',' '\n' \
| sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
}
# 該 plugin 的安裝路徑:從註冊檔取 installPath。版本與 manifest 都從這個目錄取,
# 抽成一支函式是為了讓兩邊共用同一條解析規則,各寫一份就會漂移。
# 取不到就回傳空字串,由呼叫端安靜放行。
install_path() { # $1=domain
[ -f "$REG" ] && [ -r "$REG" ] || return 0
_seg=$(tr -d '\n' < "$REG" \
| sed -n "s/.*\"jsc-$1@jsc\"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p")
[ -n "$_seg" ] || return 0
printf '%s' "$_seg" | tr ',' '\n' \
| sed -n 's/.*"installPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1
}
# 本機實際載入版本:先取該 plugin 的 installPath,再讀那個目錄下的 plugin.json。
# 只認 installPath 底下那份檔案。註冊在 installed_plugins.json 的 version 欄位不當備援:
# 註冊值可能比實際載入的版本新,拿它來比對會放過真正被載入的舊版,護欄形同虛設。
# 讀不到那份檔案就當「查不到本機載入版本」,由呼叫端安靜放行。
local_version() { # $1=domain
_lp=$(install_path "$1")
[ -n "$_lp" ] || return 0
file_json_str "$_lp/plugin.json" version
}
# manifest 宣告的相依版本:印出每行一項「{相依 plugin} {版本條件}」,例如「jsc-cli >=0.2.1」。
# 解析方式沿用本檔的 naive JSON 取值:先取 "requires" 後面那一對大括號裡的內容,
# 再以逗號斷行,這樣每行最多一組鍵值。jsc.requires 底下只有一層字串對字串,夠用。
# 只認 jsc- 開頭的鍵:這道閘門管的是本技能組自己的 plugin,別的來源查不到本機載入版本,
# 收進來也只會走到 fail-open 那條路。沒有宣告、讀不到檔案都印空字串,由呼叫端安靜放行。
requires_pairs() { # $1=manifest 路徑
[ -f "$1" ] && [ -r "$1" ] || return 0
# 補一個換行再往下送:tr -d '\n' 之後整份 JSON 只剩一行,而且結尾沒有換行,
# 這種缺行尾的串流走到最後一筆時,read 會把值讀進去卻回非零,while 迴圈的本體
# 一次都跑不到,落後的相依就靜靜被漏掉。補在源頭,後面每一段都拿得到完整的行。
{ tr -d '\n' < "$1"; printf '\n'; } \
| sed -n 's/.*"requires"[[:space:]]*:[[:space:]]*{\([^}]*\)}.*/\1/p' \
| tr ',' '\n' \
| sed -n 's/.*"\(jsc-[A-Za-z0-9_-]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1 \2/p'
}
# 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST。
# 印出「{host} {owner}」;推導不出來時 host 為空字串。
remote_host_owner() {
_url=""
if [ -f "$MK" ]; then
_url=$(tr -d '\n' < "$MK" | sed -n 's/.*"jsc"[[:space:]]*:[[:space:]]*{//p' \
| tr ',' '\n' \
| sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
fi
_h=$(printf '%s' "$_url" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')
_o=$(printf '%s' "$_url" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')
if [ -z "$_h" ] || [ -z "$_o" ]; then
_h="${GITEA_HOST:-}"; _o="${JSC_GITEA_OWNER:-plugins}"
fi
printf '%s %s' "$_h" "$_o"
}
# 遠端發佈版本:一律經由 jsc-gitea 的 gitea.sh(技能準則),它會帶 GITEA_TOKEN,
# 並在缺 token 或 401/403 時退回 tea 的登入金鑰,私有存取庫才讀得到。
# 不指定 ref:Gitea 的 raw 端點預設就取該存取庫的預設分支,比在這裡寫死分支名準。
# 查不到就回傳空字串,由呼叫端放行。
remote_version() { # $1=domain $2=host $3=owner
_gsh=$(jsc_gitea_sh) || return 0
_v=""
for _try in 1 2; do # 暫時性網路失敗不該誤判成版本問題,失敗重試一次
_body=$(GITEA_HOST="$2" sh "$_gsh" api GET "/repos/$3/$1/raw/plugin.json" 2>/dev/null) \
&& _v=$(printf '%s' "$_body" | tr '\n' ' ' | tr ',' '\n' \
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
[ -n "$_v" ] && break
sleep 1
done
printf '%s' "$_v"
}
TTL="${JSC_VERSION_TTL:-600}"
cache_root="$JSC_HOME/version-cache"
cache_cli() {
_cli=$(cli_name | sed 's/[^A-Za-z0-9._-]/_/g')
[ -n "$_cli" ] || _cli=unknown
printf '%s' "$_cli"
}
cache_file() { # $1=domain
printf '%s/%s/%s' "$cache_root" "$(cache_cli)" "$1"
}
legacy_cache_file() { # $1=domain
printf '%s/%s' "$cache_root" "$1"
}
# 帶快取的遠端版本查詢。hook 與 report 共用同一份快取與同一個 TTL:
# report 每個 domain 各打一次網路(還帶重試),/jsc-cli:deploy 一跑就是全部 domain,
# 不共用快取等於每次部署都付一輪網路成本。同主機的不同 CLI 不共用快取,避免其中一支
# 讀到另一支留下的檢查紀錄。
cached_remote_version() { # $1=domain $2=host $3=owner
_cache=$(cache_file "$1")
_legacy=$(legacy_cache_file "$1")
if [ ! -f "$_cache" ] && [ -f "$_legacy" ]; then
mkdir -p "${_cache%/*}" 2>/dev/null || true
cat "$_legacy" > "$_cache" 2>/dev/null || true
fi
_now=$(now_epoch)
if [ -f "$_cache" ]; then
_cv=$(cut -d' ' -f1 "$_cache" 2>/dev/null)
_ca=$(cut -d' ' -f2 "$_cache" 2>/dev/null)
if [ -n "$_cv" ] && [ -n "$_ca" ] && [ $((_now - _ca)) -lt "$TTL" ]; then
printf '%s' "$_cv"; return 0
fi
fi
_rv=$(remote_version "$1" "$2" "$3")
if [ -n "$_rv" ]; then
mkdir -p "${_cache%/*}" 2>/dev/null || true
printf '%s %s\n' "$_rv" "$_now" > "$_cache" 2>/dev/null || true
fi
printf '%s' "$_rv"
}
# 語意化比較:印出 -1($1 落後)、0(相等)、1($1 超前)
ver_cmp() { # $1=版本 A $2=版本 B
awk -v a="$1" -v b="$2" '
BEGIN {
n = split(a, x, "."); m = split(b, y, ".")
for (i = 1; i <= 3; i++) {
xi = (i <= n ? x[i] + 0 : 0); yi = (i <= m ? y[i] + 0 : 0)
if (xi < yi) { print -1; exit }
if (xi > yi) { print 1; exit }
}
print 0
}'
}
# ── report:一次比對所有已安裝的 jsc plugin(非 hook 模式,不讀 stdin)
# 抽成函式是為了讓 recommend 讀同一份輸出。判定規則只寫在這一支腳本裡,recommend 直接解析
# 這裡印出來的表;兩邊各實作一次比對邏輯就會漂移,結論與證據對不起來。
do_report() {
# 註冊檔不存在或讀不到就明講。這裡不能只印 behind 0:呼叫端會把它讀成「都是最新」,
# 於是把「這台機器無法做版本檢查」誤報成「不用更新」。
# 舊版用 `tr -d '\n' < "$REG" 2>/dev/null`,那個 2>/dev/null 只蓋住 tr 的 stderr,
# 蓋不住 shell 開檔失敗的訊息,所以沒裝 Claude 的機器會先漏一行 cannot open。
if [ ! -f "$REG" ] || [ ! -r "$REG" ]; then
printf 'noregistry\t%s\n' "$REG"
printf 'behind\t0\n'
return 0
fi
ho=$(remote_host_owner)
r_host=$(printf '%s' "$ho" | cut -d' ' -f1)
r_owner=$(printf '%s' "$ho" | cut -d' ' -f2)
behind=0
domains=$(tr -d '\n' < "$REG" | tr ',' '\n' \
| sed -n 's/.*"jsc-\([a-z0-9][a-z0-9-]*\)@[^"]*"[[:space:]]*:.*/\1/p' | sort -u)
for d in $domains; do
lv=$(local_version "$d")
rv=""
[ -n "$r_host" ] && rv=$(cached_remote_version "$d" "$r_host" "$r_owner")
if [ -z "$rv" ]; then
st="查詢失敗"
else
case "$(ver_cmp "$lv" "$rv")" in
-1) st="落後"; behind=$((behind + 1)) ;;
1) st="超前" ;;
*) st="最新" ;;
esac
fi
printf '%s\t%s\t%s\t%s\n' "$d" "${lv:-?}" "${rv:-?}" "$st"
done
printf 'behind\t%s\n' "$behind"
return 0
}
if [ "${1:-}" = "report" ]; then
do_report
exit 0
fi
# ── recommend:把 report 那張表收斂成一個結論(非 hook 模式,不讀 stdin)
# 規則的唯一來源就是這一段,jsc-cli:deploy 只讀第二欄,不再自己解那張表。
if [ "${1:-}" = "recommend" ]; then
rep=$(do_report)
verdict=none
if printf '%s\n' "$rep" | grep -q '^noregistry '; then
# 沒有本機註冊檔,一項都比不了。這跟「全部最新」是兩件事,不能推薦 none。
verdict=unverifiable
else
behind_n=$(printf '%s\n' "$rep" | sed -n 's/^behind //p' | head -n1)
# 查得到結果的列:狀態欄是落後、最新或超前三種之一。查詢失敗那幾列不算證據。
known=$(printf '%s\n' "$rep" | awk -F'\t' '$1 != "behind" && $1 != "noregistry" && ($4 == "落後" || $4 == "最新" || $4 == "超前")' | wc -l | tr -d ' ')
if [ -n "$behind_n" ] && [ "$behind_n" -gt 0 ] 2>/dev/null; then
verdict=update
elif [ "${known:-0}" -gt 0 ] 2>/dev/null; then
verdict=none
else
# 一列 domain 都沒有,或每一列都查詢失敗:兩種都是「沒有任何查得到的證據」。
verdict=unverifiable
fi
fi
printf 'recommend\t%s\n' "$verdict"
exit 0
fi
read_stdin
[ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0
# 技能名解析:交給 skill-name.sh,它一支 CLI 一個子命令,規則只有那一份。
# 輸出固定是「{domain}<TAB>{技能名}」;用 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
jsc-cli:deploy|jsc-hooks:hooks-install|jsc-hooks:repair|jsc-cli:models|jsc-meta:*|jsc-ask:ask|jsc-gitea:wiki) exit 0 ;;
esac
# 更新指令依實際 CLI 給。印別的 CLI 的指令等於沒給指令,使用者照著打只會失敗。
update_cmd() { # $1=domain
case "$(cli_name)" in
claude)
printf 'claude plugin marketplace update jsc && claude plugin update jsc-%s@jsc' "$1" ;;
codex)
printf 'codex plugin marketplace upgrade jsc' ;;
copilot)
printf 'copilot plugin marketplace update jsc && copilot plugin update jsc-%s@jsc' "$1" ;;
antigravity)
printf 'git -C ~/plugins/%s pull && agy plugin uninstall jsc-%s && agy plugin install ~/plugins/%s' "$1" "$1" "$1" ;;
kiro)
printf 'kiro-cli plugin marketplace update jsc && kiro-cli plugin update jsc-%s@jsc' "$1" ;;
*)
printf '用你的 CLI 的 plugin 更新指令更新 jsc-%s@jsc' "$1" ;;
esac
}
# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。訊息整段走管線送過去,
# antigravity 那一支才有辦法把多行訊息壓成同一個 reason 字串;分成好幾次呼叫會做出好幾份
# deny JSON,那支 CLI 只認第一份,後面幾段訊息使用者永遠看不到。
deny() { # $1=訊息
{ 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 的本機載入版本。
# 排在遠端比對之前,因為這一段全部讀本機檔案,離線機器也判得動。
# 每一層取不到值都直接跳過,理由見檔頭「相依版本檢查」那一段的 fail-open 說明。
plugin_dir=$(install_path "$domain")
if [ -n "$plugin_dir" ] && [ -f "$plugin_dir/plugin.json" ]; then
# 迴圈放在命令替換裡收結果。POSIX sh 的管線各跑在自己的子行程,
# 在迴圈裡累加變數帶不回來,只有印出來的內容帶得回來。
behind_list=$(requires_pairs "$plugin_dir/plugin.json" | while IFS=' ' read -r dep cond; do
[ -n "$dep" ] && [ -n "$cond" ] || continue
# 只認 >= 這一種寫法,與 manifest 現行宣告一致;其餘寫法就把整串當最低版本。
min=${cond#>=}
dep_domain=${dep#jsc-}
cur=$(local_version "$dep_domain")
# 讀不到相依的本機載入版本就跳過這一項:那是沒有證據,不是落後。
[ -n "$cur" ] || continue
[ "$(ver_cmp "$cur" "$min")" = "-1" ] || continue
printf ' - %s 需要 %s,目前 %s,更新指令:%s\n' "$dep" "$cond" "$cur" "$(update_cmd "$dep_domain")"
done)
if [ -n "$behind_list" ]; then
# 逐項清單與結語一起送進 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
# 讀不到本機實際載入版本就放行:沒有版本證據時擋下等於停掉每一次技能呼叫
local_ver=$(local_version "$domain")
[ -n "$local_ver" ] || exit 0
ho=$(remote_host_owner)
host=$(printf '%s' "$ho" | cut -d' ' -f1)
owner=$(printf '%s' "$ho" | cut -d' ' -f2)
[ -n "$host" ] || exit 0
# 遠端版本(走 hook 與 report 共用的快取與 TTL)
remote_ver=$(cached_remote_version "$domain" "$host" "$owner")
[ -n "$remote_ver" ] || exit 0
# 只擋「本機 < 遠端」這一種情況
[ "$(ver_cmp "$local_ver" "$remote_ver")" = "-1" ] || exit 0
deny "jsc-$domain 本機版本 $local_ver 落後遠端發佈版本 $remote_ver,本次技能呼叫已擋下"