claude-multiacc 2.0.26 → 2.0.28
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 +50 -138
- package/bin/claude-accounts +2 -3
- package/bin/claude-multiacc +3 -0
- package/bin/cli.mjs +16 -18
- package/bin/codex +124 -0
- package/bin/codex-accounts +2 -3
- 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/common.sh +92 -2
- 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 +295 -2
- 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.28",
|
|
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)
|