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 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-file block) /root/claude-multiacc/ (addon repo)
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
 
@@ -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
@@ -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:
@@ -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 if present) — end-of-file placement matters because those files
82
- re-prepend `~/.local/bin`; launchd agents `com.claude-multiacc.limits` +
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
- shim symlinks at `/usr/local/bin/claude` and `/usr/local/bin/codex` (shadow via
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.
@@ -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** — open a new shell, or check
26
- that the marked block is the LAST PATH manipulation in your rc file
27
- (`grep -A2 'claude-multiacc >>>' ~/.zshrc`).
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).
@@ -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
- printf '%s\n' "$MARK_BEGIN"
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
@@ -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))