#!/usr/bin/env -S bun --install=auto /** * claude-usage - Fast CLI tool to fetch Anthropic Claude usage percentages */ import { existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import chalk from 'chalk'; // Configuration const CLAUDE_CODE_PATH = join(homedir(), '.claude', '.credentials.json'); const OPENCODE_PATH = join(homedir(), '.local', 'share', 'opencode', 'auth.json'); // Parse CLI flags const args = process.argv.slice(2); const VERBOSE = args.includes('-v') || args.includes('--verbose') || args.includes('-d') || args.includes('--debug'); interface UsagePeriod { utilization: number; reset: number; } interface UsageData { five_hour: UsagePeriod; seven_day: UsagePeriod; } interface ClaudeCodeCredentials { claudeAiOauth?: { accessToken?: string; }; } interface OpenCodeAuth { anthropic?: { type: string; access?: string; refresh?: string; expires?: number; }; } interface TokenResult { token: string; source: 'claude-code' | 'opencode'; expiresAt?: number; } interface PaceResult { diff: number; status: string; } /** * Interpolate between two hex colors */ function interpolateColor(color1: string, color2: string, ratio: number): string { const r1 = parseInt(color1.slice(1, 3), 16); const g1 = parseInt(color1.slice(3, 5), 16); const b1 = parseInt(color1.slice(5, 7), 16); const r2 = parseInt(color2.slice(1, 3), 16); const g2 = parseInt(color2.slice(3, 5), 16); const b2 = parseInt(color2.slice(5, 7), 16); const r = Math.round(r1 + (r2 - r1) * ratio); const g = Math.round(g1 + (g2 - g1) * ratio); const b = Math.round(b1 + (b2 - b1) * ratio); return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; } /** * Get pastel color based on usage percentage (soft rainbow gradient) */ function getColorForPercentage(pct: number): string { const stops = [ { threshold: 0, color: '#A5D8DD' }, // Soft cyan { threshold: 15, color: '#9DCCB4' }, // Soft teal { threshold: 30, color: '#B8D99A' }, // Soft lime { threshold: 45, color: '#E8D4A2' }, // Soft yellow { threshold: 60, color: '#F4B8A4' }, // Soft orange { threshold: 75, color: '#F5A6A6' }, // Soft red { threshold: 85, color: '#E89999' }, // Medium red { threshold: 95, color: '#D88888' }, // Deeper red ]; let lower = stops[0]; let upper = stops[stops.length - 1]; for (let i = 0; i < stops.length - 1; i++) { if (pct >= stops[i].threshold && pct < stops[i + 1].threshold) { lower = stops[i]; upper = stops[i + 1]; break; } } if (pct >= stops[stops.length - 1].threshold) { return stops[stops.length - 1].color; } const range = upper.threshold - lower.threshold; const position = range > 0 ? (pct - lower.threshold) / range : 0; return interpolateColor(lower.color, upper.color, position); } /** * Generate user_id metadata for the request (mimics Claude Code's format) */ function generateUserId(accessToken: string): string { // Use hash of token as device/user ID const hash = Bun.hash(accessToken).toString(16).padStart(64, '0').substring(0, 64); // Generate a session UUID const sessionId = crypto.randomUUID(); return `user_${hash}_session_${sessionId}`; } /** * Spinner class for loading animation with rainbow gradient */ class Spinner { private frames = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']; private colors = ['#A5D8DD', '#9DCCB4', '#B8D99A', '#E8D4A2', '#F4B8A4', '#F5A6A6']; private index = 0; private interval: Timer | null = null; start(message: string) { process.stdout.write('\x1B[?25l'); this.interval = setInterval(() => { const colorIndex = this.index % this.colors.length; const frame = chalk.hex(this.colors[colorIndex])(this.frames[this.index]); process.stdout.write(`\r${frame} ${chalk.hex('#9CA3AF')(message)}`); this.index = (this.index + 1) % this.frames.length; }, 80); } stop() { if (this.interval) { clearInterval(this.interval); process.stdout.write('\r\x1B[K\x1B[?25h'); } } } /** * Read OAuth token from credentials files (checks both Claude Code and OpenCode) */ async function readToken(): Promise { const errors: string[] = []; // Try OpenCode first (likely to be more up-to-date) if (existsSync(OPENCODE_PATH)) { try { const fileContent = Bun.file(OPENCODE_PATH); const auth: OpenCodeAuth = await fileContent.json() as OpenCodeAuth; const token = auth.anthropic?.access; if (token) { const expiresAt = auth.anthropic?.expires; // Check if token is expired if (expiresAt && expiresAt < Date.now()) { errors.push(`OpenCode token found but expired (expired at ${new Date(expiresAt).toLocaleString()})`); } else { return { token, source: 'opencode', expiresAt }; } } else { errors.push(`OpenCode auth file exists but no Anthropic access token found`); } } catch (error) { errors.push(`Failed to read OpenCode auth: ${error instanceof Error ? error.message : 'unknown error'}`); } } // Try Claude Code if (existsSync(CLAUDE_CODE_PATH)) { try { const fileContent = Bun.file(CLAUDE_CODE_PATH); const credentials: ClaudeCodeCredentials = await fileContent.json() as ClaudeCodeCredentials; const token = credentials.claudeAiOauth?.accessToken; if (token) { return { token, source: 'claude-code' }; } else { errors.push('Claude Code credentials file exists but no access token found'); } } catch (error) { errors.push(`Failed to read Claude Code credentials: ${error instanceof Error ? error.message : 'unknown error'}`); } } // No valid tokens found const errorMsg = [ 'No valid OAuth token found.', '', 'Checked locations:', ` • OpenCode: ${OPENCODE_PATH}`, ` • Claude Code: ${CLAUDE_CODE_PATH}`, ]; if (errors.length > 0) { errorMsg.push('', 'Details:'); errors.forEach(err => errorMsg.push(` • ${err}`)); } throw new Error(errorMsg.join('\n')); } /** * Get human-readable status message from HTTP status code */ function getStatusMessage(status: number): string { const statusMessages: Record = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 429: 'Rate Limited', 500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable', }; return statusMessages[status] || 'Unknown Error'; } /** * Format API error with enhanced details */ function formatApiError(status: number, url: string, accessToken: string, responseText: string, tokenSource?: string): string { const statusMsg = getStatusMessage(status); const tokenPreview = `${accessToken.substring(0, 8)}...${accessToken.substring(accessToken.length - 4)}`; let errorDetails = ''; try { const errorJson = JSON.parse(responseText); // Extract key fields for compact display const errorType = errorJson.error?.type || errorJson.type || 'unknown'; const errorMessage = errorJson.error?.message || errorJson.message || 'No message'; errorDetails = chalk.hex('#E89999')(` ${chalk.bold('Type:')} ${errorType}\n`); errorDetails += chalk.hex('#F4B8A4')(` ${chalk.bold('Message:')} ${errorMessage}`); } catch { // If not JSON, show raw text (truncated) const truncated = responseText.length > 150 ? responseText.substring(0, 150) + '...' : responseText; errorDetails = chalk.hex('#F4B8A4')(` ${truncated}`); } const lines = [ chalk.hex('#E89999')(`${chalk.bold('API Error:')} ${status} ${statusMsg}`), chalk.hex('#9CA3AF')(` ${chalk.bold('URL:')} ${url}`), chalk.hex('#9CA3AF')(` ${chalk.bold('Token:')} ${tokenPreview}${tokenSource ? ` (from ${tokenSource})` : ''}`), errorDetails ]; return lines.join('\n'); } /** * Fetch usage data by making an API request and reading rate limit headers * This request matches what Claude Code sends for checking limit */ async function fetchUsage(accessToken: string, tokenSource?: string): Promise { const userId = generateUserId(accessToken); const apiUrl = 'https://api.anthropic.com/v1/messages?beta=true'; const requestBody = { model: 'claude-haiku-4-5-20251001', max_tokens: 1, messages: [ { role: 'user', content: 'limit', }, ], metadata: { user_id: userId, }, }; const requestHeaders = { 'Accept': 'application/json', 'Authorization': `Bearer ${accessToken.substring(0, 20)}...`, 'anthropic-version': '2023-06-01', 'anthropic-beta': 'oauth-2025-04-20,interleaved-thinking-2025-05-14', 'anthropic-dangerous-direct-browser-access': 'true', 'x-app': 'cli', 'User-Agent': 'claude-cli/2.0.76 (external, sdk-cli)', 'Content-Type': 'application/json', 'accept-language': '*', 'sec-fetch-mode': 'cors', 'accept-encoding': 'br, gzip, deflate', }; if (VERBOSE) { console.log(chalk.hex('#6B7280')('\n=== Debug: API Request ===')); console.log(chalk.hex('#9CA3AF')('URL:'), apiUrl); console.log(chalk.hex('#9CA3AF')('Method:'), 'POST'); console.log(chalk.hex('#9CA3AF')('Headers:'), JSON.stringify(requestHeaders, null, 2)); console.log(chalk.hex('#9CA3AF')('Body:'), JSON.stringify(requestBody, null, 2)); console.log(); } // Make the exact same request Claude Code makes for checking limit const response = await fetch(apiUrl, { method: 'POST', headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${accessToken}`, 'anthropic-version': '2023-06-01', 'anthropic-beta': 'oauth-2025-04-20,interleaved-thinking-2025-05-14', 'anthropic-dangerous-direct-browser-access': 'true', 'x-app': 'cli', 'User-Agent': 'claude-cli/2.0.76 (external, sdk-cli)', 'Content-Type': 'application/json', 'accept-language': '*', 'sec-fetch-mode': 'cors', 'accept-encoding': 'br, gzip, deflate', }, body: JSON.stringify(requestBody), }); if (!response.ok) { const text = await response.text(); throw new Error(formatApiError(response.status, apiUrl, accessToken, text, tokenSource)); } // Extract rate limit headers from response const headers = response.headers; const fiveHourUtil = parseFloat(headers.get('anthropic-ratelimit-unified-5h-utilization') || '0'); const sevenDayUtil = parseFloat(headers.get('anthropic-ratelimit-unified-7d-utilization') || '0'); const fiveHourReset = parseInt(headers.get('anthropic-ratelimit-unified-5h-reset') || '0'); const sevenDayReset = parseInt(headers.get('anthropic-ratelimit-unified-7d-reset') || '0'); if (VERBOSE) { console.log(chalk.hex('#6B7280')('=== Debug: Response Headers ===')); console.log(chalk.hex('#9CA3AF')('Status:'), response.status, response.statusText); // Show all response headers const allHeaders: Record = {}; headers.forEach((value, key) => { allHeaders[key] = value; }); console.log(chalk.hex('#9CA3AF')('All Headers:'), JSON.stringify(allHeaders, null, 2)); console.log(chalk.hex('#6B7280')('\n=== Debug: Parsed Values ===')); console.log(chalk.hex('#9CA3AF')('5h utilization:'), fiveHourUtil); console.log(chalk.hex('#9CA3AF')('5h reset:'), fiveHourReset, `(${new Date(fiveHourReset * 1000).toLocaleString()})`); console.log(chalk.hex('#9CA3AF')('7d utilization:'), sevenDayUtil); console.log(chalk.hex('#9CA3AF')('7d reset:'), sevenDayReset, `(${new Date(sevenDayReset * 1000).toLocaleString()})`); console.log(); } return { five_hour: { utilization: fiveHourUtil, reset: fiveHourReset, }, seven_day: { utilization: sevenDayUtil, reset: sevenDayReset, }, }; } /** * Get pastel color for ahead/under pace difference */ function getDiffColor(diffPct: number): string { if (diffPct > 15) return '#E89999'; // Soft red - way ahead if (diffPct > 5) return '#F4B8A4'; // Soft orange - ahead if (diffPct >= -5) return '#E8D4A2'; // Soft yellow - on track if (diffPct >= -15) return '#B8D99A'; // Soft lime - below (good) return '#A5D8DD'; // Soft cyan - well under limit } /** * Get pastel color for reset time based on urgency */ function getResetColor(resetTimestamp: number): string { const now = Date.now() / 1000; const hoursRemaining = (resetTimestamp - now) / 3600; if (hoursRemaining < 1) return '#E89999'; // Soft red - < 1 hour if (hoursRemaining < 3) return '#F4B8A4'; // Soft orange - < 3 hours if (hoursRemaining < 12) return '#E8D4A2'; // Soft yellow - < 12 hours if (hoursRemaining < 48) return '#B8D99A'; // Soft lime - < 2 days return '#9DCCB4'; // Soft teal - plenty of time } /** * Get status message based on pace difference */ function getPaceStatus(diffPct: number): string { if (diffPct > 15) return 'high usage rate'; if (diffPct > 5) return 'slightly elevated'; if (diffPct >= -5) return 'on track'; if (diffPct >= -15) return 'comfortable margin'; return 'well under limit'; } /** * Calculate pace for 5-hour period (linear usage) */ function calculate5HourPace(utilization: number, resetTimestamp: number): PaceResult { const now = Date.now() / 1000; const secondsRemaining = resetTimestamp - now; const totalSeconds = 5 * 3600; const secondsElapsed = totalSeconds - secondsRemaining; const expectedUtil = secondsElapsed / totalSeconds; const diff = (utilization - expectedUtil) * 100; return { diff, status: getPaceStatus(diff) }; } /** * Calculate elapsed active hours for 7-day period * Active window: 10am-2am (14 hours per day) * 2am is the LAST active hour (2:00-2:59am is active) */ function calculateElapsedActiveHours(now: Date, reset: Date): number { const totalPeriodSeconds = 7 * 24 * 3600; const remainingSeconds = (reset.getTime() - now.getTime()) / 1000; const elapsedSeconds = totalPeriodSeconds - remainingSeconds; const completeDays = Math.floor(elapsedSeconds / (24 * 3600)); const currentHour = now.getHours(); const currentMinute = now.getMinutes(); let activeHoursToday = 0; if (currentHour >= 10 && currentHour <= 23) { activeHoursToday = (currentHour - 10) + (currentMinute / 60); } else if (currentHour >= 0 && currentHour <= 2) { const hoursAfterMidnight = currentHour + (currentMinute / 60); activeHoursToday = 14 + hoursAfterMidnight; } const totalElapsedActiveHours = (completeDays * 14) + Math.min(activeHoursToday, 14); return totalElapsedActiveHours; } /** * Calculate pace for 7-day period (accounting for active hours) */ function calculate7DayPace(utilization: number, resetTimestamp: number): PaceResult { const now = new Date(); const resetDate = new Date(resetTimestamp * 1000); const totalActiveHours = 7 * 14; const elapsedActiveHours = calculateElapsedActiveHours(now, resetDate); const expectedUtil = elapsedActiveHours / totalActiveHours; const diff = (utilization - expectedUtil) * 100; return { diff, status: getPaceStatus(diff) }; } /** * Format reset time from Unix timestamp with relative time and contextual absolute time */ function formatResetTime(resetTimestamp: number): string { if (!resetTimestamp) return ''; const resetDate = new Date(resetTimestamp * 1000); // Convert to milliseconds const now = new Date(); const diffMs = resetDate.getTime() - now.getTime(); if (diffMs < 0) return 'reset overdue'; const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); // Format time in local timezone const timeStr = resetDate.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); // Calculate day difference const nowDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const resetDay = new Date(resetDate.getFullYear(), resetDate.getMonth(), resetDate.getDate()); const dayDiff = Math.round((resetDay.getTime() - nowDay.getTime()) / (1000 * 60 * 60 * 24)); let dateContext: string; if (dayDiff === 0) { dateContext = `${timeStr} today`; } else if (dayDiff === 1) { dateContext = `${timeStr} tomorrow`; } else if (dayDiff < 7) { // Day of week (abbreviated) const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const dayName = dayNames[resetDate.getDay()]; dateContext = `${dayName} ${timeStr}`; } else { // Check if it's exactly 7 days (same day of week) const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const dayName = dayNames[resetDate.getDay()]; if (resetDate.getDay() === now.getDay()) { dateContext = `next ${dayName} ${timeStr}`; } else { dateContext = `${dayName} ${timeStr}`; } } let relativeTime: string; if (diffHours < 24) { relativeTime = `${diffHours}h ${diffMins}m`; } else { const diffDays = Math.floor(diffHours / 24); const remainingHours = diffHours % 24; relativeTime = `${diffDays}d ${remainingHours}h`; } return `resets in ${relativeTime} (${dateContext})`; } /** * Format and display usage output with pastel colors and condensed layout */ function formatOutput(usage: UsageData): void { const fiveHourPct = usage.five_hour.utilization * 100; const sevenDayPct = usage.seven_day.utilization * 100; const fiveHourColor = getColorForPercentage(fiveHourPct); const sevenDayColor = getColorForPercentage(sevenDayPct); const fiveHourReset = formatResetTime(usage.five_hour.reset); const sevenDayReset = formatResetTime(usage.seven_day.reset); const fiveHourResetColor = getResetColor(usage.five_hour.reset); const sevenDayResetColor = getResetColor(usage.seven_day.reset); const fiveHourPace = calculate5HourPace(usage.five_hour.utilization, usage.five_hour.reset); const sevenDayPace = calculate7DayPace(usage.seven_day.utilization, usage.seven_day.reset); const fiveHourDiffColor = getDiffColor(fiveHourPace.diff); const sevenDayDiffColor = getDiffColor(sevenDayPace.diff); const fiveHourPctStr = fiveHourPct.toFixed(2) + '%'; const sevenDayPctStr = sevenDayPct.toFixed(2) + '%'; const fiveHourDiffStr = (fiveHourPace.diff >= 0 ? '+' : '') + fiveHourPace.diff.toFixed(2) + '%'; const sevenDayDiffStr = (sevenDayPace.diff >= 0 ? '+' : '') + sevenDayPace.diff.toFixed(2) + '%'; // Pastel color palette const headerColor = chalk.hex('#9CA3AF'); // Gray 400 - header const labelColor = chalk.hex('#6B7280'); // Gray 500 - period labels const bulletColor = chalk.hex('#9CA3AF'); // Gray 400 - bullets console.log(headerColor('Usage:')); console.log(` ${labelColor('Hourly (5h)')} ${bulletColor('·')} ${chalk.hex(fiveHourColor)(fiveHourPctStr)} ${bulletColor('•')} ${chalk.hex(fiveHourDiffColor)(fiveHourPace.status)} ${chalk.hex(fiveHourDiffColor)(`(${fiveHourDiffStr} ${fiveHourPace.diff >= 0 ? 'ahead' : 'under'})`)} ${bulletColor('•')} ${chalk.hex(fiveHourResetColor)(fiveHourReset)}`); console.log(` ${labelColor('Weekly (7d)')} ${bulletColor('·')} ${chalk.hex(sevenDayColor)(sevenDayPctStr)} ${bulletColor('•')} ${chalk.hex(sevenDayDiffColor)(sevenDayPace.status)} ${chalk.hex(sevenDayDiffColor)(`(${sevenDayDiffStr} ${sevenDayPace.diff >= 0 ? 'ahead' : 'below'})`)} ${bulletColor('•')} ${chalk.hex(sevenDayResetColor)(sevenDayReset)}`); } /** * Main entry point */ async function main() { const spinner = new Spinner(); try { if (VERBOSE) { console.log(chalk.hex('#6B7280')('=== Debug: Configuration ===')); console.log(chalk.hex('#9CA3AF')('Claude Code path:'), CLAUDE_CODE_PATH); console.log(chalk.hex('#9CA3AF')('OpenCode path:'), OPENCODE_PATH); console.log(chalk.hex('#9CA3AF')('Verbose mode:'), VERBOSE); console.log(); } const tokenResult = await readToken(); if (VERBOSE) { console.log(chalk.hex('#6B7280')('=== Debug: Authentication ===')); console.log(chalk.hex('#9CA3AF')('Token source:'), tokenResult.source); console.log(chalk.hex('#9CA3AF')('Token loaded:'), `${tokenResult.token.substring(0, 20)}...${tokenResult.token.substring(tokenResult.token.length - 10)}`); console.log(chalk.hex('#9CA3AF')('Token length:'), tokenResult.token.length); if (tokenResult.expiresAt) { const expiresDate = new Date(tokenResult.expiresAt); const isExpired = tokenResult.expiresAt < Date.now(); console.log(chalk.hex('#9CA3AF')('Token expires:'), expiresDate.toLocaleString(), isExpired ? chalk.hex('#E89999')('(EXPIRED)') : chalk.hex('#9DCCB4')('(valid)')); } console.log(); } if (!VERBOSE) { spinner.start('Fetching usage data...'); } const usage = await fetchUsage(tokenResult.token, tokenResult.source); if (!VERBOSE) { spinner.stop(); } formatOutput(usage); } catch (error) { spinner.stop(); if (error instanceof Error) { console.error(error.message); } else { console.error('Error: Unknown error occurred'); } process.exit(1); } } main();