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/store.py ADDED
@@ -0,0 +1,252 @@
1
+ """State file, action log, and the legacy ~/.claude-toggle import."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from . import fs
13
+ from .output import die
14
+
15
+ VERSION = 3
16
+
17
+
18
+ def mechanism_of(entry: dict) -> str | None:
19
+ """How an entry was parked, derived from the fields mechanisms.py writes."""
20
+ if entry.get("parked_at"):
21
+ return "move"
22
+ if entry.get("backup"):
23
+ return "remove_backup"
24
+ if entry.get("native"):
25
+ return "native_cli"
26
+ if entry.get("connector"): # claude.ai connector: fanned over ~/.claude.json projects
27
+ return "connector"
28
+ if entry.get("flag"): # one {file, pointer, was} config flag
29
+ return "flag"
30
+ return None
31
+
32
+
33
+ # ------------------------------------------------------------ state keys
34
+
35
+ def project_digest(project: Path | str) -> str:
36
+ return hashlib.sha1(str(Path(project).resolve()).encode("utf-8")).hexdigest()[:8]
37
+
38
+
39
+ def make_key(harness: str, type_: str, name: str, project: Path | str | None = None) -> str:
40
+ """The one state-key scheme: `harness:type:name` for user scope,
41
+ `harness@<sha1(resolved project dir)[:8]>:type:name` for project scope."""
42
+ if project is not None:
43
+ harness = f"{harness}@{project_digest(project)}"
44
+ return f"{harness}:{type_}:{name}"
45
+
46
+
47
+ def parse_key(key: str) -> tuple[str, str | None, str, str]:
48
+ """(harness, project_hash or None, type, name); a name may hold `:`."""
49
+ parts = key.split(":", 2)
50
+ if len(parts) != 3 or not all(parts):
51
+ raise ValueError(f"malformed state key {key!r}")
52
+ head, type_, name = parts
53
+ harness, at, digest = head.partition("@")
54
+ if not harness or (at and not re.fullmatch(r"[0-9a-f]{8}", digest)):
55
+ raise ValueError(f"malformed state key {key!r}")
56
+ return harness, digest or None, type_, name
57
+
58
+
59
+ # ------------------------------------------------- entry integrity (s6.1 row 2)
60
+
61
+ PATH_FIELDS = ("origin", "parked_at", "backup", "project")
62
+
63
+
64
+ def check_entry(entry: dict, table: dict, key: str | None = None) -> str | None:
65
+ """Why a (possibly tampered) state entry must not be replayed, or None.
66
+
67
+ `project` comes from the same untrusted entry, so it is refused when it is
68
+ a filesystem root, $HOME or an ancestor of it, and (given the state key)
69
+ must hash to the key's project digest. Project dirs are `.<home name>`
70
+ (`.claude`, `.opencode`); a project entry's origin must sit in one of them and
71
+ its parked_at under fs.parked_dir()/<sha8> (symlinked parents refused)."""
72
+ if not isinstance(entry, dict):
73
+ return "entry is not an object"
74
+ hname = entry.get("harness")
75
+ h = table.get(hname) if isinstance(hname, str) else None
76
+ if h is None:
77
+ return f"unknown harness {entry.get('harness')!r}"
78
+ flag = entry.get("flag")
79
+ fields = {f: entry.get(f) for f in PATH_FIELDS}
80
+ if flag is not None:
81
+ fields["flag"] = flag.get("file") if isinstance(flag, dict) else flag
82
+ for field, value in fields.items():
83
+ if value is None:
84
+ continue
85
+ if not isinstance(value, str) or not value:
86
+ return f"{field} is not a path"
87
+ if ".." in Path(value).parts:
88
+ return f"{field} holds '..': {value}"
89
+ if not Path(value).is_absolute(): # would resolve against the cwd
90
+ return f"{field} is not absolute: {value}"
91
+
92
+ project = Path(fields["project"]) if fields["project"] else None
93
+ if project is not None:
94
+ rp, home = project.resolve(), fs.home().resolve()
95
+ if rp == Path(rp.anchor) or home.is_relative_to(rp):
96
+ return f"project is a root or holds $HOME: {project}"
97
+ if key is not None:
98
+ try:
99
+ digest = parse_key(key)[1]
100
+ except ValueError as e:
101
+ return str(e)
102
+ if digest != (project_digest(project) if project else None):
103
+ return f"project does not match the state key {key!r}"
104
+ origin, parked = fields["origin"], fields["parked_at"]
105
+ if project is not None:
106
+ # Project scope: items come from <project>/.<home name>/<declared dir> and park
107
+ # ONLY under parked_dir()/<sha8>, never inside the project (a repo is shared).
108
+ base = project / ("." + h.home.name.lstrip("."))
109
+ declared = [base / sub for subs in h.dirs.values() for sub in subs
110
+ if not Path(sub).is_absolute()]
111
+ origin_ok = bool(origin) and fs.contained(Path(origin), *declared) \
112
+ and fs.contained(Path(origin), project)
113
+ parks = [fs.parked_dir() / project_digest(project)]
114
+ else:
115
+ declared = [h.home / sub for subs in h.dirs.values() for sub in subs]
116
+ origin_ok = bool(origin) and fs.contained(Path(origin), h.home, *declared)
117
+ # user scope parks only in the `*-disabled` siblings; parked_dir() is project-only
118
+ parks = [d.with_name(d.name + "-disabled") for d in declared]
119
+ if origin and not origin_ok:
120
+ return f"origin outside the {h.name} {'project' if project else 'home'}: {origin}"
121
+ if parked and not fs.contained(Path(parked), *parks):
122
+ return f"parked_at outside {' / '.join(map(str, parks))}: {parked}"
123
+ ffile = fields.get("flag")
124
+ if ffile and not ((fs.contained(Path(ffile), base) and fs.contained(Path(ffile), project))
125
+ if project is not None else
126
+ (fs.contained(Path(ffile), h.home, *declared)
127
+ or Path(ffile) in {fs.claude_json(), *([h.mcp.file] if h.mcp else [])})):
128
+ return f"flag file outside the {h.name} home/project: {ffile}"
129
+ backup = fields["backup"]
130
+ if backup and not fs.contained(Path(backup), fs.backup_dir()):
131
+ return f"backup outside {fs.backup_dir()}: {backup}"
132
+ return None
133
+
134
+
135
+ def scope_state(state: dict, project: Path | str | None = None) -> dict:
136
+ """`state` holding only one scope: user entries (project=None) or one project's."""
137
+ want = project_digest(project) if project is not None else None
138
+ keep = {}
139
+ for k, e in state["disabled"].items():
140
+ try:
141
+ if parse_key(k)[1] == want:
142
+ keep[k] = e
143
+ except ValueError:
144
+ pass
145
+ return {**state, "disabled": keep}
146
+
147
+
148
+ def upgrade(state: dict) -> bool:
149
+ """Forward-only in-place upgrade to schema v3. True if anything changed."""
150
+ if state.get("version", 0) > VERSION:
151
+ die(f"state is schema v{state['version']}, newer than this agent-toggle "
152
+ f"(v{VERSION}) -- upgrade agent-toggle")
153
+ changed = state.get("version") != VERSION
154
+ state["version"] = VERSION
155
+ for entry in state.setdefault("disabled", {}).values():
156
+ if isinstance(entry, dict) and "mechanism" not in entry:
157
+ mech = mechanism_of(entry) or "flag"
158
+ entry["mechanism"] = "flag" if mech == "connector" else mech # stored enum
159
+ changed = True
160
+ return changed
161
+
162
+
163
+ def load_state(write_back: bool = True, check_entries: bool = True) -> dict:
164
+ """Read state, upgrading it to v3. Read-only commands pass write_back=False
165
+ so they never write outside the lock; the upgrade then stays in memory.
166
+ `check_entries=False` (doctor) lets a non-object entry through to be reported."""
167
+ state_file = fs.state_file()
168
+ if not state_file.exists():
169
+ return {"version": VERSION, "disabled": {}}
170
+ if write_back: # read-only runs never chmod; status only warns
171
+ fs.tighten(state_file)
172
+ for bp in fs.backup_dir().glob("*.json"):
173
+ fs.tighten(bp)
174
+ try:
175
+ state = json.loads(state_file.read_text(encoding="utf-8"))
176
+ except (OSError, ValueError, UnicodeDecodeError) as e:
177
+ die(f"state file unreadable ({state_file}): {e}")
178
+ if not isinstance(state, dict):
179
+ die(f"state file malformed ({state_file}): top level must be an object")
180
+ version = state.get("version", 0)
181
+ if not isinstance(version, int) or isinstance(version, bool):
182
+ die(f"state file malformed ({state_file}): version must be an integer")
183
+ if not isinstance(state.get("disabled", {}), dict):
184
+ die(f"state file malformed ({state_file}): disabled must be an object")
185
+ for key, entry in state.get("disabled", {}).items():
186
+ if check_entries and not isinstance(entry, dict):
187
+ die(f"state file malformed ({state_file}): entry {key!r} must be an object")
188
+ if upgrade(state) and write_back:
189
+ save_state(state)
190
+ return state
191
+
192
+
193
+ def save_state(state: dict) -> None:
194
+ fs.private_dir(fs.state_dir())
195
+ fs.atomic_write(fs.state_file(), json.dumps(state, indent=2, ensure_ascii=False))
196
+
197
+
198
+ # The log row schema, defined once; log() writes every field, in this order.
199
+ LOG_FIELDS = ("ts", "harness", "type", "name", "action", "result", "batch",
200
+ "project", "scope", "detail")
201
+ # One id per process: every row a single CLI run writes shares it.
202
+ BATCH = f"{time.strftime('%Y%m%dT%H%M%S')}-{os.getpid()}"
203
+
204
+
205
+ def log(action: str, type_: str, name: str, result: str, detail: str = "", *,
206
+ harness: str | None = None, batch: str = BATCH, project: str | None = None,
207
+ scope: str = "user") -> None:
208
+ """Append one row; every row carries the full schema (LOG_FIELDS order)."""
209
+ fs.private_dir(fs.state_dir())
210
+ rec = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "harness": harness,
211
+ "type": type_, "name": name, "action": action, "result": result,
212
+ "batch": batch, "project": project, "scope": scope, "detail": detail}
213
+ fd = os.open(fs.log_file(), os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
214
+ with os.fdopen(fd, "a", encoding="utf-8") as fh:
215
+ fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
216
+
217
+
218
+ def migrate(state: dict) -> int:
219
+ """Import ~/.claude-toggle state so existing rollbacks keep working."""
220
+ legacy_state_dir, backup_dir = fs.legacy_state_dir(), fs.backup_dir()
221
+ old_file = legacy_state_dir / "state.json"
222
+ if not old_file.is_file():
223
+ print(f"nothing to migrate ({old_file} not found)")
224
+ return 0
225
+ try:
226
+ old = json.loads(old_file.read_text(encoding="utf-8"))
227
+ except (OSError, json.JSONDecodeError) as e:
228
+ die(f"legacy state unreadable: {e}")
229
+
230
+ fs.private_dir(backup_dir)
231
+ added = 0
232
+ for okey, entry in old.get("disabled", {}).items():
233
+ new = dict(entry, harness="claude")
234
+ if entry["type"] == "mcp" and entry.get("backup"):
235
+ src = Path(entry["backup"])
236
+ dst = backup_dir / f"claude__{entry['name'].replace('/', '_')}.json"
237
+ if src.is_file() and not dst.exists():
238
+ shutil.copy2(src, dst) # copy, not move: legacy stays usable
239
+ fs.tighten(dst)
240
+ new["backup"] = str(dst)
241
+ new["backend"] = "claude-json"
242
+ elif entry.get("parked_at"):
243
+ new.setdefault("origin", entry["parked_at"]
244
+ .replace("-disabled/", "/", 1))
245
+ new["mechanism"] = mechanism_of(new) or "flag"
246
+ key = f"claude:{okey}"
247
+ if key not in state["disabled"]:
248
+ state["disabled"][key] = new
249
+ added += 1
250
+ print(f"migrated {added} entries from {legacy_state_dir}")
251
+ print("legacy directory left untouched -- delete it yourself once happy")
252
+ return 0
File without changes
@@ -0,0 +1,279 @@
1
+ """Interactive terminal picker for agent-toggle.
2
+
3
+ Built on curses (stdlib) so there is nothing to install. The checkbox shows
4
+ the ENABLED state: `[x]` is live, `[ ]` is parked. Unticking something disables
5
+ it, ticking it back enables it.
6
+
7
+ Nothing is applied while the picker is open. Changes are staged, the screen is
8
+ torn down, and only then are the real operations run -- so their output (which
9
+ companion files moved, which were kept because they are shared) is readable
10
+ instead of fighting the curses display for the terminal.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import curses
15
+
16
+ from .. import cost
17
+
18
+ HELP = "Tab tick Enter apply Esc cancel s sort h/t filter ? keys / type to filter"
19
+ LONG_HELP = (
20
+ "Keys",
21
+ "",
22
+ " Up/Down, PgUp/PgDn move",
23
+ " Tab tick / untick the row (unticked = parked)",
24
+ " Enter apply the staged changes (with --dry-run: show the plan)",
25
+ " Esc, Ctrl-C cancel; nothing is changed",
26
+ " s cycle sort: name -> cost (biggest first)",
27
+ " h cycle the harness filter",
28
+ " t cycle the type filter",
29
+ " ? this screen",
30
+ " / start typing a filter (needed to type a filter that",
31
+ " begins with s, h, t or ?; any other letter just types)",
32
+ " Backspace, Ctrl-U edit / clear the filter",
33
+ "",
34
+ "Cost is ~tokens loaded at session start (chars/4); a parked row shows",
35
+ "(N) = what restoring it would load. A shared dir is one row, filed under",
36
+ "the harness that owns it.",
37
+ "",
38
+ "any key to close",
39
+ )
40
+ SORTS = ("name", "cost")
41
+
42
+
43
+ class Row:
44
+ __slots__ = ("harness", "type", "name", "enabled", "staged", "tokens", "would_save",
45
+ "shared")
46
+
47
+ def __init__(self, harness: str, type_: str, name: str, enabled: bool,
48
+ tokens: int = 0, would_save: int = 0, shared: tuple[str, ...] = ()):
49
+ self.harness, self.type, self.name = harness, type_, name
50
+ self.enabled = enabled
51
+ self.staged = enabled # what the user has ticked so far
52
+ self.tokens, self.would_save, self.shared = tokens, would_save, shared
53
+
54
+ @property
55
+ def changed(self) -> bool:
56
+ return self.staged != self.enabled
57
+
58
+ @property
59
+ def label(self) -> str:
60
+ return f"{self.harness}/{self.type}/{self.name}"
61
+
62
+ @property
63
+ def cost_cell(self) -> str:
64
+ return f"{self.tokens}" if self.enabled else f"({self.would_save})"
65
+
66
+
67
+ # ------------------------------------------------------------- data gathering
68
+
69
+ def collect(state: dict, harnesses: dict, warn=lambda m: None,
70
+ plugins: bool = True) -> list[Row]:
71
+ """Every togglable resource on this machine, live and parked, plugins included.
72
+
73
+ A dir shared by several harnesses is one row, filed under its owner, so staging
74
+ it cannot also be staged (and then fail) under the other harness.
75
+ """
76
+ rows = [Row(i.harness, i.type, i.name, i.enabled, i.tokens, i.would_save, i.shared_with)
77
+ for i in cost.inventory(state, harnesses, warn, plugins)]
78
+ rows.sort(key=lambda r: (r.harness, r.type, r.name))
79
+ return rows
80
+
81
+
82
+ def match(rows: list[Row], query: str) -> list[Row]:
83
+ """Case-insensitive AND over whitespace-separated terms."""
84
+ terms = query.lower().split()
85
+ if not terms:
86
+ return rows
87
+ return [r for r in rows if all(t in r.label.lower() for t in terms)]
88
+
89
+
90
+ def visible(rows: list[Row], query: str = "", harness: str = "all", type_: str = "all",
91
+ sort: str = "name") -> list[Row]:
92
+ """The rows on screen: filter chips, then the text query, then the sort."""
93
+ shown = [r for r in rows
94
+ if (harness == "all" or harness in (r.harness, *r.shared))
95
+ and (type_ == "all" or r.type == type_)]
96
+ shown = match(shown, query)
97
+ if sort == "cost":
98
+ shown = sorted(shown, key=lambda r: (-r.tokens, -r.would_save, r.harness, r.type, r.name))
99
+ return shown
100
+
101
+
102
+ def cycle(options: tuple[str, ...] | list[str], current: str) -> str:
103
+ """The option after `current` (wraps; an unknown current restarts at the first)."""
104
+ return options[(options.index(current) + 1) % len(options)] if current in options \
105
+ else options[0]
106
+
107
+
108
+ # -------------------------------------------------------------------- drawing
109
+
110
+ # curses colour-pair ids; 0 is the terminal default.
111
+ P_HARNESS, P_TYPE, P_LIVE, P_PARKED, P_PENDING = 1, 2, 3, 4, 5
112
+
113
+
114
+ def state_pair(r: Row) -> int:
115
+ """Pair id for a row's state cell: staged change > ticked (live) > unticked (parked)."""
116
+ return P_PENDING if r.changed else P_LIVE if r.staged else P_PARKED
117
+
118
+
119
+ def init_colors(color: bool) -> bool:
120
+ """Set up the pairs; True only when colours are wanted AND the terminal has them."""
121
+ if not color:
122
+ return False
123
+ try:
124
+ if not curses.has_colors():
125
+ return False
126
+ curses.start_color()
127
+ try:
128
+ curses.use_default_colors()
129
+ bg = -1
130
+ except curses.error:
131
+ bg = curses.COLOR_BLACK
132
+ for pid, fg in ((P_HARNESS, curses.COLOR_CYAN), (P_TYPE, curses.COLOR_BLUE),
133
+ (P_LIVE, curses.COLOR_GREEN), (P_PARKED, curses.COLOR_YELLOW),
134
+ (P_PENDING, curses.COLOR_MAGENTA)):
135
+ curses.init_pair(pid, fg, bg)
136
+ except curses.error:
137
+ return False
138
+ return True
139
+
140
+
141
+ def draw(win, rows: list[Row], query: str, cur: int, top: int, pending: int,
142
+ chips: str = "", color: bool = False) -> None:
143
+ win.erase()
144
+ height, width = win.getmaxyx()
145
+ body = max(1, height - 3)
146
+
147
+ header = f" filter: {query}█" if query else " filter: (type to narrow, ? for keys)"
148
+ win.addnstr(0, 0, header.ljust(width - 1), width - 1, curses.A_BOLD)
149
+
150
+ for i in range(body):
151
+ idx = top + i
152
+ if idx >= len(rows):
153
+ break
154
+ r = rows[idx]
155
+ box = "[x]" if r.staged else "[ ]"
156
+ mark = "*" if r.changed else " "
157
+ also = f" (+{','.join(r.shared)})" if r.shared else ""
158
+ parts = (f" {mark}{box} {r.cost_cell:>7} ", f"{r.harness:<9}", f"{r.type:<8}",
159
+ f"{r.name}{also}")
160
+ line = "".join(parts)
161
+ attr = curses.A_REVERSE if idx == cur else curses.A_NORMAL
162
+ if r.changed:
163
+ attr |= curses.A_BOLD
164
+ if not color:
165
+ win.addnstr(1 + i, 0, line.ljust(width - 1), width - 1, attr)
166
+ elif idx == cur: # one reversed bar, tinted by state
167
+ win.addnstr(1 + i, 0, line.ljust(width - 1), width - 1,
168
+ attr | curses.color_pair(state_pair(r)))
169
+ else:
170
+ x = 0
171
+ for n, (text, pid) in enumerate(zip(parts, (state_pair(r), P_HARNESS, P_TYPE, 0))):
172
+ if x >= width - 1:
173
+ break
174
+ if n == len(parts) - 1:
175
+ text = text.ljust(width - 1 - x)
176
+ win.addnstr(1 + i, x, text, width - 1 - x, attr | curses.color_pair(pid))
177
+ x += len(text)
178
+
179
+ count = f" {len(rows)} shown | ~{sum(r.tokens for r in rows)} tok | {chips}"
180
+ if pending:
181
+ count += f" | {pending} staged -- Enter to apply"
182
+ win.addnstr(height - 2, 0, count.ljust(width - 1), width - 1, curses.A_BOLD)
183
+ win.addnstr(height - 1, 0, " " + HELP.ljust(width - 2), width - 1, curses.A_DIM)
184
+ win.noutrefresh()
185
+ curses.doupdate()
186
+
187
+
188
+ def show_help(win) -> None:
189
+ win.erase()
190
+ height, width = win.getmaxyx()
191
+ for y, text in enumerate(LONG_HELP[:height]):
192
+ win.addnstr(y, 0, text, width - 1)
193
+ win.refresh()
194
+ try:
195
+ win.get_wch()
196
+ except (curses.error, KeyboardInterrupt):
197
+ pass
198
+
199
+
200
+ def loop(win, rows: list[Row], color: bool = False) -> list[Row] | None:
201
+ color = init_colors(color)
202
+ curses.curs_set(0)
203
+ win.keypad(True)
204
+ query, cur, top = "", 0, 0
205
+ typing = False # True after `/`: s/h/t/? are then letters, not commands
206
+ harness, type_, sort = "all", "all", "name"
207
+ harness_opts = ["all", *sorted({n for r in rows for n in (r.harness, *r.shared)})]
208
+ type_opts = ["all", *sorted({r.type for r in rows})]
209
+
210
+ while True:
211
+ shown = visible(rows, query, harness, type_, sort)
212
+ cur = max(0, min(cur, len(shown) - 1))
213
+ height = max(1, win.getmaxyx()[0] - 3)
214
+ top = max(0, min(top, max(0, len(shown) - height)))
215
+ if cur < top:
216
+ top = cur
217
+ elif cur >= top + height:
218
+ top = cur - height + 1
219
+
220
+ pending = sum(1 for r in rows if r.changed)
221
+ draw(win, shown, query, cur, top, pending,
222
+ f"harness:{harness} type:{type_} sort:{sort}", color)
223
+
224
+ try:
225
+ key = win.get_wch()
226
+ except (curses.error, KeyboardInterrupt):
227
+ return None
228
+
229
+ if key in ("\x1b", "\x03"): # Esc / Ctrl-C
230
+ return None
231
+ if key in ("\n", "\r", curses.KEY_ENTER):
232
+ return [r for r in rows if r.changed]
233
+ if key == "\t" and shown:
234
+ shown[cur].staged = not shown[cur].staged
235
+ cur += 1
236
+ elif key in (curses.KEY_UP, "\x10"):
237
+ cur -= 1
238
+ elif key in (curses.KEY_DOWN, "\x0e"):
239
+ cur += 1
240
+ elif key == curses.KEY_NPAGE:
241
+ cur += height
242
+ elif key == curses.KEY_PPAGE:
243
+ cur -= height
244
+ elif key in (curses.KEY_BACKSPACE, "\x7f", "\b"):
245
+ query = query[:-1]
246
+ typing = typing and bool(query)
247
+ cur = 0
248
+ elif key == "\x15": # Ctrl-U
249
+ query, typing, cur = "", False, 0
250
+ elif not (typing or query) and key in ("s", "h", "t", "?", "/"):
251
+ if key == "s":
252
+ sort = cycle(SORTS, sort)
253
+ elif key == "h":
254
+ harness = cycle(harness_opts, harness)
255
+ elif key == "t":
256
+ type_ = cycle(type_opts, type_)
257
+ elif key == "?":
258
+ show_help(win)
259
+ else:
260
+ typing = True
261
+ cur = 0
262
+ elif isinstance(key, str) and key.isprintable():
263
+ query += key
264
+ cur = 0
265
+ # KEY_RESIZE and anything else just redraw
266
+
267
+
268
+ def pick(state: dict, harnesses: dict, plugins: bool = True, color: bool = False) -> list[Row] | None:
269
+ """`plugins=False` skips the `claude plugin list` call (a dry run never shells out)."""
270
+ notes: list[str] = []
271
+ rows = collect(state, harnesses, notes.append, plugins)
272
+ if not rows:
273
+ print("nothing to show")
274
+ return None
275
+ try:
276
+ return curses.wrapper(loop, rows, color)
277
+ finally:
278
+ for n in notes: # after curses is torn down, where they are readable
279
+ print(f"WARNING {n}")
agent_toggle/undo.py ADDED
@@ -0,0 +1,133 @@
1
+ """`undo` (reverse the last logged batch) and `enable --all` (restore everything).
2
+
3
+ Both build a plan of ops.Op and hand it to ops.apply_plan, the one apply path,
4
+ so locking, state saving, refusal checks and logging are shared with disable/enable.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from . import fs, ops, store
12
+ from .harnesses import TYPES, harnesses, project_view
13
+ from .mechanisms import fail_row, validate_name
14
+ from .output import CliError, Result, die
15
+
16
+ REVERSE = {"disable": "enable", "enable": "disable"}
17
+
18
+
19
+ def _log_rows() -> list[dict]:
20
+ p = fs.log_file()
21
+ if not p.is_file():
22
+ return []
23
+ rows = []
24
+ for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
25
+ try:
26
+ row = json.loads(line)
27
+ except ValueError:
28
+ continue
29
+ if isinstance(row, dict):
30
+ rows.append(row)
31
+ return rows
32
+
33
+
34
+ def _toggled(row: dict) -> bool:
35
+ return row.get("result") == "ok" and row.get("action") in REVERSE
36
+
37
+
38
+ def _runnable(plan: list[ops.Op], table: dict, out: Result, dry_run: bool, batch: str) -> list[ops.Op]:
39
+ """Drop (as error rows) ops whose harness is unknown or whose home is gone.
40
+ Project ops pass through: ops.apply_plan validates the project dir itself."""
41
+ keep = []
42
+ for op in plan:
43
+ if op.project is not None:
44
+ keep.append(op)
45
+ continue
46
+ h = table.get(op.harness)
47
+ why = (f"unknown harness {op.harness!r}" if h is None
48
+ else f"{op.harness} is not installed ({h.home} does not exist)"
49
+ if not h.home.is_dir() else None)
50
+ if why:
51
+ fail_row(out, dry_run, op.harness, op.type, op.action, op.name, why, batch=batch)
52
+ else:
53
+ keep.append(op)
54
+ return keep
55
+
56
+
57
+ def cmd_undo(dry_run: bool, harness: str | None, out: Result) -> None:
58
+ if harness:
59
+ die("undo reverses a whole batch; --harness does not apply", 2)
60
+ rows = _log_rows()
61
+ last = next((r for r in reversed(rows) if _toggled(r)), None)
62
+ if last is None:
63
+ out.say("nothing to undo")
64
+ return
65
+ target = last.get("batch")
66
+ if not target:
67
+ die("nothing to undo: log predates undo (its rows carry no batch id)")
68
+ if not isinstance(target, str):
69
+ die("nothing to undo: corrupt log row (batch is not a string)")
70
+ batch_rows = [r for r in rows if _toggled(r) and r.get("batch") == target]
71
+ if any(not isinstance(r.get("harness"), str) or not r["harness"] for r in batch_rows):
72
+ die("nothing to undo: log predates undo (a row of the last batch has no harness)")
73
+ for r in batch_rows: # the log is a file anyone can edit: validate like argv
74
+ try:
75
+ if r.get("type") not in TYPES or not isinstance(r.get("name"), str):
76
+ raise CliError("bad type or name")
77
+ validate_name(r["name"])
78
+ # Only scope=project is a --project change; it replays in THAT project
79
+ # (ops.apply_plan refuses $HOME, roots and dirs without .claude), never in
80
+ # user scope. Every other scope (user, claude mcp `local`) is user scope.
81
+ if r.get("scope") == "project" and not (
82
+ isinstance(r.get("project"), str) and Path(r["project"]).is_absolute()):
83
+ raise CliError("bad project")
84
+ except CliError:
85
+ die(f"nothing to undo: corrupt log row for {r.get('type')!r} {r.get('name')!r}")
86
+ seen = {r.get("batch") for r in rows}
87
+ batch = store.BATCH
88
+ if batch in seen: # only when several runs share one process (tests)
89
+ batch = f"{batch}-{len(seen)}"
90
+ table, plan = harnesses(), []
91
+ out.say(f"undoing batch {target} ({len(batch_rows)} change(s))")
92
+ for r in reversed(batch_rows):
93
+ plan.append(ops.Op(r["harness"], r["type"], REVERSE[r["action"]], r["name"],
94
+ r["project"] if r.get("scope") == "project" else None))
95
+ plan = _runnable(plan, table, out, dry_run, batch)
96
+ if plan:
97
+ ops.apply_plan(plan, out, dry_run, batch=batch, headers=True)
98
+
99
+
100
+ def cmd_enable_all(harness: str | None, dry_run: bool, out: Result,
101
+ project: str | None = None) -> None:
102
+ """Restore every state entry, user and project scope, each in its own scope
103
+ (optionally only one harness's, or with `project` only that project's)."""
104
+ table, plan, bad = harnesses(), [], []
105
+ if project is not None:
106
+ project = str(project_view(project).project) # exit 4 like disable --project
107
+ want = store.project_digest(project) if project is not None else None
108
+ for key, entry in store.load_state(write_back=False)["disabled"].items():
109
+ try:
110
+ h, digest, type_, name = store.parse_key(key)
111
+ if project is not None and digest != want:
112
+ continue
113
+ pdir = entry.get("project") if digest and isinstance(entry, dict) else None
114
+ if digest and not (isinstance(pdir, str) and Path(pdir).is_absolute()):
115
+ raise ValueError("project-scope entry without an absolute project dir")
116
+ if type_ not in TYPES:
117
+ raise ValueError(f"unknown type {type_!r}")
118
+ validate_name(name)
119
+ except (ValueError, CliError) as e:
120
+ bad.append((key, str(getattr(e, "msg", e))))
121
+ continue
122
+ shared = entry.get("shared_with") if isinstance(entry, dict) else None
123
+ if harness and harness != h and harness not in (shared if isinstance(shared, list) else ()):
124
+ continue
125
+ plan.append(ops.Op(h, type_, "enable", name, pdir))
126
+ for key, why in bad:
127
+ out.row(None, None, key, "enable", "error", f"unusable state key: {why}")
128
+ plan = _runnable(plan, table, out, dry_run, store.BATCH)
129
+ if not plan and not bad:
130
+ out.say("nothing disabled")
131
+ return
132
+ if plan:
133
+ ops.apply_plan(plan, out, dry_run, batch=store.BATCH, headers=True)