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
|
@@ -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
|