agent-toggle 0.13.0__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.
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/PKG-INFO +20 -5
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/README.md +19 -4
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/__init__.py +1 -1
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/backends/mcp_json.py +6 -4
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/backends/plugin_cli.py +33 -3
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/mechanisms.py +58 -11
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/PKG-INFO +20 -5
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_backends.py +24 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_crash.py +14 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_mechanisms.py +94 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_project.py +17 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/LICENSE +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/__main__.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/backends/__init__.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/backends/flag_json.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/backends/mcp_toml.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/cli.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/companions.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/completions.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/config.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/cost.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/doctor.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/fs.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/harnesses.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/help.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/i18n.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/locales/__init__.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_cli.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_common.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_config.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_picker.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ops.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/output.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/profiles.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/settings.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/shims/claude.md.tmpl +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/shims/generic.md.tmpl +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/spinner.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/store.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/toml_check.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/__init__.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/config_menu.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/logo.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/menu.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/model.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/picker.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/theme.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/undo.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/update_check.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/update_prompt.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/SOURCES.txt +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/dependency_links.txt +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/entry_points.txt +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/requires.txt +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/top_level.txt +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/pyproject.toml +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/setup.cfg +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_alias.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_all_type.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_cli.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_cli_surface.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_color.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_companions.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_completions.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_config.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_config_menu.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_conformance.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_containment.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_cost.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_doctor.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_encoding.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_fs.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_harnesses.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_help.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_i18n.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_install.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_logo.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_menu.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_mods.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_picker.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_platform.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_profiles.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_safety.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_same_name.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_secret_audit.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_settings.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_store.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_theme.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_undo.py +0 -0
- {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_update_check.py +0 -0
- {agent_toggle-0.13.0 → 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.13.
|
|
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
|
|
@@ -69,7 +69,7 @@ queues the matching reload ([Claude Code pane mod](#claude-code-pane-mod)).
|
|
|
69
69
|
|
|
70
70
|
| target | native mechanism | the gap |
|
|
71
71
|
|---|---|---|
|
|
72
|
-
| plugin | `claude plugin disable/enable` |
|
|
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 |
|
|
73
73
|
| skill | none | moving files by hand hits the rename trap below |
|
|
74
74
|
| agent | none | same |
|
|
75
75
|
| command | none | same, plus nested `group/name.md` paths get flattened |
|
|
@@ -534,7 +534,9 @@ logo, centred, when the window is tall enough to fit the whole menu beside it; t
|
|
|
534
534
|
| agy | `~/.gemini/config` | ✓ | — | — | — | — | ✓ `mcp_config.json` |
|
|
535
535
|
|
|
536
536
|
Per-harness locations, mechanisms and the harness versions this was checked
|
|
537
|
-
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).
|
|
538
540
|
|
|
539
541
|
**Homes per OS.** Every `~` above is Python's `Path.home()`: `$HOME` on macOS and
|
|
540
542
|
Linux, `%USERPROFILE%` on Windows (so `~/.claude` is `C:\Users\<you>\.claude`).
|
|
@@ -551,8 +553,13 @@ Plugin ids are passed to `claude plugin ...`, which on Windows runs through
|
|
|
551
553
|
|
|
552
554
|
Copilot's `config.json` is machine-managed and never edited by this tool; Copilot
|
|
553
555
|
plugins are not yet supported. `claude` plugins are toggled through `claude
|
|
554
|
-
plugin enable/disable
|
|
555
|
-
|
|
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
|
|
556
563
|
plugin x --harness codex` exits `4`, which is why the matrix leaves it unchecked.
|
|
557
564
|
|
|
558
565
|
Flag items (openclaw plugins and flagged skills, opencode mcp) are toggled with
|
|
@@ -623,6 +630,11 @@ visible rather than silent.
|
|
|
623
630
|
MCP changes need a **new session** to take effect, except in a Claude Code session running the
|
|
624
631
|
agent-toggle mod, which runs `/mcp disable|enable <name>` for you (see the mod section).
|
|
625
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
|
+
|
|
626
638
|
### Claude MCP scopes
|
|
627
639
|
|
|
628
640
|
Both scopes stored in `~/.claude.json` are togglable, and the scope round-trips:
|
|
@@ -1001,6 +1013,9 @@ claude --plugin-dir ./mod # then run /agent-toggle inside Claude
|
|
|
1001
1013
|
`/reload-plugins`, an MCP server `/mcp disable|enable <name>`. The toast names it, e.g.
|
|
1002
1014
|
`⛔ [DISABLED] skill: foo (/reload-skills)`. Agents and rules have no reload and still say
|
|
1003
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)).
|
|
1004
1019
|
- `agent-toggle ui` in another terminal applies on Enter and writes the Claude rows it changed to
|
|
1005
1020
|
`~/.agent-toggle/reload.json`. Every open session running the mod checks that file's mtime every
|
|
1006
1021
|
5 s (one `stat`) and queues the same reloads, toasting `agent-toggle ui: /reload-skills`. A file
|
|
@@ -36,7 +36,7 @@ queues the matching reload ([Claude Code pane mod](#claude-code-pane-mod)).
|
|
|
36
36
|
|
|
37
37
|
| target | native mechanism | the gap |
|
|
38
38
|
|---|---|---|
|
|
39
|
-
| plugin | `claude plugin disable/enable` |
|
|
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 |
|
|
40
40
|
| skill | none | moving files by hand hits the rename trap below |
|
|
41
41
|
| agent | none | same |
|
|
42
42
|
| command | none | same, plus nested `group/name.md` paths get flattened |
|
|
@@ -501,7 +501,9 @@ logo, centred, when the window is tall enough to fit the whole menu beside it; t
|
|
|
501
501
|
| agy | `~/.gemini/config` | ✓ | — | — | — | — | ✓ `mcp_config.json` |
|
|
502
502
|
|
|
503
503
|
Per-harness locations, mechanisms and the harness versions this was checked
|
|
504
|
-
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).
|
|
505
507
|
|
|
506
508
|
**Homes per OS.** Every `~` above is Python's `Path.home()`: `$HOME` on macOS and
|
|
507
509
|
Linux, `%USERPROFILE%` on Windows (so `~/.claude` is `C:\Users\<you>\.claude`).
|
|
@@ -518,8 +520,13 @@ Plugin ids are passed to `claude plugin ...`, which on Windows runs through
|
|
|
518
520
|
|
|
519
521
|
Copilot's `config.json` is machine-managed and never edited by this tool; Copilot
|
|
520
522
|
plugins are not yet supported. `claude` plugins are toggled through `claude
|
|
521
|
-
plugin enable/disable
|
|
522
|
-
|
|
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
|
|
523
530
|
plugin x --harness codex` exits `4`, which is why the matrix leaves it unchecked.
|
|
524
531
|
|
|
525
532
|
Flag items (openclaw plugins and flagged skills, opencode mcp) are toggled with
|
|
@@ -590,6 +597,11 @@ visible rather than silent.
|
|
|
590
597
|
MCP changes need a **new session** to take effect, except in a Claude Code session running the
|
|
591
598
|
agent-toggle mod, which runs `/mcp disable|enable <name>` for you (see the mod section).
|
|
592
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
|
+
|
|
593
605
|
### Claude MCP scopes
|
|
594
606
|
|
|
595
607
|
Both scopes stored in `~/.claude.json` are togglable, and the scope round-trips:
|
|
@@ -968,6 +980,9 @@ claude --plugin-dir ./mod # then run /agent-toggle inside Claude
|
|
|
968
980
|
`/reload-plugins`, an MCP server `/mcp disable|enable <name>`. The toast names it, e.g.
|
|
969
981
|
`⛔ [DISABLED] skill: foo (/reload-skills)`. Agents and rules have no reload and still say
|
|
970
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)).
|
|
971
986
|
- `agent-toggle ui` in another terminal applies on Enter and writes the Claude rows it changed to
|
|
972
987
|
`~/.agent-toggle/reload.json`. Every open session running the mod checks that file's mtime every
|
|
973
988
|
5 s (one `stat`) and queues the same reloads, toasting `agent-toggle ui: /reload-skills`. A file
|
|
@@ -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
|
-
|
|
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`
|
|
@@ -68,12 +71,39 @@ 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
|
|
72
|
-
"""
|
|
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:
|
|
@@ -28,7 +28,14 @@ from .backends.mcp_json import (
|
|
|
28
28
|
write_project_mcp,
|
|
29
29
|
)
|
|
30
30
|
from .backends.mcp_toml import codex_mcp_add, codex_mcp_remove, toml_block
|
|
31
|
-
from .backends.plugin_cli import
|
|
31
|
+
from .backends.plugin_cli import (
|
|
32
|
+
PLUGIN_SCOPES,
|
|
33
|
+
claude_bin,
|
|
34
|
+
full_plugin_id,
|
|
35
|
+
list_plugins,
|
|
36
|
+
plugin_where,
|
|
37
|
+
run_cli,
|
|
38
|
+
)
|
|
32
39
|
from .companions import move, park_companions, restore_companions
|
|
33
40
|
from .fs import prune_empty
|
|
34
41
|
from .harnesses import PROBE_SUFFIXES, harnesses, project_view
|
|
@@ -118,6 +125,10 @@ def _entry_reason(entry: dict, table: dict, key: str, need: tuple[str, ...]) ->
|
|
|
118
125
|
if entry.get("mechanism") == "native_cli" and not (
|
|
119
126
|
head == "claude" and type_ == "plugin" and valid_plugin_id(name)):
|
|
120
127
|
return "native_cli entry is not a claude plugin with a plain id"
|
|
128
|
+
if entry.get("mechanism") == "native_cli" and (
|
|
129
|
+
entry.get("scope", "user") not in PLUGIN_SCOPES
|
|
130
|
+
or ("cwd" in entry and not _abs_path(entry["cwd"]))):
|
|
131
|
+
return f"plugin scope {entry.get('scope')!r} / cwd {entry.get('cwd')!r} is not usable"
|
|
121
132
|
if entry.get("backend") == "claude-json" and entry.get("scope") in ("user", "local"):
|
|
122
133
|
if entry["scope"] == "local":
|
|
123
134
|
# `project` here is the claude local-scope cwd, not a --project scope
|
|
@@ -527,7 +538,8 @@ def settle(state: dict, key: str, out: Result | None = None, *,
|
|
|
527
538
|
f"{json.dumps(was)} in {file} (its value before the run)")
|
|
528
539
|
elif mech == "native_cli": # the CLI is the op: ask it, never assume
|
|
529
540
|
try:
|
|
530
|
-
|
|
541
|
+
cwd = entry.get("cwd") # checked absolute above; a gone dir: list from here
|
|
542
|
+
on = _plugin_enabled(entry["name"], cwd if cwd and os.path.isdir(cwd) else None)
|
|
531
543
|
except ValueError as e:
|
|
532
544
|
return _report(out, dry_run, "stuck",
|
|
533
545
|
f"{what}: cannot tell whether it landed: {e}; fix: run `claude "
|
|
@@ -890,6 +902,18 @@ def _shared_text(verb: str, owner: str, harness: str, others: list[str]) -> str:
|
|
|
890
902
|
return f"{verb} (shared dir: {where}also affects {', '.join(others)})"
|
|
891
903
|
|
|
892
904
|
|
|
905
|
+
def _plugin_recorded(entry: dict | None, table: dict, key: str, name: str,
|
|
906
|
+
home: Path) -> tuple[str | None, str | None]:
|
|
907
|
+
"""(scope, cwd) an enable runs with: what the disable recorded (its project dir gone:
|
|
908
|
+
the CLI auto-detects), else, for an entry from before scope tracking, the registry."""
|
|
909
|
+
if entry and ("scope" in entry or "cwd" in entry) and not _refusal(entry, table, key, ()):
|
|
910
|
+
cwd = entry.get("cwd")
|
|
911
|
+
if cwd and not os.path.isdir(cwd):
|
|
912
|
+
return None, None
|
|
913
|
+
return entry.get("scope"), cwd
|
|
914
|
+
return plugin_where(name, home)
|
|
915
|
+
|
|
916
|
+
|
|
893
917
|
def toggle_plugin(action: str, names: list[str], state: dict, harness: str,
|
|
894
918
|
out: Result | None = None, dry_run: bool = False, *,
|
|
895
919
|
batch: str = BATCH) -> int:
|
|
@@ -923,11 +947,20 @@ def toggle_plugin(action: str, names: list[str], state: dict, harness: str,
|
|
|
923
947
|
continue
|
|
924
948
|
key = f"{harness}:plugin:{name}"
|
|
925
949
|
exe = claude_bin()
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
950
|
+
if action == "enable":
|
|
951
|
+
entry = state["disabled"].get(key)
|
|
952
|
+
scope, cwd = _plugin_recorded(entry, table, key, name, table[harness].home)
|
|
953
|
+
else:
|
|
954
|
+
scope, cwd = plugin_where(name, table[harness].home)
|
|
955
|
+
if scope is None: # the CLI auto-detects from here: enable replays it
|
|
956
|
+
with contextlib.suppress(OSError):
|
|
957
|
+
cwd = os.getcwd()
|
|
958
|
+
entry = {
|
|
959
|
+
"mechanism": "native_cli", "harness": harness, "type": "plugin",
|
|
960
|
+
"name": name, "native": True,
|
|
961
|
+
"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
962
|
+
**({"scope": scope} if scope else {}), **({"cwd": cwd} if cwd else {}),
|
|
963
|
+
}
|
|
931
964
|
# write-ahead only when state changes and settle can check it: no CLI, no entry
|
|
932
965
|
# to drop, or one settle would refuse -> the op runs as before, unlogged
|
|
933
966
|
logged = bool(exe and entry) and entry.get("mechanism") == "native_cli" \
|
|
@@ -939,7 +972,8 @@ def toggle_plugin(action: str, names: list[str], state: dict, harness: str,
|
|
|
939
972
|
fails += fail_row(out, dry_run, harness, "plugin", action, name,
|
|
940
973
|
f"state not saved, nothing changed: {e}", batch=batch)
|
|
941
974
|
continue
|
|
942
|
-
ok, res = run_cli(exe, ["plugin", action, name])
|
|
975
|
+
ok, res = run_cli(exe, ["plugin", action, name, *(["-s", scope] if scope else [])],
|
|
976
|
+
cwd=cwd)
|
|
943
977
|
if not ok:
|
|
944
978
|
# a timeout may still have landed: ask the CLI. A plain non-zero exit did not
|
|
945
979
|
# (and asking would record an already-disabled plugin as ours: no `was` here)
|
|
@@ -1175,6 +1209,8 @@ def toggle_mcp(action: str, names: list[str], state: dict,
|
|
|
1175
1209
|
state["disabled"].pop(key, None)
|
|
1176
1210
|
if logged: # never drop a stuck record this op did not write
|
|
1177
1211
|
end(state, key)
|
|
1212
|
+
if not toml:
|
|
1213
|
+
_clear_mcp_marks(out, name)
|
|
1178
1214
|
log(action, "mcp", name, "ok", harness=harness, batch=batch,
|
|
1179
1215
|
project=project, scope=scope)
|
|
1180
1216
|
_ok(out, dry_run, harness, "mcp", action, name,
|
|
@@ -1182,6 +1218,15 @@ def toggle_mcp(action: str, names: list[str], state: dict,
|
|
|
1182
1218
|
return fails
|
|
1183
1219
|
|
|
1184
1220
|
|
|
1221
|
+
def _clear_mcp_marks(out: Result, name: str, under: Path | None = None) -> None:
|
|
1222
|
+
"""After a claude MCP enable: drop `name` from projects' disabledMcpServers (`under`:
|
|
1223
|
+
only that project's). The mod follows a disable with `/mcp disable <name>`, which
|
|
1224
|
+
lists it there; left in place, the restored server stays off in that project."""
|
|
1225
|
+
cleared, res = toggle_claudeai_connector("enable", name, under=under)
|
|
1226
|
+
if not cleared:
|
|
1227
|
+
out.say(f" ! {name}: per-project /mcp disable not cleared: {res}", warn=True)
|
|
1228
|
+
|
|
1229
|
+
|
|
1185
1230
|
def toggle_project_mcp(action: str, names: list[str], state: dict, harness: str,
|
|
1186
1231
|
out: Result, dry_run: bool, table: dict, *, batch: str = BATCH) -> int:
|
|
1187
1232
|
"""`--project <dir>` MCP servers: remove_backup on the repo's own <dir>/.mcp.json.
|
|
@@ -1282,6 +1327,7 @@ def toggle_project_mcp(action: str, names: list[str], state: dict, harness: str,
|
|
|
1282
1327
|
continue
|
|
1283
1328
|
state["disabled"].pop(key, None)
|
|
1284
1329
|
end(state, key)
|
|
1330
|
+
_clear_mcp_marks(out, name, Path(pstr))
|
|
1285
1331
|
log(action, "mcp", name, "ok", harness=harness, batch=batch, project=pstr,
|
|
1286
1332
|
scope="project")
|
|
1287
1333
|
_ok(out, dry_run, harness, "mcp", action, name,
|
|
@@ -1334,15 +1380,16 @@ def _mcp_present(entry: dict, file: Path) -> bool:
|
|
|
1334
1380
|
return name in servers
|
|
1335
1381
|
|
|
1336
1382
|
|
|
1337
|
-
def _plugin_enabled(name: str) -> bool | None:
|
|
1338
|
-
"""Whether `claude plugin list --json
|
|
1383
|
+
def _plugin_enabled(name: str, cwd: str | None = None) -> bool | None:
|
|
1384
|
+
"""Whether `claude plugin list --json`, run in `cwd` (where the op ran: a project-scope
|
|
1385
|
+
plugin's state is the project's), shows plugin `name` enabled (None: not listed;
|
|
1339
1386
|
a bare name matches its one `name@marketplace` id). ValueError when the CLI cannot
|
|
1340
1387
|
say -- absent, slow (READ_TIMEOUT / AGENT_TOGGLE_CLI_TIMEOUT), failing, unparseable
|
|
1341
1388
|
or ambiguous: the verdict is then stuck, never guessed."""
|
|
1342
1389
|
if not claude_bin():
|
|
1343
1390
|
raise ValueError("claude CLI not found")
|
|
1344
1391
|
why: list[str] = []
|
|
1345
|
-
listed = list_plugins(why.append)
|
|
1392
|
+
listed = list_plugins(why.append, cwd)
|
|
1346
1393
|
if why: # its "; plugin rows come from state only" tail is list's, not ours
|
|
1347
1394
|
raise ValueError(why[0].rpartition("; ")[0])
|
|
1348
1395
|
hits = [p for p in listed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-toggle
|
|
3
|
-
Version: 0.13.
|
|
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
|
|
@@ -69,7 +69,7 @@ queues the matching reload ([Claude Code pane mod](#claude-code-pane-mod)).
|
|
|
69
69
|
|
|
70
70
|
| target | native mechanism | the gap |
|
|
71
71
|
|---|---|---|
|
|
72
|
-
| plugin | `claude plugin disable/enable` |
|
|
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 |
|
|
73
73
|
| skill | none | moving files by hand hits the rename trap below |
|
|
74
74
|
| agent | none | same |
|
|
75
75
|
| command | none | same, plus nested `group/name.md` paths get flattened |
|
|
@@ -534,7 +534,9 @@ logo, centred, when the window is tall enough to fit the whole menu beside it; t
|
|
|
534
534
|
| agy | `~/.gemini/config` | ✓ | — | — | — | — | ✓ `mcp_config.json` |
|
|
535
535
|
|
|
536
536
|
Per-harness locations, mechanisms and the harness versions this was checked
|
|
537
|
-
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).
|
|
538
540
|
|
|
539
541
|
**Homes per OS.** Every `~` above is Python's `Path.home()`: `$HOME` on macOS and
|
|
540
542
|
Linux, `%USERPROFILE%` on Windows (so `~/.claude` is `C:\Users\<you>\.claude`).
|
|
@@ -551,8 +553,13 @@ Plugin ids are passed to `claude plugin ...`, which on Windows runs through
|
|
|
551
553
|
|
|
552
554
|
Copilot's `config.json` is machine-managed and never edited by this tool; Copilot
|
|
553
555
|
plugins are not yet supported. `claude` plugins are toggled through `claude
|
|
554
|
-
plugin enable/disable
|
|
555
|
-
|
|
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
|
|
556
563
|
plugin x --harness codex` exits `4`, which is why the matrix leaves it unchecked.
|
|
557
564
|
|
|
558
565
|
Flag items (openclaw plugins and flagged skills, opencode mcp) are toggled with
|
|
@@ -623,6 +630,11 @@ visible rather than silent.
|
|
|
623
630
|
MCP changes need a **new session** to take effect, except in a Claude Code session running the
|
|
624
631
|
agent-toggle mod, which runs `/mcp disable|enable <name>` for you (see the mod section).
|
|
625
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
|
+
|
|
626
638
|
### Claude MCP scopes
|
|
627
639
|
|
|
628
640
|
Both scopes stored in `~/.claude.json` are togglable, and the scope round-trips:
|
|
@@ -1001,6 +1013,9 @@ claude --plugin-dir ./mod # then run /agent-toggle inside Claude
|
|
|
1001
1013
|
`/reload-plugins`, an MCP server `/mcp disable|enable <name>`. The toast names it, e.g.
|
|
1002
1014
|
`⛔ [DISABLED] skill: foo (/reload-skills)`. Agents and rules have no reload and still say
|
|
1003
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)).
|
|
1004
1019
|
- `agent-toggle ui` in another terminal applies on Enter and writes the Claude rows it changed to
|
|
1005
1020
|
`~/.agent-toggle/reload.json`. Every open session running the mod checks that file's mtime every
|
|
1006
1021
|
5 s (one `stat`) and queues the same reloads, toasting `agent-toggle ui: /reload-skills`. A file
|
|
@@ -127,6 +127,30 @@ class ClaudeJsonScopeTest(SandboxCase):
|
|
|
127
127
|
# scopeless entry restores with no cwd
|
|
128
128
|
self.assertIsNone(cwd)
|
|
129
129
|
|
|
130
|
+
def test_mcp_enable_clears_per_project_disabled_mark(self) -> None:
|
|
131
|
+
"""The mod follows a disable with `/mcp disable <name>`, which lists the
|
|
132
|
+
server in that project's disabledMcpServers; enable must clear it or the
|
|
133
|
+
restored server stays off in that project."""
|
|
134
|
+
self.claude_json({"projects": {
|
|
135
|
+
"/work/a": {"disabledMcpServers": ["example-mcp", "other"]},
|
|
136
|
+
"/work/b": {"disabledMcpServers": ["other"]},
|
|
137
|
+
}})
|
|
138
|
+
fs.backup_dir().mkdir(parents=True)
|
|
139
|
+
bp = fs.backup_dir() / "claude__example-mcp.json"
|
|
140
|
+
bp.write_text('{"type": "http", "url": "http://127.0.0.1:1/mcp"}', encoding="utf-8")
|
|
141
|
+
self.cli_rc = 0
|
|
142
|
+
state = {"version": 2, "disabled": {"claude:mcp:example-mcp": {
|
|
143
|
+
"mechanism": "remove_backup", "harness": "claude", "type": "mcp",
|
|
144
|
+
"name": "example-mcp", "backend": "claude-json", "backup": str(bp),
|
|
145
|
+
"scope": "user", "project": None, "at": "2026-01-01"}}}
|
|
146
|
+
fails = mechanisms.toggle_mcp("enable", ["example-mcp"], state, "claude",
|
|
147
|
+
self.home, "claude-json")
|
|
148
|
+
self.assertEqual(fails, 0)
|
|
149
|
+
projects = json.loads(fs.claude_json().read_text(encoding="utf-8"))["projects"]
|
|
150
|
+
# the stale per-project mark is gone; unrelated names stay
|
|
151
|
+
self.assertEqual(projects["/work/a"]["disabledMcpServers"], ["other"])
|
|
152
|
+
self.assertEqual(projects["/work/b"]["disabledMcpServers"], ["other"])
|
|
153
|
+
|
|
130
154
|
|
|
131
155
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
132
156
|
|
|
@@ -727,6 +727,20 @@ class PluginCrashTest(CliCrashCase):
|
|
|
727
727
|
self.assertTrue(any("recovered interrupted disable" in w for w in env["warnings"]))
|
|
728
728
|
self.assert_settled(False)
|
|
729
729
|
|
|
730
|
+
def test_a_project_scope_plugin_is_settled_from_its_project(self) -> None:
|
|
731
|
+
"""The listing's `enabled` is the state seen from its cwd: ask from the project."""
|
|
732
|
+
proj = self.tmp / "work" / "repo"
|
|
733
|
+
proj.mkdir(parents=True)
|
|
734
|
+
reg = self.home / "plugins" / "installed_plugins.json"
|
|
735
|
+
reg.parent.mkdir(parents=True)
|
|
736
|
+
reg.write_text(json.dumps({"version": 2, "plugins": {"demo@mkt": [
|
|
737
|
+
{"scope": "project", "projectPath": str(proj)}]}}), encoding="utf-8")
|
|
738
|
+
self.kill_in_cli("disable", *self.ARGS, landed=True)
|
|
739
|
+
self.run_json(*NEXT)
|
|
740
|
+
self.assert_settled(True)
|
|
741
|
+
lists = [cwd for c, cwd in self.cli_calls if c[:2] == ["plugin", "list"]]
|
|
742
|
+
self.assertEqual(lists, [str(proj)])
|
|
743
|
+
|
|
730
744
|
def test_kill_before_the_cli_disable_rolls_back(self) -> None:
|
|
731
745
|
self.kill_in_cli("disable", *self.ARGS, landed=False)
|
|
732
746
|
self.assertIn(self.KEY, self.pending())
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""Park / restore of dir-type resources, and the harness/backend gates."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
4
6
|
import json
|
|
7
|
+
import os
|
|
5
8
|
import shutil
|
|
6
9
|
import unittest
|
|
7
10
|
from pathlib import Path
|
|
@@ -189,6 +192,97 @@ class OpencodeDuplicateSkillTest(CliCase):
|
|
|
189
192
|
self.assertNotIn("still loads another copy", out)
|
|
190
193
|
|
|
191
194
|
|
|
195
|
+
class PluginScopeTest(SandboxCase):
|
|
196
|
+
"""`claude plugin disable|enable` without `-s` picks the scope from the cwd: a
|
|
197
|
+
project-scope plugin enabled from elsewhere got a new user-scope `true` while its
|
|
198
|
+
project stayed `false`. The install registry names the scope and project."""
|
|
199
|
+
|
|
200
|
+
ID = "demo-plugin@demo-mkt"
|
|
201
|
+
|
|
202
|
+
def setUp(self) -> None:
|
|
203
|
+
super().setUp()
|
|
204
|
+
self.cli_rc = 0
|
|
205
|
+
(self.tmp / "work" / "repo").mkdir(parents=True)
|
|
206
|
+
(self.tmp / "work" / "other").mkdir(parents=True)
|
|
207
|
+
# resolved: os.getcwd() is, and macOS /var is a symlink
|
|
208
|
+
self.proj = (self.tmp / "work" / "repo").resolve()
|
|
209
|
+
self.elsewhere = (self.tmp / "work" / "other").resolve()
|
|
210
|
+
self.state = {"version": 3, "disabled": {}}
|
|
211
|
+
|
|
212
|
+
def installs(self, *rows: dict) -> None:
|
|
213
|
+
reg = self.home / "plugins" / "installed_plugins.json"
|
|
214
|
+
reg.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
reg.write_text(json.dumps({"version": 2, "plugins": {self.ID: list(rows)}}),
|
|
216
|
+
encoding="utf-8")
|
|
217
|
+
|
|
218
|
+
def toggle(self, action: str, cwd: Path) -> int:
|
|
219
|
+
old = os.getcwd()
|
|
220
|
+
os.chdir(cwd)
|
|
221
|
+
try:
|
|
222
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
223
|
+
return mech.toggle_plugin(action, [self.ID], self.state, "claude")
|
|
224
|
+
finally:
|
|
225
|
+
os.chdir(old)
|
|
226
|
+
|
|
227
|
+
def calls(self) -> list[tuple[list[str], str | None]]:
|
|
228
|
+
return [(a, c) for a, c in self.cli_calls if a[:2] != ["plugin", "list"]]
|
|
229
|
+
|
|
230
|
+
def test_project_scope_round_trips_from_any_cwd(self) -> None:
|
|
231
|
+
self.installs({"scope": "project", "projectPath": str(self.proj)})
|
|
232
|
+
self.assertEqual(self.toggle("disable", self.elsewhere), 0)
|
|
233
|
+
self.assertEqual(self.toggle("enable", self.elsewhere), 0)
|
|
234
|
+
want = (str(self.proj),)
|
|
235
|
+
self.assertEqual(self.calls(), [
|
|
236
|
+
(["plugin", "disable", self.ID, "-s", "project"], *want),
|
|
237
|
+
(["plugin", "enable", self.ID, "-s", "project"], *want)])
|
|
238
|
+
|
|
239
|
+
def test_enable_replays_the_recorded_scope_not_todays_registry(self) -> None:
|
|
240
|
+
self.installs({"scope": "local", "projectPath": str(self.proj)})
|
|
241
|
+
self.toggle("disable", self.proj)
|
|
242
|
+
self.installs({"scope": "user"}) # registry changed meanwhile
|
|
243
|
+
self.toggle("enable", self.elsewhere)
|
|
244
|
+
self.assertEqual(self.calls()[-1],
|
|
245
|
+
(["plugin", "enable", self.ID, "-s", "local"], str(self.proj)))
|
|
246
|
+
|
|
247
|
+
def test_user_scope_passes_the_scope_and_no_cwd(self) -> None:
|
|
248
|
+
self.installs({"scope": "user"})
|
|
249
|
+
self.toggle("disable", self.proj)
|
|
250
|
+
self.assertEqual(self.calls(), [(["plugin", "disable", self.ID, "-s", "user"], None)])
|
|
251
|
+
|
|
252
|
+
def test_ambiguous_install_keeps_auto_detect_and_replays_its_cwd(self) -> None:
|
|
253
|
+
self.installs({"scope": "user"}, {"scope": "project", "projectPath": str(self.proj)})
|
|
254
|
+
self.toggle("disable", self.proj)
|
|
255
|
+
self.toggle("enable", self.elsewhere)
|
|
256
|
+
self.assertEqual(self.calls(), [
|
|
257
|
+
(["plugin", "disable", self.ID], str(self.proj)),
|
|
258
|
+
(["plugin", "enable", self.ID], str(self.proj))])
|
|
259
|
+
|
|
260
|
+
def test_entry_from_before_scope_tracking_resolves_from_the_registry(self) -> None:
|
|
261
|
+
self.installs({"scope": "project", "projectPath": str(self.proj)})
|
|
262
|
+
self.state["disabled"][f"claude:plugin:{self.ID}"] = {
|
|
263
|
+
"mechanism": "native_cli", "harness": "claude", "type": "plugin",
|
|
264
|
+
"name": self.ID, "native": True, "at": "2026-01-01T00:00:00+0000"}
|
|
265
|
+
self.toggle("enable", self.elsewhere)
|
|
266
|
+
self.assertEqual(self.calls(), [
|
|
267
|
+
(["plugin", "enable", self.ID, "-s", "project"], str(self.proj))])
|
|
268
|
+
|
|
269
|
+
def test_no_registry_and_a_gone_project_fall_back_to_auto_detect(self) -> None:
|
|
270
|
+
self.toggle("disable", self.elsewhere) # no installed_plugins.json
|
|
271
|
+
self.installs({"scope": "project", "projectPath": str(self.tmp / "gone")})
|
|
272
|
+
self.toggle("enable", self.elsewhere)
|
|
273
|
+
self.assertEqual(self.calls(), [
|
|
274
|
+
(["plugin", "disable", self.ID], str(self.elsewhere)),
|
|
275
|
+
(["plugin", "enable", self.ID], str(self.elsewhere))])
|
|
276
|
+
|
|
277
|
+
def test_tampered_scope_or_cwd_is_refused(self) -> None:
|
|
278
|
+
for bad in ({"scope": "global"}, {"cwd": "relative/dir"}, {"cwd": 7}):
|
|
279
|
+
with self.subTest(bad=bad):
|
|
280
|
+
entry = {"mechanism": "native_cli", "harness": "claude", "type": "plugin",
|
|
281
|
+
"name": self.ID, "native": True, "at": "2026", **bad}
|
|
282
|
+
self.assertTrue(mech._entry_reason(entry, mech.harnesses(),
|
|
283
|
+
f"claude:plugin:{self.ID}", ()))
|
|
284
|
+
|
|
285
|
+
|
|
192
286
|
class McpGateTest(SandboxCase):
|
|
193
287
|
def test_mcp_without_backend_fails_loudly(self) -> None:
|
|
194
288
|
state = {"version": 2, "disabled": {}}
|
|
@@ -442,6 +442,23 @@ class ProjectMcpTest(ProjectCase):
|
|
|
442
442
|
self.assertIn("symlink", env["results"][0]["detail"])
|
|
443
443
|
self.assertEqual(target.read_text(encoding="utf-8"), self.text)
|
|
444
444
|
|
|
445
|
+
def test_enable_clears_this_projects_mcp_disable_mark_only(self) -> None:
|
|
446
|
+
"""A session's `/mcp disable example-mcp` (the mod queues it) lists it in that
|
|
447
|
+
project's disabledMcpServers; enable clears it there, never in another repo."""
|
|
448
|
+
self.assertEqual(self.p("disable", "mcp", "example-mcp")[0], 0)
|
|
449
|
+
root = self.proj.resolve()
|
|
450
|
+
self.claude_json({"projects": {
|
|
451
|
+
str(root): {"disabledMcpServers": ["example-mcp", "keep-mcp"]},
|
|
452
|
+
str(root / "sub"): {"disabledMcpServers": ["example-mcp"]},
|
|
453
|
+
str(self.tmp / "work" / "other"): {"disabledMcpServers": ["example-mcp"]},
|
|
454
|
+
}})
|
|
455
|
+
self.assertEqual(self.p("enable", "mcp", "example-mcp")[0], 0)
|
|
456
|
+
projects = json.loads(fs.claude_json().read_text(encoding="utf-8"))["projects"]
|
|
457
|
+
self.assertEqual({k: v["disabledMcpServers"] for k, v in projects.items()}, {
|
|
458
|
+
str(root): ["keep-mcp"], str(root / "sub"): [],
|
|
459
|
+
str(self.tmp / "work" / "other"): ["example-mcp"]})
|
|
460
|
+
self.assertEqual(self.cli_calls, [])
|
|
461
|
+
|
|
445
462
|
def test_user_scope_mcp_state_is_never_touched(self) -> None:
|
|
446
463
|
self.claude_json({"mcpServers": {"example-mcp": {"url": "https://user.example.com"}}})
|
|
447
464
|
self.cli_rc = 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|