cost-claude
Version:
Claude Code cost monitoring, analytics, and optimization toolkit
327 lines • 12.9 kB
JavaScript
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