codebrag
Version:
CLI tool for tracking Claude Code usage and leaderboard participation
383 lines (305 loc) • 12.2 kB
JavaScript
import { readFile, stat } from 'node:fs/promises';
import { existsSync, statSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { homedir } from 'node:os';
import { glob } from 'tinyglobby';
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 = [];
console.error(`[DEBUG] Getting Claude paths...`);
console.error(`[DEBUG] Current working directory: ${process.cwd()}`);
console.error(`[DEBUG] User home directory: ${USER_HOME_DIR}`);
console.error(`[DEBUG] XDG_CONFIG_HOME: ${process.env.XDG_CONFIG_HOME || 'not set'}`);
const envPaths = process.env[CLAUDE_CONFIG_DIR_ENV];
if (envPaths) {
console.error(`[DEBUG] Using CLAUDE_CONFIG_DIR from env: ${envPaths}`);
paths.push(...envPaths.split(','));
} else {
console.error(`[DEBUG] Using default paths:`);
console.error(`[DEBUG] - ${DEFAULT_CLAUDE_CONFIG_PATH}`);
console.error(`[DEBUG] - ${USER_HOME_DIR}/${DEFAULT_CLAUDE_CODE_PATH}`);
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);
const exists = existsSync(projectsPath);
console.error(`[DEBUG] Checking path: ${p}`);
console.error(`[DEBUG] Projects dir: ${projectsPath}`);
console.error(`[DEBUG] Exists: ${exists}`);
return exists;
} catch (error) {
console.error(`[DEBUG] Error checking path ${p}: ${error.message}`);
return false;
}
});
console.error(`[DEBUG] Valid Claude paths found: ${validPaths.length}`);
return validPaths;
}
async function sortFilesByTimestamp(files) {
const filesWithStats = await Promise.all(
files.map(async (file) => {
try {
const stats = await stat(file);
return { file, mtime: stats.mtime };
} catch {
return { file, mtime: new Date(0) };
}
})
);
return filesWithStats
.sort((a, b) => b.mtime - a.mtime)
.map(item => item.file);
}
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)) {
console.error(`[DEBUG] Line failed validation:`, JSON.stringify(data).substring(0, 100));
return null;
}
const usage = data.message.usage;
const interactionId = createUniqueHash(data);
const result = {
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
};
console.error(`[DEBUG] Successfully parsed usage data: ${JSON.stringify(result)}`);
return result;
} catch (error) {
console.error(`[DEBUG] Error parsing line: ${error.message}`);
return null;
}
}
async function getLatestTokenUsage() {
const claudePaths = getClaudePaths();
console.error(`Claude paths found: ${claudePaths.join(', ')}`);
if (claudePaths.length === 0) {
console.error('No Claude paths found');
return null;
}
const allFiles = [];
for (const claudePath of claudePaths) {
const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME);
console.error(`[DEBUG] Searching for JSONL files in: ${claudeDir}`);
try {
const files = await glob([USAGE_DATA_GLOB_PATTERN], {
cwd: claudeDir,
absolute: true
});
console.error(`[DEBUG] Found ${files.length} files in ${claudeDir}`);
if (files.length > 0) {
console.error(`[DEBUG] Sample files: ${files.slice(0, 3).join(', ')}`);
}
allFiles.push(...files);
} catch (error) {
console.error(`[DEBUG] Error globbing in ${claudeDir}: ${error.message}`);
continue;
}
}
if (allFiles.length === 0) {
console.error(`No JSONL files found in paths: ${claudePaths.join(', ')}`);
return null;
}
console.error(`Found ${allFiles.length} JSONL files`);
console.error(`First few files: ${allFiles.slice(0, 3).join(', ')}`);
const sortedFiles = await sortFilesByTimestamp(allFiles);
const processedHashes = new Set();
for (const file of sortedFiles.slice(0, 5)) {
try {
const content = await readFile(file, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.length > 0);
console.error(`File ${path.basename(file)} has ${lines.length} lines`);
// Debug: Sample first few lines
if (lines.length > 0) {
console.error(`Sample line from file:`, lines[0].substring(0, 200));
}
for (const line of lines.reverse()) {
// Try to parse the line for debugging
try {
const data = JSON.parse(line.trim());
// Debug: Log what we found
if (data.message && data.message.usage) {
console.error(`Found usage data:`, JSON.stringify(data.message.usage));
}
} catch (e) {
// Skip invalid JSON
}
const usageData = parseUsageFromLine(line);
if (!usageData) continue;
const uniqueHash = usageData.interaction_id;
if (uniqueHash && processedHashes.has(uniqueHash)) {
continue;
}
if (uniqueHash) {
processedHashes.add(uniqueHash);
}
return usageData;
}
} catch (error) {
console.error(`[DEBUG] Error reading/parsing file ${file}: ${error.message}`);
continue;
}
}
console.error(`[DEBUG] No valid usage data found after checking ${sortedFiles.length} files`);
return null;
}
async function loadConfig() {
const configPath = path.join(USER_HOME_DIR, '.claude', 'leaderboard.json');
try {
const content = await readFile(configPath, 'utf-8');
return JSON.parse(content);
} catch {
return {
twitterUrl: "@your_handle",
endpoint: "http://localhost:8000"
};
}
}
async function sendToAPI(endpoint, payload) {
try {
console.error(`[DEBUG] Sending payload to API: ${JSON.stringify(payload)}`);
console.error(`[DEBUG] API endpoint: ${endpoint}`);
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
console.error(`[DEBUG] API response status: ${response.status} ${response.statusText}`);
if (!response.ok) {
const errorText = await response.text();
console.error(`[DEBUG] API error response: ${errorText}`);
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
console.error(`[DEBUG] API success response: ${JSON.stringify(result)}`);
return result;
} catch (error) {
console.error(`[DEBUG] Failed to send to API: ${error.message}`);
console.error(`[DEBUG] Error type: ${error.constructor.name}`);
throw error;
}
}
async function main() {
try {
console.error(`[DEBUG] ====== Hook execution started ======`);
console.error(`[DEBUG] Script path: ${process.argv[1]}`);
console.error(`[DEBUG] Process ID: ${process.pid}`);
console.error(`[DEBUG] Node version: ${process.version}`);
// Also log to file to verify hook is running
const logFile = path.join(USER_HOME_DIR, '.claude', 'hook_debug.log');
const logEntry = `[${new Date().toISOString()}] Hook started - PID: ${process.pid} CWD: ${process.cwd()}\n`;
await readFile(logFile, 'utf-8').catch(() => '').then(async (content) => {
await import('fs/promises').then(fs => fs.writeFile(logFile, content + logEntry));
});
// Read hook data from stdin (Claude passes data via stdin)
let hookData = {};
try {
const stdin = process.stdin;
stdin.setEncoding('utf-8');
let data = '';
for await (const chunk of stdin) {
data += chunk;
}
if (data.trim()) {
hookData = JSON.parse(data);
console.error(`[DEBUG] Received hook data from stdin`);
} else {
console.error(`[DEBUG] No hook data received from stdin`);
}
} catch (error) {
console.error(`[DEBUG] Error reading stdin: ${error.message}`);
}
console.error(`[DEBUG] Starting token usage search...`);
const usageData = await getLatestTokenUsage();
if (!usageData) {
console.error('[DEBUG] No token usage data found - exiting gracefully');
console.error(`[DEBUG] ====== Hook execution ended (no data) ======`);
process.exit(0);
}
console.error(`[DEBUG] Found usage data: ${JSON.stringify(usageData)}`)
const tokens = usageData.tokens;
const totalTokens = tokens.input + tokens.output + tokens.cache_creation + tokens.cache_read;
const config = await loadConfig();
// ENHANCEMENT: Check if we have authenticated user data
const apiPayload = {
twitter_handle: config.twitterUrl,
timestamp: usageData.timestamp,
tokens: tokens,
model: usageData.model,
interaction_id: usageData.interaction_id
};
// ENHANCEMENT: Add twitter_user_id if available
if (config.twitterUserId) {
apiPayload.twitter_user_id = config.twitterUserId;
console.error(`[DEBUG] Adding authenticated user ID: ${config.twitterUserId}`);
}
// Log to console instead of file
console.error(
`[${usageData.timestamp}] ` +
`Total: ${totalTokens} tokens ` +
`(Input: ${tokens.input}, Output: ${tokens.output}, ` +
`Cache Create: ${tokens.cache_creation}, Cache Read: ${tokens.cache_read}) ` +
`Model: ${usageData.model} ID: ${usageData.interaction_id}`
);
// Send to API endpoint
try {
const baseEndpoint = config.endpoint || "http://localhost:8000";
const endpoint = `${baseEndpoint}/api/usage/hook`;
console.error(`Sending to API: ${endpoint}`);
const result = await sendToAPI(endpoint, apiPayload);
console.error(`API Response: ${JSON.stringify(result)}`);
// Log successful submission
console.error(`[${new Date().toISOString()}] Successfully sent to API: ${totalTokens} tokens`);
} catch (apiError) {
console.error(`Failed to send to API: ${apiError.message}`);
// Log API failure but don't fail the hook
console.error(`[${new Date().toISOString()}] API submission failed: ${apiError.message}`);
}
console.error(`[DEBUG] ====== Hook execution completed successfully ======`);
process.exit(0);
} catch (error) {
console.error(`[DEBUG] Hook error: ${error.message}`);
console.error(`[DEBUG] Stack trace: ${error.stack}`);
console.error(`[DEBUG] ====== Hook execution ended (error) ======`);
process.exit(0);
}
}
main();