Coverage for astrocyte/harness/hosts.py: 96%
566 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"""Agent harnesses Astrocyte can register its MCP server with.
3Each host knows where its harness keeps MCP registrations, how to read the
4current ``astrocyte`` entry, and how to write one. Writes go through the
5harness's own CLI where it has one (``claude``, ``codex``, ``gemini``) — the
6CLI owns its file format and locking, and ``~/.claude.json`` in particular is
7rewritten constantly by a running Claude Code. JSON-configured harnesses
8(Cursor, Windsurf, Copilot CLI) are edited directly, atomically, refusing to
9touch a file that is not valid JSON.
11Every write is verified by re-reading the harness's config: CLI exit codes
12are not trustworthy here (``claude mcp add`` exits 0 and changes nothing when
13the name already exists), and an unverified "installed" is the failure mode
14this whole module exists to prevent.
15"""
17from __future__ import annotations
19import copy
20import json
21import os
22import re
23import shutil
24import subprocess
25import tomllib
26from abc import ABC, abstractmethod
27from dataclasses import dataclass
28from pathlib import Path
29from typing import Any, Literal
31SERVER_NAME = "astrocyte"
33Status = Literal["installed", "updated", "unchanged", "removed", "absent", "planned", "skipped", "failed"]
36_VERB = {"installed": "install", "updated": "update"}
39class HostConfigError(Exception):
40 """A harness config file exists but cannot be read safely."""
43@dataclass(frozen=True)
44class ServerSpec:
45 """The MCP server entry setup registers: an absolute command and args."""
47 command: str
48 args: tuple[str, ...]
51@dataclass(frozen=True)
52class Registration:
53 """What a harness currently has registered under ``astrocyte``."""
55 command: str
56 args: tuple[str, ...]
58 def matches(self, spec: ServerSpec) -> bool:
59 return self.command == spec.command and self.args == spec.args
62@dataclass(frozen=True)
63class Outcome:
64 host: str
65 status: Status
66 detail: str = ""
69def _home() -> Path:
70 # $HOME on POSIX; USERPROFILE on Windows (which ignores HOME, as do the
71 # agents there: their configs live under the profile even when Git Bash
72 # sets HOME elsewhere).
73 return Path.home()
76def _read_json(path: Path) -> dict[str, Any]:
77 if not path.exists():
78 return {}
79 try:
80 data = json.loads(path.read_text(encoding="utf-8") or "{}")
81 except json.JSONDecodeError as e:
82 raise HostConfigError(f"{path} is not valid JSON ({e}); fix it by hand, then re-run") from e
83 if not isinstance(data, dict):
84 raise HostConfigError(f"{path} is not a JSON object; fix it by hand, then re-run")
85 return data
88def edit_json(path: Path, change: Any) -> None:
89 """Apply ``change(data)`` to a JSON file: refuses invalid JSON before
90 writing anything, keeps a one-time backup of the user's original, writes
91 atomically, and preserves the file's permissions."""
92 data = _read_json(path)
93 change(data)
94 path.parent.mkdir(parents=True, exist_ok=True)
95 if path.exists():
96 backup = path.with_name(path.name + ".astrocyte-bak")
97 if not backup.exists():
98 shutil.copy2(path, backup)
99 tmp = path.with_name(path.name + ".astrocyte-tmp")
100 tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
101 if path.exists():
102 shutil.copymode(path, tmp)
103 os.replace(tmp, path) # atomic: a reader never sees a half-written file
106def _registration_from(entry: Any) -> Registration | None:
107 if not isinstance(entry, dict) or not entry.get("command"):
108 return None
109 return Registration(command=str(entry["command"]), args=tuple(str(a) for a in entry.get("args") or ()))
112class Host(ABC):
113 key: str
114 label: str
115 # A consent gate the harness applies to new MCP servers, which setup
116 # cannot (and should not) bypass. Shown after a fresh install so "it
117 # doesn't work in X" is never the first experience.
118 next_step: str = ""
120 @abstractmethod
121 def detected(self) -> bool:
122 """Is this harness installed for the current user?"""
124 @abstractmethod
125 def config_file(self) -> Path:
126 """The file holding this harness's MCP registrations."""
128 @abstractmethod
129 def registration(self) -> Registration | None:
130 """The current ``astrocyte`` entry; raises HostConfigError if unreadable."""
132 @abstractmethod
133 def _write(self, spec: ServerSpec) -> None: ...
135 @abstractmethod
136 def _remove(self) -> None: ...
138 def install(self, spec: ServerSpec, *, dry_run: bool = False) -> Outcome:
139 try:
140 current = self.registration()
141 except HostConfigError as e:
142 return Outcome(self.label, "failed", str(e))
143 if current is not None and current.matches(spec):
144 return Outcome(self.label, "unchanged", str(self.config_file()))
145 verb: Status = "updated" if current is not None else "installed"
146 if dry_run:
147 return Outcome(self.label, "planned", f"would {_VERB[verb]} {self.config_file()}")
148 try:
149 self._write(spec)
150 after = self.registration()
151 except (HostConfigError, OSError, subprocess.SubprocessError) as e:
152 return Outcome(self.label, "failed", str(e))
153 if after is None or not after.matches(spec):
154 return Outcome(self.label, "failed", f"wrote {self.config_file()} but the entry did not take effect")
155 return Outcome(self.label, verb, str(self.config_file()))
157 def uninstall(self, *, dry_run: bool = False) -> Outcome:
158 try:
159 if self.registration() is None:
160 return Outcome(self.label, "absent", str(self.config_file()))
161 if dry_run:
162 return Outcome(self.label, "planned", f"would remove from {self.config_file()}")
163 self._remove()
164 if self.registration() is not None:
165 return Outcome(self.label, "failed", f"entry still present in {self.config_file()}")
166 except (HostConfigError, OSError, subprocess.SubprocessError) as e:
167 return Outcome(self.label, "failed", str(e))
168 return Outcome(self.label, "removed", str(self.config_file()))
171# ── CLI-managed harnesses ────────────────────────────────────────────────
174class _CliHost(Host):
175 """Writes through the harness's own CLI; reads the file it maintains."""
177 binary: str
179 def _cli(self) -> str:
180 found = shutil.which(self.binary)
181 if not found:
182 raise HostConfigError(
183 f"`{self.binary}` is not on PATH, so its MCP config can't be edited safely. "
184 f"Add it to PATH and re-run, or register manually: {self.manual_command()}"
185 )
186 return found
188 def _run(self, *args: str, check: bool = True) -> None:
189 proc = subprocess.run(
190 [self._cli(), *args],
191 capture_output=True,
192 text=True,
193 timeout=60,
194 # Gemini's `mcp add` writes project scope relative to the cwd;
195 # pinning cwd to HOME keeps stray project files out of repos.
196 cwd=str(_home()),
197 )
198 if check and proc.returncode != 0:
199 raise subprocess.SubprocessError(
200 f"`{self.binary} {' '.join(args)}` exited {proc.returncode}: "
201 f"{(proc.stderr or proc.stdout).strip()[:300]}"
202 )
204 @abstractmethod
205 def manual_command(self, spec: ServerSpec | None = None) -> str: ...
208# ── lifecycle hooks (automatic memory) ───────────────────────────────────
211class HookHost:
212 """Installs ``astrocyte hook`` commands into a harness's hooks file.
214 Claude Code and Codex share one layout — ``{"hooks": {Event: [{"hooks":
215 [entry]}]}}`` — and one input/output contract, so one installer serves
216 both. Entries are recognised by command, never by position: the user's
217 own hooks in the same file are left exactly as they were.
218 """
220 label: str
221 #: Event → (``astrocyte hook`` subcommand, extra entry fields).
222 HOOK_EVENTS: dict[str, tuple[str, dict[str, Any]]]
223 #: ``--host`` passed to ``astrocyte hook``; None for Claude Code, whose
224 #: hooks predate the flag (keeps existing installs "unchanged").
225 hook_dialect: str | None = None
226 #: A consent gate the harness applies to new hooks (cf. Host.next_step).
227 hooks_next_step: str = ""
228 #: Do this harness's hooks save finished turns, or only recall?
229 captures_turns: bool = True
230 #: Opt-in recall on file reads and edits: (event, tool matcher, extra entry
231 #: fields), or None where the harness has no such hook (yet).
232 FILE_HOOK: tuple[str, str, dict[str, Any]] | None = None
233 #: Hooks on tool calls, installed with the HOOK_EVENTS ones: event →
234 #: (``astrocyte hook`` subcommand, tool matcher, extra entry fields).
235 TOOL_HOOKS: dict[str, tuple[str, str, dict[str, Any]]] = {}
236 # Matches the current ``<python> -I -m astrocyte.cli hook …`` form and the
237 # earlier ``astrocyte hook …`` console-script form, with or without --host.
238 _OURS = re.compile(
239 r"astrocyte(?:\.cli)?['\"]?\s+hook\s+(session-start|prompt|stop|file|edit)(?:\s+--host\s+[a-z-]+)?\s*$"
240 )
242 @abstractmethod
243 def hooks_file(self) -> Path: ...
245 def _events(self) -> dict[str, tuple[str, dict[str, Any]]]:
246 """Every always-installed hook: HOOK_EVENTS plus TOOL_HOOKS."""
247 return {**self.HOOK_EVENTS, **{e: (sub, extra) for e, (sub, _, extra) in self.TOOL_HOOKS.items()}}
249 def _matchers(self) -> dict[str, str]:
250 return {event: matcher for event, (_, matcher, _) in self.TOOL_HOOKS.items()}
252 def hooks_hint(self) -> str:
253 """What to do after installing new or changed hooks ("" for nothing)."""
254 return self.hooks_next_step
256 @classmethod
257 def _is_ours(cls, hook: Any) -> bool:
258 return isinstance(hook, dict) and bool(cls._OURS.search(str(hook.get("command", ""))))
260 def hook_commands(self) -> dict[str, str | None]:
261 """Our installed hook command per event (None where absent)."""
262 hooks = _read_json(self.hooks_file()).get("hooks", {})
263 found: dict[str, str | None] = dict.fromkeys(self._events())
264 if not isinstance(hooks, dict):
265 return found
266 for event in self._events():
267 for group in hooks.get(event) or []:
268 for hook in (group or {}).get("hooks") or []:
269 if self._is_ours(hook):
270 found[event] = hook["command"]
271 return found
273 def _wanted(self, prefix: str, file_recall: bool = False) -> dict[str, dict[str, Any]]:
274 """``prefix`` is a ready-quoted command, e.g. ``'/py' -I -m astrocyte.cli``."""
275 flag = f" --host {self.hook_dialect}" if self.hook_dialect else ""
276 wanted = {
277 event: {"type": "command", "command": f"{prefix} hook {sub}{flag}", **extra}
278 for event, (sub, extra) in self._events().items()
279 }
280 if file_recall and self.FILE_HOOK is not None:
281 event, _, extra = self.FILE_HOOK
282 wanted[event] = {"type": "command", "command": f"{prefix} hook file{flag}", **extra}
283 return wanted
285 def _all_ours(self) -> dict[str, str]:
286 """Every event holding one of our hooks, base or file recall."""
287 hooks = _read_json(self.hooks_file()).get("hooks", {})
288 found: dict[str, str] = {}
289 for event, groups in (hooks.items() if isinstance(hooks, dict) else ()):
290 for group in groups if isinstance(groups, list) else ():
291 for hook in (group or {}).get("hooks") or []:
292 if self._is_ours(hook):
293 found[event] = hook["command"]
294 return found
296 def file_recall_command(self) -> str | None:
297 """Our file-recall hook command, if installed."""
298 return self._all_ours().get(self.FILE_HOOK[0]) if self.FILE_HOOK else None
300 def _strip_ours(self, data: dict) -> None:
301 hooks = data.get("hooks")
302 if not isinstance(hooks, dict):
303 return
304 for event in list(hooks):
305 if not isinstance(hooks[event], list):
306 continue # not an event (e.g. Codex's per-hook trust state)
307 groups = []
308 for group in hooks.get(event) or []:
309 kept = [h for h in (group or {}).get("hooks") or [] if not self._is_ours(h)]
310 if kept:
311 groups.append({**group, "hooks": kept})
312 if groups:
313 hooks[event] = groups
314 else:
315 hooks.pop(event)
316 if not hooks:
317 data.pop("hooks")
319 def install_hooks(self, prefix: str, *, dry_run: bool = False, file_recall: bool = False) -> Outcome:
320 label = f"{self.label} hooks"
321 wanted = self._wanted(prefix, file_recall)
322 try:
323 current = self.hook_commands()
324 # Every hook of ours, so switching file recall off removes it.
325 unchanged = self._all_ours() == {e: entry["command"] for e, entry in wanted.items()}
326 except HostConfigError as e:
327 return Outcome(label, "failed", str(e))
328 if unchanged:
329 return Outcome(label, "unchanged", str(self.hooks_file()))
330 verb: Status = "updated" if any(current.values()) else "installed"
331 if dry_run:
332 return Outcome(label, "planned", f"would {_VERB[verb]} {self.hooks_file()}")
333 matcher = self._matchers()
334 if file_recall and self.FILE_HOOK:
335 matcher[self.FILE_HOOK[0]] = self.FILE_HOOK[1]
337 def change(data: dict) -> None:
338 self._strip_ours(data) # replace stale entries; never touch the user's own hooks
339 hooks = data.setdefault("hooks", {})
340 for event, entry in wanted.items():
341 group = {"matcher": matcher[event], "hooks": [entry]} if event in matcher else {"hooks": [entry]}
342 hooks.setdefault(event, []).append(group)
344 try:
345 edit_json(self.hooks_file(), change)
346 after = self._all_ours()
347 except (HostConfigError, OSError) as e:
348 return Outcome(label, "failed", str(e))
349 if any(after.get(e) != wanted[e]["command"] for e in wanted):
350 return Outcome(label, "failed", f"wrote {self.hooks_file()} but the hooks did not take effect")
351 return Outcome(label, verb, str(self.hooks_file()))
353 def uninstall_hooks(self, *, dry_run: bool = False) -> Outcome:
354 label = f"{self.label} hooks"
355 try:
356 if not self._all_ours():
357 return Outcome(label, "absent", str(self.hooks_file()))
358 if dry_run:
359 return Outcome(label, "planned", f"would remove from {self.hooks_file()}")
360 edit_json(self.hooks_file(), self._strip_ours)
361 except (HostConfigError, OSError) as e:
362 return Outcome(label, "failed", str(e))
363 return Outcome(label, "removed", str(self.hooks_file()))
366class ClaudeCodeHost(HookHost, _CliHost):
367 key, label, binary = "claude", "Claude Code", "claude"
369 def _dir(self) -> Path:
370 return Path(os.environ["CLAUDE_CONFIG_DIR"]) if os.environ.get("CLAUDE_CONFIG_DIR") else _home() / ".claude"
372 def detected(self) -> bool:
373 return self._dir().is_dir() or shutil.which(self.binary) is not None
375 def config_file(self) -> Path:
376 # User-scope servers live in .claude.json beside (or, with
377 # CLAUDE_CONFIG_DIR, inside) the config directory.
378 if os.environ.get("CLAUDE_CONFIG_DIR"):
379 return Path(os.environ["CLAUDE_CONFIG_DIR"]) / ".claude.json"
380 return _home() / ".claude.json"
382 def registration(self) -> Registration | None:
383 return _registration_from(_read_json(self.config_file()).get("mcpServers", {}).get(SERVER_NAME))
385 def _write(self, spec: ServerSpec) -> None:
386 # `claude mcp add` will not replace an existing name (it prints
387 # "already exists" and exits 0), so remove first.
388 self._remove()
389 self._run("mcp", "add", "--scope", "user", SERVER_NAME, "--", spec.command, *spec.args)
391 def _remove(self) -> None:
392 self._run("mcp", "remove", "--scope", "user", SERVER_NAME, check=False) # exits 1 when absent
394 def manual_command(self, spec: ServerSpec | None = None) -> str:
395 tail = f"{spec.command} {' '.join(spec.args)}" if spec else "<astrocyte-mcp> --config <path>"
396 return f"claude mcp add --scope user {SERVER_NAME} -- {tail}"
398 #: Stop is deliberately *not* async: `claude -p` exits as soon as it prints
399 #: its answer and takes background hooks with it, so an async capture never
400 #: ran in headless use (observed). The hook only spools the turn — ~0.35 s,
401 #: after the response is already on screen.
402 HOOK_EVENTS = {
403 "SessionStart": ("session-start", {"timeout": 15}),
404 "UserPromptSubmit": ("prompt", {"timeout": 5}),
405 "Stop": ("stop", {"timeout": 10}),
406 }
407 #: After a file tool runs, its memories are added next to the result
408 #: (``hookSpecificOutput.additionalContext``; code.claude.com/docs/en/hooks).
409 FILE_HOOK = ("PostToolUse", "Read|Edit|Write|NotebookEdit", {"timeout": 5})
411 def hooks_file(self) -> Path:
412 return self._dir() / "settings.json"
418class CodexHost(HookHost, _CliHost):
419 key, label, binary = "codex", "Codex CLI", "codex"
421 def _dir(self) -> Path:
422 return Path(os.environ["CODEX_HOME"]) if os.environ.get("CODEX_HOME") else _home() / ".codex"
424 def detected(self) -> bool:
425 return self._dir().is_dir() or shutil.which(self.binary) is not None
427 def config_file(self) -> Path:
428 return self._dir() / "config.toml"
430 def registration(self) -> Registration | None:
431 path = self.config_file()
432 if not path.exists():
433 return None
434 try:
435 data = tomllib.loads(path.read_text(encoding="utf-8"))
436 except tomllib.TOMLDecodeError as e:
437 raise HostConfigError(f"{path} is not valid TOML ({e}); fix it by hand, then re-run") from e
438 return _registration_from(data.get("mcp_servers", {}).get(SERVER_NAME))
440 def _write(self, spec: ServerSpec) -> None:
441 self._remove()
442 self._run("mcp", "add", SERVER_NAME, "--", spec.command, *spec.args)
444 def _remove(self) -> None:
445 self._run("mcp", "remove", SERVER_NAME, check=False)
447 def manual_command(self, spec: ServerSpec | None = None) -> str:
448 tail = f"{spec.command} {' '.join(spec.args)}" if spec else "<astrocyte-mcp> --config <path>"
449 return f"codex mcp add {SERVER_NAME} -- {tail}"
451 # Codex speaks Claude Code's hook contract (same events, same
452 # ``hookSpecificOutput.additionalContext``; verified on codex-cli 0.160),
453 # but its Stop payload carries the reply as ``last_assistant_message``
454 # rather than a transcript we can parse — hence its own dialect — and
455 # names no files, so PostToolUse on apply_patch (its one file-writing
456 # tool; reads go through the shell) notes them. Timeouts are seconds.
457 HOOK_EVENTS = {
458 "SessionStart": ("session-start", {"timeout": 15}),
459 "UserPromptSubmit": ("prompt", {"timeout": 5}),
460 "Stop": ("stop", {"timeout": 10}),
461 }
462 TOOL_HOOKS = {"PostToolUse": ("edit", "apply_patch", {"timeout": 5})}
463 hook_dialect = "codex"
464 # The released value (public API); hooks_hint() is what setup prints.
465 hooks_next_step = (
466 "Codex runs new or changed hooks only once you trust them: in Codex, run /hooks and trust "
467 "the three astrocyte hooks."
468 )
470 def hooks_hint(self) -> str:
471 return ("Codex runs new or changed hooks only once you trust them: in Codex, run /hooks and trust "
472 "the four astrocyte hooks.")
474 # Codex reads hooks from hooks.json and from config.toml, and warns on
475 # every run when both define some ("prefer a single representation"). So
476 # ours go where the user's hooks already are: hooks.json only if it holds
477 # hooks of theirs, otherwise config.toml — between marker comments, the
478 # convention other tools writing that file use, so it can be removed
479 # exactly. Every TOML edit is verified structurally: the file must parse
480 # to the old contents plus (or minus) our entries, and nothing else.
481 _BEGIN = "# >>> astrocyte hooks >>>"
482 _END = "# <<< astrocyte hooks <<<"
484 def _json_file(self) -> Path:
485 return self._dir() / "hooks.json"
487 def _toml(self) -> tuple[str, dict[str, Any]]:
488 path = self.config_file()
489 text = path.read_text(encoding="utf-8") if path.exists() else ""
490 try:
491 return text, tomllib.loads(text)
492 except tomllib.TOMLDecodeError as e:
493 raise HostConfigError(f"{path} is not valid TOML ({e}); fix it by hand, then re-run") from e
495 @staticmethod
496 def _entries(data: dict[str, Any]) -> list[Any]:
497 hooks = data.get("hooks")
498 if not isinstance(hooks, dict):
499 return []
500 return [h for groups in hooks.values() if isinstance(groups, list)
501 for group in groups for h in (group or {}).get("hooks") or []]
503 def _has_foreign_hooks(self, data: dict[str, Any]) -> bool:
504 return any(not self._is_ours(h) for h in self._entries(data))
506 def _holds_ours(self, data: dict[str, Any]) -> bool:
507 return any(self._is_ours(h) for h in self._entries(data))
509 def hooks_file(self) -> Path:
510 """Where our hooks are, or would be installed."""
511 return self._json_file() if self._has_foreign_hooks(_read_json(self._json_file())) else self.config_file()
513 def hook_commands(self) -> dict[str, str | None]:
514 found: dict[str, str | None] = dict.fromkeys(self._events())
515 for data in (_read_json(self._json_file()), self._toml()[1]):
516 hooks = data.get("hooks")
517 if not isinstance(hooks, dict):
518 continue
519 for event in self._events():
520 for group in hooks.get(event) or []:
521 for hook in (group or {}).get("hooks") or []:
522 if self._is_ours(hook):
523 found[event] = hook["command"]
524 return found
526 def _unmarked(self, text: str) -> str:
527 """``text`` without our marked block (and the blank line before it)."""
528 if self._BEGIN not in text:
529 return text
530 head, _, rest = text.partition(self._BEGIN)
531 _, sep, tail = rest.partition(self._END)
532 if not sep:
533 raise HostConfigError(f"{self.config_file()}: '{self._BEGIN}' has no closing marker; fix it by hand")
534 return head.rstrip("\n") + ("\n" if head.strip() else "") + tail.lstrip("\n")
536 def _block(self, wanted: dict[str, dict[str, Any]]) -> str:
537 lines = [self._BEGIN, "# Automatic memory, managed by `astrocyte setup` (remove: astrocyte setup --no-hooks)."]
538 for event, entry in wanted.items():
539 lines += ["", f"[[hooks.{event}]]"]
540 if event in self._matchers():
541 lines.append(f"matcher = {json.dumps(self._matchers()[event])}")
542 lines += ["", f"[[hooks.{event}.hooks]]"]
543 # JSON string escapes are valid TOML basic-string escapes.
544 lines += [f"{k} = {json.dumps(v, ensure_ascii=False)}" for k, v in entry.items()]
545 return "\n".join([*lines, self._END]) + "\n"
547 def _write_toml(self, text: str, expected: dict[str, Any]) -> None:
548 path = self.config_file()
549 try:
550 after = tomllib.loads(text)
551 except tomllib.TOMLDecodeError as e:
552 after = {"<unparseable>": str(e)}
553 if after != expected:
554 raise HostConfigError(
555 f"refusing to edit {path}: the change would alter more than Astrocyte's hooks "
556 "(hand-edited marker block, or hook events written as inline arrays?). "
557 "Fix it by hand, or move your hooks into ~/.codex/hooks.json, then re-run"
558 )
559 path.parent.mkdir(parents=True, exist_ok=True)
560 if path.exists():
561 backup = path.with_name(path.name + ".astrocyte-bak")
562 if not backup.exists():
563 shutil.copy2(path, backup)
564 tmp = path.with_name(path.name + ".astrocyte-tmp")
565 tmp.write_text(text, encoding="utf-8")
566 if path.exists():
567 shutil.copymode(path, tmp)
568 os.replace(tmp, path)
570 def _strip_json(self) -> None:
571 path = self._json_file()
572 data = _read_json(path)
573 if not self._holds_ours(data):
574 return
575 self._strip_ours(data)
576 if data == {}:
577 path.unlink() # held nothing but ours; even an empty file is a second hooks source
578 else:
579 edit_json(path, self._strip_ours)
581 def _strip_toml(self) -> None:
582 text, data = self._toml()
583 if not self._holds_ours(data):
584 return
585 expected = copy.deepcopy(data)
586 self._strip_ours(expected)
587 self._write_toml(self._unmarked(text), expected)
589 def install_hooks(self, prefix: str, *, dry_run: bool = False) -> Outcome:
590 label = f"{self.label} hooks"
591 wanted = self._wanted(prefix)
592 try:
593 current = self.hook_commands()
594 target = self.hooks_file()
595 text, data = self._toml()
596 # Where ours are now; "unchanged" also requires the right place.
597 held = {self._json_file()} if self._holds_ours(_read_json(self._json_file())) else set()
598 if self._holds_ours(data):
599 held.add(self.config_file())
600 settled = held == {target} and (target == self._json_file() or self._BEGIN in text)
601 except HostConfigError as e:
602 return Outcome(label, "failed", str(e))
603 if settled and all(current[e] == wanted[e]["command"] for e in wanted):
604 return Outcome(label, "unchanged", str(target))
605 verb: Status = "updated" if any(current.values()) else "installed"
606 if dry_run:
607 return Outcome(label, "planned", f"would {_VERB[verb]} {target}")
608 try:
609 if target == self._json_file():
610 self._strip_toml()
611 return super().install_hooks(prefix) # hooks.json: the shared JSON installer
612 self._strip_json()
613 expected = copy.deepcopy(data)
614 self._strip_ours(expected)
615 hooks = expected.setdefault("hooks", {})
616 for event, entry in wanted.items():
617 group = {"matcher": self._matchers()[event]} if event in self._matchers() else {}
618 hooks.setdefault(event, []).append({**group, "hooks": [entry]})
619 body = self._unmarked(text)
620 body = body.rstrip("\n") + "\n\n" if body.strip() else ""
621 self._write_toml(body + self._block(wanted), expected)
622 after = self.hook_commands()
623 except (HostConfigError, OSError) as e:
624 return Outcome(label, "failed", str(e))
625 if any(after[e] != wanted[e]["command"] for e in wanted):
626 return Outcome(label, "failed", f"wrote {target} but the hooks did not take effect")
627 return Outcome(label, verb, str(target))
629 def uninstall_hooks(self, *, dry_run: bool = False) -> Outcome:
630 label = f"{self.label} hooks"
631 try:
632 if not any(self.hook_commands().values()):
633 return Outcome(label, "absent", str(self.hooks_file()))
634 if dry_run:
635 return Outcome(label, "planned", f"would remove from {self.hooks_file()}")
636 self._strip_json()
637 self._strip_toml()
638 except (HostConfigError, OSError) as e:
639 return Outcome(label, "failed", str(e))
640 return Outcome(label, "removed", str(self.hooks_file()))
643class GeminiHost(_CliHost):
644 key, label, binary = "gemini", "Gemini CLI", "gemini"
645 next_step = "Gemini CLI only loads MCP servers in trusted folders: trust your project folder when prompted."
647 def detected(self) -> bool:
648 return (_home() / ".gemini").is_dir() or shutil.which(self.binary) is not None
650 def config_file(self) -> Path:
651 return _home() / ".gemini" / "settings.json"
653 def registration(self) -> Registration | None:
654 return _registration_from(_read_json(self.config_file()).get("mcpServers", {}).get(SERVER_NAME))
656 def _write(self, spec: ServerSpec) -> None:
657 # --scope user is essential: gemini defaults to *project* scope, which
658 # writes .gemini/settings.json into whatever directory you ran from.
659 # `--` keeps our server's --config from being parsed as a gemini flag.
660 self._remove()
661 self._run("mcp", "add", "--scope", "user", SERVER_NAME, spec.command, "--", *spec.args)
663 def _remove(self) -> None:
664 self._run("mcp", "remove", "--scope", "user", SERVER_NAME, check=False)
666 def manual_command(self, spec: ServerSpec | None = None) -> str:
667 cmd, args = (spec.command, " ".join(spec.args)) if spec else ("<astrocyte-mcp>", "--config <path>")
668 return f"gemini mcp add --scope user {SERVER_NAME} {cmd} -- {args}"
671# ── JSON-configured harnesses ────────────────────────────────────────────
674class _JsonHost(Host):
675 """Harnesses with no MCP CLI: edit ``mcpServers`` in a JSON file."""
677 def _entry(self, spec: ServerSpec) -> dict[str, Any]:
678 return {"command": spec.command, "args": list(spec.args)}
680 def registration(self) -> Registration | None:
681 servers = _read_json(self.config_file()).get("mcpServers", {})
682 if not isinstance(servers, dict):
683 raise HostConfigError(f'{self.config_file()}: "mcpServers" must be a JSON object')
684 return _registration_from(servers.get(SERVER_NAME))
686 def _mutate(self, change: Any) -> None:
687 edit_json(self.config_file(), lambda data: change(data.setdefault("mcpServers", {})))
689 def _write(self, spec: ServerSpec) -> None:
690 self._mutate(lambda servers: servers.__setitem__(SERVER_NAME, self._entry(spec)))
692 def _remove(self) -> None:
693 self._mutate(lambda servers: servers.pop(SERVER_NAME, None))
696class CursorHost(_JsonHost):
697 key, label = "cursor", "Cursor"
698 next_step = "Cursor asks before loading a new MCP server: enable 'astrocyte' under Settings → MCP."
700 def detected(self) -> bool:
701 return (_home() / ".cursor").is_dir()
703 def config_file(self) -> Path:
704 return _home() / ".cursor" / "mcp.json"
707class WindsurfHost(_JsonHost):
708 key, label = "windsurf", "Windsurf"
709 next_step = "Windsurf reads MCP config on refresh: press Refresh in the Cascade MCP panel."
711 def detected(self) -> bool:
712 return (_home() / ".codeium" / "windsurf").is_dir()
714 def config_file(self) -> Path:
715 return _home() / ".codeium" / "windsurf" / "mcp_config.json"
718class _OwnHookFile(HookHost):
719 """Hook layouts other than Claude Code's grouped one. Subclasses say how
720 the ``astrocyte`` entries sit in their file; install / uninstall /
721 verification are shared."""
723 _COMMAND_KEY = "command"
725 def _entries(self, data: dict[str, Any]) -> dict[str, Any]:
726 raise NotImplementedError
728 def _write_entries(self, data: dict[str, Any], entries: dict[str, dict[str, Any]]) -> None:
729 raise NotImplementedError
731 def _drop_entries(self, data: dict[str, Any]) -> None:
732 raise NotImplementedError
734 def _hook_entry(self, command: str, extra: dict[str, Any]) -> dict[str, Any]:
735 return {"type": "command", self._COMMAND_KEY: command, **extra}
737 def _our_handlers(self) -> dict[str, dict[str, Any] | None]:
738 events = self._entries(_read_json(self.hooks_file()))
739 found: dict[str, dict[str, Any] | None] = dict.fromkeys(self.HOOK_EVENTS)
740 for event in self.HOOK_EVENTS:
741 for handler in events.get(event) or []:
742 if isinstance(handler, dict) and self._OURS.search(str(handler.get(self._COMMAND_KEY, ""))):
743 found[event] = handler
744 return found
746 def hook_commands(self) -> dict[str, str | None]:
747 return {event: h[self._COMMAND_KEY] if h else None for event, h in self._our_handlers().items()}
749 def install_hooks(self, prefix: str, *, dry_run: bool = False) -> Outcome:
750 label = f"{self.label} hooks"
751 flag = f" --host {self.hook_dialect}" if self.hook_dialect else ""
752 wanted = {event: self._hook_entry(f"{prefix} hook {sub}{flag}", extra)
753 for event, (sub, extra) in self.HOOK_EVENTS.items()}
754 try:
755 current = self._our_handlers()
756 except HostConfigError as e:
757 return Outcome(label, "failed", str(e))
758 # Whole entries, not just commands: a field added in an upgrade (Copilot's
759 # `powershell`) must reach existing installs.
760 if all(current[e] == wanted[e] for e in wanted):
761 return Outcome(label, "unchanged", str(self.hooks_file()))
762 verb: Status = "updated" if any(current.values()) else "installed"
763 if dry_run:
764 return Outcome(label, "planned", f"would {_VERB[verb]} {self.hooks_file()}")
765 try:
766 edit_json(self.hooks_file(), lambda data: self._write_entries(data, wanted))
767 after = self.hook_commands()
768 except (HostConfigError, OSError) as e:
769 return Outcome(label, "failed", str(e))
770 if any(after[e] != wanted[e][self._COMMAND_KEY] for e in wanted):
771 return Outcome(label, "failed", f"wrote {self.hooks_file()} but the hooks did not take effect")
772 return Outcome(label, verb, str(self.hooks_file()))
774 def uninstall_hooks(self, *, dry_run: bool = False) -> Outcome:
775 label = f"{self.label} hooks"
776 try:
777 if not any(self.hook_commands().values()):
778 return Outcome(label, "absent", str(self.hooks_file()))
779 if dry_run:
780 return Outcome(label, "planned", f"would remove from {self.hooks_file()}")
781 edit_json(self.hooks_file(), self._drop_entries)
782 except (HostConfigError, OSError) as e:
783 return Outcome(label, "failed", str(e))
784 return Outcome(label, "removed", str(self.hooks_file()))
787class AntigravityHost(_OwnHookFile, _JsonHost):
788 """Google Antigravity: the app and the ``agy`` CLI share ~/.gemini/config.
790 Hook names are the top-level keys of hooks.json, so ours is one named
791 entry — added and removed whole, beside the user's own. Antigravity has
792 no prompt-submitted event: PreInvocation (before each model call) carries
793 recall, and the project summary on a conversation's first call. Timeouts
794 are seconds. Verified against agy 1.2: both hooks fire and injected
795 context lands in the transcript.
796 """
798 key, label = "antigravity", "Antigravity"
799 next_step = "Antigravity loads MCP servers and hooks when a conversation starts: open a new one."
800 HOOK_EVENTS = {
801 "PreInvocation": ("prompt", {"timeout": 15}),
802 "Stop": ("stop", {"timeout": 10}),
803 }
804 hook_dialect = "antigravity"
805 _HOOK_NAME = "astrocyte"
807 def _dir(self) -> Path:
808 return _home() / ".gemini" / "config"
810 def detected(self) -> bool:
811 return self._dir().is_dir() or shutil.which("agy") is not None
813 def config_file(self) -> Path:
814 return self._dir() / "mcp_config.json"
816 def hooks_file(self) -> Path:
817 return self._dir() / "hooks.json"
819 def _entries(self, data: dict[str, Any]) -> dict[str, Any]:
820 entry = data.get(self._HOOK_NAME)
821 return entry if isinstance(entry, dict) else {}
823 def _write_entries(self, data: dict[str, Any], entries: dict[str, dict[str, Any]]) -> None:
824 data[self._HOOK_NAME] = {event: [handler] for event, handler in entries.items()}
826 def _drop_entries(self, data: dict[str, Any]) -> None:
827 data.pop(self._HOOK_NAME, None)
830class CopilotHost(_OwnHookFile, _JsonHost):
831 """Copilot CLI reads every ~/.copilot/hooks/*.json; ours is its own file.
833 Session start injects the project summary and each prompt is recalled
834 against. Turns are not captured yet: agentStop gives only a transcript
835 path whose format has not been verified. Built from GitHub's hooks
836 reference (not exercised against a live Copilot CLI).
837 """
839 key, label = "copilot", "Copilot CLI"
840 captures_turns = False
841 HOOK_EVENTS = {
842 "sessionStart": ("session-start", {"timeoutSec": 15}),
843 "userPromptSubmitted": ("prompt", {"timeoutSec": 5}),
844 }
845 hook_dialect = "copilot"
846 _COMMAND_KEY = "bash"
848 def _hook_entry(self, command: str, extra: dict[str, Any]) -> dict[str, Any]:
849 # Copilot runs `bash` on macOS/Linux and `powershell` on Windows; the
850 # command is written to mean the same in both (server.hook_prefix).
851 return {"type": "command", "bash": command, "powershell": command, **extra}
853 def hooks_file(self) -> Path:
854 return self._dir() / "hooks" / "astrocyte.json"
856 def _entries(self, data: dict[str, Any]) -> dict[str, Any]:
857 hooks = data.get("hooks")
858 return hooks if isinstance(hooks, dict) else {}
860 def _write_entries(self, data: dict[str, Any], entries: dict[str, dict[str, Any]]) -> None:
861 data.clear()
862 data.update({"version": 1, "hooks": {event: [handler] for event, handler in entries.items()}})
864 def _drop_entries(self, data: dict[str, Any]) -> None:
865 data.clear()
867 def uninstall_hooks(self, *, dry_run: bool = False) -> Outcome:
868 outcome = super().uninstall_hooks(dry_run=dry_run)
869 if outcome.status == "removed":
870 self.hooks_file().unlink(missing_ok=True) # the file was only ever ours
871 self.hooks_file().with_name(self.hooks_file().name + ".astrocyte-bak").unlink(missing_ok=True)
872 return outcome
874 def _dir(self) -> Path:
875 return Path(os.environ["COPILOT_HOME"]) if os.environ.get("COPILOT_HOME") else _home() / ".copilot"
877 def detected(self) -> bool:
878 return self._dir().is_dir()
880 def config_file(self) -> Path:
881 return self._dir() / "mcp-config.json"
883 def _entry(self, spec: ServerSpec) -> dict[str, Any]:
884 # Copilot CLI requires an explicit type and tool allow-list.
885 return {"type": "local", "command": spec.command, "args": list(spec.args), "tools": ["*"]}
888ALL_HOSTS: tuple[type[Host], ...] = (ClaudeCodeHost, CodexHost, CursorHost, GeminiHost, WindsurfHost, CopilotHost)
890# Every harness setup supports. ALL_HOSTS keeps its v0.16.0 value (it is part
891# of the public API); hosts added since are appended here.
892SUPPORTED_HOSTS: tuple[type[Host], ...] = (*ALL_HOSTS, AntigravityHost)
895def hosts() -> list[Host]:
896 return [cls() for cls in SUPPORTED_HOSTS]
899def host_by_key(key: str) -> Host:
900 for h in hosts():
901 if h.key == key:
902 return h
903 raise KeyError(f"unknown harness {key!r}; expected one of {[c.key for c in SUPPORTED_HOSTS]}")