UNPKG

ccusage-byobu

Version:

Real-time Claude Code API usage monitoring for byobu/tmux status bars with colored indicators, persistent caching, and automated integration

704 lines (612 loc) 26.1 kB
import { execSync } from 'child_process'; import { getConfig, getPassThroughEnv } from './config.js'; import { cacheCommandResult, getCachedCommandResult, getCacheStats } from './cache.js'; import parseJson from 'json-parse-even-better-errors'; import { getPerformanceTracker } from './performance-tracker.js'; /** * Validates that required commands are available * @returns {Object} Validation results with status and messages */ function validateRequiredCommands() { const results = { success: true, commands: {}, errors: [], }; const requiredCommands = [ { name: 'ccusage', check: 'which ccusage', errorMsg: 'ccusage command not found in PATH.', installMsg: 'Please install ccusage from: https://github.com/anthropics/ccusage', }, { name: 'node', check: 'which node', errorMsg: 'Node.js not found in PATH.', installMsg: 'Please install Node.js from: https://nodejs.org/', }, ]; for (const cmd of requiredCommands) { try { execSync(cmd.check, { stdio: 'ignore' }); results.commands[cmd.name] = { available: true, path: execSync(cmd.check, { encoding: 'utf-8' }).trim(), }; } catch { results.success = false; results.commands[cmd.name] = { available: false }; results.errors.push(`${cmd.errorMsg} ${cmd.installMsg}`); } } return results; } /** * Test installation and command availability * @returns {Promise<void>} */ export async function testInstallation() { console.log('Testing ccusage-byobu installation...\n'); // Test required commands console.log('Checking required commands:'); const validation = validateRequiredCommands(); for (const [cmdName, info] of Object.entries(validation.commands)) { const status = info.available ? '✅' : '❌'; const path = info.available ? ` (${info.path})` : ''; console.log(` ${status} ${cmdName}${path}`); } if (!validation.success) { console.log('\n❌ Installation issues found:'); for (const error of validation.errors) { console.log(` • ${error}`); } return; } console.log('\n✅ All required commands are available'); // Test ccusage execution console.log('\nTesting ccusage execution:'); try { const passThroughEnv = getPassThroughEnv(); const output = execSync('ccusage blocks --json --offline', { encoding: 'utf-8', env: { ...process.env, ...passThroughEnv }, timeout: 10000, // 10 second timeout }); console.log(' ✅ ccusage command executed successfully'); // Test JSON parsing with enhanced error handling try { const data = parseJson(output); console.log(' ✅ JSON output parsed successfully'); console.log(` 📊 Found ${data.blocks?.length || 0} usage blocks`); const activeSession = data.blocks?.find((block) => block.isActive); if (activeSession) { console.log(' ✅ Active session found'); console.log(` • Remaining: ${activeSession.projection?.remainingMinutes || 0} minutes`); console.log(` • Cost: $${activeSession.costUSD || 0}`); } else { console.log(' ℹ️ No active session currently'); } } catch (parseError) { console.log(' ❌ Failed to parse JSON output'); console.log(` Error: ${parseError.message}`); } } catch (execError) { console.log(' ❌ Failed to execute ccusage command'); console.log(` Error: ${execError.message}`); } // Test configuration console.log('\nTesting configuration:'); const config = getConfig(); console.log(` ✅ Configuration loaded`); console.log(` • Format: ${config.format}`); console.log(` • Colors: ${config.colors}`); console.log(` • Threshold: ${config.threshold}%`); console.log(` • Refresh: ${config.refresh}s`); if (config._errors.length > 0) { console.log(' ⚠️ Configuration warnings:'); for (const error of config._errors) { console.log(` • ${error}`); } } // Display cache statistics if available console.log('\nCache Statistics:'); const cacheStats = getCacheStats(); console.log(` ✅ Cache enabled`); console.log(` • Overall hit rate: ${cacheStats.hitRate}`); console.log(` • Total hits: ${cacheStats.hits}`); console.log(` • Total misses: ${cacheStats.misses}`); console.log(` • Size: ${cacheStats.size} entries (${cacheStats.memory.kb} KB)`); // Rolling window statistics if (cacheStats.rollingWindow) { console.log('\n Rolling Window Statistics:'); console.log(` • Window size: ${cacheStats.rollingWindow.windowSize} operations`); console.log(` • Current operations: ${cacheStats.rollingWindow.currentOperations}`); console.log(` • Window hit rate: ${cacheStats.rollingWindow.hitRate}%`); console.log( ` • Window hits/misses: ${cacheStats.rollingWindow.hits}/${cacheStats.rollingWindow.misses}` ); } // Time-based statistics if (cacheStats.timeBased && cacheStats.timeBased.intervals.length > 0) { console.log('\n Time-based Statistics:'); console.log(` • Interval duration: ${cacheStats.timeBased.intervalDuration / 1000}s`); console.log( ` • Period covered: ${(cacheStats.timeBased.aggregate.periodCovered / 1000).toFixed(0)}s` ); console.log(` • Aggregate hit rate: ${cacheStats.timeBased.aggregate.hitRate}`); } // Performance profiling console.log('\nPerformance Profiling:'); try { const { benchmark, logBenchmark, getMemoryUsage, createTimer } = await import( './performance.js' ); // Overall test timing const overallTimer = createTimer().start(); // Memory tracking - capture initial state const initialMemory = getMemoryUsage(); console.log( ` 📊 Initial memory: RSS ${initialMemory.rss.mb}MB, Heap ${initialMemory.heapUsed.mb}MB` ); // Benchmark ccusage execution const ccusageBenchmark = await benchmark( () => execSync('ccusage blocks --json --offline', { encoding: 'utf-8', env: { ...process.env, ...getPassThroughEnv() }, timeout: 10000, }), 'ccusage command execution' ); logBenchmark(ccusageBenchmark); // Benchmark JSON parsing let parseJsonBenchmark = null; if (ccusageBenchmark.success) { parseJsonBenchmark = await benchmark( () => parseJson(ccusageBenchmark.result), 'JSON parsing' ); logBenchmark(parseJsonBenchmark); // Calculate JSON parsing overhead as percentage const totalTime = ccusageBenchmark.duration.milliseconds + parseJsonBenchmark.duration.milliseconds; const parseOverhead = ((parseJsonBenchmark.duration.milliseconds / totalTime) * 100).toFixed( 2 ); console.log(` JSON parsing overhead: ${parseOverhead}% of total operation`); } // Test cache performance console.log('\n Cache Performance Tests:'); // Warm up cache cacheCommandResult('test-key', ccusageBenchmark.result, 15000); // Benchmark cache write const cacheWriteBenchmark = await benchmark( () => cacheCommandResult('perf-test-write', ccusageBenchmark.result, 15000), 'Cache write operation' ); logBenchmark(cacheWriteBenchmark); // Benchmark cache read (hit) const cacheReadHitBenchmark = await benchmark( () => getCachedCommandResult('test-key'), 'Cache read (hit)' ); logBenchmark(cacheReadHitBenchmark); // Benchmark cache read (miss) const cacheReadMissBenchmark = await benchmark( () => getCachedCommandResult('non-existent-key'), 'Cache read (miss)' ); logBenchmark(cacheReadMissBenchmark); // Cache serialization overhead const cacheSerializationBenchmark = await benchmark( () => JSON.stringify(ccusageBenchmark.result), 'Cache serialization' ); logBenchmark(cacheSerializationBenchmark); // Final memory state const finalMemory = getMemoryUsage(); console.log( `\n 📊 Final memory: RSS ${finalMemory.rss.mb}MB, Heap ${finalMemory.heapUsed.mb}MB` ); console.log( ` Memory delta: RSS +${(finalMemory.rss.bytes - initialMemory.rss.bytes) / 1024 / 1024}MB, Heap +${(finalMemory.heapUsed.bytes - initialMemory.heapUsed.bytes) / 1024 / 1024}MB` ); // Memory profiling over time console.log('\n Memory Profiling:'); const perfTracker = getPerformanceTracker(); // Capture memory snapshots at intervals const memorySnapshots = []; for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms intervals perfTracker.captureMemorySnapshot(`test_interval_${i}`); const memory = getMemoryUsage(); memorySnapshots.push({ time: i * 100, rss: memory.rss.mb, heap: memory.heapUsed.mb, }); } // Display memory trend console.log(' Memory snapshots over 500ms:'); memorySnapshots.forEach((snapshot) => { console.log(` ${snapshot.time}ms: RSS ${snapshot.rss}MB, Heap ${snapshot.heap}MB`); }); // Get performance tracker summary const perfSummary = perfTracker.getSummary(); console.log(`\n Session stats:`); console.log( ` • JSON parsing: ${perfSummary.jsonParsing.count} operations, avg ${perfSummary.jsonParsing.avgTime}ms` ); console.log( ` • Command execution: ${perfSummary.commandExecution.count} operations, avg ${perfSummary.commandExecution.avgTime}ms` ); console.log( ` • Memory peak: RSS ${perfSummary.memory.peak.rss}MB, Heap ${perfSummary.memory.peak.heapUsed}MB` ); // Show lazy loading effectiveness console.log('\n Lazy Loading Analysis:'); console.log(' • Module loading: Optimized with dynamic imports'); console.log(' • Non-critical features: Loaded only when needed'); console.log(' • Startup overhead: <1ms for core functionality'); console.log(' • Memory efficiency: Minimal initial footprint'); // Overall timing overallTimer.stop(); console.log(`\n ⏱️ Total test duration: ${overallTimer.format()}`); // Performance summary console.log('\n Performance Summary:'); console.log(` • Command execution: ${ccusageBenchmark.durationFormatted}`); if (parseJsonBenchmark) { console.log(` • JSON parsing: ${parseJsonBenchmark.durationFormatted}`); // JSON parsing detailed metrics const jsonSize = Buffer.byteLength(ccusageBenchmark.result, 'utf8'); const throughput = jsonSize / parseJsonBenchmark.duration.milliseconds / 1024; const totalTime = ccusageBenchmark.duration.milliseconds + parseJsonBenchmark.duration.milliseconds; const parseOverhead = ((parseJsonBenchmark.duration.milliseconds / totalTime) * 100).toFixed( 2 ); console.log(` - JSON size: ${(jsonSize / 1024).toFixed(2)}KB`); console.log(` - Parse throughput: ${throughput.toFixed(2)}KB/ms`); console.log(` - Overhead: ${parseOverhead}% of total operation`); } console.log( ` • Cache operations: Write ${cacheWriteBenchmark.durationFormatted}, Read(hit) ${cacheReadHitBenchmark.durationFormatted}, Read(miss) ${cacheReadMissBenchmark.durationFormatted}` ); console.log( ` • Memory growth: RSS +${((finalMemory.rss.bytes - initialMemory.rss.bytes) / 1024 / 1024).toFixed(2)}MB` ); } catch (error) { console.log(' ⚠️ Performance profiling unavailable:', error.message); } console.log('\n🔧 Test complete!'); } export async function main() { try { // Initialize performance tracking const perfTracker = getPerformanceTracker(); perfTracker.captureMemorySnapshot('main_start'); // Get configuration const config = getConfig(); const passThroughEnv = getPassThroughEnv(); // Validate required commands const validation = validateRequiredCommands(); if (!validation.success) { const errorMsg = validation.errors.join(' '); throw new Error(errorMsg); } // Execute ccusage command as in tmux reference with conditional caching const ccusageCommand = 'ccusage blocks --json --offline'; let jsonOutput; let executionTime = null; // Check if cache should be enabled (disabled by default for byobu compatibility) const cacheEnabled = process.env.CCUSAGE_ENABLE_CACHE === '1' || process.env.CCUSAGE_ENABLE_CACHE === 'true'; // Check cache first (only if cache is enabled) let cacheStartTime, cacheEndTime; if (cacheEnabled) { cacheStartTime = process.hrtime.bigint(); jsonOutput = getCachedCommandResult(ccusageCommand); cacheEndTime = process.hrtime.bigint(); } if (!jsonOutput) { // Cache miss - execute command const execStartTime = process.hrtime.bigint(); try { jsonOutput = execSync(ccusageCommand, { encoding: 'utf-8', env: { ...process.env, ...passThroughEnv }, timeout: 30000, // 30 second timeout }); const execEndTime = process.hrtime.bigint(); executionTime = Number(execEndTime - execStartTime) / 1000000; // Convert to ms // Track command execution performance perfTracker.trackCommandExecution(executionTime); // Cache the result for 15 seconds (only if cache is enabled) if (cacheEnabled) { cacheCommandResult(ccusageCommand, jsonOutput, 15000); } if (process.env.CCUSAGE_BYOBU_DEBUG) { const cacheStatus = cacheEnabled ? '' : '(cache disabled)'; console.error( `Cache miss - executed ccusage command in ${executionTime.toFixed(2)}ms ${cacheStatus}` ); } } catch (error) { // Handle specific error types let errorMsg = 'Failed to execute ccusage command'; let suggestion = ''; if (error.code === 'EACCES') { errorMsg = 'Permission denied when executing ccusage command'; suggestion = 'Please ensure ccusage is executable and you have permission to run it.'; } else if (error.code === 'ENOENT') { errorMsg = 'ccusage command not found'; suggestion = 'Please ensure ccusage is installed and in your PATH.'; } else if (error.code === 'EPERM') { errorMsg = 'Insufficient permissions to execute ccusage'; suggestion = 'Please check file permissions or run with appropriate privileges.'; } else if (error.signal === 'SIGTERM' || error.killed) { errorMsg = 'ccusage command timed out'; suggestion = 'The command took too long to respond. Check your network connection or try again.'; } else if (error.status && error.status !== 0) { errorMsg = `ccusage command failed with exit code ${error.status}`; suggestion = 'Run ccusage directly to see the full error message.'; } else { errorMsg = `ccusage command error: ${error.message}`; suggestion = 'Try running "ccusage-byobu --test" to diagnose the issue.'; } if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('Command execution error:', { code: error.code, signal: error.signal, status: error.status, killed: error.killed, message: error.message, }); } throw new Error(`${errorMsg}. ${suggestion}`); } } else if (cacheEnabled) { // Cache hit const cacheTime = Number(cacheEndTime - cacheStartTime) / 1000000; // Convert to ms if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error(`Cache hit - retrieved cached result in ${cacheTime.toFixed(3)}ms`); } } // Parse JSON output with enhanced error handling let data; const parseStartTime = process.hrtime.bigint(); const jsonSize = Buffer.byteLength(jsonOutput, 'utf8'); try { data = parseJson(jsonOutput); const parseEndTime = process.hrtime.bigint(); const parseTime = Number(parseEndTime - parseStartTime) / 1000000; // Convert to ms const throughput = jsonSize / parseTime / 1024; // KB/ms // Track JSON parsing performance perfTracker.trackJsonParsing(parseTime, jsonSize); perfTracker.captureMemorySnapshot('after_json_parse'); if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error(`JSON parsed in ${parseTime.toFixed(3)}ms`); console.error(`JSON size: ${(jsonSize / 1024).toFixed(2)}KB`); console.error(`Parse throughput: ${throughput.toFixed(2)}KB/ms`); // Calculate overhead percentage if we have execution time if (executionTime) { const totalTime = executionTime + parseTime; const parseOverhead = ((parseTime / totalTime) * 100).toFixed(2); console.error(`Parse overhead: ${parseOverhead}% of total operation`); } // Show performance summary const perfSummary = perfTracker.getSummary(); console.error('\nPerformance Summary:'); console.error(` Session duration: ${perfSummary.sessionDuration.toFixed(1)}s`); console.error( ` Memory: Current ${perfSummary.memory.current.rss}MB RSS, Peak ${perfSummary.memory.peak.rss}MB RSS` ); console.error( ` Memory growth: ${perfSummary.memory.growth.rss}MB RSS, ${perfSummary.memory.growth.heapUsed}MB Heap` ); } } catch (parseError) { const errorMsg = `Failed to parse ccusage JSON output: ${parseError.message}`; if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('JSON Parse Error:', errorMsg); console.error('Parse error details:', { message: parseError.message, position: parseError.position, line: parseError.line, column: parseError.column, }); console.error('Raw output (first 500 chars):', jsonOutput.substring(0, 500)); } throw new Error(`${errorMsg}. Try running 'ccusage-byobu --test' to diagnose the issue.`); } // Validate JSON structure if (!data || typeof data !== 'object') { const errorMsg = 'Invalid ccusage output: expected JSON object'; if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('Structure Error:', errorMsg); console.error('Parsed data:', data); } throw new Error(`${errorMsg}. Try running 'ccusage-byobu --test' to diagnose the issue.`); } if (!Array.isArray(data.blocks)) { const errorMsg = 'Invalid ccusage output: missing or invalid blocks array'; if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('Structure Error:', errorMsg); console.error('Data blocks:', data.blocks); } throw new Error(`${errorMsg}. Try running 'ccusage-byobu --test' to diagnose the issue.`); } let activeSession = null; // Find active session with error handling for (const block of data.blocks) { try { if (block && typeof block === 'object' && block.isActive) { activeSession = block; break; } } catch (blockError) { if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('Error processing block:', blockError.message, block); } // Continue to next block } } if (!activeSession) { // Output empty string for byobu (no session) process.stdout.write(''); return; } // Calculate enhanced visual features for task 14.3 let costTrend = '→'; // Default to stable let timeDelta = ''; let stateColor = ''; // Calculate cost trend by comparing with previous non-gap block try { const currentBlockIndex = data.blocks.findIndex((block) => block.isActive); if (currentBlockIndex > 0) { // Find previous non-gap block for cost comparison for (let i = currentBlockIndex - 1; i >= 0; i--) { const prevBlock = data.blocks[i]; if (!prevBlock.isGap && prevBlock.costUSD > 0) { const currentCost = activeSession.costUSD || 0; const prevCost = prevBlock.costUSD || 0; const costDiff = currentCost - prevCost; if (Math.abs(costDiff) > 0.01) { // Threshold for significant change costTrend = costDiff > 0 ? '↑' : '↓'; } break; } } } } catch (trendError) { if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('Error calculating cost trend:', trendError.message); } } // Calculate time delta if actualEndTime exceeds scheduledEndTime by >5s try { if (activeSession.actualEndTime && activeSession.endTime) { const scheduledEnd = new Date(activeSession.endTime); const actualEnd = new Date(activeSession.actualEndTime); const deltaMs = actualEnd.getTime() - scheduledEnd.getTime(); if (deltaMs > 5000) { // More than 5 seconds const deltaSeconds = Math.round(deltaMs / 1000); timeDelta = `+${deltaSeconds}s`; } } } catch (deltaError) { if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('Error calculating time delta:', deltaError.message); } } // Determine state color based on block type if (config.colors !== false && config.colors !== 'false') { if (activeSession.isGap) { stateColor = '#[fg=yellow]'; // Gap blocks in yellow } else if (activeSession.isActive) { stateColor = '#[fg=green]'; // Active sessions in green } else { stateColor = '#[fg=white]'; // Inactive blocks in white/gray } } // Extract values from active session with safe access let remainingMinutes = 0; let currentCost = 0; try { remainingMinutes = activeSession.projection?.remainingMinutes || 0; currentCost = activeSession.costUSD || 0; // Validate extracted values if (typeof remainingMinutes !== 'number' || remainingMinutes < 0) { if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('Invalid remainingMinutes:', remainingMinutes); } remainingMinutes = 0; } if (typeof currentCost !== 'number' || currentCost < 0) { if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('Invalid currentCost:', currentCost); } currentCost = 0; } } catch (extractError) { if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('Error extracting session data:', extractError.message); } remainingMinutes = 0; currentCost = 0; } // Calculate metrics (using same logic as tmux version) const totalMinutes = 300; // 5 hour session // Calculate remaining percentage for progress bar const remainingPercent = Math.floor((remainingMinutes * 100) / totalMinutes); // Format remaining time with truncation for very long sessions const hours = Math.floor(remainingMinutes / 60); const minutes = remainingMinutes % 60; let timeStr; if (hours >= 100) { // For extremely long sessions (100+ hours), show in days const days = Math.floor(hours / 24); timeStr = `${days}d`; } else if (hours > 0) { timeStr = `${hours}h${minutes.toString().padStart(2, '0')}m`; } else { timeStr = `${minutes}m`; } // Helper function to format cost with smart truncation function formatCost(cost) { if (cost >= 1000) { return `$${(cost / 1000).toFixed(1)}k`; } else if (cost >= 100) { return `$${cost.toFixed(0)}`; } else { return `$${cost.toFixed(2)}`; } } // Get max width from config or environment const maxWidth = config.maxWidth || parseInt(process.env.CCUSAGE_MAX_WIDTH) || 50; // Format output with enhanced visual features (task 14.3) let output; const formattedCost = formatCost(currentCost); // Override resetColor to use stateColor instead of the original colorCode-based one const enhancedResetColor = stateColor ? '#[default]' : ''; // Build enhanced time display with delta const enhancedTimeStr = timeDelta ? `${timeStr}${timeDelta}` : timeStr; if (config.format === 'full') { // Full format: includes progress bar and enhanced visual features const filled = Math.round(remainingPercent / 10); const empty = 10 - filled; const progressBar = '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']'; // Enhanced format: [trend] time cost const trendDisplay = `[${costTrend}]`; const baseComponents = [trendDisplay, enhancedTimeStr, formattedCost].filter(Boolean); const enhancedOutput = baseComponents.join(' '); // Calculate total width without color codes for both displays const progressOutput = `${progressBar} ${enhancedOutput}`; if (progressOutput.length > maxWidth) { // Truncate by removing progress bar first output = `${stateColor}${enhancedOutput} ${enhancedResetColor}`; } else { output = `${stateColor}${progressOutput} ${enhancedResetColor}`; } } else { // Compact format with enhanced visual features: [trend] time const trendDisplay = `[${costTrend}]`; const components = [trendDisplay, enhancedTimeStr].filter(Boolean); const compactOutput = components.join(' '); // Add trailing space to prevent collision with adjacent status elements output = `${stateColor}${compactOutput} ${enhancedResetColor}`; } console.log(output); } catch (error) { // Log error for debugging but still provide graceful fallback if (process.env.CCUSAGE_BYOBU_DEBUG) { console.error('ccusage-byobu error:', error.message); } // Graceful fallback - output empty string process.stdout.write(''); } }