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.
@@ -0,0 +1,1127 @@
1
+ #!/usr/bin/env bash
2
+ # claude-accounts — manage the claude-multiacc account pool.
3
+ # Subcommands: list status add import remove mint sync verify limits post-sync health
4
+ set -u
5
+
6
+ _self="$0"
7
+ while [ -L "$_self" ]; do
8
+ _t="$(readlink "$_self")"
9
+ case "$_t" in /*) _self="$_t" ;; *) _self="$(dirname "$_self")/$_t" ;; esac
10
+ done
11
+ BIN_DIR="$(cd "$(dirname "$_self")" && pwd -P)"
12
+ REPO_DIR="$(dirname "$BIN_DIR")"
13
+ # shellcheck source=lib/common.sh
14
+ . "$REPO_DIR/lib/common.sh"
15
+
16
+ usage() {
17
+ cat <<'EOF'
18
+ claude-accounts — multi-account pool manager for claude-multiacc
19
+
20
+ USAGE
21
+ claude-accounts list brief account list
22
+ claude-accounts status full health: auth, per-bucket limits, markers
23
+ claude-accounts add <email> [--force|--tui]
24
+ login-FIRST: shows a sign-in link, you paste the browser code back, and the
25
+ account is registered only after auth is verified. Duplicate emails refused
26
+ (--force overrides). --tui uses interactive /login for full OAuth creds.
27
+ claude-accounts login <acct-NN> [--force] complete/refresh auth for an existing
28
+ account (same link+code flow)
29
+ claude-accounts import <email> [opts] register an account, optionally with credentials
30
+ --id acct-NN explicit id (default: next free)
31
+ --home mac|server which machine owns the OAuth grant (default: this one)
32
+ --creds PATH existing .credentials.json to adopt
33
+ --mode copy|move|link how to adopt --creds (default copy)
34
+ --token-file PATH|- long-lived token for server use (- reads stdin)
35
+ --no-sync skip the automatic server sync
36
+ claude-accounts adopt <acct-NN> make acct-NN THIS machine's existing
37
+ default ~/.claude login (dir symlink —
38
+ single credential file, no grant fork)
39
+ claude-accounts remove <acct-NN> [--yes] delete account (propagates to server)
40
+ claude-accounts mint <acct-NN> mint server token via `claude setup-token`
41
+ --paste paste an already-minted token instead of running setup-token
42
+ claude-accounts sync push manifest+tokens to the server (Mac only)
43
+ claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
44
+ claude-accounts limits [--quiet] [--force]
45
+ refresh usage buckets, apply >=90% markers. Skips accounts fetched in the
46
+ last 45s and honors 429 backoff; --force ignores both.
47
+ claude-accounts health limits + full verify; logs to health.log
48
+ claude-accounts self-update update the addon (npm i -g @latest, or git
49
+ pull + reinstall); logs to update.log
50
+ claude-accounts post-sync (server side) seed dirs, fix perms, quick verify
51
+
52
+ ENV
53
+ CLAUDE_ACCOUNTS_DIR override ~/.claude-accounts
54
+ CLAUDE_ACCOUNT pin the shim to one account
55
+ CLAUDE_SHIM_RETRY=0 disable -p auto-retry
56
+ CLAUDE_MULTIACC_DISABLE=1 bypass the shim entirely
57
+ EOF
58
+ }
59
+
60
+ require_manifest() { [ -f "$MANIFEST" ] || die "no manifest at $MANIFEST — run install.sh first"; }
61
+
62
+ next_id() {
63
+ local n=1 id
64
+ while :; do
65
+ id="$(printf 'acct-%02d' "$n")"
66
+ if [ ! -d "$ACC_ROOT/$id" ] && ! account_ids | grep -qx "$id"; then
67
+ printf '%s\n' "$id"
68
+ return 0
69
+ fi
70
+ n=$((n+1))
71
+ [ "$n" -gt 99 ] && die "no free account slot"
72
+ done
73
+ }
74
+
75
+ manifest_add_account() { # id email home
76
+ "$PYBIN" - "$MANIFEST" "$1" "$2" "$3" <<'PYEOF'
77
+ import json, sys, time
78
+ path, aid, email, home = sys.argv[1:5]
79
+ doc = json.load(open(path))
80
+ accounts = [a for a in doc.get('accounts', []) if a['id'] != aid]
81
+ accounts.append({
82
+ 'id': aid,
83
+ 'email': email,
84
+ 'home': home,
85
+ 'added_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
86
+ })
87
+ accounts.sort(key=lambda a: a['id'])
88
+ doc['accounts'] = accounts
89
+ import os
90
+ with open(path + '.tmp', 'w') as f:
91
+ json.dump(doc, f, indent=2)
92
+ f.write('\n')
93
+ os.replace(path + '.tmp', path)
94
+ PYEOF
95
+ }
96
+
97
+ email_owner() { # prints the id that owns <email>, empty if unregistered
98
+ [ -f "$MANIFEST" ] || return 0
99
+ "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
100
+ import json, sys
101
+ for a in json.load(open(sys.argv[1])).get('accounts', []):
102
+ if a.get('email', '').lower() == sys.argv[2].lower():
103
+ print(a['id'])
104
+ break
105
+ PYEOF
106
+ }
107
+
108
+ manifest_del_account() { # id
109
+ "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
110
+ import json, sys
111
+ path, aid = sys.argv[1:3]
112
+ doc = json.load(open(path))
113
+ doc['accounts'] = [a for a in doc.get('accounts', []) if a['id'] != aid]
114
+ import os
115
+ with open(path + '.tmp', 'w') as f:
116
+ json.dump(doc, f, indent=2)
117
+ f.write('\n')
118
+ os.replace(path + '.tmp', path)
119
+ PYEOF
120
+ }
121
+
122
+ auto_sync() { # best effort after mutations, Mac only, loud on failure
123
+ [ "$(machine_kind)" = "mac" ] || return 0
124
+ [ "${CLAUDE_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
125
+ # subshell: cmd_sync exits on failure and must not take the CLI down with it
126
+ ( cmd_sync ) || warn "server sync failed — run 'claude-accounts sync' manually"
127
+ }
128
+
129
+ cmd_list() {
130
+ require_manifest
131
+ "$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
132
+ import json, os, re, sys
133
+ doc = json.load(open(sys.argv[1]))
134
+ root = sys.argv[2]
135
+ # Only render well-formed ids — a hand-edited manifest must not surface a traversal id.
136
+ accounts = [a for a in doc.get('accounts', [])
137
+ if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
138
+ if not accounts:
139
+ print('(no accounts yet)')
140
+ for a in accounts:
141
+ d = os.path.join(root, a['id'])
142
+ auth = []
143
+ if os.path.isfile(os.path.join(d, '.credentials.json')):
144
+ auth.append('oauth')
145
+ if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
146
+ auth.append('token')
147
+ limited = os.path.isfile(os.path.join(d, '.limited'))
148
+ print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} auth={'+'.join(auth) or 'NONE':<11} {'LIMITED' if limited else ''}")
149
+ PYEOF
150
+ }
151
+
152
+ cmd_status() {
153
+ require_manifest
154
+ "$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
155
+ import json, os, sys, time
156
+ doc = json.load(open(sys.argv[1]))
157
+ root = sys.argv[2]
158
+ now = time.time()
159
+
160
+ def last_pick(aid):
161
+ path = os.path.join(root, 'selection.log')
162
+ if not os.path.isfile(path):
163
+ return '-'
164
+ last = '-'
165
+ try:
166
+ with open(path, errors='replace') as f:
167
+ for line in f:
168
+ parts = line.split()
169
+ if len(parts) >= 2 and parts[1] == aid:
170
+ last = parts[0]
171
+ except Exception:
172
+ pass
173
+ return last
174
+
175
+ print(f"pool root : {root}")
176
+ print(f"server : {doc.get('server','-')} (root: {doc.get('server_root','-')})")
177
+ print(f"threshold : {doc.get('threshold', 90)}% (any bucket at/above => account excluded)")
178
+ print()
179
+ for a in doc.get('accounts', []):
180
+ aid = a['id']
181
+ d = os.path.join(root, aid)
182
+ print(f"{aid} {a['email']} [home={a.get('home','?')}]")
183
+ cpath = os.path.join(d, '.credentials.json')
184
+ if os.path.isfile(cpath):
185
+ try:
186
+ c = json.load(open(cpath)).get('claudeAiOauth', {})
187
+ exp = c.get('expiresAt', 0) / 1000.0
188
+ rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
189
+ state = 'fresh' if exp > now else 'stale (auto-refreshes on use)'
190
+ rstate = 'ok' if rexp > now else 'EXPIRED — re-login needed'
191
+ print(f" oauth creds : {state}; refresh token {rstate} (until {time.strftime('%Y-%m-%d', time.gmtime(rexp)) if rexp else '?'})")
192
+ except Exception as e:
193
+ print(f" oauth creds : unreadable ({e})")
194
+ else:
195
+ print(" oauth creds : none on this machine")
196
+ tpath = os.path.join(d, 'server.token')
197
+ if os.path.isfile(tpath) and os.path.getsize(tpath) > 0:
198
+ age_d = int((now - os.path.getmtime(tpath)) / 86400)
199
+ note = ' — NEARING 1y LIFETIME, re-mint soon' if age_d > 330 else ''
200
+ print(f" server token: present (minted ~{age_d}d ago){note}")
201
+ else:
202
+ print(" server token: none")
203
+ lpath = os.path.join(d, 'limits.json')
204
+ if os.path.isfile(lpath):
205
+ try:
206
+ lim = json.load(open(lpath))
207
+ age = int(now - lim.get('fetched_at', 0))
208
+ parts = [f"{b['name']}={b['percent']}%" for b in lim.get('buckets', [])]
209
+ print(f" limits : {' '.join(parts) or '(none)'} [{age}s old, max {lim.get('max_percent')}%]")
210
+ except Exception as e:
211
+ print(f" limits : unreadable ({e})")
212
+ else:
213
+ print(" limits : never fetched")
214
+ mpath = os.path.join(d, '.limited')
215
+ if os.path.isfile(mpath):
216
+ try:
217
+ lines = open(mpath).read().splitlines()
218
+ reset = int(lines[0]) if lines and lines[0].isdigit() else 0
219
+ detail = lines[1] if len(lines) > 1 else ''
220
+ mins = max(0, int((reset - now) / 60))
221
+ print(f" marker : LIMITED ({detail}) — clears in ~{mins}m")
222
+ except Exception:
223
+ print(" marker : LIMITED (unreadable marker)")
224
+ else:
225
+ print(" marker : none (eligible)")
226
+ print(f" last picked : {last_pick(aid)}")
227
+ print()
228
+ PYEOF
229
+ }
230
+
231
+ cmd_add() {
232
+ # Login-FIRST: the account is registered (and synced) only after /login
233
+ # succeeded in the new dir and the authenticated email has been verified.
234
+ # An aborted or failed login leaves zero traces.
235
+ require_manifest
236
+ local email="${1:-}"
237
+ [ -n "$email" ] || die "usage: claude-accounts add <email> [--force]"
238
+ shift
239
+ local force=0 tui=0
240
+ while [ $# -gt 0 ]; do
241
+ case "$1" in
242
+ --force) force=1; shift ;;
243
+ --tui) tui=1; shift ;;
244
+ *) die "unknown option: $1" ;;
245
+ esac
246
+ done
247
+ # One account per email, always: a duplicate is never useful and silently splits the
248
+ # pool. There is no --force escape hatch here (use `login <acct-NN>` to re-auth).
249
+ local owner
250
+ owner="$(email_owner "$email")"
251
+ if [ -n "$owner" ]; then
252
+ die "$email is already registered as $owner — nothing created (run 'claude-accounts login $owner' to re-authenticate it)"
253
+ fi
254
+ if [ ! -t 0 ] && [ -z "${CLAUDE_MULTIACC_FORCE_TTY:-}" ]; then
255
+ die "add is interactive (it completes sign-in before registering) — run it from a terminal"
256
+ fi
257
+ # Serialize the whole check->allocate->authenticate->register sequence: two concurrent
258
+ # adds must not race the duplicate check, collide on an id, or clobber the manifest.
259
+ local lock="$ACC_ROOT/.locks/mutate"
260
+ mkdir -p "$ACC_ROOT/.locks"
261
+ if ! mkdir "$lock" 2>/dev/null; then
262
+ if [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 900 ]; then
263
+ rm -rf "$lock"; mkdir "$lock" 2>/dev/null || die "cannot acquire the account lock"
264
+ else
265
+ die "another claude-accounts add/login is in progress — try again when it finishes"
266
+ fi
267
+ fi
268
+ # shellcheck disable=SC2064
269
+ trap "rm -rf '$lock'" EXIT
270
+ local real
271
+ real="$(find_real_claude "$_self")" || die "real claude binary not found"
272
+ local id d got
273
+ id="$(next_id)"
274
+ d="$ACC_ROOT/$id"
275
+ seed_account_dir "$d"
276
+ if [ "$tui" = "1" ]; then
277
+ # Full-OAuth variant: interactive /login in the new dir (auto-refreshing creds).
278
+ cat <<EOF
279
+ Preparing $id for $email — NOTHING is registered until login succeeds.
280
+ An interactive claude session opens now with no account. In ONE incognito
281
+ window signed into $email:
282
+ 1. type /login (press c to copy the URL into the incognito window)
283
+ 2. /status must show $email
284
+ 3. exit the session (/exit or ctrl+d) to finish registration
285
+ EOF
286
+ CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" || true
287
+ if [ ! -f "$d/.credentials.json" ]; then
288
+ rm -rf "${ACC_ROOT:?}/${id:?}"
289
+ die "no login detected — nothing was created"
290
+ fi
291
+ got="$(CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth status 2>/dev/null | "$PYBIN" -c '
292
+ import json, sys
293
+ try:
294
+ doc = json.loads(sys.stdin.read())
295
+ print(doc.get("email", "") if doc.get("loggedIn") else "")
296
+ except Exception:
297
+ print("")')"
298
+ if [ -z "$got" ]; then
299
+ rm -rf "${ACC_ROOT:?}/${id:?}"
300
+ die "login could not be verified — nothing was created"
301
+ fi
302
+ else
303
+ # Default: sign-in link -> user pastes the code back here -> token captured.
304
+ # The token works on BOTH machines (no refresh rotation, ~1y lifetime).
305
+ echo "Preparing $id for $email — NOTHING is registered until sign-in completes."
306
+ if ! run_token_ceremony "$d"; then
307
+ rm -rf "${ACC_ROOT:?}/${id:?}"
308
+ die "sign-in failed or aborted — nothing was created"
309
+ fi
310
+ ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
311
+ chmod 600 "$d/server.token"
312
+ got="$(token_email "$CEREMONY_TOKEN")"
313
+ if [ -z "$got" ]; then
314
+ # Authentication produced a token, but we could not confirm WHICH account it
315
+ # belongs to. Registering on the claimed email could silently create a duplicate
316
+ # or mislabel the account, so refuse — the requirement is verified auth, not
317
+ # merely successful auth. --force accepts the claimed email deliberately.
318
+ if [ "$force" != "1" ]; then
319
+ rm -rf "${ACC_ROOT:?}/${id:?}"
320
+ die "signed in, but the account identity could not be verified — nothing was created (retry, or use --force to trust '$email')"
321
+ fi
322
+ warn "identity unverified — registering as $email because --force was given"
323
+ got="$email"
324
+ fi
325
+ fi
326
+ if [ "$got" != "$email" ]; then
327
+ owner="$(email_owner "$got")"
328
+ if [ -n "$owner" ]; then
329
+ rm -rf "${ACC_ROOT:?}/${id:?}"
330
+ die "you signed in as $got, which is already registered as $owner — nothing was created"
331
+ fi
332
+ warn "signed in as $got (expected $email) — registering the account that actually authenticated"
333
+ email="$got"
334
+ fi
335
+ manifest_add_account "$id" "$email" "$(machine_kind)"
336
+ log_to ops.log "add $id $email (auth-verified)"
337
+ auto_sync
338
+ cat <<EOF
339
+ Registered $id for $email (sign-in verified) — usable immediately.
340
+ Optional:
341
+ claude-accounts verify # confirm the 100% matrix
342
+ EOF
343
+ }
344
+
345
+ cmd_import() {
346
+ require_manifest
347
+ local email="${1:-}"
348
+ [ -n "$email" ] || die "usage: claude-accounts import <email> [--id acct-NN] [--home mac|server] [--creds PATH] [--mode copy|move|link] [--token-file PATH|-] [--no-sync]"
349
+ shift
350
+ local id="" home="" creds="" mode="copy" token_file="" no_sync=0 force=0
351
+ while [ $# -gt 0 ]; do
352
+ case "$1" in
353
+ --id) id="$2"; shift 2 ;;
354
+ --home) home="$2"; shift 2 ;;
355
+ --creds) creds="$2"; shift 2 ;;
356
+ --mode) mode="$2"; shift 2 ;;
357
+ --token-file) token_file="$2"; shift 2 ;;
358
+ --no-sync) no_sync=1; shift ;;
359
+ --force) force=1; shift ;;
360
+ *) die "unknown option: $1" ;;
361
+ esac
362
+ done
363
+ [ -n "$id" ] || id="$(next_id)"
364
+ [ -n "$home" ] || home="$(machine_kind)"
365
+ case "$id" in acct-[0-9][0-9]) ;; *) die "id must look like acct-NN" ;; esac
366
+ local owner
367
+ owner="$(email_owner "$email")"
368
+ if [ -n "$owner" ] && [ "$owner" != "$id" ] && [ "$force" != "1" ]; then
369
+ die "$email is already registered as $owner — use --id $owner to update it, or --force to register a duplicate"
370
+ fi
371
+ # Validate ALL inputs before creating anything, so a bad token/creds path leaves no
372
+ # half-made account dir behind.
373
+ [ -n "$creds" ] && { [ -f "$creds" ] || die "credentials file not found: $creds"; }
374
+ case "$mode" in copy|move|link) ;; *) die "mode must be copy|move|link" ;; esac
375
+ local tok=""
376
+ if [ -n "$token_file" ]; then
377
+ if [ "$token_file" = "-" ]; then
378
+ tok="$(tr -d '[:space:]')"
379
+ else
380
+ [ -f "$token_file" ] || die "token file not found: $token_file"
381
+ tok="$(tr -d '[:space:]' < "$token_file")"
382
+ fi
383
+ valid_subscription_token "$tok" \
384
+ || die "not a subscription setup-token (sk-ant-oat...). API keys are not supported."
385
+ fi
386
+ local d="$ACC_ROOT/$id"
387
+ seed_account_dir "$d"
388
+ if [ -n "$creds" ]; then
389
+ case "$mode" in
390
+ copy) cp "$creds" "$d/.credentials.json" ;;
391
+ move) mv "$creds" "$d/.credentials.json" ;;
392
+ link) ln -sf "$(canon_path "$creds")" "$d/.credentials.json" ;;
393
+ esac
394
+ chmod 600 "$d/.credentials.json" 2>/dev/null || true
395
+ fi
396
+ if [ -n "$tok" ]; then
397
+ ( umask 077; printf '%s' "$tok" > "$d/server.token" )
398
+ chmod 600 "$d/server.token"
399
+ fi
400
+ manifest_add_account "$id" "$email" "$home"
401
+ log_to ops.log "import $id $email home=$home creds=${creds:+yes} mode=$mode token=${token_file:+yes}"
402
+ echo "Imported $id ($email, home=$home)."
403
+ [ "$no_sync" = "1" ] || auto_sync
404
+ }
405
+
406
+ cmd_adopt() {
407
+ # Make <acct-NN> THIS machine's existing default login (~/.claude) without
408
+ # forking its OAuth grant: the account dir becomes a symlink to ~/.claude, so
409
+ # there is exactly one credential file no matter which path refreshes it.
410
+ require_manifest
411
+ local id="${1:-}"
412
+ [ -n "$id" ] || die "usage: claude-accounts adopt <acct-NN>"
413
+ valid_acct_id "$id" || die "not a valid account id: $id"
414
+ account_ids | grep -qx "$id" || die "unknown account: $id (import it first)"
415
+ local d="$ACC_ROOT/$id" default="$HOME/.claude"
416
+ [ -f "$default/.credentials.json" ] || die "no default login at $default to adopt"
417
+ # Seed the config-dir state file inside ~/.claude (CLAUDE_CONFIG_DIR mode reads
418
+ # <dir>/.claude.json, while default mode uses ~/.claude.json at HOME level).
419
+ if [ ! -f "$default/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
420
+ "$PYBIN" - "$HOME/.claude.json" "$default/.claude.json" <<'PYEOF'
421
+ import json, sys
422
+ doc = json.load(open(sys.argv[1]))
423
+ for k in ('oauthAccount', 'userID'):
424
+ doc.pop(k, None)
425
+ import os
426
+ with open(sys.argv[2] + '.tmp', 'w') as f:
427
+ json.dump(doc, f)
428
+ os.replace(sys.argv[2] + '.tmp', sys.argv[2])
429
+ PYEOF
430
+ fi
431
+ if [ -L "$d" ]; then
432
+ echo "$id already adopted ($(readlink "$d"))"
433
+ return 0
434
+ fi
435
+ if [ -d "$d" ]; then
436
+ [ -f "$d/.credentials.json" ] && die "$id already has its own credentials — refusing to replace with adopt"
437
+ rm -rf "${ACC_ROOT:?}/${id:?}"
438
+ fi
439
+ ln -s "$default" "$d"
440
+ log_to ops.log "adopt $id -> $default"
441
+ echo "$id now runs the default $default login (symlinked, single credential file)."
442
+ }
443
+
444
+ cmd_remove() {
445
+ require_manifest
446
+ local id="${1:-}" yes="${2:-}"
447
+ [ -n "$id" ] || die "usage: claude-accounts remove <acct-NN> [--yes]"
448
+ valid_acct_id "$id" || die "not a valid account id: $id"
449
+ account_ids | grep -qx "$id" || die "unknown account: $id"
450
+ if [ "$yes" != "--yes" ]; then
451
+ printf 'Remove %s and propagate deletion to the server? [y/N] ' "$id"
452
+ read -r ans
453
+ case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
454
+ fi
455
+ if [ -L "$ACC_ROOT/$id" ]; then
456
+ rm -f "${ACC_ROOT:?}/${id:?}" # adopted account: remove the symlink, never the target
457
+ else
458
+ rm -rf "${ACC_ROOT:?}/${id:?}"
459
+ fi
460
+ manifest_del_account "$id"
461
+ log_to ops.log "remove $id"
462
+ auto_sync
463
+ echo "Removed $id."
464
+ }
465
+
466
+ # Interactive sign-in ceremony: runs `claude setup-token` attached to the user's
467
+ # terminal — it prints a clickable sign-in link, the user completes OAuth in a
468
+ # browser and pastes the code back HERE, and the resulting long-lived token is
469
+ # captured. Sets CEREMONY_TOKEN on success (no stdout capture: the UI must stay
470
+ # visible and interactive).
471
+ # A subscription setup-token, and nothing else. API keys (sk-ant-api...) are rejected
472
+ # everywhere: this addon is subscription-only by design (spec requirement).
473
+ valid_subscription_token() {
474
+ case "$1" in
475
+ sk-ant-oat[0-9][0-9]-*) [ "${#1}" -ge 50 ] ;;
476
+ *) return 1 ;;
477
+ esac
478
+ }
479
+
480
+ CEREMONY_TOKEN=""
481
+ run_token_ceremony() { # $1 = config dir
482
+ CEREMONY_TOKEN=""
483
+ local d="$1" real cap old_umask
484
+ real="$(find_real_claude "$_self")" || { warn "real claude binary not found"; return 1; }
485
+ mkdir -p "$ACC_ROOT/tmp"
486
+ cap="$ACC_ROOT/tmp/mint.$$.log"
487
+ old_umask="$(umask)"
488
+ umask 077 # the capture file briefly holds the raw token (removed inline below).
489
+ # NB: no EXIT trap here — cmd_add owns the EXIT trap for its mutation lock, and a
490
+ # second EXIT trap would clobber it and leak the lock.
491
+ echo "A sign-in link will appear below — open it in a browser signed into the"
492
+ echo "RIGHT account, approve, then paste the code back here."
493
+ if [ -t 0 ]; then
494
+ if [ "$(machine_kind)" = "mac" ]; then
495
+ script -q "$cap" env CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token
496
+ else
497
+ script -q -c "CLAUDE_CONFIG_DIR='$d' CLAUDE_SHIM_ACTIVE=1 '$real' setup-token" "$cap"
498
+ fi
499
+ else
500
+ # Headless (tests / piped code): capture into the 0600 file only. Never tee the
501
+ # raw token to stdout — a redirected run would write the secret to a plain log.
502
+ CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token > "$cap" 2>&1
503
+ sed -E 's/sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]*/sk-ant-oat**-<redacted>/g' "$cap"
504
+ fi
505
+ umask "$old_umask"
506
+ CEREMONY_TOKEN="$(grep -aoE 'sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]{40,}' "$cap" | tail -1)"
507
+ rm -f "$cap"
508
+ [ -n "$CEREMONY_TOKEN" ] || return 1
509
+ valid_subscription_token "$CEREMONY_TOKEN" || {
510
+ CEREMONY_TOKEN=""
511
+ warn "captured credential is not a subscription setup-token"
512
+ return 1
513
+ }
514
+ }
515
+
516
+ token_email() { # $1 = token; prints the authenticated email (best effort)
517
+ local real
518
+ real="$(find_real_claude "$_self")" || { echo ""; return 0; }
519
+ CLAUDE_CODE_OAUTH_TOKEN="$1" CLAUDE_SHIM_ACTIVE=1 "$real" auth status 2>/dev/null | "$PYBIN" -c '
520
+ import json, sys
521
+ try:
522
+ doc = json.loads(sys.stdin.read())
523
+ print(doc.get("email", "") if doc.get("loggedIn") else "")
524
+ except Exception:
525
+ print("")'
526
+ }
527
+
528
+ cmd_mint() {
529
+ require_manifest
530
+ local id="${1:-}" paste="${2:-}"
531
+ [ -n "$id" ] || die "usage: claude-accounts mint <acct-NN> [--paste]"
532
+ valid_acct_id "$id" || die "not a valid account id: $id"
533
+ local d="$ACC_ROOT/$id"
534
+ [ -d "$d" ] || die "unknown account dir: $d"
535
+ local tok=""
536
+ if [ "$paste" = "--paste" ]; then
537
+ printf 'Paste the sk-ant-oat... token: '
538
+ read -r tok
539
+ tok="$(printf '%s' "$tok" | tr -d '[:space:]')"
540
+ else
541
+ echo "Running 'claude setup-token' for $id — approve in a browser signed into THIS account."
542
+ run_token_ceremony "$d" || die "no token captured — mint failed"
543
+ tok="$CEREMONY_TOKEN"
544
+ fi
545
+ [ -n "$tok" ] || die "no token captured — mint failed"
546
+ valid_subscription_token "$tok" \
547
+ || die "that is not a subscription setup-token (sk-ant-oat...). API keys are not supported."
548
+ ( umask 077; printf '%s' "$tok" > "$d/server.token" )
549
+ chmod 600 "$d/server.token"
550
+ log_to ops.log "mint $id"
551
+ echo "Token saved to $d/server.token"
552
+ auto_sync
553
+ }
554
+
555
+ cmd_login() {
556
+ # Complete (or refresh) auth for an EXISTING account: sign-in link -> paste
557
+ # code -> token saved. Verifies the authenticated email matches the manifest.
558
+ require_manifest
559
+ local id="${1:-}" force="${2:-}"
560
+ [ -n "$id" ] || die "usage: claude-accounts login <acct-NN> [--force]"
561
+ valid_acct_id "$id" || die "not a valid account id: $id"
562
+ account_ids | grep -qx "$id" || die "unknown account: $id"
563
+ local d="$ACC_ROOT/$id" email got
564
+ seed_account_dir "$d"
565
+ email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
566
+ import json, sys
567
+ for a in json.load(open(sys.argv[1])).get('accounts', []):
568
+ if a['id'] == sys.argv[2]:
569
+ print(a.get('email', ''))
570
+ break
571
+ PYEOF
572
+ )"
573
+ echo "Sign in as $email for $id."
574
+ run_token_ceremony "$d" || die "sign-in failed or aborted — nothing changed"
575
+ got="$(token_email "$CEREMONY_TOKEN")"
576
+ if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "--force" ]; then
577
+ die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
578
+ fi
579
+ printf '%s' "$CEREMONY_TOKEN" > "$d/server.token"
580
+ chmod 600 "$d/server.token"
581
+ log_to ops.log "login $id verified=${got:-unverified}"
582
+ echo "$id auth saved (token, works on Mac and server)."
583
+ echo "Optional, for auto-refreshing OAuth creds on this machine instead:"
584
+ echo " CLAUDE_ACCOUNT=$id claude # then /login"
585
+ auto_sync
586
+ }
587
+
588
+ cmd_limits() {
589
+ require_manifest
590
+ local quiet=0 force=0
591
+ while [ $# -gt 0 ]; do
592
+ case "$1" in
593
+ --quiet) quiet=1; shift ;;
594
+ --force) force=1; shift ;; # ignore freshness/backoff (manual override)
595
+ *) die "unknown option: $1" ;;
596
+ esac
597
+ done
598
+ local lock="$ACC_ROOT/.locks/limits"
599
+ mkdir -p "$ACC_ROOT/.locks"
600
+ if ! mkdir "$lock" 2>/dev/null; then
601
+ local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
602
+ if [ "$age" -lt 120 ]; then
603
+ [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
604
+ return 0
605
+ fi
606
+ rm -rf "$lock"
607
+ mkdir "$lock" 2>/dev/null || return 0
608
+ fi
609
+ # shellcheck disable=SC2064
610
+ trap "rm -rf '$lock'" EXIT
611
+ rotate_log limits.log
612
+ # Expired-bearer self-heal: on a quiet machine nothing refreshes OAuth creds,
613
+ # which would silently stall limits telemetry (fail-open keeps selection working,
614
+ # but data goes stale). `claude auth status` refreshes creds without inference.
615
+ local real="" d
616
+ real="$(find_real_claude "$_self" 2>/dev/null)" || real=""
617
+ if [ -n "$real" ]; then
618
+ for d in "$ACC_ROOT"/acct-*; do
619
+ [ -d "$d" ] || continue
620
+ [ -f "$d/.credentials.json" ] || continue
621
+ [ -s "$d/server.token" ] && continue
622
+ if "$PYBIN" -c '
623
+ import json, sys, time
624
+ c = json.load(open(sys.argv[1])).get("claudeAiOauth", {})
625
+ sys.exit(0 if c.get("expiresAt", 0) / 1000.0 <= time.time() + 60 else 1)' "$d/.credentials.json" 2>/dev/null; then
626
+ CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth status >/dev/null 2>&1 || true
627
+ fi
628
+ done
629
+ fi
630
+ # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
631
+ # never loosen it, or an account could sit at 95% and still be selected.
632
+ local threshold
633
+ threshold="$(manifest_get threshold 90)"
634
+ case "$threshold" in ''|*[!0-9]*) threshold=90 ;; esac
635
+ [ "$threshold" -gt 90 ] && threshold=90
636
+ [ "$threshold" -lt 1 ] && threshold=90
637
+ "$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
638
+ import json, os, sys, time, urllib.request
639
+
640
+ root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
641
+ now = time.time()
642
+ # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
643
+ MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '45'))
644
+
645
+ def say(msg):
646
+ if not quiet:
647
+ print(msg)
648
+ with open(os.path.join(root, 'limits.log'), 'a') as f:
649
+ f.write(time.strftime('%Y-%m-%dT%H:%M:%SZ ', time.gmtime()) + msg + '\n')
650
+
651
+ def parse_iso(s):
652
+ if not s:
653
+ return None
654
+ import datetime
655
+ try:
656
+ return datetime.datetime.fromisoformat(s.replace('Z', '+00:00')).timestamp()
657
+ except Exception:
658
+ return None
659
+
660
+ try:
661
+ manifest = json.load(open(os.path.join(root, 'accounts.json')))
662
+ except Exception as e:
663
+ sys.exit(f'cannot read manifest: {e}')
664
+
665
+ for acct in manifest.get('accounts', []):
666
+ aid = acct['id']
667
+ d = os.path.join(root, aid)
668
+ if not os.path.isdir(d):
669
+ continue
670
+ bearer = None
671
+ source = None
672
+ cpath = os.path.join(d, '.credentials.json')
673
+ if os.path.isfile(cpath):
674
+ try:
675
+ c = json.load(open(cpath)).get('claudeAiOauth', {})
676
+ if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
677
+ bearer, source = c['accessToken'], 'oauth'
678
+ except Exception:
679
+ pass
680
+ tpath = os.path.join(d, 'server.token')
681
+ if not bearer and os.path.isfile(tpath):
682
+ t = open(tpath).read().strip()
683
+ if t:
684
+ bearer, source = t, 'token'
685
+ if not bearer:
686
+ # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
687
+ say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
688
+ continue
689
+
690
+ # The usage endpoint rate-limits per account. Several callers can fire at once
691
+ # (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
692
+ # this account's data is already fresh, and honor any backoff a 429 set earlier.
693
+ lpath = os.path.join(d, 'limits.json')
694
+ prev = {}
695
+ if os.path.isfile(lpath):
696
+ try:
697
+ prev = json.load(open(lpath))
698
+ except Exception:
699
+ prev = {}
700
+ if not force:
701
+ age = now - prev.get('fetched_at', 0)
702
+ if age < MIN_FETCH_INTERVAL:
703
+ continue
704
+ retry_at = prev.get('retry_after', 0)
705
+ if retry_at > now:
706
+ say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
707
+ continue
708
+
709
+ req = urllib.request.Request(url, headers={
710
+ 'Authorization': 'Bearer ' + bearer,
711
+ 'anthropic-beta': 'oauth-2025-04-20',
712
+ 'Content-Type': 'application/json',
713
+ 'User-Agent': 'claude-multiacc/1.0',
714
+ })
715
+ try:
716
+ resp = urllib.request.urlopen(req, timeout=15)
717
+ data = json.loads(resp.read().decode())
718
+ except urllib.error.HTTPError as e:
719
+ if e.code == 429:
720
+ # Respect Retry-After; otherwise exponential backoff capped at 30 min.
721
+ try:
722
+ wait = int(e.headers.get('Retry-After') or 0)
723
+ except (TypeError, ValueError):
724
+ wait = 0
725
+ if wait <= 0:
726
+ wait = min(1800, max(120, int(prev.get('backoff', 60)) * 2))
727
+ prev['retry_after'] = int(now + wait)
728
+ prev['backoff'] = wait
729
+ tmp = lpath + '.tmp'
730
+ with open(tmp, 'w') as f:
731
+ json.dump(prev, f, indent=1)
732
+ os.replace(tmp, lpath)
733
+ say(f'{aid}: rate limited (429); backing off {wait}s; failing open')
734
+ else:
735
+ say(f'{aid}: usage fetch failed (HTTP {e.code}); failing open')
736
+ continue
737
+ except Exception as e:
738
+ say(f'{aid}: usage fetch failed ({e}); failing open')
739
+ continue
740
+
741
+ def pct_of(v):
742
+ try:
743
+ return max(0, min(100, int(round(float(v)))))
744
+ except (TypeError, ValueError):
745
+ return None
746
+
747
+ def classify_group(kind):
748
+ # session (5h, self-healing) vs weekly (multi-day, expensive) vs monthly.
749
+ # Unknown durable buckets default to 'weekly' so they are never under-weighted.
750
+ k = kind.lower()
751
+ if k.startswith('session') or k in ('five_hour', 'fivehour', '5h'):
752
+ return 'session'
753
+ if 'month' in k:
754
+ return 'monthly'
755
+ return 'weekly'
756
+
757
+ # Shape-agnostic bucket extraction: every entry in limits[] becomes a bucket
758
+ # named kind[:model]. If Anthropic drops the per-model (Fable) separation,
759
+ # renames kinds, or reshapes scope, whatever buckets remain are still tracked
760
+ # and the >=90% rule keeps working. Entries we cannot parse are skipped, and
761
+ # any per-account surprise degrades that account only (fail open), never the run.
762
+ buckets = []
763
+ try:
764
+ for lim in (data.get('limits') or []):
765
+ if not isinstance(lim, dict):
766
+ continue
767
+ pct = pct_of(lim.get('percent'))
768
+ if pct is None:
769
+ continue
770
+ kind = str(lim.get('kind') or 'unknown')
771
+ name = kind
772
+ scope = lim.get('scope') or {}
773
+ model = None
774
+ if isinstance(scope, dict):
775
+ m = scope.get('model')
776
+ if isinstance(m, dict):
777
+ model = m.get('display_name') or m.get('id')
778
+ if model:
779
+ name = f'{name}:{model}'
780
+ resets = lim.get('resets_at')
781
+ buckets.append({
782
+ 'name': name,
783
+ 'kind': kind,
784
+ 'group': str(lim.get('group') or classify_group(kind)),
785
+ 'percent': pct,
786
+ 'resets_at': resets,
787
+ 'resets_epoch': int(parse_iso(resets) or now + 3600),
788
+ })
789
+ if not buckets:
790
+ # Fallback for a payload with no limits[] array: scan EVERY top-level object
791
+ # carrying a utilization, so per-model buckets (seven_day_opus,
792
+ # seven_day_fable, ...) are picked up too — not just five_hour/seven_day.
793
+ # Missing one would let an exhausted model bucket go unnoticed.
794
+ for k, b in sorted(data.items()):
795
+ if not isinstance(b, dict) or k == 'extra_usage':
796
+ continue
797
+ pct = pct_of(b.get('utilization'))
798
+ if pct is None:
799
+ continue
800
+ buckets.append({
801
+ 'name': k,
802
+ 'kind': k,
803
+ 'group': classify_group(k),
804
+ 'percent': pct,
805
+ 'resets_at': b.get('resets_at'),
806
+ 'resets_epoch': int(parse_iso(b.get('resets_at')) or now + 3600),
807
+ })
808
+ except Exception as e:
809
+ say(f'{aid}: unexpected usage payload shape ({e}); failing open')
810
+ continue
811
+ # Successful fetch: fresh buckets replace everything, backoff state is dropped.
812
+ # THREE selection signals, per the documented reset asymmetry:
813
+ # max_percent — peak of ALL buckets; drives >=90% EXCLUSION (a full session
814
+ # bucket really does block, but its marker expires in ~5h).
815
+ # weekly_percent — peak of the durable (weekly/monthly) buckets; the PRIMARY
816
+ # ranking signal, because weekly headroom only returns on the
817
+ # account's fixed weekly reset (days away).
818
+ # session_percent— peak of the self-healing 5h bucket; a soft tiebreaker only.
819
+ maxp = max([b['percent'] for b in buckets] or [0])
820
+ weekly = [b['percent'] for b in buckets if b['group'] != 'session']
821
+ session = [b['percent'] for b in buckets if b['group'] == 'session']
822
+ weeklyp = max(weekly) if weekly else maxp
823
+ sessionp = max(session) if session else 0
824
+ out = {'fetched_at': int(now), 'source': source, 'max_percent': maxp,
825
+ 'weekly_percent': weeklyp, 'session_percent': sessionp, 'buckets': buckets}
826
+ tmp = lpath + '.tmp'
827
+ with open(tmp, 'w') as f:
828
+ json.dump(out, f, indent=1)
829
+ os.replace(tmp, lpath)
830
+ offenders = [b for b in buckets if b['percent'] >= threshold]
831
+ mpath = os.path.join(d, '.limited')
832
+ if offenders:
833
+ worst = max(offenders, key=lambda b: b['percent'])
834
+ reset_epoch = max(int(b['resets_epoch']) for b in offenders)
835
+ # Atomic: a concurrent shim must never read a half-written marker.
836
+ with open(mpath + '.tmp', 'w') as f:
837
+ f.write(f'{reset_epoch}\n')
838
+ f.write(f"bucket={worst['name']} percent={worst['percent']} "
839
+ f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
840
+ f"reason=limits resets_at={worst['resets_at']}\n")
841
+ os.replace(mpath + '.tmp', mpath)
842
+ say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
843
+ else:
844
+ if os.path.exists(mpath):
845
+ # A shim-written error-cooldown marker outlives a clean limits pass:
846
+ # the account failed a real call moments ago; give the cooldown its window.
847
+ keep = False
848
+ try:
849
+ txt = open(mpath).read()
850
+ first = txt.splitlines()[0] if txt else ''
851
+ if 'reason=error-cooldown' in txt and first.isdigit() and int(first) > now:
852
+ keep = True
853
+ except Exception:
854
+ pass
855
+ if not keep:
856
+ os.remove(mpath)
857
+ say(f'{aid}: marker cleared (max {maxp}%)')
858
+ if not quiet:
859
+ detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
860
+ print(f'{aid}: ok {detail}')
861
+ PYEOF
862
+ }
863
+
864
+ cmd_verify() {
865
+ require_manifest
866
+ local quick=0
867
+ [ "${1:-}" = "--quick" ] && quick=1
868
+ local real=""
869
+ if [ "$quick" = "0" ]; then
870
+ real="$(find_real_claude "$_self")" || die "real claude binary not found"
871
+ fi
872
+ "$PYBIN" - "$ACC_ROOT" "$quick" "$real" <<'PYEOF'
873
+ import json, os, subprocess, sys, time
874
+
875
+ root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
876
+ now = time.time()
877
+ manifest = json.load(open(os.path.join(root, 'accounts.json')))
878
+ failures = 0
879
+ tested = 0
880
+
881
+ for acct in manifest.get('accounts', []):
882
+ aid = acct['id']
883
+ d = os.path.join(root, aid)
884
+ cpath = os.path.join(d, '.credentials.json')
885
+ tpath = os.path.join(d, 'server.token')
886
+ has_creds = os.path.isfile(cpath)
887
+ has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
888
+ if not has_creds and not has_token:
889
+ print(f'{aid} {acct["email"]}: SKIP (no auth on this machine)')
890
+ continue
891
+ tested += 1
892
+ if has_creds:
893
+ try:
894
+ c = json.load(open(cpath)).get('claudeAiOauth', {})
895
+ rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
896
+ if rexp and rexp < now:
897
+ print(f'{aid} {acct["email"]}: FAIL (refresh token expired — re-login)')
898
+ failures += 1
899
+ continue
900
+ except Exception as e:
901
+ print(f'{aid} {acct["email"]}: FAIL (unreadable credentials: {e})')
902
+ failures += 1
903
+ continue
904
+ if quick:
905
+ kind = 'oauth' if has_creds else 'token'
906
+ print(f'{aid} {acct["email"]}: OK (quick, {kind} present)')
907
+ continue
908
+ env = dict(os.environ)
909
+ env['CLAUDE_CONFIG_DIR'] = d
910
+ env.pop('ANTHROPIC_API_KEY', None)
911
+ env.pop('CLAUDE_CODE_OAUTH_TOKEN', None)
912
+ env.pop('CLAUDE_ACCOUNT', None)
913
+ env['CLAUDE_SHIM_ACTIVE'] = '1'
914
+ if not has_creds:
915
+ env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
916
+ t0 = time.time()
917
+ try:
918
+ r = subprocess.run([real, '-p', 'Reply with exactly: OK'],
919
+ env=env, capture_output=True, text=True, timeout=240,
920
+ stdin=subprocess.DEVNULL, cwd=root)
921
+ except subprocess.TimeoutExpired:
922
+ print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
923
+ failures += 1
924
+ continue
925
+ dt = time.time() - t0
926
+ out = (r.stdout or '').strip()
927
+ if r.returncode == 0 and 'ok' in out.lower():
928
+ print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
929
+ else:
930
+ err = (r.stderr or '').strip()[:200]
931
+ print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} out={out[:120]!r} err={err!r}')
932
+ failures += 1
933
+
934
+ print()
935
+ print(f'verified {tested} account(s), {failures} failure(s)')
936
+ sys.exit(1 if failures else 0)
937
+ PYEOF
938
+ }
939
+
940
+ cmd_sync() {
941
+ require_manifest
942
+ [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
943
+ local allow_empty=0
944
+ [ "${1:-}" = "--allow-empty" ] && allow_empty=1
945
+ local server sroot srepo rc=0
946
+ server="$(manifest_get server "$DEFAULT_SERVER")"
947
+ sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
948
+ srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
949
+ # These land inside a remote shell command — anything but a plain target/path is a
950
+ # command-injection vector from a corrupted or hand-edited manifest.
951
+ valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
952
+ valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
953
+ valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
954
+ rotate_log sync.log
955
+ slog() { log_to sync.log "$*"; }
956
+ fail() { slog "FAIL: $*"; printf 'claude-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
957
+ slog "sync start -> $server:$sroot"
958
+
959
+ # Hard validation before anything destructive: a corrupt or accountless manifest must
960
+ # never be pushed (it would blank the server's pool), and must never make the removal
961
+ # loop below wipe the server's credentials. Emptying the pool on purpose is possible
962
+ # via `sync --allow-empty`, so this can never happen by accident.
963
+ "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
964
+ import json, re, sys
965
+ doc = json.load(open(sys.argv[1]))
966
+ accounts = doc.get('accounts')
967
+ if not isinstance(accounts, list):
968
+ sys.exit(1)
969
+ for a in accounts:
970
+ if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
971
+ or not str(a.get('email', '')).strip():
972
+ sys.exit(1)
973
+ PYEOF
974
+ if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
975
+ fail "manifest has zero accounts — refusing to blank the server pool (use 'sync --allow-empty' if that is really intended)"
976
+ fi
977
+
978
+ ssh -o BatchMode=yes -o ConnectTimeout=10 "$server" "mkdir -p '$sroot'" >>"$ACC_ROOT/sync.log" 2>&1 \
979
+ || fail "cannot reach $server"
980
+ rsync -az "$MANIFEST" "$server:$sroot/accounts.json" >>"$ACC_ROOT/sync.log" 2>&1 \
981
+ || fail "manifest push failed"
982
+
983
+ local id d
984
+ for id in $(account_ids); do
985
+ d="$ACC_ROOT/$id"
986
+ [ -d "$d" ] || continue
987
+ ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
988
+ || fail "mkdir $id failed"
989
+ if [ -s "$d/server.token" ]; then
990
+ rsync -az --chmod=F600 "$d/server.token" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
991
+ || fail "token push for $id failed"
992
+ fi
993
+ local seed
994
+ for seed in .claude.json settings.json; do
995
+ if [ -f "$d/$seed" ]; then
996
+ rsync -az --ignore-existing "$d/$seed" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
997
+ || fail "seed push for $id/$seed failed"
998
+ fi
999
+ done
1000
+ # Advisory limit state for accounts the server may lack a bearer for.
1001
+ local extra
1002
+ for extra in limits.json .limited; do
1003
+ if [ -f "$d/$extra" ]; then
1004
+ rsync -az "$d/$extra" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 || true
1005
+ fi
1006
+ done
1007
+ done
1008
+
1009
+ # Removal propagation: any server acct dir not in the manifest gets deleted.
1010
+ # Safety: an EMPTY id list (parse error, or a truly emptied pool) never deletes —
1011
+ # wiping every server credential must be an explicit manual act, not a side effect.
1012
+ local ids_list ids_spaced
1013
+ ids_list="$(account_ids)"
1014
+ if [ -z "$ids_list" ]; then
1015
+ slog "removal propagation skipped: empty account list (safety guard)"
1016
+ else
1017
+ ids_spaced=" $(printf '%s' "$ids_list" | tr '\n' ' ') "
1018
+ ssh -o BatchMode=yes "$server" "cd '$sroot' 2>/dev/null || exit 0
1019
+ for dd in acct-*; do
1020
+ [ -d \"\$dd\" ] || continue
1021
+ case '$ids_spaced' in
1022
+ *\" \$dd \"*) ;;
1023
+ *) rm -rf -- \"\$dd\" ;;
1024
+ esac
1025
+ done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation failed"
1026
+ fi
1027
+
1028
+ # Post-sync hook: server seeds dirs and re-runs its quick verification matrix.
1029
+ ssh -o BatchMode=yes "$server" "[ -x '$srepo/bin/claude-accounts' ] && '$srepo/bin/claude-accounts' post-sync" \
1030
+ >>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook not available yet (server not bootstrapped?)"
1031
+
1032
+ slog "sync ok"
1033
+ echo "sync ok -> $server:$sroot"
1034
+ return $rc
1035
+ }
1036
+
1037
+ cmd_post_sync() {
1038
+ require_manifest
1039
+ local id d
1040
+ for id in $(account_ids); do
1041
+ d="$ACC_ROOT/$id"
1042
+ seed_account_dir "$d"
1043
+ [ -f "$d/server.token" ] && chmod 600 "$d/server.token" 2>/dev/null
1044
+ [ -f "$d/.credentials.json" ] && chmod 600 "$d/.credentials.json" 2>/dev/null
1045
+ done
1046
+ log_to sync.log "post-sync: seeded $(account_ids | wc -l | tr -d ' ') account dirs"
1047
+ ( cmd_limits --quiet ) || true # subshell: release the limits lock before verify
1048
+ cmd_verify --quick
1049
+ }
1050
+
1051
+ cmd_self_update() {
1052
+ # Update the addon in place. npm global install => npm i -g @latest (its postinstall
1053
+ # re-runs install.sh). git checkout => git pull + ./install.sh. Anything else is a
1054
+ # no-op with a hint. Best-effort and fully logged; never disrupts a running claude.
1055
+ local quiet=0
1056
+ [ "${1:-}" = "--quiet" ] && quiet=1
1057
+ rotate_log update.log
1058
+ ulog() { log_to update.log "$*"; [ "$quiet" = "1" ] || echo "$*"; }
1059
+ case "$REPO_DIR" in
1060
+ */node_modules/claude-multiacc|*/node_modules/claude-multiacc/*)
1061
+ command -v npm >/dev/null 2>&1 || { ulog "self-update: npm not found; skipping"; return 0; }
1062
+ local cur lat
1063
+ cur="$(npm ls -g --depth=0 claude-multiacc 2>/dev/null | sed -n 's/.*claude-multiacc@//p' | head -1)"
1064
+ lat="$(npm view claude-multiacc version 2>/dev/null)"
1065
+ if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
1066
+ ulog "self-update: already latest ($cur)"
1067
+ return 0
1068
+ fi
1069
+ ulog "self-update: npm $cur -> ${lat:-latest}"
1070
+ if npm install -g claude-multiacc@latest >>"$ACC_ROOT/update.log" 2>&1; then
1071
+ ulog "self-update: npm update ok"
1072
+ else
1073
+ ulog "self-update: npm update FAILED (see update.log)"
1074
+ return 1
1075
+ fi
1076
+ ;;
1077
+ *)
1078
+ if [ -d "$REPO_DIR/.git" ] && command -v git >/dev/null 2>&1; then
1079
+ ulog "self-update: git pull in $REPO_DIR"
1080
+ if git -C "$REPO_DIR" pull --ff-only >>"$ACC_ROOT/update.log" 2>&1; then
1081
+ "$REPO_DIR/install.sh" >>"$ACC_ROOT/update.log" 2>&1 \
1082
+ && ulog "self-update: git update + reinstall ok" \
1083
+ || { ulog "self-update: reinstall FAILED"; return 1; }
1084
+ else
1085
+ ulog "self-update: git pull FAILED (local changes? see update.log)"
1086
+ return 1
1087
+ fi
1088
+ else
1089
+ ulog "self-update: not an npm or git install ($REPO_DIR) — update manually"
1090
+ fi
1091
+ ;;
1092
+ esac
1093
+ }
1094
+
1095
+ cmd_health() {
1096
+ require_manifest
1097
+ rotate_log health.log
1098
+ local out rc=0
1099
+ # verify first: its real claude runs refresh any expired OAuth creds, so the
1100
+ # limits pass that follows always has fresh bearers.
1101
+ out="$( { echo "== verify =="; cmd_verify; echo; echo "== limits =="; cmd_limits; } 2>&1 )" || rc=1
1102
+ printf '%s\n' "$out"
1103
+ printf '%s health rc=%s\n%s\n' "$(ts_utc)" "$rc" "$out" >> "$ACC_ROOT/health.log"
1104
+ if [ "$rc" -ne 0 ] && [ "$(machine_kind)" = "mac" ]; then
1105
+ osascript -e 'display notification "claude-multiacc health check FAILED — run claude-accounts status" with title "claude-multiacc"' 2>/dev/null || true
1106
+ fi
1107
+ return $rc
1108
+ }
1109
+
1110
+ case "${1:-help}" in
1111
+ list) shift; cmd_list "$@" ;;
1112
+ status) shift; cmd_status "$@" ;;
1113
+ add) shift; cmd_add "$@" ;;
1114
+ import) shift; cmd_import "$@" ;;
1115
+ adopt) shift; cmd_adopt "$@" ;;
1116
+ remove) shift; cmd_remove "$@" ;;
1117
+ mint) shift; cmd_mint "$@" ;;
1118
+ login) shift; cmd_login "$@" ;;
1119
+ sync) shift; cmd_sync "$@" ;;
1120
+ verify) shift; cmd_verify "$@" ;;
1121
+ limits) shift; cmd_limits "$@" ;;
1122
+ post-sync) shift; cmd_post_sync "$@" ;;
1123
+ health) shift; cmd_health "$@" ;;
1124
+ self-update) shift; cmd_self_update "$@" ;;
1125
+ help|--help|-h) usage ;;
1126
+ *) usage; exit 1 ;;
1127
+ esac