mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
905 lines
36 KiB
TypeScript
905 lines
36 KiB
TypeScript
import { Command } from 'commander'
|
|
import { installMenubarApp } from './menubar-installer.js'
|
|
import { exportCsv, exportJson, type PeriodExport } from './export.js'
|
|
import { loadPricing, setModelAliases } from './models.js'
|
|
import { parseAllSessions, filterProjectsByName } from './parser.js'
|
|
import { convertCost } from './currency.js'
|
|
import { renderStatusBar } from './format.js'
|
|
import { type PeriodData, type ProviderCost } from './menubar-json.js'
|
|
import { buildMenubarPayload } from './menubar-json.js'
|
|
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
|
|
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
|
|
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
|
import { renderDashboard } from './dashboard.js'
|
|
import { parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
|
|
import { runOptimize, scanAndDetect } from './optimize.js'
|
|
import { renderCompare } from './compare.js'
|
|
import { getAllProviders } from './providers/index.js'
|
|
import { buildRedactedShare, writeRedactedShare } from './share.js'
|
|
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
|
|
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
|
|
import { getPresetPlan, isPlanId, isPlanProvider, 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'
|
|
|
|
async function hydrateCache() {
|
|
try {
|
|
return await ensureCacheHydrated(
|
|
(range) => parseAllSessions(range, 'all'),
|
|
aggregateProjectsIntoDays,
|
|
)
|
|
} catch {
|
|
return emptyCache()
|
|
}
|
|
}
|
|
|
|
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 JsonPlanSummary = {
|
|
id: PlanId
|
|
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,
|
|
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(),
|
|
}
|
|
}
|
|
|
|
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 } = buildJsonReport(projects, label, period)
|
|
const planUsage = await getPlanUsageOrNull()
|
|
if (planUsage) {
|
|
report.plan = toJsonPlanSummary(planUsage)
|
|
}
|
|
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 ?? {})
|
|
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 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
|
|
|
|
const dailyMap: Record<string, { cost: number; calls: number }> = {}
|
|
for (const sess of sessions) {
|
|
for (const turn of sess.turns) {
|
|
if (!turn.timestamp) { continue }
|
|
const day = dateKey(turn.timestamp)
|
|
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0 } }
|
|
for (const call of turn.assistantCalls) {
|
|
dailyMap[day].cost += call.costUSD
|
|
dailyMap[day].calls += 1
|
|
}
|
|
}
|
|
}
|
|
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
|
|
date,
|
|
cost: convertCost(d.cost),
|
|
calls: d.calls,
|
|
}))
|
|
|
|
const projectList = projects.map(p => ({
|
|
name: p.project,
|
|
path: p.projectPath,
|
|
cost: convertCost(p.totalCostUSD),
|
|
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; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
|
|
for (const sess of sessions) {
|
|
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
|
|
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
|
|
modelMap[model].calls += d.calls
|
|
modelMap[model].cost += d.costUSD
|
|
modelMap[model].inputTokens += d.tokens.inputTokens
|
|
modelMap[model].outputTokens += d.tokens.outputTokens
|
|
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
|
|
modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
|
|
}
|
|
}
|
|
const models = Object.entries(modelMap)
|
|
.sort(([, a], [, b]) => b.cost - a.cost)
|
|
.map(([name, { cost, ...rest }]) => ({ name, ...rest, cost: convertCost(cost) }))
|
|
|
|
const catMap: Record<string, { turns: number; cost: 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, editTurns: 0, oneShotTurns: 0 } }
|
|
catMap[cat].turns += d.turns
|
|
catMap[cat].cost += d.costUSD
|
|
catMap[cat].editTurns += d.editTurns
|
|
catMap[cat].oneShotTurns += d.oneShotTurns
|
|
}
|
|
}
|
|
const activities = Object.entries(catMap)
|
|
.sort(([, a], [, b]) => b.cost - a.cost)
|
|
.map(([cat, d]) => ({
|
|
category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
|
|
cost: convertCost(d.cost),
|
|
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> = {}
|
|
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
|
|
}
|
|
}
|
|
|
|
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), calls: s.apiCalls })))
|
|
.sort((a, b) => b.cost - a.cost)
|
|
.slice(0, 5)
|
|
|
|
return {
|
|
generated: new Date().toISOString(),
|
|
currency: code,
|
|
period,
|
|
periodKey,
|
|
overview: {
|
|
cost: convertCost(totalCostUSD),
|
|
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),
|
|
topSessions,
|
|
}
|
|
}
|
|
|
|
program
|
|
.command('report', { isDefault: true })
|
|
.description('Interactive usage dashboard')
|
|
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
|
|
.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)', parseInt, 30)
|
|
.action(async (opts) => {
|
|
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)
|
|
}
|
|
|
|
const period = toPeriod(opts.period)
|
|
if (opts.format === 'json') {
|
|
await loadPricing()
|
|
await hydrateCache()
|
|
if (customRange) {
|
|
const label = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}`
|
|
const projects = filterProjectsByName(
|
|
await parseAllSessions(customRange, opts.provider),
|
|
opts.project,
|
|
opts.exclude,
|
|
)
|
|
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
|
|
} else {
|
|
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
|
|
}
|
|
return
|
|
}
|
|
await hydrateCache()
|
|
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange)
|
|
})
|
|
|
|
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
|
const sessions = projects.flatMap(p => p.sessions)
|
|
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
|
|
const modelTotals: Record<string, { calls: number; cost: number }> = {}
|
|
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
|
|
|
|
for (const sess of sessions) {
|
|
inputTokens += sess.totalInputTokens
|
|
outputTokens += sess.totalOutputTokens
|
|
cacheReadTokens += sess.totalCacheReadTokens
|
|
cacheWriteTokens += sess.totalCacheWriteTokens
|
|
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
|
|
if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
|
|
catTotals[cat].turns += d.turns
|
|
catTotals[cat].cost += d.costUSD
|
|
catTotals[cat].editTurns += d.editTurns
|
|
catTotals[cat].oneShotTurns += d.oneShotTurns
|
|
}
|
|
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
|
|
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 }
|
|
modelTotals[model].calls += d.calls
|
|
modelTotals[model].cost += d.costUSD
|
|
}
|
|
}
|
|
|
|
return {
|
|
label,
|
|
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
|
|
calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
|
|
sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
|
|
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
|
|
categories: Object.entries(catTotals)
|
|
.sort(([, a], [, b]) => b.cost - a.cost)
|
|
.map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
|
|
models: Object.entries(modelTotals)
|
|
.sort(([, a], [, b]) => b.cost - a.cost)
|
|
.map(([name, d]) => ({ name, ...d })),
|
|
}
|
|
}
|
|
|
|
program
|
|
.command('status')
|
|
.description('Compact status output (today + week + month)')
|
|
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
|
|
.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('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
|
|
.action(async (opts) => {
|
|
await loadPricing()
|
|
const pf = opts.provider
|
|
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
|
if (opts.format === 'menubar-json') {
|
|
const periodInfo = getDateRange(opts.period)
|
|
const now = new Date()
|
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
|
const isAllProviders = pf === 'all'
|
|
|
|
const cache = await hydrateCache()
|
|
|
|
// CURRENT PERIOD DATA
|
|
// - .all provider: assemble from cache + today (fast)
|
|
// - specific provider: parse the period range with provider filter (correct, but slower)
|
|
let currentData: PeriodData
|
|
let scanProjects: ProjectSummary[]
|
|
let scanRange: DateRange
|
|
|
|
if (isAllProviders) {
|
|
// Parse only today's sessions; historical data comes from cache to avoid double-counting
|
|
const todayRange: DateRange = { start: todayStart, end: new Date() }
|
|
const todayProjects = fp(await parseAllSessions(todayRange, 'all'))
|
|
const todayDays = aggregateProjectsIntoDays(todayProjects)
|
|
const rangeStartStr = toDateString(periodInfo.range.start)
|
|
const rangeEndStr = toDateString(periodInfo.range.end)
|
|
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
|
|
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
|
|
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
|
|
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
|
|
scanProjects = todayProjects
|
|
scanRange = periodInfo.range
|
|
} else {
|
|
const projects = fp(await parseAllSessions(periodInfo.range, pf))
|
|
currentData = buildPeriodData(periodInfo.label, projects)
|
|
scanProjects = projects
|
|
scanRange = periodInfo.range
|
|
}
|
|
|
|
// PROVIDERS
|
|
// For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
|
|
// For specific: just this single provider with its scoped cost.
|
|
const allProviders = await getAllProviders()
|
|
const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
|
|
const providers: ProviderCost[] = []
|
|
if (isAllProviders) {
|
|
// Parse only today; historical provider costs come from cache
|
|
const todayRangeForProviders: DateRange = { start: todayStart, end: new Date() }
|
|
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
|
|
const rangeStartStr = toDateString(periodInfo.range.start)
|
|
const todayStr = toDateString(todayStart)
|
|
const allDaysForProviders = [
|
|
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
|
|
...todayDaysForProviders.filter(d => d.date === todayStr),
|
|
]
|
|
const providerTotals: Record<string, number> = {}
|
|
for (const d of allDaysForProviders) {
|
|
for (const [name, p] of Object.entries(d.providers)) {
|
|
providerTotals[name] = (providerTotals[name] ?? 0) + p.cost
|
|
}
|
|
}
|
|
for (const [name, cost] of Object.entries(providerTotals)) {
|
|
providers.push({ name: displayNameByName.get(name) ?? name, cost })
|
|
}
|
|
for (const p of allProviders) {
|
|
if (providers.some(pc => pc.name === p.displayName)) continue
|
|
const sources = await p.discoverSessions()
|
|
if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 })
|
|
}
|
|
} else {
|
|
const display = displayNameByName.get(pf) ?? pf
|
|
providers.push({ name: display, cost: currentData.cost })
|
|
}
|
|
|
|
// DAILY HISTORY (last 365 days)
|
|
// Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive
|
|
// a provider-filtered history without re-parsing. Tokens aren't broken down per provider
|
|
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
|
|
const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
|
|
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
|
|
// Parse only today for history; historical days come from cache
|
|
const todayRangeForHistory: DateRange = { start: todayStart, end: new Date() }
|
|
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForHistory, 'all')))
|
|
const todayStrForHistory = toDateString(todayStart)
|
|
const fullHistory = [...allCacheDays, ...allTodayDaysForHistory.filter(d => d.date === todayStrForHistory)]
|
|
const dailyHistory = fullHistory.map(d => {
|
|
if (isAllProviders) {
|
|
const topModels = Object.entries(d.models)
|
|
.filter(([name]) => name !== '<synthetic>')
|
|
.sort(([, a], [, b]) => b.cost - a.cost)
|
|
.slice(0, 5)
|
|
.map(([name, m]) => ({
|
|
name,
|
|
cost: m.cost,
|
|
calls: m.calls,
|
|
inputTokens: m.inputTokens,
|
|
outputTokens: m.outputTokens,
|
|
}))
|
|
return {
|
|
date: d.date,
|
|
cost: d.cost,
|
|
calls: d.calls,
|
|
inputTokens: d.inputTokens,
|
|
outputTokens: d.outputTokens,
|
|
cacheReadTokens: d.cacheReadTokens,
|
|
cacheWriteTokens: d.cacheWriteTokens,
|
|
topModels,
|
|
}
|
|
}
|
|
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
|
|
return {
|
|
date: d.date,
|
|
cost: prov.cost,
|
|
calls: prov.calls,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
topModels: [],
|
|
}
|
|
})
|
|
|
|
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
|
|
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
|
|
return
|
|
}
|
|
|
|
if (opts.format === 'json') {
|
|
await hydrateCache()
|
|
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
|
|
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
|
|
const { code, rate } = getCurrency()
|
|
const payload: {
|
|
currency: string
|
|
today: { cost: number; calls: number }
|
|
month: { cost: number; calls: number }
|
|
plan?: JsonPlanSummary
|
|
} = {
|
|
currency: code,
|
|
today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
|
|
month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
|
|
}
|
|
const planUsage = await getPlanUsageOrNull()
|
|
if (planUsage) {
|
|
payload.plan = toJsonPlanSummary(planUsage)
|
|
}
|
|
console.log(JSON.stringify(payload))
|
|
return
|
|
}
|
|
|
|
await hydrateCache()
|
|
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
|
|
console.log(renderStatusBar(monthProjects))
|
|
})
|
|
|
|
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)', parseInt, 30)
|
|
.action(async (opts) => {
|
|
if (opts.format === 'json') {
|
|
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
|
|
return
|
|
}
|
|
await hydrateCache()
|
|
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)', parseInt, 30)
|
|
.action(async (opts) => {
|
|
if (opts.format === 'json') {
|
|
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
|
|
return
|
|
}
|
|
await hydrateCache()
|
|
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
|
|
})
|
|
|
|
program
|
|
.command('export')
|
|
.description('Export usage data to CSV or JSON (includes 1 day, 7 days, 30 days)')
|
|
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
|
|
.option('-o, --output <path>', 'Output file path')
|
|
.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) => {
|
|
await loadPricing()
|
|
await hydrateCache()
|
|
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)) },
|
|
]
|
|
|
|
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)
|
|
}
|
|
|
|
console.log(`\n Exported (Today + 7 Days + 30 Days) to: ${savedPath}\n`)
|
|
})
|
|
|
|
program
|
|
.command('share')
|
|
.description('Export a redacted local JSON bundle for debugging or support')
|
|
.option('-p, --period <period>', 'Share period: today, week, 30days, month, all', 'week')
|
|
.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('-o, --output <path>', 'Output file path')
|
|
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
|
.option('--project <name>', 'Include only projects matching name (repeatable)', collect, [])
|
|
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
|
.action(async (opts) => {
|
|
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)
|
|
}
|
|
|
|
await loadPricing()
|
|
await hydrateCache()
|
|
|
|
const period = toPeriod(opts.period)
|
|
const rangeInfo = customRange
|
|
? { range: customRange, label: `${opts.from ?? 'all'} to ${opts.to ?? 'today'}` }
|
|
: getDateRange(period)
|
|
|
|
const projects = filterProjectsByName(
|
|
await parseAllSessions(rangeInfo.range, opts.provider),
|
|
opts.project,
|
|
opts.exclude,
|
|
)
|
|
|
|
if (projects.length === 0) {
|
|
console.log('\n No usage data found.\n')
|
|
return
|
|
}
|
|
|
|
const now = new Date()
|
|
const timeSuffix = `${now.toTimeString().slice(0, 8).replace(/:/g, '')}${String(now.getMilliseconds()).padStart(3, '0')}`
|
|
const defaultName = `codeburn-share-${toDateString(now)}-${timeSuffix}.json`
|
|
const outputPath = opts.output ?? defaultName
|
|
const share = buildRedactedShare(projects, {
|
|
label: rangeInfo.label,
|
|
range: rangeInfo.range,
|
|
provider: opts.provider,
|
|
project: opts.project,
|
|
exclude: opts.exclude,
|
|
})
|
|
const savedPath = await writeRedactedShare(share, outputPath)
|
|
console.log(`\n Redacted share exported to: ${savedPath}`)
|
|
console.log(' Review before posting publicly; redaction is best-effort.\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('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', 'all')
|
|
.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 }) => {
|
|
const mode = action ?? 'show'
|
|
|
|
if (mode === 'show') {
|
|
const plan = await readPlan()
|
|
const displayPlan = !plan || plan.id === 'none'
|
|
? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
|
|
: {
|
|
id: plan.id,
|
|
monthlyUsd: plan.monthlyUsd,
|
|
provider: plan.provider,
|
|
resetDay: clampResetDay(plan.resetDay),
|
|
setAt: plan.setAt,
|
|
}
|
|
if (opts?.format === 'json') {
|
|
console.log(JSON.stringify(displayPlan))
|
|
return
|
|
}
|
|
if (!plan || plan.id === 'none') {
|
|
console.log('\n Plan: none')
|
|
console.log(' API-pricing view is active.')
|
|
console.log(` Config: ${getConfigFilePath()}\n`)
|
|
return
|
|
}
|
|
console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
|
|
console.log(` Budget: $${plan.monthlyUsd}/month`)
|
|
console.log(` Provider: ${plan.provider}`)
|
|
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
|
|
console.log(` Set at: ${plan.setAt}`)
|
|
console.log(` Config: ${getConfigFilePath()}\n`)
|
|
return
|
|
}
|
|
|
|
if (mode === 'reset') {
|
|
await clearPlan()
|
|
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: claude-pro, claude-max, cursor-pro, custom, none; 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()
|
|
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 = opts?.provider ?? 'all'
|
|
if (!isPlanProvider(provider)) {
|
|
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
|
|
process.exitCode = 1
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
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')
|
|
.action(async (opts) => {
|
|
await loadPricing()
|
|
await hydrateCache()
|
|
const { range, label } = getDateRange(opts.period)
|
|
const projects = await parseAllSessions(range, opts.provider)
|
|
await runOptimize(projects, label, range)
|
|
})
|
|
|
|
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) => {
|
|
await loadPricing()
|
|
await hydrateCache()
|
|
const { range } = getDateRange(opts.period)
|
|
await renderCompare(range, opts.provider)
|
|
})
|
|
|
|
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')
|
|
.action(async (opts) => {
|
|
const { computeYield, formatYieldSummary } = await import('./yield.js')
|
|
await loadPricing()
|
|
await hydrateCache()
|
|
const { range, label } = getDateRange(opts.period)
|
|
console.log(`\n Analyzing yield for ${label}...\n`)
|
|
const summary = await computeYield(range, process.cwd())
|
|
console.log(formatYieldSummary(summary))
|
|
})
|
|
|
|
program.parse()
|