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
« 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.
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.
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"""
14from __future__ import annotations
16import os
17import shutil
18import subprocess
19import time
20from dataclasses import asdict, dataclass
21from pathlib import Path
22from typing import Any, Literal
24from .choices import Choices, load_choices
25from .hosts import HookHost, Host, HostConfigError, hosts
26from .server import handshake, locate_mcp_server, server_spec
28Level = Literal["ok", "warn", "fail", "info"]
31@dataclass
32class Check:
33 area: str
34 level: Level
35 summary: str
36 fix: str = ""
37 fixable: bool = False
39 def as_dict(self) -> dict[str, Any]:
40 return asdict(self)
43def _check_install(found) -> list[Check]:
44 from importlib.metadata import PackageNotFoundError, version
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
62def _load(config_path: Path) -> tuple[Any | None, list[Check]]:
63 from astrocyte.config import load_config
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))]
75async def _check_store(config) -> list[Check]:
76 from astrocyte.wiring import resolve_store
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
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
100async def _check_models(config) -> list[Check]:
101 from astrocyte.types import Message
102 from astrocyte.wiring import resolve_llm_provider
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
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()})")
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
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)
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 "")
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
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()})")
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
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
229 from . import team
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
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
267def _check_daemon() -> Check:
268 """The per-user daemon every harness's hooks talk to."""
269 from . import agentd
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)")
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
285 if config is not None:
286 import asyncio # here, not at import: every agent hook loads this module
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 ))
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
314def _make_store_private(config_path: Path) -> list[str]:
315 from astrocyte.config import load_config
316 from astrocyte.wiring import resolve_store
318 from .privacy import make_private
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))]
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
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
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