claude-multiacc 2.0.2 → 2.0.4

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.
@@ -28,7 +28,8 @@ USAGE
28
28
  login-FIRST: runs the full Claude Code login (`claude auth login` — the normal
29
29
  browser sign-in, no long-lived-token step-up), then registers only after the
30
30
  signed-in email is read back. Email is OPTIONAL (derived from the sign-in).
31
- Stores auto-refreshing .credentials.json valid on THIS machine. Duplicates refused.
31
+ Stores an auto-refreshing login valid on THIS machine (.credentials.json, or
32
+ the macOS Keychain when the session can open it). Duplicates refused.
32
33
  --token instead mints a portable setup-token (works on Mac AND server; needs a
33
34
  recent sign-in — use it when you want the account usable on the server too).
34
35
  claude-accounts login <acct-NN> [--token] [--force]
@@ -215,12 +216,14 @@ if not accounts:
215
216
  for a in accounts:
216
217
  d = os.path.join(root, a['id'])
217
218
  auth = []
218
- if os.path.isfile(os.path.join(d, '.credentials.json')):
219
+ st = audit_account(root, a, machine=machine)
220
+ if st.get('store') == 'file':
219
221
  auth.append('oauth')
222
+ elif st.get('store') == 'keychain':
223
+ auth.append('keychain' if st['state'] != 'locked' else 'keychain(locked)')
220
224
  if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
221
225
  auth.append('token')
222
226
  limited = os.path.isfile(os.path.join(d, '.limited'))
223
- st = audit_account(root, a, machine=machine)
224
227
  flags = []
225
228
  if st['state'] == 'expired':
226
229
  flags.append('EXPIRED-LOGIN')
@@ -228,6 +231,8 @@ for a in accounts:
228
231
  flags.append('ORG-BLOCKED')
229
232
  elif st['state'] == 'missing':
230
233
  flags.append('NO-LOGIN')
234
+ elif st['state'] == 'locked':
235
+ flags.append('KEYCHAIN-LOCKED-HERE')
231
236
  if limited:
232
237
  flags.append('LIMITED')
233
238
  print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} "
@@ -264,7 +269,7 @@ import json, os, sys, time
264
269
  doc = json.load(open(sys.argv[1]))
265
270
  root = sys.argv[2]
266
271
  sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
267
- from audit import audit_account # noqa: E402 (shared with the shim's rule)
272
+ from audit import audit_account, oauth_login # noqa: E402 (shared with the shim's rule)
268
273
  machine = sys.argv[4]
269
274
  now = time.time()
270
275
  # Must match the shim's window (bin/claude), or status would call data "fresh" that
@@ -320,20 +325,25 @@ for a in doc.get('accounts', []):
320
325
  'missing': ' ** NO LOGIN — claude-accounts relogin %s **' % aid,
321
326
  'expired': ' ** LOGIN EXPIRED — claude-accounts relogin %s **' % aid,
322
327
  'blocked': ' ** ORG BLOCKED — Claude Code disabled for this account **',
328
+ 'locked': ' [login is in the macOS Keychain — locked for THIS session, fine from the Mac itself]',
323
329
  }[st['state']]
324
330
  print(f"{aid} {a['email']} [home={a.get('home','?')}]{banner}")
325
331
  print(f" selectable : {'yes' if st['state'] == 'ok' else 'NO — ' + st['reason']}")
326
332
  cpath = os.path.join(d, '.credentials.json')
327
- if os.path.isfile(cpath):
333
+ login = oauth_login(d, now)
334
+ if login['store'] == 'file' or login['doc'] is not None:
328
335
  try:
329
- c = json.load(open(cpath)).get('claudeAiOauth', {})
336
+ c = (login['doc'] or json.load(open(cpath))).get('claudeAiOauth', {})
330
337
  exp = c.get('expiresAt', 0) / 1000.0
331
338
  rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
332
339
  state = 'fresh' if exp > now else 'stale (auto-refreshes on use)'
333
340
  rstate = 'ok' if rexp > now else 'EXPIRED — re-login needed'
334
- print(f" oauth creds : {state}; refresh token {rstate} (until {time.strftime('%Y-%m-%d', time.gmtime(rexp)) if rexp else '?'})")
341
+ where = 'macOS Keychain' if login['store'] == 'keychain' else '.credentials.json'
342
+ print(f" oauth creds : {state}; refresh token {rstate} (until {time.strftime('%Y-%m-%d', time.gmtime(rexp)) if rexp else '?'}) [{where}]")
335
343
  except Exception as e:
336
344
  print(f" oauth creds : unreadable ({e})")
345
+ elif login['store'] == 'keychain':
346
+ print(f" oauth creds : in the macOS Keychain — {login['reason']}")
337
347
  else:
338
348
  print(" oauth creds : none on this machine")
339
349
  tpath = os.path.join(d, 'server.token')
@@ -499,7 +509,8 @@ cmd_add() {
499
509
  got="$(token_email "$CEREMONY_TOKEN")"
500
510
  else
501
511
  # Default: full Claude Code login (full scopes, no long-lived-token step-up).
502
- # Writes auto-refreshing .credentials.json, valid on THIS machine.
512
+ # Leaves an auto-refreshing login valid on THIS machine.credentials.json, or
513
+ # the macOS Keychain when this session can open it (see lib/keychain.py).
503
514
  echo "Preparing $id for $elabel — NOTHING is registered until login completes."
504
515
  run_login_ceremony "$d" "$email" || die "login failed or aborted — nothing was created"
505
516
  got="$(config_dir_email "$d")"
@@ -627,16 +638,25 @@ cmd_import() {
627
638
  # then the lowest id) and list the rest. Empty output => pool is already clean.
628
639
  dup_ids_to_remove() {
629
640
  [ -f "$MANIFEST" ] || return 0
630
- "$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF' 2>/dev/null
641
+ "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" <<'PYEOF' 2>/dev/null
631
642
  import json, os, re, sys
632
643
  manifest, root = sys.argv[1], sys.argv[2]
644
+ sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
645
+ import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
633
646
  accts = [a for a in json.load(open(manifest)).get('accounts', [])
634
647
  if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
635
648
  def has_auth(aid):
636
649
  d = os.path.join(root, aid)
637
650
  c = os.path.join(d, '.credentials.json')
638
651
  t = os.path.join(d, 'server.token')
639
- return (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0)
652
+ if (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0):
653
+ return True
654
+ # A login the client moved into the macOS Keychain still counts — deduping must
655
+ # not throw away the one duplicate that actually holds the grant.
656
+ try:
657
+ return keychain.probe(d)['state'] in ('present', 'locked', 'corrupt')
658
+ except Exception:
659
+ return False
640
660
  by_email = {}
641
661
  for a in accts:
642
662
  by_email.setdefault(a.get('email', '').lower(), []).append(a['id'])
@@ -678,7 +698,13 @@ PYEOF
678
698
  case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
679
699
  fi
680
700
  for id in $dups; do
681
- if [ -L "$ACC_ROOT/$id" ]; then rm -f "${ACC_ROOT:?}/${id:?}"; else rm -rf "${ACC_ROOT:?}/${id:?}"; fi
701
+ if [ -L "$ACC_ROOT/$id" ]; then
702
+ rm -f "${ACC_ROOT:?}/${id:?}"
703
+ else
704
+ # Same rule as cmd_remove: a removed dir's Keychain login must go with it.
705
+ keychain_forget "$ACC_ROOT/$id"
706
+ rm -rf "${ACC_ROOT:?}/${id:?}"
707
+ fi
682
708
  manifest_del_account "$id"
683
709
  log_to ops.log "dedupe removed $id"
684
710
  done
@@ -738,6 +764,10 @@ cmd_remove() {
738
764
  if [ -L "$ACC_ROOT/$id" ]; then
739
765
  rm -f "${ACC_ROOT:?}/${id:?}" # adopted account: remove the symlink, never the target
740
766
  else
767
+ # The client may hold this dir's login in the macOS Keychain (keyed by the dir
768
+ # path): drop it with the dir, or a removed account leaves a live grant behind
769
+ # that the next account to reuse this id would silently inherit.
770
+ keychain_forget "$ACC_ROOT/$id"
741
771
  rm -rf "${ACC_ROOT:?}/${id:?}"
742
772
  fi
743
773
  manifest_del_account "$id"
@@ -847,8 +877,10 @@ except Exception:
847
877
 
848
878
  # Full subscription login: `claude auth login --claudeai`, the SAME flow the
849
879
  # interactive app uses (claude.com/cai/oauth/authorize, full Claude Code scopes,
850
- # normal sign-in — no long-lived-token step-up wall). Writes <dir>/.credentials.json
851
- # (auto-refreshing). Returns success iff the credential landed. $2 = optional email hint.
880
+ # normal sign-in — no long-lived-token step-up wall). Leaves an auto-refreshing login
881
+ # in <dir>/.credentials.json or, on macOS from a session that can open the login
882
+ # Keychain, in a Keychain item (lib/keychain.py). Returns success iff a credential
883
+ # that can authenticate landed in either place. $2 = optional email hint.
852
884
  run_login_ceremony() { # $1 = config dir, $2 = email hint
853
885
  local d="$1" hint="${2:-}" real
854
886
  real="$(find_real_claude "$_self")" || { warn "real claude binary not found"; return 1; }
@@ -859,19 +891,21 @@ paste it after the "Paste code here" prompt and press Enter. This is the normal
859
891
  login — it draws on the subscription, no API keys, and refreshes itself over time.
860
892
  TIP
861
893
  # Run claude auth login DIRECTLY on the user's terminal (no `script` PTY wrapper):
862
- # we capture nothing here (the credential is written to .credentials.json), and a
863
- # PTY layer can suppress the echo of the pasted code. Direct invocation behaves
894
+ # we capture nothing here (the client stores the credential itself), and a PTY
895
+ # layer can suppress the echo of the pasted code. Direct invocation behaves
864
896
  # exactly like running `claude auth login` yourself, so the code you paste is shown.
865
897
  if [ -n "$hint" ]; then
866
898
  CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai --email "$hint" || true
867
899
  else
868
900
  CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai || true
869
901
  fi
870
- # Success is a credential that can AUTHENTICATE — not merely a file that exists.
871
- # Re-login targets already have a (dead) .credentials.json on disk, so a bare
872
- # existence test would call an aborted sign-in a success, clear the dead-auth
873
- # marker, and hand the account straight back to the pool.
874
- [ -f "$d/.credentials.json" ] || return 1
902
+ # Success is a credential that can AUTHENTICATE — not merely one that exists.
903
+ # Re-login targets already have a (dead) credential in place, so a bare existence
904
+ # test would call an aborted sign-in a success, clear the dead-auth marker, and
905
+ # hand the account straight back to the pool. creds_alive looks in both places
906
+ # the client writes to (.credentials.json, macOS Keychain): demanding the FILE
907
+ # here turned every Keychain-backed sign-in into "login failed", deleted the
908
+ # reserved dir, and left the fresh grant orphaned in the Keychain.
875
909
  creds_alive "$d"
876
910
  }
877
911
 
@@ -930,8 +964,8 @@ cmd_mint() {
930
964
 
931
965
  cmd_login() {
932
966
  # Complete (or refresh) auth for an EXISTING account. Default: full Claude Code login
933
- # (auto-refreshing .credentials.json, this machine). --token: portable setup-token
934
- # (Mac + server). Verifies the signed-in email matches the manifest.
967
+ # (auto-refreshing, this machine — .credentials.json or the macOS Keychain). --token:
968
+ # portable setup-token (Mac + server). Verifies the signed-in email matches the manifest.
935
969
  require_manifest
936
970
  local id="" force=0 token=0
937
971
  while [ $# -gt 0 ]; do
@@ -971,7 +1005,11 @@ cmd_login() {
971
1005
  die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
972
1006
  fi
973
1007
  clear_auth_markers "$d"
974
- echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
1008
+ if [ -s "$d/.credentials.json" ]; then
1009
+ echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
1010
+ else
1011
+ echo "$id login saved (macOS Keychain, this machine, auto-refreshing — sessions without keychain access, e.g. ssh, cannot use it)."
1012
+ fi
975
1013
  fi
976
1014
  log_to ops.log "login $id verified=${got:-unverified} mode=$([ "$token" = 1 ] && echo token || echo login)"
977
1015
  auto_sync
@@ -1231,8 +1269,10 @@ cmd_limits() {
1231
1269
  case "$threshold" in ''|*[!0-9]*) threshold=90 ;; esac
1232
1270
  [ "$threshold" -gt 90 ] && threshold=90
1233
1271
  [ "$threshold" -lt 1 ] && threshold=90
1234
- "$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
1272
+ "$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" "$LIB_DIR" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
1235
1273
  import hashlib, json, os, sys, time, urllib.request
1274
+ sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
1275
+ import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
1236
1276
 
1237
1277
  root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
1238
1278
  now = time.time()
@@ -1354,15 +1394,72 @@ def park_dead_grant(aid, d, slug, detail):
1354
1394
  say(f'{aid}: {detail} — re-login needed (claude-accounts relogin {aid})')
1355
1395
 
1356
1396
 
1357
- def refresh_oauth(aid, d, cpath):
1397
+ class FileStore:
1398
+ """The OAuth credential as `.credentials.json`: atomic 0600 writes."""
1399
+ kind = 'file'
1400
+
1401
+ def __init__(self, path):
1402
+ self.path = path
1403
+
1404
+ def read(self):
1405
+ return json.load(open(self.path))
1406
+
1407
+ def write(self, doc):
1408
+ fd = os.open(self.path + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
1409
+ with os.fdopen(fd, 'w') as f:
1410
+ json.dump(doc, f)
1411
+ f.flush()
1412
+ os.fsync(f.fileno())
1413
+ os.replace(self.path + '.tmp', self.path)
1414
+
1415
+
1416
+ class KeychainStore:
1417
+ """The same credential, held in the macOS Keychain by the client itself (see
1418
+ lib/keychain.py). Read and written in place — NEVER copied out to a file: a file
1419
+ beside a Keychain item is a second copy of a ROTATING refresh grant, and the
1420
+ client reads the Keychain first, so the two would drift apart and strand one."""
1421
+ kind = 'keychain'
1422
+
1423
+ def __init__(self, d, probe):
1424
+ self.d = d
1425
+ self.account = probe.get('account')
1426
+
1427
+ def read(self):
1428
+ p = keychain.probe(self.d)
1429
+ if p['state'] != 'present':
1430
+ raise ValueError(f'keychain credential {p["state"]}')
1431
+ return p['doc']
1432
+
1433
+ def write(self, doc):
1434
+ if not keychain.write(self.d, doc, account=self.account):
1435
+ raise OSError('keychain write refused')
1436
+
1437
+
1438
+ def oauth_store(d):
1439
+ """Where <d>'s OAuth login lives for THIS process: a FileStore, a KeychainStore,
1440
+ the string 'locked' (Keychain item exists but this session cannot open it), or
1441
+ None. The file wins when both exist — same rule as lib/audit.oauth_login."""
1442
+ cpath = os.path.join(d, '.credentials.json')
1443
+ if os.path.isfile(cpath):
1444
+ return FileStore(cpath)
1445
+ p = keychain.probe(d)
1446
+ if p['state'] == 'present':
1447
+ return KeychainStore(d, p)
1448
+ if p['state'] == 'locked':
1449
+ return 'locked'
1450
+ return None
1451
+
1452
+
1453
+ def refresh_oauth(aid, d, store):
1358
1454
  """Refresh a long-expired OAuth access token via the refresh-token grant and
1359
- persist the ROTATED credential atomically (0600). Returns the new bearer, or
1360
- None (fail open: the on-disk credential is never touched on failure).
1361
- Failures back off via <dir>/.oauth-refresh.json — a side file, NOT limits.json,
1362
- because telemetry state must only ever reflect real usage fetches."""
1455
+ persist the ROTATED credential atomically (0600 file, or the Keychain item it
1456
+ came from). Returns the new bearer, or None (fail open: the stored credential
1457
+ is never touched on failure). Failures back off via <dir>/.oauth-refresh.json —
1458
+ a side file, NOT limits.json, because telemetry state must only ever reflect
1459
+ real usage fetches."""
1363
1460
  spath = os.path.join(d, '.oauth-refresh.json')
1364
1461
  try:
1365
- doc = json.load(open(cpath))
1462
+ doc = store.read()
1366
1463
  o = doc.get('claudeAiOauth', {})
1367
1464
  # Present-but-null/non-object claudeAiOauth (interrupted or reset credential
1368
1465
  # write) must degrade THIS account only, like every other malformed input.
@@ -1472,21 +1569,15 @@ def refresh_oauth(aid, d, cpath):
1472
1569
  # on-disk refresh token is no longer the one this grant was issued against, the
1473
1570
  # other writer won: keep its result, discard ours.
1474
1571
  try:
1475
- with open(cpath) as f:
1476
- disk = json.load(f).get('claudeAiOauth', {})
1572
+ disk = store.read().get('claudeAiOauth', {})
1477
1573
  if isinstance(disk, dict) and disk.get('refreshToken') != started_with:
1478
1574
  say(f'{aid}: credential was refreshed by something else mid-flight — '
1479
- f'keeping the newer one on disk')
1575
+ f'keeping the newer one in the {store.kind}')
1480
1576
  return None
1481
1577
  except Exception:
1482
1578
  pass
1483
1579
  try:
1484
- fd = os.open(cpath + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
1485
- with os.fdopen(fd, 'w') as f:
1486
- json.dump(doc, f)
1487
- f.flush()
1488
- os.fsync(f.fileno())
1489
- os.replace(cpath + '.tmp', cpath)
1580
+ store.write(doc)
1490
1581
  except Exception as e:
1491
1582
  say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
1492
1583
  return None
@@ -1552,20 +1643,30 @@ for acct in manifest.get('accounts', []):
1552
1643
  # it. OAuth first, refresh second, token only as a genuine last resort.
1553
1644
  bearer = None
1554
1645
  source = None
1555
- cpath = os.path.join(d, '.credentials.json')
1556
1646
  tpath = os.path.join(d, 'server.token')
1557
- if os.path.isfile(cpath):
1647
+ # The login may sit in .credentials.json or in the macOS Keychain (the client
1648
+ # moves it there from any session that can open the keychain — a launchd agent
1649
+ # like the one running this probe can, an ssh session cannot).
1650
+ try:
1651
+ store = oauth_store(d)
1652
+ except Exception as e:
1653
+ say(f'{aid}: could not locate the oauth credential ({str(e)[:120]}); failing open')
1654
+ store = None
1655
+ locked = store == 'locked'
1656
+ if locked:
1657
+ store = None
1658
+ if store is not None:
1558
1659
  try:
1559
- c = json.load(open(cpath)).get('claudeAiOauth', {})
1660
+ c = store.read().get('claudeAiOauth', {})
1560
1661
  if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
1561
1662
  bearer, source = c['accessToken'], 'oauth'
1562
1663
  except Exception:
1563
1664
  pass
1564
- if not bearer and os.path.isfile(cpath):
1665
+ if not bearer and store is not None:
1565
1666
  # Hard fail-open guard: NOTHING a single account's refresh does may abort
1566
1667
  # the loop — every account after it would silently starve of telemetry.
1567
1668
  try:
1568
- tok = refresh_oauth(aid, d, cpath)
1669
+ tok = refresh_oauth(aid, d, store)
1569
1670
  except Exception as e:
1570
1671
  say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
1571
1672
  tok = None
@@ -1592,7 +1693,12 @@ for acct in manifest.get('accounts', []):
1592
1693
  bearer, source = t, 'token'
1593
1694
  if not bearer:
1594
1695
  # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
1595
- say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
1696
+ if locked:
1697
+ say(f'{aid}: oauth login is in the macOS Keychain, which this session cannot '
1698
+ f'open (ssh/background) — its telemetry comes from the Mac\'s own launchd '
1699
+ f'probe; limits left as-is')
1700
+ else:
1701
+ say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
1596
1702
  continue
1597
1703
 
1598
1704
  req = urllib.request.Request(url, headers={
@@ -1870,7 +1976,8 @@ import json, os, re, subprocess, sys, time
1870
1976
 
1871
1977
  root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
1872
1978
  sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
1873
- from audit import audit_account, creds_state # noqa: E402 (shared with the shim's rule)
1979
+ from audit import audit_account, creds_state, creds_doc_state # noqa: E402 (shared with the shim's rule)
1980
+ import keychain # noqa: E402
1874
1981
  machine = sys.argv[5]
1875
1982
  now = time.time()
1876
1983
  manifest = json.load(open(os.path.join(root, 'accounts.json')))
@@ -1914,13 +2021,28 @@ for acct in manifest.get('accounts', []):
1914
2021
  tpath = os.path.join(d, 'server.token')
1915
2022
  has_creds = os.path.isfile(cpath)
1916
2023
  has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
2024
+ cred_doc = None
2025
+ cred_state = None
2026
+ if not has_creds:
2027
+ # The login may be in the macOS Keychain (lib/keychain.py). A Keychain this
2028
+ # session cannot open is not a failure of the ACCOUNT: the real call below
2029
+ # would fail for the session, not the grant, so it is a skip with a reason.
2030
+ kc = keychain.probe(d)
2031
+ if kc['state'] == 'present':
2032
+ has_creds, cred_doc = True, kc['doc']
2033
+ elif kc['state'] == 'locked' and not has_token:
2034
+ print(f'{aid} {acct["email"]}: SKIP (login is in the macOS Keychain, locked '
2035
+ f'for this session — run verify from the Mac\'s own session)')
2036
+ continue
1917
2037
  if not has_creds and not has_token:
1918
2038
  print(f'{aid} {acct["email"]}: SKIP (no auth on this machine)')
1919
2039
  continue
1920
2040
  tested += 1
1921
2041
  if has_creds:
1922
2042
  try:
1923
- c = json.load(open(cpath)).get('claudeAiOauth', {})
2043
+ c = (cred_doc or json.load(open(cpath))).get('claudeAiOauth', {})
2044
+ cred_state = creds_doc_state(cred_doc, now)[0] if cred_doc is not None \
2045
+ else creds_state(cpath, now)[0]
1924
2046
  rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
1925
2047
  if rexp and rexp < now and not has_token:
1926
2048
  mark_expired(d, 'refresh-token-expired',
@@ -1954,7 +2076,7 @@ for acct in manifest.get('accounts', []):
1954
2076
  # Mirror the shim's acct_token(): a portable token is what actually authenticates
1955
2077
  # whenever there is no credential OR the credential beside it is dead. Testing such
1956
2078
  # an account with the dead credential would fail it — and park a healthy account.
1957
- if has_token and (not has_creds or creds_state(cpath, now)[0] != 'ok'):
2079
+ if has_token and (not has_creds or cred_state != 'ok'):
1958
2080
  env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
1959
2081
  t0 = time.time()
1960
2082
  try:
@@ -2208,23 +2330,27 @@ cmd_post_sync() {
2208
2330
 
2209
2331
  # Version of the copy at $1, read from its package.json on disk — the only answer
2210
2332
  # that describes what will actually execute.
2211
- pkg_version_at() {
2212
- [ -f "$1/package.json" ] || return 0
2213
- sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1/package.json" | head -1
2214
- }
2215
-
2216
2333
  cmd_self_update() {
2217
2334
  # Update the addon in place. npm global install => npm i -g @latest (its postinstall
2218
2335
  # re-runs install.sh). git checkout => git pull + ./install.sh. Anything else is a
2219
2336
  # no-op with a hint. Best-effort and fully logged; never disrupts a running claude.
2220
2337
  local quiet=0
2221
2338
  [ "${1:-}" = "--quiet" ] && quiet=1
2339
+ # The update log is appended to with a plain redirect below, so the pool root has to
2340
+ # exist: on a machine where this provider's pool was never initialised the redirect
2341
+ # itself failed, and the update was reported as "npm update FAILED" with nothing in
2342
+ # the log to say why.
2343
+ mkdir -p "$ACC_ROOT" 2>/dev/null || true
2222
2344
  rotate_log update.log
2223
2345
  ulog() { log_to update.log "$*"; [ "$quiet" = "1" ] || echo "$*"; }
2224
2346
  case "$REPO_DIR" in
2225
2347
  */node_modules/claude-multiacc|*/node_modules/claude-multiacc/*)
2226
- command -v npm >/dev/null 2>&1 || { ulog "self-update: npm not found; skipping"; return 0; }
2227
- local cur lat prefix after
2348
+ local cur lat prefix after npm_bin
2349
+ prefix="${REPO_DIR%/lib/node_modules/*}"
2350
+ npm_bin="$(find_npm "$prefix")" || {
2351
+ ulog "self-update: no npm found (looked in $prefix/bin, /opt/homebrew/bin, /usr/local/bin, /usr/bin, PATH); skipping"
2352
+ return 0
2353
+ }
2228
2354
  # Update THE COPY THAT IS RUNNING, not whichever one the ambient npm prefix
2229
2355
  # happens to point at. my-mini had two global installs — homebrew's on PATH and
2230
2356
  # nvm's under `npm root -g` — and self-update kept upgrading the nvm one and
@@ -2232,15 +2358,14 @@ cmd_self_update() {
2232
2358
  # It sat eleven versions behind for weeks and said "already latest" throughout.
2233
2359
  # A deploy nobody runs is not a deploy, and one that announces success is worse
2234
2360
  # than one that fails.
2235
- prefix="${REPO_DIR%/lib/node_modules/claude-multiacc*}"
2236
2361
  cur="$(pkg_version_at "$REPO_DIR")"
2237
- lat="$(npm view claude-multiacc version 2>/dev/null)"
2362
+ lat="$("$npm_bin" view claude-multiacc version 2>/dev/null)"
2238
2363
  if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
2239
2364
  ulog "self-update: already latest ($cur)"
2240
2365
  return 0
2241
2366
  fi
2242
- ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix)"
2243
- if npm install -g --prefix "$prefix" claude-multiacc@latest \
2367
+ ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix, npm $npm_bin)"
2368
+ if "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
2244
2369
  >>"$ACC_ROOT/update.log" 2>&1; then
2245
2370
  # Verify by re-reading the file on disk. npm reporting success says nothing
2246
2371
  # about which tree it wrote to.
@@ -83,6 +83,8 @@ USAGE
83
83
  `codex exec` per account
84
84
  codex-accounts limits [--quiet] [--force] [--json]
85
85
  refresh usage windows from the ChatGPT usage endpoint, apply >=90% markers.
86
+ When a fresh bucket is at least 95% used, automatically redeems an earned
87
+ usage-limit reset if the account has one, using an idempotent request.
86
88
  Auto-refreshes long-expired access tokens via the OAuth refresh-token grant
87
89
  (rotated credential is persisted), so idle accounts keep fresh telemetry and
88
90
  stay selectable. Skips accounts fetched in the last 45s and honors
@@ -102,6 +104,7 @@ ENV
102
104
  CODEX_ACCOUNT pin the shim to one account
103
105
  CODEX_SHIM_RETRY=0 disable the `codex exec` auto-retry
104
106
  CODEX_MULTIACC_DISABLE=1 bypass the shim entirely
107
+ CODEX_MULTIACC_AUTO_RESET=0 disable automatic usage-reset redemption
105
108
  EOF
106
109
  }
107
110
 
@@ -1051,10 +1054,13 @@ cmd_limits() {
1051
1054
  case "$threshold" in ''|*[!0-9]*) threshold=90 ;; esac
1052
1055
  [ "$threshold" -gt 90 ] && threshold=90
1053
1056
  [ "$threshold" -lt 1 ] && threshold=90
1054
- "$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
1057
+ "$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" "$LIB_DIR" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
1055
1058
  import base64, datetime, json, os, sys, time, urllib.request
1056
1059
 
1057
- root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
1060
+ root, threshold, quiet, url, force, lib_dir = (sys.argv[1], int(sys.argv[2]),
1061
+ sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1', sys.argv[6])
1062
+ sys.path.insert(0, lib_dir)
1063
+ from codex_reset import try_auto_redeem
1058
1064
  now = time.time()
1059
1065
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
1060
1066
  MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
@@ -1461,9 +1467,20 @@ for acct in manifest.get('accounts', []):
1461
1467
  session = [b['percent'] for b in buckets if b['group'] == 'session']
1462
1468
  weeklyp = max(weekly) if weekly else maxp
1463
1469
  sessionp = max(session) if session else 0
1470
+ try:
1471
+ reset_result = try_auto_redeem(d, aid, maxp, data, url, headers, say, int(now))
1472
+ except Exception as e:
1473
+ say(f'{aid}: usage reset automation failed unexpectedly ({type(e).__name__}); failing open')
1474
+ reset_result = {'status': 'error'}
1464
1475
  out = {'fetched_at': int(now), 'source': 'chatgpt', 'max_percent': maxp,
1465
1476
  'weekly_percent': weeklyp, 'session_percent': sessionp,
1466
1477
  'plan': str(data.get('plan_type') or ''), 'buckets': buckets}
1478
+ if reset_result.get('status') == 'redeemed':
1479
+ # The response proves the reset succeeded, but the usage GET happened before
1480
+ # it. Make that snapshot stale immediately so it cannot re-exclude the newly
1481
+ # refreshed account; the next scheduled pass replaces it with server truth.
1482
+ out['fetched_at'] = 0
1483
+ out['auto_reset'] = reset_result
1467
1484
  tmp = lpath + '.tmp'
1468
1485
  with open(tmp, 'w') as f:
1469
1486
  json.dump(out, f, indent=1)
@@ -1471,7 +1488,8 @@ for acct in manifest.get('accounts', []):
1471
1488
  # The fetch went through with this account's own bearer => its auth is alive.
1472
1489
  if clear_expired(d):
1473
1490
  say(f'{aid}: dead-auth marker cleared (authenticated successfully)')
1474
- offenders = [b for b in buckets if b['percent'] >= threshold]
1491
+ offenders = [] if reset_result.get('status') == 'redeemed' else [
1492
+ b for b in buckets if b['percent'] >= threshold]
1475
1493
  mpath = os.path.join(d, '.limited')
1476
1494
  if offenders:
1477
1495
  worst = max(offenders, key=lambda b: b['percent'])
@@ -1498,7 +1516,8 @@ for acct in manifest.get('accounts', []):
1498
1516
  try:
1499
1517
  txt = open(mpath).read()
1500
1518
  first = txt.splitlines()[0] if txt else ''
1501
- if ('reason=error-cooldown' in txt or 'reason=client-rate-limit' in txt) \
1519
+ if reset_result.get('status') != 'redeemed' \
1520
+ and ('reason=error-cooldown' in txt or 'reason=client-rate-limit' in txt) \
1502
1521
  and first.isdigit() and int(first) > now:
1503
1522
  keep = True
1504
1523
  except Exception:
@@ -1863,21 +1882,38 @@ cmd_self_update() {
1863
1882
  # no-op with a hint. Best-effort and fully logged; never disrupts a running codex.
1864
1883
  local quiet=0
1865
1884
  [ "${1:-}" = "--quiet" ] && quiet=1
1885
+ # The update log is appended to with a plain redirect below, so the pool root has to
1886
+ # exist: on a machine where this provider's pool was never initialised the redirect
1887
+ # itself failed, and the update was reported as "npm update FAILED" with nothing in
1888
+ # the log to say why.
1889
+ mkdir -p "$ACC_ROOT" 2>/dev/null || true
1866
1890
  rotate_log update.log
1867
1891
  ulog() { log_to update.log "$*"; [ "$quiet" = "1" ] || echo "$*"; }
1868
1892
  case "$REPO_DIR" in
1869
1893
  */node_modules/claude-multiacc|*/node_modules/claude-multiacc/*)
1870
- command -v npm >/dev/null 2>&1 || { ulog "self-update: npm not found; skipping"; return 0; }
1871
- local cur lat
1872
- cur="$(npm ls -g --depth=0 claude-multiacc 2>/dev/null | sed -n 's/.*claude-multiacc@//p' | head -1)"
1873
- lat="$(npm view claude-multiacc version 2>/dev/null)"
1894
+ local cur lat prefix npm_bin after
1895
+ # Same resolution and same prefix discipline as claude-accounts: a launchd/cron
1896
+ # PATH has no npm, and the npm that must run is the one owning the RUNNING copy.
1897
+ prefix="${REPO_DIR%/lib/node_modules/*}"
1898
+ npm_bin="$(find_npm "$prefix")" || {
1899
+ ulog "self-update: no npm found (looked in $prefix/bin, /opt/homebrew/bin, /usr/local/bin, /usr/bin, PATH); skipping"
1900
+ return 0
1901
+ }
1902
+ cur="$(pkg_version_at "$REPO_DIR")"
1903
+ lat="$("$npm_bin" view claude-multiacc version 2>/dev/null)"
1874
1904
  if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
1875
1905
  ulog "self-update: already latest ($cur)"
1876
1906
  return 0
1877
1907
  fi
1878
- ulog "self-update: npm $cur -> ${lat:-latest}"
1879
- if npm install -g claude-multiacc@latest >>"$ACC_ROOT/update.log" 2>&1; then
1880
- ulog "self-update: npm update ok"
1908
+ ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix, npm $npm_bin)"
1909
+ if "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
1910
+ >>"$ACC_ROOT/update.log" 2>&1; then
1911
+ after="$(pkg_version_at "$REPO_DIR")"
1912
+ if [ -n "$lat" ] && [ "$after" != "$lat" ]; then
1913
+ ulog "self-update: npm reported success but $REPO_DIR is still $after, not $lat — this install is NOT being updated"
1914
+ return 1
1915
+ fi
1916
+ ulog "self-update: npm update ok ($after)"
1881
1917
  else
1882
1918
  ulog "self-update: npm update FAILED (see update.log)"
1883
1919
  return 1
@@ -69,7 +69,11 @@ subscription — never API keys.
69
69
 
70
70
  Using the full login on purpose: it does **not** hit the *"Sign in again to continue"*
71
71
  step-up wall that the narrow long-lived-token grant triggers, and it stores an
72
- auto-refreshing `.credentials.json`. That credential is **machine-local** (never synced),
72
+ auto-refreshing login — `.credentials.json`, or on macOS the login Keychain whenever
73
+ the session can open it (Claude Code migrates the file into the Keychain on the first
74
+ refresh from a keychain-capable session; ssh sessions then see the account as
75
+ `KEYCHAIN LOCKED` and cannot run it, while the Mac's own session uses it normally).
76
+ That credential is **machine-local** (never synced),
73
77
  which is what keeps two machines from invalidating each other's refresh token — so an
74
78
  account you `add` on the Mac runs on the Mac, and you `add` it on the server (over SSH) if
75
79
  you want it there too. The account is registered **only after the signed-in email is read
@@ -244,7 +248,7 @@ whose reset time has passed does not count, an unreadable one does.
244
248
  | Credential | Where it lives | Class | Can it be copied to another machine? |
245
249
  | --- | --- | --- | --- |
246
250
  | claude setup-token | `<acct>/server.token` | **portable** | **Yes**; the fleet-distributed credential |
247
- | claude OAuth login | `<acct>/.credentials.json` | machine-local | **No**; refresh tokens rotate |
251
+ | claude OAuth login | `<acct>/.credentials.json`, or a macOS Keychain item | machine-local | **No**; refresh tokens rotate |
248
252
  | codex ChatGPT login | `<acct>/auth.json` | machine-local | **No**; Codex has no portable credential |
249
253
 
250
254
  Non-portable accounts are not a dead end: they show up as `credential_class:
@@ -342,7 +346,7 @@ The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
342
346
  ## Verification
343
347
 
344
348
  ```bash
345
- tests/run-tests.sh # 644 sandboxed compatibility tests, no quota
349
+ tests/run-tests.sh # 645 sandboxed compatibility tests, no quota
346
350
  claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
347
351
  claude-accounts verify --quick# auth presence/expiry only, no inference
348
352
  claude-accounts limits # live per-bucket usage incl. the Fable bucket