codeburn/src/main.ts
Resham Joshi 9b20dfd7dc
feat(context): per-session context token tree across CLI, TUI, and dashboard (#592)
* feat(context): add experimental context command with per-session token tree

Reconstructs context breakdown for a Claude Code session
from its transcript: messages and tokens by role, block type, and tool,
split into full-session history vs the live window since the last
compaction (following preservedSegment.headUuid).

Block tokens are chars/4 estimates, images estimated from sniffed PNG or
JPEG dimensions, reasoning derived per message from output_tokens minus
visible output since transcripts strip thinking text. The exact context
size comes from the last assistant message's API usage, and the gap to
the estimate is shown as derived system prompt and tool overhead.

* feat(context): show session titles in context --list

Reads the latest ai-title entry (summary entry as fallback for older
sessions) from one tail and one head chunk of each transcript, so the
list stays fast on 100MB files.

* feat(dash): context explorer page with Claude Code and Codex session trees

Adds a Context page to the web dashboard: a navbar toggle, a provider
picker (Claude Code / Codex), the 15 most recent sessions with titles,
and per-session expandable details showing the context token tree, the
exact live context vs window, and a live/full-history scope toggle.

Server side adds /api/context/sessions and /api/context/tree with an
mtime-keyed tree cache, plus a Codex rollout builder: response items
feed the tree, compacted entries (with replacement_history) split live
window from full history, reasoning comes exact from cumulative
token_count totals, and model_context_window gives the real window.

* feat(context): interactive TUI for the context command

codeburn context with no arguments now opens an ink TUI: arrow keys to
move, enter to expand a session's token tree inline (with a spinner
while the transcript reads), tab to switch between Claude Code and
Codex, f to toggle live window vs full history, q to quit. Flag and id
forms keep the plain output for scripts, and non-TTY runs fall back to
the static list. Tree rows are shared with the static renderer via
snapshotRows.

* fix(context): review fixes across window detection, codex parsing, and surfaces

Window sizes are no longer guessed from token counts alone: opus-4-8 and
[1m] model ids map to the 1M window (a 190K opus-4-8 session used to
render as 95% of a 200K window), and Codex omits the percentage instead
of borrowing Anthropic constants when the rollout lacks
model_context_window.

Codex compaction accounting now reads the encrypted compaction item and
developer messages from replacement_history, so post-compaction windows
stop undercounting. Both builders stream with largeLineAsBuffer so one
oversized line cannot silently truncate the walk.

The tree API stops sending session.filePath, serves the flattened rows
so the dash renders the same tree as the CLI and TUI, resolves ids
directly instead of re-scanning every session file per request, and the
tree cache evicts LRU. Session discovery honors CLAUDE_CONFIG_DIRS,
CLAUDE_CONFIG_DIR, and CODEX_HOME, stats files in parallel, and id
lookups stat only matching files.

CLI gains --provider codex and machine-readable --list --json; --full
now opens the TUI in full scope instead of bypassing it. The TUI shows
build errors instead of a frozen spinner and drops the ref-plus-counter
repaint for plain state. The dashboard scopes the usage error banner and
device sidebar to the Usage page.
2026-07-02 05:00:03 +02:00

1541 lines
66 KiB
TypeScript

import { isAbsolute } from 'path'
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
import { allProviderNames } from './providers/index.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { toDateString } from './daily-cache.js'
import { dateKey } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator.js'
import { renderDashboard } from './dashboard.js'
import { renderOverview } from './overview.js'
import { runWebDashboard } from './web-dashboard.js'
import { hostname } from 'os'
import { runShareServer } from './sharing/share-run.js'
import { addRemote, linkRemote, pullDevices, renderDevices, summarizeDeviceUsage } from './sharing/host.js'
import { browse } from './sharing/discovery.js'
import { promptChoice } from './sharing/prompt.js'
import { loadRemotes, saveRemotes } from './sharing/store.js'
import type { UsageQuery } from './sharing/share-server.js'
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize } from './optimize.js'
import { runContextCommand } from './context-tree.js'
import { renderCompare } from './compare.js'
import {
installAntigravityStatusLineHook,
runAgyStatusLineHook,
uninstallAntigravityStatusLineHook,
} from './antigravity-statusline.js'
import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type CodeburnConfig, type Plan, type PlanId, type PlanProvider } from './config.js'
import { clampResetDay, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, 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'
// 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
}
function parseNumber(value: string): number {
return Number(value)
}
function parseInteger(value: string): number {
return parseInt(value, 10)
}
type PriceOverrideConfig = NonNullable<CodeburnConfig['priceOverrides']>[string]
type PriceOverrideOptions = {
input?: number
output?: number
cacheRead?: number
cacheCreation?: number
remove?: string
list?: boolean
}
function invalidUsdPerMillionRate(option: string, value: number | undefined): string | null {
if (value === undefined) return null
if (Number.isFinite(value) && value >= 0) return null
return `Invalid ${option}: expected a finite number >= 0 (USD per 1,000,000 tokens).`
}
function formatPriceOverrideParts(rates: PriceOverrideConfig): string {
const parts = [`input ${rates.input}`, `output ${rates.output}`]
if (typeof rates.cacheRead === 'number') parts.push(`cache read ${rates.cacheRead}`)
if (typeof rates.cacheCreation === 'number') parts.push(`cache creation ${rates.cacheCreation}`)
return parts.join(', ')
}
type JsonPlanSummary = {
id: PlanId
provider: PlanProvider
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,
provider: planUsage.plan.provider,
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(),
}
}
type JsonPlanSummaryMap = Partial<Record<PlanProvider, JsonPlanSummary>>
function toJsonPlanSummaryMap(planUsages: PlanUsage[]): JsonPlanSummaryMap {
const summaries: JsonPlanSummaryMap = {}
for (const usage of planUsages) {
summaries[usage.plan.provider] = toJsonPlanSummary(usage)
}
return summaries
}
async function attachPlanSummaries<T extends object>(payload: T): Promise<T & { plan?: JsonPlanSummary; plans?: JsonPlanSummaryMap }> {
const planUsages = await getPlanUsages()
if (planUsages.length > 0) {
return {
...payload,
plan: toJsonPlanSummary(planUsages[0]!),
plans: toJsonPlanSummaryMap(planUsages),
}
}
return payload
}
function planLabel(plan: Plan): string {
const name = planDisplayName(plan.id)
return plan.id === 'custom' ? `${name} (${plan.provider})` : name
}
function toPlanDisplay(plan: Plan) {
return {
id: plan.id,
monthlyUsd: plan.monthlyUsd,
provider: plan.provider,
resetDay: clampResetDay(plan.resetDay),
setAt: plan.setAt || null,
}
}
function sortedPlans(plans: Partial<Record<PlanProvider, Plan>>): Plan[] {
return PLAN_PROVIDERS
.map(provider => plans[provider])
.filter((plan): plan is Plan => plan !== undefined)
}
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)
}
}
function assertProvider(value: string, command: string): void {
const names = allProviderNames()
if (value === 'all' || names.includes(value)) return
process.stderr.write(
`codeburn ${command}: unknown provider "${value}". Valid values: all, ${names.join(', ')}.\n`
)
process.exit(1)
}
function assertScope(value: string, allowed: readonly string[], command: string): void {
if (!allowed.includes(value)) {
process.stderr.write(
`codeburn ${command}: unknown scope "${value}". Valid values: ${allowed.join(', ')}.\n`
)
process.exit(1)
}
}
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary; plans?: JsonPlanSummaryMap } = await attachPlanSummaries(buildJsonReport(projects, label, period))
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 <zone>', '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 ?? {})
setPriceOverrides(config.priceOverrides ?? {})
setLocalModelSavings(config.localModelSavings ?? {})
setProxyPaths(config.proxyPaths ?? [])
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 totalSavingsUSD = projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
// Subscription-covered (proxied) portion of totalCostUSD, and the resulting
// out-of-pocket figure. `cost` stays the full billable/would-be amount.
const totalProxiedUSD = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
const netCostUSD = totalCostUSD - totalProxiedUSD
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<string, { cost: number; savings: number; calls: number; turns: number; editTurns: number; oneShotTurns: number }> = {}
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, savings: 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].savings += call.savingsUSD ?? 0
dailyMap[day].calls += 1
}
}
}
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
date,
cost: convertCost(d.cost),
savings: convertCost(d.savings),
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),
savings: convertCost(p.totalSavingsUSD),
avgCostPerSession: p.sessions.length > 0
? convertCost(p.totalCostUSD / p.sessions.length)
: null,
calls: p.totalApiCalls,
sessions: p.sessions.length,
}))
const modelMap: Record<string, { calls: number; cost: number; savings: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; baselineModel: string }> = {}
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, savings: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, baselineModel: '' } }
modelMap[model].calls += d.calls
modelMap[model].cost += d.costUSD
modelMap[model].savings += d.savingsUSD
modelMap[model].inputTokens += d.tokens.inputTokens
modelMap[model].outputTokens += d.tokens.outputTokens
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
}
}
// Pull the active baseline model name out of the savings config so the
// report can show what the local calls were mapped against without
// forcing the consumer to cross-reference a separate file. Empty when
// no savings are configured for this period.
for (const [model, acc] of Object.entries(modelMap)) {
if (acc.savings <= 0) continue
for (const sess of sessions) {
const bucket = sess.modelBreakdown[model]
if (!bucket || bucket.savingsUSD <= 0) continue
for (const turn of sess.turns) {
for (const call of turn.assistantCalls) {
if (call.model === model && call.savingsBaselineModel) {
acc.baselineModel = call.savingsBaselineModel
break
}
}
if (acc.baselineModel) break
}
if (acc.baselineModel) break
}
}
const models = Object.entries(modelMap)
.sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings))
.map(([name, { cost, savings, baselineModel, ...rest }]) => {
const efficiency = modelEfficiency.get(name)
return {
name,
...rest,
cost: convertCost(cost),
savings: convertCost(savings),
savingsBaselineModel: baselineModel,
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<string, { turns: number; cost: number; savings: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, savings: 0, editTurns: 0, oneShotTurns: 0 } }
catMap[cat].turns += d.turns
catMap[cat].cost += d.costUSD
catMap[cat].savings += d.savingsUSD
catMap[cat].editTurns += d.editTurns
catMap[cat].oneShotTurns += d.oneShotTurns
}
}
const activities = Object.entries(catMap)
.sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings))
.map(([cat, d]) => ({
category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
cost: convertCost(d.cost),
savings: convertCost(d.savings),
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<string, number> = {}
const mcpMap: Record<string, number> = {}
const bashMap: Record<string, number> = {}
const skillMap: Record<string, { turns: number; cost: number; savings: number }> = {}
const subagentMap: Record<string, { calls: number; cost: number; savings: number }> = {}
// Claude Code only: real subagent-transcript spend grouped by agentType
// (workflow-subagent / Explore / general-purpose / …). Distinct from
// subagentMap, which is Task-tool-input based and never sees workflow agents.
const agentTypeMap: Record<string, { calls: number; cost: number; savings: number }> = {}
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
}
for (const [skill, d] of Object.entries(sess.skillBreakdown)) {
if (!skillMap[skill]) skillMap[skill] = { turns: 0, cost: 0, savings: 0 }
skillMap[skill].turns += d.turns
skillMap[skill].cost += d.costUSD
skillMap[skill].savings += d.savingsUSD
}
for (const [sat, d] of Object.entries(sess.subagentBreakdown)) {
if (!subagentMap[sat]) subagentMap[sat] = { calls: 0, cost: 0, savings: 0 }
subagentMap[sat].calls += d.calls
subagentMap[sat].cost += d.costUSD
subagentMap[sat].savings += d.savingsUSD
}
if (sess.agentType) {
if (!agentTypeMap[sess.agentType]) agentTypeMap[sess.agentType] = { calls: 0, cost: 0, savings: 0 }
agentTypeMap[sess.agentType].calls += sess.apiCalls
agentTypeMap[sess.agentType].cost += sess.totalCostUSD
agentTypeMap[sess.agentType].savings += sess.totalSavingsUSD
}
}
const sortedMap = (m: Record<string, number>) =>
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),
savings: convertCost(s.totalSavingsUSD),
calls: s.apiCalls,
})))
.sort((a, b) => (b.cost + b.savings) - (a.cost + a.savings))
.slice(0, 5)
return {
generated: new Date().toISOString(),
currency: code,
period,
periodKey,
overview: {
cost: convertCost(totalCostUSD),
// Subscription-covered spend (config `proxyPaths`) and net out-of-pocket.
// `cost` is the full API-rate figure; `proxiedCost` is the part billed to
// a subscription; `netCost` = cost - proxiedCost. Both 0 with no proxy
// paths configured, so existing consumers are unaffected.
proxiedCost: convertCost(totalProxiedUSD),
netCost: convertCost(netCostUSD),
savings: convertCost(totalSavingsUSD),
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),
skills: Object.entries(skillMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, turns: d.turns, cost: convertCost(d.cost), savings: convertCost(d.savings) })),
subagents: Object.entries(subagentMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, calls: d.calls, cost: convertCost(d.cost), savings: convertCost(d.savings) })),
claudeAgentTypes: Object.entries(agentTypeMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, calls: d.calls, cost: convertCost(d.cost), savings: convertCost(d.savings) })),
topSessions,
}
}
program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
.option('--day <date>', 'Single day to review (YYYY-MM-DD, today, or yesterday). Overrides --period when set')
.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, gemini, cursor, copilot)', 'all')
.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('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'report')
assertProvider(opts.provider, 'report')
let customRange: DateRange | null = null
let daySelection: ReturnType<typeof parseDayFlag> = null
try {
if (opts.day && (opts.from || opts.to)) {
throw new Error('--day cannot be combined with --from or --to')
}
daySelection = parseDayFlag(opts.day)
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()
if (daySelection || customRange) {
const range = daySelection?.range ?? customRange!
const label = daySelection?.label ?? formatDateRangeLabel(opts.from, opts.to)
const periodKey = daySelection ? 'day' : 'custom'
const projects = filterProjectsByName(
await parseAllSessions(range, opts.provider),
opts.project,
opts.exclude,
)
console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(projects, label, periodKey)), null, 2))
} else {
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
}
return
}
const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day)
})
program
.command('share')
.description("Securely share this device's usage with your other devices on the same network")
.option('--port <number>', 'Port to listen on', parseInteger, 7777)
.option('--pair', 'Open a pairing window and print a PIN to add a new device')
.option('--always', 'Keep sharing until stopped (default stops after 10 min idle)')
.action(async (opts) => {
await runShareServer({ port: opts.port, pair: !!opts.pair, always: !!opts.always })
})
program
.command('devices [action] [target]')
.description('Combined usage across your devices. Actions: add (find nearby & pair) | add <host> --pin <pin> (manual) | rm <name>')
.option('--pin <pin>', 'Pairing PIN shown on the device you are adding')
.option('-p, --period <period>', 'Period: today, week, 30days, month, all', 'month')
.option('--port <number>', 'Default port when adding a device', parseInteger, 7777)
.action(async (action: string | undefined, target: string | undefined, opts) => {
await loadPricing()
if (action === 'add') {
if (target && opts.pin) {
const device = await addRemote(target, opts.pin, { defaultPort: opts.port })
console.log(`\n Paired with "${device.name}" (${device.host}:${device.port}).\n`)
return
}
process.stdout.write('\n Looking for devices on your network...\n')
const found = await browse(3000)
if (found.length === 0) {
console.error(' No devices found. On the other Mac run `codeburn share`, and make sure both are on the same Wi-Fi.\n')
process.exit(1)
}
let chosen = found[0]!
if (found.length > 1) {
found.forEach((d, i) => process.stdout.write(` ${i + 1}) ${d.name} (${d.host})\n`))
const n = await promptChoice(' Connect to which? [number]', found.length)
if (n < 1) {
console.error(' Cancelled.\n')
process.exit(1)
}
chosen = found[n - 1]!
}
const device = await linkRemote(chosen, {
onCode: (code) =>
process.stdout.write(`\n Connecting to "${chosen.name}". Confirm this code on that device: ${code}\n Waiting for approval...\n`),
})
console.log(`\n Paired with "${device.name}".\n`)
return
}
if (action === 'rm' || action === 'remove') {
const remotes = await loadRemotes()
const next = remotes.filter((r) => r.name !== target && `${r.host}:${r.port}` !== target)
await saveRemotes(next)
console.log(`\n Removed ${remotes.length - next.length} device(s).\n`)
return
}
const localGetUsage = async (q: { period?: string; from?: string; to?: string }) => {
const customRange = parseDateRangeFlags(q.from, q.to)
const periodInfo = customRange
? { range: customRange, label: formatDateRangeLabel(q.from, q.to) }
: getDateRange(toPeriod(q.period ?? opts.period))
return buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false })
}
const results = await pullDevices(localGetUsage, { period: opts.period }, hostname(), {})
process.stdout.write('\n' + renderDevices(results))
})
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('web')
.description('Open the local web dashboard in your browser')
.option('-p, --period <period>', 'Initial period: today, week, 30days, month, all', 'today')
.option('--from <date>', 'Start date (YYYY-MM-DD)')
.option('--to <date>', 'End date (YYYY-MM-DD)')
.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('--port <number>', 'Port to listen on (falls back to a free port if taken)', parseInteger, 4747)
.option('--no-open', 'Do not open the browser automatically')
.action(async (opts) => {
assertProvider(opts.provider, 'web')
await runWebDashboard({
period: opts.period,
provider: opts.provider,
from: opts.from,
to: opts.to,
project: opts.project,
exclude: opts.exclude,
port: opts.port,
open: opts.open,
})
})
program
.command('status')
.description('Compact status output (today + month)')
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
.option('--scope <scope>', 'Usage scope for menubar-json: local, combined', 'local')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
.option('--day <date>', 'Single day for menubar-json (YYYY-MM-DD, today, or yesterday). Overrides --period when set')
.option('--from <date>', 'Start date (YYYY-MM-DD) for custom range')
.option('--to <date>', 'End date (YYYY-MM-DD) for custom range')
.option('--days <dates>', 'Comma-separated dates (YYYY-MM-DD) for multi-day selection')
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.action(async (opts) => {
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
assertScope(opts.scope, ['local', 'combined'], 'status')
assertProvider(opts.provider, 'status')
if (opts.day && (opts.from || opts.to)) {
process.stderr.write('error: --day cannot be combined with --from or --to\n')
process.exit(1)
}
if (opts.days && (opts.day || opts.from || opts.to)) {
process.stderr.write('error: --days cannot be combined with --day, --from, or --to\n')
process.exit(1)
}
if (opts.format === 'menubar-json' && opts.scope === 'combined' && opts.days) {
process.stderr.write('error: --scope combined cannot be combined with --days\n')
process.exit(1)
}
if (opts.scope === 'combined' && (opts.provider !== 'all' || opts.project.length > 0 || opts.exclude.length > 0)) {
process.stderr.write('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)\n')
process.exit(1)
}
await loadPricing()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
if (opts.format === 'menubar-json') {
const daysSelection = parseDaysFlag(opts.days)
const customRange = daysSelection ? null : parseDateRangeFlags(opts.from, opts.to)
const daySelection = parseDayFlag(opts.day)
const periodInfo = daysSelection
? { range: daysSelection.range, label: daysSelection.label }
: customRange
? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) }
: daySelection ?? getDateRange(opts.period)
const payload = await buildMenubarPayloadForRange(periodInfo, {
provider: pf,
project: opts.project,
exclude: opts.exclude,
daysSelection,
optimize: opts.optimize !== false,
})
if (opts.scope === 'combined') {
// Combined multi-device usage is best-effort enrichment on the menubar's
// hot path. Never let pulling peers (or a corrupt remotes store) take
// down the base local payload: on any failure, emit local data with
// `combined` omitted so the menubar always gets a valid response.
try {
const query: UsageQuery = customRange
? { from: opts.from, to: opts.to }
: daySelection
? { from: daySelection.day, to: daySelection.day }
: { period: opts.period }
const localGetUsage = async (): Promise<typeof payload> => payload
const results = await pullDevices(localGetUsage, query, hostname(), {})
payload.combined = summarizeDeviceUsage(results, {
start: toDateString(periodInfo.range.start),
end: toDateString(periodInfo.range.end),
})
} catch {
// best-effort only: the local payload is still emitted below
}
}
console.log(JSON.stringify(payload))
return
}
if (opts.format === 'json') {
const todayProjects = fp(await parseAllSessions(getDateRange('today').range, pf))
const todayData = buildPeriodData('today', todayProjects)
clearSessionCache()
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
const monthData = buildPeriodData('month', monthProjects)
clearSessionCache()
const { code, rate } = getCurrency()
const payload: {
currency: string
today: { cost: number; savings: number; calls: number }
month: { cost: number; savings: number; calls: number }
localModelSavings?: { today: number; month: number; callsToday: number; callsMonth: number }
plan?: JsonPlanSummary
plans?: JsonPlanSummaryMap
} = {
currency: code,
today: { cost: Math.round(todayData.cost * rate * 100) / 100, savings: Math.round(todayData.savingsUSD * rate * 100) / 100, calls: todayData.calls },
month: { cost: Math.round(monthData.cost * rate * 100) / 100, savings: Math.round(monthData.savingsUSD * rate * 100) / 100, calls: monthData.calls },
}
const savingsCallsToday = todayProjects.reduce((s, p) => s + p.sessions.reduce((s2, sess) => s2 + sess.turns.reduce((s3, turn) => s3 + turn.assistantCalls.reduce((s4, c) => s4 + (c.savingsUSD && c.savingsUSD > 0 ? 1 : 0), 0), 0), 0), 0)
const savingsCallsMonth = monthProjects.reduce((s, p) => s + p.sessions.reduce((s2, sess) => s2 + sess.turns.reduce((s3, turn) => s3 + turn.assistantCalls.reduce((s4, c) => s4 + (c.savingsUSD && c.savingsUSD > 0 ? 1 : 0), 0), 0), 0), 0)
if (todayData.savingsUSD > 0 || monthData.savingsUSD > 0) {
payload.localModelSavings = {
today: payload.today.savings,
month: payload.month.savings,
callsToday: savingsCallsToday,
callsMonth: savingsCallsMonth,
}
}
console.log(JSON.stringify(await attachPlanSummaries(payload)))
return
}
const monthProjects2 = fp(await parseAllSessions(getDateRange('month').range, pf))
clearSessionCache()
console.log(renderStatusBar(monthProjects2))
})
program
.command('today')
.description('Today\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.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('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'today')
assertProvider(opts.provider, 'today')
if (opts.format === 'json') {
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
return
}
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
.command('month')
.description('This month\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.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('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'month')
assertProvider(opts.provider, 'month')
if (opts.format === 'json') {
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
return
}
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 <format>', 'Export format: csv, json', 'csv')
.option('-o, --output <path>', 'Output file path')
.option('--from <date>', 'Start date (YYYY-MM-DD). Exports a single custom period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Exports a single custom period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.action(async (opts) => {
assertFormat(opts.format, ['csv', 'json'], 'export')
assertProvider(opts.provider, 'export')
await loadPricing()
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)
}
let periods: PeriodExport[]
if (customRange) {
periods = [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }]
clearSessionCache()
} else {
const thirtyDayProjects = fp(await parseAllSessions(getDateRange('30days').range, pf))
clearSessionCache()
periods = [
{ label: 'Today', projects: filterProjectsByDateRange(thirtyDayProjects, getDateRange('today').range) },
{ label: '7 Days', projects: filterProjectsByDateRange(thirtyDayProjects, getDateRange('week').range) },
{ label: '30 Days', projects: thirtyDayProjects },
]
}
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 <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 <from>', '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 <from> <to>\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('price-override [model]')
.description('Override or add local model pricing. Rates are USD per 1,000,000 tokens (e.g. --input 0.27).')
.option('--input <usd-per-1M>', 'Input token price in USD per 1,000,000 tokens', parseNumber)
.option('--output <usd-per-1M>', 'Output token price in USD per 1,000,000 tokens', parseNumber)
.option('--cache-read <usd-per-1M>', 'Cache-read token price in USD per 1,000,000 tokens', parseNumber)
.option('--cache-creation <usd-per-1M>', 'Cache-creation token price in USD per 1,000,000 tokens', parseNumber)
.option('--remove <model>', 'Remove a price override')
.option('--list', 'List configured price overrides')
.action(async (model?: string, opts?: PriceOverrideOptions) => {
const config = await readConfig()
const overrides = new Map<string, PriceOverrideConfig>(Object.entries(config.priceOverrides ?? {}))
if (opts?.list || (!model && !opts?.remove)) {
const entries = [...overrides.entries()]
if (entries.length === 0) {
console.log('\n No price overrides configured.')
console.log(' Rates use USD per 1,000,000 tokens.')
console.log(` Config: ${getConfigFilePath()}`)
console.log(' Add one with: codeburn price-override <model> --input <usd-per-1M> --output <usd-per-1M>\n')
} else {
console.log('\n Price overrides (USD per 1,000,000 tokens):')
for (const [name, rates] of entries) {
console.log(` ${name}: ${formatPriceOverrideParts(rates)}`)
}
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
if (opts?.remove) {
if (!overrides.has(opts.remove)) {
console.error(`\n Price override not found: ${opts.remove}\n`)
process.exitCode = 1
return
}
overrides.delete(opts.remove)
config.priceOverrides = overrides.size > 0 ? Object.fromEntries(overrides) : undefined
await saveConfig(config)
console.log(`\n Removed price override: ${opts.remove}\n`)
return
}
const input = opts?.input
const output = opts?.output
const cacheRead = opts?.cacheRead
const cacheCreation = opts?.cacheCreation
if (!model || input === undefined || output === undefined) {
console.error('\n Usage: codeburn price-override <model> --input <usd-per-1M> --output <usd-per-1M> [--cache-read <usd-per-1M>] [--cache-creation <usd-per-1M>]\n')
process.exitCode = 1
return
}
const invalidRate = [
invalidUsdPerMillionRate('--input', input),
invalidUsdPerMillionRate('--output', output),
invalidUsdPerMillionRate('--cache-read', cacheRead),
invalidUsdPerMillionRate('--cache-creation', cacheCreation),
].find((message): message is string => message !== null)
if (invalidRate) {
console.error(`\n ${invalidRate}\n`)
process.exitCode = 1
return
}
const override: PriceOverrideConfig = {
input,
output,
...(cacheRead !== undefined ? { cacheRead } : {}),
...(cacheCreation !== undefined ? { cacheCreation } : {}),
}
overrides.set(model, override)
config.priceOverrides = Object.fromEntries(overrides)
await saveConfig(config)
console.log(`\n Price override saved: ${model}: ${formatPriceOverrideParts(override)}`)
console.log(' Unit: USD per 1,000,000 tokens')
console.log(` Config: ${getConfigFilePath()}\n`)
})
program
.command('model-savings [local] [baseline]')
.description('Track a local model as "savings" rather than cost. Maps a local-model name to a paid baseline so the dashboard can show what the same tokens would have cost on the baseline (e.g. codeburn model-savings "llama3.1:8b" gpt-4o). The local call itself still costs $0 — actual cost is left untouched.')
.option('--remove <local>', 'Remove a savings mapping for the given local model')
.option('--list', 'List configured savings mappings')
.action(async (local?: string, baseline?: string, opts?: { remove?: string; list?: boolean }) => {
const config = await readConfig()
const mappings = { ...(config.localModelSavings ?? {}) }
if (opts?.list || (!local && !opts?.remove)) {
const entries = Object.entries(mappings)
if (entries.length === 0) {
console.log('\n No local-model savings mappings configured.')
console.log(` Config: ${getConfigFilePath()}`)
console.log(' Add one with: codeburn model-savings <local-model> <baseline-model>\n')
} else {
console.log('\n Local-model savings mappings:')
for (const [src, dst] of entries) {
console.log(` ${src} -> ${dst}`)
}
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
if (opts?.remove) {
if (!(opts.remove in mappings)) {
console.error(`\n No savings mapping found for: ${opts.remove}\n`)
process.exitCode = 1
return
}
delete mappings[opts.remove]
config.localModelSavings = Object.keys(mappings).length > 0 ? mappings : undefined
await saveConfig(config)
console.log(`\n Removed savings mapping: ${opts.remove}\n`)
return
}
if (!local || !baseline) {
console.error('\n Usage: codeburn model-savings <local-model> <baseline-model>\n')
process.exitCode = 1
return
}
mappings[local] = baseline
config.localModelSavings = mappings
await saveConfig(config)
// Warn when the same model is also in modelAliases so the user is
// not surprised that `savings` wins for actual cost.
if (config.modelAliases && Object.hasOwn(config.modelAliases, local)) {
console.log(`\n Note: ${local} is also in modelAliases (-> ${config.modelAliases[local]}).`)
console.log(' Local-model savings take precedence: the call is treated as $0 actual cost and the baseline is used for counterfactual savings.')
}
console.log(`\n Savings mapping saved: ${local} -> ${baseline}`)
console.log(` Config: ${getConfigFilePath()}\n`)
})
program
.command('proxy-path [path]')
.description('Mark a project directory as routed through a subscription-backed LLM proxy (e.g. Claude Code over GitHub Copilot). Sessions whose canonical path is under it keep their full API-rate cost as the "would-be" figure, but that amount is reported as subscription-covered so the report can show net out-of-pocket (e.g. codeburn proxy-path ~/work/copilot-repo). Actual API-key sessions elsewhere are untouched.')
.option('--remove <path>', 'Remove a configured proxy path')
.option('--list', 'List configured proxy paths')
.action(async (path?: string, opts?: { remove?: string; list?: boolean }) => {
const config = await readConfig()
// Sanitize the on-disk shape the same way setProxyPaths does: a hand-edited
// config.json could have proxyPaths as a non-array or hold non-string
// entries, which would otherwise throw when spread or normalized below.
const paths = (Array.isArray(config.proxyPaths) ? config.proxyPaths : [])
.filter((p): p is string => typeof p === 'string')
const samePath = (a: string, b: string) => normalizeProxyPath(a) === normalizeProxyPath(b)
if (opts?.list || (!path && !opts?.remove)) {
if (paths.length === 0) {
console.log('\n No proxy paths configured.')
console.log(` Config: ${getConfigFilePath()}`)
console.log(' Add one with: codeburn proxy-path <project-dir>\n')
} else {
console.log('\n Proxy paths (sessions under these are subscription-covered):')
for (const p of paths) console.log(` ${p}`)
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
if (opts?.remove) {
const idx = paths.findIndex(p => samePath(p, opts.remove!))
if (idx === -1) {
console.error(`\n No proxy path found matching: ${opts.remove}\n`)
process.exitCode = 1
return
}
paths.splice(idx, 1)
config.proxyPaths = paths.length > 0 ? paths : undefined
await saveConfig(config)
console.log(`\n Removed proxy path: ${opts.remove}\n`)
return
}
if (!path) {
console.error('\n Usage: codeburn proxy-path <project-dir>\n')
process.exitCode = 1
return
}
const trimmed = path.trim()
if (!isAbsolute(trimmed) || normalizeProxyPath(trimmed) === '') {
console.error(`\n Proxy path must be an absolute project directory (got: ${path}).`)
console.error(' codeburn matches sessions by their recorded absolute cwd; the')
console.error(' filesystem root is too broad and is not accepted.\n')
process.exitCode = 1
return
}
if (paths.some(p => samePath(p, trimmed))) {
console.log(`\n Proxy path already configured: ${trimmed}\n`)
return
}
paths.push(trimmed)
config.proxyPaths = paths
await saveConfig(config)
console.log(`\n Proxy path saved: ${trimmed}`)
console.log(' Sessions under it keep their full API-rate cost as the would-be figure; that amount is reported as subscription-covered (net out-of-pocket excludes it).')
console.log(` Config: ${getConfigFilePath()}\n`)
})
program
.command('plan [action] [id]')
.description('Show or configure a subscription plan for overage tracking')
.option('--format <format>', 'Output format: text or json', 'text')
.option('--monthly-usd <n>', 'Monthly plan price in USD (for custom)', parseNumber)
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor')
.option('--reset-day <n>', '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'
const providerOption = opts?.provider
if (providerOption !== undefined && !isPlanProvider(providerOption)) {
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${providerOption}".\n`)
process.exitCode = 1
return
}
if (mode === 'show') {
const plans = sortedPlans(await readPlans())
.filter(plan => plan.id !== 'none')
.filter(plan => !providerOption || providerOption === 'all' || plan.provider === providerOption)
if (opts?.format === 'json') {
if (plans.length === 0) {
console.log(JSON.stringify({ id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }))
return
}
console.log(JSON.stringify({
...toPlanDisplay(plans[0]!),
plans: Object.fromEntries(plans.map(plan => [plan.provider, toPlanDisplay(plan)])),
}))
return
}
if (plans.length === 0) {
console.log('\n Plan: none')
console.log(' API-pricing view is active.')
console.log(` Config: ${getConfigFilePath()}\n`)
return
}
console.log(`\n Plans: ${plans.length}`)
for (const plan of plans) {
console.log(` ${plan.provider}: ${planLabel(plan)} (${plan.id})`)
console.log(` Budget: $${plan.monthlyUsd}/month`)
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
if (plan.setAt) console.log(` Set at: ${plan.setAt}`)
}
console.log(` Config: ${getConfigFilePath()}\n`)
return
}
if (mode === 'reset') {
await clearPlan(providerOption)
if (providerOption) {
console.log(`\n Plan reset for ${providerOption}.\n`)
} else {
console.log('\n Plan reset. API-pricing view is active.\n')
}
return
}
if (mode !== 'set') {
console.error('\n Usage: codeburn plan [set <id> | reset]\n')
process.exitCode = 1
return
}
if (!id || !isPlanId(id)) {
console.error(`\n Plan id must be one of: ${PLAN_IDS.join(', ')}; 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(providerOption)
if (providerOption) {
console.log(`\n Plan reset for ${providerOption}.\n`)
} else {
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 <positive number>.\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 = providerOption ?? 'all'
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
}
if (providerOption === 'all') {
console.error(`\n ${id} is a ${preset.provider} plan; omit --provider or use --provider ${preset.provider}.\n`)
process.exitCode = 1
return
}
if (providerOption && providerOption !== preset.provider) {
console.error(`\n ${id} is a ${preset.provider} plan; use --provider ${preset.provider} or omit --provider.\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 <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--format <format>', 'Output format: text, json', 'text')
.action(async (opts) => {
assertFormat(opts.format, ['text', 'json'], 'optimize')
assertProvider(opts.provider, 'optimize')
await loadPricing()
const { range, label } = getDateRange(opts.period)
const projects = await parseAllSessions(range, opts.provider)
await runOptimize(projects, label, range, { format: opts.format })
})
program
.command('context [session]')
.description('Context token breakdown per session: what fills the window, by role, block type, and tool (experimental). No session argument opens an interactive browser.')
.option('--list', 'List recent sessions to pick from')
.option('--full', 'Cover the whole session history instead of the live (post-compaction) window')
.option('--json', 'JSON output')
.option('--provider <provider>', 'Session source: claude or codex', 'claude')
.action(async (session: string | undefined, opts: { list?: boolean; full?: boolean; json?: boolean; provider?: string }) => {
if (opts.provider !== 'claude' && opts.provider !== 'codex') {
console.error('context: --provider must be claude or codex')
process.exitCode = 1
return
}
if (!session && !opts.list && !opts.json && process.stdout.isTTY && process.stdin.isTTY) {
const { runContextTui } = await import('./context-tui.js')
await runContextTui({ initialScope: opts.full ? 'full' : 'effective' })
return
}
await runContextCommand(session, opts)
})
program
.command('compare')
.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 (e.g. claude, gemini, cursor, copilot)', 'all')
.action(async (opts) => {
assertProvider(opts.provider, 'compare')
await loadPricing()
const { range } = getDateRange(opts.period)
await renderCompare(range, opts.provider)
})
program
.command('audit')
.description("Token audit: raw provider token fields vs codeburn's displayed totals and cost derivation")
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--from <date>', 'Custom range start (YYYY-MM-DD)')
.option('--to <date>', 'Custom range end (YYYY-MM-DD)')
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
.option('--format <format>', 'Output format: table, json', 'table')
.action(async (opts) => {
assertProvider(opts.provider, 'audit')
const { aggregateAudit, renderAuditTable, renderAuditJson } = await import('./audit-report.js')
await loadPricing()
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 aggregateAudit(projects)
const fmt = (opts.format ?? 'table').toLowerCase()
if (fmt === 'json') {
process.stdout.write(renderAuditJson(rows) + '\n')
} else {
if (rows.length === 0) {
process.stdout.write('No model usage found for the selected period.\n')
return
}
process.stdout.write(renderAuditTable(rows) + '\n')
}
})
program
.command('models')
.description('Per-model token + cost table, optionally exploded by task type')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--from <date>', 'Custom range start (YYYY-MM-DD)')
.option('--to <date>', 'Custom range end (YYYY-MM-DD)')
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
.option('--task <category>', '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 <n>', 'Show only the top N rows', (v: string) => parseInt(v, 10))
.option('--min-cost <usd>', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
.option('--no-totals', 'Suppress the footer totals row')
.option('--format <format>', 'Output format: table, markdown, json, csv', 'table')
.action(async (opts) => {
assertProvider(opts.provider, 'models')
const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js')
await loadPricing()
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 <period>', 'Analysis period: today, week, 30days, month, all', 'week')
.option('--format <format>', 'Output format: text, json', 'text')
.action(async (opts) => {
assertFormat(opts.format, ['text', 'json'], 'yield')
const { computeYield, formatYieldSummary, buildYieldJsonReport } = await import('./yield.js')
await loadPricing()
const { range, label } = getDateRange(opts.period)
if (opts.format !== 'json') {
console.log(`\n Analyzing yield for ${label}...\n`)
}
const summary = await computeYield(range, process.cwd())
if (opts.format === 'json') {
console.log(JSON.stringify(buildYieldJsonReport(summary, label, range), null, 2))
return
}
console.log(formatYieldSummary(summary))
})
program
.command('antigravity-hook')
.description('Install or remove exact Antigravity CLI usage capture')
.argument('<action>', 'install or uninstall')
.option('--force', 'Replace an existing custom Antigravity CLI statusLine command')
.action(async (action: string, opts: { force?: boolean }) => {
try {
if (action === 'install') {
const result = await installAntigravityStatusLineHook(!!opts.force)
console.log(result === 'already-installed'
? '\n Antigravity CLI usage capture is already installed.\n'
: '\n Antigravity CLI usage capture installed.\n')
return
}
if (action === 'uninstall') {
const result = await uninstallAntigravityStatusLineHook()
console.log(result === 'not-installed'
? '\n Antigravity CLI usage capture is not installed.\n'
: result === 'restored'
? '\n Antigravity CLI usage capture removed; previous statusLine restored.\n'
: '\n Antigravity CLI usage capture removed.\n')
return
}
console.error('\n Usage: codeburn antigravity-hook <install|uninstall>\n')
process.exit(1)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Antigravity hook failed: ${message}\n`)
process.exit(1)
}
})
program
.command('agy-statusline-hook', { hidden: true })
.description('Internal Antigravity CLI statusLine hook')
.action(async () => {
await runAgyStatusLineHook()
})
program
.command('mcp')
.description('Run a Model Context Protocol server (stdio) exposing usage + savings to AI agents')
.action(async () => {
// stdout MUST carry only JSON-RPC; route stray logs to stderr.
// NOTE: only console.log is guarded here. process.stdout.write is left intact
// because the MCP StdioServerTransport relies on it for JSON-RPC output.
console.log = ((...args: unknown[]) => process.stderr.write(args.join(' ') + '\n')) as typeof console.log
const { startStdioServer } = await import('./mcp/server.js')
await startStdioServer(version)
})
program.parse()