subcortex 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. subcortex/__init__.py +3 -0
  2. subcortex/__main__.py +3 -0
  3. subcortex/adapters/__init__.py +48 -0
  4. subcortex/adapters/base.py +230 -0
  5. subcortex/adapters/claude_family.py +133 -0
  6. subcortex/adapters/codex.py +87 -0
  7. subcortex/adapters/copilot.py +60 -0
  8. subcortex/adapters/cursor.py +36 -0
  9. subcortex/adapters/docker_agent.py +115 -0
  10. subcortex/adapters/gemini_family.py +60 -0
  11. subcortex/adapters/grok.py +98 -0
  12. subcortex/adapters/kimi_code.py +138 -0
  13. subcortex/adapters/letta_vibe.py +96 -0
  14. subcortex/adapters/openhands.py +153 -0
  15. subcortex/auth.py +59 -0
  16. subcortex/backends/__init__.py +23 -0
  17. subcortex/backends/base.py +22 -0
  18. subcortex/backends/jev.py +460 -0
  19. subcortex/backends/laya.py +149 -0
  20. subcortex/cli.py +809 -0
  21. subcortex/client.py +77 -0
  22. subcortex/config.py +263 -0
  23. subcortex/daemon.py +502 -0
  24. subcortex/evalset.py +241 -0
  25. subcortex/hook.py +254 -0
  26. subcortex/installers/__init__.py +62 -0
  27. subcortex/installers/amp.py +39 -0
  28. subcortex/installers/base.py +874 -0
  29. subcortex/installers/claude_family.py +229 -0
  30. subcortex/installers/codex.py +110 -0
  31. subcortex/installers/copilot.py +65 -0
  32. subcortex/installers/crush.py +36 -0
  33. subcortex/installers/cursor.py +79 -0
  34. subcortex/installers/gemini_family.py +83 -0
  35. subcortex/installers/goose.py +186 -0
  36. subcortex/installers/kimi_code.py +71 -0
  37. subcortex/installers/mcp_only.py +111 -0
  38. subcortex/installers/more_hooks.py +184 -0
  39. subcortex/installers/opencode.py +66 -0
  40. subcortex/installers/openhands.py +84 -0
  41. subcortex/installers/pi_cline.py +53 -0
  42. subcortex/ledger.py +92 -0
  43. subcortex/localhttp.py +59 -0
  44. subcortex/mcp_server.py +187 -0
  45. subcortex/metrics.py +56 -0
  46. subcortex/plugins/amp/subcortex.ts +258 -0
  47. subcortex/plugins/cline/subcortex.ts +340 -0
  48. subcortex/plugins/opencode/subcortex.ts +265 -0
  49. subcortex/plugins/pi/subcortex.ts +292 -0
  50. subcortex/policy.py +341 -0
  51. subcortex/presets.py +163 -0
  52. subcortex/provision.py +188 -0
  53. subcortex/service.py +149 -0
  54. subcortex/state.py +137 -0
  55. subcortex/transcript.py +211 -0
  56. subcortex/tuis.py +51 -0
  57. subcortex/ui.py +319 -0
  58. subcortex/verdicts.py +233 -0
  59. subcortex/wizard.py +474 -0
  60. subcortex-0.3.0.dist-info/METADATA +287 -0
  61. subcortex-0.3.0.dist-info/RECORD +64 -0
  62. subcortex-0.3.0.dist-info/WHEEL +5 -0
  63. subcortex-0.3.0.dist-info/entry_points.txt +3 -0
  64. subcortex-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,874 @@
1
+ """Shared installer machinery.
2
+
3
+ An installer knows one TUI's config file and how to merge/remove subcortex's
4
+ entries in it. This module supplies everything else, identically for every TUI:
5
+
6
+ - ``plan`` computes the new file contents without touching disk (``--dry-run``
7
+ prints its diff);
8
+ - ``install`` SELF-TESTS the exact command strings it is about to write before
9
+ writing anything: each is executed through ``/bin/sh`` with a recorded
10
+ payload, once with the daemon unreachable and once against an in-process
11
+ stub daemon, and must exit 0, stay silent on stderr, emit nothing or valid
12
+ JSON (never a blocking response), and finish within budget. Any failure
13
+ aborts the install;
14
+ - writes are atomic, and every pre-existing file is backed up with a
15
+ timestamp first;
16
+ - ``uninstall`` removes exactly subcortex's entries (including those written by
17
+ pre-0.2 installers) and leaves everything else byte-identical where the
18
+ format allows.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import difflib
24
+ import json
25
+ import os
26
+ import shlex
27
+ import secrets
28
+ import shutil
29
+ import socket
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ import threading
34
+ import time
35
+ from dataclasses import dataclass, field
36
+ from pathlib import Path
37
+ from typing import Any, Callable, Dict, List, Optional, Tuple
38
+
39
+ HOOK_SCRIPT = "subcortex-hook"
40
+ # Substrings identifying a subcortex hook command, current and legacy
41
+ # ("subcortex hook <tui> <event>" was written by 0.1.0 installers).
42
+ OUR_COMMAND_MARKERS = ("subcortex-hook", "-m subcortex.hook", "subcortex hook ", "-m subcortex hook ")
43
+ SELF_TEST_TIMEOUT_S = 15.0
44
+
45
+
46
+ # Appended to every hook command. If the subcortex executable disappears
47
+ # (uninstalled package, deleted venv) the shell would exit 127 with a "not
48
+ # found" message on stderr — and some TUIs (Gemini CLI) block the prompt on
49
+ # any exit status other than 0/1. The guard keeps a dangling hook harmless.
50
+ SHELL_GUARD = " 2>/dev/null || true"
51
+
52
+
53
+ def hook_command(tui: str, event: Optional[str] = None) -> str:
54
+ """Absolute, shell-guarded command a TUI should run for ``event``.
55
+
56
+ ``<python> -I -m subcortex.hook``: isolated mode ignores PYTHONPATH,
57
+ PYTHONHOME and friends from the user's shell and never puts the working
58
+ directory (the user's project) on sys.path. Without it, a project with a
59
+ ``json.py`` (or ``PYTHONPATH=.``) broke every hook, and ran project code in
60
+ it. The ``subcortex-hook`` console script has the same exposure.
61
+ """
62
+ argv = [sys.executable, "-I", "-m", "subcortex.hook", tui]
63
+ if event:
64
+ argv.append(event)
65
+ return shlex.join(argv) + SHELL_GUARD
66
+
67
+
68
+ def is_our_command(command: Any) -> bool:
69
+ return isinstance(command, str) and any(m in command for m in OUR_COMMAND_MARKERS)
70
+
71
+
72
+ # -- results ------------------------------------------------------------------------------
73
+
74
+
75
+ @dataclass
76
+ class Plan:
77
+ path: Path
78
+ before: str
79
+ after: str
80
+
81
+ @property
82
+ def changed(self) -> bool:
83
+ return self.before != self.after
84
+
85
+ def diff(self) -> str:
86
+ return "".join(difflib.unified_diff(
87
+ self.before.splitlines(keepends=True), self.after.splitlines(keepends=True),
88
+ fromfile=f"{self.path} (current)", tofile=f"{self.path} (after)"))
89
+
90
+
91
+ @dataclass
92
+ class Result:
93
+ ok: bool
94
+ tui: str
95
+ action: str
96
+ paths: List[str] = field(default_factory=list)
97
+ changed: bool = False
98
+ backups: List[str] = field(default_factory=list)
99
+ messages: List[str] = field(default_factory=list)
100
+
101
+ def as_dict(self) -> Dict[str, Any]:
102
+ return {"ok": self.ok, "tui": self.tui, "action": self.action, "paths": self.paths,
103
+ "changed": self.changed, "backups": self.backups, "messages": self.messages}
104
+
105
+
106
+ class InstallError(Exception):
107
+ pass
108
+
109
+
110
+ # -- file helpers ---------------------------------------------------------------------------
111
+
112
+
113
+ def read_text(path: Path) -> str:
114
+ try:
115
+ return path.read_text(encoding="utf-8")
116
+ except FileNotFoundError:
117
+ return ""
118
+
119
+
120
+ def backup(path: Path) -> Optional[Path]:
121
+ if not path.is_file():
122
+ return None
123
+ stamp = time.strftime("%Y%m%d-%H%M%S")
124
+ dest = path.with_name(f"{path.name}.subcortex-{stamp}.bak")
125
+ n = 1
126
+ while dest.exists():
127
+ dest = path.with_name(f"{path.name}.subcortex-{stamp}-{n}.bak")
128
+ n += 1
129
+ shutil.copy2(path, dest)
130
+ return dest
131
+
132
+
133
+ def atomic_write(path: Path, text: str) -> None:
134
+ """Replace ``path``'s content atomically, keeping its mode. A symlink (a
135
+ dotfiles setup) stays a symlink: the file it points to is what changes."""
136
+ if path.is_symlink():
137
+ path = Path(os.path.realpath(path))
138
+ path.parent.mkdir(parents=True, exist_ok=True)
139
+ mode = path.stat().st_mode & 0o777 if path.exists() else None
140
+ fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
141
+ try:
142
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
143
+ fh.write(text)
144
+ if mode is not None:
145
+ os.chmod(tmp, mode)
146
+ os.replace(tmp, path)
147
+ except BaseException:
148
+ try:
149
+ os.unlink(tmp)
150
+ except OSError:
151
+ pass
152
+ raise
153
+
154
+
155
+ def load_json_object(text: str, path: Path) -> Dict[str, Any]:
156
+ if not text.strip():
157
+ return {}
158
+ try:
159
+ data = json.loads(text)
160
+ except ValueError as exc:
161
+ raise InstallError(
162
+ f"{path} is not plain JSON ({exc}); refusing to rewrite it. "
163
+ "Add the entries shown by --dry-run by hand.") from exc
164
+ if not isinstance(data, dict):
165
+ raise InstallError(f"{path} does not contain a JSON object; refusing to rewrite it.")
166
+ return data
167
+
168
+
169
+ def dump_json(data: Dict[str, Any]) -> str:
170
+ return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
171
+
172
+
173
+ # -- JSON hook-table helpers ------------------------------------------------------------------
174
+ #
175
+ # "Grouped" tables (Claude Code and its imitators):
176
+ # {"hooks": {"Event": [{"matcher": "...", "hooks": [{"type": "command", "command": ...}]}]}}
177
+ # "Flat" tables (Cursor, Copilot, ...):
178
+ # {"hooks": {"event": [{"command": ...}, ...]}}
179
+
180
+
181
+ def _entry_is_ours(entry: Any, command_keys: Tuple[str, ...]) -> bool:
182
+ return isinstance(entry, dict) and any(is_our_command(entry.get(k)) for k in command_keys)
183
+
184
+
185
+ def add_grouped(table: Dict[str, Any], event: str, matcher: Optional[str], entry: Dict[str, Any],
186
+ command_keys: Tuple[str, ...] = ("command",)) -> bool:
187
+ """Add ``entry`` under ``event`` in a group of its own (never inside a
188
+ user's group), unless one of ours is already there."""
189
+ groups = table.get(event)
190
+ if not isinstance(groups, list):
191
+ groups = table[event] = []
192
+ for group in groups:
193
+ if isinstance(group, dict) and any(
194
+ _entry_is_ours(h, command_keys) for h in (group.get("hooks") or [])):
195
+ return False
196
+ group: Dict[str, Any] = {"matcher": matcher} if matcher is not None else {}
197
+ group["hooks"] = [entry]
198
+ groups.append(group)
199
+ return True
200
+
201
+
202
+ def remove_grouped(table: Dict[str, Any], command_keys: Tuple[str, ...] = ("command",)) -> List[str]:
203
+ """Drop our entries from every event; prune groups/events we emptied."""
204
+ removed: List[str] = []
205
+ for event in list(table):
206
+ groups = table.get(event)
207
+ if not isinstance(groups, list):
208
+ continue
209
+ kept_groups = []
210
+ touched = False
211
+ for group in groups:
212
+ if isinstance(group, dict) and isinstance(group.get("hooks"), list):
213
+ kept = [h for h in group["hooks"] if not _entry_is_ours(h, command_keys)]
214
+ if len(kept) != len(group["hooks"]):
215
+ touched = True
216
+ if not kept:
217
+ continue
218
+ group["hooks"] = kept
219
+ kept_groups.append(group)
220
+ if touched:
221
+ removed.append(event)
222
+ if kept_groups:
223
+ table[event] = kept_groups
224
+ else:
225
+ del table[event]
226
+ return removed
227
+
228
+
229
+ def add_flat(table: Dict[str, Any], event: str, entry: Dict[str, Any],
230
+ command_keys: Tuple[str, ...] = ("command",)) -> bool:
231
+ entries = table.get(event)
232
+ if not isinstance(entries, list):
233
+ entries = table[event] = []
234
+ if any(_entry_is_ours(e, command_keys) for e in entries):
235
+ return False
236
+ entries.append(entry)
237
+ return True
238
+
239
+
240
+ def remove_flat(table: Dict[str, Any], command_keys: Tuple[str, ...] = ("command",)) -> List[str]:
241
+ removed: List[str] = []
242
+ for event in list(table):
243
+ entries = table.get(event)
244
+ if not isinstance(entries, list):
245
+ continue
246
+ kept = [e for e in entries if not _entry_is_ours(e, command_keys)]
247
+ if len(kept) != len(entries):
248
+ removed.append(event)
249
+ if kept:
250
+ table[event] = kept
251
+ else:
252
+ del table[event]
253
+ return removed
254
+
255
+
256
+ # -- marked text blocks (TOML/YAML files we can't round-trip with the stdlib) ------------------
257
+
258
+ BLOCK_BEGIN = ">>> subcortex (managed block; remove with: subcortex uninstall {tui})"
259
+ BLOCK_END = "<<< subcortex"
260
+
261
+
262
+ def has_block(text: str, comment: str = "#") -> bool:
263
+ return any(line.strip().startswith(f"{comment} >>> subcortex") for line in text.splitlines())
264
+
265
+
266
+ def strip_block(text: str, comment: str = "#") -> str:
267
+ """``text`` without our marked block(s) and the blank line ``append_block``
268
+ put before each. Every other byte stays as it was. A block whose end marker
269
+ is missing is refused rather than guessed at: removing "to the end of the
270
+ file" could delete the user's own settings."""
271
+ lines = text.splitlines(keepends=True)
272
+ begin = next((i for i, line in enumerate(lines)
273
+ if line.strip().startswith(f"{comment} >>> subcortex")), None)
274
+ if begin is None:
275
+ return text
276
+ end = next((j for j in range(begin + 1, len(lines))
277
+ if lines[j].strip().startswith(f"{comment} {BLOCK_END}")), None)
278
+ if end is None:
279
+ raise InstallError("the subcortex block in this file has no end marker "
280
+ f"({comment} {BLOCK_END}); remove it by hand, then retry")
281
+ before, after = lines[:begin], lines[end + 1:]
282
+ if before and not before[-1].strip():
283
+ before = before[:-1] # the separator append_block added
284
+ return strip_block("".join(before + after), comment)
285
+
286
+
287
+ def append_block(text: str, body: str, tui: str, comment: str = "#") -> str:
288
+ base = strip_block(text, comment)
289
+ block = (f"{comment} {BLOCK_BEGIN.format(tui=tui)}\n" + body.rstrip("\n") + "\n"
290
+ + f"{comment} {BLOCK_END}\n")
291
+ return (base + "\n" if base else "") + block
292
+
293
+
294
+ # -- self-test ----------------------------------------------------------------------------------
295
+
296
+
297
+ class _StubBackend:
298
+ """Deterministic backend for the self-test: every prompt is simple, every
299
+ output disposable, so each hook path produces a response."""
300
+
301
+ name = "jev" # behaves as a calibrated, trimming backend so every hook path responds
302
+
303
+ def predict(self, state: Any, questions: Dict[str, Any]) -> Dict[str, Any]:
304
+ from ..verdicts import canned_answers
305
+
306
+ return canned_answers(questions)
307
+
308
+ def available(self) -> Tuple[bool, str]:
309
+ return True, "stub"
310
+
311
+
312
+ def _free_port() -> int:
313
+ with socket.socket() as s:
314
+ s.bind(("127.0.0.1", 0))
315
+ return s.getsockname()[1]
316
+
317
+
318
+ def _run_command(command: str, payload: Dict[str, Any], env: Dict[str, str]) -> Tuple[int, str, str, float]:
319
+ started = time.perf_counter()
320
+ proc = subprocess.run(["/bin/sh", "-c", command], input=json.dumps(payload),
321
+ capture_output=True, text=True, env=env, timeout=SELF_TEST_TIMEOUT_S)
322
+ return proc.returncode, proc.stdout, proc.stderr, time.perf_counter() - started
323
+
324
+
325
+ SAMPLE_TRANSCRIPT = [
326
+ {"type": "user", "message": {"role": "user", "content": "rename getUser to fetchUser"}},
327
+ {"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "Renamed in 3 files."}]}},
328
+ ]
329
+ BIG_OUTPUT = "compiling module\n" * 1200 # large, successful, no failure markers
330
+
331
+
332
+ def _seed_requests(tui: str, cases: List[Tuple[str, str, Dict[str, Any]]], data: Path) -> None:
333
+ """Record a user request for every sample session, as the prompt hook of a
334
+ real session would have: the output judge needs one to act."""
335
+ from .. import policy
336
+ from ..adapters import get_adapter
337
+
338
+ adapter = get_adapter(tui)
339
+ if adapter is None:
340
+ return
341
+ previous = os.environ.get("SUBCORTEX_DATA_DIR")
342
+ os.environ["SUBCORTEX_DATA_DIR"] = str(data)
343
+ try:
344
+ for event, _, payload in cases:
345
+ resolved = adapter.resolve(event)
346
+ parsed = adapter.parse(*resolved, payload) if resolved else None
347
+ if parsed is not None and parsed.session_id:
348
+ policy.remember_prompt(parsed.session_id, "why does the build take so long?", tui=adapter.name)
349
+ except Exception:
350
+ pass
351
+ finally:
352
+ if previous is None:
353
+ os.environ.pop("SUBCORTEX_DATA_DIR", None)
354
+ else:
355
+ os.environ["SUBCORTEX_DATA_DIR"] = previous
356
+
357
+
358
+ def _fill(value: Any, transcript: str) -> Any:
359
+ """Substitute ``{transcript}`` / ``{big_output}`` placeholders in a sample payload."""
360
+ if isinstance(value, str):
361
+ if value == "{big_output}":
362
+ return BIG_OUTPUT
363
+ return value.replace("{transcript}", transcript)
364
+ if isinstance(value, dict):
365
+ return {k: _fill(v, transcript) for k, v in value.items()}
366
+ if isinstance(value, list):
367
+ return [_fill(v, transcript) for v in value]
368
+ return value
369
+
370
+
371
+ def self_test(tui: str, cases: List[Tuple[str, str, Dict[str, Any]]],
372
+ expect_output: Callable[[str], bool] = lambda event: False) -> List[str]:
373
+ """Run each ``(event, command, payload)`` case through /bin/sh; return failures.
374
+
375
+ Two passes, each with its own empty data dir: the daemon unreachable, then
376
+ an in-process stub daemon. Every run must exit 0, stay silent on stderr,
377
+ finish within budget, and print nothing or a valid, non-blocking response.
378
+ ``expect_output(event)`` marks events that MUST respond in the stub pass
379
+ (so a silently broken pipeline fails too). Cases run in order, so a
380
+ PreCompact case before a SessionStart case exercises snapshot → restore.
381
+ """
382
+ from ..adapters import get_adapter
383
+ from ..adapters.base import HookAdapter
384
+ from ..config import load_config
385
+ from ..daemon import create_server
386
+
387
+ adapter = get_adapter(tui) or HookAdapter()
388
+ problems: List[str] = []
389
+ with tempfile.TemporaryDirectory(prefix="subcortex-selftest-") as tmp:
390
+ # Pristine defaults: the user's own config must not change the verdict.
391
+ config_file = Path(tmp) / "config.json"
392
+ config_file.write_text("{}")
393
+ transcript = Path(tmp) / "transcript.jsonl"
394
+ transcript.write_text("\n".join(json.dumps(e) for e in SAMPLE_TRANSCRIPT) + "\n")
395
+ cfg = load_config(str(config_file))
396
+ budget = float(cfg["hooks"]["budget_s"])
397
+ base_env = {k: v for k, v in os.environ.items()
398
+ if k not in ("PYTHONPATH", "SUBCORTEX_DEBUG") and not k.startswith("SUBCORTEX_")}
399
+ base_env["SUBCORTEX_CONFIG"] = str(config_file)
400
+ base_env["SUBCORTEX_AUTOSTART"] = "0" # the daemon-down pass must not spawn a daemon
401
+
402
+ def run_pass(label: str, port: int, must_respond: bool) -> None:
403
+ env = dict(base_env, SUBCORTEX_PORT=str(port),
404
+ SUBCORTEX_DATA_DIR=str(Path(tmp) / label))
405
+ _seed_requests(tui, cases, Path(tmp) / label)
406
+ for event, command, payload in cases:
407
+ try:
408
+ code, out, err, secs = _run_command(command, _fill(payload, str(transcript)), env)
409
+ except subprocess.TimeoutExpired:
410
+ problems.append(f"{event} ({label}): timed out after {SELF_TEST_TIMEOUT_S:.0f}s")
411
+ continue
412
+ if code != 0:
413
+ problems.append(f"{event} ({label}): exit {code}, must always be 0: {err.strip()[:300]}")
414
+ if err.strip():
415
+ problems.append(f"{event} ({label}): wrote to stderr: {err.strip()[:300]}")
416
+ if secs > budget + 2.0:
417
+ problems.append(f"{event} ({label}): took {secs:.1f}s (budget {budget:.1f}s)")
418
+ text = out.strip()
419
+ if not text:
420
+ if must_respond and expect_output(event):
421
+ problems.append(f"{event} ({label}): no output; the hook pipeline is broken")
422
+ continue
423
+ try:
424
+ parsed: Any = json.loads(text)
425
+ except ValueError:
426
+ parsed = text # some TUIs take plain-text stdout
427
+ resolved = adapter.resolve(event)
428
+ if adapter.guard(resolved[1] if resolved else "unknown", parsed) is None:
429
+ problems.append(f"{event} ({label}): output contains a blocking field: {text[:300]}")
430
+
431
+ run_pass("daemon-down", _free_port(), must_respond=False)
432
+
433
+ # The executable vanished (package uninstalled, venv deleted): the
434
+ # guarded command must still exit 0 silently.
435
+ env = dict(base_env, SUBCORTEX_DATA_DIR=str(Path(tmp) / "dangling"))
436
+ for event, command, payload in cases[:1]:
437
+ head = shlex.split(command.replace(SHELL_GUARD, ""))[0]
438
+ dangling = command.replace(shlex.quote(head), shlex.quote(str(Path(tmp) / "missing" / "subcortex-hook")), 1)
439
+ try:
440
+ code, out, err, _ = _run_command(dangling, _fill(payload, str(transcript)), env)
441
+ except subprocess.TimeoutExpired:
442
+ problems.append("dangling executable: timed out")
443
+ continue
444
+ if code != 0 or err.strip() or out.strip():
445
+ problems.append(f"dangling executable: exit {code}, stderr {err.strip()[:200]!r}, "
446
+ f"stdout {out.strip()[:200]!r} (must be 0 / silent)")
447
+ token = secrets.token_hex(32)
448
+ stub_dir = Path(tmp) / "stub-daemon"
449
+ stub_dir.mkdir(mode=0o700)
450
+ (stub_dir / "token").write_text(token) # what the hooks of this pass will read
451
+ server = create_server(0, cfg, backend_factory=lambda c, name=None: _StubBackend(), token=token)
452
+ threading.Thread(target=server.serve_forever, daemon=True).start()
453
+ try:
454
+ run_pass("stub-daemon", server.server_address[1], must_respond=True)
455
+ finally:
456
+ server.shutdown()
457
+ server.server_close()
458
+ return problems
459
+
460
+
461
+ # -- installer base class -------------------------------------------------------------------
462
+
463
+
464
+ @dataclass
465
+ class Target:
466
+ """One config file an installer edits."""
467
+
468
+ path: Path
469
+ merge: Callable[[str], str] # contents with our entries added (idempotent)
470
+ unmerge: Callable[[str], str] # contents with exactly our entries removed
471
+ is_installed: Callable[[str], bool]
472
+
473
+
474
+ def json_target(path: Path, add: Callable[[Dict[str, Any]], None],
475
+ remove: Callable[[Dict[str, Any]], None],
476
+ installed: Callable[[Dict[str, Any]], bool]) -> Target:
477
+ """Target for a plain-JSON config file edited through dict callbacks.
478
+
479
+ ``add`` must be idempotent (typically: remove ours, then add fresh);
480
+ ``remove`` must leave the file otherwise untouched. Files that are not
481
+ plain JSON (comments, trailing commas) are refused, never rewritten.
482
+ """
483
+
484
+ def merge(text: str) -> str:
485
+ data = load_json_object(text, path)
486
+ remove(data)
487
+ add(data)
488
+ return dump_json(data)
489
+
490
+ def unmerge(text: str) -> str:
491
+ if not text.strip():
492
+ return text
493
+ data = load_json_object(text, path)
494
+ before = json.dumps(data, sort_keys=True)
495
+ remove(data)
496
+ if json.dumps(data, sort_keys=True) == before:
497
+ return text # nothing of ours: leave the bytes alone
498
+ return dump_json(data) if data else ""
499
+
500
+ def is_installed(text: str) -> bool:
501
+ try:
502
+ return installed(load_json_object(text, path))
503
+ except InstallError:
504
+ return False
505
+
506
+ return Target(path, merge, unmerge, is_installed)
507
+
508
+
509
+ def mcp_json_target(path: Path, argv: List[str], key: str = "mcpServers",
510
+ extra: Optional[Dict[str, Any]] = None) -> Target:
511
+ """``{key: {"subcortex": {"command": ..., "args": [...]}}}`` in a JSON file."""
512
+ entry = {"command": argv[0], "args": argv[1:], **(extra or {})}
513
+
514
+ def add(data: Dict[str, Any]) -> None:
515
+ servers = data.get(key)
516
+ if not isinstance(servers, dict):
517
+ servers = data[key] = {}
518
+ servers["subcortex"] = entry
519
+
520
+ def remove(data: Dict[str, Any]) -> None:
521
+ servers = data.get(key)
522
+ if isinstance(servers, dict) and "subcortex" in servers:
523
+ del servers["subcortex"]
524
+ if not servers:
525
+ del data[key]
526
+
527
+ def installed(data: Dict[str, Any]) -> bool:
528
+ return isinstance(data.get(key), dict) and "subcortex" in data[key]
529
+
530
+ return json_target(path, add, remove, installed)
531
+
532
+
533
+ def toml_str(value: str) -> str:
534
+ """TOML basic string."""
535
+ return json.dumps(value, ensure_ascii=False) # JSON string escapes are valid TOML
536
+
537
+
538
+ def toml_block_target(path: Path, body: str, tui: str) -> Target:
539
+ """Our TOML appended as a marked block; the merged file must parse."""
540
+ import tomllib
541
+
542
+ def check(text: str) -> str:
543
+ try:
544
+ tomllib.loads(text)
545
+ except tomllib.TOMLDecodeError as exc:
546
+ raise InstallError(f"{path}: result would not be valid TOML ({exc}); refusing to write") from exc
547
+ return text
548
+
549
+ def merge(text: str) -> str:
550
+ if text.strip():
551
+ try:
552
+ tomllib.loads(text)
553
+ except tomllib.TOMLDecodeError as exc:
554
+ raise InstallError(f"{path} is not valid TOML ({exc}); fix it first") from exc
555
+ return check(append_block(text, body, tui))
556
+
557
+ def unmerge(text: str) -> str:
558
+ return check(strip_block(text)) if has_block(text) else text
559
+
560
+ return Target(path, merge, unmerge, has_block)
561
+
562
+
563
+ PLUGIN_MARKER = "subcortex plugin for"
564
+
565
+
566
+ def bundled_plugin(tui: str, filename: str) -> str:
567
+ """Text of a plugin shipped as package data under ``subcortex/plugins/<tui>/``."""
568
+ path = Path(__file__).resolve().parents[1] / "plugins" / tui / filename
569
+ try:
570
+ return path.read_text(encoding="utf-8")
571
+ except OSError as exc:
572
+ raise InstallError(f"bundled plugin {path} is missing from this installation") from exc
573
+
574
+
575
+ def rendered_plugin(tui: str, filename: str = "subcortex.ts") -> str:
576
+ """A bundled plugin with this machine's daemon URL and token file filled in."""
577
+ from ..auth import token_path
578
+
579
+ return (bundled_plugin(tui, filename).replace("__SUBCORTEX_URL__", daemon_url())
580
+ .replace("__SUBCORTEX_TOKEN_FILE__", str(token_path())))
581
+
582
+
583
+ def daemon_url() -> str:
584
+ from ..config import load_config
585
+
586
+ return f"http://127.0.0.1:{int(load_config()['port'])}"
587
+
588
+
589
+ def owned_file_target(dest: Path, content: str) -> Target:
590
+ """A config file subcortex generates and owns entirely (hooks drop-ins).
591
+ A same-named file that doesn't reference subcortex is never overwritten."""
592
+
593
+ def ours(text: str) -> bool:
594
+ return any(marker in text for marker in ("subcortex-hook", "subcortex.hook", '"_subcortex"'))
595
+
596
+ def merge(text: str) -> str:
597
+ if text.strip() and not ours(text):
598
+ raise InstallError(f"{dest} exists and was not written by subcortex; refusing to overwrite it")
599
+ return content
600
+
601
+ return Target(dest, merge, lambda t: "" if ours(t) else t, ours)
602
+
603
+
604
+ def plugin_file_target(dest: Path, content: str) -> Target:
605
+ """A plugin file we own outright. A same-named file without our marker is
606
+ someone else's and is never overwritten or deleted."""
607
+
608
+ def ours(text: str) -> bool:
609
+ return PLUGIN_MARKER in text[:400]
610
+
611
+ def merge(text: str) -> str:
612
+ if text.strip() and not ours(text):
613
+ raise InstallError(f"{dest} exists and is not a subcortex plugin; refusing to overwrite it")
614
+ return content
615
+
616
+ def unmerge(text: str) -> str:
617
+ return "" if ours(text) else text
618
+
619
+ return Target(dest, merge, unmerge, ours)
620
+
621
+
622
+ def installed_version(binary: str) -> Optional[str]:
623
+ """``"<package> <version>"`` (or just the version) of an installed TUI, read
624
+ from how it was installed — never by running it: some TUIs' ``--version``
625
+ refreshes logins, sends telemetry or starts a self-update."""
626
+ import re
627
+
628
+ try:
629
+ real = Path(os.path.realpath(binary))
630
+ except (OSError, ValueError):
631
+ return None
632
+ # npm / bun: the package.json of the package that ships the binary.
633
+ for parent in list(real.parents)[:6]:
634
+ manifest = parent / "package.json"
635
+ if manifest.is_file():
636
+ try:
637
+ data = json.loads(manifest.read_text(encoding="utf-8"))
638
+ except (OSError, ValueError):
639
+ break
640
+ if isinstance(data, dict) and isinstance(data.get("version"), str):
641
+ return f"{data.get('name') or ''} {data['version']}".strip()
642
+ break
643
+ # A Python tool's venv (uv tool, pipx, a venv): the dist-info declaring this script.
644
+ if real.parent.name == "bin" or Path(binary).parent.name == "bin":
645
+ for bin_dir in {real.parent, Path(binary).parent}:
646
+ for info in bin_dir.parent.glob("lib/python*/site-packages/*.dist-info"):
647
+ try:
648
+ entry_points = (info / "entry_points.txt").read_text(encoding="utf-8")
649
+ except OSError:
650
+ continue
651
+ if re.search(rf"^\s*{re.escape(Path(binary).name)}\s*=", entry_points, re.M):
652
+ name, _, version = info.name[:-len(".dist-info")].rpartition("-")
653
+ return f"{name} {version}"
654
+ # A version-named file or directory: Homebrew's Cellar/<name>/<v>/, .../versions/<v>.
655
+ version_part = re.compile(r"v?(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)")
656
+ for part in (real.name, *reversed(real.parent.parts)):
657
+ match = version_part.fullmatch(part)
658
+ if match:
659
+ return match.group(1)
660
+ return None
661
+
662
+
663
+ def parse_version(text: str) -> Optional[Tuple[int, ...]]:
664
+ import re
665
+
666
+ match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text or "")
667
+ if not match:
668
+ return None
669
+ return tuple(int(g) for g in match.groups(default="0"))
670
+
671
+
672
+ class Installer:
673
+ """One TUI's integration. Subclasses implement ``targets``; hook-based ones
674
+ also ``hook_events``/``sample_payload``/``expects_output``."""
675
+
676
+ name: str = ""
677
+ display_name: str = ""
678
+ seam: str = "hooks" # "hooks" | "plugin" | "mcp"
679
+ binaries: Tuple[str, ...] = ()
680
+ docs: str = ""
681
+ post_install: str = "" # printed after a successful install
682
+ min_version: Optional[str] = None
683
+ supports_mcp: bool = False # can also register the `subcortex mcp` server
684
+
685
+ def __init__(self, mcp: bool = False) -> None:
686
+ self.mcp = mcp and self.supports_mcp
687
+
688
+ # -- to implement ---------------------------------------------------------------------
689
+
690
+ def targets(self) -> List[Target]:
691
+ raise NotImplementedError
692
+
693
+ def hook_events(self) -> List[str]:
694
+ """TUI event names we register (for the self-test). Empty for non-hook seams."""
695
+ return []
696
+
697
+ def sample_payload(self, event: str) -> Dict[str, Any]:
698
+ """A realistic stdin payload for ``event`` (self-test input)."""
699
+ return {}
700
+
701
+ def expects_output(self, event: str) -> bool:
702
+ """Must the stub-daemon self-test run of ``event`` print a response?"""
703
+ return False
704
+
705
+ def warnings(self) -> List[str]:
706
+ """Situational caveats printed with the install result."""
707
+ return []
708
+
709
+ def version_problem(self, version_output: str) -> Optional[str]:
710
+ """Reason the detected binary can't be used, else None (override for quirks)."""
711
+ if not self.min_version:
712
+ return None
713
+ found = parse_version(version_output)
714
+ if found is None:
715
+ return None # unknown format: don't block, the self-test still guards us
716
+ if found < parse_version(self.min_version):
717
+ return (f"{self.display_name} {'.'.join(map(str, found))} is older than "
718
+ f"{self.min_version}, the first version with the hooks subcortex needs; upgrade it first")
719
+ return None
720
+
721
+ # -- shared behavior ------------------------------------------------------------------
722
+
723
+ def command(self, event: Optional[str] = None) -> str:
724
+ return hook_command(self.name, event)
725
+
726
+ def mcp_command(self) -> List[str]:
727
+ """argv for the stdio MCP server: absolute and isolated like hook commands
728
+ (a TUI starts it from the user's project, with the user's PYTHONPATH)."""
729
+ return [sys.executable, "-I", "-m", "subcortex", "mcp"]
730
+
731
+ def detected(self) -> Optional[str]:
732
+ for binary in self.binaries:
733
+ found = shutil.which(binary)
734
+ if found:
735
+ return found
736
+ return None
737
+
738
+ def check_version(self) -> Tuple[Optional[str], Optional[str]]:
739
+ """(blocking problem, warning) from the installed version — read from
740
+ the installation, never by executing the TUI (see ``installed_version``)."""
741
+ binary = self.detected()
742
+ if binary is None:
743
+ return None, (f"{self.display_name} not found on PATH; writing its config anyway "
744
+ "so it is ready when you install it")
745
+ found = installed_version(binary)
746
+ if found is None:
747
+ return None, (f"could not tell the {self.display_name} version without running it; "
748
+ f"subcortex needs {self.min_version} or newer" if self.min_version else None)
749
+ return self.version_problem(found), None
750
+
751
+ def plans(self, uninstall: bool = False) -> List[Plan]:
752
+ """One plan per file. Targets sharing a file (hooks + MCP entry in one
753
+ settings.json) are applied in sequence, each on the previous result."""
754
+ originals: Dict[Path, str] = {}
755
+ current: Dict[Path, str] = {}
756
+ for target in self.targets():
757
+ if target.path not in originals:
758
+ originals[target.path] = current[target.path] = read_text(target.path)
759
+ text = current[target.path]
760
+ current[target.path] = target.unmerge(text) if uninstall else target.merge(text)
761
+ return [Plan(path, originals[path], current[path]) for path in originals]
762
+
763
+ def self_test(self) -> List[str]:
764
+ cases = [(e, self.command(e), self.sample_payload(e)) for e in self.hook_events()]
765
+ if not cases:
766
+ return []
767
+ return self_test(self.name, cases, self.expects_output)
768
+
769
+ def _apply(self, result: Result, plans: List[Plan], uninstall: bool = False) -> None:
770
+ # Planning ran seconds ago (the self-test is slow). If the TUI wrote one
771
+ # of these files meanwhile, merge into its latest text instead of
772
+ # overwriting its change with our stale copy.
773
+ if any(p.changed and read_text(p.path) != p.before for p in plans):
774
+ plans = self.plans(uninstall=uninstall)
775
+ for plan in plans:
776
+ if not plan.changed:
777
+ continue
778
+ saved = backup(plan.path)
779
+ if saved:
780
+ result.backups.append(str(saved))
781
+ if plan.after.strip():
782
+ atomic_write(plan.path, plan.after)
783
+ elif plan.path.is_symlink():
784
+ atomic_write(plan.path, "{}\n" if plan.path.suffix == ".json" else "") # keep the link
785
+ elif plan.path.exists():
786
+ plan.path.unlink() # the file only ever held our entries
787
+ result.changed = True
788
+
789
+ def install(self, dry_run: bool = False, run_self_test: bool = True,
790
+ check_version: bool = True) -> Result:
791
+ result = Result(ok=False, tui=self.name, action="install")
792
+ try:
793
+ plans = self.plans()
794
+ except InstallError as exc:
795
+ result.messages.append(str(exc))
796
+ return result
797
+ result.paths = [str(p.path) for p in plans]
798
+ if check_version:
799
+ problem, warning = self.check_version()
800
+ if problem:
801
+ result.messages.append(problem)
802
+ return result
803
+ if warning:
804
+ result.messages.append(warning)
805
+ if run_self_test:
806
+ problems = self.self_test()
807
+ if problems:
808
+ result.messages.append("self-test failed; nothing was written:")
809
+ result.messages.extend(f" - {p}" for p in problems)
810
+ return result
811
+ result.messages.extend(self.warnings())
812
+ if not any(p.changed for p in plans):
813
+ result.ok = True
814
+ result.messages.append("already installed; nothing to change")
815
+ return result
816
+ if not dry_run:
817
+ self._apply(result, plans)
818
+ if self.post_install:
819
+ result.messages.append(self.post_install)
820
+ result.ok = True
821
+ return result
822
+
823
+ def uninstall(self, dry_run: bool = False) -> Result:
824
+ result = Result(ok=False, tui=self.name, action="uninstall")
825
+ try:
826
+ plans = self.plans(uninstall=True)
827
+ except InstallError as exc:
828
+ result.messages.append(str(exc))
829
+ return result
830
+ result.paths = [str(p.path) for p in plans]
831
+ if not any(p.changed for p in plans):
832
+ result.messages.append("nothing of ours to remove")
833
+ elif not dry_run:
834
+ self._apply(result, plans, uninstall=True)
835
+ result.ok = True
836
+ return result
837
+
838
+ def installed_executables(self) -> List[str]:
839
+ """Executables referenced by the subcortex commands currently in this TUI's config."""
840
+ import re
841
+
842
+ patterns = (
843
+ # <python> [-I] -m subcortex.hook ... (current) / -m subcortex hook (0.1)
844
+ r"""['"]?(/[^'"\s]+)['"]?\s+(?:-I\s+)?-m\s+subcortex(?:\.hook|\s+hook)\b""",
845
+ # MCP entries: <python> + args [..., "subcortex", "mcp"] — JSON (within one
846
+ # object), TOML (command = / args = lines), Goose YAML (cmd: / args:).
847
+ r'"command"\s*:\s*"(/[^"]+)"(?=[^{}]*"subcortex")',
848
+ r'command\s*=\s*"(/[^"]+)"\s*\n\s*args\s*=\s*\[[^\]]*"subcortex"',
849
+ r'cmd:\s*"?(/[^"\n]+?)"?\s*\n\s*args:\s*\[[^\]]*subcortex',
850
+ # the console scripts: subcortex-hook (0.2), subcortex mcp
851
+ r"""['"]?(/[^'"\s]*subcortex(?:-hook)?)['"]?\s""",
852
+ )
853
+ found: List[str] = []
854
+ for target in self.targets():
855
+ text = read_text(target.path)
856
+ for pattern in patterns:
857
+ for match in re.finditer(pattern, text):
858
+ if match.group(1) not in found:
859
+ found.append(match.group(1))
860
+ return found
861
+
862
+ def status(self) -> Dict[str, Any]:
863
+ installed = []
864
+ paths = []
865
+ for target in self.targets():
866
+ paths.append(str(target.path))
867
+ try:
868
+ installed.append(target.is_installed(read_text(target.path)))
869
+ except Exception:
870
+ installed.append(False)
871
+ return {"tui": self.name, "name": self.display_name, "seam": self.seam,
872
+ "config": paths, "installed": bool(installed) and installed[0],
873
+ "mcp_installed": len(installed) > 1 and installed[1],
874
+ "detected": self.detected()}