Files
hooks/tools/wire-cli.sh
T

739 lines
37 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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="" # 每行「{備份檔}<TAB>{原檔}」,還原時反向複製回去
# 備份目錄延後建立:沒有檔案要動時不留空目錄。
# 只設全域變數、不印路徑:呼叫端若寫成 $(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" "<!-- jsc-hooks -->"; then
backup_file "$agents" || pfail "無法備份 $agents,沒有備份就不移除"
remove_block "$agents" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" \
|| pfail "無法從 $agents 移除 jsc-hooks 標記段落"
has_block "$agents" "<!-- jsc-hooks -->" && 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" "<!-- jsc-hooks -->"; then
backup_file "$instr" || pfail "無法備份 $instr,沒有備份就不移除"
remove_block "$instr" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" \
|| pfail "無法從 $instr 移除 jsc-hooks 標記段落"
has_block "$instr" "<!-- jsc-hooks -->" && 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" "<!-- jsc-hooks -->"; then
backup_file "$rules" || pfail "無法備份 $rules,沒有備份就不移除"
remove_block "$rules" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" \
|| pfail "無法從 $rules 移除 jsc-hooks 標記段落"
has_block "$rules" "<!-- jsc-hooks -->" && 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 </dev/null; $timer mark </dev/null\"]"
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" codex'"
replace_block_toml "$config" "# jsc-hooks" "# /jsc-hooks" "$notify_line" \
|| fail "無法寫入 $config"
has_block "$config" "# jsc-hooks" || fail "$config 寫入後讀不到 jsc-hooks 標記段落"
toml_root_key "$config" notify \
|| fail "$config 的 notify 沒有落在根層(被歸進某張表,codex 讀不到,hook 會靜靜失效)"
write_alias_rc "jsc-hooks:codex" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
replace_block "$agents" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|| fail "無法寫入 $agents"
has_block "$agents" "<!-- jsc-hooks -->" || 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" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|| fail "無法寫入 $instr"
has_block "$instr" "<!-- jsc-hooks -->" || 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" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|| fail "無法寫入 $rules"
has_block "$rules" "<!-- jsc-hooks -->" || 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 <<EOF || fail "無法寫入 $startfile"
{
"name": "jsc-hooks-session-start",
"description": "jsc session timer start (auto-generated by jsc-hooks:hooks-install, do not edit by hand)",
"on": ["sessionStart"],
"env": { "JSC_CLI": "kiro" },
"run": "sh \\"$HOOKS/session-timer.sh\\" restart </dev/null"
}
EOF
cat > "$hookfile" 2>/dev/null <<EOF || fail "無法寫入 $hookfile"
{
"name": "jsc-hooks",
"description": "jsc session timer + STE100 guard bridge (auto-generated by jsc-hooks:hooks-install, do not edit by hand)",
"on": ["sessionEnd", "userPromptSubmit"],
"env": { "JSC_CLI": "kiro" },
"run": "sh \\"$HOOKS/session-timer.sh\\" mark </dev/null; sh \\"$HOOKS/ste100-guard.sh\\" </dev/null"
}
EOF
for f in "$startfile" "$hookfile"; do
json_top_key "$f" on || fail "$f 不是成對的 JSON,或 on 不在最上層"
json_top_key "$f" run || fail "$f 不是成對的 JSON,或 run 不在最上層"
grep -qF '"JSC_CLI": "kiro"' "$f" 2>/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