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.
- package/README.md +11 -138
- package/bin/claude-accounts +26 -5
- package/bin/claude-multiacc +3 -0
- package/bin/cli.mjs +16 -18
- package/bin/codex-accounts +35 -5
- package/bin/multiacc-select +4 -0
- package/docs/ACCOUNT_OPERATIONS.md +3 -61
- package/docs/CODEX.md +83 -0
- package/docs/INSTALLATION.md +91 -0
- package/docs/UNIFIED_SELECTOR.md +2 -0
- package/docs/VERIFICATION.md +68 -0
- package/install.sh +9 -284
- 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/install-help.txt +20 -0
- package/lib/install_actions.sh +238 -0
- package/lib/install_calendar.sh +59 -0
- package/package.json +5 -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/packaged_command_support.py +81 -0
- package/tests/run-tests.sh +30 -0
- package/tests/test_packaged_commands.py +170 -0
|
@@ -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.
|
|
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"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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)
|
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,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()
|