claude-multiacc 2.0.58 → 2.0.60
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.
- package/README.md +125 -4
- package/bin/claude +389 -94
- package/bin/claude-accounts +276 -42
- package/bin/codex +69 -5
- package/bin/codex-accounts +33 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +28 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +81 -9
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +146 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/mcp_registry.py +230 -8
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_codex_tui.py +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +109 -0
- package/tests/run-tests.sh +1171 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +14 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- package/tests/test_mcp_registry.py +234 -3
- package/tests/test_packaged_commands.py +41 -0
|
@@ -88,7 +88,7 @@ class EffectiveMergeTests(RegistryCase):
|
|
|
88
88
|
|
|
89
89
|
|
|
90
90
|
class ClaudeReconcileTests(RegistryCase):
|
|
91
|
-
def
|
|
91
|
+
def test_upsert_purges_tombstones_everywhere_and_adopts_extras(self):
|
|
92
92
|
(self.root / "acct-01" / ".claude.json").write_text(json.dumps({
|
|
93
93
|
"numStartups": 3,
|
|
94
94
|
"mcpServers": {"adspower-local-api": STDIO, "mine": STDIO,
|
|
@@ -107,7 +107,10 @@ class ClaudeReconcileTests(RegistryCase):
|
|
|
107
107
|
{"type": "stdio", "command": "npx",
|
|
108
108
|
"args": ["-y", "appinspire-mcp@latest", "serve"], "env": {"K": "V"}})
|
|
109
109
|
self.assertEqual(sorted(doc["projects"]["/p"]["mcpServers"]), ["keep"])
|
|
110
|
-
|
|
110
|
+
# an extra no section names is adopted: every account gets it (2026-10-22)
|
|
111
|
+
self.assertEqual(sorted(self.claude_doc("acct-02")["mcpServers"]), ["appinspire-mcp", "mine"])
|
|
112
|
+
self.assertEqual(sorted(self.claude_doc("acct-02")["projects"]["/p"]["mcpServers"]), ["keep"])
|
|
113
|
+
self.assertEqual(sorted(self.registry()["mcpServers"]), ["appinspire-mcp", "mine"])
|
|
111
114
|
self.assertEqual(self.registry()["retired"], ["adspower-local-api"])
|
|
112
115
|
self.assertTrue((self.root / "acct-01" / ".mcp-applied").exists())
|
|
113
116
|
|
|
@@ -514,7 +517,9 @@ class CodexReconcileTests(RegistryCase):
|
|
|
514
517
|
self.assertEqual(rc, 0)
|
|
515
518
|
doc = self.parsed()
|
|
516
519
|
self.assertEqual(doc["mcp_servers"]["old"]["command"], "new")
|
|
517
|
-
|
|
520
|
+
# the extra is adopted, so it now comes back in the registry's own translation
|
|
521
|
+
self.assertEqual(doc["mcp_servers"]["other"]["command"], "c")
|
|
522
|
+
self.assertIn("other", self.registry()["mcpServers"])
|
|
518
523
|
|
|
519
524
|
def test_an_unsupported_layout_is_refused_and_the_file_survives(self):
|
|
520
525
|
original = 'mcp_servers = { old = { command = "a" } }\n'
|
|
@@ -593,5 +598,231 @@ class CodexReconcileTests(RegistryCase):
|
|
|
593
598
|
self.assertIn("acct-01: unchanged", out)
|
|
594
599
|
|
|
595
600
|
|
|
601
|
+
class AdoptTests(RegistryCase):
|
|
602
|
+
"""A server some route around the shims' mirror put into ONE account (2026-10-22: the
|
|
603
|
+
operator's `alias claude='claude --dangerously-skip-permissions'` hid every typed
|
|
604
|
+
`claude mcp add` from it) is adopted by the next reconcile, and every account gets it."""
|
|
605
|
+
|
|
606
|
+
MOTION = {"type": "http", "url": "https://xgdzyqfalbibzelpdpvr.supabase.co/functions/v1/mcp"}
|
|
607
|
+
|
|
608
|
+
def write(self, account, doc):
|
|
609
|
+
(self.root / account).mkdir(exist_ok=True)
|
|
610
|
+
(self.root / account / ".claude.json").write_text(json.dumps(doc))
|
|
611
|
+
|
|
612
|
+
def test_one_accounts_server_reaches_every_account_from_a_single_account_apply(self):
|
|
613
|
+
self.write("acct-01", {"mcpServers": {"motionsites": self.MOTION}})
|
|
614
|
+
self.write("acct-02", {"mcpServers": {}})
|
|
615
|
+
# what the shim runs before exec, for the picked account alone
|
|
616
|
+
rc, _, _ = self.run_cli("claude", "apply", "--account-dir", self.acct("acct-01"),
|
|
617
|
+
"--fail-open", "--quiet")
|
|
618
|
+
self.assertEqual(rc, 0)
|
|
619
|
+
self.assertEqual(self.registry()["mcpServers"], {"motionsites": self.MOTION})
|
|
620
|
+
self.assertEqual(self.claude_doc("acct-02")["mcpServers"], {"motionsites": self.MOTION})
|
|
621
|
+
self.assertIn('mcp adopt "motionsites" into the registry',
|
|
622
|
+
(self.root / "ops.log").read_text())
|
|
623
|
+
# nothing left to adopt: a second pass changes nothing
|
|
624
|
+
before = (self.root / "mcp-servers.json").read_text()
|
|
625
|
+
rc, out, _ = self.run_cli("claude", "apply", "--all")
|
|
626
|
+
self.assertEqual((rc, before), (0, (self.root / "mcp-servers.json").read_text()))
|
|
627
|
+
self.assertNotIn("adopted", out)
|
|
628
|
+
|
|
629
|
+
def test_the_definition_most_accounts_hold_wins(self):
|
|
630
|
+
headless = {"type": "stdio", "command": "npx", "env": {},
|
|
631
|
+
"args": ["-y", "@playwright/mcp@latest", "--headless"]}
|
|
632
|
+
plain = {"type": "stdio", "command": "npx", "args": ["-y", "@playwright/mcp@latest"],
|
|
633
|
+
"env": {}}
|
|
634
|
+
(self.root / "accounts.json").write_text(json.dumps({"version": 1, "accounts": [
|
|
635
|
+
{"id": "acct-01"}, {"id": "acct-02"}, {"id": "acct-03"}]}))
|
|
636
|
+
self.write("acct-01", {"mcpServers": {"playwright": plain}}) # the newest file
|
|
637
|
+
self.write("acct-02", {"mcpServers": {"playwright": headless}})
|
|
638
|
+
self.write("acct-03", {"mcpServers": {"playwright": headless}})
|
|
639
|
+
os.utime(self.root / "acct-02" / ".claude.json", (1, 1))
|
|
640
|
+
os.utime(self.root / "acct-03" / ".claude.json", (1, 1))
|
|
641
|
+
rc, out, _ = self.run_cli("claude", "apply", "--all")
|
|
642
|
+
self.assertEqual(rc, 0)
|
|
643
|
+
self.assertIn("the definition 2 of 3 accounts hold", out)
|
|
644
|
+
for account in ("acct-01", "acct-02", "acct-03"):
|
|
645
|
+
self.assertEqual(self.claude_doc(account)["mcpServers"]["playwright"], headless)
|
|
646
|
+
|
|
647
|
+
def test_a_tie_goes_to_the_newest_config(self):
|
|
648
|
+
old = {"type": "http", "url": "https://old.example/mcp"}
|
|
649
|
+
new = {"type": "http", "url": "https://new.example/mcp"}
|
|
650
|
+
self.write("acct-01", {"mcpServers": {"x": old}})
|
|
651
|
+
self.write("acct-02", {"mcpServers": {"x": new}})
|
|
652
|
+
os.utime(self.root / "acct-01" / ".claude.json", (1, 1))
|
|
653
|
+
self.run_cli("claude", "apply", "--all")
|
|
654
|
+
self.assertEqual(self.registry()["mcpServers"]["x"], new)
|
|
655
|
+
|
|
656
|
+
def test_retired_servers_are_never_adopted(self):
|
|
657
|
+
self.run_cli("claude", "add-json", "gone", json.dumps(HTTP))
|
|
658
|
+
self.run_cli("claude", "remove", "gone")
|
|
659
|
+
self.run_cli("claude", "add-json", "useronly", json.dumps(HTTP))
|
|
660
|
+
self.run_cli("claude", "remove", "useronly", "--user-only")
|
|
661
|
+
self.write("acct-01", {"mcpServers": {"gone": HTTP, "useronly": HTTP},
|
|
662
|
+
"projects": {"/p": {"mcpServers": {"gone": HTTP}}}})
|
|
663
|
+
rc, _, _ = self.run_cli("claude", "apply", "--all")
|
|
664
|
+
self.assertEqual(rc, 0)
|
|
665
|
+
self.assertEqual(self.registry()["mcpServers"], {})
|
|
666
|
+
self.assertEqual(self.claude_doc("acct-01")["mcpServers"], {})
|
|
667
|
+
self.assertEqual(self.claude_doc("acct-01")["projects"]["/p"]["mcpServers"], {})
|
|
668
|
+
|
|
669
|
+
def test_an_overlay_owners_servers_and_tombstones_stay_out_of_the_registry(self):
|
|
670
|
+
self.run_cli("claude", "import-local", "--owner", "app-robot",
|
|
671
|
+
stdin=json.dumps({"mcpServers": {"robot": STDIO}, "retired": ["dropped"]}))
|
|
672
|
+
self.write("acct-01", {"mcpServers": {"robot": STDIO, "dropped": HTTP}})
|
|
673
|
+
self.run_cli("claude", "apply", "--all")
|
|
674
|
+
self.assertFalse((self.root / "mcp-servers.json").exists()) # nothing adopted
|
|
675
|
+
self.assertEqual(sorted(self.claude_doc("acct-02")["mcpServers"]), ["robot"])
|
|
676
|
+
|
|
677
|
+
def test_project_servers_are_adopted_but_mcpjson_approvals_are_not(self):
|
|
678
|
+
# one account's click on a project's .mcp.json server must not override another
|
|
679
|
+
# account's rejection everywhere (disabledMcpjsonServers)
|
|
680
|
+
self.write("acct-01", {"projects": {"/p": {"mcpServers": {"local": STDIO},
|
|
681
|
+
"enabledMcpjsonServers": ["team"]}}})
|
|
682
|
+
self.write("acct-02", {"projects": {"/p": {"disabledMcpjsonServers": ["team"]}}})
|
|
683
|
+
self.run_cli("claude", "apply", "--all")
|
|
684
|
+
project = self.claude_doc("acct-02")["projects"]["/p"]
|
|
685
|
+
self.assertEqual(project["mcpServers"], {"local": STDIO})
|
|
686
|
+
self.assertNotIn("team", project.get("enabledMcpjsonServers", []))
|
|
687
|
+
self.assertEqual(project["disabledMcpjsonServers"], ["team"])
|
|
688
|
+
|
|
689
|
+
def test_a_single_account_apply_chooses_what_every_account_would(self):
|
|
690
|
+
old = {"type": "http", "url": "https://old.example/mcp"}
|
|
691
|
+
new = {"type": "http", "url": "https://new.example/mcp"}
|
|
692
|
+
(self.root / "accounts.json").write_text(json.dumps({"version": 1, "accounts": [
|
|
693
|
+
{"id": "acct-01"}, {"id": "acct-02"}, {"id": "acct-03"}]}))
|
|
694
|
+
self.write("acct-01", {"mcpServers": {"x": old}}) # the account launched first
|
|
695
|
+
self.write("acct-02", {"mcpServers": {"x": new}})
|
|
696
|
+
self.write("acct-03", {"mcpServers": {"x": new}})
|
|
697
|
+
self.run_cli("claude", "apply", "--account-dir", self.acct("acct-01"),
|
|
698
|
+
"--fail-open", "--quiet")
|
|
699
|
+
for account in ("acct-01", "acct-02", "acct-03"):
|
|
700
|
+
self.assertEqual(self.claude_doc(account)["mcpServers"]["x"], new)
|
|
701
|
+
|
|
702
|
+
def test_an_account_being_seeded_gets_the_registry_too(self):
|
|
703
|
+
# seeding applies to a dir the manifest does not list yet
|
|
704
|
+
self.run_cli("claude", "add-json", "reg1", json.dumps(HTTP))
|
|
705
|
+
self.write("acct-01", {"mcpServers": {"reg1": HTTP, "newone": self.MOTION}})
|
|
706
|
+
self.write("acct-07", {"mcpServers": {}})
|
|
707
|
+
self.run_cli("claude", "apply", "--account-dir", self.acct("acct-07"))
|
|
708
|
+
self.assertEqual(sorted(self.claude_doc("acct-07")["mcpServers"]), ["newone", "reg1"])
|
|
709
|
+
|
|
710
|
+
def test_a_server_carrying_values_stays_on_this_machine(self):
|
|
711
|
+
secret = {"type": "http", "url": "https://api.example/mcp",
|
|
712
|
+
"headers": {"Authorization": "Bearer abc"}}
|
|
713
|
+
referenced = {"type": "http", "url": "https://api.example/mcp2",
|
|
714
|
+
"headers": {"Authorization": "${API_TOKEN}"}}
|
|
715
|
+
self.write("acct-01", {"mcpServers": {"secret": secret, "referenced": referenced}})
|
|
716
|
+
self.run_cli("claude", "apply", "--all")
|
|
717
|
+
self.assertEqual(list(self.registry()["mcpServers"]), ["referenced"])
|
|
718
|
+
self.assertEqual(self.overlay()["owners"]["local"]["mcpServers"], {"secret": secret})
|
|
719
|
+
self.assertEqual(self.claude_doc("acct-02")["mcpServers"]["secret"], secret)
|
|
720
|
+
|
|
721
|
+
def test_an_overlay_owners_drop_stays_retired_across_imports(self):
|
|
722
|
+
self.write("acct-01", {"mcpServers": {}})
|
|
723
|
+
for payload in ({"mcpServers": {"robot": STDIO}}, {"mcpServers": {}},
|
|
724
|
+
{"mcpServers": {"other": HTTP}}):
|
|
725
|
+
self.run_cli("claude", "import-local", "--owner", "app-robot", stdin=json.dumps(payload))
|
|
726
|
+
self.assertIn("robot", self.overlay()["owners"]["app-robot"]["retired"])
|
|
727
|
+
# a copy that never went through the ledger (a dir copied from an old account)
|
|
728
|
+
self.write("acct-02", {"mcpServers": {"robot": STDIO}})
|
|
729
|
+
self.run_cli("claude", "apply", "--all")
|
|
730
|
+
self.assertFalse((self.root / "mcp-servers.json").exists())
|
|
731
|
+
self.assertNotIn("robot", self.claude_doc("acct-02")["mcpServers"])
|
|
732
|
+
# publishing it again lifts the tombstone
|
|
733
|
+
self.run_cli("claude", "import-local", "--owner", "app-robot",
|
|
734
|
+
stdin=json.dumps({"mcpServers": {"robot": STDIO}}))
|
|
735
|
+
self.assertNotIn("robot", self.overlay()["owners"]["app-robot"]["retired"])
|
|
736
|
+
|
|
737
|
+
def test_a_replica_adopts_into_its_own_overlay(self):
|
|
738
|
+
(self.root / "sync-role").write_text("replica\n")
|
|
739
|
+
self.write("acct-01", {"mcpServers": {"motionsites": self.MOTION}})
|
|
740
|
+
self.run_cli("claude", "apply", "--all")
|
|
741
|
+
self.assertFalse((self.root / "mcp-servers.json").exists())
|
|
742
|
+
self.assertEqual(self.overlay()["owners"]["local"]["mcpServers"],
|
|
743
|
+
{"motionsites": self.MOTION})
|
|
744
|
+
self.assertEqual(self.claude_doc("acct-02")["mcpServers"], {"motionsites": self.MOTION})
|
|
745
|
+
|
|
746
|
+
def test_a_copy_the_pool_wrote_itself_is_never_adopted_back(self):
|
|
747
|
+
# README's recovery for a stray synced entry: drop it WITHOUT a tombstone. The copies
|
|
748
|
+
# the reconcile wrote stay in the accounts, and must not come back as "new" servers.
|
|
749
|
+
self.write("acct-01", {"mcpServers": {}})
|
|
750
|
+
self.write("acct-02", {"mcpServers": {}})
|
|
751
|
+
self.run_cli("claude", "add-json", "stray", json.dumps(HTTP))
|
|
752
|
+
self.assertIn("stray", self.claude_doc("acct-02")["mcpServers"])
|
|
753
|
+
registry = self.registry()
|
|
754
|
+
del registry["mcpServers"]["stray"]
|
|
755
|
+
(self.root / "mcp-servers.json").write_text(json.dumps(registry))
|
|
756
|
+
self.run_cli("claude", "apply", "--all")
|
|
757
|
+
self.assertNotIn("stray", self.registry()["mcpServers"])
|
|
758
|
+
self.assertNotIn("adopt", (self.root / "ops.log").read_text())
|
|
759
|
+
|
|
760
|
+
def test_a_vanished_overlay_owners_server_stays_out_of_the_synced_registry(self):
|
|
761
|
+
# app-robot publishes a machine-specific appinspire-mcp into the overlay; were the
|
|
762
|
+
# overlay ever gone, every account's copy must not be pushed to the whole fleet
|
|
763
|
+
self.write("acct-01", {"mcpServers": {}})
|
|
764
|
+
self.run_cli("claude", "import-local", "--owner", "app-robot",
|
|
765
|
+
stdin=json.dumps({"mcpServers": {"appinspire-mcp": STDIO}}))
|
|
766
|
+
self.assertIn("appinspire-mcp", self.claude_doc("acct-01")["mcpServers"])
|
|
767
|
+
(self.root / "mcp-servers.local.json").unlink()
|
|
768
|
+
self.run_cli("claude", "apply", "--all")
|
|
769
|
+
self.assertFalse((self.root / "mcp-servers.json").exists())
|
|
770
|
+
ledger = json.loads((self.root / "acct-01" / ".mcp-managed.json").read_text())
|
|
771
|
+
self.assertIn("appinspire-mcp", ledger["user"])
|
|
772
|
+
|
|
773
|
+
def test_the_kill_switch_leaves_extras_where_they_are(self):
|
|
774
|
+
self.write("acct-01", {"mcpServers": {"motionsites": self.MOTION}})
|
|
775
|
+
with mock_env(CLAUDE_MULTIACC_MCP_ADOPT="0"):
|
|
776
|
+
self.run_cli("claude", "apply", "--all")
|
|
777
|
+
self.assertFalse((self.root / "mcp-servers.json").exists())
|
|
778
|
+
self.assertFalse((self.root / "acct-02" / ".claude.json").exists())
|
|
779
|
+
|
|
780
|
+
def test_an_unreadable_account_never_stops_the_others(self):
|
|
781
|
+
(self.root / "acct-01" / ".claude.json").write_text("{not json")
|
|
782
|
+
self.write("acct-02", {"mcpServers": {"motionsites": self.MOTION}})
|
|
783
|
+
rc, _, _ = self.run_cli("claude", "apply", "--all")
|
|
784
|
+
self.assertEqual(rc, R.EXIT_PARTIAL) # acct-01 itself still fails to reconcile
|
|
785
|
+
self.assertEqual(self.registry()["mcpServers"], {"motionsites": self.MOTION})
|
|
786
|
+
|
|
787
|
+
@unittest.skipUnless(HAS_TOMLLIB, "codex reconcile needs tomllib (Python 3.11+)")
|
|
788
|
+
def test_a_codex_table_the_registry_cannot_express_as_it_is_is_left_alone(self):
|
|
789
|
+
text = ('[mcp_servers.odd]\ncommand = "c"\nenabled = false\n'
|
|
790
|
+
'env_vars = [{ name = "K", source = "remote" }]\n')
|
|
791
|
+
(self.root / "acct-01" / "config.toml").write_text(text)
|
|
792
|
+
rc, _, err = self.run_cli("codex", "apply", "--all")
|
|
793
|
+
self.assertEqual(rc, 0)
|
|
794
|
+
self.assertEqual((self.root / "acct-01" / "config.toml").read_text(), text)
|
|
795
|
+
self.assertFalse((self.root / "mcp-servers.json").exists())
|
|
796
|
+
|
|
797
|
+
@unittest.skipUnless(HAS_TOMLLIB, "codex reconcile needs tomllib (Python 3.11+)")
|
|
798
|
+
def test_a_codex_accounts_server_is_adopted_for_every_codex_account(self):
|
|
799
|
+
(self.root / "acct-01" / "config.toml").write_text(
|
|
800
|
+
'[mcp_servers.motionsites]\nurl = "https://xgdzyqfalbibzelpdpvr.supabase.co/functions/v1/mcp"\n')
|
|
801
|
+
rc, _, _ = self.run_cli("codex", "apply", "--all")
|
|
802
|
+
self.assertEqual(rc, 0)
|
|
803
|
+
import tomllib
|
|
804
|
+
doc = tomllib.loads((self.root / "acct-02" / "config.toml").read_text())
|
|
805
|
+
self.assertEqual(doc["mcp_servers"]["motionsites"]["url"], self.MOTION["url"])
|
|
806
|
+
# and the round trip is stable: the next pass rewrites nothing
|
|
807
|
+
text = (self.root / "acct-01" / "config.toml").read_text()
|
|
808
|
+
rc, out, _ = self.run_cli("codex", "apply", "--all")
|
|
809
|
+
self.assertIn("acct-01: unchanged", out)
|
|
810
|
+
self.assertEqual(text, (self.root / "acct-01" / "config.toml").read_text())
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@contextlib.contextmanager
|
|
814
|
+
def mock_env(**values):
|
|
815
|
+
saved = {key: os.environ.get(key) for key in values}
|
|
816
|
+
os.environ.update(values)
|
|
817
|
+
try:
|
|
818
|
+
yield
|
|
819
|
+
finally:
|
|
820
|
+
for key, value in saved.items():
|
|
821
|
+
if value is None:
|
|
822
|
+
os.environ.pop(key, None)
|
|
823
|
+
else:
|
|
824
|
+
os.environ[key] = value
|
|
825
|
+
|
|
826
|
+
|
|
596
827
|
if __name__ == "__main__":
|
|
597
828
|
unittest.main()
|
|
@@ -61,6 +61,13 @@ class PackagedCommandsTests(unittest.TestCase):
|
|
|
61
61
|
self.assertRegex(result.stdout, r"(?i)usage")
|
|
62
62
|
result = run([str(self.package / "install.sh"), "--help"], self.env, self.work)
|
|
63
63
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
64
|
+
# The catalog verb refreshes pools when it runs; asked for help it must write nothing.
|
|
65
|
+
for prefix in (["claude-accounts"], ["codex-accounts"], ["claude-multiacc"], ["claude-multiacc", "codex"]):
|
|
66
|
+
for args in (["model-catalog", "--help"], ["model-catalog", "refresh", "--help"]):
|
|
67
|
+
with self.subTest(prefix=prefix, args=args):
|
|
68
|
+
result = run(prefix + args, self.env, self.work)
|
|
69
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
70
|
+
self.assertIn("model-catalog [refresh|show]", result.stdout)
|
|
64
71
|
self.assertEqual(before, sorted(str(path) for path in self.home.rglob("*")))
|
|
65
72
|
|
|
66
73
|
def test_verify_and_json_reports_use_the_correct_pool(self):
|
|
@@ -140,6 +147,10 @@ class PackagedCommandsTests(unittest.TestCase):
|
|
|
140
147
|
self.assertEqual(text.count("_claude_multiacc_path_guard() {"), 1, rc)
|
|
141
148
|
self.assertTrue(text.rstrip().endswith("# <<< claude-multiacc <<<"), rc)
|
|
142
149
|
self.assertFalse((self.home / ".bash_profile").exists())
|
|
150
|
+
# Every install refreshes the model catalog into both pools (the stubbed CLIs exit 1
|
|
151
|
+
# and are never needed: the refresh reads files only).
|
|
152
|
+
for pool in (".claude-accounts", ".codex-accounts"):
|
|
153
|
+
self.assertEqual(json.loads((self.home / pool / "model-catalog.json").read_text())["schema"], 1)
|
|
143
154
|
# ...and fresh login shells of every flavour now resolve the package's shims,
|
|
144
155
|
# the real binaries in ~/.local/bin notwithstanding (lib/shim_path.py is the
|
|
145
156
|
# same probe `claude-accounts status` and `health` print).
|
|
@@ -185,6 +196,36 @@ class PackagedCommandsTests(unittest.TestCase):
|
|
|
185
196
|
uname.unlink()
|
|
186
197
|
identity.unlink()
|
|
187
198
|
|
|
199
|
+
def test_installer_refreshes_the_model_catalog_and_fails_open(self):
|
|
200
|
+
pools = [self.home / ".claude-accounts", self.home / ".codex-accounts"]
|
|
201
|
+
install = ["claude-multiacc", "install", "--no-server", "--no-schedule"]
|
|
202
|
+
for pool in pools:
|
|
203
|
+
if (pool / "model-catalog.json").exists():
|
|
204
|
+
(pool / "model-catalog.json").unlink()
|
|
205
|
+
result = run(install, self.env, self.work)
|
|
206
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
207
|
+
self.assertIn("model catalog: refreshed", result.stdout)
|
|
208
|
+
shipped = json.loads((self.package / "lib/model_catalog.json").read_text())
|
|
209
|
+
for pool in pools:
|
|
210
|
+
doc = json.loads((pool / "model-catalog.json").read_text())
|
|
211
|
+
self.assertEqual(doc["schema"], 1)
|
|
212
|
+
for provider in ("claude", "codex"): # no CLI cache in these pools: the shipped lineup
|
|
213
|
+
self.assertEqual(doc[provider]["models"], shipped[provider]["models"])
|
|
214
|
+
# A refresh that cannot run never fails the install: one note, nothing written.
|
|
215
|
+
module = self.package / "lib/model_catalog.py"
|
|
216
|
+
original = module.read_text()
|
|
217
|
+
for pool in pools:
|
|
218
|
+
(pool / "model-catalog.json").unlink()
|
|
219
|
+
module.write_text("raise SystemExit(1)\n")
|
|
220
|
+
try:
|
|
221
|
+
result = run(install, self.env, self.work)
|
|
222
|
+
finally:
|
|
223
|
+
module.write_text(original)
|
|
224
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
225
|
+
self.assertIn("model catalog could not be refreshed", result.stderr)
|
|
226
|
+
self.assertIn("install complete", result.stdout)
|
|
227
|
+
self.assertEqual([pool for pool in pools if (pool / "model-catalog.json").exists()], [])
|
|
228
|
+
|
|
188
229
|
def test_selector_transport_and_version_match_across_entrypoints(self):
|
|
189
230
|
expected = None
|
|
190
231
|
for prefix in (["multiacc-select"], ["claude-multiacc", "select"]):
|