diff --git a/home/dot_config/fish/functions/ccu.fish b/home/dot_config/fish/functions/ccu.fish new file mode 100644 index 0000000..b94d62b --- /dev/null +++ b/home/dot_config/fish/functions/ccu.fish @@ -0,0 +1,3 @@ +function ccu --description 'Check Claude usage (alias for claude-usage)' + claude-usage $argv +end diff --git a/home/dot_config/fish/functions/cu.fish b/home/dot_config/fish/functions/cu.fish new file mode 100644 index 0000000..325be81 --- /dev/null +++ b/home/dot_config/fish/functions/cu.fish @@ -0,0 +1,3 @@ +function cu --description 'Check Claude usage (alias for claude-usage)' + claude-usage $argv +end diff --git a/home/dot_config/fish/functions/usage.fish b/home/dot_config/fish/functions/usage.fish new file mode 100644 index 0000000..8dc4584 --- /dev/null +++ b/home/dot_config/fish/functions/usage.fish @@ -0,0 +1,3 @@ +function usage --description 'Check Claude usage (alias for claude-usage)' + claude-usage $argv +end diff --git a/home/dot_local/bin/executable_claude-usage b/home/dot_local/bin/executable_claude-usage new file mode 100644 index 0000000..2e28124 --- /dev/null +++ b/home/dot_local/bin/executable_claude-usage @@ -0,0 +1,188 @@ +#!/usr/bin/env bun + +/** + * claude-usage - Fast CLI tool to fetch Anthropic Claude usage percentages + * + * Requirements: + * - Bun runtime + * - ~/.claude/.credentials.json with valid OAuth token + * + * Usage: + * claude-usage + * cu # alias + * usage # alias + * ccu # alias (claude-check-usage) + */ + +import { existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +// Configuration +const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json'); + +interface UsagePeriod { + utilization: number; + reset: number; +} + +interface UsageData { + five_hour: UsagePeriod; + seven_day: UsagePeriod; +} + +interface Credentials { + claudeAiOauth?: { + accessToken?: string; + }; +} + +/** + * 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}`; +} + +/** + * Read OAuth token from credentials file + */ +async function readToken(): Promise { + if (!existsSync(CREDENTIALS_PATH)) { + throw new Error(`Credentials file not found: ${CREDENTIALS_PATH}\nPlease run 'claude setup-token' first.`); + } + + const fileContent = Bun.file(CREDENTIALS_PATH); + const credentials: Credentials = await fileContent.json() as Credentials; + + const token = credentials.claudeAiOauth?.accessToken; + if (!token) { + throw new Error('No access token found in credentials file. Please run \'claude setup-token\' to authenticate.'); + } + + return token; +} + +/** + * Fetch usage data by making an API request and reading rate limit headers + * This request matches what Claude Code sends for checking quota + */ +async function fetchUsage(accessToken: string): Promise { + const userId = generateUserId(accessToken); + + // Make the exact same request Claude Code makes for checking quota + const response = await fetch('https://api.anthropic.com/v1/messages?beta=true', { + 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({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 1, + messages: [ + { + role: 'user', + content: 'quota', + }, + ], + metadata: { + user_id: userId, + }, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`API request failed (${response.status}): ${text.substring(0, 200)}`); + } + + // 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'); + + return { + five_hour: { + utilization: fiveHourUtil, + reset: fiveHourReset, + }, + seven_day: { + utilization: sevenDayUtil, + reset: sevenDayReset, + }, + }; +} + +/** + * Format reset time from Unix timestamp + */ +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)); + + if (diffHours < 24) { + return `(resets in ${diffHours}h ${diffMins}m)`; + } + + const diffDays = Math.floor(diffHours / 24); + const remainingHours = diffHours % 24; + return `(resets in ${diffDays}d ${remainingHours}h)`; +} + +/** + * Main entry point + */ +async function main() { + try { + // Read OAuth token from credentials file + const accessToken = await readToken(); + + // Fetch usage data from API headers + const usage = await fetchUsage(accessToken); + + // Format and display + const fiveHourPct = Math.round(usage.five_hour.utilization * 100); + const sevenDayPct = Math.round(usage.seven_day.utilization * 100); + + const fiveHourReset = formatResetTime(usage.five_hour.reset); + const sevenDayReset = formatResetTime(usage.seven_day.reset); + + console.log(`5h: ${fiveHourPct}% ${fiveHourReset} | 7d: ${sevenDayPct}% ${sevenDayReset}`); + + } catch (error) { + if (error instanceof Error) { + console.error(`Error: ${error.message}`); + } else { + console.error('Error: Unknown error occurred'); + } + process.exit(1); + } +} + +main();