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
package/tests/run-tests.sh
CHANGED
|
@@ -4906,6 +4906,296 @@ check "codex: sign-in as registered email refused" "already added as acct-01" "$
|
|
|
4906
4906
|
n="$(grep -c '"email": "a@cx"' "$CX/accounts.json")"
|
|
4907
4907
|
[ "$n" = "1" ] && t_ok "codex: no second entry for a re-signed-in email" || t_fail "codex dup signin" "count=$n"
|
|
4908
4908
|
|
|
4909
|
+
# ---- C11b. one index for the shared session tree ------------------------------------
|
|
4910
|
+
# codex refuses to start until state_<schema>.sqlite has indexed every rollout under
|
|
4911
|
+
# $CODEX_HOME/sessions. The layout shares that tree across accounts, so it must share
|
|
4912
|
+
# the index too — a private index per account re-read the whole tree per account and
|
|
4913
|
+
# stranded a 15-minute worker lease whenever a scan was cut short (my-mini 2026-09-09:
|
|
4914
|
+
# six fresh accounts refused every launch as "local database appears to be damaged").
|
|
4915
|
+
CXH="$WORK/cxhome"
|
|
4916
|
+
mkdir -p "$CXH/.codex/sessions"
|
|
4917
|
+
printf 'home index' > "$CXH/.codex/state_5.sqlite"
|
|
4918
|
+
# (a) seeding: a new account links the tree AND the index the home already has
|
|
4919
|
+
out="$(HOME="$CXH" FAKE_EMAIL=idx@cx codex-accounts add 2>&1)"
|
|
4920
|
+
nid="$(printf '%s' "$out" | sed -n 's/.*Registered \(acct-[0-9]*\) .*/\1/p' | head -1)"
|
|
4921
|
+
[ -n "$nid" ] || t_fail "codex shared index seed" "add did not register: $(printf '%s' "$out" | head -c 200)"
|
|
4922
|
+
[ "$(readlink "$CX/$nid/sessions" 2>/dev/null)" = "$CXH/.codex/sessions" ] \
|
|
4923
|
+
&& t_ok "codex: a new account shares the home's session tree" \
|
|
4924
|
+
|| t_fail "codex shared tree seed" "sessions -> $(readlink "$CX/$nid/sessions" 2>/dev/null)"
|
|
4925
|
+
[ "$(readlink "$CX/$nid/state_5.sqlite" 2>/dev/null)" = "$CXH/.codex/state_5.sqlite" ] \
|
|
4926
|
+
&& t_ok "codex: a new account shares the home's rollout index with that tree" \
|
|
4927
|
+
|| t_fail "codex shared index seed" "state_5.sqlite -> $(readlink "$CX/$nid/state_5.sqlite" 2>/dev/null)"
|
|
4928
|
+
[ -n "$nid" ] && codex-accounts remove "$nid" --yes >/dev/null 2>&1
|
|
4929
|
+
|
|
4930
|
+
# The launch-time heal, on its own pool so nothing here leaks into the other codex tests.
|
|
4931
|
+
CXS="$WORK/cx-state"
|
|
4932
|
+
mkdir -p "$CXS/tmp" "$CXS/acct-01" "$CXS/acct-02" "$CXS/acct-03"
|
|
4933
|
+
: > "$CXS/.limits-kick"
|
|
4934
|
+
cat > "$CXS/accounts.json" <<EOF2
|
|
4935
|
+
{"version": 1, "server": "root@203.0.113.1", "server_root": "/root/.codex-accounts",
|
|
4936
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
4937
|
+
"accounts": [
|
|
4938
|
+
{"id": "acct-01", "email": "s1@cx", "home": "mac", "added_at": "2026-09-09T00:00:00Z"},
|
|
4939
|
+
{"id": "acct-02", "email": "s2@cx", "home": "mac", "added_at": "2026-09-09T00:00:00Z"},
|
|
4940
|
+
{"id": "acct-03", "email": "s3@cx", "home": "mac", "added_at": "2026-09-09T00:00:00Z"}]}
|
|
4941
|
+
EOF2
|
|
4942
|
+
for i in 01 02 03; do mk_cx_auth "$CXS/acct-$i/auth.json" "s$i@cx" "$FUTURE_EXP"; done
|
|
4943
|
+
CXH2="$WORK/cxhome2"
|
|
4944
|
+
mkdir -p "$CXH2/.codex/sessions"
|
|
4945
|
+
ln -s "$CXH2/.codex/sessions" "$CXS/acct-01/sessions"
|
|
4946
|
+
ln -s "$CXH2/.codex/sessions" "$CXS/acct-02/sessions"
|
|
4947
|
+
mkdir -p "$CXS/acct-03/sessions" # a PRIVATE tree
|
|
4948
|
+
printf 'closed private index' > "$CXS/acct-01/state_5.sqlite"
|
|
4949
|
+
printf 'open private index' > "$CXS/acct-02/state_5.sqlite"
|
|
4950
|
+
printf 'wal' > "$CXS/acct-02/state_5.sqlite-wal"
|
|
4951
|
+
printf 'shm' > "$CXS/acct-02/state_5.sqlite-shm"
|
|
4952
|
+
printf 'private tree index' > "$CXS/acct-03/state_5.sqlite"
|
|
4953
|
+
# (b) an OPEN private index (its -wal beside it) is never moved while the home has no index
|
|
4954
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-02 codex 2>&1)"
|
|
4955
|
+
check "codex: launch on an account with an open private index" "CFG=acct-02" "$out"
|
|
4956
|
+
{ [ -f "$CXS/acct-02/state_5.sqlite" ] && [ ! -L "$CXS/acct-02/state_5.sqlite" ] \
|
|
4957
|
+
&& [ ! -e "$CXH2/.codex/state_5.sqlite" ]; } \
|
|
4958
|
+
&& t_ok "codex: an open private index is never promoted (one inode, two -shm files, is how WAL corrupts)" \
|
|
4959
|
+
|| t_fail "codex open index promote" "$(ls -la "$CXS/acct-02" "$CXH2/.codex" 2>&1 | head -c 400)"
|
|
4960
|
+
# a -shm on its own is just as much a live database as a -wal is
|
|
4961
|
+
mv "$CXS/acct-02/state_5.sqlite-wal" "$CXS/acct-02/held-wal"
|
|
4962
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-02 codex 2>&1)"
|
|
4963
|
+
check "codex: launch on an account whose index has only a -shm" "CFG=acct-02" "$out"
|
|
4964
|
+
{ [ -f "$CXS/acct-02/state_5.sqlite" ] && [ ! -e "$CXH2/.codex/state_5.sqlite" ]; } \
|
|
4965
|
+
&& t_ok "codex: a -shm alone also refuses the promote" \
|
|
4966
|
+
|| t_fail "codex shm promote" "$(ls -la "$CXS/acct-02" "$CXH2/.codex" 2>&1 | head -c 400)"
|
|
4967
|
+
mv "$CXS/acct-02/held-wal" "$CXS/acct-02/state_5.sqlite-wal"
|
|
4968
|
+
# (c) a CLOSED private index is promoted into the home and linked back
|
|
4969
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-01 codex 2>&1)"
|
|
4970
|
+
check "codex: launch on an account with a closed private index" "CFG=acct-01" "$out"
|
|
4971
|
+
[ "$(cat "$CXH2/.codex/state_5.sqlite" 2>/dev/null)" = "closed private index" ] \
|
|
4972
|
+
&& t_ok "codex: the first closed private index becomes the home's shared index" \
|
|
4973
|
+
|| t_fail "codex index promote" "home index: $(cat "$CXH2/.codex/state_5.sqlite" 2>&1 | head -c 80)"
|
|
4974
|
+
[ "$(readlink "$CXS/acct-01/state_5.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_5.sqlite" ] \
|
|
4975
|
+
&& t_ok "codex: the promoting account links to the shared index" \
|
|
4976
|
+
|| t_fail "codex index promote link" "state_5.sqlite -> $(readlink "$CXS/acct-01/state_5.sqlite" 2>/dev/null)"
|
|
4977
|
+
# (d) the home has an index now, but the account's own is still OPEN: moving it would
|
|
4978
|
+
# split its holder across two databases (codex's sqlx pool re-opens BY PATH), so it
|
|
4979
|
+
# is left alone until the process that has it exits and clears its -wal/-shm.
|
|
4980
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-02 codex 2>&1)"
|
|
4981
|
+
check "codex: relaunch on the account with the open private index" "CFG=acct-02" "$out"
|
|
4982
|
+
{ [ -f "$CXS/acct-02/state_5.sqlite" ] && [ ! -L "$CXS/acct-02/state_5.sqlite" ]; } \
|
|
4983
|
+
&& t_ok "codex: an index a process can still open is never moved, even once the home has one" \
|
|
4984
|
+
|| t_fail "codex open index retire" "$(ls "$CXS/acct-02" 2>&1 | tr '\n' ' ')"
|
|
4985
|
+
# (d2) its holder exited and SQLite removed the pair: now it is retired beside the link
|
|
4986
|
+
rm -f "$CXS/acct-02/state_5.sqlite-wal" "$CXS/acct-02/state_5.sqlite-shm"
|
|
4987
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-02 codex 2>&1)"
|
|
4988
|
+
check "codex: relaunch once the private index is closed" "CFG=acct-02" "$out"
|
|
4989
|
+
[ "$(readlink "$CXS/acct-02/state_5.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_5.sqlite" ] \
|
|
4990
|
+
&& t_ok "codex: a closed private index is retired for the shared one once the home has it" \
|
|
4991
|
+
|| t_fail "codex index retire" "state_5.sqlite -> $(readlink "$CXS/acct-02/state_5.sqlite" 2>/dev/null)"
|
|
4992
|
+
[ "$(cat "$CXS/acct-02/state_5.sqlite.private" 2>/dev/null)" = "open private index" ] \
|
|
4993
|
+
&& t_ok "codex: the retired index stays beside the link, never deleted" \
|
|
4994
|
+
|| t_fail "codex index retire files" "$(ls "$CXS/acct-02" 2>&1 | tr '\n' ' ')"
|
|
4995
|
+
[ "$(cat "$CXH2/.codex/state_5.sqlite" 2>/dev/null)" = "closed private index" ] \
|
|
4996
|
+
&& t_ok "codex: retiring never overwrites the shared index" \
|
|
4997
|
+
|| t_fail "codex index retire overwrite" "home index changed"
|
|
4998
|
+
# (d3) an account with NO index of its own is linked even while the shared one is in use —
|
|
4999
|
+
# the case the whole change exists for, and the one liveness must never block
|
|
5000
|
+
mkdir -p "$CXS/acct-06" && ln -s "$CXH2/.codex/sessions" "$CXS/acct-06/sessions"
|
|
5001
|
+
mk_cx_auth "$CXS/acct-06/auth.json" s6@cx "$FUTURE_EXP"
|
|
5002
|
+
printf 'wal' > "$CXH2/.codex/state_5.sqlite-wal"
|
|
5003
|
+
printf 'shm' > "$CXH2/.codex/state_5.sqlite-shm"
|
|
5004
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-06 codex 2>&1)"
|
|
5005
|
+
check "codex: launch on a brand-new account while the shared index is in use" "CFG=acct-06" "$out"
|
|
5006
|
+
[ "$(readlink "$CXS/acct-06/state_5.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_5.sqlite" ] \
|
|
5007
|
+
&& t_ok "codex: an account with no index of its own is linked whatever the shared one is doing" \
|
|
5008
|
+
|| t_fail "codex new account link" "$(ls -la "$CXS/acct-06" 2>&1 | head -c 300)"
|
|
5009
|
+
rm -f "$CXH2/.codex/state_5.sqlite-wal" "$CXH2/.codex/state_5.sqlite-shm"
|
|
5010
|
+
# (e) a PRIVATE session tree keeps its private index
|
|
5011
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-03 codex 2>&1)"
|
|
5012
|
+
check "codex: launch on an account with its own session tree" "CFG=acct-03" "$out"
|
|
5013
|
+
{ [ -f "$CXS/acct-03/state_5.sqlite" ] && [ ! -L "$CXS/acct-03/state_5.sqlite" ]; } \
|
|
5014
|
+
&& t_ok "codex: an account with a private session tree keeps its private index" \
|
|
5015
|
+
|| t_fail "codex private tree index" "$(ls -la "$CXS/acct-03" 2>&1 | head -c 300)"
|
|
5016
|
+
# (f) the index the INSTALLED binary will create is linked ahead of time, so a schema bump
|
|
5017
|
+
# still costs one backfill per Mac, not one per account
|
|
5018
|
+
FAKEBIN_IDX="$WORK/fakebin-idx"
|
|
5019
|
+
mkdir -p "$FAKEBIN_IDX"
|
|
5020
|
+
{ cat "$FAKEBIN/codex"; printf '# rollout index name carried by the real binary: state_7.sqlite\n'; } > "$FAKEBIN_IDX/codex"
|
|
5021
|
+
chmod +x "$FAKEBIN_IDX/codex"
|
|
5022
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-01 PATH="$REPO_DIR/bin:$FAKEBIN_IDX:$PATH" codex 2>&1)"
|
|
5023
|
+
check "codex: launch through a binary that names a newer index" "CFG=acct-01" "$out"
|
|
5024
|
+
[ "$(readlink "$CXS/acct-01/state_7.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_7.sqlite" ] \
|
|
5025
|
+
&& t_ok "codex: the index the installed binary creates next is linked before it exists" \
|
|
5026
|
+
|| t_fail "codex index pre-link" "state_7.sqlite -> $(readlink "$CXS/acct-01/state_7.sqlite" 2>/dev/null)"
|
|
5027
|
+
[ ! -e "$CXH2/.codex/state_7.sqlite" ] && t_ok "codex: pre-linking creates nothing in the home itself" \
|
|
5028
|
+
|| t_fail "codex index pre-link home" "home gained state_7.sqlite"
|
|
5029
|
+
grep -q " state_7.sqlite$" "$CXS/.state-index" 2>/dev/null \
|
|
5030
|
+
&& t_ok "codex: the binary's index name is memoized per binary" \
|
|
5031
|
+
|| t_fail "codex index name memo" "$(cat "$CXS/.state-index" 2>&1)"
|
|
5032
|
+
# (f2) the launcher is a script that names no index; the schema name lives in the native
|
|
5033
|
+
# binary vendored beside it — the branch that actually fires on an npm install
|
|
5034
|
+
VENDOR="$WORK/npm/node_modules/@openai/codex/bin"
|
|
5035
|
+
mkdir -p "$VENDOR" "$WORK/npm/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin"
|
|
5036
|
+
{ cat "$FAKEBIN/codex"; printf '# a launcher script naming no index\n'; } > "$VENDOR/codex.js"
|
|
5037
|
+
chmod +x "$VENDOR/codex.js"
|
|
5038
|
+
printf 'binary bytes state_8.sqlite more bytes' \
|
|
5039
|
+
> "$WORK/npm/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex"
|
|
5040
|
+
FAKEBIN_VENDOR="$WORK/fakebin-vendor"
|
|
5041
|
+
mkdir -p "$FAKEBIN_VENDOR"
|
|
5042
|
+
ln -s "$VENDOR/codex.js" "$FAKEBIN_VENDOR/codex"
|
|
5043
|
+
rm -f "$CXS/.state-index"
|
|
5044
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-01 PATH="$REPO_DIR/bin:$FAKEBIN_VENDOR:$PATH" codex 2>&1)"
|
|
5045
|
+
check "codex: launch through an npm launcher with a vendored binary" "CFG=acct-01" "$out"
|
|
5046
|
+
[ "$(readlink "$CXS/acct-01/state_8.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_8.sqlite" ] \
|
|
5047
|
+
&& t_ok "codex: the index name is read from the vendored binary when the launcher names none" \
|
|
5048
|
+
|| t_fail "codex vendored index name" "$(ls "$CXS/acct-01" 2>&1 | tr '\n' ' '); memo=$(cat "$CXS/.state-index" 2>&1)"
|
|
5049
|
+
grep -q "^$(cd "$(dirname "$VENDOR/codex.js")" && pwd -P)/codex.js:" "$CXS/.state-index" 2>/dev/null \
|
|
5050
|
+
&& t_ok "codex: the memo keys on the file the launcher symlink resolves to, not the link" \
|
|
5051
|
+
|| t_fail "codex memo key" "$(cat "$CXS/.state-index" 2>&1)"
|
|
5052
|
+
rm -f "$CXS/.state-index" "$CXS/acct-01/state_8.sqlite"
|
|
5053
|
+
# (g) an adopted account (the dir IS the home) is never rewritten
|
|
5054
|
+
ln -s "$CXH2/.codex" "$CXS/acct-04"
|
|
5055
|
+
python3 - "$CXS/accounts.json" <<'PYEOF'
|
|
5056
|
+
import json, sys
|
|
5057
|
+
doc = json.load(open(sys.argv[1]))
|
|
5058
|
+
doc['accounts'].append({'id': 'acct-04', 'email': 'adopted@cx', 'home': 'mac', 'added_at': '2026-09-09T00:00:00Z'})
|
|
5059
|
+
json.dump(doc, open(sys.argv[1], 'w'), indent=2)
|
|
5060
|
+
PYEOF
|
|
5061
|
+
mk_cx_auth "$CXH2/.codex/auth.json" adopted@cx "$FUTURE_EXP"
|
|
5062
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-04 codex 2>&1)"
|
|
5063
|
+
check "codex: launch on an adopted account" "CFG=acct-04" "$out"
|
|
5064
|
+
{ [ -f "$CXH2/.codex/state_5.sqlite" ] && [ ! -L "$CXH2/.codex/state_5.sqlite" ]; } \
|
|
5065
|
+
&& t_ok "codex: the home's own index is never turned into a link" \
|
|
5066
|
+
|| t_fail "codex adopted index" "$(ls -la "$CXH2/.codex" 2>&1 | head -c 300)"
|
|
5067
|
+
# (i) codex's own corruption recovery moved this account's LINK into db-backups/ and
|
|
5068
|
+
# rebuilt a private index in its place: that is codex's verdict on the shared file,
|
|
5069
|
+
# so the account keeps what codex rebuilt and this name is left alone entirely.
|
|
5070
|
+
mkdir -p "$CXS/acct-01/db-backups/sqlite-1700000000-0"
|
|
5071
|
+
mv "$CXS/acct-01/state_5.sqlite" "$CXS/acct-01/db-backups/sqlite-1700000000-0/state_5.sqlite"
|
|
5072
|
+
printf 'rebuilt after damage' > "$CXS/acct-01/state_5.sqlite"
|
|
5073
|
+
homesum="$(cat "$CXH2/.codex/state_5.sqlite" 2>/dev/null)"
|
|
5074
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-01 codex 2>&1)"
|
|
5075
|
+
check "codex: launch after codex rejected the shared index for this account" "CFG=acct-01" "$out"
|
|
5076
|
+
{ [ "$(cat "$CXS/acct-01/state_5.sqlite" 2>/dev/null)" = "rebuilt after damage" ] \
|
|
5077
|
+
&& [ ! -L "$CXS/acct-01/state_5.sqlite" ]; } \
|
|
5078
|
+
&& t_ok "codex: an account codex rebuilt for keeps that index instead of the shared one" \
|
|
5079
|
+
|| t_fail "codex rejected shared index" "$(ls -la "$CXS/acct-01" 2>&1 | head -c 400)"
|
|
5080
|
+
[ "$(cat "$CXH2/.codex/state_5.sqlite" 2>/dev/null)" = "$homesum" ] \
|
|
5081
|
+
&& t_ok "codex: the rejected shared index is left exactly as it was, for its other users" \
|
|
5082
|
+
|| t_fail "codex rejected shared index home" "home index changed"
|
|
5083
|
+
# the marker only speaks for the file it names: a link pointing elsewhere is not a verdict
|
|
5084
|
+
mkdir -p "$CXS/acct-05/db-backups/sqlite-1700000000-0"
|
|
5085
|
+
mkdir -p "$CXS/acct-05" && ln -s "$CXH2/.codex/sessions" "$CXS/acct-05/sessions"
|
|
5086
|
+
mk_cx_auth "$CXS/acct-05/auth.json" s5@cx "$FUTURE_EXP"
|
|
5087
|
+
ln -s "$CXH2/.codex/state_9.sqlite" "$CXS/acct-05/db-backups/sqlite-1700000000-0/state_5.sqlite"
|
|
5088
|
+
printf 'unrelated private index' > "$CXS/acct-05/state_5.sqlite"
|
|
5089
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-05 codex 2>&1)"
|
|
5090
|
+
check "codex: launch with a db-backups link naming another file" "CFG=acct-05" "$out"
|
|
5091
|
+
[ "$(readlink "$CXS/acct-05/state_5.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_5.sqlite" ] \
|
|
5092
|
+
&& t_ok "codex: a db-backups link that names a different file is not a verdict on the shared one" \
|
|
5093
|
+
|| t_fail "codex marker scope" "$(ls -la "$CXS/acct-05" 2>&1 | head -c 300)"
|
|
5094
|
+
# (j) a second retirement keeps the first retired copy
|
|
5095
|
+
rm "$CXS/acct-02/state_5.sqlite"
|
|
5096
|
+
printf 'second private index' > "$CXS/acct-02/state_5.sqlite"
|
|
5097
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-02 codex 2>&1)"
|
|
5098
|
+
check "codex: launch on an account retiring a second private index" "CFG=acct-02" "$out"
|
|
5099
|
+
{ [ "$(cat "$CXS/acct-02/state_5.sqlite.private" 2>/dev/null)" = "open private index" ] \
|
|
5100
|
+
&& [ "$(cat "$CXS"/acct-02/state_5.sqlite.private.[0-9]* 2>/dev/null)" = "second private index" ] \
|
|
5101
|
+
&& [ -L "$CXS/acct-02/state_5.sqlite" ]; } \
|
|
5102
|
+
&& t_ok "codex: retiring never overwrites an earlier retired copy" \
|
|
5103
|
+
|| t_fail "codex retire unique" "$(ls "$CXS/acct-02" 2>&1 | tr '\n' ' ')"
|
|
5104
|
+
# (k) several processes promoting the same account at once. `mv` here could rename one
|
|
5105
|
+
# shim's fresh symlink onto the file another had just promoted, leaving
|
|
5106
|
+
# ~/.codex/state_N.sqlite pointing at itself — ELOOP, every account on the Mac unable
|
|
5107
|
+
# to start, and repaired by neither codex (ELOOP is not a corruption code) nor this
|
|
5108
|
+
# shim (`[ -L "$link" ]` would skip the name forever). link(2) refuses an existing
|
|
5109
|
+
# target, so the promote is atomic or it does not happen. The window is narrow, so
|
|
5110
|
+
# this drives the core function directly and repeats until the odds are not the test's.
|
|
5111
|
+
CORE="$WORK/state-index-core.sh"
|
|
5112
|
+
{ printf '#!/usr/bin/env bash\nset -u\n'
|
|
5113
|
+
awk '/^state_index_names\(\) \{/,/^}$/' "$REPO_DIR/bin/codex"
|
|
5114
|
+
awk '/^shared_index_rejected\(\) \{/,/^}$/' "$REPO_DIR/bin/codex"
|
|
5115
|
+
awk '/^share_state_index_links\(\) \{/,/^}$/' "$REPO_DIR/bin/codex"
|
|
5116
|
+
printf 'share_state_index_links "$1" "$2" ""\n'; } > "$CORE"
|
|
5117
|
+
chmod +x "$CORE"
|
|
5118
|
+
# The losing interleave is: the winner promotes and links, THEN the loser acts on a
|
|
5119
|
+
# decision it made before either happened. Forcing it beats waiting for it — a `ln`/`mv`
|
|
5120
|
+
# that sleeps puts the loser inside that window on purpose, and the winner is injected
|
|
5121
|
+
# there. With the rename promote this renames the loser's own link onto its target; with
|
|
5122
|
+
# link(2) it simply refuses.
|
|
5123
|
+
SLOWBIN="$WORK/slowbin"
|
|
5124
|
+
mkdir -p "$SLOWBIN"
|
|
5125
|
+
for cmd in ln mv; do
|
|
5126
|
+
printf '#!/usr/bin/env bash\nsleep 1\nfor c in /bin/%s /usr/bin/%s; do [ -x "$c" ] && exec "$c" "$@"; done\nexit 127\n' \
|
|
5127
|
+
"$cmd" "$cmd" > "$SLOWBIN/$cmd"
|
|
5128
|
+
chmod +x "$SLOWBIN/$cmd"
|
|
5129
|
+
done
|
|
5130
|
+
rd="$WORK/promote-interleave"
|
|
5131
|
+
mkdir -p "$rd/home/sessions" "$rd/acct"
|
|
5132
|
+
ln -s "$rd/home/sessions" "$rd/acct/sessions"
|
|
5133
|
+
printf 'the loser index' > "$rd/acct/state_5.sqlite"
|
|
5134
|
+
( PATH="$SLOWBIN:$PATH" "$CORE" "$rd/acct" "$rd/home" >/dev/null 2>&1 ) &
|
|
5135
|
+
slow=$!
|
|
5136
|
+
sleep 0.4 # the loser has decided the home is empty, and not yet acted
|
|
5137
|
+
printf 'the winner index' > "$rd/home/state_5.sqlite"
|
|
5138
|
+
rm -f "$rd/acct/state_5.sqlite"
|
|
5139
|
+
ln -s "$rd/home/state_5.sqlite" "$rd/acct/state_5.sqlite"
|
|
5140
|
+
wait "$slow" 2>/dev/null
|
|
5141
|
+
{ [ -f "$rd/home/state_5.sqlite" ] && [ ! -L "$rd/home/state_5.sqlite" ] \
|
|
5142
|
+
&& [ "$(cat "$rd/home/state_5.sqlite" 2>/dev/null)" = "the winner index" ]; } \
|
|
5143
|
+
&& t_ok "codex: a promote that loses the race refuses instead of renaming onto the winner" \
|
|
5144
|
+
|| t_fail "codex promote atomicity" "shared index is $(ls -la "$rd/home/state_5.sqlite" 2>&1 | head -c 160)"
|
|
5145
|
+
[ "$(cat "$rd/acct/state_5.sqlite" 2>/dev/null)" = "the winner index" ] \
|
|
5146
|
+
&& t_ok "codex: the losing account still reads the shared index through its link" \
|
|
5147
|
+
|| t_fail "codex promote atomicity" "acct reads $(cat "$rd/acct/state_5.sqlite" 2>&1 | head -c 120)"
|
|
5148
|
+
|
|
5149
|
+
races=0; bad=0; badwhy=""
|
|
5150
|
+
|
|
5151
|
+
# (l) the heal belongs to the LAUNCH, not to the pin: an unpinned selection and a retry
|
|
5152
|
+
# that rotates to another account must both link the account they actually serve.
|
|
5153
|
+
CXU="$WORK/cx-unpinned"; CXHU="$WORK/cxhome-unpinned"
|
|
5154
|
+
mkdir -p "$CXU/tmp" "$CXU/acct-01" "$CXU/acct-02" "$CXHU/.codex/sessions"
|
|
5155
|
+
: > "$CXU/.limits-kick"
|
|
5156
|
+
cat > "$CXU/accounts.json" <<EOF2
|
|
5157
|
+
{"version": 1, "server": "root@203.0.113.1", "server_root": "/root/.codex-accounts",
|
|
5158
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
5159
|
+
"accounts": [
|
|
5160
|
+
{"id": "acct-01", "email": "u1@cx", "home": "mac", "added_at": "2026-09-09T00:00:00Z"},
|
|
5161
|
+
{"id": "acct-02", "email": "u2@cx", "home": "mac", "added_at": "2026-09-09T00:00:00Z"}]}
|
|
5162
|
+
EOF2
|
|
5163
|
+
for i in 01 02; do
|
|
5164
|
+
mk_cx_auth "$CXU/acct-$i/auth.json" "u$i@cx" "$FUTURE_EXP"
|
|
5165
|
+
ln -s "$CXHU/.codex/sessions" "$CXU/acct-$i/sessions"
|
|
5166
|
+
done
|
|
5167
|
+
printf 'the shared index' > "$CXHU/.codex/state_5.sqlite"
|
|
5168
|
+
cxlj 5 5 5 > "$CXU/acct-01/limits.json"
|
|
5169
|
+
cxlj 20 20 20 > "$CXU/acct-02/limits.json"
|
|
5170
|
+
out="$(HOME="$CXHU" CODEX_ACCOUNTS_DIR="$CXU" CODEX_MULTIACC_HEADROOM_BAND=0 codex 2>&1)"
|
|
5171
|
+
picked="$(printf '%s' "$out" | sed -n 's/.*CFG=\(acct-[0-9]*\).*/\1/p' | head -1)"
|
|
5172
|
+
{ [ -n "$picked" ] \
|
|
5173
|
+
&& [ "$(readlink "$CXU/$picked/state_5.sqlite" 2>/dev/null)" = "$CXHU/.codex/state_5.sqlite" ]; } \
|
|
5174
|
+
&& t_ok "codex: an unpinned launch links the account it selected ($picked)" \
|
|
5175
|
+
|| t_fail "codex unpinned heal" "picked=${picked:-none} $(ls -la "$CXU"/acct-0*/state_5.sqlite 2>&1 | head -c 300)"
|
|
5176
|
+
rm -f "$CXU"/acct-0*/state_5.sqlite
|
|
5177
|
+
echo "fail:acct-01" > "$FAKE_CTL2"
|
|
5178
|
+
out="$(HOME="$CXHU" CODEX_ACCOUNTS_DIR="$CXU" CODEX_MULTIACC_HEADROOM_BAND=0 codex exec "hello" < /dev/null 2>&1)"
|
|
5179
|
+
rm -f "$FAKE_CTL2" "$CXU/acct-01/.limited"
|
|
5180
|
+
case "$out" in *CFG=acct-02*) t_ok "codex: the retry rotated to the second account" ;;
|
|
5181
|
+
*) t_fail "codex retry heal" "did not land on acct-02: $(printf '%s' "$out" | head -c 200)" ;; esac
|
|
5182
|
+
[ "$(readlink "$CXU/acct-02/state_5.sqlite" 2>/dev/null)" = "$CXHU/.codex/state_5.sqlite" ] \
|
|
5183
|
+
&& t_ok "codex: the account a retry rotates onto is linked too" \
|
|
5184
|
+
|| t_fail "codex retry heal" "acct-02 -> $(readlink "$CXU/acct-02/state_5.sqlite" 2>/dev/null)"
|
|
5185
|
+
|
|
5186
|
+
# (h) the two copies of the core (shim + lib/common.sh) are byte-identical
|
|
5187
|
+
core_of() { # $1 file, $2 function name -> its body
|
|
5188
|
+
awk -v fn="$2" '$0 ~ "^"fn"\\(\\) \\{" {p=1} p {print} p && /^}$/ {exit}' "$1"
|
|
5189
|
+
}
|
|
5190
|
+
for fn in state_index_names shared_index_rejected share_state_index_links; do
|
|
5191
|
+
if [ "$(core_of "$REPO_DIR/bin/codex" "$fn")" = "$(core_of "$REPO_DIR/lib/common.sh" "$fn")" ] \
|
|
5192
|
+
&& [ -n "$(core_of "$REPO_DIR/bin/codex" "$fn")" ]; then
|
|
5193
|
+
t_ok "codex: $fn is byte-identical in bin/codex and lib/common.sh"
|
|
5194
|
+
else
|
|
5195
|
+
t_fail "codex shared-index parity" "$fn differs between bin/codex and lib/common.sh"
|
|
5196
|
+
fi
|
|
5197
|
+
done
|
|
5198
|
+
|
|
4909
5199
|
# ---- C12. login command + expired worklist + relogin --------------------------------
|
|
4910
5200
|
# an account with no auth on this machine: login completes it
|
|
4911
5201
|
codex-accounts import d@cx --id acct-04 --no-sync >/dev/null 2>&1
|
|
@@ -5635,8 +5925,11 @@ EOF
|
|
|
5635
5925
|
grep -q "rt-rotated-new" "$CX/limits.log" \
|
|
5636
5926
|
&& t_fail "codex token leak" "a refresh token leaked into limits.log" \
|
|
5637
5927
|
|| t_ok "codex: limits.log leaks no tokens"
|
|
5638
|
-
# recently-expired token is left alone (a live codex session may own it) — even --force
|
|
5639
|
-
|
|
5928
|
+
# recently-expired token is left alone (a live codex session may own it) — even --force.
|
|
5929
|
+
# The expiry is measured from NOW, not from the suite's start: the gate is a five-minute
|
|
5930
|
+
# window, and pinning it to $now silently makes this assertion depend on how long every
|
|
5931
|
+
# preceding test took.
|
|
5932
|
+
mk_cx_auth "$CX/acct-01/auth.json" a@cx "$(( $(date +%s) - 100 ))"
|
|
5640
5933
|
cp "$CX/acct-01/auth.json" "$WORK/cx-recent.bak"
|
|
5641
5934
|
rm -f "$CX/acct-01/limits.json" "$CX/acct-01/.oauth-refresh.json"
|
|
5642
5935
|
out="$(CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-ok.json" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force 2>&1)"
|
|
@@ -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()
|