import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as https from 'https'; import { execFileSync } from 'child_process'; import { createDebug } from './debug.js'; const debug = createDebug('usage'); // File-based cache (HUD runs as new process each render, so in-memory cache won't persist) const CACHE_TTL_MS = 60_000; // 60 seconds const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests const KEYCHAIN_TIMEOUT_MS = 5000; const KEYCHAIN_BACKOFF_MS = 60_000; // Backoff on keychain failures to avoid re-prompting function getCachePath(homeDir) { return path.join(homeDir, '.claude', 'plugins', 'claude-hud', '.usage-cache.json'); } function readCache(homeDir, now) { try { const cachePath = getCachePath(homeDir); if (!fs.existsSync(cachePath)) return null; const content = fs.readFileSync(cachePath, 'utf8'); const cache = JSON.parse(content); // Check TTL - use shorter TTL for failure results const ttl = cache.data.apiUnavailable ? CACHE_FAILURE_TTL_MS : CACHE_TTL_MS; if (now - cache.timestamp >= ttl) return null; // JSON.stringify converts Date to ISO string, so we need to reconvert on read. // new Date() handles both Date objects and ISO strings safely. const data = cache.data; if (data.fiveHourResetAt) { data.fiveHourResetAt = new Date(data.fiveHourResetAt); } if (data.sevenDayResetAt) { data.sevenDayResetAt = new Date(data.sevenDayResetAt); } return data; } catch { return null; } } function writeCache(homeDir, data, timestamp) { try { const cachePath = getCachePath(homeDir); const cacheDir = path.dirname(cachePath); if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }); } const cache = { data, timestamp }; fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8'); } catch { // Ignore cache write failures } } const defaultDeps = { homeDir: () => os.homedir(), fetchApi: fetchUsageApi, now: () => Date.now(), readKeychain: readKeychainCredentials, }; /** * Get OAuth usage data from Anthropic API. * Returns null if user is an API user (no OAuth credentials) or credentials are expired. * Returns { apiUnavailable: true, ... } if API call fails (to show warning in HUD). * * Uses file-based cache since HUD runs as a new process each render (~300ms). * Cache TTL: 60s for success, 15s for failures. */ export async function getUsage(overrides = {}) { const deps = { ...defaultDeps, ...overrides }; const now = deps.now(); const homeDir = deps.homeDir(); // Check file-based cache first const cached = readCache(homeDir, now); if (cached) { return cached; } try { const credentials = readCredentials(homeDir, now, deps.readKeychain); if (!credentials) { return null; } const { accessToken, subscriptionType } = credentials; // Determine plan name from subscriptionType const planName = getPlanName(subscriptionType); if (!planName) { // API user, no usage limits to show return null; } // Fetch usage from API const apiResponse = await deps.fetchApi(accessToken); if (!apiResponse) { // API call failed, cache the failure to prevent retry storms const failureResult = { planName, fiveHour: null, sevenDay: null, fiveHourResetAt: null, sevenDayResetAt: null, apiUnavailable: true, }; writeCache(homeDir, failureResult, now); return failureResult; } // Parse response - API returns 0-100 percentage directly // Clamp to 0-100 and handle NaN/Infinity const fiveHour = parseUtilization(apiResponse.five_hour?.utilization); const sevenDay = parseUtilization(apiResponse.seven_day?.utilization); const fiveHourResetAt = parseDate(apiResponse.five_hour?.resets_at); const sevenDayResetAt = parseDate(apiResponse.seven_day?.resets_at); const result = { planName, fiveHour, sevenDay, fiveHourResetAt, sevenDayResetAt, }; // Write to file cache writeCache(homeDir, result, now); return result; } catch (error) { debug('getUsage failed:', error); return null; } } /** * Get path for keychain failure backoff cache. * Separate from usage cache to track keychain-specific failures. */ function getKeychainBackoffPath(homeDir) { return path.join(homeDir, '.claude', 'plugins', 'claude-hud', '.keychain-backoff'); } /** * Check if we're in keychain backoff period (recent failure/timeout). * Prevents re-prompting user on every render cycle. */ function isKeychainBackoff(homeDir, now) { try { const backoffPath = getKeychainBackoffPath(homeDir); if (!fs.existsSync(backoffPath)) return false; const timestamp = parseInt(fs.readFileSync(backoffPath, 'utf8'), 10); return now - timestamp < KEYCHAIN_BACKOFF_MS; } catch { return false; } } /** * Record keychain failure for backoff. */ function recordKeychainFailure(homeDir, now) { try { const backoffPath = getKeychainBackoffPath(homeDir); const dir = path.dirname(backoffPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(backoffPath, String(now), 'utf8'); } catch { // Ignore write failures } } /** * Read credentials from macOS Keychain. * Claude Code 2.x stores OAuth credentials in the macOS Keychain under "Claude Code-credentials". * Returns null if not on macOS or credentials not found. * * Security: Uses execFileSync with absolute path to avoid shell injection and PATH hijacking. */ function readKeychainCredentials(now, homeDir) { // Only available on macOS if (process.platform !== 'darwin') { return null; } // Check backoff to avoid re-prompting on every render after a failure if (isKeychainBackoff(homeDir, now)) { debug('Keychain in backoff period, skipping'); return null; } try { // Read from macOS Keychain using security command // Security: Use execFileSync with absolute path and args array (no shell) const keychainData = execFileSync('/usr/bin/security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS }).trim(); if (!keychainData) { return null; } const data = JSON.parse(keychainData); return parseCredentialsData(data, now); } catch (error) { // Security: Only log error message, not full error object (may contain stdout/stderr with tokens) const message = error instanceof Error ? error.message : 'unknown error'; debug('Failed to read from macOS Keychain:', message); // Record failure for backoff to avoid re-prompting recordKeychainFailure(homeDir, now); return null; } } /** * Read credentials from file (legacy method). * Older versions of Claude Code stored credentials in ~/.claude/.credentials.json */ function readFileCredentials(homeDir, now) { const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); if (!fs.existsSync(credentialsPath)) { return null; } try { const content = fs.readFileSync(credentialsPath, 'utf8'); const data = JSON.parse(content); return parseCredentialsData(data, now); } catch (error) { debug('Failed to read credentials file:', error); return null; } } /** * Parse and validate credentials data from either Keychain or file. */ function parseCredentialsData(data, now) { const accessToken = data.claudeAiOauth?.accessToken; const subscriptionType = data.claudeAiOauth?.subscriptionType ?? ''; if (!accessToken) { return null; } // Check if token is expired (expiresAt is Unix ms timestamp) // Use != null to handle expiresAt=0 correctly (would be expired) const expiresAt = data.claudeAiOauth?.expiresAt; if (expiresAt != null && expiresAt <= now) { debug('OAuth token expired'); return null; } return { accessToken, subscriptionType }; } /** * Read OAuth credentials, trying macOS Keychain first (Claude Code 2.x), * then falling back to file-based credentials (older versions). * * Token priority: Keychain token is authoritative (Claude Code 2.x stores current token there). * SubscriptionType: Can be supplemented from file if keychain lacks it (display-only field). */ function readCredentials(homeDir, now, readKeychain) { // Try macOS Keychain first (Claude Code 2.x) const keychainCreds = readKeychain(now, homeDir); if (keychainCreds) { if (keychainCreds.subscriptionType) { debug('Using credentials from macOS Keychain'); return keychainCreds; } // Keychain has token but no subscriptionType - try to supplement from file const fileCreds = readFileCredentials(homeDir, now); if (fileCreds?.subscriptionType) { debug('Using keychain token with file subscriptionType'); return { accessToken: keychainCreds.accessToken, subscriptionType: fileCreds.subscriptionType, }; } // No subscriptionType available - use keychain token anyway debug('Using keychain token without subscriptionType'); return keychainCreds; } // Fall back to file-based credentials (older versions or non-macOS) const fileCreds = readFileCredentials(homeDir, now); if (fileCreds) { debug('Using credentials from file'); return fileCreds; } return null; } function getPlanName(subscriptionType) { const lower = subscriptionType.toLowerCase(); if (lower.includes('max')) return 'Max'; if (lower.includes('pro')) return 'Pro'; if (lower.includes('team')) return 'Team'; // API users don't have subscriptionType or have 'api' if (!subscriptionType || lower.includes('api')) return null; // Unknown subscription type - show it capitalized return subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1); } /** Parse utilization value, clamping to 0-100 and handling NaN/Infinity */ function parseUtilization(value) { if (value == null) return null; if (!Number.isFinite(value)) return null; // Handles NaN and Infinity return Math.round(Math.max(0, Math.min(100, value))); } /** Parse ISO date string safely, returning null for invalid dates */ function parseDate(dateStr) { if (!dateStr) return null; const date = new Date(dateStr); // Check for Invalid Date if (isNaN(date.getTime())) { debug('Invalid date string:', dateStr); return null; } return date; } function fetchUsageApi(accessToken) { return new Promise((resolve) => { const options = { hostname: 'api.anthropic.com', path: '/api/oauth/usage', method: 'GET', headers: { 'Authorization': `Bearer ${accessToken}`, 'anthropic-beta': 'oauth-2025-04-20', 'User-Agent': 'claude-hud/1.0', }, timeout: 5000, }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk.toString(); }); res.on('end', () => { if (res.statusCode !== 200) { debug('API returned non-200 status:', res.statusCode); resolve(null); return; } try { const parsed = JSON.parse(data); resolve(parsed); } catch (error) { debug('Failed to parse API response:', error); resolve(null); } }); }); req.on('error', (error) => { debug('API request error:', error); resolve(null); }); req.on('timeout', () => { debug('API request timeout'); req.destroy(); resolve(null); }); req.end(); }); } // Export for testing export function clearCache(homeDir) { if (homeDir) { try { const cachePath = getCachePath(homeDir); if (fs.existsSync(cachePath)) { fs.unlinkSync(cachePath); } } catch { // Ignore } } } //# sourceMappingURL=usage-api.js.map