import { readFile } from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { homedir } from 'node:os'; import { join } from 'node:path'; /** * Claude 订阅额度(5 小时窗口 + 周窗口)透传。 * * 数据源:Anthropic OAuth usage API(GET https://api.anthropic.com/api/oauth/usage, * Bearer = Claude Code 的 OAuth accessToken)。这正是 Claude Code 喂给 statusline * `rate_limits` 字段的同一份数据(claude-hud 的 Usage/Weekly 即来源于此);daemon * 不在 Claude Code 会话内拿不到 stdin,故直连同一端点。 * * 凭证:优先 ~/.claude/.credentials.json(Linux),其次 macOS keychain * (service "Claude Code-credentials")。token 只进内存,绝不写日志/响应。 */ export interface UsageWindow { percent: number; // 0-100 已用百分比 resetsAt: string | null; // ISO 时间,窗口重置时刻 } export interface UsageInfo { session: UsageWindow | null; // 5 小时滚动窗口 weekly: UsageWindow | null; // 7 天窗口 } export interface UsageLogger { info(msg: string): void; error(msg: string): void; } export const USAGE_CACHE_MS = 60_000; export const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage'; const FETCH_TIMEOUT_MS = 5_000; export interface UsageDeps { /** 取 OAuth accessToken(测试注入;返回 null = 凭证不可用)。绝不把返回值写进日志。 */ readToken?: () => Promise; fetchFn?: typeof fetch; now?: () => number; log?: UsageLogger; } /** 解析凭证 JSON(~/.claude/.credentials.json 与 keychain 存的是同一结构) */ function tokenFromCredentialJson(raw: string, now: number, log: UsageLogger): string | null { const j = JSON.parse(raw) as { claudeAiOauth?: { accessToken?: string; expiresAt?: number } }; const oauth = j.claudeAiOauth; if (!oauth?.accessToken) return null; if (typeof oauth.expiresAt === 'number' && oauth.expiresAt <= now) { log.info('额度查询:OAuth token 已过期(等 Claude Code 刷新后自动恢复)'); return null; } return oauth.accessToken; } /** 默认凭证读取:credentials 文件 → macOS keychain。任何失败返回 null。 */ async function defaultReadToken(now: number, log: UsageLogger): Promise { const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); try { const raw = await readFile(join(configDir, '.credentials.json'), 'utf8'); const tok = tokenFromCredentialJson(raw, now, log); if (tok) return tok; } catch { // 文件不存在/不可解析 → 尝试 keychain } if (process.platform !== 'darwin') return null; try { const raw = await new Promise((resolve, reject) => { execFile( 'security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], { timeout: 3_000 }, (err, stdout) => (err ? reject(err) : resolve(stdout)), ); }); return tokenFromCredentialJson(raw.trim(), now, log); } catch { return null; } } /** API 响应里的一个窗口 → UsageWindow(缺失/畸形 → null) */ function parseWindow(v: unknown): UsageWindow | null { if (!v || typeof v !== 'object') return null; const w = v as { utilization?: unknown; resets_at?: unknown }; if (typeof w.utilization !== 'number' || !Number.isFinite(w.utilization)) return null; return { percent: Math.round(Math.min(100, Math.max(0, w.utilization))), resetsAt: typeof w.resets_at === 'string' ? w.resets_at : null, }; } const noopLog: UsageLogger = { info: () => undefined, error: () => undefined }; /** * 创建 getUsage:60s 内存缓存(成功与失败都缓存,杜绝重试风暴),并发去重(in-flight 共享)。 * 任何失败(无凭证 / 网络 / 非 200 / 解析失败)→ null + 记日志,绝不抛出。 */ export function createUsageFetcher(deps: UsageDeps = {}): () => Promise { const now = deps.now ?? Date.now; const fetchFn = deps.fetchFn ?? fetch; const log = deps.log ?? noopLog; const readToken = deps.readToken ?? ((): Promise => defaultReadToken(now(), log)); let cache: { at: number; value: UsageInfo | null } | null = null; let inflight: Promise | null = null; async function fetchOnce(): Promise { const token = await readToken(); if (!token) { log.info('额度查询:未取到 Claude Code OAuth 凭证,跳过'); return null; } const res = await fetchFn(USAGE_ENDPOINT, { headers: { Authorization: `Bearer ${token}`, 'anthropic-beta': 'oauth-2025-04-20', 'Content-Type': 'application/json', }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) { log.error(`额度查询:usage API 返回 ${res.status}`); return null; } const body = (await res.json()) as { five_hour?: unknown; seven_day?: unknown }; const session = parseWindow(body.five_hour); const weekly = parseWindow(body.seven_day); if (!session && !weekly) { log.error('额度查询:usage API 响应缺少 five_hour/seven_day 字段'); return null; } return { session, weekly }; } return async function getUsage(): Promise { if (cache && now() - cache.at < USAGE_CACHE_MS) return cache.value; if (inflight) return inflight; inflight = fetchOnce() .catch((e: unknown) => { log.error(`额度查询失败:${(e as Error).message}`); // 错误信息不含 token return null; }) .then((value) => { cache = { at: now(), value }; inflight = null; return value; }); return inflight; }; }