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.
- subcortex/__init__.py +3 -0
- subcortex/__main__.py +3 -0
- subcortex/adapters/__init__.py +48 -0
- subcortex/adapters/base.py +230 -0
- subcortex/adapters/claude_family.py +133 -0
- subcortex/adapters/codex.py +87 -0
- subcortex/adapters/copilot.py +60 -0
- subcortex/adapters/cursor.py +36 -0
- subcortex/adapters/docker_agent.py +115 -0
- subcortex/adapters/gemini_family.py +60 -0
- subcortex/adapters/grok.py +98 -0
- subcortex/adapters/kimi_code.py +138 -0
- subcortex/adapters/letta_vibe.py +96 -0
- subcortex/adapters/openhands.py +153 -0
- subcortex/auth.py +59 -0
- subcortex/backends/__init__.py +23 -0
- subcortex/backends/base.py +22 -0
- subcortex/backends/jev.py +460 -0
- subcortex/backends/laya.py +149 -0
- subcortex/cli.py +809 -0
- subcortex/client.py +77 -0
- subcortex/config.py +263 -0
- subcortex/daemon.py +502 -0
- subcortex/evalset.py +241 -0
- subcortex/hook.py +254 -0
- subcortex/installers/__init__.py +62 -0
- subcortex/installers/amp.py +39 -0
- subcortex/installers/base.py +874 -0
- subcortex/installers/claude_family.py +229 -0
- subcortex/installers/codex.py +110 -0
- subcortex/installers/copilot.py +65 -0
- subcortex/installers/crush.py +36 -0
- subcortex/installers/cursor.py +79 -0
- subcortex/installers/gemini_family.py +83 -0
- subcortex/installers/goose.py +186 -0
- subcortex/installers/kimi_code.py +71 -0
- subcortex/installers/mcp_only.py +111 -0
- subcortex/installers/more_hooks.py +184 -0
- subcortex/installers/opencode.py +66 -0
- subcortex/installers/openhands.py +84 -0
- subcortex/installers/pi_cline.py +53 -0
- subcortex/ledger.py +92 -0
- subcortex/localhttp.py +59 -0
- subcortex/mcp_server.py +187 -0
- subcortex/metrics.py +56 -0
- subcortex/plugins/amp/subcortex.ts +258 -0
- subcortex/plugins/cline/subcortex.ts +340 -0
- subcortex/plugins/opencode/subcortex.ts +265 -0
- subcortex/plugins/pi/subcortex.ts +292 -0
- subcortex/policy.py +341 -0
- subcortex/presets.py +163 -0
- subcortex/provision.py +188 -0
- subcortex/service.py +149 -0
- subcortex/state.py +137 -0
- subcortex/transcript.py +211 -0
- subcortex/tuis.py +51 -0
- subcortex/ui.py +319 -0
- subcortex/verdicts.py +233 -0
- subcortex/wizard.py +474 -0
- subcortex-0.3.0.dist-info/METADATA +287 -0
- subcortex-0.3.0.dist-info/RECORD +64 -0
- subcortex-0.3.0.dist-info/WHEEL +5 -0
- subcortex-0.3.0.dist-info/entry_points.txt +3 -0
- 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()}
|