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/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]