main
Kyle Bergstedt Publish 1.1.112 8d0a92c 6d 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()

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.
    for lim in u.get('limits') or []:
        kind = lim.get('kind'); pct = lim.get('percent')
        if pct is None: continue
        if kind == 'session': label = '5h'
        elif kind == 'weekly_all': label = 'week'
        elif kind == 'weekly_scoped':
            scope = lim.get('scope') or {}; model = scope.get('model') or {}
            label = model.get('display_name') or 'model'
        else: label = kind or 'limit'
        bars.append({'label': label, 'pct': pct, 'severity': lim.get('severity') or 'normal'})
    if not bars:
        for label, key in (('5h', 'five_hour'), ('week', 'seven_day')):
            v = (u.get(key) or {}).get('utilization')
            if v is not None: bars.append({'label': label, 'pct': v, 'severity': 'normal'})
        for k, v in u.items():
            if k.startswith('seven_day_') and isinstance(v, dict) and v.get('utilization') is not None:
                bars.append({'label': k[len('seven_day_'):].replace('_', ' '), 'pct': v['utilization'], 'severity': 'normal'})
    return {'items': bars}

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='nums')