import { Command } from 'commander' import { installMenubarApp } from './menubar-installer.js' import { exportCsv, exportJson, type PeriodExport } from './export.js' import { loadPricing, setModelAliases } from './models.js' import { parseAllSessions, filterProjectsByName } from './parser.js' import { convertCost } from './currency.js' import { renderStatusBar } from './format.js' import { type PeriodData, type ProviderCost } from './menubar-json.js' import { buildMenubarPayload } from './menubar-json.js' import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js' import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { renderDashboard } from './dashboard.js' import { parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js' import { runOptimize, scanAndDetect } from './optimize.js' import { renderCompare } from './compare.js' import { getAllProviders } from './providers/index.js' import { buildRedactedShare, writeRedactedShare } from './share.js' import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js' import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js' import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js' import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const { version } = require('../package.json') import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' async function hydrateCache() { try { return await ensureCacheHydrated( (range) => parseAllSessions(range, 'all'), aggregateProjectsIntoDays, ) } catch { return emptyCache() } } function collect(val: string, acc: string[]): string[] { acc.push(val) return acc } function parseNumber(value: string): number { return Number(value) } function parseInteger(value: string): number { return parseInt(value, 10) } type JsonPlanSummary = { id: PlanId budget: number spent: number percentUsed: number status: 'under' | 'near' | 'over' projectedMonthEnd: number daysUntilReset: number periodStart: string periodEnd: string } function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary { return { id: planUsage.plan.id, budget: convertCost(planUsage.budgetUsd), spent: convertCost(planUsage.spentApiEquivalentUsd), percentUsed: Math.round(planUsage.percentUsed * 10) / 10, status: planUsage.status, projectedMonthEnd: convertCost(planUsage.projectedMonthUsd), daysUntilReset: planUsage.daysUntilReset, periodStart: planUsage.periodStart.toISOString(), periodEnd: planUsage.periodEnd.toISOString(), } } async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise { await loadPricing() const { range, label } = getDateRange(period) const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude) const report: ReturnType & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period) const planUsage = await getPlanUsageOrNull() if (planUsage) { report.plan = toJsonPlanSummary(planUsage) } console.log(JSON.stringify(report, null, 2)) } const program = new Command() .name('codeburn') .description('See where your AI coding tokens go - by task, tool, model, and project') .version(version) .option('--verbose', 'print warnings to stderr on read failures and skipped files') .option('--timezone ', 'IANA timezone for date grouping (e.g. Asia/Tokyo, America/New_York)') program.hook('preAction', async (thisCommand) => { const tz = thisCommand.opts<{ timezone?: string }>().timezone ?? process.env['CODEBURN_TZ'] if (tz) { try { Intl.DateTimeFormat(undefined, { timeZone: tz }) } catch { console.error(`\n Invalid timezone: "${tz}". Use an IANA timezone like "America/New_York" or "Asia/Tokyo".\n`) process.exit(1) } process.env.TZ = tz } const config = await readConfig() setModelAliases(config.modelAliases ?? {}) if (thisCommand.opts<{ verbose?: boolean }>().verbose) { process.env['CODEBURN_VERBOSE'] = '1' } await loadCurrency() }) function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) { const sessions = projects.flatMap(p => p.sessions) const { code } = getCurrency() const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0) const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) // Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write // counts tokens being stored, not served, so it doesn't belong in the denominator. const cacheHitDenom = totalInput + totalCacheRead const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0 const dailyMap: Record = {} for (const sess of sessions) { for (const turn of sess.turns) { if (!turn.timestamp) { continue } const day = dateKey(turn.timestamp) if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0 } } for (const call of turn.assistantCalls) { dailyMap[day].cost += call.costUSD dailyMap[day].calls += 1 } } } const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({ date, cost: convertCost(d.cost), calls: d.calls, })) const projectList = projects.map(p => ({ name: p.project, path: p.projectPath, cost: convertCost(p.totalCostUSD), avgCostPerSession: p.sessions.length > 0 ? convertCost(p.totalCostUSD / p.sessions.length) : null, calls: p.totalApiCalls, sessions: p.sessions.length, })) const modelMap: Record = {} for (const sess of sessions) { for (const [model, d] of Object.entries(sess.modelBreakdown)) { if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } } modelMap[model].calls += d.calls modelMap[model].cost += d.costUSD modelMap[model].inputTokens += d.tokens.inputTokens modelMap[model].outputTokens += d.tokens.outputTokens modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens } } const models = Object.entries(modelMap) .sort(([, a], [, b]) => b.cost - a.cost) .map(([name, { cost, ...rest }]) => ({ name, ...rest, cost: convertCost(cost) })) const catMap: Record = {} for (const sess of sessions) { for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } } catMap[cat].turns += d.turns catMap[cat].cost += d.costUSD catMap[cat].editTurns += d.editTurns catMap[cat].oneShotTurns += d.oneShotTurns } } const activities = Object.entries(catMap) .sort(([, a], [, b]) => b.cost - a.cost) .map(([cat, d]) => ({ category: CATEGORY_LABELS[cat as TaskCategory] ?? cat, cost: convertCost(d.cost), turns: d.turns, editTurns: d.editTurns, oneShotTurns: d.oneShotTurns, oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null, })) const toolMap: Record = {} const mcpMap: Record = {} const bashMap: Record = {} for (const sess of sessions) { for (const [tool, d] of Object.entries(sess.toolBreakdown)) { toolMap[tool] = (toolMap[tool] ?? 0) + d.calls } for (const [server, d] of Object.entries(sess.mcpBreakdown)) { mcpMap[server] = (mcpMap[server] ?? 0) + d.calls } for (const [cmd, d] of Object.entries(sess.bashBreakdown)) { bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls } } const sortedMap = (m: Record) => Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls })) const topSessions = projects .flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls }))) .sort((a, b) => b.cost - a.cost) .slice(0, 5) return { generated: new Date().toISOString(), currency: code, period, periodKey, overview: { cost: convertCost(totalCostUSD), calls: totalCalls, sessions: totalSessions, cacheHitPercent, tokens: { input: totalInput, output: totalOutput, cacheRead: totalCacheRead, cacheWrite: totalCacheWrite, }, }, daily, projects: projectList, models, activities, tools: sortedMap(toolMap), mcpServers: sortedMap(mcpMap), shellCommands: sortedMap(bashMap), topSessions, } } program .command('report', { isDefault: true }) .description('Interactive usage dashboard') .option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week') .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set') .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--format ', 'Output format: tui, json', 'tui') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30) .action(async (opts) => { let customRange: DateRange | null = null try { customRange = parseDateRangeFlags(opts.from, opts.to) } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error(`\n Error: ${message}\n`) process.exit(1) } const period = toPeriod(opts.period) if (opts.format === 'json') { await loadPricing() await hydrateCache() if (customRange) { const label = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}` const projects = filterProjectsByName( await parseAllSessions(customRange, opts.provider), opts.project, opts.exclude, ) console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2)) } else { await runJsonReport(period, opts.provider, opts.project, opts.exclude) } return } await hydrateCache() await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange) }) function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { const sessions = projects.flatMap(p => p.sessions) const catTotals: Record = {} const modelTotals: Record = {} let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 for (const sess of sessions) { inputTokens += sess.totalInputTokens outputTokens += sess.totalOutputTokens cacheReadTokens += sess.totalCacheReadTokens cacheWriteTokens += sess.totalCacheWriteTokens for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } catTotals[cat].turns += d.turns catTotals[cat].cost += d.costUSD catTotals[cat].editTurns += d.editTurns catTotals[cat].oneShotTurns += d.oneShotTurns } for (const [model, d] of Object.entries(sess.modelBreakdown)) { if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 } modelTotals[model].calls += d.calls modelTotals[model].cost += d.costUSD } } return { label, cost: projects.reduce((s, p) => s + p.totalCostUSD, 0), calls: projects.reduce((s, p) => s + p.totalApiCalls, 0), sessions: projects.reduce((s, p) => s + p.sessions.length, 0), inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, categories: Object.entries(catTotals) .sort(([, a], [, b]) => b.cost - a.cost) .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })), models: Object.entries(modelTotals) .sort(([, a], [, b]) => b.cost - a.cost) .map(([name, d]) => ({ name, ...d })), } } program .command('status') .description('Compact status output (today + week + month)') .option('--format ', 'Output format: terminal, menubar-json, json', 'terminal') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) .option('--period ', 'Primary period for menubar-json: today, week, 30days, month, all', 'today') .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)') .action(async (opts) => { await loadPricing() const pf = opts.provider const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) if (opts.format === 'menubar-json') { const periodInfo = getDateRange(opts.period) const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) const isAllProviders = pf === 'all' const cache = await hydrateCache() // CURRENT PERIOD DATA // - .all provider: assemble from cache + today (fast) // - specific provider: parse the period range with provider filter (correct, but slower) let currentData: PeriodData let scanProjects: ProjectSummary[] let scanRange: DateRange if (isAllProviders) { // Parse only today's sessions; historical data comes from cache to avoid double-counting const todayRange: DateRange = { start: todayStart, end: new Date() } const todayProjects = fp(await parseAllSessions(todayRange, 'all')) const todayDays = aggregateProjectsIntoDays(todayProjects) const rangeStartStr = toDateString(periodInfo.range.start) const rangeEndStr = toDateString(periodInfo.range.end) const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr) const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) currentData = buildPeriodDataFromDays(allDays, periodInfo.label) scanProjects = todayProjects scanRange = periodInfo.range } else { const projects = fp(await parseAllSessions(periodInfo.range, pf)) currentData = buildPeriodData(periodInfo.label, projects) scanProjects = projects scanRange = periodInfo.range } // PROVIDERS // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero. // For specific: just this single provider with its scoped cost. const allProviders = await getAllProviders() const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName])) const providers: ProviderCost[] = [] if (isAllProviders) { // Parse only today; historical provider costs come from cache const todayRangeForProviders: DateRange = { start: todayStart, end: new Date() } const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all'))) const rangeStartStr = toDateString(periodInfo.range.start) const todayStr = toDateString(todayStart) const allDaysForProviders = [ ...getDaysInRange(cache, rangeStartStr, yesterdayStr), ...todayDaysForProviders.filter(d => d.date === todayStr), ] const providerTotals: Record = {} for (const d of allDaysForProviders) { for (const [name, p] of Object.entries(d.providers)) { providerTotals[name] = (providerTotals[name] ?? 0) + p.cost } } for (const [name, cost] of Object.entries(providerTotals)) { providers.push({ name: displayNameByName.get(name) ?? name, cost }) } for (const p of allProviders) { if (providers.some(pc => pc.name === p.displayName)) continue const sources = await p.discoverSessions() if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 }) } } else { const display = displayNameByName.get(pf) ?? pf providers.push({ name: display, cost: currentData.cost }) } // DAILY HISTORY (last 365 days) // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive // a provider-filtered history without re-parsing. Tokens aren't broken down per provider // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)) const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) // Parse only today for history; historical days come from cache const todayRangeForHistory: DateRange = { start: todayStart, end: new Date() } const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForHistory, 'all'))) const todayStrForHistory = toDateString(todayStart) const fullHistory = [...allCacheDays, ...allTodayDaysForHistory.filter(d => d.date === todayStrForHistory)] const dailyHistory = fullHistory.map(d => { if (isAllProviders) { const topModels = Object.entries(d.models) .filter(([name]) => name !== '') .sort(([, a], [, b]) => b.cost - a.cost) .slice(0, 5) .map(([name, m]) => ({ name, cost: m.cost, calls: m.calls, inputTokens: m.inputTokens, outputTokens: m.outputTokens, })) return { date: d.date, cost: d.cost, calls: d.calls, inputTokens: d.inputTokens, outputTokens: d.outputTokens, cacheReadTokens: d.cacheReadTokens, cacheWriteTokens: d.cacheWriteTokens, topModels, } } const prov = d.providers[pf] ?? { calls: 0, cost: 0 } return { date: d.date, cost: prov.cost, calls: prov.calls, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, topModels: [], } }) const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory))) return } if (opts.format === 'json') { await hydrateCache() const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf))) const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf))) const { code, rate } = getCurrency() const payload: { currency: string today: { cost: number; calls: number } month: { cost: number; calls: number } plan?: JsonPlanSummary } = { currency: code, today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls }, month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls }, } const planUsage = await getPlanUsageOrNull() if (planUsage) { payload.plan = toJsonPlanSummary(planUsage) } console.log(JSON.stringify(payload)) return } await hydrateCache() const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf)) console.log(renderStatusBar(monthProjects)) }) program .command('today') .description('Today\'s usage dashboard') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--format ', 'Output format: tui, json', 'tui') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30) .action(async (opts) => { if (opts.format === 'json') { await runJsonReport('today', opts.provider, opts.project, opts.exclude) return } await hydrateCache() await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude) }) program .command('month') .description('This month\'s usage dashboard') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--format ', 'Output format: tui, json', 'tui') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30) .action(async (opts) => { if (opts.format === 'json') { await runJsonReport('month', opts.provider, opts.project, opts.exclude) return } await hydrateCache() await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude) }) program .command('export') .description('Export usage data to CSV or JSON (includes 1 day, 7 days, 30 days)') .option('-f, --format ', 'Export format: csv, json', 'csv') .option('-o, --output ', 'Output file path') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) .action(async (opts) => { await loadPricing() await hydrateCache() const pf = opts.provider const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) const periods: PeriodExport[] = [ { label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) }, { label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) }, { label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) }, ] if (periods.every(p => p.projects.length === 0)) { console.log('\n No usage data found.\n') return } const defaultName = `codeburn-${toDateString(new Date())}` const outputPath = opts.output ?? `${defaultName}.${opts.format}` let savedPath: string try { if (opts.format === 'json') { savedPath = await exportJson(periods, outputPath) } else { savedPath = await exportCsv(periods, outputPath) } } catch (err) { // Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.) // throw with a user-readable message. Print just the message, not the stack, so the CLI // doesn't spray its internals at the user. const message = err instanceof Error ? err.message : String(err) console.error(`\n Export failed: ${message}\n`) process.exit(1) } console.log(`\n Exported (Today + 7 Days + 30 Days) to: ${savedPath}\n`) }) program .command('share') .description('Export a redacted local JSON bundle for debugging or support') .option('-p, --period ', 'Share period: today, week, 30days, month, all', 'week') .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set') .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set') .option('-o, --output ', 'Output file path') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--project ', 'Include only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) .action(async (opts) => { let customRange: DateRange | null = null try { customRange = parseDateRangeFlags(opts.from, opts.to) } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error(`\n Error: ${message}\n`) process.exit(1) } await loadPricing() await hydrateCache() const period = toPeriod(opts.period) const rangeInfo = customRange ? { range: customRange, label: `${opts.from ?? 'all'} to ${opts.to ?? 'today'}` } : getDateRange(period) const projects = filterProjectsByName( await parseAllSessions(rangeInfo.range, opts.provider), opts.project, opts.exclude, ) if (projects.length === 0) { console.log('\n No usage data found.\n') return } const now = new Date() const timeSuffix = `${now.toTimeString().slice(0, 8).replace(/:/g, '')}${String(now.getMilliseconds()).padStart(3, '0')}` const defaultName = `codeburn-share-${toDateString(now)}-${timeSuffix}.json` const outputPath = opts.output ?? defaultName const share = buildRedactedShare(projects, { label: rangeInfo.label, range: rangeInfo.range, provider: opts.provider, project: opts.project, exclude: opts.exclude, }) const savedPath = await writeRedactedShare(share, outputPath) console.log(`\n Redacted share exported to: ${savedPath}`) console.log(' Review before posting publicly; redaction is best-effort.\n') }) program .command('menubar') .description('Install and launch the macOS menubar app (one command, no clone)') .option('--force', 'Reinstall even if an older copy is already in ~/Applications') .action(async (opts: { force?: boolean }) => { try { const result = await installMenubarApp({ force: opts.force }) console.log(`\n Ready. ${result.installedPath}\n`) } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error(`\n Menubar install failed: ${message}\n`) process.exit(1) } }) program .command('currency [code]') .description('Set display currency (e.g. codeburn currency GBP)') .option('--symbol ', 'Override the currency symbol') .option('--reset', 'Reset to USD (removes currency config)') .action(async (code?: string, opts?: { symbol?: string; reset?: boolean }) => { if (opts?.reset) { const config = await readConfig() delete config.currency await saveConfig(config) console.log('\n Currency reset to USD.\n') return } if (!code) { const { code: activeCode, rate, symbol } = getCurrency() if (activeCode === 'USD' && rate === 1) { console.log('\n Currency: USD (default)') console.log(` Config: ${getConfigFilePath()}\n`) } else { console.log(`\n Currency: ${activeCode}`) console.log(` Symbol: ${symbol}`) console.log(` Rate: 1 USD = ${rate} ${activeCode}`) console.log(` Config: ${getConfigFilePath()}\n`) } return } const upperCode = code.toUpperCase() if (!isValidCurrencyCode(upperCode)) { console.error(`\n "${code}" is not a valid ISO 4217 currency code.\n`) process.exitCode = 1 return } const config = await readConfig() config.currency = { code: upperCode, ...(opts?.symbol ? { symbol: opts.symbol } : {}), } await saveConfig(config) await loadCurrency() const { rate, symbol } = getCurrency() console.log(`\n Currency set to ${upperCode}.`) console.log(` Symbol: ${symbol}`) console.log(` Rate: 1 USD = ${rate} ${upperCode}`) console.log(` Config saved to ${getConfigFilePath()}\n`) }) program .command('model-alias [from] [to]') .description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)') .option('--remove ', 'Remove an alias') .option('--list', 'List configured aliases') .action(async (from?: string, to?: string, opts?: { remove?: string; list?: boolean }) => { const config = await readConfig() const aliases = config.modelAliases ?? {} if (opts?.list || (!from && !opts?.remove)) { const entries = Object.entries(aliases) if (entries.length === 0) { console.log('\n No model aliases configured.') console.log(` Config: ${getConfigFilePath()}\n`) } else { console.log('\n Model aliases:') for (const [src, dst] of entries) { console.log(` ${src} -> ${dst}`) } console.log(` Config: ${getConfigFilePath()}\n`) } return } if (opts?.remove) { if (!(opts.remove in aliases)) { console.error(`\n Alias not found: ${opts.remove}\n`) process.exitCode = 1 return } delete aliases[opts.remove] config.modelAliases = Object.keys(aliases).length > 0 ? aliases : undefined await saveConfig(config) console.log(`\n Removed alias: ${opts.remove}\n`) return } if (!from || !to) { console.error('\n Usage: codeburn model-alias \n') process.exitCode = 1 return } aliases[from] = to config.modelAliases = aliases await saveConfig(config) console.log(`\n Alias saved: ${from} -> ${to}`) console.log(` Config: ${getConfigFilePath()}\n`) }) program .command('plan [action] [id]') .description('Show or configure a subscription plan for overage tracking') .option('--format ', 'Output format: text or json', 'text') .option('--monthly-usd ', 'Monthly plan price in USD (for custom)', parseNumber) .option('--provider ', 'Provider scope: all, claude, codex, cursor', 'all') .option('--reset-day ', 'Day of month plan resets (1-28)', parseInteger, 1) .action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => { const mode = action ?? 'show' if (mode === 'show') { const plan = await readPlan() const displayPlan = !plan || plan.id === 'none' ? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null } : { id: plan.id, monthlyUsd: plan.monthlyUsd, provider: plan.provider, resetDay: clampResetDay(plan.resetDay), setAt: plan.setAt, } if (opts?.format === 'json') { console.log(JSON.stringify(displayPlan)) return } if (!plan || plan.id === 'none') { console.log('\n Plan: none') console.log(' API-pricing view is active.') console.log(` Config: ${getConfigFilePath()}\n`) return } console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`) console.log(` Budget: $${plan.monthlyUsd}/month`) console.log(` Provider: ${plan.provider}`) console.log(` Reset day: ${clampResetDay(plan.resetDay)}`) console.log(` Set at: ${plan.setAt}`) console.log(` Config: ${getConfigFilePath()}\n`) return } if (mode === 'reset') { await clearPlan() console.log('\n Plan reset. API-pricing view is active.\n') return } if (mode !== 'set') { console.error('\n Usage: codeburn plan [set | reset]\n') process.exitCode = 1 return } if (!id || !isPlanId(id)) { console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`) process.exitCode = 1 return } const resetDay = opts?.resetDay ?? 1 if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) { console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`) process.exitCode = 1 return } if (id === 'none') { await clearPlan() console.log('\n Plan reset. API-pricing view is active.\n') return } if (id === 'custom') { if (opts?.monthlyUsd === undefined) { console.error('\n Custom plans require --monthly-usd .\n') process.exitCode = 1 return } const monthlyUsd = opts.monthlyUsd if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) { console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`) process.exitCode = 1 return } const provider = opts?.provider ?? 'all' if (!isPlanProvider(provider)) { console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`) process.exitCode = 1 return } await savePlan({ id: 'custom', monthlyUsd, provider, resetDay, setAt: new Date().toISOString(), }) console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`) console.log(` Config saved to ${getConfigFilePath()}\n`) return } const preset = getPresetPlan(id) if (!preset) { console.error(`\n Unknown preset "${id}".\n`) process.exitCode = 1 return } await savePlan({ ...preset, resetDay, setAt: new Date().toISOString(), }) console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`) console.log(` Provider: ${preset.provider}`) console.log(` Reset day: ${resetDay}`) console.log(` Config saved to ${getConfigFilePath()}\n`) }) program .command('optimize') .description('Find token waste and get exact fixes') .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .action(async (opts) => { await loadPricing() await hydrateCache() const { range, label } = getDateRange(opts.period) const projects = await parseAllSessions(range, opts.provider) await runOptimize(projects, label, range) }) program .command('compare') .description('Compare two AI models side-by-side') .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'all') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .action(async (opts) => { await loadPricing() await hydrateCache() const { range } = getDateRange(opts.period) await renderCompare(range, opts.provider) }) program .command('yield') .description('Track which AI spend shipped to main vs reverted/abandoned (experimental)') .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'week') .action(async (opts) => { const { computeYield, formatYieldSummary } = await import('./yield.js') await loadPricing() await hydrateCache() const { range, label } = getDateRange(opts.period) console.log(`\n Analyzing yield for ${label}...\n`) const summary = await computeYield(range, process.cwd()) console.log(formatYieldSummary(summary)) }) program.parse()