agent-toggle 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_toggle/__init__.py +3 -0
- agent_toggle/__main__.py +3 -0
- agent_toggle/backends/__init__.py +0 -0
- agent_toggle/backends/flag_json.py +141 -0
- agent_toggle/backends/mcp_json.py +198 -0
- agent_toggle/backends/mcp_toml.py +90 -0
- agent_toggle/backends/plugin_cli.py +41 -0
- agent_toggle/cli.py +498 -0
- agent_toggle/companions.py +160 -0
- agent_toggle/cost.py +299 -0
- agent_toggle/doctor.py +285 -0
- agent_toggle/fs.py +409 -0
- agent_toggle/harnesses.py +191 -0
- agent_toggle/mechanisms.py +932 -0
- agent_toggle/ops.py +103 -0
- agent_toggle/output.py +153 -0
- agent_toggle/profiles.py +207 -0
- agent_toggle/shims/claude.md.tmpl +45 -0
- agent_toggle/store.py +252 -0
- agent_toggle/ui/__init__.py +0 -0
- agent_toggle/ui/picker.py +279 -0
- agent_toggle/undo.py +133 -0
- agent_toggle-0.1.0.dist-info/METADATA +580 -0
- agent_toggle-0.1.0.dist-info/RECORD +28 -0
- agent_toggle-0.1.0.dist-info/WHEEL +5 -0
- agent_toggle-0.1.0.dist-info/entry_points.txt +2 -0
- agent_toggle-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_toggle-0.1.0.dist-info/top_level.txt +1 -0
agent_toggle/__init__.py
ADDED
agent_toggle/__main__.py
ADDED
|
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()
|