diff --git a/README.md b/README.md index 95cad41..d60f0a8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ same command to update them all (`git pull` under the hood). |--------|-------------------| | **rpi** | `ty pipeline -d rpi ""` — Research → Plan → Implement, **human-gated *and* reality-gated**. Neutral research questions, goal-blind research, a design gate and a plan gate you approve, then implement + simplify — each gated on your build + tests — then a PR. A human okays the approach; the machine proves the code. | | **plan-code-review** | `ty pipeline -d plan-code-review ""` — Plan → Code → two parallel reviewers → collect, on one shared branch. Each step's model/executor is configurable per project. | +| **claude-profile-router** | Two Claude logins? Each task goes to whichever account has the most rate-limit headroom left, and waits in the queue when both are spent. The one **hook** plugin here rather than a workflow — it answers ty's `task.route` hook just before a task spawns. Needs `jq` or `python3`. | | **arc-solve** | `ty pipeline -d arc-solve "solve "` — play a live [ARC-AGI-3](https://arcprize.org/arc-agi/3) game and complete a level. The gate **replays your solution against the real game**, so a win can't be faked. Needs only an ARC API key at `~/.config/arc/key`. | ## The idea: gate progress on reality @@ -51,9 +52,15 @@ Workflows are picked up by convention — any `workflows/*.yaml` in a plugin bec `ty pipeline -d ` definition. To author your own, add a directory here (or in your own repo) and open a PR. +Most plugins here are workflows, which work on any `ty`. A plugin that declares a +**hook** needs a `ty` new enough to emit that event — `claude-profile-router` uses +`task.route`, which fires before a task spawns. Each plugin's README states what it +needs; `ty plugins list` shows what your install actually discovered. + ## Trust Installing a plugin runs its scripts and workflow prompts with your agent's access, and a workflow step can set `env:` / `config_dir:` that route your agent's -credentials. **Only `ty plugins add` sources you trust.** Read a plugin before you -install it. +credentials — as can a `task.route` hook, for every task. `claude-profile-router` +additionally reads your stored Claude credentials to check each account's usage. +**Only `ty plugins add` sources you trust.** Read a plugin before you install it. diff --git a/claude-profile-router/README.md b/claude-profile-router/README.md new file mode 100644 index 0000000..969ca62 --- /dev/null +++ b/claude-profile-router/README.md @@ -0,0 +1,157 @@ +# claude-profile-router + +Route each task to whichever of your Claude accounts has the most rate-limit +headroom left. + +If you have two logins — a personal one and a work one, say — you already have +two Claude config dirs. This plugin checks how much of each account's 5-hour and +weekly limits are spent and points every task ty spawns at the one with room. If +both are spent, it holds the task in the queue instead of burning a session on a +429. + +``` +$ ty plugins run claude-profile-router status +Routing threshold: skip a profile at or above 90% used + +/Users/me/.claude-personal + me@personal.example + 99% used (weekly_all, resets 2026-08-16 10:00:00) + -> skipped by the router (at or above 90%) + +/Users/me/.claude-work + me@work.example + 12% used (weekly_all, resets 2026-08-20 21:00:00) +``` + +→ the next task runs under `.claude-work`. + +## Requirements + +- **A `ty` that emits the `task.route` hook** (TaskYou + [#690](https://github.com/bborn/taskyou/pull/690)). Unlike the other plugins in + this collection, this one is a *hook* plugin rather than a workflow, so it needs + support in ty itself, not just a workflow file. On an older ty the plugin loads + and does nothing — the event never fires. To confirm it's working, start a task + and look for a `Routed to Claude profile …` line in its log. +- **`jq` or `python3`** on the daemon's `PATH`, to read the usage API's JSON. + macOS has shipped `/usr/bin/jq` for a while; otherwise `brew install jq`. + +## Setup + +1. **Have two profiles.** A profile is a `CLAUDE_CONFIG_DIR` with its own login: + + ```bash + CLAUDE_CONFIG_DIR=~/.claude-work claude # then /login as the second account + ``` + +2. **Install and configure:** + + ```bash + ty plugins add https://github.com/taskyou/plugins + cd "$(ty plugins dir)/plugins/claude-profile-router" + cp config.example.env config.env + $EDITOR config.env # list your profile dirs in TY_CLAUDE_PROFILES + ``` + + (`ty plugins add` clones the collection into `/plugins/`, so each + plugin lives one level down. `ty plugins list` shows what was discovered.) + +3. **Check it sees both accounts:** + + ```bash + ty plugins run claude-profile-router status # or just: ./status.sh + ``` + +That's it — the next task ty spawns is routed. `ty logs` and the task's own log +record which profile it landed on and why. + +## How it decides + +- Each profile's **binding limit** is the worst of its reported windows (5-hour + session, weekly, per-model weekly). A session window at 98% blocks the next + task even when the weekly one is untouched, so the worst window is the one + that matters. +- The profile with the **lowest** binding percent wins. +- Profiles at or above `TY_CLAUDE_MAX_PERCENT` (default 90) are skipped. The + margin exists because usage is sampled at spawn, not metered continuously — + a long task started at 89% can still cross the line mid-run. +- If every profile is over the threshold, the task is **held**: it stays queued + and is reconsidered on the next daemon tick, with one log line saying why. +- A task that already names a config dir (set by hand, or by a workflow step) is + left alone. Routing fills a vacuum; it doesn't overrule you. +- **A task is routed once and stays there.** Its Claude session lives inside that + config dir, so a resume has to happen under the same profile or it would start + a fresh conversation. A task already running on a profile therefore waits for + *that* profile to reset rather than hopping to the other one. +- Anything that goes wrong — no credentials, an expired login, no `jq`/`python3`, + the usage API unreachable with no cached reading — means the plugin says nothing + and the task spawns exactly as it would have without it. The one exception is + deliberate: if *every* profile merely failed to probe, it does **not** hold your + tasks, because that's the plugin being broken rather than the accounts being + spent. + +## Configuration + +See [`config.example.env`](config.example.env). The knobs: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `TY_CLAUDE_PROFILES` | *(required)* | Space-separated config dirs to route between | +| `TY_CLAUDE_MAX_PERCENT` | `90` | Skip a profile at or above this percent used | +| `TY_CLAUDE_PROJECTS` | *(all)* | Only route tasks in these projects | +| `TY_CLAUDE_JSON` | `auto` | Force a JSON backend (`jq` or `python3`) | +| `TY_CLAUDE_CACHE_TTL` | `60` | Seconds a usage reading is served before refetching | +| `TY_CLAUDE_CACHE_STALE` | `1800` | Seconds a cached reading stays usable when a live read fails | + +Anything already set in the environment overrides `config.env`, so you can try a +threshold without editing the file: + +```bash +TY_CLAUDE_MAX_PERCENT=50 ./route.sh +``` + +## Caveats + +- **A config dir is more than an account.** It also carries that profile's + plugins, MCP servers, and trusted-worktree state. Set both profiles up the + same way, or a task routed to the quieter one may find tools missing. If you + want to swap only credentials, use a per-task `env:` override instead (see + `docs/plugins.md` in the main repo). +- **Usage is read, never written.** `usage.sh` reads each profile's stored OAuth + token — from the macOS Keychain, or `/.credentials.json` elsewhere — + to call the same endpoint Claude Code's `/usage` uses. It never refreshes, + rewrites, or prints a credential. A profile whose token has gone stale reports + as unavailable until you run a `claude` session under it. +- **The keychain lookup depends on undocumented Anthropic behavior.** Claude Code + namespaces each config dir's credentials by a hash of its path; `usage.sh` + reproduces that. If Anthropic changes it, profiles report "no credentials" + (loudly, on stderr) and routing stops — it never silently reads as "0% used". +- **Two probes per spawn**, each a single HTTPS GET, cached for a minute under + `~/.cache/ty/claude-usage`. The endpoint rate-limits, so the cache is not + optional; a cached reading up to 30 minutes old is used if a live read fails. + The hook is capped at 15s by ty; if it overruns, the task spawns normally. + +## Trust + +This plugin reads your Claude credentials (to call the usage endpoint) and +decides which account your tasks spend. Read it before installing — it's two +short shell scripts: + +- `usage.sh` — reads one profile's token and reports its used percent. +- `route.sh` — asks `usage.sh` about each profile and prints the winner. + +## Files + +| File | What it is | +| --- | --- | +| `route.sh` | The `task.route` hook: picks a profile, or holds the task | +| `usage.sh` | Reads one profile's rate-limit usage (`usage.sh percent\|show `) | +| `status.sh` | The `status` action: what the router currently sees | +| `config.env` | Your profiles and threshold (copy from `config.example.env`) | + +`usage.sh` is usable on its own: + +```bash +./usage.sh percent ~/.claude-work # -> 12 +./usage.sh show ~/.claude-work +``` diff --git a/claude-profile-router/config.example.env b/claude-profile-router/config.example.env new file mode 100644 index 0000000..41a86ca --- /dev/null +++ b/claude-profile-router/config.example.env @@ -0,0 +1,24 @@ +# Copy to config.env (next to this file) and edit. + +# The Claude profiles to route between: a space-separated list of +# CLAUDE_CONFIG_DIR paths. Each is one logged-in account. +# +# To create a second profile, log in with a different config dir: +# CLAUDE_CONFIG_DIR=~/.claude-work claude # then /login +# +# Check what each one is with: ./status.sh +TY_CLAUDE_PROFILES="$HOME/.claude $HOME/.claude-work" + +# Skip a profile once its binding limit (5-hour session or weekly, whichever is +# worse) is at or above this percent. When every profile is above it, tasks are +# held in the queue instead of spawning into a 429. +TY_CLAUDE_MAX_PERCENT=90 + +# Only route tasks in these projects (space-separated). Empty = all projects. +TY_CLAUDE_PROJECTS="" + +# Advanced (usage.sh): force a JSON backend (auto|jq|python3), relocate or +# retune the usage cache. Defaults are fine. +# TY_CLAUDE_JSON=auto +# TY_CLAUDE_CACHE_TTL=60 +# TY_CLAUDE_CACHE_STALE=1800 diff --git a/claude-profile-router/plugin.yaml b/claude-profile-router/plugin.yaml new file mode 100644 index 0000000..c13c335 --- /dev/null +++ b/claude-profile-router/plugin.yaml @@ -0,0 +1,19 @@ +name: claude-profile-router +version: 0.1.0 +description: >- + Send each task to whichever Claude account has the most rate-limit headroom + left, and hold it in the queue when every account is spent. Install with + `ty plugins add https://github.com/taskyou/plugins`, then copy + config.example.env to config.env and list your profile dirs. Needs a ty that + emits the `task.route` hook, plus jq or python3. See README.md. + +hooks: + # task.route fires just before a task spawns and ty reads this script's stdout + # back as a decision — the one hook that changes how a task runs rather than + # reacting to it having run. See docs/plugins.md in the main repo. + task.route: route.sh + +actions: + - id: status + label: Show usage for each Claude profile + command: status.sh diff --git a/claude-profile-router/route.sh b/claude-profile-router/route.sh new file mode 100755 index 0000000..1e10359 --- /dev/null +++ b/claude-profile-router/route.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# task.route hook: pick the Claude profile with the most rate-limit headroom. +# +# ty runs this synchronously just before it spawns a task and reads stdout back +# as the decision, so stdout carries KEY=VALUE lines and nothing else — every +# diagnostic goes to stderr (which lands in the daemon log). +# +# CLAUDE_CONFIG_DIR= run this task under that profile +# HOLD=1 / REASON= every profile is spent; keep the task queued +# (no output) no opinion; ty spawns as already configured +# +# Printing nothing is always safe, so every failure path here does exactly that. +set -uo pipefail + +PLUGIN_DIR="${TASK_PLUGIN_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +say() { echo "claude-profile-router: $*" >&2; } + +# config.env holds the settings, but anything already in the environment wins — +# that is what makes a one-off `TY_CLAUDE_MAX_PERCENT=50 ./route.sh` a usable way +# to try a threshold without editing the file. +_pre_profiles="${TY_CLAUDE_PROFILES:-}" +_pre_max="${TY_CLAUDE_MAX_PERCENT:-}" +_pre_projects="${TY_CLAUDE_PROJECTS:-}" +if [[ -f "$PLUGIN_DIR/config.env" ]]; then + # shellcheck disable=SC1091 + source "$PLUGIN_DIR/config.env" +fi +[[ -n "$_pre_profiles" ]] && TY_CLAUDE_PROFILES="$_pre_profiles" +[[ -n "$_pre_max" ]] && TY_CLAUDE_MAX_PERCENT="$_pre_max" +[[ -n "$_pre_projects" ]] && TY_CLAUDE_PROJECTS="$_pre_projects" + +MAX_PERCENT="${TY_CLAUDE_MAX_PERCENT:-90}" + +if [[ -z "${TY_CLAUDE_PROFILES:-}" ]]; then + say "TY_CLAUDE_PROFILES not set (see config.example.env)" + exit 0 +fi + +# Optional project allowlist, so routing can be tried on one project first. +if [[ -n "${TY_CLAUDE_PROJECTS:-}" ]]; then + match="" + for p in $TY_CLAUDE_PROJECTS; do + [[ "$p" == "${TASK_PROJECT:-}" ]] && match=1 && break + done + [[ -z "$match" ]] && exit 0 +fi + +best_dir="" +best_pct="" +exhausted_low="" # lowest usage among profiles that were over the threshold + +for raw in $TY_CLAUDE_PROFILES; do + dir="${raw/#\~/$HOME}" + + # usage.sh prints one bare number: the binding limit's used percent. It exits + # non-zero for anything it can't vouch for (no credentials, expired login, a + # failed read with no usable cache), which is exactly "skip this profile". + if ! pct=$("$PLUGIN_DIR/usage.sh" percent "$dir" 2>/dev/null); then + say "skipping $dir (usage unavailable — expired login?)" + continue + fi + if [[ ! "$pct" =~ ^[0-9]+$ ]]; then + say "skipping $dir (unparseable usage: '$pct')" + continue + fi + + if (( pct >= MAX_PERCENT )); then + say "$dir at ${pct}% (>= ${MAX_PERCENT}%), skipping" + if [[ -z "$exhausted_low" ]] || (( pct < exhausted_low )); then + exhausted_low="$pct" + fi + continue + fi + + if [[ -z "$best_pct" ]] || (( pct < best_pct )); then + best_pct="$pct" + best_dir="$dir" + fi +done + +if [[ -n "$best_dir" ]]; then + say "routing to $best_dir (${best_pct}% used)" + echo "CLAUDE_CONFIG_DIR=$best_dir" + echo "REASON=${best_pct}% of its binding limit used" + exit 0 +fi + +# Nothing usable. Only hold the task if we actually saw an exhausted profile — +# if every probe merely failed, stay out of the way and let ty spawn normally +# rather than parking the whole board behind a broken credential lookup. +if [[ -n "$exhausted_low" ]]; then + echo "HOLD=1" + echo "REASON=every Claude profile is at or above ${MAX_PERCENT}% (best is ${exhausted_low}%)" + exit 0 +fi + +say "no profile could be evaluated; leaving this task alone" diff --git a/claude-profile-router/status.sh b/claude-profile-router/status.sh new file mode 100755 index 0000000..7b4e334 --- /dev/null +++ b/claude-profile-router/status.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# `ty plugins run claude-profile-router status` — show what the router sees. +# Same numbers route.sh decides on, so a surprising routing choice can be +# checked against reality without reading the daemon log. +set -uo pipefail + +PLUGIN_DIR="${TASK_PLUGIN_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" + +_pre_profiles="${TY_CLAUDE_PROFILES:-}" +_pre_max="${TY_CLAUDE_MAX_PERCENT:-}" +if [[ -f "$PLUGIN_DIR/config.env" ]]; then + # shellcheck disable=SC1091 + source "$PLUGIN_DIR/config.env" +fi +[[ -n "$_pre_profiles" ]] && TY_CLAUDE_PROFILES="$_pre_profiles" +[[ -n "$_pre_max" ]] && TY_CLAUDE_MAX_PERCENT="$_pre_max" + +MAX_PERCENT="${TY_CLAUDE_MAX_PERCENT:-90}" + +if [[ -z "${TY_CLAUDE_PROFILES:-}" ]]; then + echo "No profiles configured. Copy config.example.env to config.env and set TY_CLAUDE_PROFILES." + exit 0 +fi + +echo "Routing threshold: skip a profile at or above ${MAX_PERCENT}% used" +echo +for raw in $TY_CLAUDE_PROFILES; do + dir="${raw/#\~/$HOME}" + if ! out=$("$PLUGIN_DIR/usage.sh" show "$dir" 2>&1); then + echo "$dir" + echo " unavailable — ${out#usage.sh: }" + else + echo "$out" + pct=$("$PLUGIN_DIR/usage.sh" percent "$dir" 2>/dev/null) + if [[ "$pct" =~ ^[0-9]+$ ]] && (( pct >= MAX_PERCENT )); then + echo " -> skipped by the router (at or above ${MAX_PERCENT}%)" + fi + fi + echo +done diff --git a/claude-profile-router/usage.sh b/claude-profile-router/usage.sh new file mode 100755 index 0000000..b7d7fdc --- /dev/null +++ b/claude-profile-router/usage.sh @@ -0,0 +1,254 @@ +#!/bin/bash +# Read how much of a Claude subscription a profile has spent. +# +# usage.sh percent -> one bare number, the binding limit's used % +# usage.sh show -> a human summary (used by status.sh) +# +# A "profile" is a CLAUDE_CONFIG_DIR: one logged-in Claude account. This reads +# that profile's stored OAuth token and calls the same endpoint Claude Code's own +# /usage command uses. It is strictly read-only — no token is refreshed, +# rewritten, or printed. +# +# Exits non-zero (with a message on stderr) whenever the answer isn't +# trustworthy: no credentials, an expired login, a failed request with no usable +# cache. route.sh treats that as "skip this profile", so a broken probe never +# routes a task somewhere wrong. +set -uo pipefail + +API="${TY_CLAUDE_API:-https://api.anthropic.com}" +CACHE_DIR="${TY_CLAUDE_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/ty/claude-usage}" +CACHE_TTL="${TY_CLAUDE_CACHE_TTL:-60}" # serve without refetching, seconds +CACHE_STALE="${TY_CLAUDE_CACHE_STALE:-1800}" # usable when a live fetch fails + +die() { echo "usage.sh: $*" >&2; exit 1; } + +# --- JSON --------------------------------------------------------------------- +# The usage response is too nested for sed to be honest about, so this needs a +# real parser. jq if it's here, python3 otherwise; both are absent often enough +# that "neither" gets a clear message rather than a confusing empty answer. +# +# TY_CLAUDE_JSON forces a backend. That exists so the two dialects can be tested +# against each other — a machine with both installed would otherwise only ever +# exercise jq, and the python3 branch could rot unnoticed until it ran on a box +# without jq. +case "${TY_CLAUDE_JSON:-auto}" in + jq) command -v jq >/dev/null 2>&1 || die "TY_CLAUDE_JSON=jq but jq is not installed"; JSON=jq ;; + python3) command -v python3 >/dev/null 2>&1 || die "TY_CLAUDE_JSON=python3 but python3 is not installed"; JSON=python3 ;; + auto) + if command -v jq >/dev/null 2>&1; then JSON=jq + elif command -v python3 >/dev/null 2>&1; then JSON=python3 + else die "needs jq or python3 to read the usage API (brew install jq)" + fi ;; + *) die "TY_CLAUDE_JSON must be auto, jq, or python3" ;; +esac + +# json_field +# Two dialects for one question. Keep the pair in sync when you touch either. +json_field() { + local body="$1" jqf="$2" pyf="$3" + if [[ "$JSON" == jq ]]; then + printf '%s' "$body" | jq -r "$jqf" 2>/dev/null + else + printf '%s' "$body" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(1) +$pyf" 2>/dev/null + fi +} + +# --- credentials -------------------------------------------------------------- +# Claude Code namespaces each config dir's credentials by the first 8 hex digits +# of the SHA-256 of the absolute path, so two logged-in accounts can coexist. +# Reproducing that derivation is what lets this read a profile's token without +# asking anyone to paste one. If Anthropic changes the scheme, the keychain +# lookup misses, the .credentials.json fallback misses, and the profile reports +# as unavailable — loudly on stderr, never silently as "0% used". +sha256_of() { + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1 + else + printf '%s' "$1" | sha256sum | cut -d' ' -f1 + fi +} + +normalize_dir() { + local d="${1/#\~/$HOME}" + while [[ "$d" == */ && "$d" != "/" ]]; do d="${d%/}"; done + printf '%s' "$d" +} + +# creds_blob -> the raw credential JSON on stdout +creds_blob() { + local dir="$1" svc blob + svc="Claude Code-credentials-$(sha256_of "$dir" | cut -c1-8)" + + if command -v security >/dev/null 2>&1; then + blob=$(security find-generic-password -s "$svc" -w 2>/dev/null) + [[ -n "$blob" ]] && { printf '%s' "$blob"; return 0; } + fi + if [[ -f "$dir/.credentials.json" ]]; then + cat "$dir/.credentials.json"; return 0 + fi + # The default ~/.claude may still use the pre-namespacing service name. + if [[ "$dir" == "$HOME/.claude" ]] && command -v security >/dev/null 2>&1; then + blob=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null) + [[ -n "$blob" ]] && { printf '%s' "$blob"; return 0; } + fi + return 1 +} + +# --- cache -------------------------------------------------------------------- +# The usage endpoint rate-limits (429), and routing probes it on every spawn — +# without a cache a busy board walks straight into losing the numbers it routes +# on. Cached on disk rather than in memory because every probe is a fresh +# process. +cache_file() { printf '%s/%s.json' "$CACHE_DIR" "$(sha256_of "$1" | cut -c1-16)"; } + +file_age() { + local f="$1" mtime now + mtime=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null) || return 1 + now=$(date +%s) + echo $(( now - mtime )) +} + +cache_read() { # + local f; f=$(cache_file "$1") + [[ -f "$f" ]] || return 1 + local age; age=$(file_age "$f") || return 1 + (( age <= $2 )) || return 1 + cat "$f" +} + +cache_write() { # + local f; f=$(cache_file "$1") + mkdir -p "$(dirname "$f")" 2>/dev/null || return 0 + # write-then-rename: several spawns can probe the same profile at once, and a + # reader must never see a half-written file. + printf '%s' "$2" > "$f.tmp.$$" 2>/dev/null && mv -f "$f.tmp.$$" "$f" 2>/dev/null + rm -f "$f.tmp.$$" 2>/dev/null + return 0 +} + +# --- fetch -------------------------------------------------------------------- +# fetch_usage -> usage JSON on stdout +fetch_usage() { + local dir="$1" blob token expires now body code + + blob=$(creds_blob "$dir") || die "no Claude credentials for $dir (log in once with CLAUDE_CONFIG_DIR=$dir claude)" + + token=$(json_field "$blob" '.claudeAiOauth.accessToken // empty' \ + "print(d.get('claudeAiOauth',{}).get('accessToken',''))") + [[ -n "$token" ]] || die "no OAuth token stored for $dir" + + # expiresAt is epoch MILLIseconds. Don't refresh it here — that would fight + # Claude Code's own credential management and risk corrupting the entry. + expires=$(json_field "$blob" '.claudeAiOauth.expiresAt // 0' \ + "print(d.get('claudeAiOauth',{}).get('expiresAt',0) or 0)") + now=$(( $(date +%s) * 1000 )) + if [[ "$expires" =~ ^[0-9]+$ ]] && (( expires > 0 && expires < now )); then + die "credentials for $dir have expired (run a claude session with CLAUDE_CONFIG_DIR=$dir to refresh)" + fi + + if body=$(cache_read "$dir" "$CACHE_TTL"); then + printf '%s' "$body"; return 0 + fi + + body=$(curl -sS --max-time 10 -w $'\n%{http_code}' \ + -H "Authorization: Bearer $token" "$API/api/oauth/usage" 2>/dev/null) + code="${body##*$'\n'}" + body="${body%$'\n'*}" + + if [[ "$code" == "200" ]]; then + cache_write "$dir" "$body" + printf '%s' "$body"; return 0 + fi + + # A failed request is not the same as a bad account: a snapshot from a few + # minutes ago is a far better basis for routing than nothing. + if body=$(cache_read "$dir" "$CACHE_STALE"); then + echo "usage.sh: $dir — live read failed (HTTP ${code:-?}), using cached numbers" >&2 + printf '%s' "$body"; return 0 + fi + + case "$code" in + 401) die "credentials for $dir were rejected (401) — this profile needs a fresh login" ;; + 429) die "the usage API is rate-limiting (429) — not the account's quota; retry shortly" ;; + *) die "could not read usage for $dir (HTTP ${code:-no response})" ;; + esac +} + +# --- reporting ---------------------------------------------------------------- +# The binding limit is the WORST window, not the average: a 5-hour session at 98% +# blocks the next task even when the weekly window is untouched. Falls back to +# the older five_hour/seven_day shape if `limits` is missing, so an API rollback +# doesn't leave routing reading 0%. +binding_percent() { + json_field "$1" \ + '[(.limits // [])[].percent, (.five_hour.utilization // empty), (.seven_day.utilization // empty)] | map(select(. != null)) | (max // 0) | floor' \ + " +lim = d.get('limits') or [] +vals = [l.get('percent') or 0 for l in lim] +if not vals: + for k in ('five_hour','seven_day'): + w = d.get(k) or {} + if w.get('utilization') is not None: vals.append(w['utilization']) +print(int(max(vals or [0])))" +} + +# account_email — which login this profile is, for the human-facing +# `show` output. A second request, so it is never made on the routing path, and +# it is best-effort: a profile's numbers are the point, its address is a label. +# Cached for a day, since it only changes when you re-login. +account_email() { + local dir="$1" f blob token body age + f="$(cache_file "$dir").email" + if [[ -f "$f" ]] && age=$(file_age "$f") && (( age <= 86400 )); then + cat "$f"; return 0 + fi + blob=$(creds_blob "$dir") || return 1 + token=$(json_field "$blob" '.claudeAiOauth.accessToken // empty' \ + "print(d.get('claudeAiOauth',{}).get('accessToken',''))") + [[ -n "$token" ]] || return 1 + body=$(curl -sS --max-time 10 -H "Authorization: Bearer $token" \ + "$API/api/oauth/profile" 2>/dev/null) || return 1 + local email + email=$(json_field "$body" '.account.email // empty' \ + "print(d.get('account',{}).get('email',''))") + [[ -n "$email" ]] || return 1 + mkdir -p "$(dirname "$f")" 2>/dev/null && printf '%s' "$email" > "$f" 2>/dev/null + printf '%s' "$email" +} + +describe() { + json_field "$1" \ + '[(.limits // [])[]] | if length == 0 then "no limits reported" else (max_by(.percent) | "\(.percent|floor)% used (\(.kind)\(if .resets_at then ", resets " + (.resets_at|sub("\\..*";"")|sub("T";" ")) else "" end))") end' \ + " +lim = d.get('limits') or [] +if not lim: + print('no limits reported') +else: + w = max(lim, key=lambda l: l.get('percent') or 0) + r = w.get('resets_at') + r = ', resets ' + r.split('.')[0].replace('T',' ') if r else '' + print('%d%% used (%s%s)' % (int(w.get('percent') or 0), w.get('kind'), r))" +} + +# --- main --------------------------------------------------------------------- +[[ $# -ge 2 ]] || die "usage: usage.sh {percent|show} " +mode="$1"; dir="$(normalize_dir "$2")" +usage_json="$(fetch_usage "$dir")" || exit 1 + +case "$mode" in + percent) binding_percent "$usage_json" ;; + show) + echo "$dir" + if email=$(account_email "$dir" 2>/dev/null) && [[ -n "$email" ]]; then + echo " $email" + fi + echo " $(describe "$usage_json")" + ;; + *) die "unknown mode: $mode" ;; +esac