UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

144 lines 6.34 kB
import { spawn as nodeSpawn } from 'node:child_process'; /** How long we wait for the readout before calling it a timeout. */ const READ_TIMEOUT_MS = 20_000; /** * Present in both subscription headers Claude Code can print (`"your * subscription"` and, mid-overage, `"your overages"`), and in neither * non-subscription case. Matching the shared tail rather than the word * "subscription" is deliberate: an account burning overage still has a quota to * report, and keying on "subscription" would read it as having none. */ const SUBSCRIPTION_HEADER = /to power your Claude Code usage/; /** * One `"<label>: <n>% used · resets <when>"` line. * * Anchored and strict on `% used` immediately after the colon, because the same * readout carries lines that are shaped just closely enough to match a lazier * pattern (`"Top skills: /dataviz 2%, /claude-api 1%"`, `" 70% of your usage * was at >150k context"`). */ const WINDOW_LINE = /^(.+?):\s+(\d+(?:\.\d+)?)% used(?:\s*·\s*(.+))?$/; /** Strips the `resets` verb, which is prose framing rather than part of the value. */ const RESETS_PREFIX = /^resets\s+/; function windowKind(label) { if (/^current session$/i.test(label)) return 'session'; if (/^current week \(all models\)$/i.test(label)) return 'week'; if (/^current week \(.+\)$/i.test(label)) return 'week-model'; return 'unknown'; } /** * Parse Claude Code's `/usage` readout (#521). * * The agent prints prose, so this is a text parse and a reworded readout is a * real failure mode. It fails to `unrecognized` rather than to an empty reading: * a silent zero would read as "nothing used" and let a consumption limit run the * account dry. */ export function parseQuotaReadout(text) { const windows = []; for (const line of text.split('\n')) { const match = WINDOW_LINE.exec(line.trim()); if (!match) continue; const [, label, percent, resets] = match; if (label === undefined || percent === undefined) continue; const resetsAtText = resets?.replace(RESETS_PREFIX, '').trim(); windows.push({ label, kind: windowKind(label), percentUsed: Number(percent), ...(resetsAtText ? { resetsAtText } : {}), }); } if (windows.length > 0) return { available: true, windows }; // No windows and no subscription header: the account has no quota to report // (API-key auth). With the header, it does have one and we simply failed to // read it, which is a parser problem and must not masquerade as the former. return { available: false, reason: SUBSCRIPTION_HEADER.test(text) ? 'unrecognized' : 'no-subscription' }; } /** * Ask Claude Code where the account's subscription quota stands (#521). * * Runs the CLI's own `/usage` command in print mode. Costs nothing: verified on * 2.1.210 as `total_cost_usd: 0` across zero turns and zero tokens, because the * CLI answers it locally rather than by prompting a model. It reaches Anthropic * itself, with its own credentials, so The Framework never reads or handles the * user's token. * * Never pass `--bare` here: it pins the CLI to API-key auth and never reads the * OAuth credentials, so the subscription quota this reads would vanish. */ export function readClaudeQuota(opts = {}) { return new Promise(resolvePromise => { if (opts.signal?.aborted) { resolvePromise({ available: false, reason: 'timeout' }); return; } const spawn = opts.spawn ?? nodeSpawn; const child = spawn(opts.bin ?? 'claude', ['-p', '/usage', '--output-format', 'json'], { cwd: opts.cwd ?? process.cwd(), env: opts.env ?? process.env, }); let settled = false; // Raw bytes, decoded once at the end: a per-chunk `String(chunk)` corrupts a multibyte // UTF-8 codepoint split across two chunks (the readout's `·` is one), and a corrupted // readout parses as 'unrecognized' instead of the real quota. const chunks = []; const settle = (quota) => { if (settled) return; settled = true; clearTimeout(timer); opts.signal?.removeEventListener('abort', onAbort); resolvePromise(quota); }; const onAbort = () => { child.kill('SIGTERM'); settle({ available: false, reason: 'timeout' }); }; opts.signal?.addEventListener('abort', onAbort); // Deliberately not unref'd: this timer is the only thing guaranteeing the // promise settles, and an unref'd one never fires once the loop goes idle, // leaving the caller awaiting forever. `settle` clears it either way. const timer = setTimeout(() => { child.kill('SIGTERM'); settle({ available: false, reason: 'timeout' }); }, opts.timeoutMs ?? READ_TIMEOUT_MS); child.stdout?.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); // ENOENT lands here rather than as a non-zero exit. child.on('error', () => settle({ available: false, reason: 'agent-not-found' })); child.on('close', code => { if (code !== 0) { settle({ available: false, reason: 'fetch-failed' }); return; } settle(parseQuotaResponse(Buffer.concat(chunks).toString('utf8'))); }); }); } /** Unwrap the CLI's `--output-format json` envelope and parse the readout inside it. */ function parseQuotaResponse(stdout) { let envelope; try { envelope = JSON.parse(stdout); } catch { return { available: false, reason: 'unrecognized' }; } if (typeof envelope !== 'object' || envelope === null) return { available: false, reason: 'unrecognized' }; const obj = envelope; // The CLI's own failure arm, e.g. its usage fetch was refused upstream. if (obj['is_error'] === true) return { available: false, reason: 'fetch-failed' }; const result = obj['result']; if (typeof result !== 'string') return { available: false, reason: 'unrecognized' }; return parseQuotaReadout(result); } //# sourceMappingURL=claude-code-quota.js.map