UNPKG

codebrag

Version:

CLI tool for tracking Claude Code usage and leaderboard participation

201 lines (166 loc) 6 kB
import { readFile, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { homedir } from 'node:os'; import { glob } from 'tinyglobby'; import ora from 'ora'; import chalk from 'chalk'; const USER_HOME_DIR = homedir(); const XDG_CONFIG_DIR = process.env.XDG_CONFIG_HOME ?? `${USER_HOME_DIR}/.config`; const DEFAULT_CLAUDE_CODE_PATH = '.claude'; const DEFAULT_CLAUDE_CONFIG_PATH = `${XDG_CONFIG_DIR}/claude`; const CLAUDE_CONFIG_DIR_ENV = 'CLAUDE_CONFIG_DIR'; const CLAUDE_PROJECTS_DIR_NAME = 'projects'; const USAGE_DATA_GLOB_PATTERN = '**/*.jsonl'; function validateUsageData(data) { try { if (!data || typeof data !== 'object') return false; if (!data.timestamp) return false; if (!data.message || typeof data.message !== 'object') return false; if (!data.message.usage || typeof data.message.usage !== 'object') return false; const usage = data.message.usage; // Must have both input_tokens and output_tokens as numbers return typeof usage.input_tokens === 'number' && typeof usage.output_tokens === 'number'; } catch { return false; } } function getClaudePaths() { const paths = []; const envPaths = process.env[CLAUDE_CONFIG_DIR_ENV]; if (envPaths) { paths.push(...envPaths.split(',')); } else { paths.push(DEFAULT_CLAUDE_CONFIG_PATH, `${USER_HOME_DIR}/${DEFAULT_CLAUDE_CODE_PATH}`); } const validPaths = paths.filter(p => { try { const projectsPath = path.join(p, CLAUDE_PROJECTS_DIR_NAME); return existsSync(projectsPath); } catch { return false; } }); return validPaths; } function createUniqueHash(data) { const requestId = data.requestId; const messageId = data.message?.id; return requestId || messageId || null; } function parseUsageFromLine(line) { try { const data = JSON.parse(line.trim()); // Validate the data structure matches ccusage schema if (!validateUsageData(data)) { return null; } const usage = data.message.usage; const interactionId = createUniqueHash(data); return { timestamp: data.timestamp, tokens: { input: usage.input_tokens, output: usage.output_tokens, cache_creation: usage.cache_creation_input_tokens || 0, cache_read: usage.cache_read_input_tokens || 0 }, model: data.message.model || 'unknown', interaction_id: interactionId }; } catch { return null; } } async function scanJsonlFile(filePath, seenInteractions) { const usageEntries = []; try { const content = await readFile(filePath, 'utf-8'); const lines = content.trim().split('\n').filter(line => line.length > 0); for (const line of lines) { const usageData = parseUsageFromLine(line); if (!usageData) continue; // Skip if we've seen this interaction before const uniqueHash = usageData.interaction_id; if (uniqueHash && seenInteractions.has(uniqueHash)) { continue; } if (uniqueHash) { seenInteractions.add(uniqueHash); } usageEntries.push(usageData); } } catch (error) { // Silently skip files that can't be read } return usageEntries; } export async function scanAllHistoricalUsage(showProgress = true) { const spinner = showProgress ? ora('Scanning for historical Claude usage...').start() : null; try { // Get Claude paths const claudePaths = getClaudePaths(); if (claudePaths.length === 0) { if (spinner) spinner.warn('No Claude configuration found'); return { entries: [], totals: { input: 0, output: 0, cache_creation: 0, cache_read: 0, total: 0 } }; } if (spinner) spinner.text = 'Finding usage files...'; // Find all JSONL files const allFiles = []; for (const claudePath of claudePaths) { const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME); try { const files = await glob([USAGE_DATA_GLOB_PATTERN], { cwd: claudeDir, absolute: true }); allFiles.push(...files); } catch { // Skip paths that can't be accessed } } if (allFiles.length === 0) { if (spinner) spinner.info('No usage history found'); return { entries: [], totals: { input: 0, output: 0, cache_creation: 0, cache_read: 0, total: 0 } }; } if (spinner) spinner.text = `Processing ${allFiles.length} usage files...`; // Process all files const allUsageEntries = []; const seenInteractions = new Set(); let processedFiles = 0; for (const file of allFiles) { const entries = await scanJsonlFile(file, seenInteractions); allUsageEntries.push(...entries); processedFiles++; if (spinner && processedFiles % 10 === 0) { spinner.text = `Processing usage files... (${processedFiles}/${allFiles.length})`; } } // Calculate totals const totals = { input: 0, output: 0, cache_creation: 0, cache_read: 0, total: 0 }; for (const entry of allUsageEntries) { totals.input += entry.tokens.input; totals.output += entry.tokens.output; totals.cache_creation += entry.tokens.cache_creation; totals.cache_read += entry.tokens.cache_read; } totals.total = totals.input + totals.output + totals.cache_creation + totals.cache_read; if (spinner) { spinner.succeed(`Found ${chalk.cyan(allUsageEntries.length.toLocaleString())} usage entries with ${chalk.cyan(totals.total.toLocaleString())} total tokens`); } return { entries: allUsageEntries, totals }; } catch (error) { if (spinner) spinner.fail(`Error scanning usage: ${error.message}`); throw error; } } // Export individual functions for testing or reuse export { getClaudePaths, parseUsageFromLine, validateUsageData };