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/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)")