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/cost.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Startup token estimates for every togglable item (read-only, stdlib only).
|
|
2
|
+
|
|
3
|
+
Formula (documented +-25 %; `cost --json` carries it as `formula`):
|
|
4
|
+
|
|
5
|
+
tokens = ceil(chars / 4) -- the tokenizer-free rule of thumb
|
|
6
|
+
|
|
7
|
+
skill chars of frontmatter `name` + `description` (body loads on invocation)
|
|
8
|
+
agent chars of frontmatter `name` + `description` + `tools`
|
|
9
|
+
command chars of the name + frontmatter `description` + `argument-hint`
|
|
10
|
+
rule chars of the WHOLE file (rules are injected in full)
|
|
11
|
+
mcp tools x per-tool weight; the tool count comes from the server entry's
|
|
12
|
+
tool list / `[mcp_servers.X.tools.*]` tables when present, else a flat
|
|
13
|
+
default. Harnesses that defer schemas (claude) load tool NAMES only:
|
|
14
|
+
10 tok per tool, flat 20 tok per server when the count is unknown.
|
|
15
|
+
Others: 300 tok per tool, flat 5 tools (1500 tok) when unknown.
|
|
16
|
+
plugin sum of its bundled skills/agents/commands/.mcp.json under `installPath`
|
|
17
|
+
|
|
18
|
+
A missing `name` falls back to the file/dir name. Frontmatter is read once, only the
|
|
19
|
+
head of the file up to the closing `---` (HEAD_CAP bytes at most); there is no cache.
|
|
20
|
+
Only the static, always-loaded text is estimated -- measured numbers come from the
|
|
21
|
+
harness. A parked item costs 0 now and reports `would_save`, the tokens it would load
|
|
22
|
+
if restored.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
from collections import Counter
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Callable
|
|
32
|
+
|
|
33
|
+
from . import fs
|
|
34
|
+
from .backends.mcp_json import ProjectMcpError, read_project_mcp
|
|
35
|
+
from .backends.plugin_cli import claude_bin, run_cli
|
|
36
|
+
from .mechanisms import _valid_name, dir_view, live_mcp, live_names, resolve_item
|
|
37
|
+
|
|
38
|
+
CHARS_PER_TOKEN = 4
|
|
39
|
+
HEAD_CAP = 16 * 1024 # bytes read per frontmatter block
|
|
40
|
+
RULE_CAP = 1024 * 1024 # bytes read per whole-file rule
|
|
41
|
+
MCP_TOKENS_PER_TOOL = {"full": 300, "deferred": 10}
|
|
42
|
+
MCP_FLAT = {"full": 5 * 300, "deferred": 20} # per server, tool count unknown
|
|
43
|
+
DEFERS_SCHEMAS = frozenset({"claude"}) # loads MCP schemas on demand
|
|
44
|
+
FIELDS = {"skill": ("name", "description"),
|
|
45
|
+
"agent": ("name", "description", "tools"),
|
|
46
|
+
"command": ("description", "argument-hint")}
|
|
47
|
+
|
|
48
|
+
FORMULA = {
|
|
49
|
+
"tokens": "ceil(chars / 4)", "accuracy": "+-25%", "chars_per_token": CHARS_PER_TOKEN,
|
|
50
|
+
"counted": {**{t: "+".join(f) for t, f in FIELDS.items()}, "rule": "whole file",
|
|
51
|
+
"plugin": "sum of bundled skills, agents, commands, mcp servers"},
|
|
52
|
+
"mcp_tokens_per_tool": MCP_TOKENS_PER_TOOL, "mcp_flat_per_server": MCP_FLAT,
|
|
53
|
+
"mcp_deferring_harnesses": sorted(DEFERS_SCHEMAS),
|
|
54
|
+
}
|
|
55
|
+
TOOL_SECTION = re.compile(r"^\[mcp_servers\.([^.\]\s]+)\.tools\.[^\]]+\]", re.M)
|
|
56
|
+
|
|
57
|
+
Estimate = tuple[int, "int | None", str] # tokens, chars, basis
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Item:
|
|
62
|
+
harness: str # the owning harness for a shared dir
|
|
63
|
+
type: str
|
|
64
|
+
name: str
|
|
65
|
+
enabled: bool
|
|
66
|
+
tokens: int # cost now: 0 when parked
|
|
67
|
+
would_save: int # parked: what restoring it would load
|
|
68
|
+
chars: int | None
|
|
69
|
+
basis: str
|
|
70
|
+
shared_with: tuple[str, ...] = ()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tokens_of(chars: int) -> int:
|
|
74
|
+
return -(-chars // CHARS_PER_TOKEN)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_head(path: Path, cap: int) -> str:
|
|
78
|
+
with open(path, "rb") as f:
|
|
79
|
+
return f.read(cap).decode("utf-8", "replace")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_frontmatter(head: str) -> dict[str, str]:
|
|
83
|
+
"""Top-level `key: value` pairs of a leading `---` block; indented lines continue
|
|
84
|
+
the previous key (folded / literal scalars). Not a YAML parser."""
|
|
85
|
+
lines = head.splitlines()
|
|
86
|
+
if not lines or lines[0].strip() != "---":
|
|
87
|
+
return {}
|
|
88
|
+
out: dict[str, str] = {}
|
|
89
|
+
key = None
|
|
90
|
+
for ln in lines[1:]:
|
|
91
|
+
if ln.strip() == "---":
|
|
92
|
+
break
|
|
93
|
+
if ln[:1] in (" ", "\t") and key:
|
|
94
|
+
out[key] = f"{out[key]} {ln.strip()}".strip()
|
|
95
|
+
elif ":" in ln and not ln.lstrip().startswith("#"):
|
|
96
|
+
k, _, v = ln.partition(":")
|
|
97
|
+
key, v = k.strip(), v.strip()
|
|
98
|
+
out[key] = "" if v in (">", "|", ">-", "|-") else v.strip("\"'")
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def file_estimate(type_: str, name: str, path: Path | None) -> Estimate:
|
|
103
|
+
"""skill / agent / command / rule from one file (a skill dir -> its SKILL.md)."""
|
|
104
|
+
if path is None:
|
|
105
|
+
return 0, None, "file not found"
|
|
106
|
+
if type_ == "skill" and path.is_dir():
|
|
107
|
+
path = path / "SKILL.md"
|
|
108
|
+
try:
|
|
109
|
+
if type_ == "rule":
|
|
110
|
+
chars = len(read_head(path, RULE_CAP))
|
|
111
|
+
return tokens_of(chars), chars, f"whole file {chars} chars"
|
|
112
|
+
fm = parse_frontmatter(read_head(path, HEAD_CAP))
|
|
113
|
+
except OSError as e:
|
|
114
|
+
return 0, None, f"unreadable: {e.strerror or e}"
|
|
115
|
+
vals = {f: fm.get(f, "") for f in FIELDS[type_]}
|
|
116
|
+
if not vals.get("name"):
|
|
117
|
+
vals["name"] = name
|
|
118
|
+
chars = sum(map(len, vals.values()))
|
|
119
|
+
parts = "+".join(f for f, v in vals.items() if v)
|
|
120
|
+
return tokens_of(chars), chars, f"{parts} {chars} chars" + ("" if fm else " (no frontmatter)")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def mcp_estimate(harness: str, tools: int | None) -> Estimate:
|
|
124
|
+
mode = "deferred" if harness in DEFERS_SCHEMAS else "full"
|
|
125
|
+
note = " (names only, deferred)" if mode == "deferred" else ""
|
|
126
|
+
if not tools:
|
|
127
|
+
return MCP_FLAT[mode], None, f"tool count unknown: {MCP_FLAT[mode]} tok flat{note}"
|
|
128
|
+
per = MCP_TOKENS_PER_TOOL[mode]
|
|
129
|
+
return tools * per, None, f"{tools} tools x {per}{note}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _entry_tools(entry) -> int | None:
|
|
133
|
+
tools = entry.get("tools") if isinstance(entry, dict) else None
|
|
134
|
+
if tools == ["*"]: # "all tools": a wildcard, not a count (flat estimate)
|
|
135
|
+
return None
|
|
136
|
+
return len(tools) if isinstance(tools, (list, dict)) and tools else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def mcp_tool_counts(h) -> dict[str, int]:
|
|
140
|
+
"""name -> tool count for the live servers whose config lists their tools."""
|
|
141
|
+
if h.backend == "toml":
|
|
142
|
+
try:
|
|
143
|
+
return dict(Counter(TOOL_SECTION.findall((h.home / "config.toml").read_text(encoding="utf-8"))))
|
|
144
|
+
except OSError:
|
|
145
|
+
return {}
|
|
146
|
+
if h.backend == "claude-json":
|
|
147
|
+
try:
|
|
148
|
+
cfg = json.loads(fs.claude_json().read_text(encoding="utf-8"))
|
|
149
|
+
except (OSError, ValueError):
|
|
150
|
+
return {}
|
|
151
|
+
servers = dict(cfg.get("mcpServers") or {})
|
|
152
|
+
for pdata in (cfg.get("projects") or {}).values():
|
|
153
|
+
if isinstance(pdata, dict):
|
|
154
|
+
for n, e in (pdata.get("mcpServers") or {}).items():
|
|
155
|
+
servers.setdefault(n, e)
|
|
156
|
+
return {n: c for n, e in servers.items() if (c := _entry_tools(e))}
|
|
157
|
+
if h.backend == "json":
|
|
158
|
+
try:
|
|
159
|
+
servers = read_project_mcp(h.mcp.file)[1]["mcpServers"]
|
|
160
|
+
except ProjectMcpError:
|
|
161
|
+
return {}
|
|
162
|
+
return {n: c for n, e in servers.items() if (c := _entry_tools(e))}
|
|
163
|
+
return {}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def flag_names(h, type_: str) -> list[str]:
|
|
167
|
+
"""Names of the live items a flag-mechanism type keeps in the harness config: the
|
|
168
|
+
object keys under the pointer's `<name>` slot whose own flag is `true`. An entry with
|
|
169
|
+
no flag (disable would refuse: a key is never invented) or `false` by hand (enable
|
|
170
|
+
would refuse: no state entry) is not something this tool can toggle, so not listed."""
|
|
171
|
+
rel, pointer = h.flags[type_]
|
|
172
|
+
prefix = pointer[:pointer.index("<name>")] if "<name>" in pointer else pointer[:-1]
|
|
173
|
+
try:
|
|
174
|
+
node = json.loads((h.home / rel).read_text(encoding="utf-8").removeprefix("\ufeff"))
|
|
175
|
+
except (OSError, ValueError):
|
|
176
|
+
return []
|
|
177
|
+
for k in prefix:
|
|
178
|
+
node = node.get(k) if isinstance(node, dict) else None
|
|
179
|
+
if not isinstance(node, dict):
|
|
180
|
+
return []
|
|
181
|
+
return sorted(n for n, v in node.items()
|
|
182
|
+
if isinstance(v, dict) and v.get("enabled") is True and _valid_name(n))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def backup_tools(entry: dict) -> int | None:
|
|
186
|
+
"""Tool count of a parked MCP server, from its verbatim backup."""
|
|
187
|
+
try:
|
|
188
|
+
raw = json.loads(Path(entry["backup"]).read_text(encoding="utf-8"))
|
|
189
|
+
except (KeyError, OSError, ValueError, TypeError):
|
|
190
|
+
return None
|
|
191
|
+
if isinstance(raw, dict) and isinstance(raw.get("json"), dict): # json-backend payload
|
|
192
|
+
return _entry_tools(raw["json"].get("entry"))
|
|
193
|
+
if isinstance(raw, dict) and "toml" in raw:
|
|
194
|
+
return len(TOOL_SECTION.findall(str(raw["toml"]))) or None
|
|
195
|
+
return _entry_tools(raw)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def plugin_estimate(root: Path) -> Estimate:
|
|
199
|
+
"""A plugin bundle: sum of what it ships, read from its install path."""
|
|
200
|
+
parts = [("skill", d.name, d / "SKILL.md") for d in sorted((root / "skills").glob("*"))
|
|
201
|
+
if (d / "SKILL.md").is_file()]
|
|
202
|
+
parts += [("agent", p.stem, p) for p in sorted((root / "agents").rglob("*.md"))]
|
|
203
|
+
parts += [("command", p.stem, p) for p in sorted((root / "commands").rglob("*.md"))]
|
|
204
|
+
ests = [file_estimate(*p) for p in parts]
|
|
205
|
+
servers = 0
|
|
206
|
+
try:
|
|
207
|
+
data = json.loads((root / ".mcp.json").read_text(encoding="utf-8"))
|
|
208
|
+
if isinstance(data, dict):
|
|
209
|
+
servers = len(data.get("mcpServers", data))
|
|
210
|
+
except (OSError, ValueError):
|
|
211
|
+
pass
|
|
212
|
+
tokens = sum(e[0] for e in ests) + servers * mcp_estimate("claude", None)[0]
|
|
213
|
+
chars = sum(e[1] or 0 for e in ests)
|
|
214
|
+
counts = Counter(p[0] for p in parts)
|
|
215
|
+
basis = ", ".join(f"{counts[t]} {t}s" for t in ("skill", "agent", "command") if counts[t])
|
|
216
|
+
basis += f"{', ' if basis else ''}{servers} mcp" if servers else ""
|
|
217
|
+
return tokens, chars, basis or "empty bundle"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def list_plugins(warn: Callable[[str], None]) -> list[dict]:
|
|
221
|
+
"""`claude plugin list --json` through the injectable runner; [] when unavailable."""
|
|
222
|
+
exe = claude_bin()
|
|
223
|
+
if not exe:
|
|
224
|
+
return []
|
|
225
|
+
ok, text = run_cli(exe, ["plugin", "list", "--json"])
|
|
226
|
+
try: # run_cli merges stderr into the text: skip any notice before the array
|
|
227
|
+
data = json.JSONDecoder().raw_decode(text, max(text.find("["), 0))[0] if ok else None
|
|
228
|
+
except ValueError:
|
|
229
|
+
data = None
|
|
230
|
+
if not isinstance(data, list):
|
|
231
|
+
warn("could not read `claude plugin list --json`; plugin rows come from state only")
|
|
232
|
+
return []
|
|
233
|
+
return [p for p in data if isinstance(p, dict) and isinstance(p.get("id"), str)]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def inventory(state: dict, table: dict, warn: Callable[[str], None] = lambda m: None,
|
|
237
|
+
plugins: bool = True) -> list[Item]:
|
|
238
|
+
"""Every togglable item once: live, then parked-only. A dir shared by several
|
|
239
|
+
harnesses is ONE item under its owner with the others in `shared_with`."""
|
|
240
|
+
items: list[Item] = []
|
|
241
|
+
seen: set[tuple[str, str, str]] = set()
|
|
242
|
+
# flag-disabled items stay in place (the config flag is off), so a live listing alone
|
|
243
|
+
# would call them enabled: the state entry is what says they are off.
|
|
244
|
+
flagged = {(e.get("harness"), e.get("type"), e.get("name"))
|
|
245
|
+
for e in state.get("disabled", {}).values()
|
|
246
|
+
if isinstance(e, dict) and e.get("mechanism") == "flag"}
|
|
247
|
+
|
|
248
|
+
def add(h: str, t: str, n: str, enabled: bool, est: Estimate, shared=()) -> None:
|
|
249
|
+
enabled = enabled and (h, t, n) not in flagged
|
|
250
|
+
if (h, t, n) not in seen:
|
|
251
|
+
seen.add((h, t, n))
|
|
252
|
+
items.append(Item(h, t, n, enabled, est[0] if enabled else 0,
|
|
253
|
+
0 if enabled else est[0], est[1], est[2], tuple(shared)))
|
|
254
|
+
|
|
255
|
+
for hname, h in table.items():
|
|
256
|
+
if not h.home.is_dir():
|
|
257
|
+
continue
|
|
258
|
+
for type_ in h.types:
|
|
259
|
+
if type_ in h.dirs:
|
|
260
|
+
for sub in h.dirs[type_]:
|
|
261
|
+
v = dir_view(table, hname, type_, h.home, sub)
|
|
262
|
+
if v.owner != hname:
|
|
263
|
+
continue
|
|
264
|
+
shared = [s for s in v.sharers if s != hname]
|
|
265
|
+
for name in live_names(v.live, type_):
|
|
266
|
+
add(hname, type_, name, True,
|
|
267
|
+
file_estimate(type_, name, resolve_item(v.live, name)), shared)
|
|
268
|
+
elif type_ in h.flags:
|
|
269
|
+
for name in flag_names(h, type_):
|
|
270
|
+
add(hname, type_, name, True, mcp_estimate(hname, None) if type_ == "mcp"
|
|
271
|
+
else (0, None, "flag entry; size unknown"))
|
|
272
|
+
elif type_ == "mcp":
|
|
273
|
+
counts = mcp_tool_counts(h)
|
|
274
|
+
for name in live_mcp(h.home, h.backend):
|
|
275
|
+
add(hname, type_, name, True, mcp_estimate(hname, counts.get(name)))
|
|
276
|
+
|
|
277
|
+
claude = table.get("claude")
|
|
278
|
+
if plugins and claude and claude.home.is_dir() and "plugin" in claude.types:
|
|
279
|
+
for p in list_plugins(warn):
|
|
280
|
+
root = p.get("installPath")
|
|
281
|
+
add("claude", "plugin", p["id"], p.get("enabled", True) is not False,
|
|
282
|
+
plugin_estimate(Path(root)) if isinstance(root, str)
|
|
283
|
+
else (0, None, "no installPath"))
|
|
284
|
+
|
|
285
|
+
for e in state.get("disabled", {}).values():
|
|
286
|
+
h, t, n = e.get("harness", "claude"), e.get("type"), e.get("name")
|
|
287
|
+
if not all(isinstance(x, str) and x for x in (h, t, n)):
|
|
288
|
+
continue # hand-edited entry: doctor reports it
|
|
289
|
+
if t == "mcp":
|
|
290
|
+
est = mcp_estimate(h, backup_tools(e))
|
|
291
|
+
elif t == "plugin":
|
|
292
|
+
est = (0, None, "not in plugin list")
|
|
293
|
+
else:
|
|
294
|
+
parked = e.get("parked_at")
|
|
295
|
+
est = file_estimate(t, n, Path(parked) if isinstance(parked, str) and parked else None)
|
|
296
|
+
shared = e.get("shared_with")
|
|
297
|
+
add(h, t, n, False, est,
|
|
298
|
+
[x for x in shared if isinstance(x, str)] if isinstance(shared, list) else ())
|
|
299
|
+
return items
|
agent_toggle/doctor.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""doctor: a read-only health check (DESIGN s6 "Harness drift").
|
|
2
|
+
|
|
3
|
+
Compares each installed harness's live layout with its table row, then
|
|
4
|
+
cross-checks state.json against disk. It takes no lock, never writes state, log,
|
|
5
|
+
backups or modes, and never shells out (no claude CLI). Every problem row names
|
|
6
|
+
the command that fixes it; nothing is deleted for you.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import fs, store
|
|
15
|
+
from .harnesses import Harness, harnesses
|
|
16
|
+
from .mechanisms import _refusal, dir_view
|
|
17
|
+
from .output import CliError, Result
|
|
18
|
+
from .store import load_state
|
|
19
|
+
|
|
20
|
+
STYLE = {"ok": "green", "error": "red", "warn": "yellow",
|
|
21
|
+
"absent": "dim", "note": "dim", "unverified": "dim"}
|
|
22
|
+
TAG = {"ok": "v", "absent": "-", "note": "i", "unverified": "?", "warn": "!", "error": "x"}
|
|
23
|
+
NEED = {"move": ("parked_at", "origin"), "remove_backup": ("backup",)}
|
|
24
|
+
# strings (kept) | comments | trailing commas: what makes strict JSON into JSONC
|
|
25
|
+
_JSONC = re.compile(r'"(?:\\.|[^"\\])*"|//[^\n]*|/\*.*?\*/|,(?=\s*[}\]])', re.S)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _row(out: Result, harness, type_, name, status: str, detail: str, **extra) -> None:
|
|
29
|
+
out.row(harness, type_, name, "doctor", status, detail, show=False, **extra)
|
|
30
|
+
what = " ".join(x for x in (harness, type_, name) if x)
|
|
31
|
+
out.say(f" {TAG[status]} {what}: {detail}", warn=status == "warn", style=STYLE[status])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_json(file: Path) -> tuple[object, str, str]:
|
|
35
|
+
"""(data, kind, why); kind is ok | missing | jsonc | broken."""
|
|
36
|
+
try:
|
|
37
|
+
text = file.read_bytes().decode("utf-8").removeprefix("\ufeff")
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
return None, "missing", "is missing"
|
|
40
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
41
|
+
return None, "broken", f"cannot be read ({e})"
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(text), "ok", ""
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
try: # parses once comments/commas are gone: JSONC
|
|
46
|
+
json.loads(_JSONC.sub(lambda m: m[0] if m[0][0] == '"' else "", text))
|
|
47
|
+
return None, "jsonc", ""
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None, "broken", f"is not valid JSON ({e})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _walk(data: object, path) -> tuple[bool, object]:
|
|
53
|
+
for k in path:
|
|
54
|
+
if not isinstance(data, dict) or k not in data:
|
|
55
|
+
return False, None
|
|
56
|
+
data = data[k]
|
|
57
|
+
return True, data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _file_row(out: Result, hname, type_, name, file: Path, kind: str, why: str, lead: str,
|
|
61
|
+
jsonc_ok: bool = True) -> None:
|
|
62
|
+
"""The row for a JSON file that did not load as strict JSON."""
|
|
63
|
+
if kind == "jsonc" and jsonc_ok: # JSONC/JSON5 is the harness's own format, not corruption
|
|
64
|
+
_row(out, hname, type_, name, "note", f"unsupported format: {file} has comments or trailing "
|
|
65
|
+
f"commas (JSONC/JSON5); agent-toggle will not edit it, doctor cannot check it")
|
|
66
|
+
else:
|
|
67
|
+
_row(out, hname, type_, name, "error", f"{lead}{file} " + (why or "is not valid JSON"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _json_at(out: Result, hname, type_, name, file: Path, path, lead: str, jsonc_ok: bool = True,
|
|
71
|
+
absent_ok: bool = False):
|
|
72
|
+
"""The value at `path` in a strict-JSON `file`, or None after emitting the row.
|
|
73
|
+
`absent_ok` (layout checks): a missing file / key is `absent`, not an error --
|
|
74
|
+
a harness that never configured it is fine; a recorded state entry is not."""
|
|
75
|
+
data, kind, why = _read_json(file)
|
|
76
|
+
if kind == "missing" and absent_ok:
|
|
77
|
+
_row(out, hname, type_, name, "absent", f"{file} does not exist (nothing to check)")
|
|
78
|
+
return None
|
|
79
|
+
if kind != "ok":
|
|
80
|
+
_file_row(out, hname, type_, name, file, kind, why, lead, jsonc_ok)
|
|
81
|
+
return None
|
|
82
|
+
found, value = _walk(data, path)
|
|
83
|
+
if found:
|
|
84
|
+
return value
|
|
85
|
+
_row(out, hname, type_, name, "absent" if absent_ok else "error",
|
|
86
|
+
f"{'.'.join(path)} not in {file} (nothing to check)" if absent_ok
|
|
87
|
+
else f"{lead}{'.'.join(path)} not found in {file}")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_layout(out: Result, h: Harness, table: dict) -> None:
|
|
92
|
+
start = len(out.rows)
|
|
93
|
+
for type_, subs in h.dirs.items():
|
|
94
|
+
views = [dir_view(table, h.name, type_, h.home, s) for s in subs]
|
|
95
|
+
cands = [p for s, v in zip(subs, views) for c in (h.home / s, v.live)
|
|
96
|
+
for p in (c, c.with_name(c.name + "-disabled"))]
|
|
97
|
+
if not any(p.exists() or p.is_symlink() for p in cands):
|
|
98
|
+
_row(out, h.name, type_, None, "absent",
|
|
99
|
+
f"no {' / '.join(subs)} dir (or *-disabled park dir) under {h.home}")
|
|
100
|
+
if others := sorted({x for v in views for x in v.sharers} - {h.name}):
|
|
101
|
+
_row(out, h.name, type_, None, "note", f"dir shared with {', '.join(others)} "
|
|
102
|
+
f"(disable here also affects them)")
|
|
103
|
+
if h.mcp:
|
|
104
|
+
spec, lead = h.mcp, "layout changed: "
|
|
105
|
+
if spec.backend == "toml":
|
|
106
|
+
toml = fs._tomllib()
|
|
107
|
+
if toml is None:
|
|
108
|
+
state = "unverified" if spec.file.is_file() else "absent"
|
|
109
|
+
_row(out, h.name, "mcp", None, state, f"{spec.file}: no tomllib, not parsed"
|
|
110
|
+
if state == "unverified" else f"{spec.file} does not exist (nothing to check)")
|
|
111
|
+
else:
|
|
112
|
+
try:
|
|
113
|
+
data = toml.loads(spec.file.read_text(encoding="utf-8"))
|
|
114
|
+
except FileNotFoundError:
|
|
115
|
+
_row(out, h.name, "mcp", None, "absent",
|
|
116
|
+
f"{spec.file} does not exist (nothing to check)")
|
|
117
|
+
except (OSError, ValueError) as e:
|
|
118
|
+
_row(out, h.name, "mcp", None, "error", f"{lead}{spec.file} does not parse ({e})")
|
|
119
|
+
else:
|
|
120
|
+
if not _walk(data, spec.key_path)[0]:
|
|
121
|
+
_row(out, h.name, "mcp", None, "absent",
|
|
122
|
+
f"[{'.'.join(spec.key_path)}] not in {spec.file} (nothing to check)")
|
|
123
|
+
else:
|
|
124
|
+
_json_at(out, h.name, "mcp", None, spec.file, spec.key_path, lead, jsonc_ok=False,
|
|
125
|
+
absent_ok=True)
|
|
126
|
+
loaded: dict[str, tuple] = {} # one read + one row per flag file, not per flag type
|
|
127
|
+
for type_, (rel, pointer) in h.flags.items():
|
|
128
|
+
prefix = pointer[:pointer.index("<name>")] if "<name>" in pointer else pointer[:-1]
|
|
129
|
+
file = h.home / rel
|
|
130
|
+
if rel not in loaded:
|
|
131
|
+
loaded[rel] = _read_json(file)
|
|
132
|
+
if loaded[rel][1] == "missing":
|
|
133
|
+
_row(out, h.name, type_, None, "absent", f"{file} does not exist (nothing to check)")
|
|
134
|
+
elif loaded[rel][1] != "ok":
|
|
135
|
+
_file_row(out, h.name, type_, None, file, *loaded[rel][1:], "layout changed: ")
|
|
136
|
+
data, kind, _ = loaded[rel]
|
|
137
|
+
if kind == "ok" and not _walk(data, prefix)[0]:
|
|
138
|
+
_row(out, h.name, type_, None, "absent",
|
|
139
|
+
f"{'.'.join(prefix)} not in {file} (nothing to check)")
|
|
140
|
+
if not any(r["status"] == "error" for r in out.rows[start:]):
|
|
141
|
+
_row(out, h.name, None, None, "ok", "layout matches the table row")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_entry(out: Result, key: str, e: dict, table: dict) -> None:
|
|
145
|
+
sf = fs.state_file()
|
|
146
|
+
try:
|
|
147
|
+
hname, digest, type_, name = store.parse_key(key)
|
|
148
|
+
except ValueError as ex:
|
|
149
|
+
_row(out, None, None, None, "error", f"{ex}; fix: remove it from {sf} by hand")
|
|
150
|
+
return
|
|
151
|
+
ident = (hname, type_, name)
|
|
152
|
+
mech = e.get("mechanism") if isinstance(e, dict) else None
|
|
153
|
+
mech = mech if isinstance(mech, str) else None
|
|
154
|
+
if not e or not isinstance(e, dict): # _refusal treats an empty entry as "nothing"
|
|
155
|
+
why = "entry is empty" if isinstance(e, dict) else "entry is not an object"
|
|
156
|
+
else:
|
|
157
|
+
why = _refusal(e, table, key, NEED.get(mech, ()))
|
|
158
|
+
if why:
|
|
159
|
+
_row(out, *ident, "error", f"{why}; entry fails the tamper checks, fix: inspect {sf} "
|
|
160
|
+
f"and remove the entry by hand if it is not yours")
|
|
161
|
+
return
|
|
162
|
+
enable = f"agent-toggle enable {type_} {name}"
|
|
163
|
+
project = e.get("project") if digest else None
|
|
164
|
+
if project and not Path(project).is_dir():
|
|
165
|
+
_row(out, *ident, "error", f"project dir gone: {project}; fix: recreate it, then "
|
|
166
|
+
f"`agent-toggle enable --all --project {project}` (items stay parked under "
|
|
167
|
+
f"{fs.parked_dir() / digest})")
|
|
168
|
+
return
|
|
169
|
+
flag = e.get("flag") if mech == "flag" and not e.get("connector") else None
|
|
170
|
+
if mech == "move":
|
|
171
|
+
if not Path(e["parked_at"]).exists() and not Path(e["parked_at"]).is_symlink():
|
|
172
|
+
_row(out, *ident, "error", f"parked item missing: {e['parked_at']}; fix: put it back "
|
|
173
|
+
f"there and run `{enable}`, or remove the entry from {sf}")
|
|
174
|
+
elif not Path(e["origin"]).parent.is_dir():
|
|
175
|
+
_row(out, *ident, "error", f"origin dir gone: {Path(e['origin']).parent}; "
|
|
176
|
+
f"fix: recreate it, then `{enable}`")
|
|
177
|
+
elif mech == "remove_backup" and not Path(e["backup"]).is_file():
|
|
178
|
+
_row(out, *ident, "error", f"backup missing: {e['backup']}; the saved config is lost -- "
|
|
179
|
+
f"re-add the server by hand, then remove the entry from {sf}")
|
|
180
|
+
elif flag:
|
|
181
|
+
value = _json_at(out, *ident, Path(flag["file"]), flag["pointer"], "")
|
|
182
|
+
if isinstance(value, bool) and value == flag["was"]:
|
|
183
|
+
_row(out, *ident, "error", f"{'.'.join(flag['pointer'])} is {str(value).lower()} in "
|
|
184
|
+
f"{flag['file']} but the item is recorded as disabled (changed outside this "
|
|
185
|
+
f"tool); fix: `{enable}` clears the entry")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _untracked(root: Path, tracked: set[str]) -> list[Path]:
|
|
189
|
+
"""Items under a park dir that no state entry points at (descending into groups)."""
|
|
190
|
+
found = []
|
|
191
|
+
try:
|
|
192
|
+
children = sorted(root.iterdir())
|
|
193
|
+
except OSError:
|
|
194
|
+
return found
|
|
195
|
+
for c in children:
|
|
196
|
+
if c.name.startswith(".") or str(c) in tracked: # .DS_Store and friends
|
|
197
|
+
continue
|
|
198
|
+
if c.is_dir() and not c.is_symlink() and any(t.startswith(str(c) + "/") or
|
|
199
|
+
t.startswith(str(c) + "\\") for t in tracked):
|
|
200
|
+
found += _untracked(c, tracked)
|
|
201
|
+
else:
|
|
202
|
+
found.append(c)
|
|
203
|
+
return found
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _check_orphans(out: Result, state: dict, table: dict, only: str | None) -> None:
|
|
207
|
+
entries = state["disabled"].values()
|
|
208
|
+
tracked = {e["parked_at"] for e in entries
|
|
209
|
+
if isinstance(e, dict) and isinstance(e.get("parked_at"), str)}
|
|
210
|
+
seen: set[Path] = set()
|
|
211
|
+
for h in table.values():
|
|
212
|
+
if (only and h.name != only) or not h.home.is_dir():
|
|
213
|
+
continue
|
|
214
|
+
for type_, subs in h.dirs.items():
|
|
215
|
+
for sub in subs:
|
|
216
|
+
parked = dir_view(table, h.name, type_, h.home, sub).parked
|
|
217
|
+
if parked in seen:
|
|
218
|
+
continue
|
|
219
|
+
seen.add(parked)
|
|
220
|
+
for p in _untracked(parked, tracked):
|
|
221
|
+
_row(out, h.name, type_, p.name, "warn", f"{p} is parked but has no state "
|
|
222
|
+
f"entry (parked outside this tool); fix: move it back by hand")
|
|
223
|
+
if only:
|
|
224
|
+
return
|
|
225
|
+
digests = set()
|
|
226
|
+
for k in state["disabled"]:
|
|
227
|
+
try:
|
|
228
|
+
digests.add(store.parse_key(k)[1])
|
|
229
|
+
except ValueError:
|
|
230
|
+
pass
|
|
231
|
+
root = fs.parked_dir()
|
|
232
|
+
for d in sorted(root.iterdir()) if root.is_dir() else ():
|
|
233
|
+
if d.name.startswith("."):
|
|
234
|
+
continue
|
|
235
|
+
if d.name not in digests:
|
|
236
|
+
if d.is_dir() and not d.is_symlink() and not any(
|
|
237
|
+
p.is_symlink() or not p.is_dir() for p in d.rglob("*")):
|
|
238
|
+
continue # only empty dirs: what a project enable leaves behind
|
|
239
|
+
_row(out, None, None, d.name, "warn", f"{d} holds parked project items with no state "
|
|
240
|
+
f"entry; fix: move what you want back by hand, then delete the dir")
|
|
241
|
+
continue
|
|
242
|
+
for sub in sorted(d.iterdir()) if d.is_dir() else ():
|
|
243
|
+
for p in _untracked(sub, tracked):
|
|
244
|
+
_row(out, None, None, p.name, "warn", f"{p} is parked but has no state entry; "
|
|
245
|
+
f"fix: move it back by hand")
|
|
246
|
+
used = {Path(e["backup"]).name for e in entries
|
|
247
|
+
if isinstance(e, dict) and isinstance(e.get("backup"), str)}
|
|
248
|
+
bdir = fs.backup_dir()
|
|
249
|
+
for b in sorted(bdir.glob("*.json")) if bdir.is_dir() else ():
|
|
250
|
+
if b.name not in used: # enable keeps the backup, so this is a note, not a fault
|
|
251
|
+
_row(out, None, None, b.name, "note", f"{b} has no state entry (kept after enable); "
|
|
252
|
+
f"delete it yourself once the server is confirmed restored")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _check_modes(out: Result) -> None:
|
|
256
|
+
for p in (fs.state_file(), *fs.backup_dir().glob("*.json")):
|
|
257
|
+
if fs.too_open(p, 0o077):
|
|
258
|
+
_row(out, None, None, p.name, "warn", f"{p} is looser than 0600; fix: chmod 600 {p}")
|
|
259
|
+
if fs.too_open(fs.state_dir()):
|
|
260
|
+
_row(out, None, None, None, "warn", f"{fs.state_dir()} is group/world readable; "
|
|
261
|
+
f"fix: chmod 700 {fs.state_dir()}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cmd_doctor(harness: str | None, out: Result) -> None:
|
|
265
|
+
table = harnesses()
|
|
266
|
+
installed = [h for n, h in table.items() if (not harness or n == harness) and h.home.is_dir()]
|
|
267
|
+
if harness and not installed:
|
|
268
|
+
_row(out, harness, None, None, "note", f"not installed ({table[harness].home} does not exist)")
|
|
269
|
+
for h in installed:
|
|
270
|
+
_check_layout(out, h, table)
|
|
271
|
+
try:
|
|
272
|
+
state = load_state(write_back=False, check_entries=False) # bad entries get rows
|
|
273
|
+
except CliError as e:
|
|
274
|
+
_row(out, None, None, None, "error", f"{e.msg}; fix: repair or move aside {fs.state_file()}")
|
|
275
|
+
else:
|
|
276
|
+
for key, e in state["disabled"].items():
|
|
277
|
+
if harness and not (isinstance(e, dict) and harness in (e.get("harness"),
|
|
278
|
+
*(e.get("shared_with") or ()))):
|
|
279
|
+
continue
|
|
280
|
+
_check_entry(out, key, e, table)
|
|
281
|
+
_check_orphans(out, state, table, harness)
|
|
282
|
+
if not harness:
|
|
283
|
+
_check_modes(out)
|
|
284
|
+
errors = sum(r["status"] == "error" for r in out.rows)
|
|
285
|
+
out.say(f"\ndoctor: {errors} problem(s), {len(out.warnings)} warning(s)")
|