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/ops.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The one apply path: every command that changes things hands a plan to apply_plan.
|
|
2
|
+
|
|
3
|
+
cmd_toggle, the picker (cmd_ui) and later profile apply / undo / enable --all
|
|
4
|
+
all build a list of Op and call apply_plan, which routes each item by the
|
|
5
|
+
harness MECHANISM table (Harness.mechanisms[type]), never by hard-coded types.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
from . import fs
|
|
13
|
+
from .harnesses import harnesses, project_view
|
|
14
|
+
from .mechanisms import (
|
|
15
|
+
fail_row,
|
|
16
|
+
toggle_dir_type,
|
|
17
|
+
toggle_json_mcp,
|
|
18
|
+
toggle_mcp,
|
|
19
|
+
toggle_plugin,
|
|
20
|
+
toggle_project_mcp,
|
|
21
|
+
)
|
|
22
|
+
from .output import CliError, Result
|
|
23
|
+
from .store import BATCH, load_state, save_state
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Op(NamedTuple):
|
|
27
|
+
harness: str
|
|
28
|
+
type: str
|
|
29
|
+
action: str # "disable" | "enable"
|
|
30
|
+
name: str
|
|
31
|
+
project: str | None = None # --project dir: project scope, never user scope
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _dispatch(plan: list[Op], state: dict, out: Result, dry_run: bool, batch: str,
|
|
35
|
+
headers: bool) -> int:
|
|
36
|
+
"""Run the plan, grouped per harness/type/action in first-seen order."""
|
|
37
|
+
groups: dict[tuple[str, str, str, str | None], list[str]] = {}
|
|
38
|
+
for op in plan:
|
|
39
|
+
groups.setdefault((op.harness, op.type, op.action, op.project), []).append(op.name)
|
|
40
|
+
user = harnesses()
|
|
41
|
+
fails = 0
|
|
42
|
+
for (harness, type_, action, project), names in groups.items():
|
|
43
|
+
table = user
|
|
44
|
+
if project is not None:
|
|
45
|
+
# project scope: only the claude dir types, through a project-only table,
|
|
46
|
+
# so no user-scope dir or entry can be reached (DESIGN s5.5)
|
|
47
|
+
try:
|
|
48
|
+
if harness != "claude":
|
|
49
|
+
raise CliError(f"{harness} has no --project scope", 4)
|
|
50
|
+
table = {"claude": project_view(Path(project))}
|
|
51
|
+
except CliError as e:
|
|
52
|
+
for name in names:
|
|
53
|
+
fails += fail_row(out, dry_run, harness, type_, action, name, e.msg,
|
|
54
|
+
batch=batch, project=project)
|
|
55
|
+
continue
|
|
56
|
+
h = table[harness]
|
|
57
|
+
mech = h.mechanisms.get(type_)
|
|
58
|
+
if headers:
|
|
59
|
+
out.say(f"\n{action} {type_} on {out.paint(harness, 'cyan')}"
|
|
60
|
+
+ (f" (project {h.project}):" if h.project else ":"))
|
|
61
|
+
if mech == "move":
|
|
62
|
+
fails += toggle_dir_type(action, type_, names, state, harness, h.home, out,
|
|
63
|
+
dry_run, batch=batch, table=table)
|
|
64
|
+
elif mech == "native_cli" or (mech == "flag" and type_ == "plugin"):
|
|
65
|
+
fails += toggle_plugin(action, names, state, harness, out, dry_run, batch=batch)
|
|
66
|
+
elif mech == "remove_backup" and h.project: # the repo's own .mcp.json
|
|
67
|
+
fails += toggle_project_mcp(action, names, state, harness, out, dry_run, table,
|
|
68
|
+
batch=batch)
|
|
69
|
+
elif mech == "remove_backup" and h.backend == "json": # user-scope strict JSON file
|
|
70
|
+
fails += toggle_json_mcp(action, names, state, harness, out, dry_run, table,
|
|
71
|
+
batch=batch)
|
|
72
|
+
elif mech == "remove_backup" or (mech == "flag" and type_ == "mcp"):
|
|
73
|
+
fails += toggle_mcp(action, names, state, harness, h.home, h.backend, out,
|
|
74
|
+
dry_run, batch=batch)
|
|
75
|
+
else:
|
|
76
|
+
for name in names:
|
|
77
|
+
fails += fail_row(out, dry_run, harness, type_, action, name,
|
|
78
|
+
f"{harness} has no {type_} support"
|
|
79
|
+
+ (" in project scope" if h.project else ""), "unsupported",
|
|
80
|
+
batch=batch, project=project)
|
|
81
|
+
return fails
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def apply_plan(plan: list[Op], out: Result, dry_run: bool = False, *,
|
|
85
|
+
batch: str = BATCH, state: dict | None = None,
|
|
86
|
+
headers: bool = False) -> int:
|
|
87
|
+
"""Apply `plan`; returns the fail count.
|
|
88
|
+
|
|
89
|
+
A real run takes ONE fs.lock() for the whole plan, re-reads state under it
|
|
90
|
+
and saves it in `finally`, so what already moved is kept even if a later
|
|
91
|
+
item crashes. A dry run plans against a read-only state: no lock, state,
|
|
92
|
+
log or backup write. A caller that already holds the lock passes `state`.
|
|
93
|
+
"""
|
|
94
|
+
if state is not None:
|
|
95
|
+
return _dispatch(plan, state, out, dry_run, batch, headers)
|
|
96
|
+
if dry_run:
|
|
97
|
+
return _dispatch(plan, load_state(write_back=False), out, True, batch, headers)
|
|
98
|
+
with fs.lock():
|
|
99
|
+
state = load_state()
|
|
100
|
+
try:
|
|
101
|
+
return _dispatch(plan, state, out, False, batch, headers)
|
|
102
|
+
finally:
|
|
103
|
+
save_state(state)
|
agent_toggle/output.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Command output: one collector that renders as human text OR one JSON document.
|
|
2
|
+
|
|
3
|
+
Every command builds a `Result`. In human mode lines stream to stdout as they
|
|
4
|
+
happen (diagnostics to stderr); in --json mode nothing is printed until
|
|
5
|
+
`render()` emits the single envelope, so text can never leak into stdout.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
FAILED = ("error", "unsupported") # row statuses that make the run a failure
|
|
14
|
+
MARK = {"ok": "v", "planned": "~", "skipped": "-"} # human marker per status; else "x"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
STYLE = {"green": "32", "red": "31", "yellow": "33", "cyan": "36", "dim": "2"}
|
|
18
|
+
COLOR_MODES = ("auto", "always", "never")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _vt_enable(stream) -> bool:
|
|
22
|
+
"""Windows 10+: make sure the console is in ANSI mode. True iff it is (TTY only)."""
|
|
23
|
+
try:
|
|
24
|
+
import ctypes
|
|
25
|
+
import msvcrt
|
|
26
|
+
k32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
27
|
+
h = ctypes.c_void_p(msvcrt.get_osfhandle(stream.fileno()))
|
|
28
|
+
mode = ctypes.c_uint32()
|
|
29
|
+
if not k32.GetConsoleMode(h, ctypes.byref(mode)):
|
|
30
|
+
return False
|
|
31
|
+
return bool(mode.value & 0x0004) or bool(k32.SetConsoleMode(h, mode.value | 0x0004))
|
|
32
|
+
except Exception: # no console / old Windows: plain text
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def use_color(stream, mode: str = "auto") -> bool:
|
|
37
|
+
"""Decide for ONE stream, at write time -- nothing is cached between calls.
|
|
38
|
+
--color always|never wins; then NO_COLOR, FORCE_COLOR, TERM=dumb, then isatty.
|
|
39
|
+
A Windows console that cannot do ANSI stays plain, even for --color always."""
|
|
40
|
+
if mode == "never":
|
|
41
|
+
return False
|
|
42
|
+
env = os.environ
|
|
43
|
+
try:
|
|
44
|
+
tty = bool(stream.isatty())
|
|
45
|
+
except Exception:
|
|
46
|
+
tty = False
|
|
47
|
+
if mode != "always":
|
|
48
|
+
if env.get("NO_COLOR"):
|
|
49
|
+
return False
|
|
50
|
+
forced = env.get("FORCE_COLOR", "0") not in ("", "0") # beats TERM=dumb and non-tty
|
|
51
|
+
if not forced and (env.get("TERM") == "dumb" or not tty):
|
|
52
|
+
return False
|
|
53
|
+
return not (tty and os.name == "nt" and not _vt_enable(stream))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def scan_color(argv: list[str]) -> str:
|
|
57
|
+
"""The --color value from raw argv (so even argparse errors honor it); bad -> auto."""
|
|
58
|
+
mode = "auto"
|
|
59
|
+
for i, a in enumerate(argv):
|
|
60
|
+
if a == "--color" and i + 1 < len(argv):
|
|
61
|
+
mode = argv[i + 1]
|
|
62
|
+
elif a.startswith("--color="):
|
|
63
|
+
mode = a[8:]
|
|
64
|
+
return mode if mode in COLOR_MODES else "auto"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CliError(SystemExit):
|
|
68
|
+
"""A fatal, user-facing error. `code` is the process exit code (a SystemExit,
|
|
69
|
+
so legacy `except SystemExit` callers keep working); `msg` is for the human."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, msg: str, code: int = 1) -> None:
|
|
72
|
+
super().__init__(code)
|
|
73
|
+
self.msg = msg
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def die(msg: str, code: int = 1) -> None:
|
|
77
|
+
raise CliError(msg, code)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Result:
|
|
81
|
+
def __init__(self, command: str = "", json_mode: bool = False,
|
|
82
|
+
color: str = "auto") -> None:
|
|
83
|
+
self.command = command
|
|
84
|
+
self.json_mode = json_mode
|
|
85
|
+
self.color = color
|
|
86
|
+
self.rows: list[dict] = []
|
|
87
|
+
self.warnings: list[str] = []
|
|
88
|
+
|
|
89
|
+
def paint(self, text: str, style: str, stream=None) -> str:
|
|
90
|
+
"""`text` in `style` if `stream` (default stdout) takes color; never in --json.
|
|
91
|
+
Apply AFTER width padding so uncolored output stays byte-identical."""
|
|
92
|
+
if self.json_mode or not text or not use_color(stream or sys.stdout, self.color):
|
|
93
|
+
return text
|
|
94
|
+
return f"\033[{STYLE[style]}m{text}\033[0m"
|
|
95
|
+
|
|
96
|
+
def say(self, msg: str, warn: bool = False, style: str | None = None) -> None:
|
|
97
|
+
"""A human line on stdout. warn=True also records it in the envelope."""
|
|
98
|
+
if warn:
|
|
99
|
+
self.warnings.append(msg.strip())
|
|
100
|
+
if not self.json_mode:
|
|
101
|
+
style = style or ("yellow" if warn else None)
|
|
102
|
+
print(self.paint(msg, style) if style else msg)
|
|
103
|
+
|
|
104
|
+
def warn(self, msg: str) -> None:
|
|
105
|
+
"""A warning: stderr for humans, `warnings` for --json."""
|
|
106
|
+
self.warnings.append(msg)
|
|
107
|
+
if not self.json_mode:
|
|
108
|
+
print(f"{self.paint('WARNING', 'yellow', sys.stderr)} {msg}", file=sys.stderr)
|
|
109
|
+
|
|
110
|
+
def row(self, harness: str | None, type_: str | None, name: str | None,
|
|
111
|
+
action: str, status: str, text: str = "", show: bool = True,
|
|
112
|
+
**extra) -> dict:
|
|
113
|
+
"""Record one result; `show=False` records it without a human line."""
|
|
114
|
+
row = {"harness": harness, "type": type_, "name": name,
|
|
115
|
+
"action": action, "status": status, "detail": text, **extra}
|
|
116
|
+
self.rows.append(row)
|
|
117
|
+
if show and not self.json_mode:
|
|
118
|
+
if status in MARK:
|
|
119
|
+
mark = self.paint(MARK[status], "green" if status == "ok" else "dim")
|
|
120
|
+
print(f" {mark} {type_} {name} {text}")
|
|
121
|
+
else:
|
|
122
|
+
print(self.paint(f" x {type_} {name}: {text}", "red"))
|
|
123
|
+
return row
|
|
124
|
+
|
|
125
|
+
def error(self, msg: str) -> None:
|
|
126
|
+
self.rows.append({"harness": None, "type": None, "name": None,
|
|
127
|
+
"action": self.command, "status": "error", "detail": msg})
|
|
128
|
+
if not self.json_mode:
|
|
129
|
+
print(f"{self.paint('error:', 'red', sys.stderr)} {msg}", file=sys.stderr)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def failed(self) -> int:
|
|
133
|
+
return sum(r["status"] in FAILED for r in self.rows)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def needs_new_session(self) -> bool:
|
|
137
|
+
return any(r["status"] == "ok" and r["action"] in ("disable", "enable")
|
|
138
|
+
for r in self.rows)
|
|
139
|
+
|
|
140
|
+
def exit_code(self) -> int:
|
|
141
|
+
"""0 ok; 4 when every failure was an unsupported pair; else 1 (partial)."""
|
|
142
|
+
bad = [r for r in self.rows if r["status"] in FAILED]
|
|
143
|
+
if not bad:
|
|
144
|
+
return 0
|
|
145
|
+
return 4 if all(r["status"] == "unsupported" for r in bad) else 1
|
|
146
|
+
|
|
147
|
+
def envelope(self) -> dict:
|
|
148
|
+
return {"ok": self.failed == 0, "command": self.command, "results": self.rows,
|
|
149
|
+
"warnings": self.warnings, "needs_new_session": self.needs_new_session}
|
|
150
|
+
|
|
151
|
+
def render(self) -> None:
|
|
152
|
+
if self.json_mode:
|
|
153
|
+
print(json.dumps(self.envelope(), indent=2, ensure_ascii=False))
|
agent_toggle/profiles.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Profiles (DESIGN s5.7): a portable snapshot of which items are live.
|
|
2
|
+
|
|
3
|
+
A profile holds ONLY {harness, type, name, live} per item -- never a path or a
|
|
4
|
+
secret -- so it can sit in a dotfiles repo. `apply` toggles just the items the
|
|
5
|
+
profile mentions: an item installed after the save is never touched, and an item
|
|
6
|
+
the profile names that this machine lacks is skipped, not failed.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import cost, fs, ops, store
|
|
15
|
+
from .harnesses import TYPES, harnesses, project_view
|
|
16
|
+
from .mechanisms import _valid_name, validate_name
|
|
17
|
+
from .output import Result, die
|
|
18
|
+
|
|
19
|
+
VERSION = 1
|
|
20
|
+
MAX_BYTES = 1 << 20 # a real profile is a few KB
|
|
21
|
+
ITEM_KEYS = {"harness", "type", "name", "live"}
|
|
22
|
+
WIN_RESERVED = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)),
|
|
23
|
+
*(f"LPT{i}" for i in range(1, 10))} # not valid file names on Windows
|
|
24
|
+
ACTIONS = ("save", "apply", "diff", "list")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_path(arg: str) -> bool:
|
|
28
|
+
return arg.endswith(".json") or "/" in arg or "\\" in arg
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _file_path(arg: str) -> Path:
|
|
32
|
+
p = Path(arg).expanduser()
|
|
33
|
+
if ".." in p.parts:
|
|
34
|
+
die(f"invalid profile path {arg!r}: `..` is not allowed", 2)
|
|
35
|
+
return p
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _stored_path(name: str) -> Path:
|
|
39
|
+
validate_name(name)
|
|
40
|
+
if ":" in name or name.split(".")[0].upper() in WIN_RESERVED or name.endswith("."):
|
|
41
|
+
die(f"invalid profile name {name!r}", 2)
|
|
42
|
+
return fs.profiles_dir() / f"{name}.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load(arg: str) -> dict:
|
|
46
|
+
p = _file_path(arg) if _is_path(arg) else _stored_path(arg)
|
|
47
|
+
try:
|
|
48
|
+
with open(p, "rb") as fh:
|
|
49
|
+
data = fh.read(MAX_BYTES + 1)
|
|
50
|
+
except OSError as e:
|
|
51
|
+
die(f"cannot read profile {arg!r}: {e.strerror or e}", 2)
|
|
52
|
+
if len(data) > MAX_BYTES:
|
|
53
|
+
die(f"profile {arg!r} is larger than {MAX_BYTES} bytes", 2)
|
|
54
|
+
try:
|
|
55
|
+
return _validate(json.loads(data.decode("utf-8")))
|
|
56
|
+
except (ValueError, RecursionError) as e: # JSON, UTF-8 and absurd nesting
|
|
57
|
+
die(f"profile {arg!r} is not valid JSON: {e}", 2)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate(doc) -> dict:
|
|
61
|
+
"""Exit 2 on anything but a well-formed v1 profile (it may come from anywhere)."""
|
|
62
|
+
table = harnesses()
|
|
63
|
+
if not isinstance(doc, dict) or set(doc) - {"version", "saved_at", "items", "scope"}:
|
|
64
|
+
die("profile must be an object with version, saved_at, items", 2)
|
|
65
|
+
if doc.get("scope", "user") not in ("user", "project"):
|
|
66
|
+
die(f"unknown profile scope {doc['scope']!r} (expected user or project)", 2)
|
|
67
|
+
v = doc.get("version")
|
|
68
|
+
if type(v) is not int or v != VERSION:
|
|
69
|
+
die(f"unsupported profile version {v!r} (expected {VERSION})", 2)
|
|
70
|
+
if not isinstance(doc.get("items"), list):
|
|
71
|
+
die("profile items must be a list", 2)
|
|
72
|
+
seen = set()
|
|
73
|
+
for it in doc["items"]:
|
|
74
|
+
if not isinstance(it, dict) or set(it) != ITEM_KEYS:
|
|
75
|
+
die(f"profile item must have exactly {sorted(ITEM_KEYS)}", 2)
|
|
76
|
+
if not isinstance(it["harness"], str) or it["harness"] not in table:
|
|
77
|
+
die(f"profile names unknown harness {it['harness']!r}", 2)
|
|
78
|
+
if not isinstance(it["type"], str) or it["type"] not in TYPES:
|
|
79
|
+
die(f"profile names unknown type {it['type']!r}", 2)
|
|
80
|
+
if not isinstance(it["name"], str):
|
|
81
|
+
die("profile item name must be a string", 2)
|
|
82
|
+
validate_name(it["name"])
|
|
83
|
+
if not isinstance(it["live"], bool):
|
|
84
|
+
die("profile item live must be true or false", 2)
|
|
85
|
+
key = (it["harness"], it["type"], it["name"])
|
|
86
|
+
if key in seen:
|
|
87
|
+
die(f"profile lists {'/'.join(key)} twice", 2)
|
|
88
|
+
seen.add(key)
|
|
89
|
+
return doc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _inventory(out: Result, plugins: bool,
|
|
93
|
+
project: Path | None = None) -> dict[tuple[str, str, str], bool]:
|
|
94
|
+
"""(harness, type, name) -> live now, in ONE scope: user scope, or (`project`, a
|
|
95
|
+
resolved --project dir) that project's .claude only -- the two never mix.
|
|
96
|
+
Reuses the cost inventory (one owner per shared dir)."""
|
|
97
|
+
state = store.scope_state(store.load_state(write_back=False), project)
|
|
98
|
+
table = {"claude": project_view(project)} if project else harnesses()
|
|
99
|
+
# a project dir symlinked out of the project (to ~/.claude/skills) is user scope
|
|
100
|
+
outside = {t for t, subs in table["claude"].dirs.items()
|
|
101
|
+
if any(not (table["claude"].home / s).resolve().is_relative_to(project)
|
|
102
|
+
for s in subs)} if project else set()
|
|
103
|
+
return {(i.harness, i.type, i.name): i.enabled
|
|
104
|
+
for i in cost.inventory(state, table, out.warn, plugins and not project)
|
|
105
|
+
if i.type not in outside}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _project(arg: str | None, only: str | None) -> Path | None:
|
|
109
|
+
"""The resolved --project dir (claude layout only, exit 4 otherwise), or None."""
|
|
110
|
+
if arg is None:
|
|
111
|
+
return None
|
|
112
|
+
if only not in (None, "claude"):
|
|
113
|
+
die(f"--project supports only the claude layout, not {only}", 4)
|
|
114
|
+
return project_view(arg).project
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_save(arg: str, out_file: str | None, only: str | None, out: Result,
|
|
118
|
+
project: Path | None = None) -> None:
|
|
119
|
+
if _is_path(arg):
|
|
120
|
+
die(f"{arg!r} looks like a path: save <name> [--out <file>]", 2)
|
|
121
|
+
path = _file_path(out_file) if out_file else _stored_path(arg)
|
|
122
|
+
if out_file:
|
|
123
|
+
validate_name(arg)
|
|
124
|
+
if path.is_dir() or not path.parent.is_dir():
|
|
125
|
+
die(f"cannot write {path}: not a file in an existing directory", 2)
|
|
126
|
+
inv = _inventory(out, plugins=not only or only == "claude", project=project)
|
|
127
|
+
items = []
|
|
128
|
+
for (h, t, n), live in sorted(inv.items()):
|
|
129
|
+
if only and h != only:
|
|
130
|
+
continue
|
|
131
|
+
if _valid_name(n):
|
|
132
|
+
items.append({"harness": h, "type": t, "name": n, "live": live})
|
|
133
|
+
else: # apply/diff would refuse the whole profile over this name
|
|
134
|
+
out.warn(f"skipped {h} {t} {n!r}: not a name agent-toggle can toggle")
|
|
135
|
+
doc = {"version": VERSION, "saved_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
136
|
+
"items": items, **({"scope": "project"} if project else {})}
|
|
137
|
+
if not out_file:
|
|
138
|
+
fs.private_dir(fs.profiles_dir())
|
|
139
|
+
fs.atomic_write(path, json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
|
|
140
|
+
out.say(f"saved {len(items)} item(s) -> {path}")
|
|
141
|
+
out.row(None, None, arg, "save", "ok", f"{len(items)} items", show=False,
|
|
142
|
+
path=str(path), items=len(items))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_apply(arg: str, only: str | None, dry_run: bool, out: Result,
|
|
146
|
+
project: Path | None = None) -> None:
|
|
147
|
+
doc = _load(arg)
|
|
148
|
+
if (doc.get("scope", "user") == "project") != (project is not None):
|
|
149
|
+
die(f"profile {arg!r} is {doc.get('scope', 'user')}-scope: "
|
|
150
|
+
+ ("pass --project <dir>" if project is None else "drop --project"), 2)
|
|
151
|
+
inv = _inventory(out, plugins=not dry_run and (not only or only == "claude"), # dry run: no CLI
|
|
152
|
+
project=project)
|
|
153
|
+
plan, same, skipped = [], 0, 0
|
|
154
|
+
for it in doc["items"]:
|
|
155
|
+
h, t, n, live = it["harness"], it["type"], it["name"], it["live"]
|
|
156
|
+
if only and h != only:
|
|
157
|
+
continue
|
|
158
|
+
if (h, t, n) not in inv:
|
|
159
|
+
skipped += 1
|
|
160
|
+
why = ("plugin state is not read in a dry run" if dry_run and t == "plugin"
|
|
161
|
+
else "not on this machine")
|
|
162
|
+
out.row(h, t, n, "would-skip" if dry_run else "skip", "skipped", why)
|
|
163
|
+
elif inv[h, t, n] == live:
|
|
164
|
+
same += 1
|
|
165
|
+
else:
|
|
166
|
+
plan.append(ops.Op(h, t, "enable" if live else "disable", n,
|
|
167
|
+
str(project) if project else None))
|
|
168
|
+
if plan:
|
|
169
|
+
ops.apply_plan(plan, out, dry_run, batch=store.BATCH, headers=True)
|
|
170
|
+
out.say(f"\n{len(plan)} {'to change' if dry_run else 'attempted'}, "
|
|
171
|
+
f"{same} already as profiled, {skipped} skipped")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_list(out: Result) -> None:
|
|
175
|
+
files = sorted(fs.profiles_dir().glob("*.json")) if fs.profiles_dir().is_dir() else []
|
|
176
|
+
if not files:
|
|
177
|
+
out.say("no profiles saved")
|
|
178
|
+
for p in files:
|
|
179
|
+
try:
|
|
180
|
+
doc = json.loads(p.read_text(encoding="utf-8"))
|
|
181
|
+
detail = f"{len(doc['items'])} items, saved {str(doc.get('saved_at', '?'))[:10]}"
|
|
182
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
183
|
+
detail = "unreadable"
|
|
184
|
+
out.say(f" {p.stem:<24} {detail}")
|
|
185
|
+
out.row(None, None, p.stem, "list", "ok", detail, show=False, path=str(p))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cmd_profile(args, out: Result) -> None:
|
|
189
|
+
action, target = args.action, args.target
|
|
190
|
+
if action not in ACTIONS:
|
|
191
|
+
die(f"unknown profile action {action!r} (expected: {', '.join(ACTIONS)})", 2)
|
|
192
|
+
if (action == "list") == (target is not None):
|
|
193
|
+
die("profile list takes no argument" if action == "list"
|
|
194
|
+
else f"profile {action} needs a <name|file>", 2)
|
|
195
|
+
if args.out and action != "save":
|
|
196
|
+
die("--out only applies to `profile save`", 2)
|
|
197
|
+
if args.dry_run and action not in ("apply", "diff"):
|
|
198
|
+
die("--dry-run only applies to `profile apply` / `diff`", 2)
|
|
199
|
+
if args.project is not None and action == "list":
|
|
200
|
+
die("--project does not apply to `profile list`", 2)
|
|
201
|
+
project = _project(args.project, args.harness)
|
|
202
|
+
if action == "save":
|
|
203
|
+
cmd_save(target, args.out, args.harness, out, project)
|
|
204
|
+
elif action == "list":
|
|
205
|
+
cmd_list(out)
|
|
206
|
+
else: # diff is apply, planned only
|
|
207
|
+
cmd_apply(target, args.harness, action == "diff" or args.dry_run, out, project)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-toggle
|
|
3
|
+
description: Temporarily disable or restore AI-agent resources — skills, agents, commands, rules, plugins, MCP servers — across Claude Code, Codex, Grok CLI, OpenCode, OpenClaw, Copilot and Vibe. Nothing is deleted; everything is parked and reversible. Use when the user wants to turn something off, trim startup context cost, re-enable something previously disabled, or asks "how do I disable X" / "停用這個 skill" / "關掉某個 MCP" / "把它加回來".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# agent-toggle
|
|
7
|
+
|
|
8
|
+
Parks and restores agent resources (skills, agents, commands, rules, plugins,
|
|
9
|
+
MCP servers). **Never deletes anything**; every change is reversible.
|
|
10
|
+
|
|
11
|
+
## Run it
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
agent-toggle <command> --json
|
|
15
|
+
python3 __AGENT_TOGGLE_ROOT__/agent_toggle.py <command> --json # if agent-toggle is not on PATH
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Commands: `status` (run first), `doctor` (read-only health check), `list [type]`,
|
|
19
|
+
`cost [--type T]` (startup token estimates, biggest first), `disable <type> <name>...`,
|
|
20
|
+
`enable <type> <name>...`, `enable --all`, `undo` (reverse the last batch),
|
|
21
|
+
`profile save|apply|diff <name>`, `profile list`, `migrate`; add `--harness claude|codex|grok|opencode|openclaw|copilot|vibe|devin|agy`
|
|
22
|
+
(default `claude`) and `--dry-run` to preview (always preview `enable --all`, `undo`,
|
|
23
|
+
`profile apply`). `--project <dir>` targets a repo's own `.claude/` and `.mcp.json`
|
|
24
|
+
(warn the user: git shows the deletion). `<type>` = `skill` / `agent` /
|
|
25
|
+
`command` / `rule` / `plugin` / `mcp`. A colon addresses nesting: `orch:batch`.
|
|
26
|
+
A row with `shared_with` means another harness reads the same directory (OpenCode
|
|
27
|
+
aliasing a skills dir): the change affects them too, so say so.
|
|
28
|
+
`ui` is an interactive picker for the user, not for you.
|
|
29
|
+
|
|
30
|
+
## Read the result
|
|
31
|
+
|
|
32
|
+
`--json` prints one envelope on stdout: `ok`, `command`, `results` (one row
|
|
33
|
+
per item: harness, type, name, status, detail), `warnings`,
|
|
34
|
+
`needs_new_session`. Summarise the rows for the user; do not paste the JSON.
|
|
35
|
+
Branch on the exit code:
|
|
36
|
+
|
|
37
|
+
| code | meaning | do |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| 0 | ok | report; if `needs_new_session`, tell the user to open a NEW session (MCP changes only apply then) |
|
|
40
|
+
| 1 | partial | list the failed rows and why |
|
|
41
|
+
| 2 | usage error | fix the arguments and retry |
|
|
42
|
+
| 3 | locked | another run is active; retry later |
|
|
43
|
+
| 4 | unsupported pair / harness missing | tell the user; do not retry |
|
|
44
|
+
|
|
45
|
+
Rollback is `undo` or `enable <type> <name>`. State lives in `~/.agent-toggle/`.
|