cost-claude
Version:
Claude Code cost monitoring, analytics, and optimization toolkit
517 lines โข 26.3 kB
JavaScript
import chalk from 'chalk';
import ora from 'ora';
import { homedir } from 'os';
import { glob } from 'glob';
import { readFile } from 'fs/promises';
import { ClaudeFileWatcher } from '../../services/file-watcher.js';
import { NotificationService } from '../../services/notification.js';
import { CostCalculator } from '../../core/cost-calculator.js';
import { JSONLParser } from '../../core/jsonl-parser.js';
import { logger } from '../../utils/logger.js';
import { formatCostColored, formatCost, formatDuration, formatNumber, shortenProjectName } from '../../utils/format.js';
import { SessionDetector } from '../../services/session-detector.js';
import { ProjectParser } from '../../core/project-parser.js';
function getMessageCost(message, parser, calculator) {
if (message.costUSD !== null && message.costUSD !== undefined) {
return message.costUSD;
}
const content = parser.parseMessageContent(message);
if (content?.usage) {
return calculator.calculate(content.usage);
}
return 0;
}
async function getRecentMessages(basePath, count, parser, calculator) {
const pattern = `${basePath}/**/*.jsonl`;
const files = await glob(pattern);
const allMessages = [];
for (const file of files) {
try {
const content = await readFile(file, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const message = JSON.parse(line);
if (message.type === 'assistant') {
const cost = getMessageCost(message, parser, calculator);
if (cost > 0) {
allMessages.push(message);
}
}
}
catch {
}
}
}
catch (error) {
logger.warn(`Failed to read file ${file}:`, error);
}
}
return allMessages
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, count);
}
export async function watchCommand(options) {
if (options.verbose) {
logger.level = 'debug';
logger.transports.forEach((transport) => {
transport.level = 'debug';
});
}
const model = options.parent?.opts?.()?.model || 'claude-opus-4-20250514';
const notifySession = options.notify && options.notifySession !== false;
const notifyCost = options.notify && options.notifyCost === true;
console.log(chalk.bold.blue('Claude Code Cost Watcher'));
console.log(chalk.gray('Real-time monitoring for Claude usage'));
console.log(chalk.dim(`Model: ${model}`));
if (options.test) {
console.log(chalk.yellow('๐งช TEST MODE ENABLED'));
}
if (options.verbose) {
console.log(chalk.gray('Verbose logging enabled'));
}
if (options.notify) {
const notificationTypes = [];
if (notifySession)
notificationTypes.push('Session');
if (notifyCost)
notificationTypes.push('Cost');
if (options.notifyTask)
notificationTypes.push('Task (Delayed)');
if (options.notifyProgress !== false)
notificationTypes.push('Progress');
console.log(chalk.gray(`Notifications: ${notificationTypes.join(', ') || 'None'}`));
if (options.delayedTimeout) {
console.log(chalk.gray(`Delayed completion: ${parseInt(options.delayedTimeout) / 1000}s`));
}
if (options.progressInterval) {
console.log(chalk.gray(`Progress interval: ${parseInt(options.progressInterval) / 1000}s`));
}
}
const recentCount = parseInt(options.recent || '5');
if (options.includeExisting) {
console.log(chalk.gray('Processing all existing messages'));
}
else if (recentCount > 0) {
console.log(chalk.gray(`Showing last ${recentCount} messages before monitoring`));
}
else {
console.log(chalk.gray('Monitoring new messages only'));
}
console.log();
const spinner = ora('Initializing watcher...').start();
try {
const basePath = options.path.replace('~', homedir());
if (options.test) {
const testPath = `${homedir()}/.cost-claude/test`;
const { mkdir } = await import('fs/promises');
await mkdir(testPath, { recursive: true });
console.log(chalk.gray(`Test directory created: ${testPath}`));
}
const minCost = parseFloat(options.minCost);
const recentCount = parseInt(options.recent || '5');
const watcher = new ClaudeFileWatcher({
paths: [`${basePath}/**/*.jsonl`],
ignoreInitial: !options.includeExisting,
pollInterval: 100,
debounceDelay: 300,
});
const notificationService = new NotificationService({
soundEnabled: options.sound || options.sessionSoundOnly,
taskCompleteSound: options.taskSound,
sessionCompleteSound: options.sessionSound,
});
const calculator = new CostCalculator(undefined, model);
await calculator.ensureRatesLoaded();
const parser = new JSONLParser();
const sessionDetector = new SessionDetector({
inactivityTimeout: 300000,
summaryMessageTimeout: 5000,
taskCompletionTimeout: 3000,
delayedTaskCompletionTimeout: parseInt(options.delayedTimeout || '30000'),
minTaskCost: parseFloat(options.minTaskCost || '0.01'),
minTaskMessages: parseInt(options.minTaskMessages || '1'),
enableProgressNotifications: options.notifyProgress !== false,
progressCheckInterval: parseInt(options.progressInterval || '10000'),
minProgressCost: 0.02,
minProgressDuration: 15000
});
const sessionCosts = new Map();
const sessionMessages = new Map();
let dailyTotal = 0;
let currentDay = new Date().toDateString();
let lastSummary = {
total: 0,
sessions: 0,
messages: 0
};
spinner.succeed('Watcher initialized');
console.log(chalk.gray(`Watching: ${basePath}`));
console.log(chalk.gray(`Min cost for notification: $${minCost.toFixed(4)}`));
console.log(chalk.gray('Press Ctrl+C to stop'));
if (options.test) {
console.log(chalk.yellow('\n๐ Test Mode Instructions:'));
console.log(chalk.gray(' 1. Create or modify .jsonl files in the watched directory'));
console.log(chalk.gray(' 2. Add messages in JSONL format (one JSON object per line)'));
console.log(chalk.gray(' 3. Watch for real-time cost updates'));
console.log(chalk.gray(`\nExample message format:`));
console.log(chalk.dim(` {"uuid":"msg-123","type":"assistant","costUSD":0.05,"timestamp":"${new Date().toISOString()}"}}`));
}
console.log();
sessionDetector.on('task-completed', async (data) => {
const durationSec = Math.round(data.taskDuration / 1000);
const completionIcon = data.completionType === 'delayed' ? '๐ฏ' : '๐ฌ';
const completionText = data.completionType === 'delayed' ? 'Task Completed (Confident)' : 'Task Completed';
console.log(chalk.bold.cyan(`\n${completionIcon} ${completionText}`));
console.log(chalk.gray(` Project: ${data.projectName}`));
console.log(chalk.gray(` Duration: ${durationSec} seconds`));
console.log(chalk.gray(` Cost: ${formatCostColored(data.taskCost)}`));
console.log(chalk.gray(` Messages: ${data.assistantMessageCount}`));
console.log(chalk.gray(` Type: ${data.completionType}\n`));
if (options.notifyTask) {
const title = data.completionType === 'delayed'
? `๐ฏ ${shortenProjectName(data.projectName)} - Task Complete`
: `๐ฌ ${shortenProjectName(data.projectName)} - Quick Task`;
const message = [
`โฑ๏ธ ${durationSec}s โข ๐ฌ ${data.assistantMessageCount} messages`,
`๐ฐ ${formatCost(data.taskCost)}`
].join('\n');
await notificationService.sendCustom(title, message, {
soundType: 'task',
timeout: 20,
sound: options.sound && !options.sessionSoundOnly
});
}
});
sessionDetector.on('task-progress', async (data) => {
const durationMin = Math.round(data.currentDuration / 60000);
const durationSec = Math.round((data.currentDuration % 60000) / 1000);
const timeStr = durationMin > 0 ? `${durationMin}m ${durationSec}s` : `${durationSec}s`;
console.log(chalk.bold.yellow(`\nโณ Task in Progress`));
console.log(chalk.gray(` Project: ${data.projectName}`));
console.log(chalk.gray(` Duration: ${timeStr}`));
console.log(chalk.gray(` Current Cost: ${formatCostColored(data.currentCost)}`));
console.log(chalk.gray(` Messages: ${data.assistantMessageCount}`));
if (data.estimatedCompletion) {
const estSec = Math.round(data.estimatedCompletion / 1000);
console.log(chalk.gray(` Est. completion: ~${estSec}s`));
}
console.log();
if (options.notifyProgress !== false) {
const message = [
`โฑ๏ธ ${timeStr} โข ๐ฌ ${data.assistantMessageCount} messages`,
`๐ฐ Current: ${formatCost(data.currentCost)}`,
data.estimatedCompletion ? `โฐ Est: ~${Math.round(data.estimatedCompletion / 1000)}s` : ''
].filter(Boolean).join('\n');
await notificationService.sendCustom(`โณ ${shortenProjectName(data.projectName)} - In Progress`, message, {
sound: false,
timeout: 10
});
}
});
sessionDetector.on('session-completed', async (data) => {
const durationMin = Math.round(data.duration / 60000);
const avgCostPerMessage = data.messageCount > 0 ? data.totalCost / data.messageCount : 0;
console.log(chalk.bold.green(`\nโ
Session Completed: ${data.projectName}`));
console.log(chalk.gray(` Summary: ${data.summary}`));
console.log(chalk.gray(` Duration: ${durationMin} minutes`));
console.log(chalk.gray(` Total Cost: ${formatCostColored(data.totalCost)}`));
console.log(chalk.gray(` Messages: ${data.messageCount}`));
console.log(chalk.gray(` Avg Cost/Message: ${formatCostColored(avgCostPerMessage)}\n`));
if (notifySession && data.totalCost > 0) {
const message = [
`๐ ${data.summary}`,
`โฑ๏ธ ${durationMin} min โข ๐ฌ ${data.messageCount} messages`,
`๐ฐ Total: ${formatCost(data.totalCost)}`
].join('\n');
await notificationService.sendCustom(`โ
${shortenProjectName(data.projectName)} - Session Complete`, message, {
soundType: 'session',
sound: options.sound || options.sessionSoundOnly
});
}
});
watcher.on('new-message', async (message) => {
const messageAge = Date.now() - new Date(message.timestamp).getTime();
const maxAgeMinutes = parseInt(options.maxAge || '5', 10);
const maxAge = maxAgeMinutes * 60 * 1000;
if (messageAge > maxAge && !options.includeExisting) {
logger.debug(`Skipping old message (${Math.round(messageAge / 1000)}s old):`, {
uuid: message.uuid,
timestamp: message.timestamp,
type: message.type
});
sessionDetector.processMessage(message);
return;
}
sessionDetector.processMessage(message);
const todayDate = new Date().toDateString();
if (currentDay !== todayDate) {
console.log(chalk.bold.blue(`\n๐
New day: ${todayDate}\n`));
dailyTotal = 0;
currentDay = todayDate;
}
if (message.type === 'assistant') {
const messageCost = getMessageCost(message, parser, calculator);
if (messageCost === 0)
return;
const sessionId = message.sessionId || 'unknown';
const currentSessionCost = sessionCosts.get(sessionId) || 0;
const currentSessionMessages = sessionMessages.get(sessionId) || 0;
const newSessionCost = currentSessionCost + messageCost;
const newSessionMessages = currentSessionMessages + 1;
sessionCosts.set(sessionId, newSessionCost);
sessionMessages.set(sessionId, newSessionMessages);
dailyTotal += messageCost;
const content = parser.parseMessageContent(message);
const tokens = content?.usage || {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
const cacheEfficiency = calculator.calculateCacheEfficiency(tokens);
const projectName = ProjectParser.getProjectFromMessage(message) || 'Unknown Project';
const msgDateTime = new Date(message.timestamp);
const dateStr = msgDateTime.toLocaleDateString();
const timeStr = msgDateTime.toLocaleTimeString();
const isToday = dateStr === new Date().toLocaleDateString();
const timestamp = isToday ? timeStr : `${dateStr} ${timeStr}`;
if (msgDateTime.toDateString() === currentDay) {
dailyTotal += messageCost;
}
let duration = message.durationMs || 0;
let durationEstimated = false;
if (!duration && content?.ttftMs) {
const outputTime = (tokens.output_tokens || 0) * 10;
duration = content.ttftMs + outputTime;
durationEstimated = true;
}
else if (!duration && tokens.output_tokens > 0) {
duration = Math.max(1000, tokens.output_tokens * 20);
durationEstimated = true;
}
const durationDisplay = durationEstimated
? `${formatDuration(duration)}~`
: formatDuration(duration);
console.log(`[${chalk.gray(timestamp)}] ` +
`Cost: ${formatCostColored(messageCost)} | ` +
`Duration: ${chalk.cyan(durationDisplay)} | ` +
`Tokens: ${chalk.gray(formatNumber(tokens.input_tokens + tokens.output_tokens))} | ` +
`Cache: ${chalk.green(cacheEfficiency.toFixed(0) + '%')} | ` +
chalk.bold(projectName));
if (notifyCost && messageCost >= minCost) {
await notificationService.notifyCostUpdate({
sessionId,
messageId: message.uuid,
cost: messageCost,
duration: duration,
tokens: {
input: tokens.input_tokens || 0,
output: tokens.output_tokens || 0,
cacheHit: tokens.cache_read_input_tokens || 0,
},
sessionTotal: newSessionCost,
dailyTotal,
projectName,
});
}
if (newSessionMessages % 10 === 0) {
console.log(chalk.dim(' โโ Session summary: ') +
`${newSessionMessages} messages | ` +
`Total: ${formatCostColored(newSessionCost)} | ` +
`Avg: ${formatCostColored(newSessionCost / newSessionMessages)}`);
}
}
});
watcher.on('error', (error) => {
logger.error('Watcher error:', error);
console.error(chalk.red('Error:'), error.message);
});
watcher.on('file-added', (filePath) => {
if (options.verbose) {
console.log(chalk.dim(`๐ New file detected: ${filePath}`));
}
});
if (options.verbose) {
watcher.on('parse-error', ({ filePath, line, error }) => {
console.error(chalk.yellow('โ ๏ธ Parse error:'), {
file: filePath,
line: line.substring(0, 50) + '...',
error: error instanceof Error ? error.message : error
});
});
}
if (recentCount > 0 && !options.includeExisting) {
const recentSpinner = ora('Loading recent messages...').start();
try {
const recentMessages = await getRecentMessages(basePath, recentCount, parser, calculator);
recentSpinner.succeed(`Found ${recentMessages.length} recent messages`);
if (recentMessages.length > 0) {
console.log(chalk.bold.cyan('\n๐ Recent Messages:'));
let lastDate = '';
for (const message of recentMessages.reverse()) {
const content = parser.parseMessageContent(message);
const tokens = content?.usage || {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
const cacheEfficiency = calculator.calculateCacheEfficiency(tokens);
const messageDate = new Date(message.timestamp);
const dateStr = messageDate.toLocaleDateString();
const timeStr = messageDate.toLocaleTimeString();
if (dateStr !== lastDate) {
console.log(chalk.bold.gray(`\n๐
${dateStr}`));
lastDate = dateStr;
}
const projectName = ProjectParser.getProjectFromMessage(message) || 'Unknown Project';
const messageCost = getMessageCost(message, parser, calculator);
let duration = message.durationMs || 0;
let durationEstimated = false;
if (!duration && content?.ttftMs) {
const outputTime = (tokens.output_tokens || 0) * 10;
duration = content.ttftMs + outputTime;
durationEstimated = true;
}
else if (!duration && tokens.output_tokens > 0) {
duration = Math.max(1000, tokens.output_tokens * 20);
durationEstimated = true;
}
const durationDisplay = durationEstimated
? `${formatDuration(duration)}~`
: formatDuration(duration);
console.log(`[${chalk.gray(timeStr)}] ` +
`Cost: ${formatCostColored(messageCost)} | ` +
`Duration: ${chalk.cyan(durationDisplay)} | ` +
`Tokens: ${chalk.gray(formatNumber(tokens.input_tokens + tokens.output_tokens))} | ` +
`Cache: ${chalk.green(cacheEfficiency.toFixed(0) + '%')} | ` +
chalk.bold(projectName));
const sessionId = message.sessionId || 'unknown';
const currentSessionCost = sessionCosts.get(sessionId) || 0;
const currentSessionMessages = sessionMessages.get(sessionId) || 0;
sessionCosts.set(sessionId, currentSessionCost + messageCost);
sessionMessages.set(sessionId, currentSessionMessages + 1);
const messageDateStr = new Date(message.timestamp).toDateString();
if (messageDateStr === currentDay) {
dailyTotal += messageCost;
}
}
console.log(chalk.dim('โ'.repeat(60)) + '\n');
}
}
catch (error) {
recentSpinner.fail('Failed to load recent messages');
logger.error('Error loading recent messages:', error);
}
}
await watcher.start();
if (options.test) {
const generateTestData = async () => {
const testFile = `${homedir()}/.cost-claude/test/test-session-${Date.now()}.jsonl`;
const { writeFile } = await import('fs/promises');
console.log(chalk.blue('\n๐ฒ Generating test data...'));
const sessionId = `test-${Date.now()}`;
const messages = [];
messages.push({
uuid: `${sessionId}-1`,
type: 'user',
timestamp: new Date().toISOString(),
sessionId,
message: JSON.stringify({
role: 'user',
content: 'Test question about coding'
})
});
messages.push({
uuid: `${sessionId}-2`,
type: 'assistant',
timestamp: new Date().toISOString(),
sessionId,
costUSD: 0.0234,
durationMs: 2345,
message: JSON.stringify({
role: 'assistant',
content: 'Test response content',
model: model,
usage: {
input_tokens: 523,
output_tokens: 234,
cache_read_input_tokens: 100,
cache_creation_input_tokens: 50
}
})
});
await writeFile(testFile, messages.map(m => JSON.stringify(m)).join('\n') + '\n');
console.log(chalk.green(`โ Created test file: ${testFile}`));
console.log(chalk.gray(` Added ${messages.length} messages`));
};
setTimeout(generateTestData, 2000);
setInterval(generateTestData, 30000);
}
const summaryInterval = setInterval(() => {
const currentMessages = Array.from(sessionMessages.values()).reduce((a, b) => a + b, 0);
const currentSessions = sessionCosts.size;
const hasChanges = dailyTotal !== lastSummary.total ||
currentSessions !== lastSummary.sessions ||
currentMessages !== lastSummary.messages;
if (dailyTotal > 0 && hasChanges) {
console.log(chalk.bold.yellow('\n๐ Hourly Summary:') +
`\n Today's total: ${formatCostColored(dailyTotal)}` +
`\n Active sessions: ${currentSessions}` +
`\n Total messages: ${currentMessages}\n`);
lastSummary = {
total: dailyTotal,
sessions: currentSessions,
messages: currentMessages
};
}
}, 3600000);
let isShuttingDown = false;
process.on('SIGINT', async () => {
if (isShuttingDown)
return;
isShuttingDown = true;
console.log(chalk.yellow('\n\nShutting down...'));
clearInterval(summaryInterval);
const activeSessions = sessionDetector.getActiveSessions();
if (activeSessions.length > 0) {
console.log(chalk.gray(`Completing ${activeSessions.length} active sessions...`));
sessionDetector.completeAllSessions();
await new Promise(resolve => setTimeout(resolve, 1000));
}
try {
await watcher.stop();
}
catch (error) {
logger.debug('Error stopping watcher:', error);
}
if (sessionCosts.size > 0) {
console.log(chalk.bold.blue('\n๐ Final Summary:'));
console.log(` Total sessions: ${sessionCosts.size}`);
console.log(` Total cost: ${formatCostColored(dailyTotal)}`);
const topSessions = Array.from(sessionCosts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
if (topSessions.length > 0) {
console.log(chalk.bold('\n Top Sessions:'));
topSessions.forEach(([sessionId, cost], index) => {
const messages = sessionMessages.get(sessionId) || 0;
console.log(` ${index + 1}. ${sessionId.substring(0, 8)}... - ` +
`${formatCostColored(cost)} (${messages} messages)`);
});
}
}
console.log(chalk.green('\nGoodbye! ๐'));
process.exit(0);
});
await new Promise(() => { });
}
catch (error) {
spinner.fail('Failed to start watcher');
logger.error('Watch command error:', error);
console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
process.exit(1);
}
}
//# sourceMappingURL=watch.js.map