Coverage for astrocyte/harness/doctor.py: 89%

256 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1"""``astrocyte doctor``: verify a local install end to end, and repair it. 

2 

3Static checks (does the config exist, is the path registered) are necessary 

4but proved insufficient: the published Claude Code plugin passed all of them 

5and still crashed on first use with a missing dependency. So doctor also 

6*runs* things — the store, the models, and the MCP server exactly as each 

7harness will launch it. 

8 

9``--fix`` repairs what setup owns: a missing config, and harness entries that 

10are stale or broken. It never adds Astrocyte to a harness the user did not 

11wire — "not wired" is reported, not "fixed". 

12""" 

13 

14from __future__ import annotations 

15 

16import os 

17import shutil 

18import subprocess 

19import time 

20from dataclasses import asdict, dataclass 

21from pathlib import Path 

22from typing import Any, Literal 

23 

24from .choices import Choices, load_choices 

25from .hosts import HookHost, Host, HostConfigError, hosts 

26from .server import handshake, locate_mcp_server, server_spec 

27 

28Level = Literal["ok", "warn", "fail", "info"] 

29 

30 

31@dataclass 

32class Check: 

33 area: str 

34 level: Level 

35 summary: str 

36 fix: str = "" 

37 fixable: bool = False 

38 

39 def as_dict(self) -> dict[str, Any]: 

40 return asdict(self) 

41 

42 

43def _check_install(found) -> list[Check]: 

44 from importlib.metadata import PackageNotFoundError, version 

45 

46 try: 

47 installed = version("astrocyte") 

48 except PackageNotFoundError: 

49 installed = "(not installed as a package)" 

50 out = [Check("install", "ok", f"astrocyte {installed}")] 

51 if found is None: 

52 out.append(Check("install", "fail", "astrocyte-mcp not found in this installation", 

53 fix="reinstall with: uv tool install 'astrocyte[local]'")) 

54 elif found.ephemeral: 

55 out.append(Check("install", "warn", f"astrocyte-mcp runs from a cache that may be pruned ({found.command})", 

56 fix="install persistently: uv tool install 'astrocyte[local]', then astrocyte setup")) 

57 else: 

58 out.append(Check("install", "ok", f"server runs as {found.command} -I -m astrocyte.mcp")) 

59 return out 

60 

61 

62def _load(config_path: Path) -> tuple[Any | None, list[Check]]: 

63 from astrocyte.config import load_config 

64 

65 if not config_path.is_file(): 

66 return None, [Check("config", "fail", f"no config at {config_path}", 

67 fix="astrocyte setup (or astrocyte doctor --fix)", fixable=True)] 

68 try: 

69 config = load_config(str(config_path)) 

70 except Exception as e: # noqa: BLE001 — report any parse failure 

71 return None, [Check("config", "fail", f"{config_path} does not load: {e}", fix="correct the YAML")] 

72 return config, [Check("config", "ok", str(config_path))] 

73 

74 

75async def _check_store(config) -> list[Check]: 

76 from astrocyte.wiring import resolve_store 

77 

78 try: 

79 store = resolve_store(config, "vector_store") 

80 except Exception as e: # noqa: BLE001 

81 return [Check("store", "fail", f"vector_store cannot be built: {e}")] 

82 if store is None: 

83 return [Check("store", "fail", "no vector_store configured", fix="set vector_store (e.g. sqlite)")] 

84 status = await store.health() 

85 out = [Check("store", "ok" if status.healthy else "fail", status.message or type(store).__name__)] 

86 path = getattr(store, "path", None) 

87 if isinstance(path, str) and path: 

88 from .privacy import exposed 

89 

90 if open_to_others := exposed(Path(path)): 

91 out.append(Check( 

92 "store", "warn", 

93 "other accounts on this machine can read your memories: " 

94 + ", ".join(p.name or str(p) for p in open_to_others), 

95 fix="astrocyte doctor --fix", fixable=True, 

96 )) 

97 return out 

98 

99 

100async def _check_models(config) -> list[Check]: 

101 from astrocyte.types import Message 

102 from astrocyte.wiring import resolve_llm_provider 

103 

104 try: 

105 llm = resolve_llm_provider(config) 

106 except Exception as e: # noqa: BLE001 

107 return [Check("models", "fail", f"provider cannot be built: {e}")] 

108 if type(llm).__name__ == "MockLLMProvider": 

109 return [Check("models", "warn", "mock provider configured: memories will not be meaningfully recalled", 

110 fix="run astrocyte setup to pick a real provider")] 

111 out: list[Check] = [] 

112 started = time.perf_counter() 

113 try: 

114 vecs = await llm.embed(["astrocyte doctor probe"]) 

115 out.append(Check("models", "ok", f"embeddings: {len(vecs[0])}-dim in {time.perf_counter() - started:.1f}s")) 

116 except Exception as e: # noqa: BLE001 

117 out.append(Check("models", "fail", f"embedding failed: {type(e).__name__}: {e}")) 

118 started = time.perf_counter() 

119 try: 

120 reply = await llm.complete([Message(role="user", content="Reply with exactly: OK")], max_tokens=8) 

121 out.append(Check("models", "ok", f"completions: replied in {time.perf_counter() - started:.1f}s" 

122 f" ({(reply.text or '').strip()[:20]!r})")) 

123 except Exception as e: # noqa: BLE001 

124 out.append(Check("models", "fail", f"completion failed: {type(e).__name__}: {e}")) 

125 return out 

126 

127 

128def _check_host(host: Host, expected, choices: Choices | None = None) -> Check: 

129 try: 

130 reg = host.registration() 

131 except HostConfigError as e: 

132 return Check(host.label, "fail", str(e)) 

133 if reg is None: 

134 if choices is not None and host.key in choices.off: 

135 return Check(host.label, "info", f"switched off; astrocyte setup --{host.key} turns it back on") 

136 return Check(host.label, "info", "installed, Astrocyte not wired", 

137 fix=f"astrocyte setup --{host.key}") 

138 if not Path(reg.command).exists(): 

139 return Check(host.label, "fail", f"points at a missing server: {reg.command}", 

140 fix="astrocyte doctor --fix", fixable=expected is not None) 

141 if expected is not None and not reg.matches(expected): 

142 return Check(host.label, "warn", f"registered with a different command or config: {reg.command} " 

143 f"{' '.join(reg.args)}", fix="astrocyte doctor --fix", fixable=True) 

144 return Check(host.label, "ok", f"wired ({host.config_file()})") 

145 

146 

147def _probe_shells() -> list[tuple[str, list[str]]]: 

148 """The shells an agent may run a hook command through on this machine.""" 

149 if os.name != "nt": 

150 return [("sh", ["sh", "-c"])] 

151 shells = [("cmd", ["cmd", "/d", "/s", "/c"])] 

152 if ps := shutil.which("pwsh") or shutil.which("powershell"): 

153 shells.append(("PowerShell", [ps, "-NoProfile", "-NonInteractive", "-Command"])) 

154 # Git Bash, the bash Claude Code uses on Windows; never WSL's System32\bash.exe. 

155 if bash := os.environ.get("CLAUDE_CODE_GIT_BASH_PATH") or _git_bash(): 

156 shells.append(("Git Bash", [bash, "-c"])) 

157 return shells 

158 

159 

160def _git_bash() -> str | None: 

161 """Git for Windows' bash, found the way Claude Code looks for it: the 

162 standard install locations, then beside the ``git`` on PATH (which may be 

163 Git\\cmd\\git.exe or Git\\bin\\git.exe).""" 

164 candidates = [Path(os.environ[var]) / "Git" / "bin" / "bash.exe" 

165 for var in ("ProgramFiles", "ProgramFiles(x86)") if os.environ.get(var)] 

166 if git := shutil.which("git"): 

167 here = Path(git).resolve().parent 

168 candidates += [here / "bash.exe", here.parent / "bin" / "bash.exe", here.parent / "usr" / "bin" / "bash.exe"] 

169 return next((str(c) for c in candidates if c.is_file()), None) 

170 

171 

172def _probe(command: str, shell: list[str]) -> str | None: 

173 """None if ``command`` answers a ping when run through ``shell``; else why not.""" 

174 try: 

175 proc = subprocess.run([*shell, command], input='{"ping": true}', capture_output=True, text=True, 

176 encoding="utf-8", errors="replace", timeout=60) 

177 except (OSError, subprocess.SubprocessError) as e: 

178 return f"{type(e).__name__}: {e}" 

179 if proc.returncode == 0 and '"pong"' in proc.stdout: 

180 return None 

181 last = (proc.stderr or proc.stdout).strip().splitlines()[-1:] 

182 return f"exit {proc.returncode}" + (f": {last[0][:160]}" if last else "") 

183 

184 

185def _check_hooks(host, choices: Choices | None = None) -> Check: 

186 """One harness's lifecycle hooks (automatic memory).""" 

187 from .server import HookPathError, hook_prefix, locate_mcp_server 

188 

189 label = f"{host.label} hooks" 

190 try: 

191 commands = host.hook_commands() 

192 except HostConfigError as e: 

193 return Check(label, "fail", str(e)) 

194 if not any(commands.values()): 

195 if choices is not None and host.key in choices.hooks_off: 

196 return Check(label, "info", f"automatic memory switched off; astrocyte setup --{host.key} turns it on") 

197 return Check(label, "info", "automatic memory off", fix=f"astrocyte setup --{host.key}") 

198 missing = [event for event, cmd in commands.items() if not cmd] 

199 found = locate_mcp_server() 

200 try: 

201 prefix = hook_prefix(found.command) if found else None 

202 except HookPathError as e: 

203 return Check(label, "fail", str(e)) 

204 file_cmd = host.file_recall_command() if isinstance(host, HookHost) else None 

205 stale = [event for event, cmd in {**commands, "file recall": file_cmd}.items() 

206 if cmd and prefix and not cmd.startswith(prefix + " ")] 

207 if missing or stale: 

208 detail = ", ".join([f"{e} missing" for e in missing] + [f"{e} points at another install" for e in stale]) 

209 return Check(label, "fail", detail, fix="astrocyte doctor --fix", fixable=prefix is not None) 

210 # Run it for real: a command the agent's shell can't parse would fail 

211 # silently in every session (the agent ignores a failing hook). 

212 command = next(cmd for cmd in commands.values() if cmd) 

213 broken = [f"{name}: {why}" for name, shell in _probe_shells() if (why := _probe(command, shell))] 

214 if broken: 

215 return Check(label, "fail", "hook command does not run under " + "; ".join(broken)) 

216 extra = ", with file recall" if file_cmd else "" 

217 return Check(label, "ok", f"automatic memory on{extra} ({host.hooks_file()})") 

218 

219 

220#: A joined project not synced for this long is reported: the daemon syncs it 

221#: every few minutes while an agent is open. 

222TEAM_STALE_HOURS = 24 

223 

224 

225def _check_team(cfg_path: Path) -> list[Check]: 

226 """Each project shared with a team: token, gateway, last sync.""" 

227 from datetime import datetime, timezone 

228 

229 from . import team 

230 

231 checks = [] 

232 for bank, entry in team.load_memberships(cfg_path).items(): 

233 area = f"team {bank}" 

234 state = team.SyncState.load(bank) 

235 if not team.token_for(bank, entry): 

236 checks.append(Check(area, "fail", f"no token for {entry['url']}", 

237 fix=f"astrocyte team join {entry['url']} --token … --bank {bank}")) 

238 continue 

239 import asyncio 

240 

241 try: 

242 asyncio.run(team.check(bank, entry)) 

243 except team.TeamError as e: 

244 checks.append(Check(area, "fail", str(e))) 

245 continue 

246 if state.last_error: 

247 checks.append(Check(area, "warn", f"last sync failed: {state.last_error}", fix="astrocyte team sync")) 

248 continue 

249 if not state.last_sync: 

250 checks.append(Check(area, "info", f"{entry['url']} reachable; not synced yet", fix="astrocyte team sync")) 

251 continue 

252 try: 

253 age = datetime.now(timezone.utc) - datetime.fromisoformat(state.last_sync) 

254 except ValueError: 

255 age = None 

256 hours = age.total_seconds() / 3600 if age is not None else 0 

257 summary = (f"{entry['url']}: {len(state.pulled)} from the team, last sync {state.last_sync}" 

258 + ("" if entry.get("share_captured") is not True else "; captured turns shared")) 

259 if hours > TEAM_STALE_HOURS: 

260 checks.append(Check(area, "warn", summary + f" ({int(hours // 24)} days ago)", 

261 fix="astrocyte team sync (the daemon syncs while an agent is open)")) 

262 else: 

263 checks.append(Check(area, "ok", summary)) 

264 return checks 

265 

266 

267def _check_daemon() -> Check: 

268 """The per-user daemon every harness's hooks talk to.""" 

269 from . import agentd 

270 

271 if not agentd.supported(): 

272 return Check("agent daemon", "warn", "no local transport; automatic recall is off on this OS") 

273 ping = agentd.request("ping", timeout=0.5) 

274 return Check("agent daemon", "ok" if ping else "info", 

275 f"running (pid {ping['pid']})" if ping else "not running (starts with the next session)") 

276 

277 

278def run_checks(config_path: Path, *, model_probes: bool = True) -> list[Check]: 

279 found = locate_mcp_server() 

280 checks = _check_install(found) 

281 config, config_checks = _load(config_path) 

282 checks += config_checks 

283 expected = server_spec(found.command, config_path) if found else None 

284 

285 if config is not None: 

286 import asyncio # here, not at import: every agent hook loads this module 

287 

288 checks += asyncio.run(_check_store(config)) 

289 if model_probes: 

290 checks += asyncio.run(_check_models(config)) 

291 if expected is not None: 

292 result = handshake(expected) 

293 checks.append(Check( 

294 "server", "ok" if result.ok else "fail", 

295 f"MCP handshake: {result.detail} in {result.seconds:.1f}s" if result.ok 

296 else f"MCP server failed to start: {result.detail}", 

297 )) 

298 

299 choices = load_choices(config_path) 

300 hook_checks: list[Check] = [] 

301 for host in hosts(): 

302 if host.detected(): 

303 check = _check_host(host, expected, choices) 

304 checks.append(check) 

305 if isinstance(host, HookHost) and not check.summary.startswith("switched off"): 

306 hook_checks.append(_check_hooks(host, choices)) 

307 checks += hook_checks 

308 if any(c.level != "info" for c in hook_checks): 

309 checks.append(_check_daemon()) 

310 checks += _check_team(config_path) 

311 return checks 

312 

313 

314def _make_store_private(config_path: Path) -> list[str]: 

315 from astrocyte.config import load_config 

316 from astrocyte.wiring import resolve_store 

317 

318 from .privacy import make_private 

319 

320 try: 

321 store = resolve_store(load_config(str(config_path)), "vector_store") 

322 except Exception as e: # noqa: BLE001 

323 return [f"could not open the store to fix permissions: {e}"] 

324 path = getattr(store, "path", None) 

325 if not isinstance(path, str) or not path: 

326 return [] 

327 return [f"made private: {p}" for p in make_private(Path(path))] 

328 

329 

330def apply_fixes(config_path: Path, checks: list[Check]) -> list[str]: 

331 """Repair what setup owns. Returns a line per action taken.""" 

332 from .localconfig import SetupError, choose_providers, render_config, write_config 

333 from .paths import database_path 

334 

335 done: list[str] = [] 

336 if any(c.area == "config" and c.fixable for c in checks): 

337 try: 

338 write_config(config_path, render_config(choose_providers(), database_path())) 

339 done.append(f"wrote {config_path}") 

340 except SetupError as e: 

341 done.append(f"could not write config: {e}") 

342 if any(c.area == "store" and c.fixable for c in checks): 

343 done += _make_store_private(config_path) 

344 found = locate_mcp_server() 

345 if found is None: 

346 return done 

347 from .server import HookPathError, hook_prefix 

348 

349 spec = server_spec(found.command, config_path) 

350 broken = {c.area for c in checks if c.fixable and c.area not in ("config",)} 

351 for host in hosts(): 

352 if host.label in broken: 

353 outcome = host.install(spec) 

354 done.append(f"{host.label}: {outcome.status} {outcome.detail}".rstrip()) 

355 if f"{host.label} hooks" in broken: 

356 try: 

357 outcome = host.install_hooks(hook_prefix(found.command)) 

358 except HookPathError as e: 

359 done.append(f"{host.label} hooks: not repaired: {e}") 

360 continue 

361 done.append(f"{host.label} hooks: {outcome.status} {outcome.detail}".rstrip()) 

362 return done