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