diff --git a/package.json b/package.json index b831b30..72dd6db 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ ], "scripts": { "bundle-litellm": "node scripts/bundle-litellm.mjs", - "build": "node scripts/bundle-litellm.mjs && tsup", + "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"require('fs').copyFileSync('src/cli.ts','dist/cli.js')\"", "dev": "tsx src/cli.ts", "test": "vitest", "prepublishOnly": "npm run build" @@ -31,7 +31,7 @@ "developer-tools" ], "engines": { - "node": ">=22" + "node": ">=22.13.0" }, "author": "AgentSeal ", "license": "MIT", diff --git a/src/cli.ts b/src/cli.ts index 4ebfe33..dec3d49 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,978 +1,15 @@ -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 { aggregateModelEfficiency } from './model-efficiency.js' -import { renderDashboard } from './dashboard.js' -import { formatDateRangeLabel, 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 { 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(), - } -} - -function assertFormat(value: string, allowed: readonly string[], command: string): void { - if (!allowed.includes(value)) { - process.stderr.write( - `codeburn ${command}: unknown format "${value}". Valid values: ${allowed.join(', ')}.\n` - ) - process.exit(1) - } -} - -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() +#!/usr/bin/env node +// This launcher must stay parseable by Node 18. Do NOT add static imports. +const [major, minor] = process.versions.node.split('.').map(Number) +if (major < 22 || (major === 22 && minor < 13)) { + process.stderr.write( + `codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` + + 'Upgrade at https://nodejs.org/\n', + ) + process.exit(1) +} + +import('./main.js').catch((err) => { + process.stderr.write(String(err?.message ?? err) + '\n') + process.exit(1) }) - -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 - - // Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a - // consumer summing daily[].editTurns over a period gets the same total as - // sum(activities[].editTurns) for that period: every turn counts once for - // `turns`, edit turns count for `editTurns`, edit turns with zero retries - // count for `oneShotTurns`. Issue #279 — daily-resolution efficiency - // dashboards need this without re-deriving from activity-level rollups. - const dailyMap: Record = {} - for (const sess of sessions) { - for (const turn of sess.turns) { - // Prefer the user-message timestamp on the turn; fall back to the first - // assistant-call timestamp when the user line is missing (continuation - // sessions where the JSONL begins mid-conversation). Previously these - // turns dropped from daily but stayed in activities, breaking the - // sum(daily[].editTurns) === sum(activities[].editTurns) invariant. - const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp - if (!ts) { continue } - const day = dateKey(ts) - if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } } - dailyMap[day].turns += 1 - if (turn.hasEdits) { - dailyMap[day].editTurns += 1 - if (turn.retries === 0) dailyMap[day].oneShotTurns += 1 - } - 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, - turns: d.turns, - editTurns: d.editTurns, - oneShotTurns: d.oneShotTurns, - // Pre-computed convenience for dashboards that don't want to do the math. - // null when there are no edit turns (the rate is undefined, not zero — - // a day where the user only had Q&A turns shouldn't read as 0% one-shot). - oneShotRate: d.editTurns > 0 - ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 - : null, - })) - - 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 = {} - const modelEfficiency = aggregateModelEfficiency(projects) - 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 }]) => { - const efficiency = modelEfficiency.get(name) - return { - name, - ...rest, - cost: convertCost(cost), - editTurns: efficiency?.editTurns ?? 0, - oneShotTurns: efficiency?.oneShotTurns ?? 0, - oneShotRate: efficiency?.oneShotRate ?? null, - retriesPerEdit: efficiency?.retriesPerEdit ?? null, - costPerEdit: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined - ? convertCost(efficiency.costPerEditUSD) - : null, - } - }) - - 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)', parseInteger, 30) - .action(async (opts) => { - assertFormat(opts.format, ['tui', 'json'], 'report') - 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 = formatDateRangeLabel(opts.from, opts.to) - 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() - const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined - await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel) - }) - -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 + 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) => { - assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status') - 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)', parseInteger, 30) - .action(async (opts) => { - assertFormat(opts.format, ['tui', 'json'], 'today') - 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)', parseInteger, 30) - .action(async (opts) => { - assertFormat(opts.format, ['tui', 'json'], 'month') - 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') - .option('-f, --format ', 'Export format: csv, json', 'csv') - .option('-o, --output ', 'Output file path') - .option('--from ', 'Start date (YYYY-MM-DD). Exports a single custom period when set') - .option('--to ', 'End date (YYYY-MM-DD). Exports a single custom period when set') - .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) => { - assertFormat(opts.format, ['csv', 'json'], 'export') - await loadPricing() - await hydrateCache() - const pf = opts.provider - const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) - 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 periods: PeriodExport[] = customRange - ? [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }] - : [ - { 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) - } - - const exportedLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : 'Today + 7 Days + 30 Days' - console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\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 }) => { - assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan') - 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('models') - .description('Per-model token + cost table, optionally exploded by task type') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') - .option('--from ', 'Custom range start (YYYY-MM-DD)') - .option('--to ', 'Custom range end (YYYY-MM-DD)') - .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') - .option('--task ', 'Filter to one task type (e.g. feature, debugging, refactoring)') - .option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)') - .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10)) - .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v)) - .option('--no-totals', 'Suppress the footer totals row') - .option('--format ', 'Output format: table, markdown, json, csv', 'table') - .action(async (opts) => { - const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js') - await loadPricing() - await hydrateCache() - - let range - if (opts.from || opts.to) { - const customRange = parseDateRangeFlags(opts.from, opts.to) - if (!customRange) { - process.stderr.write('codeburn: --from and --to must be valid YYYY-MM-DD dates\n') - process.exit(1) - } - range = customRange - } else { - range = getDateRange(opts.period).range - } - - const projects = await parseAllSessions(range, opts.provider) - const rows = await aggregateModels(projects, { - byTask: !!opts.byTask, - taskFilter: opts.task, - topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, - minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01, - }) - - const fmt = (opts.format ?? 'table').toLowerCase() - if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) { - process.stdout.write('No model usage found for the selected period.\n') - return - } - if (fmt === 'json') { - process.stdout.write(renderJson(rows) + '\n') - } else if (fmt === 'csv') { - process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n') - } else if (fmt === 'markdown' || fmt === 'md') { - process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n') - } else if (fmt === 'table') { - process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n') - } else { - process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`) - process.exit(1) - } - }) - -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() diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..4ebfe33 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,978 @@ +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 { aggregateModelEfficiency } from './model-efficiency.js' +import { renderDashboard } from './dashboard.js' +import { formatDateRangeLabel, 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 { 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(), + } +} + +function assertFormat(value: string, allowed: readonly string[], command: string): void { + if (!allowed.includes(value)) { + process.stderr.write( + `codeburn ${command}: unknown format "${value}". Valid values: ${allowed.join(', ')}.\n` + ) + process.exit(1) + } +} + +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 + + // Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a + // consumer summing daily[].editTurns over a period gets the same total as + // sum(activities[].editTurns) for that period: every turn counts once for + // `turns`, edit turns count for `editTurns`, edit turns with zero retries + // count for `oneShotTurns`. Issue #279 — daily-resolution efficiency + // dashboards need this without re-deriving from activity-level rollups. + const dailyMap: Record = {} + for (const sess of sessions) { + for (const turn of sess.turns) { + // Prefer the user-message timestamp on the turn; fall back to the first + // assistant-call timestamp when the user line is missing (continuation + // sessions where the JSONL begins mid-conversation). Previously these + // turns dropped from daily but stayed in activities, breaking the + // sum(daily[].editTurns) === sum(activities[].editTurns) invariant. + const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp + if (!ts) { continue } + const day = dateKey(ts) + if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } } + dailyMap[day].turns += 1 + if (turn.hasEdits) { + dailyMap[day].editTurns += 1 + if (turn.retries === 0) dailyMap[day].oneShotTurns += 1 + } + 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, + turns: d.turns, + editTurns: d.editTurns, + oneShotTurns: d.oneShotTurns, + // Pre-computed convenience for dashboards that don't want to do the math. + // null when there are no edit turns (the rate is undefined, not zero — + // a day where the user only had Q&A turns shouldn't read as 0% one-shot). + oneShotRate: d.editTurns > 0 + ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 + : null, + })) + + 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 = {} + const modelEfficiency = aggregateModelEfficiency(projects) + 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 }]) => { + const efficiency = modelEfficiency.get(name) + return { + name, + ...rest, + cost: convertCost(cost), + editTurns: efficiency?.editTurns ?? 0, + oneShotTurns: efficiency?.oneShotTurns ?? 0, + oneShotRate: efficiency?.oneShotRate ?? null, + retriesPerEdit: efficiency?.retriesPerEdit ?? null, + costPerEdit: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined + ? convertCost(efficiency.costPerEditUSD) + : null, + } + }) + + 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)', parseInteger, 30) + .action(async (opts) => { + assertFormat(opts.format, ['tui', 'json'], 'report') + 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 = formatDateRangeLabel(opts.from, opts.to) + 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() + const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined + await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel) + }) + +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 + 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) => { + assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status') + 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)', parseInteger, 30) + .action(async (opts) => { + assertFormat(opts.format, ['tui', 'json'], 'today') + 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)', parseInteger, 30) + .action(async (opts) => { + assertFormat(opts.format, ['tui', 'json'], 'month') + 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') + .option('-f, --format ', 'Export format: csv, json', 'csv') + .option('-o, --output ', 'Output file path') + .option('--from ', 'Start date (YYYY-MM-DD). Exports a single custom period when set') + .option('--to ', 'End date (YYYY-MM-DD). Exports a single custom period when set') + .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) => { + assertFormat(opts.format, ['csv', 'json'], 'export') + await loadPricing() + await hydrateCache() + const pf = opts.provider + const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) + 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 periods: PeriodExport[] = customRange + ? [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }] + : [ + { 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) + } + + const exportedLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : 'Today + 7 Days + 30 Days' + console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\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 }) => { + assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan') + 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('models') + .description('Per-model token + cost table, optionally exploded by task type') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') + .option('--from ', 'Custom range start (YYYY-MM-DD)') + .option('--to ', 'Custom range end (YYYY-MM-DD)') + .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') + .option('--task ', 'Filter to one task type (e.g. feature, debugging, refactoring)') + .option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)') + .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10)) + .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v)) + .option('--no-totals', 'Suppress the footer totals row') + .option('--format ', 'Output format: table, markdown, json, csv', 'table') + .action(async (opts) => { + const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js') + await loadPricing() + await hydrateCache() + + let range + if (opts.from || opts.to) { + const customRange = parseDateRangeFlags(opts.from, opts.to) + if (!customRange) { + process.stderr.write('codeburn: --from and --to must be valid YYYY-MM-DD dates\n') + process.exit(1) + } + range = customRange + } else { + range = getDateRange(opts.period).range + } + + const projects = await parseAllSessions(range, opts.provider) + const rows = await aggregateModels(projects, { + byTask: !!opts.byTask, + taskFilter: opts.task, + topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, + minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01, + }) + + const fmt = (opts.format ?? 'table').toLowerCase() + if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) { + process.stdout.write('No model usage found for the selected period.\n') + return + } + if (fmt === 'json') { + process.stdout.write(renderJson(rows) + '\n') + } else if (fmt === 'csv') { + process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n') + } else if (fmt === 'markdown' || fmt === 'md') { + process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n') + } else if (fmt === 'table') { + process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n') + } else { + process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`) + process.exit(1) + } + }) + +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() diff --git a/tsup.config.ts b/tsup.config.ts index 2ba26c5..957fdce 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/cli.ts'], + entry: ['src/main.ts'], format: ['esm'], target: 'node20', outDir: 'dist', @@ -9,7 +9,4 @@ export default defineConfig({ splitting: false, sourcemap: true, dts: false, - banner: { - js: '#!/usr/bin/env node', - }, })