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/cli.py ADDED
@@ -0,0 +1,498 @@
1
+ """Temporarily disable / re-enable AI-agent resources across harnesses.
2
+
3
+ Skills, agents, commands, rules, plugins and MCP servers can all be parked and put
4
+ back. Nothing is ever deleted.
5
+
6
+ Supported harnesses: claude (Claude Code), codex, grok, opencode, openclaw, copilot,
7
+ vibe, devin, agy. Not every harness has every resource type; unsupported pairs fail
8
+ loudly instead of silently doing nothing.
9
+
10
+ State lives in ONE place ($HOME/.agent-toggle/), never as sidecar files next
11
+ to the targets -- a user's `git status` must not change because of our
12
+ bookkeeping.
13
+
14
+ Usage:
15
+ agent_toggle.py ui [--dry-run] # interactive picker (curses)
16
+ agent_toggle.py cost [--type T] # startup token estimates, biggest first
17
+ agent_toggle.py disable <type> <name>... [--harness H] [--dry-run]
18
+ agent_toggle.py enable <type> <name>... [--harness H] [--dry-run]
19
+ agent_toggle.py enable --all [--harness H] [--dry-run] # restore everything
20
+ agent_toggle.py undo [--dry-run] # reverse the last logged batch
21
+ agent_toggle.py disable|enable <type> <name>... --project <dir> # a repo's .claude/ + .mcp.json
22
+ agent_toggle.py enable --all --project <dir> | profile save|apply|diff ... --project <dir>
23
+ agent_toggle.py list [<type>] [--project D] # what is currently disabled
24
+ agent_toggle.py status # health check
25
+ agent_toggle.py doctor [--harness H] # read-only drift + state check; exit 1 on problems
26
+ agent_toggle.py migrate # import old ~/.claude-toggle state
27
+ agent_toggle.py profile save|apply|diff|list [name|file] [--out F] [--dry-run]
28
+ agent_toggle.py install-shims [--dry-run] # write the skill shim into each harness
29
+
30
+ <type> = skill | agent | command | rule | plugin | mcp
31
+ --json prints exactly one JSON document; exit codes: 0 ok, 1 partial
32
+ failure, 2 usage error, 3 locked, 4 unsupported pair / harness missing.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import contextlib
38
+ import io
39
+ import os
40
+ import sys
41
+ import traceback
42
+ from pathlib import Path
43
+
44
+ from . import __version__, cost, doctor, fs, ops, profiles, store, undo
45
+ from .backends.plugin_cli import claude_bin
46
+ from .fs import gitignored
47
+ from .harnesses import TYPES, harness_of, harnesses, project_view
48
+ from .mechanisms import dir_view, validate_name
49
+ from .output import COLOR_MODES, CliError, Result, die, scan_color, use_color
50
+ from .store import load_state, save_state
51
+
52
+
53
+ def project_of(key: str, entry: dict) -> str | None:
54
+ """The --project dir of a project-scope entry (`harness@<sha8>:...`), else None.
55
+ (A claude local-scope mcp entry's `project` is its cwd, not a --project scope.)"""
56
+ try:
57
+ scoped = store.parse_key(key)[1]
58
+ except ValueError:
59
+ return None
60
+ return str(entry.get("project")) if scoped else None
61
+
62
+
63
+ def _list_of(v: dict, key: str) -> list:
64
+ """A state entry's list field; a hand-edited non-list counts as empty."""
65
+ x = v.get(key)
66
+ return x if isinstance(x, list) else []
67
+
68
+
69
+ def cmd_list(type_filter: str | None, state: dict, out: Result,
70
+ harness: str | None = None, project: str | None = None) -> None:
71
+ if project is not None: # one project's entries only
72
+ project = str(project_view(project).project) # exit 4 like disable --project
73
+ state = store.scope_state(state, project)
74
+ items = [(project_of(k, v), v) for k, v in state["disabled"].items()
75
+ if (not type_filter or v.get("type") == type_filter)
76
+ and (not harness or harness in (v.get("harness"), *_list_of(v, "shared_with")))]
77
+ if not items:
78
+ out.say("nothing disabled")
79
+ return
80
+ for proj, v in sorted(items, key=lambda x: (str(x[1].get("harness", "")), str(x[1].get("type")),
81
+ str(x[1].get("name")), x[0] or "")):
82
+ shared = ", ".join(map(str, _list_of(v, "shared_with")))
83
+ extra = f" +{len(_list_of(v, 'companions'))} files" if _list_of(v, "companions") else ""
84
+ extra += f" shared with {shared}" if shared else ""
85
+ extra += f" project {proj}" if proj else ""
86
+ at = str(v.get("at", "?"))
87
+ who = out.paint(f"{v.get('harness', '?')!s:<9}", "cyan")
88
+ out.say(f" {who} {v.get('type')!s:<8} {v.get('name')!s:<36} "
89
+ + out.paint(f"since {at[:10]}{extra}", "dim"))
90
+ out.row(v.get("harness"), v.get("type"), v.get("name"), "list", "disabled",
91
+ f"since {at[:10]}", show=False, at=v.get("at"),
92
+ mechanism=v.get("mechanism"), companions=len(_list_of(v, "companions")),
93
+ shared_with=_list_of(v, "shared_with"), **({"project": proj} if proj else {}))
94
+ out.say(f"\n{len(items)} disabled")
95
+
96
+
97
+ def cmd_status(state: dict, out: Result, only: str | None = None) -> None:
98
+ legacy_state_dir = fs.legacy_state_dir()
99
+ claude = claude_bin()
100
+ legacy = None
101
+ out.say(f"state {fs.state_file()} ({len(state['disabled'])} disabled)")
102
+ out.say(f"log {fs.log_file()}")
103
+ loose = [p for p in (fs.state_file(), *fs.backup_dir().glob("*.json"))
104
+ if fs.too_open(p, 0o077)]
105
+ for p in loose:
106
+ out.say(f"WARNING {p} is looser than 0600 -- fix: chmod 600 {p}", warn=True)
107
+ if fs.too_open(fs.state_dir()):
108
+ out.say(f"WARNING {fs.state_dir()} is group/world readable -- backups may hold "
109
+ f"auth headers; fix: chmod 700 {fs.state_dir()}", warn=True)
110
+ out.say(f"claude {claude or 'NOT FOUND -- plugin/mcp actions will fail'}")
111
+ if legacy_state_dir.exists():
112
+ done = any(k.startswith("claude:") for k in state["disabled"])
113
+ legacy = "imported" if done else "present"
114
+ out.say(f"legacy {legacy_state_dir} present -- "
115
+ + ("already imported; safe to delete once verified" if done
116
+ else "run `migrate` to import it"))
117
+ out.row(None, None, None, "status", "ok", "", show=False,
118
+ state_file=str(fs.state_file()), log_file=str(fs.log_file()),
119
+ disabled=len(state["disabled"]), claude_cli=claude, legacy=legacy)
120
+ table = harnesses()
121
+ for hname, h in table.items():
122
+ if only and hname != only:
123
+ continue
124
+ home, backend = h.home, h.backend
125
+ if not home.is_dir():
126
+ out.say(f"{out.paint(f'{hname:<9}', 'cyan')} {home} "
127
+ + out.paint("(not installed)", "dim"))
128
+ out.row(hname, None, None, "status", "not-installed", "", show=False,
129
+ home=str(home))
130
+ continue
131
+ bits = [t for t in h.types
132
+ if t not in h.dirs or any((home / s).is_dir() for s in h.dirs[t])]
133
+ shown = ",".join(bits) if h.types else "(found, nothing supported yet)"
134
+ out.say(f"{out.paint(f'{hname:<9}', 'cyan')} {home} types: {shown} mcp: {backend or '-'}")
135
+ info: dict = {}
136
+ for t in bits:
137
+ if t not in h.dirs:
138
+ continue
139
+ # any harness's entry: a shared dir's items are tracked under its owner
140
+ tracked = {e["parked_at"] for e in state["disabled"].values()
141
+ if e.get("type") == t and e.get("parked_at")}
142
+ items: list[Path] = []
143
+ untracked: list[Path] = []
144
+ twins: list[str] = []
145
+ ign = True
146
+ shared: set[str] = set()
147
+ for sub in h.dirs[t]:
148
+ view = dir_view(table, hname, t, home, sub)
149
+ parked = view.parked
150
+ shared.update(set(view.sharers) - {hname})
151
+ if markers := fs.sync_markers(view.live):
152
+ out.say(f" ! {view.live} carries {', '.join(markers)} -- a "
153
+ f"sync job may re-create parked items; park in the source harness",
154
+ warn=True)
155
+ # Skills are directories; agents and commands are files that may
156
+ # sit one level down. Counting rglob("*") for skills would report
157
+ # every file inside every skill.
158
+ if not parked.is_dir():
159
+ found = []
160
+ elif t == "skill":
161
+ found = list(parked.iterdir())
162
+ else:
163
+ found = [p for p in parked.rglob("*") if p.is_file()]
164
+ items += found
165
+ ign = ign and gitignored(
166
+ parked, home if view.owner == hname else table[view.owner].home)
167
+ u, tw = parked_drift(found, parked, home / sub, tracked)
168
+ untracked += u
169
+ twins += tw
170
+ out.say(f" {t:<8} {len(items):>3} parked "
171
+ f"[{'gitignored' if ign else 'NOT gitignored'}]")
172
+ if untracked:
173
+ out.say(f" ! {len(untracked)} untracked (parked outside this tool)"
174
+ + (f", {len(twins)} also live: {', '.join(twins)}"
175
+ " -- stale copies; `disable` of these names will refuse" if twins else ""),
176
+ warn=True)
177
+ if shared:
178
+ out.say(f" shared dir with: {', '.join(sorted(shared))} "
179
+ f"(disable here also affects them)")
180
+ info[t] = {"parked": len(items), "gitignored": ign, "untracked": len(untracked),
181
+ "live_twins": twins, "shared_with": sorted(shared)}
182
+ out.row(hname, None, None, "status", "installed", "", show=False,
183
+ home=str(home), types=bits, mcp=backend, parked=info)
184
+ projects: dict[tuple, int] = {}
185
+ for k, e in state["disabled"].items():
186
+ if (proj := project_of(k, e)) is not None:
187
+ projects[e.get("harness"), proj] = projects.get((e.get("harness"), proj), 0) + 1
188
+ for (hname, proj), n in sorted(projects.items(), key=str):
189
+ out.say(f"project {proj} ({n} parked under {fs.parked_dir()})")
190
+ out.row(hname, None, None, "status", "project", "", show=False, project=proj, parked=n)
191
+
192
+
193
+ def parked_drift(items: list[Path], parked: Path, live: Path,
194
+ tracked: set[str]) -> tuple[list[Path], list[str]]:
195
+ """Parked items with no state entry, and the names among them that also exist live."""
196
+ untracked = [p for p in items if str(p) not in tracked]
197
+ twins = sorted(str(p.relative_to(parked)) for p in untracked
198
+ if (live / p.relative_to(parked)).exists() or (live / p.relative_to(parked)).is_symlink())
199
+ return untracked, twins
200
+
201
+
202
+ def cmd_cost(state: dict, out: Result, harness: str | None = None,
203
+ type_: str | None = None) -> None:
204
+ """Estimated startup tokens per item, biggest first. Read-only."""
205
+ items = [i for i in cost.inventory(store.scope_state(state), harnesses(), out.warn)
206
+ if (not harness or harness in (i.harness, *i.shared_with))
207
+ and (not type_ or i.type == type_)]
208
+ items.sort(key=lambda i: (-i.tokens, -i.would_save, i.harness, i.type, i.name))
209
+ for i in items:
210
+ what = (f"~{i.tokens:>6} tok {i.basis}" if i.enabled
211
+ else f"~{0:>6} tok parked, would save ~{i.would_save} tok ({i.basis})")
212
+ extra = f" shared with {', '.join(i.shared_with)}" if i.shared_with else ""
213
+ tail = what + extra
214
+ out.say(f" {out.paint(f'{i.harness:<9}', 'cyan')} {i.type:<8} {i.name:<36} "
215
+ + (tail if i.enabled else out.paint(tail, "dim")))
216
+ out.row(i.harness, i.type, i.name, "cost", "ok", i.basis, show=False,
217
+ enabled=i.enabled, tokens=i.tokens, would_save=i.would_save,
218
+ chars=i.chars, shared_with=list(i.shared_with))
219
+ live = sum(i.tokens for i in items)
220
+ saved = sum(i.would_save for i in items)
221
+ out.say(f"\n{len(items)} item(s): ~{live} tok loaded at startup; "
222
+ f"~{saved} tok already saved by parked items "
223
+ f"(chars/{cost.CHARS_PER_TOKEN} estimate, +-25%)")
224
+ out.row(harness, type_, None, "cost", "ok", "total", show=False, items=len(items),
225
+ total_tokens=live, saved_tokens=saved, formula=cost.FORMULA)
226
+
227
+
228
+ def cmd_ui(state: dict, out: Result, dry_run: bool = False) -> None:
229
+ try:
230
+ from .ui import picker as ui
231
+ except ImportError as e: # no curses build (rare)
232
+ die(f"interactive UI unavailable: {e}")
233
+ changes = ui.pick(store.scope_state(state), harnesses(), plugins=not dry_run,
234
+ color=use_color(sys.stdout, out.color))
235
+ if changes is None:
236
+ out.say("cancelled -- nothing changed")
237
+ return
238
+ if not changes:
239
+ out.say("no changes")
240
+ return
241
+ plan = sorted((ops.Op(r.harness, r.type, "enable" if r.staged else "disable", r.name)
242
+ for r in changes), key=lambda op: op[:3])
243
+ # the picker's state copy may be stale: apply_plan re-reads it under the lock
244
+ ops.apply_plan(plan, out, dry_run, batch=store.BATCH, headers=True)
245
+ if dry_run:
246
+ out.say("\ndry run -- nothing changed")
247
+ return
248
+ if any(r.type in ("mcp", "plugin") for r in changes):
249
+ out.say("\nMCP/plugin changed -- open a NEW session for it to take effect.")
250
+
251
+
252
+ def cmd_toggle(args: argparse.Namespace, out: Result) -> None:
253
+ action, type_, names, harness = args.command, args.type, args.names, args.harness
254
+ if not type_ or not names:
255
+ die(f"{action} needs <type> <name>... (or `enable --all`)", 2)
256
+ for name in names:
257
+ validate_name(name)
258
+ if args.project is not None:
259
+ # project scope: claude layout dir types only (DESIGN s5.5)
260
+ if harness != "claude":
261
+ die(f"--project supports only the claude layout, not {harness}", 4)
262
+ h = project_view(args.project)
263
+ if type_ not in h.types:
264
+ die(f"--project supports {', '.join(h.types)} (not {type_})", 4)
265
+ ops.apply_plan([ops.Op(harness, type_, action, n, str(h.project)) for n in names],
266
+ out, args.dry_run, batch=store.BATCH)
267
+ return
268
+ h = harness_of(harness)
269
+ home, supported = h.home, h.types
270
+ if not home.is_dir():
271
+ die(f"{harness} is not installed ({home} does not exist)", 4)
272
+ if type_ not in supported:
273
+ have = f"it has: {', '.join(supported)}" if supported else "no supported types"
274
+ die(f"{harness} has no {type_} support ({have})", 4)
275
+
276
+ ops.apply_plan([ops.Op(harness, type_, action, n) for n in names], out, args.dry_run,
277
+ batch=store.BATCH)
278
+
279
+
280
+ def cmd_migrate(out: Result) -> None:
281
+ buf = io.StringIO() # store.migrate prints; fold it into the result
282
+ with fs.lock():
283
+ state = load_state()
284
+ with contextlib.redirect_stdout(buf):
285
+ store.migrate(state)
286
+ save_state(state)
287
+ lines = buf.getvalue().splitlines()
288
+ out.say(buf.getvalue().rstrip("\n"))
289
+ out.row(None, None, None, "migrate", "ok", "; ".join(lines), show=False)
290
+
291
+
292
+ SHIM_TEMPLATE = Path(__file__).with_name("shims") / "claude.md.tmpl"
293
+
294
+
295
+ def cmd_install_shims(args: argparse.Namespace, out: Result) -> None:
296
+ """Write <home>/skills/agent-toggle/SKILL.md into every installed harness that
297
+ supports skills; keep park dirs out of an existing harness-home .gitignore."""
298
+ template = SHIM_TEMPLATE.read_text(encoding="utf-8")
299
+ root = Path(__file__).resolve().parent.parent
300
+ if (root / "agent_toggle.py").is_file():
301
+ # Harness dirs often sync across machines: write `~`-relative under $HOME.
302
+ try:
303
+ shown = "~/" + root.relative_to(fs.home()).as_posix()
304
+ except ValueError:
305
+ shown = str(root)
306
+ text = template.replace("__AGENT_TOGGLE_ROOT__", shown)
307
+ else: # installed package: no checkout to point at
308
+ text = "".join(ln for ln in template.splitlines(keepends=True)
309
+ if "__AGENT_TOGGLE_ROOT__" not in ln)
310
+ table = harnesses()
311
+ park = sorted({f"{sub}-disabled" for h in table.values()
312
+ for subs in h.dirs.values() for sub in subs
313
+ if not Path(sub).is_absolute()}) # skills.paths redirects live elsewhere
314
+ verb = "would install" if args.dry_run else "installed"
315
+ installed = 0
316
+ for hname, h in table.items():
317
+ home = h.home
318
+ if args.harness and hname != args.harness:
319
+ continue
320
+ if "skill" not in h.dirs or not home.is_dir():
321
+ out.row(hname, None, None, "install-shims", "skipped",
322
+ "not installed" if not home.is_dir() else "no skill support",
323
+ show=False, home=str(home))
324
+ continue
325
+ dest = home / h.dirs["skill"][0] / "agent-toggle" / "SKILL.md"
326
+ if not args.dry_run:
327
+ dest.parent.mkdir(parents=True, exist_ok=True)
328
+ dest.write_text(text, encoding="utf-8")
329
+ out.row(hname, None, None, "install-shims", "planned" if args.dry_run else "ok",
330
+ str(dest), show=False, home=str(home))
331
+ out.say(f" {verb} {dest}")
332
+ installed += 1
333
+ # A tracked park dir turns every disable into deletion noise in
334
+ # `git status`; only touch a .gitignore that already exists.
335
+ ignore = home / ".gitignore"
336
+ if ignore.is_file():
337
+ body = ignore.read_text(encoding="utf-8")
338
+ missing = [f"{d}/" for d in park if f"{d}/" not in body.splitlines()]
339
+ if missing and not args.dry_run:
340
+ with ignore.open("a", encoding="utf-8") as fh:
341
+ fh.write(("" if body.endswith("\n") or not body else "\n")
342
+ + "".join(f"{m}\n" for m in missing))
343
+ if missing:
344
+ out.say(f" gitignore {ignore}: {' '.join(missing)}")
345
+ if not installed:
346
+ die("no harness found", 4)
347
+ out.say(f"{installed} harness(es) {'planned' if args.dry_run else 'installed'}")
348
+
349
+
350
+ COMMANDS = ("ui", "pick", "status", "list", "cost", "migrate", "disable", "enable",
351
+ "install-shims", "profile", "undo", "doctor")
352
+ _GLOBAL_FLAGS = ("--json", "-v", "--verbose")
353
+
354
+
355
+ def normalize_argv(argv: list[str]) -> list[str]:
356
+ """Accept `--status` for `status` and bare `help` / `version` for `--help` / `--version`.
357
+
358
+ Only the command position is rewritten (after any leading global flags), so a
359
+ resource that happens to be named `list` or `help` is never touched.
360
+ """
361
+ argv = list(argv)
362
+ i = 0
363
+ while i < len(argv):
364
+ a = argv[i]
365
+ if a in ("--harness", "--color"):
366
+ i += 2
367
+ elif a in _GLOBAL_FLAGS or a.startswith(("--harness=", "--color=")):
368
+ i += 1
369
+ else:
370
+ if a.startswith("--") and a[2:] in COMMANDS:
371
+ argv[i] = a[2:]
372
+ elif a in ("help", "version"):
373
+ argv[i] = "--" + a
374
+ break
375
+ return argv
376
+
377
+
378
+ class _Parser(argparse.ArgumentParser):
379
+ def error(self, message: str) -> None:
380
+ die(message, 2)
381
+
382
+
383
+ PROJECT_HELP = ("project scope: <dir>/.claude (claude dir types; `.` = cwd) and <dir>/.mcp.json "
384
+ "(mcp); parks under ~/.agent-toggle/parked, never inside the project")
385
+
386
+
387
+ def build_parser() -> argparse.ArgumentParser:
388
+ # SUPPRESS: a flag given before the subcommand must survive the subparser's
389
+ # own defaults, so neither level sets a default; main() fills them in.
390
+ common = argparse.ArgumentParser(add_help=False)
391
+ common.add_argument("--harness", choices=list(harnesses()), default=argparse.SUPPRESS,
392
+ help="target harness (default: claude)")
393
+ common.add_argument("--color", choices=COLOR_MODES, default=argparse.SUPPRESS,
394
+ help="colorize output (default: auto; honors NO_COLOR / FORCE_COLOR)")
395
+ common.add_argument("--json", action="store_true", default=argparse.SUPPRESS,
396
+ help="print exactly one JSON document instead of text")
397
+ common.add_argument("-v", "--verbose", action="store_true", default=argparse.SUPPRESS,
398
+ help="print a traceback on unexpected errors")
399
+ common.add_argument("--version", action="version", default=argparse.SUPPRESS,
400
+ version=f"agent-toggle {__version__}")
401
+ p = _Parser(prog="agent-toggle", parents=[common],
402
+ description="Temporarily disable / re-enable AI-agent resources.",
403
+ epilog="exit codes: 0 ok, 1 partial failure, 2 usage error, "
404
+ "3 locked, 4 unsupported pair / harness not installed")
405
+ sub = p.add_subparsers(dest="command", required=True, metavar="command",
406
+ parser_class=_Parser)
407
+ up = sub.add_parser("ui", aliases=["pick"], parents=[common], help="interactive picker")
408
+ up.add_argument("--dry-run", action="store_true",
409
+ help="show the plan for what you stage; change nothing")
410
+ sub.add_parser("status", parents=[common], help="health check")
411
+ ls = sub.add_parser("list", parents=[common], help="what is currently disabled")
412
+ ls.add_argument("type", nargs="?", choices=TYPES)
413
+ ls.add_argument("--project", metavar="dir", help="only this project's entries")
414
+ cp = sub.add_parser("cost", parents=[common],
415
+ help="estimated startup tokens per item, biggest first")
416
+ cp.add_argument("--type", choices=TYPES, help="only this resource type")
417
+ sub.add_parser("migrate", parents=[common], help="import an older ~/.claude-toggle state")
418
+ sp = sub.add_parser("install-shims", parents=[common],
419
+ help="write the skill shim into every installed harness")
420
+ sp.add_argument("--dry-run", action="store_true", help="show the plan; change nothing")
421
+ pp = sub.add_parser("profile", parents=[common],
422
+ help="save / apply / diff / list named sets of live items")
423
+ pp.add_argument("action", help="save | apply | diff | list")
424
+ pp.add_argument("target", nargs="?", metavar="name|file")
425
+ pp.add_argument("--out", metavar="file", help="save: write the profile here instead")
426
+ pp.add_argument("--dry-run", action="store_true", help="apply: show the plan; change nothing")
427
+ pp.add_argument("--project", metavar="dir", help=PROJECT_HELP)
428
+ sub.add_parser("doctor", parents=[common],
429
+ help="read-only check of harness layouts and state against disk")
430
+ up2 = sub.add_parser("undo", parents=[common], help="reverse the last logged batch")
431
+ up2.add_argument("--dry-run", action="store_true", help="show the plan; change nothing")
432
+ for name, verb in (("disable", "park"), ("enable", "restore")):
433
+ sp = sub.add_parser(name, parents=[common], help=f"{verb} one or more items")
434
+ sp.add_argument("type", choices=TYPES, nargs="?" if name == "enable" else None)
435
+ sp.add_argument("names", nargs="*" if name == "enable" else "+", metavar="name")
436
+ sp.add_argument("--dry-run", action="store_true",
437
+ help="show the plan; change nothing")
438
+ sp.add_argument("--project", metavar="dir", help=PROJECT_HELP)
439
+ if name == "enable":
440
+ sp.add_argument("--all", action="store_true",
441
+ help="restore every disabled item (optionally --harness H)")
442
+ return p
443
+
444
+
445
+ def main(argv: list[str] | None = None) -> int:
446
+ """Run one command and return its exit code (the process exit code)."""
447
+ argv = normalize_argv(sys.argv[1:] if argv is None else argv)
448
+ out = Result(next((a for a in argv if a in COMMANDS), ""), "--json" in argv,
449
+ scan_color(argv))
450
+ try:
451
+ args = build_parser().parse_args(argv)
452
+ out.command = "ui" if args.command == "pick" else args.command
453
+ out.json_mode = getattr(args, "json", False)
454
+ args.harness = getattr(args, "harness", None)
455
+ cmd = out.command
456
+ if cmd == "ui":
457
+ if out.json_mode:
458
+ die("ui is interactive; --json is not supported", 2)
459
+ cmd_ui(load_state(write_back=False), out, args.dry_run)
460
+ elif cmd == "cost":
461
+ cmd_cost(load_state(write_back=False), out, args.harness, args.type)
462
+ elif cmd == "status":
463
+ cmd_status(load_state(write_back=False), out, args.harness)
464
+ elif cmd == "list":
465
+ cmd_list(args.type, load_state(write_back=False), out, args.harness, args.project)
466
+ elif cmd == "migrate":
467
+ cmd_migrate(out)
468
+ elif cmd == "install-shims":
469
+ cmd_install_shims(args, out)
470
+ elif cmd == "profile":
471
+ profiles.cmd_profile(args, out)
472
+ elif cmd == "doctor":
473
+ doctor.cmd_doctor(args.harness, out)
474
+ elif cmd == "undo":
475
+ undo.cmd_undo(args.dry_run, args.harness, out)
476
+ elif cmd == "enable" and args.all:
477
+ if args.type or args.names:
478
+ die("enable --all takes no <type> or <name>", 2)
479
+ undo.cmd_enable_all(args.harness, args.dry_run, out, args.project)
480
+ else:
481
+ args.harness = args.harness or "claude"
482
+ cmd_toggle(args, out)
483
+ rc = out.exit_code()
484
+ except fs.Locked as e:
485
+ out.error(str(e))
486
+ rc = 3
487
+ except CliError as e:
488
+ out.error(e.msg)
489
+ rc = e.code
490
+ except SystemExit as e: # argparse --help / --version
491
+ return e.code or 0
492
+ except Exception as e: # never a bare traceback; --json stays one document
493
+ out.error(f"{type(e).__name__}: {e}")
494
+ if os.environ.get("AGENT_TOGGLE_DEBUG") == "1" or {"-v", "--verbose"} & set(argv):
495
+ traceback.print_exc()
496
+ rc = 1
497
+ out.render()
498
+ return rc
@@ -0,0 +1,160 @@
1
+ """Companion files: helpers an item references that live outside the item."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from . import fs
9
+ from .fs import iter_text_files, read_text, safe_move
10
+ from .output import Result
11
+
12
+ REF_RE = re.compile(
13
+ r"""[`'"(\[]?\s*((?:\$HOME|~|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?)?[\w./@-]*"""
14
+ r"""\.(?:sh|py|mjs|cjs|js|ts|json|ya?ml|toml|txt|md))"""
15
+ )
16
+
17
+
18
+ def move(src: Path, dest_dir: Path, dry_run: bool = False) -> Path:
19
+ """safe_move, or (dry run) the same refusals with nothing written.
20
+
21
+ Returns where `src` lands.
22
+ """
23
+ if not dry_run:
24
+ return safe_move(src, dest_dir)
25
+ if not (src.exists() or src.is_symlink()):
26
+ raise FileNotFoundError(f"{src} does not exist")
27
+ if dest_dir.exists() and not dest_dir.is_dir():
28
+ raise NotADirectoryError(f"{dest_dir} exists but is not a directory")
29
+ target = dest_dir / src.name
30
+ if target.exists() or target.is_symlink():
31
+ raise FileExistsError(f"{target} already exists -- refusing to overwrite")
32
+ return target
33
+
34
+
35
+ def _own_scope(item: Path, source: Path | None) -> tuple[list[Path], list[Path]]:
36
+ """(dirs, files) that count as the item itself, so they are never companions.
37
+
38
+ `item` is where the item lives (real run) or WILL live once parked (dry run).
39
+ A dry run also passes `source`, where it still sits, so the plan matches
40
+ what a real run sees after the move.
41
+ """
42
+ real = source or item
43
+ dirs = [item if real.is_dir() else item.parent]
44
+ files = [item]
45
+ if source is not None:
46
+ (dirs if real.is_dir() else files).append(source)
47
+ return dirs, files
48
+
49
+
50
+ def _is_own(p: Path, scope: tuple[list[Path], list[Path]]) -> bool:
51
+ dirs, files = scope
52
+ return p in files or any(p.is_relative_to(d) for d in dirs)
53
+
54
+
55
+ def find_companions(item: Path, home: Path, source: Path | None = None) -> list[Path]:
56
+ """Files under `home` that `item` references and that live outside `item`.
57
+
58
+ Deliberately a heuristic over file text: a runtime-built path cannot be
59
+ found by reading source. Everything it finds is REPORTED before anything
60
+ moves, so a wrong guess is visible rather than silent.
61
+ """
62
+ # Resolve everything into one space: ~/.claude is often a symlink, and on
63
+ # macOS /var itself is one, so an unresolved comparison silently drops
64
+ # every companion as "outside the harness".
65
+ home, item = home.resolve(), item.resolve()
66
+ source = source.resolve() if source else None
67
+ scope = _own_scope(item, source)
68
+ item_dir = scope[0][0]
69
+ user_home = fs.home()
70
+ found: set[Path] = set()
71
+ for src in iter_text_files(source or item):
72
+ for raw in REF_RE.findall(read_text(src)):
73
+ token = raw.strip("`'\"([ ")
74
+ # Anchor on fs.home(), the same home the harness table resolves
75
+ # from, so `~` and $HOME agree with the harness being operated on.
76
+ if token.startswith("~"):
77
+ token = str(user_home) + token[1:]
78
+ token = token.replace("${HOME}", str(user_home)).replace("$HOME", str(user_home))
79
+ expanded = os.path.expandvars(token)
80
+ if "$" in expanded: # unresolved variable -- cannot verify
81
+ continue
82
+ cands = ([Path(expanded)] if expanded.startswith("/")
83
+ else [home / expanded, item_dir / expanded])
84
+ for c in cands:
85
+ try:
86
+ c = c.resolve()
87
+ except OSError:
88
+ continue
89
+ if not c.is_file() or not c.is_relative_to(home):
90
+ continue
91
+ if _is_own(c, scope):
92
+ continue # part of the item itself
93
+ found.add(c)
94
+ return sorted(found)
95
+
96
+
97
+ def other_users(target: Path, item: Path, home: Path,
98
+ source: Path | None = None) -> list[Path]:
99
+ """Live files outside `item` that also reference `target`."""
100
+ home, item, target = home.resolve(), item.resolve(), target.resolve()
101
+ scope = _own_scope(item, source.resolve() if source else None)
102
+ needle = target.name
103
+ rel = str(target.relative_to(home))
104
+ users: list[Path] = []
105
+ for src in iter_text_files(home):
106
+ if src == target or _is_own(src, scope):
107
+ continue
108
+ text = read_text(src)
109
+ if needle in text or rel in text:
110
+ users.append(src)
111
+ if len(users) >= 3: # enough to prove "shared"
112
+ break
113
+ return users
114
+
115
+
116
+ def park_companions(item: Path, home: Path, key: str, out: Result | None = None,
117
+ dry_run: bool = False, source: Path | None = None) -> list[dict]:
118
+ """Move companions used ONLY by this item; report the shared ones.
119
+
120
+ Dry run: plan the same moves without making them (`source` = where the item
121
+ still sits).
122
+
123
+ Moving a shared helper (tg-send.sh is referenced by five different things)
124
+ would silently break every other user, so sharing is a veto, not a warning.
125
+ """
126
+ out = out or Result()
127
+ home = home.resolve()
128
+ moved: list[dict] = []
129
+ for comp in find_companions(item, home, source):
130
+ users = other_users(comp, item, home, source)
131
+ if users:
132
+ names = ", ".join(u.name for u in users[:2])
133
+ out.say(f" - kept {comp.relative_to(home)} (also used by {names})", warn=True)
134
+ continue
135
+ dest = fs.companion_dir() / key / comp.relative_to(home).parent
136
+ try:
137
+ target = move(comp, dest, dry_run)
138
+ except (OSError, FileExistsError, NotADirectoryError) as e:
139
+ out.say(f" ! companion {comp.name}: {e}", warn=True)
140
+ continue
141
+ moved.append({"from": str(comp), "to": str(target)})
142
+ out.say(f" + {'would park' if dry_run else 'parked'} "
143
+ f"{comp.relative_to(home)} (exclusive)")
144
+ return moved
145
+
146
+
147
+ def restore_companions(entry: dict, out: Result | None = None,
148
+ dry_run: bool = False) -> None:
149
+ out = out or Result()
150
+ for c in entry.get("companions", []):
151
+ src, dst = Path(c["to"]), Path(c["from"])
152
+ if not src.exists():
153
+ out.say(f" ! companion missing: {src}", warn=True)
154
+ continue
155
+ try:
156
+ move(src, dst.parent, dry_run)
157
+ except (OSError, FileExistsError, NotADirectoryError) as e:
158
+ out.say(f" ! companion {dst.name}: {e}", warn=True)
159
+ else:
160
+ out.say(f" + {'would restore' if dry_run else 'restored'} {dst.name}")