isep-doctor 判準改成「每一個 Claude Code 自己回報有沒有載入」,不再看設定長相(inkstone/ISEP#150 → c6995)
同一台 Mac 有 CLI 與 Desktop 內建兩個 Claude Code,讀同一份 ~/.claude 卻不一定同結論: settings 多了 path 時 Desktop 2.1.266 忽略 inkstone marketplace、CLI 2.1.220 照常載入(c6991)。 第一版看到 path 就對整台判 ❌,在 CLI 上是假陽性。 - 逐一找 CLI(PATH)與 Desktop(最新版本,數字排序),各跑 plugin list --json,依 errors 各判各的 - 設定比對只在某一個真的報錯時當解釋+修法,永遠不單獨判 ❌ - 查不了 ⇒ ◐、離開碼 2,不講成 ✅ 或 ❌ - test-isep-doctor.sh 離線 11 條(假 claude 可執行檔);舊版跑這份 pass=1 fail=10 - test-isep-doctor-live.sh 實機 3 條:真的 CLI 2.1.220/Desktop 2.1.266,隔離 HOME 重現有/無 path,不動 ~/.claude - 盤點表與 plugin.json 描述的腳本數 62→65(這棵樹上實數) 版本號待總管定。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+108
-58
@@ -1,28 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""isep-doctor — 這台機器的 Claude Code 到底有沒有把 ISEP 載進來(inkstone/ISEP#150)
|
||||
"""isep-doctor — 這台機器上每一個 Claude Code,到底有沒有把 ISEP 載進來(inkstone/ISEP#150)
|
||||
|
||||
為什麼要有這支:
|
||||
plugin 載入失敗時,ISEP 的 59 支閘**一支都不會跑**,而且**沒有任何閘能講出來**——
|
||||
plugin 載入失敗時,ISEP 的閘**一支都不會跑**,而且**沒有任何閘能講出來**——
|
||||
講話的 isep-presence-beacon 本身就在那個沒載入的 plugin 裡。
|
||||
2026-09-05 起 `~/.claude/settings.json` 的 `extraKnownMarketplaces.inkstone.source`
|
||||
多了一個 `path`(本機 checkout 路徑),Claude Code 把它當成 git 來源的抓取欄位
|
||||
(debug log:「its network source differs from the one declared for it in settings
|
||||
(… path …)」),整個 marketplace 被忽略 ⇒ `isep@inkstone` 報 "Marketplace inkstone not found"。
|
||||
CLI 與 Desktop 同病,當時被誤判成「Desktop 不跑 hook」。
|
||||
|
||||
判準(狀態,不是措辭):
|
||||
① `claude plugin list --json` 裡 isep@inkstone 帶 errors ⇒ ❌
|
||||
② settings 對 inkstone 的宣告,比 known_marketplaces.json 記的多了抓取欄位 ⇒ 指出是哪一個、給修法
|
||||
都沒有 ⇒ ✅,離開碼 0
|
||||
為什麼一台機器要分開問(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 上是假陽性。
|
||||
|
||||
離開碼:0 通|1 沒載入(附原因與一行修法)|2 查不了(找不到 claude 可執行檔等)
|
||||
判準(實際狀態,不是設定長相):
|
||||
對每一個找得到的 Claude Code 各跑一次 `plugin list --json`,看它**自己**回報的 isep@inkstone:
|
||||
沒有這一筆 ⇒ ❌ 沒安裝
|
||||
帶 errors ⇒ ❌ 沒載入(照實轉述;若 settings 比已安裝的 marketplace 多了抓取欄位,附上原因與修法)
|
||||
沒有 errors ⇒ ✅(即使設定裡有 path——那台實際載得進來就是載得進來)
|
||||
跑不動/看不懂 ⇒ ◐ 查不了(讀不到不等於沒載入,也不等於有)
|
||||
設定比對**只當解釋**,永遠不單獨判 ❌。
|
||||
|
||||
測試用環境變數:ISEP_DOCTOR_PLUGIN_LIST_JSON(直接給 plugin list 的輸出檔)、
|
||||
ISEP_DOCTOR_CLAUDE_HOME(代替 ~/.claude)。迴歸測試:scripts/test-isep-doctor.sh
|
||||
離開碼: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
|
||||
@@ -30,73 +40,113 @@ 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 find_claude():
|
||||
c = shutil.which("claude")
|
||||
if c:
|
||||
return c
|
||||
cands = sorted(glob.glob(os.path.expanduser(
|
||||
"~/Library/Application Support/Claude/claude-code/*/claude.app/Contents/MacOS/claude")))
|
||||
return cands[-1] if cands else None
|
||||
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 plugin_list():
|
||||
f = os.environ.get("ISEP_DOCTOR_PLUGIN_LIST_JSON")
|
||||
if f:
|
||||
return json.load(open(f))
|
||||
exe = find_claude()
|
||||
if not exe:
|
||||
return None
|
||||
out = subprocess.run([exe, "plugin", "list", "--json"], capture_output=True, text=True, timeout=60)
|
||||
return json.loads(out.stdout)
|
||||
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:
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
try:
|
||||
plugins = plugin_list()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"◐ 查不了:claude plugin list 失敗({e})")
|
||||
sys.exit(2)
|
||||
if plugins is None:
|
||||
print("◐ 查不了:找不到 claude 可執行檔")
|
||||
sys.exit(2)
|
||||
|
||||
entry = next((p for p in plugins if p.get("id") == PLUGIN_ID), None)
|
||||
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))]
|
||||
|
||||
if entry is None:
|
||||
print(f"❌ {PLUGIN_ID} 沒有安裝(這台的閘全部不存在)")
|
||||
print(" 修法:claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git && claude plugin install isep@inkstone")
|
||||
sys.exit(1)
|
||||
claudes = find_claudes()
|
||||
if not claudes:
|
||||
print("◐ 查不了:這台找不到任何 Claude Code 可執行檔(CLI 與 Desktop 都沒有)")
|
||||
sys.exit(2)
|
||||
|
||||
errors = entry.get("errors") or []
|
||||
if not errors and not extra:
|
||||
print(f"✅ {PLUGIN_ID} {entry.get('version')} 已載入(enabled={entry.get('enabled')})")
|
||||
sys.exit(0)
|
||||
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 被忽略")
|
||||
|
||||
print(f"❌ {PLUGIN_ID} {entry.get('version')} 沒有載入——這台 Claude Code(CLI 與 Desktop 都一樣)一支 ISEP 閘都不會跑")
|
||||
for e in errors:
|
||||
print(f" Claude Code 回報:{e}")
|
||||
if extra:
|
||||
print(f" 原因:~/.claude/settings.json 的 extraKnownMarketplaces.{MARKET}.source 多了 {', '.join(extra)},"
|
||||
"Claude Code 把它當 git 抓取欄位,跟已安裝的 marketplace 對不上,整個 marketplace 被忽略")
|
||||
if extra_hit:
|
||||
fields = ", ".join(repr(k) for k in extra)
|
||||
print(" 修法(一行,改完重開 Claude Desktop/CLI session):")
|
||||
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)\"")
|
||||
sys.exit(1)
|
||||
|
||||
if bad:
|
||||
sys.exit(1)
|
||||
sys.exit(2 if unknown else 0)
|
||||
|
||||
Reference in New Issue
Block a user