mirror of
https://github.com/Xevion/dotfiles.git
synced 2026-01-31 14:24:09 -06:00
- Add rainbow gradient spinner and pastel color scheme for better readability - Calculate usage pace against expected linear/active-hours patterns - Show reset times with relative + contextual absolute formatting - Add --verbose flag for debugging API requests/responses
515 lines
17 KiB
Plaintext
515 lines
17 KiB
Plaintext
#!/usr/bin/env -S bun --install=fallback
|
|
|
|
/**
|
|
* 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';
|
|
import chalk from 'chalk';
|
|
|
|
// Configuration
|
|
const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.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 Credentials {
|
|
claudeAiOauth?: {
|
|
accessToken?: string;
|
|
};
|
|
}
|
|
|
|
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 file
|
|
*/
|
|
async function readToken(): Promise<string> {
|
|
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<UsageData> {
|
|
const userId = generateUserId(accessToken);
|
|
|
|
const requestBody = {
|
|
model: 'claude-haiku-4-5-20251001',
|
|
max_tokens: 1,
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: 'quota',
|
|
},
|
|
],
|
|
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:'), 'https://api.anthropic.com/v1/messages?beta=true');
|
|
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 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(requestBody),
|
|
});
|
|
|
|
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');
|
|
|
|
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<string, string> = {};
|
|
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/behind 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 - behind (good)
|
|
return '#A5D8DD'; // Soft cyan - well under
|
|
}
|
|
|
|
/**
|
|
* 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 quota';
|
|
}
|
|
|
|
/**
|
|
* 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' : 'behind'})`)} ${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' : 'behind'})`)} ${bulletColor('•')} ${chalk.hex(sevenDayResetColor)(sevenDayReset)}`);
|
|
}
|
|
|
|
/**
|
|
* Main entry point
|
|
*/
|
|
async function main() {
|
|
try {
|
|
const spinner = new Spinner();
|
|
|
|
if (VERBOSE) {
|
|
console.log(chalk.hex('#6B7280')('=== Debug: Configuration ==='));
|
|
console.log(chalk.hex('#9CA3AF')('Credentials path:'), CREDENTIALS_PATH);
|
|
console.log(chalk.hex('#9CA3AF')('Verbose mode:'), VERBOSE);
|
|
console.log();
|
|
}
|
|
|
|
const accessToken = await readToken();
|
|
|
|
if (VERBOSE) {
|
|
console.log(chalk.hex('#6B7280')('=== Debug: Authentication ==='));
|
|
console.log(chalk.hex('#9CA3AF')('Token loaded:'), `${accessToken.substring(0, 20)}...${accessToken.substring(accessToken.length - 10)}`);
|
|
console.log(chalk.hex('#9CA3AF')('Token length:'), accessToken.length);
|
|
console.log();
|
|
}
|
|
|
|
if (!VERBOSE) {
|
|
spinner.start('Fetching usage data...');
|
|
}
|
|
const usage = await fetchUsage(accessToken);
|
|
if (!VERBOSE) {
|
|
spinner.stop();
|
|
}
|
|
|
|
formatOutput(usage);
|
|
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
console.error(`Error: ${error.message}`);
|
|
} else {
|
|
console.error('Error: Unknown error occurred');
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|