|
#!/usr/bin/env python3.14 |
|
""" |
|
Claude Code Usage Status Bar Component for iTerm2 |
|
|
|
Displays: 01:37 20% | 01:22 5% |
|
- First: 5-hour session (time until reset + % used) |
|
- Second: 7-day weekly (time until reset + % used) |
|
- Time format: hh:mm if < 5 hours, else Mon 14:03 |
|
- Error state: Show 'err' indicator |
|
""" |
|
|
|
import iterm2 |
|
import json |
|
import subprocess |
|
from datetime import datetime, timezone |
|
from urllib.request import Request, urlopen |
|
from urllib.error import URLError, HTTPError |
|
|
|
|
|
def get_credentials(): |
|
"""Get Claude Code credentials from macOS Keychain.""" |
|
try: |
|
result = subprocess.run( |
|
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"], |
|
capture_output=True, |
|
text=True, |
|
timeout=5 |
|
) |
|
if result.returncode != 0: |
|
return None |
|
return json.loads(result.stdout.strip()) |
|
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception): |
|
return None |
|
|
|
|
|
def get_access_token(credentials): |
|
"""Extract access token from credentials (handles nested structure).""" |
|
if not credentials: |
|
return None |
|
# Handle nested structure: claudeAiOauth.accessToken or direct accessToken |
|
if "claudeAiOauth" in credentials: |
|
return credentials["claudeAiOauth"].get("accessToken") |
|
return credentials.get("accessToken") |
|
|
|
|
|
def fetch_usage(access_token): |
|
"""Call Anthropic OAuth usage API.""" |
|
url = "https://api.anthropic.com/api/oauth/usage" |
|
headers = { |
|
"Authorization": f"Bearer {access_token}", |
|
"anthropic-beta": "oauth-2025-04-20", |
|
"User-Agent": "claude-code/2.0.32" |
|
} |
|
|
|
try: |
|
request = Request(url, headers=headers, method="GET") |
|
with urlopen(request, timeout=10) as response: |
|
return json.loads(response.read().decode("utf-8")) |
|
except (URLError, HTTPError, json.JSONDecodeError, Exception): |
|
return None |
|
|
|
|
|
def format_time_until(resets_at_str): |
|
""" |
|
Format time until reset. |
|
If < 5 hours: hh:mm |
|
If >= 5 hours: Mon 14:03 |
|
""" |
|
try: |
|
# Parse ISO 8601 timestamp |
|
# Handle formats like: 2024-01-15T14:30:00Z or 2024-01-15T14:30:00.123Z |
|
resets_at_str = resets_at_str.replace("Z", "+00:00") |
|
# Remove milliseconds if present |
|
if "." in resets_at_str: |
|
parts = resets_at_str.split(".") |
|
# Keep everything before . and timezone after |
|
tz_start = max(parts[1].find("+"), parts[1].find("-")) |
|
if tz_start == -1: |
|
resets_at_str = parts[0] + "+00:00" |
|
else: |
|
resets_at_str = parts[0] + parts[1][tz_start:] |
|
|
|
resets_at = datetime.fromisoformat(resets_at_str) |
|
now = datetime.now(timezone.utc) |
|
|
|
diff = resets_at - now |
|
diff_seconds = max(0, int(diff.total_seconds())) |
|
|
|
five_hours = 5 * 60 * 60 |
|
|
|
if diff_seconds < five_hours: |
|
# Format as hh:mm |
|
hours = diff_seconds // 3600 |
|
minutes = (diff_seconds % 3600) // 60 |
|
return f"{hours:02d}:{minutes:02d}" |
|
else: |
|
# Format as Mon 14:03 (in local time) |
|
local_time = resets_at.astimezone() |
|
return local_time.strftime("%a %H:%M") |
|
except Exception: |
|
return "err" |
|
|
|
|
|
def format_percentage(utilization): |
|
"""Format utilization as integer percentage.""" |
|
try: |
|
util = float(utilization) |
|
# If value is in 0-1 range, multiply by 100 |
|
if util <= 1: |
|
pct = int(util * 100) |
|
else: |
|
pct = int(util) |
|
return str(pct) |
|
except (ValueError, TypeError): |
|
return "?" |
|
|
|
|
|
def get_usage_string(): |
|
"""Get formatted usage string for status bar.""" |
|
# Get credentials |
|
credentials = get_credentials() |
|
if not credentials: |
|
return "err" |
|
|
|
# Extract access token |
|
access_token = get_access_token(credentials) |
|
if not access_token: |
|
return "err" |
|
|
|
# Fetch usage data |
|
usage = fetch_usage(access_token) |
|
if not usage: |
|
return "err" |
|
|
|
# Extract data |
|
try: |
|
five_hour = usage.get("five_hour", {}) |
|
seven_day = usage.get("seven_day", {}) |
|
|
|
seven_day_resets_at = seven_day.get("resets_at") |
|
seven_day_utilization = seven_day.get("utilization") |
|
|
|
# If no weekly session data, return "NO CURRENT SESSION" |
|
if not seven_day_resets_at or seven_day_utilization is None: |
|
return "NO CURRENT SESSION" |
|
|
|
five_hour_resets_at = five_hour.get("resets_at") |
|
five_hour_utilization = five_hour.get("utilization") |
|
|
|
# Format weekly values |
|
weekly_time = format_time_until(seven_day_resets_at) |
|
weekly_pct = format_percentage(seven_day_utilization) |
|
|
|
if weekly_time == "err": |
|
return "err" |
|
|
|
# If no active session, show 5:00 for session time |
|
if not five_hour_resets_at or five_hour_utilization is None: |
|
return f"5:00 0% | {weekly_time} {weekly_pct}%" |
|
|
|
# Format session values |
|
session_time = format_time_until(five_hour_resets_at) |
|
session_pct = format_percentage(five_hour_utilization) |
|
|
|
if session_time == "err": |
|
return "err" |
|
|
|
return f"{session_time} {session_pct}% | {weekly_time} {weekly_pct}%" |
|
except Exception: |
|
return "err" |
|
|
|
|
|
async def main(connection): |
|
"""Main entry point for iTerm2 status bar component.""" |
|
|
|
component = iterm2.StatusBarComponent( |
|
short_description="Claude Usage", |
|
detailed_description="Shows Claude Code usage quotas (5-hour session and 7-day weekly)", |
|
knobs=[], |
|
exemplar="01:37 20% | Thu 14:03 5%", |
|
update_cadence=60, # Update every 60 seconds |
|
identifier="com.anthropic.claude-usage" |
|
) |
|
|
|
@iterm2.StatusBarRPC |
|
async def claude_usage_coroutine(knobs): |
|
"""Coroutine called by iTerm2 to get status bar content.""" |
|
return get_usage_string() |
|
|
|
await component.async_register(connection, claude_usage_coroutine) |
|
|
|
|
|
iterm2.run_forever(main) |