#!/usr/bin/env bash
# claude-accounts — save and switch the live Claude Code keychain login.
# Saved identities pair credentials with oauthAccount; new sessions take the live login.
# Running sessions keep their own account. Session account attribution is inferred.
# No config directories, shell function, or active pointer are used.
set -euo pipefail
exec python3 - "$0" "$@" <<'PY'
import contextlib
import datetime as dt
import fcntl
import json
import math
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request

ROOT = Path(os.environ.get('CLAUDE_ACCOUNTS_DIR', str(Path.home() / '.claude-accounts')))
CONFIG = Path(os.environ.get('CLAUDE_ACCOUNTS_CLAUDE_JSON', str(Path.home() / '.claude.json')))
LIVE = 'Claude Code-credentials'
USER = os.environ.get('USER', '')
SCRIPT = Path(sys.argv[1]).resolve()
USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
REFRESH_URL = 'https://console.anthropic.com/v1/oauth/token'
HELP = '''claude-accounts — saved keychain logins; one live Claude Code login
  save <name>                 capture the live credentials and oauthAccount
  rename <old> <new>         rename a saved login and its switch history
  forget <name>               delete only the saved copy
  list                        list indexed identities without keychain reads
  whoami [--json]             identify the live login (unknown exits 1)
  active                      print its saved name, or unknown (compatibility)
  use [--discard-live] <name> save back rotated live tokens, then switch login
  status                      LIVE NAME EMAIL 5H% 5H RESETS 7D% 7D RESETS REFRESH-TOKEN NOTE
  --tsv status                raw rows: live, name, email, 5h, reset, 7d, reset, fable weekly, refresh days, note
  pick [--use]                choose expiring 7d headroom, skipping 5h >= 90%
  refresh <name>              manually refresh a NON-live saved account
  sessions [--json]           inferred session accounts and up to five /login suggestions
  alerts [--notify]           capacity, refresh-token expiry and unsaved-live warnings
  serve [--port N]            loopback API (default 47311)
  --help
NEW sessions take the live keychain login; running sessions keep their own account.
Session attribution is inferred from switch.log; a hand /login is invisible.
Refresh is manual because refresh-token single-use behavior is not yet measured.
Saved secrets stay in keychain services claude-accounts:<name>; accounts.json is an index.
use backs up .claude.json to .claude.json.bak and replaces only oauthAccount.
Concurrent Claude token writers remain last writer wins; a switch is not a global lock.
'''

class Failure(Exception):
    def __init__(self, message, code=2):
        super().__init__(message)
        self.code = code


def fail(message, code=2):
    raise Failure(message, code)


def iso():
    return dt.datetime.now(dt.timezone.utc).isoformat()


def timestamp(value):
    parsed = dt.datetime.fromisoformat(value.replace('Z', '+00:00'))
    if parsed.tzinfo is None:
        raise ValueError('timezone required')
    return parsed.timestamp()


def clean(value):
    return str(value).replace('\t', ' ').replace('\r', ' ').replace('\n', ' ')


def atomic(path, data, mode=0o600):
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    fd, temporary = tempfile.mkstemp(prefix='.' + path.name + '.', dir=path.parent)
    try:
        with os.fdopen(fd, 'wb') as stream:
            os.fchmod(stream.fileno(), mode)
            stream.write(data)
        os.replace(temporary, path)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)


def write_json(path, value):
    atomic(path, (json.dumps(value, indent=2) + '\n').encode())


def keychain(service, value=None, delete=False):
    fixture = os.environ.get('CLAUDE_ACCOUNTS_KEYCHAIN_DIR')
    if fixture is not None:
        if not fixture:
            fail('empty fixture keychain path; set CLAUDE_ACCOUNTS_KEYCHAIN_DIR and run claude-accounts status')
        path = Path(fixture) / (service + '.json')
        if delete:
            path.unlink()
        elif value is not None:
            write_json(path, value)
        else:
            return json.loads(path.read_bytes())
        return
    base = ['-a', USER, '-s', service]
    if value is not None:
        # security's interactive tokenizer accepts double-quoted, backslash-escaped words.
        # Only the fixed '-i' is in argv; capture output so it cannot echo credentials.
        def quote(word):
            return '"' + word.replace('\\', '\\\\').replace('"', '\\"') + '"'
        words = ['add-generic-password', '-U', *base, '-w', json.dumps(value, separators=(',', ':'))]
        result = subprocess.run(['security', '-i'], input=' '.join(map(quote, words)) + '\n',
                                text=True, capture_output=True)
        if result.returncode or 'SecKeychain' in result.stderr or 'error:' in result.stderr.lower():
            fail('keychain write failed; unlock the login keychain, then run claude-accounts status')
    else:
        operation = 'delete-generic-password' if delete else 'find-generic-password'
        result = subprocess.run(['security', operation, *base, *([] if delete else ['-w'])],
                                text=True, capture_output=True)
        if result.returncode:
            fail('keychain item unavailable; unlock the login keychain, then run claude-accounts save <name>')
        if not delete:
            return json.loads(result.stdout)


def index():
    path = ROOT / 'accounts.json'
    return json.loads(path.read_bytes()) if path.exists() else {}


def valid_name(name):
    if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]*', name):
        fail('invalid account name; run claude-accounts save <letters-digits-underscores-or-hyphens>')
    return name


def known(name, accounts):
    valid_name(name)
    if name not in accounts:
        fail("no account named '%s'; run: claude-accounts save <name>" % name)


def identity():
    account = json.loads(CONFIG.read_bytes()).get('oauthAccount')
    if not isinstance(account, dict) or not account.get('accountUuid') or not account.get('emailAddress'):
        fail('live oauthAccount is missing; run claude /login, then claude-accounts save <name>')
    return account


def name_for(account, accounts):
    return next((name for name, value in accounts.items()
                 if value['accountUuid'] == account['accountUuid']), None)


def live_pair():
    blob = keychain(LIVE)
    if not isinstance(blob, dict) or not isinstance(blob.get('claudeAiOauth'), dict):
        fail('live credentials are missing; run claude /login, then claude-accounts save <name>')
    return {**blob, 'oauthAccount': identity()}


def store(name, pair, accounts):
    saved = {**pair, 'saved_at': iso()}
    keychain('claude-accounts:' + name, saved)
    account = saved['oauthAccount']
    accounts[name] = {key: account[key] for key in ('accountUuid', 'emailAddress')}
    accounts[name]['saved_at'] = saved['saved_at']
    write_json(ROOT / 'accounts.json', accounts)


def live_blob(pair):
    return {key: value for key, value in pair.items() if key not in ('oauthAccount', 'saved_at')}


def replace_account(original, account):
    # Locate the top-level value with the JSON decoder, preserving every other byte.
    text = original.decode('utf-8')
    decoder = json.JSONDecoder()
    json.loads(text)
    position = text.index('{') + 1
    matches = []
    while True:
        position = re.match(r'\s*', text[position:]).end() + position
        if text[position] == '}':
            break
        key, end = decoder.raw_decode(text, position)
        position = end + re.match(r'\s*:\s*', text[end:]).end()
        start = position
        _, position = decoder.raw_decode(text, position)
        if key == 'oauthAccount':
            matches.append((start, position))
        position += re.match(r'\s*', text[position:]).end()
        if text[position] == ',':
            position += 1
        else:
            break
    if len(matches) != 1:
        fail('oauthAccount must occur exactly once; run claude /login, then claude-accounts save <name>')
    start, end = matches[0]
    indent_match = re.search(r'\n([ \t]+)"', text)
    indent = indent_match.group(1) if indent_match else None
    newline = '\r\n' if '\r\n' in text else '\n'
    replacement = json.dumps(account, ensure_ascii=False, indent=indent)
    if indent is not None:
        replacement = replacement.replace('\n', newline + indent)
    return (text[:start] + replacement + text[end:]).encode('utf-8')


def use(name, discard=False):
    accounts = index()
    known(name, accounts)
    pair = live_pair()
    current = name_for(pair['oauthAccount'], accounts)
    if current is None and not discard:
        fail('the current login (%s) is not saved; run `claude-accounts save <name>` first or `use --discard-live <name>`' % clean(pair['oauthAccount']['emailAddress']))
    if current is not None and not discard:
        store(current, pair, accounts)
    target = keychain('claude-accounts:' + name)
    original = CONFIG.read_bytes()
    replacement = replace_account(original, target['oauthAccount'])
    atomic(CONFIG.with_name(CONFIG.name + '.bak'), original)
    keychain(LIVE, live_blob(target))
    try:
        # Avoid overwriting unrelated state changed while the keychain command was running.
        if CONFIG.read_bytes() != original:
            fail('.claude.json changed during switch; run claude-accounts whoami, then retry claude-accounts use ' + name)
        atomic(CONFIG, replacement, CONFIG.stat().st_mode & 0o777)
    except (OSError, Failure):
        keychain(LIVE, live_blob(pair))
        raise
    ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
    with (ROOT / 'switch.log').open('a') as stream:
        stream.write('%s use %s %s\n' % (iso(), name, target['oauthAccount']['accountUuid']))
    print('live: %s %s — NEW sessions land here; running sessions keep their own account' %
          (name, clean(target['oauthAccount']['emailAddress'])))


def request_json(url, headers=None, payload=None):
    request = urllib.request.Request(url, headers=headers or {},
                                     data=None if payload is None else json.dumps(payload).encode())
    try:
        with urllib.request.urlopen(request, timeout=20) as response:
            return json.load(response)
    except urllib.error.HTTPError as error:
        try:
            body = json.load(error)
        except ValueError:
            body = {}
        if not isinstance(body, dict):
            body = {}
        if error.code in (401, 429):
            body['error'] = {'type': 'rate_limit_error' if error.code == 429 else 'authentication_error'}
        return body or {'error': {'message': 'HTTP %s' % error.code}}
    except (OSError, ValueError):
        return {'error': {'message': 'endpoint unreachable'}}


def stub_json(variable, name):
    result = subprocess.run([os.environ[variable], name], capture_output=True, text=True, timeout=30)
    try:
        return json.loads(result.stdout)
    except ValueError:
        return {'error': {'message': 'fixture command failed'}}


def usage_data(name, pair, current):
    for attempt in range(2):
        if attempt:
            if not os.environ.get('CLAUDE_ACCOUNTS_USAGE_CMD'):
                time.sleep(1)
            pair = live_pair() if current else keychain('claude-accounts:' + name)
        if os.environ.get('CLAUDE_ACCOUNTS_USAGE_CMD'):
            body = stub_json('CLAUDE_ACCOUNTS_USAGE_CMD', name)
        else:
            token = pair.get('claudeAiOauth', {}).get('accessToken')
            if not token:
                return {'error': {'message': 'missing accessToken; run claude-accounts refresh ' + name}}
            body = request_json(USAGE_URL, {'Authorization': 'Bearer ' + token,
                                           'anthropic-beta': 'oauth-2025-04-20'})
        error = body.get('error') if isinstance(body, dict) else None
        if isinstance(body, dict) and ('five_hour' in body or (isinstance(error, dict) and error.get('type') == 'rate_limit_error')):
            return body
    return body


def rows():
    accounts = index()
    account = identity()
    current = name_for(account, accounts)
    names = sorted(accounts)
    if current is None:
        names.append('unsaved live')
    output = []
    for name in names:
        is_live = name == current or name == 'unsaved live'
        row = {'live': is_live, 'name': name, 'email': account['emailAddress'] if is_live else accounts[name]['emailAddress'],
               'five': None, 'five_reset': None, 'seven': None, 'seven_reset': None, 'refresh_days': None,
               'scoped': {}, 'note': '', 'subscription_created_at': None, 'account_created_at': None}
        try:
            pair = live_pair() if is_live else keychain('claude-accounts:' + name)
            for target, key in (('subscription_created_at', 'subscriptionCreatedAt'), ('account_created_at', 'accountCreatedAt')):
                created = (pair.get('oauthAccount') or {}).get(key)
                if created:
                    try:
                        row[target] = dt.datetime.fromtimestamp(timestamp(created), dt.timezone.utc).isoformat()
                    except (ValueError, TypeError, OverflowError):
                        pass
            oauth = pair.get('claudeAiOauth') or {}
            expiry = oauth.get('refreshTokenExpiresAt')
            if isinstance(expiry, (int, float)):
                row['refresh_days'] = math.floor((expiry / 1000 - time.time()) / 86400)
            if not is_live and oauth.get('expiresAt', 0) <= time.time() * 1000:
                row['note'] = 'access token expired; run claude-accounts refresh ' + name
            else:
                data = usage_data(name, pair, is_live)
                if not isinstance(data, dict):
                    data = {}
                locked = data.get('locked_reason') or next((v.get('locked_reason') for v in data.values() if isinstance(v, dict) and v.get('locked_reason')), '')
                f, s = data.get('five_hour'), data.get('seven_day')
                try:
                    for window in (f, s):
                        if not isinstance(window, dict) or isinstance(window.get('utilization'), bool) or not isinstance(window.get('utilization'), (int, float)) or not math.isfinite(window['utilization']):
                            raise ValueError()
                        if window.get('resets_at'):
                            timestamp(window['resets_at'])
                    row.update(five=int(f['utilization']), five_reset=f.get('resets_at'),
                               seven=int(s['utilization']), seven_reset=s.get('resets_at'), note=clean(locked))
                    # Per-model weekly caps ride in `limits` as kind=weekly_scoped with a scope.model
                    # (measured 2026-09-06: Fable at 78% while weekly_all sat at 44%). They are the
                    # reason to move a SEAT to another model, not the account to another session.
                    for limit in data.get('limits') or []:
                        if not isinstance(limit, dict) or limit.get('kind') != 'weekly_scoped':
                            continue
                        model = ((limit.get('scope') or {}).get('model') or {}).get('display_name')
                        percent = limit.get('percent')
                        if model and isinstance(percent, (int, float)) and not isinstance(percent, bool):
                            row['scoped'][clean(model)] = int(percent)
                except (ValueError, TypeError, AttributeError, OverflowError):
                    error = data.get('error') or {}
                    limited = isinstance(error, dict) and error.get('type') == 'rate_limit_error'
                    row['note'] = ('locked: ' + clean(locked) + '; ' if locked else '') + (
                        'usage API rate-limited this laptop: wait a minute, then run claude-accounts status' if limited else
                        'login expired; run claude /login' if isinstance(error, dict) and error.get('type') == 'authentication_error' else
                        'usage API unreachable; run claude-accounts status')
        except (Failure, OSError, ValueError):
            row['note'] = 'saved credentials unavailable; run claude-accounts save ' + name
        if name == 'unsaved live':
            row['note'] = ('live login is unsaved; run claude-accounts save <name>' + ('; ' + row['note'] if row['note'] else ''))
        output.append(row)
    return sorted(output, key=score)


def score(row):
    if row['five'] is None or row['note']:
        return (9e9, row['name'])
    if row['five'] >= 90:
        return (1e9 + row['five'], row['name'])
    hours = (timestamp(row['seven_reset']) - time.time()) / 3600 if row['seven_reset'] else 999
    # Headroom that expires within a day is worth spending first: everything unused at the
    # reset is gone. Beyond a day the plain "least used weekly" ordering holds.
    expiring = (100 - row['seven']) * 10 if hours <= 24 else 0
    return (row['seven'] * 10 + hours * 0.5 + row['five'] * 0.2 - expiring, row['name'])


def pick(values):
    return next((r['name'] for r in values if r['five'] is not None and r['five'] < 90 and not r['note']), None)


def when(value, raw):
    if not value:
        return '-'
    if raw:
        return dt.datetime.fromtimestamp(timestamp(value), dt.timezone.utc).isoformat()
    moment = dt.datetime.fromtimestamp(timestamp(value)).astimezone()
    hours = int((timestamp(value) - time.time()) // 3600)
    return moment.strftime('%a %H:%M') + (' (in %dh)' % hours if hours < 48 else ' (in %dd)' % (hours // 24))


def status(raw=False, as_json=False):
    values = rows()
    if as_json:
        print(json.dumps([{'live': r['live'], 'name': r['name'], 'email': r['email'],
                           'five_hour_pct': r['five'], 'five_hour_resets_at': when(r['five_reset'], True),
                           'seven_day_pct': r['seven'], 'seven_day_resets_at': when(r['seven_reset'], True),
                           'fable_pct': r['scoped'].get('Fable'), 'refresh_days': r['refresh_days'],
                           'note': r['note'], 'subscription_created_at': r['subscription_created_at'],
                           'account_created_at': r['account_created_at']} for r in values]))
        return
    if not raw:
        print('LIVE\tNAME\tEMAIL\t5H%\t5H RESETS\t7D%\t7D RESETS\tFABLE%\tREFRESH-TOKEN\tNOTE')
    for r in values:
        fields = ['*' if r['live'] else '-', r['name'], r['email'],
                  '-' if r['five'] is None else r['five'], when(r['five_reset'], raw),
                  '-' if r['seven'] is None else r['seven'], when(r['seven_reset'], raw),
                  '-' if 'Fable' not in r['scoped'] else r['scoped']['Fable'],
                  '-' if r['refresh_days'] is None else str(r['refresh_days']) + 'd', r['note']]
        print('\t'.join(map(clean, fields)))
    if not raw:
        print('spend next: ' + (pick(values) or 'NONE — run claude-accounts status and check NOTE'))


def refresh(name):
    accounts = index()
    known(name, accounts)
    if name_for(identity(), accounts) == name:
        fail('running sessions own that token — they refresh it themselves; run claude-accounts status')
    pair = keychain('claude-accounts:' + name)
    oauth = pair['claudeAiOauth']
    if os.environ.get('CLAUDE_ACCOUNTS_REFRESH_CMD'):
        result = stub_json('CLAUDE_ACCOUNTS_REFRESH_CMD', name)
    else:
        result = request_json(REFRESH_URL, {'Content-Type': 'application/json'},
                              {'grant_type': 'refresh_token', 'refresh_token': oauth['refreshToken'],
                               'client_id': '9d1c250a-e61b-44d9-88ed-5944d1962f5e'})
    if not isinstance(result, dict) or result.get('error'):
        error = result.get('error') if isinstance(result, dict) else {'message': 'invalid response'}
        fail('refresh %s failed: %s; run claude-accounts refresh %s' % (name, clean(json.dumps(error)), name), 1)
    if not isinstance(result.get('access_token'), str) or not result['access_token'] or not isinstance(result.get('expires_in'), (int, float)) or result['expires_in'] <= 0:
        fail('refresh response missing token or expiry; run claude-accounts refresh ' + name, 1)
    oauth['accessToken'] = result['access_token']
    oauth['expiresAt'] = int(time.time() * 1000 + result['expires_in'] * 1000)
    if result.get('refresh_token'):
        oauth['refreshToken'] = result['refresh_token']
    if isinstance(result.get('refresh_token_expires_in'), (int, float)):
        oauth['refreshTokenExpiresAt'] = int(time.time() * 1000 + result['refresh_token_expires_in'] * 1000)
    store(name, pair, accounts)
    print('refreshed: %s — access token expires %s' % (name, dt.datetime.fromtimestamp(oauth['expiresAt'] / 1000, dt.timezone.utc).isoformat()))


def alerts(notify=False):
    lines = []
    for r in sorted(rows(), key=lambda r: r['name']):
        name, note = r['name'], r['note']
        if name == 'unsaved live':
            lines.append('live login is unsaved — run claude-accounts save <name>')
        if r['refresh_days'] is not None and r['refresh_days'] < 7:
            lines.append(name + ': refresh token expires within 7 days — run claude /login, then claude-accounts save ' + (name if name != 'unsaved live' else '<name>'))
        if r['five'] is None:
            lines.append(name + ': unreachable — ' + note)
            if note.startswith('locked:'):
                lines.append(name + ': locked — ' + note[len('locked: '):])
            continue
        if r['five'] >= 85:
            lines.append(name + ': 5h nearly spent — run claude-accounts pick --use')
        for model, percent in sorted(r['scoped'].items()):
            if percent >= 85:
                lines.append('%s: %s weekly cap at %d%% — move %s seats on this account to Opus 5 (/model)' % (name, model, percent, model))
        left = timestamp(r['seven_reset']) - time.time() if r['seven_reset'] else -1
        if 0 <= left <= 86400 and r['seven'] < 30:
            lines.append(name + ': 7d window expiring unused — run claude-accounts use ' + name)
        if note and name != 'unsaved live':
            lines.append(name + ': locked — ' + note + '; run claude-accounts status')
    for line in lines:
        print(line)
    if notify:
        last = ROOT / '.alerts-last'
        previous = set(last.read_text().splitlines()) if last.exists() else set()
        notified = []
        for line in lines:
            if line not in previous:
                try:
                    subprocess.run(['osascript', '-e', 'display notification ' + json.dumps(line, ensure_ascii=False) + ' with title "claude-accounts"'], check=True, capture_output=True)
                except (OSError, subprocess.CalledProcessError):
                    print('claude-accounts: notification failed; run claude-accounts alerts --notify on macOS', file=sys.stderr)
                    continue
            notified.append(line)
        atomic(last, ''.join(line + '\n' for line in notified).encode())
    return 1 if lines else 0


def transcript_info(path):
    info = {'pids': set(), 'cwds': set(), 'context': None}
    try:
        info['mtime'] = path.stat().st_mtime
        with path.open(errors='replace') as stream:
            for line in stream:
                try:
                    record = json.loads(line)
                    if not isinstance(record, dict):
                        continue
                    if record.get('pid') is not None:
                        info['pids'].add(str(record['pid']))
                    if isinstance(record.get('cwd'), str):
                        info['cwds'].add(record['cwd'])
                    if record.get('type') == 'assistant' and not record.get('isSidechain'):
                        usage = (record.get('message') or {}).get('usage')
                        info['context'] = sum(usage.get(k, 0) for k in ('input_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens')) if isinstance(usage, dict) else None
                except (ValueError, TypeError, AttributeError):
                    continue
        return info
    except OSError:
        return None


def process_cwd(pid):
    # Fixture ps owns all process data; never inspect real pids in fixture mode.
    if os.environ.get('CLAUDE_ACCOUNTS_PS_CMD'):
        return None
    try:
        output = subprocess.run(['lsof', '-a', '-p', str(pid), '-d', 'cwd', '-Fn'], capture_output=True, text=True, timeout=5).stdout
        return next((line[1:] for line in output.splitlines() if line.startswith('n')), None)
    except (OSError, subprocess.TimeoutExpired):
        return None


def sessions():
    env = dict(os.environ, LC_ALL='C')
    command = [os.environ['CLAUDE_ACCOUNTS_PS_CMD']] if os.environ.get('CLAUDE_ACCOUNTS_PS_CMD') else ['ps', '-axo', 'pid,lstart,etime,command']
    result = subprocess.run(command, capture_output=True, text=True, env=env, timeout=20)
    if result.returncode:
        fail('cannot enumerate sessions; run claude-accounts sessions again')
    projects = Path(os.environ.get('CLAUDE_ACCOUNTS_TRANSCRIPT_DIR', str(Path.home() / '.claude/projects')))
    candidates = {path: info for path in projects.glob('*/*.jsonl') if (info := transcript_info(path))}
    history = []
    log = ROOT / 'switch.log'
    for line in log.read_text().splitlines() if log.exists() else []:
        try:
            at, action, name, uuid = line.split()
            if action == 'use':
                history.append((timestamp(at), name, uuid))
        except ValueError:
            continue
    history.sort()
    values = []
    for line in result.stdout.splitlines():
        match = re.match(r'^\s*(\d+)\s+(\w+\s+\w+\s+\d+\s+\d+:\d+:\d+\s+\d+)\s+\S+\s+(.+)$', line)
        if not match:
            continue
        pid, started, command_text = match.groups()
        try:
            argv = shlex.split(command_text)
            start = time.mktime(time.strptime(started, '%a %b %d %H:%M:%S %Y'))
        except ValueError:
            continue
        if not argv:
            continue
        version = str(Path.home() / '.local/share/claude/versions') + '/'
        if not (argv[0].startswith(version) or argv[0].startswith('~/.local/share/claude/versions/') or (Path(argv[0]).name == 'claude' and any(a == '--resume' or a.startswith('--resume=') for a in argv[1:]))):
            continue
        cwd = process_cwd(pid)
        selected = None
        for i, argument in enumerate(argv):
            resume = argv[i + 1] if argument == '--resume' and i + 1 < len(argv) else argument.split('=', 1)[1] if argument.startswith('--resume=') else None
            if resume:
                path = Path(resume).expanduser()
                if not path.is_absolute() and cwd:
                    path = Path(cwd) / path
                # Fixture mode must not follow an explicit resume path outside the fixture tree.
                allowed = not os.environ.get('CLAUDE_ACCOUNTS_TRANSCRIPT_DIR') or path.resolve().is_relative_to(projects.resolve())
                if allowed and path.is_file():
                    info = transcript_info(path)
                    if info:
                        selected = (path, info)
                break
        if selected is None:
            eligible = [(p, info) for p, info in candidates.items() if info['mtime'] > start and (pid in info['pids'] or (cwd and cwd in info['cwds']))]
            if eligible:
                selected = max(eligible, key=lambda item: item[1]['mtime'])
        previous = [entry for entry in history if entry[0] <= start]
        account, uuid = (previous[-1][1], previous[-1][2]) if previous else ('unknown', None)
        path, info = selected if selected else (None, {})
        project = cwd or next(iter(sorted(info.get('cwds', []))), None)
        values.append({'pid': int(pid), 'started_at': dt.datetime.fromtimestamp(start, dt.timezone.utc).isoformat(),
                       'transcript': str(path) if path else None, 'project': Path(project).name if project else path.parent.name if path else '?',
                       'context': info.get('context'), 'idle_seconds': max(0, int(time.time() - info['mtime'])) if info else None,
                       'account': account, 'accountUuid': uuid, 'attribution': 'inferred'})
    target = pick(rows())
    accounts = index()
    target_uuid = accounts[target]['accountUuid'] if target else None
    eligible = [r for r in values if target and r['accountUuid'] != target_uuid]
    eligible.sort(key=lambda r: (not (r['idle_seconds'] is not None and r['idle_seconds'] >= 600),
                                 r['context'] if r['context'] is not None else float('inf'), r['pid']))
    recommendations = []
    for r in eligible[:5]:
        context = '~%dk' % (r['context'] // 1000) if r['context'] is not None else '?'
        idle = '%dm' % (r['idle_seconds'] // 60) if r['idle_seconds'] is not None else '?'
        recommendations.append({'pid': r['pid'], 'target': target, 'message':
            'switch pid %s (%s, %s context, idle %s, on %s [inferred]) → %s: /login in that session' %
            (r['pid'], r['project'], context, idle, r['account'], target)})
    return {'sessions': values, 'pick': target, 'new_sessions_already_on_pick': bool(target and name_for(identity(), accounts) == target),
            'recommendations': recommendations}


@contextlib.contextmanager
def mutation_lock():
    ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
    with (ROOT / '.lock').open('a') as stream:
        fcntl.flock(stream, fcntl.LOCK_EX)
        yield


def main(args):
    if not args:
        args = ['status']
    if args in (['--help'], ['-h'], ['help']):
        print(HELP, end='')
        return 0
    if args == ['--tsv', 'status']:
        status(True)
        return 0
    command, rest = args[0], args[1:]
    if command == 'rename' and len(rest) == 2:
        old, new = map(valid_name, rest)
        with mutation_lock():
            accounts = index()
            known(old, accounts)
            if new in accounts:
                fail('account name already exists; choose another name')
            pair = keychain('claude-accounts:' + old)
            log = ROOT / 'switch.log'
            rewritten = None
            if log.exists():
                rewritten = re.sub(r'(?m)^(\S+ use )' + re.escape(old) + r'(?= )',
                                   lambda match: match.group(1) + new, log.read_text())
            keychain('claude-accounts:' + new, pair)
            renamed = {new if name == old else name: value for name, value in accounts.items()}
            write_json(ROOT / 'accounts.json', renamed)
            if rewritten is not None:
                atomic(log, rewritten.encode())
            keychain('claude-accounts:' + old, delete=True)
            print('renamed: ' + old + ' → ' + new + ' — live login unchanged')
        return 0
    if command in ('save', 'forget', 'refresh') and len(rest) == 1:
        name = valid_name(rest[0])
        with mutation_lock():
            accounts = index()
            if command == 'save':
                pair = live_pair()
                account = pair['oauthAccount']
                if name in accounts and accounts[name]['accountUuid'] != account['accountUuid']:
                    fail('%s is already %s; pick another name or `forget %s`' % (name, clean(accounts[name]['emailAddress']), name))
                other = name_for(account, accounts)
                if other is not None and other != name:
                    fail('this account is already saved as %s; run claude-accounts save %s' % (other, other))
                store(name, pair, accounts)
                print('saved: %s %s' % (name, clean(account['emailAddress'])))
            elif command == 'forget':
                known(name, accounts)
                keychain('claude-accounts:' + name, delete=True)
                del accounts[name]
                write_json(ROOT / 'accounts.json', accounts)
                print('forgot: ' + name + ' — live login unchanged')
            else:
                refresh(name)
        return 0
    if command == 'use' and (len(rest) == 1 or (len(rest) == 2 and rest[0] == '--discard-live')):
        with mutation_lock():
            use(rest[-1], len(rest) == 2)
        return 0
    if command in ('whoami', 'active') and (not rest or (command == 'whoami' and rest == ['--json'])):
        account = identity()
        name = name_for(account, index())
        if rest:
            print(json.dumps({'name': name, 'emailAddress': account['emailAddress'], 'accountUuid': account['accountUuid'], 'saved': name is not None}))
        elif command == 'active':
            print(name or 'unknown')
        else:
            print('%s %s%s' % (name or 'unknown', clean(account['emailAddress']), '' if name else ' — run: claude-accounts save <name>'))
        return 0 if name or command == 'active' else 1
    if command == 'list' and not rest:
        for name, account in sorted(index().items()):
            print('%s %s' % (name, clean(account['emailAddress'])))
        return 0
    if command == 'status' and rest in ([], ['--json']):
        status(as_json=bool(rest))
        return 0
    if command == 'pick' and rest in ([], ['--use']):
        target = pick(rows())
        if target is None:
            fail('no usable account; run: claude-accounts status')
        if rest:
            with mutation_lock():
                # Buffer the success lines so a refusal emits only the corrective error.
                import io
                captured = io.StringIO()
                with contextlib.redirect_stdout(captured):
                    use(target)
                print(target)
                print(captured.getvalue(), end='')
        else:
            print(target)
        return 0
    if command == 'alerts' and rest in ([], ['--notify']):
        return alerts(bool(rest))
    if command == 'sessions' and rest in ([], ['--json']):
        data = sessions()
        if rest:
            print(json.dumps(data))
        else:
            for row in data['sessions']:
                print('pid %s %s — context: %s; on %s [inferred]' % (row['pid'], row['project'], row['context'] if row['context'] is not None else '?', row['account']))
            if data['new_sessions_already_on_pick']:
                print('new sessions already land on ' + data['pick'])
            if not data['pick']:
                print('no usable account — run claude-accounts status')
            for item in data['recommendations']:
                print(item['message'])
        return 0
    if command == 'serve' and (not rest or (len(rest) == 2 and rest[0] == '--port')):
        os.execv(sys.executable, [sys.executable, str(SCRIPT.with_name('claude-accounts-serve.py')), *rest])
    fail('invalid command or arguments; run: claude-accounts --help')

try:
    sys.exit(main(sys.argv[2:]))
except Failure as error:
    print('claude-accounts: ' + str(error), file=sys.stderr)
    sys.exit(error.code)
except (OSError, ValueError, KeyError, TypeError, AttributeError, IndexError, OverflowError, subprocess.TimeoutExpired):
    # Do not surface exception text that may contain credential contents.
    print('claude-accounts: account data or command unavailable; run claude-accounts status, then claude-accounts save <name>', file=sys.stderr)
    sys.exit(2)
PY
