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.
Files changed (91) hide show
  1. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/PKG-INFO +20 -5
  2. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/README.md +19 -4
  3. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/__init__.py +1 -1
  4. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/backends/mcp_json.py +6 -4
  5. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/backends/plugin_cli.py +33 -3
  6. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/mechanisms.py +58 -11
  7. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/PKG-INFO +20 -5
  8. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_backends.py +24 -0
  9. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_crash.py +14 -0
  10. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_mechanisms.py +94 -0
  11. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_project.py +17 -0
  12. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/LICENSE +0 -0
  13. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/__main__.py +0 -0
  14. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/backends/__init__.py +0 -0
  15. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/backends/flag_json.py +0 -0
  16. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/backends/mcp_toml.py +0 -0
  17. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/cli.py +0 -0
  18. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/companions.py +0 -0
  19. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/completions.py +0 -0
  20. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/config.py +0 -0
  21. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/cost.py +0 -0
  22. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/doctor.py +0 -0
  23. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/fs.py +0 -0
  24. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/harnesses.py +0 -0
  25. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/help.py +0 -0
  26. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/i18n.py +0 -0
  27. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/locales/__init__.py +0 -0
  28. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_cli.py +0 -0
  29. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_common.py +0 -0
  30. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_config.py +0 -0
  31. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/locales/zh_tw_picker.py +0 -0
  32. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ops.py +0 -0
  33. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/output.py +0 -0
  34. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/profiles.py +0 -0
  35. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/settings.py +0 -0
  36. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/shims/claude.md.tmpl +0 -0
  37. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/shims/generic.md.tmpl +0 -0
  38. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/spinner.py +0 -0
  39. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/store.py +0 -0
  40. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/toml_check.py +0 -0
  41. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/__init__.py +0 -0
  42. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/config_menu.py +0 -0
  43. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/logo.py +0 -0
  44. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/menu.py +0 -0
  45. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/model.py +0 -0
  46. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/picker.py +0 -0
  47. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/ui/theme.py +0 -0
  48. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/undo.py +0 -0
  49. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/update_check.py +0 -0
  50. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle/update_prompt.py +0 -0
  51. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/SOURCES.txt +0 -0
  52. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/dependency_links.txt +0 -0
  53. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/entry_points.txt +0 -0
  54. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/requires.txt +0 -0
  55. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/agent_toggle.egg-info/top_level.txt +0 -0
  56. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/pyproject.toml +0 -0
  57. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/setup.cfg +0 -0
  58. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_alias.py +0 -0
  59. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_all_type.py +0 -0
  60. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_cli.py +0 -0
  61. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_cli_surface.py +0 -0
  62. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_color.py +0 -0
  63. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_companions.py +0 -0
  64. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_completions.py +0 -0
  65. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_config.py +0 -0
  66. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_config_menu.py +0 -0
  67. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_conformance.py +0 -0
  68. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_containment.py +0 -0
  69. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_cost.py +0 -0
  70. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_doctor.py +0 -0
  71. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_encoding.py +0 -0
  72. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_fs.py +0 -0
  73. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_harnesses.py +0 -0
  74. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_help.py +0 -0
  75. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_i18n.py +0 -0
  76. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_install.py +0 -0
  77. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_logo.py +0 -0
  78. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_menu.py +0 -0
  79. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_mods.py +0 -0
  80. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_picker.py +0 -0
  81. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_platform.py +0 -0
  82. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_profiles.py +0 -0
  83. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_safety.py +0 -0
  84. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_same_name.py +0 -0
  85. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_secret_audit.py +0 -0
  86. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_settings.py +0 -0
  87. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_store.py +0 -0
  88. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_theme.py +0 -0
  89. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_undo.py +0 -0
  90. {agent_toggle-0.13.0 → agent_toggle-0.13.1}/tests/test_update_check.py +0 -0
  91. {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.0
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` | 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 |
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`; `openclaw` plugins through a config flag (see [Assumed
555
- 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
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` | 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 |
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`; `openclaw` plugins through a config flag (see [Assumed
522
- 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
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
@@ -1,3 +1,3 @@
1
1
  """agent-toggle: temporarily disable / re-enable AI-agent resources."""
2
2
 
3
- __version__ = "0.13.0"
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`
@@ -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 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:
@@ -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 claude_bin, full_plugin_id, list_plugins, run_cli
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
- on = _plugin_enabled(entry["name"])
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
- entry = state["disabled"].get(key) if action == "enable" else {
927
- "mechanism": "native_cli", "harness": harness, "type": "plugin",
928
- "name": name, "native": True,
929
- "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
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` shows plugin `name` enabled (None: not listed;
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.0
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` | 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 |
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`; `openclaw` plugins through a config flag (see [Assumed
555
- 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
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