diff --git a/README.md b/README.md index cfdbda38..a02087a7 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,38 @@ codeburn menubar Requires **Node.js 22.13+** and at least one supported tool with session data on disk. For Cursor and OpenCode, `better-sqlite3` installs automatically. +## Your month at a glance + +```bash +codeburn overview # this month, clean tables +codeburn overview --no-color # plain text, ready to paste +codeburn overview --from 2026-06-01 --to 2026-06-15 # any date range +codeburn overview -p all # all time +codeburn overview --provider claude # one tool only +``` + +`codeburn overview` prints a copy-pasteable summary of where your AI spend went: totals (cost, tokens, cache hit), a breakdown by tool and by top model, your highest-value days, top projects, a per-day table, and activity and tool usage. Pipe it anywhere (into `pbcopy`, a PR, Slack, or a tweet); color drops automatically when the output is not a terminal, or pass `--no-color`. + +```text +CodeBurn June 2026 + +Totals + Cost $2,795.10 + Tokens 3.49B in 23.9M / out 20.2M / cache-w 72.5M / cache-r 3.38B + Calls 14,755 sessions 753 + Cache hit 99.3% + +By tool +┌──────────┬───────────┬────────┬───────┐ +│ Tool │ Cost │ Tokens │ Share │ +├──────────┼───────────┼────────┼───────┤ +│ claude │ $2,662.37 │ 3.34B │ 95% │ +│ codex │ $119.12 │ 128.1M │ 4% │ +└──────────┴───────────┴────────┴───────┘ + +(plus Top models, Highest-value days, Top projects, a per-day table, By activity, and Tools) +``` + ## Find and fix waste ```bash @@ -213,7 +245,7 @@ If multiple providers have session data on disk, press `p` in the dashboard to t Each provider doc lists the exact data location, storage format, and known quirks. Linux and Windows paths are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues). -The `--provider` flag filters any command to a single provider: `codeburn report --provider claude`, `codeburn today --provider codex`, `codeburn export --provider cursor`. Works on all commands: `report`, `today`, `month`, `status`, `export`, `optimize`, `compare`, `yield`. +The `--provider` flag filters any command to a single provider: `codeburn report --provider claude`, `codeburn today --provider codex`, `codeburn export --provider cursor`. Works on all commands: `report`, `today`, `month`, `overview`, `status`, `export`, `optimize`, `compare`, `yield`. Adding a new provider is a single file. See `src/providers/codex.ts` for an example. @@ -231,6 +263,7 @@ Run `codeburn` for the dashboard, or use a subcommand below. Most commands also | `codeburn` | Interactive dashboard, last 7 days (the default view) | | `codeburn today` | Today's usage | | `codeburn month` | This calendar month's usage | +| `codeburn overview` | Plain-text monthly summary, copy-pasteable (`--no-color`, `--from`/`--to`) | | `codeburn report -p 30days` | Rolling 30-day window | | `codeburn report -p all` | Every recorded session | | `codeburn report --from 2026-04-01 --to 2026-04-10` | An exact date range | diff --git a/src/main.ts b/src/main.ts index f92c07f3..8c6c674b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,7 @@ import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory import { aggregateModelEfficiency } from './model-efficiency.js' import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator.js' import { renderDashboard } from './dashboard.js' +import { renderOverview } from './overview.js' import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js' import { runOptimize } from './optimize.js' import { renderCompare } from './compare.js' @@ -30,6 +31,14 @@ const require = createRequire(import.meta.url) const { version } = require('../package.json') import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' +// A downstream reader that closes the pipe early (`| head`, quitting `less`, or +// a missing command) makes stdout writes fail with EPIPE. Exit cleanly rather +// than crashing with an unhandled error event. +process.stdout.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') process.exit(0) + throw err +}) + function collect(val: string, acc: string[]): string[] { acc.push(val) return acc @@ -468,6 +477,33 @@ program await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day) }) +program + .command('overview') + .description('Plain-text usage overview, copy-pasteable (defaults to this month)') + .option('-p, --period ', 'Period: today, week, 30days, month, all', 'month') + .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, codex, copilot)', 'all') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--no-color', 'Disable ANSI colors') + .action(async (opts) => { + assertProvider(opts.provider, 'overview') + await loadPricing() + let customRange: DateRange | null = null + try { + customRange = parseDateRangeFlags(opts.from, opts.to) + } catch (err) { + console.error(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) + } + const { range, label } = customRange + ? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) } + : getDateRange(toPeriod(opts.period)) + const projects = filterProjectsByName(await parseAllSessions(range, opts.provider), opts.project, opts.exclude) + process.stdout.write(renderOverview(projects, { label, color: opts.color })) + }) + program .command('status') diff --git a/src/overview.ts b/src/overview.ts new file mode 100644 index 00000000..d268d1d4 --- /dev/null +++ b/src/overview.ts @@ -0,0 +1,239 @@ +import { Chalk, type ChalkInstance } from 'chalk' + +import { homedir } from 'os' + +import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' +import { formatCost as baseCost } from './currency.js' +import { formatTokens as baseTokens } from './format.js' +import { getShortModelName } from './models.js' +import { dateKey } from './day-aggregator.js' + +// Display-only helpers. The shared formatters omit thousands separators and stop +// at M; aggregation uses raw numbers, these only affect rendering. +function formatCost(usd: number): string { + return baseCost(usd).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,') +} +function formatTokens(n: number): string { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B` + return baseTokens(n) +} +function projectName(p: ProjectSummary): string { + const path = p.projectPath + if (path) { + if (path === homedir()) return 'Home' + const base = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop() + if (base) return base + } + return p.project.split('-').filter(Boolean).pop() || p.project +} + +type Col = { header: string; right?: boolean } + +// Visible width, ignoring ANSI color codes, so padding stays aligned. +function vlen(s: string): number { + // eslint-disable-next-line no-control-regex + return s.replace(/\[[0-9;]*m/g, '').length +} + +function renderTable(c: ChalkInstance, cols: Col[], rows: string[][]): string { + const widths = cols.map((col, i) => + Math.max(vlen(col.header), ...rows.map((r) => vlen(r[i] ?? ''))), + ) + const pad = (s: string, w: number, right?: boolean): string => { + const fill = ' '.repeat(Math.max(0, w - vlen(s))) + return right ? fill + s : s + fill + } + const sep = ' ' + c.dim('│') + ' ' + const edge = c.dim('│') + const bar = (l: string, mid: string, r: string): string => + c.dim(l + widths.map((w) => '─'.repeat(w + 2)).join(mid) + r) + const line = (cells: string[], header = false): string => + edge + ' ' + cells.map((cell, i) => { + const padded = pad(cell, widths[i]!, cols[i]!.right) + return header ? c.bold(padded) : padded + }).join(sep) + ' ' + edge + return [ + bar('┌', '┬', '┐'), + line(cols.map((col) => col.header), true), + bar('├', '┼', '┤'), + ...rows.map((r) => line(r)), + bar('└', '┴', '┘'), + ].join('\n') +} + +export function renderOverview( + projects: ProjectSummary[], + opts: { label: string; color: boolean }, +): string { + const c = new Chalk(opts.color ? {} : { level: 0 }) + const heading = (text: string): string => c.cyan.bold(text) + const out: string[] = [] + + out.push(c.bold('CodeBurn') + c.dim(' ' + opts.label)) + out.push('') + + if (projects.length === 0) { + out.push(c.dim(`No usage found for ${opts.label}.`)) + return out.join('\n') + '\n' + } + + let cost = 0, savings = 0, calls = 0, sessions = 0 + let inTok = 0, outTok = 0, cacheR = 0, cacheW = 0 + const byProvider = new Map() + const byModel = new Map() + const byCat = new Map() + const byTool = new Map() + const byDay = new Map }>() + const byProject = new Map() + + for (const p of projects) { + cost += p.totalCostUSD + savings += p.totalSavingsUSD + calls += p.totalApiCalls + sessions += p.sessions.length + const pname = projectName(p) + const pe = byProject.get(pname) ?? { cost: 0, sessions: 0 } + pe.cost += p.totalCostUSD + pe.sessions += p.sessions.length + byProject.set(pname, pe) + for (const s of p.sessions) { + inTok += s.totalInputTokens + outTok += s.totalOutputTokens + cacheR += s.totalCacheReadTokens + cacheW += s.totalCacheWriteTokens + for (const [m, d] of Object.entries(s.modelBreakdown)) { + const e = byModel.get(m) ?? { cost: 0, calls: 0, tokens: 0 } + e.cost += d.costUSD + e.calls += d.calls + e.tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens + byModel.set(m, e) + } + for (const [cat, d] of Object.entries(s.categoryBreakdown)) { + const e = byCat.get(cat) ?? { cost: 0, turns: 0 } + e.cost += d.costUSD + e.turns += d.turns + byCat.set(cat, e) + } + for (const [tool, d] of Object.entries(s.toolBreakdown)) { + byTool.set(tool, (byTool.get(tool) ?? 0) + d.calls) + } + for (const t of s.turns) { + const day = dateKey(t.timestamp || t.assistantCalls[0]?.timestamp || '') + for (const call of t.assistantCalls) { + const tk = call.usage.inputTokens + call.usage.outputTokens + call.usage.cacheReadInputTokens + call.usage.cacheCreationInputTokens + const pv = byProvider.get(call.provider) ?? { cost: 0, tokens: 0 } + pv.cost += call.costUSD + pv.tokens += tk + byProvider.set(call.provider, pv) + if (day) { + const dd = byDay.get(day) ?? { cost: 0, tokens: 0, providers: new Set() } + dd.cost += call.costUSD + dd.tokens += tk + dd.providers.add(call.provider) + byDay.set(day, dd) + } + } + } + } + } + + const totalTokens = inTok + outTok + cacheR + cacheW + const cacheHitDenom = inTok + cacheR + const cacheHit = cacheHitDenom > 0 ? (cacheR / cacheHitDenom) * 100 : 0 + + // Totals + out.push(heading('Totals')) + const kv = (k: string, v: string): string => ' ' + c.dim(k.padEnd(11)) + v + out.push(kv('Cost', c.bold(formatCost(cost)))) + out.push(kv('Tokens', formatTokens(totalTokens) + c.dim(` in ${formatTokens(inTok)} / out ${formatTokens(outTok)} / cache-w ${formatTokens(cacheW)} / cache-r ${formatTokens(cacheR)}`))) + out.push(kv('Calls', calls.toLocaleString() + c.dim(' sessions ') + sessions.toLocaleString())) + out.push(kv('Cache hit', `${cacheHit.toFixed(1)}%`)) + if (savings > 0) out.push(kv('Savings', formatCost(savings) + c.dim(' (local models)'))) + out.push('') + + // By tool (provider) + const providerRows = [...byProvider.entries()] + .filter(([, v]) => v.cost > 0 || v.tokens > 0) + .sort((a, b) => b[1].cost - a[1].cost) + if (providerRows.length) { + out.push(heading('By tool')) + out.push(renderTable(c, + [{ header: 'Tool' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }, { header: 'Share', right: true }], + providerRows.map(([name, v]) => [name, formatCost(v.cost), formatTokens(v.tokens), cost > 0 ? `${Math.round((v.cost / cost) * 100)}%` : '0%']), + )) + out.push('') + } + + // Top models + const modelRows = [...byModel.entries()].filter(([, v]) => v.cost > 0 || v.tokens > 0).sort((a, b) => b[1].cost - a[1].cost).slice(0, 10) + if (modelRows.length) { + out.push(heading('Top models')) + out.push(renderTable(c, + [{ header: 'Model' }, { header: 'Cost', right: true }, { header: 'Calls', right: true }, { header: 'Tokens', right: true }], + modelRows.map(([m, v]) => [getShortModelName(m), formatCost(v.cost), v.calls.toLocaleString(), formatTokens(v.tokens)]), + )) + out.push('') + } + + // Highest-value days + const topDays = [...byDay.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 5) + if (topDays.length) { + out.push(heading('Highest-value days')) + out.push(renderTable(c, + [{ header: '#' }, { header: 'Date' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }], + topDays.map(([d, v], i) => [String(i + 1), d, formatCost(v.cost), formatTokens(v.tokens)]), + )) + out.push('') + } + + // Top projects + const projRows = [...byProject.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 10) + if (projRows.length) { + out.push(heading('Top projects')) + out.push(renderTable(c, + [{ header: 'Project' }, { header: 'Cost', right: true }, { header: 'Sessions', right: true }], + projRows.map(([name, v]) => [name, formatCost(v.cost), v.sessions.toLocaleString()]), + )) + out.push('') + } + + // Daily + const dailyRows = [...byDay.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + if (dailyRows.length) { + out.push(heading('Daily')) + out.push(renderTable(c, + [{ header: 'Date' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }, { header: 'Providers' }], + dailyRows.map(([d, v]) => [d, formatCost(v.cost), formatTokens(v.tokens), [...v.providers].sort().join(', ')]), + )) + out.push('') + } + + // By activity + const catRows = [...byCat.entries()].filter(([, v]) => v.cost > 0 || v.turns > 0).sort((a, b) => b[1].cost - a[1].cost) + if (catRows.length) { + out.push(heading('By activity')) + out.push(renderTable(c, + [{ header: 'Activity' }, { header: 'Cost', right: true }, { header: 'Turns', right: true }], + catRows.map(([cat, v]) => [CATEGORY_LABELS[cat as TaskCategory] ?? cat, formatCost(v.cost), v.turns.toLocaleString()]), + )) + out.push('') + } + + // Tools + const toolRows = [...byTool.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12) + if (toolRows.length) { + out.push(heading('Tools')) + out.push(renderTable(c, + [{ header: 'Tool' }, { header: 'Calls', right: true }], + toolRows.map(([t, n]) => [t, n.toLocaleString()]), + )) + out.push('') + } + + const topTool = providerRows[0]?.[0] + const topModel = modelRows[0] ? getShortModelName(modelRows[0][0]) : '' + const mostly = topTool ? `, mostly ${topTool}${topModel ? ` / ${topModel}` : ''}` : '' + out.push(c.dim('Bottom line: ') + `${opts.label} totals ${formatCost(cost)} across ${formatTokens(totalTokens)} tokens${mostly}.`) + + return out.join('\n') + '\n' +} diff --git a/tests/overview.test.ts b/tests/overview.test.ts new file mode 100644 index 00000000..1914a029 --- /dev/null +++ b/tests/overview.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest' + +import { renderOverview } from '../src/overview.js' +import type { ProjectSummary } from '../src/types.js' + +function makeProject(opts: { + project: string + projectPath: string + cost: number + calls: number + model: string + provider: string + tokens: { input: number; output: number; cacheR: number; cacheW: number } +}): ProjectSummary { + const usage = { + inputTokens: opts.tokens.input, + outputTokens: opts.tokens.output, + cacheReadInputTokens: opts.tokens.cacheR, + cacheCreationInputTokens: opts.tokens.cacheW, + } + return { + project: opts.project, + projectPath: opts.projectPath, + totalCostUSD: opts.cost, + totalSavingsUSD: 0, + totalProxiedCostUSD: 0, + totalApiCalls: opts.calls, + sessions: [{ + sessionId: 's1', + project: opts.project, + totalInputTokens: opts.tokens.input, + totalOutputTokens: opts.tokens.output, + totalCacheReadTokens: opts.tokens.cacheR, + totalCacheWriteTokens: opts.tokens.cacheW, + apiCalls: opts.calls, + modelBreakdown: { [opts.model]: { calls: opts.calls, costUSD: opts.cost, savingsUSD: 0, tokens: usage } }, + categoryBreakdown: { coding: { turns: 1, costUSD: opts.cost, savingsUSD: 0, retries: 0, editTurns: 1, oneShotTurns: 1 } }, + toolBreakdown: { Bash: { calls: 5 }, Read: { calls: 2 } }, + mcpBreakdown: {}, + bashBreakdown: {}, + skillBreakdown: {}, + subagentBreakdown: {}, + turns: [{ + userMessage: 'hi', + timestamp: '2026-06-15T10:00:00Z', + sessionId: 's1', + category: 'coding', + retries: 0, + hasEdits: true, + assistantCalls: [{ provider: opts.provider, model: opts.model, costUSD: opts.cost, usage }], + }], + }], + } as unknown as ProjectSummary +} + +describe('renderOverview', () => { + it('renders the detailed sections from real aggregation', () => { + const out = renderOverview([makeProject({ + project: 'myproject', + projectPath: '/Users/test/myproject', + cost: 12.5, + calls: 3, + model: 'claude-opus-4-8', + provider: 'claude', + tokens: { input: 1000, output: 200, cacheR: 5000, cacheW: 100 }, + })], { label: 'June 2026', color: false }) + + for (const section of ['Totals', 'By tool', 'Top models', 'Highest-value days', 'Top projects', 'Daily', 'By activity', 'Tools']) { + expect(out).toContain(section) + } + expect(out).toContain('Opus 4.8') // model display name + expect(out).toContain('claude') // provider in By tool + expect(out).toContain('myproject') // clean project name from path basename + expect(out).toContain('$12.50') + expect(out).toContain('2026-06-15') + expect(out).toContain('Coding') + expect(out).toContain('Bash') + }) + + it('uses thousands separators and a B unit, and strips color in no-color mode', () => { + const out = renderOverview([makeProject({ + project: 'big', + projectPath: '/Users/test/big', + cost: 1234.56, + calls: 10, + model: 'claude-opus-4-8', + provider: 'claude', + tokens: { input: 1_000_000, output: 1_000_000, cacheR: 2_000_000_000, cacheW: 0 }, + })], { label: 'June 2026', color: false }) + + expect(out).toContain('$1,234.56') + expect(out).toMatch(/2\.\d\dB/) + // no-color mode must not emit ANSI escape codes + // eslint-disable-next-line no-control-regex + expect(out).not.toMatch(/\[/) + }) + + it('reports no usage for an empty range', () => { + const out = renderOverview([], { label: 'June 2026', color: false }) + expect(out).toContain('No usage found for June 2026') + }) +})