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