import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; export const CLAUDE_USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"; const DEFAULT_POLL_INTERVAL_MS = 300_000; const DEFAULT_STALE_TTL_MS = 600_000; const REQUEST_TIMEOUT_MS = 10_000; function extractClaudeCliToken(raw: string): string | undefined { try { const parsed: unknown = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return undefined; const token = (parsed as { claudeAiOauth?: { accessToken?: unknown } }).claudeAiOauth ?.accessToken; return typeof token === "string" && token.length > 0 ? token : undefined; } catch { return undefined; } } // Pi stores OAuth credentials as { type: "oauth", access, refresh } and API keys as // { type: "api_key", key }. Only the OAuth access token is read here: the usage endpoint // is OAuth-only, so an API key would be rejected as a Bearer token. An api_key-shaped // entry therefore falls through to the keychain and the credentials file on purpose. function extractPiAuthToken(raw: string): string | undefined { try { const parsed: unknown = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return undefined; const token = (parsed as { anthropic?: { access?: unknown } }).anthropic?.access; return typeof token === "string" && token.length > 0 ? token : undefined; } catch { return undefined; } } function readPiAuthFile(path = join(homedir(), ".pi", "agent", "auth.json")): string | undefined { try { return readFileSync(path, "utf8"); } catch { return undefined; } } function readKeychainCredentials(): string | undefined { if (process.platform !== "darwin") return undefined; try { return execFileSync( "security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", timeout: REQUEST_TIMEOUT_MS, // execFileSync forwards the child's stderr to the parent's unless stdio is // explicit, which would print a failed lookup's diagnostic into the pi TUI. stdio: ["ignore", "pipe", "pipe"], }, ); } catch { return undefined; } } export function parseSpendPercent(raw: unknown): number | undefined { if (!raw || typeof raw !== "object") return undefined; const spend = (raw as { spend?: { enabled?: unknown; percent?: unknown } }).spend; if (!spend || spend.enabled !== true) return undefined; const value = spend.percent; return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100 ? value : undefined; } export function readClaudeAccessToken( readPiAuth: () => string | undefined = readPiAuthFile, readKeychain: () => string | undefined = readKeychainCredentials, credentialsPath = join(homedir(), ".claude", ".credentials.json"), ): string | undefined { let piAuthRaw: string | undefined; try { piAuthRaw = readPiAuth(); } catch { piAuthRaw = undefined; } const piAuthToken = extractPiAuthToken(piAuthRaw ?? ""); if (piAuthToken) return piAuthToken; let keychainRaw: string | undefined; try { keychainRaw = readKeychain(); } catch { keychainRaw = undefined; } const keychainToken = extractClaudeCliToken(keychainRaw ?? ""); if (keychainToken) return keychainToken; try { return extractClaudeCliToken(readFileSync(credentialsPath, "utf8")); } catch { return undefined; } } async function requestClaudeUsage( token: string, fetchFn: typeof fetch = fetch, ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const response = await fetchFn(CLAUDE_USAGE_ENDPOINT, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "anthropic-beta": "oauth-2025-04-20", }, signal: controller.signal, }); if (!response.ok) return undefined; return parseSpendPercent(await response.json()); } catch { return undefined; } finally { clearTimeout(timeout); } } export async function fetchClaudeUsedPercent( fetchFn: typeof fetch = fetch, readPiAuth?: () => string | undefined, readKeychain?: () => string | undefined, credentialsPath?: string, ): Promise { const token = readClaudeAccessToken(readPiAuth, readKeychain, credentialsPath); if (!token) return undefined; return requestClaudeUsage(token, fetchFn); } export class ClaudeQuotaState { private value: number | undefined; private token: string | undefined; private updatedAt = 0; private intervalId: ReturnType | undefined; private inFlight = false; constructor( private readonly fetcher: (token: string) => Promise = ( token, ) => requestClaudeUsage(token), private readonly now: () => number = Date.now, private readonly pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, private readonly staleTtlMs = DEFAULT_STALE_TTL_MS, private readonly readToken: () => string | undefined = () => readClaudeAccessToken(), ) {} get usedPercent(): number | undefined { return this.value !== undefined && this.now() - this.updatedAt <= this.staleTtlMs ? this.value : undefined; } startPolling(onComplete: () => void = () => {}): void { if (this.intervalId) return; void this.pollOnce(onComplete); this.intervalId = setInterval(() => void this.pollOnce(onComplete), this.pollIntervalMs); } stopPolling(): void { if (this.intervalId) clearInterval(this.intervalId); this.intervalId = undefined; } get isPolling(): boolean { return this.intervalId !== undefined; } private async pollOnce(onComplete: () => void): Promise { if (this.inFlight) return; this.inFlight = true; try { const token = this.token ?? this.readToken(); if (!token) return; const next = await this.fetcher(token); if (next === undefined) { // A failed poll may mean the memoized credential has gone stale, so drop it and // re-resolve from auth.json, the keychain, or the credentials file next time. this.token = undefined; return; } this.token = token; this.value = next; this.updatedAt = this.now(); } finally { this.inFlight = false; onComplete(); } } }