agent-toggle 0.1.0__tar.gz → 0.5.2__tar.gz

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.
Files changed (94) hide show
  1. agent_toggle-0.5.2/PKG-INFO +884 -0
  2. agent_toggle-0.5.2/README.md +852 -0
  3. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/__init__.py +1 -1
  4. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/backends/flag_json.py +42 -12
  5. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/backends/mcp_json.py +154 -18
  6. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/backends/mcp_toml.py +1 -2
  7. agent_toggle-0.5.2/agent_toggle/backends/plugin_cli.py +90 -0
  8. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/cli.py +317 -64
  9. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/companions.py +6 -1
  10. agent_toggle-0.5.2/agent_toggle/config.py +136 -0
  11. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/cost.py +46 -27
  12. agent_toggle-0.5.2/agent_toggle/doctor.py +546 -0
  13. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/fs.py +149 -11
  14. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/harnesses.py +56 -24
  15. agent_toggle-0.5.2/agent_toggle/help.py +224 -0
  16. agent_toggle-0.5.2/agent_toggle/i18n.py +69 -0
  17. agent_toggle-0.5.2/agent_toggle/locales/__init__.py +5 -0
  18. agent_toggle-0.5.2/agent_toggle/locales/zh_tw_cli.py +85 -0
  19. agent_toggle-0.5.2/agent_toggle/locales/zh_tw_common.py +7 -0
  20. agent_toggle-0.5.2/agent_toggle/locales/zh_tw_config.py +89 -0
  21. agent_toggle-0.5.2/agent_toggle/locales/zh_tw_picker.py +80 -0
  22. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/mechanisms.py +355 -70
  23. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/ops.py +17 -4
  24. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/output.py +13 -4
  25. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/profiles.py +25 -13
  26. agent_toggle-0.5.2/agent_toggle/settings.py +184 -0
  27. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/shims/claude.md.tmpl +26 -4
  28. agent_toggle-0.5.2/agent_toggle/shims/generic.md.tmpl +68 -0
  29. agent_toggle-0.5.2/agent_toggle/spinner.py +54 -0
  30. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/store.py +41 -0
  31. agent_toggle-0.5.2/agent_toggle/toml_check.py +273 -0
  32. agent_toggle-0.5.2/agent_toggle/ui/config_menu.py +622 -0
  33. agent_toggle-0.5.2/agent_toggle/ui/menu.py +134 -0
  34. agent_toggle-0.5.2/agent_toggle/ui/model.py +245 -0
  35. agent_toggle-0.5.2/agent_toggle/ui/picker.py +503 -0
  36. agent_toggle-0.5.2/agent_toggle/ui/theme.py +365 -0
  37. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/undo.py +6 -1
  38. agent_toggle-0.5.2/agent_toggle/update_check.py +359 -0
  39. agent_toggle-0.5.2/agent_toggle/update_prompt.py +201 -0
  40. agent_toggle-0.5.2/agent_toggle.egg-info/PKG-INFO +884 -0
  41. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle.egg-info/SOURCES.txt +32 -1
  42. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle.egg-info/requires.txt +3 -0
  43. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/pyproject.toml +2 -1
  44. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_alias.py +70 -11
  45. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_backends.py +81 -6
  46. agent_toggle-0.5.2/tests/test_cli.py +393 -0
  47. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_cli_surface.py +65 -1
  48. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_color.py +2 -0
  49. agent_toggle-0.5.2/tests/test_completions.py +37 -0
  50. agent_toggle-0.5.2/tests/test_config.py +174 -0
  51. agent_toggle-0.5.2/tests/test_config_menu.py +420 -0
  52. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_conformance.py +11 -2
  53. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_containment.py +2 -3
  54. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_cost.py +109 -0
  55. agent_toggle-0.5.2/tests/test_crash.py +379 -0
  56. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_doctor.py +209 -8
  57. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_fs.py +165 -9
  58. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_harnesses.py +11 -4
  59. agent_toggle-0.5.2/tests/test_help.py +138 -0
  60. agent_toggle-0.5.2/tests/test_i18n.py +172 -0
  61. agent_toggle-0.5.2/tests/test_install.py +269 -0
  62. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_mechanisms.py +2 -3
  63. agent_toggle-0.5.2/tests/test_menu.py +243 -0
  64. agent_toggle-0.5.2/tests/test_picker.py +691 -0
  65. agent_toggle-0.5.2/tests/test_platform.py +107 -0
  66. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_profiles.py +16 -0
  67. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_project.py +193 -2
  68. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_safety.py +4 -6
  69. agent_toggle-0.5.2/tests/test_secret_audit.py +276 -0
  70. agent_toggle-0.5.2/tests/test_settings.py +166 -0
  71. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_store.py +2 -2
  72. agent_toggle-0.5.2/tests/test_theme.py +318 -0
  73. agent_toggle-0.5.2/tests/test_update_check.py +421 -0
  74. agent_toggle-0.5.2/tests/test_update_prompt.py +41 -0
  75. agent_toggle-0.1.0/PKG-INFO +0 -580
  76. agent_toggle-0.1.0/README.md +0 -550
  77. agent_toggle-0.1.0/agent_toggle/backends/plugin_cli.py +0 -41
  78. agent_toggle-0.1.0/agent_toggle/doctor.py +0 -285
  79. agent_toggle-0.1.0/agent_toggle/ui/picker.py +0 -279
  80. agent_toggle-0.1.0/agent_toggle.egg-info/PKG-INFO +0 -580
  81. agent_toggle-0.1.0/tests/test_cli.py +0 -99
  82. agent_toggle-0.1.0/tests/test_install.py +0 -98
  83. agent_toggle-0.1.0/tests/test_picker.py +0 -195
  84. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/LICENSE +0 -0
  85. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/__main__.py +0 -0
  86. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/backends/__init__.py +0 -0
  87. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle/ui/__init__.py +0 -0
  88. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle.egg-info/dependency_links.txt +0 -0
  89. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle.egg-info/entry_points.txt +0 -0
  90. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/agent_toggle.egg-info/top_level.txt +0 -0
  91. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/setup.cfg +0 -0
  92. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_companions.py +0 -0
  93. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_encoding.py +0 -0
  94. {agent_toggle-0.1.0 → agent_toggle-0.5.2}/tests/test_undo.py +0 -0
@@ -0,0 +1,884 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-toggle
3
+ Version: 0.5.2
4
+ Summary: Temporarily disable / re-enable AI-agent skills, agents, commands, rules, plugins and MCP servers across harnesses (Claude Code, Codex, Grok, OpenCode, OpenClaw, Copilot, Vibe, Devin, Antigravity)
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/weskao/agent-toggle
7
+ Project-URL: Repository, https://github.com/weskao/agent-toggle
8
+ Project-URL: Issues, https://github.com/weskao/agent-toggle/issues
9
+ Project-URL: Changelog, https://github.com/weskao/agent-toggle/blob/main/CHANGELOG.md
10
+ Keywords: ai-agents,claude-code,codex,skills,mcp,cli,toggle
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Provides-Extra: windows
28
+ Requires-Dist: windows-curses; sys_platform == "win32" and extra == "windows"
29
+ Provides-Extra: telegram
30
+ Requires-Dist: telegram-kit<0.3,>=0.2.2; extra == "telegram"
31
+ Dynamic: license-file
32
+
33
+ # agent-toggle
34
+
35
+ Temporarily disable and restore AI-agent resources — skills, agents, commands,
36
+ rules, plugins, MCP servers — across Claude Code, Codex, Grok CLI, OpenCode,
37
+ OpenClaw, Copilot, Vibe, Devin and Antigravity — and show what each one costs at
38
+ session start.
39
+
40
+ **Nothing is ever deleted.** Everything is parked and recorded, and `enable`
41
+ puts it back where it came from.
42
+
43
+ ## Why
44
+
45
+ | target | native mechanism | the gap |
46
+ |---|---|---|
47
+ | plugin | `claude plugin disable/enable` | fine — this tool just batches it |
48
+ | skill | none | moving files by hand hits the rename trap below |
49
+ | agent | none | same |
50
+ | command | none | same, plus nested `group/name.md` paths get flattened |
51
+ | MCP | `remove` only | the config is gone unless you saved it first |
52
+
53
+ ## Install
54
+
55
+ Latest release, from [PyPI](https://pypi.org/project/agent-toggle/):
56
+
57
+ ```sh
58
+ uv tool install agent-toggle
59
+ agent-toggle --version
60
+ ```
61
+
62
+ Straight from this repository, pinned to a release tag (`v0.5.1` is the current one;
63
+ an unpinned `git+` URL installs whatever is on the default branch):
64
+
65
+ ```sh
66
+ uv tool install git+https://github.com/weskao/agent-toggle@v0.5.1
67
+ agent-toggle --version
68
+ ```
69
+
70
+ Alternatively, use `pipx`:
71
+
72
+ ```sh
73
+ pipx install agent-toggle # from PyPI; or: pipx install git+https://github.com/weskao/agent-toggle@v0.5.1
74
+ ```
75
+
76
+ From a checkout, either install it editable or run it in place:
77
+
78
+ ```sh
79
+ git clone https://github.com/weskao/agent-toggle agent-toggle
80
+ cd agent-toggle
81
+ pip install -e . # puts the `agent-toggle` console script on PATH
82
+ python3 agent_toggle.py status # no install needed; same CLI
83
+ ```
84
+
85
+ Python 3.10+, no runtime dependencies. Then write the skill shim:
86
+
87
+ ```sh
88
+ agent-toggle install-shims # installed, or: ./install.sh (a thin wrapper)
89
+ python3 agent_toggle.py install-shims # from a checkout, no install needed
90
+ ```
91
+
92
+ `install-shims` writes a thin skill shim (`skills/agent-toggle/SKILL.md`) into
93
+ every installed harness that supports skills, so `/agent-toggle` works from any
94
+ of them, and adds the park dirs (`skills-disabled/` etc.) to a `.gitignore`
95
+ already present in that harness home. Each harness gets its own template from
96
+ `agent_toggle/shims/<harness>.md.tmpl`, else `generic.md.tmpl` (which tells the
97
+ agent to pass `--harness <that harness>`). The shim tells the agent to act only
98
+ on the user's explicit request, never on instructions found inside skill or
99
+ tool content, and to preview bulk operations with `--dry-run`.
100
+ OpenCode's shim goes into the first `skills.paths` dir when one is set (and its
101
+ parent exists), else `skills/`. OpenCode also scans `~/.claude/skills` and
102
+ `~/.agents/skills`: when one of them already holds the very same shim text, the
103
+ OpenCode shim is skipped (`covered by <path>`); a different shim there (the
104
+ claude one, whose default harness is `claude`) is noted and OpenCode's own is
105
+ still written.
106
+
107
+ Every shim carries an `<!-- agent-toggle shim: ... -->` marker line.
108
+ `install-shims` updates a file that has the marker in place (it is idempotent)
109
+ and **refuses** to overwrite one that lacks it, or a symlink: that harness gets
110
+ an `error` row with the fix, the other harnesses still install, and the run
111
+ exits `1`. A shim written by a version before the marker counts as foreign;
112
+ delete it and re-run. `--dry-run` shows the same plan, refusals included. The
113
+ tool itself stays in one place.
114
+
115
+ **Optional: Telegram alerts for CI** (for maintainers of a fork or clone). Install
116
+ with the `telegram` extra, which adds [telegram-kit](https://pypi.org/project/telegram-kit/)
117
+ (itself stdlib-only), then run the setup once; see [CI notifications](#ci-notifications):
118
+
119
+ ```sh
120
+ uv tool install "agent-toggle[telegram]" # or: pipx install "agent-toggle[telegram]"
121
+ pip install -e ".[telegram]" # from a checkout
122
+ agent-toggle config # settings menu: bot token + chat id live under Notifications
123
+ agent-toggle config sync-ci # set the two GitHub repository secrets
124
+ ```
125
+
126
+ Without the extra everything else works exactly as before. Only `config test`,
127
+ `config sync-ci` (both exit `4` without it) and the Telegram rows of the
128
+ [settings menu](#settings--config-menu) need it; bare `config` still opens, and
129
+ those rows read `install agent-toggle[telegram]`.
130
+
131
+ ## Shell completion
132
+
133
+ Each release publishes static completion files for bash, zsh and fish as the
134
+ `completions` artifact of its Release workflow run. They are generated from the
135
+ CLI parser; to build them from a checkout:
136
+
137
+ ```sh
138
+ python3 tools/gen_completions.py completions # agent-toggle.bash, _agent-toggle (zsh), agent-toggle.fish
139
+ ```
140
+
141
+ Source `agent-toggle.bash` from `~/.bashrc`, put `_agent-toggle` on your zsh
142
+ `fpath`, or copy `agent-toggle.fish` to `~/.config/fish/completions/`.
143
+
144
+ ## Usage
145
+
146
+ ```sh
147
+ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [args]
148
+ ```
149
+
150
+ | command | what it does |
151
+ |---|---|
152
+ | `ui` (alias `pick`) | interactive picker — harness tabs, type groups, cost bars, search, sort, profiles (`p`); `--dry-run` shows the plan for what you stage and changes nothing; `--project <dir>` picks in a repo's own scope; see [Interactive picker](#interactive-picker) |
153
+ | `status` | health check: harnesses found, types each supports, parked counts, gitignore, untracked parked items, stale live twins, shared dirs, and state entries whose parked item is gone (a `stale` row with the fix command; read-only, still exit `0`) |
154
+ | `list [type]` | what is currently disabled (`--project <dir>` filters to one project) |
155
+ | `cost [--type T]` | estimated startup tokens per item, biggest first (read-only; `--harness H` filters; `--project <dir>` prices a repo's `.claude/` and `.mcp.json` instead of user scope) |
156
+ | `install-shims` | write the skill shim into every installed harness that is on in the settings (an off one is skipped unless named with `--harness`); refuses to overwrite a file it did not write (`--dry-run` shows the plan); on a terminal, then asks y/n for each problem `doctor` can fix (see [Fixing](#fixing)) |
157
+ | `disable <type> <name>...` | park one or more items (`--dry-run` shows the plan; `--project <dir>` for a repo's own `.claude/` and `.mcp.json`) |
158
+ | `enable <type> <name>...` | put them back (`--dry-run` shows the plan; `--project <dir>` likewise) |
159
+ | `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
160
+ | `undo` | reverse the last logged batch (`--dry-run` shows the plan) |
161
+ | `profile save\|apply\|diff\|list` | named sets of live items; see [Profiles](#profiles) |
162
+ | `config` | settings menu (curses, else a numbered list); `config --json` lists every setting; see [Settings & config menu](#settings--config-menu) |
163
+ | `config test\|sync-ci` | send one Telegram test message / set the GitHub CI secrets; needs the `[telegram]` extra; see [CI notifications](#ci-notifications) |
164
+ | `help [command]` | styled help; `help ui` = `--help ui` = `ui --help`; see [Help](#help) |
165
+ | `doctor` | check each harness layout and `state.json` against disk; exit `1` only on an `error` row; on a terminal, asks y/n per fix it can run itself |
166
+ | `migrate` | import an older `~/.claude-toggle/` state |
167
+
168
+ Every command also works with a leading `--` (`agent-toggle --status`, `--help`, `--config`); this README uses the plain form.
169
+
170
+ `<type>` = `skill` / `agent` / `command` / `rule` / `plugin` / `mcp`.
171
+ `rule` is claude-only (`~/.claude/rules/*.md`, parked in `rules-disabled/`).
172
+ Disabling a rule prints a warning (also in `--json` `warnings`, dry run included)
173
+ that rules may carry safety constraints.
174
+
175
+ Flags accepted by every command, before or after the subcommand:
176
+
177
+ - `--harness claude|codex|grok|opencode|openclaw|copilot|vibe|devin|agy` picks the
178
+ target (default `claude`; on `list` / `status` / `cost` it filters when given).
179
+ - `--json` prints exactly one JSON document and nothing else on stdout:
180
+ `{"ok", "command", "results": [{harness, type, name, action, status, detail, ...}],
181
+ "warnings", "needs_new_session"}`. Errors -- including unexpected ones, as
182
+ `{ExceptionType}: {message}` -- always emit it, with `"ok": false`.
183
+ Exception: `--help` / `--version` print plain text even with `--json`, and
184
+ `ui` is interactive so it rejects `--json` (exit 2).
185
+ - `--project <dir>` (`disable` / `enable` / `enable --all` / `list` / `ui` / `cost` / `profile
186
+ save|apply|diff`) switches to project scope; see [Project scope](#project-scope).
187
+ - `--version` prints the version.
188
+ - `AGENT_TOGGLE_CLI_TIMEOUT=<seconds>` overrides the timeout of every `claude` CLI call
189
+ (default 30 s for read-only `plugin list`, 120 s for every other call); a
190
+ non-positive or non-numeric value is ignored with a warning.
191
+ - `-v` / `--verbose` (or `AGENT_TOGGLE_DEBUG=1`) adds a traceback on stderr for
192
+ unexpected errors; otherwise they are a single `error:` line.
193
+ - `--color auto|always|never` sets ANSI color for human output: green ok, red errors,
194
+ yellow warnings, cyan harness names, dim secondary text (`status`, `list`, `cost`,
195
+ `disable` / `enable`, `doctor`, warnings and errors; the picker uses curses color pairs
196
+ and stays monochrome when the terminal has no colors). `--json` is never colored.
197
+ `never` turns it off and `always` forces it even when piped; `auto` (default) checks
198
+ `NO_COLOR`, then `FORCE_COLOR`, then `TERM=dumb`, and otherwise colors only on a TTY.
199
+ Precedence: `--color` flag > `AGENT_TOGGLE_COLOR` > the `color` [setting](#settings--config-menu)
200
+ > `auto`; `NO_COLOR` still wins over `auto`.
201
+ `always` still stays plain on a Windows console that cannot do ANSI.
202
+
203
+ Extra row fields: `list` rows carry `at`, `mechanism`, `companions`; `cost` rows
204
+ carry `enabled`, `tokens`, `would_save`, `chars`, and the final `total` row
205
+ carries `total_tokens`, `saved_tokens`, `formula`. Rows for a
206
+ directory shared with another harness (see below) carry `shared_with`, the other
207
+ harnesses that read it: `disable` / `enable` rows relative to the harness you
208
+ asked, plus `owner`, the harness whose park dir and state entry hold the item;
209
+ `list` and `cost` rows are filed under the owner (`shared_with` is `[]` when
210
+ unshared).
211
+
212
+ `--dry-run` (`disable` / `enable` / `enable --all` / `undo` / `profile apply` /
213
+ `install-shims` / `ui`) computes the plan --
214
+ moves, companions, backups, MCP edits, warnings -- and writes nothing: no state,
215
+ log, lock or backup, and no chmod; it never shells out to `claude`. `disable` /
216
+ `enable` result rows have action `would-disable` / `would-enable`; every plan
217
+ row has status `planned`. Read-only commands (`list`, `status`, `cost`) also
218
+ change nothing on disk -- `status` warns about a `state.json` or
219
+ backup looser than `0600` instead of fixing it. A dry run whose plan contains a
220
+ failing item exits `1`, like the real run would.
221
+
222
+ | exit code | meaning |
223
+ |:-:|---|
224
+ | `0` | ok |
225
+ | `1` | partial failure (some items failed), or an unexpected error |
226
+ | `2` | usage error, including an unknown harness or type |
227
+ | `3` | locked by another run |
228
+ | `4` | unsupported harness/type pair, or harness not installed |
229
+
230
+ ```sh
231
+ agent-toggle disable skill demo-skill other-skill
232
+ agent-toggle disable command demo:batch # nested commands/demo/batch.md
233
+ agent-toggle disable agent demo-agent
234
+ agent-toggle disable mcp example-mcp
235
+ agent-toggle disable skill demo-skill --harness codex
236
+ agent-toggle enable mcp example-mcp
237
+ agent-toggle cost --type skill --json
238
+ ```
239
+
240
+ A colon addresses nesting: `demo:batch` is `commands/demo/batch.md`.
241
+
242
+ ## Interactive picker
243
+
244
+ ```sh
245
+ agent-toggle ui # alias: agent-toggle pick
246
+ agent-toggle # same, on a terminal; elsewhere prints help and exits 2
247
+ ```
248
+
249
+ ```
250
+ agent-toggle v0.5.1 5 resources · ~6.3k tok live · 1 staged (+300 tok)
251
+ All 5 │ claude 4 │ codex 2
252
+ 🔍 Type to search (name, path, fuzzy) type: All sort: name
253
+ Skills 3 ─────────────────────────────────────────────────────────────│ beta
254
+ ● alpha claude 1.2k █▎ │
255
+ › ● + beta claude (300) ▎ │ harness claude
256
+ ● zeta claude 5.0k █████ │ type skill
257
+ Agents 1 ─────────────────────────────────────────────────────────────│ state ○ parked
258
+ ● gamma claude 50 ▏ +shared │ staged → live
259
+ Commands 1 ───────────────────────────────────────────────────────────│ path ~/.claude/skills-disabled/beta
260
+ ● delta codex 10 ▏ │ cost 0 now; ~300 tok if restored
261
+ │ since 2026-09-19 10:00:00
262
+ 5 of 5 shown · 2/5
263
+ Space toggle Enter apply Esc cancel ? help / search ←→ harness t type s sort p profile a all
264
+ ```
265
+
266
+ What is on screen:
267
+
268
+ - **Title bar**: version and a summary (resources, live tokens, staged changes and their token effect).
269
+ - **Harness tab bar**: `All` plus each enabled harness, with counts. Harnesses switched off in
270
+ the [settings](#settings--config-menu) are hidden.
271
+ - **Rows grouped by type** (Skills, Agents, Commands, ...) under a heading with a count.
272
+ `●` is live, `○` parked (`*` / `o` in ASCII); a `+` / `-` before the name marks a staged change,
273
+ and `+shared` marks a directory another harness also reads.
274
+ - **Cost column**: estimated startup tokens (chars / 4, about +-25 %) with a colored bar. A parked
275
+ row shows `(N)` in brackets, what restoring it would load. Plugins appear as rows too (via
276
+ `claude plugin list --json`; skipped under `ui --dry-run`, which never shells out).
277
+ - **Detail pane** at 100 columns or wider: harness, type, state, staged, path, cost, since,
278
+ mechanism, and what it is shared with.
279
+ - **Search bar** (🔍; plain `/` on terminals that cannot draw emoji: the Linux console, the legacy
280
+ Windows console, non-UTF-8 locales) under the tabs, always visible.
281
+ - **Per-harness colors** on the harness column, matching `ai-accounts list` (claude orange on
282
+ 256-color terminals, codex cyan, agy blue, grok yellow, vibe green, copilot red, opencode magenta).
283
+ - **Key-chip footer** and a `?` help overlay. A spinner shows on stderr while the list loads
284
+ (only on a TTY; off with `NO_COLOR`). The mouse wheel scrolls (xterm alternate-scroll mode).
285
+
286
+ | key | action |
287
+ |---|---|
288
+ | `Space` | toggle the highlighted row (live / parked) and stay on it (while filtering, `Space` types a space) |
289
+ | `Tab` | toggle the highlighted row and advance to the next |
290
+ | `Enter` | apply every staged change (with `--dry-run`: show the plan) |
291
+ | `Esc` / `Ctrl-C` | cancel; nothing is applied |
292
+ | `↑` `↓` `PgUp` `PgDn` `Home` `End`, `Ctrl-P` / `Ctrl-N` | move |
293
+ | `/` | start a search; any other non-command letter starts one too. Terms are ANDed, case-insensitive, and match the harness/type/name and the path; when nothing matches, letters-in-order (fuzzy) is tried: `ctxmd` finds `context-md`, and the bar says `≈ fuzzy match` |
294
+ | `Backspace` / `Ctrl-U` | delete one character / clear the filter |
295
+ | `←` `→` | switch harness tab (also while typing a filter); `0` = All, `1`-`9` = that tab, `h` = next tab |
296
+ | `t` | cycle the type filter |
297
+ | `s` | cycle sort: name, cost (biggest first); applies within each type group |
298
+ | `p` | profiles: stage a saved one, or save the live state (see below) |
299
+ | `?` | show the key list |
300
+ | `a` / `Ctrl-A` | toggle every visible row |
301
+
302
+ The command keys (`t`, `s`, `p`, `h`, `a`, `0`-`9`, `?`) act only while no filter is active.
303
+ Press `/` first to type a filter that begins with one of them; once the filter is non-empty,
304
+ every letter just types.
305
+
306
+ The start view (sort, harness tab, type filter) comes from the `picker_sort`, `picker_harness`
307
+ and `picker_type` [settings](#settings--config-menu). A profile skips harnesses that are hidden
308
+ and counts those items as `hidden by settings`; `ui --harness <name>` shows that harness even
309
+ when it is switched off.
310
+
311
+ `p` opens a prompt over the list of saved profiles: type a number or name (or
312
+ `apply <name>`) and Enter to **stage** that profile's changes (only the items it
313
+ mentions; ones this machine lacks are skipped), then Enter in the picker applies
314
+ them like any other change, so `ui --dry-run` previews a profile and a stray
315
+ `p` changes nothing. `save <name>` writes the live state (not your staged changes) as
316
+ a profile, with the same name rules and project scope as `profile save`; it is
317
+ off under `--dry-run`. Esc closes the prompt.
318
+
319
+ Nothing happens while the picker is open. Changes are staged, the screen is
320
+ torn down, and only then do the real operations run, so their output (which
321
+ companion files moved, which were kept because they are shared) is readable
322
+ instead of fighting curses for the terminal.
323
+
324
+ Built on stdlib `curses`, so there is nothing to install on macOS and Linux
325
+ (on Windows, `pip install "agent-toggle[windows]"` pulls `windows-curses`).
326
+ Without curses, `ui` falls back to a numbered menu with the same staging and the
327
+ same result. It is grouped by type, with the same glyphs and color: type row
328
+ numbers (`1 3 5-7`) to tick or untick, `/text` to filter (`/` alone clears it),
329
+ `s` / `h` / `t` to sort and cycle the harness and type filters, `p` to list profiles,
330
+ `p <number|name>` to stage one and `p save <name>` to save the live state, `a` to
331
+ apply, `q` (or end of input) to cancel.
332
+
333
+ The chrome (title, tabs, footer, help, detail labels) is translated when `language` is
334
+ `zh-TW`; item names and paths are never translated.
335
+
336
+ ## Settings & config menu
337
+
338
+ ```sh
339
+ agent-toggle config # or: agent-toggle --config
340
+ agent-toggle config --json # every setting with its value and source; the token is masked
341
+ ```
342
+
343
+ `config` opens the settings menu: curses on a terminal, a numbered list otherwise. Each change
344
+ is saved the moment you make it. On a numbered list, end of input, `q` or an empty line exits `0`
345
+ (safe in CI; note it reads piped digits, so an open stdin pipe waits).
346
+
347
+ | group | rows |
348
+ |---|---|
349
+ | General | Check for updates, Color, Language, Default harness |
350
+ | Harnesses | one On/Off per harness, with `found ~/.x` or `not on this machine` |
351
+ | Picker | default sort, harness filter, type filter |
352
+ | Notifications | Telegram bot token (masked), Telegram chat ID |
353
+ | Tools | Health check (`doctor`; then asks y/n per fix it can run), Install shims, Undo last change (asks y/n), Send test message, Sync CI secrets (asks y/n); their output shows in an overlay |
354
+
355
+ Keys: `↑` `↓` move (wraps, skips headings), `←` `→` cycle a value, `Enter` / `Space` change or run
356
+ the row, `r` reset the row, `R` reset everything (asks y/n), `q` / `Esc` / `Ctrl-C` quit. In an
357
+ inline text field `Enter` commits, `Esc` cancels and `-` then `Enter` clears. A dim `env` tag
358
+ marks a row whose value is overridden by an environment variable. The Language row switches the
359
+ UI live.
360
+
361
+ Settings live in `~/.agent-toggle/config.json` (mode `0600`, written atomically; keys the
362
+ tool does not know are kept):
363
+
364
+ | key | values | default | env override |
365
+ |---|---|---|---|
366
+ | `update_check` | `true` / `false` | `true` | `AGENT_TOGGLE_UPDATE_CHECK` |
367
+ | `color` | `auto` / `always` / `never` | `auto` | `AGENT_TOGGLE_COLOR` |
368
+ | `language` | `en` / `zh-TW` | `en` | `AGENT_TOGGLE_LANG` |
369
+ | `default_harness` | a harness name | `claude` | `AGENT_TOGGLE_DEFAULT_HARNESS` |
370
+ | `harness.<name>` | `true` / `false`, for `claude` `codex` `grok` `opencode` `openclaw` `copilot` `vibe` `devin` `agy` | `true` | |
371
+ | `picker_sort` | `name` / `cost` | `name` | |
372
+ | `picker_harness` | `all` or a harness name | `all` | |
373
+ | `picker_type` | `all` or a type | `all` | |
374
+ | `telegram_chat_id` | a number, `-100...` or `@channel` | unset | `TG_CHAT_ID` |
375
+
376
+ The bot token is not in this file: it is in the OS keystore through telegram-kit
377
+ (`TG_BOT_TOKEN` overrides it). An environment variable always beats the file; an
378
+ invalid value in either is ignored with one warning on stderr.
379
+
380
+ - `harness.<name>` off hides that harness from `ui`, `list`, `status` and `cost` (`list`, `status` and
381
+ `cost` print a dim `N hidden by settings` note, and `--json` carries it in `warnings`; an item shared with a harness that is still
382
+ on stays listed) and `install-shims` skips it; an explicit
383
+ `--harness <name>` still reaches it, `ui --harness <name>` included.
384
+ - `default_harness` is what `disable` / `enable` act on without `--harness`, even when that
385
+ harness is switched off (with one warning on stderr);
386
+ `disable` / `enable --project` without `--harness` still default to `claude`.
387
+ - `language` translates the config menu, help, update prompt and picker chrome; command output stays English.
388
+ - Bare `config` works without the `[telegram]` extra; `config test` and `config sync-ci` are
389
+ unchanged and need it (exit `4` without it).
390
+
391
+ ## Update check
392
+
393
+ On every invocation (unless disabled) agent-toggle checks PyPI for a newer release. It starts
394
+ before the command and is offered after it, on every exit path; it overlaps the command and waits at most 0.8 s afterwards.
395
+
396
+ - **On a terminal** (stdin and stderr are TTYs, no `--json`): a panel on stderr, "agent-toggle X
397
+ is available (you have Y)", with `Update now`, `Skip` and `Skip until next version`, a release
398
+ notes link, and `↑` `↓` `Enter` `q`. `Update now` runs `uv tool upgrade agent-toggle`; if that
399
+ fails you get a yellow warning with the command to run yourself.
400
+ - **Off a terminal**: two stderr lines, `agent-toggle X is available (you have Y)` and
401
+ ` uv tool upgrade agent-toggle`.
402
+ - It never changes the exit code or stdout, so `--json` output stays one document.
403
+
404
+ It is one `GET https://pypi.org/pypi/agent-toggle/json` on a background thread with a 0.8 s
405
+ timeout, cached for 10 minutes in `~/.agent-toggle/update-check.json`. Nothing is sent beyond a
406
+ normal HTTP request with the User-Agent `agent-toggle-update-check`. See `SECURITY.md`
407
+ for the hardening. To turn it off, set `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting
408
+ to off (the settings menu's "Check for updates").
409
+
410
+ ## Help
411
+
412
+ ```sh
413
+ agent-toggle help # grouped overview
414
+ agent-toggle help config # one command; same as: --help config, config --help
415
+ ```
416
+
417
+ Help is grouped as Toggle (`ui`, `disable`, `enable`, `undo`, `profile`), Inspect (`status`,
418
+ `list`, `cost`, `doctor`) and Setup (`config`, `install-shims`, `migrate`), with the global
419
+ options, examples and exit codes. `help <command>` shows that command's usage, arguments,
420
+ options and examples; `help help` is the overview, `help version` shows how to print the version,
421
+ and an unknown command exits `2`. It honours `--color` and `NO_COLOR`, and is
422
+ translated when `language` is `zh-TW`.
423
+
424
+ ## What each harness supports
425
+
426
+ | harness | home | skill | agent | command | rule | plugin | mcp |
427
+ |---|---|:-:|:-:|:-:|:-:|:-:|:-:|
428
+ | claude | `~/.claude` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ `~/.claude.json` |
429
+ | codex | `~/.codex` | ✓ | ✓ | ✓ (`commands/` + `prompts/`) | — | — | ✓ `config.toml` |
430
+ | grok | `~/.grok` | ✓ | — | — | — | — | ✓ `config.toml` |
431
+ | opencode | `$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode` | ✓ | — | ✓ (`command/`) | — | — | ✓ `opencode.json` (flag, *assumed*) |
432
+ | openclaw | `~/.openclaw` | ✓ (dir move, or flag when `skills.entries.<name>` exists, *assumed*) | ✓ | — | — | ✓ `openclaw.json` (flag, *assumed*) | — (sqlite) |
433
+ | copilot | `~/.copilot` | ✓ | ✓ | — | — | — | ✓ `mcp-config.json` |
434
+ | vibe | `~/.vibe` | ✓ | — | — | — | — | — |
435
+ | devin | `~/.devin` | — | — | — | — | — | — |
436
+ | agy | `~/.antigravity` | — | — | — | — | — | — |
437
+
438
+ Per-harness locations, mechanisms and the harness versions this was checked
439
+ against are in [docs/harnesses.md](docs/harnesses.md).
440
+
441
+ **Homes per OS.** Every `~` above is Python's `Path.home()`: `$HOME` on macOS and
442
+ Linux, `%USERPROFILE%` on Windows (so `~/.claude` is `C:\Users\<you>\.claude`).
443
+ opencode honours an absolute `$XDG_CONFIG_HOME` on every OS and otherwise uses
444
+ `~/.config/opencode`, Windows included; the tool does not look in `%APPDATA%`. This
445
+ tool's own state lives in `~/.agent-toggle/`. The Linux and Windows locations
446
+ follow the harnesses' documentation and have not yet been checked against real
447
+ installs there; if a harness keeps its files elsewhere, `status` shows it as not
448
+ installed.
449
+
450
+ Plugin ids are passed to `claude plugin ...`, which on Windows runs through
451
+ `cmd.exe`. An id with anything outside `A-Z a-z 0-9 . _ @ : / -` (for example `&`,
452
+ `%`, `|` or a space) is refused on every OS before any command runs.
453
+
454
+ Copilot's `config.json` is machine-managed and never edited by this tool; Copilot
455
+ plugins are not yet supported. `claude` plugins are toggled through `claude
456
+ plugin enable/disable`; `openclaw` plugins through a config flag (see [Assumed
457
+ formats](#assumed-formats)). Codex has no plugin CLI this tool can drive: `disable
458
+ plugin x --harness codex` exits `4`, which is why the matrix leaves it unchecked.
459
+
460
+ Flag items (openclaw plugins and flagged skills, opencode mcp) are toggled with
461
+ `disable` / `enable`, `undo` and `enable --all`. The picker, `cost` and `profile`
462
+ list them live (read from the config file; only entries that carry a boolean
463
+ `enabled`, since a key is never invented) and parked (from `state.json`); an
464
+ openclaw skill whose flag was switched off by this tool is shown as disabled even
465
+ though its directory is still in place.
466
+
467
+ The grok MCP location (`~/.grok/config.toml`, the same `[mcp_servers.<name>]`
468
+ tables as codex, including a `.headers` sub-table for remote servers) was
469
+ confirmed on a live install. Backups keep every sub-table verbatim.
470
+
471
+ Unsupported pairs fail loudly. OpenClaw keeps MCP servers in
472
+ `state/openclaw.sqlite`, not a file this tool can safely slice, so it refuses
473
+ rather than guessing.
474
+
475
+ ### Shared directories
476
+
477
+ OpenCode may read skills from another harness's directory through
478
+ `opencode.json` → `skills.paths` (absolute, `~`, `$HOME/...`
479
+ and `${HOME}/...` entries; read from `opencode.json`, else `opencode.jsonc`;
480
+ relative entries are skipped, since OpenCode resolves them against the session
481
+ directory). OpenCode also always scans `~/.claude/skills` and `~/.agents/skills`,
482
+ so claude's skills count as shared with it. Such a directory is **one** item, filed under
483
+ its owner (the harness whose home really holds it): it is
484
+ parked once, tracked once, and every row reports `shared_with`, the other
485
+ harnesses it also affects. `status` prints `shared dir with: ...`.
486
+
487
+ If the live directory carries `.synced-from-*` markers, a sync job may
488
+ re-create what you parked; `status` and `disable` warn about it. Park in the
489
+ source harness instead. The warning is printed once per real directory, naming
490
+ every harness that views it.
491
+
492
+ ## Companion files
493
+
494
+ A skill or command often calls a helper next to it — `scripts/foo.py`,
495
+ `tg-send.sh`. On `disable`, the item's text is scanned for such references and
496
+ each one is classified:
497
+
498
+ - **exclusive** (nothing else in the harness mentions it) → parked alongside
499
+ the item, restored with it.
500
+ - **shared** (anything else references it) → **left alone**, and reported.
501
+
502
+ Sharing is a veto, not a warning. `tg-send.sh` is referenced by five different
503
+ commands; moving it because one of them got disabled would silently break the
504
+ other four.
505
+
506
+ Detection reads file text, so a path built at runtime cannot be found. Every
507
+ companion decision is printed before anything moves, so a wrong guess is
508
+ visible rather than silent.
509
+
510
+ ## MCP backups are verbatim
511
+
512
+ - **Claude**: the raw entry from `~/.claude.json`. Never `claude mcp get` —
513
+ that prints a human summary and silently drops auth fields (`headers`,
514
+ `headersHelper`), so a server restored from it fails with 401.
515
+ - **Codex**: the exact `[mcp_servers.<name>]` text block including its
516
+ `.tools.*` sub-tables. Python's stdlib reads TOML but cannot write it, and a
517
+ hand-rolled serializer would mangle comments — a text slice is lossless and
518
+ much less code.
519
+
520
+ MCP changes need a **new session** to take effect.
521
+
522
+ ### Claude MCP scopes
523
+
524
+ Both scopes stored in `~/.claude.json` are togglable, and the scope round-trips:
525
+
526
+ | scope | lives in | toggle |
527
+ |---|---|---|
528
+ | user | top-level `mcpServers` | ✓ |
529
+ | local | `projects/<dir>/mcpServers` | ✓ — project path saved with the backup |
530
+ | project | the repo's own `.mcp.json` | ✓ with `--project <dir>` only, see [Project scope](#project-scope) |
531
+ | claude.ai connector | your account | ✓ — recorded in each *existing* project's `disabledMcpServers`; a project first opened later needs the toggle re-run |
532
+
533
+ `claude mcp remove -s local` only sees the project it runs in, so the project
534
+ path is recorded at disable time and the restore runs back in that directory.
535
+ One name that is local-scope in several projects is refused unless your cwd
536
+ picks the winner — guessing would restore it into the wrong project.
537
+
538
+ ## Profiles
539
+
540
+ A profile is a named snapshot of which items are live, for the dotfiles repo
541
+ or a second machine. It holds only `{harness, type, name, live}` per item --
542
+ never a path or a secret.
543
+
544
+ ```sh
545
+ agent-toggle profile save work # -> ~/.agent-toggle/profiles/work.json
546
+ agent-toggle profile save work --out ~/dotfiles/agent-toggle/work.json
547
+ agent-toggle profile diff work # what apply would do; writes nothing
548
+ agent-toggle profile apply ~/dotfiles/agent-toggle/work.json --dry-run
549
+ agent-toggle profile apply work
550
+ agent-toggle profile list
551
+ ```
552
+
553
+ **`apply` toggles only the items the profile mentions.** An item the profile
554
+ lists as live but that is parked now is enabled; one it lists as parked but
555
+ that is live now is disabled; everything else is left alone -- in particular an
556
+ item installed after the save is never touched. An item the profile names that
557
+ this machine does not have gets a `skipped` row (`not on this machine`), not a
558
+ failure. `apply` runs through the same path as `enable` / `disable` (one lock,
559
+ one batch, logged, undoable with `undo`).
560
+
561
+ - An argument ending in `.json` or containing a path separator is a **file
562
+ path** (absolute paths are fine); anything else is a stored profile name under
563
+ `~/.agent-toggle/profiles`. `..` in a path, and names that are not plain file
564
+ names, are refused (exit `2`), as are a bad version, an unknown harness or
565
+ type, an invalid item name, a duplicate item or a file over 1 MiB.
566
+ - `--out` belongs to `save` only; `--dry-run` to `apply` / `diff`. `--harness H`
567
+ narrows `save`, `apply` and `diff` to one harness.
568
+ - `diff` and `apply --dry-run` do not read plugin state (that needs the claude
569
+ CLI, and a dry run never shells out), so profile plugin items show as
570
+ `skipped` there.
571
+ - A user-scope profile never contains project-scope entries; with
572
+ `--project <dir>` the same commands save, diff and apply that project's
573
+ items (claude layout) instead -- the profile holds no directory, so `apply`
574
+ needs `--project` again. A project profile records `"scope": "project"`;
575
+ applying it without `--project` (or a user profile with `--project`) exits `2`,
576
+ so a project profile can never disable the user's items.
577
+ - `save` skips (with a warning) any item whose name `apply` would refuse, such as
578
+ an MCP server called `team/search`, so a saved profile always applies.
579
+
580
+ ## Undo and `enable --all`
581
+
582
+ `undo` reads `~/.agent-toggle/log.jsonl`, takes the batch of the last
583
+ successful `disable` / `enable`, and reverses its rows in reverse order. The
584
+ reversal is logged as its own batch, so `undo` twice puts things back.
585
+ Project rows are replayed in their own project scope, never in user scope.
586
+
587
+ ```sh
588
+ agent-toggle undo --dry-run
589
+ agent-toggle undo
590
+ ```
591
+
592
+ - Nothing logged yet: `nothing to undo` (exit `0`). A log written before
593
+ batch ids existed: `nothing to undo: log predates undo` (exit `1`).
594
+ - `undo` rejects `--harness` (exit `2`): it reverses a whole batch.
595
+ - The log is a plain file you can edit; its names and project dirs are checked
596
+ like command-line input, and a project dir is trusted as written, exactly as
597
+ user-scope replay trusts the log.
598
+
599
+ `enable --all [--harness H] [--project <dir>]` restores every disabled entry in
600
+ scope, each in its own scope (`--project` takes only that project's).
601
+ `enable --all <name>` and `disable --all` are usage errors (exit `2`). Preview
602
+ bulk operations with `--dry-run`.
603
+
604
+ ## Project scope
605
+
606
+ `--project <dir>` (`.` = the current directory) points `disable`, `enable`,
607
+ `enable --all`, `list` and `profile` at one repo: its `.claude/` dir types
608
+ (`skill`, `agent`, `command`, `rule`) and its own `.mcp.json` servers (`mcp`).
609
+ Claude layout only: `--harness codex --project ...` exits `4`, as does a
610
+ missing directory or one with neither `.claude/` nor `.mcp.json`. `$HOME`, its
611
+ ancestors, the tool's own state dir and the harness homes are refused (exit
612
+ `2`) -- that is user scope. `list`, `enable --all`, `cost` and `ui` run the same
613
+ checks. `cost --project` prices that project's items only (no plugins), and
614
+ `ui --project` stages and applies in project scope (its `p` key saves and applies
615
+ project profiles).
616
+
617
+ ```sh
618
+ agent-toggle disable skill demo-skill --project .
619
+ agent-toggle disable mcp example-mcp --project ~/work/repo
620
+ agent-toggle list --project .
621
+ agent-toggle enable --all --project .
622
+ ```
623
+
624
+ - Parked items go to `~/.agent-toggle/parked/<sha8>/`, **never inside the
625
+ project**; project and user state entries are separate, so one can never
626
+ restore into the other. Companion files are not moved in project scope.
627
+ - **A tracked file still disappears from the worktree.** Every project-scope
628
+ disable prints a warning like this one, and `git status` shows the deletion:
629
+
630
+ ```
631
+ <repo>/.claude/skills/demo-skill is a tracked deletion in git status; restore with: agent-toggle enable skill demo-skill --project <repo>
632
+ <repo>/.mcp.json is a tracked change in git status; restore with: agent-toggle enable mcp example-mcp --project <repo>
633
+ ```
634
+
635
+ Restore with that command, or `git checkout`; do not commit the deletion if
636
+ the repo is shared.
637
+ - `.mcp.json` is edited directly (no `claude` CLI) and must be **strict JSON**:
638
+ a BOM, comments or trailing commas are refused. `disable` cuts only that
639
+ server's entry, leaving every other byte as it was, and saves a backup at
640
+ `mcp-backups/<sha8>__claude__<name>.json` (mode `0600`) holding only that entry
641
+ -- its own headers, never another server's. `enable` puts the entry's bytes
642
+ back into the current file (an unchanged file comes back byte for byte; after
643
+ other edits it goes after its old neighbour, else at the end, indented like the
644
+ file) and checks that nothing else changed.
645
+ - A move across filesystems copies into a temp dir beside the target, renames it
646
+ into place, then deletes the source, so a killed run leaves either the source
647
+ intact or the target complete, and the next run settles it (see "Where state
648
+ lives"). `enable` removes the emptied `parked/<sha8>/*-disabled` and
649
+ `parked/<sha8>` dirs; a dir that still holds anything is kept.
650
+ - `status` prints one `project <dir>` line per project holding parked items.
651
+ A project with only `.mcp.json` has its live servers listed too in
652
+ `profile save --project`, next to its parked ones.
653
+
654
+ ## Doctor
655
+
656
+ ```sh
657
+ agent-toggle doctor [--harness H] [--json]
658
+ ```
659
+
660
+ The check is read-only: no lock, no state write-back, no `claude` CLI call. For each
661
+ installed harness it compares the live layout with the table row (expected
662
+ dirs and config keys), then cross-checks `state.json` against disk (parked
663
+ item present, origin dir present, backup present, project dir present, entry
664
+ passes the same tamper checks `enable` runs, companion files present and not also
665
+ live, modes no looser than `0600` / `0700`). Rows (`action: doctor`) carry a status:
666
+
667
+ | status | meaning |
668
+ |---|---|
669
+ | `ok` | matches |
670
+ | `absent` | a dir, config file or key the row expects is not there (an MCP file never created, a missing `mcpServers` key) -- informational, exit `0` |
671
+ | `note` | worth knowing: shared dir, orphan backup, JSONC `openclaw.json` / `opencode.json`, a `--harness` that is not installed |
672
+ | `unverified` | no version could be read, or the row has nothing to check |
673
+ | `warn` | loose file modes; a parked item or companion file with no state entry; a leftover `parked/<sha8>` dir that still holds files (an empty one is ignored); an op a killed run left in flight that the next change settles (`pending: done` / `undone`) |
674
+ | `error` | needs fixing: a config that exists but is unparseable or unsupported (`layout changed`), a state entry whose files (companions included) are gone or fail the tamper checks, a companion both live and parked, a flag re-enabled outside the tool, an op a killed run left in flight that needs you (`pending: stuck`, with the exact fix) |
675
+
676
+ Only `error` makes the exit code `1`; each problem row names the command that
677
+ fixes it. `--harness X` for a harness that is not installed is a `note` (exit
678
+ `0`).
679
+
680
+ A harness item parked with no state entry also carries `orphan`, and its fix
681
+ follows from it: `identical` (the live copy has the same content: delete the
682
+ parked copy), `differs` (a different live copy exists: compare, keep one), or
683
+ `parked-only` (move it back, then `disable` it so the state records it).
684
+
685
+ ### Fixing
686
+
687
+ `install-shims` (and so `./install.sh`) runs the same check silently after
688
+ writing the shims and asks only about these fixable problems, each shown first.
689
+
690
+ On a terminal (stdin and stdout both a TTY, no `--json`) doctor then asks
691
+ `(y/n)` for each problem it can fix without a judgment call, and runs each yes
692
+ under the run lock; anything else keeps its `fix:` hint for you. A fixed row's
693
+ status becomes `fixed` and no longer counts toward the exit code; a failed fix
694
+ adds an `error` row. Piped, `--json`, or run by an agent through a shim, doctor
695
+ stays read-only.
696
+
697
+ | problem | fix it offers |
698
+ |---|---|
699
+ | loose mode on the state dir, `state.json` or a backup | `chmod 700` / `chmod 600` |
700
+ | backup with no state entry, server configured again in its harness | delete the backup (not offered while the server is missing, nor for project backups) |
701
+ | flag re-enabled outside the tool | `enable` the item, which clears the stale entry |
702
+ | origin dir gone | recreate it, then `enable` the item |
703
+ | op a killed run left in flight (`pending: done` / `undone`) | settle it now, as the next change would |
704
+ | orphan parked item, `identical` | delete the parked copy |
705
+ | orphan parked item, `parked-only` | move it back (restores it) |
706
+
707
+ Not offered: `differs` orphans, missing parked items or backups, a gone project
708
+ dir, tamper refusals, `pending: stuck`, companion conflicts, unparseable configs.
709
+
710
+ ## Assumed formats
711
+
712
+ Two config shapes came from the design survey (`docs/DESIGN.md` §4 / §11). Both
713
+ were since seen on one real install each (openclaw 2026.7.1-2, opencode 2.0.22; see
714
+ `docs/harnesses.md`), but the tool still treats them defensively:
715
+
716
+ - `openclaw.json`: `skills.entries.<name>.enabled` and
717
+ `plugins.entries.<name>.enabled`
718
+ - `opencode.json`: `mcp.<name>.enabled`
719
+
720
+ The edit changes one boolean token and nothing else, is verified after writing
721
+ and rolled back on any mismatch (bytes and file mode). JSONC (`//` and `/* */`
722
+ comments, trailing commas) is edited the same way: only the flag token changes,
723
+ comments and commas are kept. JSON5 (unquoted keys, single quotes, hex) and files
724
+ that repeat a key are **refused**, never rewritten; a leading BOM is kept as is; a
725
+ missing key is refused, never invented. An openclaw skill
726
+ uses the flag only when `skills.entries.<name>` already exists, otherwise its
727
+ directory is moved. The state entry (with the flag's previous value) is saved
728
+ before the flag is written, so a run killed in between is settled by the next
729
+ run, never guessed (see "Where state lives"). Please report a real install that
730
+ differs.
731
+
732
+ ## Safety checks
733
+
734
+ - `enable` refuses a state entry whose `origin`, `parked_at`, backup or flag
735
+ file is outside its harness home, project or `~/.agent-toggle`, or holds `..`:
736
+ an error row `refused: <reason>`, nothing moved.
737
+ - Every edit of a JSON or TOML config is verified after writing (still parses,
738
+ only the target changed) and rolled back, bytes and mode, on failure. An
739
+ invalid codex `config.toml` makes an MCP edit fail and roll back instead of
740
+ being rewritten. A codex `config.toml` edit also fails (and is left as the other
741
+ tool wrote it) if the file changed between read and write, and CRLF files keep their
742
+ line endings. On Python 3.10 (no `tomllib`) the edit is parse-checked by a stdlib structural
743
+ validator (`agent_toggle/toml_check.py`, differentially tested against `tomllib`),
744
+ on top of the textual check that only the one block changed.
745
+ - Profiles and project dirs are validated like command-line input.
746
+
747
+ ## Where state lives
748
+
749
+ All of it in `~/.agent-toggle/` (mode `0700`), never as marker files next to the targets —
750
+ your `git status` in your own project must not change because of our
751
+ bookkeeping.
752
+
753
+ | file | contents |
754
+ |---|---|
755
+ | `state.json` | current disabled list (schema v3; atomic write, mode `0600`), saved before each flag write or dir move with the op in flight under an optional `pending` key, and after each MCP / plugin item |
756
+ | `lock` | held by `disable` / `enable` / `enable --all` / `undo` / `profile apply` / `migrate` / `ui` for the whole batch; a second run waits 5 s then exits `3` (stale after 10 min *and* its PID is gone; a live batch refreshes it per item; the error says when its PID is gone, i.e. a killed run left it) |
757
+ | `log.jsonl` | one line per operation (mode `0600`), see below |
758
+ | `mcp-backups/` | `<harness>__<server>.json`, or `<sha8>__<harness>__<server>.json` for a project `.mcp.json` (mode `0600` -- may hold auth headers) |
759
+ | `companions/` | parked exclusive helper files |
760
+ | `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
761
+ | `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
762
+ | `config.json` | [settings](#settings--config-menu) and the Telegram chat id (mode `0600`, atomic write, unknown keys kept) |
763
+ | `update-check.json` | the [update check](#update-check)'s 10-minute cache (mode `0600`, atomic write) |
764
+
765
+ A killed run loses at most the one item in flight. If that was a flag write or a
766
+ dir move (with its companions), its `pending` record (the full entry, a flag's
767
+ previous value included) lets the next `disable` / `enable` / `enable --all` /
768
+ `undo` / ... finish it (it reached disk: recorded) or drop it (it did not), with
769
+ a warning and a `recovered` log row (which `undo` does not reverse). `status` and
770
+ `doctor` report it, with `agent-toggle enable ...` when the item ends up
771
+ disabled. The one case left to you is a cross-filesystem move killed between its
772
+ two renames: both copies are complete, and the report names the `diff -r` to
773
+ check and the `rm -rf` that keeps either one. MCP and plugin items are saved
774
+ per item (when the next one starts), without a `pending` record (a project `.mcp.json`
775
+ edit has one): a kill inside that window
776
+ leaves the change unrecorded (an MCP server's backup stays in `mcp-backups/`).
777
+ A killed run also leaves its `lock`; the next run's error says so.
778
+
779
+ Each `log.jsonl` row is `{ts, harness, type, name, action, result, batch,
780
+ project, scope, detail}`. `batch` is one id per run (what `undo` reverses);
781
+ `project` is `null` and `scope` is `user` outside project scope (a claude
782
+ local-scope MCP row has `scope: local` and its working directory in `project`).
783
+ Older rows without `batch`/`harness` cannot be undone.
784
+
785
+ ## Two guardrails, both earned
786
+
787
+ **1. `safe_move()` never lets the source be renamed into the destination.**
788
+ `mv X dest/` when `dest` does not exist *renames* `X` to `dest` — the first
789
+ item fails, the second "succeeds" by becoming that directory, and `SKILL.md`
790
+ and `.git` end up scattered at the root. `shutil.move()` behaves identically.
791
+ So: create the directory, prove it *is* a directory, prove the target does not
792
+ exist, and only then touch the source.
793
+
794
+ Order matters too: `mkdir(exist_ok=True)` raises `FileExistsError` when the
795
+ path exists as a *file*, so the `is_dir()` check has to come **before** the
796
+ `mkdir` or it is unreachable.
797
+
798
+ **2. Park dirs that are not gitignored get a warning.** Without it, every
799
+ disable leaves dozens of deletion lines in `git status`. The warning is shown once
800
+ per park dir, and only for a dir inside a git work tree (elsewhere `git status`
801
+ cannot be dirtied and the `.gitignore` fix would not apply).
802
+
803
+ ## Paths are resolved before comparison
804
+
805
+ `~/.claude` is often a symlink, and on macOS `/var` is one. Comparing
806
+ unresolved paths makes every companion look like it lives outside the harness.
807
+ Both sides are resolved first.
808
+
809
+ ## Design and roadmap
810
+
811
+ Architecture, harness survey, cost model, known gaps and the phased roadmap
812
+ toward a public multi-OS release live in `docs/DESIGN.md`. Release notes are in
813
+ `CHANGELOG.md`.
814
+
815
+ ## Tests
816
+
817
+ ```sh
818
+ python3 -m unittest discover -s tests -v
819
+ ruff check .
820
+ ```
821
+
822
+ stdlib `unittest`, no fixtures, no network. Every test runs against a
823
+ throwaway temp `HOME` and a stubbed `claude` CLI.
824
+
825
+ ## CI notifications
826
+
827
+ `.github/workflows/ci.yml` can send a Telegram message when the test matrix fails on a
828
+ push (a lint-only failure does not page). Alerts never fire for pull requests or green
829
+ runs. The message names the repository, branch, 7-character commit and a link to the run.
830
+
831
+ Set it up once, with the `telegram` extra installed (see [Install](#install)):
832
+
833
+ ```sh
834
+ agent-toggle config # settings menu: Notifications has the bot token (hidden) and chat id
835
+ agent-toggle config test # send one test message
836
+ agent-toggle config sync-ci # set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID on the repo
837
+ ```
838
+
839
+ In the [settings menu](#settings--config-menu) an inline field commits on `Enter`, cancels on
840
+ `Esc`, and clears on `-` then `Enter`; the Tools group also has Send test message and Sync CI
841
+ secrets (the same as `config test` / `config sync-ci`, and they need the extra). The bot token
842
+ is kept only in the OS credential store (macOS Keychain, Linux Secret Service, Windows DPAPI)
843
+ through telegram-kit, never in a file or on a command line, and is shown masked. With no
844
+ credential store it refuses to store the token; set `TG_BOT_TOKEN` in the environment instead
845
+ (`TG_CHAT_ID` likewise for the chat id). The chat id (`telegram_chat_id`) is ordinary
846
+ configuration in `~/.agent-toggle/config.json`: a number, `-100...` for a group, or an
847
+ `@channel`. `config sync-ci` needs the GitHub CLI (`gh`) signed in; it passes both values on
848
+ stdin, and `--repo OWNER/REPO` / `--dry-run` work as elsewhere.
849
+
850
+ To skip the tool, set the secrets by hand (each command prompts for the value):
851
+
852
+ ```sh
853
+ gh secret set TELEGRAM_BOT_TOKEN
854
+ gh secret set TELEGRAM_CHAT_ID
855
+ ```
856
+
857
+ With either secret unset (a fork, or a clone you have not configured) the notify job
858
+ prints a `::notice::` and exits 0, so it never adds a second red X.
859
+
860
+ ## Releasing
861
+
862
+ A release is a git tag `vX.Y.Z` (where `X.Y.Z` must match `agent_toggle.__version__`).
863
+ Pushing the tag triggers `.github/workflows/release.yml`:
864
+
865
+ 1. **Build** job checks that the tag matches `__version__`, builds a wheel and
866
+ source distribution, and generates the shell completion files (uploaded as the
867
+ `completions` artifact; the workflow does not create a GitHub Release).
868
+ 2. **Publish** job uploads to PyPI using
869
+ [trusted publishing](https://docs.pypi.org/trusted-publishers/),
870
+ with no stored tokens—only the `pypi` environment and OIDC setup.
871
+ 3. **Smoke** job installs the published version on macOS under a throwaway `HOME`
872
+ and runs a `disable` / `enable` round trip to verify the install.
873
+
874
+ Before the first tag, register a trusted publisher for this repository with
875
+ PyPI: workflow `release.yml`, environment `pypi`. See
876
+ [PyPI trusted publishers documentation](https://docs.pypi.org/trusted-publishers/).
877
+
878
+ ## Cross-machine behaviour
879
+
880
+ Disabling is a **local** decision: park dirs are gitignored and do not sync.
881
+ But a disappearance under `skills/` is itself a tracked change, so committing
882
+ it means other machines lose those skills on pull. The content stays in git
883
+ history — `git checkout <commit> -- skills/<name>` brings it back. Don't
884
+ commit those deletions if you don't want them to travel.