Two bugs, one story: ./install on a machine that had been half set up before produced no Hyprland at all, and said so in one line among twenty minutes of scrollback. Terra bootstraps itself with --repofrompath, which defines a throwaway repo id just long enough to install terra-release. Run it again on a machine that already has terra-release and dnf5 refuses the whole transaction -- the throwaway id collides with the real one. That step sits above everything, so set -e ended the stage before a single package was considered. It is skipped now when terra-release is already installed. The rest is the reason one failed repo cost the desktop. Hyprland was installed near the bottom of the stage, below a codec swap, two group updates and a GStreamer glob, any one of which can fail for reasons outside this repository. It now installs directly after the packages it needs and before anything optional, and everything fragile below it runs through a soft helper that logs and continues rather than ending the run. What was stepped over is listed at the end, because tolerating a failure only beats aborting on it if somebody is told. A missing Hyprland is still fatal, and now says so in words. Also removes the leftover disabled solopasha/hyprland COPR, which would mix with lionheartp's the moment anyone enabled it while debugging. Fixes the usage widget reading 1500%: the endpoint reports percentages, not 0..1 fractions. Clamped as well, and the widget answers a click now -- it set interactive:false, which disables the mouse area its own handler needed.
119 lines
5.4 KiB
Bash
Executable File
119 lines
5.4 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 '
|
|
# 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"
|