claude-multiacc 1.0.13 → 1.0.14

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # claude-accounts — manage the claude-multiacc account pool.
3
3
  # Subcommands: list status add import remove mint sync verify limits post-sync health
4
- # expired relogin
4
+ # expired relogin export-credential import-credential
5
5
  set -u
6
6
  # lib/audit.py is imported by several subcommands; keep the install tree free of
7
7
  # __pycache__ (it may be root-owned, read-only, or an npm global prefix).
@@ -22,8 +22,8 @@ usage() {
22
22
  claude-accounts — multi-account pool manager for claude-multiacc
23
23
 
24
24
  USAGE
25
- claude-accounts list brief account list
26
- claude-accounts status full health: auth, per-bucket limits, markers
25
+ claude-accounts list [--json] brief account list (--json: machine-readable)
26
+ claude-accounts status [--json] full health: auth, per-bucket limits, markers
27
27
  claude-accounts add [email] [--token] [--force]
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
@@ -51,17 +51,34 @@ USAGE
51
51
  claude-accounts adopt <acct-NN> make acct-NN THIS machine's existing
52
52
  default ~/.claude login (dir symlink —
53
53
  single credential file, no grant fork)
54
+ claude-accounts export-credential <acct-NN> [--out PATH] [--identity-only]
55
+ print a self-contained JSON blob (credential + identity metadata) for a
56
+ PORTABLE account, i.e. one with a setup-token. REFUSES a machine-local OAuth
57
+ credential (exit 3) — those cannot be copied; sign in on the machine that
58
+ needs them, or run 'mint <acct-NN>' once to make the account portable.
59
+ --identity-only exports registry metadata with NO credential material.
60
+ Exit: 0 ok, 3 machine-local, 4 no credential, 5 unusable material.
61
+ claude-accounts import-credential [acct-NN] [--in PATH|-] [--home mac|server]
62
+ [--force] [--no-sync]
63
+ install a blob from export-credential (stdin by default): creates the account
64
+ dir + manifest entry as needed, or refreshes the credential of the account
65
+ that already owns that email. Non-interactive — this is how a daemon
66
+ distributes accounts to a machine.
54
67
  claude-accounts remove <acct-NN> [--yes] delete account (propagates to server)
55
68
  claude-accounts dedupe [--yes] remove any account registered twice
56
69
  (same email), keeping one per email
57
70
  claude-accounts mint <acct-NN> mint server token via `claude setup-token`
58
71
  --paste paste an already-minted token instead of running setup-token
59
- claude-accounts sync push manifest+tokens to the server AND any
60
- manifest 'peers' (extra machines). Mac only.
61
- A pool with a 'sync-role' file saying
72
+ claude-accounts sync [--no-server] push manifest+tokens to the sync target AND
73
+ any manifest 'peers' (extra machines). Mac
74
+ only. A pool with a 'sync-role' file saying
62
75
  'replica' never pushes (it receives).
76
+ --no-server (or a pool whose target is
77
+ 'none') keeps sync LOCAL: validate + seed +
78
+ fix perms, push nowhere — for pools a panel
79
+ or runner daemon distributes.
63
80
  claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
64
- claude-accounts limits [--quiet] [--force]
81
+ claude-accounts limits [--quiet] [--force] [--json]
65
82
  refresh usage buckets, apply >=90% markers. Auto-refreshes long-expired
66
83
  OAuth access tokens via the refresh-token grant (rotated credential is
67
84
  persisted), so idle accounts keep fresh telemetry and stay selectable.
@@ -73,7 +90,12 @@ USAGE
73
90
  claude-accounts post-sync (server side) seed dirs, fix perms, quick verify
74
91
 
75
92
  ENV
76
- CLAUDE_ACCOUNTS_DIR override ~/.claude-accounts
93
+ CLAUDE_ACCOUNTS_ROOT pool root, overriding ~/.claude-accounts — one isolated pool
94
+ per app-robot instance on a shared machine (legacy spelling
95
+ CLAUDE_ACCOUNTS_DIR still works)
96
+ CLAUDE_MULTIACC_SYNC_TARGET sync target (user@host), overriding the manifest;
97
+ 'none' = local-only, nothing is pushed anywhere
98
+ CLAUDE_MULTIACC_SYNC_ROOT / _SYNC_REPO remote pool root / addon repo for it
77
99
  CLAUDE_ACCOUNT pin the shim to one account
78
100
  CLAUDE_SHIM_RETRY=0 disable -p auto-retry
79
101
  CLAUDE_MULTIACC_DISABLE=1 bypass the shim entirely
@@ -95,17 +117,19 @@ next_id() {
95
117
  done
96
118
  }
97
119
 
98
- manifest_add_account() { # id email home
99
- "$PYBIN" - "$MANIFEST" "$1" "$2" "$3" <<'PYEOF'
120
+ manifest_add_account() { # id email home [added_at]
121
+ "$PYBIN" - "$MANIFEST" "$1" "$2" "$3" "${4:-}" <<'PYEOF'
100
122
  import json, sys, time
101
- path, aid, email, home = sys.argv[1:5]
123
+ path, aid, email, home, added_at = sys.argv[1:6]
102
124
  doc = json.load(open(path))
103
125
  accounts = [a for a in doc.get('accounts', []) if a['id'] != aid]
104
126
  accounts.append({
105
127
  'id': aid,
106
128
  'email': email,
107
129
  'home': home,
108
- 'added_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
130
+ # An imported account keeps the added_at it was registered with on the machine it
131
+ # came from, so the same account reads identically across the fleet.
132
+ 'added_at': added_at or time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
109
133
  })
110
134
  accounts.sort(key=lambda a: a['id'])
111
135
  doc['accounts'] = accounts
@@ -128,6 +152,17 @@ for a in json.load(open(sys.argv[1])).get('accounts', []):
128
152
  PYEOF
129
153
  }
130
154
 
155
+ manifest_email_of() { # prints the email registered for <id>, empty if unknown
156
+ [ -f "$MANIFEST" ] || return 0
157
+ "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
158
+ import json, sys
159
+ for a in json.load(open(sys.argv[1])).get('accounts', []):
160
+ if isinstance(a, dict) and a.get('id') == sys.argv[2]:
161
+ print(a.get('email', ''))
162
+ break
163
+ PYEOF
164
+ }
165
+
131
166
  manifest_del_account() { # id
132
167
  "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
133
168
  import json, sys
@@ -143,8 +178,11 @@ PYEOF
143
178
  }
144
179
 
145
180
  auto_sync() { # best effort after mutations, Mac only, loud on failure
146
- [ "$(machine_kind)" = "mac" ] || return 0
147
181
  [ "${CLAUDE_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
182
+ # Local-only pool (target 'none'): there is nothing to push — the panel/runner
183
+ # daemon distributes accounts — so a mutation must not warn about a missing server.
184
+ sync_target_is_local "$(sync_target)" && return 0
185
+ [ "$(machine_kind)" = "mac" ] || return 0
148
186
  # A replica pool never pushes (the source machine owns the account set) —
149
187
  # silently, so every mutation on a replica does not nag about it.
150
188
  sync_is_replica && return 0
@@ -154,6 +192,14 @@ auto_sync() { # best effort after mutations, Mac only, loud on failure
154
192
 
155
193
  cmd_list() {
156
194
  require_manifest
195
+ local json=0
196
+ while [ $# -gt 0 ]; do
197
+ case "$1" in
198
+ --json) json=1; shift ;;
199
+ *) die "unknown option: $1 (usage: claude-accounts list [--json])" ;;
200
+ esac
201
+ done
202
+ if [ "$json" = "1" ]; then emit_report_json list; return $?; fi
157
203
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
158
204
  import json, os, re, sys
159
205
  doc = json.load(open(sys.argv[1]))
@@ -205,6 +251,14 @@ PYEOF
205
251
 
206
252
  cmd_status() {
207
253
  require_manifest
254
+ local json=0
255
+ while [ $# -gt 0 ]; do
256
+ case "$1" in
257
+ --json) json=1; shift ;;
258
+ *) die "unknown option: $1 (usage: claude-accounts status [--json])" ;;
259
+ esac
260
+ done
261
+ if [ "$json" = "1" ]; then emit_report_json status; return $?; fi
208
262
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
209
263
  import json, os, sys, time
210
264
  doc = json.load(open(sys.argv[1]))
@@ -922,11 +976,12 @@ cmd_relogin() {
922
976
 
923
977
  cmd_limits() {
924
978
  require_manifest
925
- local quiet=0 force=0
979
+ local quiet=0 force=0 json=0
926
980
  while [ $# -gt 0 ]; do
927
981
  case "$1" in
928
982
  --quiet) quiet=1; shift ;;
929
983
  --force) force=1; shift ;; # ignore freshness/backoff (manual override)
984
+ --json) json=1; quiet=1; shift ;; # refresh silently, then emit the report
930
985
  *) die "unknown option: $1" ;;
931
986
  esac
932
987
  done
@@ -935,14 +990,20 @@ cmd_limits() {
935
990
  if ! mkdir "$lock" 2>/dev/null; then
936
991
  local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
937
992
  if [ "$age" -lt 120 ]; then
993
+ # A --json caller still gets the document (built from the state on disk) —
994
+ # a machine-readable verb must never answer a concurrent run with silence.
995
+ if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
938
996
  [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
939
997
  return 0
940
998
  fi
941
999
  rm -rf "$lock"
942
- mkdir "$lock" 2>/dev/null || return 0
1000
+ if ! mkdir "$lock" 2>/dev/null; then
1001
+ if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1002
+ return 0
1003
+ fi
943
1004
  fi
944
- # shellcheck disable=SC2064
945
- trap "rm -rf '$lock'" EXIT
1005
+ LIMITS_LOCK="$lock"
1006
+ trap limits_lock_release EXIT
946
1007
  rotate_log limits.log
947
1008
  # NB: expired OAuth access tokens are refreshed inside the Python below via the
948
1009
  # refresh-token grant. (`claude auth status` was tried for this and does NOT
@@ -960,7 +1021,7 @@ import json, os, sys, time, urllib.request
960
1021
  root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
961
1022
  now = time.time()
962
1023
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
963
- MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '45'))
1024
+ MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '240'))
964
1025
 
965
1026
  # OAuth refresh-token grant — the same endpoint + public client id Claude Code
966
1027
  # itself uses to keep .credentials.json alive. An account that sits idle past its
@@ -1350,13 +1411,21 @@ for acct in manifest.get('accounts', []):
1350
1411
  say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
1351
1412
  else:
1352
1413
  if os.path.exists(mpath):
1353
- # A shim-written error-cooldown marker outlives a clean limits pass:
1354
- # the account failed a real call moments ago; give the cooldown its window.
1414
+ # A shim-written marker outlives a clean limits pass while its own window
1415
+ # is still open:
1416
+ # error-cooldown — the account failed a real call moments ago.
1417
+ # client-rate-limit — Claude Code itself was REJECTED on this account and
1418
+ # recorded the reset the API handed it. That is
1419
+ # first-hand evidence; a usage payload that disagrees
1420
+ # (different bucket set, cached edge) must not unpark
1421
+ # the account early and send work straight back into
1422
+ # the 429.
1355
1423
  keep = False
1356
1424
  try:
1357
1425
  txt = open(mpath).read()
1358
1426
  first = txt.splitlines()[0] if txt else ''
1359
- if 'reason=error-cooldown' in txt and first.isdigit() and int(first) > now:
1427
+ if ('reason=error-cooldown' in txt or 'reason=client-rate-limit' in txt) \
1428
+ and first.isdigit() and int(first) > now:
1360
1429
  keep = True
1361
1430
  except Exception:
1362
1431
  pass
@@ -1367,6 +1436,18 @@ for acct in manifest.get('accounts', []):
1367
1436
  detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
1368
1437
  print(f'{aid}: ok {detail}')
1369
1438
  PYEOF
1439
+ local refresh_rc=$?
1440
+ # Release the lock BEFORE the report: a --json caller must not hold the refresh
1441
+ # lock while a consumer reads its output.
1442
+ limits_lock_release
1443
+ trap - EXIT
1444
+ # The refresher fails open per account; a NON-zero status means the pass itself
1445
+ # broke (unreadable manifest, dead python). Report the state anyway — stale data
1446
+ # beats silence — but hand the caller the failure, exactly as before --json existed.
1447
+ if [ "$json" = "1" ]; then
1448
+ emit_report_json limits || return $?
1449
+ fi
1450
+ return "$refresh_rc"
1370
1451
  }
1371
1452
 
1372
1453
  cmd_verify() {
@@ -1606,17 +1687,45 @@ sync_is_replica() {
1606
1687
 
1607
1688
  cmd_sync() {
1608
1689
  require_manifest
1690
+ local allow_empty=0 no_server=0
1691
+ while [ $# -gt 0 ]; do
1692
+ case "$1" in
1693
+ --allow-empty) allow_empty=1; shift ;;
1694
+ --no-server) no_server=1; shift ;; # this run pushes nowhere, whatever the manifest says
1695
+ *) die "unknown option: $1 (usage: claude-accounts sync [--no-server] [--allow-empty])" ;;
1696
+ esac
1697
+ done
1698
+ local server sroot srepo
1699
+ server="$(sync_target)"
1700
+ sroot="$(sync_target_root)"
1701
+ srepo="$(sync_target_repo)"
1702
+ # LOCAL-ONLY: no ssh target at all, because a panel/runner daemon distributes this
1703
+ # pool. Validate + fix up locally and stop — the same verb keeps working, it simply
1704
+ # has nowhere to push. (Not Mac-gated: a local pool is legitimate on any host.)
1705
+ # This mode NARROWS the replica rule, it never widens it: a replica pushes nothing,
1706
+ # and a local-only pool pushes nothing whether or not it is a replica. The marker is
1707
+ # still honored and still reported, so a replica can never start pushing by having
1708
+ # its sync target changed.
1709
+ if [ "$no_server" = "1" ] || sync_target_is_local "$server"; then
1710
+ rotate_log sync.log
1711
+ manifest_well_formed || { log_to sync.log "FAIL: manifest malformed (local sync)"; \
1712
+ die "manifest is not valid JSON or has no well-formed accounts — fix $MANIFEST"; }
1713
+ local role="source"
1714
+ sync_is_replica && role="replica"
1715
+ log_to sync.log "sync (local-only, role=$role): no server target${no_server:+ (--no-server)}"
1716
+ local_pool_fixup
1717
+ if [ "$role" = "replica" ]; then
1718
+ echo "sync ok (local-only, and this pool is a sync replica — nothing pushed either way)"
1719
+ else
1720
+ echo "sync ok (local-only: pool at $ACC_ROOT validated and re-seeded; nothing pushed)"
1721
+ fi
1722
+ return 0
1723
+ fi
1609
1724
  [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
1610
1725
  if sync_is_replica; then
1611
1726
  echo "this pool is a sync replica — the source machine pushes here; nothing sent"
1612
1727
  return 0
1613
1728
  fi
1614
- local allow_empty=0
1615
- [ "${1:-}" = "--allow-empty" ] && allow_empty=1
1616
- local server sroot srepo
1617
- server="$(manifest_get server "$DEFAULT_SERVER")"
1618
- sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
1619
- srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
1620
1729
  # These land inside remote shell commands — anything but a plain target/path is a
1621
1730
  # command-injection vector from a corrupted or hand-edited manifest. EVERY target
1622
1731
  # (primary and peers) is validated before anything is pushed anywhere.
@@ -1648,17 +1757,8 @@ EOF
1648
1757
  # never be pushed (it would blank the target pools), and must never make the removal
1649
1758
  # propagation wipe a target's credentials. Emptying the pool on purpose is possible
1650
1759
  # via `sync --allow-empty`, so this can never happen by accident.
1651
- "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
1652
- import json, re, sys
1653
- doc = json.load(open(sys.argv[1]))
1654
- accounts = doc.get('accounts')
1655
- if not isinstance(accounts, list):
1656
- sys.exit(1)
1657
- for a in accounts:
1658
- if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
1659
- or not str(a.get('email', '')).strip():
1660
- sys.exit(1)
1661
- PYEOF
1760
+ manifest_well_formed \
1761
+ || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
1662
1762
  if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
1663
1763
  fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
1664
1764
  fi
@@ -1685,13 +1785,7 @@ EOF
1685
1785
 
1686
1786
  cmd_post_sync() {
1687
1787
  require_manifest
1688
- local id d
1689
- for id in $(account_ids); do
1690
- d="$ACC_ROOT/$id"
1691
- seed_account_dir "$d"
1692
- [ -f "$d/server.token" ] && chmod 600 "$d/server.token" 2>/dev/null
1693
- [ -f "$d/.credentials.json" ] && chmod 600 "$d/.credentials.json" 2>/dev/null
1694
- done
1788
+ local_pool_fixup
1695
1789
  log_to sync.log "post-sync: seeded $(account_ids | wc -l | tr -d ' ') account dirs"
1696
1790
  ( cmd_limits --quiet ) || true # subshell: release the limits lock before verify
1697
1791
  cmd_verify --quick
@@ -1761,6 +1855,8 @@ case "${1:-help}" in
1761
1855
  status) shift; cmd_status "$@" ;;
1762
1856
  add) shift; cmd_add "$@" ;;
1763
1857
  import) shift; cmd_import "$@" ;;
1858
+ export-credential|export-cred) shift; cmd_export_credential "$@" ;;
1859
+ import-credential|import-cred) shift; cmd_import_credential "$@" ;;
1764
1860
  adopt) shift; cmd_adopt "$@" ;;
1765
1861
  dedupe) shift; cmd_dedupe "$@" ;;
1766
1862
  remove) shift; cmd_remove "$@" ;;