diff --git a/CLAUDE.md b/CLAUDE.md index e4e073f..d03d64e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,13 +18,22 @@ return a real snapshot wins. `rate_limits` when Claude Code is in an interactive Pro/Max session and Anthropic chooses to populate them; can be silent for hours. TTL: `CLAUDE_HOOK_TTL_SECONDS` (default 15 min). -2. **`~/.config/codexmeter/claude-plan-usage.json`** — written by - `scripts/claude-plan-usage-firefox.py` (primary) every 60 s via the - `com.justin.codexmeter.claude-plan-scrape` LaunchAgent. Reads - `sessionKey` from Firefox's unencrypted `cookies.sqlite` and calls - `https://claude.ai/api/organizations/{org}/usage` directly. Structured - JSON — immune to claude.ai page-layout changes. - TTL: `CLAUDE_PLAN_TTL_SECONDS` (default 60 min). +2. **`~/.config/codexmeter/claude-plan-usage.json`** — written every 60 s + by the `com.justin.codexmeter.claude-plan-scrape` LaunchAgent. The + scrape has two cookie sources in priority order: + - **`scripts/claude-plan-usage-desktop.py`** (primary). Reads cookies + from Claude Desktop at `~/Library/Application Support/Claude/Cookies`, + decrypted via the `Claude Safe Storage` Keychain entry. Claude + Desktop renews `sessionKey` whenever you use it, so this source + basically never goes stale as long as you have the desktop app. + First run triggers a Keychain prompt — click "Always Allow". + - **`scripts/claude-plan-usage-firefox.py`** (fallback). Reads + `sessionKey` from Firefox's unencrypted `cookies.sqlite`. The + desktop script falls through to this automatically if Keychain + access fails or no Claude Desktop cookies are present. + Both call `https://claude.ai/api/organizations/{org}/usage` and + produce the same normalized JSON. Immune to claude.ai page-layout + changes. TTL: `CLAUDE_PLAN_TTL_SECONDS` (default 60 min). 3. **`~/.config/codexmeter/claude-statusline.json`** — legacy raw statusline capture; same data as #1 but read from a different cache. 4. **Stale cached `claude-plan-usage.json`** — past TTL, surfaced with @@ -51,15 +60,22 @@ This single endpoint tells you which sources are stale, what the last scrape attempt said, and what snapshot the daemon is currently serving. Common failure modes and what `/health` shows: -### A. Firefox session expired +### A. Cookie sessions expired `scrape.ok = false`, message contains `HTTP 403` and -`account_session_invalid`. Fix: - -```bash -./scripts/claude-plan-usage-firefox.py login # opens Firefox to /login -# sign in, then: -./scripts/claude-plan-usage-firefox.py -``` +`account_session_invalid`. Two cases: + +1. Claude Desktop still running and logged in → re-grant Keychain access + if the prompt was dismissed: + ```bash + security find-generic-password -w -s "Claude Safe Storage" -a "Claude Key" + # If empty, open Keychain Access → search "Claude Safe Storage" → + # right-click → Access Control → Always Allow for /usr/bin/security + ``` +2. Both Claude Desktop and Firefox are signed out → re-login to Claude + Desktop (preferred) or run the Firefox fallback bootstrap: + ```bash + ./scripts/claude-plan-usage-firefox.py login + ``` The cache should refresh within 60 s on the next LaunchAgent tick. @@ -126,7 +142,8 @@ GET http://localhost:9596/health # diagnostic — source freshness, last scrap | Path / process | What it is | |---|---| | `daemon/codex-usage-daemon.py` | HTTP daemon, polls every 30 s | -| `scripts/claude-plan-usage-firefox.py` | Primary scrape (Firefox cookies → claude.ai API) | +| `scripts/claude-plan-usage-desktop.py` | Primary scrape (Claude Desktop cookies → claude.ai API) | +| `scripts/claude-plan-usage-firefox.py` | Fallback scrape (Firefox cookies → claude.ai API) | | `scripts/claude-plan-usage-cache.py` | Legacy Chrome AppleScript scrape — manual fallback only | | `scripts/claude-statusline-limits.py` | Persists Claude Code statusLine `rate_limits` | | `~/Library/LaunchAgents/com.justin.codexmeter.claude.plist` | Daemon (Claude provider, port 9596) | diff --git a/install.sh b/install.sh index 76ef229..df24334 100755 --- a/install.sh +++ b/install.sh @@ -64,7 +64,7 @@ PLIST echo "Installed LaunchAgent: $PLIST_FILE" echo "Logs: $HOME/Library/Logs/codexmeter.log" - SCRAPE_SCRIPT="$SCRIPT_DIR/scripts/claude-plan-usage-firefox.py" + SCRAPE_SCRIPT="$SCRIPT_DIR/scripts/claude-plan-usage-desktop.py" SCRAPE_PLIST="$PLIST_DIR/com.justin.codexmeter.claude-plan-scrape.plist" if [ -x "$SCRAPE_SCRIPT" ]; then cat > "$SCRAPE_PLIST" <$PYTHON $SCRAPE_SCRIPT - StartInterval60 + StartInterval15 RunAtLoad StandardOutPath$HOME/Library/Logs/codexmeter-claude-scrape.log StandardErrorPath$HOME/Library/Logs/codexmeter-claude-scrape.err.log diff --git a/ios/CodexMeterApp/CodexMeterApp/ContentView.swift b/ios/CodexMeterApp/CodexMeterApp/ContentView.swift index 66e2a88..6ec422d 100644 --- a/ios/CodexMeterApp/CodexMeterApp/ContentView.swift +++ b/ios/CodexMeterApp/CodexMeterApp/ContentView.swift @@ -353,6 +353,8 @@ struct SettingsView: View { @State private var selectedProvider: MeterViewModel.UsageProviderKind = .codex @AppStorage("widget_usage_mode", store: UserDefaults.sharedGroup) private var widgetUsageMode: String = "codex" + @AppStorage("display_framing", store: UserDefaults.sharedGroup) + private var displayFraming: String = "used" @AppStorage("selected_pet", store: UserDefaults.sharedGroup) private var selectedPet: String = "sukuna" @State private var showPetPicker = false @@ -389,6 +391,23 @@ struct SettingsView: View { } } + Section { + Picker("Percentages", selection: $displayFraming) { + Text("Used").tag("used") + Text("Left").tag("left") + } + .pickerStyle(.segmented) + .onChange(of: displayFraming) { _, newValue in + UserDefaults.standard.set(newValue, forKey: "display_framing") + UserDefaults.sharedGroup.set(newValue, forKey: "display_framing") + WidgetCenter.shared.reloadAllTimelines() + } + } header: { + Text("Display") + } footer: { + Text("\"Used\" matches claude.ai (fills as you consume). \"Left\" shows how much you have remaining.") + } + Section { TextField("http://100.x.x.x:9595", text: $urlText) .keyboardType(.URL) @@ -699,13 +718,15 @@ struct UsageCard: View { let title: String let pct: Double let resetMins: Int + @AppStorage("display_framing", store: UserDefaults.sharedGroup) + private var displayFraming: String = "used" var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text(title).font(.headline) Spacer() - Text("\(Int(pct.rounded()))% remaining") + Text("\(Int(displayValue.rounded()))% \(displayFraming == "left" ? "left" : "used")") .font(.title2).fontWeight(.bold) .foregroundColor(barColor) } @@ -717,7 +738,7 @@ struct UsageCard: View { .frame(height: 12) RoundedRectangle(cornerRadius: 4) .fill(barColor.gradient) - .frame(width: geometry.size.width * pct / 100, height: 12) + .frame(width: geometry.size.width * displayValue / 100, height: 12) } } .frame(height: 12) @@ -732,9 +753,16 @@ struct UsageCard: View { .clipShape(RoundedRectangle(cornerRadius: 12)) } + var usedPct: Double { max(0, min(100, 100 - pct)) } + var leftPct: Double { max(0, min(100, pct)) } + /// What the big number AND the bar fill width represent. + var displayValue: Double { displayFraming == "left" ? leftPct : usedPct } + var barColor: Color { - if pct <= 20 { return .red } - if pct <= 50 { return .orange } + // Color stays keyed on usage pressure regardless of framing: + // red when heavily used, green when fresh. + if usedPct >= 80 { return .red } + if usedPct >= 50 { return .orange } return .green } diff --git a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift index fb1730d..a885e82 100644 --- a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift +++ b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift @@ -226,7 +226,7 @@ final class MeterViewModel: ObservableObject { Task { await fetchUsage() } - fetchTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + fetchTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in await self?.fetchUsage() } } diff --git a/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift b/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift index 94f2409..5826fcf 100644 --- a/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift +++ b/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift @@ -63,7 +63,7 @@ struct UsageProvider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { fetchLiveEntries { entry in let fallback = self.loadEntry() ?? UsageEntry(date: Date(), payload: UsagePayload(sourceName: "Codex", sessionPct: 0, weeklyPct: 0, sessionResetMins: -1, weeklyResetMins: -1, status: "Open app to sync", isOk: false, lastUpdated: nil)) - let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(900) + let next = Calendar.current.date(byAdding: .minute, value: 5, to: Date()) ?? Date().addingTimeInterval(300) completion(Timeline(entries: [entry ?? fallback], policy: .after(next))) } } @@ -247,12 +247,35 @@ private func formatResetTime(mins: Int) -> String { return "\(h)h \(m)m" } -private func remainingColor(for pct: Int) -> Color { - if pct <= 20 { return Color.red } - if pct <= 50 { return Color.orange } +/// Convert a "remaining %" value from the daemon payload to the "used %" we +/// want to display so framings line up with claude.ai and the main app. +private func usedPct(from remainingPct: Int) -> Int { + max(0, min(100, 100 - remainingPct)) +} + +private func usedColor(forUsed pct: Int) -> Color { + if pct >= 80 { return Color.red } + if pct >= 50 { return Color.orange } return Color.green } +private enum DisplayFraming: String { + case used, left + + static var current: DisplayFraming { + let raw = UserDefaults.sharedGroup.string(forKey: "display_framing") ?? "used" + return DisplayFraming(rawValue: raw) ?? .used + } + + var suffix: String { self == .left ? "LEFT" : "USED" } + var lowerSuffix: String { self == .left ? "left" : "used" } +} + +/// Number to render and bar width to use, given a "remaining %" from the payload. +private func displayNumber(remaining: Int) -> Int { + DisplayFraming.current == .left ? max(0, min(100, remaining)) : usedPct(from: remaining) +} + private func sourceIconName(_ sourceName: String) -> String { sourceName.lowercased() == "claude" ? "ClaudeCodeIcon" : "CodexIcon" } @@ -282,12 +305,13 @@ struct AtomProgressBar: View { .fill(barBg) .padding(1) - if active && pct > 0 { - let clamped = min(max(pct, 0), 100) - let fillWidth = (geo.size.width - 2) * CGFloat(clamped) / 100.0 + if active { + let used = usedPct(from: pct) + let display = displayNumber(remaining: pct) + let fillWidth = (geo.size.width - 2) * CGFloat(display) / 100.0 if fillWidth > 0 { RoundedRectangle(cornerRadius: 2) - .fill(remainingColor(for: pct)) + .fill(usedColor(forUsed: used)) .frame(width: fillWidth) .padding(1) } @@ -339,33 +363,33 @@ struct AtomWidgetStyleView: View { } else { // Session (Today) - Text(entry.isOk ? "\(formatResetTime(mins: entry.sessionResetMins).uppercased()) LEFT" : "TODAY") + Text(entry.isOk ? "5H \(DisplayFraming.current.suffix)" : "TODAY") .font(.system(size: 8, weight: .bold, design: .rounded)) .foregroundStyle(atomDim) - - Text(entry.isOk ? "\(entry.sessionPct)%" : "--") + + Text(entry.isOk ? "\(displayNumber(remaining: entry.sessionPct))%" : "--") .font(.system(size: 20, weight: .bold, design: .rounded)) .foregroundStyle(atomText) - + AtomProgressBar(pct: entry.sessionPct, active: entry.isOk) .frame(height: 10) .padding(.vertical, 2) - + Text(entry.isOk ? "reset \(formatResetTime(mins: entry.sessionResetMins))" : entry.status) .font(.system(size: 9, weight: .medium, design: .rounded)) .foregroundStyle(atomDim) .lineLimit(1) - + Spacer(minLength: 4) - + // Weekly HStack(alignment: .bottom, spacing: 0) { VStack(alignment: .leading, spacing: 0) { - Text(entry.isOk ? "WK LEFT" : "WEEK") + Text(entry.isOk ? "7D \(DisplayFraming.current.suffix)" : "WEEK") .font(.system(size: 8, weight: .bold, design: .rounded)) .foregroundStyle(atomDim) - - Text(entry.isOk ? "\(entry.weeklyPct)%" : "--") + + Text(entry.isOk ? "\(displayNumber(remaining: entry.weeklyPct))%" : "--") .font(.system(size: 16, weight: .bold, design: .rounded)) .foregroundStyle(atomText) } @@ -479,8 +503,9 @@ private func relativeTime(_ date: Date) -> String { struct AccessoryCircularView: View { let entry: UsageEntry + private var shown: Int { displayNumber(remaining: entry.sessionPct) } private var fraction: Double { - let value = Double(entry.sessionPct) / 100.0 + let value = Double(shown) / 100.0 guard entry.isOk else { return max(0, value) } return max(0.01, value) } @@ -493,7 +518,7 @@ struct AccessoryCircularView: View { .stroke(.white, style: StrokeStyle(lineWidth: 4, lineCap: .round)) .rotationEffect(.degrees(-90)) VStack(spacing: -1) { - Text("\(entry.sessionPct)") + Text("\(shown)") .font(.system(size: 17, weight: .bold, design: .rounded)) .monospacedDigit() Text("%") @@ -518,7 +543,7 @@ struct AccessoryRectangularView: View { .font(.system(size: 9, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) .tracking(1.2) - Text("\(entry.sessionPct)% left") + Text("\(displayNumber(remaining: entry.sessionPct))% \(DisplayFraming.current.lowerSuffix)") .font(.system(size: 16, weight: .bold, design: .rounded)) .monospacedDigit() if !entry.status.isEmpty { diff --git a/requirements.txt b/requirements.txt index b8dc90d..2e68bed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pytest>=7.0 ruff>=0.15 black>=24.0 zeroconf>=0.51 +pycryptodome>=3.20 diff --git a/scripts/claude-plan-usage-desktop.py b/scripts/claude-plan-usage-desktop.py new file mode 100755 index 0000000..15cd0a0 --- /dev/null +++ b/scripts/claude-plan-usage-desktop.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Read Claude plan usage by replaying Claude Desktop's session cookies. + +Claude Desktop is Electron + Chromium and stores cookies at + ~/Library/Application Support/Claude/Cookies +encrypted with AES-128-CBC under a key derived from the macOS Keychain +entry service="Claude Safe Storage", account="Claude Key" (the same +scheme Chrome uses for its "Chrome Safe Storage" entry). + +This is the preferred scrape source because Claude Desktop typically +stays logged in indefinitely, so the session cookies stay fresh without +manual maintenance. + +One-time setup: the first run triggers a Keychain prompt — click +"Always Allow" so subsequent (background) runs can read the password +silently. + +Usage: + ./scripts/claude-plan-usage-desktop.py # scrape + write cache + ./scripts/claude-plan-usage-desktop.py print # print only, no write +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +from Crypto.Cipher import AES +from Crypto.Protocol.KDF import PBKDF2 + +CONFIG_DIR = Path.home() / ".config" / "codexmeter" +CACHE_PATH = Path( + os.getenv( + "CODEXMETER_CLAUDE_PLAN_USAGE_FILE", str(CONFIG_DIR / "claude-plan-usage.json") + ) +) +HEALTH_PATH = Path( + os.getenv( + "CODEXMETER_CLAUDE_SCRAPE_HEALTH", str(CONFIG_DIR / "claude-scrape-health.json") + ) +) +COOKIE_DB = Path( + os.getenv( + "CODEXMETER_CLAUDE_DESKTOP_COOKIES", + str(Path.home() / "Library/Application Support/Claude/Cookies"), + ) +) +KEYCHAIN_SERVICE = os.getenv("CODEXMETER_CLAUDE_KEYCHAIN_SERVICE", "Claude Safe Storage") +KEYCHAIN_ACCOUNT = os.getenv("CODEXMETER_CLAUDE_KEYCHAIN_ACCOUNT", "Claude Key") +DEFAULT_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" +) + + +def keychain_password() -> str: + proc = subprocess.run( + [ + "security", + "find-generic-password", + "-w", + "-s", + KEYCHAIN_SERVICE, + "-a", + KEYCHAIN_ACCOUNT, + ], + check=False, + capture_output=True, + text=True, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + raise SystemExit( + f"Could not read '{KEYCHAIN_SERVICE}' / '{KEYCHAIN_ACCOUNT}' from " + f"Keychain (exit {proc.returncode}, stderr={proc.stderr.strip()!r}). " + "On first run, macOS prompts to allow access — click 'Always Allow' " + "and try again. If the prompt didn't appear, run the same security " + "command in Terminal manually so the GUI dialog can show." + ) + return proc.stdout.strip() + + +def derive_key(password: str) -> bytes: + return PBKDF2(password.encode("utf-8"), b"saltysalt", dkLen=16, count=1003) + + +def decrypt_value(key: bytes, encrypted: bytes) -> str: + cipher = AES.new(key, AES.MODE_CBC, IV=b" " * 16) + plain = cipher.decrypt(encrypted[3:]) # strip 'v10' prefix + pad = plain[-1] + if 1 <= pad <= 16: + plain = plain[:-pad] + # Some Chromium versions prepend a SHA256 of the host before the value. + try: + return plain.decode("utf-8") + except UnicodeDecodeError: + return plain[32:].decode("utf-8") + + +def read_claude_cookies() -> dict[str, str]: + if not COOKIE_DB.exists(): + raise SystemExit(f"Claude Desktop cookie DB not found at {COOKIE_DB}") + password = keychain_password() + key = derive_key(password) + + with tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + shutil.copy(COOKIE_DB, tmp_path) + conn = sqlite3.connect(tmp_path) + cookies: dict[str, str] = {} + for name, value, encrypted in conn.execute( + "SELECT name, value, encrypted_value FROM cookies " + "WHERE host_key LIKE '%claude.ai%'" + ): + try: + if encrypted and encrypted[:3] == b"v10": + cookies[name] = decrypt_value(key, encrypted) + elif value: + cookies[name] = value + except Exception as exc: # noqa: BLE001 + # Don't let a single corrupt cookie kill the whole pull. + print(f"warn: decrypt {name!r} failed: {exc}", file=sys.stderr) + conn.close() + return cookies + finally: + try: + tmp_path.unlink() + except OSError: + pass + + +def claude_org_id() -> str: + env = os.getenv("CODEXMETER_CLAUDE_ORG_ID") + if env: + return env + profile = Path.home() / ".claude.json" + if profile.exists(): + try: + data = json.loads(profile.read_text(errors="replace")) + org = (data.get("oauthAccount") or {}).get("organizationUuid") + if org: + return str(org) + except (OSError, json.JSONDecodeError): + pass + raise SystemExit( + "Set CODEXMETER_CLAUDE_ORG_ID or sign in to Claude Code so that " + "~/.claude.json has oauthAccount.organizationUuid." + ) + + +def fetch_usage(cookies: dict[str, str]) -> dict: + org = claude_org_id() + url = f"https://claude.ai/api/organizations/{org}/usage" + cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items()) + req = urllib.request.Request( + url, + headers={ + "Cookie": cookie_str, + "User-Agent": DEFAULT_UA, + "Accept": "application/json", + "Referer": "https://claude.ai/settings/usage", + "Accept-Language": "en-US,en;q=0.5", + }, + ) + with urllib.request.urlopen(req, timeout=12) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def minutes_until_iso(iso: str | None) -> int: + if not iso: + return -1 + try: + when = datetime.fromisoformat(iso.replace("Z", "+00:00")) + except ValueError: + return -1 + return max(0, round((when - datetime.now(timezone.utc)).total_seconds() / 60)) + + +def normalize(data: dict) -> dict: + five = data.get("five_hour") if isinstance(data.get("five_hour"), dict) else {} + week = data.get("seven_day") if isinstance(data.get("seven_day"), dict) else {} + return { + "source": "claude_api_desktop_cookies", + "captured_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "session": { + "used_pct": int(round(float(five.get("utilization") or 0))), + "reset_mins": minutes_until_iso(five.get("resets_at")), + }, + "weekly": { + "used_pct": int(round(float(week.get("utilization") or 0))), + "reset_mins": minutes_until_iso(week.get("resets_at")), + }, + } + + +def write_atomic(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, separators=(",", ":"))) + tmp.replace(path) + + +def write_health(ok: bool, message: str, payload: dict | None = None) -> None: + health = { + "ok": ok, + "source": "claude_desktop", + "checked_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "message": message, + } + if payload: + health["last_payload"] = payload + try: + write_atomic(HEALTH_PATH, health) + except OSError: + pass + + +def try_fallback() -> int: + """Defer to the Firefox scrape if it exists and is callable.""" + fallback = Path(__file__).parent / "claude-plan-usage-firefox.py" + if not fallback.exists(): + return 1 + print( + f"claude-desktop scrape failed; falling back to {fallback.name}", + file=sys.stderr, + ) + proc = subprocess.run([sys.executable, str(fallback)], check=False) + return proc.returncode + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "command", + nargs="?", + default="scrape", + choices=["scrape", "print"], + ) + ap.add_argument( + "--no-fallback", + action="store_true", + help="Don't try the Firefox script if this one fails.", + ) + args = ap.parse_args() + + try: + cookies = read_claude_cookies() + except SystemExit as exc: + msg = f"cookie read failed: {exc}" + write_health(False, msg) + if args.no_fallback: + raise + print(msg, file=sys.stderr) + return try_fallback() + except Exception as exc: # noqa: BLE001 + msg = f"cookie read failed: {type(exc).__name__}: {exc}" + write_health(False, msg) + if args.no_fallback: + raise SystemExit(msg) from exc + print(msg, file=sys.stderr) + return try_fallback() + + if "sessionKey" not in cookies: + msg = "Claude Desktop has no claude.ai sessionKey cookie" + write_health(False, msg) + if args.no_fallback: + raise SystemExit(msg) + print(msg, file=sys.stderr) + return try_fallback() + + try: + data = fetch_usage(cookies) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace")[:300] + msg = f"claude.ai/api HTTP {exc.code}: {body}" + write_health(False, msg) + if args.no_fallback: + raise SystemExit(msg) from exc + print(msg, file=sys.stderr) + return try_fallback() + except Exception as exc: # noqa: BLE001 + msg = f"claude.ai/api request failed: {type(exc).__name__}: {exc}" + write_health(False, msg) + if args.no_fallback: + raise SystemExit(msg) from exc + print(msg, file=sys.stderr) + return try_fallback() + + payload = normalize(data) + if args.command == "scrape": + write_atomic(CACHE_PATH, payload) + write_health(True, "ok", payload) + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())