master
John Lauer Publish 1.1.120 13ac4bc 2d ago
#!/usr/bin/env python3
"""Claude usage in the title bar: 5-hour session, weekly, and the weekly model buckets that
apply to your plan, as percent used. Reads the Claude Code sign-in in ~/.claude/.credentials.json
and asks the same usage endpoint the Claude Code CLI shows in /usage. Asks the provider at most once every three minutes, pauses while the window is hidden, retries at 5, 15, 45 s on errors. Click the slot to cycle the views (rows, one number, a ring, a pair, all numbers, all rings)."""
import json, os, sys, time
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
from importlib.machinery import SourceFileLoader
marks = SourceFileLoader('hwmarks', os.path.join(os.path.dirname(os.path.realpath(__file__)), 'hydrogen-widget-marks.py')).load_module()
lib = SourceFileLoader('hwlib', os.path.join(os.path.dirname(os.path.realpath(__file__)), 'hydrogen-widget-lib.py')).load_module()

SESSION_S, WEEK_S = 5 * 3600, 7 * 86400

def epoch(iso):
    """The provider's ISO reset time as Unix seconds (None when absent or unreadable)."""
    if not iso: return None
    try:
        from datetime import datetime
        return round(datetime.fromisoformat(str(iso).replace('Z', '+00:00')).timestamp())
    except Exception: return None

def data():
    if not os.path.exists(os.path.expanduser('~/.claude/.credentials.json')):
        return {'error': 'sign in to Claude Code in this workspace'}
    c = lib.read_json_if_changed('~/.claude/.credentials.json')
    oa = c.get('claudeAiOauth') or c
    tok = oa.get('accessToken')
    if not tok: return {'error': 'not signed in to Claude Code'}
    exp = oa.get('expiresAt') or 0
    if exp and exp < time.time() * 1000:
        # Do not spend a call on a token the file already says is expired; Claude Code refreshes
        # it on its next run and read_json_if_changed picks the new file up.
        e = Exception('token expired'); e.code = 401; raise e
    u = lib.http_json('https://api.anthropic.com/api/oauth/usage', {'Authorization': 'Bearer ' + tok, 'anthropic-beta': 'oauth-2025-04-20', 'User-Agent': 'hydrogen-widget-claude-usage'})
    bars = []
    # The structured list is what Claude Code's own Usage panel draws: the session window, the
    # weekly window, and every weekly limit scoped to a model (Fable, Opus, ...), each with the
    # provider's severity. Fall back to the flat fields on an older response.
    # Every limit also carries when it resets and how long its window is, so the hover view can
    # draw a countdown and say whether the current pace runs out before the reset (Drew
    # 2026-10-12: "a meter to show time remaining until my usage limits reset").
    for lim in u.get('limits') or []:
        kind = lim.get('kind'); pct = lim.get('percent')
        if pct is None: continue
        if kind == 'session': label, name, win = '5h', 'Current session', SESSION_S
        elif kind == 'weekly_all': label, name, win = 'week', 'This week, all models', WEEK_S
        elif kind == 'weekly_scoped':
            scope = lim.get('scope') or {}; model = scope.get('model') or {}
            label = model.get('display_name') or 'model'
            name, win = label + ' this week', WEEK_S
        else: label, name, win = kind or 'limit', (kind or 'limit').replace('_', ' ').capitalize(), 0
        bars.append({'label': label, 'pct': pct, 'severity': lim.get('severity') or 'normal',
                     'name': name, 'resets_at': epoch(lim.get('resets_at')), 'window_s': win})
    if not bars:
        for label, key, name, win in (('5h', 'five_hour', 'Current session', SESSION_S), ('week', 'seven_day', 'This week, all models', WEEK_S)):
            v = u.get(key) or {}
            if v.get('utilization') is not None:
                bars.append({'label': label, 'pct': v['utilization'], 'severity': 'normal', 'name': name, 'resets_at': epoch(v.get('resets_at')), 'window_s': win})
        for k, v in u.items():
            if k.startswith('seven_day_') and isinstance(v, dict) and v.get('utilization') is not None:
                label = k[len('seven_day_'):].replace('_', ' ')
                bars.append({'label': label, 'pct': v['utilization'], 'severity': 'normal', 'name': label.capitalize() + ' this week', 'resets_at': epoch(v.get('resets_at')), 'window_s': WEEK_S})
    out = {'items': bars}
    # Which account and plan: the plan is in the sign-in file, the email in Claude Code's own
    # settings (~/.claude.json oauthAccount), so a second sign-in is never a mystery.
    if oa.get('subscriptionType'): out['plan'] = oa['subscriptionType']
    try:
        acc = (lib.read_json_if_changed('~/.claude.json').get('oauthAccount') or {})
        if acc.get('emailAddress'): out['account'] = acc['emailAddress']
    except Exception: pass
    # Usage credits (the paid overflow past the plan limits), when the account has them turned on.
    sp = u.get('spend') or {}
    if sp.get('enabled') and isinstance(sp.get('limit'), dict):
        def money(m): return (m.get('amount_minor') or 0) / (10 ** (m.get('exponent') or 0))
        out['credits'] = {'used': money(sp.get('used') or {}), 'limit': money(sp['limit']), 'currency': (sp['limit'].get('currency') or 'USD')}
    return out

RENDER = r"""(d.error?`<span class="err">${d.error}</span>`:(d.items||[]).map(b=>bar(b.label,b.pct,b.severity)).join(''))"""
lib.serve('Claude usage', data, RENDER, 180000, mark=marks.MARKS['claude'], default_view='big:week')