claude-multiacc 2.0.28 → 2.0.30
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/bin/claude-accounts +24 -2
- package/bin/codex-accounts +33 -2
- package/docs/ACCOUNT_OPERATIONS.md +9 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.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/common.sh +4 -1
- package/package.json +4 -2
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.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/run-tests.sh +30 -0
- package/tests/test_account_seeding.py +112 -0
package/bin/claude-accounts
CHANGED
|
@@ -2482,6 +2482,21 @@ AUTH_ERR = re.compile(
|
|
|
2482
2482
|
ORG_ERR = re.compile(
|
|
2483
2483
|
r'organization has disabled|subscription access.*disabl|disabled claude subscription'
|
|
2484
2484
|
r'|ask your admin to enable|not authorized to use claude code', re.I)
|
|
2485
|
+
# EXCULPATORY, tested FIRST — the twin of BROKEN_CLI in bin/codex-accounts, and
|
|
2486
|
+
# it matters MORE here: AUTH_ERR above matches a bare `401` or `403`, so any
|
|
2487
|
+
# machine fault whose output happens to contain those three digits parks a live
|
|
2488
|
+
# login. `mark_expired` writes no `soft_until`, so that verdict is permanent
|
|
2489
|
+
# until a person clears it. These patterns say the CLI never reached the API,
|
|
2490
|
+
# which is a fact about the machine and never about the login.
|
|
2491
|
+
# See health.log 2026-08-24: `env: node: No such file or directory` failed
|
|
2492
|
+
# every account in one pass.
|
|
2493
|
+
BROKEN_CLI = re.compile(
|
|
2494
|
+
r'missing optional dependency|unsupported (platform|target triple):'
|
|
2495
|
+
r'|cannot find module|ERR_MODULE_NOT_FOUND|ERR_DLOPEN_FAILED'
|
|
2496
|
+
r'|env: node: no such file or directory|node: command not found'
|
|
2497
|
+
r'|spawn .{0,80}(ENOENT|EACCES|EPERM)|^dyld\[[0-9]+\]:'
|
|
2498
|
+
r'|cannot execute binary file|bad CPU type in executable|exec format error',
|
|
2499
|
+
re.I | re.M)
|
|
2485
2500
|
|
|
2486
2501
|
def mark_expired(d, slug, detail=''):
|
|
2487
2502
|
"""Park an account the shim must stop selecting. Verify is the strongest signal
|
|
@@ -2607,9 +2622,16 @@ for acct in manifest.get('accounts', []):
|
|
|
2607
2622
|
pass
|
|
2608
2623
|
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
|
|
2609
2624
|
else:
|
|
2610
|
-
|
|
2625
|
+
raw = (r.stderr or '').strip()
|
|
2626
|
+
err = raw[:200]
|
|
2611
2627
|
hint = ''
|
|
2612
|
-
|
|
2628
|
+
# STDERR only, and the WHOLE of it: stdout is the model's own answer,
|
|
2629
|
+
# and a session that merely discusses "Cannot find module" must not be
|
|
2630
|
+
# able to excuse a real dead login.
|
|
2631
|
+
if BROKEN_CLI.search(raw):
|
|
2632
|
+
hint = (' — the Claude CLI cannot run on this machine; not a login '
|
|
2633
|
+
'problem, nothing was parked')
|
|
2634
|
+
elif ORG_ERR.search(out) or ORG_ERR.search(err):
|
|
2613
2635
|
# Not an auth problem: the account authenticates fine, its organization
|
|
2614
2636
|
# has simply turned Claude Code subscription access off. Park it — a
|
|
2615
2637
|
# re-login changes nothing — and say what actually helps.
|
package/bin/codex-accounts
CHANGED
|
@@ -1768,6 +1768,30 @@ ORG_ERR = re.compile(
|
|
|
1768
1768
|
r'disabled by (your )?(workspace )?admin|admin (has )?disabled'
|
|
1769
1769
|
r'|(workspace|organization) has disabled (codex|chatgpt)'
|
|
1770
1770
|
r'|codex.{0,20}disabled for (your|this) (workspace|organization)', re.I)
|
|
1771
|
+
# EXCULPATORY, and therefore tested FIRST. These say the CLI never reached the
|
|
1772
|
+
# API at all — a missing native package, a missing node, an unrunnable binary —
|
|
1773
|
+
# so they are facts about this MACHINE, not about the login. Parking on one
|
|
1774
|
+
# converts an indeterminate probe into "this login is dead", and that verdict
|
|
1775
|
+
# does not self-heal: `mark_expired` here writes no `soft_until`, so the shim
|
|
1776
|
+
# reads it as proven dead and keeps it, while the panel refuses to re-import
|
|
1777
|
+
# the token until its fingerprint changes.
|
|
1778
|
+
#
|
|
1779
|
+
# Not hypothetical. health.log 2026-08-24T06:37:02Z: all four accounts
|
|
1780
|
+
# `FAIL rc=127 err='env: node: No such file or directory'` — one broken PATH
|
|
1781
|
+
# reddened the entire matrix, and it escaped parking only because that exact
|
|
1782
|
+
# string happens not to match AUTH_ERR. `Missing optional dependency
|
|
1783
|
+
# @openai/codex-<platform>` (my-mini, 2026-09-10) is the same class.
|
|
1784
|
+
#
|
|
1785
|
+
# Keep it NARROW. It must never contain a word a genuine auth failure uses
|
|
1786
|
+
# ("permission denied", "forbidden", a bare "not found"), or a real dead login
|
|
1787
|
+
# would be excused as a broken machine. Widen only with a test.
|
|
1788
|
+
BROKEN_CLI = re.compile(
|
|
1789
|
+
r'missing optional dependency|unsupported (platform|target triple):'
|
|
1790
|
+
r'|cannot find module|ERR_MODULE_NOT_FOUND|ERR_DLOPEN_FAILED'
|
|
1791
|
+
r'|env: node: no such file or directory|node: command not found'
|
|
1792
|
+
r'|spawn .{0,80}(ENOENT|EACCES|EPERM)|^dyld\[[0-9]+\]:'
|
|
1793
|
+
r'|cannot execute binary file|bad CPU type in executable|exec format error',
|
|
1794
|
+
re.I | re.M)
|
|
1771
1795
|
|
|
1772
1796
|
def mark_expired(d, slug, detail=''):
|
|
1773
1797
|
"""Park an account the shim must stop selecting. Verify is the strongest signal
|
|
@@ -1845,9 +1869,16 @@ for acct in manifest.get('accounts', []):
|
|
|
1845
1869
|
pass
|
|
1846
1870
|
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {(last or out)[:60]!r}')
|
|
1847
1871
|
else:
|
|
1848
|
-
|
|
1872
|
+
raw = (r.stderr or '').strip()
|
|
1873
|
+
err = raw[:200]
|
|
1849
1874
|
hint = ''
|
|
1850
|
-
|
|
1875
|
+
# STDERR only, and the WHOLE of it: stdout is the model's own answer,
|
|
1876
|
+
# and a session that merely discusses "Cannot find module" must not be
|
|
1877
|
+
# able to excuse a real dead login.
|
|
1878
|
+
if BROKEN_CLI.search(raw):
|
|
1879
|
+
hint = (' — the Codex CLI cannot run on this machine; not a login '
|
|
1880
|
+
'problem, nothing was parked')
|
|
1881
|
+
elif ORG_ERR.search(out) or ORG_ERR.search(err):
|
|
1851
1882
|
# Not an auth problem: the account authenticates fine, a workspace admin
|
|
1852
1883
|
# has simply turned Codex access off. Park it — a re-login changes
|
|
1853
1884
|
# nothing — and say what actually helps.
|
|
@@ -131,6 +131,15 @@ manually via:
|
|
|
131
131
|
claude-accounts sync # and codex-accounts sync for the codex pool
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
+
Account creation and sync seed missing settings from the machine's default provider
|
|
135
|
+
directory and link its global instructions: `~/.claude/CLAUDE.md` for Claude,
|
|
136
|
+
`~/.codex/AGENTS.md` for Codex. Existing account settings (including models) and
|
|
137
|
+
instruction files or links are preserved. The addon does not generate instructions
|
|
138
|
+
or enable/disable agents. To share newly created global instructions with existing
|
|
139
|
+
accounts, run `claude-accounts sync --no-server` and/or `codex-accounts sync --no-server`.
|
|
140
|
+
This only repairs accounts in the manifest, leaving retired directories untouched.
|
|
141
|
+
Global instruction files remain machine-local; sync does not copy them between hosts.
|
|
142
|
+
|
|
134
143
|
Targets are the resolved sync target — env override, else the manifest's
|
|
135
144
|
`server`/`server_root`/`server_repo` (the Linux box), see *Pointing sync somewhere else*
|
|
136
145
|
below — plus an optional `peers` array for additional machines (e.g. a second Mac):
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/common.sh
CHANGED
|
@@ -677,7 +677,7 @@ codex_share_state_index() { # $1 acct dir — seed time: whatever index names ex
|
|
|
677
677
|
}
|
|
678
678
|
|
|
679
679
|
# Seed an account config dir so headless runs never prompt.
|
|
680
|
-
# claude: stripped .claude.json, copied settings.json, shared projects/
|
|
680
|
+
# claude: stripped .claude.json, copied settings.json, shared CLAUDE.md + projects/
|
|
681
681
|
# (shared history => --continue/--resume work regardless of picked account).
|
|
682
682
|
# codex: copied config.toml (carries project trust + settings, no identity),
|
|
683
683
|
# shared sessions/ symlink (=> `codex resume` finds every session) with the
|
|
@@ -718,6 +718,9 @@ PYEOF
|
|
|
718
718
|
if [ ! -f "$d/settings.json" ] && [ -f "$HOME/.claude/settings.json" ]; then
|
|
719
719
|
cp "$HOME/.claude/settings.json" "$d/settings.json" 2>/dev/null || true
|
|
720
720
|
fi
|
|
721
|
+
if [ ! -e "$d/CLAUDE.md" ] && [ -f "$HOME/.claude/CLAUDE.md" ]; then
|
|
722
|
+
ln -s "$HOME/.claude/CLAUDE.md" "$d/CLAUDE.md" 2>/dev/null || true
|
|
723
|
+
fi
|
|
721
724
|
if [ ! -e "$d/projects" ] && [ -d "$HOME/.claude/projects" ]; then
|
|
722
725
|
ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
|
|
723
726
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.30",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -55,7 +55,9 @@
|
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"postinstall": "node scripts/postinstall.mjs",
|
|
58
|
-
"test": "
|
|
58
|
+
"test": "npm run test:core && npm run test:seeding && npm run test:commands",
|
|
59
|
+
"test:core": "bash tests/run-tests.sh && python3 tests/test_selector.py",
|
|
60
|
+
"test:seeding": "python3 tests/test_account_seeding.py",
|
|
59
61
|
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
60
62
|
},
|
|
61
63
|
"dependencies": {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -136,6 +136,15 @@ case "${CLAUDE_CODE_OAUTH_TOKEN:-}" in
|
|
|
136
136
|
*) echo "Failed to authenticate. API Error: 401 OAuth access token is invalid." >&2; exit 1 ;;
|
|
137
137
|
esac ;;
|
|
138
138
|
esac
|
|
139
|
+
if [ -f "$ctl" ] && grep -qx "brokencli:$acct" "$ctl" 2>/dev/null; then
|
|
140
|
+
# An infrastructure fault, not a login verdict: the CLI never reached the API.
|
|
141
|
+
# Verbatim from my-mini, 2026-09-10. Note it carries no auth vocabulary at all
|
|
142
|
+
# — the danger is that AUTH_ERR matches a bare `401`, and a node stack frame
|
|
143
|
+
# or a version string can contain one.
|
|
144
|
+
echo "Error: Missing optional dependency @openai/codex-darwin-arm64. Reinstall Codex: npm install -g @openai/codex@latest" >&2
|
|
145
|
+
echo " at findCodexExecutable (file:///opt/homebrew/lib/node_modules/@openai/codex/bin/codex.js:107:9)" >&2
|
|
146
|
+
exit 1
|
|
147
|
+
fi
|
|
139
148
|
case " $* " in
|
|
140
149
|
*" -p --output-format text --max-turns 1 "*) echo "OK"; exit 0 ;;
|
|
141
150
|
esac
|
|
@@ -3898,6 +3907,27 @@ check "import rejects an API key as token" "not a subscription setup-token" "$ou
|
|
|
3898
3907
|
out="$(printf 'sk-ant-api03-ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ' | claude-accounts mint acct-01 --paste 2>&1)"
|
|
3899
3908
|
check "mint --paste rejects an API key" "not a subscription setup-token" "$out"
|
|
3900
3909
|
|
|
3910
|
+
# ---- 17a2. a CLI that cannot run is not a dead login ----------------------------------
|
|
3911
|
+
# health.log 2026-08-24T06:37:02Z: all four accounts FAIL rc=127
|
|
3912
|
+
# err='env: node: No such file or directory'. One broken PATH reddened the whole
|
|
3913
|
+
# verify matrix, and it escaped parking only because that string happens not to
|
|
3914
|
+
# match AUTH_ERR — which matches a bare `401`, so a node stack frame carrying one
|
|
3915
|
+
# is enough. `mark_expired` writes no soft stamp, so such a park is permanent,
|
|
3916
|
+
# and the panel then refuses to re-import the token until its fingerprint changes.
|
|
3917
|
+
rm -f "$ACC/acct-01/.expired"
|
|
3918
|
+
printf 'brokencli:acct-01\n' > "$WORK/ctl-brokencli"
|
|
3919
|
+
out="$(FAKE_CTL="$WORK/ctl-brokencli" claude-accounts verify 2>&1)"
|
|
3920
|
+
check "a broken CLI is reported as a machine fault" "cannot run on this machine" "$out"
|
|
3921
|
+
check "a broken CLI still fails the account" "acct-01" "$out"
|
|
3922
|
+
[ ! -f "$ACC/acct-01/.expired" ] \
|
|
3923
|
+
&& t_ok "a broken CLI never parks a live login" \
|
|
3924
|
+
|| t_fail "broken CLI parking" "verify parked an account over an infrastructure fault"
|
|
3925
|
+
case "$out" in
|
|
3926
|
+
*"login is dead"*) t_fail "broken CLI wording" "verify blamed the login for a machine fault" ;;
|
|
3927
|
+
*) t_ok "a broken CLI is not called a dead login" ;;
|
|
3928
|
+
esac
|
|
3929
|
+
rm -f "$WORK/ctl-brokencli"
|
|
3930
|
+
|
|
3901
3931
|
# ---- 17b. verify authenticates the way the SHIM would ---------------------------------
|
|
3902
3932
|
# A dead credential next to a live portable token: the shim runs that account with the
|
|
3903
3933
|
# TOKEN, so verify must too — testing it with the dead credential would fail a healthy
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Account seeding preserves settings and shares machine-local global instructions."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
13
|
+
PROVIDERS = (("claude", "CLAUDE.md", "settings.json"), ("codex", "AGENTS.md", "config.toml"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AccountSeedingTests(unittest.TestCase):
|
|
17
|
+
def setUp(self):
|
|
18
|
+
self.work = tempfile.TemporaryDirectory()
|
|
19
|
+
self.addCleanup(self.work.cleanup)
|
|
20
|
+
self.home = Path(self.work.name)
|
|
21
|
+
self.env = {**os.environ, "HOME": str(self.home), "CLAUDE_MULTIACC_PYTHON": "python3"}
|
|
22
|
+
for provider, _, _ in PROVIDERS:
|
|
23
|
+
(self.home / f".{provider}").mkdir()
|
|
24
|
+
self.env[f"{provider.upper()}_ACCOUNTS_ROOT"] = str(self.home / f".{provider}-accounts")
|
|
25
|
+
|
|
26
|
+
def seed(self, provider, directory=None):
|
|
27
|
+
account = directory or self.home / f".{provider}-accounts" / "acct-01"
|
|
28
|
+
subprocess.run(
|
|
29
|
+
["bash", "-c", '. "$1"; seed_account_dir "$2"', "seed", str(REPO / "lib/common.sh"), str(account)],
|
|
30
|
+
env={**self.env, "MULTIACC_PROVIDER": provider}, check=True, capture_output=True, text=True,
|
|
31
|
+
)
|
|
32
|
+
return account
|
|
33
|
+
|
|
34
|
+
def test_instructions_are_shared_and_updates_remain_visible(self):
|
|
35
|
+
for provider, name, _ in PROVIDERS:
|
|
36
|
+
with self.subTest(provider=provider):
|
|
37
|
+
source = self.home / f".{provider}" / name
|
|
38
|
+
source.write_text("Global preferences\n")
|
|
39
|
+
account = self.seed(provider)
|
|
40
|
+
self.assertTrue((account / name).is_symlink())
|
|
41
|
+
self.assertEqual((account / name).resolve(), source.resolve())
|
|
42
|
+
source.write_text("Updated preferences\n")
|
|
43
|
+
self.seed(provider)
|
|
44
|
+
self.assertEqual((account / name).read_text(), "Updated preferences\n")
|
|
45
|
+
|
|
46
|
+
def test_existing_files_and_links_are_preserved(self):
|
|
47
|
+
for provider, name, _ in PROVIDERS:
|
|
48
|
+
with self.subTest(provider=provider):
|
|
49
|
+
(self.home / f".{provider}" / name).write_text("Global preferences\n")
|
|
50
|
+
account = self.seed(provider)
|
|
51
|
+
target = account / name
|
|
52
|
+
target.unlink()
|
|
53
|
+
target.write_text("Account preferences\n")
|
|
54
|
+
self.seed(provider)
|
|
55
|
+
self.assertFalse(target.is_symlink())
|
|
56
|
+
self.assertEqual(target.read_text(), "Account preferences\n")
|
|
57
|
+
target.unlink()
|
|
58
|
+
local = self.home / f"{provider}-local.md"
|
|
59
|
+
target.symlink_to(local)
|
|
60
|
+
self.seed(provider)
|
|
61
|
+
self.assertEqual(target.readlink(), local) # Dangling custom link is also owned by the user.
|
|
62
|
+
local.write_text("Custom preferences\n")
|
|
63
|
+
self.seed(provider)
|
|
64
|
+
self.assertEqual(target.read_text(), "Custom preferences\n")
|
|
65
|
+
|
|
66
|
+
def test_missing_global_file_never_creates_a_dangling_or_self_link(self):
|
|
67
|
+
for provider, name, _ in PROVIDERS:
|
|
68
|
+
with self.subTest(provider=provider):
|
|
69
|
+
account = self.seed(provider)
|
|
70
|
+
self.assertFalse(os.path.lexists(account / name))
|
|
71
|
+
base = self.home / f".{provider}"
|
|
72
|
+
self.seed(provider, base)
|
|
73
|
+
self.assertFalse(os.path.lexists(base / name))
|
|
74
|
+
(base / name).write_text("Global preferences\n")
|
|
75
|
+
self.seed(provider, base)
|
|
76
|
+
self.assertFalse((base / name).is_symlink())
|
|
77
|
+
|
|
78
|
+
def test_existing_settings_and_model_are_not_overwritten(self):
|
|
79
|
+
for provider, _, name in PROVIDERS:
|
|
80
|
+
with self.subTest(provider=provider):
|
|
81
|
+
source = self.home / f".{provider}" / name
|
|
82
|
+
source.write_text('{"model":"global"}\n' if provider == "claude" else 'model = "global"\n')
|
|
83
|
+
account = self.seed(provider)
|
|
84
|
+
self.assertEqual((account / name).read_bytes(), source.read_bytes())
|
|
85
|
+
original = '{"model":"account"}\n' if provider == "claude" else 'model = "account"\n'
|
|
86
|
+
(account / name).write_text(original)
|
|
87
|
+
self.seed(provider)
|
|
88
|
+
self.assertEqual((account / name).read_text(), original)
|
|
89
|
+
|
|
90
|
+
def test_local_sync_repairs_only_manifest_accounts(self):
|
|
91
|
+
for provider, name, _ in PROVIDERS:
|
|
92
|
+
with self.subTest(provider=provider):
|
|
93
|
+
(self.home / f".{provider}" / name).write_text("Global preferences\n")
|
|
94
|
+
root = self.home / f".{provider}-accounts"
|
|
95
|
+
(root / "acct-01").mkdir(parents=True)
|
|
96
|
+
(root / "acct-99").mkdir()
|
|
97
|
+
(root / "accounts.json").write_text(json.dumps({
|
|
98
|
+
"version": 1, "server": "none",
|
|
99
|
+
"accounts": [{"id": "acct-01", "email": "fixture@example.test", "home": "mac"}],
|
|
100
|
+
}))
|
|
101
|
+
for _ in range(2):
|
|
102
|
+
result = subprocess.run(
|
|
103
|
+
[str(REPO / "bin" / f"{provider}-accounts"), "sync", "--no-server"],
|
|
104
|
+
env=self.env, check=True, capture_output=True, text=True,
|
|
105
|
+
)
|
|
106
|
+
self.assertIn("nothing pushed", result.stdout)
|
|
107
|
+
self.assertTrue((root / "acct-01" / name).is_symlink())
|
|
108
|
+
self.assertFalse(os.path.lexists(root / "acct-99" / name))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
unittest.main()
|