agent-toggle 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_toggle/__init__.py +3 -0
- agent_toggle/__main__.py +3 -0
- agent_toggle/backends/__init__.py +0 -0
- agent_toggle/backends/flag_json.py +141 -0
- agent_toggle/backends/mcp_json.py +198 -0
- agent_toggle/backends/mcp_toml.py +90 -0
- agent_toggle/backends/plugin_cli.py +41 -0
- agent_toggle/cli.py +498 -0
- agent_toggle/companions.py +160 -0
- agent_toggle/cost.py +299 -0
- agent_toggle/doctor.py +285 -0
- agent_toggle/fs.py +409 -0
- agent_toggle/harnesses.py +191 -0
- agent_toggle/mechanisms.py +932 -0
- agent_toggle/ops.py +103 -0
- agent_toggle/output.py +153 -0
- agent_toggle/profiles.py +207 -0
- agent_toggle/shims/claude.md.tmpl +45 -0
- agent_toggle/store.py +252 -0
- agent_toggle/ui/__init__.py +0 -0
- agent_toggle/ui/picker.py +279 -0
- agent_toggle/undo.py +133 -0
- agent_toggle-0.1.0.dist-info/METADATA +580 -0
- agent_toggle-0.1.0.dist-info/RECORD +28 -0
- agent_toggle-0.1.0.dist-info/WHEEL +5 -0
- agent_toggle-0.1.0.dist-info/entry_points.txt +2 -0
- agent_toggle-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_toggle-0.1.0.dist-info/top_level.txt +1 -0
agent_toggle/fs.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Filesystem layer: the ONLY place that resolves the home / state dirs.
|
|
2
|
+
|
|
3
|
+
Every path is computed at call time from Path.home(), so a test (or a caller)
|
|
4
|
+
that changes HOME / USERPROFILE gets a consistent view without patching
|
|
5
|
+
module constants.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import importlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
TEXT_SUFFIXES = {".md", ".sh", ".py", ".js", ".mjs", ".cjs", ".ts", ".json",
|
|
19
|
+
".yaml", ".yml", ".toml", ".txt", ".zsh", ".bash"}
|
|
20
|
+
# Directories never worth walking when deciding if a companion is shared.
|
|
21
|
+
PRUNE = {".git", "node_modules", "cache", "__pycache__", "dist", "build",
|
|
22
|
+
"skills-disabled", "agents-disabled", "commands-disabled", "rules-disabled",
|
|
23
|
+
"prompts-disabled", "command-disabled", "venv"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------- paths
|
|
27
|
+
|
|
28
|
+
def home() -> Path:
|
|
29
|
+
return Path.home()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def state_dir() -> Path:
|
|
33
|
+
return home() / ".agent-toggle"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def state_file() -> Path:
|
|
37
|
+
return state_dir() / "state.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def log_file() -> Path:
|
|
41
|
+
return state_dir() / "log.jsonl"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def backup_dir() -> Path:
|
|
45
|
+
return state_dir() / "mcp-backups"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def companion_dir() -> Path:
|
|
49
|
+
return state_dir() / "companions"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def legacy_state_dir() -> Path:
|
|
53
|
+
return home() / ".claude-toggle"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def claude_json() -> Path:
|
|
57
|
+
return home() / ".claude.json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def lock_file() -> Path:
|
|
61
|
+
return state_dir() / "lock"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def profiles_dir() -> Path:
|
|
65
|
+
return state_dir() / "profiles"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parked_dir() -> Path:
|
|
69
|
+
return state_dir() / "parked"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def contained(path: Path, *roots: Path) -> bool:
|
|
73
|
+
"""True if `path` sits inside one of `roots` (DESIGN s6.1 rows 1-2).
|
|
74
|
+
|
|
75
|
+
Refuses any `..` part. Only the PARENT is resolved: the item itself may be
|
|
76
|
+
a symlink and is never followed. A root inside our own state dir (parked,
|
|
77
|
+
backups) must not be reached through a symlink at all, since nothing
|
|
78
|
+
legitimate puts one there; harness homes may be symlinked (dotfile repos).
|
|
79
|
+
"""
|
|
80
|
+
path = Path(path)
|
|
81
|
+
if ".." in path.parts:
|
|
82
|
+
return False
|
|
83
|
+
parent = path.parent.resolve()
|
|
84
|
+
sd = state_dir()
|
|
85
|
+
for root in map(Path, roots):
|
|
86
|
+
if not parent.is_relative_to(root.resolve()):
|
|
87
|
+
continue
|
|
88
|
+
if root.is_relative_to(sd):
|
|
89
|
+
chain = [sd, *(p for p in path.parent.parents if p.is_relative_to(sd)),
|
|
90
|
+
path.parent]
|
|
91
|
+
if any(p.is_symlink() for p in chain):
|
|
92
|
+
continue
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------- lock + private atomic write
|
|
98
|
+
|
|
99
|
+
LOCK_WAIT = 5.0 # seconds a second run waits before giving up
|
|
100
|
+
LOCK_STALE = 600.0 # a lock older than this is a crashed run's leftover
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Locked(Exception):
|
|
104
|
+
"""Another agent-toggle run holds the lock (the CLI maps this to exit 3)."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def private_dir(path: Path) -> Path:
|
|
108
|
+
"""mkdir the state dir and (one level below it) `path`, both mode 0700."""
|
|
109
|
+
sd = state_dir()
|
|
110
|
+
sd.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
111
|
+
if path != sd:
|
|
112
|
+
path.mkdir(mode=0o700, exist_ok=True)
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _holder_alive(path: Path) -> bool:
|
|
117
|
+
"""True if the PID recorded in the lock belongs to a running process.
|
|
118
|
+
Unknown (non-POSIX, unreadable / empty file) counts as not alive."""
|
|
119
|
+
if os.name == "nt":
|
|
120
|
+
return False
|
|
121
|
+
try:
|
|
122
|
+
pid = int(path.read_text(encoding="utf-8").strip())
|
|
123
|
+
if pid <= 0:
|
|
124
|
+
return False
|
|
125
|
+
os.kill(pid, 0)
|
|
126
|
+
except ProcessLookupError:
|
|
127
|
+
return False
|
|
128
|
+
except PermissionError:
|
|
129
|
+
return True # exists, owned by someone else
|
|
130
|
+
except (OSError, ValueError):
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def refresh_lock() -> None:
|
|
136
|
+
"""Bump the mtime of a lock WE hold, so a batch longer than LOCK_STALE is
|
|
137
|
+
not mistaken for a crashed run. No-op when we do not hold it."""
|
|
138
|
+
path = lock_file()
|
|
139
|
+
try:
|
|
140
|
+
if path.read_text(encoding="utf-8") == str(os.getpid()):
|
|
141
|
+
os.utime(path)
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@contextlib.contextmanager
|
|
147
|
+
def lock():
|
|
148
|
+
"""Hold <state_dir>/lock (O_EXCL create, PID inside) for a whole batch."""
|
|
149
|
+
path = lock_file()
|
|
150
|
+
private_dir(state_dir())
|
|
151
|
+
deadline = time.monotonic() + LOCK_WAIT
|
|
152
|
+
while True:
|
|
153
|
+
try:
|
|
154
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
155
|
+
break
|
|
156
|
+
except FileExistsError:
|
|
157
|
+
try:
|
|
158
|
+
if (time.time() - path.stat().st_mtime > LOCK_STALE
|
|
159
|
+
and not _holder_alive(path)):
|
|
160
|
+
# Takeover is stat-then-rename, so a rival may replace the
|
|
161
|
+
# lock in between: rename can then move THEIR fresh lock.
|
|
162
|
+
# Re-stat what we actually moved and put it back if so.
|
|
163
|
+
# ponytail: the lock path is briefly absent during put-back;
|
|
164
|
+
# fine for a CLI, use flock/O_TMPFILE if contention matters.
|
|
165
|
+
stale = path.with_name(f"lock.stale.{os.getpid()}")
|
|
166
|
+
os.rename(path, stale)
|
|
167
|
+
if time.time() - stale.stat().st_mtime <= LOCK_STALE:
|
|
168
|
+
try:
|
|
169
|
+
os.link(stale, path) # fails if a third run took it
|
|
170
|
+
stale.unlink(missing_ok=True)
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
raise Locked(f"{path} is held by another agent-toggle run")
|
|
174
|
+
stale.unlink(missing_ok=True)
|
|
175
|
+
continue
|
|
176
|
+
except FileNotFoundError:
|
|
177
|
+
continue # released / taken over by a rival
|
|
178
|
+
if time.monotonic() >= deadline:
|
|
179
|
+
raise Locked(f"{path} is held by another agent-toggle run "
|
|
180
|
+
f"(waited {LOCK_WAIT:g}s)") from None
|
|
181
|
+
time.sleep(0.05)
|
|
182
|
+
pid = str(os.getpid())
|
|
183
|
+
try:
|
|
184
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
185
|
+
fh.write(pid)
|
|
186
|
+
yield
|
|
187
|
+
finally:
|
|
188
|
+
try: # only remove a lock that is still ours
|
|
189
|
+
if path.read_text(encoding="utf-8") == pid:
|
|
190
|
+
path.unlink(missing_ok=True)
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def atomic_write(path: Path, text: str, mode: int = 0o600) -> None:
|
|
196
|
+
"""Write `text` to `path` via a same-dir tmp file created with `mode`."""
|
|
197
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
198
|
+
tmp.unlink(missing_ok=True) # leftover from a crashed run with our PID
|
|
199
|
+
fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode)
|
|
200
|
+
try:
|
|
201
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
202
|
+
fh.write(text)
|
|
203
|
+
fh.flush()
|
|
204
|
+
os.fsync(fh.fileno())
|
|
205
|
+
os.replace(tmp, path)
|
|
206
|
+
except BaseException:
|
|
207
|
+
tmp.unlink(missing_ok=True)
|
|
208
|
+
raise
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class WriteError(Exception):
|
|
212
|
+
"""A checked_write was rolled back; the message names the file and why."""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _replace_bytes(path: Path, data: bytes, mode: int) -> None:
|
|
216
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
217
|
+
tmp.unlink(missing_ok=True)
|
|
218
|
+
fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode)
|
|
219
|
+
try:
|
|
220
|
+
with os.fdopen(fd, "wb") as fh:
|
|
221
|
+
fh.write(data)
|
|
222
|
+
fh.flush()
|
|
223
|
+
os.fsync(fh.fileno())
|
|
224
|
+
os.chmod(tmp, mode) # os.open's mode is umask-filtered
|
|
225
|
+
os.replace(tmp, path)
|
|
226
|
+
except BaseException:
|
|
227
|
+
tmp.unlink(missing_ok=True)
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def checked_write(path: Path, text: str, verify) -> str:
|
|
232
|
+
"""Replace `path` with `text`, then re-read it and run verify(before, after).
|
|
233
|
+
|
|
234
|
+
Returns verify's note ("" when fully verified). On ANY exception the
|
|
235
|
+
previous bytes and mode are put back (a new file is removed) and
|
|
236
|
+
WriteError is raised. Bytes, not text mode, so line endings survive.
|
|
237
|
+
A symlinked config (dotfile repo) is written through, keeping the link.
|
|
238
|
+
"""
|
|
239
|
+
path = Path(os.path.realpath(path))
|
|
240
|
+
try:
|
|
241
|
+
old = path.read_bytes()
|
|
242
|
+
old_mode = path.stat().st_mode & 0o7777
|
|
243
|
+
except FileNotFoundError:
|
|
244
|
+
old, old_mode = None, 0o600
|
|
245
|
+
try:
|
|
246
|
+
_replace_bytes(path, text.encode("utf-8"), old_mode)
|
|
247
|
+
after = path.read_bytes().decode("utf-8")
|
|
248
|
+
return verify("" if old is None else old.decode("utf-8", errors="replace"), after)
|
|
249
|
+
except BaseException as e: # Ctrl-C mid-verify must not leave the write
|
|
250
|
+
try:
|
|
251
|
+
if _untouched(path, old, old_mode):
|
|
252
|
+
pass # failed before os.replace: nothing to put back
|
|
253
|
+
elif old is None:
|
|
254
|
+
path.unlink(missing_ok=True)
|
|
255
|
+
else:
|
|
256
|
+
_replace_bytes(path, old, old_mode)
|
|
257
|
+
except OSError as r:
|
|
258
|
+
raise WriteError(f"{path}: write failed ({e}) AND restore failed ({r})") from e
|
|
259
|
+
if not isinstance(e, Exception):
|
|
260
|
+
raise
|
|
261
|
+
raise WriteError(f"{path}: write rolled back ({type(e).__name__}: {e})") from e
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _untouched(path: Path, old: bytes | None, old_mode: int) -> bool:
|
|
265
|
+
"""True if `path` still holds exactly its pre-write bytes and mode (or is still absent)."""
|
|
266
|
+
try:
|
|
267
|
+
return path.read_bytes() == old and path.stat().st_mode & 0o7777 == old_mode
|
|
268
|
+
except FileNotFoundError:
|
|
269
|
+
return old is None
|
|
270
|
+
except OSError:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def json_verify(expected: str | None = None):
|
|
275
|
+
"""verify(): the result parses as JSON and, if given, equals `expected` exactly."""
|
|
276
|
+
def verify(before: str, after: str) -> str:
|
|
277
|
+
json.loads(after)
|
|
278
|
+
if expected is not None and after != expected:
|
|
279
|
+
raise ValueError("result differs from the expected edit")
|
|
280
|
+
return ""
|
|
281
|
+
return verify
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _tomllib():
|
|
285
|
+
"""The tomllib module, or None on Python 3.10 (no dependency may be added)."""
|
|
286
|
+
try:
|
|
287
|
+
return importlib.import_module("tomllib")
|
|
288
|
+
except ImportError:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def toml_verify(expected: str | None = None):
|
|
293
|
+
"""verify(): the result parses (when tomllib exists) and, if given, equals
|
|
294
|
+
`expected` -- the before text with the one [mcp_servers.<name>] block
|
|
295
|
+
removed/appended. Note is `unverified (no tomllib)` only when neither ran."""
|
|
296
|
+
def verify(before: str, after: str) -> str:
|
|
297
|
+
toml = _tomllib()
|
|
298
|
+
if toml is not None:
|
|
299
|
+
toml.loads(after) # TOMLDecodeError is a ValueError
|
|
300
|
+
if expected is not None and after != expected:
|
|
301
|
+
raise ValueError("result differs from the expected edit")
|
|
302
|
+
return "" if toml is not None or expected is not None else "unverified (no tomllib)"
|
|
303
|
+
return verify
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def tighten(path: Path) -> None:
|
|
307
|
+
"""chmod 0600 when group/world can read it. Best effort."""
|
|
308
|
+
try:
|
|
309
|
+
if path.is_file() and path.stat().st_mode & 0o077:
|
|
310
|
+
path.chmod(0o600)
|
|
311
|
+
except OSError:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def too_open(path: Path, mask: int = 0o044) -> bool:
|
|
316
|
+
"""True if `path` has any of the `mask` bits set (default: group/world read);
|
|
317
|
+
always False where modes don't apply."""
|
|
318
|
+
try:
|
|
319
|
+
return os.name != "nt" and bool(path.stat().st_mode & mask)
|
|
320
|
+
except OSError:
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ------------------------------------------------------------ the safe move
|
|
325
|
+
|
|
326
|
+
def safe_move(src: Path, dest_dir: Path) -> Path:
|
|
327
|
+
"""Move src INTO dest_dir, refusing every way this can silently corrupt data.
|
|
328
|
+
|
|
329
|
+
The bug this exists to prevent: `mv X dest/` when `dest` does not exist
|
|
330
|
+
RENAMES X to dest. The first item becomes the directory and its contents
|
|
331
|
+
end up scattered at the parked-dir root. shutil.move() behaves the same.
|
|
332
|
+
So: create the directory, then prove it is a directory, before touching src.
|
|
333
|
+
"""
|
|
334
|
+
if not (src.exists() or src.is_symlink()):
|
|
335
|
+
raise FileNotFoundError(f"{src} does not exist")
|
|
336
|
+
|
|
337
|
+
# Order matters: mkdir(exist_ok=True) raises FileExistsError when the path
|
|
338
|
+
# exists as a FILE, so the is_dir() guard has to come first to give a
|
|
339
|
+
# useful error instead of a confusing one.
|
|
340
|
+
if dest_dir.exists() and not dest_dir.is_dir():
|
|
341
|
+
raise NotADirectoryError(
|
|
342
|
+
f"{dest_dir} exists but is not a directory -- refusing to move "
|
|
343
|
+
f"{src.name} (it would be renamed INTO that path and destroyed)"
|
|
344
|
+
)
|
|
345
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
if not dest_dir.is_dir(): # lost a race, or odd fs
|
|
347
|
+
raise NotADirectoryError(f"{dest_dir} is not a directory after mkdir")
|
|
348
|
+
|
|
349
|
+
target = dest_dir / src.name
|
|
350
|
+
if target.exists() or target.is_symlink():
|
|
351
|
+
raise FileExistsError(f"{target} already exists -- refusing to overwrite")
|
|
352
|
+
|
|
353
|
+
shutil.move(str(src), str(target))
|
|
354
|
+
return target
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def prune_empty(start: Path, stop: Path) -> None:
|
|
358
|
+
"""Drop directories left empty by a move, up to but excluding `stop`.
|
|
359
|
+
|
|
360
|
+
Restoring commands/orch/batch.md otherwise leaves an empty `orch/` behind
|
|
361
|
+
in the park dir, which accumulates and inflates the parked counts.
|
|
362
|
+
"""
|
|
363
|
+
cur = start
|
|
364
|
+
while cur != stop and stop in cur.parents:
|
|
365
|
+
try:
|
|
366
|
+
cur.rmdir() # refuses non-empty, which is the guard
|
|
367
|
+
except OSError:
|
|
368
|
+
return
|
|
369
|
+
cur = cur.parent
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def sync_markers(d: Path) -> list[str]:
|
|
373
|
+
"""Names of `.synced-from-*` markers in `d`: a sync job may re-create parked items."""
|
|
374
|
+
try:
|
|
375
|
+
return sorted(p.name for p in d.glob(".synced-from-*"))
|
|
376
|
+
except OSError:
|
|
377
|
+
return []
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def gitignored(path: Path, repo: Path) -> bool:
|
|
381
|
+
"""True if git ignores `path`. Unknown (no git / not a repo) counts as False."""
|
|
382
|
+
try:
|
|
383
|
+
target_str = str(path) + "/"
|
|
384
|
+
p = subprocess.run(["git", "-C", str(repo), "check-ignore", "-q", target_str],
|
|
385
|
+
capture_output=True, timeout=10)
|
|
386
|
+
return p.returncode == 0
|
|
387
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def iter_text_files(root: Path):
|
|
392
|
+
if root.is_file():
|
|
393
|
+
yield root
|
|
394
|
+
return
|
|
395
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
396
|
+
dirnames[:] = [d for d in dirnames if d not in PRUNE and not d.startswith(".")]
|
|
397
|
+
for fn in filenames:
|
|
398
|
+
p = Path(dirpath) / fn
|
|
399
|
+
if p.suffix.lower() in TEXT_SUFFIXES:
|
|
400
|
+
yield p
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def read_text(p: Path) -> str:
|
|
404
|
+
try:
|
|
405
|
+
if p.stat().st_size > 2_000_000:
|
|
406
|
+
return ""
|
|
407
|
+
return p.read_text(encoding="utf-8", errors="ignore")
|
|
408
|
+
except OSError:
|
|
409
|
+
return ""
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""The harness table: which harnesses exist, where they live, what they support."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Mapping
|
|
10
|
+
|
|
11
|
+
from . import fs
|
|
12
|
+
from .output import die
|
|
13
|
+
|
|
14
|
+
TYPES = ("skill", "agent", "command", "rule", "plugin", "mcp")
|
|
15
|
+
|
|
16
|
+
# The on-disk SHAPE (directory vs .md file) is probed, not declared:
|
|
17
|
+
# harnesses disagree and the probe is both shorter and more correct than a
|
|
18
|
+
# per-harness shape table.
|
|
19
|
+
PROBE_SUFFIXES = ("", ".md", ".toml", ".yaml", ".yml")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class McpSpec:
|
|
24
|
+
backend: str # "claude-json" | "toml" | "json" | "project-json"
|
|
25
|
+
file: Path # the config file holding the servers
|
|
26
|
+
key_path: tuple[str, ...] # where the servers live inside it
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Harness:
|
|
31
|
+
name: str
|
|
32
|
+
home: Path
|
|
33
|
+
dirs: Mapping[str, tuple[str, ...]] # type -> candidate subdirs
|
|
34
|
+
mechanisms: Mapping[str, str] # type -> move|flag|remove_backup|native_cli
|
|
35
|
+
mcp: McpSpec | None = None
|
|
36
|
+
# type -> (file relative to home, pointer tuple; "<name>" is replaced by the item name)
|
|
37
|
+
flags: Mapping[str, tuple] = field(default_factory=dict)
|
|
38
|
+
editable: frozenset[str] = frozenset() # files the tool may write
|
|
39
|
+
aliases_from: tuple[str, ...] = ()
|
|
40
|
+
project: Path | None = None # set only on a --project view (resolved dir)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
# frozen only stops rebinding; wrap the dicts so they cannot be mutated either
|
|
44
|
+
for f in ("dirs", "mechanisms", "flags"):
|
|
45
|
+
object.__setattr__(self, f, MappingProxyType(dict(getattr(self, f))))
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def types(self) -> tuple[str, ...]:
|
|
49
|
+
"""Supported types, in the canonical TYPES order."""
|
|
50
|
+
return tuple(t for t in TYPES if t in self.mechanisms)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def backend(self) -> str | None:
|
|
54
|
+
return self.mcp.backend if self.mcp else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def opencode_home(home: Path) -> Path:
|
|
58
|
+
"""`$XDG_CONFIG_HOME/opencode`, else `<home>/.config/opencode` (read at call time).
|
|
59
|
+
|
|
60
|
+
A relative XDG_CONFIG_HOME is ignored, per the XDG spec.
|
|
61
|
+
"""
|
|
62
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "")
|
|
63
|
+
return (Path(xdg) if os.path.isabs(xdg) else home / ".config") / "opencode"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def opencode_skill_dirs(home: Path, oc: Path) -> tuple[str, ...]:
|
|
67
|
+
"""OpenCode's skill dirs: its own `skills/` plus every `opencode.json` ->
|
|
68
|
+
`skills.paths[]` entry (the redirect DESIGN s4 pins; on the surveyed machine
|
|
69
|
+
it points at codex's skills dir). A path is `~/`-expanded against `home`, a
|
|
70
|
+
relative one is taken relative to `oc` (assumption: DESIGN does not say).
|
|
71
|
+
Extra dirs are absolute strings, which `home / sub` passes through intact.
|
|
72
|
+
A missing or malformed file adds nothing: the table build never fails.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
paths = json.loads((oc / "opencode.json").read_text(encoding="utf-8"))["skills"]["paths"]
|
|
76
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
77
|
+
paths = []
|
|
78
|
+
extra = []
|
|
79
|
+
for p in paths if isinstance(paths, list) else []:
|
|
80
|
+
if isinstance(p, str) and p:
|
|
81
|
+
q = home / p[2:] if p.startswith("~/") else oc / p
|
|
82
|
+
extra.append(str(q))
|
|
83
|
+
return tuple(dict.fromkeys(("skills", *extra)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build(home: Path) -> dict[str, Harness]:
|
|
87
|
+
"""The table for a given user home."""
|
|
88
|
+
claude, codex, grok = home / ".claude", home / ".codex", home / ".grok"
|
|
89
|
+
oc = opencode_home(home)
|
|
90
|
+
return {h.name: h for h in (
|
|
91
|
+
Harness("claude", claude,
|
|
92
|
+
dirs={"skill": ("skills",), "agent": ("agents",), "command": ("commands",),
|
|
93
|
+
"rule": ("rules",)},
|
|
94
|
+
mechanisms={"skill": "move", "agent": "move", "command": "move",
|
|
95
|
+
"rule": "move", "plugin": "native_cli", "mcp": "remove_backup"},
|
|
96
|
+
mcp=McpSpec("claude-json", home / ".claude.json", ("mcpServers",))),
|
|
97
|
+
Harness("codex", codex,
|
|
98
|
+
dirs={"skill": ("skills",), "agent": ("agents",),
|
|
99
|
+
"command": ("commands", "prompts")},
|
|
100
|
+
mechanisms={"skill": "move", "agent": "move", "command": "move",
|
|
101
|
+
"plugin": "native_cli", "mcp": "remove_backup"},
|
|
102
|
+
mcp=McpSpec("toml", codex / "config.toml", ("mcp_servers",))),
|
|
103
|
+
# DESIGN §4 survey: grok keeps `[mcp_servers.<name>]` blocks in config.toml,
|
|
104
|
+
# the same shape as codex, so the existing TOML backend serves it.
|
|
105
|
+
Harness("grok", grok,
|
|
106
|
+
dirs={"skill": ("skills",)},
|
|
107
|
+
mechanisms={"skill": "move", "mcp": "remove_backup"},
|
|
108
|
+
mcp=McpSpec("toml", grok / "config.toml", ("mcp_servers",))),
|
|
109
|
+
# DESIGN s4: command/*.md; skills may be redirected by opencode.json.
|
|
110
|
+
# Assumption (DESIGN s11 q2): with no skills.paths, `skills/` is its own dir.
|
|
111
|
+
Harness("opencode", oc,
|
|
112
|
+
dirs={"skill": opencode_skill_dirs(home, oc), "command": ("command",)},
|
|
113
|
+
# ASSUMED shape (DESIGN s4/s11, not verified on a real install):
|
|
114
|
+
# `mcp.<name>.enabled` in opencode.json, strict JSON only.
|
|
115
|
+
mechanisms={"skill": "move", "command": "move", "mcp": "flag"},
|
|
116
|
+
flags={"mcp": ("opencode.json", ("mcp", "<name>", "enabled"))},
|
|
117
|
+
editable=frozenset({"opencode.json"}),
|
|
118
|
+
aliases_from=("skills.paths",)),
|
|
119
|
+
# ASSUMED shapes (DESIGN s4/s11, not verified on a real install):
|
|
120
|
+
# `skills.entries.<name>.enabled` and `plugins.entries.<name>.enabled` in
|
|
121
|
+
# openclaw.json. A skill uses the flag only when its entry exists, else
|
|
122
|
+
# the dir move; a plugin is flag-only.
|
|
123
|
+
Harness("openclaw", home / ".openclaw",
|
|
124
|
+
dirs={"skill": ("skills",), "agent": ("agents",)},
|
|
125
|
+
mechanisms={"skill": "move", "agent": "move", "plugin": "flag"},
|
|
126
|
+
flags={"skill": ("openclaw.json", ("skills", "entries", "<name>", "enabled")),
|
|
127
|
+
"plugin": ("openclaw.json", ("plugins", "entries", "<name>", "enabled"))},
|
|
128
|
+
editable=frozenset({"openclaw.json"})),
|
|
129
|
+
# DESIGN s4: `mcp-config.json -> mcpServers`, strict JSON, backend "json". Its
|
|
130
|
+
# `config.json` is machine-managed JSONC: never listed, never in `editable`.
|
|
131
|
+
Harness("copilot", home / ".copilot",
|
|
132
|
+
dirs={"skill": ("skills",), "agent": ("agents",)},
|
|
133
|
+
mechanisms={"skill": "move", "agent": "move", "mcp": "remove_backup"},
|
|
134
|
+
mcp=McpSpec("json", home / ".copilot" / "mcp-config.json", ("mcpServers",)),
|
|
135
|
+
editable=frozenset({"mcp-config.json"})),
|
|
136
|
+
# DESIGN s4: vibe keeps skills/<name>/SKILL.md; no MCP config found.
|
|
137
|
+
Harness("vibe", home / ".vibe",
|
|
138
|
+
dirs={"skill": ("skills",)},
|
|
139
|
+
mechanisms={"skill": "move"}),
|
|
140
|
+
# DESIGN s4: nothing togglable locally (resources are cloud-side). An
|
|
141
|
+
# explicit not-applicable row: no dirs, no mechanisms, so every type
|
|
142
|
+
# exits 4 through the ops dispatch.
|
|
143
|
+
Harness("devin", home / ".devin", dirs={}, mechanisms={}),
|
|
144
|
+
# DESIGN s11 q1: skills layout unobserved, so types=() and NO adapter yet
|
|
145
|
+
# (`status` lists it as "found, nothing supported yet").
|
|
146
|
+
Harness("agy", home / ".antigravity", dirs={}, mechanisms={}),
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def harnesses() -> dict[str, Harness]:
|
|
151
|
+
"""The table for the CURRENT home."""
|
|
152
|
+
return build(fs.home())
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
PROJECT_TYPES = ("skill", "agent", "command", "rule")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def project_view(project: Path | str) -> Harness:
|
|
159
|
+
"""The claude-shaped row for `--project <dir>` (DESIGN s5.5): home=<dir>/.claude,
|
|
160
|
+
the dir types plus `mcp` (the repo's own <dir>/.mcp.json, strict JSON, edited
|
|
161
|
+
directly). Refuses (CliError) a dir that is missing (4), has neither .claude
|
|
162
|
+
nor .mcp.json (4),
|
|
163
|
+
is a root / $HOME / an ancestor of $HOME / inside our state dir or a user harness
|
|
164
|
+
home, or whose .claude resolves outside it (2):
|
|
165
|
+
a project view must never address user-scope files."""
|
|
166
|
+
p = Path(project)
|
|
167
|
+
if not p.is_dir():
|
|
168
|
+
die(f"--project {project}: no such directory", 4)
|
|
169
|
+
p = p.resolve()
|
|
170
|
+
if p == Path(p.anchor) or fs.home().resolve().is_relative_to(p):
|
|
171
|
+
die(f"--project {p} is a root, $HOME or holds $HOME -- drop --project for user scope", 2)
|
|
172
|
+
for root in (fs.state_dir(), *(h.home for h in harnesses().values())):
|
|
173
|
+
if p.is_relative_to(root.resolve()): # user-scope files under a project key
|
|
174
|
+
die(f"--project {p} is inside {root} -- not a project", 2)
|
|
175
|
+
claude = p / ".claude"
|
|
176
|
+
if not claude.is_dir() and not (p / ".mcp.json").is_file():
|
|
177
|
+
die(f"--project {p} has no .claude directory or .mcp.json", 4)
|
|
178
|
+
if os.path.lexists(claude) and not claude.resolve().is_relative_to(p):
|
|
179
|
+
die(f"--project {p}: .claude resolves outside the project ({claude.resolve()})", 2)
|
|
180
|
+
return Harness("claude", claude,
|
|
181
|
+
dirs={"skill": ("skills",), "agent": ("agents",), "command": ("commands",),
|
|
182
|
+
"rule": ("rules",)},
|
|
183
|
+
mechanisms={**dict.fromkeys(PROJECT_TYPES, "move"), "mcp": "remove_backup"},
|
|
184
|
+
mcp=McpSpec("project-json", p / ".mcp.json", ("mcpServers",)), project=p)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def harness_of(name: str) -> Harness:
|
|
188
|
+
table = harnesses()
|
|
189
|
+
if name not in table:
|
|
190
|
+
die(f"unknown harness {name!r} (expected: {', '.join(table)})")
|
|
191
|
+
return table[name]
|