#!/usr/bin/env sh # wire-cli.sh — 單一 CLI 的 hook 生命週期:先清、再接、再冒煙(供 hooks-install 技能呼叫)。 # 用法: # wire-cli.sh {claude|codex|copilot|antigravity|kiro} 接線 # wire-cli.sh purge {claude|codex|copilot|antigravity|kiro} 備份後移除該 CLI 的所有 hook # wire-cli.sh smoke {claude|codex|copilot|antigravity|kiro} 跑一輪五支 hook,驗執行期 # # purge 移除的是「所有 hook」,含非 jsc 的第三方項目。安裝一律先 purge 再接線:混著別人的 # hook 接線,出錯時分不清是誰的 hook 壞掉,也修不了。移除前每個要動的檔案先原樣複製到 # $JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/,備份失敗就不移除。 # # smoke 在接線之後跑,補上接線驗證看不到的那一半:接線只證明設定寫對位置,證不了 hook # 跑起來不出錯(缺 node、路徑錯、權限不足都只在真的執行時才現形)。 # # 接線行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc-hooks 標記段落,不會重複疊加): # claude — 什麼都不用寫,hooks.json 已自動接線五支 hook # codex — 在 shell rc 檔加上 codex 別名,轉呼叫 tools/jsc-wrap.sh codex(開始計時); # 在 config.toml 設 notify(每輪補 session-timer.sh start 再 mark,JSC_CLI=codex); # 在 AGENTS.md 附加 STE100 規則段落(prompt 降級) # copilot — 在 shell rc 檔加上 copilot 別名,轉呼叫 tools/jsc-wrap.sh copilot; # 在 copilot-instructions.md 附加 STE100 規則段落 # antigravity — 在 shell rc 檔加上 agy 別名,轉呼叫 tools/jsc-wrap.sh antigravity; # 在全域規則檔附加 STE100 規則段落 # kiro — 在工作區 .kiro/hooks/ 下建立 jsc-hooks.json(每輪 mark 加 STE100) # 與 jsc-hooks-session-start.json(sessionStart 開始計時),皆帶 JSC_CLI=kiro # # 覆蓋範圍要據實回報,不得暗示每個 CLI 都有保護: # claude 五支 hook 全接,回報 wired # codex、copilot、antigravity、kiro 只有別名或規則檔,接不上 PreToolUse 與 # UserPromptSubmit,版本前置檢查與 SDLC 模型鎖 # 都沒接上,一律回報 degraded 並在 reason 講明 # 所有 jsc 標記段落都採整段重寫,重跑等同先移除舊內容再重裝 # # 寫入後自我驗證,通過才回報成功:每個寫過的檔案重新讀一次,確認標記段落存在且落在 # 正確位置(codex 的 notify 必須是根層鍵,不能被歸進前一張表;kiro 的 JSON 必須成對 # 且 on、run 在最上層)。腳本說寫好了卻寫錯位置,是最難查的失敗,所以驗證放在腳本裡。 # # 輸出: 第一行固定為 `status=... reason=...`(可供程式判讀),其後為人類可讀的繁中說明。 # 結束碼(接線): 0=wired(已完整接線) 1=degraded(降級為 prompt/技能步驟檢查) # 2=用法錯誤 3=skipped(該 CLI 未偵測到執行檔,略過) # 4=failed(寫入或驗證沒過,接線沒生效;由 hooks-install 呼叫 report-error.sh 回報) # 結束碼(purge): 0=purged 2=用法錯誤 3=skipped 4=failed # 結束碼(smoke): 0=ok 2=用法錯誤 4=failed # # JSC_CLAUDE_SETTINGS_DIR 可覆寫 claude 使用者層設定檔目錄(預設 ~/.claude)。 # 有這個逃生門才測得動 purge 的 JSON 刪鍵:預設路徑是使用者自己的設定檔,拿真檔案試刪 # 等於拿使用者的環境當測試場。指向一份複製品就能完整跑過 purge claude 而不動到本人設定。 set -u HERE=$(cd "$(dirname "$0")" && pwd) ROOT=$(cd "$HERE/.." && pwd) HOOKS="$ROOT/hooks" # cli_bin(CLI 代號 → 實際執行檔)的唯一來源在 lib.sh,包裝啟動器也用同一份 . "$HOOKS/lib.sh" usage() { echo "用法:wire-cli.sh [purge|smoke] {claude|codex|copilot|antigravity|kiro}" >&2 exit 2 } # 第一個參數是子命令時走新流程,否則沿用原本的「wire-cli.sh {cli}」接線。 action=wire case "${1:-}" in purge|smoke) action="$1"; shift ;; esac cli="${1:-}" case "$cli" in claude|codex|copilot|antigravity|kiro) ;; *) usage ;; esac # STE100 規則段落的唯一來源:ste100-guard.sh 的實際輸出 ste100_text() { sh "$HOOKS/ste100-guard.sh" 2>/dev/null | sed '/^exit /d'; } # 以標記整段取代(冪等);標記不存在就在檔尾新增;檔案不存在就建立。 # 適用 markdown 規則檔與 shell rc 檔:這兩種檔案沒有「區段」概念,附在檔尾就對了。 # $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容 replace_block() { file="$1"; bopen="$2"; bshut="$3"; content="$4" dir=$(dirname "$file") mkdir -p "$dir" 2>/dev/null || return 1 touch "$file" 2>/dev/null || return 1 # touch 對目錄也會成功,所以要另外確認它真的是一般檔案;不然接著的寫入才失敗, # 而 shell 開檔失敗的訊息蓋不掉,會漏一行 cannot create 給使用者看。 [ -f "$file" ] || return 1 block=$(printf '%s\n%s\n%s' "$bopen" "$content" "$bshut") if grep -qF "$bopen" "$file" 2>/dev/null; then awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" ' $0==bopen { print block; skip=1; next } $0==bshut { skip=0; next } skip { next } { print } ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } else # 包一層子 shell 才蓋得住 shell 自己的開檔失敗訊息(>> 失敗時那行不走命令的 stderr) ( printf '\n%s\n' "$block" >> "$file" ) 2>/dev/null || return 1 fi } # TOML 版的整段取代:標記段落一律放在第一個表頭(`[table]`、`[[array]]`)之前。 # TOML 的根層鍵只在第一個表頭之前有效,附在檔尾會被歸進最後那張表——檔案照樣解析 # 得過,codex 卻永遠讀不到 notify,hook 靜靜失效。所以位置本身就是正確性的一部分。 # 舊版寫錯位置的段落也會被這支函式移到正確位置(先整段刪除,再插到表頭之前)。 # $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容 replace_block_toml() { file="$1"; bopen="$2"; bshut="$3"; content="$4" dir=$(dirname "$file") mkdir -p "$dir" 2>/dev/null || return 1 touch "$file" 2>/dev/null || return 1 # touch 對目錄也會成功,所以要另外確認它真的是一般檔案;不然接著的寫入才失敗, # 而 shell 開檔失敗的訊息蓋不掉,會漏一行 cannot create 給使用者看。 [ -f "$file" ] || return 1 block=$(printf '%s\n%s\n%s' "$bopen" "$content" "$bshut") awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" ' $0==bopen { skip=1; next } $0==bshut { skip=0; next } skip { next } # 表頭樣式:整行只有 [name] 或 [[name]]。多行陣列裡的 [1, 2], 不會命中。 !done && /^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { print block; print ""; done=1 } { print } END { if (!done) print block } ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } } # 驗證:檔案裡有這段標記嗎($1=檔案 $2=開頭標記行) has_block() { grep -qF "$2" "$1" 2>/dev/null; } # 驗證:TOML 的某個鍵是不是落在根層(第一個表頭之前)。$1=檔案 $2=鍵名 toml_root_key() { awk -v k="$2" ' /^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { intable=1; next } !intable && $0 ~ "^[ \t]*" k "[ \t]*=" { found=1 } END { exit(found ? 0 : 1) } ' "$1" 2>/dev/null } # 驗證:JSON 括號成對,且某個鍵出現在最上層($1=檔案 $2=鍵名 $3=要求)。 # 解析失敗(括號不成對、字串沒收尾)也回傳非 0,所以這支同時當語法檢查用。 # $3=key(預設)要求該鍵存在;$3=pairs 只檢查語法,不管鍵在不在——purge 之後要驗的是 # 「鍵不見了而且檔案還是合法 JSON」,這兩件事得分開問,不然刪壞檔也會被當成刪成功。 json_top_key() { awk -v k="$2" -v want="${3:-key}" ' { s = s $0 "\n" } END { n = length(s); depth = 0; i = 1; found = 0; bad = 0 while (i <= n) { c = substr(s, i, 1) if (c == "\"") { buf = ""; i++; closed = 0 while (i <= n) { c = substr(s, i, 1) if (c == "\\") { i += 2; continue } if (c == "\"") { i++; closed = 1; break } buf = buf c; i++ } if (!closed) { bad = 1; break } j = i while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++ if (substr(s, j, 1) == ":" && depth == 1 && buf == k) found = 1 continue } if (c == "{" || c == "[") depth++ else if (c == "}" || c == "]") { depth--; if (depth < 0) { bad = 1; break } } i++ } if (bad || depth != 0) exit(1) exit((want == "pairs" || found) ? 0 : 1) }' "$1" 2>/dev/null } # 驗證:JSON 語法成對(括號收齊、字串收尾)。鍵不管。 json_pairs_ok() { json_top_key "$1" __no_such_key__ pairs; } # 找出已存在的 shell rc 檔(purge 用)。rc_files 找不到會建立 ~/.bashrc,移除流程不建檔: # 為了清 hook 而生出一個新檔案,是把環境弄得更亂,不是更乾淨。 rc_files_existing() { for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do [ -f "$f" ] && printf '%s\n' "$f" done return 0 } # 找出可寫入別名的 shell rc 檔;都不存在就以 ~/.bashrc 為預設(自動建立)。 # 印出找到或建立的 rc 檔路徑,一行一個。 rc_files() { found="" for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do [ -f "$f" ] && { printf '%s\n' "$f"; found=1; } done [ -n "$found" ] || printf '%s\n' "$HOME/.bashrc" } # 把別名寫進每個 rc 檔並逐檔驗證。$1=標記名(不含 # 與 /)$2=別名內容 # 迴圈不可以放在管線右邊:那會變成子 shell,寫入失敗的旗標傳不回來, # 明明沒寫成功也照樣回報 wired。改成從暫存檔讀,迴圈就留在本 shell。 write_alias_rc() { _mark="$1"; _line="$2"; _ok=1 _list=$(mktemp) || return 1 rc_files > "$_list" || { rm -f "$_list"; return 1; } while IFS= read -r rc; do [ -n "$rc" ] || continue replace_block "$rc" "# $_mark" "# /$_mark" "$_line" || { _ok=0; continue; } grep -qF "$_line" "$rc" 2>/dev/null || _ok=0 done < "$_list" rm -f "$_list" [ "$_ok" = 1 ] } # --- 移除(purge)用的函式 --- # 以標記整段移除(冪等)。與 replace_block 對稱:同一組標記,一支寫入、一支移除。 # 標記不存在就當成已移除、回傳成功——purge 重跑不該因為「上次已經清掉了」而失敗。 # $1=檔案 $2=開頭標記行 $3=結尾標記行 remove_block() { file="$1"; bopen="$2"; bshut="$3" [ -f "$file" ] || return 0 grep -qF "$bopen" "$file" 2>/dev/null || return 0 awk -v bopen="$bopen" -v bshut="$bshut" ' $0==bopen { skip=1; next } $0==bshut { skip=0; next } skip { next } { print } ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } } # 移除 rc 檔裡所有 `# jsc-hooks*` 標記段落,不管後面接哪個 CLI 名。 # 舊版接線可能留下已改名的段落,逐一指名會漏掉,所以用前綴一次掃乾淨。 # $1=檔案 remove_rc_blocks() { file="$1" [ -f "$file" ] || return 0 grep -q '^# jsc-hooks' "$file" 2>/dev/null || return 0 awk ' /^# jsc-hooks/ { skip=1; next } /^# \/jsc-hooks/ { skip=0; next } skip { next } { print } ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } } # 移除 TOML 的根層鍵(第一個表頭之前的那個鍵),含非 jsc 設的值。 # 值可能是多行陣列或多行行內表,所以要追括號深度,收齊才停;只刪一行會留下孤兒括號。 # $1=檔案 $2=鍵名 remove_toml_root_key() { file="$1"; key="$2" [ -f "$file" ] || return 0 awk -v k="$key" ' BEGIN { intable=0; drop=0; depth=0 } /^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { intable=1 } { if (drop) { depth += gsub(/[[{]/, "&") - gsub(/[]}]/, "&") if (depth <= 0) drop=0 next } if (!intable && $0 ~ "^[ \t]*" k "[ \t]*=") { depth = gsub(/[[{]/, "&") - gsub(/[]}]/, "&") if (depth > 0) drop=1 next } print } ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } } # 刪掉 JSON 最上層的 hooks 鍵(含後面多餘的逗號),逐字元追蹤引號與括號深度。 # jq 在目標機器上不保證存在,所以要有這條純 awk 的路;只用 sed 刪不了嵌套的 {...}。 # 追蹤引號是必要的:字串裡的 { 與 } 不算深度,漏算就會把整段設定切壞。 # $1=檔案,結果印到標準輸出;解析不出來(括號不成對、字串沒收尾)就 exit 1,不輸出半份檔案。 awk_del_hooks() { awk ' function skip_string(s, i, n, c) { i++ while (i <= n) { c = substr(s, i, 1) if (c == "\\") { i += 2; continue } if (c == "\"") return i + 1 i++ } return 0 } function skip_value(s, i, n, c, d) { while (i <= n && substr(s, i, 1) ~ /[ \t\r\n]/) i++ c = substr(s, i, 1) if (c == "\"") return skip_string(s, i, n) if (c == "{" || c == "[") { d = 0 while (i <= n) { c = substr(s, i, 1) if (c == "\"") { i = skip_string(s, i, n); if (i == 0) return 0; continue } if (c == "{" || c == "[") { d++; i++; continue } if (c == "}" || c == "]") { d--; i++; if (d == 0) return i; continue } i++ } return 0 } while (i <= n && substr(s, i, 1) !~ /[,}\]\t\r\n ]/) i++ return i } { s = s $0 "\n" } END { n = length(s); i = 1; depth = 0; out = "" while (i <= n) { c = substr(s, i, 1) if (c == "\"") { start = i; buf = ""; j = i + 1 while (j <= n) { c = substr(s, j, 1) if (c == "\\") { j += 2; continue } if (c == "\"") break buf = buf c; j++ } if (j > n) exit 1 i = j + 1 j = i while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++ if (depth == 1 && buf == "hooks" && substr(s, j, 1) == ":") { i = skip_value(s, j + 1, n) if (i == 0) exit 1 j = i while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++ if (substr(s, j, 1) == ",") { # 後面還有成員:連逗號一起吃掉,並把 hooks 那行留下的縮排收乾淨 i = j + 1 sub(/[ \t]+$/, "", out) while (i <= n && substr(s, i, 1) ~ /[ \t\r]/) i++ if (substr(s, i, 1) == "\n") i++ } else { # hooks 是最後一個成員:改刪前一個逗號,不刪會留下「, }」這種壞掉的 JSON sub(/,[ \t\r\n]*$/, "", out) } continue } out = out substr(s, start, i - start) continue } if (c == "{" || c == "[") depth++ else if (c == "}" || c == "]") { depth--; if (depth < 0) exit 1 } out = out c; i++ } if (depth != 0) exit 1 printf "%s", out }' "$1" } # 刪掉設定檔最上層的 hooks 鍵:有 jq 就用 jq,沒有就走 awk_del_hooks。$1=檔案 json_del_hooks() { _f="$1" [ -f "$_f" ] || return 0 if command -v jq >/dev/null 2>&1; then jq 'del(.hooks)' "$_f" > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } else awk_del_hooks "$_f" > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } fi [ -s "$_f.jsc-tmp" ] || { rm -f "$_f.jsc-tmp"; return 1; } mv "$_f.jsc-tmp" "$_f" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } } # --- 備份:先備份才准移除 --- BACKUP_DIR="" BACKUP_STAMP=$(date +%Y%m%d_%H%M%S) BACKUP_LIST="" # 每行「{備份檔}{原檔}」,還原時反向複製回去 # 備份目錄延後建立:沒有檔案要動時不留空目錄。 # 只設全域變數、不印路徑:呼叫端若寫成 $(backup_dir) 就變成子 shell,設好的 BACKUP_DIR # 與 BACKUP_LIST 傳不回本 shell,接著的備份與還原全部失準。 ensure_backup_dir() { [ -z "$BACKUP_DIR" ] || return 0 _d="$JSC_HOME/backup/hooks/$cli/$BACKUP_STAMP" mkdir -p "$_d" 2>/dev/null || return 1 BACKUP_LIST=$(mktemp) || return 1 BACKUP_DIR="$_d" return 0 } # 原樣複製一份到備份目錄,保留原檔名;同名就加 -1、-2 後綴(不同目錄可能有同名檔)。 # 複製失敗回傳 1,呼叫端必須就此停手:沒有備份就移除,等於把使用者的設定弄不見。 backup_file() { # $1=檔案 _src="$1" [ -f "$_src" ] || return 0 ensure_backup_dir || return 1 _base=$(basename "$_src") _dst="$BACKUP_DIR/$_base"; _n=0 while [ -e "$_dst" ]; do _n=$((_n + 1)); _dst="$BACKUP_DIR/$_base-$_n" done cp "$_src" "$_dst" 2>/dev/null || return 1 [ -f "$_dst" ] || return 1 printf '%s\t%s\n' "$_dst" "$_src" >> "$BACKUP_LIST" || return 1 return 0 } # 還原這次所有備份(驗證沒過時用)。已刪除的檔案會被複製回來。 restore_backups() { [ -n "$BACKUP_LIST" ] && [ -f "$BACKUP_LIST" ] || return 0 while IFS="$(printf '\t')" read -r _b _o; do [ -n "$_b" ] && [ -n "$_o" ] || continue mkdir -p "$(dirname "$_o")" 2>/dev/null || true cp "$_b" "$_o" 2>/dev/null || true done < "$BACKUP_LIST" return 0 } skip() { # $1=reason printf 'status=skipped reason=%s\n' "$1" echo "[jsc] 略過:$1" exit 3 } fail() { # $1=reason printf 'status=failed reason=%s\n' "$1" echo "[jsc] 接線沒生效:$1" >&2 echo "[jsc] 請先以 tools/report-error.sh 回報這次失敗,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" >&2 exit 4 } # purge 專用的失敗出口:先把備份還原回去,再回報。移除做一半的環境比沒動過更難修。 pfail() { # $1=reason restore_backups printf 'status=failed reason=%s\n' "$1" echo "[jsc] 移除沒完成,已從備份還原:$1" >&2 [ -n "$BACKUP_DIR" ] && echo "[jsc] 備份目錄:$BACKUP_DIR" >&2 echo "[jsc] 請先以 tools/report-error.sh 回報這次失敗,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" >&2 exit 4 } purged() { # $1=reason printf 'status=purged reason=%s\n' "$1" if [ -n "$BACKUP_DIR" ]; then echo "[jsc] $cli:已移除全部 hook,移除前的原檔備份在 $BACKUP_DIR。" else echo "[jsc] $cli:沒有找到任何 hook 設定,已是乾淨狀態,未建立備份目錄。" fi } if [ "$action" = purge ]; then case "$cli" in claude) bin=$(cli_bin claude) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 claude 執行檔" # claude 的 hook 全部宣告在使用者層設定檔的 hooks 鍵裡,清掉那個鍵就等於清掉所有 hook。 cdir="${JSC_CLAUDE_SETTINGS_DIR:-$HOME/.claude}" done_files="" for f in "$cdir/settings.json" "$cdir/settings.local.json"; do [ -f "$f" ] || continue # 先問語法:讀不懂的設定檔不能刪鍵,也不能當成「沒有 hooks 鍵」帶過—— # 那會回報 purged 卻留著整套 hook,比直接說失敗更難查。 json_pairs_ok "$f" || pfail "$f 不是成對的 JSON,讀不懂就不動它,請先修好這個檔案" json_top_key "$f" hooks || continue backup_file "$f" || pfail "無法備份 $f,沒有備份就不移除" json_del_hooks "$f" || pfail "無法從 $f 刪除 hooks 鍵" json_pairs_ok "$f" || pfail "$f 刪除 hooks 鍵後 JSON 括號不成對" ! json_top_key "$f" hooks || pfail "$f 刪除後最上層仍有 hooks 鍵" done_files="$done_files $f" done if [ -n "$done_files" ]; then purged "已從 claude 使用者層設定檔刪除 hooks 鍵,含非 jsc 的第三方項目" echo "[jsc] claude:已處理的設定檔:$done_files" else purged "claude 使用者層設定檔沒有 hooks 鍵,沒有 hook 要移除" fi echo "[jsc] claude:其他 plugin 自帶的 hooks.json 不在使用者設定檔裡,purge 動不到;要靠移除該 plugin 才能清掉。" echo "[jsc] claude:jsc 自己的 hooks/hooks.json 同樣隨 plugin 提供,移除 jsc-hooks plugin 才會消失。" exit 0 ;; codex) bin=$(cli_bin codex) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 codex 執行檔" CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" config="$CODEX_HOME/config.toml" agents="$CODEX_HOME/AGENTS.md" if [ -f "$config" ]; then if has_block "$config" "# jsc-hooks" || toml_root_key "$config" notify; then backup_file "$config" || pfail "無法備份 $config,沒有備份就不移除" remove_block "$config" "# jsc-hooks" "# /jsc-hooks" \ || pfail "無法從 $config 移除 jsc-hooks 標記段落" remove_toml_root_key "$config" notify || pfail "無法從 $config 移除根層 notify" has_block "$config" "# jsc-hooks" && pfail "$config 移除後仍讀得到 jsc-hooks 標記段落" toml_root_key "$config" notify && pfail "$config 移除後仍有根層 notify" fi fi # rc 檔清所有 `# jsc-hooks*` 段落:別名段落沒有分 CLI 的必要,一次清乾淨最可靠。 rclist=$(mktemp) || pfail "無法建立暫存檔" rc_files_existing > "$rclist" while IFS= read -r rc; do [ -n "$rc" ] || continue grep -q '^# jsc-hooks' "$rc" 2>/dev/null || continue backup_file "$rc" || pfail "無法備份 $rc,沒有備份就不移除" remove_rc_blocks "$rc" || pfail "無法從 $rc 移除 jsc-hooks 標記段落" grep -q '^# jsc-hooks' "$rc" 2>/dev/null && pfail "$rc 移除後仍有 jsc-hooks 標記段落" done < "$rclist" rm -f "$rclist" if [ -f "$agents" ] && has_block "$agents" ""; then backup_file "$agents" || pfail "無法備份 $agents,沒有備份就不移除" remove_block "$agents" "" "" \ || pfail "無法從 $agents 移除 jsc-hooks 標記段落" has_block "$agents" "" && pfail "$agents 移除後仍讀得到 jsc-hooks 標記段落" fi purged "已移除 config.toml 的標記段落與根層 notify、rc 檔的 jsc-hooks 段落、AGENTS.md 的規則段落" echo "[jsc] codex:別名要開新的 shell 或重新 source rc 檔才真的失效。" exit 0 ;; copilot) bin=$(cli_bin copilot) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔" instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" rclist=$(mktemp) || pfail "無法建立暫存檔" rc_files_existing > "$rclist" while IFS= read -r rc; do [ -n "$rc" ] || continue has_block "$rc" "# jsc-hooks:copilot" || continue backup_file "$rc" || pfail "無法備份 $rc,沒有備份就不移除" remove_block "$rc" "# jsc-hooks:copilot" "# /jsc-hooks:copilot" \ || pfail "無法從 $rc 移除 jsc-hooks:copilot 段落" has_block "$rc" "# jsc-hooks:copilot" && pfail "$rc 移除後仍有 jsc-hooks:copilot 段落" done < "$rclist" rm -f "$rclist" if [ -f "$instr" ] && has_block "$instr" ""; then backup_file "$instr" || pfail "無法備份 $instr,沒有備份就不移除" remove_block "$instr" "" "" \ || pfail "無法從 $instr 移除 jsc-hooks 標記段落" has_block "$instr" "" && pfail "$instr 移除後仍讀得到 jsc-hooks 標記段落" fi purged "已移除 rc 檔的 jsc-hooks:copilot 段落與指引檔的規則段落" echo "[jsc] copilot:別名要開新的 shell 或重新 source rc 檔才真的失效。" exit 0 ;; antigravity) bin=$(cli_bin antigravity) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔" rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}" rclist=$(mktemp) || pfail "無法建立暫存檔" rc_files_existing > "$rclist" while IFS= read -r rc; do [ -n "$rc" ] || continue has_block "$rc" "# jsc-hooks:antigravity" || continue backup_file "$rc" || pfail "無法備份 $rc,沒有備份就不移除" remove_block "$rc" "# jsc-hooks:antigravity" "# /jsc-hooks:antigravity" \ || pfail "無法從 $rc 移除 jsc-hooks:antigravity 段落" has_block "$rc" "# jsc-hooks:antigravity" && pfail "$rc 移除後仍有 jsc-hooks:antigravity 段落" done < "$rclist" rm -f "$rclist" if [ -f "$rules" ] && has_block "$rules" ""; then backup_file "$rules" || pfail "無法備份 $rules,沒有備份就不移除" remove_block "$rules" "" "" \ || pfail "無法從 $rules 移除 jsc-hooks 標記段落" has_block "$rules" "" && pfail "$rules 移除後仍讀得到 jsc-hooks 標記段落" fi purged "已移除 rc 檔的 jsc-hooks:antigravity 段落與全域規則檔的規則段落" echo "[jsc] antigravity:別名要開新的 shell 或重新 source rc 檔才真的失效。" exit 0 ;; kiro) command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔" hookdir="./.kiro/hooks" if [ -d "$hookdir" ]; then for f in "$hookdir"/*; do [ -f "$f" ] || continue backup_file "$f" || pfail "無法備份 $f,沒有備份就不移除" rm -f "$f" 2>/dev/null || pfail "無法刪除 $f" done left=$(find "$hookdir" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') [ "$left" = 0 ] || pfail "$hookdir 底下還有 $left 個 hook 檔沒刪掉" fi purged "已刪除工作區 .kiro/hooks/ 底下所有 hook 檔,含非 jsc 的第三方項目" echo "[jsc] kiro:hook 檔綁在工作區,這次只清得到目前目錄的 ./.kiro/hooks/,其他工作區要各自跑一次。" exit 0 ;; esac fi if [ "$action" = smoke ]; then smoke_out=$(mktemp) || { printf 'status=failed reason=%s\n' "無法建立暫存檔"; exit 4; } smoke_fails=0 # 跑一支 hook 並判定結果。$1=腳本檔名 $2=子命令(可省略) # $2 不加引號展開:子命令是固定字面字,空字串時要展成「沒有參數」而不是空參數。 smoke_one() { _h="$1"; _s="${2:-}" # 技能名一律清空:冒煙要驗的是「沒有技能情境時腳本跑得完」。留著繼承來的 JSC_SKILL, # sdlc-gate.sh wp-check skill 會拿它當真實呼叫判定,有未結清 PR 時就誤報成執行期錯誤。 _out=$(printf '{}' | JSC_CLI="$cli" JSC_SKILL="" SKILL="" sh "$HOOKS/$_h" $_s 2>&1); _rc=$? if [ "$_rc" -eq 0 ]; then printf '[jsc] %s%s:exit 0,正常。\n' "$_h" "${_s:+ $_s}" >> "$smoke_out" elif [ "$_h" = sdlc-gate.sh ] && [ "$_s" = check ] && [ "$_rc" -eq 2 ]; then # 唯一放行的非零退出:sdlc-gate.sh check 的 exit 2 是刻意設計的階段鎖阻擋 # (見 hooks/lib.sh 開頭)——鎖存在且模型不符時就該擋下該輪提示。那是 hook 正常 # 工作,不是執行期錯誤;把它算成錯誤會讓每個正在上鎖的工作階段都誤報一次失敗。 printf '[jsc] %s check:exit 2,SDLC 階段鎖擋下該輪提示,屬設計行為,不算錯誤。\n' "$_h" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] %s%s:exit %s,執行期出錯:%s\n' "$_h" "${_s:+ $_s}" "$_rc" \ "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi } smoke_one session-timer.sh mark smoke_one sdlc-gate.sh check smoke_one version-guard.sh smoke_one skill-usage.sh smoke_one ste100-guard.sh # sdlc-gate.sh 有兩個 hook 模式,接在不同事件上,兩個都要驗:wp-check prompt 一律 exit 0, # wp-check skill 在取不到技能名時放行(上面已清空技能名),所以兩者都不需要白名單例外。 smoke_one sdlc-gate.sh "wp-check prompt" smoke_one sdlc-gate.sh "wp-check skill" if [ "$smoke_fails" -eq 0 ]; then printf 'status=ok reason=%s\n' "五支 hook 的每個接線模式都跑得完,沒有執行期錯誤" cat "$smoke_out"; rm -f "$smoke_out"; exit 0 fi printf 'status=failed reason=%s\n' "$smoke_fails 支 hook 有執行期錯誤" cat "$smoke_out" rm -f "$smoke_out" echo "[jsc] 請先以 tools/report-error.sh 回報,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" >&2 exit 4 fi case "$cli" in claude) bin=$(cli_bin claude) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 claude 執行檔" [ -f "$HOOKS/hooks.json" ] || fail "找不到 $HOOKS/hooks.json,claude 接不到任何 hook" printf 'status=wired reason=%s\n' "hooks.json 自動接線" echo "[jsc] claude:由 hooks/hooks.json 自動接線全部五支 hook,無需寫入設定。" exit 0 ;; codex) bin=$(cli_bin codex) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 codex 執行檔" CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" config="$CODEX_HOME/config.toml" agents="$CODEX_HOME/AGENTS.md" # codex 的 notify 只在每一輪結束時觸發,沒有工作階段開始事件,所以計時分兩段接: # 1. shell 別名走 jsc-wrap.sh:啟動當下就 session-timer start,並給這次工作階段 # 一個 JSC_SESSION_ID,codex 內觸發的 notify 會沿用同一個 id。 # 2. notify 每輪先補 start 再 mark。start 已有紀錄就不動,所以沒走別名啟動時 # 仍拿得到起始時間(從第一輪算起)。少了這一段,worklog 只會拿到 0 秒。 timer="sh '$HOOKS/session-timer.sh'" notify_line="notify = [\"env\", \"JSC_CLI=codex\", \"sh\", \"-c\", \"$timer start " "" "$(ste100_text)" \ || fail "無法寫入 $agents" has_block "$agents" "" || fail "$agents 寫入後讀不到 jsc-hooks 標記段落" printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查" echo "[jsc] codex:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh codex,啟動當下開始計時。" echo "[jsc] codex:別名要開新的 shell 或重新 source rc 檔才生效。" echo "[jsc] codex:已設定 $config 的 notify(根層鍵,已驗證),每輪補 session-timer.sh start 再 mark。" echo "[jsc] codex:已在 $agents 寫入 STE100 規則段落(prompt 降級)。" echo "[jsc] codex:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" echo "[jsc] codex:版本前置檢查接不上(codex 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。" exit 1 ;; copilot) bin=$(cli_bin copilot) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔" instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" copilot'" write_alias_rc "jsc-hooks:copilot" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔" replace_block "$instr" "" "" "$(ste100_text)" \ || fail "無法寫入 $instr" has_block "$instr" "" || fail "$instr 寫入後讀不到 jsc-hooks 標記段落" printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查" echo "[jsc] copilot:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh copilot。" echo "[jsc] copilot:已在 $instr 寫入 STE100 規則段落(prompt 降級)。" echo "[jsc] copilot:別名要開新的 shell 或重新 source rc 檔才生效。" echo "[jsc] copilot:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" echo "[jsc] copilot:版本前置檢查接不上(copilot 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。" exit 1 ;; antigravity) bin=$(cli_bin antigravity) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔" rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}" alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" antigravity'" write_alias_rc "jsc-hooks:antigravity" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔" replace_block "$rules" "" "" "$(ste100_text)" \ || fail "無法寫入 $rules" has_block "$rules" "" || fail "$rules 寫入後讀不到 jsc-hooks 標記段落" printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查" echo "[jsc] antigravity:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh antigravity。" echo "[jsc] antigravity:已在 $rules 寫入 STE100 規則段落(prompt 降級)。" echo "[jsc] antigravity:別名要開新的 shell 或重新 source rc 檔才生效。" echo "[jsc] antigravity:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" echo "[jsc] antigravity:版本前置檢查接不上(antigravity 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。" exit 1 ;; kiro) command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔" hookdir="./.kiro/hooks" hookfile="$hookdir/jsc-hooks.json" startfile="$hookdir/jsc-hooks-session-start.json" mkdir -p "$hookdir" 2>/dev/null || fail "無法建立 $hookdir" # 計時要分兩個檔:kiro 的一個 hook 檔只有一組 run,所有事件共用。 # sessionStart 單獨一檔跑 restart,才算得出這一次工作階段的花費時間; # kiro 給不到 session id,紀錄共用 default,不覆寫起始時間就會把上一階段算進來。 cat > "$startfile" 2>/dev/null < "$hookfile" 2>/dev/null </dev/null || fail "$f 缺少 JSC_CLI=kiro" grep -qF 'session-timer.sh' "$f" 2>/dev/null || fail "$f 的 run 沒有接到 session-timer.sh" done grep -qF '"sessionStart"' "$startfile" 2>/dev/null || fail "$startfile 沒有接在 sessionStart" grep -qF '"userPromptSubmit"' "$hookfile" 2>/dev/null || fail "$hookfile 沒有接在 userPromptSubmit" printf 'status=degraded reason=%s\n' "SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查" echo "[jsc] kiro:已建立 $startfile(sessionStart 開始計時)與 $hookfile(JSC_CLI=kiro),兩份都已驗證。" echo "[jsc] kiro:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" echo "[jsc] kiro:版本前置檢查接不上(kiro 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。" exit 1 ;; esac