claude-multiacc 2.0.32 → 2.0.34
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 -1
- package/bin/claude-accounts +46 -2
- package/bin/codex-accounts +16 -0
- package/docs/INSTALLATION.md +13 -4
- package/docs/VERIFICATION.md +13 -4
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.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/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/install_actions.sh +67 -13
- package/lib/shim_path.py +306 -0
- package/package.json +2 -2
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +223 -25
- package/tests/test_packaged_commands.py +39 -3
- package/tests/test_shim_path.py +285 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.34",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"postinstall": "node scripts/postinstall.mjs",
|
|
58
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",
|
|
59
|
+
"test:core": "bash tests/run-tests.sh && python3 tests/test_selector.py && python3 tests/test_shim_path.py",
|
|
60
60
|
"test:seeding": "python3 tests/test_account_seeding.py && npm run test:codex-settings",
|
|
61
61
|
"test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
|
|
62
62
|
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -292,6 +292,10 @@ export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
|
|
|
292
292
|
# extra `security` process per account would only slow them down); section 17 turns
|
|
293
293
|
# them on explicitly against the fake `security` above.
|
|
294
294
|
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
295
|
+
# `status` probes fresh login shells with $HOME's rc files (lib/shim_path.py). This suite
|
|
296
|
+
# runs under the operator's real HOME, whose rc files are not under test — section 18
|
|
297
|
+
# turns the probe on against its own fake HOME.
|
|
298
|
+
export CLAUDE_MULTIACC_PATH_PROBE=0
|
|
295
299
|
|
|
296
300
|
now="$(date +%s)"
|
|
297
301
|
|
|
@@ -2454,10 +2458,18 @@ done
|
|
|
2454
2458
|
&& t_ok "an account with no weekly reading never enters the band (0 of 10 picks)" \
|
|
2455
2459
|
|| t_fail "session-only ranking" "the weekly-less account took $nd_sess_hits of 10 picks"
|
|
2456
2460
|
|
|
2457
|
-
# The mirror image
|
|
2458
|
-
#
|
|
2459
|
-
#
|
|
2460
|
-
#
|
|
2461
|
+
# The mirror image — a weekly reading whose 5h bucket has nothing to say — is NOT the
|
|
2462
|
+
# same defect, and treating it as one broke selection on 2026-09-22. A weekly window
|
|
2463
|
+
# always exists (a fixed calendar week, running whether or not the account is), so a
|
|
2464
|
+
# silent weekly bucket is an endpoint that DECLINED. The 5h window only exists while it
|
|
2465
|
+
# is OPEN, so a silent session bucket is an account that has simply been IDLE — and an
|
|
2466
|
+
# exhausted session is never silent: a spent 5h bucket always reports ~100% with the
|
|
2467
|
+
# reset it is waiting on. Recording idle as UNKNOWN cost the pool its entire band,
|
|
2468
|
+
# because quota_known needs BOTH readings: the accounts with the MOST headroom
|
|
2469
|
+
# (acct-13/acct-14, 0% weekly) were scored 100 and dropped out, leaving acct-17 at 95%
|
|
2470
|
+
# weekly as the only band member — selection.log 2026-09-22T12:37:36Z, band-count=1,
|
|
2471
|
+
# four picks in a row onto an account already out of weekly headroom. A closed window
|
|
2472
|
+
# holds no usage: it reads 0.
|
|
2461
2473
|
cat > "$WORK/usage-weekly-only.json" <<'EOF'
|
|
2462
2474
|
{"limits":[
|
|
2463
2475
|
{"kind":"session","group":"session","percent":0,"resets_at":null,"scope":null},
|
|
@@ -2472,10 +2484,33 @@ assert 'no_data' not in d, d
|
|
|
2472
2484
|
assert (d['weekly_percent'], d['max_percent']) == (37, 37), d
|
|
2473
2485
|
assert d['weekly_resets_epoch'] == calendar.timegm(
|
|
2474
2486
|
time.strptime("2099-01-05T00:00:00", "%Y-%m-%dT%H:%M:%S")), d
|
|
2475
|
-
|
|
2487
|
+
# The 5h window is CLOSED, not undocumented, so it holds no usage. max_percent is still
|
|
2488
|
+
# taken over the INFORMATIVE buckets only, so this idle 0 never becomes a bucket reading.
|
|
2489
|
+
assert d['session_percent'] == 0, d
|
|
2476
2490
|
EOF
|
|
2477
|
-
[ $? -eq 0 ] && t_ok "
|
|
2478
|
-
|| t_fail "
|
|
2491
|
+
[ $? -eq 0 ] && t_ok "an idle 5h bucket beside a real weekly one records session_percent 0" \
|
|
2492
|
+
|| t_fail "idle-session signals" "see $ND/acct-01/limits.json"
|
|
2493
|
+
|
|
2494
|
+
# A payload with NO session bucket at ALL is the same story told by omission — the shape
|
|
2495
|
+
# every codex account produces while it is quiet. It must rank, not vanish.
|
|
2496
|
+
cat > "$WORK/usage-no-session-bucket.json" <<'EOF'
|
|
2497
|
+
{"limits":[
|
|
2498
|
+
{"kind":"weekly_all","group":"weekly","percent":37,"resets_at":"2099-01-05T00:00:00+00:00","scope":null}
|
|
2499
|
+
]}
|
|
2500
|
+
EOF
|
|
2501
|
+
ndlimits "$WORK/usage-no-session-bucket.json" --quiet >/dev/null
|
|
2502
|
+
python3 - "$ND/acct-01/limits.json" <<'EOF'
|
|
2503
|
+
import json, sys
|
|
2504
|
+
d = json.load(open(sys.argv[1]))
|
|
2505
|
+
assert 'no_data' not in d, d
|
|
2506
|
+
assert (d['weekly_percent'], d['session_percent'], d['max_percent']) == (37, 0, 37), d
|
|
2507
|
+
EOF
|
|
2508
|
+
[ $? -eq 0 ] && t_ok "a payload with no session bucket at all still records session_percent 0" \
|
|
2509
|
+
|| t_fail "absent-session signals" "see $ND/acct-01/limits.json"
|
|
2510
|
+
|
|
2511
|
+
# ...and that account RANKS: idle plus the better weekly must beat a busier rival — the
|
|
2512
|
+
# pick the pool was losing. acct-02 is worse on weekly (80 vs 37) and clears the gate
|
|
2513
|
+
# too, so the band alone separates them.
|
|
2479
2514
|
ndl 80 10 80 > "$ND/acct-02/limits.json"
|
|
2480
2515
|
rm -f "$ND/.pick-seq" "$ND"/acct-0*/.last-pick "$ND"/acct-0*/.limited
|
|
2481
2516
|
: > "$ND/selection.log"
|
|
@@ -2483,12 +2518,26 @@ nd_wk_hits=0
|
|
|
2483
2518
|
for _ in $(seq 1 6); do
|
|
2484
2519
|
case "$(CLAUDE_ACCOUNTS_ROOT="$ND" claude 2>&1)" in *CFG=acct-01*) nd_wk_hits=$((nd_wk_hits+1)) ;; esac
|
|
2485
2520
|
done
|
|
2486
|
-
[ "$nd_wk_hits" = "
|
|
2487
|
-
&& t_ok "an
|
|
2488
|
-
|| t_fail "
|
|
2489
|
-
grep -q "acct-
|
|
2490
|
-
&& t_ok "the log shows
|
|
2491
|
-
|| t_fail "
|
|
2521
|
+
[ "$nd_wk_hits" = "6" ] \
|
|
2522
|
+
&& t_ok "an idle account with the better weekly takes every pick (6 of 6)" \
|
|
2523
|
+
|| t_fail "idle-session ranking" "the idle account took $nd_wk_hits of 6"
|
|
2524
|
+
grep -q "acct-01 weekly=37% session=0% band=30 band-count=1 session-gate=50 session-ok=2" "$ND/selection.log" \
|
|
2525
|
+
&& t_ok "the log shows both clearing the gate and the idle account alone in the band" \
|
|
2526
|
+
|| t_fail "idle-session log" "$(tail -1 "$ND/selection.log" 2>/dev/null)"
|
|
2527
|
+
|
|
2528
|
+
# The guard that must NOT move: a document where NOTHING answered is still unknown. The
|
|
2529
|
+
# all-zero 2026-09-04 shape has no informative bucket anywhere, so it writes no signals
|
|
2530
|
+
# at all and never picks up a fabricated session 0 on the way out.
|
|
2531
|
+
ndlimits "$WORK/usage-allzero.json" --quiet >/dev/null
|
|
2532
|
+
python3 - "$ND/acct-01/limits.json" <<'EOF'
|
|
2533
|
+
import json, sys
|
|
2534
|
+
d = json.load(open(sys.argv[1]))
|
|
2535
|
+
assert d.get('no_data') is True, d
|
|
2536
|
+
for k in ('max_percent', 'weekly_percent', 'session_percent', 'weekly_resets_epoch'):
|
|
2537
|
+
assert k not in d, (k, d)
|
|
2538
|
+
EOF
|
|
2539
|
+
[ $? -eq 0 ] && t_ok "an all-zero payload still writes no session_percent (2026-09-04 holds)" \
|
|
2540
|
+
|| t_fail "allzero still unknown" "see $ND/acct-01/limits.json"
|
|
2492
2541
|
|
|
2493
2542
|
# ---- 16-nodata-blind. a fresh timestamp is not a usable reading ---------------------
|
|
2494
2543
|
# The third face of the same defect: blindness was judged on fetched_at alone, so a pool
|
|
@@ -5868,6 +5917,13 @@ done
|
|
|
5868
5917
|
&& t_ok "codex: an account with no weekly reading never enters the band (0 of 10 picks)" \
|
|
5869
5918
|
|| t_fail "codex session-only ranking" "the weekly-less account took $cs_hits of 10 picks"
|
|
5870
5919
|
|
|
5920
|
+
# The mirror image — a weekly reading whose ~5h window has nothing to say — is NOT the
|
|
5921
|
+
# same defect, and codex feels it hardest: an idle account's payload carries the 7d
|
|
5922
|
+
# window and no usable session window at all, so session_percent went permanently
|
|
5923
|
+
# absent across the whole pool. quota_known needs BOTH readings, so EVERY codex account
|
|
5924
|
+
# scored unknown, bestw never left 101, and the band collapsed into the all-gated tie —
|
|
5925
|
+
# codex picked uniformly at RANDOM instead of by headroom (selection.log 2026-09-22:
|
|
5926
|
+
# `session=?%` on every ranked line). A closed window holds no usage: it reads 0.
|
|
5871
5927
|
cat > "$WORK/cx-usage-weekly-only.json" <<EOF
|
|
5872
5928
|
{"email":"nd@cx","plan_type":"pro",
|
|
5873
5929
|
"rate_limit":{"allowed":true,"limit_reached":false,
|
|
@@ -5881,13 +5937,32 @@ import json, sys
|
|
|
5881
5937
|
d = json.load(open(sys.argv[1]))
|
|
5882
5938
|
assert 'no_data' not in d, d
|
|
5883
5939
|
assert (d['weekly_percent'], d['max_percent']) == (37, 37), d
|
|
5884
|
-
|
|
5940
|
+
# The ~5h window is CLOSED, not undocumented, so it holds no usage. max_percent is still
|
|
5941
|
+
# taken over the INFORMATIVE windows only, so this idle 0 never becomes a reading.
|
|
5942
|
+
assert d['session_percent'] == 0, d
|
|
5943
|
+
EOF
|
|
5944
|
+
[ $? -eq 0 ] && t_ok "codex: an idle 5h window beside a real weekly one records session_percent 0" \
|
|
5945
|
+
|| t_fail "codex idle-session signals" "see $CD/acct-01/limits.json"
|
|
5946
|
+
|
|
5947
|
+
# The same story told by omission: no session window in the payload at all.
|
|
5948
|
+
cat > "$WORK/cx-usage-no-session.json" <<EOF
|
|
5949
|
+
{"email":"nd@cx","plan_type":"pro",
|
|
5950
|
+
"rate_limit":{"allowed":true,"limit_reached":false,
|
|
5951
|
+
"secondary_window":{"used_percent":37,"limit_window_seconds":604800,"reset_at":$((now+90000))}},
|
|
5952
|
+
"additional_rate_limits":[]}
|
|
5953
|
+
EOF
|
|
5954
|
+
cdlimits "$WORK/cx-usage-no-session.json" --quiet >/dev/null
|
|
5955
|
+
python3 - "$CD/acct-01/limits.json" <<'EOF'
|
|
5956
|
+
import json, sys
|
|
5957
|
+
d = json.load(open(sys.argv[1]))
|
|
5958
|
+
assert 'no_data' not in d, d
|
|
5959
|
+
assert (d['weekly_percent'], d['session_percent'], d['max_percent']) == (37, 0, 37), d
|
|
5885
5960
|
EOF
|
|
5886
|
-
[ $? -eq 0 ] && t_ok "codex: a
|
|
5887
|
-
|| t_fail "codex
|
|
5888
|
-
|
|
5889
|
-
#
|
|
5890
|
-
#
|
|
5961
|
+
[ $? -eq 0 ] && t_ok "codex: a payload with no session window at all records session_percent 0" \
|
|
5962
|
+
|| t_fail "codex absent-session signals" "see $CD/acct-01/limits.json"
|
|
5963
|
+
|
|
5964
|
+
# ...and it RANKS: idle plus the better weekly (37) must beat busier rivals (80), which
|
|
5965
|
+
# is the pick this pool was losing on every single invocation.
|
|
5891
5966
|
cdl 80 10 80 > "$CD/acct-02/limits.json"
|
|
5892
5967
|
cdl 80 10 80 > "$CD/acct-03/limits.json"
|
|
5893
5968
|
rm -f "$CD/.pick-seq" "$CD"/acct-0*/.last-pick
|
|
@@ -5896,12 +5971,25 @@ cwk_hits=0
|
|
|
5896
5971
|
for _ in $(seq 1 6); do
|
|
5897
5972
|
case "$(CODEX_ACCOUNTS_ROOT="$CD" codex 2>&1)" in *CFG=acct-01*) cwk_hits=$((cwk_hits+1)) ;; esac
|
|
5898
5973
|
done
|
|
5899
|
-
[ "$cwk_hits" = "
|
|
5900
|
-
&& t_ok "codex: an
|
|
5901
|
-
|| t_fail "codex
|
|
5902
|
-
grep -q "band=30 band-count=
|
|
5903
|
-
&& t_ok "codex: the
|
|
5904
|
-
|| t_fail "codex
|
|
5974
|
+
[ "$cwk_hits" = "6" ] \
|
|
5975
|
+
&& t_ok "codex: an idle account with the better weekly takes every pick (6 of 6)" \
|
|
5976
|
+
|| t_fail "codex idle-session ranking" "the idle account took $cwk_hits of 6"
|
|
5977
|
+
grep -q "acct-01 weekly=37% session=0% band=30 band-count=1 session-gate=50 session-ok=3" "$CD/selection.log" \
|
|
5978
|
+
&& t_ok "codex: all three clear the gate and the idle account is alone in the band" \
|
|
5979
|
+
|| t_fail "codex idle-session log" "$(tail -1 "$CD/selection.log" 2>/dev/null)"
|
|
5980
|
+
|
|
5981
|
+
# The guard that must NOT move: a payload where NOTHING answered stays unknown, and
|
|
5982
|
+
# never picks up a fabricated session 0 on the way out.
|
|
5983
|
+
cdlimits "$WORK/cx-usage-allzero.json" --quiet >/dev/null
|
|
5984
|
+
python3 - "$CD/acct-01/limits.json" <<'EOF'
|
|
5985
|
+
import json, sys
|
|
5986
|
+
d = json.load(open(sys.argv[1]))
|
|
5987
|
+
assert d.get('no_data') is True, d
|
|
5988
|
+
for k in ('max_percent', 'weekly_percent', 'session_percent'):
|
|
5989
|
+
assert k not in d, (k, d)
|
|
5990
|
+
EOF
|
|
5991
|
+
[ $? -eq 0 ] && t_ok "codex: an all-zero payload still writes no session_percent (2026-09-04 holds)" \
|
|
5992
|
+
|| t_fail "codex allzero still unknown" "see $CD/acct-01/limits.json"
|
|
5905
5993
|
rm -f "$CD"/acct-0*/.limited
|
|
5906
5994
|
|
|
5907
5995
|
# ---- C14. oauth refresh via the token endpoint --------------------------------------
|
|
@@ -6897,6 +6985,116 @@ grep -q '"acct-04"' "$KCP/accounts.json" \
|
|
|
6897
6985
|
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
6898
6986
|
unset FAKE_KEYCHAIN_DIR
|
|
6899
6987
|
|
|
6988
|
+
# ---- 18. the rc block shadows `claude` even when the rc file that carries it is cut short ----
|
|
6989
|
+
# 2026-09-17: a Ctrl-C at the slow conda hook in ~/.zshrc stopped the file before the
|
|
6990
|
+
# multiacc block, ~/.local/bin stayed in front, and the next `claude` at that prompt ran
|
|
6991
|
+
# the real binary on ~/.claude — "Not logged in · Please run /login". Every `bash -l` and
|
|
6992
|
+
# `sh -l` on that Mac bypassed the shim too: ~/.profile (read when there is no
|
|
6993
|
+
# ~/.bash_profile) carried no block at all. The block now ends with a prompt hook that
|
|
6994
|
+
# re-asserts the shim dir before every prompt, the installer appends to ~/.profile, and
|
|
6995
|
+
# `status` probes fresh login shells so a bypass is reported instead of guessed at.
|
|
6996
|
+
RCH="$WORK/rc-home"
|
|
6997
|
+
mkdir -p "$RCH/.local/bin"
|
|
6998
|
+
printf '#!/bin/sh\necho REAL\n' > "$RCH/.local/bin/claude"
|
|
6999
|
+
cp "$RCH/.local/bin/claude" "$RCH/.local/bin/codex"
|
|
7000
|
+
chmod +x "$RCH/.local/bin/claude" "$RCH/.local/bin/codex"
|
|
7001
|
+
rc_block_text() { # the block the installer writes, for THIS repo
|
|
7002
|
+
REPO_DIR="$REPO_DIR" MARK_BEGIN='# >>> claude-multiacc >>>' MARK_END='# <<< claude-multiacc <<<' \
|
|
7003
|
+
bash -c '. "$REPO_DIR/lib/install_actions.sh"; rc_block'
|
|
7004
|
+
}
|
|
7005
|
+
rc_block_text > "$WORK/rc-block.sh"
|
|
7006
|
+
for rsh in sh bash zsh; do
|
|
7007
|
+
command -v "$rsh" >/dev/null 2>&1 || continue
|
|
7008
|
+
if "$rsh" -n "$WORK/rc-block.sh" 2>/dev/null; then t_ok "rc block parses under $rsh"
|
|
7009
|
+
else t_fail "rc block parses under $rsh" "$("$rsh" -n "$WORK/rc-block.sh" 2>&1 | head -2)"; fi
|
|
7010
|
+
done
|
|
7011
|
+
grep -q '_claude_multiacc_path_guard' "$WORK/rc-block.sh" \
|
|
7012
|
+
&& t_ok "rc block carries the prompt hook" || t_fail "rc block carries the prompt hook"
|
|
7013
|
+
# bash: the PROMPT_COMMAND hook undoes a later re-prepend, and registers itself once
|
|
7014
|
+
out="$(HOME="$RCH" bash -c '. "$1"; . "$1"; PATH="$HOME/.local/bin:$PATH"; eval "$PROMPT_COMMAND"; printf "%s|%s" "${PATH%%:*}" "$PROMPT_COMMAND"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
7015
|
+
[ "$out" = "$REPO_DIR/bin|_claude_multiacc_path_guard" ] \
|
|
7016
|
+
&& t_ok "bash prompt hook re-asserts the shim dir (registered once)" \
|
|
7017
|
+
|| t_fail "bash prompt hook re-asserts the shim dir" "got: $out"
|
|
7018
|
+
if command -v zsh >/dev/null 2>&1; then
|
|
7019
|
+
out="$(HOME="$RCH" zsh -c '. "$1"; . "$1"; PATH="$HOME/.local/bin:$PATH"; _claude_multiacc_path_guard; print -r -- "${PATH%%:*}|${(j:,:)precmd_functions}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
7020
|
+
[ "$out" = "$REPO_DIR/bin|_claude_multiacc_path_guard" ] \
|
|
7021
|
+
&& t_ok "zsh precmd hook re-asserts the shim dir (registered once)" \
|
|
7022
|
+
|| t_fail "zsh precmd hook re-asserts the shim dir" "got: $out"
|
|
7023
|
+
fi
|
|
7024
|
+
# ORDER: a hook registered before ours that rewrites PATH (direnv, a venv) must not have
|
|
7025
|
+
# the final word — bash appends after it, zsh moves itself behind hooks ~/.zshrc added.
|
|
7026
|
+
out="$(HOME="$RCH" bash -c 'PROMPT_COMMAND="other_hook"; . "$1"; printf %s "$PROMPT_COMMAND"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
7027
|
+
[ "$out" = "other_hook;_claude_multiacc_path_guard" ] \
|
|
7028
|
+
&& t_ok "bash hook is appended after an existing PROMPT_COMMAND" \
|
|
7029
|
+
|| t_fail "bash hook is appended after an existing PROMPT_COMMAND" "got: $out"
|
|
7030
|
+
if command -v zsh >/dev/null 2>&1; then
|
|
7031
|
+
out="$(HOME="$RCH" zsh -c '. "$1"; precmd_functions+=(later_hook); . "$1"; print -r -- "${(j:,:)precmd_functions}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
7032
|
+
[ "$out" = "later_hook,_claude_multiacc_path_guard" ] \
|
|
7033
|
+
&& t_ok "zsh hook re-registers itself LAST when the block is sourced again" \
|
|
7034
|
+
|| t_fail "zsh hook re-registers itself LAST" "got: $out"
|
|
7035
|
+
out="$(HOME="$RCH" zsh -c 'IFS=":"; . "$1"; . "$1"; print -r -- "${(j:,:)precmd_functions}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
7036
|
+
[ "$out" = "_claude_multiacc_path_guard" ] \
|
|
7037
|
+
&& t_ok "zsh hook registers once whatever IFS is" \
|
|
7038
|
+
|| t_fail "zsh hook registers once whatever IFS is" "got: $out"
|
|
7039
|
+
fi
|
|
7040
|
+
# the hook is inert once the shim dir is gone: no error, PATH left alone
|
|
7041
|
+
out="$(HOME="$RCH" bash -c 'sed "s|'"$REPO_DIR"'|/nonexistent/multiacc|g" "$1" > "$1.gone"; . "$1.gone"; PATH="$HOME/.local/bin:$PATH"; _claude_multiacc_path_guard; printf "rc=%s %s" "$?" "${PATH%%:*}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
7042
|
+
[ "$out" = "rc=0 $RCH/.local/bin" ] \
|
|
7043
|
+
&& t_ok "prompt hook is inert when the shim dir is gone" \
|
|
7044
|
+
|| t_fail "prompt hook is inert when the shim dir is gone" "got: $out"
|
|
7045
|
+
# ~/.profile: CREATED when absent (a stock Mac has none — without it every bash/sh login
|
|
7046
|
+
# shell stays bypassed and the probe would fail health with a remedy that does nothing),
|
|
7047
|
+
# appended to exactly once when present, and ~/.bash_profile is never created.
|
|
7048
|
+
rm -f "$RCH/.profile"
|
|
7049
|
+
HOME="$RCH" REPO_DIR="$REPO_DIR" MARK_BEGIN='# >>> claude-multiacc >>>' MARK_END='# <<< claude-multiacc <<<' \
|
|
7050
|
+
bash -c '. "$REPO_DIR/lib/install_actions.sh"; install_profile_block' >/dev/null
|
|
7051
|
+
[ -f "$RCH/.profile" ] && [ "$(grep -c '# >>> claude-multiacc >>>' "$RCH/.profile")" = "1" ] \
|
|
7052
|
+
&& t_ok "~/.profile is created with the block when absent" \
|
|
7053
|
+
|| t_fail "~/.profile is created with the block when absent"
|
|
7054
|
+
printf 'export PATH="$HOME/.local/bin:$PATH"\n' > "$RCH/.profile"
|
|
7055
|
+
for _ in 1 2; do
|
|
7056
|
+
HOME="$RCH" REPO_DIR="$REPO_DIR" MARK_BEGIN='# >>> claude-multiacc >>>' MARK_END='# <<< claude-multiacc <<<' \
|
|
7057
|
+
bash -c '. "$REPO_DIR/lib/install_actions.sh"; install_profile_block' >/dev/null
|
|
7058
|
+
done
|
|
7059
|
+
[ "$(grep -c '# >>> claude-multiacc >>>' "$RCH/.profile")" = "1" ] \
|
|
7060
|
+
&& t_ok "~/.profile gets the block exactly once" || t_fail "~/.profile gets the block exactly once"
|
|
7061
|
+
[ ! -e "$RCH/.bash_profile" ] && t_ok "installer never creates ~/.bash_profile" \
|
|
7062
|
+
|| t_fail "installer never creates ~/.bash_profile" "it exists"
|
|
7063
|
+
for lsh in sh bash; do
|
|
7064
|
+
out="$(env -i HOME="$RCH" USER="${USER:-tester}" TERM=dumb PATH=/usr/bin:/bin "$lsh" -lc 'command -v claude' 2>/dev/null)"
|
|
7065
|
+
[ "$out" = "$REPO_DIR/bin/claude" ] && t_ok "$lsh -l resolves the shim through ~/.profile" \
|
|
7066
|
+
|| t_fail "$lsh -l resolves the shim through ~/.profile" "got: $out"
|
|
7067
|
+
done
|
|
7068
|
+
# the interrupted-rc incident, end to end, through the probe `status` prints
|
|
7069
|
+
if command -v zsh >/dev/null 2>&1; then
|
|
7070
|
+
cp "$WORK/rc-block.sh" "$RCH/.zshenv"
|
|
7071
|
+
{ printf 'export PATH="$HOME/.local/bin:$PATH"\n'; cat "$WORK/rc-block.sh"; } > "$RCH/.zprofile"
|
|
7072
|
+
printf 'export PATH="$HOME/.local/bin:$PATH"\nreturn 0 # the rest of this rc file was never reached\n' > "$RCH/.zshrc"
|
|
7073
|
+
cat "$WORK/rc-block.sh" >> "$RCH/.zshrc"
|
|
7074
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 python3 "$REPO_DIR/lib/shim_path.py" "$REPO_DIR" 2>&1)"; rc=$?
|
|
7075
|
+
[ "$rc" = 0 ] && case "$out" in *"zsh -li"*"claude: OK"*) t_ok "interrupted ~/.zshrc: the prompt hook still hands claude to the shim" ;; *) t_fail "interrupted ~/.zshrc probe" "$out" ;; esac
|
|
7076
|
+
[ "$rc" = 0 ] || t_fail "interrupted ~/.zshrc probe exit" "rc=$rc: $out"
|
|
7077
|
+
# the same rc files with the OLD two-line block (no hook) are the incident itself
|
|
7078
|
+
for f in .zshenv .zprofile .zshrc; do
|
|
7079
|
+
awk '/^_claude_multiacc_path_guard\(\)/{skip=1} /^# <<< claude-multiacc <<</{skip=0} !skip' "$RCH/$f" > "$RCH/$f.old" && mv "$RCH/$f.old" "$RCH/$f"
|
|
7080
|
+
done
|
|
7081
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 python3 "$REPO_DIR/lib/shim_path.py" "$REPO_DIR" 2>&1)"; rc=$?
|
|
7082
|
+
case "$rc:$out" in 1:*"zsh -li"*"BYPASSED -> $RCH/.local/bin/claude"*) t_ok "the probe catches the pre-fix block (zsh -li bypassed)" ;;
|
|
7083
|
+
*) t_fail "the probe catches the pre-fix block" "rc=$rc: $out" ;; esac
|
|
7084
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 claude-accounts status 2>&1)"
|
|
7085
|
+
check "status reports the bypass" "BYPASSED -> $RCH/.local/bin/claude" "$out"
|
|
7086
|
+
check "status names the fix" "claude-multiacc install" "$out"
|
|
7087
|
+
# restore the hook: status is clean again
|
|
7088
|
+
cp "$WORK/rc-block.sh" "$RCH/.zshenv"
|
|
7089
|
+
{ printf 'export PATH="$HOME/.local/bin:$PATH"\n'; cat "$WORK/rc-block.sh"; } > "$RCH/.zprofile"
|
|
7090
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 claude-accounts status 2>&1)"
|
|
7091
|
+
case "$out" in *BYPASSED*|*"NOT ON PATH"*) t_fail "status is clean with the hooked block" "$out" ;;
|
|
7092
|
+
*"shim on PATH"*) t_ok "status is clean with the hooked block" ;;
|
|
7093
|
+
*) t_fail "status prints the shim probe" "$out" ;; esac
|
|
7094
|
+
fi
|
|
7095
|
+
out="$(HOME="$RCH" claude-accounts status 2>&1)"
|
|
7096
|
+
check "status honours CLAUDE_MULTIACC_PATH_PROBE=0" "probe disabled" "$out"
|
|
7097
|
+
|
|
6900
7098
|
# The reset-credit contract is easier to prove against a stateful local HTTP server
|
|
6901
7099
|
# than file:// fixtures: it pins thresholding, credit ordering and idempotent POST retry.
|
|
6902
7100
|
if CODEX_MULTIACC_AUTO_RESET=1 python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_codex_reset*.py'; then
|
|
@@ -6,6 +6,7 @@ import plistlib
|
|
|
6
6
|
import re
|
|
7
7
|
import shlex
|
|
8
8
|
import shutil
|
|
9
|
+
import sys
|
|
9
10
|
import unittest
|
|
10
11
|
|
|
11
12
|
from packaged_command_support import ROOT, install_package, run, seed_pools
|
|
@@ -117,13 +118,46 @@ class PackagedCommandsTests(unittest.TestCase):
|
|
|
117
118
|
identity.write_text('#!/usr/bin/env bash\necho 0\n')
|
|
118
119
|
uname.chmod(0o755)
|
|
119
120
|
identity.chmod(0o755)
|
|
121
|
+
# bash/sh login shells read ~/.profile when ~/.bash_profile is absent, and a
|
|
122
|
+
# ~/.bashrc exists on most machines: both must end with the block, ~/.bash_profile
|
|
123
|
+
# must NOT be created (its existence would stop bash reading ~/.profile).
|
|
124
|
+
prepend = 'export PATH="$HOME/.local/bin:$PATH"\n'
|
|
125
|
+
for rc in (".profile", ".bashrc"):
|
|
126
|
+
(self.home / rc).write_text(prepend)
|
|
127
|
+
real_bin = self.home / ".local/bin"
|
|
128
|
+
real_bin.mkdir(parents=True)
|
|
129
|
+
for name in ("claude", "codex"):
|
|
130
|
+
(real_bin / name).write_text('#!/bin/sh\necho REAL\n')
|
|
131
|
+
(real_bin / name).chmod(0o755)
|
|
120
132
|
try:
|
|
121
133
|
for _ in range(2):
|
|
122
134
|
result = run(["claude-multiacc", "install", "--no-server", "--no-schedule"], self.env, self.work)
|
|
123
135
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
124
136
|
self.assertNotIn("status |", result.stdout)
|
|
125
|
-
for rc in (".zshenv", ".zprofile", ".zshrc"):
|
|
126
|
-
|
|
137
|
+
for rc in (".zshenv", ".zprofile", ".zshrc", ".profile", ".bashrc"):
|
|
138
|
+
text = (self.home / rc).read_text()
|
|
139
|
+
self.assertEqual(text.count("# >>> claude-multiacc >>>"), 1, rc)
|
|
140
|
+
self.assertEqual(text.count("_claude_multiacc_path_guard() {"), 1, rc)
|
|
141
|
+
self.assertTrue(text.rstrip().endswith("# <<< claude-multiacc <<<"), rc)
|
|
142
|
+
self.assertFalse((self.home / ".bash_profile").exists())
|
|
143
|
+
# ...and fresh login shells of every flavour now resolve the package's shims,
|
|
144
|
+
# the real binaries in ~/.local/bin notwithstanding (lib/shim_path.py is the
|
|
145
|
+
# same probe `claude-accounts status` and `health` print).
|
|
146
|
+
probe_env = {**self.env, "CLAUDE_MULTIACC_PATH_PROBE": "1", "USER": "tester"}
|
|
147
|
+
result = run([sys.executable, str(self.package / "lib/shim_path.py"), str(self.package), "--json"],
|
|
148
|
+
probe_env, self.work)
|
|
149
|
+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
150
|
+
report = json.loads(result.stdout)
|
|
151
|
+
self.assertEqual(report["bypassed"], [])
|
|
152
|
+
answered = [m for m in report["modes"] if m["error"] != "shell not installed"]
|
|
153
|
+
self.assertGreaterEqual(len(answered), 2)
|
|
154
|
+
for mode in answered:
|
|
155
|
+
self.assertIsNone(mode["error"], mode)
|
|
156
|
+
for name in ("claude", "codex"):
|
|
157
|
+
self.assertTrue(mode["results"][name]["ok"], mode)
|
|
158
|
+
status = run(["claude-accounts", "status"], probe_env, self.work)
|
|
159
|
+
self.assertIn("shim on PATH", status.stdout)
|
|
160
|
+
self.assertNotIn("BYPASSED", status.stdout)
|
|
127
161
|
calls = (self.home / "tool-calls").read_text()
|
|
128
162
|
self.assertNotRegex(calls, r"/(launchctl|crontab|ln) ")
|
|
129
163
|
script = 'source "$0" --no-server --no-schedule; mac_schedule_install'
|
|
@@ -137,7 +171,9 @@ class PackagedCommandsTests(unittest.TestCase):
|
|
|
137
171
|
self.assertTrue(Path(job["ProgramArguments"][0]).is_file())
|
|
138
172
|
result = run(["claude-multiacc", "uninstall"], self.env, self.work)
|
|
139
173
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
140
|
-
|
|
174
|
+
for rc in (".zshenv", ".zprofile", ".zshrc", ".profile", ".bashrc"):
|
|
175
|
+
self.assertNotIn("claude-multiacc", (self.home / rc).read_text(), rc)
|
|
176
|
+
self.assertEqual((self.home / ".profile").read_text(), prepend)
|
|
141
177
|
self.assertTrue((self.home / ".codex-accounts/accounts.json").exists())
|
|
142
178
|
uname.write_text('#!/usr/bin/env bash\necho Linux\n')
|
|
143
179
|
(self.home / "tool-calls").write_text("")
|