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.
@@ -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
- err = (r.stderr or '').strip()[:200]
2625
+ raw = (r.stderr or '').strip()
2626
+ err = raw[:200]
2611
2627
  hint = ''
2612
- if ORG_ERR.search(out) or ORG_ERR.search(err):
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.
@@ -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
- err = (r.stderr or '').strip()[:200]
1872
+ raw = (r.stderr or '').strip()
1873
+ err = raw[:200]
1849
1874
  hint = ''
1850
- if ORG_ERR.search(out) or ORG_ERR.search(err):
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):
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/ symlink
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.28",
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": "bash tests/run-tests.sh && python3 tests/test_selector.py && npm run test:commands",
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": {
@@ -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()