729 lines
23 KiB
Python
Executable File
729 lines
23 KiB
Python
Executable File
#!/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())
|