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 接線修正。
405 lines
22 KiB
Bash
Executable File
405 lines
22 KiB
Bash
Executable File
#!/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,本次技能呼叫已擋下"
|