#!/usr/bin/env bash
# One subscription inventory; Claude credentials remain owned by claude-accounts.
set -euo pipefail
exec python3 - "$0" "$@" <<'PY'
import argparse
import base64
import calendar
import datetime as dt
import fcntl
import http.server
import json
import math
import os
from pathlib import Path
import queue
import shlex
import subprocess
import sys
import tempfile
import threading
import time
try:
    import tomllib  # 3.11+
except ModuleNotFoundError:  # the app launches us with /usr/bin/python3 (3.9); roles.toml is a tiny subset
    tomllib = None


def _toml_subset(text):
    # Parses exactly what `ai-subs` writes: [a.b] tables, ints, floats, quoted strings, string arrays.
    data, table = {}, None
    for raw in text.splitlines():
        line = raw.split('#', 1)[0].strip()
        if not line:
            continue
        if line.startswith('[') and line.endswith(']'):
            table = data
            for part in line[1:-1].split('.'):
                table = table.setdefault(part.strip(), {})
            continue
        if '=' not in line:
            raise ValueError('roles.toml line not understood: ' + raw)
        key, value = [x.strip() for x in line.split('=', 1)]
        if value.startswith('['):
            items = [v.strip() for v in value.strip('[]').split(',') if v.strip()]
            parsed = [v.strip('"').strip("'") for v in items]
        elif value.startswith(('"', "'")):
            parsed = value[1:-1]
        elif value in ('true', 'false'):
            parsed = value == 'true'
        else:
            parsed = float(value) if '.' in value else int(value)
        (table if table is not None else data)[key] = parsed
    return data
import urllib.error
import urllib.request

SCRIPT = Path(sys.argv[1]).resolve()
ROOT = Path(os.environ.get('AI_SUBS_HOME', str(Path.home() / '.ai-subs'))).expanduser()
FIXTURES = os.environ.get('AI_SUBS_FIXTURE_DIR')
PROVIDERS = ('claude', 'codex', 'kimi', 'zai', 'grok', 'openrouter')
LABELS = dict(zip(PROVIDERS, ('Claude', 'Codex', 'Kimi', 'z.ai', 'Grok', 'OpenRouter')))
FIX = {'claude': 'claude-accounts status', 'codex': 'codex login', 'kimi': 'kimi login',
       'zai': 'opencode auth login', 'grok': 'grok login', 'openrouter': 'opencode auth login'}
URLS = {'codex': 'https://chatgpt.com/backend-api/wham/usage',
        'kimi': 'https://api.kimi.com/coding/v1/usages',
        'zai': 'https://api.z.ai/api/monitor/usage/quota/limit',
        'grok': 'https://cli-chat-proxy.grok.com/v1/billing?format=credits',
        'openrouter': 'https://openrouter.ai/api/v1/credits'}
LINKS = {
    'claude': ('https://claude.ai/settings/usage', 'https://claude.ai/settings/billing'),
    'codex': ('https://chatgpt.com/codex/settings/usage', 'https://chatgpt.com/#settings/Subscription'),
    'kimi': ('https://www.kimi.com/code/console',) * 2,
    'zai': ('https://z.ai/manage-apikey/subscription',) * 2,
    'grok': ('https://grok.com/?_s=usage',) * 2,
    'openrouter': ('https://openrouter.ai/settings/credits',) * 2,
}
DEFAULTS = {'session_hold': 90, 'model_switch': 85, 'credits_floor': 5, 'spend_within_hours': 24}


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


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


def read_json(path):
    return json.loads(path.read_text())


def number(value):
    if isinstance(value, bool) or value is None:
        raise ValueError('number required')
    result = float(value)
    if not math.isfinite(result):
        raise ValueError('finite number required')
    return result


def pct(value):
    return None if value is None else max(0, min(100, int(number(value))))


def stamp(value):
    if value is None or value == '' or value == '-':
        return None
    if isinstance(value, (int, float)) or (isinstance(value, str) and value.replace('.', '', 1).isdigit()):
        seconds = number(value)
        if seconds > 100000000000:
            seconds /= 1000
        moment = dt.datetime.fromtimestamp(seconds, dt.timezone.utc)
    else:
        moment = dt.datetime.fromisoformat(value.replace('Z', '+00:00'))
        if moment.tzinfo is None:
            raise ValueError('timezone required')
    return moment.astimezone(dt.timezone.utc).isoformat()


def seconds(value):
    return dt.datetime.fromisoformat(stamp(value)).timestamp()


def monthly_anniversary(created, now=None):
    if not created:
        return None
    anchor = dt.datetime.fromisoformat(stamp(created))
    current = dt.datetime.fromtimestamp(time.time() if now is None else now, dt.timezone.utc)
    year, month = current.year, current.month
    for _ in range(2):
        candidate = anchor.replace(year=year, month=month,
                                   day=min(anchor.day, calendar.monthrange(year, month)[1]))
        if candidate > current:
            return candidate.isoformat()
        year, month = (year + 1, 1) if month == 12 else (year, month + 1)


def jwt_email(token):
    # Identity display only: this unverified claim never authorizes a request.
    try:
        payload = token.split('.')[1]
        email = json.loads(base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4))).get('email')
        return email if isinstance(email, str) else None
    except (ValueError, TypeError, AttributeError, IndexError):
        return None


def window(kind, label, used, reset=None):
    return {'kind': kind, 'label': label, 'used_pct': pct(used), 'resets_at': stamp(reset)}


def duration_window(duration):
    duration = number(duration)
    if duration <= 0:
        raise ValueError('positive duration required')
    kind = 'monthly' if duration > 7 * 86400 else 'weekly' if duration >= 86400 else 'session'
    label = '7d' if duration == 604800 else '%gh' % (duration / 3600)
    return kind, label


def seat(provider, account='default', email=None):
    return {'id': provider + ':' + account, 'provider': provider, 'provider_label': LABELS[provider],
            'account': account, 'email': email, 'plan': None, 'role': None, 'live': False,
            'short': account if provider == 'claude' else LABELS[provider],
            'label': account + (' · ' + email if email else '') if provider == 'claude' else LABELS[provider],
            'stale': False, 'stale_since': None, 'fetched_at': None,
            'renews_at': None, 'renews_source': None, 'links': dict(zip(('usage', 'billing'), LINKS[provider])),
            'binding': None, 'binding_pct': None, 'status': 'ok',
            'windows': [], 'advice': {'verb': 'ok', 'reason': 'Ready.', 'fix': ''}, 'note': '', 'error': None}


def failed(provider, code, message, account='default'):
    value = seat(provider, account, claude_inventory().get(account) if provider == 'claude' else None)
    value.update(note=message, error={'code': code, 'message': message})
    return value


def credential_data(provider):
    # Fixture mode must not even inspect local credentials.
    if FIXTURES:
        return {}
    home = Path.home()
    if provider == 'claude':
        return {}
    if provider == 'codex':
        data = read_json(Path(os.environ.get('CODEX_HOME', str(home / '.codex'))) / 'auth.json')['tokens']
        return {'token': data['access_token'], 'account_id': data.get('account_id'),
                'email': jwt_email(data.get('id_token'))}
    if provider == 'kimi':
        data = read_json(home / '.kimi-code/credentials/kimi-code.json')
        return {'token': data['access_token'], 'expires_at': data.get('expires_at')}
    if provider == 'grok':
        data = next(v for k, v in read_json(home / '.grok/auth.json').items() if k.startswith('https://auth.x.ai::'))
        return {'token': data['key'], 'email': data.get('email'), 'expires_at': data.get('expires_at')}
    override = os.environ.get('Z_AI_API_KEY' if provider == 'zai' else 'OPENROUTER_API_KEY')
    if override:
        return {'token': override}
    data = read_json(home / '.local/share/opencode/auth.json')
    return {'token': data['zai-coding-plan' if provider == 'zai' else provider]['key']}


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None  # Never forward bearer credentials to another host.


def request(url, creds, timeout, provider):
    headers = {'Authorization': 'Bearer ' + creds['token'], 'Accept': 'application/json'}
    if provider == 'codex' and creds.get('account_id'):
        headers['ChatGPT-Account-Id'] = creds['account_id']
    if provider == 'grok':
        headers['x-xai-token-auth'] = 'xai-grok-cli'
    req = urllib.request.Request(url, headers=headers)
    try:
        with urllib.request.build_opener(NoRedirect).open(req, timeout=max(0.01, timeout)) as response:
            return {'status': response.status, 'body': json.load(response)}
    except urllib.error.HTTPError as error:
        # Error bodies can echo credentials; the HTTP status is sufficient for the user.
        try:
            body = json.load(error)
        except ValueError:
            body = {'non_json_response': True}
        return {'status': error.code, 'body': body}
    except (OSError, ValueError):
        return {'status': 0, 'body': {}, 'failure': 'network_error'}


def claude_command(args, timeout):
    result = subprocess.run([str(SCRIPT.with_name('claude-accounts')), *args],
                            capture_output=True, text=True, timeout=max(0.01, timeout))
    return result.returncode, result.stdout


def retrieve(provider, creds, deadline):
    if FIXTURES:
        directory = Path(FIXTURES)
        if provider == 'claude':
            return {'status': 200, 'body': (directory / 'claude.tsv').read_text(),
                    'alerts': (directory / 'claude-alerts.txt').read_text() if (directory / 'claude-alerts.txt').exists() else ''}
        result = read_json(directory / (provider + '.json'))
        time.sleep(max(0, number(result.get('delay_s', 0))))
        return result
    if provider == 'claude':
        replies = queue.Queue()
        def call(label, args):
            try:
                code, output = claude_command(args, deadline - time.monotonic())
                replies.put((label, code, output))
            except (OSError, subprocess.TimeoutExpired):
                replies.put((label, 2, ''))
        for label, args in [('body', ['status', '--json']), ('alerts', ['alerts'])]:
            threading.Thread(target=call, args=(label, args), daemon=True).start()
        result = {'status': 200, 'body': '', 'alerts': ''}
        for _ in range(2):
            label, code, output = replies.get(timeout=max(0.01, deadline - time.monotonic()))
            result[label] = output
            if (label == 'body' and code != 0) or (label == 'alerts' and code not in (0, 1)):
                if label == 'body':
                    result['status'] = 0
                else:
                    result['alerts'] = 'Claude alerts could not be read — run claude-accounts alerts\n'
        return result
    result = request(URLS[provider], creds, deadline - time.monotonic(), provider)
    optional = {'grok': ('https://cli-chat-proxy.grok.com/v1/settings', 2),
                'openrouter': ('https://openrouter.ai/api/v1/key', 1)}.get(provider)
    if optional and result['status'] == 200 and deadline - time.monotonic() > optional[1]:
        result['optional'] = request(optional[0], creds, optional[1], provider)
    return result


def raw_reply(provider, creds, fresh=False, deadline=None):
    deadline = deadline or time.monotonic() + 15
    path = ROOT / 'cache' / (provider + '.json')
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    # Lock across CLI invocations too: serve shells out for every data request.
    with path.with_suffix('.lock').open('a') as lock:
        while True:
            try:
                fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except BlockingIOError:
                if time.monotonic() >= deadline:
                    raise Failure('timeout', 'The provider is still busy; run ai-subs status after a minute.')
                time.sleep(0.02)
        cached = cache_data(provider)
        remember_good(provider, cached, cached.get('reply'), cached.get('cached_at'))
        retry_at = backoff_until(provider)
        if retry_at:
            atomic(path, json.dumps(cached))
            return {'status': 429, 'body': {}, 'retry_at': retry_at}
        if not fresh and path.exists():
            try:
                # Anthropic's usage endpoint locked this laptop out for 13 h after a day of
                # 1-minute polling across four accounts; 15 min is the Claude cadence now.
                if 0 <= time.time() - cached['cached_at'] < (900 if provider == 'claude' else 60):
                    atomic(path, json.dumps(cached))
                    return cached['reply']
            except (OSError, ValueError, KeyError, TypeError):
                pass
        try:
            if not creds and not FIXTURES:
                creds.update(credential_data(provider))
            if provider != 'claude' and not FIXTURES and not creds.get('token'):
                raise KeyError('token')
            result = retrieve(provider, creds, deadline)
        except (OSError, ValueError, KeyError, TypeError, StopIteration):
            result = {'status': 0, 'body': {}, 'failure': 'missing_credentials'}
        except (queue.Empty, subprocess.TimeoutExpired):
            result = {'status': 0, 'body': {}, 'failure': 'timeout'}
        if time.monotonic() <= deadline:
            result['fetched_at'] = stamp(time.time())
            result['email'] = creds.get('email')
            cached.update(cached_at=time.time(), reply=result)
            remember_good(provider, cached, result, cached['cached_at'])
            limited = result.get('status') == 429 or result.get('optional', {}).get('status') == 429
            if provider == 'claude':
                limited = limited or 'rate-limited' in (str(result.get('body', '')) + result.get('alerts', ''))
            if limited:
                strikes = backoff_strikes(provider) + 1
                atomic(ROOT / 'backoff' / provider, '%s %d' % (time.time(), strikes))
            atomic(path, json.dumps(cached))
        return result



def cache_data(provider):
    try:
        cached = read_json(ROOT / 'cache' / (provider + '.json'))
        return cached if isinstance(cached, dict) else {}
    except (OSError, ValueError):
        return {}


def parse_reply(provider, raw, creds=None):
    value = globals()['fetch_' + provider]({**{'email': raw.get('email')}, **(creds or {}), 'raw': raw})
    values = value if provider == 'claude' else [value]
    for item in values:
        item['fetched_at'] = raw.get('fetched_at')
    return values


def remember_good(provider, cached, raw, at):
    if raw is None or at is None:
        return
    try:
        values = parse_reply(provider, raw)
    except Exception:
        return
    for value in values:
        value['fetched_at'] = stamp(at)
    good = [v for v in values if v['windows'] and not v['error']]
    if good:
        cached.update(last_good=raw, last_good_at=at)
        history = cached.setdefault('last_good_seats', {})
        for value in good:
            history[value['id']] = {'at': at, 'seat': value}
    history = cached.get('last_good_seats', {})
    for value in values:
        if value['id'] in history:
            history[value['id']]['seat']['live'] = value['live']


BACKOFF_STEPS = (300, 900, 2700, 3600)  # 5, 15, 45, 60 min: retrying every 5 min kept the lock alive


def backoff_strikes(provider):
    try:
        parts = (ROOT / 'backoff' / provider).read_text().split()
        at = float(parts[0])
        strikes = int(parts[1]) if len(parts) > 1 else 1
        # a strike older than the longest step is forgotten
        return strikes if 0 <= time.time() - at < BACKOFF_STEPS[-1] * 2 else 0
    except (OSError, ValueError, IndexError):
        return 0


def backoff_until(provider):
    try:
        parts = (ROOT / 'backoff' / provider).read_text().split()
        at = float(parts[0])
        strikes = int(parts[1]) if len(parts) > 1 else 1
        wait = BACKOFF_STEPS[min(max(strikes, 1), len(BACKOFF_STEPS)) - 1]
        return at + wait if 0 <= time.time() - at < wait else None
    except (OSError, ValueError, IndexError):
        return None


def last_good_seats(provider, values):
    cached = cache_data(provider)
    # Also accepts caches written before the per-account history was introduced.
    if not cached.get('last_good_seats'):
        remember_good(provider, cached, cached.get('last_good'), cached.get('last_good_at'))
    history = cached.get('last_good_seats', {})
    result = []
    for value in values:
        saved = history.get(value['id'])
        if value['error'] and saved and 0 <= time.time() - saved['at'] < 86400:
            code = value['error']['code']
            current = value
            value = dict(seat(provider, current['account']), **saved['seat'])
            if provider == 'claude' and current.get('plan'):
                value['live'] = current['live']
            at = saved['at']
            value['fetched_at'] = stamp(at)
            clock = lambda t: dt.datetime.fromtimestamp(t).astimezone().strftime('%I:%M %p').lstrip('0')
            reason = (LABELS[provider] + "'s usage service is rate-limiting this Mac" if code == 'rate_limited' else
                      ("this account's saved token has expired (its running sessions refresh their own copy); run `claude-accounts refresh " + value['account'] + "`"
                       if provider == 'claude' else 'the login has expired') if code == 'login_expired' else
                      'the usage request timed out' if code == 'timeout' else 'the usage service could not be read')
            retry_at = backoff_until(provider)
            if retry_at:
                reason += '; retrying after ' + clock(retry_at)
            # A window that has RESET since the reading is not stale, it is gone: a 13-hour-old
            # "Fable 99%" read as current while the live page said 3% (2026-09-07). Blank it.
            reset_since = []
            for item in value['windows']:
                try:
                    if item.get('resets_at') and at is not None and at < seconds(item['resets_at']) <= time.time():
                        item['used_pct'] = None
                        item['remaining_pct'] = None
                        reset_since.append(item['label'])
                except (ValueError, TypeError):
                    pass
            if reset_since:
                reason = ', '.join(reset_since) + ' reset since then; ' + reason
            value.update(stale=True, stale_since=stamp(at), error=None,
                         note='Showing the reading from ' + clock(at) + '; ' + reason + '.')
        result.append(value)
    return result


def body_for(provider, creds):
    raw = creds['raw']
    status = raw.get('status')
    if status != 200:
        code = ('login_expired' if status == 401 else 'rate_limited' if status == 429
                else raw.get('failure', 'provider_error'))
        message = ('The login has expired' if code == 'login_expired' else
                   'The provider is rate-limited' if code == 'rate_limited' else
                   'The provider could not be read') + '; run `' + FIX[provider] + '`.'
        raise Failure(code, message)
    if not isinstance(raw['body'], dict):
        raise ValueError('object required')
    return raw['body']


def optional_body(creds):
    optional = creds['raw'].get('optional', {})
    body = optional.get('body', {}) if optional.get('status') == 200 else {}
    return body if isinstance(body, dict) else {}


def fetch_claude(creds):
    raw = creds['raw']
    if raw.get('status') != 200:
        body_for('claude', creds)
    values, live_seen = [], False
    body = raw['body']
    rows = json.loads(body) if isinstance(body, str) and body.lstrip().startswith('[') else body
    for row in rows if isinstance(rows, list) else rows.splitlines():
        metadata = row if isinstance(row, dict) else {}
        fields = (['*' if row['live'] else '-', row['name'], row.get('email'),
                   row['five_hour_pct'], row['five_hour_resets_at'], row['seven_day_pct'],
                   row['seven_day_resets_at'], row.get('fable_pct'), row.get('refresh_days'), row['note']]
                  if metadata else row.split('\t'))
        fields = ['-' if v is None else v for v in fields]

        if len(fields) != 10:
            raise ValueError('ten TSV fields required')
        live, name, email, five, five_reset, seven, seven_reset, fable, refresh, note = fields
        value = seat('claude', name, email if email and email != '-' else None)
        value['renews_at'] = monthly_anniversary(metadata.get('subscription_created_at'))
        value['renews_source'] = 'inferred' if value['renews_at'] else None
        value.update(plan='Max', live=live == '*' and not live_seen, note=note)
        live_seen |= value['live']
        if five == '-' or seven == '-':
            message = note or 'Claude usage is unavailable; run `claude-accounts status`.'
            value['error'] = {'code': 'rate_limited' if 'rate-limited' in note else 'login_expired' if 'expired' in note else 'timeout' if 'timed out' in note else 'provider_error', 'message': message}
            value['note'] = message
        else:
            value['windows'] = [window('session', '5h', five, five_reset), window('weekly', '7d', seven, seven_reset)]
            if fable != '-':
                value['windows'].append(window('model', 'Fable', fable, seven_reset))
        values.append(value)
    if not values:
        raise Failure('missing_credentials', 'No Claude seats were returned; run `claude-accounts status`.')
    return values


def fetch_codex(creds):
    body = body_for('codex', creds)
    value = seat('codex', email=body.get('email') or creds.get('email'))
    value['plan'] = body.get('plan_type')
    limits = body['rate_limit']
    for key in ('primary_window', 'secondary_window'):
        item = limits.get(key)
        if item:
            kind, label = duration_window(item['limit_window_seconds'])
            value['windows'].append(window(kind, label, item['used_percent'], item.get('reset_at')))
    credits = body.get('credits') or {}
    if credits.get('has_credits'):
        value['windows'].append(dict(window('credits', 'credits', None), remaining=number(credits['balance']), currency='USD'))
    if not value['windows']:
        raise ValueError('no usage windows')
    return value


def fetch_kimi(creds):
    body = body_for('kimi', creds)
    value = seat('kimi')
    value['plan'] = ((body.get('user') or {}).get('membership') or {}).get('level')
    def detail(item, kind, label):
        limit = number(item['limit'])
        used = number(item['used']) if item.get('used') is not None else limit - number(item['remaining'])
        if limit <= 0:
            raise ValueError('positive limit required')
        return window(kind, label, used / limit * 100, item.get('resetTime'))
    value['windows'] = [detail(body['usage'], 'weekly', '7d')]
    units = {'TIME_UNIT_MINUTE': 60, 'TIME_UNIT_HOUR': 3600, 'TIME_UNIT_DAY': 86400, 'TIME_UNIT_WEEK': 604800}
    for item in body.get('limits') or []:
        duration = number(item['window']['duration']) * units[item['window']['timeUnit']]
        value['windows'].append(detail(item['detail'], *duration_window(duration)))
    wallet = body.get('boosterWallet') or {}
    balance = wallet.get('balance') or {}
    if wallet.get('status') == 'STATUS_ACTIVE' and balance.get('unit') == 'UNIT_CURRENCY':
        # Wallet amounts use 1e-8 USD; topupLimit.priceInCents uses ordinary cents.
        remaining = number(balance['amountLeft']) / 100000000
        value['windows'].append(dict(window('credits', 'booster', None), remaining=remaining, currency='USD'))
    return value


def fetch_zai(creds):
    body = body_for('zai', creds)
    if body.get('success') is False or body.get('code', 200) != 200:
        raise Failure('provider_error', 'z.ai refused the quota request; run `opencode auth login`.')
    data = body['data']
    value = seat('zai')
    value['plan'] = data.get('planName') or data.get('plan') or body.get('plan')
    units = {1: 86400, 3: 3600, 5: 60, 6: 604800}
    limits = sorted(data['limits'], key=lambda item: number(item['number']) * units[item['unit']])
    for item in limits:
        if item['type'] not in ('TOKENS_LIMIT', 'CREDIT_LIMIT', 'TIME_LIMIT'):
            continue
        duration = number(item['number']) * units[item['unit']]
        kind, label = duration_window(duration)
        if item['type'] == 'TIME_LIMIT':
            kind, label = ('monthly' if item['unit'] == 5 and item['number'] == 1 else kind), 'MCP'
        reset = stamp(item.get('nextResetTime'))
        if item['type'] != 'TIME_LIMIT' and duration == 18000 and reset and seconds(reset) > time.time() + 18060:
            reset = None
        value['windows'].append(window(kind, label, item['percentage'], reset))
    if not value['windows']:
        raise ValueError('no quota windows')
    return value


def fetch_grok(creds):
    body = body_for('grok', creds)
    config = body['config']
    value = seat('grok', email=creds.get('email'))
    value['plan'] = optional_body(creds).get('subscription_tier_display') or config.get('subscriptionTier') or body.get('subscriptionTier')
    used = config.get('creditUsagePercent')
    if used is None and number((config.get('onDemandCap') or {}).get('val', 0)) > 0:
        used = number(config['onDemandUsed']['val']) / number(config['onDemandCap']['val']) * 100
    reset = (config.get('currentPeriod') or {}).get('end') or config.get('billingPeriodEnd')
    if used is None and not reset:
        raise ValueError('no usage or reset')
    value['renews_at'] = stamp(reset)
    value['renews_source'] = 'reported' if reset else None
    value['windows'] = [window('monthly', 'cycle', used, reset)]
    return value


def fetch_openrouter(creds):
    data = body_for('openrouter', creds)['data']
    value = seat('openrouter')
    balance = number(data['total_credits']) - number(data['total_usage'])
    value['windows'] = [dict(window('credits', 'credits', None), remaining=balance, currency='USD')]
    try:
        key = optional_body(creds).get('data', {})
        if key.get('limit') is not None and number(key['limit']) > 0:
            reset = key.get('limit_reset')
            kind = {'daily': 'session', 'weekly': 'weekly', 'monthly': 'monthly'}.get(reset)
            if kind:
                used = (number(key['limit']) - number(key['limit_remaining'])) / number(key['limit']) * 100
                value['windows'].append(window(kind, reset, used))
    except (ValueError, TypeError, KeyError, AttributeError):
        value['note'] = 'The optional key cap could not be read; run `ai-subs status --fresh`.'
    return value


def claude_inventory():
    # Metadata only, never keychain secrets; retain seats if the adapter times out.
    try:
        if FIXTURES:
            return {line.split('\t')[1]: line.split('\t')[2] for line in (Path(FIXTURES) / 'claude.tsv').read_text().splitlines() if len(line.split('\t')) == 10}
        path = Path(os.environ.get('CLAUDE_ACCOUNTS_DIR', str(Path.home() / '.claude-accounts'))) / 'accounts.json'
        return {name: account.get('emailAddress') for name, account in read_json(path).items()}
    except (OSError, ValueError, TypeError, AttributeError):
        return {}


def collect(fresh=False):
    results, pending = {}, queue.Queue()
    deadline = time.monotonic() + 15
    def worker(provider):
        creds = {}
        try:
            raw = raw_reply(provider, creds, fresh, deadline)
            if provider in ('codex', 'kimi', 'grok') and not FIXTURES and not creds.get('token'):
                try:
                    creds.update(credential_data(provider))
                except (OSError, ValueError, KeyError, StopIteration):
                    pass
            values = parse_reply(provider, raw, creds)
            for value in values:
                value['fetched_at'] = raw.get('fetched_at') or stamp(cache_data(provider).get('cached_at'))
            # A stale stored token is NOT an expired login: the provider's own CLI refreshes it on
            # its next run (Kimi's token lapsed 48 s before a fetch and read "sign in again").
            # Only when the fetch itself failed does the expiry explain anything.
            if (creds.get('expires_at') is not None and seconds(creds['expires_at']) <= time.time()
                    and all(v.get('error') for v in values)):
                message = 'The stored token is stale; run `' + provider + '` once to refresh it, then `ai-subs status --fresh`.'
                values = [failed(provider, 'token_stale', message)]
            values = last_good_seats(provider, values)
            pending.put((provider, values, raw.get('alerts', '') if provider == 'claude' else ''))
        except Exception as error:
            code = error.code if isinstance(error, Failure) else 'invalid_response'
            message = error.message if isinstance(error, Failure) else 'The provider response could not be read; run `' + FIX[provider] + '`.'
            # The 401 arrives here as a Failure; a token that had already lapsed explains it, and
            # the provider's CLI refreshes it on its next run (proven on Kimi 2026-09-06: one run
            # moved expires_at forward and the endpoint went 401 -> 200).
            try:
                stale = creds.get('expires_at') is not None and seconds(creds['expires_at']) <= time.time()
            except Exception:
                stale = False
            if stale and code in ('login_expired', 'provider_error', 'invalid_response'):
                code = 'token_stale'
                message = 'The stored token is stale; run `' + provider + '` once to refresh it, then `ai-subs status --fresh`.'
            names = claude_inventory() if provider == 'claude' else []
            pending.put((provider, last_good_seats(provider, [failed(provider, code, message, name) for name in names or ['default']]), ''))
    for provider in PROVIDERS:
        threading.Thread(target=worker, args=(provider,), daemon=True).start()
    while len(results) < len(PROVIDERS):
        try:
            provider, values, lines = pending.get(timeout=max(0, deadline - time.monotonic()))
            results[provider] = (values, lines)
        except queue.Empty:
            break
    for provider in PROVIDERS:
        if provider not in results:
            message = 'The provider timed out after 15 seconds; run `ai-subs status` after a minute.'
            names = claude_inventory() if provider == 'claude' else []
            results[provider] = (last_good_seats(provider, [failed(provider, 'timeout', message, name) for name in names or ['default']]), '')
    return [s for p in PROVIDERS for s in results[p][0]], results['claude'][1].splitlines()


def roles_config(seats=None):
    path = ROOT / 'roles.toml'
    if not path.exists():
        names = [s['id'] for s in seats or [] if s['provider'] == 'claude']
        if not names:
            # A roles-only first run still gets the adapter's pick ordering.
            values, _ = collect()
            names = [s['id'] for s in values if s['provider'] == 'claude']
        roles = {'operator': names, 'build': ['codex', 'zai', 'kimi'], 'review': ['kimi', 'codex'],
                 'soundboard': ['grok', 'kimi', 'zai'], 'probes': ['openrouter']}
        text = '# Ordered seat IDs or provider aliases. Edit with: ai-subs roles edit\n[thresholds]\n'
        text += ''.join('%s = %s\n' % pair for pair in DEFAULTS.items())
        text += ''.join('\n[roles.%s]\nseats = %s\n' % (role, json.dumps(ids)) for role, ids in roles.items())
        # First-run creation never overwrites an operator's concurrent edit.
        path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
        try:
            fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
        except FileExistsError:
            pass
        else:
            with os.fdopen(fd, 'w') as stream:
                stream.write(text)
            print('Created roles file: ' + str(path) + '; edit with `ai-subs roles edit`.', file=sys.stderr)
    try:
        data = tomllib.loads(path.read_text()) if tomllib else _toml_subset(path.read_text())
        thresholds = dict(DEFAULTS, **data.get('thresholds', {}))
        for key in DEFAULTS:
            if number(thresholds[key]) < 0 or (key in ('session_hold', 'model_switch') and number(thresholds[key]) > 100):
                raise ValueError('threshold out of range')
        if not isinstance(data['roles'], dict):
            raise ValueError('roles required')
        for value in data['roles'].values():
            if not isinstance(value['seats'], list) or not all(isinstance(s, str) for s in value['seats']):
                raise ValueError('seat list required')
        return data['roles'], thresholds
    except (ValueError, OSError, KeyError, TypeError):
        raise Failure('invalid_roles', 'The roles file is invalid; run `ai-subs roles edit`.')


def when(iso, clock=False):
    moment = dt.datetime.fromtimestamp(seconds(iso)).astimezone()
    now = dt.datetime.fromtimestamp(time.time()).astimezone()
    if clock:
        return 'at ' + moment.strftime('%H:%M') if moment.date() == now.date() else 'on ' + moment.strftime('%a')
    minutes = max(0, math.ceil((moment.timestamp() - now.timestamp()) / 60))
    return 'in %dm' % minutes if minutes < 60 else 'in %dh' % math.ceil(minutes / 60)


def advise(value, thresholds):
    provider = value['provider']
    def result(verb, reason, fix):
        return {'verb': verb, 'reason': reason, 'fix': fix}
    if not value['stale'] and (value['error'] or 'expired' in value['note'].lower()):
        if (value['error'] or {}).get('code') == 'token_stale':
            return result('login', 'Run ' + LABELS[provider] + ' once to refresh its token.', 'run ' + provider + ' then ai-subs status --fresh')
        return result('login', 'Sign in to ' + LABELS[provider] + ' again.', 'run ' + FIX[provider])
    windows = value['windows']
    for item in windows:
        if item['kind'] == 'model' and item['used_pct'] is not None and item['used_pct'] >= thresholds['model_switch']:
            substitute = 'Opus 5' if provider == 'claude' else 'another model'
            return result('switch-model', item['label'] + ' is capped this week. Use ' + substitute + ' here.',
                          'run /model in the affected sessions')
    # A spent window of ANY kind means hold: a weekly at 97% is as unusable as a 5h at 97%
    # (Kimi read 7d:97 and was still named the review seat before this rule covered weekly).
    for item in windows:
        if item['kind'] in ('session', 'weekly', 'monthly') and item['used_pct'] is not None and item['used_pct'] >= thresholds['session_hold']:
            booster = next((w for w in windows if w['kind'] == 'credits' and w['label'] == 'booster'), None)
            if provider == 'kimi' and item['kind'] == 'weekly' and booster:
                balance = booster['remaining']
                if balance > thresholds['credits_floor']:
                    return result('ok', 'Weekly quota is full; running on the $%.2f booster.' % balance, '')
                return result('top-up', '$%.2f left on the booster. Top up.' % balance,
                              'run open ' + value['links']['billing'])
            reset = when(item['resets_at'], clock=True) if item['resets_at'] else None
            reason = ('Full. Resets ' + reset + '.' if reset and reset.startswith('at ') else
                      'Full for ' + when(item['resets_at'])[3:] + '.' if reset else 'Full. Reset time unavailable.')
            return result('hold', reason, 'run claude-accounts pick --use' if provider == 'claude' else 'run ai-subs timeline')
    for item in windows:
        if item['kind'] == 'credits' and item['remaining'] < thresholds['credits_floor']:
            return result('top-up', '$%.2f left. Top up.' % item['remaining'],
                          'run open https://openrouter.ai/settings/credits' if provider == 'openrouter' else
                          'run open ' + value['links']['usage'])
    for item in windows:
        # Only a WEEK or MONTH can 'expire unused'; a 5-hour window always does and would nag forever.
        if item['kind'] in ('weekly', 'monthly') and item['resets_at'] and item['used_pct'] is not None:
            left = seconds(item['resets_at']) - time.time()
            if 0 <= left <= thresholds['spend_within_hours'] * 3600 and item['used_pct'] < 70:
                period = {'session': '5-hour window', 'weekly': 'week', 'monthly': 'month'}[item['kind']]
                return result('spend', 'Use now: %d%% of this %s expires %s.' % (100 - item['used_pct'], period, when(item['resets_at'], clock=True)),
                              'run claude-accounts use ' + shlex.quote(value['account']) if provider == 'claude' else
                              'run ai-subs next and assign work to ' + provider)
    return result('ok', 'Ready.', '')


def presentation(value, thresholds=DEFAULTS):
    display_plan = {'pro': 'Pro', 'plus': 'Plus', 'free': 'Free'}.get(value['plan'], value['plan'])
    value['label'] = (value['account'] + (' · ' + value['email'] if value['email'] else '') if value['provider'] == 'claude'
                      else value['provider_label'] + (' · ' + display_plan if display_plan else ''))
    windows = [w for w in value['windows'] if w['kind'] in ('session', 'weekly', 'monthly', 'model') and w['used_pct'] is not None]
    binding = max(windows, key=lambda w: w['used_pct']) if windows else next((w for w in value['windows'] if w['kind'] == 'credits'), None)
    value['binding'] = binding['label'] if binding else None
    value['binding_pct'] = binding['used_pct'] if binding else None
    verb = value['advice']['verb']
    booster_running = (value['provider'] == 'kimi' and verb == 'ok' and
                       any(w['kind'] == 'credits' and w['label'] == 'booster' for w in value['windows']) and
                       any(w['kind'] == 'weekly' and w['used_pct'] is not None and
                           w['used_pct'] >= thresholds['session_hold'] for w in value['windows']))
    value['status'] = ('unreadable' if value['error'] else
                       {'login': 'login', 'switch-model': 'capped', 'hold': 'hold', 'top-up': 'top-up', 'spend': 'spend'}.get(
                           verb, 'warn' if booster_running or value['binding_pct'] is not None and value['binding_pct'] >= 70 else 'ok'))


def summary(seats):
    labels = [('spend', 'expiring'), ('capped', 'capped'), ('hold', 'full'), ('top-up', 'low credit'),
              ('login', 'sign-in needed'), ('unreadable', 'unreadable')]
    parts = []
    for status, label in labels:
        count = sum(s['status'] == status for s in seats)
        if count:
            parts.append('%d %s' % (count, label))
    return ' · '.join(parts[:3])


def matches(selector, value):
    return selector in (value['id'], value['provider'])


def choose(seats, roles, thresholds):
    result = {}
    rank = {'spend': 0, 'ok': 0, 'switch-model': 1, 'hold': 2, 'top-up': 3, 'login': 4}
    for role, config in roles.items():
        candidates = [(selector, s) for selector in config['seats'] for s in seats if matches(selector, s)]
        eligible = [(selector, s) for selector, s in candidates if s['advice']['verb'] in ('ok', 'spend') and
                    (s['provider'] != 'claude' or any(w['kind'] == 'session' and w['used_pct'] is not None and w['used_pct'] < thresholds['session_hold'] for w in s['windows']))]
        if eligible:
            result[role] = eligible[0][0]
        elif candidates:
            selector, value = min(candidates, key=lambda pair: rank[pair[1]['advice']['verb']])
            result[role] = selector
        else:
            result[role] = None
    return result


def timeline(seats):
    now = time.time()
    values = [{'seat': s['id'], 'label': w['label'], 'resets_at': w['resets_at']} for s in seats for w in s['windows']
              if w['resets_at'] and now <= seconds(w['resets_at']) <= now + 7 * 86400]
    values.extend({'seat': s['id'], 'label': 'renews', 'resets_at': s['renews_at']} for s in seats
                  if s.get('renews_at') and now <= seconds(s['renews_at']) <= now + 7 * 86400)
    return sorted(values, key=lambda item: seconds(item['resets_at']))


def notification_title(value):
    return value['provider_label'] + (' · ' + value['account'] if value['provider'] == 'claude' else '')


def alert_detail(seats, passthrough):
    def detail(value):
        return {'seat': value['id'], 'short': value['short'], 'title': notification_title(value), 'human': value['advice']['reason'],
                'fix': value['advice']['fix'], 'status': value['status']}
    details = [detail(s) for s in seats if s['advice']['verb'] != 'ok']
    for line in passthrough:
        value = next((s for s in seats if s['provider'] == 'claude' and
                      (line.startswith(s['account'] + ': ') or line.startswith(s['id'] + ': '))), None)
        human, _, fix = line.partition(' — ')
        if value:
            human = human.split(': ', 1)[1]
        elif human == 'live login is unsaved':
            value = next((s for s in seats if s['provider'] == 'claude' and s['account'] == 'unsaved live'), None)
        diagnostic = ('Save this Claude sign-in.' if human == 'live login is unsaved' else
                      'Sign in to Claude again.' if human.startswith('refresh token expires') else
                      'Claude access needs attention.' if human == 'locked' else
                      'Claude usage could not be read.' if human == 'unreachable' else None)
        if value and not diagnostic and value['advice']['verb'] != 'ok':
            item = detail(value)
        else:
            # Keep adapter-only warnings and their actions, including warnings below our thresholds.
            if not diagnostic and value:
                diagnostic = ('The 5-hour window is nearly full.' if human == '5h nearly spent' else
                              'Weekly capacity expires soon.' if human == '7d window expiring unused' else
                              'Claude capacity needs attention.')
            item = {'seat': value['id'] if value else 'claude:default', 'short': value['short'] if value else 'Claude',
                    'title': notification_title(value) if value else 'Claude',
                    'human': diagnostic or 'Claude alerts could not be read.',
                    'fix': fix or 'run claude-accounts alerts', 'status': value['status'] if value else 'unreadable'}
        if item not in details:
            details.append(item)
    for value in seats:
        if value.get('renews_at') and 0 <= seconds(value['renews_at']) - time.time() <= 36 * 3600:
            details.append({'seat': value['id'], 'short': value['short'], 'title': notification_title(value),
                            'human': 'renews tomorrow', 'fix': 'open the billing page: ' + value['links']['billing'],
                            'status': 'renews', 'renews_at': value['renews_at']})
    return details


def alert_lines(seats, passthrough):
    return [item['seat'] + ': ' + item['human'] + (' — ' + item['fix'] if item['fix'] else '')
            for item in alert_detail(seats, passthrough)]


def status_data(fresh=False):
    seats, passthrough = collect(fresh)
    roles, thresholds = roles_config(seats)
    for value in seats:
        for item in value['windows']:
            item['resets_in_s'] = max(0, int(seconds(item['resets_at']) - time.time())) if item['resets_at'] else None
            item['remaining_pct'] = 100 - item['used_pct'] if item['used_pct'] is not None else None
        value['role'] = next((role for role, config in roles.items() if any(matches(s, value) for s in config['seats'])), None)
        value['advice'] = advise(value, thresholds)
        presentation(value, thresholds)
    return {'fetched_at': dt.datetime.now(dt.timezone.utc).isoformat(), 'seats': seats,
            'alerts': alert_lines(seats, passthrough), 'alerts_detail': alert_detail(seats, passthrough),
            'summary': summary(seats), 'next': choose(seats, roles, thresholds), 'timeline': timeline(seats)}


def notify(lines, details, seats=None):
    try:
        if subprocess.run(['pgrep', '-x', 'KapableScribe'], capture_output=True, timeout=5).returncode == 0:
            print('notifications: app is running, left to it')
            return
    except (OSError, subprocess.TimeoutExpired):
        pass
    by_id = {s['id']: s for s in seats or []}
    ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
    with (ROOT / '.alerts-lock').open('a') as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        last = ROOT / '.alerts-last'
        previous = set(last.read_text().splitlines()) if last.exists() else set()
        holds_path = ROOT / '.alerts-holds'
        held = set(holds_path.read_text().splitlines()) if holds_path.exists() else set()
        holding = set()
        notified = []
        renewals_path = ROOT / '.alerts-renewals'
        renewals = set(renewals_path.read_text().splitlines()) if renewals_path.exists() else set()
        for line, item in zip(lines, details):
            value = by_id.get(item['seat'])
            if value and value['stale']:
                if item['seat'] in held:
                    holding.add(item['seat'])
                continue
            if value and value['error'] and value['error']['code'] != 'login_expired':
                continue
            if item['status'] not in ('login', 'capped', 'hold', 'top-up', 'spend', 'renews') and not (value and (value['error'] or {}).get('code') == 'login_expired'):
                continue
            if item['human'] in ('Claude usage could not be read.', 'Claude alerts could not be read.'):
                continue
            if item['status'] == 'hold' and item['seat'] in held:
                holding.add(item['seat'])
                notified.append(line)
                continue
            renewal_key = item['seat'] + ':' + item['renews_at'] if item['status'] == 'renews' else None
            if renewal_key in renewals:
                continue
            if line not in previous or renewal_key:
                try:
                    subprocess.run(['osascript', '-e', 'display notification ' + json.dumps(item['human'], ensure_ascii=False) + ' with title ' + json.dumps(item['title'], ensure_ascii=False)],
                                   check=True, capture_output=True, timeout=5)
                except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
                    print('Notifications failed; run `ai-subs alerts --notify` on macOS.', file=sys.stderr)
                    continue
            if renewal_key:
                renewals.add(renewal_key)
            notified.append(line)
            if item['status'] == 'hold':
                holding.add(item['seat'])
        atomic(renewals_path, ''.join(key + '\n' for key in sorted(renewals)))
        atomic(holds_path, ''.join(seat + '\n' for seat in sorted(holding)))
        atomic(last, ''.join(line + '\n' for line in notified))


def table(data):
    kinds = [k for k in ('session', 'weekly', 'model', 'monthly', 'credits') if any(w['kind'] == k for s in data['seats'] for w in s['windows'])]
    labels = {'session': '5H%', 'weekly': '7D%', 'model': 'MODEL%', 'monthly': 'MONTH%', 'credits': 'CREDITS'}
    rows = [['PROVIDER', 'ACCOUNT', 'ROLE', *[labels[k] for k in kinds], 'ADVICE', 'NOTE']]
    for s in data['seats']:
        fields = []
        for kind in kinds:
            items = [w for w in s['windows'] if w['kind'] == kind]
            fields.append(', '.join(('%s %.2f' % (w['currency'], w['remaining'])) if kind == 'credits' else
                                    '%s:%s' % (w['label'], '-' if w['used_pct'] is None else w['used_pct']) for w in items) or '-')
        rows.append([s['provider_label'], s['account'] + (' *' if s['live'] else ''), s['role'] or '-', *fields,
                     s['advice']['verb'] + ': ' + s['advice']['reason'], s['note'] or '-'])
    rows = [[str(cell).replace('\n', ' ').replace('\r', ' ').replace('\t', ' ') for cell in row] for row in rows]
    widths = [max(len(row[i]) for row in rows) for i in range(len(rows[0]))]
    print('\n'.join(' · '.join(cell.ljust(widths[i]) for i, cell in enumerate(row)).rstrip() for row in rows))


def serve(port):
    class Handler(http.server.BaseHTTPRequestHandler):
        def log_message(self, *args):
            pass

        def respond(self, code, body):
            encoded = json.dumps(body, allow_nan=False).encode()
            self.send_response(code)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Content-Length', str(len(encoded)))
            self.end_headers()
            self.wfile.write(encoded)

        def do_GET(self):
            if self.path == '/healthz':
                return self.respond(200, {'ok': True})
            routes = {'/subs': None, '/subs/next': 'next', '/subs/alerts': 'alerts', '/subs/timeline': 'timeline'}
            if self.path not in routes:
                return self.respond(404, {'error': {'code': 'not_found', 'message': 'This route does not exist; run `ai-subs --help`.'}})
            try:
                process = subprocess.run([str(SCRIPT), 'status', '--json'], text=True, capture_output=True, timeout=20)
                if process.returncode:
                    raise ValueError('CLI failed')
                data = json.loads(process.stdout)
                self.respond(200, data if routes[self.path] is None else data[routes[self.path]])
            except (OSError, ValueError, KeyError, subprocess.TimeoutExpired):
                self.respond(502, {'error': {'code': 'cli_failed', 'message': 'Subscription data is unavailable; run `ai-subs status`.'}})

        def do_POST(self):
            self.respond(405, {'error': {'code': 'method_not_allowed', 'message': 'Only GET is supported; run `ai-subs --help`.'}})
        do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = do_POST
    try:
        server = http.server.ThreadingHTTPServer(('127.0.0.1', port), Handler)
    except (OSError, OverflowError):
        raise Failure('bind_failed', 'The loopback port is unavailable; run `ai-subs serve --port 47313`.')
    print('Serving http://127.0.0.1:%d; stop with Ctrl-C.' % server.server_port, file=sys.stderr, flush=True)
    try:
        server.serve_forever()
    finally:
        server.server_close()


def main(args):
    class Parser(argparse.ArgumentParser):
        def error(self, message):
            raise Failure('invalid_arguments', 'The command or arguments are invalid; run `ai-subs --help`.')
    parser = Parser(description='Spend the AI subscriptions already paid for.', epilog='Claude renewal is inferred from the Stripe monthly subscription anniversary (UTC, clamped in shorter months); no API exposes the real renewal date. Fix invalid arguments with: ai-subs --help')
    commands = parser.add_subparsers(dest='command')
    status = commands.add_parser('status')
    status.add_argument('--json', action='store_true')
    status.add_argument('--fresh', action='store_true')
    next_parser = commands.add_parser('next')
    next_parser.add_argument('--for', dest='role')
    alerts = commands.add_parser('alerts')
    alerts.add_argument('--notify', action='store_true')
    commands.add_parser('timeline')
    roles = commands.add_parser('roles')
    roles.add_argument('action', choices=['edit'], nargs='?')
    server = commands.add_parser('serve')
    server.add_argument('--port', type=int, default=47312)
    options = parser.parse_args(args or ['status'])
    if FIXTURES and (not os.environ.get('AI_SUBS_HOME') or ROOT.resolve() == (Path.home() / '.ai-subs').resolve()):
        raise Failure('unsafe_fixture_home', 'Fixtures require an isolated home; run `AI_SUBS_HOME=$(mktemp -d) ai-subs status`.')
    if options.command == 'serve':
        serve(options.port)
        return 0
    if options.command == 'roles':
        if not (ROOT / 'roles.toml').exists():
            roles_config()
        if options.action == 'edit':
            subprocess.run([*shlex.split(os.environ.get('EDITOR', 'vi')), str(ROOT / 'roles.toml')], check=True)
        else:
            print((ROOT / 'roles.toml').read_text(), end='')
        return 0
    data = status_data(getattr(options, 'fresh', False))
    if options.command == 'status':
        if options.json:
            print(json.dumps(data, indent=2, allow_nan=False))
        else:
            table(data)
    elif options.command == 'next':
        if options.role and options.role not in data['next']:
            raise Failure('unknown_role', 'The role is not configured; run `ai-subs roles edit`.')
        chosen = {options.role: data['next'][options.role]} if options.role else data['next']
        for role, selector in chosen.items():
            value = next((s for s in data['seats'] if selector and matches(selector, s)), None)
            if value and value['advice']['verb'] not in ('ok', 'spend'):
                print(role + ': ' + value['advice']['reason'], file=sys.stderr)
            elif value is None:
                print('No seat is configured for ' + role + '; run `ai-subs roles edit`.', file=sys.stderr)
        print(chosen[options.role] or 'NONE' if options.role else json.dumps(chosen, indent=2))
    elif options.command == 'alerts':
        for line in data['alerts']:
            print(line)
        if options.notify:
            notify(data['alerts'], data['alerts_detail'], data['seats'])
        return 1 if data['alerts'] else 0
    elif options.command == 'timeline':
        for item in data['timeline']:
            print('%s · %s · %s' % (item['resets_at'], item['seat'], item['label']))
    return 0


if __name__ == '__main__':
    try:
        sys.exit(main(sys.argv[2:]))
    except KeyboardInterrupt:
        sys.exit(0)
    except Failure as error:
        print(error.message, file=sys.stderr)
        sys.exit(2)
    except Exception:
        # Never expose raw replies, credential values or subprocess exception argv.
        print('Subscription data or configuration could not be read; run `ai-subs status`, then `ai-subs roles edit`.', file=sys.stderr)
        sys.exit(2)
PY
