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 +9 -0
- tycho/__main__.py +12 -0
- tycho/astdiff.py +102 -0
- tycho/checks.py +681 -0
- tycho/cli.py +516 -0
- tycho/config.py +177 -0
- tycho/doctor.py +304 -0
- tycho/events.py +393 -0
- tycho/fsstate.py +30 -0
- tycho/gitstate.py +46 -0
- tycho/harness.py +303 -0
- tycho/hook.py +212 -0
- tycho/init.py +1018 -0
- tycho/model.py +147 -0
- tycho/opencode.py +154 -0
- tycho/report.py +29 -0
- tycho/runlog.py +54 -0
- tycho/state.py +469 -0
- tycho/status.py +171 -0
- tycho/verify.py +202 -0
- tycho/version.py +99 -0
- tycho_cli-0.0.1.dist-info/METADATA +379 -0
- tycho_cli-0.0.1.dist-info/RECORD +26 -0
- tycho_cli-0.0.1.dist-info/WHEEL +4 -0
- tycho_cli-0.0.1.dist-info/entry_points.txt +2 -0
- tycho_cli-0.0.1.dist-info/licenses/LICENSE +201 -0
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}"
|