feat(cli): 待修項目合併收成共用腳本,檢查改為併行

「待修項目」表原本由 doctor 與 setup 各寫一次。同一套合併與排序規則寫在
兩個地方,遲早各自漂移:一邊改了排序,另一邊漏掉一整類項目,而且沒有
任何地方看得出來。現在規則只留一份,兩支技能都呼叫它。輸入與輸出都是
TSV,一項都沒有時照樣印一列,呼叫端永遠有東西可以呈現。

doctor 的四項檢查彼此不共用資料,排成一列跑只是把等待時間乘上四倍,
現在同時啟動。doctor 呼叫接線腳本一律帶唯讀旗標,把「打錯一個子命令就
改到或刪掉檔案」的風險移進程式層,不再只靠指令打對。

deploy 的 CLI 偵測、版本結論與 marketplace 清單同樣互不相干,改成併行
取得。版本結論改讀版本守門腳本的單行結論,不再自己從表格推導。setup 把
已經確認過的模式與版本報告直接交給 deploy,操作者不必再答一次同樣的問題。

deploy、doctor、models 都補上結束碼分流:腳本回什麼碼就走哪條路,不再從
輸出內容猜。體檢目錄頁改成先讀回再更新自己那一列,整頁覆蓋會把別台機器
的紀錄一次抹掉。設定規格表補上技能盤點頁要用的環境變數,盤點頁才有地方
可寫。
This commit is contained in:
2026-08-31 11:09:59 +08:00
parent e8bf4ddaaf
commit e30bbf5780
7 changed files with 332 additions and 55 deletions
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env sh
# build-todo.sh — 把三支檢查腳本的輸出合併成一張「待修項目」表。
#
# /jsc-cli:doctor 與 /jsc-cli:setup 都要這張表,合併規則只留一個真實來源,
# 兩支技能各寫一次就會各自漂移,一邊排序、另一邊漏掉某一類。
#
# 用法:
# build-todo.sh [--config {檔案}]... [--wiring {cli}={檔案}]... [--version {檔案}]...
# 每個選項都可以重複。檔案給「-」代表讀標準輸入(整份只能有一個 -)。
# 三種輸入都省略時視為用法錯誤:空跑會印出「沒有待修項目」,那是假通過。
#
# 輸入格式(由各自的腳本產生,本腳本不自己執行它們,維持唯讀):
# --config jsc-cli/tools/scan-config.sh scan 的輸出
# {項目}<TAB>{範圍}<TAB>{必要}<TAB>{現況}<TAB>{說明}<TAB>{修法}<TAB>{判定}
# 只取判定為 missing 與 invalid 的列,summary 列略過。
# --wiring jsc-hooks/tools/wire-cli.sh status {cli} 的輸出,前面掛上該 CLI 代號。
# 首行 status=... reason=...;其後 item<TAB>{項目}<TAB>{路徑}<TAB>{present|missing}
# 只有 status=unwired 才進待修表,每個 missing 項目一列。
# degraded 是 codex、copilot、antigravity、kiro 的健康狀態,不是缺失。
# --version jsc-hooks/hooks/version-guard.sh report 的輸出
# {domain}<TAB>{本機}<TAB>{遠端}<TAB>{落後|最新|超前|查詢失敗}
# 只取「落後」的列。noregistry 與 behind 列略過。
#
# 輸出(TSV,一行一筆):
# todo<TAB>{序號}<TAB>{類別}<TAB>{項目}<TAB>{範圍}<TAB>{現況}<TAB>{修法}
# summary<TAB>{missing 數}<TAB>{invalid 數}<TAB>{unwired 數}<TAB>{落後數}
# 類別排序固定為 missing、invalid、unwired、落後;同類別內照輸入順序。
# 一項都沒有時仍印一列 todo,類別欄為「無」,呼叫端照樣有東西可以呈現。
#
# 結束碼:0=合併完成(有沒有待修項目都算完成,判斷交給呼叫端)
# 2=用法錯誤(沒給任何輸入、選項寫錯、--wiring 少了 {cli}= 前綴)
# 3=指定的輸入檔讀不到
set -u
TAB=$(printf '\t')
usage() {
[ -n "${WORK:-}" ] && rm -rf "$WORK"
echo "用法:build-todo.sh [--config {檔案}]... [--wiring {cli}={檔案}]... [--version {檔案}]..." >&2
exit 2
}
WORK=$(mktemp -d 2>/dev/null) || { echo "無法建立暫存目錄" >&2; exit 3; }
F_MISSING="$WORK/missing"; F_INVALID="$WORK/invalid"
F_UNWIRED="$WORK/unwired"; F_BEHIND="$WORK/behind"
: > "$F_MISSING"; : > "$F_INVALID"; : > "$F_UNWIRED"; : > "$F_BEHIND"
cleanup() { rm -rf "$WORK"; }
die() { cleanup; echo "$1" >&2; exit "$2"; }
# 輸入檔的存在性先在主 shell 檢查。放進管線裡檢查的話,die 只會結束子 shell,
# 主流程照樣往下跑,最後印出一張少了整類項目卻看起來正常的表。
check_input() { # $1=路徑
[ "$1" = "-" ] && return 0
[ -f "$1" ] || die "讀不到輸入檔:$1" 3
[ -r "$1" ] || die "輸入檔沒有讀取權限:$1" 3
}
# 取得一份輸入的內容。「-」讀標準輸入,其餘一律當檔案路徑。
slurp() { # $1=路徑
if [ "$1" = "-" ]; then cat; else cat "$1"; fi
}
# scan-config.sh scan 的輸出 → missing 與 invalid 兩類。
take_config() { # $1=路徑
slurp "$1" | while IFS="$TAB" read -r key scope req actual desc fix verdict; do
[ -n "${key:-}" ] || continue
[ "$key" = summary ] && continue
case "${verdict:-}" in
missing) printf '%s\t%s\t%s\t%s\n' "$key" "${scope:--}" "${actual:--}" "${fix:--}" >> "$F_MISSING" ;;
invalid) printf '%s\t%s\t%s\t%s\n' "$key" "${scope:--}" "${actual:--}" "${fix:--}" >> "$F_INVALID" ;;
esac
done
}
# wire-cli.sh status 的輸出 → unwired 一類。$1=cli $2=路徑
take_wiring() {
_txt="$WORK/wiring.$$"
slurp "$2" > "$_txt"
# 只有 status=unwired 才是待修。wired 沒事,degraded 是四支非 Claude CLI 的健康狀態,
# skipped 代表那支 CLI 根本沒裝,三者都不該出現在待修表上。
grep -q '^status=unwired' "$_txt" || { rm -f "$_txt"; return 0; }
while IFS="$TAB" read -r kind item path state; do
[ "${kind:-}" = item ] || continue
[ "${state:-}" = missing ] || continue
printf '%s\t%s\t%s\t%s\n' "${item:--}" "$1" "缺少接線:${path:--}" "jsc-hooks:hooks-install" >> "$F_UNWIRED"
done < "$_txt"
rm -f "$_txt"
}
# version-guard.sh report 的輸出 → 落後一類。
take_version() { # $1=路徑
slurp "$1" | while IFS="$TAB" read -r domain local_v remote_v state; do
[ -n "${domain:-}" ] || continue
case "$domain" in behind|noregistry) continue ;; esac
[ "${state:-}" = "落後" ] || continue
printf '%s\t%s\t%s\t%s\n' "jsc-$domain" "版本" "本機 ${local_v:--}、遠端 ${remote_v:--}" "jsc-cli:deploy update" >> "$F_BEHIND"
done
}
got=0
while [ "$#" -gt 0 ]; do
case "$1" in
--config) [ "$#" -ge 2 ] || usage; check_input "$2"; take_config "$2"; got=1; shift 2 ;;
--version) [ "$#" -ge 2 ] || usage; check_input "$2"; take_version "$2"; got=1; shift 2 ;;
--wiring)
[ "$#" -ge 2 ] || usage
case "$2" in *=*) ;; *) usage ;; esac
_cli=${2%%=*}; _file=${2#*=}
[ -n "$_cli" ] && [ -n "$_file" ] || usage
check_input "$_file"; take_wiring "$_cli" "$_file"; got=1; shift 2 ;;
*) usage ;;
esac
done
[ "$got" = 1 ] || usage
n_missing=$(wc -l < "$F_MISSING" | tr -d ' ')
n_invalid=$(wc -l < "$F_INVALID" | tr -d ' ')
n_unwired=$(wc -l < "$F_UNWIRED" | tr -d ' ')
n_behind=$(wc -l < "$F_BEHIND" | tr -d ' ')
seq_no=0
emit_class() { # $1=類別 $2=檔案
while IFS="$TAB" read -r item scope actual fix; do
[ -n "${item:-}" ] || continue
seq_no=$((seq_no + 1))
printf 'todo\t%s\t%s\t%s\t%s\t%s\t%s\n' "$seq_no" "$1" "$item" "$scope" "$actual" "$fix"
done < "$2"
}
emit_class missing "$F_MISSING"
emit_class invalid "$F_INVALID"
emit_class unwired "$F_UNWIRED"
emit_class 落後 "$F_BEHIND"
[ "$seq_no" -gt 0 ] || printf 'todo\t1\t無\t沒有待修項目\t-\t-\t-\n'
printf 'summary\t%s\t%s\t%s\t%s\n' "$n_missing" "$n_invalid" "$n_unwired" "$n_behind"
cleanup
exit 0