feat(overview): plain-text monthly usage overview command (#528)

* feat(overview): plain-text monthly usage overview command

Add 'codeburn overview', a copy-pasteable text report. Defaults to the
current month, with --from/--to to filter and --no-color for plain
output. Renders Totals, By tool, Top models, Highest-value days, Top
projects, a daily table, By activity, and Tools, with colored section
headings (stripped under --no-color or when piped).

Reuses the existing session aggregation. Cost gains thousands
separators and tokens roll up to a B unit for readability via
display-only wrappers, without changing the shared formatters. Project
names use the path basename instead of the sanitized full path.

* fix(cli): exit cleanly on EPIPE when the stdout pipe closes early

Piping output to a reader that closes early (| head, quitting less, a
missing command) made stdout writes throw EPIPE and crash with an
unhandled error event. Handle EPIPE on process.stdout and exit 0 so
piping the overview and other commands behaves normally.

* docs(readme): document and highlight the overview command

Add a 'Your month at a glance' section featuring codeburn overview with
examples and sample output, a Commands-table entry, and the provider
flag note.
This commit is contained in:
Resham Joshi 2026-06-20 11:46:45 +02:00 committed by GitHub
parent fac7d141ce
commit c55dba2dd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 411 additions and 1 deletions

View file

@ -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 |

View file

@ -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>', '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')

239
src/overview.ts Normal file
View file

@ -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'
}

102
tests/overview.test.ts Normal file
View file

@ -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')
})
})