claude-multiacc 2.0.59 → 2.0.61
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 +19 -1
- package/bin/claude +78 -11
- package/bin/claude-accounts +3 -0
- package/bin/codex +65 -7
- package/bin/codex-accounts +2 -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_mirror.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/common.sh +15 -0
- package/lib/mcp_mirror.py +49 -0
- package/lib/mcp_mirror.sh +47 -0
- package/lib/mcp_registry.py +237 -10
- 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_mcp_mirror.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_registry.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/run-tests.sh +146 -1
- package/tests/test_mcp_mirror.py +148 -0
- package/tests/test_mcp_mirror_args.py +129 -0
- package/tests/test_mcp_registry.py +234 -3
package/lib/mcp_registry.py
CHANGED
|
@@ -33,8 +33,12 @@ Reconcile rules, the same for both providers:
|
|
|
33
33
|
any more" needs to hold fleet-wide; a name in `retiredUser` (a learned stock
|
|
34
34
|
`claude mcp remove -s user`, or `mcp remove --user-only`) leaves project
|
|
35
35
|
entries alone, exactly as the client itself did;
|
|
36
|
-
*
|
|
37
|
-
|
|
36
|
+
* a server an account holds that no section names, live or retired, is ADOPTED
|
|
37
|
+
into the registry before the reconcile (`adopt`), and so reaches every account:
|
|
38
|
+
some route around the shims' mirror put it there (2026-10-22: the operator's
|
|
39
|
+
`alias claude='claude --dangerously-skip-permissions'` hid every typed
|
|
40
|
+
`claude mcp add` from it, and motionsites sat in 2 of 11 accounts). Kill switch
|
|
41
|
+
`<PROVIDER>_MULTIACC_MCP_ADOPT=0`;
|
|
38
42
|
* project-scoped entries (`projects` in the registry) exist for claude only:
|
|
39
43
|
Codex 0.156 reads no per-project config at all.
|
|
40
44
|
|
|
@@ -73,6 +77,12 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
73
77
|
REGISTRY_FILE = "mcp-servers.json"
|
|
74
78
|
OVERLAY_FILE = "mcp-servers.local.json"
|
|
75
79
|
STAMP_FILE = ".mcp-applied"
|
|
80
|
+
#: Every name the pool itself ever wrote into the account (from any section): adoption
|
|
81
|
+
#: never takes one of those back, so a server dropped from a section without a tombstone
|
|
82
|
+
#: (README's recovery for a stray synced entry) or one whose overlay owner vanished is a
|
|
83
|
+
#: stale copy, not a new server — app-robot's machine-specific appinspire-mcp must never
|
|
84
|
+
#: reach the synced registry that way.
|
|
85
|
+
MANAGED_FILE = ".mcp-managed.json"
|
|
76
86
|
LOCK_DIR = os.path.join(".locks", "mcp")
|
|
77
87
|
PROVIDERS = ("claude", "codex")
|
|
78
88
|
CONFIG_FILE = {"claude": ".claude.json", "codex": "config.toml"}
|
|
@@ -168,13 +178,14 @@ def _log(root, message):
|
|
|
168
178
|
class _Lock:
|
|
169
179
|
"""mkdir lock, the same shape as lib/common.sh mutate_lock (30 s stale)."""
|
|
170
180
|
|
|
171
|
-
def __init__(self, root):
|
|
181
|
+
def __init__(self, root, wait=60):
|
|
172
182
|
self.path = os.path.join(root, LOCK_DIR)
|
|
173
183
|
self.held = False
|
|
184
|
+
self.wait = wait
|
|
174
185
|
|
|
175
186
|
def __enter__(self):
|
|
176
187
|
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
177
|
-
deadline = time.time() +
|
|
188
|
+
deadline = time.time() + self.wait
|
|
178
189
|
while True:
|
|
179
190
|
try:
|
|
180
191
|
os.mkdir(self.path)
|
|
@@ -864,18 +875,215 @@ def snapshot_codex(account_dir):
|
|
|
864
875
|
|
|
865
876
|
# ---------------------------------------------------------------- reconcile
|
|
866
877
|
|
|
878
|
+
def load_managed(account_dir):
|
|
879
|
+
"""{"user": set(names), "projects": {path: set(names)}} — the account's ledger."""
|
|
880
|
+
doc = _read_json(os.path.join(account_dir, MANAGED_FILE), {})
|
|
881
|
+
doc = doc if isinstance(doc, dict) else {}
|
|
882
|
+
user = doc.get("user") if isinstance(doc.get("user"), list) else []
|
|
883
|
+
projects = doc.get("projects") if isinstance(doc.get("projects"), dict) else {}
|
|
884
|
+
return {"user": {str(n) for n in user},
|
|
885
|
+
"projects": {str(p): {str(n) for n in names} for p, names in projects.items()
|
|
886
|
+
if isinstance(names, list)}}
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def record_managed(provider, account_dir, eff):
|
|
890
|
+
"""Add what this reconcile wrote to the ledger (it only ever grows)."""
|
|
891
|
+
managed = load_managed(account_dir)
|
|
892
|
+
user = managed["user"] | set(eff.servers)
|
|
893
|
+
projects = {path: set(names) for path, names in managed["projects"].items()}
|
|
894
|
+
if provider == "claude":
|
|
895
|
+
for path, spec in eff.projects.items():
|
|
896
|
+
names = set(spec.get("mcpServers") or {})
|
|
897
|
+
if names:
|
|
898
|
+
projects[path] = projects.get(path, set()) | names
|
|
899
|
+
if user == managed["user"] and projects == managed["projects"]:
|
|
900
|
+
return
|
|
901
|
+
try:
|
|
902
|
+
_atomic_write(os.path.join(account_dir, MANAGED_FILE), json.dumps(
|
|
903
|
+
{"user": sorted(user), "projects": {p: sorted(n) for p, n in sorted(projects.items())}},
|
|
904
|
+
indent=2) + "\n")
|
|
905
|
+
except OSError:
|
|
906
|
+
pass
|
|
907
|
+
|
|
908
|
+
|
|
867
909
|
def apply_account(root, provider, account_dir):
|
|
868
910
|
eff = effective(load_registry(root), load_overlay(root))
|
|
869
911
|
if provider == "claude":
|
|
870
912
|
state = apply_claude(account_dir, eff)
|
|
871
913
|
else:
|
|
872
914
|
state = apply_codex(account_dir, eff)
|
|
915
|
+
record_managed(provider, account_dir, eff)
|
|
873
916
|
write_stamp(root, provider, account_dir)
|
|
874
917
|
return state
|
|
875
918
|
|
|
876
919
|
|
|
920
|
+
def _adopt_enabled(provider):
|
|
921
|
+
return os.environ.get(f"{provider.upper()}_MULTIACC_MCP_ADOPT", "1") != "0"
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _unknown(eff, snap, managed=None):
|
|
925
|
+
"""What one account's snapshot holds that no section names, live or retired:
|
|
926
|
+
[(scope, path, name, block)] (path None for user scope). A project's `.mcp.json`
|
|
927
|
+
approvals are NOT adopted: one account's click would override another's rejection
|
|
928
|
+
(`disabledMcpjsonServers`) everywhere — only a mirrored `claude mcp` command carries one."""
|
|
929
|
+
managed = managed or {"user": set(), "projects": {}}
|
|
930
|
+
found = []
|
|
931
|
+
for name, block in (snap.get("user") or {}).items():
|
|
932
|
+
if not isinstance(block, dict):
|
|
933
|
+
continue
|
|
934
|
+
if name in eff.servers or name in eff.retired or name in eff.retired_user \
|
|
935
|
+
or name in managed["user"]:
|
|
936
|
+
continue
|
|
937
|
+
found.append(("user", None, name, block))
|
|
938
|
+
if snap.get("provider") != "claude":
|
|
939
|
+
return found
|
|
940
|
+
for path, entry in (snap.get("projects") or {}).items():
|
|
941
|
+
spec = eff.projects.get(path) or {}
|
|
942
|
+
known = set(spec.get("mcpServers") or {}) | set(spec.get("retired") or []) \
|
|
943
|
+
| eff.retired | managed["projects"].get(path, set())
|
|
944
|
+
for name, block in (entry.get("mcpServers") or {}).items():
|
|
945
|
+
if isinstance(block, dict) and name not in known:
|
|
946
|
+
found.append(("project", path, name, block))
|
|
947
|
+
return found
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _still_unknown(eff, scope, path, name):
|
|
951
|
+
if scope == "user":
|
|
952
|
+
return not (name in eff.servers or name in eff.retired or name in eff.retired_user)
|
|
953
|
+
spec = eff.projects.get(path) or {}
|
|
954
|
+
return not (name in (spec.get("mcpServers") or {}) or name in (spec.get("retired") or [])
|
|
955
|
+
or name in eff.retired)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _config_mtime(provider, account_dir):
|
|
959
|
+
try:
|
|
960
|
+
return os.stat(os.path.join(account_dir, CONFIG_FILE[provider])).st_mtime
|
|
961
|
+
except OSError:
|
|
962
|
+
return 0.0
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _codex_faithful(name, table):
|
|
966
|
+
"""Whether the registry's codex translation gives this table back as it is (bar the
|
|
967
|
+
startup timeout default it adds to a stdio server): only then is a codex table adopted,
|
|
968
|
+
because the reconcile then rewrites the user's own table in that translation."""
|
|
969
|
+
try:
|
|
970
|
+
again, _notes = codex_block(name, codex_to_claude(name, table))
|
|
971
|
+
except RegistryError:
|
|
972
|
+
return False
|
|
973
|
+
if again is None:
|
|
974
|
+
return False
|
|
975
|
+
again = dict(again)
|
|
976
|
+
if "startup_timeout_sec" not in table and again.get("startup_timeout_sec") == 60:
|
|
977
|
+
again.pop("startup_timeout_sec")
|
|
978
|
+
if not table.get("args") and again.get("args") == []:
|
|
979
|
+
again.pop("args", None)
|
|
980
|
+
return again == {k: v for k, v in table.items()}
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _carries_values(block):
|
|
984
|
+
"""A block whose env or headers hold literal values — a token, an API key — stays on
|
|
985
|
+
this machine: the synced registry is a plain file pushed to every peer."""
|
|
986
|
+
for key in ("env", "headers"):
|
|
987
|
+
values = block.get(key)
|
|
988
|
+
if isinstance(values, dict) and any(str(v) and not str(v).startswith("${")
|
|
989
|
+
for v in values.values()):
|
|
990
|
+
return True
|
|
991
|
+
return False
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def adopt(root, provider, account_dirs, dry=False, lock_wait=60):
|
|
995
|
+
"""Take every server these accounts hold that no section names into the registry
|
|
996
|
+
(on a replica: this machine's overlay, like any learned change). The mirror
|
|
997
|
+
(`learn`) sees only a `mcp add` the shim recognised; anything else — a global
|
|
998
|
+
option in front of `mcp` (the operator's aliases), the real binary run directly, a
|
|
999
|
+
hand edit, another tool's installer — left the server in the one account it ran
|
|
1000
|
+
under, for ever. Several accounts holding different definitions of one name: the
|
|
1001
|
+
one most of them hold wins, then the newest config file. Returns the change list
|
|
1002
|
+
(dry: the names it would adopt, nothing written)."""
|
|
1003
|
+
if not account_dirs or not _adopt_enabled(provider):
|
|
1004
|
+
return []
|
|
1005
|
+
eff = effective(load_registry(root), load_overlay(root))
|
|
1006
|
+
variants = {} # (scope, path, name) -> {canonical json: [count, newest mtime, block]}
|
|
1007
|
+
# Every account votes, whichever one this run reconciles: a single-account apply (the
|
|
1008
|
+
# shim's, before exec) must choose what `apply --all` would, never its own stale copy.
|
|
1009
|
+
voters = list(dict.fromkeys(list(account_dirs) + manifest_account_dirs(root)))
|
|
1010
|
+
for account_dir in voters:
|
|
1011
|
+
try:
|
|
1012
|
+
snap = snapshot(provider, account_dir)
|
|
1013
|
+
except RegistryError:
|
|
1014
|
+
continue # apply reports an unreadable config itself
|
|
1015
|
+
mtime = _config_mtime(provider, account_dir)
|
|
1016
|
+
for scope, path, name, block in _unknown(eff, snap, load_managed(account_dir)):
|
|
1017
|
+
slot = variants.setdefault((scope, path, name), {})
|
|
1018
|
+
key = json.dumps(block, sort_keys=True)
|
|
1019
|
+
count = slot.setdefault(key, [0, 0.0, block])
|
|
1020
|
+
count[0] += 1
|
|
1021
|
+
count[1] = max(count[1], mtime)
|
|
1022
|
+
if not variants or dry:
|
|
1023
|
+
return sorted(name for _scope, _path, name in variants) # dry: what would be adopted
|
|
1024
|
+
changes = []
|
|
1025
|
+
synced_changed = local_changed = False
|
|
1026
|
+
with _Lock(root, wait=lock_wait):
|
|
1027
|
+
target = _Target(root)
|
|
1028
|
+
local_overlay = target.overlay if target.replica else load_overlay(root)
|
|
1029
|
+
eff = effective(load_registry(root), load_overlay(root)) # what holds under the lock
|
|
1030
|
+
for (scope, path, name), slot in sorted(variants.items(), key=lambda kv: (
|
|
1031
|
+
kv[0][0], kv[0][1] or "", kv[0][2])):
|
|
1032
|
+
if not _still_unknown(eff, scope, path, name):
|
|
1033
|
+
continue # named meanwhile (a concurrent add, another machine's push)
|
|
1034
|
+
count, _mtime, block = max(slot.values(), key=lambda v: (v[0], v[1]))
|
|
1035
|
+
if provider == "codex" and not _codex_faithful(name, block):
|
|
1036
|
+
_warn(f'not adopted: "{name}" — the registry cannot express its codex table '
|
|
1037
|
+
f"as it is; register it with: codex-accounts mcp add")
|
|
1038
|
+
continue
|
|
1039
|
+
try:
|
|
1040
|
+
learned = block if provider == "claude" else codex_to_claude(name, block)
|
|
1041
|
+
validate_block(name, learned)
|
|
1042
|
+
except RegistryError as exc:
|
|
1043
|
+
_warn(f"not adopted: {exc}")
|
|
1044
|
+
continue
|
|
1045
|
+
where = f" (project {path})" if path else ""
|
|
1046
|
+
if _carries_values(learned) and not target.replica:
|
|
1047
|
+
local = local_overlay.setdefault("owners", {}).setdefault(
|
|
1048
|
+
"local", _normalize_section({}))
|
|
1049
|
+
_upsert(local, name, learned, scope, path)
|
|
1050
|
+
local_changed = True
|
|
1051
|
+
where += " (this machine only: it carries env/header values)"
|
|
1052
|
+
else:
|
|
1053
|
+
_upsert(target.section, name, learned, scope, path)
|
|
1054
|
+
synced_changed = True
|
|
1055
|
+
if len(slot) > 1:
|
|
1056
|
+
changes.append(f'"{name}"{where} (the definition {count} of '
|
|
1057
|
+
f'{sum(v[0] for v in slot.values())} accounts hold)')
|
|
1058
|
+
else:
|
|
1059
|
+
changes.append(f'"{name}"{where}')
|
|
1060
|
+
if synced_changed:
|
|
1061
|
+
target.save()
|
|
1062
|
+
if local_changed:
|
|
1063
|
+
save_overlay(root, local_overlay)
|
|
1064
|
+
if changes:
|
|
1065
|
+
_log(root, f"adopt {', '.join(changes)} into {target.where}")
|
|
1066
|
+
return changes
|
|
1067
|
+
|
|
1068
|
+
|
|
877
1069
|
def apply_many(root, provider, account_dirs, quiet=False, fail_open=False):
|
|
878
|
-
"""
|
|
1070
|
+
"""Adopt what these accounts hold that no section names, then reconcile each dir;
|
|
1071
|
+
returns the number of accounts that could not be."""
|
|
1072
|
+
try:
|
|
1073
|
+
# a launch must not wait long on a lock left by a killed holder: the next launch
|
|
1074
|
+
# or the limits pass adopts instead
|
|
1075
|
+
adopted = adopt(root, provider, account_dirs, lock_wait=2 if fail_open else 60)
|
|
1076
|
+
except Exception as exc: # noqa: BLE001 — adoption must never stop a reconcile
|
|
1077
|
+
adopted = []
|
|
1078
|
+
_warn(f"adopt: {type(exc).__name__}: {exc}")
|
|
1079
|
+
if adopted and not quiet:
|
|
1080
|
+
print(f"adopted {', '.join(adopted)}: every {provider} account gets "
|
|
1081
|
+
f"{'it' if len(adopted) == 1 else 'them'}")
|
|
1082
|
+
if adopted and len(account_dirs) < len(manifest_account_dirs(root)):
|
|
1083
|
+
# One account told the registry something new: the others need it as well (the
|
|
1084
|
+
# shims would bring it to each on its next launch; an idle one would wait).
|
|
1085
|
+
# (a dir being seeded is not in the manifest yet: it stays in the list)
|
|
1086
|
+
account_dirs = list(dict.fromkeys(list(account_dirs) + manifest_account_dirs(root)))
|
|
879
1087
|
failures = 0
|
|
880
1088
|
for account_dir in account_dirs:
|
|
881
1089
|
label = os.path.basename(account_dir.rstrip("/"))
|
|
@@ -1194,7 +1402,8 @@ def cmd_apply(args, leftovers):
|
|
|
1194
1402
|
if args.all or not args.account_dir:
|
|
1195
1403
|
rc = _apply_all(args, quiet=args.quiet)
|
|
1196
1404
|
return 0 if args.fail_open else rc
|
|
1197
|
-
failures = apply_many(args.root, args.provider, args.account_dir, quiet=args.quiet
|
|
1405
|
+
failures = apply_many(args.root, args.provider, args.account_dir, quiet=args.quiet,
|
|
1406
|
+
fail_open=args.fail_open)
|
|
1198
1407
|
return 0 if args.fail_open or not failures else 1
|
|
1199
1408
|
|
|
1200
1409
|
|
|
@@ -1204,14 +1413,18 @@ def cmd_snapshot(args):
|
|
|
1204
1413
|
|
|
1205
1414
|
|
|
1206
1415
|
def cmd_learn(args):
|
|
1416
|
+
from mcp_mirror import scoped_snapshots
|
|
1417
|
+
|
|
1207
1418
|
try:
|
|
1208
1419
|
with open(args.before, encoding="utf-8") as handle:
|
|
1209
1420
|
before = json.load(handle)
|
|
1210
1421
|
except (OSError, ValueError) as exc:
|
|
1211
1422
|
raise RegistryError(f"snapshot {args.before}: {exc}") from exc
|
|
1212
|
-
after = snapshot(args.provider, args.account_dir)
|
|
1213
1423
|
with _Lock(args.root):
|
|
1424
|
+
after = snapshot(args.provider, args.account_dir)
|
|
1214
1425
|
target = _Target(args.root)
|
|
1426
|
+
before, after = scoped_snapshots(before, after, args.name, load_overlay(args.root),
|
|
1427
|
+
"local" if target.replica else None)
|
|
1215
1428
|
changes = _learn_into(target.section, before, after)
|
|
1216
1429
|
if changes:
|
|
1217
1430
|
target.save()
|
|
@@ -1251,8 +1464,12 @@ def cmd_import_local(args):
|
|
|
1251
1464
|
# it: an owner that stops shipping a server means it, and every account
|
|
1252
1465
|
# would otherwise keep the stale copy for ever.
|
|
1253
1466
|
dropped = [name for name in previous["mcpServers"] if name not in servers]
|
|
1467
|
+
# ...and stays retired until it publishes the name again: an account still holding
|
|
1468
|
+
# a copy must never have it adopted into the synced registry (adopt) once the next
|
|
1469
|
+
# import has forgotten the drop.
|
|
1470
|
+
kept = [name for name in previous["retired"] if name not in servers]
|
|
1254
1471
|
section = _normalize_section({"mcpServers": servers,
|
|
1255
|
-
"retired": sorted(set(retired) | set(dropped))})
|
|
1472
|
+
"retired": sorted(set(retired) | set(dropped) | set(kept))})
|
|
1256
1473
|
if previous == section and owner in overlay["owners"]:
|
|
1257
1474
|
changed = False
|
|
1258
1475
|
else:
|
|
@@ -1306,7 +1523,8 @@ def build_parser():
|
|
|
1306
1523
|
p = modes.add_parser("learn")
|
|
1307
1524
|
p.add_argument("--account-dir", required=True)
|
|
1308
1525
|
p.add_argument("--before", required=True)
|
|
1309
|
-
p.add_argument("--
|
|
1526
|
+
p.add_argument("--name", help="mirror only this stock command's server, including project approvals")
|
|
1527
|
+
p.add_argument("--project", help="accepted for the shim; --name applies across project entries")
|
|
1310
1528
|
|
|
1311
1529
|
p = modes.add_parser("import-local")
|
|
1312
1530
|
p.add_argument("--owner", required=True)
|
|
@@ -1330,7 +1548,16 @@ def main(argv=None):
|
|
|
1330
1548
|
from codex_python import require_python311
|
|
1331
1549
|
require_python311(os.path.abspath(__file__))
|
|
1332
1550
|
if args.mode == "apply" and not _registry_present(args.root):
|
|
1333
|
-
|
|
1551
|
+
# Nothing registered: every account is already "reconciled" — unless one holds a
|
|
1552
|
+
# server to adopt (the first this pool ever had, added where the mirror never saw).
|
|
1553
|
+
dirs = args.account_dir if args.account_dir and not args.all \
|
|
1554
|
+
else manifest_account_dirs(args.root)
|
|
1555
|
+
try:
|
|
1556
|
+
if not adopt(args.root, args.provider, dirs, dry=True):
|
|
1557
|
+
return 0
|
|
1558
|
+
except Exception as exc: # noqa: BLE001 — the old no-op, never a failure
|
|
1559
|
+
_warn(f"adopt: {type(exc).__name__}: {exc}")
|
|
1560
|
+
return 0
|
|
1334
1561
|
if args.mode == "list":
|
|
1335
1562
|
return cmd_list(args)
|
|
1336
1563
|
if args.mode == "add":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.61",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"test": "npm run test:core && npm run test:seeding && npm run test:commands",
|
|
59
59
|
"test:core": "bash tests/run-tests.sh && npm run test:selector && python3 tests/test_shim_path.py",
|
|
60
60
|
"test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
|
|
61
|
-
"test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3
|
|
61
|
+
"test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 -m unittest discover -s tests -p 'test_mcp_*.py' && npm run test:codex-settings",
|
|
62
62
|
"test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
|
|
63
63
|
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
64
64
|
},
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|