claude-multiacc 2.0.27 → 2.0.29

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.
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env bash
2
+ # Installer actions, sourced by install.sh. Bash 3.2 compatible.
3
+
4
+ is_pool_root() { # true when $1 is safe to delete as an account pool
5
+ case "$1" in
6
+ ''|/|"$HOME") return 1 ;;
7
+ */.claude-accounts|*/.codex-accounts) return 0 ;;
8
+ /*) [ -f "$1/accounts.json" ] ;;
9
+ *) return 1 ;;
10
+ esac
11
+ }
12
+
13
+ strip_block() { # remove our marked block from a file (portable, no sed -i)
14
+ local f="$1"
15
+ [ -f "$f" ] || return 0
16
+ # Unmatched begin marker (end line hand-deleted): stripping would eat the rest
17
+ # of the rc file. Leave it alone and say so.
18
+ if grep -qF "$MARK_BEGIN" "$f" && ! grep -qF "$MARK_END" "$f"; then
19
+ echo " WARNING: $f has an unterminated claude-multiacc block — fix it by hand; not touching this file" >&2
20
+ return 1
21
+ fi
22
+ awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
23
+ $0 == b { skip = 1; next }
24
+ $0 == e { skip = 0; next }
25
+ !skip { print }
26
+ ' "$f" > "$f.claude-multiacc.tmp" && mv "$f.claude-multiacc.tmp" "$f"
27
+ }
28
+
29
+ path_block_body() {
30
+ # Move (not just add) the shim dir to the front: later rc lines prepend
31
+ # ~/.local/bin, so a plain add-once guard would leave the real binary first.
32
+ printf 'PATH="$(printf %%s ":$PATH:" | sed '\''s|:%s/bin:|:|g; s|^:||; s|:$||'\'')"\n' "$REPO_DIR"
33
+ printf 'export PATH="%s/bin:$PATH"\n' "$REPO_DIR"
34
+ }
35
+
36
+ append_block() { # strip then append our PATH block to a file
37
+ local f="$1"
38
+ strip_block "$f" || return 1 # never create a second block next to a broken one
39
+ {
40
+ printf '%s\n' "$MARK_BEGIN"
41
+ path_block_body
42
+ printf '%s\n' "$MARK_END"
43
+ } >> "$f"
44
+ }
45
+
46
+ do_uninstall() {
47
+ echo "claude-multiacc: uninstalling (restoring stock behavior)"
48
+ # An instance install never wrote a shell rc block, a /etc/profile.d file or the
49
+ # /usr/local/bin shims — those belong to the DEFAULT install and are shared with it.
50
+ if [ -n "$INSTANCE" ]; then
51
+ echo " instance $INSTANCE: removing its agents only (PATH block and shims belong to the default install)"
52
+ else
53
+ strip_block "$HOME/.zshenv"
54
+ strip_block "$HOME/.zprofile"
55
+ strip_block "$HOME/.zshrc"
56
+ strip_block "$HOME/.bashrc"
57
+ strip_block "$HOME/.bash_profile"
58
+ strip_block "$HOME/.profile"
59
+ fi
60
+ if [ "$(machine_kind)" = "mac" ]; then
61
+ mac_schedule_remove
62
+ else
63
+ linux_schedule_remove
64
+ if [ -z "$INSTANCE" ]; then
65
+ [ -f "$PROFILED" ] && rm -f "$PROFILED"
66
+ if [ -L /usr/local/bin/claude ] \
67
+ && [ "$(canon_path /usr/local/bin/claude)" = "$(canon_path "$REPO_DIR/bin/claude")" ]; then
68
+ rm -f /usr/local/bin/claude
69
+ echo " removed /usr/local/bin/claude shim symlink"
70
+ fi
71
+ if [ -L /usr/local/bin/codex ] \
72
+ && [ "$(canon_path /usr/local/bin/codex)" = "$(canon_path "$REPO_DIR/bin/codex")" ]; then
73
+ rm -f /usr/local/bin/codex
74
+ echo " removed /usr/local/bin/codex shim symlink"
75
+ fi
76
+ fi
77
+ fi
78
+ local root
79
+ for root in "$ACC_ROOT" "$CODEX_ACC_ROOT"; do
80
+ if [ "$PURGE" = "1" ]; then
81
+ # Either the conventional location, or a directory that is provably a pool (it
82
+ # holds a manifest) — an instance root lives anywhere, so the manifest is what
83
+ # makes `rm -rf` safe. Anything else is left alone.
84
+ if is_pool_root "$root"; then
85
+ rm -rf "$root"; echo " purged $root"
86
+ else
87
+ echo " refusing to purge unusual accounts root: $root" >&2
88
+ fi
89
+ fi
90
+ done
91
+ if [ "$PURGE" != "1" ]; then
92
+ echo " account data kept at $ACC_ROOT and $CODEX_ACC_ROOT (use --purge-data to remove)"
93
+ fi
94
+ echo "uninstall complete — stock claude behavior restored"
95
+ }
96
+
97
+ install_binaries() {
98
+ [ -x "$REPO_DIR/bin/claude" ] || chmod +x "$REPO_DIR/bin/claude" 2>/dev/null || true
99
+ [ -x "$REPO_DIR/bin/claude-accounts" ] || chmod +x "$REPO_DIR/bin/claude-accounts" 2>/dev/null || true
100
+ [ -x "$REPO_DIR/bin/codex" ] || chmod +x "$REPO_DIR/bin/codex" 2>/dev/null || true
101
+ [ -x "$REPO_DIR/bin/codex-accounts" ] || chmod +x "$REPO_DIR/bin/codex-accounts" 2>/dev/null || true
102
+
103
+ local real
104
+ if real="$(find_real_claude "$REPO_DIR/bin/claude")"; then
105
+ echo " real claude binary: $real ($("$real" --version 2>/dev/null | head -1 || echo 'version unknown'))"
106
+ else
107
+ echo " WARNING: no real claude binary found — install Claude Code (https://claude.com/claude-code)" >&2
108
+ fi
109
+ local real_codex
110
+ if real_codex="$(find_real_codex "$REPO_DIR/bin/codex")"; then
111
+ echo " real codex binary: $real_codex"
112
+ "$real_codex" --version 2>/dev/null | head -1 || echo ' version unknown'
113
+ else
114
+ echo " note: no codex binary found — install Codex CLI with: npm i -g @openai/codex"
115
+ fi
116
+
117
+ # Keychain-mode note: Claude Code keeps per-config-dir logins in the login Keychain
118
+ # (one item per dir) whenever the session can open it, and only sessions without
119
+ # keychain access (ssh, launchd background jobs) fall back to .credentials.json. The
120
+ # pool reads both (lib/keychain.py); what an operator has to know is that a login
121
+ # made from a GUI session is invisible to their ssh sessions — mint a portable
122
+ # token for anything that must work from everywhere.
123
+ if [ "$kind" = "mac" ] && [ ! -f "$HOME/.claude/.credentials.json" ] \
124
+ && security find-generic-password -s "Claude Code-credentials" >/dev/null 2>&1; then
125
+ echo " note: this Mac keeps Claude Code logins in the Keychain. Per-dir logins made here"
126
+ echo " are read from it; ssh/background sessions cannot open it (they see 'locked') —"
127
+ echo " use 'claude-accounts mint <acct-NN>' for accounts that must work from everywhere."
128
+ fi
129
+ }
130
+
131
+ install_pools() {
132
+ manifest_init "${SERVER_OVERRIDE:-$DEFAULT_SERVER}"
133
+ if [ -n "$SERVER_OVERRIDE" ]; then
134
+ "$PYBIN" - "$MANIFEST" "$SERVER_OVERRIDE" <<'PYEOF'
135
+ import json, os, sys
136
+ doc = json.load(open(sys.argv[1]))
137
+ doc['server'] = sys.argv[2]
138
+ with open(sys.argv[1] + '.tmp', 'w') as f:
139
+ json.dump(doc, f, indent=2)
140
+ f.write('\n')
141
+ os.replace(sys.argv[1] + '.tmp', sys.argv[1])
142
+ PYEOF
143
+ fi
144
+ if sync_target_is_local "$(sync_target)"; then
145
+ echo " account pool: $ACC_ROOT (manifest ready; sync target: none — local-only)"
146
+ else
147
+ echo " account pool: $ACC_ROOT (manifest ready; sync target: $(sync_target))"
148
+ fi
149
+ # The codex pool gets its own skeleton + manifest (same schema, separate root).
150
+ if ! "$REPO_DIR/bin/codex-accounts" init-pool "${SERVER_OVERRIDE:-}" >/dev/null 2>&1; then
151
+ echo " WARNING: codex pool init failed (codex-accounts init-pool)" >&2
152
+ else
153
+ echo " codex account pool: $CODEX_ACC_ROOT (manifest ready)"
154
+ fi
155
+ }
156
+
157
+ install_mac_paths() {
158
+ # .zshenv covers non-interactive zsh; the .zshrc block must be LAST so it wins
159
+ # over ~/.local/bin re-prepends done earlier in .zshrc/.zprofile.
160
+ # zsh reads: .zshenv always; .zprofile for login; .zshrc for interactive.
161
+ # The block must end each file that later re-prepends ~/.local/bin.
162
+ if [ -z "$INSTANCE" ]; then
163
+ touch "$HOME/.zshenv"
164
+ append_block "$HOME/.zshenv"
165
+ touch "$HOME/.zprofile"
166
+ append_block "$HOME/.zprofile"
167
+ touch "$HOME/.zshrc"
168
+ append_block "$HOME/.zshrc"
169
+ [ -f "$HOME/.bash_profile" ] && append_block "$HOME/.bash_profile"
170
+ [ -f "$HOME/.bashrc" ] && append_block "$HOME/.bashrc"
171
+ echo " PATH block: end of ~/.zshenv, ~/.zprofile, ~/.zshrc (+ bash rc files if present)"
172
+ fi
173
+ }
174
+
175
+ install_linux_paths() {
176
+ if [ -z "$INSTANCE" ]; then
177
+ touch "$HOME/.bashrc"
178
+ append_block "$HOME/.bashrc"
179
+ if [ -w /etc/profile.d ] 2>/dev/null || [ "$(id -u)" = "0" ]; then
180
+ {
181
+ printf '%s\n' "$MARK_BEGIN"
182
+ path_block_body
183
+ printf '%s\n' "$MARK_END"
184
+ } > "$PROFILED"
185
+ echo " PATH block: ~/.bashrc + $PROFILED"
186
+ fi
187
+ fi
188
+ if [ -z "$INSTANCE" ] && [ "$(id -u)" = "0" ]; then
189
+ if [ -e /usr/local/bin/claude ] && [ ! -L /usr/local/bin/claude ]; then
190
+ echo " WARNING: /usr/local/bin/claude exists and is a real file — NOT overwriting." >&2
191
+ else
192
+ ln -sfn "$REPO_DIR/bin/claude" /usr/local/bin/claude
193
+ echo " shim: /usr/local/bin/claude -> $REPO_DIR/bin/claude (systemd-PATH compatible)"
194
+ fi
195
+ if [ -e /usr/local/bin/codex ] && [ ! -L /usr/local/bin/codex ]; then
196
+ echo " WARNING: /usr/local/bin/codex exists and is a real file — NOT overwriting." >&2
197
+ else
198
+ ln -sfn "$REPO_DIR/bin/codex" /usr/local/bin/codex
199
+ echo " shim: /usr/local/bin/codex -> $REPO_DIR/bin/codex (systemd-PATH compatible)"
200
+ fi
201
+ fi
202
+ }
203
+
204
+ do_install() {
205
+ local kind
206
+ kind="$(machine_kind)"
207
+ echo "claude-multiacc: installing (mode: $kind, repo: $REPO_DIR)"
208
+ if [ -n "$INSTANCE" ]; then
209
+ echo " instance: $INSTANCE (agents labelled $LABEL.*, pools $ACC_ROOT + $CODEX_ACC_ROOT)"
210
+ fi
211
+
212
+ install_binaries
213
+ install_pools
214
+
215
+ # An INSTANCE install never rewrites the shell rc blocks: one interactive PATH
216
+ # cannot serve two pools, and clobbering the default install's block would point
217
+ # the operator's shell at an instance pool. Its agents (below) carry the roots
218
+ # instead, and the runner daemon invokes the shims by absolute path.
219
+ if [ -n "$INSTANCE" ]; then
220
+ echo " PATH block: skipped (instance install) — for a shell against this pool:"
221
+ echo " export CLAUDE_ACCOUNTS_ROOT='$ACC_ROOT' CODEX_ACCOUNTS_ROOT='$CODEX_ACC_ROOT'"
222
+ echo " export PATH=\"$REPO_DIR/bin:\$PATH\""
223
+ fi
224
+ if [ "$kind" = "mac" ]; then
225
+ install_mac_paths
226
+ [ "$NO_SCHEDULE" = "1" ] || mac_schedule_install
227
+ else
228
+ install_linux_paths
229
+ [ "$NO_SCHEDULE" = "1" ] || linux_schedule_install
230
+ fi
231
+
232
+ echo
233
+ echo "install complete. Open a new shell (or 'export PATH=\"$REPO_DIR/bin:\$PATH\"'), then:"
234
+ echo " claude-accounts list # the Claude Code pool"
235
+ echo " codex-accounts list # the Codex pool"
236
+ echo " claude-accounts status # Claude auth + limits detail"
237
+ echo " codex-accounts status # Codex auth + limits detail"
238
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # Calendar job templates, sourced only by mac_schedule_install in install.sh.
3
+
4
+ cat > "$PLIST_HEALTH" <<EOF
5
+ <?xml version="1.0" encoding="UTF-8"?>
6
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
7
+ <plist version="1.0"><dict>
8
+ <key>Label</key><string>$LABEL.health</string>
9
+ <key>ProgramArguments</key><array>
10
+ <string>$REPO_DIR/bin/claude-accounts</string>
11
+ <string>health</string>
12
+ </array>
13
+ $(plist_env_block) <key>StartCalendarInterval</key><dict>
14
+ <key>Weekday</key><integer>1</integer>
15
+ <key>Hour</key><integer>9</integer>
16
+ <key>Minute</key><integer>17</integer>
17
+ </dict>
18
+ <key>StandardOutPath</key><string>/dev/null</string>
19
+ <key>StandardErrorPath</key><string>/dev/null</string>
20
+ </dict></plist>
21
+ EOF
22
+
23
+ cat > "$PLIST_UPDATE" <<EOF
24
+ <?xml version="1.0" encoding="UTF-8"?>
25
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
26
+ <plist version="1.0"><dict>
27
+ <key>Label</key><string>$LABEL.update</string>
28
+ <key>ProgramArguments</key><array>
29
+ <string>$REPO_DIR/bin/claude-accounts</string>
30
+ <string>self-update</string>
31
+ <string>--quiet</string>
32
+ </array>
33
+ $(plist_env_block) <key>StartCalendarInterval</key><dict>
34
+ <key>Hour</key><integer>4</integer>
35
+ <key>Minute</key><integer>7</integer>
36
+ </dict>
37
+ <key>StandardOutPath</key><string>/dev/null</string>
38
+ <key>StandardErrorPath</key><string>/dev/null</string>
39
+ </dict></plist>
40
+ EOF
41
+
42
+ cat > "$PLIST_CODEX_HEALTH" <<EOF
43
+ <?xml version="1.0" encoding="UTF-8"?>
44
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
45
+ <plist version="1.0"><dict>
46
+ <key>Label</key><string>$LABEL.codex-health</string>
47
+ <key>ProgramArguments</key><array>
48
+ <string>$REPO_DIR/bin/codex-accounts</string>
49
+ <string>health</string>
50
+ </array>
51
+ $(plist_env_block) <key>StartCalendarInterval</key><dict>
52
+ <key>Weekday</key><integer>1</integer>
53
+ <key>Hour</key><integer>9</integer>
54
+ <key>Minute</key><integer>37</integer>
55
+ </dict>
56
+ <key>StandardOutPath</key><string>/dev/null</string>
57
+ <key>StandardErrorPath</key><string>/dev/null</string>
58
+ </dict></plist>
59
+ EOF
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.27",
3
+ "version": "2.0.29",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claude-multiacc": "bin/cli.mjs",
8
+ "claude-accounts": "bin/claude-accounts",
9
+ "codex-accounts": "bin/codex-accounts",
8
10
  "multiacc-select": "bin/multiacc-select"
9
11
  },
10
12
  "files": [
@@ -53,7 +55,8 @@
53
55
  },
54
56
  "scripts": {
55
57
  "postinstall": "node scripts/postinstall.mjs",
56
- "test": "bash tests/run-tests.sh && python3 tests/test_selector.py"
58
+ "test": "bash tests/run-tests.sh && python3 tests/test_selector.py && npm run test:commands",
59
+ "test:commands": "python3 tests/test_packaged_commands.py"
57
60
  },
58
61
  "dependencies": {
59
62
  "update-notifier": "^7.3.1"
@@ -0,0 +1,81 @@
1
+ """Offline npm installation and isolated command execution for the CLI contract."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+
12
+
13
+ def run(args, env, cwd, **kwargs):
14
+ return subprocess.run(args, env=env, cwd=cwd, text=True, capture_output=True, timeout=90, **kwargs)
15
+
16
+
17
+ def install_package(test):
18
+ scratch = tempfile.TemporaryDirectory(prefix="multiacc-packaged-commands-")
19
+ test.addClassCleanup(scratch.cleanup)
20
+ test.work = Path(scratch.name).resolve()
21
+ test.prefix = test.work / "npm prefix"
22
+ test.home = test.work / "home"
23
+ test.home.mkdir()
24
+ npm = shutil.which("npm")
25
+ cache = subprocess.check_output([npm, "config", "get", "cache"], text=True).strip()
26
+ paths = [str(Path(shutil.which(name)).parent) for name in ("node", "python3", "npm")]
27
+ test.env = {
28
+ "HOME": str(test.home), "PATH": os.pathsep.join(dict.fromkeys(paths + ["/usr/bin", "/bin"])),
29
+ "CI": "1", "NO_UPDATE_NOTIFIER": "1", "PYTHONDONTWRITEBYTECODE": "1",
30
+ "CLAUDE_MULTIACC_KEYCHAIN": "0", "CLAUDE_MULTIACC_NO_SYNC": "1", "CODEX_MULTIACC_NO_SYNC": "1",
31
+ "CLAUDE_ACCOUNTS_ROOT": str(test.home / ".claude-accounts"),
32
+ "CODEX_ACCOUNTS_ROOT": str(test.home / ".codex-accounts"),
33
+ "npm_config_cache": cache, "npm_config_update_notifier": "false",
34
+ "npm_config_prefix": str(test.prefix), "npm_config_offline": "true",
35
+ "npm_config_ignore_scripts": "true",
36
+ }
37
+ packed = run([npm, "pack", "--ignore-scripts", "--pack-destination", str(test.work)],
38
+ test.env, ROOT)
39
+ if packed.returncode:
40
+ raise RuntimeError(packed.stderr)
41
+ # npm 11 emits a JSON array; npm 12 keys it by package name. Test the artifact
42
+ # itself so npm's presentation format cannot strand an otherwise valid release.
43
+ archives = list(test.work.glob("*.tgz"))
44
+ if len(archives) != 1:
45
+ raise RuntimeError(f"Expected one packed archive, found {len(archives)}")
46
+ archive = archives[0]
47
+ installed = run([npm, "install", "-g", "--prefix", str(test.prefix), "--ignore-scripts", "--offline",
48
+ "--no-audit", "--no-fund", str(archive)], test.env, test.work)
49
+ if installed.returncode:
50
+ raise RuntimeError("Offline install failed; run npm install --ignore-scripts to warm dependencies.\n"
51
+ + installed.stderr)
52
+ test.package = test.prefix / "lib/node_modules/claude-multiacc"
53
+ test.env["PATH"] = str(test.prefix / "bin") + os.pathsep + test.env["PATH"]
54
+ guard = test.prefix / "bin/npm"
55
+ guard.write_text('#!/usr/bin/env bash\necho "unexpected npm invocation" >&2\nexit 89\n')
56
+ guard.chmod(0o755)
57
+ for name in ("claude", "codex", "security", "launchctl", "crontab", "ln"):
58
+ guard = test.prefix / "bin" / name
59
+ guard.write_text('#!/usr/bin/env bash\n'
60
+ 'printf "%s\\n" "$0 $*" >> "$HOME/tool-calls"\nexit 1\n')
61
+ guard.chmod(0o755)
62
+
63
+
64
+ def seed_pools(test):
65
+ for provider in ("claude", "codex"):
66
+ pool = Path(test.env[f"{provider.upper()}_ACCOUNTS_ROOT"])
67
+ account = pool / "acct-01"
68
+ account.mkdir(parents=True)
69
+ (pool / "accounts.json").write_text(json.dumps({
70
+ "version": 1, "server": "none", "threshold": 90,
71
+ "accounts": [{"id": "acct-01", "email": f"{provider}@example.test",
72
+ "home": "mac" if os.uname().sysname == "Darwin" else "server"}],
73
+ }))
74
+ credential = ({"tokens": {"access_token": "fixture", "refresh_token": "fixture"}}
75
+ if provider == "codex" else {"claudeAiOauth": {
76
+ "accessToken": "fixture", "refreshToken": "fixture", "expiresAt": 9999999999999}})
77
+ filename = "auth.json" if provider == "codex" else ".credentials.json"
78
+ (account / filename).write_text(json.dumps(credential))
79
+ fake = test.prefix / "bin" / provider
80
+ fake.write_text('#!/usr/bin/env bash\nprintf "OK\\n"\n')
81
+ fake.chmod(0o755)
@@ -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,170 @@
1
+ """Exercise documented commands from npm's actual bin links, without shell rc files."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import plistlib
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import unittest
10
+
11
+ from packaged_command_support import ROOT, install_package, run, seed_pools
12
+
13
+
14
+ @unittest.skipUnless(shutil.which("node") and shutil.which("npm"), "Node/npm required")
15
+ class PackagedCommandsTests(unittest.TestCase):
16
+ @classmethod
17
+ def setUpClass(cls):
18
+ install_package(cls)
19
+
20
+ def test_global_commands_work_without_postinstall_or_shell_rc(self):
21
+ for command in ("claude-multiacc", "claude-accounts", "codex-accounts", "multiacc-select"):
22
+ with self.subTest(command=command):
23
+ resolved = shutil.which(command, path=self.env["PATH"])
24
+ self.assertEqual(resolved, str(self.prefix / "bin" / command))
25
+ flag = "--version" if command == "multiacc-select" else "--help"
26
+ result = run([command, flag], self.env, self.work)
27
+ self.assertEqual(result.returncode, 0, result.stderr)
28
+ for shell in ("bash", "zsh"):
29
+ if not shutil.which(shell):
30
+ continue
31
+ result = run([shell, "-fc", "codex-accounts verify --help"], self.env, self.work)
32
+ self.assertEqual(result.returncode, 0, result.stderr)
33
+ for command in ("claude-accounts", "codex-accounts"):
34
+ result = run([command, "list"], self.env, self.work)
35
+ self.assertNotEqual(result.returncode, 0)
36
+ self.assertIn("run claude-multiacc install first", result.stderr)
37
+
38
+ def test_every_account_help_command_dispatches_directly_and_via_wrapper(self):
39
+ for provider in ("claude", "codex"):
40
+ direct = f"{provider}-accounts"
41
+ help_result = run([str(self.package / "bin" / direct), "--help"], self.env, self.work)
42
+ verbs = set(re.findall(rf"^ {direct} ([a-z-]+)", help_result.stdout, re.MULTILINE))
43
+ self.assertGreaterEqual(len(verbs), 18)
44
+ wrapper = ["claude-multiacc"] + (["codex"] if provider == "codex" else [])
45
+ for prefix in ([direct], wrapper):
46
+ for verb in sorted(verbs):
47
+ with self.subTest(prefix=prefix, verb=verb):
48
+ result = run(prefix + [verb, "--help"], self.env, self.work)
49
+ self.assertEqual(result.returncode, 0, result.stderr)
50
+ self.assertIn(f"{direct} {verb}", result.stdout)
51
+ result = run(prefix + ["not-a-command", "--help"], self.env, self.work)
52
+ self.assertNotEqual(result.returncode, 0)
53
+
54
+ def test_wrapper_and_installer_help_have_no_side_effects(self):
55
+ before = sorted(str(path) for path in self.home.rglob("*"))
56
+ for verb in ("help", "install", "update", "uninstall", "doctor", "self-update", "codex", "select"):
57
+ with self.subTest(verb=verb):
58
+ result = run(["claude-multiacc", verb, "--help"], self.env, self.work)
59
+ self.assertEqual(result.returncode, 0, result.stderr)
60
+ self.assertRegex(result.stdout, r"(?i)usage")
61
+ result = run([str(self.package / "install.sh"), "--help"], self.env, self.work)
62
+ self.assertEqual(result.returncode, 0, result.stderr)
63
+ self.assertEqual(before, sorted(str(path) for path in self.home.rglob("*")))
64
+
65
+ def test_verify_and_json_reports_use_the_correct_pool(self):
66
+ seed_pools(self)
67
+ for provider in ("claude", "codex"):
68
+ wrapper = ["claude-multiacc"] + (["codex"] if provider == "codex" else [])
69
+ for prefix in ([f"{provider}-accounts"], wrapper):
70
+ for verb in ("list", "status"):
71
+ result = run(prefix + [verb, "--json"], self.env, self.work)
72
+ self.assertEqual(result.returncode, 0, result.stderr)
73
+ self.assertEqual(json.loads(result.stdout)["provider"], provider)
74
+ for flags, expected in ((["--quick"], ": OK"), ([], ": PASS")):
75
+ result = run(prefix + ["verify"] + flags, self.env, self.work)
76
+ self.assertEqual(result.returncode, 0, result.stderr)
77
+ self.assertIn(f"{provider}@example.test{expected}", result.stdout)
78
+
79
+ def test_self_update_uses_the_running_install_prefix(self):
80
+ fake_npm = self.prefix / "bin/npm"
81
+ original = fake_npm.read_text()
82
+ fake_npm.write_text('#!/usr/bin/env bash\n'
83
+ 'if [ "$1" = view ]; then echo 999.0.0; exit 0; fi\n'
84
+ 'printf "%s\\n" "$@" > "$HOME/update-args"\nexit 42\n')
85
+ fake_npm.chmod(0o755)
86
+ try:
87
+ result = run(["claude-multiacc", "self-update"], self.env, self.work)
88
+ self.assertNotEqual(result.returncode, 0)
89
+ args = (self.home / "update-args").read_text().splitlines()
90
+ self.assertEqual(args, ["install", "-g", "--prefix", str(self.prefix), "claude-multiacc@latest"])
91
+ finally:
92
+ fake_npm.write_text(original)
93
+
94
+ def test_git_and_npx_path_entrypoint_matches_npm(self):
95
+ entrypoint = self.package / "bin/claude-multiacc"
96
+ self.assertTrue(entrypoint.is_file())
97
+ result = run([str(entrypoint), "--version"], self.env, self.work)
98
+ self.assertEqual(result.returncode, 0, result.stderr)
99
+ self.assertEqual(result.stdout.strip(), json.loads((self.package / "package.json").read_text())["version"])
100
+
101
+ def test_installer_is_idempotent_and_instances_leave_system_commands_alone(self):
102
+ uname = self.prefix / "bin/uname"
103
+ identity = self.prefix / "bin/id"
104
+ uname.write_text('#!/usr/bin/env bash\necho Darwin\n')
105
+ identity.write_text('#!/usr/bin/env bash\necho 0\n')
106
+ uname.chmod(0o755)
107
+ identity.chmod(0o755)
108
+ try:
109
+ for _ in range(2):
110
+ result = run(["claude-multiacc", "install", "--no-server", "--no-schedule"], self.env, self.work)
111
+ self.assertEqual(result.returncode, 0, result.stderr)
112
+ self.assertNotIn("status |", result.stdout)
113
+ for rc in (".zshenv", ".zprofile", ".zshrc"):
114
+ self.assertEqual((self.home / rc).read_text().count("# >>> claude-multiacc >>>"), 1)
115
+ calls = (self.home / "tool-calls").read_text()
116
+ self.assertNotRegex(calls, r"/(launchctl|crontab|ln) ")
117
+ script = 'source "$0" --no-server --no-schedule; mac_schedule_install'
118
+ result = run(["bash", "-c", script, str(self.package / "install.sh")], self.env, self.work)
119
+ self.assertEqual(result.returncode, 0, result.stderr)
120
+ plists = list((self.home / "Library/LaunchAgents").glob("*.plist"))
121
+ self.assertEqual(len(plists), 5)
122
+ for path in plists:
123
+ job = plistlib.loads(path.read_bytes())
124
+ self.assertIn("PATH", job["EnvironmentVariables"])
125
+ self.assertTrue(Path(job["ProgramArguments"][0]).is_file())
126
+ result = run(["claude-multiacc", "uninstall"], self.env, self.work)
127
+ self.assertEqual(result.returncode, 0, result.stderr)
128
+ self.assertNotIn("claude-multiacc", (self.home / ".zshrc").read_text())
129
+ self.assertTrue((self.home / ".codex-accounts/accounts.json").exists())
130
+ uname.write_text('#!/usr/bin/env bash\necho Linux\n')
131
+ (self.home / "tool-calls").write_text("")
132
+ args = ["install", "--instance", "sandbox", "--no-server", "--no-schedule"]
133
+ result = run(["claude-multiacc"] + args, self.env, self.work)
134
+ self.assertEqual(result.returncode, 0, result.stderr)
135
+ self.assertNotIn("/ln ", (self.home / "tool-calls").read_text())
136
+ finally:
137
+ uname.unlink()
138
+ identity.unlink()
139
+
140
+ def test_selector_transport_and_version_match_across_entrypoints(self):
141
+ expected = None
142
+ for prefix in (["multiacc-select"], ["claude-multiacc", "select"]):
143
+ version = run(prefix + ["--version"], self.env, self.work)
144
+ self.assertEqual(version.returncode, 0, version.stderr)
145
+ result = run(prefix + ["--request-json", "-", "--response-json", "-"], self.env, self.work, input="{}")
146
+ self.assertEqual(result.returncode, 0, result.stderr)
147
+ response = json.loads(result.stdout)
148
+ self.assertEqual(response["selector_version"], version.stdout.strip())
149
+ self.assertEqual(response["error_code"], "invalid_request")
150
+ if expected is not None:
151
+ self.assertEqual(response, expected)
152
+ expected = response
153
+
154
+ def test_documented_shell_examples_parse_and_command_names_exist(self):
155
+ documents = [ROOT / "README.md", *sorted((ROOT / "docs").glob("*.md"))]
156
+ for document in documents:
157
+ for block in re.findall(r"```(?:bash|sh)\n(.*?)```", document.read_text(), re.DOTALL):
158
+ result = run(["bash", "-n"], self.env, self.work, input=block)
159
+ self.assertEqual(result.returncode, 0, f"{document.name}: {result.stderr}")
160
+ for line in block.splitlines():
161
+ tokens = shlex.split(line, comments=True)
162
+ if not tokens or tokens[0] not in ("claude-accounts", "codex-accounts", "claude-multiacc"):
163
+ continue
164
+ self.assertNotIn("|", tokens, f"{document.name}: alternatives must be separate commands")
165
+ self.assertIsNone(re.search(r"--[a-z-]+#", line), line)
166
+ self.assertIsNotNone(shutil.which(tokens[0], path=self.env["PATH"]), line)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ unittest.main()