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.
@@ -2,6 +2,7 @@
2
2
  # codex-accounts — manage the codex-multiacc account pool (OpenAI Codex CLI).
3
3
  # Subcommands: list status add import adopt dedupe remove login expired relogin
4
4
  # sync verify limits post-sync health self-update
5
+ # export-credential import-credential
5
6
  set -u
6
7
  # lib/codex_audit.py is imported by several subcommands; keep the install tree free
7
8
  # of __pycache__ (it may be root-owned, read-only, or an npm global prefix).
@@ -25,8 +26,8 @@ usage() {
25
26
  codex-accounts — multi-account pool manager for codex-multiacc (OpenAI Codex CLI)
26
27
 
27
28
  USAGE
28
- codex-accounts list brief account list
29
- codex-accounts status full health: auth, per-window limits, markers
29
+ codex-accounts list [--json] brief account list (--json: machine-readable)
30
+ codex-accounts status [--json] full health: auth, per-window limits, markers
30
31
  codex-accounts add [email] [--browser] [--force]
31
32
  login-FIRST: runs the Codex DEVICE-CODE sign-in by default — it prints a URL
32
33
  + one-time code you can open in ANY browser (this machine, your laptop, a
@@ -55,17 +56,32 @@ USAGE
55
56
  codex-accounts adopt <acct-NN> make acct-NN THIS machine's existing
56
57
  default ~/.codex login (dir symlink —
57
58
  single credential file, no grant fork)
59
+ codex-accounts export-credential <acct-NN> [--out PATH] [--identity-only]
60
+ codex has NO portable credential class: auth.json carries a rotating refresh
61
+ token, so a copy breaks both machines. This ALWAYS refuses a credential
62
+ export (exit 3) and says what to do instead (sign in on that machine with
63
+ the device-code flow). --identity-only exports the registry entry
64
+ (id/email/home) so a daemon can seed the account list on another machine.
65
+ codex-accounts import-credential [acct-NN] [--in PATH|-] [--home mac|server]
66
+ [--force] [--no-sync]
67
+ install a blob from export-credential (stdin by default). For codex that is
68
+ always an identity-only blob: the account is registered and then needs ONE
69
+ interactive sign-in here (codex-accounts login <acct-NN>).
58
70
  codex-accounts remove <acct-NN> [--yes] delete account (propagates to server)
59
71
  codex-accounts dedupe [--yes] remove any account registered twice
60
72
  (same email), keeping one per email
61
- codex-accounts sync push manifest+seeds to the server AND any
62
- manifest 'peers' (extra machines). Mac only.
63
- auth.json is machine-local, NEVER synced.
64
- A pool with a 'sync-role' file saying
73
+ codex-accounts sync [--no-server] push manifest+seeds to the sync target AND
74
+ any manifest 'peers' (extra machines). Mac
75
+ only. auth.json is machine-local, NEVER
76
+ synced. A pool with a 'sync-role' file saying
65
77
  'replica' never pushes (it receives).
78
+ --no-server (or a pool whose target is
79
+ 'none') keeps sync LOCAL: validate + seed +
80
+ fix perms, push nowhere — for pools a panel
81
+ or runner daemon distributes.
66
82
  codex-accounts verify [--quick] auth matrix; full mode runs a real
67
83
  `codex exec` per account
68
- codex-accounts limits [--quiet] [--force]
84
+ codex-accounts limits [--quiet] [--force] [--json]
69
85
  refresh usage windows from the ChatGPT usage endpoint, apply >=90% markers.
70
86
  Auto-refreshes long-expired access tokens via the OAuth refresh-token grant
71
87
  (rotated credential is persisted), so idle accounts keep fresh telemetry and
@@ -77,7 +93,12 @@ USAGE
77
93
  codex-accounts post-sync (server side) seed dirs, fix perms, quick verify
78
94
 
79
95
  ENV
80
- CODEX_ACCOUNTS_DIR override ~/.codex-accounts
96
+ CODEX_ACCOUNTS_ROOT pool root, overriding ~/.codex-accounts — one isolated pool
97
+ per app-robot instance on a shared machine (legacy spelling
98
+ CODEX_ACCOUNTS_DIR still works)
99
+ CODEX_MULTIACC_SYNC_TARGET sync target (user@host), overriding the manifest;
100
+ 'none' = local-only, nothing is pushed anywhere
101
+ CODEX_MULTIACC_SYNC_ROOT / _SYNC_REPO remote pool root / addon repo for it
81
102
  CODEX_ACCOUNT pin the shim to one account
82
103
  CODEX_SHIM_RETRY=0 disable the `codex exec` auto-retry
83
104
  CODEX_MULTIACC_DISABLE=1 bypass the shim entirely
@@ -99,17 +120,19 @@ next_id() {
99
120
  done
100
121
  }
101
122
 
102
- manifest_add_account() { # id email home
103
- "$PYBIN" - "$MANIFEST" "$1" "$2" "$3" <<'PYEOF'
123
+ manifest_add_account() { # id email home [added_at]
124
+ "$PYBIN" - "$MANIFEST" "$1" "$2" "$3" "${4:-}" <<'PYEOF'
104
125
  import json, sys, time
105
- path, aid, email, home = sys.argv[1:5]
126
+ path, aid, email, home, added_at = sys.argv[1:6]
106
127
  doc = json.load(open(path))
107
128
  accounts = [a for a in doc.get('accounts', []) if a['id'] != aid]
108
129
  accounts.append({
109
130
  'id': aid,
110
131
  'email': email,
111
132
  'home': home,
112
- 'added_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
133
+ # An imported account keeps the added_at it was registered with on the machine it
134
+ # came from, so the same account reads identically across the fleet.
135
+ 'added_at': added_at or time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
113
136
  })
114
137
  accounts.sort(key=lambda a: a['id'])
115
138
  doc['accounts'] = accounts
@@ -132,6 +155,17 @@ for a in json.load(open(sys.argv[1])).get('accounts', []):
132
155
  PYEOF
133
156
  }
134
157
 
158
+ manifest_email_of() { # prints the email registered for <id>, empty if unknown
159
+ [ -f "$MANIFEST" ] || return 0
160
+ "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
161
+ import json, sys
162
+ for a in json.load(open(sys.argv[1])).get('accounts', []):
163
+ if isinstance(a, dict) and a.get('id') == sys.argv[2]:
164
+ print(a.get('email', ''))
165
+ break
166
+ PYEOF
167
+ }
168
+
135
169
  manifest_del_account() { # id
136
170
  "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
137
171
  import json, sys
@@ -177,8 +211,11 @@ login_lock_busy() { # $1 = acct dir; true when a LIVE login ceremony holds it
177
211
  }
178
212
 
179
213
  auto_sync() { # best effort after mutations, Mac only, loud on failure
180
- [ "$(machine_kind)" = "mac" ] || return 0
181
214
  [ "${CODEX_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
215
+ # Local-only pool (target 'none'): there is nothing to push — the panel/runner
216
+ # daemon distributes accounts — so a mutation must not warn about a missing server.
217
+ sync_target_is_local "$(sync_target)" && return 0
218
+ [ "$(machine_kind)" = "mac" ] || return 0
182
219
  # A replica pool never pushes (the source machine owns the account set) —
183
220
  # silently, so every mutation on a replica does not nag about it.
184
221
  sync_is_replica && return 0
@@ -188,6 +225,14 @@ auto_sync() { # best effort after mutations, Mac only, loud on failure
188
225
 
189
226
  cmd_list() {
190
227
  require_manifest
228
+ local json=0
229
+ while [ $# -gt 0 ]; do
230
+ case "$1" in
231
+ --json) json=1; shift ;;
232
+ *) die "unknown option: $1 (usage: codex-accounts list [--json])" ;;
233
+ esac
234
+ done
235
+ if [ "$json" = "1" ]; then emit_report_json list; return $?; fi
191
236
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
192
237
  import json, os, re, sys
193
238
  doc = json.load(open(sys.argv[1]))
@@ -236,6 +281,14 @@ PYEOF
236
281
 
237
282
  cmd_status() {
238
283
  require_manifest
284
+ local json=0
285
+ while [ $# -gt 0 ]; do
286
+ case "$1" in
287
+ --json) json=1; shift ;;
288
+ *) die "unknown option: $1 (usage: codex-accounts status [--json])" ;;
289
+ esac
290
+ done
291
+ if [ "$json" = "1" ]; then emit_report_json status; return $?; fi
239
292
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
240
293
  import json, os, sys, time
241
294
  doc = json.load(open(sys.argv[1]))
@@ -527,20 +580,29 @@ cmd_import() {
527
580
  link) link_target="$(canon_path "$auth")" ;;
528
581
  *)
529
582
  mkdir -p "$ACC_ROOT/tmp"
530
- stage="$ACC_ROOT/tmp/import-auth.$$"
583
+ # mktemp, not a predictable name: the staged file briefly holds a live
584
+ # credential, and a predictable path in a shared pool root could be
585
+ # pre-planted as a symlink and written through.
586
+ stage="$(mktemp "$ACC_ROOT/tmp/import-auth.XXXXXX")" \
587
+ || die "could not create a staging file under $ACC_ROOT/tmp"
588
+ IMPORT_STAGE="$stage"
589
+ trap import_cred_cleanup EXIT
531
590
  case "$mode" in
532
- copy) ( umask 077; cp "$auth" "$stage" ) ;;
533
- move) mv "$auth" "$stage" ;;
534
- esac || { rm -f "$stage" 2>/dev/null; die "could not stage $auth (mode=$mode) — nothing registered"; }
591
+ copy) ( umask 077; cat "$auth" > "$stage" ) ;;
592
+ move) cat "$auth" > "$stage" && rm -f "$auth" ;;
593
+ esac || { import_cred_cleanup; trap - EXIT; die "could not stage $auth (mode=$mode) — nothing registered"; }
594
+ chmod 600 "$stage" 2>/dev/null || true
535
595
  ;;
536
596
  esac
537
597
  fi
538
598
  # Id allocation + duplicate check + manifest write run under the mutation lock, so
539
599
  # two parallel imports can never claim the same slot or drop each other's entry.
540
- mutate_lock || { rm -f "$stage" 2>/dev/null; die "could not acquire the account lock (another op is stuck?) — try again"; }
541
- # shellcheck disable=SC2064
542
- trap "mutate_unlock 2>/dev/null || true; rm -f '$stage' 2>/dev/null || true" EXIT
543
- fail_locked() { mutate_unlock; rm -f "$stage" 2>/dev/null; die "$@"; }
600
+ IMPORT_STAGE="$stage"
601
+ mutate_lock || { import_cred_cleanup; trap - EXIT; die "could not acquire the account lock (another op is stuck?) — try again"; }
602
+ # A CONSTANT trap program (import_cred_cleanup): interpolating the staged path would
603
+ # let a pool root containing a quote inject shell code into the trap body.
604
+ trap import_cred_cleanup EXIT
605
+ fail_locked() { import_cred_cleanup; trap - EXIT; die "$@"; }
544
606
  [ -n "$id" ] || id="$(next_id)"
545
607
  case "$id" in acct-[0-9][0-9]) ;; *) fail_locked "id must look like acct-NN" ;; esac
546
608
  local owner
@@ -555,6 +617,7 @@ cmd_import() {
555
617
  mv -f "$stage" "$d/auth.json" \
556
618
  || fail_locked "could not adopt $auth into $d (mode=$mode) — nothing registered"
557
619
  stage=""
620
+ IMPORT_STAGE=""
558
621
  chmod 600 "$d/auth.json" 2>/dev/null || true
559
622
  elif [ -n "$link_target" ]; then
560
623
  ln -sf "$link_target" "$d/auth.json" \
@@ -952,11 +1015,12 @@ cmd_relogin() {
952
1015
 
953
1016
  cmd_limits() {
954
1017
  require_manifest
955
- local quiet=0 force=0
1018
+ local quiet=0 force=0 json=0
956
1019
  while [ $# -gt 0 ]; do
957
1020
  case "$1" in
958
1021
  --quiet) quiet=1; shift ;;
959
1022
  --force) force=1; shift ;; # ignore freshness/backoff (manual override)
1023
+ --json) json=1; quiet=1; shift ;; # refresh silently, then emit the report
960
1024
  *) die "unknown option: $1" ;;
961
1025
  esac
962
1026
  done
@@ -965,14 +1029,20 @@ cmd_limits() {
965
1029
  if ! mkdir "$lock" 2>/dev/null; then
966
1030
  local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
967
1031
  if [ "$age" -lt 120 ]; then
1032
+ # A --json caller still gets the document (built from the state on disk) —
1033
+ # a machine-readable verb must never answer a concurrent run with silence.
1034
+ if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
968
1035
  [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
969
1036
  return 0
970
1037
  fi
971
1038
  rm -rf "$lock"
972
- mkdir "$lock" 2>/dev/null || return 0
1039
+ if ! mkdir "$lock" 2>/dev/null; then
1040
+ if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1041
+ return 0
1042
+ fi
973
1043
  fi
974
- # shellcheck disable=SC2064
975
- trap "rm -rf '$lock'" EXIT
1044
+ LIMITS_LOCK="$lock"
1045
+ trap limits_lock_release EXIT
976
1046
  rotate_log limits.log
977
1047
  # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
978
1048
  # never loosen it, or an account could sit at 95% and still be selected.
@@ -987,7 +1057,7 @@ import base64, datetime, json, os, sys, time, urllib.request
987
1057
  root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
988
1058
  now = time.time()
989
1059
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
990
- MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '45'))
1060
+ MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
991
1061
 
992
1062
  # OAuth refresh-token grant — the same endpoint + public client id the Codex CLI
993
1063
  # itself uses to keep auth.json alive. An account that sits idle past its
@@ -1416,13 +1486,20 @@ for acct in manifest.get('accounts', []):
1416
1486
  say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
1417
1487
  else:
1418
1488
  if os.path.exists(mpath):
1419
- # A shim-written error-cooldown marker outlives a clean limits pass:
1420
- # the account failed a real call moments ago; give the cooldown its window.
1489
+ # A shim-written marker outlives a clean limits pass while its own window
1490
+ # is still open:
1491
+ # error-cooldown — the account failed a real call moments ago.
1492
+ # client-rate-limit — a real codex run was told by the server that this
1493
+ # window is spent, and recorded the reset it was
1494
+ # handed. That is first-hand evidence; a usage
1495
+ # payload that disagrees must not unpark the account
1496
+ # early and send work straight back into the wall.
1421
1497
  keep = False
1422
1498
  try:
1423
1499
  txt = open(mpath).read()
1424
1500
  first = txt.splitlines()[0] if txt else ''
1425
- if 'reason=error-cooldown' in txt and first.isdigit() and int(first) > now:
1501
+ if ('reason=error-cooldown' in txt or 'reason=client-rate-limit' in txt) \
1502
+ and first.isdigit() and int(first) > now:
1426
1503
  keep = True
1427
1504
  except Exception:
1428
1505
  pass
@@ -1433,6 +1510,18 @@ for acct in manifest.get('accounts', []):
1433
1510
  detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
1434
1511
  print(f'{aid}: ok {detail}')
1435
1512
  PYEOF
1513
+ local refresh_rc=$?
1514
+ # Release the lock BEFORE the report: a --json caller must not hold the refresh
1515
+ # lock while a consumer reads its output.
1516
+ limits_lock_release
1517
+ trap - EXIT
1518
+ # The refresher fails open per account; a NON-zero status means the pass itself
1519
+ # broke (unreadable manifest, dead python). Report the state anyway — stale data
1520
+ # beats silence — but hand the caller the failure, exactly as before --json existed.
1521
+ if [ "$json" = "1" ]; then
1522
+ emit_report_json limits || return $?
1523
+ fi
1524
+ return "$refresh_rc"
1436
1525
  }
1437
1526
 
1438
1527
  cmd_verify() {
@@ -1664,17 +1753,45 @@ sync_is_replica() {
1664
1753
 
1665
1754
  cmd_sync() {
1666
1755
  require_manifest
1756
+ local allow_empty=0 no_server=0
1757
+ while [ $# -gt 0 ]; do
1758
+ case "$1" in
1759
+ --allow-empty) allow_empty=1; shift ;;
1760
+ --no-server) no_server=1; shift ;; # this run pushes nowhere, whatever the manifest says
1761
+ *) die "unknown option: $1 (usage: codex-accounts sync [--no-server] [--allow-empty])" ;;
1762
+ esac
1763
+ done
1764
+ local server sroot srepo
1765
+ server="$(sync_target)"
1766
+ sroot="$(sync_target_root)"
1767
+ srepo="$(sync_target_repo)"
1768
+ # LOCAL-ONLY: no ssh target at all, because a panel/runner daemon distributes this
1769
+ # pool. Validate + fix up locally and stop — the same verb keeps working, it simply
1770
+ # has nowhere to push. (Not Mac-gated: a local pool is legitimate on any host.)
1771
+ # This mode NARROWS the replica rule, it never widens it: a replica pushes nothing,
1772
+ # and a local-only pool pushes nothing whether or not it is a replica. The marker is
1773
+ # still honored and still reported, so a replica can never start pushing by having
1774
+ # its sync target changed.
1775
+ if [ "$no_server" = "1" ] || sync_target_is_local "$server"; then
1776
+ rotate_log sync.log
1777
+ manifest_well_formed || { log_to sync.log "FAIL: manifest malformed (local sync)"; \
1778
+ die "manifest is not valid JSON or has no well-formed accounts — fix $MANIFEST"; }
1779
+ local role="source"
1780
+ sync_is_replica && role="replica"
1781
+ log_to sync.log "sync (local-only, role=$role): no server target${no_server:+ (--no-server)}"
1782
+ local_pool_fixup
1783
+ if [ "$role" = "replica" ]; then
1784
+ echo "sync ok (local-only, and this pool is a sync replica — nothing pushed either way)"
1785
+ else
1786
+ echo "sync ok (local-only: pool at $ACC_ROOT validated and re-seeded; nothing pushed)"
1787
+ fi
1788
+ return 0
1789
+ fi
1667
1790
  [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
1668
1791
  if sync_is_replica; then
1669
1792
  echo "this pool is a sync replica — the source machine pushes here; nothing sent"
1670
1793
  return 0
1671
1794
  fi
1672
- local allow_empty=0
1673
- [ "${1:-}" = "--allow-empty" ] && allow_empty=1
1674
- local server sroot srepo
1675
- server="$(manifest_get server "$DEFAULT_SERVER")"
1676
- sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
1677
- srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
1678
1795
  # These land inside remote shell commands — anything but a plain target/path is a
1679
1796
  # command-injection vector from a corrupted or hand-edited manifest. EVERY target
1680
1797
  # (primary and peers) is validated before anything is pushed anywhere.
@@ -1706,17 +1823,8 @@ EOF
1706
1823
  # never be pushed (it would blank the target pools), and must never make the removal
1707
1824
  # propagation wipe a target's account dirs. Emptying the pool on purpose is possible
1708
1825
  # via `sync --allow-empty`, so this can never happen by accident.
1709
- "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
1710
- import json, re, sys
1711
- doc = json.load(open(sys.argv[1]))
1712
- accounts = doc.get('accounts')
1713
- if not isinstance(accounts, list):
1714
- sys.exit(1)
1715
- for a in accounts:
1716
- if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
1717
- or not str(a.get('email', '')).strip():
1718
- sys.exit(1)
1719
- PYEOF
1826
+ manifest_well_formed \
1827
+ || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
1720
1828
  if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
1721
1829
  fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
1722
1830
  fi
@@ -1743,12 +1851,7 @@ EOF
1743
1851
 
1744
1852
  cmd_post_sync() {
1745
1853
  require_manifest
1746
- local id d
1747
- for id in $(account_ids); do
1748
- d="$ACC_ROOT/$id"
1749
- seed_account_dir "$d"
1750
- [ -f "$d/auth.json" ] && chmod 600 "$d/auth.json" 2>/dev/null
1751
- done
1854
+ local_pool_fixup
1752
1855
  log_to sync.log "post-sync: seeded $(account_ids | wc -l | tr -d ' ') account dirs"
1753
1856
  ( cmd_limits --quiet ) || true # subshell: release the limits lock before verify
1754
1857
  cmd_verify --quick
@@ -1837,6 +1940,8 @@ case "${1:-help}" in
1837
1940
  status) shift; cmd_status "$@" ;;
1838
1941
  add) shift; cmd_add "$@" ;;
1839
1942
  import) shift; cmd_import "$@" ;;
1943
+ export-credential|export-cred) shift; cmd_export_credential "$@" ;;
1944
+ import-credential|import-cred) shift; cmd_import_credential "$@" ;;
1840
1945
  adopt) shift; cmd_adopt "$@" ;;
1841
1946
  dedupe) shift; cmd_dedupe "$@" ;;
1842
1947
  remove) shift; cmd_remove "$@" ;;