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.
@@ -0,0 +1,932 @@
1
+ """The toggle strategies: park/restore a file or dir, drive the plugin CLI,
2
+ remove/re-add an MCP server."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import time
8
+ from pathlib import Path
9
+ from typing import NamedTuple
10
+
11
+ from . import fs
12
+ from .backends.flag_json import FlagError, FlagMissing, read_flag, set_flag
13
+ from .backends.mcp_json import (
14
+ ProjectMcpError,
15
+ _finite,
16
+ _no_dup,
17
+ _not_json,
18
+ claude_mcp_config,
19
+ claudeai_connector_names,
20
+ project_mcp_remove,
21
+ project_mcp_restore,
22
+ read_project_mcp,
23
+ toggle_claudeai_connector,
24
+ write_project_mcp,
25
+ )
26
+ from .backends.mcp_toml import codex_mcp_add, codex_mcp_remove
27
+ from .backends.plugin_cli import claude_bin, run_cli
28
+ from .companions import move, park_companions, restore_companions
29
+ from .fs import gitignored, prune_empty
30
+ from .harnesses import PROBE_SUFFIXES, harnesses
31
+ from .output import CliError, Result, die
32
+ from .store import BATCH, check_entry, log, make_key, project_digest
33
+
34
+
35
+ def fail_row(out: Result, dry_run: bool, harness: str, type_: str, action: str, name: str,
36
+ msg: str, status: str = "error", *, batch: str = BATCH,
37
+ project: str | None = None) -> int:
38
+ """Record one failed item (returns 1, the fail count to add)."""
39
+ out.row(harness, type_, name, f"would-{action}" if dry_run else action, status, msg,
40
+ **({"project": project} if project else {}))
41
+ if not dry_run:
42
+ log(action, type_, name, status, msg, harness=harness, batch=batch, project=project,
43
+ scope="project" if project else "user")
44
+ return 1
45
+
46
+
47
+ def _refusal(entry: dict | None, table: dict, key: str, need: tuple[str, ...]) -> str | None:
48
+ """`refused: <reason>` when a state entry must not be replayed (DESIGN s6.1 row 2).
49
+
50
+ store.check_entry covers the path fields; this adds what the replay itself
51
+ reads: the entry must be the one its key names, carry the `need` fields the
52
+ mechanism dereferences, and its companions must stay in their own roots."""
53
+ if not entry:
54
+ return None
55
+ reason = _entry_reason(entry, table, key, need)
56
+ return f"refused: {reason}" if reason else None
57
+
58
+
59
+ def _abs_path(value) -> bool:
60
+ return isinstance(value, str) and bool(value) and Path(value).is_absolute() \
61
+ and ".." not in Path(value).parts
62
+
63
+
64
+ def _entry_reason(entry: dict, table: dict, key: str, need: tuple[str, ...]) -> str | None:
65
+ if not isinstance(entry, dict):
66
+ return "entry is not an object"
67
+ head, _, rest = key.partition(":")
68
+ type_, _, name = rest.partition(":")
69
+ if (entry.get("harness"), entry.get("type"), entry.get("name")) != \
70
+ (head.partition("@")[0], type_, name):
71
+ return f"entry does not match its key {key!r}"
72
+ for f in need:
73
+ if not isinstance(entry.get(f), str) or not entry.get(f):
74
+ return f"missing {f}"
75
+ if entry.get("backend") == "project-json" and "@" not in head:
76
+ return "project .mcp.json entry under a user-scope key"
77
+ if entry.get("backend") == "json" and (
78
+ "@" in head or getattr(table.get(head), "backend", None) != "json"):
79
+ return "json entry for a harness whose table row does not use the json backend"
80
+ if entry.get("backend") == "claude-json" and entry.get("scope") in ("user", "local"):
81
+ if entry["scope"] == "local":
82
+ # `project` here is the claude local-scope cwd, not a --project scope
83
+ proj = entry.get("project")
84
+ if not _abs_path(proj) or Path(proj) == Path(Path(proj).anchor):
85
+ return f"local-scope project is not a usable dir: {proj!r}"
86
+ entry = {k: v for k, v in entry.items() if k != "project"}
87
+ elif entry.get("backend") == "claude-json" and entry.get("scope") is not None:
88
+ return f"unknown mcp scope {entry.get('scope')!r}"
89
+ if entry.get("mechanism") == "flag" and not entry.get("connector"):
90
+ if reason := _flag_reason(entry, table):
91
+ return reason
92
+ comps = entry.get("companions") or []
93
+ if not isinstance(comps, list):
94
+ return "companions is not a list"
95
+ home = table[entry["harness"]].home if entry["harness"] in table else None
96
+ for c in comps:
97
+ src, dst = (c.get("to"), c.get("from")) if isinstance(c, dict) else (None, None)
98
+ if not (_abs_path(src) and fs.contained(Path(src), fs.companion_dir())):
99
+ return f"companion outside {fs.companion_dir()}: {src!r}"
100
+ if not (home and _abs_path(dst) and fs.contained(Path(dst), home)):
101
+ return f"companion origin outside the harness home: {dst!r}"
102
+ return check_entry(entry, table, key=key)
103
+
104
+
105
+ def flag_target(h, type_: str, name: str) -> tuple[Path, tuple[str, ...]] | None:
106
+ """(file, pointer) the table declares for this harness/type/name, or None.
107
+ `<name>` in the declared pointer is the item name; the file is under the home."""
108
+ spec = h.flags.get(type_)
109
+ if not spec:
110
+ return None
111
+ rel, pointer = spec
112
+ return h.home / rel, tuple(name if p == "<name>" else p for p in pointer)
113
+
114
+
115
+ def _flag_reason(entry: dict, table: dict) -> str | None:
116
+ """A flag entry may only replay the exact file+pointer the table declares for
117
+ its harness/type/name, so a tampered state file cannot aim the write elsewhere."""
118
+ h = table.get(entry.get("harness"))
119
+ target = flag_target(h, entry.get("type"), entry.get("name")) if h else None
120
+ f = entry.get("flag")
121
+ if target is None or not isinstance(f, dict):
122
+ return "flag entry has no flag record for this harness/type"
123
+ if not isinstance(f.get("file"), str) or Path(f["file"]) != target[0]:
124
+ return f"flag file is not the declared one: {f.get('file')!r}"
125
+ if not isinstance(f.get("pointer"), list) or tuple(f["pointer"]) != target[1]:
126
+ return f"flag pointer is not the declared one: {f.get('pointer')!r}"
127
+ if not isinstance(f.get("was"), bool):
128
+ return "flag.was is not a boolean"
129
+ return None
130
+
131
+
132
+ def _ok(out: Result, dry_run: bool, harness: str, type_: str, action: str, name: str,
133
+ text: str, **extra) -> dict:
134
+ """Record one success; a dry run records the plan instead, with human text
135
+ `would <action>` (the real text describes what already happened)."""
136
+ if dry_run:
137
+ return out.row(harness, type_, name, f"would-{action}", "planned",
138
+ f"would {action}", **extra)
139
+ return out.row(harness, type_, name, action, "ok", text, **extra)
140
+
141
+
142
+ def validate_name(name: str) -> None:
143
+ """Refuse names that could address anything outside the harness dir (exit 2)."""
144
+ parts = name.split(":")
145
+ if (not name or Path(name).is_absolute() or "/" in name or "\\" in name
146
+ or any(p in ("", ".", "..") or p.startswith("-") for p in parts)):
147
+ die(f"invalid name {name!r}: use plain names, `a:b` for nesting", 2)
148
+
149
+
150
+ def _valid_name(name: str) -> bool:
151
+ try:
152
+ validate_name(name)
153
+ except CliError:
154
+ return False
155
+ return True
156
+
157
+
158
+ def resolve_item(base: Path, name: str) -> Path | None:
159
+ """Find a resource on disk. `a:b` addresses a nested `a/b`.
160
+
161
+ The item itself may be a symlink (it is moved as a link), but its parent
162
+ must resolve inside `base`, so no name can reach outside the harness dir.
163
+ """
164
+ rel = name.replace(":", "/")
165
+ root = base.resolve()
166
+ for suffix in PROBE_SUFFIXES:
167
+ p = base / (rel + suffix)
168
+ if (p.exists() or p.is_symlink()) and p.parent.resolve().is_relative_to(root):
169
+ return p
170
+ return None
171
+
172
+
173
+ MCP_TOML_RE = re.compile(r"^\[mcp_servers\.([^.\]]+)\]\s*$", re.M)
174
+
175
+
176
+ def live_names(base: Path, type_: str) -> list[str]:
177
+ """Names of live resources, with nesting addressed by a colon."""
178
+ if not base.is_dir():
179
+ return []
180
+ if type_ == "skill":
181
+ return sorted(p.name for p in base.iterdir()
182
+ if (p.is_dir() or p.is_symlink()) and not p.name.startswith("."))
183
+ names = []
184
+ for p in sorted(base.rglob("*")):
185
+ if p.name.startswith("."):
186
+ continue
187
+ if p.suffix in (".md", ".toml", ".yaml", ".yml") or p.is_symlink() or (p.is_file() and not p.suffix):
188
+ rel = p.relative_to(base).with_suffix("")
189
+ names.append(str(rel).replace("/", ":"))
190
+ return sorted(set(names))
191
+
192
+
193
+ def live_mcp(home: Path, backend: str | None) -> list[str]:
194
+ """Names of the MCP servers currently configured for one harness."""
195
+ if backend == "claude-json":
196
+ try:
197
+ cfg = json.loads(fs.claude_json().read_text(encoding="utf-8"))
198
+ except (OSError, json.JSONDecodeError):
199
+ return []
200
+ # Local-scope servers are nested per project; without them the picker
201
+ # silently hides everything added with `claude mcp add -s local`.
202
+ names = set(cfg.get("mcpServers", {}))
203
+ for pdata in cfg.get("projects", {}).values():
204
+ if isinstance(pdata, dict):
205
+ names.update(pdata.get("mcpServers") or {})
206
+ return sorted(names)
207
+ if backend == "project-json": # home is <project>/.claude; repo text, so filter names
208
+ try:
209
+ names = read_project_mcp(home.parent / ".mcp.json")[1]["mcpServers"]
210
+ except ProjectMcpError:
211
+ return []
212
+ return sorted(n for n in names if n.isprintable() and _valid_name(n))
213
+ if backend == "json": # home is ~/.copilot; user-edited text, so filter names
214
+ try:
215
+ names = read_project_mcp(home / "mcp-config.json")[1]["mcpServers"]
216
+ except ProjectMcpError:
217
+ return []
218
+ return sorted(n for n in names if n.isprintable() and _valid_name(n))
219
+ if backend == "toml":
220
+ config = home / "config.toml"
221
+ if not config.is_file():
222
+ return []
223
+ try:
224
+ # Same text-level approach as the write path: no TOML parser needed.
225
+ return sorted(set(MCP_TOML_RE.findall(config.read_text(encoding="utf-8"))))
226
+ except OSError:
227
+ return []
228
+ return []
229
+
230
+
231
+ class DirView(NamedTuple):
232
+ """One candidate dir as the harness that physically owns it sees it."""
233
+ owner: str # harness whose state entry / park dir this item belongs to
234
+ live: Path # the dir, spelled by the owner (never a symlink if avoidable)
235
+ parked: Path # sibling `<live>-disabled`
236
+ sharers: list[str] # every harness whose dir resolves to the same real path
237
+
238
+
239
+ def dir_view(table: dict, harness: str, type_: str, home: Path, sub: str) -> DirView:
240
+ """Dedupe by real path: two harnesses whose dirs resolve to the same place share ONE
241
+ park dir and ONE state entry, owned by the harness whose home really holds it
242
+ (else the first non-symlink spelling, else the first in table order)."""
243
+ mine = home / sub
244
+ real = mine.resolve()
245
+ found = [(harness, mine)]
246
+ for hn, h in table.items():
247
+ for s in h.dirs.get(type_, ()):
248
+ d = (home if hn == harness else h.home) / s
249
+ if (hn, d) != (harness, mine) and d.resolve() == real:
250
+ found.append((hn, d))
251
+ order = list(table) # table order, so the owner never depends on who asks
252
+ found.sort(key=lambda f: order.index(f[0]) if f[0] in order else len(order))
253
+ plain = [(hn, d) for hn, d in found if not d.is_symlink()]
254
+ owner, live = next(((hn, d) for hn, d in plain
255
+ if hn in table and real.is_relative_to(table[hn].home.resolve())),
256
+ plain[0] if plain else found[0])
257
+ return DirView(owner, live, live.parent / f"{live.name}-disabled",
258
+ sorted({hn for hn, _ in found}))
259
+
260
+
261
+ def toggle_flag(action: str, type_: str, names: list[str], state: dict, harness: str,
262
+ out: Result, dry_run: bool = False, *, batch: str = BATCH,
263
+ optional: bool = False) -> tuple[int, list[str]]:
264
+ """Flip one boolean in the harness's strict-JSON config (mechanism `flag`).
265
+
266
+ Returns (fails, rest). With `optional` (a `move` type that ALSO has a flag),
267
+ a name with no flag entry (disable) or no flag state entry (enable) is not
268
+ handled here: it comes back in `rest` for the dir-move path. Otherwise every
269
+ name is handled and a missing/unsafe flag is an error row. The flag shapes
270
+ are ASSUMED (DESIGN s4/s11); JSONC/JSON5 is refused by flag_json."""
271
+ table = harnesses()
272
+ h = table[harness]
273
+ rest: list[str] = []
274
+ fails = 0
275
+
276
+ def fail(name: str, msg: str) -> int:
277
+ return fail_row(out, dry_run, harness, type_, action, name, msg, batch=batch)
278
+
279
+ for name in names:
280
+ fs.refresh_lock()
281
+ key = f"{harness}:{type_}:{name}"
282
+ file, pointer = flag_target(h, type_, name)
283
+ entry = state["disabled"].get(key)
284
+ if file.name not in h.editable:
285
+ if optional:
286
+ rest.append(name)
287
+ else:
288
+ fails += fail(name, f"{file.name} is not an editable {harness} file")
289
+ continue
290
+ if action == "disable":
291
+ if entry: # never overwrite another mechanism's record
292
+ fails += fail(name, "already disabled (see `agent-toggle list`)")
293
+ continue
294
+ try:
295
+ was = read_flag(file, pointer)
296
+ except FlagError as e:
297
+ if not optional:
298
+ fails += fail(name, str(e))
299
+ elif isinstance(e, FlagMissing):
300
+ rest.append(name) # no flag entry: plain dir move
301
+ else: # unsafe config: keep the dir move, say why
302
+ out.warn(f"{e}; parking {name} by dir move instead")
303
+ rest.append(name)
304
+ continue
305
+ if not was:
306
+ fails += fail(name, f"{'.'.join(pointer)} is already false in {file}")
307
+ elif dry_run:
308
+ _ok(out, dry_run, harness, type_, action, name, "")
309
+ else:
310
+ try:
311
+ set_flag(file, pointer, False)
312
+ except (FlagError, fs.WriteError) as e:
313
+ fails += fail(name, str(e))
314
+ continue
315
+ state["disabled"][key] = {
316
+ "mechanism": "flag", "harness": harness, "type": type_, "name": name,
317
+ "flag": {"file": str(file), "pointer": list(pointer), "was": was},
318
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
319
+ }
320
+ _ok(out, dry_run, harness, type_, action, name,
321
+ f"disabled (set {'.'.join(pointer)} to false in {file})")
322
+ log(action, type_, name, "ok", str(file), harness=harness, batch=batch)
323
+ continue
324
+ if optional and not (entry or {}).get("flag"):
325
+ rest.append(name)
326
+ continue
327
+ # _flag_reason is NOT gated on the entry's own mechanism/connector fields
328
+ # (they are untrusted): a tampered entry must refuse, never crash or replay.
329
+ refused = _refusal(entry, table, key, ())
330
+ if not refused and entry and (why := _flag_reason(entry, table)):
331
+ refused = f"refused: {why}"
332
+ if refused:
333
+ fails += fail(name, refused)
334
+ elif not entry:
335
+ fails += fail(name, f"no flag state entry to restore (if an interrupted run left "
336
+ f"{'.'.join(pointer)} false in {file}, set it to true by hand)")
337
+ elif dry_run:
338
+ _ok(out, dry_run, harness, type_, action, name, "")
339
+ else:
340
+ try:
341
+ set_flag(file, pointer, entry["flag"]["was"])
342
+ except (FlagError, fs.WriteError) as e:
343
+ fails += fail(name, str(e))
344
+ continue
345
+ state["disabled"].pop(key, None)
346
+ _ok(out, dry_run, harness, type_, action, name,
347
+ f"enabled (restored {'.'.join(pointer)} in {file})")
348
+ log(action, type_, name, "ok", str(file), harness=harness, batch=batch)
349
+ return fails, rest
350
+
351
+
352
+ def toggle_dir_type(action: str, type_: str, names: list[str], state: dict,
353
+ harness: str, home: Path, out: Result | None = None,
354
+ dry_run: bool = False, *, batch: str = BATCH,
355
+ table: dict | None = None) -> int:
356
+ """Park / restore skills, agents, commands. dry_run plans and writes nothing.
357
+
358
+ `table` = {"claude": harnesses.project_view(dir)} runs in project scope: items
359
+ park under fs.parked_dir()/<sha8>/<sub>-disabled, NEVER inside the project, keys
360
+ are `claude@<sha8>:...`, and no user-scope dir is ever a view (no dir_view aliasing).
361
+ """
362
+ out = out or Result()
363
+ table = table or harnesses()
364
+ proj = table[harness].project
365
+ pstr = str(proj) if proj else None
366
+ fails = 0
367
+
368
+ def fail(name: str, msg: str) -> int:
369
+ return fail_row(out, dry_run, harness, type_, action, name, msg, batch=batch,
370
+ project=pstr)
371
+
372
+ if type_ in table[harness].flags: # openclaw skill: flag when an entry exists
373
+ fails, names = toggle_flag(action, type_, names, state, harness, out, dry_run,
374
+ batch=batch, optional=True)
375
+ if proj:
376
+ park = fs.parked_dir() / project_digest(proj)
377
+ subs = table[harness].dirs[type_]
378
+ views = [DirView(harness, home / sub, park / f"{sub}-disabled", [harness])
379
+ for sub in subs if (home / sub).resolve().is_relative_to(proj)]
380
+ if not views: # e.g. .claude/skills -> ~/.claude/skills
381
+ for name in names:
382
+ fails += fail(name, f"{home / subs[0]} resolves outside the project {proj}")
383
+ return fails
384
+ else:
385
+ views = [dir_view(table, harness, type_, home, sub) for sub in table[harness].dirs[type_]]
386
+
387
+ if action == "disable" and names and not proj:
388
+ for v in views:
389
+ ohome = home if v.owner == harness else table[v.owner].home
390
+ if not gitignored(v.parked, ohome):
391
+ out.warn(f"{v.parked} is NOT gitignored -- disabling will dirty `git status`. "
392
+ f"fix: echo '{v.parked.name}/' >> {ohome}/.gitignore")
393
+ if markers := fs.sync_markers(v.live):
394
+ out.warn(f"{v.live} carries {', '.join(markers)} -- a sync job may re-create "
395
+ f"parked items; park in the source harness instead")
396
+
397
+ for name in names:
398
+ fs.refresh_lock()
399
+ if action == "disable":
400
+ v, src = next(((v, p) for v in views if (p := resolve_item(v.live, name))),
401
+ (views[0], None))
402
+ if src is None:
403
+ fails += fail(name, "not found under " + " or ".join(str(v.live) for v in views))
404
+ continue
405
+ key = make_key(v.owner, type_, name, project=proj)
406
+ others = [h for h in v.sharers if h != harness] # relative to this request
407
+ ohome = home if v.owner == harness else table[v.owner].home
408
+ # Preserve nesting: commands/orch/batch.md parks as orch/batch.md,
409
+ # so two different <group>/mcp.md cannot collide at the park root.
410
+ rel = src.relative_to(v.live)
411
+ if proj and not fs.contained(v.parked / rel, fs.parked_dir()):
412
+ fails += fail(name, f"{fs.parked_dir()} is reached through a symlink -- refusing")
413
+ continue
414
+ if proj and not dry_run:
415
+ fs.private_dir(fs.parked_dir())
416
+ try:
417
+ # ponytail: a project on another filesystem than ~/.agent-toggle makes
418
+ # shutil.move copy+delete (not atomic; symlinks kept as links, modes via
419
+ # copy2, ownership/xattrs may differ; a copy failing midway can leave a
420
+ # partial park target that later disables refuse to overwrite -- remove it
421
+ # by hand). Same-fs moves stay atomic renames.
422
+ target = move(src, (v.parked / rel).parent, dry_run)
423
+ except (OSError, FileNotFoundError, FileExistsError, NotADirectoryError) as e:
424
+ fails += fail(name, str(e))
425
+ continue
426
+ row = _ok(out, dry_run, harness, type_, action, name, _shared_text(
427
+ "disabled", v.owner, harness, others), parked_at=str(target),
428
+ **({"shared_with": others, "owner": v.owner} if others else {}))
429
+ # project scope moves the item only: repo text must not steer other tracked
430
+ # files out of a shared repo (companions are a user-scope heuristic)
431
+ companions = [] if proj else park_companions(
432
+ target, ohome, key.replace(":", "_"), out, dry_run, src if dry_run else None)
433
+ row["companions"] = companions
434
+ if proj:
435
+ row["project"] = pstr
436
+ out.warn(f"{src} {'would be' if dry_run else 'is'} a tracked deletion in git "
437
+ f"status; restore with: "
438
+ f"agent-toggle enable {type_} {name} --project {proj}")
439
+ if not dry_run:
440
+ state["disabled"][key] = {
441
+ "mechanism": "move", "harness": v.owner, "type": type_, "name": name,
442
+ "parked_at": str(target), "origin": str(src),
443
+ "companions": companions, **({"project": pstr} if proj else {}),
444
+ **({"shared_with": [h for h in v.sharers if h != v.owner]}
445
+ if len(v.sharers) > 1 else {}),
446
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
447
+ }
448
+ log(action, type_, name, "ok", str(target), harness=harness, batch=batch,
449
+ project=pstr, scope="project" if proj else "user")
450
+ else:
451
+ # The entry lives under the owning harness; a request through an alias
452
+ # (or a pre-alias entry under the requested harness) finds the same one.
453
+ keys = dict.fromkeys([make_key(v.owner, type_, name, project=proj) for v in views]
454
+ + [make_key(harness, type_, name, project=proj)])
455
+ key = next((k for k in keys if k in state["disabled"]), next(iter(keys)))
456
+ entry = state["disabled"].get(key)
457
+ if refused := _refusal(entry, table, key, ("parked_at", "origin")):
458
+ fails += fail(name, refused)
459
+ continue
460
+ if entry:
461
+ src = Path(entry["parked_at"])
462
+ v = next((v for v in views if src.is_relative_to(v.parked)), views[0])
463
+ else:
464
+ v, src = next(((v, p) for v in views if (p := resolve_item(v.parked, name))),
465
+ (views[0], None))
466
+ if src is None or not (src.exists() or src.is_symlink()):
467
+ fails += fail(name, "nothing parked to restore")
468
+ continue
469
+ others = [h for h in v.sharers if h != harness]
470
+ dest_parent = (Path(entry["origin"]).parent if entry
471
+ else (v.live / src.relative_to(v.parked)).parent)
472
+ try:
473
+ target = move(src, dest_parent, dry_run)
474
+ except (OSError, FileExistsError, NotADirectoryError) as e:
475
+ fails += fail(name, str(e))
476
+ continue
477
+ if not dry_run:
478
+ prune_empty(src.parent, v.parked)
479
+ _ok(out, dry_run, harness, type_, action, name,
480
+ _shared_text("enabled", v.owner, harness, others), restored_to=str(target),
481
+ **({"shared_with": others, "owner": v.owner} if others else {}),
482
+ **({"project": pstr} if proj else {}))
483
+ if entry:
484
+ restore_companions(entry, out, dry_run)
485
+ if not dry_run:
486
+ state["disabled"].pop(key, None)
487
+ if not dry_run:
488
+ log(action, type_, name, "ok", str(target), harness=harness, batch=batch,
489
+ project=pstr, scope="project" if proj else "user")
490
+ return fails
491
+
492
+
493
+ def _shared_text(verb: str, owner: str, harness: str, others: list[str]) -> str:
494
+ if not others:
495
+ return verb
496
+ where = f"parked under {owner}; " if owner != harness else ""
497
+ return f"{verb} (shared dir: {where}also affects {', '.join(others)})"
498
+
499
+
500
+ def toggle_plugin(action: str, names: list[str], state: dict, harness: str,
501
+ out: Result | None = None, dry_run: bool = False, *,
502
+ batch: str = BATCH) -> int:
503
+ """Claude Code has a native, fully reversible plugin disable -- drive it."""
504
+ out = out or Result()
505
+ if "plugin" in harnesses()[harness].flags:
506
+ return toggle_flag(action, "plugin", names, state, harness, out, dry_run, batch=batch)[0]
507
+ if harness != "claude":
508
+ for name in names:
509
+ fail_row(out, dry_run, harness, "plugin", action, name,
510
+ f"{harness} has no plugin CLI -- park it as a skill instead",
511
+ "unsupported", batch=batch)
512
+ return len(names)
513
+ fails = 0
514
+ for name in names:
515
+ fs.refresh_lock()
516
+ if dry_run:
517
+ if claude_bin():
518
+ _ok(out, dry_run, harness, "plugin", action, name, "")
519
+ else:
520
+ fails += fail_row(out, dry_run, harness, "plugin", action, name,
521
+ "claude CLI not found", batch=batch)
522
+ continue
523
+ ok, res = run_cli(claude_bin(), ["plugin", action, name])
524
+ if not ok:
525
+ fails += fail_row(out, dry_run, harness, "plugin", action, name,
526
+ res.splitlines()[-1] if res else "failed", batch=batch)
527
+ continue
528
+ key = f"{harness}:plugin:{name}"
529
+ if action == "disable":
530
+ state["disabled"][key] = {
531
+ "mechanism": "native_cli", "harness": harness, "type": "plugin",
532
+ "name": name, "native": True,
533
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
534
+ }
535
+ else:
536
+ state["disabled"].pop(key, None)
537
+ _ok(out, dry_run, harness, "plugin", action, name, f"{action}d")
538
+ log(action, "plugin", name, "ok", harness=harness, batch=batch)
539
+ return fails
540
+
541
+
542
+ def toggle_mcp(action: str, names: list[str], state: dict,
543
+ harness: str, home: Path, backend: str | None,
544
+ out: Result | None = None, dry_run: bool = False, *,
545
+ batch: str = BATCH) -> int:
546
+ """Remove / re-add MCP servers. dry_run plans and writes nothing."""
547
+ out = out or Result()
548
+ if "mcp" in harnesses()[harness].flags:
549
+ return toggle_flag(action, "mcp", names, state, harness, out, dry_run, batch=batch)[0]
550
+ if backend is None:
551
+ for name in names:
552
+ fail_row(out, dry_run, harness, "mcp", action, name,
553
+ f"{harness} stores MCP outside a togglable config "
554
+ f"(sqlite / none) -- not supported", "unsupported", batch=batch)
555
+ return len(names)
556
+
557
+ def fail(name: str, msg: str) -> int:
558
+ return fail_row(out, dry_run, harness, "mcp", action, name, msg, batch=batch)
559
+
560
+ if backend not in ("claude-json", "toml"): # fail closed: no id reaches an else branch
561
+ for name in names: # (before any directory is created)
562
+ fail(name, f"{harness} mcp backend {backend!r} is not handled here")
563
+ return len(names)
564
+
565
+ backup_dir = fs.backup_dir()
566
+ if not dry_run:
567
+ fs.private_dir(backup_dir)
568
+ fails = 0
569
+
570
+ table = harnesses()
571
+ for name in names:
572
+ fs.refresh_lock()
573
+ key = f"{harness}:mcp:{name}"
574
+ entry = state["disabled"].get(key)
575
+ need = () if (entry or {}).get("connector") else ("backup",)
576
+ if action == "enable" and (refused := _refusal(entry, table, key, need)):
577
+ fails += fail(name, refused)
578
+ continue
579
+
580
+ if backend == "claude-json" and name in claudeai_connector_names():
581
+ ok, detail = toggle_claudeai_connector(action, name, dry_run)
582
+ if not ok:
583
+ fails += fail(name, str(detail))
584
+ continue
585
+ n = len(detail)
586
+ if action == "disable":
587
+ if not dry_run:
588
+ state["disabled"][key] = {
589
+ "mechanism": "flag", "harness": harness, "type": "mcp",
590
+ "name": name, "connector": True,
591
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
592
+ }
593
+ _ok(out, dry_run, harness, "mcp", action, name,
594
+ f"disabled across {n} project(s) "
595
+ f"(claude.ai connector -- account stays connected, "
596
+ f"just parked per-project; new project dirs need a re-run)",
597
+ connector=True, projects=detail)
598
+ else:
599
+ if not dry_run:
600
+ state["disabled"].pop(key, None)
601
+ _ok(out, dry_run, harness, "mcp", action, name,
602
+ f"restored across {n} project(s) "
603
+ f"(open a NEW session for it to load)",
604
+ connector=True, projects=detail)
605
+ if not dry_run:
606
+ log(action, "mcp", name, "ok", json.dumps(detail), harness=harness,
607
+ batch=batch)
608
+ continue
609
+
610
+ bp = backup_dir / f"{harness}__{name.replace('/', '_')}.json"
611
+ scope, project = "user", None
612
+
613
+ if action == "disable":
614
+ if backend == "claude-json":
615
+ try:
616
+ found = claude_mcp_config(name)
617
+ except LookupError as e:
618
+ fails += fail(name, str(e))
619
+ continue
620
+ if found is None:
621
+ hint = ("not in ~/.claude.json (user or local scope) -- "
622
+ "a repo's own .mcp.json needs --project <dir>")
623
+ if not dry_run: # a dry run never shells out to claude
624
+ ok, cfg = run_cli(claude_bin(), ["mcp", "get", name])
625
+ if ok and "claude.ai config" in cfg:
626
+ hint = ("account-level claude.ai connector -- disable it "
627
+ "at claude.ai (Settings -> Connectors)")
628
+ fails += fail(name, hint)
629
+ continue
630
+ raw, scope, project = found
631
+ if dry_run:
632
+ if not claude_bin():
633
+ fails += fail(name, "claude CLI not found")
634
+ continue
635
+ else:
636
+ fs.atomic_write(bp, json.dumps(raw, indent=2, ensure_ascii=False))
637
+ ok, res = run_cli(claude_bin(), ["mcp", "remove", name, "-s", scope],
638
+ cwd=project)
639
+ if not ok:
640
+ fails += fail(name, res[:100])
641
+ continue
642
+ else: # toml
643
+ config = home / "config.toml"
644
+ # plan first, back up, THEN edit: the block is never only in memory
645
+ block = codex_mcp_remove(config, name, True) if config.exists() else None
646
+ if block is None:
647
+ fails += fail(name, f"no [mcp_servers.{name}] in {config}")
648
+ continue
649
+ if not dry_run:
650
+ fs.atomic_write(bp, json.dumps({"toml": block}, ensure_ascii=False,
651
+ indent=2))
652
+ try:
653
+ codex_mcp_remove(config, name)
654
+ except fs.WriteError as e: # config rolled back; drop the backup
655
+ bp.unlink(missing_ok=True)
656
+ fails += fail(name, str(e))
657
+ continue
658
+
659
+ where = f" [{scope}{f': {project}' if project else ''}]"
660
+ if not dry_run:
661
+ state["disabled"][key] = {
662
+ "mechanism": "remove_backup", "harness": harness, "type": "mcp",
663
+ "name": name, "backend": backend, "backup": str(bp),
664
+ "scope": scope, "project": project,
665
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
666
+ }
667
+ # claude local scope logs its real scope + cwd; undo treats every
668
+ # scope but `project` (a --project change) as user scope, which is right
669
+ # for local: the replay finds the user-keyed entry and its own cwd.
670
+ log(action, "mcp", name, "ok", str(bp), harness=harness, batch=batch,
671
+ project=project, scope=scope)
672
+ _ok(out, dry_run, harness, "mcp", action, name,
673
+ f"removed{where} (config saved to {bp})",
674
+ scope=scope, project=project, backup=str(bp))
675
+ else:
676
+ entry = state["disabled"].get(key)
677
+ path = Path(entry["backup"]) if entry else bp
678
+ if not path.is_file():
679
+ fails += fail(name, f"backup missing at {path}")
680
+ continue
681
+ try:
682
+ payload = path.read_text(encoding="utf-8")
683
+ except (OSError, ValueError) as e: # unreadable / not UTF-8: this row only
684
+ fails += fail(name, f"backup {path} is unreadable ({type(e).__name__}: {e})")
685
+ continue
686
+ if (entry or {}).get("backend", backend) == "toml":
687
+ if not dry_run:
688
+ try:
689
+ block = json.loads(payload)["toml"]
690
+ if not isinstance(block, str):
691
+ raise TypeError("toml is not text")
692
+ except (ValueError, KeyError, TypeError) as e:
693
+ fails += fail(name, f"backup {path} is unusable ({type(e).__name__}: {e})")
694
+ continue
695
+ try:
696
+ codex_mcp_add(home / "config.toml", block)
697
+ except (fs.WriteError, OSError, ValueError) as e: # the config, not the backup
698
+ fails += fail(name, str(e))
699
+ continue
700
+ else:
701
+ # Entries parked before scope tracking have neither field;
702
+ # they were user-scope by construction, so that is the default.
703
+ scope = (entry or {}).get("scope") or "user"
704
+ project = (entry or {}).get("project")
705
+ if project and not Path(project).is_dir():
706
+ fails += fail(name, f"project {project} is gone -- "
707
+ f"re-add it by hand from {path}")
708
+ continue
709
+ if dry_run:
710
+ if not claude_bin():
711
+ fails += fail(name, "claude CLI not found")
712
+ continue
713
+ else:
714
+ ok, res = run_cli(claude_bin(),
715
+ ["mcp", "add-json", "--scope", scope, name, payload],
716
+ cwd=project)
717
+ if not ok:
718
+ fails += fail(name, res[:120])
719
+ continue
720
+ if not dry_run:
721
+ state["disabled"].pop(key, None)
722
+ log(action, "mcp", name, "ok", harness=harness, batch=batch,
723
+ project=project, scope=scope)
724
+ _ok(out, dry_run, harness, "mcp", action, name,
725
+ "restored (open a NEW session for it to load)", backup=str(path))
726
+ return fails
727
+
728
+
729
+ def toggle_project_mcp(action: str, names: list[str], state: dict, harness: str,
730
+ out: Result, dry_run: bool, table: dict, *, batch: str = BATCH) -> int:
731
+ """`--project <dir>` MCP servers: remove_backup on the repo's own <dir>/.mcp.json.
732
+
733
+ A direct, verified JSON edit (fs.checked_write, rolled back on any failure) with
734
+ NO claude CLI call. disable saves the raw entry, and the exact before/after file
735
+ text, in a private backup under fs.backup_dir() (never inside the project);
736
+ enable puts the file back byte for byte when it is still as we left it, else
737
+ merges the entry in. State keys carry the project digest, so a project entry
738
+ never meets the user-scope `claude:mcp:<name>` one (nor claude `local` scope).
739
+ """
740
+ project = table[harness].project
741
+ pstr, file, digest = str(project), project / ".mcp.json", project_digest(project)
742
+ fails = 0
743
+
744
+ def fail(name: str, msg: str) -> int:
745
+ return fail_row(out, dry_run, harness, "mcp", action, name, msg, batch=batch,
746
+ project=pstr)
747
+
748
+ for name in names:
749
+ fs.refresh_lock()
750
+ key = make_key(harness, "mcp", name, project=project)
751
+ entry = state["disabled"].get(key)
752
+ bp = fs.backup_dir() / f"{digest}__{harness}__{name.replace('/', '_')}.json"
753
+ if action == "disable":
754
+ try:
755
+ raw, before, after = project_mcp_remove(file, name)
756
+ except ProjectMcpError as e:
757
+ fails += fail(name, str(e))
758
+ continue
759
+ if entry:
760
+ fails += fail(name, "already disabled in state -- enable it first")
761
+ continue
762
+ if not dry_run:
763
+ fs.private_dir(fs.backup_dir())
764
+ fs.atomic_write(bp, json.dumps(
765
+ {"project_json": {"entry": raw, "before": before, "after": after}},
766
+ ensure_ascii=False, indent=2))
767
+ try:
768
+ write_project_mcp(file, after, before)
769
+ except fs.WriteError as e: # file rolled back; drop the backup
770
+ if "restore failed" not in str(e): # else it holds the only good copy
771
+ bp.unlink(missing_ok=True)
772
+ fails += fail(name, str(e))
773
+ continue
774
+ state["disabled"][key] = {
775
+ "mechanism": "remove_backup", "harness": harness, "type": "mcp",
776
+ "name": name, "backend": "project-json", "backup": str(bp),
777
+ "scope": "project", "project": pstr,
778
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
779
+ }
780
+ log(action, "mcp", name, "ok", str(bp), harness=harness, batch=batch,
781
+ project=pstr, scope="project")
782
+ _ok(out, dry_run, harness, "mcp", action, name,
783
+ f"removed from {file} (config saved to {bp})", project=pstr, backup=str(bp))
784
+ out.warn(f"{file} {'would be' if dry_run else 'is'} a tracked change in git status; "
785
+ f"restore with: agent-toggle enable mcp {name} --project {project}")
786
+ continue
787
+ # enable
788
+ if not entry:
789
+ fails += fail(name, f"{name} is not disabled in project {pstr}")
790
+ continue
791
+ refused = _refusal(entry, table, key, ("backup",))
792
+ if not refused and Path(entry["backup"]).name.split("__")[0] != digest:
793
+ refused = "refused: backup belongs to another project"
794
+ if refused:
795
+ fails += fail(name, refused)
796
+ continue
797
+ try:
798
+ payload = json.loads(Path(entry["backup"]).read_text(encoding="utf-8"))["project_json"]
799
+ if not (isinstance(payload["entry"], dict) and isinstance(payload["before"], str)
800
+ and isinstance(payload["after"], str)):
801
+ raise TypeError("bad field")
802
+ except OSError:
803
+ fails += fail(name, f"backup missing at {entry['backup']}")
804
+ continue
805
+ except (ValueError, KeyError, TypeError):
806
+ fails += fail(name, f"refused: backup {entry['backup']} is not a project .mcp.json backup")
807
+ continue
808
+ try:
809
+ current, text = project_mcp_restore(file, name, payload)
810
+ if not dry_run:
811
+ write_project_mcp(file, text, current)
812
+ except (ProjectMcpError, fs.WriteError) as e:
813
+ fails += fail(name, str(e))
814
+ continue
815
+ if not dry_run:
816
+ state["disabled"].pop(key, None)
817
+ log(action, "mcp", name, "ok", harness=harness, batch=batch, project=pstr,
818
+ scope="project")
819
+ _ok(out, dry_run, harness, "mcp", action, name,
820
+ "restored (open a NEW session for it to load)", project=pstr,
821
+ backup=entry["backup"])
822
+ return fails
823
+
824
+
825
+ def toggle_json_mcp(action: str, names: list[str], state: dict, harness: str,
826
+ out: Result, dry_run: bool, table: dict, *, batch: str = BATCH) -> int:
827
+ """User-scope strict-JSON MCP file (backend `json`, e.g. copilot mcp-config.json).
828
+
829
+ Same direct, verified edit as toggle_project_mcp (fs.checked_write, rolled back on
830
+ failure, NO claude CLI call), but the file comes from the harness TABLE
831
+ (h.mcp.file, which must be in h.editable), never from the state entry. The backup
832
+ `<harness>__<name>.json` (0600) holds the raw entry plus the exact before/after
833
+ text; enable restores the pre-disable bytes when the file is unchanged since, else
834
+ merges the entry in. A JSONC / symlinked / oversized file is refused untouched."""
835
+ h = table[harness]
836
+ file = h.mcp.file
837
+ fails = 0
838
+
839
+ def fail(name: str, msg: str) -> int:
840
+ return fail_row(out, dry_run, harness, "mcp", action, name, msg, batch=batch)
841
+
842
+ for name in names:
843
+ fs.refresh_lock()
844
+ if h.backend != "json" or file.name not in h.editable:
845
+ fails += fail(name, f"{file.name} is not an editable {harness} file")
846
+ continue
847
+ key = make_key(harness, "mcp", name)
848
+ entry = state["disabled"].get(key)
849
+ bp = fs.backup_dir() / f"{harness}__{name.replace('/', '_')}.json"
850
+ if action == "disable":
851
+ try:
852
+ raw, before, after = project_mcp_remove(file, name)
853
+ except ProjectMcpError as e:
854
+ fails += fail(name, str(e))
855
+ continue
856
+ if entry:
857
+ fails += fail(name, "already disabled in state -- enable it first")
858
+ continue
859
+ if not dry_run:
860
+ fs.private_dir(fs.backup_dir())
861
+ fs.atomic_write(bp, json.dumps(
862
+ {"json": {"entry": raw, "before": before, "after": after}},
863
+ ensure_ascii=False, indent=2))
864
+ try:
865
+ write_project_mcp(file, after, before)
866
+ except fs.WriteError as e: # file rolled back; drop the backup
867
+ if "restore failed" not in str(e): # else it holds the only good copy
868
+ bp.unlink(missing_ok=True)
869
+ fails += fail(name, str(e))
870
+ continue
871
+ state["disabled"][key] = {
872
+ "mechanism": "remove_backup", "harness": harness, "type": "mcp",
873
+ "name": name, "backend": "json", "backup": str(bp),
874
+ "scope": "user", "project": None,
875
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
876
+ }
877
+ log(action, "mcp", name, "ok", str(bp), harness=harness, batch=batch,
878
+ project=None, scope="user")
879
+ _ok(out, dry_run, harness, "mcp", action, name,
880
+ f"removed from {file} (config saved to {bp})", scope="user", project=None,
881
+ backup=str(bp))
882
+ continue
883
+ # enable
884
+ if not entry:
885
+ fails += fail(name, f"{name} is not disabled on {harness}")
886
+ continue
887
+ refused = _refusal(entry, table, key, ("backup",))
888
+ if not refused and entry.get("backend") != "json":
889
+ refused = "refused: entry is not a json-backend entry"
890
+ if not refused and Path(entry["backup"]) != bp:
891
+ refused = "refused: backup is not the one declared for this server"
892
+ if refused:
893
+ fails += fail(name, refused)
894
+ continue
895
+ try:
896
+ payload = json.loads(bp.read_text(encoding="utf-8"))["json"]
897
+ if not (isinstance(payload["entry"], dict) and isinstance(payload["before"], str)
898
+ and isinstance(payload["after"], str)):
899
+ raise TypeError("bad field")
900
+ # `before` is written back verbatim when the file is unchanged: it must be
901
+ # exactly the strict-JSON config this server was removed from
902
+ servers = json.loads(payload["before"], object_pairs_hook=_no_dup,
903
+ parse_constant=_not_json, parse_float=_finite)["mcpServers"]
904
+ if servers[name] != payload["entry"]:
905
+ raise ValueError("before does not hold the entry")
906
+ except OSError:
907
+ fails += fail(name, f"backup missing at {bp}")
908
+ continue
909
+ except (ValueError, KeyError, TypeError, RecursionError):
910
+ fails += fail(name, f"refused: backup {bp} is not a {harness} mcp backup")
911
+ continue
912
+ try:
913
+ current, text = project_mcp_restore(file, name, payload)
914
+ if not dry_run:
915
+ write_project_mcp(file, text, current)
916
+ except ProjectMcpError as e:
917
+ msg = str(e)
918
+ if "backup stays in state" not in msg: # file / mcpServers key vanished
919
+ msg += (f" -- restore {file.name} with an mcpServers object first; "
920
+ f"the backup stays in state")
921
+ fails += fail(name, msg)
922
+ continue
923
+ except fs.WriteError as e:
924
+ fails += fail(name, str(e))
925
+ continue
926
+ if not dry_run:
927
+ state["disabled"].pop(key, None)
928
+ log(action, "mcp", name, "ok", harness=harness, batch=batch, project=None,
929
+ scope="user")
930
+ _ok(out, dry_run, harness, "mcp", action, name,
931
+ "restored (open a NEW session for it to load)", backup=str(bp))
932
+ return fails