UNPKG

claude-usage-monitor

Version:

Real-time CLI dashboard for monitoring Claude AI token usage with 5-hour session tracking

362 lines (361 loc) 16.5 kB
#!/usr/bin/env node import { exec } from "child_process"; import { promisify } from "util"; import * as path from "path"; import { readdir, readFile } from "fs/promises"; import { homedir } from "os"; const execAsync = promisify(exec); async function fetchUsage() { try { // Calculate date for 2 days ago to ensure we capture yesterday and today const twoDaysAgo = new Date(); twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); const dateStr = twoDaysAgo.toISOString().slice(0, 10).replace(/-/g, ""); const { stdout } = await execAsync(`npx ccusage@latest session -s ${dateStr} -j`); return JSON.parse(stdout); } catch (error) { console.error("Error fetching usage:", error); return null; } } function formatNumber(num) { return num.toLocaleString(); } async function analyzeLocalSessions() { const claudeDir = path.join(homedir(), ".claude", "projects"); const allSessions = []; try { const projects = await readdir(claudeDir); for (const project of projects) { if (project.startsWith(".")) continue; const projectDir = path.join(claudeDir, project); const files = await readdir(projectDir); for (const file of files) { if (!file.endsWith(".jsonl")) continue; const filePath = path.join(projectDir, file); const content = await readFile(filePath, "utf-8"); const lines = content.trim().split("\n"); if (lines.length === 0) continue; const entries = lines .map((line) => { try { return JSON.parse(line); } catch { return null; } }) .filter(Boolean); if (entries.length === 0) continue; const timestamps = entries .filter((e) => e.timestamp) .map((e) => new Date(e.timestamp)) .sort((a, b) => a.getTime() - b.getTime()); if (timestamps.length === 0) continue; const startTime = timestamps[0]; const endTime = timestamps[timestamps.length - 1]; const userMessages = entries.filter((e) => e.type === "user").length; const assistantMessages = entries.filter((e) => e.type === "assistant").length; let estimatedTokens = 0; entries.forEach((entry) => { if (entry.message?.content) { const content = typeof entry.message.content === "string" ? entry.message.content : JSON.stringify(entry.message.content); estimatedTokens += Math.ceil(content.length / 4); } }); const durationMs = endTime.getTime() - startTime.getTime(); const hours = Math.floor(durationMs / (1000 * 60 * 60)); const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); allSessions.push({ sessionId: file.replace(".jsonl", ""), projectPath: project.replace(/-/g, "/"), startTime, endTime, userMessages, assistantMessages, totalMessages: userMessages + assistantMessages, duration: `${hours}h ${minutes}m`, estimatedTokens, }); } } return allSessions.sort((a, b) => b.startTime.getTime() - a.startTime.getTime()); } catch (error) { console.error("Error analyzing local sessions:", error); return []; } } function getTerminalHeight() { return process.stdout.rows || 24; // Default to 24 if not available } async function gatherUsageData() { const data = await fetchUsage(); if (!data) { return null; } const localSessions = await analyzeLocalSessions(); return { data, localSessions }; } async function displayUsage(cachedData) { // Use cached data if provided, otherwise fetch new data const usageData = cachedData || (await gatherUsageData()); if (!usageData) { console.log("Failed to fetch usage data"); return; } const { data, localSessions } = usageData; const terminalHeight = getTerminalHeight(); let currentLine = 0; console.log(`\x1b[32mCLAUDE USAGE\x1b[0m`); console.log(); currentLine += 2; // Analyze local sessions const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000); const recentLocalSessions = localSessions.filter((s) => s.endTime >= fiveHoursAgo); if (recentLocalSessions.length > 0) { console.log("\x1b[36m5-Hour Session Window\x1b[0m"); console.log(); currentLine += 2; let totalRecentTokens = 0; // Reserve at least 8 lines for: title(2) + 5hr header(2) + 1 session(1) + separator(1) + total(1) + footer(3) const minLinesNeeded = 10; const availableFor5Hr = Math.max(0, terminalHeight - currentLine - minLinesNeeded); const maxRecentSessions = Math.min(5, Math.max(1, Math.floor(availableFor5Hr))); recentLocalSessions.slice(0, maxRecentSessions).forEach((session) => { totalRecentTokens += session.estimatedTokens; // Check if session is still within 5-hour window const sessionAge = Date.now() - session.startTime.getTime(); const fiveHoursMs = 5 * 60 * 60 * 1000; let timeInfo = ""; if (sessionAge < fiveHoursMs) { const timeRemaining = fiveHoursMs - sessionAge; const hoursRemaining = Math.floor(timeRemaining / (1000 * 60 * 60)); const minutesRemaining = Math.floor((timeRemaining % (1000 * 60 * 60)) / (1000 * 60)); timeInfo = `${hoursRemaining}h ${minutesRemaining}m left`; } else { timeInfo = "Expired"; } const projectName = session.projectPath.split("/").pop() || session.projectPath; const startTime = session.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }); const endTime = session.endTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }); console.log(` ${projectName.padEnd(20)} ` + `\x1b[90m${startTime} - ${endTime}\x1b[0m ` + `\x1b[90m(${session.duration})\x1b[0m ` + `\x1b[36m${formatNumber(session.estimatedTokens).padStart(9)}\x1b[0m ` + `\x1b[90m${timeInfo}\x1b[0m`); currentLine++; }); console.log("\x1b[90m─────────────────────────────────────────────────────────────────\x1b[0m"); console.log(` \x1b[36mTotal (5hr)\x1b[0m: ${formatNumber(totalRecentTokens)} tokens`); currentLine += 2; } // Show sessions from today and yesterday const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const todaySessions = localSessions.filter((s) => s.startTime >= today); const yesterdaySessions = localSessions.filter((s) => s.startTime >= yesterday && s.startTime < today); // Calculate remaining space for session listings const remainingLines = terminalHeight - currentLine - 3; // Reserve 3 lines for footer // Only show detailed sessions if we have enough space if (remainingLines > 8 && (todaySessions.length > 0 || yesterdaySessions.length > 0)) { console.log(); currentLine++; // Calculate how many sessions we can show const headerLines = 4; // Headers and separators per section const availableForSessions = Math.max(0, remainingLines - headerLines * 2); // Account for both sections // If very limited space, show only today's sessions let maxTodaySessions = 0; let maxYesterdaySessions = 0; if (availableForSessions > 0) { if (availableForSessions < 4) { // Very small screen - only show today's sessions maxTodaySessions = availableForSessions; maxYesterdaySessions = 0; } else { // Normal allocation maxTodaySessions = Math.floor(availableForSessions * 0.7); // 70% for today maxYesterdaySessions = Math.floor(availableForSessions * 0.3); // 30% for yesterday } } // Today's sessions if (todaySessions.length > 0 && maxTodaySessions > 0) { console.log("\n\x1b[32mToday\x1b[0m\n"); console.log(` ${"Project".padEnd(20)} ` + `${"Time Range".padEnd(13)} ` + `${"Duration".padEnd(12)} ` + `${"Est. Tokens".padStart(11)}`); console.log("\x1b[90m─────────────────────────────────────────────────────────────────\x1b[0m"); currentLine += 4; todaySessions.slice(0, maxTodaySessions).forEach((session) => { const projectName = session.projectPath.split("/").pop() || session.projectPath; const startTime = session.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }); const endTime = session.endTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }); console.log(` ${projectName.padEnd(20)} ` + `\x1b[90m${startTime} - ${endTime}\x1b[0m ` + `\x1b[90m(${session.duration.padStart(7)})\x1b[0m ` + `\x1b[36m${formatNumber(session.estimatedTokens).padStart(9)}\x1b[0m tokens`); currentLine++; }); // Show count of hidden today's sessions if any const hiddenToday = todaySessions.length - maxTodaySessions; if (hiddenToday > 0) { console.log(); console.log(` \x1b[90m... and ${hiddenToday} more today's sessions\x1b[0m`); currentLine++; } } // Yesterday's sessions if (yesterdaySessions.length > 0 && maxYesterdaySessions > 0) { console.log("\n\x1b[32mYesterday\x1b[0m\n"); console.log(` ${"Project".padEnd(20)} ` + `${"Time Range".padEnd(13)} ` + `${"Duration".padEnd(12)} ` + `${"Est. Tokens".padStart(11)}`); console.log("\x1b[90m─────────────────────────────────────────────────────────────────\x1b[0m"); currentLine += 4; yesterdaySessions.slice(0, maxYesterdaySessions).forEach((session) => { const projectName = session.projectPath.split("/").pop() || session.projectPath; const startTime = session.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }); const endTime = session.endTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }); console.log(` ${projectName.padEnd(20)} ` + `\x1b[90m${startTime} - ${endTime}\x1b[0m ` + `\x1b[90m(${session.duration.padStart(7)})\x1b[0m ` + `\x1b[36m${formatNumber(session.estimatedTokens).padStart(9)}\x1b[0m tokens`); currentLine++; }); // Show count of hidden yesterday's sessions if any const hiddenYesterday = yesterdaySessions.length - maxYesterdaySessions; if (hiddenYesterday > 0) { console.log(); console.log(` \x1b[90m... and ${hiddenYesterday} more yesterday's sessions\x1b[0m`); } } } else if (remainingLines <= 8 && (todaySessions.length > 0 || yesterdaySessions.length > 0)) { // For very small screens, show a summary instead console.log(); const todayTokens = todaySessions.reduce((sum, s) => sum + s.estimatedTokens, 0); const yesterdayTokens = yesterdaySessions.reduce((sum, s) => sum + s.estimatedTokens, 0); if (todaySessions.length > 0) { console.log(` \x1b[32mToday\x1b[0m: ${todaySessions.length} sessions, ${formatNumber(todayTokens)} tokens`); } if (yesterdaySessions.length > 0) { console.log(` \x1b[33mYesterday\x1b[0m: ${yesterdaySessions.length} sessions, ${formatNumber(yesterdayTokens)} tokens`); } } console.log(); console.log("\x1b[90m─────────────────────────────────────────────────────────────────\x1b[0m"); } async function main() { let secondsUntilRefresh = 60; let countdownInterval; let refreshInterval; let cachedData = null; let isRefreshing = false; // Function to update the countdown const updateCountdown = () => { // Move cursor to the last line process.stdout.write("\x1b[?25l"); // Hide cursor process.stdout.write("\x1b[s"); // Save cursor position process.stdout.write(`\x1b[${process.stdout.rows};1H`); // Move to last line process.stdout.write("\x1b[K"); // Clear line process.stdout.write(`\x1b[90mNext refresh in ${secondsUntilRefresh}s${isRefreshing ? " (updating...)" : ""}\x1b[0m`); process.stdout.write("\x1b[u"); // Restore cursor position process.stdout.write("\x1b[?25h"); // Show cursor }; // Function to refresh display with smooth update const refresh = async (forceRedraw = false) => { if (forceRedraw || !cachedData) { console.clear(); } else { // Use cursor positioning to overwrite content without clearing process.stdout.write("\x1b[H"); // Move cursor to home position } await displayUsage(cachedData || undefined); secondsUntilRefresh = 60; updateCountdown(); }; // Function to fetch data in background const fetchInBackground = async () => { isRefreshing = true; updateCountdown(); try { const newData = await gatherUsageData(); if (newData) { cachedData = newData; // Only update display if we have new data await refresh(false); } } finally { isRefreshing = false; } }; // Initial display console.clear(); await fetchInBackground(); // Update countdown every second countdownInterval = setInterval(() => { secondsUntilRefresh--; if (secondsUntilRefresh <= 0) { secondsUntilRefresh = 60; } updateCountdown(); }, 1000); // Refresh data every 60 seconds in background refreshInterval = setInterval(() => { fetchInBackground(); }, 60000); // Handle terminal resize process.stdout.on("resize", () => { refresh(true); // Force redraw on resize }); } // Handle graceful exit process.on("SIGINT", () => { console.clear(); console.log("\x1b[32mStopped monitoring Claude usage\x1b[0m"); process.exit(0); }); // Main main().catch(console.error);