commit 4d57e293e6f27f3e1979bac276f924124a616272 Author: Johannes Merz (Mac) Date: Thu May 14 17:09:31 2026 +0200 Initial release: Anthropic subscription usage footer extension diff --git a/README.md b/README.md new file mode 100644 index 0000000..15ed1a9 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# pi-anthropic-usage + +Pi agent extension that shows your **Anthropic claude.ai Pro/Max subscription usage** live in the TUI footer. + +## What it shows + +``` +☁ 5h █████░░░ 62% ↺1h23m 7d ██░░░░░░ 18% ↺4d2h +``` + +- **5h** — 5-hour rolling rate limit window +- **7d** — 7-day rolling rate limit window +- Color-coded: green → yellow (≥70%) → red (≥90%) +- Reset timer shows when each window clears + +Only active when using an Anthropic claude.ai subscription (OAuth token). Regular API keys → no display. + +## Install + +```bash +pi install git:git.vpsj.de/jay/pi-anthropic-usage +``` + +## Commands + +| Command | Description | +|----------|--------------------------------------| +| `/usage` | Manual refresh with detailed output | + +## Auto-updates + +Usage refreshes automatically after every model response and on model switch. diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..13b3415 --- /dev/null +++ b/index.ts @@ -0,0 +1,182 @@ +/** + * Anthropic Subscription Usage Extension + * + * Zeigt die aktuellen Rate-Limit-Auslastungen für Anthropic claude.ai + * Pro/Max Subscriptions live im TUI Footer an. + * + * Endpunkt: https://api.anthropic.com/api/oauth/usage + * Nur aktiv wenn OAuth-Token vorhanden (sk-ant-oat... = Subscription). + * + * Anzeige: Footer-Status via ctx.ui.setStatus() + * Update: nach agent_end + model_select + /usage Command + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; +const STATUS_KEY = "anthropic-usage"; +const FETCH_TIMEOUT_MS = 8000; + +// ─── Typen ─────────────────────────────────────────────────────────────────── + +interface RateWindow { + utilization: number; // 0–100 + resets_at: string | null; // ISO-8601 oder null +} + +interface UsageResponse { + five_hour?: RateWindow | null; + seven_day?: RateWindow | null; + seven_day_opus?: RateWindow | null; + seven_day_sonnet?: RateWindow | null; + [key: string]: RateWindow | null | undefined; +} + +// ─── Hilfsfunktionen ───────────────────────────────────────────────────────── + +function formatTimeUntil(isoString: string | null): string { + if (!isoString) return "—"; + const diffMs = new Date(isoString).getTime() - Date.now(); + if (diffMs <= 0) return "jetzt"; + const totalMin = Math.floor(diffMs / 60_000); + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + const d = Math.floor(h / 24); + if (d > 0) return `${d}d${h % 24}h`; + if (h === 0) return `${m}m`; + if (m === 0) return `${h}h`; + return `${h}h${m}m`; +} + +function bar(pct: number, width = 8): string { + const filled = Math.round((pct / 100) * width); + return "█".repeat(filled) + "░".repeat(width - filled); +} + +function colorForPct(pct: number, text: string, theme: any): string { + if (pct >= 90) return theme.fg("error", text); + if (pct >= 70) return theme.fg("warning", text); + return theme.fg("success", text); +} + +function fmtWindowShort(label: string, w: RateWindow, theme: any): string { + const pct = Math.round(w.utilization); + const reset = formatTimeUntil(w.resets_at); + const indicator = colorForPct(pct, `${bar(pct)} ${pct}%`, theme); + return `${theme.fg("dim", label)} ${indicator} ${theme.fg("dim", `↺${reset}`)}`; +} + +function formatStatus(data: UsageResponse, theme: any): string { + const parts: string[] = []; + + if (data.five_hour?.utilization != null) + parts.push(fmtWindowShort("5h", data.five_hour, theme)); + if (data.seven_day?.utilization != null) + parts.push(fmtWindowShort("7d", data.seven_day, theme)); + + // Optionale Modell-spezifische Fenster (falls belegt) + if (data.seven_day_opus?.utilization) + parts.push(fmtWindowShort("opus", data.seven_day_opus, theme)); + if (data.seven_day_sonnet?.utilization) + parts.push(fmtWindowShort("sonnet", data.seven_day_sonnet, theme)); + + if (parts.length === 0) return theme.fg("dim", "☁ keine Usage-Daten"); + return `☁ ${parts.join(" ")}`; +} + +// ─── Fetch ─────────────────────────────────────────────────────────────────── + +async function fetchUsage(token: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(USAGE_URL, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + signal: controller.signal, + }); + if (!res.ok) return null; + return (await res.json()) as UsageResponse; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +// ─── Extension ─────────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + + async function getOAuthToken(ctx: any): Promise { + const model = ctx.model; + if (!model || model.provider !== "anthropic") return null; + if (!ctx.modelRegistry.isUsingOAuth(model)) return null; + return (await ctx.modelRegistry.getApiKeyForProvider("anthropic")) ?? null; + } + + async function refresh(ctx: any): Promise { + if (!ctx.hasUI) return; + + const token = await getOAuthToken(ctx); + if (!token) { + ctx.ui.setStatus(STATUS_KEY, undefined); + return; + } + + const data = await fetchUsage(token); + if (!data) { + ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", "☁ usage: Fehler")); + return; + } + + ctx.ui.setStatus(STATUS_KEY, formatStatus(data, ctx.ui.theme)); + } + + pi.on("session_start", async (_e, ctx) => { await refresh(ctx); }); + pi.on("agent_end", async (_e, ctx) => { await refresh(ctx); }); + pi.on("model_select", async (_e, ctx) => { await refresh(ctx); }); + + // ── /usage Command ──────────────────────────────────────────────────────── + + pi.registerCommand("usage", { + description: "Anthropic Subscription Usage anzeigen (Pro/Max)", + handler: async (_args, ctx) => { + const token = await getOAuthToken(ctx); + if (!token) { + ctx.ui.notify( + "Kein OAuth-Token — nur für claude.ai Pro/Max Subscriptions", + "warning" + ); + return; + } + + ctx.ui.notify("Lade Usage-Daten…", "info"); + const data = await fetchUsage(token); + if (!data) { + ctx.ui.notify("Fetch fehlgeschlagen", "error"); + return; + } + + const lines: string[] = ["Anthropic Subscription Usage\n"]; + + const fmtDetail = (label: string, w: RateWindow) => { + const pct = w.utilization.toFixed(1); + const reset = formatTimeUntil(w.resets_at); + lines.push(`${label.padEnd(12)} ${bar(w.utilization, 14)} ${pct}% ↺ ${reset}`); + }; + + if (data.five_hour?.utilization != null) fmtDetail("5h-Fenster", data.five_hour); + if (data.seven_day?.utilization != null) fmtDetail("7d-Fenster", data.seven_day); + if (data.seven_day_opus?.utilization) fmtDetail("7d Opus", data.seven_day_opus); + if (data.seven_day_sonnet?.utilization) fmtDetail("7d Sonnet", data.seven_day_sonnet); + + if (lines.length === 1) lines.push("Keine Daten verfügbar"); + + ctx.ui.notify(lines.join("\n"), "info"); + ctx.ui.setStatus(STATUS_KEY, formatStatus(data, ctx.ui.theme)); + }, + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..765183b --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "pi-anthropic-usage", + "version": "1.0.0", + "description": "Pi agent extension: shows Anthropic claude.ai Pro/Max subscription usage (5h + 7d rate limits) in the TUI footer", + "keywords": ["pi-package", "pi", "pi-coding-agent", "anthropic", "usage", "subscription"], + "author": "jay", + "license": "MIT", + "pi": { + "extensions": ["./index.ts"] + } +}