#!/usr/bin/env bash
# claude-accounts — manage the claude-multiacc account pool.
# Subcommands: list status add import remove mint sync verify limits post-sync health
#              expired relogin
set -u
# lib/audit.py is imported by several subcommands; keep the install tree free of
# __pycache__ (it may be root-owned, read-only, or an npm global prefix).
export PYTHONDONTWRITEBYTECODE=1

_self="$0"
while [ -L "$_self" ]; do
  _t="$(readlink "$_self")"
  case "$_t" in /*) _self="$_t" ;; *) _self="$(dirname "$_self")/$_t" ;; esac
done
BIN_DIR="$(cd "$(dirname "$_self")" && pwd -P)"
REPO_DIR="$(dirname "$BIN_DIR")"
# shellcheck source=lib/common.sh
. "$REPO_DIR/lib/common.sh"

usage() {
  cat <<'EOF'
claude-accounts — multi-account pool manager for claude-multiacc

USAGE
  claude-accounts list                      brief account list
  claude-accounts status                    full health: auth, per-bucket limits, markers
  claude-accounts add [email] [--token] [--force]
      login-FIRST: runs the full Claude Code login (`claude auth login` — the normal
      browser sign-in, no long-lived-token step-up), then registers only after the
      signed-in email is read back. Email is OPTIONAL (derived from the sign-in).
      Stores auto-refreshing .credentials.json valid on THIS machine. Duplicates refused.
      --token instead mints a portable setup-token (works on Mac AND server; needs a
      recent sign-in — use it when you want the account usable on the server too).
  claude-accounts login <acct-NN> [--token] [--force]
      complete/refresh auth for an existing account (full login, or --token)
  claude-accounts expired [--quiet]
      which accounts CANNOT authenticate (expired refresh token, revoked grant, no
      login on this machine) and why. These are excluded from selection — `claude`
      never runs under them. Exits 1 when any account needs a human. --quiet prints
      bare ids for scripts.
  claude-accounts relogin [acct-NN ...] [--all] [--token] [--yes]
      sign in again, one account at a time. With no arguments it re-authenticates
      exactly what `expired` lists; --all covers every account. Syncs once at the end.
  claude-accounts import <email> [opts]     register an account, optionally with credentials
      --id acct-NN          explicit id (default: next free)
      --home mac|server     which machine owns the OAuth grant (default: this one)
      --creds PATH          existing .credentials.json to adopt
      --mode copy|move|link how to adopt --creds (default copy)
      --token-file PATH|-   long-lived token for server use (- reads stdin)
      --no-sync             skip the automatic server sync
  claude-accounts adopt <acct-NN>           make acct-NN THIS machine's existing
                                            default ~/.claude login (dir symlink —
                                            single credential file, no grant fork)
  claude-accounts remove <acct-NN> [--yes]  delete account (propagates to server)
  claude-accounts dedupe [--yes]            remove any account registered twice
                                            (same email), keeping one per email
  claude-accounts mint <acct-NN>            mint server token via `claude setup-token`
      --paste               paste an already-minted token instead of running setup-token
  claude-accounts sync                      push manifest+tokens to the server (Mac only)
  claude-accounts verify [--quick]          auth matrix; full mode runs `-p "reply OK"` per account
  claude-accounts limits [--quiet] [--force]
      refresh usage buckets, apply >=90% markers. Auto-refreshes long-expired
      OAuth access tokens via the refresh-token grant (rotated credential is
      persisted), so idle accounts keep fresh telemetry and stay selectable.
      Skips accounts fetched in the last 45s and honors 429/refresh backoff;
      --force ignores all three.
  claude-accounts health                    limits + full verify; logs to health.log
  claude-accounts self-update               update the addon (npm i -g @latest, or git
                                            pull + reinstall); logs to update.log
  claude-accounts post-sync                 (server side) seed dirs, fix perms, quick verify

ENV
  CLAUDE_ACCOUNTS_DIR   override ~/.claude-accounts
  CLAUDE_ACCOUNT        pin the shim to one account
  CLAUDE_SHIM_RETRY=0   disable -p auto-retry
  CLAUDE_MULTIACC_DISABLE=1  bypass the shim entirely
EOF
}

require_manifest() { [ -f "$MANIFEST" ] || die "no manifest at $MANIFEST — run install.sh first"; }

next_id() {
  local n=1 id
  while :; do
    id="$(printf 'acct-%02d' "$n")"
    if [ ! -d "$ACC_ROOT/$id" ] && ! account_ids | grep -qx "$id"; then
      printf '%s\n' "$id"
      return 0
    fi
    n=$((n+1))
    [ "$n" -gt 99 ] && die "no free account slot"
  done
}

manifest_add_account() { # id email home
  "$PYBIN" - "$MANIFEST" "$1" "$2" "$3" <<'PYEOF'
import json, sys, time
path, aid, email, home = sys.argv[1:5]
doc = json.load(open(path))
accounts = [a for a in doc.get('accounts', []) if a['id'] != aid]
accounts.append({
    'id': aid,
    'email': email,
    'home': home,
    'added_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
})
accounts.sort(key=lambda a: a['id'])
doc['accounts'] = accounts
import os
with open(path + '.tmp', 'w') as f:
    json.dump(doc, f, indent=2)
    f.write('\n')
os.replace(path + '.tmp', path)
PYEOF
}

email_owner() { # prints the id that owns <email>, empty if unregistered
  [ -f "$MANIFEST" ] || return 0
  "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
import json, sys
for a in json.load(open(sys.argv[1])).get('accounts', []):
    if a.get('email', '').lower() == sys.argv[2].lower():
        print(a['id'])
        break
PYEOF
}

manifest_del_account() { # id
  "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
import json, sys
path, aid = sys.argv[1:3]
doc = json.load(open(path))
doc['accounts'] = [a for a in doc.get('accounts', []) if a['id'] != aid]
import os
with open(path + '.tmp', 'w') as f:
    json.dump(doc, f, indent=2)
    f.write('\n')
os.replace(path + '.tmp', path)
PYEOF
}

auto_sync() { # best effort after mutations, Mac only, loud on failure
  [ "$(machine_kind)" = "mac" ] || return 0
  [ "${CLAUDE_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
  # subshell: cmd_sync exits on failure and must not take the CLI down with it
  ( cmd_sync ) || warn "server sync failed — run 'claude-accounts sync' manually"
}

cmd_list() {
  require_manifest
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
import json, os, re, sys
doc = json.load(open(sys.argv[1]))
root = sys.argv[2]
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
from audit import audit_account   # noqa: E402  (shared with the shim's rule)
machine = sys.argv[4]
# Only render well-formed ids — a hand-edited manifest must not surface a traversal id.
accounts = [a for a in doc.get('accounts', [])
            if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
if not accounts:
    print('(no accounts yet)')
for a in accounts:
    d = os.path.join(root, a['id'])
    auth = []
    if os.path.isfile(os.path.join(d, '.credentials.json')):
        auth.append('oauth')
    if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
        auth.append('token')
    limited = os.path.isfile(os.path.join(d, '.limited'))
    st = audit_account(root, a, machine=machine)
    flags = []
    if st['state'] == 'expired':
        flags.append('EXPIRED-LOGIN')
    elif st['state'] == 'blocked':
        flags.append('ORG-BLOCKED')
    elif st['state'] == 'missing':
        flags.append('NO-LOGIN')
    if limited:
        flags.append('LIMITED')
    print(f"{a['id']}  {a['email']:<28} home={a.get('home','?'):<7} "
          f"auth={'+'.join(auth) or 'NONE':<11} {' '.join(flags)}")
bad = [a for a in accounts
       if audit_account(root, a, machine=machine)['state']
       in ('expired', 'blocked', 'missing')]
if bad:
    print()
    print(f"{len(bad)} account(s) are NOT usable — details: claude-accounts expired")
seen = {}
for a in accounts:
    seen.setdefault(a.get('email', '').lower(), []).append(a['id'])
dups = {e: ids for e, ids in seen.items() if len(ids) > 1}
if dups:
    print()
    for e, ids in dups.items():
        print(f"WARNING: {e} is registered {len(ids)}x ({', '.join(ids)}) — run 'claude-accounts dedupe'")
PYEOF
}

cmd_status() {
  require_manifest
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
import json, os, sys, time
doc = json.load(open(sys.argv[1]))
root = sys.argv[2]
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
from audit import audit_account   # noqa: E402  (shared with the shim's rule)
machine = sys.argv[4]
now = time.time()

def last_pick(aid):
    path = os.path.join(root, 'selection.log')
    if not os.path.isfile(path):
        return '-'
    last = '-'
    try:
        with open(path, errors='replace') as f:
            for line in f:
                parts = line.split()
                if len(parts) >= 2 and parts[1] == aid:
                    last = parts[0]
    except Exception:
        pass
    return last

print(f"pool root : {root}")
print(f"server    : {doc.get('server','-')}  (root: {doc.get('server_root','-')})")
print(f"threshold : {doc.get('threshold', 90)}% (any bucket at/above => account excluded)")
print()
needs_login = []
for a in doc.get('accounts', []):
    aid = a['id']
    d = os.path.join(root, aid)
    st = audit_account(root, a, machine=machine)
    if st['state'] in ('expired', 'blocked', 'missing'):
        needs_login.append(aid)
    banner = {'ok': '', 'remote': '  [not logged in here — grant lives elsewhere]',
              'missing': '  ** NO LOGIN — claude-accounts relogin %s **' % aid,
              'expired': '  ** LOGIN EXPIRED — claude-accounts relogin %s **' % aid,
              'blocked': '  ** ORG BLOCKED — Claude Code disabled for this account **',
              }[st['state']]
    print(f"{aid}  {a['email']}  [home={a.get('home','?')}]{banner}")
    print(f"  selectable  : {'yes' if st['state'] == 'ok' else 'NO — ' + st['reason']}")
    cpath = os.path.join(d, '.credentials.json')
    if os.path.isfile(cpath):
        try:
            c = json.load(open(cpath)).get('claudeAiOauth', {})
            exp = c.get('expiresAt', 0) / 1000.0
            rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
            state = 'fresh' if exp > now else 'stale (auto-refreshes on use)'
            rstate = 'ok' if rexp > now else 'EXPIRED — re-login needed'
            print(f"  oauth creds : {state}; refresh token {rstate} (until {time.strftime('%Y-%m-%d', time.gmtime(rexp)) if rexp else '?'})")
        except Exception as e:
            print(f"  oauth creds : unreadable ({e})")
    else:
        print("  oauth creds : none on this machine")
    tpath = os.path.join(d, 'server.token')
    if os.path.isfile(tpath) and os.path.getsize(tpath) > 0:
        age_d = int((now - os.path.getmtime(tpath)) / 86400)
        note = ' — NEARING 1y LIFETIME, re-mint soon' if age_d > 330 else ''
        print(f"  server token: present (minted ~{age_d}d ago){note}")
    else:
        print("  server token: none")
    lpath = os.path.join(d, 'limits.json')
    if os.path.isfile(lpath):
        try:
            lim = json.load(open(lpath))
            age = int(now - lim.get('fetched_at', 0))
            parts = [f"{b['name']}={b['percent']}%" for b in lim.get('buckets', [])]
            print(f"  limits      : {'  '.join(parts) or '(none)'}  [{age}s old, max {lim.get('max_percent')}%]")
        except Exception as e:
            print(f"  limits      : unreadable ({e})")
    else:
        print("  limits      : never fetched")
    mpath = os.path.join(d, '.limited')
    if os.path.isfile(mpath):
        try:
            lines = open(mpath).read().splitlines()
            reset = int(lines[0]) if lines and lines[0].isdigit() else 0
            detail = lines[1] if len(lines) > 1 else ''
            mins = max(0, int((reset - now) / 60))
            print(f"  marker      : LIMITED ({detail}) — clears in ~{mins}m")
        except Exception:
            print("  marker      : LIMITED (unreadable marker)")
    else:
        print("  marker      : none (eligible)")
    print(f"  last picked : {last_pick(aid)}")
    print()
if needs_login:
    print(f"{len(needs_login)} account(s) are EXCLUDED from selection: {', '.join(needs_login)}")
    print("What each one needs: claude-accounts expired")
PYEOF
}

cmd_add() {
  # Login-FIRST: the account is registered (and synced) only after sign-in succeeds
  # and the authenticated email is read back from the account itself. The email
  # argument is OPTIONAL — omit it and it's derived from whoever you sign in as.
  # An aborted or failed sign-in leaves zero traces.
  require_manifest
  local email="" force=0 token=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --force) force=1; shift ;;
      --token) token=1; shift ;;   # portable setup-token instead of full login (server use)
      --tui) shift ;;              # accepted for compatibility; full login is now the default
      --*) die "unknown option: $1" ;;
      *)
        if [ -z "$email" ]; then email="$1"; shift
        else die "unexpected argument: $1 (usage: claude-accounts add [email] [--token] [--force])"; fi ;;
    esac
  done
  # If an email was named and it's already in the pool, SKIP before any sign-in — no
  # duplicate is ever created. (A graceful skip, exit 0: adding an existing account is
  # a no-op, not an error. Re-auth an existing account with 'login'.)
  local owner
  if [ -n "$email" ]; then
    owner="$(email_owner "$email")"
    if [ -n "$owner" ]; then
      echo "$email is already added as $owner — skipping (nothing to do; run 'claude-accounts login $owner' to re-authenticate it)."
      return 0
    fi
  fi
  if [ ! -t 0 ] && [ -z "${CLAUDE_MULTIACC_FORCE_TTY:-}" ]; then
    die "add is interactive (it completes sign-in before registering) — run it from a terminal"
  fi
  local real
  real="$(find_real_claude "$_self")" || die "real claude binary not found"

  # Reserve a unique id under a BRIEF lock (creating the dir claims the id, so a parallel
  # add gets the next one). The long browser sign-in below runs WITHOUT the lock, so
  # several `add` in different terminals proceed in parallel. RESERVED_DIR drives a trap
  # that removes the half-made dir on any failure/abort BEFORE registration.
  local id d got elabel
  elabel="${email:-the account you sign in as}"
  RESERVED_DIR=""
  trap 'add_cleanup_reserved; exit 130' INT TERM
  trap 'add_cleanup_reserved' EXIT
  mutate_lock || die "could not acquire the account lock (another op is stuck?) — try again"
  id="$(next_id)"
  d="$ACC_ROOT/$id"
  seed_account_dir "$d"
  RESERVED_DIR="$d"
  mutate_unlock

  if [ "$token" = "1" ]; then
    # Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
    echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
    run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created"
    ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
    chmod 600 "$d/server.token"
    got="$(token_email "$CEREMONY_TOKEN")"
  else
    # Default: full Claude Code login (full scopes, no long-lived-token step-up).
    # Writes auto-refreshing .credentials.json, valid on THIS machine.
    echo "Preparing $id for $elabel — NOTHING is registered until login completes."
    run_login_ceremony "$d" "$email" || die "login failed or aborted — nothing was created"
    got="$(config_dir_email "$d")"
  fi
  if [ -z "$got" ]; then
    if [ -n "$email" ] && [ "$force" = "1" ]; then
      warn "identity unverified — registering as $email because --force was given"
      got="$email"
    else
      die "signed in, but the account identity could not be read back — nothing was created (retry; or 'add <email> --force' to trust a named email)"
    fi
  fi
  # Dedup + register under a BRIEF lock, re-reading the manifest (a parallel add may have
  # registered the same email meanwhile — the loser skips gracefully). This is the
  # guarantee that a code pasted for an already-added account never yields a second entry.
  mutate_lock || die "could not acquire the account lock — try again"
  owner="$(email_owner "$got")"
  if [ -n "$owner" ]; then
    mutate_unlock
    echo "$got is already added as $owner — skipping (nothing added)."
    return 0   # RESERVED_DIR still set -> trap removes the temp dir
  fi
  if [ -n "$email" ] && [ "$got" != "$email" ]; then
    warn "signed in as $got (you named $email) — registering the account that actually authenticated"
  fi
  manifest_add_account "$id" "$got" "$(machine_kind)"
  clear_auth_markers "$d"
  mutate_unlock
  RESERVED_DIR=""            # committed — the trap must not delete it now
  trap - EXIT INT TERM
  log_to ops.log "add $id $got (auth-verified)"
  auto_sync
  cat <<EOF
Registered $id for $got (sign-in verified) — usable immediately.
Optional:
  claude-accounts verify       # confirm the 100% matrix
EOF
}

add_cleanup_reserved() {
  # EXIT trap for cmd_add: remove a reserved-but-uncommitted account dir (and, in case
  # we died mid-critical-section, release the lock).
  [ -n "${RESERVED_DIR:-}" ] && rm -rf "$RESERVED_DIR" 2>/dev/null
  mutate_unlock 2>/dev/null || true
}

cmd_import() {
  require_manifest
  local email="${1:-}"
  [ -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]"
  shift
  local id="" home="" creds="" mode="copy" token_file="" no_sync=0 force=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --id) id="$2"; shift 2 ;;
      --home) home="$2"; shift 2 ;;
      --creds) creds="$2"; shift 2 ;;
      --mode) mode="$2"; shift 2 ;;
      --token-file) token_file="$2"; shift 2 ;;
      --no-sync) no_sync=1; shift ;;
      --force) force=1; shift ;;
      *) die "unknown option: $1" ;;
    esac
  done
  [ -n "$id" ] || id="$(next_id)"
  [ -n "$home" ] || home="$(machine_kind)"
  case "$id" in acct-[0-9][0-9]) ;; *) die "id must look like acct-NN" ;; esac
  local owner
  owner="$(email_owner "$email")"
  if [ -n "$owner" ] && [ "$owner" != "$id" ] && [ "$force" != "1" ]; then
    die "$email is already registered as $owner — use --id $owner to update it, or --force to register a duplicate"
  fi
  # Validate ALL inputs before creating anything, so a bad token/creds path leaves no
  # half-made account dir behind.
  [ -n "$creds" ] && { [ -f "$creds" ] || die "credentials file not found: $creds"; }
  case "$mode" in copy|move|link) ;; *) die "mode must be copy|move|link" ;; esac
  local tok=""
  if [ -n "$token_file" ]; then
    if [ "$token_file" = "-" ]; then
      tok="$(tr -d '[:space:]')"
    else
      [ -f "$token_file" ] || die "token file not found: $token_file"
      tok="$(tr -d '[:space:]' < "$token_file")"
    fi
    valid_subscription_token "$tok" \
      || die "not a subscription setup-token (sk-ant-oat...). API keys are not supported."
  fi
  local d="$ACC_ROOT/$id"
  seed_account_dir "$d"
  if [ -n "$creds" ]; then
    case "$mode" in
      copy) cp "$creds" "$d/.credentials.json" ;;
      move) mv "$creds" "$d/.credentials.json" ;;
      link) ln -sf "$(canon_path "$creds")" "$d/.credentials.json" ;;
    esac
    chmod 600 "$d/.credentials.json" 2>/dev/null || true
  fi
  if [ -n "$tok" ]; then
    ( umask 077; printf '%s' "$tok" > "$d/server.token" )
    chmod 600 "$d/server.token"
  fi
  manifest_add_account "$id" "$email" "$home"
  log_to ops.log "import $id $email home=$home creds=${creds:+yes} mode=$mode token=${token_file:+yes}"
  echo "Imported $id ($email, home=$home)."
  [ "$no_sync" = "1" ] || auto_sync
}

# Prints duplicate account ids to REMOVE, one per line: for every email that appears
# more than once, keep exactly one (prefer an account that has auth on this machine,
# then the lowest id) and list the rest. Empty output => pool is already clean.
dup_ids_to_remove() {
  [ -f "$MANIFEST" ] || return 0
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF' 2>/dev/null
import json, os, re, sys
manifest, root = sys.argv[1], sys.argv[2]
accts = [a for a in json.load(open(manifest)).get('accounts', [])
         if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
def has_auth(aid):
    d = os.path.join(root, aid)
    c = os.path.join(d, '.credentials.json')
    t = os.path.join(d, 'server.token')
    return (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0)
by_email = {}
for a in accts:
    by_email.setdefault(a.get('email', '').lower(), []).append(a['id'])
for email, ids in by_email.items():
    if len(ids) < 2:
        continue
    # keep: authed first, then lowest id
    keep = sorted(ids, key=lambda i: (not has_auth(i), i))[0]
    for i in ids:
        if i != keep:
            print(i)
PYEOF
}

cmd_dedupe() {
  require_manifest
  local yes=0
  [ "${1:-}" = "--yes" ] && yes=1
  local dups
  dups="$(dup_ids_to_remove)"
  if [ -z "$dups" ]; then
    echo "No duplicate accounts — every email appears once."
    return 0
  fi
  echo "Duplicate accounts (same email registered more than once):"
  local id email
  for id in $dups; do
    email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
import json, sys
for a in json.load(open(sys.argv[1])).get('accounts', []):
    if a.get('id') == sys.argv[2]: print(a.get('email', '')); break
PYEOF
)"
    echo "  will remove $id ($email)"
  done
  if [ "$yes" != "1" ]; then
    printf 'Remove these duplicates (keeps one per email)? [y/N] '
    read -r ans
    case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
  fi
  for id in $dups; do
    if [ -L "$ACC_ROOT/$id" ]; then rm -f "${ACC_ROOT:?}/${id:?}"; else rm -rf "${ACC_ROOT:?}/${id:?}"; fi
    manifest_del_account "$id"
    log_to ops.log "dedupe removed $id"
  done
  echo "Removed $(printf '%s\n' "$dups" | grep -c .) duplicate account(s)."
  auto_sync
}

cmd_adopt() {
  # Make <acct-NN> THIS machine's existing default login (~/.claude) without
  # forking its OAuth grant: the account dir becomes a symlink to ~/.claude, so
  # there is exactly one credential file no matter which path refreshes it.
  require_manifest
  local id="${1:-}"
  [ -n "$id" ] || die "usage: claude-accounts adopt <acct-NN>"
  valid_acct_id "$id" || die "not a valid account id: $id"
  account_ids | grep -qx "$id" || die "unknown account: $id (import it first)"
  local d="$ACC_ROOT/$id" default="$HOME/.claude"
  [ -f "$default/.credentials.json" ] || die "no default login at $default to adopt"
  # Seed the config-dir state file inside ~/.claude (CLAUDE_CONFIG_DIR mode reads
  # <dir>/.claude.json, while default mode uses ~/.claude.json at HOME level).
  if [ ! -f "$default/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
    "$PYBIN" - "$HOME/.claude.json" "$default/.claude.json" <<'PYEOF'
import json, sys
doc = json.load(open(sys.argv[1]))
for k in ('oauthAccount', 'userID'):
    doc.pop(k, None)
import os
with open(sys.argv[2] + '.tmp', 'w') as f:
    json.dump(doc, f)
os.replace(sys.argv[2] + '.tmp', sys.argv[2])
PYEOF
  fi
  if [ -L "$d" ]; then
    echo "$id already adopted ($(readlink "$d"))"
    return 0
  fi
  if [ -d "$d" ]; then
    [ -f "$d/.credentials.json" ] && die "$id already has its own credentials — refusing to replace with adopt"
    rm -rf "${ACC_ROOT:?}/${id:?}"
  fi
  ln -s "$default" "$d"
  log_to ops.log "adopt $id -> $default"
  echo "$id now runs the default $default login (symlinked, single credential file)."
}

cmd_remove() {
  require_manifest
  local id="${1:-}" yes="${2:-}"
  [ -n "$id" ] || die "usage: claude-accounts remove <acct-NN> [--yes]"
  valid_acct_id "$id" || die "not a valid account id: $id"
  account_ids | grep -qx "$id" || die "unknown account: $id"
  if [ "$yes" != "--yes" ]; then
    printf 'Remove %s and propagate deletion to the server? [y/N] ' "$id"
    read -r ans
    case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
  fi
  if [ -L "$ACC_ROOT/$id" ]; then
    rm -f "${ACC_ROOT:?}/${id:?}"   # adopted account: remove the symlink, never the target
  else
    rm -rf "${ACC_ROOT:?}/${id:?}"
  fi
  manifest_del_account "$id"
  log_to ops.log "remove $id"
  auto_sync
  echo "Removed $id."
}

# Interactive sign-in ceremony: runs `claude setup-token` attached to the user's
# terminal — it prints a clickable sign-in link, the user completes OAuth in a
# browser and pastes the code back HERE, and the resulting long-lived token is
# captured. Sets CEREMONY_TOKEN on success (no stdout capture: the UI must stay
# visible and interactive).
# A subscription setup-token, and nothing else. API keys (sk-ant-api...) are rejected
# everywhere: this addon is subscription-only by design (spec requirement).
valid_subscription_token() {
  case "$1" in
    sk-ant-oat[0-9][0-9]-*) [ "${#1}" -ge 50 ] ;;
    *) return 1 ;;
  esac
}

CEREMONY_TOKEN=""
run_token_ceremony() { # $1 = config dir
  CEREMONY_TOKEN=""
  local d="$1" real cap old_umask
  real="$(find_real_claude "$_self")" || { warn "real claude binary not found"; return 1; }
  mkdir -p "$ACC_ROOT/tmp"
  cap="$ACC_ROOT/tmp/mint.$$.log"
  old_umask="$(umask)"
  umask 077   # the capture file briefly holds the raw token (removed inline below).
  # NB: no EXIT trap here — cmd_add owns the EXIT trap for its mutation lock, and a
  # second EXIT trap would clobber it and leak the lock.
  cat <<'TIP'
A sign-in link will appear below. For a smooth grant (avoids the "Sign in again
to continue" step-up screen):
  1. Open a fresh incognito/private window.
  2. Sign in at claude.ai to the EXACT account you're adding — do this FIRST.
  3. THEN paste the link below into that same window and approve
     ("Contribute to your Claude subscription usage" is the correct permission).
  4. Copy the code it shows and paste it back here.
If you do see "Sign in again to continue", that is Claude's security step, not an
error — just sign in to that account and approve; the code still appears.
TIP
  if [ -t 0 ]; then
    if [ "$(machine_kind)" = "mac" ]; then
      script -q "$cap" env CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token
    else
      script -q -c "CLAUDE_CONFIG_DIR='$d' CLAUDE_SHIM_ACTIVE=1 '$real' setup-token" "$cap"
    fi
  else
    # Headless (tests / piped code): capture into the 0600 file only. Never tee the
    # raw token to stdout — a redirected run would write the secret to a plain log.
    CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token > "$cap" 2>&1
    sed -E 's/sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]*/sk-ant-oat**-<redacted>/g' "$cap"
  fi
  umask "$old_umask"
  CEREMONY_TOKEN="$(grep -aoE 'sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]{40,}' "$cap" | tail -1)"
  rm -f "$cap"
  [ -n "$CEREMONY_TOKEN" ] || return 1
  valid_subscription_token "$CEREMONY_TOKEN" || {
    CEREMONY_TOKEN=""
    warn "captured credential is not a subscription setup-token"
    return 1
  }
}

token_email() { # $1 = token; prints the authenticated email (best effort)
  local real
  real="$(find_real_claude "$_self")" || { echo ""; return 0; }
  CLAUDE_CODE_OAUTH_TOKEN="$1" CLAUDE_SHIM_ACTIVE=1 "$real" auth status 2>/dev/null | "$PYBIN" -c '
import json, sys
try:
    doc = json.loads(sys.stdin.read())
    print(doc.get("email", "") if doc.get("loggedIn") else "")
except Exception:
    print("")'
}

config_dir_email() { # $1 = config dir; prints the logged-in email or empty
  local real
  real="$(find_real_claude "$_self")" || { echo ""; return 0; }
  CLAUDE_CONFIG_DIR="$1" CLAUDE_SHIM_ACTIVE=1 "$real" auth status 2>/dev/null | "$PYBIN" -c '
import json, sys
try:
    doc = json.loads(sys.stdin.read())
    print(doc.get("email", "") if doc.get("loggedIn") else "")
except Exception:
    print("")'
}

# Full subscription login: `claude auth login --claudeai`, the SAME flow the
# interactive app uses (claude.com/cai/oauth/authorize, full Claude Code scopes,
# normal sign-in — no long-lived-token step-up wall). Writes <dir>/.credentials.json
# (auto-refreshing). Returns success iff the credential landed. $2 = optional email hint.
run_login_ceremony() { # $1 = config dir, $2 = email hint
  local d="$1" hint="${2:-}" real
  real="$(find_real_claude "$_self")" || { warn "real claude binary not found"; return 1; }
  cat <<'TIP'
A browser will open (or a sign-in link will be printed) for the FULL Claude Code
login. Sign in as the account you want to add and approve; if a code is shown,
paste it after the "Paste code here" prompt and press Enter. This is the normal
login — it draws on the subscription, no API keys, and refreshes itself over time.
TIP
  # Run claude auth login DIRECTLY on the user's terminal (no `script` PTY wrapper):
  # we capture nothing here (the credential is written to .credentials.json), and a
  # PTY layer can suppress the echo of the pasted code. Direct invocation behaves
  # exactly like running `claude auth login` yourself, so the code you paste is shown.
  if [ -n "$hint" ]; then
    CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai --email "$hint" || true
  else
    CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai || true
  fi
  # Success is a credential that can AUTHENTICATE — not merely a file that exists.
  # Re-login targets already have a (dead) .credentials.json on disk, so a bare
  # existence test would call an aborted sign-in a success, clear the dead-auth
  # marker, and hand the account straight back to the pool.
  [ -f "$d/.credentials.json" ] || return 1
  creds_alive "$d"
}

cmd_mint() {
  require_manifest
  local id="${1:-}" paste="${2:-}"
  [ -n "$id" ] || die "usage: claude-accounts mint <acct-NN> [--paste]"
  valid_acct_id "$id" || die "not a valid account id: $id"
  local d="$ACC_ROOT/$id"
  [ -d "$d" ] || die "unknown account dir: $d"
  local tok=""
  if [ "$paste" = "--paste" ]; then
    printf 'Paste the sk-ant-oat... token: '
    read -r tok
    tok="$(printf '%s' "$tok" | tr -d '[:space:]')"
  else
    echo "Running 'claude setup-token' for $id — approve in a browser signed into THIS account."
    run_token_ceremony "$d" || die "no token captured — mint failed"
    tok="$CEREMONY_TOKEN"
  fi
  [ -n "$tok" ] || die "no token captured — mint failed"
  valid_subscription_token "$tok" \
    || die "that is not a subscription setup-token (sk-ant-oat...). API keys are not supported."
  ( umask 077; printf '%s' "$tok" > "$d/server.token" )
  chmod 600 "$d/server.token"
  clear_auth_markers "$d"
  log_to ops.log "mint $id"
  echo "Token saved to $d/server.token"
  auto_sync
}

cmd_login() {
  # Complete (or refresh) auth for an EXISTING account. Default: full Claude Code login
  # (auto-refreshing .credentials.json, this machine). --token: portable setup-token
  # (Mac + server). Verifies the signed-in email matches the manifest.
  require_manifest
  local id="" force=0 token=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --force) force=1; shift ;;
      --token) token=1; shift ;;
      --*) die "unknown option: $1" ;;
      *) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
    esac
  done
  [ -n "$id" ] || die "usage: claude-accounts login <acct-NN> [--token] [--force]"
  valid_acct_id "$id" || die "not a valid account id: $id"
  account_ids | grep -qx "$id" || die "unknown account: $id"
  local d="$ACC_ROOT/$id" email got
  seed_account_dir "$d"
  email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
import json, sys
for a in json.load(open(sys.argv[1])).get('accounts', []):
    if a['id'] == sys.argv[2]:
        print(a.get('email', ''))
        break
PYEOF
)"
  echo "Sign in as $email for $id."
  if [ "$token" = "1" ]; then
    run_token_ceremony "$d" || die "sign-in failed or aborted — nothing changed"
    got="$(token_email "$CEREMONY_TOKEN")"
    if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
      die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
    fi
    ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
    chmod 600 "$d/server.token"
    clear_auth_markers "$d"
    echo "$id token saved (portable — works on Mac and server)."
  else
    run_login_ceremony "$d" "$email" \
      || die "login failed or aborted (no working credential landed) — nothing changed"
    got="$(config_dir_email "$d")"
    if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
      die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
    fi
    if [ -z "$got" ] && [ "$force" != "1" ]; then
      die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
    fi
    clear_auth_markers "$d"
    echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
  fi
  log_to ops.log "login $id verified=${got:-unverified} mode=$([ "$token" = 1 ] && echo token || echo login)"
  auto_sync
}

cmd_expired() {
  # Which accounts CANNOT authenticate right now — the pool's re-login worklist.
  # Same rule the shim selects by (lib/audit.py), so what is listed here is exactly
  # what is excluded from selection.
  require_manifest
  local quiet=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --quiet|--ids) quiet=1; shift ;;   # ids only, for scripts
      *) die "unknown option: $1 (usage: claude-accounts expired [--quiet])" ;;
    esac
  done
  local rows bad
  rows="$(account_audit)" || die "the account audit failed — cannot say which logins are dead"
  if [ -z "$rows" ]; then
    # No rows at all: either the pool is genuinely empty, or the manifest lost its
    # accounts. Never render that as "all clear".
    if [ -z "$(account_ids)" ]; then
      echo "No accounts registered yet — add one with: claude-accounts add"
      return 0
    fi
    die "the manifest lists accounts but none could be audited — check $MANIFEST"
  fi
  bad="$(printf '%s\n' "$rows" | awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }')"
  if [ "$quiet" = "1" ]; then
    [ -n "$bad" ] || return 0
    printf '%s\n' "$bad"
    return 1
  fi
  printf '%s\n' "$rows" | awk -F'\t' '
    BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0 }
    NF < 4 || $1 == "" { next }          # never invent an account from a blank line
    $4 == "ok"     { ok++; next }
    $4 == "remote" { remote++; rem = rem sprintf("  %-9s %-28s %s\n", $1, $2, $6); next }
    {
      bad++
      relogin++
      printf "  %-9s %-28s %-9s %s\n", $1, $2, $5, $6
      printf "  %-9s %-28s %-9s fix: %s\n", "", "", "", $7
    }
    END {
      if (bad == 0) printf "All %d account(s) with a login on this machine can authenticate.\n", ok
      else printf "\n%d account(s) cannot be used, %d fine.\n", bad, ok
      if (remote > 0) {
        printf "\nNot logged in here on purpose (another machine owns the grant):\n"
        printf "%s", rem
      }
      if (relogin > 0) {
        printf "\nRe-authenticate them:\n"
        printf "  claude-accounts relogin            # every account that needs it\n"
        printf "  claude-accounts relogin acct-NN    # just one\n"
      }
    }'
  # Exit 1 when something needs a human, so cron/health checks can alert on it.
  [ -z "$bad" ]
}

cmd_relogin() {
  # Re-authenticate accounts whose login died. With no arguments it targets exactly
  # what `expired` lists; ids (or --all) override that. Runs the same verified login
  # ceremony as `login`, one account at a time, and syncs ONCE at the end.
  require_manifest
  local all=0 yes=0 token=0 ids=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --all) all=1; shift ;;
      --yes|-y) yes=1; shift ;;
      --token) token=1; shift ;;
      --*) die "unknown option: $1" ;;
      *)
        valid_acct_id "$1" || die "not a valid account id: $1"
        account_ids | grep -qx "$1" || die "unknown account: $1"
        ids="$ids $1"; shift ;;
    esac
  done
  if [ -n "$ids" ] && [ "$all" = "1" ]; then
    die "give account ids OR --all, not both"
  fi
  if [ -z "$ids" ]; then
    if [ "$all" = "1" ]; then
      ids="$(account_ids | tr '\n' ' ')"
    else
      # Only what a sign-in can actually fix — org-blocked accounts are listed by
      # `expired` but signing into them again would fail exactly the same way.
      # A FAILED audit must not read as "nothing to do".
      account_audit >/dev/null || die "the account audit failed — refusing to guess what needs a re-login"
      ids="$(accounts_needing_login | tr '\n' ' ')"
    fi
  fi
  ids="$(printf '%s' "$ids" | tr -s ' ' | sed 's/^ //; s/ $//')"
  if [ -z "$ids" ]; then
    echo "Nothing to re-authenticate — every account on this machine can be used."
    return 0
  fi
  local count rows
  count="$(printf '%s\n' "$ids" | tr ' ' '\n' | grep -c .)"
  rows="$(account_audit)"
  echo "Accounts to re-authenticate ($count):"
  local id
  for id in $ids; do
    printf '  %s  %s\n' "$id" \
      "$(printf '%s\n' "$rows" | awk -F'\t' -v i="$id" '$1 == i { print $2 "  (" $5 ")" }')"
  done
  if [ "$yes" != "1" ]; then
    if [ ! -t 0 ]; then
      die "relogin is interactive (each account needs a browser sign-in) — run it from a terminal, or pass --yes"
    fi
    printf 'Sign in to each of them now? [y/N] '
    read -r ans
    case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
  fi
  # One sync at the end instead of one per account: each auto_sync is an ssh round trip.
  local prev_no_sync="${CLAUDE_MULTIACC_NO_SYNC:-0}" failed="" done_ok=0
  export CLAUDE_MULTIACC_NO_SYNC=1
  for id in $ids; do
    echo
    echo "=== $id ==============================================================="
    if [ "$token" = "1" ]; then
      ( cmd_login "$id" --token ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
    else
      ( cmd_login "$id" ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
    fi
  done
  export CLAUDE_MULTIACC_NO_SYNC="$prev_no_sync"
  [ "$prev_no_sync" = "0" ] && unset CLAUDE_MULTIACC_NO_SYNC
  echo
  echo "re-authenticated $done_ok of $count account(s)."
  if [ -n "$failed" ]; then
    warn "still failing:$failed (re-run: claude-accounts relogin$failed)"
  fi
  [ "$done_ok" -gt 0 ] && auto_sync
  [ -z "$failed" ]
}

cmd_limits() {
  require_manifest
  local quiet=0 force=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --quiet) quiet=1; shift ;;
      --force) force=1; shift ;;   # ignore freshness/backoff (manual override)
      *) die "unknown option: $1" ;;
    esac
  done
  local lock="$ACC_ROOT/.locks/limits"
  mkdir -p "$ACC_ROOT/.locks"
  if ! mkdir "$lock" 2>/dev/null; then
    local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
    if [ "$age" -lt 120 ]; then
      [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
      return 0
    fi
    rm -rf "$lock"
    mkdir "$lock" 2>/dev/null || return 0
  fi
  # shellcheck disable=SC2064
  trap "rm -rf '$lock'" EXIT
  rotate_log limits.log
  # NB: expired OAuth access tokens are refreshed inside the Python below via the
  # refresh-token grant. (`claude auth status` was tried for this and does NOT
  # refresh credentials — it only reports the on-disk state.)
  # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
  # never loosen it, or an account could sit at 95% and still be selected.
  local threshold
  threshold="$(manifest_get threshold 90)"
  case "$threshold" in ''|*[!0-9]*) threshold=90 ;; esac
  [ "$threshold" -gt 90 ] && threshold=90
  [ "$threshold" -lt 1 ] && threshold=90
  "$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
import json, os, sys, time, urllib.request

root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
now = time.time()
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '45'))

# OAuth refresh-token grant — the same endpoint + public client id Claude Code
# itself uses to keep .credentials.json alive. An account that sits idle past its
# access-token TTL would otherwise drop out of telemetry forever (stale data ranks
# neutral, so truly-idle accounts lose selection to busy-but-fresh ones).
TOKEN_URL = os.environ.get('CLAUDE_MULTIACC_TOKEN_URL',
                           'https://console.anthropic.com/v1/oauth/token')
CLIENT_ID = os.environ.get('CLAUDE_MULTIACC_CLIENT_ID',
                           '9d1c250a-e61b-44d9-88ed-5944d1962f5e')
# Only refresh a token that has been expired for a while: a LIVE session refreshes
# its own credential within moments of expiry, so a long-expired one proves no
# other writer is active (refresh tokens rotate; two racing refreshers strand one).
REFRESH_MIN_EXPIRED = 300
REFRESH_FAIL_BACKOFF = 600     # transient (network/5xx/429): retry in 10 min
REFRESH_DENIED_BACKOFF = 21600  # 4xx = grant likely revoked: 6h; re-login needed anyway

def say(msg):
    if not quiet:
        print(msg)
    with open(os.path.join(root, 'limits.log'), 'a') as f:
        f.write(time.strftime('%Y-%m-%dT%H:%M:%SZ ', time.gmtime()) + msg + '\n')

def parse_iso(s):
    if not s:
        return None
    import datetime
    try:
        return datetime.datetime.fromisoformat(s.replace('Z', '+00:00')).timestamp()
    except Exception:
        return None

# `.expired` — the persistent "this account cannot authenticate" marker the shim
# honors. Written only for a PROVEN dead grant (expired/absent refresh token, or a
# 4xx from the refresh endpoint), never for a transient network/5xx/429 hiccup.
def mark_expired(d, slug, detail=''):
    mpath = os.path.join(d, '.expired')
    try:
        with open(mpath + '.tmp', 'w') as f:
            f.write(f'{int(now)}\n')
            f.write(f"reason={slug} marked_at="
                    f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
        os.replace(mpath + '.tmp', mpath)
    except Exception:
        pass

def clear_expired(d):
    """A usage fetch that succeeded PROVES the bearer works — drop any dead-auth mark.
    EXCEPT an org-blocked one: those accounts authenticate perfectly (telemetry works),
    they are just barred from Claude Code inference, so telemetry says nothing about
    them. Only a passing `verify` (a real call) or a re-login lifts that."""
    mpath = os.path.join(d, '.expired')
    try:
        if 'reason=org-blocked' in open(mpath, errors='replace').read():
            return False
    except OSError:
        return False
    try:
        os.remove(mpath)
        return True
    except OSError:
        return False

try:
    manifest = json.load(open(os.path.join(root, 'accounts.json')))
except Exception as e:
    sys.exit(f'cannot read manifest: {e}')

def refresh_oauth(aid, d, cpath):
    """Refresh a long-expired OAuth access token via the refresh-token grant and
    persist the ROTATED credential atomically (0600). Returns the new bearer, or
    None (fail open: the on-disk credential is never touched on failure).
    Failures back off via <dir>/.oauth-refresh.json — a side file, NOT limits.json,
    because telemetry state must only ever reflect real usage fetches."""
    spath = os.path.join(d, '.oauth-refresh.json')
    try:
        doc = json.load(open(cpath))
        o = doc.get('claudeAiOauth', {})
        # Present-but-null/non-object claudeAiOauth (interrupted or reset credential
        # write) must degrade THIS account only, like every other malformed input.
        if not isinstance(doc, dict) or not isinstance(o, dict):
            return None
    except Exception:
        return None
    if not o.get('accessToken'):
        return None
    if not o.get('refreshToken'):
        mark_expired(d, 'no-refresh-token',
                     'credential has no refresh token and its access token expired')
        return None
    if o.get('expiresAt', 0) / 1000.0 > now - REFRESH_MIN_EXPIRED:
        return None  # not expired long enough to prove no live session owns it
    if o.get('refreshTokenExpiresAt', 0) / 1000.0 <= now:
        # Nothing can revive this account: park it so the shim stops selecting it
        # (every run under it would fail with "OAuth session expired").
        mark_expired(d, 'refresh-token-expired',
                     'the refresh token itself expired; only a re-login can fix it')
        say(f'{aid}: refresh token expired — re-login needed (claude-accounts relogin {aid})')
        return None
    if not force:
        try:
            if json.load(open(spath)).get('retry_after', 0) > now:
                return None  # earlier refresh failure still backing off
        except Exception:
            pass

    def back_off(wait, why, denials=0):
        try:
            with open(spath + '.tmp', 'w') as f:
                json.dump({'retry_after': int(now + wait), 'error': why,
                           'denials': denials,
                           'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
            os.replace(spath + '.tmp', spath)
        except Exception:
            pass
        say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')

    body = json.dumps({'grant_type': 'refresh_token',
                       'refresh_token': o['refreshToken'],
                       'client_id': CLIENT_ID}).encode()
    req = urllib.request.Request(TOKEN_URL, data=body, headers={
        'Content-Type': 'application/json',
        'User-Agent': 'claude-multiacc/1.0',
    })
    try:
        data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
    except urllib.error.HTTPError as e:
        if e.code in (400, 401, 403):
            # A 4xx from the TOKEN endpoint is only proof of a dead grant when the
            # server says so (OAuth's invalid_grant). Everything else 4xx — a bad
            # client_id, an endpoint change, a WAF page, a provider incident — would
            # hit EVERY account at once, so it must never park the whole pool on the
            # first try: back off, and only park after this account has been refused
            # repeatedly.
            body = ''
            try:
                body = e.read().decode('utf-8', 'replace')[:400]
            except Exception:
                pass
            denials = 1
            try:
                denials = int(json.load(open(spath)).get('denials', 0)) + 1
            except Exception:
                pass
            if 'invalid_grant' in body:
                mark_expired(d, f'refresh-denied-http-{e.code}',
                             'the refresh grant was refused as invalid_grant (revoked or rotated away)')
            elif denials >= 3:
                mark_expired(d, f'refresh-denied-http-{e.code}',
                             f'the refresh grant was refused {denials} times in a row')
            back_off(REFRESH_DENIED_BACKOFF,
                     f'HTTP {e.code} — refresh token may be revoked; re-login needed',
                     denials=denials)
        else:
            back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
        return None
    except Exception as e:
        back_off(REFRESH_FAIL_BACKOFF, str(e)[:200])
        return None
    tok = data.get('access_token') if isinstance(data, dict) else None
    if not tok:
        back_off(REFRESH_DENIED_BACKOFF, 'no access_token in response')
        return None
    o['accessToken'] = tok
    # The grant ROTATES the refresh token: persist it (and both expiries) or the
    # account is stranded — hence atomic write, and a loud message if it fails.
    if data.get('refresh_token'):
        o['refreshToken'] = data['refresh_token']
    if data.get('expires_in'):
        o['expiresAt'] = int((now + float(data['expires_in'])) * 1000)
    else:
        # No expires_in in the response: assume a conservative 1h. Leaving the old
        # (past) expiresAt would make every later pass re-run the grant in a loop.
        o['expiresAt'] = int((now + 3600) * 1000)
    if data.get('refresh_token_expires_in'):
        o['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
    doc['claudeAiOauth'] = o
    try:
        fd = os.open(cpath + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, 'w') as f:
            json.dump(doc, f)
        os.replace(cpath + '.tmp', cpath)
    except Exception as e:
        say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
        return None
    try:
        os.remove(spath)
    except OSError:
        pass
    # The grant answered: whatever parked this account before, it authenticates now.
    if clear_expired(d):
        say(f'{aid}: dead-auth marker cleared (refresh grant works again)')
    say(f'{aid}: oauth access token refreshed via refresh-token grant')
    return tok

for acct in manifest.get('accounts', []):
    aid = acct['id']
    d = os.path.join(root, aid)
    if not os.path.isdir(d):
        continue

    # The usage endpoint rate-limits per account. Several callers can fire at once
    # (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
    # this account's data is already fresh, and honor any backoff a 429 set earlier.
    # Checked FIRST so a skipped account never burns an oauth refresh for nothing.
    lpath = os.path.join(d, 'limits.json')
    prev = {}
    if os.path.isfile(lpath):
        try:
            prev = json.load(open(lpath))
        except Exception:
            prev = {}
    if not force:
        age = now - prev.get('fetched_at', 0)
        if age < MIN_FETCH_INTERVAL:
            continue
        retry_at = prev.get('retry_after', 0)
        if retry_at > now:
            say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
            continue

    bearer = None
    source = None
    cpath = os.path.join(d, '.credentials.json')
    if os.path.isfile(cpath):
        try:
            c = json.load(open(cpath)).get('claudeAiOauth', {})
            if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
                bearer, source = c['accessToken'], 'oauth'
        except Exception:
            pass
    tpath = os.path.join(d, 'server.token')
    if not bearer and os.path.isfile(tpath):
        t = open(tpath).read().strip()
        if t:
            bearer, source = t, 'token'
    if not bearer and os.path.isfile(cpath):
        # Hard fail-open guard: NOTHING a single account's refresh does may abort
        # the loop — every account after it would silently starve of telemetry.
        try:
            tok = refresh_oauth(aid, d, cpath)
        except Exception as e:
            say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
            tok = None
        if tok:
            bearer, source = tok, 'oauth'
    if not bearer:
        # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
        say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
        continue

    req = urllib.request.Request(url, headers={
        'Authorization': 'Bearer ' + bearer,
        'anthropic-beta': 'oauth-2025-04-20',
        'Content-Type': 'application/json',
        'User-Agent': 'claude-multiacc/1.0',
    })
    try:
        resp = urllib.request.urlopen(req, timeout=15)
        data = json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        if e.code == 429:
            # Respect Retry-After; otherwise exponential backoff capped at 30 min.
            try:
                wait = int(e.headers.get('Retry-After') or 0)
            except (TypeError, ValueError):
                wait = 0
            if wait <= 0:
                wait = min(1800, max(120, int(prev.get('backoff', 60)) * 2))
            prev['retry_after'] = int(now + wait)
            prev['backoff'] = wait
            tmp = lpath + '.tmp'
            with open(tmp, 'w') as f:
                json.dump(prev, f, indent=1)
            os.replace(tmp, lpath)
            say(f'{aid}: rate limited (429); backing off {wait}s; failing open')
        else:
            say(f'{aid}: usage fetch failed (HTTP {e.code}); failing open')
        continue
    except Exception as e:
        say(f'{aid}: usage fetch failed ({e}); failing open')
        continue

    def pct_of(v):
        try:
            return max(0, min(100, int(round(float(v)))))
        except (TypeError, ValueError):
            return None

    def classify_group(kind):
        # session (5h, self-healing) vs weekly (multi-day, expensive) vs monthly.
        # Unknown durable buckets default to 'weekly' so they are never under-weighted.
        k = kind.lower()
        if k.startswith('session') or k in ('five_hour', 'fivehour', '5h'):
            return 'session'
        if 'month' in k:
            return 'monthly'
        return 'weekly'

    # Shape-agnostic bucket extraction: every entry in limits[] becomes a bucket
    # named kind[:model]. If Anthropic drops the per-model (Fable) separation,
    # renames kinds, or reshapes scope, whatever buckets remain are still tracked
    # and the >=90% rule keeps working. Entries we cannot parse are skipped, and
    # any per-account surprise degrades that account only (fail open), never the run.
    buckets = []
    try:
        for lim in (data.get('limits') or []):
            if not isinstance(lim, dict):
                continue
            pct = pct_of(lim.get('percent'))
            if pct is None:
                continue
            kind = str(lim.get('kind') or 'unknown')
            name = kind
            scope = lim.get('scope') or {}
            model = None
            if isinstance(scope, dict):
                m = scope.get('model')
                if isinstance(m, dict):
                    model = m.get('display_name') or m.get('id')
            if model:
                name = f'{name}:{model}'
            resets = lim.get('resets_at')
            buckets.append({
                'name': name,
                'kind': kind,
                'group': str(lim.get('group') or classify_group(kind)),
                'percent': pct,
                'resets_at': resets,
                'resets_epoch': int(parse_iso(resets) or now + 3600),
            })
        if not buckets:
            # Fallback for a payload with no limits[] array: scan EVERY top-level object
            # carrying a utilization, so per-model buckets (seven_day_opus,
            # seven_day_fable, ...) are picked up too — not just five_hour/seven_day.
            # Missing one would let an exhausted model bucket go unnoticed.
            for k, b in sorted(data.items()):
                if not isinstance(b, dict) or k == 'extra_usage':
                    continue
                pct = pct_of(b.get('utilization'))
                if pct is None:
                    continue
                buckets.append({
                    'name': k,
                    'kind': k,
                    'group': classify_group(k),
                    'percent': pct,
                    'resets_at': b.get('resets_at'),
                    'resets_epoch': int(parse_iso(b.get('resets_at')) or now + 3600),
                })
    except Exception as e:
        say(f'{aid}: unexpected usage payload shape ({e}); failing open')
        continue
    # Successful fetch: fresh buckets replace everything, backoff state is dropped.
    # THREE selection signals, per the documented reset asymmetry:
    #   max_percent    — peak of ALL buckets; drives >=90% EXCLUSION (a full session
    #                    bucket really does block, but its marker expires in ~5h).
    #   weekly_percent — peak of the durable (weekly/monthly) buckets; the PRIMARY
    #                    ranking signal, because weekly headroom only returns on the
    #                    account's fixed weekly reset (days away).
    #   session_percent— peak of the self-healing 5h bucket; a soft tiebreaker only.
    maxp = max([b['percent'] for b in buckets] or [0])
    weekly = [b['percent'] for b in buckets if b['group'] != 'session']
    session = [b['percent'] for b in buckets if b['group'] == 'session']
    weeklyp = max(weekly) if weekly else maxp
    sessionp = max(session) if session else 0
    out = {'fetched_at': int(now), 'source': source, 'max_percent': maxp,
           'weekly_percent': weeklyp, 'session_percent': sessionp, 'buckets': buckets}
    tmp = lpath + '.tmp'
    with open(tmp, 'w') as f:
        json.dump(out, f, indent=1)
    os.replace(tmp, lpath)
    # The fetch went through with this account's own bearer => its auth is alive.
    if clear_expired(d):
        say(f'{aid}: dead-auth marker cleared (authenticated successfully)')
    offenders = [b for b in buckets if b['percent'] >= threshold]
    mpath = os.path.join(d, '.limited')
    if offenders:
        worst = max(offenders, key=lambda b: b['percent'])
        reset_epoch = max(int(b['resets_epoch']) for b in offenders)
        # Atomic: a concurrent shim must never read a half-written marker.
        with open(mpath + '.tmp', 'w') as f:
            f.write(f'{reset_epoch}\n')
            f.write(f"bucket={worst['name']} percent={worst['percent']} "
                    f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
                    f"reason=limits resets_at={worst['resets_at']}\n")
        os.replace(mpath + '.tmp', mpath)
        say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
    else:
        if os.path.exists(mpath):
            # A shim-written error-cooldown marker outlives a clean limits pass:
            # the account failed a real call moments ago; give the cooldown its window.
            keep = False
            try:
                txt = open(mpath).read()
                first = txt.splitlines()[0] if txt else ''
                if 'reason=error-cooldown' in txt and first.isdigit() and int(first) > now:
                    keep = True
            except Exception:
                pass
            if not keep:
                os.remove(mpath)
                say(f'{aid}: marker cleared (max {maxp}%)')
        if not quiet:
            detail = '  '.join(f"{b['name']}={b['percent']}%" for b in buckets)
            print(f'{aid}: ok  {detail}')
PYEOF
}

cmd_verify() {
  require_manifest
  local quick=0
  [ "${1:-}" = "--quick" ] && quick=1
  local real=""
  if [ "$quick" = "0" ]; then
    real="$(find_real_claude "$_self")" || die "real claude binary not found"
  fi
  "$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
import json, os, re, subprocess, sys, time

root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
from audit import audit_account, creds_state   # noqa: E402  (shared with the shim's rule)
machine = sys.argv[5]
now = time.time()
manifest = json.load(open(os.path.join(root, 'accounts.json')))
failures = 0
tested = 0

# Same failure vocabulary the shim retries on (bin/claude: AUTHPAT / ORGPAT).
AUTH_ERR = re.compile(
    r'401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)'
    r'|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)|session expired'
    r'|could not be refreshed|please (run|sign in|log ?in)|re-?authenticate', re.I)
ORG_ERR = re.compile(
    r'organization has disabled|subscription access.*disabl|disabled claude subscription'
    r'|ask your admin to enable|not authorized to use claude code', re.I)

def mark_expired(d, slug, detail=''):
    """Park an account the shim must stop selecting. Verify is the strongest signal
    there is — a real inference call that came back 'not authenticated'."""
    mpath = os.path.join(d, '.expired')
    try:
        with open(mpath + '.tmp', 'w') as f:
            f.write(f'{int(time.time())}\n')
            f.write(f"reason={slug} marked_at="
                    f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
        os.replace(mpath + '.tmp', mpath)
    except Exception:
        pass

for acct in manifest.get('accounts', []):
    aid = acct['id']
    d = os.path.join(root, aid)
    cpath = os.path.join(d, '.credentials.json')
    tpath = os.path.join(d, 'server.token')
    has_creds = os.path.isfile(cpath)
    has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
    if not has_creds and not has_token:
        print(f'{aid} {acct["email"]}: SKIP (no auth on this machine)')
        continue
    tested += 1
    if has_creds:
        try:
            c = json.load(open(cpath)).get('claudeAiOauth', {})
            rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
            if rexp and rexp < now and not has_token:
                mark_expired(d, 'refresh-token-expired',
                             'the refresh token itself expired; only a re-login can fix it')
                print(f'{aid} {acct["email"]}: FAIL (refresh token expired — '
                      f'run: claude-accounts relogin {aid})')
                failures += 1
                continue
        except Exception as e:
            print(f'{aid} {acct["email"]}: FAIL (unreadable credentials: {e})')
            failures += 1
            continue
    if quick:
        # Quick mode must agree with what the shim will actually do — presence of a
        # credential file is not proof it can authenticate.
        st = audit_account(root, acct, machine=machine)
        kind = 'oauth' if has_creds else 'token'
        if st['state'] == 'ok':
            print(f'{aid} {acct["email"]}: OK (quick, {kind} present)')
        else:
            print(f'{aid} {acct["email"]}: FAIL ({st["label"]} — {st["reason"]})'
                  + (f'; fix: {st["fix"]}' if st['fix'] else ''))
            failures += 1
        continue
    env = dict(os.environ)
    env['CLAUDE_CONFIG_DIR'] = d
    env.pop('ANTHROPIC_API_KEY', None)
    env.pop('CLAUDE_CODE_OAUTH_TOKEN', None)
    env.pop('CLAUDE_ACCOUNT', None)
    env['CLAUDE_SHIM_ACTIVE'] = '1'
    # Mirror the shim's acct_token(): a portable token is what actually authenticates
    # whenever there is no credential OR the credential beside it is dead. Testing such
    # an account with the dead credential would fail it — and park a healthy account.
    if has_token and (not has_creds or creds_state(cpath, now)[0] != 'ok'):
        env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
    t0 = time.time()
    try:
        r = subprocess.run([real, '-p', 'Reply with exactly: OK'],
                           env=env, capture_output=True, text=True, timeout=240,
                           stdin=subprocess.DEVNULL, cwd=root)
    except subprocess.TimeoutExpired:
        print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
        failures += 1
        continue
    dt = time.time() - t0
    out = (r.stdout or '').strip()
    if r.returncode == 0 and 'ok' in out.lower():
        # A real call succeeded: this account is definitively alive.
        try:
            os.remove(os.path.join(d, '.expired'))
        except OSError:
            pass
        print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
    else:
        err = (r.stderr or '').strip()[:200]
        hint = ''
        if ORG_ERR.search(out) or ORG_ERR.search(err):
            # Not an auth problem: the account authenticates fine, its organization
            # has simply turned Claude Code subscription access off. Park it — a
            # re-login changes nothing — and say what actually helps.
            mark_expired(d, 'org-blocked',
                         "the account's organization has disabled Claude Code access")
            hint = (f' — ORG BLOCKED, excluded from the pool; '
                    f'try: claude-accounts relogin {aid}')
        elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
            mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
            hint = f' — login is dead, run: claude-accounts relogin {aid}'
        print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} '
              f'out={out[:120]!r} err={err!r}{hint}')
        failures += 1

print()
print(f'verified {tested} account(s), {failures} failure(s)')
sys.exit(1 if failures else 0)
PYEOF
}

cmd_sync() {
  require_manifest
  [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
  local allow_empty=0
  [ "${1:-}" = "--allow-empty" ] && allow_empty=1
  local server sroot srepo rc=0
  server="$(manifest_get server "$DEFAULT_SERVER")"
  sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
  srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
  # These land inside a remote shell command — anything but a plain target/path is a
  # command-injection vector from a corrupted or hand-edited manifest.
  valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
  valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
  valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
  rotate_log sync.log
  slog() { log_to sync.log "$*"; }
  fail() { slog "FAIL: $*"; printf 'claude-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
  slog "sync start -> $server:$sroot"

  # Hard validation before anything destructive: a corrupt or accountless manifest must
  # never be pushed (it would blank the server's pool), and must never make the removal
  # loop below wipe the server's credentials. Emptying the pool on purpose is possible
  # via `sync --allow-empty`, so this can never happen by accident.
  "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
import json, re, sys
doc = json.load(open(sys.argv[1]))
accounts = doc.get('accounts')
if not isinstance(accounts, list):
    sys.exit(1)
for a in accounts:
    if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
       or not str(a.get('email', '')).strip():
        sys.exit(1)
PYEOF
  if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
    fail "manifest has zero accounts — refusing to blank the server pool (use 'sync --allow-empty' if that is really intended)"
  fi

  ssh -o BatchMode=yes -o ConnectTimeout=10 "$server" "mkdir -p '$sroot'" >>"$ACC_ROOT/sync.log" 2>&1 \
    || fail "cannot reach $server"
  rsync -az "$MANIFEST" "$server:$sroot/accounts.json" >>"$ACC_ROOT/sync.log" 2>&1 \
    || fail "manifest push failed"

  local id d
  for id in $(account_ids); do
    d="$ACC_ROOT/$id"
    [ -d "$d" ] || continue
    ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
      || fail "mkdir $id failed"
    if [ -s "$d/server.token" ]; then
      # NB: no --chmod — macOS 26 ships openrsync, which rejects it (the push would
      # fail outright). The mode is fixed with an explicit remote chmod instead.
      rsync -az "$d/server.token" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
        || fail "token push for $id failed"
      ssh -o BatchMode=yes "$server" "chmod 600 '$sroot/$id/server.token'" \
        >>"$ACC_ROOT/sync.log" 2>&1 || fail "token chmod for $id failed"
    fi
    local seed
    for seed in .claude.json settings.json; do
      if [ -f "$d/$seed" ]; then
        rsync -az --ignore-existing "$d/$seed" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
          || fail "seed push for $id/$seed failed"
      fi
    done
    # Advisory limit state for accounts the server may lack a bearer for.
    local extra
    for extra in limits.json .limited; do
      if [ -f "$d/$extra" ]; then
        rsync -az "$d/$extra" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 || true
      fi
    done
  done

  # Removal propagation: any server acct dir not in the manifest gets deleted.
  # Safety: an EMPTY id list (parse error, or a truly emptied pool) never deletes —
  # wiping every server credential must be an explicit manual act, not a side effect.
  local ids_list ids_spaced
  ids_list="$(account_ids)"
  if [ -z "$ids_list" ]; then
    slog "removal propagation skipped: empty account list (safety guard)"
  else
    ids_spaced=" $(printf '%s' "$ids_list" | tr '\n' ' ') "
    ssh -o BatchMode=yes "$server" "cd '$sroot' 2>/dev/null || exit 0
for dd in acct-*; do
  [ -d \"\$dd\" ] || continue
  case '$ids_spaced' in
    *\" \$dd \"*) ;;
    *) rm -rf -- \"\$dd\" ;;
  esac
done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation failed"
  fi

  # Post-sync hook: server seeds dirs and re-runs its quick verification matrix.
  ssh -o BatchMode=yes "$server" "[ -x '$srepo/bin/claude-accounts' ] && '$srepo/bin/claude-accounts' post-sync" \
    >>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook not available yet (server not bootstrapped?)"

  slog "sync ok"
  echo "sync ok -> $server:$sroot"
  return $rc
}

cmd_post_sync() {
  require_manifest
  local id d
  for id in $(account_ids); do
    d="$ACC_ROOT/$id"
    seed_account_dir "$d"
    [ -f "$d/server.token" ] && chmod 600 "$d/server.token" 2>/dev/null
    [ -f "$d/.credentials.json" ] && chmod 600 "$d/.credentials.json" 2>/dev/null
  done
  log_to sync.log "post-sync: seeded $(account_ids | wc -l | tr -d ' ') account dirs"
  ( cmd_limits --quiet ) || true   # subshell: release the limits lock before verify
  cmd_verify --quick
}

cmd_self_update() {
  # Update the addon in place. npm global install => npm i -g @latest (its postinstall
  # re-runs install.sh). git checkout => git pull + ./install.sh. Anything else is a
  # no-op with a hint. Best-effort and fully logged; never disrupts a running claude.
  local quiet=0
  [ "${1:-}" = "--quiet" ] && quiet=1
  rotate_log update.log
  ulog() { log_to update.log "$*"; [ "$quiet" = "1" ] || echo "$*"; }
  case "$REPO_DIR" in
    */node_modules/claude-multiacc|*/node_modules/claude-multiacc/*)
      command -v npm >/dev/null 2>&1 || { ulog "self-update: npm not found; skipping"; return 0; }
      local cur lat
      cur="$(npm ls -g --depth=0 claude-multiacc 2>/dev/null | sed -n 's/.*claude-multiacc@//p' | head -1)"
      lat="$(npm view claude-multiacc version 2>/dev/null)"
      if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
        ulog "self-update: already latest ($cur)"
        return 0
      fi
      ulog "self-update: npm $cur -> ${lat:-latest}"
      if npm install -g claude-multiacc@latest >>"$ACC_ROOT/update.log" 2>&1; then
        ulog "self-update: npm update ok"
      else
        ulog "self-update: npm update FAILED (see update.log)"
        return 1
      fi
      ;;
    *)
      if [ -d "$REPO_DIR/.git" ] && command -v git >/dev/null 2>&1; then
        ulog "self-update: git pull in $REPO_DIR"
        if git -C "$REPO_DIR" pull --ff-only >>"$ACC_ROOT/update.log" 2>&1; then
          "$REPO_DIR/install.sh" >>"$ACC_ROOT/update.log" 2>&1 \
            && ulog "self-update: git update + reinstall ok" \
            || { ulog "self-update: reinstall FAILED"; return 1; }
        else
          ulog "self-update: git pull FAILED (local changes? see update.log)"
          return 1
        fi
      else
        ulog "self-update: not an npm or git install ($REPO_DIR) — update manually"
      fi
      ;;
  esac
}

cmd_health() {
  require_manifest
  rotate_log health.log
  local out rc=0
  # verify first: its real claude runs refresh any expired OAuth creds, so the
  # limits pass that follows always has fresh bearers.
  out="$( { echo "== verify =="; cmd_verify; echo; echo "== limits =="; cmd_limits; } 2>&1 )" || rc=1
  printf '%s\n' "$out"
  printf '%s health rc=%s\n%s\n' "$(ts_utc)" "$rc" "$out" >> "$ACC_ROOT/health.log"
  if [ "$rc" -ne 0 ] && [ "$(machine_kind)" = "mac" ]; then
    osascript -e 'display notification "claude-multiacc health check FAILED — run claude-accounts status" with title "claude-multiacc"' 2>/dev/null || true
  fi
  return $rc
}

case "${1:-help}" in
  list) shift; cmd_list "$@" ;;
  status) shift; cmd_status "$@" ;;
  add) shift; cmd_add "$@" ;;
  import) shift; cmd_import "$@" ;;
  adopt) shift; cmd_adopt "$@" ;;
  dedupe) shift; cmd_dedupe "$@" ;;
  remove) shift; cmd_remove "$@" ;;
  mint) shift; cmd_mint "$@" ;;
  login) shift; cmd_login "$@" ;;
  expired) shift; cmd_expired "$@" ;;
  relogin|re-login) shift; cmd_relogin "$@" ;;
  sync) shift; cmd_sync "$@" ;;
  verify) shift; cmd_verify "$@" ;;
  limits) shift; cmd_limits "$@" ;;
  post-sync) shift; cmd_post_sync "$@" ;;
  health) shift; cmd_health "$@" ;;
  self-update) shift; cmd_self_update "$@" ;;
  help|--help|-h) usage ;;
  *) usage; exit 1 ;;
esac
