agent-toggle 0.17.0__tar.gz → 0.19.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/PKG-INFO +24 -10
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/README.md +23 -9
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/__init__.py +1 -1
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/mechanisms.py +34 -50
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle.egg-info/PKG-INFO +24 -10
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_all_type.py +1 -4
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_backends.py +39 -4
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_conformance.py +5 -5
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_crash.py +44 -15
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_safety.py +14 -14
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_secret_audit.py +19 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/LICENSE +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/__main__.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/backends/__init__.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/backends/flag_json.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/backends/mcp_json.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/backends/mcp_toml.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/backends/plugin_cli.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/cli.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/companions.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/completions.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/config.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/cost.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/doctor.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/fs.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/harnesses.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/help.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/i18n.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/locales/__init__.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/locales/zh_tw_cli.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/locales/zh_tw_common.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/locales/zh_tw_config.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/locales/zh_tw_picker.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/ops.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/output.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/profiles.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/settings.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/shims/claude.md.tmpl +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/shims/generic.md.tmpl +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/spinner.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/store.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/toml_check.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/ui/__init__.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/ui/config_menu.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/ui/logo.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/ui/menu.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/ui/model.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/ui/picker.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/ui/theme.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/undo.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/update_check.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle/update_prompt.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle.egg-info/SOURCES.txt +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle.egg-info/dependency_links.txt +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle.egg-info/entry_points.txt +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle.egg-info/requires.txt +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/agent_toggle.egg-info/top_level.txt +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/pyproject.toml +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/setup.cfg +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_alias.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_cli.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_cli_surface.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_color.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_companions.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_completions.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_config.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_config_menu.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_containment.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_cost.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_doctor.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_encoding.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_fs.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_harnesses.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_help.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_i18n.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_install.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_logo.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_mechanisms.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_menu.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_mod_version.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_mods.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_picker.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_platform.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_profiles.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_project.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_same_name.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_settings.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_store.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_theme.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_undo.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/tests/test_update_check.py +0 -0
- {agent_toggle-0.17.0 → agent_toggle-0.19.0}/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.
|
|
3
|
+
Version: 0.19.0
|
|
4
4
|
Summary: Temporarily disable / re-enable AI-agent skills, agents, commands, rules, plugins and MCP servers across harnesses (Claude Code, Codex, Grok, OpenCode, OpenClaw, Copilot, Vibe, Devin, Antigravity)
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/weskao/agent-toggle
|
|
@@ -665,7 +665,9 @@ visible rather than silent.
|
|
|
665
665
|
|
|
666
666
|
## MCP backups are verbatim
|
|
667
667
|
|
|
668
|
-
- **Claude**:
|
|
668
|
+
- **Claude**: only servers an older agent-toggle removed have one; a disable now marks the
|
|
669
|
+
server off in place (see [Claude MCP scopes](#claude-mcp-scopes)). The backup is the raw
|
|
670
|
+
entry from `~/.claude.json`. Never `claude mcp get` —
|
|
669
671
|
that prints a human summary and silently drops auth fields (`headers`,
|
|
670
672
|
`headersHelper`), so a server restored from it fails with 401.
|
|
671
673
|
- **Codex**: the exact `[mcp_servers.<name>]` text block including its
|
|
@@ -687,15 +689,20 @@ Both scopes stored in `~/.claude.json` are togglable, and the scope round-trips:
|
|
|
687
689
|
|
|
688
690
|
| scope | lives in | toggle |
|
|
689
691
|
|---|---|---|
|
|
690
|
-
| user | top-level `mcpServers` | ✓ |
|
|
691
|
-
| local | `projects/<dir>/mcpServers` | ✓ —
|
|
692
|
+
| user | top-level `mcpServers` | ✓ — the entry stays; recorded in each *existing* project's `disabledMcpServers` |
|
|
693
|
+
| local | `projects/<dir>/mcpServers` | ✓ — as user scope |
|
|
692
694
|
| project | the repo's own `.mcp.json` | ✓ with `--project <dir>` only, see [Project scope](#project-scope) |
|
|
693
695
|
| claude.ai connector | your account | ✓ — recorded in each *existing* project's `disabledMcpServers`; a project first opened later needs the toggle re-run |
|
|
694
696
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
697
|
+
Claude Code reads its MCP config once, at session start, and `/mcp enable` reaches only a server
|
|
698
|
+
it listed then. A removed server could not load until a new session, so a user- or local-scope
|
|
699
|
+
server is never removed: it stays in `~/.claude.json`, off in every project that existed at
|
|
700
|
+
disable time. A project first opened later loads it, and the mod turns it off at that session's
|
|
701
|
+
start (`/mcp disable`, which also marks the project). Without the mod, re-run the disable.
|
|
702
|
+
|
|
703
|
+
A server an older agent-toggle removed still comes back from its backup, with
|
|
704
|
+
`claude mcp add-json` run in the project recorded at disable time. A running session did not list
|
|
705
|
+
it, so it loads in a new session.
|
|
699
706
|
|
|
700
707
|
## Profiles
|
|
701
708
|
|
|
@@ -1022,8 +1029,9 @@ The mod and the CLI share one version. To run your own checkout instead (edits r
|
|
|
1022
1029
|
claude --plugin-dir ./mod # then run /agent-toggle inside Claude
|
|
1023
1030
|
```
|
|
1024
1031
|
|
|
1025
|
-
- `/agent-toggle` or `/agent-toggle
|
|
1026
|
-
|
|
1032
|
+
- `/agent-toggle` (or `/agent-toggle toggle`) closes the pane when it is shown and opens it otherwise
|
|
1033
|
+
(a pane behind another tab comes to the front); `/agent-toggle open` always opens, `/agent-toggle close`
|
|
1034
|
+
always closes; any other argument that is not a CLI command (below) prints the usage. Ctrl-X X
|
|
1027
1035
|
also closes it. Claude can do the same when you ask it to open or close agent-toggle: the mod
|
|
1028
1036
|
registers a `pane` tool (`mcp__agent-toggle__pane`, `action`: `open` | `close` | `toggle`),
|
|
1029
1037
|
e.g. "open agent-toggle", "close the agent-toggle pane", "toggle the agent-toggle pane".
|
|
@@ -1063,6 +1071,12 @@ claude --plugin-dir ./mod # then run /agent-toggle inside Claude
|
|
|
1063
1071
|
`/reload-skills` and `/reload-plugins` only re-read what is on disk. `/mcp disable` also
|
|
1064
1072
|
leaves the per-project mark that `enable` clears (see
|
|
1065
1073
|
[MCP backups are verbatim](#mcp-backups-are-verbatim)).
|
|
1074
|
+
`/mcp` only reaches a server the session listed at its start. The CLI keeps a disabled server
|
|
1075
|
+
listed, so `/mcp enable` loads it in any session, even one started while it was off. Only a
|
|
1076
|
+
server an older version removed and `enable` restores from its backup gets no command; its note
|
|
1077
|
+
says `example-mcp: loads in a new session`. At session start the mod also runs
|
|
1078
|
+
`/mcp disable <name>` for each server agent-toggle keeps off that the session's project has not
|
|
1079
|
+
marked (a project dir first opened after the disable).
|
|
1066
1080
|
- `agent-toggle ui` in another terminal applies on Enter and writes the Claude rows it changed to
|
|
1067
1081
|
`~/.agent-toggle/reload.json`. Every open session running the mod checks that file's mtime every
|
|
1068
1082
|
5 s (one `stat`) and queues the same reloads, toasting `agent-toggle ui: /reload-skills`. A file
|
|
@@ -631,7 +631,9 @@ visible rather than silent.
|
|
|
631
631
|
|
|
632
632
|
## MCP backups are verbatim
|
|
633
633
|
|
|
634
|
-
- **Claude**:
|
|
634
|
+
- **Claude**: only servers an older agent-toggle removed have one; a disable now marks the
|
|
635
|
+
server off in place (see [Claude MCP scopes](#claude-mcp-scopes)). The backup is the raw
|
|
636
|
+
entry from `~/.claude.json`. Never `claude mcp get` —
|
|
635
637
|
that prints a human summary and silently drops auth fields (`headers`,
|
|
636
638
|
`headersHelper`), so a server restored from it fails with 401.
|
|
637
639
|
- **Codex**: the exact `[mcp_servers.<name>]` text block including its
|
|
@@ -653,15 +655,20 @@ Both scopes stored in `~/.claude.json` are togglable, and the scope round-trips:
|
|
|
653
655
|
|
|
654
656
|
| scope | lives in | toggle |
|
|
655
657
|
|---|---|---|
|
|
656
|
-
| user | top-level `mcpServers` | ✓ |
|
|
657
|
-
| local | `projects/<dir>/mcpServers` | ✓ —
|
|
658
|
+
| user | top-level `mcpServers` | ✓ — the entry stays; recorded in each *existing* project's `disabledMcpServers` |
|
|
659
|
+
| local | `projects/<dir>/mcpServers` | ✓ — as user scope |
|
|
658
660
|
| project | the repo's own `.mcp.json` | ✓ with `--project <dir>` only, see [Project scope](#project-scope) |
|
|
659
661
|
| claude.ai connector | your account | ✓ — recorded in each *existing* project's `disabledMcpServers`; a project first opened later needs the toggle re-run |
|
|
660
662
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
663
|
+
Claude Code reads its MCP config once, at session start, and `/mcp enable` reaches only a server
|
|
664
|
+
it listed then. A removed server could not load until a new session, so a user- or local-scope
|
|
665
|
+
server is never removed: it stays in `~/.claude.json`, off in every project that existed at
|
|
666
|
+
disable time. A project first opened later loads it, and the mod turns it off at that session's
|
|
667
|
+
start (`/mcp disable`, which also marks the project). Without the mod, re-run the disable.
|
|
668
|
+
|
|
669
|
+
A server an older agent-toggle removed still comes back from its backup, with
|
|
670
|
+
`claude mcp add-json` run in the project recorded at disable time. A running session did not list
|
|
671
|
+
it, so it loads in a new session.
|
|
665
672
|
|
|
666
673
|
## Profiles
|
|
667
674
|
|
|
@@ -988,8 +995,9 @@ The mod and the CLI share one version. To run your own checkout instead (edits r
|
|
|
988
995
|
claude --plugin-dir ./mod # then run /agent-toggle inside Claude
|
|
989
996
|
```
|
|
990
997
|
|
|
991
|
-
- `/agent-toggle` or `/agent-toggle
|
|
992
|
-
|
|
998
|
+
- `/agent-toggle` (or `/agent-toggle toggle`) closes the pane when it is shown and opens it otherwise
|
|
999
|
+
(a pane behind another tab comes to the front); `/agent-toggle open` always opens, `/agent-toggle close`
|
|
1000
|
+
always closes; any other argument that is not a CLI command (below) prints the usage. Ctrl-X X
|
|
993
1001
|
also closes it. Claude can do the same when you ask it to open or close agent-toggle: the mod
|
|
994
1002
|
registers a `pane` tool (`mcp__agent-toggle__pane`, `action`: `open` | `close` | `toggle`),
|
|
995
1003
|
e.g. "open agent-toggle", "close the agent-toggle pane", "toggle the agent-toggle pane".
|
|
@@ -1029,6 +1037,12 @@ claude --plugin-dir ./mod # then run /agent-toggle inside Claude
|
|
|
1029
1037
|
`/reload-skills` and `/reload-plugins` only re-read what is on disk. `/mcp disable` also
|
|
1030
1038
|
leaves the per-project mark that `enable` clears (see
|
|
1031
1039
|
[MCP backups are verbatim](#mcp-backups-are-verbatim)).
|
|
1040
|
+
`/mcp` only reaches a server the session listed at its start. The CLI keeps a disabled server
|
|
1041
|
+
listed, so `/mcp enable` loads it in any session, even one started while it was off. Only a
|
|
1042
|
+
server an older version removed and `enable` restores from its backup gets no command; its note
|
|
1043
|
+
says `example-mcp: loads in a new session`. At session start the mod also runs
|
|
1044
|
+
`/mcp disable <name>` for each server agent-toggle keeps off that the session's project has not
|
|
1045
|
+
marked (a project dir first opened after the disable).
|
|
1032
1046
|
- `agent-toggle ui` in another terminal applies on Enter and writes the Claude rows it changed to
|
|
1033
1047
|
`~/.agent-toggle/reload.json`. Every open session running the mod checks that file's mtime every
|
|
1034
1048
|
5 s (one `stat`) and queues the same reloads, toasting `agent-toggle ui: /reload-skills`. A file
|
|
@@ -18,7 +18,6 @@ from .backends.mcp_json import (
|
|
|
18
18
|
_finite,
|
|
19
19
|
_no_dup,
|
|
20
20
|
_not_json,
|
|
21
|
-
claude_mcp_config,
|
|
22
21
|
claudeai_connector_names,
|
|
23
22
|
project_mcp_backup,
|
|
24
23
|
project_mcp_remove,
|
|
@@ -1042,7 +1041,13 @@ def toggle_mcp(action: str, names: list[str], state: dict,
|
|
|
1042
1041
|
fails += fail(name, refused)
|
|
1043
1042
|
continue
|
|
1044
1043
|
|
|
1045
|
-
|
|
1044
|
+
connector = backend == "claude-json" and name in claudeai_connector_names()
|
|
1045
|
+
# A configured server is marked off per project like a connector, never removed:
|
|
1046
|
+
# Claude Code builds its MCP list once per session and `/mcp enable` reaches only a
|
|
1047
|
+
# listed server, so a removed one could not load until a new session.
|
|
1048
|
+
if connector or backend == "claude-json" and (
|
|
1049
|
+
(entry or {}).get("connector")
|
|
1050
|
+
or action == "disable" and name in live_mcp(home, backend)):
|
|
1046
1051
|
ok, detail = toggle_claudeai_connector(action, name, dry_run)
|
|
1047
1052
|
if not ok:
|
|
1048
1053
|
fails += fail(name, str(detail))
|
|
@@ -1057,15 +1062,17 @@ def toggle_mcp(action: str, names: list[str], state: dict,
|
|
|
1057
1062
|
}
|
|
1058
1063
|
_ok(out, dry_run, harness, "mcp", action, name,
|
|
1059
1064
|
f"disabled across {n} project(s) "
|
|
1060
|
-
|
|
1061
|
-
|
|
1065
|
+
+ ("(claude.ai connector -- account stays connected, "
|
|
1066
|
+
"just parked per-project; new project dirs need a re-run)" if connector
|
|
1067
|
+
else "(config kept in ~/.claude.json; the mod marks new project dirs)"),
|
|
1062
1068
|
connector=True, projects=detail)
|
|
1063
1069
|
else:
|
|
1064
1070
|
if not dry_run:
|
|
1065
1071
|
state["disabled"].pop(key, None)
|
|
1066
1072
|
_ok(out, dry_run, harness, "mcp", action, name,
|
|
1067
1073
|
f"restored across {n} project(s) "
|
|
1068
|
-
|
|
1074
|
+
+ ("(open a NEW session for it to load)" if connector
|
|
1075
|
+
else f"(`/mcp enable {name}` loads it in a running session)"),
|
|
1069
1076
|
connector=True, projects=detail)
|
|
1070
1077
|
if not dry_run:
|
|
1071
1078
|
log(action, "mcp", name, "ok", json.dumps(detail), harness=harness,
|
|
@@ -1076,35 +1083,23 @@ def toggle_mcp(action: str, names: list[str], state: dict,
|
|
|
1076
1083
|
scope, project = "user", None
|
|
1077
1084
|
|
|
1078
1085
|
if action == "disable":
|
|
1079
|
-
if backend == "claude-json":
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
if dry_run and not claude_bin():
|
|
1097
|
-
fails += fail(name, "claude CLI not found")
|
|
1098
|
-
continue
|
|
1099
|
-
saved = json.dumps(raw, indent=2, ensure_ascii=False)
|
|
1100
|
-
else: # toml
|
|
1101
|
-
config = home / "config.toml"
|
|
1102
|
-
# plan first, back up, THEN edit: the block is never only in memory
|
|
1103
|
-
block = codex_mcp_remove(config, name, True) if config.exists() else None
|
|
1104
|
-
if block is None:
|
|
1105
|
-
fails += fail(name, f"no [mcp_servers.{name}] in {config}")
|
|
1106
|
-
continue
|
|
1107
|
-
saved = json.dumps({"toml": block}, ensure_ascii=False, indent=2)
|
|
1086
|
+
if backend == "claude-json": # a configured server was marked off above
|
|
1087
|
+
hint = ("not in ~/.claude.json (user or local scope) -- "
|
|
1088
|
+
"a repo's own .mcp.json needs --project <dir>")
|
|
1089
|
+
if not dry_run: # a dry run never shells out to claude
|
|
1090
|
+
ok, cfg = run_cli(claude_bin(), ["mcp", "get", name])
|
|
1091
|
+
if ok and "claude.ai config" in cfg:
|
|
1092
|
+
hint = ("account-level claude.ai connector -- disable it "
|
|
1093
|
+
"at claude.ai (Settings -> Connectors)")
|
|
1094
|
+
fails += fail(name, hint)
|
|
1095
|
+
continue
|
|
1096
|
+
config = home / "config.toml"
|
|
1097
|
+
# plan first, back up, THEN edit: the block is never only in memory
|
|
1098
|
+
block = codex_mcp_remove(config, name, True) if config.exists() else None
|
|
1099
|
+
if block is None:
|
|
1100
|
+
fails += fail(name, f"no [mcp_servers.{name}] in {config}")
|
|
1101
|
+
continue
|
|
1102
|
+
saved = json.dumps({"toml": block}, ensure_ascii=False, indent=2)
|
|
1108
1103
|
|
|
1109
1104
|
where = f" [{scope}{f': {project}' if project else ''}]"
|
|
1110
1105
|
if not dry_run:
|
|
@@ -1123,25 +1118,14 @@ def toggle_mcp(action: str, names: list[str], state: dict,
|
|
|
1123
1118
|
bp.unlink(missing_ok=True)
|
|
1124
1119
|
fails += fail(name, f"state not saved, nothing changed: {e}")
|
|
1125
1120
|
continue
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
try:
|
|
1132
|
-
codex_mcp_remove(config, name)
|
|
1133
|
-
err = None
|
|
1134
|
-
except fs.WriteError as e: # config rolled back
|
|
1135
|
-
err = str(e)
|
|
1136
|
-
if err is not None: # settled from the file: untouched -> backup dropped
|
|
1137
|
-
settle(state, key, out, inline=True)
|
|
1138
|
-
fails += fail(name, err)
|
|
1121
|
+
try:
|
|
1122
|
+
codex_mcp_remove(config, name)
|
|
1123
|
+
except fs.WriteError as e: # config rolled back; settled from the file:
|
|
1124
|
+
settle(state, key, out, inline=True) # untouched -> backup dropped
|
|
1125
|
+
fails += fail(name, str(e))
|
|
1139
1126
|
continue
|
|
1140
1127
|
state["disabled"][key] = new
|
|
1141
1128
|
end(state, key)
|
|
1142
|
-
# claude local scope logs its real scope + cwd; undo treats every
|
|
1143
|
-
# scope but `project` (a --project change) as user scope, which is right
|
|
1144
|
-
# for local: the replay finds the user-keyed entry and its own cwd.
|
|
1145
1129
|
log(action, "mcp", name, "ok", str(bp), harness=harness, batch=batch,
|
|
1146
1130
|
project=project, scope=scope)
|
|
1147
1131
|
_ok(out, dry_run, harness, "mcp", action, name,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-toggle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.0
|
|
4
4
|
Summary: Temporarily disable / re-enable AI-agent skills, agents, commands, rules, plugins and MCP servers across harnesses (Claude Code, Codex, Grok, OpenCode, OpenClaw, Copilot, Vibe, Devin, Antigravity)
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/weskao/agent-toggle
|
|
@@ -665,7 +665,9 @@ visible rather than silent.
|
|
|
665
665
|
|
|
666
666
|
## MCP backups are verbatim
|
|
667
667
|
|
|
668
|
-
- **Claude**:
|
|
668
|
+
- **Claude**: only servers an older agent-toggle removed have one; a disable now marks the
|
|
669
|
+
server off in place (see [Claude MCP scopes](#claude-mcp-scopes)). The backup is the raw
|
|
670
|
+
entry from `~/.claude.json`. Never `claude mcp get` —
|
|
669
671
|
that prints a human summary and silently drops auth fields (`headers`,
|
|
670
672
|
`headersHelper`), so a server restored from it fails with 401.
|
|
671
673
|
- **Codex**: the exact `[mcp_servers.<name>]` text block including its
|
|
@@ -687,15 +689,20 @@ Both scopes stored in `~/.claude.json` are togglable, and the scope round-trips:
|
|
|
687
689
|
|
|
688
690
|
| scope | lives in | toggle |
|
|
689
691
|
|---|---|---|
|
|
690
|
-
| user | top-level `mcpServers` | ✓ |
|
|
691
|
-
| local | `projects/<dir>/mcpServers` | ✓ —
|
|
692
|
+
| user | top-level `mcpServers` | ✓ — the entry stays; recorded in each *existing* project's `disabledMcpServers` |
|
|
693
|
+
| local | `projects/<dir>/mcpServers` | ✓ — as user scope |
|
|
692
694
|
| project | the repo's own `.mcp.json` | ✓ with `--project <dir>` only, see [Project scope](#project-scope) |
|
|
693
695
|
| claude.ai connector | your account | ✓ — recorded in each *existing* project's `disabledMcpServers`; a project first opened later needs the toggle re-run |
|
|
694
696
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
697
|
+
Claude Code reads its MCP config once, at session start, and `/mcp enable` reaches only a server
|
|
698
|
+
it listed then. A removed server could not load until a new session, so a user- or local-scope
|
|
699
|
+
server is never removed: it stays in `~/.claude.json`, off in every project that existed at
|
|
700
|
+
disable time. A project first opened later loads it, and the mod turns it off at that session's
|
|
701
|
+
start (`/mcp disable`, which also marks the project). Without the mod, re-run the disable.
|
|
702
|
+
|
|
703
|
+
A server an older agent-toggle removed still comes back from its backup, with
|
|
704
|
+
`claude mcp add-json` run in the project recorded at disable time. A running session did not list
|
|
705
|
+
it, so it loads in a new session.
|
|
699
706
|
|
|
700
707
|
## Profiles
|
|
701
708
|
|
|
@@ -1022,8 +1029,9 @@ The mod and the CLI share one version. To run your own checkout instead (edits r
|
|
|
1022
1029
|
claude --plugin-dir ./mod # then run /agent-toggle inside Claude
|
|
1023
1030
|
```
|
|
1024
1031
|
|
|
1025
|
-
- `/agent-toggle` or `/agent-toggle
|
|
1026
|
-
|
|
1032
|
+
- `/agent-toggle` (or `/agent-toggle toggle`) closes the pane when it is shown and opens it otherwise
|
|
1033
|
+
(a pane behind another tab comes to the front); `/agent-toggle open` always opens, `/agent-toggle close`
|
|
1034
|
+
always closes; any other argument that is not a CLI command (below) prints the usage. Ctrl-X X
|
|
1027
1035
|
also closes it. Claude can do the same when you ask it to open or close agent-toggle: the mod
|
|
1028
1036
|
registers a `pane` tool (`mcp__agent-toggle__pane`, `action`: `open` | `close` | `toggle`),
|
|
1029
1037
|
e.g. "open agent-toggle", "close the agent-toggle pane", "toggle the agent-toggle pane".
|
|
@@ -1063,6 +1071,12 @@ claude --plugin-dir ./mod # then run /agent-toggle inside Claude
|
|
|
1063
1071
|
`/reload-skills` and `/reload-plugins` only re-read what is on disk. `/mcp disable` also
|
|
1064
1072
|
leaves the per-project mark that `enable` clears (see
|
|
1065
1073
|
[MCP backups are verbatim](#mcp-backups-are-verbatim)).
|
|
1074
|
+
`/mcp` only reaches a server the session listed at its start. The CLI keeps a disabled server
|
|
1075
|
+
listed, so `/mcp enable` loads it in any session, even one started while it was off. Only a
|
|
1076
|
+
server an older version removed and `enable` restores from its backup gets no command; its note
|
|
1077
|
+
says `example-mcp: loads in a new session`. At session start the mod also runs
|
|
1078
|
+
`/mcp disable <name>` for each server agent-toggle keeps off that the session's project has not
|
|
1079
|
+
marked (a project dir first opened after the disable).
|
|
1066
1080
|
- `agent-toggle ui` in another terminal applies on Enter and writes the Claude rows it changed to
|
|
1067
1081
|
`~/.agent-toggle/reload.json`. Every open session running the mod checks that file's mtime every
|
|
1068
1082
|
5 s (one `stat`) and queues the same reloads, toasting `agent-toggle ui: /reload-skills`. A file
|
|
@@ -5,7 +5,7 @@ import json
|
|
|
5
5
|
|
|
6
6
|
from test_cli_surface import CliCase
|
|
7
7
|
|
|
8
|
-
from agent_toggle import fs
|
|
8
|
+
from agent_toggle import fs
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class AllTypeTest(CliCase):
|
|
@@ -42,9 +42,6 @@ class AllTypeTest(CliCase):
|
|
|
42
42
|
|
|
43
43
|
def test_mcp_with_the_same_name_is_included(self) -> None:
|
|
44
44
|
self.claude_json({"mcpServers": {"dup": {"command": "dup-server"}}})
|
|
45
|
-
saved = mechanisms.claude_mcp_config
|
|
46
|
-
self.addCleanup(setattr, mechanisms, "claude_mcp_config", saved)
|
|
47
|
-
mechanisms.claude_mcp_config = lambda name: ({"command": "dup-server"}, "user", None)
|
|
48
45
|
rc, env = self.run_json("disable", "all", "dup")
|
|
49
46
|
self.assertEqual(rc, 0)
|
|
50
47
|
self.assertIn(("mcp", "ok"), [(r["type"], r["status"]) for r in env["results"]])
|
|
@@ -103,13 +103,15 @@ class ClaudeJsonScopeTest(SandboxCase):
|
|
|
103
103
|
# ambiguous local scope is refused
|
|
104
104
|
with self.assertRaisesRegex(LookupError, "2 projects"):
|
|
105
105
|
mcp_json.claude_mcp_config("dup")
|
|
106
|
+
# a disable marks it off in every project and never removes either copy, so
|
|
107
|
+
# nothing has to be guessed
|
|
106
108
|
state = {"version": 2, "disabled": {}}
|
|
107
109
|
fails = mechanisms.toggle_mcp("disable", ["dup"], state, "claude", self.home,
|
|
108
110
|
"claude-json")
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
self.assertEqual(fails, 0)
|
|
112
|
+
projects = json.loads(fs.claude_json().read_text(encoding="utf-8"))["projects"]
|
|
113
|
+
self.assertEqual([(d["mcpServers"]["dup"]["command"], d["disabledMcpServers"])
|
|
114
|
+
for d in projects.values()], [("a", ["dup"]), ("b", ["dup"])])
|
|
113
115
|
|
|
114
116
|
def test_mcp_enable_defaults_to_user_scope_for_old_entries(self) -> None:
|
|
115
117
|
"""Entries parked before scope tracking have no scope field."""
|
|
@@ -151,6 +153,39 @@ class ClaudeJsonScopeTest(SandboxCase):
|
|
|
151
153
|
self.assertEqual(projects["/work/a"]["disabledMcpServers"], ["other"])
|
|
152
154
|
self.assertEqual(projects["/work/b"]["disabledMcpServers"], ["other"])
|
|
153
155
|
|
|
156
|
+
def test_configured_server_is_marked_off_in_place(self) -> None:
|
|
157
|
+
"""Claude Code builds its MCP list once per session and `/mcp enable` reaches only a
|
|
158
|
+
listed server: disable keeps the config entry and marks it off per project, so a
|
|
159
|
+
session started while it is off still lists it and `/mcp enable` loads it there."""
|
|
160
|
+
entry = {"type": "http", "url": "https://example.invalid/mcp"}
|
|
161
|
+
self.claude_json({"mcpServers": {"example-mcp": entry}, "projects": {
|
|
162
|
+
"/work/a": {"disabledMcpServers": ["other"]}, "/work/b": {}}})
|
|
163
|
+
state = {"version": 3, "disabled": {}}
|
|
164
|
+
out = Result()
|
|
165
|
+
fails = mechanisms.toggle_mcp("disable", ["example-mcp"], state, "claude",
|
|
166
|
+
self.home, "claude-json", out)
|
|
167
|
+
self.assertEqual(fails, 0)
|
|
168
|
+
cfg = json.loads(fs.claude_json().read_text(encoding="utf-8"))
|
|
169
|
+
# the entry stays where it was; no `claude mcp remove`, no backup file
|
|
170
|
+
self.assertEqual(cfg["mcpServers"]["example-mcp"], entry)
|
|
171
|
+
self.assertEqual(self.cli_calls, [])
|
|
172
|
+
self.assertFalse(mechanisms._json_backup_path("claude", "example-mcp").exists())
|
|
173
|
+
self.assertEqual({p: d.get("disabledMcpServers") for p, d in cfg["projects"].items()},
|
|
174
|
+
{"/work/a": ["other", "example-mcp"], "/work/b": ["example-mcp"]})
|
|
175
|
+
row = out.rows[-1]
|
|
176
|
+
self.assertEqual((row["status"], row.get("connector"), row.get("backup")), ("ok", True, None))
|
|
177
|
+
self.assertTrue(state["disabled"]["claude:mcp:example-mcp"]["connector"])
|
|
178
|
+
|
|
179
|
+
fails = mechanisms.toggle_mcp("enable", ["example-mcp"], state, "claude",
|
|
180
|
+
self.home, "claude-json", Result())
|
|
181
|
+
self.assertEqual(fails, 0)
|
|
182
|
+
cfg = json.loads(fs.claude_json().read_text(encoding="utf-8"))
|
|
183
|
+
self.assertEqual(cfg["mcpServers"]["example-mcp"], entry)
|
|
184
|
+
self.assertEqual({p: d.get("disabledMcpServers") for p, d in cfg["projects"].items()},
|
|
185
|
+
{"/work/a": ["other"], "/work/b": []})
|
|
186
|
+
self.assertEqual(state["disabled"], {})
|
|
187
|
+
self.assertEqual(self.cli_calls, [])
|
|
188
|
+
|
|
154
189
|
|
|
155
190
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
156
191
|
|
|
@@ -98,11 +98,11 @@ class ConformanceTest(CliCase):
|
|
|
98
98
|
self.assertEqual(self.run_cli("disable", "mcp", "example-mcp")[0], 0)
|
|
99
99
|
self.assertEqual(self.run_cli("enable", "mcp", "example-mcp")[0], 0)
|
|
100
100
|
calls = [c for c, _ in self.cli_calls]
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
self.
|
|
105
|
-
self.assertEqual(
|
|
101
|
+
# an MCP server is marked off in place: no `claude mcp remove|add-json`
|
|
102
|
+
self.assertEqual(calls, [["plugin", "disable", "demo-plugin"],
|
|
103
|
+
["plugin", "enable", "demo-plugin"]])
|
|
104
|
+
cfg = json.loads((self.tmp / ".claude.json").read_text(encoding="utf-8"))
|
|
105
|
+
self.assertEqual(cfg["mcpServers"]["example-mcp"], orig)
|
|
106
106
|
|
|
107
107
|
def test_dry_run_writes_nothing(self) -> None:
|
|
108
108
|
for harness, type_ in PAIRS:
|
|
@@ -850,8 +850,10 @@ class PluginCrashTest(CliCrashCase):
|
|
|
850
850
|
|
|
851
851
|
|
|
852
852
|
class ClaudeMcpCrashTest(CliCrashCase):
|
|
853
|
-
"""claude MCP servers
|
|
854
|
-
|
|
853
|
+
"""claude MCP servers an older version removed (`claude mcp remove`, config in a
|
|
854
|
+
backup): enable's `claude mcp add-json` writes ~/.claude.json, so settle reads that
|
|
855
|
+
file strictly (no CLI call); unreadable -> stuck. A disable now only marks a server
|
|
856
|
+
off (no write-ahead), so a pending disable is one an older version left."""
|
|
855
857
|
|
|
856
858
|
KEY = "claude:mcp:demo-mcp"
|
|
857
859
|
ARGS = ("mcp", "demo-mcp", "--harness", "claude")
|
|
@@ -873,8 +875,35 @@ class ClaudeMcpCrashTest(CliCrashCase):
|
|
|
873
875
|
"name": "demo-mcp", "backend": "claude-json", "backup": str(self.bp),
|
|
874
876
|
"scope": "user", "project": None, "at": "2026-01-01T00:00:00+0000", **fields}
|
|
875
877
|
|
|
878
|
+
def old_disable(self, landed: bool, name: str = "demo-mcp", project: str | None = None) -> None:
|
|
879
|
+
"""A disable by an older version, killed around its `claude mcp remove`: the
|
|
880
|
+
backup written, the op pending, the server gone from ~/.claude.json if it landed."""
|
|
881
|
+
cfg = json.loads(self.cj.read_text(encoding="utf-8"))
|
|
882
|
+
box = cfg if project is None else cfg["projects"][project]
|
|
883
|
+
bp = fs.backup_dir() / f"claude__{name}.json"
|
|
884
|
+
fs.private_dir(fs.backup_dir())
|
|
885
|
+
bp.write_text(json.dumps(box["mcpServers"][name]), encoding="utf-8")
|
|
886
|
+
if landed:
|
|
887
|
+
del box["mcpServers"][name]
|
|
888
|
+
self.cj.write_text(json.dumps(cfg), encoding="utf-8")
|
|
889
|
+
self.forge(f"claude:mcp:{name}", "disable", self.entry(
|
|
890
|
+
name=name, backup=str(bp), scope="local" if project else "user", project=project))
|
|
891
|
+
|
|
892
|
+
def old_disabled(self) -> None:
|
|
893
|
+
"""A server an older version disabled: removed, its config in the backup."""
|
|
894
|
+
self.old_disable(landed=True)
|
|
895
|
+
store.save_state({"version": 3, "disabled": {self.KEY: self.entry()}})
|
|
896
|
+
|
|
897
|
+
def test_a_disable_marks_it_off_and_never_calls_the_cli(self) -> None:
|
|
898
|
+
self.assertEqual(self.run_json("disable", *self.ARGS)[0], 0)
|
|
899
|
+
self.assertNotIn("pending", self.raw_state())
|
|
900
|
+
self.assertIn(self.KEY, self.raw_state()["disabled"])
|
|
901
|
+
self.assertIn("demo-mcp", self.servers())
|
|
902
|
+
self.assertFalse(self.bp.exists())
|
|
903
|
+
self.assertEqual(self.cli_calls, [])
|
|
904
|
+
|
|
876
905
|
def test_kill_after_the_cli_remove_is_recorded_and_restorable(self) -> None:
|
|
877
|
-
self.
|
|
906
|
+
self.old_disable(landed=True)
|
|
878
907
|
self.assertEqual(self.pending()[self.KEY]["entry"]["backend"], "claude-json")
|
|
879
908
|
self.assertNotIn("demo-mcp", self.servers())
|
|
880
909
|
self.assertEqual(self.raw_state()["disabled"], {})
|
|
@@ -890,7 +919,7 @@ class ClaudeMcpCrashTest(CliCrashCase):
|
|
|
890
919
|
self.assertEqual(self.servers()["demo-mcp"], {"command": "demo"})
|
|
891
920
|
|
|
892
921
|
def test_kill_before_the_cli_remove_rolls_back_and_drops_the_backup(self) -> None:
|
|
893
|
-
self.
|
|
922
|
+
self.old_disable(landed=False)
|
|
894
923
|
self.assertTrue(self.bp.exists())
|
|
895
924
|
self.assertTrue(any("never reached disk" in w for w in self.warnings("status")))
|
|
896
925
|
self.run_json(*NEXT)
|
|
@@ -898,15 +927,14 @@ class ClaudeMcpCrashTest(CliCrashCase):
|
|
|
898
927
|
self.assertFalse(self.bp.exists())
|
|
899
928
|
|
|
900
929
|
def test_kill_after_the_cli_add_drops_the_entry(self) -> None:
|
|
901
|
-
self.
|
|
902
|
-
self.assertNotIn("pending", self.raw_state())
|
|
930
|
+
self.old_disabled()
|
|
903
931
|
self.kill_in_cli("enable", *self.ARGS, landed=True)
|
|
904
932
|
self.assertEqual(len(self.doctor_rows("done")), 1)
|
|
905
933
|
self.run_json(*NEXT)
|
|
906
934
|
self.assert_settled(False)
|
|
907
935
|
|
|
908
936
|
def test_kill_before_the_cli_add_keeps_it_disabled(self) -> None:
|
|
909
|
-
self.
|
|
937
|
+
self.old_disabled()
|
|
910
938
|
self.kill_in_cli("enable", *self.ARGS, landed=False)
|
|
911
939
|
self.assertEqual(len(self.doctor_rows("undone")), 1)
|
|
912
940
|
self.run_json(*NEXT)
|
|
@@ -915,20 +943,21 @@ class ClaudeMcpCrashTest(CliCrashCase):
|
|
|
915
943
|
|
|
916
944
|
def test_settle_needs_no_cli(self) -> None:
|
|
917
945
|
from agent_toggle.backends import plugin_cli
|
|
918
|
-
self.
|
|
946
|
+
self.old_disable(landed=True)
|
|
919
947
|
plugin_cli.which = lambda name: None
|
|
920
948
|
self.run_json(*NEXT)
|
|
921
949
|
self.assert_settled(True)
|
|
922
950
|
|
|
923
|
-
def
|
|
951
|
+
def test_a_failed_cli_add_settles_inline(self) -> None:
|
|
952
|
+
self.old_disabled()
|
|
924
953
|
self.broken = True
|
|
925
|
-
rc, env = self.run_json("
|
|
954
|
+
rc, env = self.run_json("enable", *self.ARGS)
|
|
926
955
|
self.assertEqual(rc, 1)
|
|
927
|
-
self.assert_settled(
|
|
928
|
-
self.
|
|
956
|
+
self.assert_settled(True)
|
|
957
|
+
self.assertTrue(self.bp.exists())
|
|
929
958
|
|
|
930
959
|
def test_an_unreadable_claude_json_is_stuck_never_guessed(self) -> None:
|
|
931
|
-
self.
|
|
960
|
+
self.old_disable(landed=True)
|
|
932
961
|
for body in ("{broken", "[]", '{"mcpServers": []}'):
|
|
933
962
|
with self.subTest(body):
|
|
934
963
|
self.cj.write_text(body, encoding="utf-8")
|
|
@@ -941,7 +970,7 @@ class ClaudeMcpCrashTest(CliCrashCase):
|
|
|
941
970
|
self.assertTrue(self.bp.exists())
|
|
942
971
|
|
|
943
972
|
def test_a_gone_server_without_its_backup_is_stuck(self) -> None:
|
|
944
|
-
self.
|
|
973
|
+
self.old_disable(landed=True)
|
|
945
974
|
self.bp.unlink()
|
|
946
975
|
stuck = self.doctor_rows("stuck")
|
|
947
976
|
self.assertEqual(len(stuck), 1, stuck)
|
|
@@ -955,7 +984,7 @@ class ClaudeMcpCrashTest(CliCrashCase):
|
|
|
955
984
|
proj.mkdir()
|
|
956
985
|
self.cj.write_text(json.dumps({"mcpServers": {}, "projects": {
|
|
957
986
|
str(proj): {"mcpServers": {"loc-mcp": {"command": "loc"}}}}}), encoding="utf-8")
|
|
958
|
-
self.
|
|
987
|
+
self.old_disable(landed=True, name="loc-mcp", project=str(proj))
|
|
959
988
|
entry = self.pending()["claude:mcp:loc-mcp"]["entry"]
|
|
960
989
|
self.assertEqual((entry["scope"], entry["project"]), ("local", str(proj)))
|
|
961
990
|
self.run_json(*NEXT)
|
|
@@ -23,6 +23,14 @@ def mode(p: Path) -> int:
|
|
|
23
23
|
return p.stat().st_mode & 0o777
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def codex_toml(home: Path) -> None:
|
|
27
|
+
"""A codex config whose MCP server carries a token: its disable writes a backup."""
|
|
28
|
+
(home / ".codex").mkdir()
|
|
29
|
+
(home / ".codex" / "config.toml").write_text(
|
|
30
|
+
'[mcp_servers.example-mcp]\ncommand = "example"\n\n'
|
|
31
|
+
'[mcp_servers.example-mcp.env]\nTOKEN = "Bearer test-token-000"\n', encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
26
34
|
class LockTest(SandboxCase):
|
|
27
35
|
def setUp(self) -> None:
|
|
28
36
|
super().setUp()
|
|
@@ -121,16 +129,12 @@ class PrivateModeTest(SandboxCase):
|
|
|
121
129
|
self.assertEqual([x.name for x in self.tmp.glob(".out.json*")], [])
|
|
122
130
|
|
|
123
131
|
def test_mcp_disable_writes_0600_backup_and_state(self) -> None:
|
|
124
|
-
|
|
125
|
-
saved = mechanisms.claude_mcp_config
|
|
126
|
-
self.addCleanup(setattr, mechanisms, "claude_mcp_config", saved)
|
|
127
|
-
mechanisms.claude_mcp_config = lambda name: (raw, "user", None)
|
|
128
|
-
self.cli_rc = 0 # stubbed `claude mcp remove` succeeds
|
|
132
|
+
codex_toml(self.tmp)
|
|
129
133
|
state = {"version": 3, "disabled": {}}
|
|
130
|
-
fails = mechanisms.toggle_mcp("disable", ["example-mcp"], state, "
|
|
131
|
-
self.
|
|
134
|
+
fails = mechanisms.toggle_mcp("disable", ["example-mcp"], state, "codex",
|
|
135
|
+
self.tmp / ".codex", "toml")
|
|
132
136
|
self.assertEqual(fails, 0)
|
|
133
|
-
entry = state["disabled"]["
|
|
137
|
+
entry = state["disabled"]["codex:mcp:example-mcp"]
|
|
134
138
|
self.assertEqual(entry["mechanism"], "remove_backup")
|
|
135
139
|
self.assertEqual(mode(Path(entry["backup"])), 0o600)
|
|
136
140
|
store.save_state(state)
|
|
@@ -159,12 +163,8 @@ class PrivateModeTest(SandboxCase):
|
|
|
159
163
|
class PrivateDirTest(SandboxCase):
|
|
160
164
|
def test_state_backup_dirs_and_log_are_private_under_open_umask(self) -> None:
|
|
161
165
|
self.addCleanup(os.umask, os.umask(0))
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
self.addCleanup(setattr, mechanisms, "claude_mcp_config", saved)
|
|
165
|
-
mechanisms.claude_mcp_config = lambda name: (raw, "user", None)
|
|
166
|
-
self.cli_rc = 0
|
|
167
|
-
self.assertEqual(cli.main(["disable", "mcp", "example-mcp"]), 0)
|
|
166
|
+
codex_toml(self.tmp)
|
|
167
|
+
self.assertEqual(cli.main(["disable", "mcp", "example-mcp", "--harness", "codex"]), 0)
|
|
168
168
|
for d in (fs.state_dir(), fs.backup_dir()):
|
|
169
169
|
self.assertEqual(mode(d), 0o700, d)
|
|
170
170
|
self.assertEqual(mode(fs.log_file()), 0o600)
|
|
@@ -114,6 +114,23 @@ class SecretAuditTest(CliCase):
|
|
|
114
114
|
self.assertEqual(stat.S_IMODE(b.stat().st_mode), 0o600, b)
|
|
115
115
|
self.assertEqual(stat.S_IMODE(fs.backup_dir().stat().st_mode) & 0o077, 0)
|
|
116
116
|
|
|
117
|
+
def park_claude_legacy(self) -> None:
|
|
118
|
+
"""The claude servers as an older version disabled them: removed from ~/.claude.json,
|
|
119
|
+
their config in a backup, so enable runs `claude mcp add-json` with it."""
|
|
120
|
+
cj = json.loads(fs.claude_json().read_text(encoding="utf-8"))
|
|
121
|
+
state = store.load_state()
|
|
122
|
+
fs.private_dir(fs.backup_dir())
|
|
123
|
+
for name, box, proj in (("secret-mcp", cj, None),
|
|
124
|
+
("local-mcp", cj["projects"][str(self.proj)], str(self.proj))):
|
|
125
|
+
bp = fs.backup_dir() / f"claude__{name}.json"
|
|
126
|
+
fs.atomic_write(bp, json.dumps(box["mcpServers"].pop(name), indent=2))
|
|
127
|
+
state["disabled"][f"claude:mcp:{name}"] = {
|
|
128
|
+
"mechanism": "remove_backup", "harness": "claude", "type": "mcp", "name": name,
|
|
129
|
+
"backend": "claude-json", "backup": str(bp), "scope": "local" if proj else "user",
|
|
130
|
+
"project": proj, "at": "2026-01-01T00:00:00+0000"}
|
|
131
|
+
fs.claude_json().write_text(json.dumps(cj), encoding="utf-8")
|
|
132
|
+
store.save_state(state)
|
|
133
|
+
|
|
117
134
|
def batch(self, name: str):
|
|
118
135
|
return mock.patch.object(store, "BATCH", name)
|
|
119
136
|
|
|
@@ -189,6 +206,7 @@ class SecretAuditTest(CliCase):
|
|
|
189
206
|
|
|
190
207
|
def test_cli_error_echoing_the_config_is_redacted(self) -> None:
|
|
191
208
|
self.each("disable")
|
|
209
|
+
self.park_claude_legacy()
|
|
192
210
|
self.cli_rc = 1 # add-json fails and echoes its config
|
|
193
211
|
for extra in VARIANTS:
|
|
194
212
|
for t in self.TARGETS[:2]:
|
|
@@ -205,6 +223,7 @@ class SecretAuditTest(CliCase):
|
|
|
205
223
|
|
|
206
224
|
def test_unexpected_exception_traceback_shows_no_value(self) -> None:
|
|
207
225
|
self.each("disable")
|
|
226
|
+
self.park_claude_legacy()
|
|
208
227
|
|
|
209
228
|
def boom(cmd, **kw):
|
|
210
229
|
raise RuntimeError("cli crashed")
|
|
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
|
|
File without changes
|