agent-toggle 0.12.1__tar.gz → 0.13.1__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.12.1 → agent_toggle-0.13.1}/PKG-INFO +76 -40
  2. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/README.md +75 -39
  3. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/__init__.py +1 -1
  4. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/backends/mcp_json.py +6 -4
  5. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/backends/plugin_cli.py +37 -6
  6. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/cli.py +18 -2
  7. agent_toggle-0.13.1/agent_toggle/completions.py +210 -0
  8. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/doctor.py +4 -2
  9. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/harnesses.py +13 -19
  10. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/help.py +11 -1
  11. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_cli.py +4 -0
  12. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/mechanisms.py +331 -108
  13. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/ops.py +5 -4
  14. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/store.py +4 -3
  15. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/ui/config_menu.py +12 -6
  16. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle.egg-info/PKG-INFO +76 -40
  17. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle.egg-info/SOURCES.txt +1 -0
  18. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_alias.py +2 -2
  19. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_backends.py +47 -5
  20. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_cli_surface.py +9 -0
  21. agent_toggle-0.13.1/tests/test_completions.py +98 -0
  22. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_config.py +11 -0
  23. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_config_menu.py +32 -7
  24. agent_toggle-0.13.1/tests/test_crash.py +1072 -0
  25. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_harnesses.py +22 -0
  26. agent_toggle-0.13.1/tests/test_mechanisms.py +293 -0
  27. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_project.py +17 -0
  28. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_secret_audit.py +62 -0
  29. agent_toggle-0.12.1/tests/test_completions.py +0 -37
  30. agent_toggle-0.12.1/tests/test_crash.py +0 -382
  31. agent_toggle-0.12.1/tests/test_mechanisms.py +0 -79
  32. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/LICENSE +0 -0
  33. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/__main__.py +0 -0
  34. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/backends/__init__.py +0 -0
  35. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/backends/flag_json.py +0 -0
  36. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/backends/mcp_toml.py +0 -0
  37. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/companions.py +0 -0
  38. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/config.py +0 -0
  39. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/cost.py +0 -0
  40. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/fs.py +0 -0
  41. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/i18n.py +0 -0
  42. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/locales/__init__.py +0 -0
  43. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_common.py +0 -0
  44. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_config.py +0 -0
  45. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_picker.py +0 -0
  46. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/output.py +0 -0
  47. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/profiles.py +0 -0
  48. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/settings.py +0 -0
  49. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/shims/claude.md.tmpl +0 -0
  50. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/shims/generic.md.tmpl +0 -0
  51. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/spinner.py +0 -0
  52. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/toml_check.py +0 -0
  53. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/ui/__init__.py +0 -0
  54. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/ui/logo.py +0 -0
  55. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/ui/menu.py +0 -0
  56. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/ui/model.py +0 -0
  57. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/ui/picker.py +0 -0
  58. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/ui/theme.py +0 -0
  59. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/undo.py +0 -0
  60. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/update_check.py +0 -0
  61. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle/update_prompt.py +0 -0
  62. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle.egg-info/dependency_links.txt +0 -0
  63. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle.egg-info/entry_points.txt +0 -0
  64. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle.egg-info/requires.txt +0 -0
  65. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/agent_toggle.egg-info/top_level.txt +0 -0
  66. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/pyproject.toml +0 -0
  67. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/setup.cfg +0 -0
  68. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_all_type.py +0 -0
  69. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_cli.py +0 -0
  70. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_color.py +0 -0
  71. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_companions.py +0 -0
  72. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_conformance.py +0 -0
  73. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_containment.py +0 -0
  74. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_cost.py +0 -0
  75. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_doctor.py +0 -0
  76. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_encoding.py +0 -0
  77. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_fs.py +0 -0
  78. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_help.py +0 -0
  79. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_i18n.py +0 -0
  80. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_install.py +0 -0
  81. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_logo.py +0 -0
  82. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_menu.py +0 -0
  83. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_mods.py +0 -0
  84. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_picker.py +0 -0
  85. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_platform.py +0 -0
  86. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_profiles.py +0 -0
  87. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_safety.py +0 -0
  88. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_same_name.py +0 -0
  89. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_settings.py +0 -0
  90. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_store.py +0 -0
  91. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_theme.py +0 -0
  92. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_undo.py +0 -0
  93. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_update_check.py +0 -0
  94. {agent_toggle-0.12.1 → agent_toggle-0.13.1}/tests/test_update_prompt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-toggle
3
- Version: 0.12.1
3
+ Version: 0.13.1
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
@@ -59,16 +59,17 @@ language, update check and Telegram notifications. It also runs tools such as `d
59
59
 
60
60
  ![agent-toggle config: the settings menu](https://raw.githubusercontent.com/weskao/agent-toggle/main/docs/images/config-menu.jpeg)
61
61
 
62
- **`/agent-toggle` inside Claude Code**: the same picker as a pane, for turning skills, MCP
63
- servers, plugins and mods on or off without leaving the session ([Claude Code pane mod](#claude-code-pane-mod)).
62
+ **`/agent-toggle` inside Claude Code**: the same picker as a pane. Search, then turn skills, MCP
63
+ servers, plugins and mods on or off without leaving the session; each toggle applies at once and
64
+ queues the matching reload ([Claude Code pane mod](#claude-code-pane-mod)).
64
65
 
65
- ![/agent-toggle: the picker as a Claude Code pane](https://raw.githubusercontent.com/weskao/agent-toggle/main/docs/images/claude-pane-mod.jpeg)
66
+ ![/agent-toggle: searching and toggling resources in the Claude Code pane](https://raw.githubusercontent.com/weskao/agent-toggle/main/docs/images/claude-mods-demo.gif)
66
67
 
67
68
  ## Why
68
69
 
69
70
  | target | native mechanism | the gap |
70
71
  |---|---|---|
71
- | plugin | `claude plugin disable/enable` | fine — this tool just batches it |
72
+ | plugin | `claude plugin disable/enable` | without `-s` the scope follows the current dir, so a project plugin enabled from elsewhere stays off; this tool pins the install's scope and project |
72
73
  | skill | none | moving files by hand hits the rename trap below |
73
74
  | agent | none | same |
74
75
  | command | none | same, plus nested `group/name.md` paths get flattened |
@@ -146,17 +147,12 @@ tool itself stays in one place.
146
147
  **Optional: Telegram alerts for CI** (for maintainers of a fork or clone). The
147
148
  Telegram support comes from [telegram-kit](https://pypi.org/project/telegram-kit/)
148
149
  (itself stdlib-only), which every install includes, so a reinstall or upgrade keeps
149
- your settings. Run the setup once; see [CI notifications](#ci-notifications):
150
-
151
- ```sh
152
- agent-toggle config # settings menu: bot token + chat id live under Notifications
153
- agent-toggle config sync-ci # set the two GitHub repository secrets
154
- ```
150
+ your settings. The settings menu hides its Telegram rows for now; set up the alerts as in
151
+ [CI notifications](#ci-notifications).
155
152
 
156
153
  The old `agent-toggle[telegram]` extra still resolves and is now a no-op. If
157
154
  telegram-kit is missing from a broken install, `config test` and `config sync-ci`
158
- exit `4`, bare `config` still opens, and the Telegram rows read
159
- `telegram-kit missing: reinstall agent-toggle`.
155
+ exit `4`.
160
156
 
161
157
  **Optional: the Claude Code pane mod** (the picker as a pane inside Claude Code, claude harness
162
158
  only). It is not part of the PyPI package; with `agent-toggle` already on `PATH` from one of the
@@ -172,16 +168,27 @@ See [Claude Code pane mod](#claude-code-pane-mod).
172
168
 
173
169
  ## Shell completion
174
170
 
175
- Each release publishes static completion files for bash, zsh and fish as the
176
- `completions` artifact of its Release workflow run. They are generated from the
177
- CLI parser; to build them from a checkout:
171
+ `agent-toggle completion <shell>` prints a static completion script, built from the CLI
172
+ parser, for bash, zsh, fish or PowerShell. Save it once where your shell loads it, and
173
+ run the same line again after an upgrade so new commands complete:
178
174
 
179
175
  ```sh
180
- python3 tools/gen_completions.py completions # agent-toggle.bash, _agent-toggle (zsh), agent-toggle.fish
176
+ # zsh: needs fpath=(~/.zfunc $fpath) before compinit in ~/.zshrc
177
+ mkdir -p ~/.zfunc && agent-toggle completion zsh > ~/.zfunc/_agent-toggle
178
+ # bash: loaded on demand by the bash-completion package
179
+ mkdir -p ~/.local/share/bash-completion/completions && agent-toggle completion bash > ~/.local/share/bash-completion/completions/agent-toggle
180
+ # fish
181
+ agent-toggle completion fish > ~/.config/fish/completions/agent-toggle.fish
181
182
  ```
182
183
 
183
- Source `agent-toggle.bash` from `~/.bashrc`, put `_agent-toggle` on your zsh
184
- `fpath`, or copy `agent-toggle.fish` to `~/.config/fish/completions/`.
184
+ ```powershell
185
+ # PowerShell: save the script, then load it from your profile
186
+ agent-toggle completion powershell > "$HOME\agent-toggle.ps1"; Add-Content $PROFILE '. "$HOME\agent-toggle.ps1"'
187
+ ```
188
+
189
+ Without the bash-completion package, add `source <file>` to `~/.bashrc` instead.
190
+ `python3 tools/gen_completions.py completions` writes all four files from a checkout; the
191
+ Release workflow uploads them as its `completions` artifact.
185
192
 
186
193
  ## Usage
187
194
 
@@ -258,7 +265,9 @@ unshared).
258
265
  `--dry-run` (`disable` / `enable` / `enable --all` / `undo` / `profile apply` /
259
266
  `install-shims` / `ui`) computes the plan --
260
267
  moves, companions, backups, MCP edits, warnings -- and writes nothing: no state,
261
- log, lock or backup, and no chmod; it never shells out to `claude`. `disable` /
268
+ log, lock or backup, and no chmod; it never shells out to `claude`, except one
269
+ read-only `claude plugin list --json` (up to 30 s) when a killed run left a plugin
270
+ op pending and has to be settled. `disable` /
262
271
  `enable` result rows have action `would-disable` / `would-enable`; every plan
263
272
  row has status `planned`. Read-only commands (`list`, `status`, `cost`) also
264
273
  change nothing on disk -- `status` warns about a `state.json` or
@@ -391,8 +400,6 @@ a band above the prompt, buttons) and change Claude Code's behaviour. A plugin w
391
400
  `hooks.json`, or with only ordinary hooks (shell commands such as `PreToolUse`), is a plain
392
401
  plugin: it ships skills, commands, agents, MCP servers or hooks and draws nothing.
393
402
 
394
- ![Claude Mods: toggling mods from the picker](https://raw.githubusercontent.com/weskao/agent-toggle/main/docs/images/claude-mods-demo.gif)
395
-
396
403
  - The check reads the install path `claude plugin list` reports and every cached version
397
404
  under `~/.claude/plugins/cache/<marketplace>/<name>/`, so a parked mod is still a mod.
398
405
  To list the installed mods by hand:
@@ -428,8 +435,7 @@ end of input, `q` or an empty line exits `0`
428
435
  | General | Check for updates, Color, Logo, Language, Default harness |
429
436
  | Harnesses | one On/Off per harness, with `found ~/.x` or `not on this machine` |
430
437
  | Picker | default sort, harness filter, type filter |
431
- | Notifications | Telegram bot token (masked, last 4 characters shown), Telegram chat ID |
432
- | 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 |
438
+ | Tools | Health check (`doctor`; then asks y/n per fix it can run), Install shims, Undo last change (asks y/n); their output shows in an overlay |
433
439
 
434
440
  Keys: `↑` `↓` move (wraps, skips headings), `←` `→` cycle a value, `Enter` / `Space` change or run
435
441
  the row, `r` reset the row, `R` reset everything (asks y/n), `q` / `Esc` / `Ctrl-C` quit. In an
@@ -528,7 +534,9 @@ logo, centred, when the window is tall enough to fit the whole menu beside it; t
528
534
  | agy | `~/.gemini/config` | ✓ | — | — | — | — | ✓ `mcp_config.json` |
529
535
 
530
536
  Per-harness locations, mechanisms and the harness versions this was checked
531
- against are in [docs/harnesses.md](docs/harnesses.md).
537
+ against are in [docs/harnesses.md](docs/harnesses.md). What `disable` and `enable`
538
+ change on disk for each type, with example live, parked and backup paths, is in
539
+ [docs/toggle-paths.md](docs/toggle-paths.md).
532
540
 
533
541
  **Homes per OS.** Every `~` above is Python's `Path.home()`: `$HOME` on macOS and
534
542
  Linux, `%USERPROFILE%` on Windows (so `~/.claude` is `C:\Users\<you>\.claude`).
@@ -545,8 +553,13 @@ Plugin ids are passed to `claude plugin ...`, which on Windows runs through
545
553
 
546
554
  Copilot's `config.json` is machine-managed and never edited by this tool; Copilot
547
555
  plugins are not yet supported. `claude` plugins are toggled through `claude
548
- plugin enable/disable`; `openclaw` plugins through a config flag (see [Assumed
549
- formats](#assumed-formats)). Codex has no plugin CLI this tool can drive: `disable
556
+ plugin enable/disable -s <scope>`. The scope comes from the plugin's one install in
557
+ `~/.claude/plugins/installed_plugins.json`, and a project or local install runs in its
558
+ project dir, so a disable and an enable from different directories still hit the same
559
+ setting. With several installs of one id, the CLI picks the scope from the current dir;
560
+ `enable` then runs in the dir the disable ran in. Details are in
561
+ [docs/toggle-paths.md](docs/toggle-paths.md#plugins). `openclaw` plugins are toggled
562
+ through a config flag (see [Assumed formats](#assumed-formats)). Codex has no plugin CLI this tool can drive: `disable
550
563
  plugin x --harness codex` exits `4`, which is why the matrix leaves it unchecked.
551
564
 
552
565
  Flag items (openclaw plugins and flagged skills, opencode mcp) are toggled with
@@ -568,7 +581,7 @@ rather than guessing.
568
581
 
569
582
  OpenCode may read skills from another harness's directory through
570
583
  `opencode.json` → `skills.paths` (absolute, `~`, `$HOME/...`
571
- and `${HOME}/...` entries; read from `opencode.json`, else `opencode.jsonc`;
584
+ and `${HOME}/...` entries; read from `opencode.json` and `opencode.jsonc` (OpenCode loads both and merges them);
572
585
  relative entries are skipped, since OpenCode resolves them against the session
573
586
  directory). OpenCode also always scans `~/.claude/skills` and `~/.agents/skills`,
574
587
  so claude's skills count as shared with it. Such a directory is **one** item, filed under
@@ -576,6 +589,11 @@ its owner (the harness whose home really holds it): it is
576
589
  parked once, tracked once, and every row reports `shared_with`, the other
577
590
  harnesses it also affects. `status` prints `shared dir with: ...`.
578
591
 
592
+ Disabling an OpenCode skill while a same-named copy sits in another scanned dir warns
593
+ that OpenCode still loads that copy (stderr, and `warnings` in `--json`). Two copies of
594
+ one name under one owner share one record, so a second `disable` is refused with
595
+ `another copy is already parked at <path> -- enable it first`.
596
+
579
597
  If the live directory carries `.synced-from-*` markers, a sync job may
580
598
  re-create what you parked; `status` and `disable` warn about it. Park in the
581
599
  source harness instead. The warning is printed once per real directory, naming
@@ -612,6 +630,11 @@ visible rather than silent.
612
630
  MCP changes need a **new session** to take effect, except in a Claude Code session running the
613
631
  agent-toggle mod, which runs `/mcp disable|enable <name>` for you (see the mod section).
614
632
 
633
+ `/mcp disable <name>` also lists the server in that project's `disabledMcpServers` in
634
+ `~/.claude.json`. Left there, a restored server would stay off in that project, so `enable`
635
+ removes the name again: from every project for a user- or local-scope server, and only from
636
+ that repo and the dirs inside it for a `--project` `.mcp.json` server.
637
+
615
638
  ### Claude MCP scopes
616
639
 
617
640
  Both scopes stored in `~/.claude.json` are togglable, and the scope round-trips:
@@ -875,10 +898,11 @@ a warning and a `recovered` log row (which `undo` does not reverse). `status` an
875
898
  `doctor` report it, with `agent-toggle enable ...` when the item ends up
876
899
  disabled. The one case left to you is a cross-filesystem move killed between its
877
900
  two renames: both copies are complete, and the report names the `diff -r` to
878
- check and the `rm -rf` that keeps either one. MCP and plugin items are saved
879
- per item (when the next one starts), without a `pending` record (a project `.mcp.json`
880
- edit has one): a kill inside that window
881
- leaves the change unrecorded (an MCP server's backup stays in `mcp-backups/`).
901
+ check and the `rm -rf` that keeps either one. MCP servers (user-scope json,
902
+ claude-json, toml, project `.mcp.json`) and claude plugins have a `pending` record
903
+ too (a plugin is settled from a live `claude plugin list --json`); only a
904
+ `claude.ai` connector toggle is saved per item (when the next one starts), and a
905
+ kill inside that window leaves its change unrecorded.
882
906
  A killed run also leaves its `lock`; the next run's error says so.
883
907
 
884
908
  Each `log.jsonl` row is `{ts, harness, type, name, action, result, batch,
@@ -989,6 +1013,9 @@ claude --plugin-dir ./mod # then run /agent-toggle inside Claude
989
1013
  `/reload-plugins`, an MCP server `/mcp disable|enable <name>`. The toast names it, e.g.
990
1014
  `⛔ [DISABLED] skill: foo (/reload-skills)`. Agents and rules have no reload and still say
991
1015
  `takes effect in a new session`. If Claude refuses a queued command, a toast says so.
1016
+ `/reload-skills` and `/reload-plugins` only re-read what is on disk. `/mcp disable` also
1017
+ leaves the per-project mark that `enable` clears (see
1018
+ [MCP backups are verbatim](#mcp-backups-are-verbatim)).
992
1019
  - `agent-toggle ui` in another terminal applies on Enter and writes the Claude rows it changed to
993
1020
  `~/.agent-toggle/reload.json`. Every open session running the mod checks that file's mtime every
994
1021
  5 s (one `stat`) and queues the same reloads, toasting `agent-toggle ui: /reload-skills`. A file
@@ -1029,21 +1056,24 @@ it on macOS, Linux and Windows.
1029
1056
 
1030
1057
  ## CI notifications
1031
1058
 
1032
- `.github/workflows/ci.yml` can send a Telegram message when the test matrix fails on a
1033
- push (a lint-only failure does not page). Alerts never fire for pull requests or green
1034
- runs. The message names the repository, branch, 7-character commit and a link to the run.
1059
+ CI and the Release workflow can send a Telegram message when they fail: `ci.yml` when the
1060
+ test matrix fails on a push (a lint-only failure does not page), `release.yml` when the
1061
+ build, PyPI publish or post-publish smoke test fails. Alerts never fire for pull requests or
1062
+ green runs. Both call `.github/workflows/notify-telegram.yml`, and the message names the
1063
+ failure, repository, tag or branch, 7-character commit and a link to the run.
1035
1064
 
1036
- Set it up once, with the `telegram` extra installed (see [Install](#install)):
1065
+ The [settings menu](#settings--config-menu) hides its Telegram rows (bot token, chat ID,
1066
+ Send test message, Sync CI secrets) for now; `SHOW_TELEGRAM` in `agent_toggle/ui/config_menu.py`
1067
+ brings them back. `config test` and `config sync-ci` still work: they read a bot token and
1068
+ chat ID saved earlier, else `TG_BOT_TOKEN` / `TG_CHAT_ID` from the environment. Without
1069
+ either, set the secrets by hand (below).
1037
1070
 
1038
1071
  ```sh
1039
- agent-toggle config # settings menu: Notifications has the bot token (hidden) and chat id
1040
1072
  agent-toggle config test # send one test message
1041
1073
  agent-toggle config sync-ci # set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID on the repo
1042
1074
  ```
1043
1075
 
1044
- In the [settings menu](#settings--config-menu) an inline field commits on `Enter`, cancels on
1045
- `Esc`, and clears on `-` then `Enter`; the Tools group also has Send test message and Sync CI
1046
- secrets (the same as `config test` / `config sync-ci`). The bot token
1076
+ When the rows are shown, the bot token
1047
1077
  is kept only in the OS credential store (macOS Keychain, Linux Secret Service, Windows DPAPI)
1048
1078
  through telegram-kit, never in a file or on a command line, and is shown masked. With no
1049
1079
  credential store it refuses to store the token; set `TG_BOT_TOKEN` in the environment instead
@@ -1059,6 +1089,12 @@ gh secret set TELEGRAM_BOT_TOKEN
1059
1089
  gh secret set TELEGRAM_CHAT_ID
1060
1090
  ```
1061
1091
 
1092
+ To check the repository secrets themselves, send a test message through the same workflow:
1093
+
1094
+ ```sh
1095
+ gh workflow run notify-telegram.yml # optional: -f title="..."
1096
+ ```
1097
+
1062
1098
  With either secret unset (a fork, or a clone you have not configured) the notify job
1063
1099
  prints a `::notice::` and exits 0, so it never adds a second red X.
1064
1100
 
@@ -26,16 +26,17 @@ language, update check and Telegram notifications. It also runs tools such as `d
26
26
 
27
27
  ![agent-toggle config: the settings menu](https://raw.githubusercontent.com/weskao/agent-toggle/main/docs/images/config-menu.jpeg)
28
28
 
29
- **`/agent-toggle` inside Claude Code**: the same picker as a pane, for turning skills, MCP
30
- servers, plugins and mods on or off without leaving the session ([Claude Code pane mod](#claude-code-pane-mod)).
29
+ **`/agent-toggle` inside Claude Code**: the same picker as a pane. Search, then turn skills, MCP
30
+ servers, plugins and mods on or off without leaving the session; each toggle applies at once and
31
+ queues the matching reload ([Claude Code pane mod](#claude-code-pane-mod)).
31
32
 
32
- ![/agent-toggle: the picker as a Claude Code pane](https://raw.githubusercontent.com/weskao/agent-toggle/main/docs/images/claude-pane-mod.jpeg)
33
+ ![/agent-toggle: searching and toggling resources in the Claude Code pane](https://raw.githubusercontent.com/weskao/agent-toggle/main/docs/images/claude-mods-demo.gif)
33
34
 
34
35
  ## Why
35
36
 
36
37
  | target | native mechanism | the gap |
37
38
  |---|---|---|
38
- | plugin | `claude plugin disable/enable` | fine — this tool just batches it |
39
+ | plugin | `claude plugin disable/enable` | without `-s` the scope follows the current dir, so a project plugin enabled from elsewhere stays off; this tool pins the install's scope and project |
39
40
  | skill | none | moving files by hand hits the rename trap below |
40
41
  | agent | none | same |
41
42
  | command | none | same, plus nested `group/name.md` paths get flattened |
@@ -113,17 +114,12 @@ tool itself stays in one place.
113
114
  **Optional: Telegram alerts for CI** (for maintainers of a fork or clone). The
114
115
  Telegram support comes from [telegram-kit](https://pypi.org/project/telegram-kit/)
115
116
  (itself stdlib-only), which every install includes, so a reinstall or upgrade keeps
116
- your settings. Run the setup once; see [CI notifications](#ci-notifications):
117
-
118
- ```sh
119
- agent-toggle config # settings menu: bot token + chat id live under Notifications
120
- agent-toggle config sync-ci # set the two GitHub repository secrets
121
- ```
117
+ your settings. The settings menu hides its Telegram rows for now; set up the alerts as in
118
+ [CI notifications](#ci-notifications).
122
119
 
123
120
  The old `agent-toggle[telegram]` extra still resolves and is now a no-op. If
124
121
  telegram-kit is missing from a broken install, `config test` and `config sync-ci`
125
- exit `4`, bare `config` still opens, and the Telegram rows read
126
- `telegram-kit missing: reinstall agent-toggle`.
122
+ exit `4`.
127
123
 
128
124
  **Optional: the Claude Code pane mod** (the picker as a pane inside Claude Code, claude harness
129
125
  only). It is not part of the PyPI package; with `agent-toggle` already on `PATH` from one of the
@@ -139,16 +135,27 @@ See [Claude Code pane mod](#claude-code-pane-mod).
139
135
 
140
136
  ## Shell completion
141
137
 
142
- Each release publishes static completion files for bash, zsh and fish as the
143
- `completions` artifact of its Release workflow run. They are generated from the
144
- CLI parser; to build them from a checkout:
138
+ `agent-toggle completion <shell>` prints a static completion script, built from the CLI
139
+ parser, for bash, zsh, fish or PowerShell. Save it once where your shell loads it, and
140
+ run the same line again after an upgrade so new commands complete:
145
141
 
146
142
  ```sh
147
- python3 tools/gen_completions.py completions # agent-toggle.bash, _agent-toggle (zsh), agent-toggle.fish
143
+ # zsh: needs fpath=(~/.zfunc $fpath) before compinit in ~/.zshrc
144
+ mkdir -p ~/.zfunc && agent-toggle completion zsh > ~/.zfunc/_agent-toggle
145
+ # bash: loaded on demand by the bash-completion package
146
+ mkdir -p ~/.local/share/bash-completion/completions && agent-toggle completion bash > ~/.local/share/bash-completion/completions/agent-toggle
147
+ # fish
148
+ agent-toggle completion fish > ~/.config/fish/completions/agent-toggle.fish
148
149
  ```
149
150
 
150
- Source `agent-toggle.bash` from `~/.bashrc`, put `_agent-toggle` on your zsh
151
- `fpath`, or copy `agent-toggle.fish` to `~/.config/fish/completions/`.
151
+ ```powershell
152
+ # PowerShell: save the script, then load it from your profile
153
+ agent-toggle completion powershell > "$HOME\agent-toggle.ps1"; Add-Content $PROFILE '. "$HOME\agent-toggle.ps1"'
154
+ ```
155
+
156
+ Without the bash-completion package, add `source <file>` to `~/.bashrc` instead.
157
+ `python3 tools/gen_completions.py completions` writes all four files from a checkout; the
158
+ Release workflow uploads them as its `completions` artifact.
152
159
 
153
160
  ## Usage
154
161
 
@@ -225,7 +232,9 @@ unshared).
225
232
  `--dry-run` (`disable` / `enable` / `enable --all` / `undo` / `profile apply` /
226
233
  `install-shims` / `ui`) computes the plan --
227
234
  moves, companions, backups, MCP edits, warnings -- and writes nothing: no state,
228
- log, lock or backup, and no chmod; it never shells out to `claude`. `disable` /
235
+ log, lock or backup, and no chmod; it never shells out to `claude`, except one
236
+ read-only `claude plugin list --json` (up to 30 s) when a killed run left a plugin
237
+ op pending and has to be settled. `disable` /
229
238
  `enable` result rows have action `would-disable` / `would-enable`; every plan
230
239
  row has status `planned`. Read-only commands (`list`, `status`, `cost`) also
231
240
  change nothing on disk -- `status` warns about a `state.json` or
@@ -358,8 +367,6 @@ a band above the prompt, buttons) and change Claude Code's behaviour. A plugin w
358
367
  `hooks.json`, or with only ordinary hooks (shell commands such as `PreToolUse`), is a plain
359
368
  plugin: it ships skills, commands, agents, MCP servers or hooks and draws nothing.
360
369
 
361
- ![Claude Mods: toggling mods from the picker](https://raw.githubusercontent.com/weskao/agent-toggle/main/docs/images/claude-mods-demo.gif)
362
-
363
370
  - The check reads the install path `claude plugin list` reports and every cached version
364
371
  under `~/.claude/plugins/cache/<marketplace>/<name>/`, so a parked mod is still a mod.
365
372
  To list the installed mods by hand:
@@ -395,8 +402,7 @@ end of input, `q` or an empty line exits `0`
395
402
  | General | Check for updates, Color, Logo, Language, Default harness |
396
403
  | Harnesses | one On/Off per harness, with `found ~/.x` or `not on this machine` |
397
404
  | Picker | default sort, harness filter, type filter |
398
- | Notifications | Telegram bot token (masked, last 4 characters shown), Telegram chat ID |
399
- | 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 |
405
+ | Tools | Health check (`doctor`; then asks y/n per fix it can run), Install shims, Undo last change (asks y/n); their output shows in an overlay |
400
406
 
401
407
  Keys: `↑` `↓` move (wraps, skips headings), `←` `→` cycle a value, `Enter` / `Space` change or run
402
408
  the row, `r` reset the row, `R` reset everything (asks y/n), `q` / `Esc` / `Ctrl-C` quit. In an
@@ -495,7 +501,9 @@ logo, centred, when the window is tall enough to fit the whole menu beside it; t
495
501
  | agy | `~/.gemini/config` | ✓ | — | — | — | — | ✓ `mcp_config.json` |
496
502
 
497
503
  Per-harness locations, mechanisms and the harness versions this was checked
498
- against are in [docs/harnesses.md](docs/harnesses.md).
504
+ against are in [docs/harnesses.md](docs/harnesses.md). What `disable` and `enable`
505
+ change on disk for each type, with example live, parked and backup paths, is in
506
+ [docs/toggle-paths.md](docs/toggle-paths.md).
499
507
 
500
508
  **Homes per OS.** Every `~` above is Python's `Path.home()`: `$HOME` on macOS and
501
509
  Linux, `%USERPROFILE%` on Windows (so `~/.claude` is `C:\Users\<you>\.claude`).
@@ -512,8 +520,13 @@ Plugin ids are passed to `claude plugin ...`, which on Windows runs through
512
520
 
513
521
  Copilot's `config.json` is machine-managed and never edited by this tool; Copilot
514
522
  plugins are not yet supported. `claude` plugins are toggled through `claude
515
- plugin enable/disable`; `openclaw` plugins through a config flag (see [Assumed
516
- formats](#assumed-formats)). Codex has no plugin CLI this tool can drive: `disable
523
+ plugin enable/disable -s <scope>`. The scope comes from the plugin's one install in
524
+ `~/.claude/plugins/installed_plugins.json`, and a project or local install runs in its
525
+ project dir, so a disable and an enable from different directories still hit the same
526
+ setting. With several installs of one id, the CLI picks the scope from the current dir;
527
+ `enable` then runs in the dir the disable ran in. Details are in
528
+ [docs/toggle-paths.md](docs/toggle-paths.md#plugins). `openclaw` plugins are toggled
529
+ through a config flag (see [Assumed formats](#assumed-formats)). Codex has no plugin CLI this tool can drive: `disable
517
530
  plugin x --harness codex` exits `4`, which is why the matrix leaves it unchecked.
518
531
 
519
532
  Flag items (openclaw plugins and flagged skills, opencode mcp) are toggled with
@@ -535,7 +548,7 @@ rather than guessing.
535
548
 
536
549
  OpenCode may read skills from another harness's directory through
537
550
  `opencode.json` → `skills.paths` (absolute, `~`, `$HOME/...`
538
- and `${HOME}/...` entries; read from `opencode.json`, else `opencode.jsonc`;
551
+ and `${HOME}/...` entries; read from `opencode.json` and `opencode.jsonc` (OpenCode loads both and merges them);
539
552
  relative entries are skipped, since OpenCode resolves them against the session
540
553
  directory). OpenCode also always scans `~/.claude/skills` and `~/.agents/skills`,
541
554
  so claude's skills count as shared with it. Such a directory is **one** item, filed under
@@ -543,6 +556,11 @@ its owner (the harness whose home really holds it): it is
543
556
  parked once, tracked once, and every row reports `shared_with`, the other
544
557
  harnesses it also affects. `status` prints `shared dir with: ...`.
545
558
 
559
+ Disabling an OpenCode skill while a same-named copy sits in another scanned dir warns
560
+ that OpenCode still loads that copy (stderr, and `warnings` in `--json`). Two copies of
561
+ one name under one owner share one record, so a second `disable` is refused with
562
+ `another copy is already parked at <path> -- enable it first`.
563
+
546
564
  If the live directory carries `.synced-from-*` markers, a sync job may
547
565
  re-create what you parked; `status` and `disable` warn about it. Park in the
548
566
  source harness instead. The warning is printed once per real directory, naming
@@ -579,6 +597,11 @@ visible rather than silent.
579
597
  MCP changes need a **new session** to take effect, except in a Claude Code session running the
580
598
  agent-toggle mod, which runs `/mcp disable|enable <name>` for you (see the mod section).
581
599
 
600
+ `/mcp disable <name>` also lists the server in that project's `disabledMcpServers` in
601
+ `~/.claude.json`. Left there, a restored server would stay off in that project, so `enable`
602
+ removes the name again: from every project for a user- or local-scope server, and only from
603
+ that repo and the dirs inside it for a `--project` `.mcp.json` server.
604
+
582
605
  ### Claude MCP scopes
583
606
 
584
607
  Both scopes stored in `~/.claude.json` are togglable, and the scope round-trips:
@@ -842,10 +865,11 @@ a warning and a `recovered` log row (which `undo` does not reverse). `status` an
842
865
  `doctor` report it, with `agent-toggle enable ...` when the item ends up
843
866
  disabled. The one case left to you is a cross-filesystem move killed between its
844
867
  two renames: both copies are complete, and the report names the `diff -r` to
845
- check and the `rm -rf` that keeps either one. MCP and plugin items are saved
846
- per item (when the next one starts), without a `pending` record (a project `.mcp.json`
847
- edit has one): a kill inside that window
848
- leaves the change unrecorded (an MCP server's backup stays in `mcp-backups/`).
868
+ check and the `rm -rf` that keeps either one. MCP servers (user-scope json,
869
+ claude-json, toml, project `.mcp.json`) and claude plugins have a `pending` record
870
+ too (a plugin is settled from a live `claude plugin list --json`); only a
871
+ `claude.ai` connector toggle is saved per item (when the next one starts), and a
872
+ kill inside that window leaves its change unrecorded.
849
873
  A killed run also leaves its `lock`; the next run's error says so.
850
874
 
851
875
  Each `log.jsonl` row is `{ts, harness, type, name, action, result, batch,
@@ -956,6 +980,9 @@ claude --plugin-dir ./mod # then run /agent-toggle inside Claude
956
980
  `/reload-plugins`, an MCP server `/mcp disable|enable <name>`. The toast names it, e.g.
957
981
  `⛔ [DISABLED] skill: foo (/reload-skills)`. Agents and rules have no reload and still say
958
982
  `takes effect in a new session`. If Claude refuses a queued command, a toast says so.
983
+ `/reload-skills` and `/reload-plugins` only re-read what is on disk. `/mcp disable` also
984
+ leaves the per-project mark that `enable` clears (see
985
+ [MCP backups are verbatim](#mcp-backups-are-verbatim)).
959
986
  - `agent-toggle ui` in another terminal applies on Enter and writes the Claude rows it changed to
960
987
  `~/.agent-toggle/reload.json`. Every open session running the mod checks that file's mtime every
961
988
  5 s (one `stat`) and queues the same reloads, toasting `agent-toggle ui: /reload-skills`. A file
@@ -996,21 +1023,24 @@ it on macOS, Linux and Windows.
996
1023
 
997
1024
  ## CI notifications
998
1025
 
999
- `.github/workflows/ci.yml` can send a Telegram message when the test matrix fails on a
1000
- push (a lint-only failure does not page). Alerts never fire for pull requests or green
1001
- runs. The message names the repository, branch, 7-character commit and a link to the run.
1026
+ CI and the Release workflow can send a Telegram message when they fail: `ci.yml` when the
1027
+ test matrix fails on a push (a lint-only failure does not page), `release.yml` when the
1028
+ build, PyPI publish or post-publish smoke test fails. Alerts never fire for pull requests or
1029
+ green runs. Both call `.github/workflows/notify-telegram.yml`, and the message names the
1030
+ failure, repository, tag or branch, 7-character commit and a link to the run.
1002
1031
 
1003
- Set it up once, with the `telegram` extra installed (see [Install](#install)):
1032
+ The [settings menu](#settings--config-menu) hides its Telegram rows (bot token, chat ID,
1033
+ Send test message, Sync CI secrets) for now; `SHOW_TELEGRAM` in `agent_toggle/ui/config_menu.py`
1034
+ brings them back. `config test` and `config sync-ci` still work: they read a bot token and
1035
+ chat ID saved earlier, else `TG_BOT_TOKEN` / `TG_CHAT_ID` from the environment. Without
1036
+ either, set the secrets by hand (below).
1004
1037
 
1005
1038
  ```sh
1006
- agent-toggle config # settings menu: Notifications has the bot token (hidden) and chat id
1007
1039
  agent-toggle config test # send one test message
1008
1040
  agent-toggle config sync-ci # set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID on the repo
1009
1041
  ```
1010
1042
 
1011
- In the [settings menu](#settings--config-menu) an inline field commits on `Enter`, cancels on
1012
- `Esc`, and clears on `-` then `Enter`; the Tools group also has Send test message and Sync CI
1013
- secrets (the same as `config test` / `config sync-ci`). The bot token
1043
+ When the rows are shown, the bot token
1014
1044
  is kept only in the OS credential store (macOS Keychain, Linux Secret Service, Windows DPAPI)
1015
1045
  through telegram-kit, never in a file or on a command line, and is shown masked. With no
1016
1046
  credential store it refuses to store the token; set `TG_BOT_TOKEN` in the environment instead
@@ -1026,6 +1056,12 @@ gh secret set TELEGRAM_BOT_TOKEN
1026
1056
  gh secret set TELEGRAM_CHAT_ID
1027
1057
  ```
1028
1058
 
1059
+ To check the repository secrets themselves, send a test message through the same workflow:
1060
+
1061
+ ```sh
1062
+ gh workflow run notify-telegram.yml # optional: -f title="..."
1063
+ ```
1064
+
1029
1065
  With either secret unset (a fork, or a clone you have not configured) the notify job
1030
1066
  prints a `::notice::` and exits 0, so it never adds a second red X.
1031
1067
 
@@ -1,3 +1,3 @@
1
1
  """agent-toggle: temporarily disable / re-enable AI-agent resources."""
2
2
 
3
- __version__ = "0.12.1"
3
+ __version__ = "0.13.1"
@@ -58,11 +58,12 @@ def claudeai_connector_names() -> set[str]:
58
58
  return set(cfg.get("claudeAiMcpEverConnected", []) or [])
59
59
 
60
60
 
61
- def toggle_claudeai_connector(action: str, name: str,
62
- dry_run: bool = False) -> tuple[bool, list | str]:
61
+ def toggle_claudeai_connector(action: str, name: str, dry_run: bool = False,
62
+ under: Path | None = None) -> tuple[bool, list | str]:
63
63
  """Fan a claude.ai connector on/off across every EXISTING project entry
64
64
  in ~/.claude.json's disabledMcpServers list -- the only place /mcp writes
65
- that state; no `claude mcp` CLI verb reaches it.
65
+ that state; no `claude mcp` CLI verb reaches it. `under`: only the project
66
+ entries that are that dir or inside it.
66
67
 
67
68
  ponytail: covers projects that exist now; a project dir opened for the
68
69
  first time later starts without the entry until this is re-run for it.
@@ -75,7 +76,8 @@ def toggle_claudeai_connector(action: str, name: str,
75
76
 
76
77
  touched = []
77
78
  for proj, pdata in cfg.get("projects", {}).items():
78
- if not isinstance(pdata, dict):
79
+ if not isinstance(pdata, dict) or (
80
+ under is not None and under != Path(proj) and under not in Path(proj).parents):
79
81
  continue
80
82
  cur = pdata.get("disabledMcpServers") or []
81
83
  has = name in cur
@@ -10,6 +10,7 @@ import os
10
10
  import shutil
11
11
  import subprocess
12
12
  import sys
13
+ from pathlib import Path
13
14
  from typing import Callable
14
15
 
15
16
  from .. import fs
@@ -17,6 +18,8 @@ from .. import fs
17
18
  which = shutil.which
18
19
  runner = subprocess.run
19
20
 
21
+ PLUGIN_SCOPES = ("user", "project", "local") # `claude plugin enable|disable -s`
22
+
20
23
  TIMEOUT_ENV = "AGENT_TOGGLE_CLI_TIMEOUT"
21
24
  MUTATE_TIMEOUT = 120 # enable / disable / install / mcp add|remove: may fetch from a marketplace
22
25
  READ_TIMEOUT = 30 # read-only `plugin list`
@@ -59,8 +62,8 @@ def run_cli(binary: str | None, args: list[str], cwd: str | None = None,
59
62
  return False, "CLI not found on PATH"
60
63
  limit = _timeout(timeout)
61
64
  try:
62
- p = runner([binary, *args], capture_output=True, text=True,
63
- timeout=limit, cwd=cwd)
65
+ p = runner([binary, *args], capture_output=True, text=True, encoding="utf-8",
66
+ errors="replace", timeout=limit, cwd=cwd) # Windows: not cp1252
64
67
  except subprocess.TimeoutExpired:
65
68
  return False, f"CLI timed out after {limit:g}s (raise it with {TIMEOUT_ENV}=<seconds>)"
66
69
  except OSError as e: # cwd gone, binary unexecutable
@@ -68,18 +71,46 @@ def run_cli(binary: str | None, args: list[str], cwd: str | None = None,
68
71
  return p.returncode == 0, (p.stdout + p.stderr).strip()
69
72
 
70
73
 
71
- def list_plugins(warn: Callable[[str], None]) -> list[dict]:
72
- """`claude plugin list --json` through the injectable runner; [] when unavailable."""
74
+ def plugin_where(name: str, claude_home: Path) -> tuple[str | None, str | None]:
75
+ """(scope, cwd) of plugin `name`'s one install, from Claude Code's install registry
76
+ (`<claude_home>/plugins/installed_plugins.json`; a bare name: its one `name@mkt` id).
77
+
78
+ Without `-s` the CLI picks the scope from its cwd, so a project-scope plugin toggled
79
+ from another dir lands in user scope. (None, None) when the registry cannot say --
80
+ absent, another shape, several installs, a project dir that is gone: the caller
81
+ then lets the CLI auto-detect, as before. A file read, not `claude plugin list`
82
+ (~0.8 s per call, paid on every pane toggle)."""
83
+ try:
84
+ reg = json.loads((claude_home / "plugins" / "installed_plugins.json")
85
+ .read_text(encoding="utf-8"))
86
+ rows = [r for k, v in reg["plugins"].items()
87
+ if k == name or ("@" not in name and k.partition("@")[0] == name) for r in v]
88
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
89
+ return None, None
90
+ if len(rows) != 1 or not isinstance(rows[0], dict) or rows[0].get("scope") not in PLUGIN_SCOPES:
91
+ return None, None
92
+ scope, proj = rows[0]["scope"], rows[0].get("projectPath")
93
+ if scope == "user":
94
+ return scope, None
95
+ if isinstance(proj, str) and os.path.isabs(proj) and os.path.isdir(proj):
96
+ return scope, proj
97
+ return None, None
98
+
99
+
100
+ def list_plugins(warn: Callable[[str], None], cwd: str | None = None) -> list[dict]:
101
+ """`claude plugin list --json` through the injectable runner; [] when unavailable.
102
+ `cwd`: its `enabled` is the plugin's state as seen from there."""
73
103
  exe = claude_bin()
74
104
  if not exe:
75
105
  return []
76
- ok, text = run_cli(exe, ["plugin", "list", "--json"], timeout=READ_TIMEOUT)
106
+ ok, text = run_cli(exe, ["plugin", "list", "--json"], cwd=cwd, timeout=READ_TIMEOUT)
77
107
  try: # run_cli merges stderr into the text: skip any notice before the array
78
108
  data = json.JSONDecoder().raw_decode(text, max(text.find("["), 0))[0] if ok else None
79
109
  except ValueError:
80
110
  data = None
81
111
  if not isinstance(data, list):
82
- warn("could not read `claude plugin list --json`; plugin rows come from state only")
112
+ why = f" ({text.splitlines()[-1][:120]})" if not ok and text else "" # e.g. a timeout
113
+ warn(f"could not read `claude plugin list --json`{why}; plugin rows come from state only")
83
114
  return []
84
115
  return [p for p in data if isinstance(p, dict) and isinstance(p.get("id"), str)]
85
116