#!/usr/bin/python3 # blob:summary=Print the Claude Code usage record as JSON # blob:args=[--force] [--limits-only] # blob:hidden=true from __future__ import annotations import argparse import datetime as dt import fcntl import hashlib import json import os import re import sys import tempfile import time import urllib.error import urllib.request from pathlib import Path from typing import Any AGENT_ID = "claude" AGENT_NAME = "Claude Code" AUTH_HELP = "Run `claude auth login` to restore usage limits." USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage" PROBE_MIN_INTERVAL_SECONDS = 15 CURRENT_ENOUGH_SECONDS = 900 INDEX_VERSION = 4 INDEX_HEAD_BYTES = 4096 def config_dir() -> Path: return expand_path(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude") def expand_path(value: str) -> Path: return Path(os.path.expandvars(os.path.expanduser(value))).resolve() def cache_root() -> Path: root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "blob" / "agent-usage" root.mkdir(parents=True, exist_ok=True) return root def date_string(value: dt.date) -> str: return value.strftime("%Y-%m-%d") def recent_date_strings() -> list[str]: today = dt.datetime.now().date() return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)] def local_date_string() -> str: return date_string(dt.datetime.now().date()) def local_date_from_timestamp(value: Any) -> str: if value is None: return local_date_string() if isinstance(value, (int, float)): try: seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value) return date_string(dt.datetime.fromtimestamp(seconds).date()) except Exception: return local_date_string() raw = str(value).strip() if not raw: return local_date_string() try: parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) if parsed.tzinfo is not None: parsed = parsed.astimezone() return date_string(parsed.date()) except Exception: return local_date_string() def usage_token(usage: dict[str, Any], snake_key: str, camel_key: str) -> int: value = usage.get(snake_key, usage.get(camel_key, 0)) try: return round(float(value or 0)) except Exception: return 0 def number(value: Any) -> int: try: n = float(value or 0) return round(n) if n == n else 0 except Exception: return 0 def empty_bucket() -> dict[str, int]: return { "inputTokens": 0, "outputTokens": 0, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0, } def read_json(path: Path) -> dict[str, Any] | None: try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return None def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None: if max_age_seconds <= 0 or not path.exists(): return None try: age = time.time() - path.stat().st_mtime if 0 <= age <= max_age_seconds: return read_json(path) except Exception: return None return None def write_json(path: Path, payload: dict[str, Any]) -> None: handle_fd, temporary_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp") temporary_path = Path(temporary_name) try: with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n") temporary_path.chmod(0o644) temporary_path.replace(path) except BaseException: temporary_path.unlink(missing_ok=True) raise def index_zone() -> list[Any]: return [os.environ.get("TZ"), list(time.tzname), time.timezone, time.altzone] def load_index(index_file: Path) -> dict[str, Any]: data = read_json(index_file) if ( isinstance(data, dict) and data.get("version") == INDEX_VERSION and data.get("zone") == index_zone() and isinstance(data.get("files"), dict) ): return data["files"] return {} def read_digest(path: Path, length: int) -> str: digest = hashlib.sha1() with path.open("rb") as handle: digest.update(handle.read(min(length, INDEX_HEAD_BYTES))) handle.seek(max(0, length - INDEX_HEAD_BYTES)) digest.update(handle.read(min(length, INDEX_HEAD_BYTES))) return digest.hexdigest() def parse_usage_records(path: Path, entry: dict[str, Any] | None) -> dict[str, Any]: stat = path.stat() size = stat.st_size mtime = stat.st_mtime_ns if entry and entry.get("size") == size and entry.get("mtime") == mtime: return entry if ( entry and size > number(entry.get("size")) and entry.get("file") == [stat.st_dev, stat.st_ino] and entry.get("head") == read_digest(path, number(entry.get("offset"))) ): records = list(entry.get("records") or []) offset = number(entry.get("offset")) line_number = number(entry.get("lines")) else: records = [] offset = 0 line_number = 0 positions = {record[0]: index for index, record in enumerate(records)} with path.open("rb") as handle: handle.seek(offset) for raw in handle: if not raw.endswith(b"\n"): break offset += len(raw) line_number += 1 if b'"usage":' not in raw: continue try: parsed = json.loads(raw.decode("utf-8", errors="replace")) except Exception: continue message = parsed.get("message") if isinstance(parsed.get("message"), dict) else {} if parsed.get("type") != "assistant" and message.get("role") != "assistant": continue usage = message.get("usage") or parsed.get("usage") if not isinstance(usage, dict): continue message_id = message.get("id") or parsed.get("messageId") or "" key = str(message_id) if message_id else f"{path}:{parsed.get('uuid') or parsed.get('requestId') or line_number}" input_tokens = usage_token(usage, "input_tokens", "inputTokens") output_tokens = usage_token(usage, "output_tokens", "outputTokens") cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens") cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens") if input_tokens + output_tokens + cache_read + cache_write <= 0: continue held = positions.get(key) if held is not None and output_tokens < records[held][4]: continue timestamp = parsed.get("timestamp") or message.get("timestamp") record = [ key, str(message.get("model") or parsed.get("model") or "claude"), local_date_from_timestamp(timestamp) if timestamp is not None else None, input_tokens, output_tokens, cache_read, cache_write, str(parsed.get("sessionId") or path), ] if held is None: positions[key] = len(records) records.append(record) else: records[held] = record return { "size": size, "file": [stat.st_dev, stat.st_ino], "mtime": mtime, "head": read_digest(path, offset), "offset": offset, "lines": line_number, "records": records, } def scan_projects(projects_path: Path, index_file: Path) -> dict[str, Any]: previous = load_index(index_file) current: dict[str, Any] = {} order: list[str] = [] unreadable_count = 0 files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else [] for path in files: key = str(path) try: current[key] = parse_usage_records(path, previous.get(key)) except Exception: unreadable_count += 1 if key in previous: current[key] = previous[key] if key in current: order.append(key) if unreadable_count: print(f"Ignoring {unreadable_count} unreadable Claude project files", file=sys.stderr) if current != previous: try: write_json(index_file, {"version": INDEX_VERSION, "zone": index_zone(), "files": current}) except Exception as error: print(f"Could not write Claude scan index {index_file}: {error}", file=sys.stderr) return summarize_records(current, order) def summarize_records(indexed: dict[str, Any], order: list[str]) -> dict[str, Any]: today = local_date_string() recent_dates = recent_date_strings() recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} seen: dict[str, list[Any]] = {} sessions: set[str] = set() active_days: set[str] = set() today_sessions: set[str] = set() today_tokens: dict[str, int] = {} usage_by_model: dict[str, dict[str, int]] = {} prompts = 0 today_prompt_count = 0 today_token_total = 0 for key in order: for record in indexed[key].get("records") or []: held = seen.get(record[0]) if held is not None and record[4] < held[4]: continue seen[record[0]] = record for _, model, day, input_tokens, output_tokens, cache_read, cache_write, session_key in seen.values(): if day is None: day = today total = input_tokens + output_tokens + cache_read + cache_write sessions.add(session_key) active_days.add(day) prompts += 1 bucket = usage_by_model.setdefault(model, empty_bucket()) bucket["inputTokens"] += input_tokens bucket["outputTokens"] += output_tokens bucket["cacheReadInputTokens"] += cache_read bucket["cacheCreationInputTokens"] += cache_write if day in recent: recent[day]["messageCount"] += total if day == today: today_prompt_count += 1 today_sessions.add(session_key) today_token_total += total today_tokens[model] = today_tokens.get(model, 0) + total return { "todayPrompts": today_prompt_count, "todaySessions": len(today_sessions), "todayTotalTokens": today_token_total, "todayTokensByModel": today_tokens, "recentDays": [recent[day] for day in recent_dates], "modelUsage": usage_by_model, "totalPrompts": prompts, "totalSessions": len(sessions), "activeDays": len(active_days), } def cached_scan(projects_path: Path, max_age_seconds: float, use_index: bool) -> dict[str, Any]: digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16] root = cache_root() cache_file = root / f"claude-scan-{digest}.json" lock_file = root / f"claude-scan-{digest}.lock" index_file = root / f"claude-index-{digest}.json" cached = read_fresh_json(cache_file, max_age_seconds) if cached is not None: return cached with lock_file.open("w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) cached = read_fresh_json(cache_file, max_age_seconds) if cached is not None: return cached if not use_index: index_file.unlink(missing_ok=True) summary = scan_projects(projects_path, index_file) write_json(cache_file, summary) return summary def stats_cache_fallback(claude_dir: Path) -> dict[str, Any] | None: data = read_json(claude_dir / "stats-cache.json") if data is None: return None today = local_date_string() today_tokens = {} for entry in data.get("dailyModelTokens") or []: if isinstance(entry, dict) and entry.get("date") == today: today_tokens = entry.get("tokensByModel") or {} break daily_activity = [day for day in (data.get("dailyActivity") or []) if isinstance(day, dict)] active_dates = {str(day.get("date")) for day in daily_activity if number(day.get("messageCount")) > 0 and day.get("date")} today_prompts, today_sessions = today_prompts_from_history(claude_dir) return { "todayPrompts": today_prompts, "todaySessions": today_sessions, "todayTotalTokens": sum(number(value) for value in today_tokens.values()), "todayTokensByModel": today_tokens, "recentDays": daily_activity[-7:], "modelUsage": data.get("modelUsage") or {}, "totalPrompts": number(data.get("totalMessages")), "totalSessions": number(data.get("totalSessions")), "activeDays": len(active_dates), } def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]: prompts = 0 sessions: set[str] = set() start_of_day = dt.datetime.combine(dt.datetime.now().date(), dt.time.min).timestamp() * 1000 try: with (claude_dir / "history.jsonl").open("r", encoding="utf-8", errors="replace") as handle: lines = handle.readlines() except Exception: return 0, 0 for line in reversed(lines): line = line.strip() if not line: continue try: entry = json.loads(line) except Exception: continue if number(entry.get("timestamp")) < start_of_day: break prompts += 1 if entry.get("sessionId"): sessions.add(str(entry.get("sessionId"))) return prompts, len(sessions) def oauth_login(claude_dir: Path) -> tuple[str, int, int, str]: data = read_json(claude_dir / ".credentials.json") or {} login = data.get("claudeAiOauth") if not isinstance(login, dict): return "", 0, 0, "" tier = profile_tier(claude_dir) if not re.search(r"max_(\d+x)", tier, re.IGNORECASE): tier = str(login.get("rateLimitTier") or "") plan = plan_label(tier, str(login.get("subscriptionType") or "")) return ( str(login.get("accessToken") or ""), number(login.get("expiresAt")), number(login.get("refreshTokenExpiresAt")), plan, ) def profile_tier(claude_dir: Path) -> str: profile = read_json(profile_path(claude_dir)) or {} account = profile.get("oauthAccount") if not isinstance(account, dict): return "" for key in ("userRateLimitTier", "organizationRateLimitTier"): tier = str(account.get(key) or "") if tier: return tier return "" def profile_path(home: Path) -> Path: legacy = home / ".config.json" if legacy.exists(): return legacy configured = os.environ.get("CLAUDE_CONFIG_DIR") if home == expand_path("~/.claude") and not (configured and expand_path(configured) == home): return Path.home() / ".claude.json" return home / ".claude.json" def plan_label(tier: str, subscription: str) -> str: plan = subscription[0].upper() + subscription[1:] if subscription else "" match = re.search(r"max_(\d+x)", tier, re.IGNORECASE) if tier else None if not match: return plan return (plan or "Max") + " " + match.group(1) def parse_utilization(value: Any) -> float: try: return float(str(value).strip().replace("%", "")) except Exception: return float("nan") def normalize_utilization(value: Any, percent_scale: bool) -> float: n = parse_utilization(value) if not (n >= 0): return -1.0 if percent_scale or n > 1: return min(1.0, n / 100.0) return min(1.0, n) def normalize_reset_at(value: Any) -> str: if value is None: return "" raw = str(value).strip() if raw == "": return "" if raw.isdigit(): timestamp = int(raw) if timestamp < 1e12: timestamp *= 1000 try: return dt.datetime.fromtimestamp(timestamp / 1000, dt.timezone.utc).isoformat() except Exception: return raw try: return dt.datetime.fromisoformat(raw.replace("Z", "+00:00")).isoformat() except Exception: return raw def usage_bucket(payload: dict[str, Any], key: str) -> dict[str, Any] | None: bucket = payload.get(key) return bucket if isinstance(bucket, dict) else None def scoped_window(kind: str) -> str: text = kind.lower() if "month" in text: return "Monthly" if "week" in text or "day" in text: return "Weekly" if "hour" in text or "session" in text: return "Session" return "" def scoped_limits(payload: dict[str, Any], percent_scale: bool) -> list[dict[str, Any]]: entries = payload.get("limits") if not isinstance(entries, list): return [] scoped: list[dict[str, Any]] = [] seen: set[tuple[str, str]] = set() for entry in entries: if not isinstance(entry, dict): continue scope = entry.get("scope") model = scope.get("model") if isinstance(scope, dict) else None if not isinstance(model, dict): continue name = str(model.get("display_name") or model.get("id") or "").strip() kind = str(entry.get("kind") or "").strip() if name == "" or (name, kind) in seen: continue percent = normalize_utilization(entry.get("percent"), percent_scale) if percent < 0: continue seen.add((name, kind)) window = scoped_window(kind) title = name + " " + window if window else name scoped.append({ "label": title, "title": title, "percent": percent, "resetsAt": normalize_reset_at(entry.get("resets_at")), }) return scoped def probe_limits(access_token: str) -> dict[str, Any]: request = urllib.request.Request( USAGE_ENDPOINT, headers={ "Authorization": "Bearer " + access_token, "anthropic-beta": "oauth-2025-04-20", "Accept": "application/json", }, ) try: with urllib.request.urlopen(request, timeout=10) as response: payload = json.loads(response.read().decode("utf-8", errors="replace")) except urllib.error.HTTPError as error: retry_after = error.headers.get("retry-after", "") if error.headers else "" if error.code == 429: help_text = "Anthropic's usage endpoint is rate limiting checks right now" + ( f" (retry after {retry_after}s)" if retry_after else "" ) + "." else: help_text = f"Anthropic's usage endpoint returned status {error.code}." return {"ok": False, "helpText": help_text} except Exception: return { "ok": False, "transport": True, "helpText": "Couldn't reach Anthropic's usage endpoint. Retrying shortly.", } weekly = usage_bucket(payload, "seven_day_oauth_apps") or usage_bucket(payload, "seven_day") session = usage_bucket(payload, "five_hour") raw_values = [session.get("utilization") if session else None, weekly.get("utilization") if weekly else None] entries = payload.get("limits") if isinstance(entries, list): raw_values += [entry.get("percent") for entry in entries if isinstance(entry, dict)] percent_scale = any(parse_utilization(value) >= 1 for value in raw_values) limits = [] if session is not None: percent = normalize_utilization(session.get("utilization"), percent_scale) if percent >= 0: limits.append({"label": "Session (5-hour)", "percent": percent, "resetsAt": normalize_reset_at(session.get("resets_at"))}) if weekly is not None: percent = normalize_utilization(weekly.get("utilization"), percent_scale) if percent >= 0: limits.append({"label": "Weekly (7-day)", "percent": percent, "resetsAt": normalize_reset_at(weekly.get("resets_at"))}) limits.extend(scoped_limits(payload, percent_scale)) if not limits: return {"ok": False, "helpText": "Anthropic's usage endpoint returned no limits."} return {"ok": True, "limits": limits} def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool: raw = str(entry.get("resetsAt") or "") if raw == "": return True try: resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) except Exception: return True if resets_at.tzinfo is None: resets_at = resets_at.replace(tzinfo=dt.timezone.utc) return resets_at > now def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]: entries = cached.get("limits") if not isinstance(entries, list): return [] now = dt.datetime.now(dt.timezone.utc) return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)] def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms: int, force: bool) -> dict[str, Any]: result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False, "fetchedAtMs": 0} probe_cache = cache_root() / "claude-limits.json" cached = read_json(probe_cache) or {} fallback = usable_cached_limits(cached) result["fetchedAtMs"] = number(cached.get("fetchedAtMs")) now_ms = time.time() * 1000 if access_token == "": result["limits"] = fallback result["usageStatusText"] = "Waiting for sign-in" return result if expires_at_ms > 0 and expires_at_ms <= now_ms: result["limits"] = fallback shown = " Showing the last known limits." if fallback else "" if refresh_expires_at_ms > now_ms: result["usageStatusText"] = "Limits paused" result["authHelpText"] = "Claude Code's access token has lapsed." + shown + " Start Claude Code to refresh it." else: result["usageStatusText"] = "Sign-in expired" result["authHelpText"] = "Claude Code's saved sign-in expired." + shown + " Run `claude auth login` to sign in again." return result fetched_age = time.time() - number(cached.get("fetchedAtMs")) / 1000 if fallback and not force and 0 <= fetched_age < PROBE_MIN_INTERVAL_SECONDS: result["limits"] = fallback result["live"] = True result["authHelpText"] = "" return result probe = probe_limits(access_token) if probe["ok"]: result["limits"] = probe["limits"] result["live"] = True result["authHelpText"] = "" result["fetchedAtMs"] = round(time.time() * 1000) write_json(probe_cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": probe["limits"]}) return result if probe.get("transport"): result["retryAdvised"] = True if fallback: result["limits"] = fallback fetched_age = time.time() - result["fetchedAtMs"] / 1000 result["live"] = 0 <= fetched_age < CURRENT_ENOUGH_SECONDS else: result["usageStatusText"] = "Limits unavailable" result["authHelpText"] = probe["helpText"] return result def local_stats(claude_dir: Path, scan_age: float, force: bool) -> dict[str, Any]: stats = cached_scan(claude_dir / "projects", scan_age, use_index=not force) if number(stats.get("totalPrompts")) > 0: return stats fallback = stats_cache_fallback(claude_dir) if fallback is not None: return fallback today_prompts, today_sessions = today_prompts_from_history(claude_dir) if today_prompts or today_sessions: return dict(stats, todayPrompts=today_prompts, todaySessions=today_sessions) return stats def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--force", action="store_true") parser.add_argument("--limits-only", action="store_true") parser.add_argument("--cache-seconds", type=float, default=20) args = parser.parse_args() claude_dir = config_dir() scan_age = 0 if args.force else (900 if args.limits_only else args.cache_seconds) stats = local_stats(claude_dir, scan_age, args.force) access_token, expires_at_ms, refresh_expires_at_ms, plan = oauth_login(claude_dir) limits = collect_limits(access_token, expires_at_ms, refresh_expires_at_ms, args.force) record = { "schemaVersion": 1, "id": AGENT_ID, "name": AGENT_NAME, "updatedAt": dt.datetime.now(dt.timezone.utc).isoformat(), "tierLabel": plan, "usageStatusText": limits["usageStatusText"], "authHelpText": limits["authHelpText"], "limits": limits["limits"], "limitsStale": not limits["live"], "limitsFetchedAt": limits["fetchedAtMs"], } if limits.get("retryAdvised"): record["retryAdvised"] = True record.update(stats) print(json.dumps(record, separators=(",", ":"), sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())