Files
dotfiles/home/dot_local/bin/executable_claude-usage
Xevion dbe4804bc5 refactor: fix active hours calculation with monotonic cycle model
Previous implementation had discontinuities at day boundaries. New
cycle-based approach (2 AM sleep start) provides continuous, testable
active hour tracking.
2026-01-30 17:41:07 -06:00

667 lines
23 KiB
Plaintext

#!/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');
// Active hours model: 9 AM to 2 AM active, 2 AM to 9 AM sleep
export const SLEEP_START_HOUR = 2; // Sleep begins at 2:00 AM
export const WAKE_HOUR = 9; // Active period begins at 9:00 AM
export const SLEEP_HOURS_PER_DAY = 7; // 2 AM to 9 AM
export const ACTIVE_HOURS_PER_DAY = 17; // 9 AM to 2 AM
// 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<TokenResult> {
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<number, string> = {
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<UsageData> {
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<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/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)
};
}
/**
* Compute the offset (in fractional hours) into the current day-cycle.
* A "cycle" runs from SLEEP_START_HOUR (2 AM) to the next SLEEP_START_HOUR.
* Offset 0 = exactly 2:00 AM, offset 7 = 9:00 AM, offset 24 = next 2:00 AM.
*/
export function offsetInCycle(t: Date): number {
const hours = t.getHours() + t.getMinutes() / 60 + t.getSeconds() / 3600;
if (hours >= SLEEP_START_HOUR) return hours - SLEEP_START_HOUR;
return hours + (24 - SLEEP_START_HOUR);
}
/**
* Compute the start of the current day-cycle (the most recent 2:00 AM).
*/
export function getCycleStart(t: Date): Date {
const result = new Date(t);
if (t.getHours() >= SLEEP_START_HOUR) {
result.setHours(SLEEP_START_HOUR, 0, 0, 0);
} else {
result.setDate(result.getDate() - 1);
result.setHours(SLEEP_START_HOUR, 0, 0, 0);
}
return result;
}
/**
* Compute active hours within a partial cycle given the offset from cycle start.
* Sleep occupies offsets [0, SLEEP_HOURS_PER_DAY), active occupies [SLEEP_HOURS_PER_DAY, 24).
* Returns a value in [0, ACTIVE_HOURS_PER_DAY].
*/
export function activeHoursInPartialCycle(offsetHours: number): number {
if (offsetHours <= SLEEP_HOURS_PER_DAY) return 0;
return Math.min(offsetHours - SLEEP_HOURS_PER_DAY, ACTIVE_HOURS_PER_DAY);
}
/**
* Compute total elapsed active hours between two timestamps.
* Uses a cycle-based model (2 AM to 2 AM) that is continuous and monotonically
* increasing. During sleep hours (2 AM - 9 AM), the value is flat.
* During active hours (9 AM - 2 AM), it increases linearly.
*/
export function elapsedActiveHoursBetween(start: Date, end: Date): number {
const startCycleStart = getCycleStart(start);
const endCycleStart = getCycleStart(end);
const completeCycles = Math.round(
(endCycleStart.getTime() - startCycleStart.getTime()) / 86_400_000
);
const startActiveInCycle = activeHoursInPartialCycle(offsetInCycle(start));
const endActiveInCycle = activeHoursInPartialCycle(offsetInCycle(end));
return completeCycles * ACTIVE_HOURS_PER_DAY + endActiveInCycle - startActiveInCycle;
}
/**
* Calculate pace for 7-day period (accounting for active hours).
* Uses cycle-based model: 9 AM to 2 AM active, 2 AM to 9 AM sleep.
*/
function calculate7DayPace(utilization: number, resetTimestamp: number): PaceResult {
const now = new Date();
const resetDate = new Date(resetTimestamp * 1000);
const periodStart = new Date(resetDate.getTime() - 7 * 24 * 3600 * 1000);
const totalActiveHours = 7 * ACTIVE_HOURS_PER_DAY;
const elapsed = elapsedActiveHoursBetween(periodStart, now);
const expectedUtil = elapsed / 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();