#!/usr/bin/env sh # deploy.sh — 對單一 CLI 執行 jsc 技能組的安裝、更新或解除安裝。 # 用法: # deploy.sh [-n] {install|update|uninstall} {claude|codex|copilot|antigravity|kiro} {domain} [domain...] # -n 或 --dry-run(或 JSC_DEPLOY_DRYRUN=1):只印指令,不執行。 # {domain} 裸名或帶 jsc- 前綴皆可(例:ask 或 jsc-ask),腳本會自動去掉前綴再組 # jsc-{domain}@jsc;marketplace.json 的 plugins[].name 本身就帶前綴,不必事先剝掉。 # 輸出(TSV,一行一筆): # cmd{指令} 即將執行的指令 # exit{結束碼}{指令} 該指令的結束碼;dry-run 時結束碼印「-」 # skip{domain}{原因} 本地 clone 是開發中的樹,略過 git pull # note{cli}{原因} 非逐指令的說明(例:kiro 整批改走複製退路的理由) # restart{路徑} 這次寫下的重啟狀態檔 # requires{domain}{檢查結果} update 前的 jsc.requires 檢查 # result{cli}{mode}{domain 清單}{ok|fail} # 結束碼:全部指令成功 0;任一指令失敗 1;參數錯誤 2。skip、note 不算失敗,但呼叫端要據實回報。 # marketplace 指令一輪只跑一次:install 與 update 先跑,uninstall 最後跑。 # 各 CLI 的細節都收在這裡,SKILL.md 只描述何時呼叫與參數: # antigravity 不接受 gitea URL,先 clone 到本地再從路徑安裝,更新時 pull 同一份。 # kiro 的執行檔是 kiro-cli;先探測這個版本認不認得 plugin 子指令(部分版本已經完全 # 移除,例:2.18.1),認得才逐一嘗試、失敗才退回複製,不認得就整批直接走複製, # 不逐一撞一次「unrecognized subcommand」才退回。 # install 或 update 全數成功時,收尾轉呼叫 jsc-hooks 的 restart-gate.sh require,掛上這支 CLI # 的重啟閘門(狀態檔一支 CLI 一份,落在 $JSC_HOME/restart-required.d/{cli})。擋人邏輯不在 # 這裡:由 jsc-hooks 讀該 CLI 那一份提示使用者重啟,逃生門 JSC_RESTART_GATE=off 也由那邊判讀。 # 更新指引與移除指引由同目錄的 write-guides.sh 產生,一輪部署跑一次,不在這支腳本裡: # 這支腳本的職責是「對單一 CLI 部署」,指引寫的是整台機器的樣貌。 # 環境變數: # JSC_HOME hook 資料目錄,重啟狀態檔寫在這裡(預設 ~/.jsc) # GITEA_HOST Gitea 站台,可省略 scheme(預設 https://gitea.jsc.idv.tw) # JSC_GITEA_OWNER 存取庫的 owner(預設 plugins) # JSC_LOCAL_PLUGINS antigravity/kiro 用的本地 clone 目錄 # 預設 $JSC_HOME/plugins(即 ~/.jsc/plugins),刻意不用 ~/plugins: # 那是維護者放開發 checkout 的地方,pull 下去會蓋掉未提交的工作。 # 指到開發中的樹(有未提交變更或未推送的 commit)時只印 skip,不 pull。 # JSC_KIRO_SKILLS kiro 退路用的 skills 目錄(預設 ~/.kiro/skills) # JSC_DEPLOY_DRYRUN 設為 1 等同 -n set -u HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) # Gitea 站台一律讀 GITEA_HOST(技能準則指定的變數),未設定才用正本站台。 HOST="${GITEA_HOST:-https://gitea.jsc.idv.tw}" case "$HOST" in http://*|https://*) ;; *) HOST="https://$HOST" ;; esac HOST="${HOST%/}" OWNER="${JSC_GITEA_OWNER:-plugins}" MKT="$HOST/$OWNER/meta.git" REPO_BASE="$HOST/$OWNER" LOCAL_DIR="${JSC_LOCAL_PLUGINS:-${JSC_HOME:-$HOME/.jsc}/plugins}" KIRO_SKILLS="${JSC_KIRO_SKILLS:-$HOME/.kiro/skills}" DRYRUN="${JSC_DEPLOY_DRYRUN:-0}" FAILED=0 # CLI 代號 → 實際執行檔。唯一真實來源是 jsc-hooks 的 hooks/lib.sh cli_bin()。 # 這裡保留一份副本,因為這支腳本是整組技能的安裝入口:jsc-hooks 還沒裝上來時 # 也要能跑,不能 source 一個可能不存在的檔案。lib.sh 的對應表改了就同步改這裡。 cli_bin() { # $1=CLI 代號 case "$1" in antigravity) printf 'agy' ;; kiro) printf 'kiro-cli' ;; *) printf '%s' "$1" ;; esac } usage() { echo "用法:deploy.sh [-n] {install|update|uninstall} {claude|codex|copilot|antigravity|kiro} {domain} [domain...]" >&2 exit 2 } # 找 jsc-hooks 的 restart-gate.sh。優先用穩定連結與安裝後複製目錄,再找並排工作樹, # 最後才掃各 CLI 的 plugin 快取。找不到就回非零,由呼叫端據實回報。 restart_gate_sh() { if [ -n "${JSC_HOOKS_DIR:-}" ] && [ -f "$JSC_HOOKS_DIR/restart-gate.sh" ]; then printf '%s\n' "$JSC_HOOKS_DIR/restart-gate.sh"; return 0 fi _root=$(cd "$(dirname "$0")/.." 2>/dev/null && pwd) || return 1 for _c in \ "${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks/hooks/restart-gate.sh" \ "${JSC_LOCAL_PLUGINS:-${JSC_HOME:-$HOME/.jsc}/plugins}/hooks/hooks/restart-gate.sh" \ "${JSC_KIRO_SKILLS:-$HOME/.kiro/skills}/jsc-hooks/hooks/restart-gate.sh" \ "$_root/../hooks/hooks/restart-gate.sh" \ "$_root/../jsc-hooks/hooks/restart-gate.sh" do [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } done _c=$(ls -d \ "$HOME"/.claude/plugins/cache/*/jsc-hooks/*/hooks/restart-gate.sh \ "$HOME"/.codex/plugins/cache/*/jsc-hooks/*/hooks/restart-gate.sh \ "$HOME"/.kiro/skills/jsc-hooks/hooks/restart-gate.sh \ 2>/dev/null | sort | tail -n1) [ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } return 1 } check_requires() { # $1=domain;0=可更新,1=略過這個 domain [ "$MODE" = update ] || return 0 sync_local "$1" manifest="$LOCAL_DIR/$1/plugin.json" out=$(sh "$HERE/check-requires.sh" "$CLI" "$manifest" 2>&1) code=$? printf 'requires\t%s\t%s\n' "$1" "$out" if [ "$code" -eq 0 ]; then return 0 fi if [ "$code" -eq 1 ]; then printf 'skip\t%s\t%s\n' "$1" "相依版本不符,未更新:$out" return 1 fi FAILED=1 printf 'skip\t%s\t%s\n' "$1" "相依版本檢查失敗,未更新:$out" return 1 } # 掛上部署後的重啟閘門。狀態檔的路徑、格式與判讀全在 jsc-hooks 的 restart-gate.sh, # 這裡只轉呼叫它的 require 子命令,比照 jsc-sdlc 轉呼叫 sdlc-gate.sh wp-lock 的慣例。 # 兩邊各拼一份格式就會對不上:2026-08-27 這裡曾自己寫四欄 TSV,而 hooks 那端讀的是 # key=value,狀態檔存在卻解不出欄位。格式只能有一個真實來源。 # 找不到 restart-gate.sh 時不要自己補寫一份:閘門本來就由 jsc-hooks 判讀,它不在就沒有 # 判定點,寫下去只是留一個沒人讀的檔案,還會讓下一輪誤以為閘門掛上了。 mark_restart() { [ "$DRYRUN" = 1 ] && return 0 case "$MODE" in install|update) ;; *) return 0 ;; esac if ! rg=$(restart_gate_sh); then printf 'note\t%s\t%s\n' "$CLI" "找不到 jsc-hooks 的 restart-gate.sh,這次沒有掛上重啟閘門" return 0 fi if JSC_CLI="$CLI" sh "$rg" require "$MODE" $DOMAINS /dev/null)" ]; then printf '有未提交變更' return 0 fi up=$(git -C "$1" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null) || return 0 [ -n "$up" ] || return 0 ahead=$(git -C "$1" rev-list --count "$up..HEAD" 2>/dev/null) || return 0 [ "${ahead:-0}" -eq 0 ] || printf '有 %s 個未推送的 commit' "$ahead" } # 把某 domain 的存取庫抓到本地:有 .git 就 pull,沒有就 clone。 # 目標是開發中的樹時只印 skip,改用現地內容安裝,不 pull:這支腳本可以被指到任何 # 目錄,蓋掉維護者未提交或未推送的工作救不回來,安裝一份舊內容還能重跑。 sync_local() { # $1=domain dir="$LOCAL_DIR/$1" if [ -d "$dir/.git" ]; then hold=$(local_hold "$dir") if [ -n "$hold" ]; then printf 'skip\t%s\t%s %s,未執行 git pull\n' "$1" "$dir" "$hold" return 0 fi run git -C "$dir" pull else run git clone "$REPO_BASE/$1.git" "$dir" fi } # claude、copilot、kiro-cli 共用的 plugin 指令組。 marketplace_cli() { # $1=執行檔 case "$MODE" in install) run "$1" plugin marketplace add "$MKT" for d in $DOMAINS; do run "$1" plugin install "jsc-$d@jsc"; done ;; update) run "$1" plugin marketplace update jsc for d in $DOMAINS; do check_requires "$d" && run "$1" plugin update "jsc-$d@jsc"; done ;; uninstall) for d in $DOMAINS; do run "$1" plugin uninstall "jsc-$d@jsc"; done run "$1" plugin marketplace remove jsc ;; esac } # codex 沒有 plugin update 子指令,只有 add、list、marketplace、remove。 # marketplace upgrade 只重抓 marketplace 快照,而 jsc 的 marketplace.json 只列各網域的 # git URL、不含版本,所以那份檔案內容不會變,codex 一律回「already up to date」並結束碼 0。 # 已安裝外掛的版本是 plugin add 當下決定的,快取不會被連帶重抓——換句話說,只跑 # marketplace upgrade 的話,指令全部成功而版本一個都沒動,是最難察覺的那種失敗。 # 正確做法是照樣逐網域 plugin add:codex 的 add 會就地升級到快照裡的最新版。 deploy_codex() { bin=$(cli_bin codex) case "$MODE" in install) run "$bin" plugin marketplace add "$MKT" for d in $DOMAINS; do run "$bin" plugin add "jsc-$d@jsc"; done ;; update) run "$bin" plugin marketplace upgrade jsc for d in $DOMAINS; do check_requires "$d" && run "$bin" plugin add "jsc-$d@jsc"; done ;; uninstall) for d in $DOMAINS; do run "$bin" plugin remove "jsc-$d@jsc"; done run "$bin" plugin marketplace remove jsc ;; esac } deploy_antigravity() { bin=$(cli_bin antigravity) for d in $DOMAINS; do case "$MODE" in install) sync_local "$d" run "$bin" plugin install "$LOCAL_DIR/$d" ;; update) check_requires "$d" || continue sync_local "$d" run "$bin" plugin uninstall "jsc-$d" run "$bin" plugin install "$LOCAL_DIR/$d" ;; uninstall) run "$bin" plugin uninstall "jsc-$d" ;; esac done } # plugin 指令不支援時的退路:從本地 clone 複製整個 plugin 的可用內容。 # # 技能文件會直接引用同伴目錄,例如 jsc-sdlc 的 implement 要跑 jsc-sdlc/tools/wp-gate.sh # 與 jsc-gitea/tools/pr-watch.sh,jsc-sdlc 的 analyze 要讀 references/consensus.md。 # 這些引用在 SKILL.md 裡是完成條件,不是選配。只複製 skills 目錄,kiro 會拿到一份 # 要求跑腳本、腳本卻不在機器上的技能,比不更新更糟——所以 tools、references、 # templates、hooks 與 plugin.json 一併複製。 # # 目標路徑刻意維持 $KIRO_SKILLS/jsc-{domain}/,跨 plugin 的引用(jsc-gitea/tools/…) # 以 $KIRO_SKILLS 為根就解析得到。 # # 每個子目錄都用「先 mkdir,再複製 src/. 到 dst/」的寫法:目標目錄已存在時, # cp -R src dst/ 會把來源塞進 dst/{名稱}/{名稱},第二次更新就多一層。 kiro_copy() { # $1=domain sync_local "$1" dest="$KIRO_SKILLS/jsc-$1" run mkdir -p "$dest" run cp -R "$LOCAL_DIR/$1/skills/." "$dest/" for sub in tools references templates hooks; do if [ -d "$LOCAL_DIR/$1/$sub" ]; then run mkdir -p "$dest/$sub" run cp -R "$LOCAL_DIR/$1/$sub/." "$dest/$sub/" fi done # plugin.json 讓 version-guard.sh 之類的呼叫端查得到本機版本。缺了不算錯誤, # 只是查不到版本而已,所以不進 run、也不影響整體結束碼。 [ -f "$LOCAL_DIR/$1/plugin.json" ] && run cp "$LOCAL_DIR/$1/plugin.json" "$dest/plugin.json" return 0 } # kiro-cli 是否認得 plugin 子指令:一次性偵測,探測本身不印 cmd/exit(不是部署動作, # 印出來只會讓人誤以為那也是一次失敗的部署嘗試)。部分版本(例:2.18.1)完全沒有這個 # 子指令,逐一嘗試再退回複製,會先洗出一長串看似失敗、實則設計內的錯誤訊息。 kiro_has_plugin_cmd() { "$bin" --help-all 2>/dev/null | grep -qE '^ plugin( |$)' } deploy_kiro() { bin=$(cli_bin kiro) if kiro_has_plugin_cmd; then case "$MODE" in install) run_soft "$bin" plugin marketplace add "$MKT" ;; update) run_soft "$bin" plugin marketplace update jsc ;; esac for d in $DOMAINS; do case "$MODE" in install) run_soft "$bin" plugin install "jsc-$d@jsc" || kiro_copy "$d" ;; update) check_requires "$d" || continue run_soft "$bin" plugin update "jsc-$d@jsc" || kiro_copy "$d" ;; uninstall) run_soft "$bin" plugin uninstall "jsc-$d@jsc" || run rm -rf "$KIRO_SKILLS/jsc-$d" ;; esac done return 0 fi printf 'note\tkiro\t%s\n' "此版本 kiro-cli 沒有 plugin 子指令,改走本地複製(git pull 或 clone 後,複製 skills、tools、references、templates、hooks 與 plugin.json)" for d in $DOMAINS; do case "$MODE" in install) kiro_copy "$d" ;; update) check_requires "$d" && kiro_copy "$d" ;; uninstall) run rm -rf "$KIRO_SKILLS/jsc-$d" ;; esac done [ "$MODE" = uninstall ] && run_soft "$bin" plugin marketplace remove jsc return 0 } case "${1:-}" in -n|--dry-run) DRYRUN=1; shift ;; esac [ $# -ge 3 ] || usage MODE=$1 CLI=$2 shift 2 # 每個 domain 一律去掉開頭的 jsc-:往下每處都自己組 jsc-$d@jsc,收到已帶前綴的名字 # (marketplace.json 的 plugins[].name 就是這樣,例如 jsc-ask)會兜成 jsc-jsc-ask@jsc, # 讓 claude/copilot/antigravity/kiro 全部裝不上、更新不了。這裡正規化一次, # 呼叫端不管傳哪種格式都能正常動作,不必每個呼叫端各自記得先去前綴。 _domains="" for _d in "$@"; do case "$_d" in jsc-*) _d="${_d#jsc-}" ;; esac _domains="$_domains $_d" done DOMAINS="${_domains# }" case "$MODE" in install|update|uninstall) ;; *) usage ;; esac case "$CLI" in claude) marketplace_cli "$(cli_bin claude)" ;; copilot) marketplace_cli "$(cli_bin copilot)" ;; codex) deploy_codex ;; antigravity) deploy_antigravity ;; kiro) deploy_kiro ;; *) usage ;; esac if [ "$FAILED" -eq 0 ]; then mark_restart printf 'result\t%s\t%s\t%s\tok\n' "$CLI" "$MODE" "$DOMAINS" exit 0 fi printf 'result\t%s\t%s\t%s\tfail\n' "$CLI" "$MODE" "$DOMAINS" exit 1