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/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))
@@ -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/`.