diff --git a/package-lock.json b/package-lock.json index 4001b6d..e01e8f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-usage", - "version": "1.0.0", + "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-usage", - "version": "1.0.0", + "version": "1.0.8", "license": "MIT", "devDependencies": { "@types/glob": "^9.0.0", diff --git a/package.json b/package.json index d11d246..172ba34 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,11 @@ "minimum": 0, "maximum": 4, "description": "Number of decimal places for cost display" + }, + "claudeCodeUsage.usageLimitTracking": { + "type": "boolean", + "default": true, + "description": "Enable usage limit tracking (requires Claude Code authentication via 'claude auth login')" } } }, diff --git a/src/claudeApiClient.ts b/src/claudeApiClient.ts new file mode 100644 index 0000000..9ffaf8a --- /dev/null +++ b/src/claudeApiClient.ts @@ -0,0 +1,199 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ClaudeCredentials, ClaudeApiUsageResponse } from './types'; + +export class ClaudeApiClient { + private credentialsPath: string; + private credentials: ClaudeCredentials | null = null; + private rateLimitedUntil: number = 0; + + constructor() { + const homeDir = os.homedir(); + this.credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); + } + + /** + * Load credentials from disk + */ + private async loadCredentials(): Promise { + try { + if (!fs.existsSync(this.credentialsPath)) { + console.warn('[ClaudeAPI] Credentials file not found:', this.credentialsPath); + return null; + } + + const content = await fs.promises.readFile(this.credentialsPath, 'utf-8'); + this.credentials = JSON.parse(content); + return this.credentials; + } catch (error) { + console.error('[ClaudeAPI] Failed to load credentials:', error); + return null; + } + } + + /** + * Save updated credentials to disk + */ + private async saveCredentials(credentials: ClaudeCredentials): Promise { + try { + await fs.promises.writeFile( + this.credentialsPath, + JSON.stringify(credentials), + 'utf-8' + ); + this.credentials = credentials; + } catch (error) { + console.error('[ClaudeAPI] Failed to save credentials:', error); + throw error; + } + } + + /** + * Check if access token is expired or about to expire (within 60 seconds) + */ + private isTokenExpired(credentials: ClaudeCredentials): boolean { + const now = Date.now(); + const expiresAt = credentials.claudeAiOauth.expiresAt; + const bufferTime = 60 * 1000; // 60 seconds buffer + return now >= (expiresAt - bufferTime); + } + + /** + * Refresh the access token using the refresh token + * Uses console.anthropic.com endpoint (same as Claude Code CLI) + */ + private async refreshAccessToken(credentials: ClaudeCredentials): Promise { + console.log('[ClaudeAPI] Refreshing access token...'); + const response = await fetch('https://console.anthropic.com/v1/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + refresh_token: credentials.claudeAiOauth.refreshToken, + grant_type: 'refresh_token', + }), + }); + + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { access_token: string; expires_in: number }; + + const updatedCredentials: ClaudeCredentials = { + ...credentials, + claudeAiOauth: { + ...credentials.claudeAiOauth, + accessToken: data.access_token, + expiresAt: Date.now() + (data.expires_in * 1000), + }, + }; + + await this.saveCredentials(updatedCredentials); + console.log('[ClaudeAPI] Token refreshed successfully'); + return updatedCredentials; + } + + /** + * Get valid credentials, refreshing if necessary + */ + private async getValidCredentials(): Promise { + let credentials = this.credentials || await this.loadCredentials(); + + if (!credentials) { + return null; + } + + if (this.isTokenExpired(credentials)) { + console.log('[ClaudeAPI] Access token expired, refreshing...'); + try { + credentials = await this.refreshAccessToken(credentials); + } catch (error) { + console.error('[ClaudeAPI] Failed to refresh token:', error); + return null; + } + } + + return credentials; + } + + /** + * Fetch usage limits from the Anthropic API + * Uses api.anthropic.com/api/oauth/usage (NOT claude.ai which has Cloudflare) + */ + private async callUsageApi(accessToken: string): Promise { + return fetch('https://api.anthropic.com/api/oauth/usage', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'anthropic-beta': 'oauth-2025-04-20', + 'Content-Type': 'application/json', + }, + }); + } + + async fetchUsageLimits(): Promise { + try { + // Respect rate limit backoff + if (Date.now() < this.rateLimitedUntil) { + console.log('[ClaudeAPI] Rate limited, skipping until', new Date(this.rateLimitedUntil).toISOString()); + return null; + } + + const credentials = await this.getValidCredentials(); + if (!credentials) { + console.warn('[ClaudeAPI] No valid credentials available. Run "claude auth login" first.'); + return null; + } + + console.log('[ClaudeAPI] Fetching usage limits from api.anthropic.com...'); + const response = await this.callUsageApi(credentials.claudeAiOauth.accessToken); + console.log('[ClaudeAPI] Response status:', response.status); + + // Handle rate limiting with backoff + if (response.status === 429) { + const retryAfter = response.headers.get('retry-after'); + const backoffMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5 * 60 * 1000; + this.rateLimitedUntil = Date.now() + backoffMs; + console.warn(`[ClaudeAPI] Rate limited, backing off for ${backoffMs / 1000}s`); + return null; + } + + // If 401, try refreshing token and retry once + if (response.status === 401) { + console.log('[ClaudeAPI] Got 401, attempting token refresh and retry...'); + try { + const refreshed = await this.refreshAccessToken(credentials); + const retryResponse = await this.callUsageApi(refreshed.claudeAiOauth.accessToken); + + if (!retryResponse.ok) { + console.error('[ClaudeAPI] Retry failed:', retryResponse.status); + return null; + } + + const data = await retryResponse.json() as ClaudeApiUsageResponse; + console.log('[ClaudeAPI] Usage limits fetched successfully (after retry)'); + return data; + } catch (refreshError) { + console.error('[ClaudeAPI] Token refresh failed:', refreshError); + return null; + } + } + + if (!response.ok) { + const errorText = await response.text(); + console.error('[ClaudeAPI] API error:', response.status, errorText); + return null; + } + + const data = await response.json() as ClaudeApiUsageResponse; + console.log('[ClaudeAPI] Usage limits fetched successfully:', JSON.stringify(data, null, 2)); + return data; + } catch (error) { + console.error('[ClaudeAPI] Failed to fetch usage limits:', error); + return null; + } + } +} diff --git a/src/dataLoader.ts b/src/dataLoader.ts index 3723417..ca93636 100644 --- a/src/dataLoader.ts +++ b/src/dataLoader.ts @@ -138,16 +138,20 @@ export class ClaudeDataLoader { static async loadUsageRecords(dataDirectory?: string): Promise { try { const claudePaths = dataDirectory ? [dataDirectory] : this.getClaudePaths(); + console.log('[DataLoader] Loading records from paths:', claudePaths); const allFiles: string[] = []; for (const claudePath of claudePaths) { const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME); + console.log('[DataLoader] Checking directory:', claudeDir, 'exists:', fs.existsSync(claudeDir)); if (fs.existsSync(claudeDir)) { const files = await findJsonlFiles(claudeDir); + console.log('[DataLoader] Found JSONL files:', files.length); allFiles.push(...files); } } + console.log('[DataLoader] Total files found:', allFiles.length); const sortedFiles = await this.sortFilesByTimestamp(allFiles); const processedHashes = new Set(); const records: ClaudeUsageRecord[] = []; @@ -159,12 +163,14 @@ export class ClaudeDataLoader { .trim() .split('\n') .filter((line) => line.trim() !== ''); + console.log('[DataLoader] Processing file:', file, 'lines:', lines.length); for (const line of lines) { try { const parsed = JSON.parse(line) as unknown; if (!validateUsageRecord(parsed)) { + console.log('[DataLoader] Record validation failed for line in', file); continue; } @@ -181,17 +187,18 @@ export class ClaudeDataLoader { records.push(data as ClaudeUsageRecord); } catch (parseError) { - console.warn(`Failed to parse line in ${file}:`, parseError); + console.warn('[DataLoader] Failed to parse line in', file, ':', parseError); } } } catch (fileError) { - console.warn(`Failed to read file ${file}:`, fileError); + console.warn('[DataLoader] Failed to read file', file, ':', fileError); } } + console.log('[DataLoader] Loaded total records:', records.length); return records; } catch (error) { - console.error('Error loading usage records:', error); + console.error('[DataLoader] Error loading usage records:', error); return []; } } diff --git a/src/extension.ts b/src/extension.ts index c11ff25..1e05124 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,27 +3,34 @@ import { ClaudeDataLoader } from './dataLoader'; import { StatusBarManager } from './statusBar'; import { UsageWebviewProvider } from './webview'; import { I18n } from './i18n'; -import { ExtensionConfig, UsageData, SessionData } from './types'; +import { ClaudeApiClient } from './claudeApiClient'; +import { ExtensionConfig, UsageData, SessionData, ClaudeApiUsageResponse } from './types'; export class ClaudeCodeUsageExtension { private statusBar: StatusBarManager; private webviewProvider: UsageWebviewProvider; + private apiClient: ClaudeApiClient; private refreshTimer: NodeJS.Timeout | undefined; private cache: { records: any[]; lastUpdate: Date; dataDirectory: string | null; + usageLimits: ClaudeApiUsageResponse | null; + usageLimitsLastUpdate: Date; } = { records: [], lastUpdate: new Date(0), - dataDirectory: null + dataDirectory: null, + usageLimits: null, + usageLimitsLastUpdate: new Date(0) }; constructor(private context: vscode.ExtensionContext) { console.log('Claude Code Usage Extension: Constructor called'); this.statusBar = new StatusBarManager(); this.webviewProvider = new UsageWebviewProvider(context); - + this.apiClient = new ClaudeApiClient(); + this.setupCommands(); this.loadConfiguration(); this.startAutoRefresh(); @@ -103,18 +110,23 @@ export class ClaudeCodeUsageExtension { private async refreshData(): Promise { try { + console.log('[Extension] refreshData started'); this.statusBar.setLoading(true); this.webviewProvider.setLoading(true); const config = this.getConfiguration(); - + console.log('[Extension] Config:', { refreshInterval: config.refreshInterval, dataDirectory: config.dataDirectory }); + // Find Claude data directory + console.log('[Extension] Finding Claude data directory...'); const dataDirectory = await ClaudeDataLoader.findClaudeDataDirectory( config.dataDirectory || undefined ); + console.log('[Extension] Data directory found:', dataDirectory); if (!dataDirectory) { const error = 'Claude data directory not found. Please check your configuration.'; + console.error('[Extension]', error); this.statusBar.updateUsageData(null, error); this.webviewProvider.updateData(null, null, null, null, [], [], [], error, null); return; @@ -122,21 +134,40 @@ export class ClaudeCodeUsageExtension { // Check if we need to reload data const shouldReload = this.shouldReloadData(dataDirectory); - + console.log('[Extension] Should reload data:', shouldReload); + let records = this.cache.records; if (shouldReload) { + console.log('[Extension] Loading usage records...'); records = await ClaudeDataLoader.loadUsageRecords(dataDirectory); + console.log('[Extension] Loaded records:', records.length); this.cache.records = records; this.cache.lastUpdate = new Date(); this.cache.dataDirectory = dataDirectory; + } else { + console.log('[Extension] Using cached records:', records.length); + } + + // Fetch usage limits from API (cache for 2 minutes) + const shouldReloadUsageLimits = Date.now() - this.cache.usageLimitsLastUpdate.getTime() > 120000; + let usageLimits = this.cache.usageLimits; + if (shouldReloadUsageLimits) { + const fetchedLimits = await this.apiClient.fetchUsageLimits(); + if (fetchedLimits) { + usageLimits = fetchedLimits; + this.cache.usageLimits = usageLimits; + this.cache.usageLimitsLastUpdate = new Date(); + } } if (records.length === 0) { const error = 'No usage records found. Make sure Claude Code is running.'; - this.statusBar.updateUsageData(null, error); - this.webviewProvider.updateData(null, null, null, null, [], [], [], error, dataDirectory); + console.warn('[Extension]', error); + this.statusBar.updateUsageData(null, error, usageLimits); + this.webviewProvider.updateData(null, null, null, null, [], [], [], error, dataDirectory, undefined, usageLimits); return; } + console.log('[Extension] Processing', records.length, 'records'); // Calculate usage data const sessionData = ClaudeDataLoader.getCurrentSessionData(records); @@ -148,13 +179,16 @@ export class ClaudeCodeUsageExtension { const hourlyDataForToday = ClaudeDataLoader.getHourlyDataForToday(records); // Update UI - this.statusBar.updateUsageData(todayData); - this.webviewProvider.updateData(sessionData, todayData, monthData, allTimeData, dailyDataForMonth, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records); + console.log('[Extension] Updating UI with data. Today cost:', todayData?.totalCost || 0); + this.statusBar.updateUsageData(todayData, undefined, usageLimits); + this.webviewProvider.updateData(sessionData, todayData, monthData, allTimeData, dailyDataForMonth, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, usageLimits); + console.log('[Extension] refreshData completed successfully'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - console.error('Error refreshing Claude Code usage data:', error); - + console.error('[Extension] Error refreshing Claude Code usage data:', errorMessage); + console.error('[Extension] Full error:', error); + this.statusBar.updateUsageData(null, errorMessage); this.webviewProvider.updateData(null, null, null, null, [], [], [], errorMessage, null); } diff --git a/src/statusBar.ts b/src/statusBar.ts index 608d476..beb8380 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -1,10 +1,11 @@ import * as vscode from 'vscode'; -import { UsageData } from './types'; +import { UsageData, ClaudeApiUsageResponse } from './types'; import { I18n } from './i18n'; export class StatusBarManager { private statusBarItem: vscode.StatusBarItem; private isLoading: boolean = false; + private usageLimits: ClaudeApiUsageResponse | null = null; constructor() { this.statusBarItem = vscode.window.createStatusBarItem( @@ -13,7 +14,8 @@ export class StatusBarManager { ); this.statusBarItem.command = 'claudeCodeUsage.showDetails'; this.statusBarItem.show(); - this.updateStatusBar(); + // Initialize with loading state + this.setLoading(true); } setLoading(loading: boolean): void { @@ -21,9 +23,13 @@ export class StatusBarManager { this.updateStatusBar(); } - updateUsageData(todayData: UsageData | null, error?: string): void { + updateUsageData(todayData: UsageData | null, error?: string, usageLimits?: ClaudeApiUsageResponse | null): void { this.isLoading = false; - + + if (usageLimits !== undefined) { + this.usageLimits = usageLimits; + } + if (error) { this.showError(error); return; @@ -47,11 +53,33 @@ export class StatusBarManager { private showTodayData(todayData: UsageData): void { const cost = I18n.formatCurrency(todayData.totalCost); - this.statusBarItem.text = `$(pulse) ${cost}`; - + let text = `$(pulse) ${cost}`; + + // Append usage limits if available + if (this.usageLimits?.five_hour || this.usageLimits?.seven_day) { + const parts: string[] = []; + if (this.usageLimits.five_hour) { + parts.push(`5h:${Math.round(this.usageLimits.five_hour.utilization)}% |`); + } + if (this.usageLimits.seven_day) { + parts.push(`7d:${Math.round(this.usageLimits.seven_day.utilization)}%`); + } + text += ` | ${parts.join(' ')}`; + } + + this.statusBarItem.text = text; + const tooltip = this.createTooltip(todayData); this.statusBarItem.tooltip = tooltip; - this.statusBarItem.backgroundColor = undefined; + + // Warn if either limit is high + const fiveHourHigh = this.usageLimits?.five_hour && this.usageLimits.five_hour.utilization >= 80; + const weeklyHigh = this.usageLimits?.seven_day && this.usageLimits.seven_day.utilization >= 80; + if (fiveHourHigh || weeklyHigh) { + this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + } else { + this.statusBarItem.backgroundColor = undefined; + } } private showNoData(): void { @@ -72,11 +100,30 @@ export class StatusBarManager { `${I18n.t.popup.cost}: ${I18n.formatCurrency(todayData.totalCost)}`, `${I18n.t.popup.inputTokens}: ${I18n.formatNumber(todayData.totalInputTokens)}`, `${I18n.t.popup.outputTokens}: ${I18n.formatNumber(todayData.totalOutputTokens)}`, - `${I18n.t.popup.messages}: ${I18n.formatNumber(todayData.messageCount)}`, - '', - 'Click for detailed breakdown' + `${I18n.t.popup.messages}: ${I18n.formatNumber(todayData.messageCount)}` ]; + // Add usage limits if available + if (this.usageLimits) { + lines.push(''); + lines.push('Usage Limits:'); + + if (this.usageLimits.five_hour) { + const resetDate = new Date(this.usageLimits.five_hour.resets_at); + const hoursUntilReset = Math.max(0, (resetDate.getTime() - Date.now()) / (1000 * 60 * 60)); + lines.push(`5-Hour: ${this.usageLimits.five_hour.utilization.toFixed(1)}% (resets in ${hoursUntilReset.toFixed(1)}h)`); + } + + if (this.usageLimits.seven_day) { + const resetDate = new Date(this.usageLimits.seven_day.resets_at); + const daysUntilReset = Math.max(0, (resetDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + lines.push(`Weekly: ${this.usageLimits.seven_day.utilization.toFixed(1)}% (resets in ${daysUntilReset.toFixed(1)}d)`); + } + } + + lines.push(''); + lines.push('Click for detailed breakdown'); + return lines.join('\n'); } diff --git a/src/types.ts b/src/types.ts index eeff010..dc04e58 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,4 +52,29 @@ export interface ModelPricing { cache_read_input_token_cost?: number; } -export type SupportedLanguage = 'en' | "de-DE" | 'zh-TW' | 'zh-CN' | 'ja' | 'ko'; \ No newline at end of file +export type SupportedLanguage = 'en' | "de-DE" | 'zh-TW' | 'zh-CN' | 'ja' | 'ko'; + +// Claude API Usage Limits +export interface UsageLimit { + utilization: number; // percentage (0-100) + resets_at: string; // ISO timestamp +} + +export interface ClaudeApiUsageResponse { + five_hour?: UsageLimit; + seven_day?: UsageLimit; + seven_day_sonnet?: UsageLimit; + seven_day_opus?: UsageLimit; +} + +export interface ClaudeCredentials { + claudeAiOauth: { + accessToken: string; + refreshToken: string; + expiresAt: number; // Unix timestamp in ms + scopes: string[]; + subscriptionType: string | null; + rateLimitTier: string | null; + }; + organizationUuid: string; +} \ No newline at end of file diff --git a/src/webview.ts b/src/webview.ts index c6d9b3e..d615416 100644 --- a/src/webview.ts +++ b/src/webview.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { I18n } from './i18n'; -import { SessionData, UsageData } from './types'; +import { SessionData, UsageData, ClaudeApiUsageResponse } from './types'; export class UsageWebviewProvider { private panel: vscode.WebviewPanel | undefined; @@ -17,6 +17,7 @@ export class UsageWebviewProvider { private currentTab: string = 'today'; private hourlyDataCache: Map = new Map(); private allRecords: any[] = []; + private usageLimits: ClaudeApiUsageResponse | null = null; constructor(private context: vscode.ExtensionContext) {} @@ -96,7 +97,8 @@ export class UsageWebviewProvider { hourlyDataForToday: { hour: string; data: UsageData }[] = [], error?: string, dataDirectory?: string | null, - allRecords?: any[] + allRecords?: any[], + usageLimits?: ClaudeApiUsageResponse | null ): void { this.currentSessionData = sessionData; this.todayData = todayData; @@ -111,6 +113,9 @@ export class UsageWebviewProvider { if (allRecords) { this.allRecords = allRecords; } + if (usageLimits !== undefined) { + this.usageLimits = usageLimits; + } if (this.panel) { this.updateWebview(); @@ -262,6 +267,10 @@ export class UsageWebviewProvider { + ` + + this.renderUsageLimits() + + ` +
'; + + return html; + } + private renderMonthData(): string { if (!this.monthData) { return `

${I18n.t.popup.noDataMessage}

`; @@ -1269,6 +1362,100 @@ export class UsageWebviewProvider { color: var(--vscode-descriptionForeground); padding: 20px; } + + .usage-limits-section { + margin-bottom: 24px; + padding: 16px; + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + } + + .usage-limits-section h2 { + margin: 0 0 16px 0; + font-size: 16px; + color: var(--vscode-foreground); + } + + .usage-limits-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + } + + .limit-card { + padding: 16px; + background: var(--vscode-input-background); + border-radius: 8px; + border: 2px solid var(--vscode-input-border); + } + + .limit-card.warning { + border-color: var(--vscode-editorWarning-foreground); + background: rgba(255, 165, 0, 0.1); + } + + .limit-card.critical { + border-color: var(--vscode-editorError-foreground); + background: rgba(255, 0, 0, 0.1); + } + + .limit-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .limit-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--vscode-foreground); + } + + .limit-percentage { + font-size: 18px; + font-weight: bold; + color: var(--vscode-charts-blue); + } + + .limit-card.warning .limit-percentage { + color: var(--vscode-editorWarning-foreground); + } + + .limit-card.critical .limit-percentage { + color: var(--vscode-editorError-foreground); + } + + .limit-bar { + width: 100%; + height: 8px; + background: var(--vscode-progressBar-background); + border-radius: 4px; + overflow: hidden; + margin-bottom: 8px; + } + + .limit-progress { + height: 100%; + background: var(--vscode-charts-blue); + border-radius: 4px; + transition: width 0.3s ease; + } + + .limit-card.warning .limit-progress { + background: var(--vscode-editorWarning-foreground); + } + + .limit-card.critical .limit-progress { + background: var(--vscode-editorError-foreground); + } + + .limit-footer { + font-size: 12px; + color: var(--vscode-descriptionForeground); + } `; }