agent-toggle 0.1.0__tar.gz → 0.2.0__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 (64) hide show
  1. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/PKG-INFO +117 -14
  2. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/README.md +114 -13
  3. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/__init__.py +1 -1
  4. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/cli.py +68 -17
  5. agent_toggle-0.2.0/agent_toggle/config.py +143 -0
  6. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/doctor.py +58 -7
  7. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/fs.py +21 -2
  8. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/harnesses.py +13 -7
  9. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/mechanisms.py +41 -3
  10. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/ops.py +5 -0
  11. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/shims/claude.md.tmpl +26 -4
  12. agent_toggle-0.2.0/agent_toggle/shims/generic.md.tmpl +68 -0
  13. agent_toggle-0.2.0/agent_toggle/ui/menu.py +93 -0
  14. agent_toggle-0.2.0/agent_toggle/ui/model.py +72 -0
  15. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/ui/picker.py +1 -67
  16. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle.egg-info/PKG-INFO +117 -14
  17. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle.egg-info/SOURCES.txt +9 -0
  18. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle.egg-info/requires.txt +3 -0
  19. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/pyproject.toml +1 -0
  20. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_alias.py +3 -2
  21. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_cli.py +10 -0
  22. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_cli_surface.py +24 -0
  23. agent_toggle-0.2.0/tests/test_completions.py +37 -0
  24. agent_toggle-0.2.0/tests/test_config.py +141 -0
  25. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_containment.py +2 -3
  26. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_doctor.py +42 -2
  27. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_fs.py +5 -5
  28. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_harnesses.py +11 -4
  29. agent_toggle-0.2.0/tests/test_install.py +173 -0
  30. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_mechanisms.py +2 -3
  31. agent_toggle-0.2.0/tests/test_menu.py +86 -0
  32. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_picker.py +26 -26
  33. agent_toggle-0.2.0/tests/test_platform.py +107 -0
  34. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_project.py +3 -2
  35. agent_toggle-0.2.0/tests/test_secret_audit.py +253 -0
  36. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_store.py +2 -2
  37. agent_toggle-0.1.0/tests/test_install.py +0 -98
  38. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/LICENSE +0 -0
  39. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/__main__.py +0 -0
  40. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/backends/__init__.py +0 -0
  41. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/backends/flag_json.py +0 -0
  42. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/backends/mcp_json.py +0 -0
  43. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/backends/mcp_toml.py +0 -0
  44. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/backends/plugin_cli.py +0 -0
  45. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/companions.py +0 -0
  46. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/cost.py +0 -0
  47. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/output.py +0 -0
  48. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/profiles.py +0 -0
  49. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/store.py +0 -0
  50. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/ui/__init__.py +0 -0
  51. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle/undo.py +0 -0
  52. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle.egg-info/dependency_links.txt +0 -0
  53. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle.egg-info/entry_points.txt +0 -0
  54. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/agent_toggle.egg-info/top_level.txt +0 -0
  55. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/setup.cfg +0 -0
  56. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_backends.py +0 -0
  57. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_color.py +0 -0
  58. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_companions.py +0 -0
  59. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_conformance.py +0 -0
  60. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_cost.py +0 -0
  61. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_encoding.py +0 -0
  62. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_profiles.py +0 -0
  63. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_safety.py +0 -0
  64. {agent_toggle-0.1.0 → agent_toggle-0.2.0}/tests/test_undo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-toggle
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
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
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/weskao/agent-toggle
@@ -26,6 +26,8 @@ Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
27
  Provides-Extra: windows
28
28
  Requires-Dist: windows-curses; sys_platform == "win32" and extra == "windows"
29
+ Provides-Extra: telegram
30
+ Requires-Dist: telegram-kit<0.3,>=0.2.0; extra == "telegram"
29
31
  Dynamic: license-file
30
32
 
31
33
  # agent-toggle
@@ -50,30 +52,31 @@ puts it back where it came from.
50
52
 
51
53
  ## Install
52
54
 
53
- Latest release (on PyPI from the first tagged release):
55
+ Latest release, from [PyPI](https://pypi.org/project/agent-toggle/):
54
56
 
55
57
  ```sh
56
58
  uv tool install agent-toggle
57
59
  agent-toggle --version
58
60
  ```
59
61
 
60
- Pre-release versions from this repository:
62
+ Straight from this repository, pinned to a release tag (`v0.2.0` is the current one;
63
+ an unpinned `git+` URL installs whatever is on the default branch):
61
64
 
62
65
  ```sh
63
- uv tool install git+<repo-url>
66
+ uv tool install git+https://github.com/weskao/agent-toggle@v0.2.0
64
67
  agent-toggle --version
65
68
  ```
66
69
 
67
70
  Alternatively, use `pipx`:
68
71
 
69
72
  ```sh
70
- pipx install agent-toggle # from the first tagged release; or: pipx install git+<repo-url>
73
+ pipx install agent-toggle # from PyPI; or: pipx install git+https://github.com/weskao/agent-toggle@v0.2.0
71
74
  ```
72
75
 
73
76
  From a checkout, either install it editable or run it in place:
74
77
 
75
78
  ```sh
76
- git clone <repo-url> agent-toggle
79
+ git clone https://github.com/weskao/agent-toggle agent-toggle
77
80
  cd agent-toggle
78
81
  pip install -e . # puts the `agent-toggle` console script on PATH
79
82
  python3 agent_toggle.py status # no install needed; same CLI
@@ -89,8 +92,45 @@ python3 agent_toggle.py install-shims # from a checkout, no install needed
89
92
  `install-shims` writes a thin skill shim (`skills/agent-toggle/SKILL.md`) into
90
93
  every installed harness that supports skills, so `/agent-toggle` works from any
91
94
  of them, and adds the park dirs (`skills-disabled/` etc.) to a `.gitignore`
92
- already present in that harness home. It is idempotent; `--dry-run` shows the
93
- plan. The tool itself stays in one place.
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
+
101
+ Every shim carries an `<!-- agent-toggle shim: ... -->` marker line.
102
+ `install-shims` updates a file that has the marker in place (it is idempotent)
103
+ and **refuses** to overwrite one that lacks it, or a symlink: that harness gets
104
+ an `error` row with the fix, the other harnesses still install, and the run
105
+ exits `1`. A shim written by a version before the marker counts as foreign;
106
+ delete it and re-run. `--dry-run` shows the same plan, refusals included. The
107
+ tool itself stays in one place.
108
+
109
+ **Optional: Telegram alerts for CI** (for maintainers of a fork or clone). Install
110
+ with the `telegram` extra, which adds [telegram-kit](https://pypi.org/project/telegram-kit/)
111
+ (itself stdlib-only), then run the setup once; see [CI notifications](#ci-notifications):
112
+
113
+ ```sh
114
+ uv tool install "agent-toggle[telegram]" # or: pipx install "agent-toggle[telegram]"
115
+ pip install -e ".[telegram]" # from a checkout
116
+ agent-toggle config # bot token + chat id
117
+ agent-toggle config sync-ci # set the two GitHub repository secrets
118
+ ```
119
+
120
+ Without the extra everything else works exactly as before; only `config` asks for it.
121
+
122
+ ## Shell completion
123
+
124
+ Each release publishes static completion files for bash, zsh and fish as the
125
+ `completions` artifact of its Release workflow run. They are generated from the
126
+ CLI parser; to build them from a checkout:
127
+
128
+ ```sh
129
+ python3 tools/gen_completions.py completions # agent-toggle.bash, _agent-toggle (zsh), agent-toggle.fish
130
+ ```
131
+
132
+ Source `agent-toggle.bash` from `~/.bashrc`, put `_agent-toggle` on your zsh
133
+ `fpath`, or copy `agent-toggle.fish` to `~/.config/fish/completions/`.
94
134
 
95
135
  ## Usage
96
136
 
@@ -101,20 +141,23 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
101
141
  | command | what it does |
102
142
  |---|---|
103
143
  | `ui` | interactive picker — cost column, sort, filters; `--dry-run` shows the plan for what you stage and changes nothing |
104
- | `status` | health check: harnesses found, types each supports, parked counts, gitignore, untracked parked items, stale live twins, shared dirs |
144
+ | `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`) |
105
145
  | `list [type]` | what is currently disabled (`--project <dir>` filters to one project) |
106
146
  | `cost [--type T]` | estimated startup tokens per item, biggest first (read-only; `--harness H` filters) |
107
- | `install-shims` | write the skill shim into every installed harness (`--dry-run` shows the plan) |
147
+ | `install-shims` | write the skill shim into every installed harness; refuses to overwrite a file it did not write (`--dry-run` shows the plan) |
108
148
  | `disable <type> <name>...` | park one or more items (`--dry-run` shows the plan; `--project <dir>` for a repo's own `.claude/` and `.mcp.json`) |
109
149
  | `enable <type> <name>...` | put them back (`--dry-run` shows the plan; `--project <dir>` likewise) |
110
150
  | `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
111
151
  | `undo` | reverse the last logged batch (`--dry-run` shows the plan) |
112
152
  | `profile save\|apply\|diff\|list` | named sets of live items; see [Profiles](#profiles) |
153
+ | `config [test\|sync-ci]` | Telegram settings for the CI failure alerts; see [CI notifications](#ci-notifications) |
113
154
  | `doctor` | read-only check of each harness layout and of `state.json` against disk; exit `1` only on an `error` row |
114
155
  | `migrate` | import an older `~/.claude-toggle/` state |
115
156
 
116
157
  `<type>` = `skill` / `agent` / `command` / `rule` / `plugin` / `mcp`.
117
158
  `rule` is claude-only (`~/.claude/rules/*.md`, parked in `rules-disabled/`).
159
+ Disabling a rule prints a warning (also in `--json` `warnings`, dry run included)
160
+ that rules may carry safety constraints.
118
161
 
119
162
  Flags accepted by every command, before or after the subcommand:
120
163
 
@@ -228,6 +271,10 @@ instead of fighting curses for the terminal.
228
271
 
229
272
  Built on stdlib `curses`, so there is nothing to install on macOS and Linux
230
273
  (on Windows, `pip install "agent-toggle[windows]"` pulls `windows-curses`).
274
+ Without curses, `ui` falls back to a numbered menu with the same staging and the
275
+ same result: type row numbers (`1 3 5-7`) to tick or untick, `/text` to filter,
276
+ `s` / `h` / `t` to sort and cycle the harness and type filters, `a` to apply, `q`
277
+ (or end of input) to cancel.
231
278
 
232
279
  ## What each harness supports
233
280
 
@@ -243,6 +290,22 @@ Built on stdlib `curses`, so there is nothing to install on macOS and Linux
243
290
  | devin | `~/.devin` | — | — | — | — | — | — |
244
291
  | agy | `~/.antigravity` | — | — | — | — | — | — |
245
292
 
293
+ Per-harness locations, mechanisms and the harness versions this was checked
294
+ against are in [docs/harnesses.md](docs/harnesses.md).
295
+
296
+ **Homes per OS.** Every `~` above is Python's `Path.home()`: `$HOME` on macOS and
297
+ Linux, `%USERPROFILE%` on Windows (so `~/.claude` is `C:\Users\<you>\.claude`).
298
+ opencode honours an absolute `$XDG_CONFIG_HOME` on every OS and otherwise uses
299
+ `~/.config/opencode`, Windows included; the tool does not look in `%APPDATA%`. This
300
+ tool's own state lives in `~/.agent-toggle/`. The Linux and Windows locations
301
+ follow the harnesses' documentation and have not yet been checked against real
302
+ installs there; if a harness keeps its files elsewhere, `status` shows it as not
303
+ installed.
304
+
305
+ Plugin ids are passed to `claude plugin ...`, which on Windows runs through
306
+ `cmd.exe`. An id with anything outside `A-Z a-z 0-9 . _ @ : / -` (for example `&`,
307
+ `%`, `|` or a space) is refused on every OS before any command runs.
308
+
246
309
  Copilot's `config.json` is machine-managed and never edited by this tool; Copilot
247
310
  plugins are not yet supported. `claude` plugins are toggled through `claude
248
311
  plugin enable/disable`; `openclaw` plugins through a config flag (see [Assumed
@@ -461,10 +524,16 @@ Only `error` makes the exit code `1`; each problem row names the command that
461
524
  fixes it. `--harness X` for a harness that is not installed is a `note` (exit
462
525
  `0`). Companion files are not checked.
463
526
 
527
+ A harness item parked with no state entry also carries `orphan`, and its fix
528
+ follows from it: `identical` (the live copy has the same content: delete the
529
+ parked copy), `differs` (a different live copy exists: compare, keep one), or
530
+ `parked-only` (move it back, then `disable` it so the state records it).
531
+
464
532
  ## Assumed formats
465
533
 
466
- Two config shapes come from the design survey (`docs/DESIGN.md` §4 / §11) and
467
- were **not verified on a real install**:
534
+ Two config shapes came from the design survey (`docs/DESIGN.md` §4 / §11). Both
535
+ were since seen on one real install each (openclaw 2026.7.1-2, opencode 2.0.22; see
536
+ `docs/harnesses.md`), but the tool still treats them defensively:
468
537
 
469
538
  - `openclaw.json`: `skills.entries.<name>.enabled` and
470
539
  `plugins.entries.<name>.enabled`
@@ -554,13 +623,47 @@ ruff check .
554
623
  stdlib `unittest`, no fixtures, no network. Every test runs against a
555
624
  throwaway temp `HOME` and a stubbed `claude` CLI.
556
625
 
626
+ ## CI notifications
627
+
628
+ `.github/workflows/ci.yml` can send a Telegram message when the test matrix fails on a
629
+ push (a lint-only failure does not page). Alerts never fire for pull requests or green
630
+ runs. The message names the repository, branch, 7-character commit and a link to the run.
631
+
632
+ Set it up once, with the `telegram` extra installed (see [Install](#install)):
633
+
634
+ ```sh
635
+ agent-toggle config # prompts for the bot token (hidden) and the chat id
636
+ agent-toggle config test # send one test message
637
+ agent-toggle config sync-ci # set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID on the repo
638
+ ```
639
+
640
+ `config` works like aicp's `--config`: Enter keeps the current value, `-` clears it.
641
+ The bot token is kept only in the OS credential store (macOS Keychain, Linux Secret
642
+ Service, Windows DPAPI) through telegram-kit, never in a file or on a command line, and
643
+ is shown masked. With no credential store it refuses to store the token; set
644
+ `TG_BOT_TOKEN` in the environment instead (`TG_CHAT_ID` likewise for the chat id). The
645
+ chat id is ordinary configuration in `~/.agent-toggle/config.json`: a number, `-100...`
646
+ for a group, or an `@channel`. `config sync-ci` needs the GitHub CLI (`gh`) signed in; it
647
+ passes both values on stdin, and `--repo OWNER/REPO` / `--dry-run` work as elsewhere.
648
+
649
+ To skip the tool, set the secrets by hand (each command prompts for the value):
650
+
651
+ ```sh
652
+ gh secret set TELEGRAM_BOT_TOKEN
653
+ gh secret set TELEGRAM_CHAT_ID
654
+ ```
655
+
656
+ With either secret unset (a fork, or a clone you have not configured) the notify job
657
+ prints a `::notice::` and exits 0, so it never adds a second red X.
658
+
557
659
  ## Releasing
558
660
 
559
661
  A release is a git tag `vX.Y.Z` (where `X.Y.Z` must match `agent_toggle.__version__`).
560
662
  Pushing the tag triggers `.github/workflows/release.yml`:
561
663
 
562
- 1. **Build** job checks that the tag matches `__version__` and builds a wheel and
563
- source distribution.
664
+ 1. **Build** job checks that the tag matches `__version__`, builds a wheel and
665
+ source distribution, and generates the shell completion files (uploaded as the
666
+ `completions` artifact; the workflow does not create a GitHub Release).
564
667
  2. **Publish** job uploads to PyPI using
565
668
  [trusted publishing](https://docs.pypi.org/trusted-publishers/),
566
669
  with no stored tokens—only the `pypi` environment and OIDC setup.
@@ -20,30 +20,31 @@ puts it back where it came from.
20
20
 
21
21
  ## Install
22
22
 
23
- Latest release (on PyPI from the first tagged release):
23
+ Latest release, from [PyPI](https://pypi.org/project/agent-toggle/):
24
24
 
25
25
  ```sh
26
26
  uv tool install agent-toggle
27
27
  agent-toggle --version
28
28
  ```
29
29
 
30
- Pre-release versions from this repository:
30
+ Straight from this repository, pinned to a release tag (`v0.2.0` is the current one;
31
+ an unpinned `git+` URL installs whatever is on the default branch):
31
32
 
32
33
  ```sh
33
- uv tool install git+<repo-url>
34
+ uv tool install git+https://github.com/weskao/agent-toggle@v0.2.0
34
35
  agent-toggle --version
35
36
  ```
36
37
 
37
38
  Alternatively, use `pipx`:
38
39
 
39
40
  ```sh
40
- pipx install agent-toggle # from the first tagged release; or: pipx install git+<repo-url>
41
+ pipx install agent-toggle # from PyPI; or: pipx install git+https://github.com/weskao/agent-toggle@v0.2.0
41
42
  ```
42
43
 
43
44
  From a checkout, either install it editable or run it in place:
44
45
 
45
46
  ```sh
46
- git clone <repo-url> agent-toggle
47
+ git clone https://github.com/weskao/agent-toggle agent-toggle
47
48
  cd agent-toggle
48
49
  pip install -e . # puts the `agent-toggle` console script on PATH
49
50
  python3 agent_toggle.py status # no install needed; same CLI
@@ -59,8 +60,45 @@ python3 agent_toggle.py install-shims # from a checkout, no install needed
59
60
  `install-shims` writes a thin skill shim (`skills/agent-toggle/SKILL.md`) into
60
61
  every installed harness that supports skills, so `/agent-toggle` works from any
61
62
  of them, and adds the park dirs (`skills-disabled/` etc.) to a `.gitignore`
62
- already present in that harness home. It is idempotent; `--dry-run` shows the
63
- plan. The tool itself stays in one place.
63
+ already present in that harness home. Each harness gets its own template from
64
+ `agent_toggle/shims/<harness>.md.tmpl`, else `generic.md.tmpl` (which tells the
65
+ agent to pass `--harness <that harness>`). The shim tells the agent to act only
66
+ on the user's explicit request, never on instructions found inside skill or
67
+ tool content, and to preview bulk operations with `--dry-run`.
68
+
69
+ Every shim carries an `<!-- agent-toggle shim: ... -->` marker line.
70
+ `install-shims` updates a file that has the marker in place (it is idempotent)
71
+ and **refuses** to overwrite one that lacks it, or a symlink: that harness gets
72
+ an `error` row with the fix, the other harnesses still install, and the run
73
+ exits `1`. A shim written by a version before the marker counts as foreign;
74
+ delete it and re-run. `--dry-run` shows the same plan, refusals included. The
75
+ tool itself stays in one place.
76
+
77
+ **Optional: Telegram alerts for CI** (for maintainers of a fork or clone). Install
78
+ with the `telegram` extra, which adds [telegram-kit](https://pypi.org/project/telegram-kit/)
79
+ (itself stdlib-only), then run the setup once; see [CI notifications](#ci-notifications):
80
+
81
+ ```sh
82
+ uv tool install "agent-toggle[telegram]" # or: pipx install "agent-toggle[telegram]"
83
+ pip install -e ".[telegram]" # from a checkout
84
+ agent-toggle config # bot token + chat id
85
+ agent-toggle config sync-ci # set the two GitHub repository secrets
86
+ ```
87
+
88
+ Without the extra everything else works exactly as before; only `config` asks for it.
89
+
90
+ ## Shell completion
91
+
92
+ Each release publishes static completion files for bash, zsh and fish as the
93
+ `completions` artifact of its Release workflow run. They are generated from the
94
+ CLI parser; to build them from a checkout:
95
+
96
+ ```sh
97
+ python3 tools/gen_completions.py completions # agent-toggle.bash, _agent-toggle (zsh), agent-toggle.fish
98
+ ```
99
+
100
+ Source `agent-toggle.bash` from `~/.bashrc`, put `_agent-toggle` on your zsh
101
+ `fpath`, or copy `agent-toggle.fish` to `~/.config/fish/completions/`.
64
102
 
65
103
  ## Usage
66
104
 
@@ -71,20 +109,23 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
71
109
  | command | what it does |
72
110
  |---|---|
73
111
  | `ui` | interactive picker — cost column, sort, filters; `--dry-run` shows the plan for what you stage and changes nothing |
74
- | `status` | health check: harnesses found, types each supports, parked counts, gitignore, untracked parked items, stale live twins, shared dirs |
112
+ | `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`) |
75
113
  | `list [type]` | what is currently disabled (`--project <dir>` filters to one project) |
76
114
  | `cost [--type T]` | estimated startup tokens per item, biggest first (read-only; `--harness H` filters) |
77
- | `install-shims` | write the skill shim into every installed harness (`--dry-run` shows the plan) |
115
+ | `install-shims` | write the skill shim into every installed harness; refuses to overwrite a file it did not write (`--dry-run` shows the plan) |
78
116
  | `disable <type> <name>...` | park one or more items (`--dry-run` shows the plan; `--project <dir>` for a repo's own `.claude/` and `.mcp.json`) |
79
117
  | `enable <type> <name>...` | put them back (`--dry-run` shows the plan; `--project <dir>` likewise) |
80
118
  | `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
81
119
  | `undo` | reverse the last logged batch (`--dry-run` shows the plan) |
82
120
  | `profile save\|apply\|diff\|list` | named sets of live items; see [Profiles](#profiles) |
121
+ | `config [test\|sync-ci]` | Telegram settings for the CI failure alerts; see [CI notifications](#ci-notifications) |
83
122
  | `doctor` | read-only check of each harness layout and of `state.json` against disk; exit `1` only on an `error` row |
84
123
  | `migrate` | import an older `~/.claude-toggle/` state |
85
124
 
86
125
  `<type>` = `skill` / `agent` / `command` / `rule` / `plugin` / `mcp`.
87
126
  `rule` is claude-only (`~/.claude/rules/*.md`, parked in `rules-disabled/`).
127
+ Disabling a rule prints a warning (also in `--json` `warnings`, dry run included)
128
+ that rules may carry safety constraints.
88
129
 
89
130
  Flags accepted by every command, before or after the subcommand:
90
131
 
@@ -198,6 +239,10 @@ instead of fighting curses for the terminal.
198
239
 
199
240
  Built on stdlib `curses`, so there is nothing to install on macOS and Linux
200
241
  (on Windows, `pip install "agent-toggle[windows]"` pulls `windows-curses`).
242
+ Without curses, `ui` falls back to a numbered menu with the same staging and the
243
+ same result: type row numbers (`1 3 5-7`) to tick or untick, `/text` to filter,
244
+ `s` / `h` / `t` to sort and cycle the harness and type filters, `a` to apply, `q`
245
+ (or end of input) to cancel.
201
246
 
202
247
  ## What each harness supports
203
248
 
@@ -213,6 +258,22 @@ Built on stdlib `curses`, so there is nothing to install on macOS and Linux
213
258
  | devin | `~/.devin` | — | — | — | — | — | — |
214
259
  | agy | `~/.antigravity` | — | — | — | — | — | — |
215
260
 
261
+ Per-harness locations, mechanisms and the harness versions this was checked
262
+ against are in [docs/harnesses.md](docs/harnesses.md).
263
+
264
+ **Homes per OS.** Every `~` above is Python's `Path.home()`: `$HOME` on macOS and
265
+ Linux, `%USERPROFILE%` on Windows (so `~/.claude` is `C:\Users\<you>\.claude`).
266
+ opencode honours an absolute `$XDG_CONFIG_HOME` on every OS and otherwise uses
267
+ `~/.config/opencode`, Windows included; the tool does not look in `%APPDATA%`. This
268
+ tool's own state lives in `~/.agent-toggle/`. The Linux and Windows locations
269
+ follow the harnesses' documentation and have not yet been checked against real
270
+ installs there; if a harness keeps its files elsewhere, `status` shows it as not
271
+ installed.
272
+
273
+ Plugin ids are passed to `claude plugin ...`, which on Windows runs through
274
+ `cmd.exe`. An id with anything outside `A-Z a-z 0-9 . _ @ : / -` (for example `&`,
275
+ `%`, `|` or a space) is refused on every OS before any command runs.
276
+
216
277
  Copilot's `config.json` is machine-managed and never edited by this tool; Copilot
217
278
  plugins are not yet supported. `claude` plugins are toggled through `claude
218
279
  plugin enable/disable`; `openclaw` plugins through a config flag (see [Assumed
@@ -431,10 +492,16 @@ Only `error` makes the exit code `1`; each problem row names the command that
431
492
  fixes it. `--harness X` for a harness that is not installed is a `note` (exit
432
493
  `0`). Companion files are not checked.
433
494
 
495
+ A harness item parked with no state entry also carries `orphan`, and its fix
496
+ follows from it: `identical` (the live copy has the same content: delete the
497
+ parked copy), `differs` (a different live copy exists: compare, keep one), or
498
+ `parked-only` (move it back, then `disable` it so the state records it).
499
+
434
500
  ## Assumed formats
435
501
 
436
- Two config shapes come from the design survey (`docs/DESIGN.md` §4 / §11) and
437
- were **not verified on a real install**:
502
+ Two config shapes came from the design survey (`docs/DESIGN.md` §4 / §11). Both
503
+ were since seen on one real install each (openclaw 2026.7.1-2, opencode 2.0.22; see
504
+ `docs/harnesses.md`), but the tool still treats them defensively:
438
505
 
439
506
  - `openclaw.json`: `skills.entries.<name>.enabled` and
440
507
  `plugins.entries.<name>.enabled`
@@ -524,13 +591,47 @@ ruff check .
524
591
  stdlib `unittest`, no fixtures, no network. Every test runs against a
525
592
  throwaway temp `HOME` and a stubbed `claude` CLI.
526
593
 
594
+ ## CI notifications
595
+
596
+ `.github/workflows/ci.yml` can send a Telegram message when the test matrix fails on a
597
+ push (a lint-only failure does not page). Alerts never fire for pull requests or green
598
+ runs. The message names the repository, branch, 7-character commit and a link to the run.
599
+
600
+ Set it up once, with the `telegram` extra installed (see [Install](#install)):
601
+
602
+ ```sh
603
+ agent-toggle config # prompts for the bot token (hidden) and the chat id
604
+ agent-toggle config test # send one test message
605
+ agent-toggle config sync-ci # set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID on the repo
606
+ ```
607
+
608
+ `config` works like aicp's `--config`: Enter keeps the current value, `-` clears it.
609
+ The bot token is kept only in the OS credential store (macOS Keychain, Linux Secret
610
+ Service, Windows DPAPI) through telegram-kit, never in a file or on a command line, and
611
+ is shown masked. With no credential store it refuses to store the token; set
612
+ `TG_BOT_TOKEN` in the environment instead (`TG_CHAT_ID` likewise for the chat id). The
613
+ chat id is ordinary configuration in `~/.agent-toggle/config.json`: a number, `-100...`
614
+ for a group, or an `@channel`. `config sync-ci` needs the GitHub CLI (`gh`) signed in; it
615
+ passes both values on stdin, and `--repo OWNER/REPO` / `--dry-run` work as elsewhere.
616
+
617
+ To skip the tool, set the secrets by hand (each command prompts for the value):
618
+
619
+ ```sh
620
+ gh secret set TELEGRAM_BOT_TOKEN
621
+ gh secret set TELEGRAM_CHAT_ID
622
+ ```
623
+
624
+ With either secret unset (a fork, or a clone you have not configured) the notify job
625
+ prints a `::notice::` and exits 0, so it never adds a second red X.
626
+
527
627
  ## Releasing
528
628
 
529
629
  A release is a git tag `vX.Y.Z` (where `X.Y.Z` must match `agent_toggle.__version__`).
530
630
  Pushing the tag triggers `.github/workflows/release.yml`:
531
631
 
532
- 1. **Build** job checks that the tag matches `__version__` and builds a wheel and
533
- source distribution.
632
+ 1. **Build** job checks that the tag matches `__version__`, builds a wheel and
633
+ source distribution, and generates the shell completion files (uploaded as the
634
+ `completions` artifact; the workflow does not create a GitHub Release).
534
635
  2. **Publish** job uploads to PyPI using
535
636
  [trusted publishing](https://docs.pypi.org/trusted-publishers/),
536
637
  with no stored tokens—only the `pypi` environment and OIDC setup.
@@ -1,3 +1,3 @@
1
1
  """agent-toggle: temporarily disable / re-enable AI-agent resources."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -12,7 +12,7 @@ to the targets -- a user's `git status` must not change because of our
12
12
  bookkeeping.
13
13
 
14
14
  Usage:
15
- agent_toggle.py ui [--dry-run] # interactive picker (curses)
15
+ agent_toggle.py ui [--dry-run] # interactive picker (curses, else numbered menu)
16
16
  agent_toggle.py cost [--type T] # startup token estimates, biggest first
17
17
  agent_toggle.py disable <type> <name>... [--harness H] [--dry-run]
18
18
  agent_toggle.py enable <type> <name>... [--harness H] [--dry-run]
@@ -26,6 +26,7 @@ Usage:
26
26
  agent_toggle.py migrate # import old ~/.claude-toggle state
27
27
  agent_toggle.py profile save|apply|diff|list [name|file] [--out F] [--dry-run]
28
28
  agent_toggle.py install-shims [--dry-run] # write the skill shim into each harness
29
+ agent_toggle.py config [test|sync-ci] # Telegram settings for CI failure alerts
29
30
 
30
31
  <type> = skill | agent | command | rule | plugin | mcp
31
32
  --json prints exactly one JSON document; exit codes: 0 ok, 1 partial
@@ -41,7 +42,7 @@ import sys
41
42
  import traceback
42
43
  from pathlib import Path
43
44
 
44
- from . import __version__, cost, doctor, fs, ops, profiles, store, undo
45
+ from . import __version__, config, cost, doctor, fs, ops, profiles, store, undo
45
46
  from .backends.plugin_cli import claude_bin
46
47
  from .fs import gitignored
47
48
  from .harnesses import TYPES, harness_of, harnesses, project_view
@@ -161,6 +162,7 @@ def cmd_status(state: dict, out: Result, only: str | None = None) -> None:
161
162
  found = list(parked.iterdir())
162
163
  else:
163
164
  found = [p for p in parked.rglob("*") if p.is_file()]
165
+ found = [p for p in found if not p.name.startswith(".")] # .DS_Store, as doctor
164
166
  items += found
165
167
  ign = ign and gitignored(
166
168
  parked, home if view.owner == hname else table[view.owner].home)
@@ -188,6 +190,14 @@ def cmd_status(state: dict, out: Result, only: str | None = None) -> None:
188
190
  for (hname, proj), n in sorted(projects.items(), key=str):
189
191
  out.say(f"project {proj} ({n} parked under {fs.parked_dir()})")
190
192
  out.row(hname, None, None, "status", "project", "", show=False, project=proj, parked=n)
193
+ # the inverse of `untracked`: a state entry whose parked item is gone (DESIGN s6)
194
+ for k, e in state["disabled"].items():
195
+ if only and only not in (e.get("harness"), *_list_of(e, "shared_with")):
196
+ continue
197
+ if gone := doctor.missing_parked(k, e):
198
+ out.say(f"WARNING {e.get('harness')} {e.get('type')} {e.get('name')}: {gone}", warn=True)
199
+ out.row(e.get("harness"), e.get("type"), e.get("name"), "status", "stale", gone,
200
+ show=False, parked_at=e.get("parked_at"))
191
201
 
192
202
 
193
203
  def parked_drift(items: list[Path], parked: Path, live: Path,
@@ -228,8 +238,8 @@ def cmd_cost(state: dict, out: Result, harness: str | None = None,
228
238
  def cmd_ui(state: dict, out: Result, dry_run: bool = False) -> None:
229
239
  try:
230
240
  from .ui import picker as ui
231
- except ImportError as e: # no curses build (rare)
232
- die(f"interactive UI unavailable: {e}")
241
+ except ImportError: # no curses (Windows without windows-curses)
242
+ from .ui import menu as ui
233
243
  changes = ui.pick(store.scope_state(state), harnesses(), plugins=not dry_run,
234
244
  color=use_color(sys.stdout, out.color))
235
245
  if changes is None:
@@ -289,13 +299,17 @@ def cmd_migrate(out: Result) -> None:
289
299
  out.row(None, None, None, "migrate", "ok", "; ".join(lines), show=False)
290
300
 
291
301
 
292
- SHIM_TEMPLATE = Path(__file__).with_name("shims") / "claude.md.tmpl"
302
+ SHIMS = Path(__file__).with_name("shims")
303
+ # Every template carries this line; install-shims only overwrites a file that has it.
304
+ SHIM_MARKER = ("<!-- agent-toggle shim: written by `agent-toggle install-shims`; "
305
+ "re-running it overwrites this file -->")
293
306
 
294
307
 
295
- def cmd_install_shims(args: argparse.Namespace, out: Result) -> None:
296
- """Write <home>/skills/agent-toggle/SKILL.md into every installed harness that
297
- supports skills; keep park dirs out of an existing harness-home .gitignore."""
298
- template = SHIM_TEMPLATE.read_text(encoding="utf-8")
308
+ def shim_text(harness: str) -> str:
309
+ """The shim for `harness`: shims/<harness>.md.tmpl, else shims/generic.md.tmpl."""
310
+ tmpl = SHIMS / f"{harness}.md.tmpl"
311
+ template = (tmpl if tmpl.is_file() else SHIMS / "generic.md.tmpl").read_text(encoding="utf-8")
312
+ template = template.replace("__HARNESS__", harness)
299
313
  root = Path(__file__).resolve().parent.parent
300
314
  if (root / "agent_toggle.py").is_file():
301
315
  # Harness dirs often sync across machines: write `~`-relative under $HOME.
@@ -303,16 +317,38 @@ def cmd_install_shims(args: argparse.Namespace, out: Result) -> None:
303
317
  shown = "~/" + root.relative_to(fs.home()).as_posix()
304
318
  except ValueError:
305
319
  shown = str(root)
306
- text = template.replace("__AGENT_TOGGLE_ROOT__", shown)
307
- else: # installed package: no checkout to point at
308
- text = "".join(ln for ln in template.splitlines(keepends=True)
309
- if "__AGENT_TOGGLE_ROOT__" not in ln)
320
+ return template.replace("__AGENT_TOGGLE_ROOT__", shown)
321
+ # installed package: no checkout to point at
322
+ return "".join(ln for ln in template.splitlines(keepends=True)
323
+ if "__AGENT_TOGGLE_ROOT__" not in ln)
324
+
325
+
326
+ def shim_refusal(dest: Path) -> str | None:
327
+ """Why `dest` must not be overwritten (it is not a shim we wrote), else None."""
328
+ if dest.is_symlink() or (dest.exists() and not dest.is_file()):
329
+ return "is a symlink or not a regular file"
330
+ if not dest.exists():
331
+ return None
332
+ try:
333
+ text = dest.read_text(encoding="utf-8")
334
+ except (OSError, UnicodeDecodeError):
335
+ text = ""
336
+ # Shims from before the marker existed: our frontmatter name plus our heading.
337
+ legacy = text.startswith("---\nname: agent-toggle\n") and "\n# agent-toggle\n" in text
338
+ ours = SHIM_MARKER in text or legacy
339
+ return None if ours else "exists and lacks the agent-toggle shim marker"
340
+
341
+
342
+ def cmd_install_shims(args: argparse.Namespace, out: Result) -> None:
343
+ """Write <home>/skills/agent-toggle/SKILL.md into every installed harness that
344
+ supports skills; keep park dirs out of an existing harness-home .gitignore.
345
+ A file there that is not our shim (no SHIM_MARKER) is refused, never overwritten."""
310
346
  table = harnesses()
311
347
  park = sorted({f"{sub}-disabled" for h in table.values()
312
348
  for subs in h.dirs.values() for sub in subs
313
349
  if not Path(sub).is_absolute()}) # skills.paths redirects live elsewhere
314
350
  verb = "would install" if args.dry_run else "installed"
315
- installed = 0
351
+ installed = found = 0
316
352
  for hname, h in table.items():
317
353
  home = h.home
318
354
  if args.harness and hname != args.harness:
@@ -322,10 +358,17 @@ def cmd_install_shims(args: argparse.Namespace, out: Result) -> None:
322
358
  "not installed" if not home.is_dir() else "no skill support",
323
359
  show=False, home=str(home))
324
360
  continue
361
+ found += 1
325
362
  dest = home / h.dirs["skill"][0] / "agent-toggle" / "SKILL.md"
363
+ if why := shim_refusal(dest):
364
+ fix = (f"refused: {dest} {why}; fix: move it aside (or delete it if it is an "
365
+ f"older agent-toggle shim), then re-run install-shims")
366
+ out.row(hname, None, None, "install-shims", "error", fix, show=False, home=str(home))
367
+ out.say(f" {fix}", style="red")
368
+ continue
326
369
  if not args.dry_run:
327
370
  dest.parent.mkdir(parents=True, exist_ok=True)
328
- dest.write_text(text, encoding="utf-8")
371
+ dest.write_text(shim_text(hname), encoding="utf-8")
329
372
  out.row(hname, None, None, "install-shims", "planned" if args.dry_run else "ok",
330
373
  str(dest), show=False, home=str(home))
331
374
  out.say(f" {verb} {dest}")
@@ -342,13 +385,13 @@ def cmd_install_shims(args: argparse.Namespace, out: Result) -> None:
342
385
  + "".join(f"{m}\n" for m in missing))
343
386
  if missing:
344
387
  out.say(f" gitignore {ignore}: {' '.join(missing)}")
345
- if not installed:
388
+ if not found:
346
389
  die("no harness found", 4)
347
390
  out.say(f"{installed} harness(es) {'planned' if args.dry_run else 'installed'}")
348
391
 
349
392
 
350
393
  COMMANDS = ("ui", "pick", "status", "list", "cost", "migrate", "disable", "enable",
351
- "install-shims", "profile", "undo", "doctor")
394
+ "install-shims", "profile", "undo", "doctor", "config")
352
395
  _GLOBAL_FLAGS = ("--json", "-v", "--verbose")
353
396
 
354
397
 
@@ -427,6 +470,12 @@ def build_parser() -> argparse.ArgumentParser:
427
470
  pp.add_argument("--project", metavar="dir", help=PROJECT_HELP)
428
471
  sub.add_parser("doctor", parents=[common],
429
472
  help="read-only check of harness layouts and state against disk")
473
+ cf = sub.add_parser("config", parents=[common],
474
+ help="Telegram settings for the CI failure alerts (needs the telegram extra)")
475
+ cf.add_argument("action", nargs="?", choices=("test", "sync-ci"),
476
+ help="test: send one message | sync-ci: set the GitHub repo secrets")
477
+ cf.add_argument("--repo", metavar="OWNER/REPO", help="sync-ci: repository (default: this one)")
478
+ cf.add_argument("--dry-run", action="store_true", help="sync-ci: show the plan; set nothing")
430
479
  up2 = sub.add_parser("undo", parents=[common], help="reverse the last logged batch")
431
480
  up2.add_argument("--dry-run", action="store_true", help="show the plan; change nothing")
432
481
  for name, verb in (("disable", "park"), ("enable", "restore")):
@@ -471,6 +520,8 @@ def main(argv: list[str] | None = None) -> int:
471
520
  profiles.cmd_profile(args, out)
472
521
  elif cmd == "doctor":
473
522
  doctor.cmd_doctor(args.harness, out)
523
+ elif cmd == "config":
524
+ config.cmd_config(args, out)
474
525
  elif cmd == "undo":
475
526
  undo.cmd_undo(args.dry_run, args.harness, out)
476
527
  elif cmd == "enable" and args.all: