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,3 @@
1
+ """agent-toggle: temporarily disable / re-enable AI-agent resources."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from agent_toggle.cli import main
2
+
3
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,141 @@
1
+ """Boolean flag in a JSON config (DESIGN s4, s5.5): openclaw skills/plugins, opencode mcp.
2
+
3
+ The edit is TEXT-LEVEL: walk to the value span and replace only the `true` /
4
+ `false` token, so comments-free formatting, key order, escapes, CRLF, tabs and
5
+ a BOM all survive byte for byte. Never json.dump the whole file.
6
+
7
+ Decisions:
8
+ - Strict JSON only. opencode.json may be JSONC (comments, trailing commas) and
9
+ the openclaw config may be JSON5; such files are REFUSED (FlagError), never
10
+ silently rewritten, since a re-serialise would drop the user's comments.
11
+ - A missing pointer is refused: we never invent a key, because an absent key
12
+ means the harness default, which we cannot know.
13
+ - A duplicated key anywhere in the file is refused: which copy the harness
14
+ honours is parser-specific, so flipping either one could be a no-op.
15
+ - The shapes `skills.entries.<name>.enabled`, `plugins.entries.<name>.enabled`
16
+ (openclaw) and `mcp.<name>.enabled` (opencode) are ASSUMED from DESIGN s4/s11,
17
+ not verified on a real install.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from json.decoder import WHITESPACE, scanstring
23
+ from pathlib import Path
24
+ from typing import Sequence
25
+
26
+ from agent_toggle import fs
27
+
28
+ _DECODER = json.JSONDecoder()
29
+ BOM = "\ufeff"
30
+
31
+
32
+ class FlagError(ValueError):
33
+ """The flag cannot be read or set safely; the message says why."""
34
+
35
+
36
+ class FlagMissing(FlagError):
37
+ """The file or the pointer does not exist (as opposed to an unsafe file)."""
38
+
39
+
40
+ def _no_dupes(pairs):
41
+ seen = set()
42
+ for k, _ in pairs:
43
+ if k in seen:
44
+ raise FlagError(f"duplicate key {k!r}")
45
+ seen.add(k)
46
+ return dict(pairs)
47
+
48
+
49
+ def _no_constant(name):
50
+ raise ValueError(f"{name} is not valid JSON")
51
+
52
+
53
+ def _strict(text: str, where: str) -> None:
54
+ """Parse as strict JSON (no comments, trailing commas, NaN, duplicate keys)."""
55
+ try:
56
+ json.loads(text.removeprefix(BOM), object_pairs_hook=_no_dupes,
57
+ parse_constant=_no_constant)
58
+ except FlagError as e:
59
+ raise FlagError(f"{where}: {e}; refusing to edit") from None
60
+ except (ValueError, RecursionError) as e: # 3.10-3.12 recurse on deep nesting
61
+ raise FlagError(f"{where}: not strict JSON (JSONC/JSON5 is not supported): {e}") \
62
+ from None
63
+
64
+
65
+ def _ws(text: str, i: int) -> int:
66
+ return WHITESPACE.match(text, i).end()
67
+
68
+
69
+ def _span(text: str, pointer: Sequence[str], where: str) -> tuple[int, int, bool]:
70
+ """(start, end, value) of the boolean token at `pointer`. `text` must already
71
+ have passed _strict, so the walk only has to find, not validate."""
72
+ shown = ".".join(pointer)
73
+ if not pointer:
74
+ raise FlagError(f"{where}: empty pointer")
75
+ i = _ws(text, 1 if text.startswith(BOM) else 0)
76
+ for depth, want in enumerate(pointer):
77
+ if text[i:i + 1] != "{":
78
+ raise FlagMissing(f"{where}: {shown} not found")
79
+ i = _ws(text, i + 1)
80
+ while True:
81
+ if text[i] == "}":
82
+ raise FlagMissing(f"{where}: {shown} not found")
83
+ key, i = scanstring(text, i + 1) # text[i] is the opening quote
84
+ i = _ws(text, _ws(text, i) + 1) # past ':'
85
+ if key == want:
86
+ break
87
+ _, i = _DECODER.raw_decode(text, i) # skip this value
88
+ i = _ws(text, i)
89
+ if text[i] == ",":
90
+ i = _ws(text, i + 1)
91
+ for token, value in (("true", True), ("false", False)):
92
+ if text.startswith(token, i):
93
+ return i, i + len(token), value
94
+ raise FlagError(f"{where}: {shown} is not a boolean")
95
+
96
+
97
+ def _load(file: Path) -> str:
98
+ try:
99
+ text = Path(file).read_bytes().decode("utf-8")
100
+ except UnicodeDecodeError as e:
101
+ raise FlagError(f"{file}: not strict JSON (not UTF-8: {e})") from None
102
+ except OSError as e: # missing / unreadable: a row, not a traceback
103
+ raise (FlagMissing if isinstance(e, FileNotFoundError) else FlagError)(f"{file}: cannot read ({e.strerror or e})") from None
104
+ _strict(text, str(file))
105
+ return text
106
+
107
+
108
+ def read_flag(file: Path, pointer: Sequence[str]) -> bool:
109
+ """The boolean at `pointer` (a sequence of object keys) in `file`."""
110
+ text = _load(file)
111
+ return _span(text, pointer, str(file))[2]
112
+
113
+
114
+ def set_flag(file: Path, pointer: Sequence[str], value: bool) -> bool:
115
+ """Set the boolean at `pointer` to `value`; return the previous value.
116
+
117
+ Same value -> no write. Otherwise written through fs.checked_write; the
118
+ invariant is that the file reads back as exactly `before` with only that
119
+ token swapped, still strict JSON, with the new value at `pointer`. Any
120
+ mismatch (including a concurrent edit since our read) rolls back and
121
+ raises fs.WriteError.
122
+ """
123
+ where = str(file)
124
+ text = _load(file)
125
+ start, end, was = _span(text, pointer, where)
126
+ if was is bool(value):
127
+ return was
128
+ expected = text[:start] + ("true" if value else "false") + text[end:]
129
+
130
+ def verify(before: str, after: str) -> str:
131
+ if before != text:
132
+ raise ValueError("file changed since it was read")
133
+ if after != expected:
134
+ raise ValueError("result differs from the one-token edit")
135
+ _strict(after, where)
136
+ if _span(after, pointer, where)[2] is not bool(value):
137
+ raise ValueError("flag did not take the new value")
138
+ return ""
139
+
140
+ fs.checked_write(Path(file), expected, verify)
141
+ return was
@@ -0,0 +1,198 @@
1
+ """JSON-key MCP backend: raw server entries in ~/.claude.json."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .. import fs
8
+
9
+
10
+ def claude_mcp_config(name: str) -> tuple[dict, str, str | None] | None:
11
+ """Find a server's RAW config in ~/.claude.json -> (config, scope, project).
12
+
13
+ `claude mcp get` prints a human summary that silently drops auth fields
14
+ (headers, headersHelper), so a backup built from it restores a server that
15
+ then fails with 401. The raw entry is the only lossless source.
16
+
17
+ Two places hold one: user scope at the top level, and local scope nested
18
+ under projects/<dir>. The project path travels with the backup because
19
+ `claude mcp remove -s local` only sees the project it is run in -- restore
20
+ it from the wrong directory and the server reappears in the wrong project.
21
+
22
+ Raises LookupError when one name is local-scope in several projects and
23
+ the cwd does not pick a winner.
24
+ """
25
+ try:
26
+ cfg = json.loads(fs.claude_json().read_text(encoding="utf-8"))
27
+ except (OSError, json.JSONDecodeError):
28
+ return None
29
+
30
+ raw = cfg.get("mcpServers", {}).get(name)
31
+ if raw is not None:
32
+ return raw, "user", None
33
+
34
+ hits = [(proj, pdata["mcpServers"][name])
35
+ for proj, pdata in cfg.get("projects", {}).items()
36
+ if isinstance(pdata, dict) and name in (pdata.get("mcpServers") or {})]
37
+ if not hits:
38
+ return None
39
+ cwd = str(Path.cwd())
40
+ for proj, raw in hits:
41
+ if proj == cwd:
42
+ return raw, "local", proj
43
+ if len(hits) > 1:
44
+ raise LookupError(
45
+ f"local-scope in {len(hits)} projects "
46
+ f"({', '.join(p for p, _ in hits)}) -- cd into the one you mean")
47
+ return hits[0][1], "local", hits[0][0]
48
+
49
+
50
+ def claudeai_connector_names() -> set[str]:
51
+ """Names claude.ai connectors are known under (account-level, no local
52
+ mcpServers entry -- .claudeAiMcpEverConnected is the only place they are
53
+ listed)."""
54
+ try:
55
+ cfg = json.loads(fs.claude_json().read_text(encoding="utf-8"))
56
+ except (OSError, json.JSONDecodeError):
57
+ return set()
58
+ return set(cfg.get("claudeAiMcpEverConnected", []) or [])
59
+
60
+
61
+ def toggle_claudeai_connector(action: str, name: str,
62
+ dry_run: bool = False) -> tuple[bool, list | str]:
63
+ """Fan a claude.ai connector on/off across every EXISTING project entry
64
+ in ~/.claude.json's disabledMcpServers list -- the only place /mcp writes
65
+ that state; no `claude mcp` CLI verb reaches it.
66
+
67
+ ponytail: covers projects that exist now; a project dir opened for the
68
+ first time later starts without the entry until this is re-run for it.
69
+ """
70
+ path = fs.claude_json()
71
+ try:
72
+ cfg = json.loads(path.read_text(encoding="utf-8"))
73
+ except (OSError, json.JSONDecodeError) as e:
74
+ return False, f"~/.claude.json unreadable: {e}"
75
+
76
+ touched = []
77
+ for proj, pdata in cfg.get("projects", {}).items():
78
+ if not isinstance(pdata, dict):
79
+ continue
80
+ cur = pdata.get("disabledMcpServers") or []
81
+ has = name in cur
82
+ if action == "disable" and not has:
83
+ pdata["disabledMcpServers"] = [*cur, name]
84
+ touched.append(proj)
85
+ elif action == "enable" and has:
86
+ pdata["disabledMcpServers"] = [n for n in cur if n != name]
87
+ touched.append(proj)
88
+
89
+ if not touched or dry_run:
90
+ return True, touched
91
+ text = json.dumps(cfg, indent=2, ensure_ascii=False)
92
+ try:
93
+ fs.checked_write(path, text, fs.json_verify(text))
94
+ except fs.WriteError as e: # rolled back to the previous bytes
95
+ return False, str(e)
96
+ return True, touched
97
+
98
+
99
+ # ---------------------------------------------------- project <dir>/.mcp.json
100
+ # The file is repo-controlled text: strict JSON only (no JSONC/BOM/duplicate keys),
101
+ # never edited through a symlink, never larger than MAX_PROJECT_MCP.
102
+
103
+ MAX_PROJECT_MCP = 4 << 20
104
+
105
+
106
+ class ProjectMcpError(Exception):
107
+ """A .mcp.json that must not be edited; the message is the error row detail."""
108
+
109
+
110
+ def _no_dup(pairs: list) -> dict:
111
+ out: dict = {}
112
+ for k, v in pairs:
113
+ if k in out:
114
+ raise ValueError(f"duplicate key {k!r}")
115
+ out[k] = v
116
+ return out
117
+
118
+
119
+ def _not_json(const: str):
120
+ raise ValueError(f"{const} is not JSON")
121
+
122
+
123
+ def _finite(num: str) -> float:
124
+ f = float(num)
125
+ if f in (float("inf"), float("-inf")):
126
+ raise ValueError(f"{num} is out of range")
127
+ return f
128
+
129
+
130
+ def read_project_mcp(file: Path) -> tuple[str, dict]:
131
+ """(exact text, parsed object) of a project .mcp.json, or ProjectMcpError."""
132
+ if file.is_symlink():
133
+ raise ProjectMcpError(f"{file} is a symlink -- refusing to edit through it")
134
+ try:
135
+ if file.stat().st_size > MAX_PROJECT_MCP:
136
+ raise ProjectMcpError(f"{file} is larger than {MAX_PROJECT_MCP >> 20} MiB")
137
+ text = file.read_bytes().decode("utf-8") # bytes: CRLF must survive
138
+ except FileNotFoundError:
139
+ raise ProjectMcpError(f"{file} not found") from None
140
+ except (OSError, UnicodeDecodeError) as e:
141
+ raise ProjectMcpError(f"{file} unreadable: {e}") from None
142
+ try:
143
+ obj = json.loads(text, object_pairs_hook=_no_dup, parse_constant=_not_json,
144
+ parse_float=_finite)
145
+ json.dumps(obj, ensure_ascii=False).encode("utf-8") # lone surrogates
146
+ except (ValueError, RecursionError) as e:
147
+ raise ProjectMcpError(f"{file} is not strict JSON ({e}) -- refusing "
148
+ f"(JSONC, BOM, trailing commas and duplicate keys are not supported)"
149
+ ) from None
150
+ if not isinstance(obj, dict) or not isinstance(obj.get("mcpServers"), dict):
151
+ raise ProjectMcpError(f"{file} has no mcpServers object")
152
+ return text, obj
153
+
154
+
155
+ def _dump_like(obj: dict, text: str) -> str:
156
+ """Serialise `obj` in the layout of `text` (tab/2-space indent, CRLF, final newline)."""
157
+ out = json.dumps(obj, indent="\t" if "\n\t" in text else 2 if "\n" in text else None,
158
+ ensure_ascii=False)
159
+ if text.endswith("\n"):
160
+ out += "\n"
161
+ return out.replace("\n", "\r\n") if "\r\n" in text else out
162
+
163
+
164
+ def project_mcp_remove(file: Path, name: str) -> tuple[dict, str, str]:
165
+ """Plan the removal of `name`: (its raw entry, text before, text after). Writes nothing."""
166
+ text, obj = read_project_mcp(file)
167
+ if name not in obj["mcpServers"]:
168
+ raise ProjectMcpError(f"no mcpServers.{name} in {file}")
169
+ raw = obj["mcpServers"].pop(name)
170
+ return raw, text, _dump_like(obj, text)
171
+
172
+
173
+ def project_mcp_restore(file: Path, name: str, payload: dict) -> tuple[str, str]:
174
+ """Plan putting `name` back: (current text, new text). The new text is the exact
175
+ pre-disable text when the file is still as we left it, else the entry merged into
176
+ the current file. Writes nothing."""
177
+ text, obj = read_project_mcp(file)
178
+ if name in obj["mcpServers"]:
179
+ raise ProjectMcpError(f"mcpServers.{name} is already defined in {file} -- "
180
+ f"remove it first, the backup stays in state")
181
+ if text == payload["after"]:
182
+ return text, payload["before"]
183
+ obj["mcpServers"][name] = payload["entry"]
184
+ return text, _dump_like(obj, text)
185
+
186
+
187
+ def write_project_mcp(file: Path, text: str, expect_before: str) -> None:
188
+ """Replace the file with `text` (the whole planned result); fs.WriteError, with
189
+ the previous bytes and mode restored, when the file is no longer `expect_before`
190
+ (an editor saved between plan and write) or the written result is not exactly `text`."""
191
+ check = fs.json_verify(text)
192
+
193
+ def verify(before: str, after: str) -> str:
194
+ if before != expect_before:
195
+ raise ValueError("the file changed while it was being edited")
196
+ return check(before, after)
197
+
198
+ fs.checked_write(file, text, verify)
@@ -0,0 +1,90 @@
1
+ """TOML-block MCP backend (codex config.toml): verbatim text slices."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from .. import fs
8
+
9
+
10
+ def toml_block(text: str, name: str) -> tuple[int, int] | None:
11
+ """Line span of `[mcp_servers.<name>]` and its sub-tables, or None.
12
+
13
+ Returned as a slice so the backup is the VERBATIM source text. Python's
14
+ stdlib reads TOML (tomllib) but cannot write it, and hand-rolling a
15
+ serializer would mangle comments and formatting -- a text slice is both
16
+ lossless and far less code.
17
+ """
18
+ lines = text.splitlines(keepends=True)
19
+ head = f"[mcp_servers.{name}]"
20
+ sub = f"[mcp_servers.{name}."
21
+ start = end = None
22
+ for i, line in enumerate(lines):
23
+ s = line.strip()
24
+ if s == head:
25
+ start = i
26
+ end = len(lines)
27
+ elif start is not None and s.startswith("["):
28
+ if not s.startswith(sub):
29
+ end = i
30
+ break
31
+ return None if start is None else (start, end)
32
+
33
+
34
+ def remove_verify(text: str, name: str, block: str):
35
+ """verify() for a removal planned against `text`: the file is still exactly what we
36
+ read (no concurrent edit), and only that block is gone. The note says
37
+ `unverified (no tomllib)` when no TOML parser could also parse the result."""
38
+ parse = fs.toml_verify()
39
+
40
+ def verify(before: str, after: str) -> str:
41
+ if before != text:
42
+ raise ValueError("file changed since it was read")
43
+ if after != before.replace(block, "", 1) or toml_block(after, name) is not None:
44
+ raise ValueError("result differs from the one-block removal")
45
+ return parse(before, after)
46
+ return verify
47
+
48
+
49
+ def add_verify(text: str, block: str):
50
+ """verify() for appending `block` to `text` (see remove_verify)."""
51
+ parse = fs.toml_verify()
52
+
53
+ def verify(before: str, after: str) -> str:
54
+ if before != text:
55
+ raise ValueError("file changed since it was read")
56
+ if not (after.startswith(before) and after.endswith(block.rstrip("\n") + "\n")):
57
+ raise ValueError("result differs from the one-block append")
58
+ return parse(before, after)
59
+ return verify
60
+
61
+
62
+ def _read(config: Path) -> str:
63
+ """The file's exact text: read_text would turn CRLF into LF and break byte-identity."""
64
+ return config.read_bytes().decode("utf-8")
65
+
66
+
67
+ def codex_mcp_remove(config: Path, name: str, dry_run: bool = False) -> str | None:
68
+ text = _read(config)
69
+ span = toml_block(text, name)
70
+ if span is None:
71
+ return None
72
+ lines = text.splitlines(keepends=True)
73
+ block = "".join(lines[span[0]:span[1]])
74
+ if dry_run:
75
+ return block
76
+ after = "".join(lines[:span[0]] + lines[span[1]:])
77
+ fs.checked_write(config, after, remove_verify(text, name, block)) # WriteError, rolled back
78
+ return block
79
+
80
+
81
+ def codex_mcp_add(config: Path, block: str) -> None:
82
+ text = _read(config) if config.exists() else ""
83
+ read = text
84
+ if text and not text.endswith("\n"):
85
+ text += "\n"
86
+ # Removal leaves the blank line that separated the block from its neighbour;
87
+ # not adding a second one makes disable -> enable byte-identical for a last block.
88
+ sep = "" if not text or re.search(r"\n\r?\n\Z", text) else "\n"
89
+ after = text + sep + block.rstrip("\n") + "\n"
90
+ fs.checked_write(config, after, add_verify(read, block)) # raises fs.WriteError, rolled back
@@ -0,0 +1,41 @@
1
+ """Claude CLI access: binary lookup and the subprocess runner.
2
+
3
+ `which` and `runner` are module-level hooks so tests can stub them and never
4
+ spawn the real claude CLI.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+
12
+ from .. import fs
13
+
14
+ which = shutil.which
15
+ runner = subprocess.run
16
+
17
+
18
+ def claude_bin() -> str | None:
19
+ """Find the claude CLI. It is often absent from a hook/agent PATH."""
20
+ found = which("claude")
21
+ if found:
22
+ return found
23
+ home = fs.home()
24
+ for cand in (home / ".local/bin/claude", home / ".claude/local/claude"):
25
+ if cand.is_file() and os.access(cand, os.X_OK):
26
+ return str(cand)
27
+ return None
28
+
29
+
30
+ def run_cli(binary: str | None, args: list[str], cwd: str | None = None) -> tuple[bool, str]:
31
+ """Run a harness CLI. `cwd` matters: `claude mcp -s local` is per-project."""
32
+ if not binary:
33
+ return False, "CLI not found on PATH"
34
+ try:
35
+ p = runner([binary, *args], capture_output=True, text=True,
36
+ timeout=120, cwd=cwd)
37
+ except subprocess.TimeoutExpired:
38
+ return False, "CLI timed out after 120s"
39
+ except OSError as e: # cwd gone, binary unexecutable
40
+ return False, f"cannot run CLI{f' in {cwd}' if cwd else ''}: {e}"
41
+ return p.returncode == 0, (p.stdout + p.stderr).strip()