The last of Section F, and the only thing in Panama that reads an authentication token, so most of the design is about that rather than about the number. It never refreshes the token and never writes to the credentials file. That token expires roughly hourly and Claude Code refreshes it on demand; if this refreshed it too, two processes would be rotating one credential, and a rotation invalidates the other holder's copy. The failure mode is being silently signed out of Claude Code by a status widget, which no bar indicator is worth. So it reads the token, uses it while valid, and reports "waiting for Claude Code to refresh" when not -- which covers the case that matters, because while you are using Claude Code the token is fresh, and while you are not there is nothing to watch. The token never reaches argv either: curl takes the Authorization header on stdin through --config, because a header passed as an argument sits in /proc/<pid>/cmdline for the length of the request. Same rule the password and MOK paths already follow. And it never reaches the output: the record carries percentages and timestamps and nothing else. Both are pinned, and both were checked by sabotaging the collector to pass -H and watching the contract name it. Off by default. It is a coding-tool readout, not something a general-purpose desktop shows without being asked, and it hides unless the collector has real numbers rather than displaying "unknown".
114 lines
5.0 KiB
Bash
Executable File
114 lines
5.0 KiB
Bash
Executable File
#!/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/<pid>/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 '
|
|
def pct: if type == "number" then (. * 100 | round) 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"
|