src/server/claude-usage.ts
Rama main · réplica de lectura del repositorio original.
// Claude Code subscription usage, read from Anthropic's OAuth usage endpoint using the credentials
// the Claude Code CLI maintains on this box. Fails CLOSED: every error path returns null, and the
// caller renders nothing — a broken or zeroed gauge is worse than no gauge.
import { execFile } from 'node:child_process';
import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { promisify } from 'node:util';
import { dataPath } from './paths.ts';
const execFileAsync = promisify(execFile);
const TAG = '[claude-usage]';
/** The endpoint gates on the OAuth token carrying this scope; without it it answers
* 403 permission_error and the deployment is effectively API-key-only for our purposes. */
const REQUIRED_SCOPE = 'user:profile';
/** One limit window as the endpoint reports it. `resetsAt` is passed through verbatim: the caller
* derives any human label from it. NEVER label a `weekly_*` kind "weekly"/"7 days" — that window
* actually rolls on a ~72h cadence, so only resets_at tells the truth. */
export interface ClaudeUsageLimit {
kind: string;
group: string;
percent: number;
severity: string;
resetsAt: string | null;
/** Present on scoped limits, e.g. a per-model window. */
scopeLabel?: string;
}
/** A Claude login's identity — safe to persist and show (no secrets). */
export interface ClaudeLoginIdentity {
email: string;
/** `subscriptionType` from the login's credentials (e.g. `pro`, `max`); omitted when unknown. */
plan?: string;
}
export interface ClaudeUsage {
subscriptionType: string | null;
/** Which Claude account these numbers belong to (email, else display name). A box can be signed in
* as somebody other than the person reading the gauge — say whose quota this is. */
account: string | null;
limits: ClaudeUsageLimit[];
/** When these numbers were actually read from upstream (ISO). The client labels the gauge with it. */
fetchedAt: string;
/** True when this is the last known-good reading being served because the current attempt failed
* (429 cooldown, 403, network). The gauge stays VISIBLE and says how old it is. */
stale?: boolean;
/** Why the last refresh failed, in words (e.g. "rate limited by the usage endpoint (429)"). Set only
* alongside `stale` — a multi-day-old reading must say WHY it is old, not just that it is. */
error?: string;
/** When the next upstream attempt is due (ISO), if the failure told us (Retry-After / cooldown). */
retryAt?: string;
}
/** The subset of the Agent SDK's `rate_limit_event.rate_limit_info` we consume. Observed live: it
* carries status/resetsAt/rateLimitType but NO utilization while allowed — so it can only ever say
* "this window is exhausted", never a percentage. That is exactly the reading the throttled usage
* endpoint fails to deliver when it matters most (the account at its limit). */
export interface RateLimitInfo {
status: string;
resetsAt?: number;
rateLimitType?: string;
}
/** SDK rateLimitType -> the usage endpoint's window, so an exhausted window lands on the row it is. */
const RATE_LIMIT_WINDOW: Record<string, { kind: string; group: string; scopeLabel?: string }> = {
five_hour: { kind: 'session', group: 'session' },
seven_day: { kind: 'weekly_all', group: 'weekly' },
seven_day_opus: { kind: 'weekly_scoped', group: 'weekly', scopeLabel: 'Opus' },
seven_day_sonnet: { kind: 'weekly_scoped', group: 'weekly', scopeLabel: 'Sonnet' },
};
export class ClaudeUsageOptions {
credentialsPath = path.join(homedir(), '.claude', '.credentials.json');
endpoint = 'https://api.anthropic.com/api/oauth/usage';
/** Claude Code's own config, the only place the signed-in account's identity is written. */
accountPath = path.join(homedir(), '.claude.json');
/** How long a completed upstream result (success OR failure) is reused for. Concurrent callers are
* deduped separately onto one in-flight request, so a reload storm or a wall of open tabs costs at
* most one upstream call per window — this endpoint answers 429 aggressively. Failures are cached
* on the same terms, so a 403/API-key box does not retry on every client poll. */
ttlMs = 300_000;
timeoutMs = 8_000;
/** A 429 is not a transient blip here: retrying it on the ordinary TTL is what keeps a box wedged in
* the penalty box all day (25 straight 429s on prod). Each consecutive 429 climbs this ladder and a
* success drops back to the first rung; a `Retry-After` header wins over the rung when it is longer.
* Capped at 15m, not 30m: on a busy box (many CLI sessions sharing one account quota) upstream can
* answer 429 for hours with a ~73s Retry-After, and every attempt is a chance at the first reading
* that unhides the gauge. A failed attempt no longer costs the user anything — the last known-good
* reading stays on screen — so a slightly shorter ceiling is the better trade. */
rateLimitBackoffMs = [60_000, 300_000, 600_000, 900_000];
/** macOS stores Claude Code's OAuth credentials in the login Keychain and writes NO credentials
* file, so on darwin an absent file is not proof of an API-key deployment — we look there second.
* Linux keeps the file as the only source; we never shell out there. */
platform: string = process.platform;
/** Claude Code writes its OAuth blob under this service — but NEWER CLIs suffix it per profile
* (`Claude Code-credentials-<hash>`), leaving the bare name holding only mcpOAuth. Reading just the
* bare name is why the gauge silently vanished on an otherwise healthy box, so we enumerate. */
keychainService = 'Claude Code-credentials';
/** A locked keychain can block (or prompt) indefinitely — never let that stall a client poll. */
keychainTimeoutMs = 3_000;
/** Where the last known-good reading is mirrored, so a restart (deploy, self-upgrade) does not
* blank the gauge on a box whose upstream is rate-limited for the next hour. Identity + percentages
* only — never a token. */
cachePath = dataPath('claude-usage-last.json');
/** Seam: hands back the raw secret string, or null on ANY failure. Tests inject here so the suite
* never shells out to the real `security` binary. */
readKeychain: (options: ClaudeUsageOptions) => Promise<string | null> = readKeychainSecret;
}
/** `security` writes the secret to stdout with -w. The value is never logged or returned upward;
* only the parsed, scope-gated accessToken leaves this module. */
async function readKeychainSecret(options: ClaudeUsageOptions): Promise<string | null> {
for (const service of await keychainServices(options)) {
try {
const { stdout } = await execFileAsync('security', ['find-generic-password', '-s', service, '-w'], {
timeout: options.keychainTimeoutMs,
killSignal: 'SIGKILL',
encoding: 'utf8',
maxBuffer: 8 * 1024 * 1024,
});
const raw = stdout.trim();
// The bare service often exists but holds only mcpOAuth — keep looking rather than failing here.
if (raw && hasOauthToken(raw)) return raw;
} catch (err) {
// Missing binary, non-zero exit (no such item), locked keychain, timeout — all the same answer.
console.debug(`${TAG} keychain lookup failed for "${service}": ${(err as Error).message}`);
}
}
return null;
}
function hasOauthToken(raw: string): boolean {
try {
return typeof JSON.parse(raw)?.claudeAiOauth?.accessToken === 'string';
} catch {
return false;
}
}
/** The bare service first (cheap, the common case), then any `<service>-<suffix>` items the keychain
* actually holds. `dump-keychain` without -d lists ATTRIBUTES only — no secrets, and no prompt. */
async function keychainServices(options: ClaudeUsageOptions): Promise<string[]> {
const services = [options.keychainService];
try {
const { stdout } = await execFileAsync('security', ['dump-keychain'], {
timeout: options.keychainTimeoutMs,
killSignal: 'SIGKILL',
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
});
const re = new RegExp(`"svce"<blob>="(${escapeRe(options.keychainService)}[^"]*)"`, 'g');
for (const m of stdout.matchAll(re)) if (!services.includes(m[1])) services.push(m[1]);
} catch (err) {
console.debug(`${TAG} keychain enumeration failed: ${(err as Error).message}`);
}
return services;
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export class ClaudeUsageReader {
public options: ClaudeUsageOptions;
private cache: { at: number; value: ClaudeUsage | null } | null = null;
/** The one upstream request currently in flight, shared by every caller that arrives while it runs.
* Without this the TTL is useless against a stampede: the cache is only written once the request
* RESOLVES, so N simultaneous callers all miss and all hit upstream. */
private inflight: Promise<ClaudeUsage | null> | null = null;
/** While set in the future, `get()` answers null WITHOUT touching upstream — see rateLimitBackoffMs. */
private cooldownUntil = 0;
private rateLimitStreak = 0;
/** The last reading that actually came back from upstream. Once we have proven this box IS on a
* subscription, a later failure (429 cooldown, 403, network blip) must NOT make the gauge vanish —
* a widget that flickers in and out reads as a bug. We keep serving this, flagged `stale`, and the
* client shows how old it is. Only a box that never had a good reading answers null. */
private lastGood: { at: number; value: ClaudeUsage } | null = null;
/** One-shot rehydrate of `lastGood` from disk, awaited by the first get(). */
private restored: Promise<void> | null = null;
/** Why the most recent upstream attempt failed; cleared by a success. Surfaced on stale readings. */
private lastError: { reason: string; retryAt?: number } | null = null;
/** Latest exhausted windows reported by the agent's own inference calls (SDK rate_limit_event). */
private exhausted = new Map<string, number>();
public constructor(options?: Partial<ClaudeUsageOptions>) {
this.options = { ...new ClaudeUsageOptions(), ...options };
}
/** null => this box is not on a Claude subscription, or we could not prove that it ever was. */
async get(): Promise<ClaudeUsage | null> {
return this.overlay(await this.read());
}
/** Fed from every agent turn's `rate_limit_event`. Costs zero upstream calls. 'rejected' marks the
* window exhausted until its reset; any other status for that window clears the mark. */
observeRateLimit(info: RateLimitInfo | null | undefined) {
const type = info?.rateLimitType;
if (!type || !RATE_LIMIT_WINDOW[type]) return;
if (info.status === 'rejected' && typeof info.resetsAt === 'number') {
if (!this.exhausted.has(type)) console.warn(`${TAG} agent reports ${type} limit reached; resets ${new Date(info.resetsAt * 1000).toISOString()}`);
this.exhausted.set(type, info.resetsAt * 1000);
} else {
this.exhausted.delete(type);
}
}
/** Paint any window the agent has PROVEN exhausted at 100% over whatever the endpoint last said —
* the endpoint's reading may be days old. Never conjures a gauge for a box with no reading. */
private overlay(usage: ClaudeUsage | null): ClaudeUsage | null {
if (!usage || !this.exhausted.size) return usage;
const now = Date.now();
const limits = [...usage.limits];
for (const [type, resetsAtMs] of this.exhausted) {
if (resetsAtMs <= now) { this.exhausted.delete(type); continue; }
const w = RATE_LIMIT_WINDOW[type];
const hit = { ...w, percent: 100, severity: 'exceeded', resetsAt: new Date(resetsAtMs).toISOString() };
const i = limits.findIndex(l => l.kind === w.kind && (!w.scopeLabel || l.scopeLabel === w.scopeLabel));
if (i >= 0) limits[i] = { ...limits[i], ...hit, scopeLabel: limits[i].scopeLabel ?? w.scopeLabel };
else limits.push(hit);
}
return { ...usage, limits };
}
/** The login changed (connect/replace/disconnect): every cached or persisted reading describes the
* OLD account, so drop them all and let the next poll read the new one. */
invalidate(): void {
this.cache = null;
this.identityCache = null;
this.lastGood = null;
this.cooldownUntil = 0;
this.restored = Promise.resolve();
rm(this.options.cachePath, { force: true }).catch((err: Error) => console.warn(`${TAG} could not drop cached reading:`, err.message));
}
private async read(): Promise<ClaudeUsage | null> {
await (this.restored ??= this.restore());
const now = Date.now();
if (now < this.cooldownUntil) return this.stale();
if (this.cache && now - this.cache.at < this.options.ttlMs) return this.cache.value ?? this.stale();
if (this.inflight) return this.inflight;
// Clear inflight before the value is handed out, so a rejection can never wedge the reader:
// the next call past the TTL starts a fresh request.
this.inflight = this.fetchUsage()
.then((value) => {
const at = Date.now();
const stamped = value ? { ...value, fetchedAt: new Date(at).toISOString() } : null;
this.cache = { at, value: stamped };
if (stamped) { this.lastGood = { at, value: stamped }; void this.persist(this.lastGood); }
return stamped ?? this.stale();
})
.finally(() => { this.inflight = null; });
return this.inflight;
}
/** Rehydrate the last reading a previous process wrote. Never throws: a missing or corrupt file
* just means we start with nothing, exactly as before. */
private async restore(): Promise<void> {
try {
const saved = JSON.parse(await readFile(this.options.cachePath, 'utf8'));
if (Array.isArray(saved?.value?.limits) && saved.value.limits.length && typeof saved.at === 'number') {
this.lastGood = { at: saved.at, value: saved.value };
}
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') console.debug(`${TAG} could not restore the last reading: ${(err as Error).message}`);
}
}
/** Mirror a fresh reading to disk. Best-effort — a write failure must never break the response. */
private async persist(entry: { at: number; value: ClaudeUsage }) {
try {
await writeFile(this.options.cachePath, JSON.stringify(entry));
} catch (err) {
console.debug(`${TAG} could not persist the last reading: ${(err as Error).message}`);
}
}
/** Last known-good reading, marked stale. Never invents numbers — null when we never had any. */
private stale(): ClaudeUsage | null {
if (!this.lastGood) return null;
const err = this.lastError;
return {
...this.lastGood.value,
stale: true,
error: err?.reason ?? 'refresh failed (no detail recorded)',
...(err?.retryAt ? { retryAt: new Date(err.retryAt).toISOString() } : {}),
};
}
private fail(reason: string, retryAt?: number): null {
this.lastError = { reason, retryAt };
return null;
}
private async fetchUsage(): Promise<Omit<ClaudeUsage, 'fetchedAt'> | null> {
const creds = await this.readCredentials();
if (!creds) return this.fail('no usable Claude OAuth credentials on this box');
// Only the Claude Code CLI refreshes this token (it does so when the agent runs). An expired one
// is a guaranteed 401 — prod logged 33 in a row overnight — so skip the call and say so instead.
if (creds.expiresAt !== null && creds.expiresAt <= Date.now()) {
const at = new Date(creds.expiresAt).toISOString();
console.warn(`${TAG} oauth token expired at ${at}; skipping upstream until Claude Code refreshes it`);
return this.fail(`OAuth token expired at ${at} — refreshes on the next agent run`);
}
try {
const res = await fetch(this.options.endpoint, {
headers: {
Authorization: `Bearer ${creds.accessToken}`,
'anthropic-beta': 'oauth-2025-04-20',
// MANDATORY. Without a claude-code/<version> UA the endpoint drops us into an aggressively
// rate-limited bucket and answers 429 persistently.
'User-Agent': `claude-code/${claudeCodeVersion()}`,
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(this.options.timeoutMs),
});
if (res.status === 429) {
// The reason lives in the BODY, not the status: "too many requests" (our own poll rate) and
// an account/org-level throttle read identically from the outside, and only the first one is
// ours to fix. Backing off blind once cost a day of a hidden gauge, so surface it.
const detail = await describeError(res);
return this.fail(`usage endpoint rate-limited this account (429): ${detail}`, this.enterCooldown(res.headers.get('retry-after'), detail));
}
if (!res.ok) {
const detail = await describeError(res);
console.warn(`${TAG} usage endpoint returned ${res.status}; hiding widget — ${detail}`);
return this.fail(`usage endpoint returned ${res.status}: ${detail}`);
}
const body: any = await res.json();
const limits = Array.isArray(body?.limits) ? body.limits.map(toLimit).filter(Boolean) as ClaudeUsageLimit[] : [];
if (!limits.length) {
console.warn(`${TAG} usage response carried no limits[]; hiding widget`);
return this.fail('usage endpoint answered with no limits');
}
this.rateLimitStreak = 0;
this.lastError = null;
return { subscriptionType: creds.subscriptionType, account: await this.readAccount(), limits };
} catch (err) {
console.warn(`${TAG} usage lookup failed:`, (err as Error).message);
return this.fail(`usage lookup failed: ${(err as Error).message}`);
}
}
/** Climb the backoff ladder one rung per consecutive 429, capped at the last rung. `Retry-After`
* (seconds, or an HTTP-date) only ever EXTENDS the wait — never shortens the rung we earned. */
private enterCooldown(retryAfter: string | null, detail: string): number {
const ladder = this.options.rateLimitBackoffMs;
const rung = ladder[Math.min(this.rateLimitStreak, ladder.length - 1)] ?? 60_000;
this.rateLimitStreak++;
const hinted = parseRetryAfter(retryAfter);
const waitMs = Math.max(rung, hinted ?? 0);
this.cooldownUntil = Date.now() + waitMs;
console.warn(
`${TAG} usage endpoint returned 429 (ua=claude-code/${claudeCodeVersion()}, retry-after=${retryAfter ?? 'none'}); ` +
`hiding widget and backing off ${Math.round(waitMs / 1000)}s — ${detail}`,
);
return this.cooldownUntil;
}
/** Re-read per poll from whichever source holds them — the token lives ~8h and Claude Code
* refreshes it in place, so nothing here may be cached in a field. Files first, Keychain second.
* Neither source is a single fixed name any more: newer CLIs write a per-profile SUFFIX
* (`.credentials-<hash>.json`, `Claude Code-credentials-<hash>`) and leave the bare name holding
* non-OAuth data, so we try every candidate before concluding "no subscription". */
private async readCredentials(): Promise<Credentials | null> {
for (const file of await this.credentialFiles()) {
try {
const creds = parseCredentials(await readFile(file, 'utf8'), `credentials file ${path.basename(file)}`);
if (creds) return creds;
} catch (err) {
// ENOENT is the ordinary API-key deployment on Linux, not a fault — keep it quiet at debug level.
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') console.debug(`${TAG} no credentials file at ${file}`);
else console.warn(`${TAG} could not read ${file}:`, (err as Error).message);
}
}
if (this.options.platform !== 'darwin') return null;
// The seam is contractually fail-closed, but readCredentials runs OUTSIDE fetchUsage's try —
// an unexpected rejection here would surface as a 500 rather than a hidden widget.
const secret = await this.options.readKeychain(this.options).catch((err: Error) => {
console.warn(`${TAG} keychain reader threw:`, err.message);
return null;
});
if (!secret) {
console.debug(`${TAG} keychain held no "${this.options.keychainService}*" secret (API-key deployment)`);
return null;
}
return parseCredentials(secret, 'keychain');
}
/** Whose account this box is signed in as. Identity only — never a token, and never fatal: an
* unreadable config just means the card omits the line. */
private async readAccount(): Promise<string | null> {
const account = await this.readOauthAccount();
return account?.emailAddress ?? account?.displayName ?? null;
}
private async readOauthAccount(): Promise<{ emailAddress?: string; displayName?: string } | null> {
try {
return JSON.parse(await readFile(this.options.accountPath, 'utf8'))?.oauthAccount ?? null;
} catch (err) {
console.debug(`${TAG} could not read the signed-in account: ${(err as Error).message}`);
return null;
}
}
private identityCache: { at: number; value: Promise<ClaudeLoginIdentity | null> } | null = null;
/** Which login this reader's dir is signed in as: email + plan, never a token. Local files only
* (no upstream call); cached for `ttlMs` because on darwin the plan lookup may shell out to the
* keychain, and it is asked once per agent turn. null when no email is recorded. */
identity(): Promise<ClaudeLoginIdentity | null> {
const now = Date.now();
if (this.identityCache && now - this.identityCache.at < this.options.ttlMs) return this.identityCache.value;
const value = Promise.all([this.readOauthAccount(), this.readCredentials()]).then(([account, creds]) => {
const email = account?.emailAddress?.trim();
if (!email) return null;
return creds?.subscriptionType ? { email, plan: creds.subscriptionType } : { email };
}).catch((err: Error) => {
console.warn(`${TAG} could not resolve the signed-in identity:`, err.message);
return null;
});
this.identityCache = { at: now, value };
return value;
}
/** The configured path first, then any sibling `.credentials*.json` the CLI may have written. */
private async credentialFiles(): Promise<string[]> {
const configured = this.options.credentialsPath;
const files = [configured];
const base = path.basename(configured).replace(/\.json$/, '');
try {
for (const name of await readdir(path.dirname(configured))) {
const full = path.join(path.dirname(configured), name);
if (name.startsWith(base) && name.endsWith('.json') && !files.includes(full)) files.push(full);
}
} catch (err) {
console.debug(`${TAG} could not scan for sibling credential files: ${(err as Error).message}`);
}
return files;
}
}
/** The ONE gate, shared by both sources: a usable accessToken carrying user:profile. Never throws —
* every rejection is a logged null, i.e. hidden widget and zero upstream calls. */
interface Credentials { accessToken: string; subscriptionType: string | null; expiresAt: number | null }
function parseCredentials(raw: string, source: string): Credentials | null {
let oauth: any;
try {
oauth = JSON.parse(raw)?.claudeAiOauth;
} catch {
console.warn(`${TAG} ${source} did not hold valid JSON; hiding widget`);
return null;
}
if (!oauth?.accessToken || typeof oauth.accessToken !== 'string') {
console.debug(`${TAG} ${source} carried no claudeAiOauth accessToken; hiding widget`);
return null;
}
if (!Array.isArray(oauth.scopes) || !oauth.scopes.includes(REQUIRED_SCOPE)) {
console.warn(`${TAG} oauth token lacks the ${REQUIRED_SCOPE} scope; hiding widget`);
return null;
}
return { accessToken: oauth.accessToken, subscriptionType: oauth.subscriptionType ?? null,
expiresAt: typeof oauth.expiresAt === 'number' && Number.isFinite(oauth.expiresAt) ? oauth.expiresAt : null };
}
/** Anthropic answers errors as `{ error: { type, message } }`. Never throws and never returns more
* than a line: this only ever lands in a log, next to a status we already decided to fail on. */
async function describeError(res: Response): Promise<string> {
try {
const raw = (await res.text()).slice(0, 300);
const parsed = JSON.parse(raw)?.error;
return parsed?.message ? `${parsed.type ?? 'error'}: ${parsed.message}` : raw || '(empty body)';
} catch (err) {
return `(unreadable body: ${(err as Error).message})`;
}
}
/** `Retry-After` is either delta-seconds or an HTTP-date. Anything unparseable => no hint. */
function parseRetryAfter(raw: string | null): number | null {
if (!raw) return null;
const secs = Number(raw.trim());
if (Number.isFinite(secs) && secs >= 0) return secs * 1000;
const at = Date.parse(raw);
if (!Number.isNaN(at)) return Math.max(0, at - Date.now());
return null;
}
function toLimit(l: any): ClaudeUsageLimit | null {
if (!l || typeof l.percent !== 'number' || !Number.isFinite(l.percent)) return null;
return {
kind: String(l.kind ?? 'unknown'),
group: String(l.group ?? 'unknown'),
percent: Math.max(0, Math.min(100, Math.round(l.percent))),
severity: String(l.severity ?? 'normal'),
resetsAt: typeof l.resets_at === 'string' ? l.resets_at : null,
scopeLabel: l.scope?.model?.display_name ?? undefined,
};
}
let cachedVersion: string | null = null;
/** The UA this endpoint gates on is the Claude Code CLI's, and prod proved the gate is real: we were
* sending `claude-code/0.2.141` — the AGENT SDK's version, which is not a Claude Code version at all
* (those are 2.x) — and drew a sustained rate_limit_error with ~40min Retry-Afters. Read the CLI's
* own package when it is installed; otherwise send a plausible CURRENT version, never the SDK's. */
const FALLBACK_CLI_VERSION = '2.0.0';
function claudeCodeVersion(): string {
if (cachedVersion) return cachedVersion;
try {
const require = createRequire(import.meta.url);
const v = require('@anthropic-ai/claude-code/package.json').version;
cachedVersion = typeof v === 'string' && /^\d+\./.test(v) ? v : FALLBACK_CLI_VERSION;
} catch { cachedVersion = FALLBACK_CLI_VERSION; }
return cachedVersion!;
}
export const claudeUsage = new ClaudeUsageReader();
const accountReaders = new Map<string, ClaudeUsageReader>();
/** The reader for a per-user Claude login dir (claude-account.ts), or the box default for null. One
* lazily-created reader per dir, so each account keeps its own cache, cooldown and rate-limit marks.
* The dir's own files are the ONLY source: the keychain enumeration picks the first OAuth item it
* finds, which on a shared box may be somebody else's login. The mirror lives inside the dir, which
* data-sync already ignores. */
export function claudeUsageFor(accountDir: string | null): ClaudeUsageReader {
if (!accountDir) return claudeUsage;
let reader = accountReaders.get(accountDir);
if (!reader) {
reader = new ClaudeUsageReader({
credentialsPath: path.join(accountDir, '.credentials.json'),
accountPath: path.join(accountDir, '.claude.json'),
cachePath: path.join(accountDir, 'shraga-usage-last.json'),
readKeychain: async () => null,
});
accountReaders.set(accountDir, reader);
}
return reader;
}