Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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 |
Expand Down
36 changes: 36 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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>', 'Period: today, week, 30days, month, all', 'month')
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', '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')
Expand Down
239 changes: 239 additions & 0 deletions src/overview.ts
Original file line number Diff line number Diff line change
@@ -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<string, { cost: number; tokens: number }>()
const byModel = new Map<string, { cost: number; calls: number; tokens: number }>()
const byCat = new Map<string, { cost: number; turns: number }>()
const byTool = new Map<string, number>()
const byDay = new Map<string, { cost: number; tokens: number; providers: Set<string> }>()
const byProject = new Map<string, { cost: number; sessions: number }>()

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<string>() }
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'
}
Loading