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/README.md
CHANGED
|
@@ -59,7 +59,7 @@ MAC (source of truth) SERVER (mirror)
|
|
|
59
59
|
server.token / .credentials.json
|
|
60
60
|
(or the login lives in the macOS Keychain — see below)
|
|
61
61
|
selection.log sync.log health.log /usr/local/bin/claude -> repo shim
|
|
62
|
-
repo bin/ first on PATH (rc
|
|
62
|
+
repo bin/ first on PATH (rc block + hook) /root/claude-multiacc/ (addon repo)
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
**Shim selection order** (identical file on both machines, `bin/claude`):
|
|
@@ -380,6 +380,16 @@ into its global bin directory, so account commands work in the current shell. Op
|
|
|
380
380
|
new shell for the `claude` and `codex` shims. If auto-setup was skipped, run
|
|
381
381
|
`claude-multiacc install` to initialize the pools and shell setup.
|
|
382
382
|
|
|
383
|
+
The shims shadow the real binaries by PATH order alone, so the installer's rc block
|
|
384
|
+
(`~/.zshenv`, `~/.zprofile`, `~/.zshrc`, `~/.profile` and the bash rc files when they
|
|
385
|
+
exist) ends with a prompt hook that puts the shim dir back in front before every
|
|
386
|
+
prompt: an rc file interrupted with Ctrl-C, or a tool that rewrites PATH, can no
|
|
387
|
+
longer leave `~/.local/bin/claude` first — that launch would run on the machine's own
|
|
388
|
+
`~/.claude` login and never reach the pool. `claude-accounts status` probes fresh
|
|
389
|
+
`zsh`/`bash`/`sh` login shells with your rc files and prints `BYPASSED -> <path>` for
|
|
390
|
+
any that still resolve something other than the shim; the weekly `health` check fails
|
|
391
|
+
on it.
|
|
392
|
+
|
|
383
393
|
See [installation, updates, removal, and command-not-found recovery](docs/INSTALLATION.md)
|
|
384
394
|
for npm, npx, git checkout, and server instructions.
|
|
385
395
|
|
package/bin/claude-accounts
CHANGED
|
@@ -23,7 +23,8 @@ claude-accounts — multi-account pool manager for claude-multiacc
|
|
|
23
23
|
|
|
24
24
|
USAGE
|
|
25
25
|
claude-accounts list [--json] brief account list (--json: machine-readable)
|
|
26
|
-
claude-accounts status [--json] full health: auth, per-bucket limits, markers
|
|
26
|
+
claude-accounts status [--json] full health: auth, per-bucket limits, markers,
|
|
27
|
+
and whether fresh login shells reach the shim
|
|
27
28
|
claude-accounts add [email] [--token] [--force]
|
|
28
29
|
login-FIRST: runs the full Claude Code login (`claude auth login` — the normal
|
|
29
30
|
browser sign-in, no long-lived-token step-up), then registers only after the
|
|
@@ -85,7 +86,7 @@ USAGE
|
|
|
85
86
|
persisted), so idle accounts keep fresh telemetry and stay selectable.
|
|
86
87
|
Skips accounts fetched in the last 45s and honors 429/refresh backoff;
|
|
87
88
|
--force ignores all three.
|
|
88
|
-
claude-accounts health limits + full verify; logs to health.log
|
|
89
|
+
claude-accounts health limits + full verify + shim-on-PATH probe; logs to health.log
|
|
89
90
|
claude-accounts self-update update this npm/git install; logs to update.log
|
|
90
91
|
claude-accounts post-sync (server side) seed dirs, fix perms, quick verify
|
|
91
92
|
|
|
@@ -255,6 +256,18 @@ if dups:
|
|
|
255
256
|
PYEOF
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
# A fresh login shell that never reaches the shim is the one failure the pool cannot
|
|
260
|
+
# see from the inside — the launch simply happens elsewhere (my-mini 2026-09-17: an
|
|
261
|
+
# interrupted ~/.zshrc, and every `bash -l`, resolved the real binary and opened on the
|
|
262
|
+
# un-pooled ~/.claude login). lib/shim_path.py starts each login shell with this user's
|
|
263
|
+
# rc files and asks. Off with CLAUDE_MULTIACC_PATH_PROBE=0 — the sandboxed suites run
|
|
264
|
+
# under the operator's real HOME and must not depend on its rc files.
|
|
265
|
+
shim_path_report() {
|
|
266
|
+
# The module honours the kill switch itself and says so in its output, so a status
|
|
267
|
+
# or health.log read with the probe off never looks like a probe that passed.
|
|
268
|
+
"$PYBIN" "$LIB_DIR/shim_path.py" "$REPO_DIR"
|
|
269
|
+
}
|
|
270
|
+
|
|
258
271
|
cmd_status() {
|
|
259
272
|
require_manifest
|
|
260
273
|
local json=0
|
|
@@ -482,6 +495,8 @@ elif verdict == 'degraded':
|
|
|
482
495
|
print(" fix : claude-accounts limits --force # then, if it still fails:")
|
|
483
496
|
print(" claude-accounts login <acct-NN> # per account, on THIS machine")
|
|
484
497
|
PYEOF
|
|
498
|
+
echo
|
|
499
|
+
shim_path_report || true
|
|
485
500
|
}
|
|
486
501
|
|
|
487
502
|
cmd_add() {
|
|
@@ -2290,6 +2305,28 @@ for acct in manifest.get('accounts', []):
|
|
|
2290
2305
|
maxp = max([b['percent'] for b in live] or [0])
|
|
2291
2306
|
weeklyp = max(weekly) if weekly else None
|
|
2292
2307
|
sessionp = max(session) if session else None
|
|
2308
|
+
# ...but only the WEEKLY half of that rule is about a missing answer. The two kinds
|
|
2309
|
+
# of bucket go silent for opposite reasons, and 2026-09-22 cost a day of picks to
|
|
2310
|
+
# the difference. A weekly window always exists — it is a fixed calendar week,
|
|
2311
|
+
# running whether or not the account is — so a weekly bucket with nothing to say is
|
|
2312
|
+
# an endpoint that DECLINED, and inventing a number for it is the 2026-09-04 bug.
|
|
2313
|
+
# The 5h SESSION window only exists while it is OPEN: leave an account alone for
|
|
2314
|
+
# five hours and there is no window left to describe, so the endpoint answers
|
|
2315
|
+
# `percent: 0, resets_at: null` — not silence but "nothing has been used". An
|
|
2316
|
+
# exhausted session is never silent: a spent 5h bucket ALWAYS carries the window it
|
|
2317
|
+
# resets in (the live pool on 2026-09-22 — every session bucket above 0% had a real
|
|
2318
|
+
# resets_at, every silent one belonged to an idle account), so a silent session
|
|
2319
|
+
# cannot be hiding a full one. Recording it as UNKNOWN is what broke selection:
|
|
2320
|
+
# pick_best's quota_known rule needs BOTH readings, so an IDLE account — precisely
|
|
2321
|
+
# the one with the most headroom — was scored weekly=100 and dropped out of the
|
|
2322
|
+
# band, leaving only the busy accounts that still held an open 5h window. From
|
|
2323
|
+
# selection.log at 2026-09-22T12:37:36Z: band-count=1, and that one band member was
|
|
2324
|
+
# acct-17 at 95% weekly, while acct-13 and acct-14 sat at 0% and unrankable.
|
|
2325
|
+
# So: a session bucket that said nothing inside a document that DID answer reads 0.
|
|
2326
|
+
# A document where NOTHING answered still writes no signals at all (see below) —
|
|
2327
|
+
# the 2026-09-04 all-zero shape stays unknown, which is the whole point.
|
|
2328
|
+
if sessionp is None and live:
|
|
2329
|
+
sessionp = 0
|
|
2293
2330
|
# How long weekly_percent keeps meaning something. A weekly bucket only ever RISES
|
|
2294
2331
|
# until its reset, so before that moment a stale percent is still a valid lower
|
|
2295
2332
|
# bound and the shim can rank on it when nothing fresher exists; after it, the
|
|
@@ -2956,6 +2993,13 @@ cmd_health() {
|
|
|
2956
2993
|
# verify first: its real claude runs refresh any expired OAuth creds, so the
|
|
2957
2994
|
# limits pass that follows always has fresh bearers.
|
|
2958
2995
|
out="$( { echo "== verify =="; cmd_verify; echo; echo "== limits =="; cmd_limits; } 2>&1 )" || rc=1
|
|
2996
|
+
# The shim being reachable at all is part of the pool's health: a login shell that
|
|
2997
|
+
# resolves the real binary hands every `claude` typed there to ~/.claude, whatever
|
|
2998
|
+
# the accounts above say. Reported (and failed) here, where the weekly check pages.
|
|
2999
|
+
local shim_out
|
|
3000
|
+
shim_out="$( { echo; echo "== shim =="; shim_path_report; } 2>&1 )" || rc=1
|
|
3001
|
+
out="$out
|
|
3002
|
+
$shim_out"
|
|
2959
3003
|
printf '%s\n' "$out"
|
|
2960
3004
|
printf '%s health rc=%s\n%s\n' "$(ts_utc)" "$rc" "$out" >> "$ACC_ROOT/health.log"
|
|
2961
3005
|
if [ "$rc" -ne 0 ] && [ "$(machine_kind)" = "mac" ]; then
|
package/bin/codex-accounts
CHANGED
|
@@ -1585,6 +1585,22 @@ for acct in manifest.get('accounts', []):
|
|
|
1585
1585
|
maxp = max([b['percent'] for b in live] or [0])
|
|
1586
1586
|
weeklyp = max(weekly) if weekly else None
|
|
1587
1587
|
sessionp = max(session) if session else None
|
|
1588
|
+
# ...but only the WEEKLY half of that rule is about a missing answer — same
|
|
1589
|
+
# asymmetry the claude writer documents, and codex feels it harder. A durable
|
|
1590
|
+
# window always exists, so a silent one is an endpoint that DECLINED. A ~5h session
|
|
1591
|
+
# window only exists while it is OPEN, and codex does not even keep a placeholder
|
|
1592
|
+
# for a closed one: an idle account's payload carries the 7d window and NO session
|
|
1593
|
+
# window whatsoever, so `session` here is empty for every quiet account. That made
|
|
1594
|
+
# session_percent permanently absent across this pool, and pick_best's quota_known
|
|
1595
|
+
# rule needs BOTH readings — so EVERY codex account scored unknown, bestw never left
|
|
1596
|
+
# 101, and the band degenerated to the all-gated tie: codex has been picking
|
|
1597
|
+
# uniformly at RANDOM rather than by headroom (selection.log: `session=?%` on every
|
|
1598
|
+
# ranked line). A session window that is closed has nothing in it, and an exhausted
|
|
1599
|
+
# one is never silent — it reports at/near 100% with the reset it is waiting on.
|
|
1600
|
+
# So a missing or silent session reads 0 whenever the payload answered at all; a
|
|
1601
|
+
# payload where NOTHING answered still writes no signals and stays unknown below.
|
|
1602
|
+
if sessionp is None and live:
|
|
1603
|
+
sessionp = 0
|
|
1588
1604
|
try:
|
|
1589
1605
|
reset_result, reset_view = refresh_reset_credits(d, aid, maxp, data, url, headers, say, int(now))
|
|
1590
1606
|
except Exception as e:
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -78,14 +78,23 @@ one layout that can NOT self-update — don't ship the addon that way.
|
|
|
78
78
|
What install does (all reversible, nothing else):
|
|
79
79
|
|
|
80
80
|
- **macOS:** marked PATH block at the END of `~/.zshenv`, `~/.zprofile`, `~/.zshrc`
|
|
81
|
-
(+ bash rc files
|
|
82
|
-
re-prepend `~/.local/bin
|
|
81
|
+
(+ `~/.profile` and the bash rc files when they exist) — end-of-file placement
|
|
82
|
+
matters because those files re-prepend `~/.local/bin`. Every copy of the block also
|
|
83
|
+
registers a prompt hook (zsh `precmd` / bash `PROMPT_COMMAND`) that puts the shim
|
|
84
|
+
dir back in front before each prompt, so an rc file interrupted with Ctrl-C or a
|
|
85
|
+
tool that rewrites PATH (conda, nvm, a venv) cannot leave the real binary first at
|
|
86
|
+
the next prompt. `~/.profile` is what `bash -l` / `sh -l` read when there is no
|
|
87
|
+
`~/.bash_profile`, so a launcher that runs `bash -lc claude` reaches the pool too;
|
|
88
|
+
it is created when absent (a stock macOS account has none), and the installer never
|
|
89
|
+
creates `~/.bash_profile` (that would stop bash reading `~/.profile`). launchd agents `com.claude-multiacc.limits` +
|
|
83
90
|
`.codex-limits` (15m Claude / 5m Codex), `.health` + `.codex-health` (weekly Mon morning), and
|
|
84
91
|
`.update` (daily 04:07). Notes when this Mac keeps Claude Code logins in the
|
|
85
92
|
Keychain (the pool reads them; ssh sessions cannot — mint portable tokens for
|
|
86
93
|
accounts that must work from everywhere).
|
|
87
|
-
- **Linux (root):** PATH block in `~/.bashrc` + `/etc/profile.d/claude-multiacc.sh
|
|
88
|
-
|
|
94
|
+
- **Linux (root):** PATH block in `~/.bashrc` + `/etc/profile.d/claude-multiacc.sh`
|
|
95
|
+
(+ `~/.profile`, created when absent — Debian's own `~/.profile` prepends
|
|
96
|
+
`~/.local/bin` after `/etc/profile.d` has run, so the block has to end that file
|
|
97
|
+
too), shim symlinks at `/usr/local/bin/claude` and `/usr/local/bin/codex` (shadow via
|
|
89
98
|
PATH order — on the systemd default PATH too; the original binaries are untouched),
|
|
90
99
|
cron entries for limits/health (both providers) + the daily auto-update.
|
|
91
100
|
- Both: `~/.claude-accounts/` and `~/.codex-accounts/`, each with an `accounts.json` manifest.
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -11,7 +11,8 @@ claude-accounts verify --quick # auth presence/expiry only, no inference
|
|
|
11
11
|
codex-accounts verify # real codex exec call per account
|
|
12
12
|
codex-accounts verify --quick # local Codex auth check, no inference
|
|
13
13
|
claude-accounts limits # live per-bucket usage incl. the Fable bucket
|
|
14
|
-
claude-accounts health # limits + full verify, logs to health.log, notifies on failure (Mac)
|
|
14
|
+
claude-accounts health # limits + full verify + shim-on-PATH probe, logs to health.log, notifies on failure (Mac)
|
|
15
|
+
python3 lib/shim_path.py "$(npm root -g)/claude-multiacc" # just the probe; exit 1 on a bypass
|
|
15
16
|
```
|
|
16
17
|
|
|
17
18
|
Verified end-to-end on both machines (2026-07-13): full matrix PASS, 20-invocation shim
|
|
@@ -22,9 +23,17 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
|
|
|
22
23
|
|
|
23
24
|
## Troubleshooting
|
|
24
25
|
|
|
25
|
-
- **`claude` resolves to the real binary, not the shim** —
|
|
26
|
-
|
|
27
|
-
(
|
|
26
|
+
- **`claude` resolves to the real binary, not the shim** — the launch then never
|
|
27
|
+
reaches the pool: the real client starts on the machine's own `~/.claude` login, and
|
|
28
|
+
from ssh/tmux (where that login sits in a locked Keychain) it opens on
|
|
29
|
+
"Not logged in · Please run /login" (my-mini, 2026-09-17). `claude-accounts status`
|
|
30
|
+
and the weekly `health` probe every login shell mode (`zsh -li`/`-lc`, `bash -li`/`-lc`,
|
|
31
|
+
`sh -lc`) with your own rc files and report `BYPASSED -> <path>` for any that resolve
|
|
32
|
+
something other than the shim. Fix: `claude-multiacc install` rewrites the rc blocks
|
|
33
|
+
(`~/.zshenv`, `~/.zprofile`, `~/.zshrc`, `~/.profile`, bash rc files) — each carries
|
|
34
|
+
a prompt hook that re-asserts the shim dir before every prompt — then open a new
|
|
35
|
+
shell; an already-open shell keeps the PATH it has. To check one shell by hand:
|
|
36
|
+
`zsh -lic 'whence -p claude'` / `bash -lc 'type -P claude'`.
|
|
28
37
|
- **An account never gets picked** — `claude-accounts status`: no auth on this machine,
|
|
29
38
|
a dead login (`selectable: NO`, see `claude-accounts expired`), or an active `.limited`
|
|
30
39
|
marker (shows bucket + minutes to reset).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/install_actions.sh
CHANGED
|
@@ -33,14 +33,48 @@ path_block_body() {
|
|
|
33
33
|
printf 'export PATH="%s/bin:$PATH"\n' "$REPO_DIR"
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
path_guard_body() {
|
|
37
|
+
# The two lines above win only while they are the LAST thing to touch PATH. A later
|
|
38
|
+
# rc line, a tool that rewrites PATH (conda, nvm, a venv) or an rc file that was
|
|
39
|
+
# INTERRUPTED before reaching them (a Ctrl-C at a slow `conda` hook, my-mini
|
|
40
|
+
# 2026-09-17) leaves ~/.local/bin in front again — and the next `claude` typed at
|
|
41
|
+
# that prompt runs the real binary on the machine's own ~/.claude login (or on no
|
|
42
|
+
# login at all), never reaching the pool. Nothing inside the shim can notice a launch
|
|
43
|
+
# that never reached it, so the rc block also registers a prompt hook (zsh precmd /
|
|
44
|
+
# bash PROMPT_COMMAND) that puts the shim dir back in front before EVERY prompt.
|
|
45
|
+
# Fork-free while PATH is already right, registered once, inert once the addon is
|
|
46
|
+
# gone. The array syntax is wrapped in eval so sh/dash — which read ~/.profile and
|
|
47
|
+
# /etc/profile.d as well — can parse the block.
|
|
48
|
+
printf '_claude_multiacc_path_guard() {\n'
|
|
49
|
+
printf ' [ -x "%s/bin/claude" ] || return 0\n' "$REPO_DIR"
|
|
50
|
+
printf ' case ":$PATH:" in ":%s/bin:"*) return 0 ;; esac\n' "$REPO_DIR"
|
|
51
|
+
printf ' PATH="$(printf %%s ":$PATH:" | sed '\''s|:%s/bin:|:|g; s|^:||; s|:$||'\'')"\n' "$REPO_DIR"
|
|
52
|
+
printf ' export PATH="%s/bin:$PATH"\n' "$REPO_DIR"
|
|
53
|
+
printf '}\n'
|
|
54
|
+
# Registered LAST on purpose, in both shells: a hook that runs after this one and
|
|
55
|
+
# rewrites PATH (direnv's, a venv's) would otherwise have the final say at the
|
|
56
|
+
# prompt. zsh removes any earlier registration and re-appends, so the copy of this
|
|
57
|
+
# block at the END of ~/.zshrc outranks hooks that ~/.zshrc registered before it;
|
|
58
|
+
# the filter is IFS-independent. bash appends once — its rc block is the last thing
|
|
59
|
+
# sourced, so an append lands last.
|
|
60
|
+
printf 'if [ -n "${ZSH_VERSION:-}" ]; then\n'
|
|
61
|
+
printf ' eval '\''precmd_functions=(${precmd_functions:#_claude_multiacc_path_guard} _claude_multiacc_path_guard)'\''\n'
|
|
62
|
+
printf 'elif [ -n "${BASH_VERSION:-}" ]; then\n'
|
|
63
|
+
printf ' case ";${PROMPT_COMMAND:-};" in *";_claude_multiacc_path_guard;"*) ;; *) PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}_claude_multiacc_path_guard" ;; esac\n'
|
|
64
|
+
printf 'fi\n'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
rc_block() { # the complete marked block, for every rc file and /etc/profile.d alike
|
|
68
|
+
printf '%s\n' "$MARK_BEGIN"
|
|
69
|
+
path_block_body
|
|
70
|
+
path_guard_body
|
|
71
|
+
printf '%s\n' "$MARK_END"
|
|
72
|
+
}
|
|
73
|
+
|
|
36
74
|
append_block() { # strip then append our PATH block to a file
|
|
37
75
|
local f="$1"
|
|
38
76
|
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"
|
|
77
|
+
rc_block >> "$f"
|
|
44
78
|
}
|
|
45
79
|
|
|
46
80
|
do_uninstall() {
|
|
@@ -154,11 +188,33 @@ PYEOF
|
|
|
154
188
|
fi
|
|
155
189
|
}
|
|
156
190
|
|
|
191
|
+
# bash and sh read ~/.profile for a LOGIN shell when ~/.bash_profile and ~/.bash_login
|
|
192
|
+
# are absent — `bash -lc`, `sh -lc`, a tmux/ssh session whose login shell is bash. Left
|
|
193
|
+
# without a block, such a shell ends its startup with whatever /etc/profile and its own
|
|
194
|
+
# ~/.local/bin prepend put in front and never sees the shim (my-mini 2026-09-17: every
|
|
195
|
+
# `bash -l` resolved the real binary). The file is CREATED when absent — a stock macOS
|
|
196
|
+
# account has no ~/.profile at all, and without one the bash/sh modes stay bypassed and
|
|
197
|
+
# the shim probe fails `health` with a remedy that changes nothing — exactly as the
|
|
198
|
+
# installer already creates ~/.zshenv and ~/.zprofile. It never creates ~/.bash_profile:
|
|
199
|
+
# that file's mere existence stops bash reading ~/.profile.
|
|
200
|
+
install_profile_block() {
|
|
201
|
+
touch "$HOME/.profile" 2>/dev/null || {
|
|
202
|
+
echo " WARNING: cannot create $HOME/.profile — bash/sh login shells will not see the shim" >&2
|
|
203
|
+
return 0
|
|
204
|
+
}
|
|
205
|
+
if append_block "$HOME/.profile"; then
|
|
206
|
+
echo " PATH block: also at the end of ~/.profile (bash/sh login shells)"
|
|
207
|
+
else
|
|
208
|
+
echo " WARNING: ~/.profile was NOT updated (see above) — bash/sh login shells will not see the shim" >&2
|
|
209
|
+
fi
|
|
210
|
+
}
|
|
211
|
+
|
|
157
212
|
install_mac_paths() {
|
|
158
213
|
# .zshenv covers non-interactive zsh; the .zshrc block must be LAST so it wins
|
|
159
214
|
# over ~/.local/bin re-prepends done earlier in .zshrc/.zprofile.
|
|
160
215
|
# zsh reads: .zshenv always; .zprofile for login; .zshrc for interactive.
|
|
161
|
-
# The block must end each file that later re-prepends ~/.local/bin
|
|
216
|
+
# The block must end each file that later re-prepends ~/.local/bin, and every copy
|
|
217
|
+
# also carries the prompt hook (path_guard_body) for the rc-interrupted case.
|
|
162
218
|
if [ -z "$INSTANCE" ]; then
|
|
163
219
|
touch "$HOME/.zshenv"
|
|
164
220
|
append_block "$HOME/.zshenv"
|
|
@@ -168,7 +224,8 @@ install_mac_paths() {
|
|
|
168
224
|
append_block "$HOME/.zshrc"
|
|
169
225
|
[ -f "$HOME/.bash_profile" ] && append_block "$HOME/.bash_profile"
|
|
170
226
|
[ -f "$HOME/.bashrc" ] && append_block "$HOME/.bashrc"
|
|
171
|
-
echo " PATH block: end of ~/.zshenv, ~/.zprofile, ~/.zshrc (+ bash rc files if present)"
|
|
227
|
+
echo " PATH block: end of ~/.zshenv, ~/.zprofile, ~/.zshrc (+ bash rc files if present), with a prompt hook that re-asserts it"
|
|
228
|
+
install_profile_block
|
|
172
229
|
fi
|
|
173
230
|
}
|
|
174
231
|
|
|
@@ -177,13 +234,10 @@ install_linux_paths() {
|
|
|
177
234
|
touch "$HOME/.bashrc"
|
|
178
235
|
append_block "$HOME/.bashrc"
|
|
179
236
|
if [ -w /etc/profile.d ] 2>/dev/null || [ "$(id -u)" = "0" ]; then
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
path_block_body
|
|
183
|
-
printf '%s\n' "$MARK_END"
|
|
184
|
-
} > "$PROFILED"
|
|
185
|
-
echo " PATH block: ~/.bashrc + $PROFILED"
|
|
237
|
+
rc_block > "$PROFILED"
|
|
238
|
+
echo " PATH block: ~/.bashrc + $PROFILED (with a prompt hook that re-asserts it)"
|
|
186
239
|
fi
|
|
240
|
+
install_profile_block
|
|
187
241
|
fi
|
|
188
242
|
if [ -z "$INSTANCE" ] && [ "$(id -u)" = "0" ]; then
|
|
189
243
|
if [ -e /usr/local/bin/claude ] && [ ! -L /usr/local/bin/claude ]; then
|
package/lib/shim_path.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Does a FRESH shell resolve `claude` / `codex` to this addon's shims?
|
|
2
|
+
|
|
3
|
+
The shims shadow the real binaries by PATH ORDER, and the rc-file block that puts them
|
|
4
|
+
first is only as good as the shell startup that runs it. Three ways it silently is not:
|
|
5
|
+
a ~/.profile with no block (every `bash -l` / `sh -l` on my-mini resolved the real
|
|
6
|
+
binary, 2026-09-17), an rc file interrupted half-way (a Ctrl-C at a slow `conda` hook
|
|
7
|
+
left ~/.local/bin in front, and the next `claude` at that prompt opened on the
|
|
8
|
+
un-pooled ~/.claude login: "Not logged in · Please run /login"), or a later
|
|
9
|
+
`export PATH=` that re-prepends ~/.local/bin. Nothing inside the shim can notice a
|
|
10
|
+
launch that never reached it, so this is checked from the OUTSIDE: start each login
|
|
11
|
+
shell the way a terminal or a `bash -lc` launcher would — clean environment, the user's
|
|
12
|
+
own rc files — and ask what the command names resolve to.
|
|
13
|
+
|
|
14
|
+
Interactive modes (`-li`) are fed the probe on stdin, so the shell runs its prompt loop
|
|
15
|
+
and the rc block's prompt hook (lib/install_actions.sh path_guard_body) gets its turn,
|
|
16
|
+
exactly as it does for a person at a prompt. The `-lc` modes are what a launcher that
|
|
17
|
+
runs `bash -lc claude` gets: rc order alone, no prompt hook.
|
|
18
|
+
|
|
19
|
+
What is measured is the PATH search — `whence -p` / `type -P`, aliases and functions set
|
|
20
|
+
aside — because the incident was a PATH-order failure and an `alias claude='claude
|
|
21
|
+
--flags'` still resolves through PATH. A function or alias that names the real binary
|
|
22
|
+
outright is not detected here; it is also not something an installer can fix.
|
|
23
|
+
|
|
24
|
+
Every answer line carries a sentinel, the first answer per name wins, and a name the
|
|
25
|
+
shell never answered for is an ERROR on that row, not a bypass: an rc file that reads a
|
|
26
|
+
line of stdin (an update prompt, a `read`) can eat part of the fed script. Nothing
|
|
27
|
+
touches the operator's shell history — the script disables it first thing.
|
|
28
|
+
|
|
29
|
+
probe(repo_dir) -> [ {shell, mode, argv, results: {name: {resolved, ok}}, error} ... ]
|
|
30
|
+
ok is True for the shim, False for anything else (the real binary, or nothing on
|
|
31
|
+
PATH at all), None when the shell could not answer (timeout / no output).
|
|
32
|
+
bypassed(rows) -> the (mode, name, resolved) triples with ok False.
|
|
33
|
+
|
|
34
|
+
Run directly: python3 lib/shim_path.py <repo-dir> [--json] [--timeout=SECONDS]
|
|
35
|
+
Prints the report; exit 1 when any mode bypasses the shim, 2 on usage, else 0.
|
|
36
|
+
CLAUDE_MULTIACC_PATH_PROBE=0 makes probe() return [] (the sandboxed suites run with the
|
|
37
|
+
operator's real HOME and must not depend on its rc files).
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import getpass
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import shutil
|
|
45
|
+
import signal
|
|
46
|
+
import subprocess
|
|
47
|
+
import sys
|
|
48
|
+
import tempfile
|
|
49
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
50
|
+
|
|
51
|
+
NAMES = ('claude', 'codex')
|
|
52
|
+
# The marker every shim carries in its header; lib/common.sh is_shim_file greps the
|
|
53
|
+
# same word, so a /usr/local/bin/claude -> shim symlink (Linux) counts as the shim too.
|
|
54
|
+
SHIM_MARK = 'multiacc-shim'
|
|
55
|
+
DEFAULT_TIMEOUT = 25
|
|
56
|
+
# (shell, flags, interactive) — a terminal is a login AND interactive shell; a
|
|
57
|
+
# `bash -lc` launcher is login only; sh has no interactive rc worth probing.
|
|
58
|
+
MODES = (
|
|
59
|
+
('zsh', '-li', True), ('zsh', '-lc', False),
|
|
60
|
+
('bash', '-li', True), ('bash', '-lc', False),
|
|
61
|
+
('sh', '-lc', False),
|
|
62
|
+
)
|
|
63
|
+
# What resolves a NAME to the executable a shell would run, per shell, bypassing
|
|
64
|
+
# aliases and functions (an interactive zsh answers `command -v claude` with the
|
|
65
|
+
# user's alias text, which says nothing about the file). POSIX sh has only `command -v`,
|
|
66
|
+
# which reports an alias or a function by name, so the sh script drops those first.
|
|
67
|
+
_RESOLVERS = {'zsh': 'whence -p', 'bash': 'type -P', 'sh': 'command -v'}
|
|
68
|
+
SENTINEL = '__claude_multiacc_probe__'
|
|
69
|
+
# The PATH a login shell STARTS from before any rc file runs — what sshd/login/launchd
|
|
70
|
+
# hand it. /usr/local/bin matters on Linux: the root install's /usr/local/bin/claude
|
|
71
|
+
# symlink is how a shell with no rc block still reaches the shim, so leaving it out
|
|
72
|
+
# would call a working server login a bypass. macOS's path_helper rebuilds PATH from
|
|
73
|
+
# /etc/paths anyway.
|
|
74
|
+
_SEED_PATH = {'darwin': '/usr/bin:/bin:/usr/sbin:/sbin'}
|
|
75
|
+
_SEED_PATH_DEFAULT = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def enabled():
|
|
79
|
+
return os.environ.get('CLAUDE_MULTIACC_PATH_PROBE', '').strip().lower() \
|
|
80
|
+
not in ('0', 'false', 'no', 'off')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def shell_path(name):
|
|
84
|
+
"""The shell binary a terminal would start, or None when it is not installed."""
|
|
85
|
+
found = shutil.which(name, path='/bin:/usr/bin:/usr/local/bin:/opt/homebrew/bin')
|
|
86
|
+
if found:
|
|
87
|
+
return found
|
|
88
|
+
candidate = os.path.join('/bin', name)
|
|
89
|
+
return candidate if os.access(candidate, os.X_OK) else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _script(shell, names):
|
|
93
|
+
resolver = _RESOLVERS[shell]
|
|
94
|
+
# First thing, before any answer: no history. The interactive shells would otherwise
|
|
95
|
+
# save these lines into the operator's real ~/.zsh_history / ~/.bash_history on exit
|
|
96
|
+
# (macOS /etc/zshrc sets HISTFILE unconditionally, so an env var is not enough — this
|
|
97
|
+
# runs AFTER the rc files and wins). bash honours `unset HISTFILE`; zsh honours
|
|
98
|
+
# SAVEHIST=0. The stderr redirect swallows "no such option" from the other shell.
|
|
99
|
+
# `set +o history` is bash-only: zsh's `set` rejects the option, and a NON-interactive
|
|
100
|
+
# zsh exits on a special-builtin error — the whole `-lc` row would read as "exited
|
|
101
|
+
# without answering". zsh needs nothing beyond SAVEHIST=0.
|
|
102
|
+
lines = ['unset HISTFILE 2>/dev/null; HISTFILE=/dev/null; SAVEHIST=0; HISTSIZE=0; '
|
|
103
|
+
'[ -z "${BASH_VERSION:-}" ] || set +o history; true']
|
|
104
|
+
if shell == 'sh':
|
|
105
|
+
# `command -v` answers with alias text or a bare function name; the PATH search
|
|
106
|
+
# is the question, so those go first (the shell is a throwaway).
|
|
107
|
+
lines.append('unalias -a 2>/dev/null; unset -f ' + ' '.join(names) + ' 2>/dev/null; true')
|
|
108
|
+
lines += [f'printf \'%s %s=%s\\n\' {SENTINEL} {n} "$({resolver} {n} 2>/dev/null)"'
|
|
109
|
+
for n in names]
|
|
110
|
+
# The interactive shells read this on stdin; without the exit an rc file that
|
|
111
|
+
# left the shell in a state where EOF is ignored (IGNOREEOF) would hang to timeout.
|
|
112
|
+
lines.append('exit 0')
|
|
113
|
+
return '\n'.join(lines) + '\n'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _env(home, user, shell):
|
|
117
|
+
# A TERM a terminal would set: rc files commonly bail out early on TERM=dumb (the
|
|
118
|
+
# Emacs TRAMP guard), which would judge a branch no person at a prompt ever sees.
|
|
119
|
+
return {'HOME': home, 'USER': user, 'LOGNAME': user, 'SHELL': shell,
|
|
120
|
+
'TERM': 'xterm-256color',
|
|
121
|
+
'PATH': _SEED_PATH.get(sys.platform, _SEED_PATH_DEFAULT),
|
|
122
|
+
'LANG': os.environ.get('LANG', 'C.UTF-8'),
|
|
123
|
+
'CLAUDE_MULTIACC_PATH_PROBE': '1'}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_shim(path, repo_dir, name):
|
|
127
|
+
if not path:
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
if os.path.realpath(path) == os.path.realpath(os.path.join(repo_dir, 'bin', name)):
|
|
131
|
+
return True
|
|
132
|
+
with open(path, 'rb') as f:
|
|
133
|
+
return SHIM_MARK.encode() in f.read(300)
|
|
134
|
+
except OSError:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse(text, names):
|
|
139
|
+
"""First sentinel answer per name. Anything else the shell printed is ignored."""
|
|
140
|
+
answers = {}
|
|
141
|
+
for line in text.splitlines():
|
|
142
|
+
if not line.startswith(SENTINEL + ' '):
|
|
143
|
+
continue
|
|
144
|
+
body = line[len(SENTINEL) + 1:]
|
|
145
|
+
name, _sep, value = body.partition('=')
|
|
146
|
+
if name in names and name not in answers:
|
|
147
|
+
answers[name] = value.strip()
|
|
148
|
+
return answers
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_mode(shell, flags, interactive, names, repo_dir, home, user, timeout):
|
|
152
|
+
exe = shell_path(shell)
|
|
153
|
+
mode = f'{shell} {flags}'
|
|
154
|
+
row = {'shell': shell, 'mode': mode, 'interactive': interactive, 'argv': None,
|
|
155
|
+
'results': {}, 'error': None}
|
|
156
|
+
if not exe:
|
|
157
|
+
row['error'] = 'shell not installed'
|
|
158
|
+
return row
|
|
159
|
+
script = _script(shell, names)
|
|
160
|
+
argv = [exe, flags] if interactive else [exe, flags, script]
|
|
161
|
+
row['argv'] = argv
|
|
162
|
+
try:
|
|
163
|
+
text, timed_out, returncode = _run_shell(argv, script if interactive else '',
|
|
164
|
+
_env(home, user, exe), home, timeout)
|
|
165
|
+
except Exception as e: # noqa: BLE001 — one mode must never take down the report
|
|
166
|
+
row['error'] = f'probe could not run: {e!r}'[:200]
|
|
167
|
+
return row
|
|
168
|
+
answers = _parse(text, names)
|
|
169
|
+
missing = [n for n in names if n not in answers]
|
|
170
|
+
if timed_out and missing:
|
|
171
|
+
row['error'] = f'no answer within {timeout}s (an rc file is hanging or prompting)'
|
|
172
|
+
return row
|
|
173
|
+
if not answers:
|
|
174
|
+
row['error'] = (f'shell exited {returncode} without answering '
|
|
175
|
+
'(an rc file exec\'d something, exited early, or read the probe)')
|
|
176
|
+
return row
|
|
177
|
+
for n in names:
|
|
178
|
+
if n in answers:
|
|
179
|
+
resolved = answers[n] or None
|
|
180
|
+
row['results'][n] = {'resolved': resolved, 'ok': is_shim(resolved, repo_dir, n)}
|
|
181
|
+
else:
|
|
182
|
+
row['results'][n] = {'resolved': None, 'ok': None}
|
|
183
|
+
if missing:
|
|
184
|
+
row['error'] = (f'no answer for {", ".join(missing)} — an rc file consumed part of '
|
|
185
|
+
'the probe (a prompt or `read` on stdin?)')
|
|
186
|
+
return row
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_shell(argv, stdin_text, env, cwd, timeout):
|
|
190
|
+
"""(stdout text, timed_out, returncode). stdout goes to a temp FILE, not a pipe:
|
|
191
|
+
a pipe only closes when every process holding it is gone, so an rc line that
|
|
192
|
+
backgrounds something (`ssh-agent`, `tmux new -d`, `cmd &`) would keep a pipe open
|
|
193
|
+
long after the shell answered and exited, and the whole answer would be lost to
|
|
194
|
+
the timeout. The shell gets its own session so a timeout can kill everything it
|
|
195
|
+
started. Whatever was written before a timeout is still returned."""
|
|
196
|
+
with tempfile.TemporaryFile() as out:
|
|
197
|
+
p = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=out, stderr=subprocess.DEVNULL,
|
|
198
|
+
env=env, cwd=cwd if os.path.isdir(cwd) else None,
|
|
199
|
+
start_new_session=True)
|
|
200
|
+
timed_out = False
|
|
201
|
+
try:
|
|
202
|
+
p.communicate(stdin_text.encode('utf-8'), timeout=timeout)
|
|
203
|
+
except subprocess.TimeoutExpired:
|
|
204
|
+
timed_out = True
|
|
205
|
+
try:
|
|
206
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
try:
|
|
210
|
+
p.wait(timeout=5)
|
|
211
|
+
except subprocess.TimeoutExpired:
|
|
212
|
+
pass
|
|
213
|
+
out.seek(0)
|
|
214
|
+
text = out.read().decode('utf-8', errors='replace')
|
|
215
|
+
return text, timed_out, p.returncode
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def probe(repo_dir, home=None, user=None, names=NAMES, timeout=DEFAULT_TIMEOUT, modes=MODES):
|
|
219
|
+
if not enabled():
|
|
220
|
+
return []
|
|
221
|
+
home = home or os.path.expanduser('~')
|
|
222
|
+
try:
|
|
223
|
+
user = user or getpass.getuser()
|
|
224
|
+
except Exception: # noqa: BLE001 — no passwd entry; the name only seeds $USER
|
|
225
|
+
user = os.environ.get('USER') or 'user'
|
|
226
|
+
with ThreadPoolExecutor(max_workers=len(modes)) as pool:
|
|
227
|
+
return list(pool.map(
|
|
228
|
+
lambda m: _run_mode(m[0], m[1], m[2], names, repo_dir, home, user, timeout), modes))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def bypassed(rows):
|
|
232
|
+
out = []
|
|
233
|
+
for row in rows:
|
|
234
|
+
for name, r in row['results'].items():
|
|
235
|
+
if r['ok'] is False:
|
|
236
|
+
out.append((row['mode'], name, r['resolved']))
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def render(rows):
|
|
241
|
+
"""Operator-facing lines. The verdict line is the one that matters; the per-mode
|
|
242
|
+
lines say WHICH shell to open to reproduce it."""
|
|
243
|
+
lines = ['shim on PATH (fresh login shells, this user\'s rc files):']
|
|
244
|
+
if not rows:
|
|
245
|
+
lines.append(' (probe disabled: CLAUDE_MULTIACC_PATH_PROBE=0)')
|
|
246
|
+
return lines
|
|
247
|
+
for row in rows:
|
|
248
|
+
cell = f' {row["mode"]:<8}'
|
|
249
|
+
if row['error']:
|
|
250
|
+
lines.append(f'{cell} ? {row["error"]}')
|
|
251
|
+
continue
|
|
252
|
+
parts = []
|
|
253
|
+
for name, r in row['results'].items():
|
|
254
|
+
if r['ok'] is None:
|
|
255
|
+
parts.append(f'{name}: ?')
|
|
256
|
+
elif r['ok']:
|
|
257
|
+
parts.append(f'{name}: OK')
|
|
258
|
+
elif r['resolved']:
|
|
259
|
+
parts.append(f'{name}: BYPASSED -> {r["resolved"]}')
|
|
260
|
+
else:
|
|
261
|
+
parts.append(f'{name}: NOT ON PATH')
|
|
262
|
+
lines.append(f'{cell} {" ".join(parts)}' + (f' ({row["error"]})' if row['error'] else ''))
|
|
263
|
+
bad = bypassed(rows)
|
|
264
|
+
if bad:
|
|
265
|
+
modes = sorted({m for m, _n, _r in bad})
|
|
266
|
+
lines.append(f' ** {len(modes)} shell mode(s) start `claude`/`codex` OUTSIDE the pool '
|
|
267
|
+
f'({", ".join(modes)}): such a session runs the real binary on the '
|
|
268
|
+
'machine\'s own ~/.claude login — or on no login at all ("Not logged '
|
|
269
|
+
'in · Please run /login"). **')
|
|
270
|
+
lines.append(' fix: claude-multiacc install # rewrites the rc blocks (~/.zshenv, '
|
|
271
|
+
'~/.zprofile, ~/.zshrc, ~/.profile, bash rc files) with the prompt hook')
|
|
272
|
+
lines.append(' then open a new shell (or run: exec "$SHELL" -l) — an already-open '
|
|
273
|
+
'shell keeps the PATH it has')
|
|
274
|
+
unanswered = [row['mode'] for row in rows
|
|
275
|
+
if row['error'] and row['error'] != 'shell not installed' and not row['results']]
|
|
276
|
+
if unanswered:
|
|
277
|
+
lines.append(f' note: {", ".join(unanswered)} could not be probed — open one by hand '
|
|
278
|
+
'and run: command -v claude')
|
|
279
|
+
return lines
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main(argv):
|
|
283
|
+
args = [a for a in argv[1:] if not a.startswith('--')]
|
|
284
|
+
flags = [a for a in argv[1:] if a.startswith('--')]
|
|
285
|
+
if len(args) != 1 or any(f not in ('--json',) and not f.startswith('--timeout=') for f in flags):
|
|
286
|
+
print('usage: shim_path.py <repo-dir> [--json] [--timeout=SECONDS]', file=sys.stderr)
|
|
287
|
+
return 2
|
|
288
|
+
timeout = DEFAULT_TIMEOUT
|
|
289
|
+
for f in flags:
|
|
290
|
+
if f.startswith('--timeout='):
|
|
291
|
+
try:
|
|
292
|
+
timeout = max(1, int(f.split('=', 1)[1]))
|
|
293
|
+
except ValueError:
|
|
294
|
+
print('usage: --timeout takes an integer number of seconds', file=sys.stderr)
|
|
295
|
+
return 2
|
|
296
|
+
rows = probe(args[0], timeout=timeout)
|
|
297
|
+
if '--json' in flags:
|
|
298
|
+
print(json.dumps({'repo_dir': args[0], 'enabled': enabled(), 'modes': rows,
|
|
299
|
+
'bypassed': [list(b) for b in bypassed(rows)]}, indent=2))
|
|
300
|
+
else:
|
|
301
|
+
print('\n'.join(render(rows)))
|
|
302
|
+
return 1 if bypassed(rows) else 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == '__main__':
|
|
306
|
+
sys.exit(main(sys.argv))
|