#!/usr/bin/env sh # wire-cli.sh — 把 jsc 五支 hook 接線到單一 CLI(供 hooks-install 技能呼叫)。 # 用法: wire-cli.sh {claude|codex|copilot|antigravity|kiro} # 行為(依 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 講明 # # 寫入後自我驗證,通過才回報成功:每個寫過的檔案重新讀一次,確認標記段落存在且落在 # 正確位置(codex 的 notify 必須是根層鍵,不能被歸進前一張表;kiro 的 JSON 必須成對 # 且 on、run 在最上層)。腳本說寫好了卻寫錯位置,是最難查的失敗,所以驗證放在腳本裡。 # # 輸出: 第一行固定為 `status={wired|degraded|skipped|failed} reason=...`(可供程式判讀), # 其後為人類可讀的繁中說明。 # 結束碼: 0=wired(已完整接線) 1=degraded(降級為 prompt/技能步驟檢查) # 2=用法錯誤 3=skipped(該 CLI 未偵測到執行檔,略過) # 4=failed(寫入或驗證沒過,接線沒生效;由 hooks-install 呼叫 report-error.sh 回報) set -u HERE=$(cd "$(dirname "$0")" && pwd) ROOT=$(cd "$HERE/.." && pwd) HOOKS="$ROOT/hooks" # cli_bin(CLI 代號 → 實際執行檔)的唯一來源在 lib.sh,包裝啟動器也用同一份 . "$HOOKS/lib.sh" cli="${1:-}" case "$cli" in claude|codex|copilot|antigravity|kiro) ;; *) echo "用法:wire-cli.sh {claude|codex|copilot|antigravity|kiro}" >&2 exit 2 ;; 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=鍵名)。 # 解析失敗(括號不成對、字串沒收尾)也回傳非 0,所以這支同時當語法檢查用。 json_top_key() { awk -v k="$2" ' { 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++ } exit((found && !bad && depth == 0) ? 0 : 1) }' "$1" 2>/dev/null } # 找出可寫入別名的 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 ] } 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 回報這次失敗,再修好原因重跑本腳本。" >&2 exit 4 } 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