#!/usr/bin/env python3
import json
import os
import urllib.error
import urllib.request
from datetime import datetime, timezone
CREDENTIALS_PATH = "~/.claude/.credentials.json"
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
REQUEST_TIMEOUT_SECONDS = 10
FIVE_HOURS_IN_SECONDS = 5 * 60 * 60
class DisplayError(Exception):
"""An expected failure with a short message suitable for the panel."""
def load_access_token():
credentials_path = os.path.expanduser(CREDENTIALS_PATH)
try:
with open(credentials_path, encoding="utf-8") as credentials_file:
credentials = json.load(credentials_file)
except FileNotFoundError as error:
raise DisplayError("Claude: log in") from error
except (OSError, json.JSONDecodeError) as error:
raise DisplayError("Claude: auth error") from error
try:
access_token = credentials["claudeAiOauth"]["accessToken"]
except (KeyError, TypeError) as error:
raise DisplayError("Claude: auth error") from error
if not isinstance(access_token, str) or not access_token:
raise DisplayError("Claude: auth error")
return access_token
def fetch_usage(access_token):
request = urllib.request.Request(
USAGE_URL,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"anthropic-version": "2023-06-01",
"anthropic-beta": "oauth-2025-04-20",
},
)
try:
with urllib.request.urlopen(
request,
timeout=REQUEST_TIMEOUT_SECONDS,
) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code in (401, 403):
raise DisplayError("Claude: log in") from error
raise DisplayError("Claude: service error") from error
except (urllib.error.URLError, TimeoutError) as error:
raise DisplayError("Claude: offline") from error
except json.JSONDecodeError as error:
raise DisplayError("Claude: bad data") from error
def parse_reset_time(reset_time_text):
reset_time = datetime.fromisoformat(reset_time_text)
if reset_time.tzinfo is None:
raise ValueError("reset time must include a timezone")
return reset_time
def pace_arrow(used_percentage, reset_time_text, total_seconds, now):
reset_time = parse_reset_time(reset_time_text)
remaining_seconds = (reset_time - now).total_seconds()
elapsed_percentage = (1 - remaining_seconds / total_seconds) * 100
if used_percentage <= elapsed_percentage:
return "↑"
return "↓"
def format_remaining(reset_time_text, now):
reset_time = parse_reset_time(reset_time_text)
remaining_seconds = int((reset_time - now).total_seconds())
if remaining_seconds <= 0:
return "0m"
hours, minutes = divmod(remaining_seconds // 60, 60)
if hours:
return f"{hours}h{minutes:02d}m"
return f"{minutes}m"
def format_usage(usage, now):
five_hour_usage = usage["five_hour"]
seven_day_usage = usage["seven_day"]
reset_time_text = five_hour_usage["resets_at"]
seven_day_percentage = round(seven_day_usage["utilization"])
if not reset_time_text:
return f"5h: -- | 7d: {seven_day_percentage}%"
five_hour_percentage = round(five_hour_usage["utilization"])
arrow = pace_arrow(
five_hour_percentage,
reset_time_text,
FIVE_HOURS_IN_SECONDS,
now,
)
remaining = format_remaining(reset_time_text, now)
return (
f"5h: {five_hour_percentage}%{arrow} {remaining}"
f" | 7d: {seven_day_percentage}%"
)
def main():
try:
access_token = load_access_token()
usage = fetch_usage(access_token)
now = datetime.now(timezone.utc)
print(format_usage(usage, now))
except DisplayError as error:
print(error)
except (KeyError, TypeError, ValueError):
print("Claude: bad data")
if __name__ == "__main__":
main()