Created
August 17, 2026 14:42
-
-
Save hamiltont/7edec59f3124ed5f00315fb086c84da4 to your computer and use it in GitHub Desktop.
Monitor your Claude subscription usage quota / quotas from Ralph loops (best effort, uses private API)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # claude-usage — authoritative Claude subscription usage via the undocumented | |
| # OAuth endpoint (the same data that powers Claude Code's `/usage` panel). | |
| # | |
| # Notes / gotchas: | |
| # * Undocumented internal endpoint — fields can churn (tangelo, iguana_necktie, | |
| # omelette_promotional, etc. are experimental). Treat automation as best-effort. | |
| # * Sends the same UA as the official client; requests without it are rejected. | |
| # * Do NOT poll faster than ~180s or you'll rate-limit yourself. | |
| # * `curl -q` ignores ~/.curlrc (which appends a -w timing line that breaks JSON). | |
| # | |
| # Usage: | |
| # claude-usage # pretty human summary | |
| # claude-usage --json # raw JSON (for agents / piping to jq) | |
| set -euo pipefail | |
| raw=false | |
| [ "${1:-}" = "--json" ] && raw=true | |
| ver=$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) | |
| [ -z "$ver" ] && ver="0.0.0" | |
| # Token: macOS Keychain first, then ~/.claude/.credentials.json, then env var. | |
| token="" | |
| if command -v security >/dev/null 2>&1 && \ | |
| security find-generic-password -s "Claude Code-credentials" -w >/dev/null 2>&1; then | |
| token=$(security find-generic-password -s "Claude Code-credentials" -w \ | |
| | python3 -c 'import sys,json;print(json.load(sys.stdin)["claudeAiOauth"]["accessToken"])') | |
| elif [ -f "$HOME/.claude/.credentials.json" ]; then | |
| token=$(python3 -c 'import json,os;print(json.load(open(os.path.expanduser("~/.claude/.credentials.json")))["claudeAiOauth"]["accessToken"])') | |
| elif [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then | |
| token="$CLAUDE_CODE_OAUTH_TOKEN" | |
| fi | |
| if [ -z "$token" ]; then | |
| echo "claude-usage: no OAuth token found (Keychain / ~/.claude/.credentials.json / \$CLAUDE_CODE_OAUTH_TOKEN)" >&2 | |
| exit 1 | |
| fi | |
| resp=$(curl -qs --max-time 15 https://api.anthropic.com/api/oauth/usage \ | |
| -H "Authorization: Bearer $token" \ | |
| -H "anthropic-beta: oauth-2025-04-20" \ | |
| -H "User-Agent: claude-code/$ver" \ | |
| -H "Content-Type: application/json") | |
| if [ -z "$resp" ]; then | |
| echo "claude-usage: empty response (rate-limited? token expired? run 'claude' once to refresh)" >&2 | |
| exit 1 | |
| fi | |
| # An expired or invalid token is NOT a transport failure: the endpoint answers | |
| # with a normal body carrying {"type":"error"}. That parses fine and simply has | |
| # none of the window keys, so an unguarded render prints the bare header and | |
| # exits 0 -- a total auth failure that reads as a clean run with no usage. Catch | |
| # it before rendering. | |
| err=$(CLAUDE_USAGE_RESP="$resp" python3 -c ' | |
| import os, json | |
| try: | |
| d = json.loads(os.environ["CLAUDE_USAGE_RESP"]) | |
| except ValueError: | |
| print("unparseable response from the usage endpoint"); raise SystemExit | |
| if isinstance(d, dict) and d.get("type") == "error": | |
| print((d.get("error") or {}).get("message") or "unknown API error") | |
| ') | |
| if [ -n "$err" ]; then | |
| $raw && printf '%s\n' "$resp" | |
| echo "claude-usage: $err" >&2 | |
| echo "claude-usage: run 'claude' once on this machine to refresh the OAuth token" >&2 | |
| exit 1 | |
| fi | |
| if $raw; then | |
| printf '%s\n' "$resp" | |
| exit 0 | |
| fi | |
| CLAUDE_USAGE_RESP="$resp" python3 <<'PY' | |
| import os, json, sys | |
| from datetime import datetime, timezone | |
| d = json.loads(os.environ["CLAUDE_USAGE_RESP"]) | |
| rows = [] | |
| def pct(v): | |
| # The endpoint returns an explicit null for utilization on windows it is | |
| # not tracking, so a .get() default never fires. Render, don't crash. | |
| return " -" if v is None else f"{v:>5.1f}" | |
| def line(label, w): | |
| if not w: return | |
| u = w.get("utilization") | |
| r = w.get("resets_at") | |
| when = "" | |
| if r: | |
| try: | |
| dt = datetime.fromisoformat(r.replace("Z","+00:00")) | |
| rem = dt - datetime.now(timezone.utc) | |
| secs = int(rem.total_seconds()) | |
| h, m = divmod(max(secs,0)//60, 60) | |
| when = f" (resets in {h}h{m:02d}m — {dt.astimezone().strftime('%a %H:%M')})" | |
| except Exception: | |
| when = f" (resets {r})" | |
| rows.append(f" {label:<16} {pct(u)}%{when}") | |
| line("5-hour", d.get("five_hour")) | |
| line("7-day (all)", d.get("seven_day")) | |
| line("7-day Opus", d.get("seven_day_opus")) | |
| line("7-day Sonnet",d.get("seven_day_sonnet")) | |
| ex = d.get("extra_usage") or {} | |
| sp = d.get("spend") or {} | |
| if ex.get("is_enabled") or sp.get("enabled"): | |
| # MONEY IS IN MINOR UNITS. The `spend` block says so unambiguously — | |
| # {"amount_minor": 3000, "exponent": 2} is $30.00, not $3000. The | |
| # `extra_usage` block carries the same numbers with the exponent named | |
| # "decimal_places", which reads like display precision and is not: using it | |
| # that way prints a 100x overstatement of the user's spending cap. Prefer | |
| # `spend`, which is self-describing, and treat `extra_usage` as fallback. | |
| def money(m): | |
| if not isinstance(m, dict) or m.get("amount_minor") is None: return None | |
| return m["amount_minor"] / (10 ** m.get("exponent", 2)) | |
| used = money(sp.get("used")) | |
| lim = money(sp.get("limit")) | |
| cur = (sp.get("limit") or {}).get("currency") or ex.get("currency", "") | |
| if used is None or lim is None: | |
| exp = ex.get("decimal_places", 2) | |
| used = None if ex.get("used_credits") is None else ex["used_credits"] / (10 ** exp) | |
| lim = None if ex.get("monthly_limit") is None else ex["monthly_limit"] / (10 ** exp) | |
| u = sp.get("percent") | |
| if u is None: u = ex.get("utilization") | |
| # utilization is null until credits are actually spent; derive it. | |
| if u is None and used is not None and lim: | |
| u = used / lim * 100 | |
| amt = "?" if used is None or lim is None else f"{used:,.2f}/{lim:,.2f} {cur}".strip() | |
| rows.append(f" {'extra usage':<16} {pct(u)}% ({amt})") | |
| # Nothing to show means the payload carried none of the windows we know how to | |
| # read. Report it as a failure rather than printing a lone header that looks | |
| # like "you have used nothing". | |
| if not rows: | |
| print("claude-usage: response contained no recognizable usage windows", file=sys.stderr) | |
| print("claude-usage: the endpoint's shape may have changed -- inspect with --json", file=sys.stderr) | |
| raise SystemExit(1) | |
| print("Claude subscription usage") | |
| print("\n".join(rows)) | |
| PY |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment