#!/usr/bin/env bash # How much of the Claude subscription this account has used. # # Writes one display-ready record to $XDG_STATE_HOME/panama/agent-usage.json. # The bar widget only ever reads that file, so adding a second agent later is a # collector rather than a change to any QML. # # ── What this deliberately does NOT do ─────────────────────────────────────── # # It never refreshes the OAuth token, and it never writes to # ~/.claude/.credentials.json. # # That token expires about hourly and Claude Code refreshes it on demand. If # this refreshed it too, two processes would be rotating one credential: a # refresh that rotates the refresh token invalidates the other holder's copy, # and the failure mode is being silently logged out of Claude Code by a status # widget. No bar indicator is worth that. # # So this reads the token, uses it if it is still valid, and reports # "unavailable" if it is not. In practice that covers the case that matters -- # while you are actually using Claude Code the token is fresh, and while you # are not, there is nothing to watch. # # ── The token ──────────────────────────────────────────────────────────────── # # Never reaches argv. `curl --config -` takes the Authorization header on # stdin, because a header passed as an argument is world-readable in # /proc//cmdline for as long as the request takes -- the same rule # panama-pick follows for passwords and panama-sudo for the MOK hash. # # Never reaches the output either. The record below carries percentages and # timestamps and nothing else; the widget has no business seeing a credential # and neither does anyone reading the state file. set -uo pipefail CREDENTIALS="${PANAMA_AGENT_CREDENTIALS:-$HOME/.claude/.credentials.json}" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama" OUTPUT="$STATE_DIR/agent-usage.json" ENDPOINT="${PANAMA_AGENT_USAGE_ENDPOINT:-https://api.anthropic.com/api/oauth/usage}" mkdir -p "$STATE_DIR" # Written whatever happens, so the widget can distinguish "no data yet" from # "collector never ran" and hide itself for the right reason. emit() { local status="$1" detail="${2:-}" body="${3:-null}" local tmp tmp="$(mktemp "$OUTPUT.XXXXXX")" jq -n --arg status "$status" --arg detail "$detail" \ --argjson usage "$body" --arg at "$(date -Is)" \ '{status: $status, detail: $detail, collectedAt: $at, usage: $usage}' \ >"$tmp" 2>/dev/null || printf '{"status":"error","detail":"could not write","usage":null}' >"$tmp" mv "$tmp" "$OUTPUT" } command -v jq >/dev/null 2>&1 || exit 0 [[ -r "$CREDENTIALS" ]] || { emit unavailable "Claude Code is not signed in on this machine."; exit 0; } expires="$(jq -r '.claudeAiOauth.expiresAt // 0' "$CREDENTIALS" 2>/dev/null)" [[ "$expires" =~ ^[0-9]+$ ]] || expires=0 now="$(( $(date +%s) * 1000 ))" # Thirty seconds of headroom: a token about to expire will have expired by the # time the request lands, and a 401 is a worse answer than an honest wait. if (( expires <= now + 30000 )); then emit stale "Waiting for Claude Code to refresh its session." exit 0 fi config="$(mktemp)" cleanup() { rm -f "$config"; } trap cleanup EXIT chmod 600 "$config" jq -r '"header = \"Authorization: Bearer \(.claudeAiOauth.accessToken)\"\nheader = \"anthropic-beta: oauth-2025-04-20\"\nsilent\nshow-error"' \ "$CREDENTIALS" >"$config" 2>/dev/null \ || { emit unavailable "Could not read the Claude Code session."; exit 0; } response="$(curl --max-time 10 --config "$config" "$ENDPOINT" 2>/dev/null)" || { emit unavailable "Could not reach the usage service." exit 0 } rm -f "$config" jq -e . >/dev/null 2>&1 <<<"$response" || { emit unavailable "The usage service returned something unreadable."; exit 0; } if jq -e '.error' >/dev/null 2>&1 <<<"$response"; then emit unavailable "$(jq -r '.error.message // "The usage service refused the request."' <<<"$response")" exit 0 fi # Reshaped into a small, stable record rather than passed through, so the # widget does not depend on the shape of an endpoint nobody documents. Every # field is optional: an endpoint that stops reporting one should cost that # number, not the whole indicator. usage="$(jq -c ' # The endpoint reports utilisation as a percentage already -- 15 means 15%. # This multiplied by 100 on the assumption it was a 0..1 fraction, which is # how the bar came to read 1500%. Clamped as well as rounded, because a # readout is a number you glance at and trust; one that can exceed 100 # teaches you not to. def pct: if type == "number" then ([[(. | round), 0] | max, 100] | min) else null end; { tier: (.rate_limit_tier // .rateLimitTier // null), subscription: (.subscription_type // .subscriptionType // null), fiveHour: { used: ((.five_hour.utilization // .fiveHour.utilization // null) | pct), resetsAt: (.five_hour.resets_at // .fiveHour.resetsAt // null) }, week: { used: ((.seven_day.utilization // .week.utilization // null) | pct), resetsAt: (.seven_day.resets_at // .week.resetsAt // null) } }' <<<"$response" 2>/dev/null)" [[ -n "$usage" ]] || { emit unavailable "The usage service returned an unfamiliar shape."; exit 0; } emit ok "" "$usage"