UNPKG

cost-claude

Version:

Claude Code cost monitoring, analytics, and optimization toolkit

327 lines 12.9 kB
import { EventEmitter } from 'events'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import chalk from 'chalk'; import { logger } from '../utils/logger.js'; import { JSONLParser } from '../core/jsonl-parser.js'; export class BudgetManager extends EventEmitter { static instance; configPath; statePath; config = {}; usageState = {}; alertedThresholds = { daily: new Set(), weekly: new Set(), monthly: new Set(), total: new Set() }; constructor() { super(); const configDir = join(homedir(), '.cost-claude'); if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } this.configPath = join(configDir, 'budget-config.json'); this.statePath = join(configDir, 'budget-state.json'); this.loadConfig(); this.loadState(); this.checkResetDates(); } static getInstance() { if (!BudgetManager.instance) { BudgetManager.instance = new BudgetManager(); } return BudgetManager.instance; } loadConfig() { try { if (existsSync(this.configPath)) { const data = readFileSync(this.configPath, 'utf-8'); this.config = JSON.parse(data); logger.debug('Budget config loaded:', this.config); } else { this.config = { alertThresholds: [50, 75, 90, 100], notifyOnExceed: true, resetDates: { daily: new Date().toISOString().split('T')[0], weekly: this.getWeekStart().toISOString().split('T')[0], monthly: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0] } }; this.saveConfig(); } } catch (error) { logger.error('Error loading budget config:', error); } } loadState() { try { if (existsSync(this.statePath)) { const data = readFileSync(this.statePath, 'utf-8'); const state = JSON.parse(data); this.usageState = state.usage || {}; if (state.alertedThresholds) { Object.keys(state.alertedThresholds).forEach(type => { this.alertedThresholds[type] = new Set(state.alertedThresholds[type]); }); } } } catch (error) { logger.error('Error loading budget state:', error); } } saveConfig() { try { writeFileSync(this.configPath, JSON.stringify(this.config, null, 2)); logger.debug('Budget config saved'); } catch (error) { logger.error('Error saving budget config:', error); } } saveState() { try { const state = { usage: this.usageState, alertedThresholds: Object.fromEntries(Object.entries(this.alertedThresholds).map(([key, set]) => [key, Array.from(set)])), lastUpdated: new Date().toISOString() }; writeFileSync(this.statePath, JSON.stringify(state, null, 2)); logger.debug('Budget state saved'); } catch (error) { logger.error('Error saving budget state:', error); } } getWeekStart() { const date = new Date(); const day = date.getDay(); const diff = date.getDate() - day + (day === 0 ? -6 : 1); return new Date(date.setDate(diff)); } checkResetDates() { const today = new Date().toISOString().split('T')[0]; const weekStart = this.getWeekStart().toISOString().split('T')[0]; const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]; let updated = false; if (this.config.resetDates?.daily !== today) { this.usageState.daily = 0; this.alertedThresholds['daily']?.clear(); if (!this.config.resetDates) this.config.resetDates = {}; this.config.resetDates.daily = today; updated = true; logger.info('Daily budget reset'); } if (this.config.resetDates?.weekly !== weekStart) { this.usageState.weekly = 0; this.alertedThresholds['weekly']?.clear(); if (!this.config.resetDates) this.config.resetDates = {}; this.config.resetDates.weekly = weekStart; updated = true; logger.info('Weekly budget reset'); } if (this.config.resetDates?.monthly !== monthStart) { this.usageState.monthly = 0; this.alertedThresholds['monthly']?.clear(); if (!this.config.resetDates) this.config.resetDates = {}; this.config.resetDates.monthly = monthStart; updated = true; logger.info('Monthly budget reset'); } if (updated) { this.saveConfig(); this.saveState(); } } setBudget(type, amount) { this.config[type] = amount; this.saveConfig(); logger.info(`${type} budget set to $${amount}`); } setAlertThresholds(thresholds) { this.config.alertThresholds = thresholds.sort((a, b) => a - b); this.saveConfig(); logger.info(`Alert thresholds set to: ${thresholds.join('%, ')}%`); } async updateUsage(cost) { this.checkResetDates(); this.usageState.daily = (this.usageState.daily || 0) + cost; this.usageState.weekly = (this.usageState.weekly || 0) + cost; this.usageState.monthly = (this.usageState.monthly || 0) + cost; this.usageState.total = (this.usageState.total || 0) + cost; this.saveState(); await this.checkBudgets(); } async checkBudgets() { const budgetTypes = ['daily', 'weekly', 'monthly', 'total']; for (const type of budgetTypes) { const limit = this.config[type]; if (!limit) continue; const used = this.usageState[type] || 0; const percentage = (used / limit) * 100; const thresholds = this.config.alertThresholds || [50, 75, 90, 100]; for (const threshold of thresholds) { if (percentage >= threshold && !this.alertedThresholds[type]?.has(threshold)) { this.alertedThresholds[type]?.add(threshold); const alert = { type, threshold, used, limit, message: this.formatAlertMessage(type, threshold, used, limit), timestamp: new Date() }; this.emit('budget-alert', alert); logger.warn(alert.message); } } if (percentage >= 100 && this.config.notifyOnExceed) { this.emit('budget-exceeded', { type, used, limit, message: `${type.charAt(0).toUpperCase() + type.slice(1)} budget exceeded! Used: $${used.toFixed(2)} / Limit: $${limit.toFixed(2)}` }); } } } formatAlertMessage(type, threshold, used, limit) { const typeLabel = type.charAt(0).toUpperCase() + type.slice(1); const remaining = limit - used; if (threshold === 100) { return chalk.red(`⚠️ ${typeLabel} budget reached! Used: $${used.toFixed(2)} / Limit: $${limit.toFixed(2)}`); } else if (threshold >= 90) { return chalk.yellow(`⚠️ ${typeLabel} budget ${threshold}% used! Used: $${used.toFixed(2)} / Limit: $${limit.toFixed(2)} (Remaining: $${remaining.toFixed(2)})`); } else { return chalk.blue(`ℹ️ ${typeLabel} budget ${threshold}% used. Used: $${used.toFixed(2)} / Limit: $${limit.toFixed(2)}`); } } getStatus() { this.checkResetDates(); const calculateStatus = (type) => { const limit = this.config[type] || 0; const used = this.usageState[type] || 0; const percentage = limit > 0 ? (used / limit) * 100 : 0; const exceeded = percentage > 100; return { used, limit, percentage, exceeded }; }; return { daily: calculateStatus('daily'), weekly: calculateStatus('weekly'), monthly: calculateStatus('monthly'), total: calculateStatus('total') }; } async calculateCurrentPeriodUsage(projectPath) { try { const parser = new JSONLParser(); const messages = await parser.parseDirectory(projectPath); this.usageState = { total: this.usageState.total || 0 }; const today = new Date(); const weekStart = this.getWeekStart(); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); for (const message of messages) { if (message.costUSD && message.timestamp) { const messageDate = new Date(message.timestamp); const cost = message.costUSD; this.usageState.total = (this.usageState.total || 0) + cost; if (messageDate.toDateString() === today.toDateString()) { this.usageState.daily = (this.usageState.daily || 0) + cost; } if (messageDate >= weekStart) { this.usageState.weekly = (this.usageState.weekly || 0) + cost; } if (messageDate >= monthStart) { this.usageState.monthly = (this.usageState.monthly || 0) + cost; } } } this.saveState(); logger.info('Budget usage recalculated from project files'); } catch (error) { logger.error('Error calculating current period usage:', error); } } reset(type) { if (type) { this.usageState[type] = 0; this.alertedThresholds[type]?.clear(); logger.info(`${type} budget usage reset`); } else { this.usageState.daily = 0; this.usageState.weekly = 0; this.usageState.monthly = 0; this.alertedThresholds['daily']?.clear(); this.alertedThresholds['weekly']?.clear(); this.alertedThresholds['monthly']?.clear(); logger.info('All period budgets reset (total unchanged)'); } this.saveState(); } getConfig() { return { ...this.config }; } exportReport() { const status = this.getStatus(); const report = [ 'Claude Cost Checker - Budget Report', '='.repeat(40), `Generated: ${new Date().toLocaleString()}`, '', 'Budget Status:', '-'.repeat(40) ]; const formatStatus = (label, status) => { if (status.limit === 0) return `${label}: No limit set`; const bar = this.createProgressBar(status.percentage); const statusIcon = status.exceeded ? '❌' : status.percentage >= 90 ? '⚠️' : '✅'; return [ `${statusIcon} ${label}:`, ` Used: $${status.used.toFixed(2)} / $${status.limit.toFixed(2)} (${status.percentage.toFixed(1)}%)`, ` ${bar}` ].join('\n'); }; report.push(formatStatus('Daily Budget', status.daily)); report.push(''); report.push(formatStatus('Weekly Budget', status.weekly)); report.push(''); report.push(formatStatus('Monthly Budget', status.monthly)); report.push(''); report.push(formatStatus('Total Budget', status.total)); return report.join('\n'); } createProgressBar(percentage, width = 30) { const filled = Math.round((Math.min(percentage, 100) / 100) * width); const empty = width - filled; let bar = '█'.repeat(filled) + '░'.repeat(empty); if (percentage > 100) { bar = chalk.red(bar); } else if (percentage >= 90) { bar = chalk.yellow(bar); } else if (percentage >= 75) { bar = chalk.blue(bar); } else { bar = chalk.green(bar); } return `[${bar}]`; } } //# sourceMappingURL=budget-manager.js.map