tycho-cli 0.0.1__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.
tycho/init.py ADDED
@@ -0,0 +1,1018 @@
1
+ """`tycho init` / `tycho uninstall` — manage the completion hook in each harness's config.
2
+
3
+ Everything written here is **repo-local**: only `<repo>/.claude`, `.cursor`, `.codex`,
4
+ and `.opencode` are ever touched. A harness's home directory under `$HOME` is *read*
5
+ (never written) as a detection signal — it tells us the harness exists on this machine
6
+ and is therefore worth hooking up in this repo.
7
+
8
+ Idempotent both ways: re-running init never duplicates the entry and never clobbers
9
+ hooks the user already configured; uninstall removes only Tycho-owned entries and is
10
+ a no-op once they're gone. The config shapes differ per harness, so each gets its own
11
+ installer/uninstaller; the load/merge/write skeleton is shared.
12
+
13
+ Install and uninstall share `_is_tycho_hook` + `_strip_claude_tycho` on purpose — the
14
+ rule for "which entry is ours" must never drift between the two, or uninstall would
15
+ orphan exactly the entries install replaces.
16
+
17
+ This runs against a real developer's real settings, so every mutation is defensive:
18
+ refuse what we can't parse instead of "recovering" it to `{}` and writing that back,
19
+ back up before touching, write atomically so an interrupted run can't truncate the
20
+ original, and skip the write entirely when the file is already correct.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ import shutil
29
+ import sys
30
+ import shlex
31
+ from pathlib import Path
32
+
33
+ from . import config as config_mod
34
+ from . import harness as harness_mod
35
+ from . import opencode as opencode_mod
36
+ from . import state
37
+
38
+
39
+ # The header install stamps into the OpenCode plugin, and the proof uninstall looks
40
+ # for before deleting it. One constant so the two can't drift apart.
41
+ _PLUGIN_MARKER = "// Generated by tycho init."
42
+
43
+ # Stamped into the `/tycho` slash-command file; the proof uninstall looks for before
44
+ # deleting it, so a hand-written tycho.md is never removed. Kept as an HTML comment so it
45
+ # renders invisibly if the file is ever previewed as Markdown.
46
+ _SLASH_MARKER = "<!-- Generated by tycho init. -->"
47
+
48
+ # Marker for "we declined to touch this file". Built into the status line from the same
49
+ # constant the CLI matches on, so the exit code can't drift from the message.
50
+ REFUSED = ": refused — "
51
+
52
+ _BACKUP_SUFFIX = ".tycho.bak"
53
+ _TMP_SUFFIX = ".tycho-tmp"
54
+
55
+ HARNESSES = ("claude", "cursor", "codex", "opencode")
56
+
57
+ # Where each harness keeps its per-repo config. A harness creates this on first use in
58
+ # a repo, which makes its presence a detection signal in its own right.
59
+ _LOCAL_DIR = {"claude": ".claude", "cursor": ".cursor", "codex": ".codex", "opencode": ".opencode"}
60
+
61
+ # statusLine poll cadence, so the badge settles on the verdict shortly after a turn rather
62
+ # than lagging to the next prompt (TYCHO-59). Claude Code's `refreshInterval` is milliseconds
63
+ # (contract in docs/harness-support.md); 1s is responsive and the read it triggers is cheap
64
+ # (one small JSON file, no engine). A user who sets their own value keeps it.
65
+ _STATUS_REFRESH_MS = 1000
66
+
67
+
68
+ class ConfigRefused(Exception):
69
+ """This file must not be written — malformed, unreadable, or read-only.
70
+
71
+ The message always carries the fix, not just the complaint: whoever sees it is
72
+ mid-install and needs to know what to do about it.
73
+ """
74
+
75
+
76
+ def _quote_program(path: str) -> str:
77
+ """Wrap a program path in double quotes if it contains a space.
78
+
79
+ Absolute paths on Windows routinely hold spaces (``C:\\Users\\...\\Swail Labs\\``);
80
+ an unquoted one splits into two argv tokens under both cmd and /bin/sh, so the
81
+ harness runs ``...\\Swail`` and fails. Double quotes work in both shells.
82
+ """
83
+ return f'"{path}"' if " " in path and not path.startswith('"') else path
84
+
85
+
86
+ def hook_command() -> str:
87
+ """A command the harness's plain `/bin/sh` can actually run.
88
+
89
+ The Stop hook fires without an activated venv, so a bare ``tycho`` only works
90
+ when it's on the global PATH (a pipx/global install). Otherwise resolve an
91
+ absolute path: the installed console script if we can find one, else the
92
+ current interpreter with ``-m``. Absolute paths run regardless of PATH.
93
+ """
94
+ return _command_for("hook")
95
+
96
+
97
+ def status_command() -> str:
98
+ """Same resolution as `hook_command`, for the status bar (TYCHO-39)."""
99
+ return _command_for("status")
100
+
101
+
102
+ def _command_for(subcommand: str) -> str:
103
+ # Claude Code runs hook/statusLine commands through Git Bash, which consumes unquoted
104
+ # backslashes as escapes — so a native Windows path silently fails to resolve and the
105
+ # hook/status just never fires. Emit forward slashes, per Claude Code's own shipped
106
+ # guidance. A blunt separator swap (not Path.as_posix, which is host-flavored) so it's
107
+ # correct for a Windows path on any host — sys.executable / the console script never
108
+ # carry a literal backslash on POSIX (TYCHO-43).
109
+ found = shutil.which("tycho")
110
+ program = _quote_program((found or sys.executable).replace("\\", "/"))
111
+ return f"{program} {subcommand}" if found else f"{program} -m tycho.cli {subcommand}"
112
+
113
+
114
+ def hook_argv(command: str) -> list[str]:
115
+ """Split a hook command into argv, keeping Windows paths intact.
116
+
117
+ ``posix=False`` so a ``C:\\...`` path keeps its backslashes (POSIX mode treats ``\\``
118
+ as an escape and mangles it), then strip the quotes it leaves on a token. An
119
+ unbalanced quote yields ``[]`` ("nothing runnable"). Behaves like the old POSIX split
120
+ on the quote- and backslash-free commands *nix produces.
121
+ """
122
+ try:
123
+ argv = shlex.split(command, posix=False)
124
+ except ValueError:
125
+ return []
126
+ return [a.strip('"') for a in argv]
127
+
128
+
129
+ def _is_ours(command: object, subcommand: str) -> bool:
130
+ """True if an existing entry is one of ours (any resolved form).
131
+
132
+ Install and uninstall both route through this, so "which entry is ours" can't drift
133
+ between them — uninstall must remove exactly what install would replace. Tolerant of
134
+ the platform executable suffix (on Windows the console script is ``tycho.exe``, so the
135
+ command ends ``tycho.EXE hook`` not ``tycho hook``), surrounding quotes, and both path
136
+ separators, so the answer is identical on every OS (matching, not resolution: no
137
+ filesystem access).
138
+ """
139
+ if not isinstance(command, str) or not command.endswith(f" {subcommand}"):
140
+ return False
141
+ program = command[: -len(f" {subcommand}")].rstrip()
142
+ if program.endswith("tycho.cli"): # `<python> -m tycho.cli <subcommand>`
143
+ return True
144
+ base = re.split(r"[\\/]", program.strip('"'))[-1] # basename, either separator
145
+ return base.lower() in ("tycho", "tycho.exe")
146
+
147
+
148
+ def _is_tycho_hook(command: object) -> bool:
149
+ return _is_ours(command, "hook")
150
+
151
+
152
+ def _is_tycho_status(command: object) -> bool:
153
+ return _is_ours(command, "status")
154
+
155
+
156
+ def _is_tycho_session_start(command: object) -> bool:
157
+ return _is_ours(command, "session-start")
158
+
159
+
160
+ def _is_tycho_prompt_submit(command: object) -> bool:
161
+ return _is_ours(command, "prompt-submit")
162
+
163
+
164
+ def _is_tycho_owned(command: object) -> bool:
165
+ """Any hook command Tycho installs into a matcher-group — Stop (`hook`), bootup
166
+ (`session-start`), or run-start (`prompt-submit`). All live in `hooks.*` groups, so one
167
+ predicate strips any of them."""
168
+ return (
169
+ _is_tycho_hook(command)
170
+ or _is_tycho_session_start(command)
171
+ or _is_tycho_prompt_submit(command)
172
+ )
173
+
174
+
175
+ def config_path(repo: Path, name: str) -> Path:
176
+ """Where `name`'s repo-local config lives. One map, so init and doctor can't disagree.
177
+
178
+ Resolved from the same root as `.tycho/` (TYCHO-79): a harness config is repo-local in
179
+ exactly the way our state is, and if the two disagree, `doctor` run from a subdirectory
180
+ finds the root's install record, looks for the harness config beside *itself*, and cries
181
+ HOOK BROKEN over a hook that is wired fine.
182
+ """
183
+ return state.root_for(repo) / {
184
+ "claude": ".claude/settings.json",
185
+ "cursor": ".cursor/hooks.json",
186
+ "codex": ".codex/hooks.json",
187
+ "opencode": ".opencode/plugins/tycho.js",
188
+ }[name]
189
+
190
+
191
+ def installed_command(repo: Path, name: str) -> str | None:
192
+ """The tycho hook command `name` would actually run, read back from its own config.
193
+
194
+ The config is the truth — not what we *meant* to install. `doctor` diagnoses what the
195
+ harness will really execute, which is the only thing that decides whether Tycho fires.
196
+ Raises ConfigRefused if the config can't be read (same rule as everywhere else).
197
+ """
198
+ path = config_path(repo, name)
199
+ if name == "opencode":
200
+ text = _current_text(path)
201
+ if text is None or _PLUGIN_MARKER not in text:
202
+ return None
203
+ # The plugin holds its argv as a JSON array: `const command = ["...", "hook"]`.
204
+ match = re.search(r"^const command = (\[.*\])$", text, re.MULTILINE)
205
+ if not match:
206
+ return None
207
+ try:
208
+ return shlex.join(json.loads(match.group(1)))
209
+ except (json.JSONDecodeError, TypeError):
210
+ return None
211
+ data = _load(path)
212
+ hooks = data.get("hooks") if isinstance(data.get("hooks"), dict) else {}
213
+ if name == "cursor":
214
+ entries = hooks.get("stop") or []
215
+ else:
216
+ entries = [h for g in (hooks.get("Stop") or []) if isinstance(g, dict) for h in (g.get("hooks") or [])]
217
+ for entry in entries:
218
+ if isinstance(entry, dict) and _is_tycho_hook(entry.get("command")):
219
+ return entry["command"]
220
+ return None
221
+
222
+
223
+ # --- detection ---------------------------------------------------------------
224
+ #
225
+ # "Is this harness worth hooking up here?" Two cheap directory probes, either sufficient:
226
+ # the harness has run in this repo (dotdir exists) or is installed for the user (home
227
+ # exists) — never executed. A false negative just means the user passes `--harness`.
228
+
229
+
230
+ def detect(repo: Path) -> list[str]:
231
+ """Supported harnesses that appear to be present, in stable order."""
232
+ return [name for name in HARNESSES if _is_present(name, repo)]
233
+
234
+
235
+ def _is_present(name: str, repo: Path) -> bool:
236
+ if (repo / _LOCAL_DIR[name]).is_dir():
237
+ return True
238
+ if name == "opencode":
239
+ # OpenCode's data lives in an XDG data dir, not a ~/.<name> dotdir.
240
+ return opencode_mod.db_path().parent.is_dir()
241
+ return harness_mod.home(name).is_dir()
242
+
243
+
244
+ def _ask(name: str) -> bool:
245
+ """Prompt before touching one harness. Default yes; anything but n/no is yes."""
246
+ try:
247
+ reply = input(f"tycho: install the {name} completion hook in this repo? [Y/n] ")
248
+ except EOFError:
249
+ return False
250
+ return reply.strip().lower() not in ("n", "no")
251
+
252
+
253
+ def init(
254
+ repo: Path,
255
+ only: str | None = None,
256
+ assume_yes: bool = False,
257
+ confirm=None,
258
+ relay_confirm=None,
259
+ ) -> list[str]:
260
+ """Install the repo-local Stop hook per harness; return status lines.
261
+
262
+ Touches only harnesses that are actually present, and asks before each one. With
263
+ ``only`` set (a harness name), install just that harness and skip detection — the
264
+ user named it, so its absence is not our call to second-guess. ``assume_yes`` skips
265
+ the prompts for scripted/CI installs; ``confirm`` overrides the prompt (tests).
266
+ """
267
+ installers = {
268
+ "claude": _install_claude,
269
+ "cursor": _install_cursor,
270
+ "codex": _install_codex,
271
+ "opencode": _install_opencode,
272
+ }
273
+ if only:
274
+ names = [only]
275
+ else:
276
+ names = detect(repo)
277
+ if not names:
278
+ return ["no supported harness detected here — pass --harness <name> to install anyway"]
279
+ ask = confirm or _ask
280
+ if not assume_yes and confirm is None and not sys.stdin.isatty():
281
+ # Nobody's there to answer, and installing unasked is exactly what this ticket
282
+ # exists to stop. Say how to proceed rather than silently doing nothing.
283
+ return [f"tycho init needs a terminal to confirm ({', '.join(names)}) — pass --yes to install non-interactively"]
284
+
285
+ lines = []
286
+ installed = []
287
+ for name in names:
288
+ if not assume_yes and not ask(name):
289
+ lines.append(f"{name}: skipped")
290
+ continue
291
+ try:
292
+ lines.append(installers[name](repo))
293
+ installed.append(name)
294
+ except ConfigRefused as exc:
295
+ lines.append(f"{name}{REFUSED}{exc}")
296
+ installed_any = bool(installed)
297
+ # Did the user bring their own .tycho.toml? Capture it *before* `ensure` creates one, so the
298
+ # relay setup below can tell "user already configured this" from "we just seeded it" (TYCHO-114).
299
+ config_existed = config_mod.path(repo).is_file()
300
+ # Seed a .tycho.toml so scope is discoverable and editable (TYCHO-55) — but only once a
301
+ # harness actually got wired: no install (declined/refused) means don't leave config
302
+ # behind. Harness-agnostic, empty (behaviour unchanged until a glob is added), never
303
+ # clobbers an existing one (TYCHO-6). Appended last so a harness line stays lines[0].
304
+ if installed_any and config_mod.ensure(repo):
305
+ lines.append(
306
+ f"created {config_mod.CONFIG_NAME} — no scope set yet, so nothing changes until you "
307
+ f"run `tycho scope add '<glob>'`"
308
+ )
309
+ lines += _offer_relay(repo, "claude" in installed, assume_yes, config_existed, relay_confirm)
310
+ return lines
311
+
312
+
313
+ def _ask_relay() -> bool:
314
+ """Prompt (default NO) to enable the verdict relay — it feeds Tycho's verdict into the
315
+ agent and spends extra tokens, so it must be *explicitly* opted into (TYCHO-35). Only
316
+ 'y'/'yes' enables; a bare Enter or EOF leaves it off."""
317
+ try:
318
+ reply = input(
319
+ "tycho: let the agent see its own verdict and keep working until VERIFIED? This feeds "
320
+ "Tycho's verdict back into the agent (extra tokens; the loop is bounded). [y/N] "
321
+ )
322
+ except EOFError:
323
+ return False
324
+ return reply.strip().lower() in ("y", "yes")
325
+
326
+
327
+ def _offer_relay(
328
+ repo: Path, claude_installed: bool, assume_yes: bool, config_existed: bool, relay_confirm=None
329
+ ) -> list[str]:
330
+ """Set up the verdict relay at install time (TYCHO-35/114, Claude only). If the user already
331
+ had a `.tycho.toml`, *respect* its relay choice and just say how to change it; otherwise ask
332
+ (default NO) and write the initial state. A scripted install (`--yes`, no TTY) never enables
333
+ it silently. ``relay_confirm`` overrides the prompt for tests. Returns status lines to print."""
334
+ if not claude_installed:
335
+ return []
336
+ if config_existed:
337
+ # The user brought their own config — don't re-ask or override it. Point at how to change.
338
+ state_txt = "ON" if state.relay_enabled(repo) else "OFF"
339
+ return [
340
+ f"claude: verdict relay is {state_txt} (from your {config_mod.CONFIG_NAME}). Change it "
341
+ f"with `tycho relay --on|--off`, or /tycho-relay-on / /tycho-relay-off in Claude Code."
342
+ ]
343
+ interactive = relay_confirm is not None or (not assume_yes and sys.stdin.isatty())
344
+ if not interactive:
345
+ return [] # scripted / no TTY: leave the default (off), already written by config.ensure
346
+ if (relay_confirm or _ask_relay)():
347
+ state.set_relay_enabled(repo, True)
348
+ return [
349
+ f"claude: verdict relay ON — the agent will see a non-VERIFIED verdict and keep working "
350
+ f"until VERIFIED, up to {state.relay_max()} automatic re-checks per turn (extra tokens). "
351
+ f"Turn it off with `tycho relay --off` or /tycho-relay-off."
352
+ ]
353
+ return [
354
+ "claude: verdict relay OFF (the default) — turn it on with `tycho relay --on` or "
355
+ "/tycho-relay-on to have the agent work until VERIFIED."
356
+ ]
357
+
358
+
359
+ def _wired_here(repo: Path, name: str) -> bool:
360
+ """Does `name`'s config already carry our hook? A config we can't read counts as wired —
361
+ we don't offer to overwrite something we can't inspect."""
362
+ try:
363
+ return installed_command(repo, name) is not None
364
+ except ConfigRefused:
365
+ return True
366
+
367
+
368
+ def _ask_setup(harnesses: list[str]) -> bool:
369
+ reply = input(f"Set up Tycho in this repo ({', '.join(harnesses)})? [Y/n] ").strip().lower()
370
+ return reply in ("", "y", "yes")
371
+
372
+
373
+ def offer_first_run(repo: Path, confirm=None) -> list[str]:
374
+ """First-run nudge (TYCHO-49): a supported agent is here but Tycho isn't wired.
375
+
376
+ Offer to set it up — interactively when there's a terminal (`Set up Tycho here? [Y/n]` →
377
+ runs init), or by printing the one-liner when there isn't. Fires **once per repo** (a
378
+ declined offer is never re-nagged) and **never writes config without consent** (TYCHO-6):
379
+ the "already offered" marker lives outside the repo, and a decline touches nothing.
380
+ Returns status lines for the caller to print; `[]` when there's nothing to offer.
381
+ """
382
+ if state.already_offered(repo):
383
+ return []
384
+ detected = detect(repo)
385
+ if not detected or state.read_install(repo) or any(_wired_here(repo, n) for n in detected):
386
+ return [] # nothing here, or already set up
387
+ state.mark_offered(repo) # offered — regardless of the answer, don't ask again
388
+ ask = confirm or _ask_setup
389
+ interactive = confirm is not None or sys.stdin.isatty() # a real TTY, or a test's confirm
390
+ if interactive and ask(detected):
391
+ return ["Tycho: setting up here…", *init(repo, only=None, assume_yes=True)]
392
+ return [
393
+ f"Tycho isn't set up in this repo yet — a supported agent ({', '.join(detected)}) is here.",
394
+ " Run `tycho init` to wire it up (offered once; it won't ask again).",
395
+ ]
396
+
397
+
398
+ def uninstall(repo: Path, only: str | None = None, purge: bool = False) -> list[str]:
399
+ """Remove Tycho-owned hook entries; return status lines.
400
+
401
+ Only ours come out — unrelated hooks, unrelated keys, and user-owned groups are
402
+ left as they were. With ``only`` set (a harness name), touch just that harness.
403
+ With ``purge`` set, also delete the repo-local `.tycho/` state and `.tycho.toml`
404
+ config — an explicit opt-in, never the default, since it drops the catch trail.
405
+ """
406
+ removers = {
407
+ "claude": _uninstall_claude,
408
+ "cursor": _uninstall_cursor,
409
+ "codex": _uninstall_codex,
410
+ "opencode": _uninstall_opencode,
411
+ }
412
+ lines = []
413
+ for name, fn in removers.items():
414
+ if only and name != only:
415
+ continue
416
+ try:
417
+ lines.append(fn(repo))
418
+ # Forget what we recorded, or `doctor` would keep diagnosing a ghost. Only
419
+ # after the config write succeeded — state must never claim less than truth.
420
+ state.drop_install(repo, name)
421
+ except ConfigRefused as exc:
422
+ # Same rule as install: a config we can't parse is one we can't safely
423
+ # rewrite. Removing our hook is not worth risking the rest of the file.
424
+ lines.append(f"{name}{REFUSED}{exc}")
425
+ if purge:
426
+ lines += _purge_repo_local(repo)
427
+ return lines
428
+
429
+
430
+ def _purge_repo_local(repo: Path) -> list[str]:
431
+ """Delete the two repo-local artifacts Tycho owns whole: the `.tycho/` state dir
432
+ (catch tally + evidence) and the `.tycho.toml` config. Idempotent — a missing target
433
+ is a no-op line, not an error. Stays repo-local: the machine-wide state under
434
+ `~/.local/share/tycho` (the all-time tally, shared across repos) is never touched.
435
+ """
436
+ lines = []
437
+ for path in (state.dir_for(repo), config_mod.path(repo)):
438
+ try:
439
+ if path.is_dir():
440
+ shutil.rmtree(path)
441
+ lines.append(f"removed {path.name}/")
442
+ elif path.exists():
443
+ path.unlink()
444
+ lines.append(f"removed {path.name}")
445
+ else:
446
+ lines.append(f"{path.name}: nothing to remove")
447
+ except OSError as exc:
448
+ # A delete we couldn't finish is an unfinished uninstall — mark it REFUSED so
449
+ # the CLI exits non-zero, same contract as a config we couldn't rewrite.
450
+ lines.append(f"{path.name}{REFUSED}{exc.strerror or exc}")
451
+ return lines
452
+
453
+
454
+ def _current_text(path: Path) -> str | None:
455
+ """What's on disk, or None if there's no file. Unreadable is refused, not ignored."""
456
+ try:
457
+ return path.read_text(encoding="utf-8")
458
+ except FileNotFoundError:
459
+ return None
460
+ except OSError as exc:
461
+ raise ConfigRefused(f"cannot read {path} ({exc.strerror}) — fix the permissions and re-run") from exc
462
+
463
+
464
+ def _load(path: Path) -> dict:
465
+ """Existing config as a dict. Missing or empty is {}; anything else we can't parse is refused.
466
+
467
+ Refusing matters more than it looks: treating malformed JSON as `{}` and writing
468
+ that back is how a verifier eats a developer's whole config. A hand-edit with a
469
+ trailing comma is a file to leave alone, not a file to replace.
470
+ """
471
+ text = _current_text(path)
472
+ if text is None or not text.strip():
473
+ return {}
474
+ try:
475
+ data = json.loads(text)
476
+ except json.JSONDecodeError as exc:
477
+ raise ConfigRefused(
478
+ f"{path} is not valid JSON (line {exc.lineno}: {exc.msg}) — fix or move it, then re-run"
479
+ ) from exc
480
+ if not isinstance(data, dict):
481
+ raise ConfigRefused(f"{path} holds {type(data).__name__}, not a JSON object — leaving it alone")
482
+ return data
483
+
484
+
485
+ def _write_text(path: Path, text: str, backup: bool = True) -> bool:
486
+ """Write `text` to `path` atomically; return False if it was already correct.
487
+
488
+ Resolves symlinks first: a settings file symlinked into a dotfiles repo must stay a
489
+ symlink, so replace what it points at rather than the link itself. Writes to a
490
+ sibling temp file and renames — rename is atomic, so a run killed mid-write leaves
491
+ the original whole rather than a truncated husk. ``backup=False`` skips the `.bak` for
492
+ files that are wholly ours and regenerable (a `/tycho` command), where a backup is
493
+ just clutter — the settings file, which holds *user* data, always keeps one.
494
+ """
495
+ target = Path(os.path.realpath(path))
496
+ current = _current_text(target)
497
+ if current == text:
498
+ return False # already correct: no write, no backup, no mtime churn
499
+ if current is not None and not os.access(target, os.W_OK):
500
+ # A rename lands regardless of the *file's* mode, so without this the user's
501
+ # deliberate chmod -w would be silently undone. (The dir's mode, and every
502
+ # other way the filesystem can say no, is caught below.)
503
+ raise ConfigRefused(f"{target} is read-only — chmod +w (or move it), then re-run")
504
+ tmp = target.with_name(target.name + _TMP_SUFFIX)
505
+ try:
506
+ if current is not None and backup:
507
+ # .bak holds the state immediately before this change, not a frozen
508
+ # pristine original: it's the file you'd restore to undo what we just did.
509
+ # Nothing is lost by overwriting it — we only ever get here for content
510
+ # we're about to change, we refuse anything unparseable long before this,
511
+ # and the user's own keys survive into every later copy regardless.
512
+ shutil.copy2(target, target.with_name(target.name + _BACKUP_SUFFIX))
513
+ target.parent.mkdir(parents=True, exist_ok=True)
514
+ tmp.write_text(text, encoding="utf-8")
515
+ if current is not None:
516
+ shutil.copymode(target, tmp) # the user's permissions, not our umask's
517
+ tmp.replace(target)
518
+ except OSError as exc:
519
+ # Anything the filesystem refuses — an unwritable parent dir, a full disk, a
520
+ # quota — is a refusal like any other, not a traceback. The original is still
521
+ # whole either way: nothing has replaced it yet.
522
+ tmp.unlink(missing_ok=True)
523
+ raise ConfigRefused(f"cannot write {target} ({exc.strerror}) — fix that, then re-run") from exc
524
+ return True
525
+
526
+
527
+ def _write(path: Path, data: dict) -> bool:
528
+ return _write_text(path, json.dumps(data, indent=2) + "\n")
529
+
530
+
531
+ def _status(label: str, what: str, path: Path, command: str, existed: bool, changed: bool) -> str:
532
+ if not changed:
533
+ return f"{label}: {what} already current → {path}"
534
+ verb = "updated" if existed else "installed"
535
+ return f"{label}: {verb} {what} → {path} ({command})"
536
+
537
+
538
+ def _install_claude(repo: Path) -> str:
539
+ """Claude gets two things in the same file: the Stop hook, and (if free) the status bar.
540
+
541
+ Both land in **one** write, which is not an optimisation. `.bak` must hold the state
542
+ from before Tycho touched anything, so a second write would back up our own first one
543
+ and destroy the copy the user would restore.
544
+ """
545
+ # .claude/settings.json → hooks.Stop is a list of matcher-groups, each with a `hooks` list.
546
+ path = config_path(repo, "claude")
547
+ data = _load(path)
548
+ command = hook_command()
549
+ hooks = dict(data.get("hooks") or {}) if isinstance(data.get("hooks"), dict) else {}
550
+ groups, existed = _strip_claude_tycho(hooks.get("Stop") or [])
551
+ groups.append({"hooks": [{"type": "command", "command": command}]})
552
+ hooks["Stop"] = groups
553
+ # SessionStart: surface a newer-version notice to the user at agent bootup (TYCHO-53).
554
+ ss_command = _command_for("session-start")
555
+ ss_groups, ss_existed = _strip_claude_tycho(hooks.get("SessionStart") or [])
556
+ ss_groups.append({"hooks": [{"type": "command", "command": ss_command}]})
557
+ hooks["SessionStart"] = ss_groups
558
+ # UserPromptSubmit: mark a run in flight the moment a prompt is sent, so the badge turns
559
+ # frost-blue "verifying" for the whole turn rather than only flickering at the Stop that
560
+ # ends it (TYCHO-94). The Stop hook clears the pending beat to the verdict.
561
+ ups_command = _command_for("prompt-submit")
562
+ ups_groups, ups_existed = _strip_claude_tycho(hooks.get("UserPromptSubmit") or [])
563
+ ups_groups.append({"hooks": [{"type": "command", "command": ups_command}]})
564
+ hooks["UserPromptSubmit"] = ups_groups
565
+ merged, statusline = _with_statusline(repo, {**data, "hooks": hooks}, path)
566
+ changed = _write(path, merged)
567
+ state.write_install(repo, "claude", command)
568
+ hook_line = _status("claude", "Stop hook", path, command, existed, changed)
569
+ ss_line = _status("claude", "SessionStart hook", path, ss_command, ss_existed, changed)
570
+ ups_line = _status("claude", "UserPromptSubmit hook", path, ups_command, ups_existed, changed)
571
+ slash = _install_slash_commands(repo)
572
+ return "\n".join(filter(None, (hook_line, ss_line, ups_line, statusline, slash)))
573
+
574
+
575
+ # One flat file per subcommand (`.claude/commands/tycho-status.md` → `/tycho-status`) so
576
+ # each shows up with its own description in Claude Code's `/` autocomplete (TYCHO-48).
577
+ # `hide`/`show` are the discoverable face of the `tycho status --off/--on` toggle (TYCHO-47).
578
+ _SLASH_SUBCOMMANDS = {
579
+ "status": ("Is Tycho live here, and its last verdict", "status"),
580
+ "doctor": ("Full diagnostics: is Tycho installed, current, and firing?", "doctor"),
581
+ "verify": ("Verify the latest session and render a verdict", "verify $ARGUMENTS"),
582
+ "help": ("What Tycho is, whether it's live here, and every command", "help"),
583
+ "count": ("How many problems Tycho has caught here, and all-time", "count"),
584
+ "hide": ("Hide the Tycho status indicator (the hook keeps verifying)", "status --off"),
585
+ "show": ("Show the Tycho status indicator again", "status --on"),
586
+ "relay": ("Is the verdict relay on? (agent sees its verdict, works till VERIFIED)", "relay"),
587
+ "relay-on": ("Turn ON the verdict relay — agent sees non-VERIFIED verdicts, works till VERIFIED (bounded)", "relay --on"),
588
+ "relay-off": ("Turn OFF the verdict relay — verdicts stay human-only (the default)", "relay --off"),
589
+ }
590
+
591
+
592
+ def _commands_dir(repo: Path) -> Path:
593
+ return repo / ".claude" / "commands"
594
+
595
+
596
+ def _q(s: str) -> str:
597
+ """Quote a YAML frontmatter value. A bare colon-space in a description ("Full
598
+ diagnostics: ...") reads as a nested mapping and breaks Claude Code's parse, which then
599
+ falls back to showing the marker — so always quote, escaping `\\` and `"`."""
600
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
601
+
602
+
603
+ def _slash_body(description: str, cli_args: str, argument_hint: str = "") -> str:
604
+ """A prompt-style command file backed by the real CLI (TYCHO-48).
605
+
606
+ Prompt-style on purpose: relies only on the stable `.claude/commands/*.md` convention
607
+ and `$ARGUMENTS`, not on `!`-exec / `allowed-tools` glob matching against a quoted
608
+ interpreter path (the fiddly, version-sensitive part). Runs the exact command the
609
+ terminal runs, so there's nothing extra to keep in sync.
610
+ """
611
+ # The YAML frontmatter MUST be the first thing in the file — Claude Code only parses it
612
+ # when `---` is line 1, so the marker goes in the body, not above it (a marker on top
613
+ # left the frontmatter unparsed and showed the comment itself as the description).
614
+ hint = f"argument-hint: {_q(argument_hint)}\n" if argument_hint else ""
615
+ return (
616
+ "---\n"
617
+ f"description: {_q(description)}\n{hint}"
618
+ "---\n\n"
619
+ f"{_SLASH_MARKER}\n\n"
620
+ "Run this exact command with the Bash tool and show its output verbatim - "
621
+ "nothing else, no commentary:\n\n"
622
+ f"`{_command_for(cli_args)}`\n\n"
623
+ "This is Tycho's own CLI (it also runs standalone in your terminal); it never edits code.\n"
624
+ )
625
+
626
+
627
+ # The four scope commands (TYCHO-55). Each is its own `/tycho-scope-*` so it autocompletes
628
+ # with its own description and argument-hint. set/add/remove take path globs, which MUST be
629
+ # passed single-quoted so the shell doesn't expand them before Tycho stores them — the body
630
+ # says so explicitly. Value: (description, cli action, argument-hint).
631
+ _SCOPE_SLASH = {
632
+ "scope-list": ("List the files/dirs the agent may edit (the scope_drift allowlist)", "list", ""),
633
+ "scope-set": ("Replace the scope_drift allowlist with these path globs", "set", "<glob>… e.g. 'src/**' 'tests/**'"),
634
+ "scope-add": ("Add path globs to the scope_drift allowlist", "add", "<glob>… e.g. 'src/**' 'tests/**'"),
635
+ "scope-remove": ("Remove path globs from the scope_drift allowlist", "remove", "<glob>… e.g. 'tests/**'"),
636
+ }
637
+
638
+
639
+ def _scope_slash_body(description: str, action: str, argument_hint: str) -> str:
640
+ """A `/tycho-scope-*` command file. `list` takes no args and reuses the generic body;
641
+ set/add/remove instruct the agent to single-quote each glob so the shell keeps it
642
+ literal (an un-quoted `src/**` would expand to files before Tycho ever sees it)."""
643
+ if action == "list":
644
+ return _slash_body(description, "scope list")
645
+ cmd = _command_for(f"scope {action}")
646
+ return (
647
+ "---\n"
648
+ f"description: {_q(description)}\n"
649
+ f"argument-hint: {_q(argument_hint)}\n"
650
+ "---\n\n"
651
+ f"{_SLASH_MARKER}\n\n"
652
+ "Update this repo's Tycho edit-scope, then show the command's output verbatim. Run it with "
653
+ "the Bash tool, passing **each glob as a separate single-quoted argument** so the shell keeps "
654
+ "it literal (an unquoted `src/**` expands to files before Tycho stores it):\n\n"
655
+ f"`{cmd} '<glob>' '<glob>' …`\n\n"
656
+ "The globs to use: $ARGUMENTS\n\n"
657
+ "This edits `.tycho.toml` only — it never touches your code.\n"
658
+ )
659
+
660
+
661
+ def _slash_files(repo: Path) -> dict[Path, str]:
662
+ """Path → body for every `/tycho` command file we install (flat `tycho-<sub>.md`)."""
663
+ d = _commands_dir(repo)
664
+ files = {d / "tycho.md": _slash_body(
665
+ "Run Tycho - verify what the agent claimed", "$ARGUMENTS",
666
+ argument_hint="<status|doctor|verify|scope|help>",
667
+ )}
668
+ for name, (desc, args) in _SLASH_SUBCOMMANDS.items():
669
+ files[d / f"tycho-{name}.md"] = _slash_body(desc, args)
670
+ for name, (desc, action, hint) in _SCOPE_SLASH.items():
671
+ files[d / f"tycho-{name}.md"] = _scope_slash_body(desc, action, hint)
672
+ return files
673
+
674
+
675
+ def _install_slash_commands(repo: Path) -> str | None:
676
+ """Write the `/tycho` command set; skip any file the user wrote themselves.
677
+
678
+ Same non-clobber rule as every other target: a command file without our marker is the
679
+ user's, and stays put.
680
+ """
681
+ target = _slash_files(repo)
682
+ # Remove any of *our* files from an earlier layout (the old `tycho/` namespace) that
683
+ # aren't in the current set, plus any `.bak` clutter a previous write left behind — a
684
+ # command file is wholly ours, so it never needed a backup.
685
+ for path in [*_tycho_command_files(repo), *_commands_dir(repo).glob("tycho*.tycho.bak")]:
686
+ if path not in target and (path.suffix == ".bak" or _SLASH_MARKER in (_current_text(path) or "")):
687
+ path.unlink()
688
+ try:
689
+ (_commands_dir(repo) / "tycho").rmdir() # tidy the old namespace dir if now empty
690
+ except OSError:
691
+ pass
692
+ installed, skipped = [], []
693
+ for path, body in target.items():
694
+ existing = _current_text(path)
695
+ if existing is not None and _SLASH_MARKER not in existing:
696
+ skipped.append(path.name)
697
+ elif _write_text(path, body, backup=False):
698
+ installed.append(path)
699
+ lines = []
700
+ if installed:
701
+ lines.append(f"claude: installed /tycho slash commands ({len(installed)}) → {_commands_dir(repo)}")
702
+ if skipped:
703
+ lines.append(f"claude: left your own command file(s) alone: {', '.join(sorted(set(skipped)))}")
704
+ return "\n".join(lines) or None
705
+
706
+
707
+ def _user_statusline_command(repo: Path) -> str | None:
708
+ """The command in the *user-level* Claude settings' statusLine, if any (read-only).
709
+
710
+ We never write user-level config — but reading it tells us whether a repo-level
711
+ statusLine of ours would *shadow* an existing one (a third-party badge lives here), and
712
+ the repo slot wins. Knowing that lets Tycho compose with it instead of hiding it. Same
713
+ read-only posture as harness detection: `$HOME` is a signal, never a write target.
714
+ """
715
+ try:
716
+ data = json.loads((harness_mod.home("claude") / "settings.json").read_text(encoding="utf-8"))
717
+ except (OSError, json.JSONDecodeError):
718
+ return None
719
+ sl = data.get("statusLine") if isinstance(data, dict) else None
720
+ cmd = sl.get("command") if isinstance(sl, dict) else None
721
+ return cmd if isinstance(cmd, str) and not _is_tycho_status(cmd) else None
722
+
723
+
724
+ def _with_statusline(repo: Path, data: dict, path: Path) -> tuple[dict, str]:
725
+ """Claim Claude Code's status bar, *composing* with any existing one (TYCHO-39/47).
726
+
727
+ `statusLine` holds exactly one command, and a repo-level line wins over the user-level
728
+ one — so Tycho could silently hide a working feature (a third-party badge, a shell prompt).
729
+ It doesn't: instead of replacing, it becomes the slot and *runs the other command too*.
730
+ - A foreign line in **this repo's** file is recorded (origin "repo") and restored on
731
+ uninstall — we own the write, so we owe the restore.
732
+ - A **user-level** line is recorded (origin "user") and left untouched; it resurfaces on
733
+ its own once ours is removed. `tycho status` runs it and prepends its output.
734
+
735
+ Shape verified against Claude Code 2.1.210: {"type": "command", "command": str}, plus
736
+ optional padding/refreshInterval which are the user's to set — hence the merge.
737
+ """
738
+ current = data.get("statusLine")
739
+ command = status_command()
740
+ ours = isinstance(current, dict) and _is_tycho_status(current.get("command"))
741
+ foreign = current.get("command") if (isinstance(current, dict) and not ours) else None
742
+
743
+ if isinstance(foreign, str):
744
+ state.write_statusline_wrap(repo, foreign, origin="repo")
745
+ elif (user_cmd := _user_statusline_command(repo)) is not None:
746
+ state.write_statusline_wrap(repo, user_cmd, origin="user")
747
+ else:
748
+ state.clear_statusline_wrap(repo)
749
+
750
+ base = current if (ours and isinstance(current, dict)) else {} # keep our own extra keys only
751
+ merged = {**base, "type": "command", "command": command}
752
+ # Poll on top of the event-driven render (TYCHO-59). Claude Code renders the badge at the
753
+ # Stop event, which fires while our hook is still verifying (entry beat = "pending"), so
754
+ # without polling the badge sits on yellow until the *next* prompt instead of settling on
755
+ # the verdict. A short interval re-renders it a beat later, once the verdict is written.
756
+ # Left to the user if they've set their own (see the `base` merge above).
757
+ merged.setdefault("refreshInterval", _STATUS_REFRESH_MS)
758
+ if merged == current and state.read_statusline_wrap(repo) is None:
759
+ return data, "" # already ours, nothing to compose — the Stop-hook line speaks for it
760
+ composing = " (composing with your existing status line)" if state.read_statusline_wrap(repo) else ""
761
+ return {**data, "statusLine": merged}, f"claude: installed statusLine → {path}{composing}"
762
+
763
+
764
+ def _strip_claude_tycho(groups: list) -> tuple[list, bool]:
765
+ """Drop any existing tycho hook entries (and now-empty groups); flag if any removed."""
766
+ out, existed = [], False
767
+ for group in groups:
768
+ entries = (group.get("hooks") or []) if isinstance(group, dict) else []
769
+ kept = [h for h in entries if not (isinstance(h, dict) and _is_tycho_owned(h.get("command")))]
770
+ existed = existed or len(kept) != len(entries)
771
+ if not isinstance(group, dict):
772
+ out.append(group)
773
+ elif kept or not entries:
774
+ out.append({**group, "hooks": kept})
775
+ return out, existed
776
+
777
+
778
+ def _install_cursor(repo: Path) -> str:
779
+ # .cursor/hooks.json → hooks.stop is a flat list of {command, ...}; needs `version`.
780
+ path = config_path(repo, "cursor")
781
+ data = _load(path)
782
+ hooks = dict(data.get("hooks") or {}) if isinstance(data.get("hooks"), dict) else {}
783
+ command = hook_command()
784
+ stop = list(hooks.get("stop") or [])
785
+ kept = [h for h in stop if not (isinstance(h, dict) and _is_tycho_hook(h.get("command")))]
786
+ existed = len(kept) != len(stop)
787
+ kept.append({"command": command})
788
+ hooks["stop"] = kept
789
+ changed = _write(path, {**data, "version": data.get("version", 1), "hooks": hooks})
790
+ state.write_install(repo, "cursor", command)
791
+ return _status("cursor", "stop hook", path, command, existed, changed)
792
+
793
+
794
+ def _install_codex(repo: Path) -> str:
795
+ """Codex shares Claude's hooks shape: `hooks.<Event>` is a list of matcher-groups. Stop
796
+ drives the verdict; SessionStart surfaces the bootup update notice via `systemMessage`
797
+ (Codex's output wire schema clones Claude's — docs/harness-support.md, TYCHO-72). Both go
798
+ in one write so the .bak doesn't churn (same reason `_install_claude` batches its keys).
799
+ """
800
+ path = config_path(repo, "codex")
801
+ data = _load(path)
802
+ hooks = dict(data.get("hooks") or {}) if isinstance(data.get("hooks"), dict) else {}
803
+ command = hook_command()
804
+ groups, existed = _strip_claude_tycho(hooks.get("Stop") or [])
805
+ groups.append({"hooks": [{"type": "command", "command": command}]})
806
+ hooks["Stop"] = groups
807
+ ss_command = _command_for("session-start")
808
+ ss_groups, ss_existed = _strip_claude_tycho(hooks.get("SessionStart") or [])
809
+ ss_groups.append({"hooks": [{"type": "command", "command": ss_command}]})
810
+ hooks["SessionStart"] = ss_groups
811
+ changed = _write(path, {**data, "hooks": hooks})
812
+ state.write_install(repo, "codex", command)
813
+ hook_line = _status("codex", "Stop hook", path, command, existed, changed)
814
+ ss_line = _status("codex", "SessionStart hook", path, ss_command, ss_existed, changed)
815
+ return "\n".join(filter(None, (hook_line, ss_line)))
816
+
817
+
818
+ def _install_opencode(repo: Path) -> str:
819
+ # OpenCode has no Stop hook; a project plugin drives two Tycho entrypoints off the
820
+ # plugin event bus, each handed the session id on stdin. Both toast their result via
821
+ # client.tui.showToast — a genuinely user-facing sink (TYCHO-72):
822
+ # - session.idle → `tycho hook` (the Stop verdict, after every turn)
823
+ # - session.created → `tycho session-start` (the bootup update-notice, once per session)
824
+ # Tycho rebuilds the transcript from opencode.db — no `opencode export` (which truncates
825
+ # at 128 KB over a pipe).
826
+ path = config_path(repo, "opencode")
827
+ command = hook_argv(hook_command())
828
+ notice = hook_argv(_command_for("session-start"))
829
+ source = f'''{_PLUGIN_MARKER} OpenCode loads project plugins at startup.
830
+ const command = {json.dumps(command)}
831
+ const noticeCommand = {json.dumps(notice)}
832
+
833
+ export const TychoPlugin = async ({{ client, directory }}) => {{
834
+ const run = async (argv, sessionID) => {{
835
+ const proc = Bun.spawn([...argv], {{
836
+ cwd: directory, stdin: "pipe", stdout: "pipe", stderr: "ignore",
837
+ }})
838
+ proc.stdin.write(JSON.stringify({{ harness: "opencode", sessionID, directory }}))
839
+ proc.stdin.end()
840
+ const text = (await new Response(proc.stdout).text()).trim()
841
+ await proc.exited
842
+ if (!text) return
843
+ try {{
844
+ const message = JSON.parse(text).message
845
+ if (message) await client.tui.showToast({{
846
+ body: {{ title: "Tycho", message, variant: "info" }},
847
+ }})
848
+ }} catch {{}}
849
+ }}
850
+ return {{
851
+ event: async ({{ event }}) => {{
852
+ if (event.type === "session.idle") {{
853
+ const sessionID = event.properties?.sessionID
854
+ if (sessionID) await run(command, sessionID)
855
+ }} else if (event.type === "session.created") {{
856
+ await run(noticeCommand, event.properties?.sessionID)
857
+ }}
858
+ }},
859
+ }}
860
+ }}
861
+ '''
862
+ # The whole file is ours, but a hand-written tycho.js is not — refuse rather than
863
+ # overwrite someone else's plugin that happens to share our name.
864
+ existed = path.exists()
865
+ if existed and _PLUGIN_MARKER not in (_current_text(path) or ""):
866
+ raise ConfigRefused(f"{path} wasn't generated by tycho init — move it aside, then re-run")
867
+ changed = _write_text(path, source)
868
+ state.write_install(repo, "opencode", shlex.join(command))
869
+ return _status("opencode", "session.idle + session.created plugin", path, shlex.join(command), existed, changed)
870
+
871
+
872
+ # --- uninstall ---------------------------------------------------------------
873
+ #
874
+ # We never delete a harness's config file: it may hold settings we didn't write
875
+ # (a `model`, unrelated hooks). We remove our entries, then drop a container only
876
+ # when it's empty *and* we were the ones who would have created it. The OpenCode
877
+ # plugin is the one exception — that whole file is ours, so it goes.
878
+
879
+
880
+ def _prune(data: dict, hooks: dict, key: str, kept: list) -> dict:
881
+ """Put `kept` back under `key`, dropping containers we emptied."""
882
+ if kept:
883
+ hooks[key] = kept
884
+ else:
885
+ hooks.pop(key, None) # init created this list; don't leave an empty one behind
886
+ merged = {**data, "hooks": hooks}
887
+ if not hooks:
888
+ merged.pop("hooks", None)
889
+ return merged
890
+
891
+
892
+ def _uninstall_claude(repo: Path) -> str:
893
+ """Take back the Stop hook and our statusLine — only ours, and in one write.
894
+
895
+ Claude's file holds several Tycho-owned things (Stop, SessionStart, UserPromptSubmit,
896
+ statusLine); removing them in separate writes would churn the backup for the same reason
897
+ install batches them. Codex now batches its two keys the same way (`_uninstall_codex`).
898
+ """
899
+ path = config_path(repo, "claude")
900
+ if not path.exists():
901
+ return "claude: nothing to remove (no config)"
902
+ data = _load(path)
903
+ hooks = dict(data.get("hooks") or {}) if isinstance(data.get("hooks"), dict) else {}
904
+ groups, had_hook = _strip_claude_tycho(hooks.get("Stop") or [])
905
+ ss_groups, had_ss = _strip_claude_tycho(hooks.get("SessionStart") or [])
906
+ ups_groups, had_ups = _strip_claude_tycho(hooks.get("UserPromptSubmit") or [])
907
+ current = data.get("statusLine")
908
+ had_statusline = isinstance(current, dict) and _is_tycho_status(current.get("command"))
909
+ slash = _uninstall_slash_commands(repo)
910
+ wrap = state.read_statusline_wrap(repo)
911
+ state.clear_statusline_wrap(repo) # forget the compose target either way
912
+ if not had_hook and not had_ss and not had_ups and not had_statusline:
913
+ return slash or "claude: nothing to remove"
914
+ _prune(data, hooks, "Stop", groups) # mutates `hooks` in place…
915
+ _prune(data, hooks, "SessionStart", ss_groups) # …then the second key…
916
+ merged = _prune(data, hooks, "UserPromptSubmit", ups_groups) # …then the third
917
+ restored = False
918
+ if had_statusline:
919
+ merged = {k: v for k, v in merged.items() if k != "statusLine"}
920
+ # A foreign repo-level line we replaced is ours to put back; a user-level one
921
+ # (origin "user", or no record) resurfaces on its own once ours is gone.
922
+ if isinstance(wrap, dict) and wrap.get("origin") == "repo" and isinstance(wrap.get("command"), str):
923
+ merged = {**merged, "statusLine": {"type": "command", "command": wrap["command"]}}
924
+ restored = True
925
+ _write(path, merged)
926
+ lines = []
927
+ if had_hook:
928
+ lines.append(f"claude: removed Stop hook → {path}")
929
+ if had_ss:
930
+ lines.append(f"claude: removed SessionStart hook → {path}")
931
+ if had_ups:
932
+ lines.append(f"claude: removed UserPromptSubmit hook → {path}")
933
+ if had_statusline:
934
+ lines.append(f"claude: restored your statusLine → {path}" if restored
935
+ else f"claude: removed statusLine → {path}")
936
+ if slash:
937
+ lines.append(slash)
938
+ return "\n".join(lines)
939
+
940
+
941
+ def _tycho_command_files(repo: Path) -> list[Path]:
942
+ """Every command file that could be ours — the flat `tycho.md`/`tycho-*.md`, plus the
943
+ old namespaced `tycho/*.md` from before the rename, so uninstall cleans both layouts."""
944
+ d = _commands_dir(repo)
945
+ return [*d.glob("tycho.md"), *d.glob("tycho-*.md"), *(d / "tycho").glob("*.md")]
946
+
947
+
948
+ def _uninstall_slash_commands(repo: Path) -> str | None:
949
+ """Remove every `/tycho` command we generated; leave hand-written files alone."""
950
+ removed = False
951
+ for path in _tycho_command_files(repo):
952
+ text = _current_text(path)
953
+ if text is not None and _SLASH_MARKER in text:
954
+ path.unlink()
955
+ removed = True
956
+ for tidy in (_commands_dir(repo) / "tycho", _commands_dir(repo)):
957
+ try:
958
+ tidy.rmdir() # only if now empty — never take a dir holding someone's file
959
+ except OSError:
960
+ pass
961
+ return f"claude: removed /tycho slash commands → {_commands_dir(repo)}" if removed else None
962
+
963
+
964
+ def _uninstall_codex(repo: Path) -> str:
965
+ """Remove the Stop and SessionStart hooks (TYCHO-72) — only ours, in one write."""
966
+ path = repo / ".codex" / "hooks.json"
967
+ if not path.exists():
968
+ return "codex: nothing to remove (no config)"
969
+ data = _load(path)
970
+ hooks = dict(data.get("hooks") or {}) if isinstance(data.get("hooks"), dict) else {}
971
+ groups, had_hook = _strip_claude_tycho(hooks.get("Stop") or [])
972
+ ss_groups, had_ss = _strip_claude_tycho(hooks.get("SessionStart") or [])
973
+ if not had_hook and not had_ss:
974
+ return "codex: nothing to remove"
975
+ _prune(data, hooks, "Stop", groups) # mutates `hooks` in place…
976
+ merged = _prune(data, hooks, "SessionStart", ss_groups) # …then the second key
977
+ _write(path, merged)
978
+ lines = []
979
+ if had_hook:
980
+ lines.append(f"codex: removed Stop hook → {path}")
981
+ if had_ss:
982
+ lines.append(f"codex: removed SessionStart hook → {path}")
983
+ return "\n".join(lines)
984
+
985
+
986
+ def _uninstall_cursor(repo: Path) -> str:
987
+ # .cursor/hooks.json → hooks.stop is a flat list of {command, ...}.
988
+ path = repo / ".cursor" / "hooks.json"
989
+ if not path.exists():
990
+ return "cursor: nothing to remove (no config)"
991
+ data = _load(path)
992
+ hooks = dict(data.get("hooks") or {}) if isinstance(data.get("hooks"), dict) else {}
993
+ stop = list(hooks.get("stop") or [])
994
+ kept = [h for h in stop if not (isinstance(h, dict) and _is_tycho_hook(h.get("command")))]
995
+ if len(kept) == len(stop):
996
+ return "cursor: nothing to remove"
997
+ _write(path, _prune(data, hooks, "stop", kept))
998
+ return f"cursor: removed stop hook → {path}"
999
+
1000
+
1001
+ def _uninstall_opencode(repo: Path) -> str:
1002
+ # The whole plugin file is ours — but only if we generated it. A hand-written
1003
+ # tycho.js belongs to the user and is left alone.
1004
+ path = repo / ".opencode" / "plugins" / "tycho.js"
1005
+ if not path.exists():
1006
+ return "opencode: nothing to remove"
1007
+ try:
1008
+ generated = _PLUGIN_MARKER in path.read_text(encoding="utf-8")
1009
+ except OSError:
1010
+ generated = False
1011
+ if not generated:
1012
+ return f"opencode: left {path} alone (not generated by tycho init)"
1013
+ path.unlink()
1014
+ try:
1015
+ path.parent.rmdir() # tidy the plugins/ dir we made — only if now empty
1016
+ except OSError:
1017
+ pass # user has other plugins, or the dir predates us: leave it
1018
+ return f"opencode: removed plugin → {path}"