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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

### Added
- **Persistent parse cache for all providers.** Repeated CLI runs now reuse parsed source summaries across fresh processes instead of reparsing raw logs every time.
- **`--no-cache` on parse-backed commands.** `report`, `today`, `month`, `status`, `export`, `optimize`, and `compare` can bypass cached entries for that run and rebuild them from raw logs.
- **`Updating cache` stderr progress.** Non-JSON cold or partial cache rebuilds now show progress while CodeBurn refreshes changed sources.

### Changed
- **Cursor now uses the shared parse cache.** The provider-specific Cursor cache path is gone; SQLite-backed provider data now flows through the same persistent cache layer as the other providers.

## 0.8.0 - 2026-04-19

### Added
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,25 @@ codeburn today --format json | jq '.overview.cost'

For the lighter `status --format json` (today + month totals only) or file-based exports (`export -f json`), see above.

## Cache behavior

CodeBurn now keeps a persistent parse cache under `~/.cache/codeburn/source-cache-v1/`.
It applies to every provider. Unchanged sources load from cache across fresh CLI runs,
while changed sources are refreshed on demand so rolling windows like `today` stay current
as new log entries land.

Use `--no-cache` on any command that reads session data to ignore cached entries for that
run and rebuild them from raw logs:

```bash
codeburn today --no-cache
codeburn report --period all --no-cache
codeburn export --no-cache
```

When a non-JSON command needs to rebuild part of the cache, CodeBurn shows an
`Updating cache` progress bar on stderr. JSON output stays clean on stdout.

## Providers

CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
Expand Down
20 changes: 20 additions & 0 deletions bin/codeburn
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env node

import { homedir } from "node:os";

try {
process.cwd();
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error).code === "ENOENT"
) {
process.chdir(homedir());
} else {
throw error;
}
}

await import("../dist/cli.js");
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
"type": "module",
"main": "./dist/cli.js",
"bin": {
"codeburn": "dist/cli.js"
"codeburn": "bin/codeburn"
},
"files": [
"dist"
"dist",
"bin"
],
"scripts": {
"build": "tsup",
Expand Down
73 changes: 51 additions & 22 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './d
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { renderDashboard } from './dashboard.js'
import { parseDateRangeFlags } from './cli-date.js'
import { createTerminalProgressReporter } from './parse-progress.js'
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js'
Expand Down Expand Up @@ -120,10 +121,14 @@ function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
}
}

async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[], noCache = false): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
const projects = filterProjectsByName(
await parseAllSessions(range, provider, { noCache, progress: null }),
project,
exclude,
)
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
const planUsage = await getPlanUsageOrNull()
if (planUsage) {
Expand All @@ -132,6 +137,17 @@ async function runJsonReport(period: Period, provider: string, project: string[]
console.log(JSON.stringify(report, null, 2))
}

function noCacheRequested(opts: { cache?: boolean }): boolean {
return opts.cache === false
}

function buildParseOptions(noCache: boolean, enableProgress: boolean) {
return {
noCache,
progress: createTerminalProgressReporter(enableProgress),
}
}

const program = new Command()
.name('codeburn')
.description('See where your AI coding tokens go - by task, tool, model, and project')
Expand Down Expand Up @@ -288,8 +304,10 @@ program
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30)
.action(async (opts) => {
const noCache = noCacheRequested(opts)
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
Expand All @@ -305,17 +323,17 @@ program
if (customRange) {
const label = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}`
const projects = filterProjectsByName(
await parseAllSessions(customRange, opts.provider),
await parseAllSessions(customRange, opts.provider, { noCache, progress: null }),
opts.project,
opts.exclude,
)
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
} else {
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
await runJsonReport(period, opts.provider, opts.project, opts.exclude, noCache)
}
return
}
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange)
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, noCache)
})

function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
Expand Down Expand Up @@ -367,8 +385,11 @@ program
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.action(async (opts) => {
await loadPricing()
const noCache = noCacheRequested(opts)
const parseOptions = buildParseOptions(noCache, opts.format === 'terminal')
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
if (opts.format === 'menubar-json') {
Expand Down Expand Up @@ -403,7 +424,7 @@ program

if (gapStart.getTime() <= yesterdayEnd.getTime()) {
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
const gapProjects = filterProjectsByName(await parseAllSessions(gapRange, 'all'), opts.project, opts.exclude)
const gapProjects = filterProjectsByName(await parseAllSessions(gapRange, 'all', { noCache, progress: null }), opts.project, opts.exclude)
const gapDays = aggregateProjectsIntoDays(gapProjects)
c = addNewDays(c, gapDays, yesterdayStr)
await saveDailyCache(c)
Expand All @@ -420,7 +441,7 @@ program

if (isAllProviders) {
const todayRange: DateRange = { start: todayStart, end: now }
const todayProjects = fp(await parseAllSessions(todayRange, 'all'))
const todayProjects = fp(await parseAllSessions(todayRange, 'all', { noCache, progress: null }))
const todayDays = aggregateProjectsIntoDays(todayProjects)
const rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
Expand All @@ -431,7 +452,7 @@ program
scanProjects = todayProjects
scanRange = todayRange
} else {
const projects = fp(await parseAllSessions(periodInfo.range, pf))
const projects = fp(await parseAllSessions(periodInfo.range, pf, { noCache, progress: null }))
currentData = buildPeriodData(periodInfo.label, projects)
scanProjects = projects
scanRange = periodInfo.range
Expand All @@ -445,7 +466,7 @@ program
const providers: ProviderCost[] = []
if (isAllProviders) {
const todayRangeForProviders: DateRange = { start: todayStart, end: now }
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all', { noCache, progress: null })))
const rangeStartStr = toDateString(periodInfo.range.start)
const allDaysForProviders = [
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
Expand Down Expand Up @@ -476,7 +497,7 @@ program
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
const historyStartStr = toDateString(new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY))
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions({ start: todayStart, end: now }, 'all')))
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions({ start: todayStart, end: now }, 'all', { noCache, progress: null })))
const fullHistory = [...allCacheDays, ...allTodayDaysForHistory]
const dailyHistory = fullHistory.map(d => {
if (isAllProviders) {
Expand Down Expand Up @@ -521,8 +542,8 @@ program
}

if (opts.format === 'json') {
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf, { noCache, progress: null })))
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf, { noCache, progress: null })))
const { code, rate } = getCurrency()
const payload: {
currency: string
Expand All @@ -542,7 +563,7 @@ program
return
}

const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf, parseOptions))
console.log(renderStatusBar(monthProjects))
})

Expand All @@ -553,13 +574,15 @@ program
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30)
.action(async (opts) => {
const noCache = noCacheRequested(opts)
if (opts.format === 'json') {
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
await runJsonReport('today', opts.provider, opts.project, opts.exclude, noCache)
return
}
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude, null, noCache)
})

program
Expand All @@ -569,13 +592,15 @@ program
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30)
.action(async (opts) => {
const noCache = noCacheRequested(opts)
if (opts.format === 'json') {
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
await runJsonReport('month', opts.provider, opts.project, opts.exclude, noCache)
return
}
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude, null, noCache)
})

program
Expand All @@ -586,14 +611,16 @@ program
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.action(async (opts) => {
await loadPricing()
const parseOptions = buildParseOptions(noCacheRequested(opts), true)
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
const periods: PeriodExport[] = [
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf, parseOptions)) },
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf, parseOptions)) },
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf, parseOptions)) },
]

if (periods.every(p => p.projects.length === 0)) {
Expand Down Expand Up @@ -813,10 +840,11 @@ program
.description('Find token waste and get exact fixes')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.action(async (opts) => {
await loadPricing()
const { range, label } = getDateRange(opts.period)
const projects = await parseAllSessions(range, opts.provider)
const projects = await parseAllSessions(range, opts.provider, buildParseOptions(noCacheRequested(opts), true))
await runOptimize(projects, label, range)
})

Expand All @@ -825,10 +853,11 @@ program
.description('Compare two AI models side-by-side')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', 'all')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.action(async (opts) => {
await loadPricing()
const { range } = getDateRange(opts.period)
await renderCompare(range, opts.provider)
await renderCompare(range, opts.provider, noCacheRequested(opts))
})

program.parse()
8 changes: 6 additions & 2 deletions src/compare.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ModelStats, ComparisonRow, CategoryComparison, WorkingStyleRow } f
import { aggregateModelStats, computeComparison, computeCategoryComparison, computeWorkingStyle, scanSelfCorrections } from './compare-stats.js'
import { formatCost } from './format.js'
import { parseAllSessions } from './parser.js'
import { createTerminalProgressReporter } from './parse-progress.js'
import { getAllProviders } from './providers/index.js'
import type { ProjectSummary, DateRange } from './types.js'

Expand Down Expand Up @@ -441,14 +442,17 @@ export function CompareView({ projects, onBack }: CompareViewProps) {
)
}

export async function renderCompare(range: DateRange, provider: string): Promise<void> {
export async function renderCompare(range: DateRange, provider: string, noCache = false): Promise<void> {
const isTTY = process.stdin.isTTY && process.stdout.isTTY
if (!isTTY) {
process.stdout.write('Model comparison requires an interactive terminal.\n')
return
}

const projects = await parseAllSessions(range, provider)
const projects = await parseAllSessions(range, provider, {
noCache,
progress: createTerminalProgressReporter(true),
})
const { waitUntilExit } = render(
<CompareView projects={projects} onBack={() => process.exit(0)} />
)
Expand Down
Loading