claude-multiacc 1.0.0

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/bin/claude ADDED
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env bash
2
+ # claude-multiacc-shim — PATH-shadows the real `claude` binary; never replaces or edits it.
3
+ # Every invocation runs under a randomly picked subscription account with limit headroom.
4
+ # Self-contained on purpose: no sourcing, so a broken repo file can never break `claude`.
5
+ # Selection: CLAUDE_CONFIG_DIR/CLAUDE_CODE_OAUTH_TOKEN passthrough > CLAUDE_ACCOUNT pin >
6
+ # random among limit-eligible accounts > least-utilized fallback (degraded beats down).
7
+
8
+ set -u
9
+
10
+ # ${HOME:-} guards: with HOME stripped (env -i, some cron/systemd units) the shim
11
+ # must still fail OPEN into plain passthrough, never abort on an unbound variable.
12
+ ACC_ROOT="${CLAUDE_ACCOUNTS_DIR:-${HOME:-/nonexistent}/.claude-accounts}"
13
+ MANIFEST="$ACC_ROOT/accounts.json"
14
+
15
+ canon_path() {
16
+ local p="$1" t i=0 d b
17
+ case "$p" in /*) ;; *) p="$PWD/$p" ;; esac
18
+ while [ -L "$p" ] && [ "$i" -lt 40 ]; do
19
+ t="$(readlink "$p")" || break
20
+ case "$t" in /*) p="$t" ;; *) p="$(dirname "$p")/$t" ;; esac
21
+ i=$((i+1))
22
+ done
23
+ d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)" || { printf '%s\n' "$p"; return 0; }
24
+ b="$(basename "$p")"
25
+ if [ "$d" = "/" ]; then printf '/%s\n' "$b"; else printf '%s/%s\n' "$d" "$b"; fi
26
+ }
27
+
28
+ is_shim_file() { head -c 300 "$1" 2>/dev/null | grep -q claude-multiacc-shim; }
29
+
30
+ SELF="$(canon_path "$0")"
31
+ SELF_DIR="$(dirname "$SELF")"
32
+
33
+ find_real() {
34
+ local cand c d
35
+ local oldifs="$IFS"
36
+ IFS=':'; set -f
37
+ # shellcheck disable=SC2086
38
+ set -- $PATH
39
+ IFS="$oldifs"; set +f
40
+ for d in "$@"; do
41
+ [ -n "$d" ] || continue
42
+ cand="$d/claude"
43
+ [ -f "$cand" ] && [ -x "$cand" ] || continue
44
+ c="$(canon_path "$cand")"
45
+ [ "$c" = "$SELF" ] && continue
46
+ case "$c" in "$ACC_ROOT"/*) continue ;; esac
47
+ is_shim_file "$c" && continue
48
+ printf '%s\n' "$cand"; return 0
49
+ done
50
+ # Fallbacks: resolved dynamically at exec time, so `claude update`/reinstalls keep working.
51
+ for cand in "${HOME:-/nonexistent}/.local/bin/claude" /usr/local/bin/claude /opt/homebrew/bin/claude /usr/bin/claude; do
52
+ [ -f "$cand" ] && [ -x "$cand" ] || continue
53
+ c="$(canon_path "$cand")"
54
+ [ "$c" = "$SELF" ] && continue
55
+ is_shim_file "$c" && continue
56
+ printf '%s\n' "$cand"; return 0
57
+ done
58
+ return 1
59
+ }
60
+
61
+ REAL="$(find_real)" || {
62
+ printf 'claude-multiacc shim: real claude binary not found (PATH or fallback locations)\n' >&2
63
+ exit 127
64
+ }
65
+
66
+ # Fast passthrough: caller pinned a config dir or token, addon disabled, recursion
67
+ # guard, or no account data yet. Byte-identical behavior to stock claude.
68
+ if [ -n "${CLAUDE_CONFIG_DIR:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] \
69
+ || [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CLAUDE_SHIM_ACTIVE:-}" ] \
70
+ || [ ! -f "$MANIFEST" ]; then
71
+ exec "$REAL" "$@"
72
+ fi
73
+
74
+ now="$(date +%s)"
75
+
76
+ # Threshold used by the telemetry backstop below; the manifest is the source of
77
+ # truth, but a corrupt/unreadable manifest must never break selection => plain
78
+ # sed with a safe default, never a JSON parse.
79
+ if [ -z "${CLAUDE_MULTIACC_THRESHOLD:-}" ]; then
80
+ CLAUDE_MULTIACC_THRESHOLD="$(sed -n 's/.*"threshold"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$MANIFEST" 2>/dev/null | head -1)"
81
+ case "$CLAUDE_MULTIACC_THRESHOLD" in ''|*[!0-9]*) CLAUDE_MULTIACC_THRESHOLD=90 ;; esac
82
+ fi
83
+
84
+ if [ "$(uname -s)" = "Darwin" ]; then
85
+ file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
86
+ else
87
+ file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
88
+ fi
89
+
90
+ sel_log() {
91
+ printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$ACC_ROOT/selection.log" 2>/dev/null || true
92
+ }
93
+
94
+ marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
95
+ local m="$1/.limited" reset=""
96
+ [ -f "$m" ] || return 1
97
+ IFS= read -r reset < "$m" 2>/dev/null || reset=""
98
+ case "$reset" in
99
+ ''|*[!0-9]*)
100
+ # Empty/partial/garbled marker — e.g. read during a concurrent rewrite.
101
+ # Treat as ACTIVE and never delete: deleting here could destroy a marker
102
+ # another process is mid-write. The next limits refresh rewrites or clears it.
103
+ return 0 ;;
104
+ esac
105
+ if [ "$now" -ge "$reset" ]; then
106
+ rm -f "$m" 2>/dev/null
107
+ return 1
108
+ fi
109
+ return 0
110
+ }
111
+
112
+ STALE_AFTER=900
113
+
114
+ fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fresh, else fail
115
+ local f="$1/limits.json" fetched v
116
+ [ -f "$f" ] || return 1
117
+ fetched="$(sed -n 's/.*"fetched_at"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
118
+ case "$fetched" in ''|*[!0-9]*) return 1 ;; esac
119
+ [ $((now - fetched)) -le "$STALE_AFTER" ] || return 1
120
+ v="$(sed -n "s/.*\"$2\"[^0-9]*\([0-9][0-9]*\).*/\1/p" "$f" 2>/dev/null | head -1)"
121
+ case "$v" in ''|*[!0-9]*) return 1 ;; esac
122
+ printf '%s\n' "$v"
123
+ }
124
+
125
+ # RANKING score — lower is better (more headroom). Weekly headroom dominates: a weekly
126
+ # bucket only refills on the account's fixed weekly reset (days away), while the 5h
127
+ # session bucket self-heals, so session is a mild tiebreaker only. (Anthropic's docs
128
+ # confirm this reset asymmetry — an account whose only near-full bucket is the cheap
129
+ # session one must NOT rank behind one burning durable weekly headroom.)
130
+ # score = weekly%*1000 + session% weekly,session in [0,100]
131
+ # Stale/unreadable telemetry ranks NEUTRAL (weekly 50, session 50), never "free".
132
+ sel_score_of() { # $1 = acct dir
133
+ local w s
134
+ w="$(fresh_field "$1" weekly_percent)" || w="$(fresh_field "$1" max_percent)" || w=50
135
+ s="$(fresh_field "$1" session_percent)" || s=50
136
+ printf '%s\n' $((w * 1000 + s))
137
+ }
138
+
139
+ # Peak of ALL buckets (session included) — the EXCLUSION signal. Stale/unknown => 50.
140
+ util_of() {
141
+ local v
142
+ v="$(fresh_field "$1" max_percent)" || v=50
143
+ printf '%s\n' "$v"
144
+ }
145
+
146
+ # Backstop for a lost/failed marker write: fresh telemetry with ANY bucket at/over the
147
+ # threshold excludes the account even if .limited is missing (a full session bucket
148
+ # really blocks now; its marker just expires soon). Stale/unreadable => not over
149
+ # (fail open — telemetry must never invent exclusions).
150
+ over_threshold() { # $1 = acct dir
151
+ local v
152
+ v="$(fresh_field "$1" max_percent)" || return 1
153
+ [ "$v" -ge "${CLAUDE_MULTIACC_THRESHOLD:-90}" ]
154
+ }
155
+
156
+ # An empty credentials file is NOT auth (an interrupted write must not make a
157
+ # dead account selectable and turn a working stock run into an auth failure).
158
+ has_auth() { [ -s "$1/.credentials.json" ] || [ -s "$1/server.token" ]; }
159
+
160
+ # Explicit pin wins over everything — markers, and even missing auth: the
161
+ # add/login ceremony pins to a dir that has no credentials yet, and the login
162
+ # must land exactly there, never in a randomly selected account's dir.
163
+ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
164
+ d="$ACC_ROOT/$CLAUDE_ACCOUNT"
165
+ if [ -d "$d" ]; then
166
+ sel_log "$CLAUDE_ACCOUNT pinned pwd=$PWD"
167
+ export CLAUDE_CONFIG_DIR="$d"
168
+ export CLAUDE_SHIM_ACTIVE=1
169
+ if [ ! -f "$d/.credentials.json" ] && [ -s "$d/server.token" ]; then
170
+ CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '[:space:]' < "$d/server.token")"
171
+ export CLAUDE_CODE_OAUTH_TOKEN
172
+ fi
173
+ exec "$REAL" "$@"
174
+ fi
175
+ sel_log "pin-invalid account=$CLAUDE_ACCOUNT (no such dir; random fallback)"
176
+ fi
177
+
178
+ valid=()
179
+ eligible=()
180
+ for d in "$ACC_ROOT"/acct-*; do
181
+ [ -d "$d" ] || continue
182
+ has_auth "$d" || continue
183
+ valid+=("$d")
184
+ marker_active "$d" && continue
185
+ over_threshold "$d" && continue
186
+ eligible+=("$d")
187
+ done
188
+
189
+ # No usable accounts => stock behavior (fail open, never block work).
190
+ [ "${#valid[@]}" -gt 0 ] || exec "$REAL" "$@"
191
+
192
+ # Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
193
+ # session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
194
+ # Sets PICK_DIR/PICK_SCORE as globals — it must never touch "$@", which holds the
195
+ # user's claude arguments.
196
+ PICK_DIR=""
197
+ PICK_SCORE=""
198
+ pick_best() { # args: candidate dirs
199
+ local d v best="" bestv=1000000 ties=1
200
+ for d in "$@"; do
201
+ v="$(sel_score_of "$d")"
202
+ if [ "$v" -lt "$bestv" ]; then
203
+ bestv="$v"; best="$d"; ties=1
204
+ elif [ "$v" -eq "$bestv" ]; then
205
+ ties=$((ties + 1))
206
+ [ $((RANDOM % ties)) -eq 0 ] && best="$d" # reservoir-sample among equals
207
+ fi
208
+ done
209
+ PICK_DIR="$best"
210
+ PICK_SCORE="$bestv"
211
+ }
212
+
213
+ if [ "${#eligible[@]}" -gt 0 ]; then
214
+ if [ "${CLAUDE_SHIM_SELECT:-headroom}" = "random" ]; then
215
+ PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
216
+ else
217
+ pick_best "${eligible[@]}"
218
+ fi
219
+ else
220
+ # Every account is limit-marked: degraded service beats a hard failure (100% rule).
221
+ pick_best "${valid[@]}"
222
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
223
+ fi
224
+ pick="$PICK_DIR"
225
+
226
+ # Opportunistic limits refresh: non-blocking, throttled, backgrounded.
227
+ kick="$ACC_ROOT/.limits-kick"
228
+ stale=0
229
+ for d in "${valid[@]}"; do
230
+ f="$d/limits.json"
231
+ if [ ! -f "$f" ] || [ $((now - $(file_mtime "$f"))) -gt 180 ]; then stale=1; break; fi
232
+ done
233
+ if [ "$stale" = 1 ] && [ -x "$SELF_DIR/claude-accounts" ]; then
234
+ last=0
235
+ [ -f "$kick" ] && last="$(file_mtime "$kick")"
236
+ if [ $((now - last)) -gt 120 ]; then
237
+ : > "$kick" 2>/dev/null || true
238
+ ( "$SELF_DIR/claude-accounts" limits --quiet >/dev/null 2>&1 & ) >/dev/null 2>&1
239
+ fi
240
+ fi
241
+
242
+ acct="$(basename "$pick")"
243
+ sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
244
+
245
+ acct_token() { # $1 = acct dir; prints token if dir is token-auth (no local creds)
246
+ if [ ! -f "$1/.credentials.json" ] && [ -s "$1/server.token" ]; then
247
+ tr -d '[:space:]' < "$1/server.token"
248
+ fi
249
+ }
250
+
251
+ export CLAUDE_SHIM_ACTIVE=1
252
+
253
+ # Auto-retry applies only to -p/--print runs with an alternative account available,
254
+ # and only when stdin is finite (tty, regular file, or char device like /dev/null).
255
+ # A service-spawned pipe that never EOFs must take the plain exec path, or the
256
+ # stdin pre-buffering below would hang the call.
257
+ wants_retry=0
258
+ if [ "${CLAUDE_SHIM_RETRY:-1}" != "0" ] && [ "${#eligible[@]}" -ge 2 ]; then
259
+ for a in "$@"; do
260
+ case "$a" in -p|--print) wants_retry=1; break ;; esac
261
+ done
262
+ if [ "$wants_retry" = "1" ]; then
263
+ # A TTY cannot be buffered or replayed: `claude -p` with no prompt argument reads
264
+ # the terminal, and the retry path would hand it /dev/null. Plain exec instead —
265
+ # stdin is inherited untouched. A pipe that never EOFs would hang the pre-buffer,
266
+ # so only finite stdin (regular file, /dev/null-style char device) takes the retry
267
+ # path; everything else execs directly.
268
+ if [ -t 0 ]; then
269
+ wants_retry=0
270
+ elif [ -f /dev/fd/0 ] || [ -c /dev/fd/0 ]; then
271
+ :
272
+ else
273
+ wants_retry=0
274
+ fi
275
+ fi
276
+ fi
277
+
278
+ if [ "$wants_retry" = "0" ]; then
279
+ export CLAUDE_CONFIG_DIR="$pick"
280
+ tok="$(acct_token "$pick")"
281
+ [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
282
+ exec "$REAL" "$@"
283
+ fi
284
+
285
+ # Retry path: buffer stdio so a retried call never double-emits partial output.
286
+ mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
287
+ tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
288
+ export CLAUDE_CONFIG_DIR="$pick"
289
+ tok="$(acct_token "$pick")"
290
+ [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
291
+ exec "$REAL" "$@"
292
+ }
293
+ trap 'rm -rf "$tmpd"' EXIT
294
+
295
+ # The output buffers must be writable BEFORE the run: if redirection failed at exec
296
+ # time (disk full), the real binary would never launch and the shim would exit
297
+ # nonzero — a hard failure. Verify now, fall back to plain exec if we cannot.
298
+ if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
299
+ export CLAUDE_CONFIG_DIR="$pick"
300
+ tok="$(acct_token "$pick")"
301
+ [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
302
+ exec "$REAL" "$@"
303
+ fi
304
+
305
+ stdin_file=""
306
+ if [ ! -t 0 ]; then
307
+ stdin_file="$tmpd/in"
308
+ # If buffering fails midway (disk full) stdin is already partly consumed and cannot
309
+ # be rewound — keep whatever landed rather than silently substituting /dev/null.
310
+ cat > "$stdin_file" 2>/dev/null || [ -s "$stdin_file" ] || stdin_file=""
311
+ fi
312
+
313
+ ERRPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|overloaded|"?529"?|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)|credit balance'
314
+
315
+ attempt=1
316
+ cur="$pick"
317
+ rc=0
318
+ while :; do
319
+ tok="$(acct_token "$cur")"
320
+ if [ -n "$stdin_file" ]; then exec 3< "$stdin_file"; else exec 3< /dev/null; fi
321
+ if [ -n "$tok" ]; then
322
+ CLAUDE_CONFIG_DIR="$cur" CLAUDE_CODE_OAUTH_TOKEN="$tok" "$REAL" "$@" <&3 > "$tmpd/out" 2> "$tmpd/err"
323
+ else
324
+ CLAUDE_CONFIG_DIR="$cur" "$REAL" "$@" <&3 > "$tmpd/out" 2> "$tmpd/err"
325
+ fi
326
+ rc=$?
327
+ exec 3<&-
328
+ if [ "$rc" -ne 0 ] && [ "$attempt" -eq 1 ] \
329
+ && grep -qiE "$ERRPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
330
+ # Atomic marker write: a reader must never observe a half-written .limited
331
+ # (it would parse as garbage and, before, could be deleted as "expired").
332
+ {
333
+ echo $((now + 600))
334
+ echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
335
+ } > "$cur/.limited.$$" 2>/dev/null \
336
+ && mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
337
+ || rm -f "$cur/.limited.$$" 2>/dev/null || true
338
+ next=""
339
+ n="${#eligible[@]}"
340
+ start=$((RANDOM % n))
341
+ i=0
342
+ while [ "$i" -lt "$n" ]; do
343
+ c="${eligible[$(((start + i) % n))]}"
344
+ if [ "$c" != "$cur" ]; then next="$c"; break; fi
345
+ i=$((i+1))
346
+ done
347
+ if [ -n "$next" ]; then
348
+ sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
349
+ cur="$next"
350
+ attempt=2
351
+ continue
352
+ fi
353
+ fi
354
+ break
355
+ done
356
+
357
+ cat "$tmpd/out"
358
+ cat "$tmpd/err" >&2
359
+ exit "$rc"