Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbc56b9e59 | |||
| 8eb4c58d2d | |||
| c1df3c7a08 | |||
| 544beefc5e | |||
| c0eec76b52 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:62 支機械閘(87 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、62 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.30.0",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:62 支機械閘(87 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、65 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.30.1",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -1210,6 +1210,30 @@ claude -p '請執行 git tag -a v9.9.9 -m test'
|
||||
> 為什麼挑 `release-tag-guard` 當考題:它**只存在於 ISEP**,舊的 `.claude/` 那份沒有。
|
||||
> 用它才分得出「載到的是 plugin」還是「載到的是舊的」。
|
||||
|
||||
### A8b — 這台**每一個** Claude Code 都真的載入了 ISEP:離線 11 條+實機 3 條
|
||||
```
|
||||
python3 scripts/isep-doctor # 看這台現況
|
||||
bash scripts/test-isep-doctor.sh # 離線:假的 claude 可執行檔
|
||||
bash scripts/test-isep-doctor-live.sh # 實機:真的 CLI 與 Desktop 內建那一份,隔離 HOME,不動 ~/.claude
|
||||
```
|
||||
**該看到**:`isep-doctor` 逐一列出 CLI 與 Desktop,各一行 ✅;離線 `pass=11 fail=0`;實機 `pass=3 fail=0 skip=0`。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#150`):plugin 沒載入時,**會講話的信標本身也沒載入**,
|
||||
所以只能從 plugin 外面問。而同一台 Mac 有**不只一個** Claude Code,它們讀同一份 `~/.claude` 卻不一定同一個結論:
|
||||
`extraKnownMarketplaces.inkstone.source` 多了 `path` 時,**Desktop 內建的 2.1.266 整個忽略 marketplace、CLI 2.1.220 照常載入**
|
||||
(c6991 兩份 debug log;本支實機測試在隔離 HOME 重現)。第一版 doctor 看設定長相就對整台判 ❌——在 CLI 上是假陽性。
|
||||
⇒ 判準是**每一個 Claude Code 自己 `plugin list --json` 回報的 errors**;設定比對只當解釋,永遠不單獨判 ❌。
|
||||
|
||||
**失敗**:
|
||||
- ① 紅 ⇒ **誤攔**,又回到看設定長相判 ❌。鑑別力實跑過:拿 8eb4c58 那版跑這份測試是 `pass=1 fail=10`,
|
||||
大多是輸出格式不同;**真正分得出判準對錯的是 ①**——舊版在「CLI 實際載入」時照樣 exit 1 並點名 path
|
||||
- ② 紅 ⇒ 分不出哪一個 surface 壞了,或又把整台機器講成同一個結論
|
||||
- ④ 紅 ⇒ 別的原因失敗時編了一個 path 原因出來
|
||||
- ⑥⑦ 紅 ⇒ 「查不了」被講成 ✅ 或 ❌(讀不到不等於沒載入)
|
||||
- ⑧⑧b 紅 ⇒ 印出來的修法照著貼走不通
|
||||
- ⑨ 紅 ⇒ Desktop 版本用字串排序,`2.1.99` 會被當成比 `2.1.266` 新
|
||||
- 實機測試印 SKIP ⇒ 這台的版本不是 2.1.220/2.1.266,行為可能不同,那一格**沒驗過**
|
||||
|
||||
---
|
||||
|
||||
## B. 只有 leo 能跑的(雲端)
|
||||
|
||||
@@ -43,6 +43,19 @@
|
||||
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=62)。
|
||||
|
||||
> 📌 **`0.30.1`(`inkstone/ISEP#130`,2026-09-13)+0 支、+0 條**:修補 0.30.0 的抓票入口——
|
||||
> `mainline set/refresh/adopt` 讀不到任何一條 `/dependencies` 時,名單標成不完整、`set` 離開碼 3 不印 ✅、
|
||||
> 保留舊快取;否則雲端工人會拿殘缺名單 `pick` 到「抓盡」而收工(09-13 實撞:hub 票底下三張整批漏收)。
|
||||
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=62)。
|
||||
|
||||
> 📌 **`inkstone/ISEP#150`(2026-09-13)+0 支、+0 條、腳本 62→65**:沒加閘。`scripts/isep-doctor` 從 plugin **外面**
|
||||
> 問這台每一個 Claude Code(CLI、Desktop 內建那一份)有沒有真的載入 ISEP——plugin 沒載入時信標自己也講不出話。
|
||||
> 判準是各自 `plugin list --json` 回報的 errors,不是設定長相(`path` 在 Desktop 2.1.266 會讓 marketplace 被忽略、CLI 2.1.220 不會)。
|
||||
> 另加兩支測試:`test-isep-doctor.sh`(離線)、`test-isep-doctor-live.sh`(真的 Claude Code、隔離 HOME)。
|
||||
> **62 支、87 條、65 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=65)。
|
||||
|
||||
> 📌 **`inkstone/ISEP#147`(2026-09-07)+0 支、+1 條**:`line-needs-own-worktree.sh` 多掛 PostToolUse `Agent|Task`
|
||||
> ——教你開分身的那支閘,在線收工那一刻替你收(分支推了就 remove;沒推就點名分支與路徑,不靜默、不 force)。
|
||||
> 分身的家從「repo 旁邊」改成「repo 裡面的 `.worktrees/`」:leo 在 Finder 看到一排 `ISEP-wt-117`/`InkStoneCo-wt-112`
|
||||
|
||||
+31
-5
@@ -364,18 +364,42 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
||||
回 (members, info):
|
||||
members 排好序的 `owner/repo#N` 清單(含已關的——進度與 `has` 都要看得到它們)
|
||||
info {"milestone": [...], "via_deps": [...], "hub_ticket": ref|None,
|
||||
"reached": bool, "capped": bool}
|
||||
"reached": bool, "capped": bool, "unread": [...], "complete": bool}
|
||||
🔴 `reached=False` = 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取**
|
||||
(讀不到 ≠ 沒有成員)。
|
||||
🔴 `unread` = 讀不到 `/dependencies` 的票(重讀過仍失敗)。**只要有一張,名單就不完整**:
|
||||
那張底下的前置全部沒收到(inkstone/ISEP#130 c6973:Gitea 握手逾時,hub 票底下三張全漏,
|
||||
`set` 卻印 ✅)。`complete`=reached 且沒有 unread 且沒被上限截斷——呼叫端只准在它為真時
|
||||
把名單當成完整的寫出去。
|
||||
判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。
|
||||
"""
|
||||
import time
|
||||
import urllib.parse
|
||||
try:
|
||||
retries = max(0, int(os.environ.get("ISEP_MEMBER_RETRIES", "2")))
|
||||
except ValueError:
|
||||
retries = 2
|
||||
try:
|
||||
backoff = max(0.0, float(os.environ.get("ISEP_MEMBER_BACKOFF", "2")))
|
||||
except ValueError:
|
||||
backoff = 2.0
|
||||
|
||||
def read_list(path):
|
||||
"""讀一頁清單;拿不到 list 就重讀(握手逾時常常下一次就好)。仍拿不到回 None。"""
|
||||
for n in range(retries + 1):
|
||||
got = api(path)
|
||||
if isinstance(got, list):
|
||||
return got
|
||||
if n < retries and backoff:
|
||||
time.sleep(backoff * (n + 1))
|
||||
return None
|
||||
|
||||
owner, repo, mid = ms.get("owner"), ms.get("repo"), ms.get("id")
|
||||
title = ms.get("title") or ""
|
||||
info = {"milestone": [], "via_deps": [], "hub_ticket": hub_ticket(ms),
|
||||
"reached": False, "capped": False}
|
||||
"reached": False, "capped": False, "unread": [], "complete": False}
|
||||
q = urllib.parse.urlencode({"state": "all", "type": "issues", "milestones": title, "limit": 100})
|
||||
rows = api("/repos/%s/%s/issues?%s" % (owner, repo, q))
|
||||
rows = read_list("/repos/%s/%s/issues?%s" % (owner, repo, q))
|
||||
if not isinstance(rows, list):
|
||||
return [], info
|
||||
info["reached"] = True
|
||||
@@ -400,9 +424,10 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
||||
parts = split_ref(cur)
|
||||
if not parts:
|
||||
continue
|
||||
deps = api("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts)
|
||||
deps = read_list("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts)
|
||||
if not isinstance(deps, list):
|
||||
continue # 這一張讀不到:跳過它,不當成「沒有相依」以外的事
|
||||
info["unread"].append(cur) # 讀不到 ≠ 沒有相依:記下來,名單就不算完整
|
||||
continue
|
||||
for d in deps:
|
||||
r = issue_ref(d)
|
||||
if not r or r in seen:
|
||||
@@ -412,6 +437,7 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
||||
break
|
||||
seen.add(r); out.append(r); queue.append(r)
|
||||
info["via_deps"].append(r)
|
||||
info["complete"] = not info["unread"] and not info["capped"]
|
||||
return sorted(set(out)), info
|
||||
|
||||
|
||||
|
||||
@@ -95,7 +95,14 @@ class H(BaseHTTPRequestHandler):
|
||||
r = "%s/%s#%s" % m.groups()[:3]
|
||||
if r not in w["issues"]: return self._send(404, {"message": "not found"})
|
||||
if not m.group(4): return self._send(200, issue_obj(w, r))
|
||||
if m.group(4) == "/dependencies": return self._send(200, [issue_obj(w, c) for c in w["deps"].get(r, [])])
|
||||
if m.group(4) == "/dependencies":
|
||||
# inkstone/ISEP#130 c6973:Gitea 握手逾時時,票本身讀得到、它的 /dependencies 讀不到
|
||||
fo = os.path.join(TMP, "depsfail-once"); ff = os.path.join(TMP, "depsfail")
|
||||
if os.path.exists(fo) and r in open(fo).read().split():
|
||||
os.remove(fo); return self._send(500, {"message": "handshake timed out (test, once)"})
|
||||
if os.path.exists(ff) and r in open(ff).read().split():
|
||||
return self._send(500, {"message": "handshake timed out (test)"})
|
||||
return self._send(200, [issue_obj(w, c) for c in w["deps"].get(r, [])])
|
||||
return self._send(200, [issue_obj(w, pr) for pr, cs in w["deps"].items() if r in cs])
|
||||
return self._send(404, {"message": "not found", "path": p})
|
||||
def do_POST(self):
|
||||
@@ -299,6 +306,38 @@ OUT=$(ml refresh --members); RC=$?
|
||||
[ "$RC" = 0 ] && [ "$(members)" = "$BEFORE" ] && ok "㊻ Gitea 回 500 ⇒ refresh --members 不炸、成員一張不少(讀不到 ≠ 沒有成員)" || no "㊻ 成員被清掉或炸了" "$RC $OUT / $(members)"
|
||||
rm -f "$TMP/down"
|
||||
|
||||
echo "── F. 讀不到任何一條 /dependencies ⇒ 不准寫出一份看起來完整的名單(inkstone/ISEP#130 → c6973)──"
|
||||
jget(){ python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get(sys.argv[2]); print(v if not isinstance(v,list) else ' '.join(v))" "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$1"; }
|
||||
export ISEP_MEMBER_BACKOFF=0
|
||||
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail"
|
||||
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
|
||||
[ "$RC" != 0 ] && ok "F1 hub 票的 /dependencies 讀不到 ⇒ set 離開碼非 0(實際 $RC)" || no "F1 set 當成成功" "$RC $OUT"
|
||||
printf '%s' "$OUT" | grep -q '✅' && no "F2 名單不完整卻印 ✅(c6973 重現的就是這個)" "$OUT" || ok "F2 名單不完整時不印 ✅"
|
||||
case "$OUT" in *"成員不完整"*"inkstone/InkStoneCo#44"*) ok "F3 訊息講出「成員不完整」並點名讀不到的是哪一張" ;; *) no "F3 沒講不完整/沒點名" "$OUT" ;; esac
|
||||
[ "$(jget members_complete)" = "False" ] && ok "F4 寫出去的主線檔標 members_complete=false(讀檔的人也看得出)" || no "F4 檔案沒標不完整" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
|
||||
case " $(jget members_unread) " in *" inkstone/InkStoneCo#44 "*) ok "F5 members_unread 列出讀不到的票" ;; *) no "F5 沒列" "$(jget members_unread)" ;; esac
|
||||
[ "$(jget ref)" = "inkstone/Arcrun#48" ] && ok "F6 主線本身照樣標定(壞的只有成員那一格)" || no "F6 主線沒標上" "$(jget ref)"
|
||||
OUT=$(python3 "$TICKET" pick --mainline "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" 2>&1); RC=$?
|
||||
[ "$RC" != 1 ] && ok "F7 同樣讀不到時 ticket pick 不回離開碼 1(不會讓 queue-drain-guard 當成抓盡;實際 $RC)" || no "F7 pick 回了「抓盡」" "$OUT"
|
||||
rm -f "$TMP/depsfail"
|
||||
|
||||
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail-once"
|
||||
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
|
||||
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '✅' && ok "F8 只逾時一次 ⇒ 重讀一次就完整,set 照常 ✅" || no "F8 沒重試" "$RC $OUT"
|
||||
case " $(members) " in *" inkstone/arcrun-rag#104 "*) ok "F9 重讀後 hub 票底下的 arcrun-rag#104 收到了" ;; *) no "F9 還是漏收" "$(members)" ;; esac
|
||||
[ "$(jget members_complete)" = "True" ] && ok "F10 完整時 members_complete=true" || no "F10 完整卻沒標" "$(jget members_complete)"
|
||||
|
||||
OUT=$(ml refresh --members); RC=$?
|
||||
case "$OUT" in *"成員"*"張"*) ok "F11 refresh --members 讀得到時會印出成員張數(c6973:原本零輸出)" ;; *) no "F11 refresh --members 還是零輸出" "$OUT" ;; esac
|
||||
BEFORE=$(members)
|
||||
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail"
|
||||
OUT=$(ml refresh --members); RC=$?
|
||||
[ "$RC" = 0 ] && ok "F12 refresh --members 讀不到也不炸(SessionStart 那條不准炸)" || no "F12 炸了" "$RC $OUT"
|
||||
[ "$(members)" = "$BEFORE" ] && ok "F13 不完整時保留舊快取,不拿殘缺名單蓋掉" || no "F13 快取被蓋掉" "$BEFORE → $(members)"
|
||||
case "$OUT" in *"成員不完整"*) ok "F14 refresh --members 講出成員不完整" ;; *) no "F14 靜默" "$OUT" ;; esac
|
||||
rm -f "$TMP/depsfail"
|
||||
unset ISEP_MEMBER_BACKOFF
|
||||
|
||||
echo "── E. milestone-account 的帳本落在會被 clone 走的地方(票上驗法第 3 條)──"
|
||||
OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$ACCOUNT" ledger-path 2>&1)
|
||||
[ "$OUT" = "$HUB/system-dev/estimates/ledger.jsonl" ] && ok "㊼ 雲端薄殼形狀(\$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/)⇒ 帳本在 InkStoneCo/system-dev/estimates/" || no "㊼ 帳本路徑不對" "$OUT"
|
||||
|
||||
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""isep-doctor — 這台機器上每一個 Claude Code,到底有沒有把 ISEP 載進來(inkstone/ISEP#150)
|
||||
|
||||
為什麼要有這支:
|
||||
plugin 載入失敗時,ISEP 的閘**一支都不會跑**,而且**沒有任何閘能講出來**——
|
||||
講話的 isep-presence-beacon 本身就在那個沒載入的 plugin 裡。
|
||||
|
||||
為什麼一台機器要分開問(09-13 總管複驗 c6991 推翻了第一版):
|
||||
同一台 Mac 上有**不只一個** Claude Code:CLI(例:/opt/homebrew/bin/claude 2.1.220)
|
||||
與 Claude Desktop 內建的那一份(~/Library/Application Support/Claude/claude-code/<版本>/…,例:2.1.266)。
|
||||
它們讀同一份 ~/.claude,**卻不一定得出同一個結論**:
|
||||
settings.json 的 extraKnownMarketplaces.inkstone.source 多了 path 時——
|
||||
2.1.266:整個 marketplace 被忽略 ⇒ isep@inkstone 報 "Marketplace inkstone not found"
|
||||
2.1.220:照常載入
|
||||
第一版只看「設定長得像會出事」就對整台機器判 ❌,在 2.1.220 上是假陽性。
|
||||
|
||||
判準(實際狀態,不是設定長相):
|
||||
對每一個找得到的 Claude Code 各跑一次 `plugin list --json`,看它**自己**回報的 isep@inkstone:
|
||||
沒有這一筆 ⇒ ❌ 沒安裝
|
||||
帶 errors ⇒ ❌ 沒載入(照實轉述;若 settings 比已安裝的 marketplace 多了抓取欄位,附上原因與修法)
|
||||
沒有 errors ⇒ ✅(即使設定裡有 path——那台實際載得進來就是載得進來)
|
||||
跑不動/看不懂 ⇒ ◐ 查不了(讀不到不等於沒載入,也不等於有)
|
||||
設定比對**只當解釋**,永遠不單獨判 ❌。
|
||||
|
||||
離開碼:0 全部 ✅|1 至少一個 ❌|2 沒有 ❌、但至少一個查不了(或一個 Claude Code 都找不到)
|
||||
|
||||
測試用環境變數:
|
||||
ISEP_DOCTOR_CLAUDE_BINS 用這份清單取代自動尋找;以 os.pathsep 分隔,每項 `名稱=可執行檔路徑`
|
||||
ISEP_DOCTOR_CLAUDE_HOME 代替 ~/.claude
|
||||
迴歸測試:scripts/test-isep-doctor.sh(離線)/scripts/test-isep-doctor-live.sh(真的 Claude Code、隔離 HOME)
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PLUGIN_ID = "isep@inkstone"
|
||||
MARKET = "inkstone"
|
||||
FETCH_FIELDS = ("path", "ref", "headers", "sparsePaths")
|
||||
DESKTOP_GLOB = "~/Library/Application Support/Claude/claude-code/*/claude.app/Contents/MacOS/claude"
|
||||
|
||||
home = os.environ.get("ISEP_DOCTOR_CLAUDE_HOME") or os.path.expanduser("~/.claude")
|
||||
|
||||
|
||||
def _ver_key(path):
|
||||
m = re.search(r"/claude-code/([0-9][0-9.]*)/", path)
|
||||
return tuple(int(x) for x in m.group(1).split(".") if x) if m else ()
|
||||
|
||||
|
||||
def find_claudes():
|
||||
"""回傳 [(名稱, 路徑)]。Desktop 只取最新版本那一份(它啟動的就是那一份;舊資料夾可能殘留)。"""
|
||||
env = os.environ.get("ISEP_DOCTOR_CLAUDE_BINS")
|
||||
if env is not None:
|
||||
out = []
|
||||
for item in env.split(os.pathsep):
|
||||
if "=" in item:
|
||||
name, path = item.split("=", 1)
|
||||
out.append((name, path))
|
||||
return out
|
||||
out = []
|
||||
cli = shutil.which("claude")
|
||||
if cli:
|
||||
out.append(("CLI", cli))
|
||||
desk = sorted(glob.glob(os.path.expanduser(DESKTOP_GLOB)), key=_ver_key) # 數字排序:2.1.99 < 2.1.266
|
||||
if desk:
|
||||
out.append(("Desktop", desk[-1]))
|
||||
return out
|
||||
|
||||
|
||||
def version_of(exe):
|
||||
try:
|
||||
r = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=30)
|
||||
return (r.stdout.strip().split() or ["?"])[0]
|
||||
except Exception: # noqa: BLE001
|
||||
return "?"
|
||||
|
||||
|
||||
def ask(exe):
|
||||
"""回傳 (狀態, 內容)。狀態:'list' 內容=plugin list 陣列;'err' 內容=查不了的原因。"""
|
||||
try:
|
||||
r = subprocess.run([exe, "plugin", "list", "--json"], capture_output=True, text=True, timeout=90)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return "err", f"執行失敗({e})"
|
||||
try:
|
||||
data = json.loads(r.stdout)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("不是陣列")
|
||||
return "list", data
|
||||
except Exception: # noqa: BLE001
|
||||
tail = (r.stdout or r.stderr or "").strip().splitlines()[-1:] or ["(沒有輸出)"]
|
||||
return "err", f"plugin list --json 看不懂(exit={r.returncode}:{tail[0][:200]})"
|
||||
|
||||
|
||||
def load(p):
|
||||
try:
|
||||
return json.load(open(p))
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
declared = (load(os.path.join(home, "settings.json")).get("extraKnownMarketplaces") or {}).get(MARKET, {}).get("source")
|
||||
known = (load(os.path.join(home, "plugins", "known_marketplaces.json")).get(MARKET) or {}).get("source")
|
||||
extra = []
|
||||
if isinstance(declared, dict):
|
||||
extra = [k for k in FETCH_FIELDS if k in declared and (not isinstance(known, dict) or declared.get(k) != known.get(k))]
|
||||
|
||||
claudes = find_claudes()
|
||||
if not claudes:
|
||||
print("◐ 查不了:這台找不到任何 Claude Code 可執行檔(CLI 與 Desktop 都沒有)")
|
||||
sys.exit(2)
|
||||
|
||||
bad, unknown, extra_hit = [], [], False
|
||||
for name, exe in claudes:
|
||||
label = f"{name} {version_of(exe)}"
|
||||
state, data = ask(exe)
|
||||
if state == "err":
|
||||
unknown.append(name)
|
||||
print(f"◐ {label}:查不了——{data}(讀不到不等於沒載入)")
|
||||
print(f" {exe}")
|
||||
continue
|
||||
entry = next((p for p in data if p.get("id") == PLUGIN_ID), None)
|
||||
if entry is None:
|
||||
bad.append(name)
|
||||
print(f"❌ {label}:{PLUGIN_ID} 沒有安裝——用這個 Claude Code 開的 session 一支 ISEP 閘都沒有")
|
||||
print(" 修法:claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git && claude plugin install isep@inkstone")
|
||||
continue
|
||||
errors = entry.get("errors") or []
|
||||
if not errors:
|
||||
print(f"✅ {label}:{PLUGIN_ID} {entry.get('version')} 已載入(enabled={entry.get('enabled')})")
|
||||
continue
|
||||
bad.append(name)
|
||||
print(f"❌ {label}:{PLUGIN_ID} {entry.get('version')} 沒有載入——用這個 Claude Code 開的 session 一支 ISEP 閘都沒有")
|
||||
for e in errors:
|
||||
print(f" Claude Code 回報:{e}")
|
||||
if extra:
|
||||
extra_hit = True
|
||||
print(f" 可能原因:~/.claude/settings.json 的 extraKnownMarketplaces.{MARKET}.source 多了 {', '.join(extra)},"
|
||||
"這一版把它當 git 抓取欄位,跟已安裝的 marketplace 對不上,整個 marketplace 被忽略")
|
||||
|
||||
if extra_hit:
|
||||
fields = ", ".join(repr(k) for k in extra)
|
||||
print("修法(一行,只拿掉上面點名的欄位;改完完全關掉 Claude Desktop/重開 CLI session 再跑一次本指令):")
|
||||
print(f" python3 -c \"import json,os;p=os.path.expanduser('~/.claude/settings.json');d=json.load(open(p));"
|
||||
f"s=d['extraKnownMarketplaces']['{MARKET}']['source'];[s.pop(k,None) for k in ({fields},)];"
|
||||
"json.dump(d,open(p,'w'),ensure_ascii=False,indent=2)\"")
|
||||
|
||||
if bad:
|
||||
sys.exit(1)
|
||||
sys.exit(2 if unknown else 0)
|
||||
+51
-7
@@ -252,21 +252,50 @@ def cmd_set(argv):
|
||||
"members": [],
|
||||
}
|
||||
refs, info = collect_members(ms)
|
||||
complete = bool(info.get("reached") and info.get("complete"))
|
||||
if not complete:
|
||||
# 名單不完整:同一條線重標時,舊快取裡有的成員先留著(讀不到 ≠ 沒有)
|
||||
prev = ML.load() or {}
|
||||
if prev.get("ref") == ms["ref"]:
|
||||
refs = sorted(set(refs) | set(prev.get("members") or []))
|
||||
ms["members"] = refs
|
||||
ms["members_complete"] = complete
|
||||
ms["members_unread"] = incomplete_what(info)
|
||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
ms["set_at"] = ms["refreshed_at"] = now
|
||||
if not ML.save(ms, mirror=True):
|
||||
die("🔴 寫不進 %s——主線沒有被標上。" % ML.path())
|
||||
print("✅ 主線已標定:%s/%s#%d「%s」" % (owner, repo, mid, title))
|
||||
# 🔴 inkstone/ISEP#130 c6973:名單不完整時不准印 ✅——雲端工人靠這份名單抓票,
|
||||
# 漏收=以為抓盡=收工。主線本身照樣標定,壞的只有成員那一格,所以講清楚是哪一格。
|
||||
print("%s 主線已標定:%s/%s#%d「%s」%s" % (
|
||||
"✅" if complete else "⚠️", owner, repo, mid, title, "" if complete else "(🔴 成員不完整)"))
|
||||
if hub:
|
||||
print(" hub 票:%s" % hub)
|
||||
print(members_line(refs, info) if info.get("reached")
|
||||
else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)")
|
||||
if info.get("capped"):
|
||||
print(" ⚠️ 相依走到 %d 張就停了(上限),剩下的沒收" % ML.MEMBER_WALK_CAP)
|
||||
if not complete:
|
||||
print(incomplete_line(info))
|
||||
print(mirror_line())
|
||||
print()
|
||||
cmd_show([])
|
||||
if not complete:
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
def incomplete_what(info):
|
||||
"""名單不完整的原因,寫進主線檔(讀檔的人不必重跑也看得出)。完整時回 []。"""
|
||||
what = list(info.get("unread") or [])
|
||||
if not info.get("reached"):
|
||||
what.insert(0, "(hub 里程碑那一頁)")
|
||||
if info.get("capped"):
|
||||
what.append("(相依走到 %d 張上限)" % ML.MEMBER_WALK_CAP)
|
||||
return what
|
||||
|
||||
|
||||
def incomplete_line(info):
|
||||
return (" 🔴 成員不完整:讀不到 %s——那底下的前置沒收到。\n"
|
||||
" 雲端工人靠這份名單抓票;Gitea 恢復後重跑 `mainline refresh --members`(或同一行 set)補齊。"
|
||||
% "、".join(incomplete_what(info)))
|
||||
|
||||
|
||||
def mirror_line():
|
||||
@@ -328,6 +357,9 @@ def cmd_refresh(argv):
|
||||
except SystemExit:
|
||||
return # 拿不到 token:主線已經在了,進度晚點再更新,不炸
|
||||
if not isinstance(d, dict) or not d.get("title"):
|
||||
if "--members" in argv: # 明講要成員的人要看得到結果(c6973:原本零輸出)
|
||||
print(" ⚠️ 讀不到主線里程碑 %s——成員沒更新,沿用舊快取 %d 張"
|
||||
% (ML.ref_of(ms), len(ms.get("members") or [])))
|
||||
return # 讀不到 ≠ 主線不存在
|
||||
ms.update({
|
||||
"title": d.get("title") or ms["title"],
|
||||
@@ -342,12 +374,19 @@ def cmd_refresh(argv):
|
||||
# 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members(成本不變);
|
||||
# `set`/`adopt` 會重收,`pick`/`has`/focus guard 本來就每次現問 Gitea,快取只餵 `show`。
|
||||
# 讀不到(reached=False)就保留舊快取——寧可資料舊,不要把成員弄丟。
|
||||
# 🔴 inkstone/ISEP#130 c6973:只有**完整**的名單才准蓋掉快取;不完整就留舊的並講出來。
|
||||
try:
|
||||
refs, info = collect_members(ms)
|
||||
except SystemExit:
|
||||
refs, info = [], {"reached": False}
|
||||
if info.get("reached"):
|
||||
if info.get("reached") and info.get("complete"):
|
||||
ms["members"] = refs
|
||||
ms["members_complete"], ms["members_unread"] = True, []
|
||||
print(members_line(refs, info))
|
||||
else:
|
||||
ms["members_complete"], ms["members_unread"] = False, incomplete_what(info)
|
||||
print(" ⚠️ 成員不完整,沿用舊快取 %d 張(沒有拿殘缺名單蓋掉)" % len(ms.get("members") or []))
|
||||
print(incomplete_line(info))
|
||||
ML.save(ms)
|
||||
|
||||
|
||||
@@ -391,13 +430,18 @@ hub 里程碑裡現在有:%s
|
||||
T.add_dependency(vparts, (owner, repo, num))
|
||||
how = "成為 hub 票 %s 的相依(%s/%s 沒有多出任何里程碑)" % (via, owner, repo)
|
||||
refs, info = collect_members(ms)
|
||||
if info.get("reached"):
|
||||
complete = bool(info.get("reached") and info.get("complete"))
|
||||
if complete:
|
||||
ms["members"] = sorted(set(refs) | {ref})
|
||||
else:
|
||||
ms["members"] = sorted(set(ms.get("members") or []) | {ref})
|
||||
# 不完整(c6973):新收到的與舊快取取聯集,不拿殘缺名單蓋掉
|
||||
ms["members"] = sorted(set(ms.get("members") or []) | set(refs) | {ref})
|
||||
ms["members_complete"], ms["members_unread"] = complete, incomplete_what(info)
|
||||
ML.save(ms, mirror=True)
|
||||
print("✅ 補收:%s → %s" % (ref, how))
|
||||
print(members_line(ms["members"], info) if info.get("reached") else " (成員清單沿用舊快取+這一張)")
|
||||
print(members_line(ms["members"], info) if complete else " (成員清單=舊快取+這次讀得到的+這一張)")
|
||||
if not complete:
|
||||
print(incomplete_line(info))
|
||||
print(mirror_line())
|
||||
|
||||
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# 實測:scripts/isep-doctor 對**真的** Claude Code(這台的 CLI 與 Desktop 內建那一份)判得對不對(inkstone/ISEP#150)
|
||||
#
|
||||
# 不動 ~/.claude:把 inkstone marketplace 與 isep 安裝紀錄複製到 mktemp 的隔離 HOME,
|
||||
# 分別放「有 path」「沒有 path」兩份 settings.json,用真的可執行檔跑 doctor。
|
||||
# 會打網路:只有 Claude Code 自己可能匿名讀 git.uncle6.me(Gitea),不碰 GitHub、不寫任何遠端。
|
||||
#
|
||||
# 期望(09-13 在 CLI 2.1.220/Desktop 2.1.266 上實測過的行為):
|
||||
# 沒有 path ⇒ 兩個都 ✅,離開碼 0 (真陰性)
|
||||
# 有 path、Desktop 2.1.266 ⇒ ❌ 並點名 path (真陽性)
|
||||
# 有 path、CLI 2.1.220 ⇒ ✅,不因設定長相被判 ❌ (舊版 doctor 的假陽性)
|
||||
# 版本不是這兩個時,該版本那一格印 SKIP(行為可能不同,不假裝驗過)。
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
R="$HOME/.claude/plugins"
|
||||
[ -d "$R/cache/inkstone" ] && [ -d "$R/marketplaces/inkstone" ] || { echo "SKIP:這台沒有安裝 isep@inkstone,無從複製"; exit 0; }
|
||||
|
||||
CLI=$(command -v claude || true)
|
||||
DESK=$(ls -d "$HOME/Library/Application Support/Claude/claude-code/"*/claude.app/Contents/MacOS/claude 2>/dev/null \
|
||||
| python3 -c 'import sys,re;L=[l.strip() for l in sys.stdin if l.strip()];k=lambda p:tuple(int(x) for x in re.search(r"/claude-code/([0-9.]+)/",p).group(1).split(".") if x);print(sorted(L,key=k)[-1] if L else "")')
|
||||
BINS=""; [ -n "$CLI" ] && BINS="CLI=$CLI"; [ -n "$DESK" ] && BINS="${BINS:+$BINS:}Desktop=$DESK"
|
||||
[ -n "$BINS" ] || { echo "SKIP:找不到任何 Claude Code"; exit 0; }
|
||||
vcli=$([ -n "$CLI" ] && "$CLI" --version | awk '{print $1}'); vdesk=$([ -n "$DESK" ] && "$DESK" --version | awk '{print $1}')
|
||||
echo "CLI=${CLI:-無} ${vcli:-}|Desktop=${DESK:-無} ${vdesk:-}"
|
||||
|
||||
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||
C="$T/home/.claude"; mkdir -p "$C/plugins/cache" "$C/plugins/marketplaces"
|
||||
cp -R "$R/cache/inkstone" "$C/plugins/cache/" && cp -R "$R/marketplaces/inkstone" "$C/plugins/marketplaces/"
|
||||
python3 - "$R" "$C" <<'EOF'
|
||||
import json, sys
|
||||
R, C = sys.argv[1], sys.argv[2]
|
||||
km = json.load(open(R + "/known_marketplaces.json"))
|
||||
json.dump({"inkstone": json.loads(json.dumps(km["inkstone"]).replace(R, C + "/plugins"))}, open(C + "/plugins/known_marketplaces.json", "w"))
|
||||
ip = json.load(open(R + "/installed_plugins.json"))
|
||||
ip["plugins"] = {"isep@inkstone": ip["plugins"]["isep@inkstone"]}
|
||||
json.dump(json.loads(json.dumps(ip).replace(R, C + "/plugins")), open(C + "/plugins/installed_plugins.json", "w"))
|
||||
src = {"source": "git", "url": "https://git.uncle6.me/inkstone/ISEP.git"}
|
||||
json.dump({"enabledPlugins": {"isep@inkstone": True}, "extraKnownMarketplaces": {"inkstone": {"source": src}}}, open(C + "/settings.nopath.json", "w"))
|
||||
src["path"] = "/Users/youlinhsieh/Documents/tech_projects/ISEP"
|
||||
json.dump({"enabledPlugins": {"isep@inkstone": True}, "extraKnownMarketplaces": {"inkstone": {"source": src}}}, open(C + "/settings.path.json", "w"))
|
||||
EOF
|
||||
cp -R "$C" "$T/pristine"
|
||||
|
||||
pass=0; fail=0; skip=0
|
||||
doctor() { # settings 變體
|
||||
rm -rf "$C" && cp -R "$T/pristine" "$C" && cp "$C/settings.$1.json" "$C/settings.json"
|
||||
OUT=$(cd "$T" && env -u CLAUDE_CODE_CHILD_SESSION HOME="$T/home" ISEP_DOCTOR_CLAUDE_BINS="$BINS" python3 "$HERE/isep-doctor" 2>&1); CODE=$?
|
||||
echo "── settings=$1(exit=$CODE)"; printf '%s\n' "$OUT" | sed 's/^/ /'
|
||||
}
|
||||
has() { printf '%s' "$OUT" | grep -q -- "$1"; }
|
||||
judge() { if eval "$2"; then pass=$((pass+1)); echo "ok $1"; else fail=$((fail+1)); echo "FAIL $1"; fi; }
|
||||
|
||||
doctor nopath
|
||||
judge "真陰性:沒有 path → 離開碼 0、沒有 ❌" '[ "$CODE" = 0 ] && ! has "❌"'
|
||||
|
||||
doctor path
|
||||
if [ "$vdesk" = 2.1.266 ]; then
|
||||
judge "真陽性:Desktop 2.1.266 有 path → ❌ 並點名 path、離開碼 1" '[ "$CODE" = 1 ] && has "❌ Desktop 2.1.266" && has "多了 path"'
|
||||
else skip=$((skip+1)); echo "SKIP 真陽性:Desktop 是 ${vdesk:-無},不是 2.1.266"; fi
|
||||
if [ "$vcli" = 2.1.220 ]; then
|
||||
judge "假陽性:CLI 2.1.220 有 path 但實際載入 → ✅" 'has "✅ CLI 2.1.220"'
|
||||
else skip=$((skip+1)); echo "SKIP 假陽性:CLI 是 ${vcli:-無},不是 2.1.220"; fi
|
||||
|
||||
echo "pass=$pass fail=$fail skip=$skip"; [ "$fail" = 0 ]
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# 迴歸測試:scripts/isep-doctor(inkstone/ISEP#150)——全離線
|
||||
# 用假的 claude 可執行檔(shell 腳本)代替真的 CLI/Desktop,走的是 doctor 真正的 subprocess 那條路。
|
||||
# 真的 Claude Code 版本的實測在 scripts/test-isep-doctor-live.sh。
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||
pass=0; fail=0
|
||||
URL=https://git.uncle6.me/inkstone/ISEP.git
|
||||
|
||||
mkfake() { # 路徑 版本 plugin-list 輸出(原樣印出)
|
||||
mkdir -p "$(dirname "$1")"
|
||||
printf '%s' "$3" > "$1.out"
|
||||
cat > "$1" <<EOF
|
||||
#!/bin/sh
|
||||
if [ "\$1" = "--version" ]; then echo "$2 (Claude Code)"; exit 0; fi
|
||||
cat "$1.out"
|
||||
EOF
|
||||
chmod +x "$1"
|
||||
}
|
||||
run() { OUT=$(ISEP_DOCTOR_CLAUDE_HOME="$T/home" python3 "$HERE/isep-doctor" 2>&1); CODE=$?; }
|
||||
ok() { pass=$((pass+1)); echo "ok $1"; }
|
||||
bad() { fail=$((fail+1)); echo "FAIL $1 (exit=$CODE)"; printf '%s\n' "$OUT" | sed 's/^/ /'; }
|
||||
expect() { # 名稱 離開碼 要有的樣式(| 分隔,可空) 不准有的樣式(可空)
|
||||
local p miss=""
|
||||
[ "$CODE" = "$2" ] || miss="exit"
|
||||
local IFS='|'
|
||||
for p in $3; do printf '%s' "$OUT" | grep -q -- "$p" || miss="$miss 缺[$p]"; done
|
||||
if [ -n "${4:-}" ] && printf '%s' "$OUT" | grep -q -- "$4"; then miss="$miss 多了[$4]"; fi
|
||||
[ -z "$miss" ] && ok "$1" || { bad "$1 →$miss"; }
|
||||
}
|
||||
|
||||
mkdir -p "$T/home/plugins"
|
||||
echo "{\"inkstone\":{\"source\":{\"source\":\"git\",\"url\":\"$URL\"}}}" > "$T/home/plugins/known_marketplaces.json"
|
||||
SET_PATH="{\"extraKnownMarketplaces\":{\"inkstone\":{\"source\":{\"source\":\"git\",\"url\":\"$URL\",\"path\":\"/Users/x/ISEP\"}}},\"model\":\"keep\"}"
|
||||
SET_CLEAN="{\"extraKnownMarketplaces\":{\"inkstone\":{\"source\":{\"source\":\"git\",\"url\":\"$URL\"}}}}"
|
||||
LOADED='[{"id":"isep@inkstone","version":"0.30.1","enabled":true}]'
|
||||
NOTFOUND='[{"id":"isep@inkstone","version":"0.30.1","enabled":true,"errors":["Marketplace inkstone not found"]}]'
|
||||
|
||||
# ① 假陽性(c6991):設定有 path,但這個 Claude Code 自己回報載入正常 → ✅、不准點名 path
|
||||
echo "$SET_PATH" > "$T/home/settings.json"
|
||||
mkfake "$T/b/cli" 2.1.220 "$LOADED"
|
||||
export ISEP_DOCTOR_CLAUDE_BINS="CLI=$T/b/cli"; run
|
||||
expect "① 有 path 但實際載入(2.1.220 形狀)→ ✅ 不點名 path" 0 "✅ CLI 2.1.220" "path"
|
||||
|
||||
# ② 真陽性(c6990):同一份設定,CLI 載得進、Desktop 載不進 → 1;兩邊各講各的;點名 path;給修法
|
||||
mkfake "$T/b/desk" 2.1.266 "$NOTFOUND"
|
||||
export ISEP_DOCTOR_CLAUDE_BINS="CLI=$T/b/cli:Desktop=$T/b/desk"; run
|
||||
expect "② CLI 載得進、Desktop 載不進 → 1,逐一講、點名 path" 1 "✅ CLI 2.1.220|❌ Desktop 2.1.266|Marketplace inkstone not found|多了 path|^ python3 "
|
||||
printf '%s' "$OUT" | grep -q "CLI 與 Desktop 都一樣" && bad "② 不准再說 CLI 與 Desktop 都一樣" || ok "② 沒有把整台機器講成同一個結論"
|
||||
|
||||
# ③ 真陰性:拿掉 path,兩邊都載入 → 0
|
||||
echo "$SET_CLEAN" > "$T/home/settings.json"
|
||||
mkfake "$T/b/desk" 2.1.266 "$LOADED"; run
|
||||
expect "③ 沒有 path、兩邊都載入 → 0" 0 "✅ CLI 2.1.220|✅ Desktop 2.1.266" "❌"
|
||||
|
||||
# ④ 別的原因失敗(設定沒有多餘欄位)→ 1,照實轉述,不編原因、不給 path 修法
|
||||
mkfake "$T/b/desk" 2.1.266 '[{"id":"isep@inkstone","version":"0.30.1","enabled":true,"errors":["Plugin directory missing"]}]'; run
|
||||
expect "④ 其他錯誤照實轉述、不編原因" 1 "❌ Desktop|Plugin directory missing" "修法(一行"
|
||||
|
||||
# ⑤ 沒安裝 → 1
|
||||
mkfake "$T/b/desk" 2.1.266 '[]'; run
|
||||
expect "⑤ 沒安裝 → 1" 1 "❌ Desktop 2.1.266:isep@inkstone 沒有安裝"
|
||||
|
||||
# ⑥ 查不了(輸出不是 JSON)+另一個正常 → 2,◐,不准講成 ❌ 或 ✅
|
||||
mkfake "$T/b/desk" 2.1.266 'Error: something broke'; run
|
||||
expect "⑥ 一個查不了、一個正常 → 2" 2 "◐ Desktop|讀不到不等於沒載入|✅ CLI" "❌"
|
||||
|
||||
# ⑦ 一個 Claude Code 都找不到 → 2
|
||||
export ISEP_DOCTOR_CLAUDE_BINS=""; run
|
||||
expect "⑦ 找不到任何 Claude Code → 2" 2 "◐ 查不了"
|
||||
|
||||
# ⑧ 修法那一行真的修得好(在假 HOME 上原封不動執行印給 leo 的那一行)
|
||||
echo "$SET_PATH" > "$T/home/settings.json"
|
||||
mkfake "$T/b/desk" 2.1.266 "$NOTFOUND"
|
||||
export ISEP_DOCTOR_CLAUDE_BINS="Desktop=$T/b/desk"; run
|
||||
fixline=$(printf '%s\n' "$OUT" | grep '^ python3 ' | sed 's/^ //')
|
||||
mkdir -p "$T/fake/.claude" && cp "$T/home/settings.json" "$T/fake/.claude/settings.json"
|
||||
HOME="$T/fake" bash -c "$fixline"
|
||||
if [ -n "$fixline" ] && python3 -c "import json,sys;d=json.load(open('$T/fake/.claude/settings.json'));s=d['extraKnownMarketplaces']['inkstone']['source'];sys.exit(0 if 'path' not in s and d['model']=='keep' and s['url'].endswith('ISEP.git') else 1)"; then
|
||||
ok "⑧ 修法一行拿掉 path、其他設定不動"
|
||||
else fail=$((fail+1)); echo "FAIL ⑧ 修法一行"; cat "$T/fake/.claude/settings.json"; fi
|
||||
# ⑧b 修完再問一次(Desktop 改回報正常)→ 0:出路走得通
|
||||
cp "$T/fake/.claude/settings.json" "$T/home/settings.json"; mkfake "$T/b/desk" 2.1.266 "$LOADED"; run
|
||||
expect "⑧b 照修法修完再跑 → ✅" 0 "✅ Desktop" "多了 path"
|
||||
|
||||
# ⑨ 自動尋找:Desktop 取數字上最新的那一份(2.1.99 < 2.1.266,字串排序會選錯)
|
||||
unset ISEP_DOCTOR_CLAUDE_BINS
|
||||
D="$T/h9/Library/Application Support/Claude/claude-code"
|
||||
mkfake "$D/2.1.99/claude.app/Contents/MacOS/claude" 2.1.99 "$NOTFOUND"
|
||||
mkfake "$D/2.1.266/claude.app/Contents/MacOS/claude" 2.1.266 "$LOADED"
|
||||
OUT=$(HOME="$T/h9" PATH="/usr/bin:/bin" ISEP_DOCTOR_CLAUDE_HOME="$T/home" /usr/bin/env python3 "$HERE/isep-doctor" 2>&1); CODE=$?
|
||||
expect "⑨ Desktop 取最新版本(數字排序)" 0 "✅ Desktop 2.1.266" "2.1.99"
|
||||
|
||||
echo "pass=$pass fail=$fail"; [ "$fail" = 0 ]
|
||||
Reference in New Issue
Block a user