mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-19 16:13:56 +00:00
Merge branch 'main' into feat/omp-support-model-aliases
This commit is contained in:
commit
0c5c6fa655
75 changed files with 9776 additions and 1298 deletions
451
src/cli.ts
451
src/cli.ts
|
|
@ -1,11 +1,18 @@
|
|||
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 } from './parser.js'
|
||||
import { parseAllSessions, filterProjectsByName } from './parser.js'
|
||||
import { convertCost } from './currency.js'
|
||||
import { renderStatusBar } from './format.js'
|
||||
import { installMenubar, renderMenubarFormat, type PeriodData, type ProviderCost, uninstallMenubar } from './menubar.js'
|
||||
import { type PeriodData, type ProviderCost } from './menubar-json.js'
|
||||
import { buildMenubarPayload } from './menubar-json.js'
|
||||
import { addNewDays, getDaysInRange, loadDailyCache, saveDailyCache, withDailyCacheLock } from './daily-cache.js'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
|
||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { renderDashboard } from './dashboard.js'
|
||||
import { parseDateRangeFlags } from './cli-date.js'
|
||||
import { runOptimize, scanAndDetect } from './optimize.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import { readConfig, saveConfig, getConfigFilePath } from './config.js'
|
||||
import { createRequire } from 'node:module'
|
||||
|
|
@ -14,6 +21,13 @@ const require = createRequire(import.meta.url)
|
|||
const { version } = require('../package.json')
|
||||
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
const BACKFILL_DAYS = 365
|
||||
|
||||
function toDateString(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function getDateRange(period: string): { range: DateRange; label: string } {
|
||||
const now = new Date()
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
|
||||
|
|
@ -41,7 +55,11 @@ function getDateRange(period: string): { range: DateRange; label: string } {
|
|||
return { range: { start, end }, label: 'Last 30 Days' }
|
||||
}
|
||||
case 'all': {
|
||||
return { range: { start: new Date(0), end }, label: 'All Time' }
|
||||
// Cap "All Time" to the last 6 months. Older data is rarely actionable for a cost
|
||||
// tracker and keeps the parse path bounded so providers like Codex/Cursor with sparse
|
||||
// data still load in seconds.
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate())
|
||||
return { range: { start, end }, label: 'Last 6 months' }
|
||||
}
|
||||
default: {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
|
||||
|
|
@ -50,7 +68,9 @@ function getDateRange(period: string): { range: DateRange; label: string } {
|
|||
}
|
||||
}
|
||||
|
||||
function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
|
||||
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
||||
|
||||
function toPeriod(s: string): Period {
|
||||
if (s === 'today') return 'today'
|
||||
if (s === 'month') return 'month'
|
||||
if (s === '30days') return '30days'
|
||||
|
|
@ -58,25 +78,204 @@ function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
|
|||
return 'week'
|
||||
}
|
||||
|
||||
function collect(val: string, acc: string[]): string[] {
|
||||
acc.push(val)
|
||||
return acc
|
||||
}
|
||||
|
||||
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)
|
||||
console.log(JSON.stringify(buildJsonReport(projects, label, period), 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')
|
||||
|
||||
program.hook('preAction', async () => {
|
||||
program.hook('preAction', async (thisCommand) => {
|
||||
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 = turn.timestamp.slice(0, 10)
|
||||
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?.slice(0, 10) ?? 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: all, claude, codex, cursor', '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', parseInt)
|
||||
.action(async (opts) => {
|
||||
await renderDashboard(toPeriod(opts.period), opts.provider, opts.refresh)
|
||||
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()
|
||||
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 renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange)
|
||||
})
|
||||
|
||||
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||
|
|
@ -108,6 +307,7 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData
|
|||
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)
|
||||
|
|
@ -121,30 +321,154 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData
|
|||
program
|
||||
.command('status')
|
||||
.description('Compact status output (today + week + month)')
|
||||
.option('--format <format>', 'Output format: terminal, menubar, json', 'terminal')
|
||||
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--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
|
||||
if (opts.format === 'menubar') {
|
||||
const todayRange = getDateRange('today').range
|
||||
const todayData = buildPeriodData('Today', await parseAllSessions(todayRange, pf))
|
||||
const weekData = buildPeriodData('7 Days', await parseAllSessions(getDateRange('week').range, pf))
|
||||
const thirtyDayData = buildPeriodData('30 Days', await parseAllSessions(getDateRange('30days').range, pf))
|
||||
const monthData = buildPeriodData('Month', await parseAllSessions(getDateRange('month').range, pf))
|
||||
const todayProviders: ProviderCost[] = []
|
||||
for (const p of await getAllProviders()) {
|
||||
const data = await parseAllSessions(todayRange, p.name)
|
||||
const cost = data.reduce((s, proj) => s + proj.totalCostUSD, 0)
|
||||
if (cost > 0) todayProviders.push({ name: p.displayName, cost })
|
||||
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 yesterdayEnd = new Date(todayStart.getTime() - 1)
|
||||
const yesterdayStr = toDateString(new Date(todayStart.getTime() - MS_PER_DAY))
|
||||
const isAllProviders = pf === 'all'
|
||||
|
||||
// The daily cache is provider-agnostic: always backfill it from .all so subsequent
|
||||
// provider-filtered reads can derive per-provider cost+calls from DailyEntry.providers.
|
||||
const cache = await withDailyCacheLock(async () => {
|
||||
let c = await loadDailyCache()
|
||||
const gapStart = c.lastComputedDate
|
||||
? new Date(new Date(`${c.lastComputedDate}T00:00:00.000Z`).getTime() + MS_PER_DAY)
|
||||
: new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY)
|
||||
|
||||
if (gapStart.getTime() <= yesterdayEnd.getTime()) {
|
||||
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
|
||||
const gapProjects = filterProjectsByName(await parseAllSessions(gapRange, 'all'), opts.project, opts.exclude)
|
||||
const gapDays = aggregateProjectsIntoDays(gapProjects)
|
||||
c = addNewDays(c, gapDays, yesterdayStr)
|
||||
await saveDailyCache(c)
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
// 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) {
|
||||
const todayRange: DateRange = { start: todayStart, end: now }
|
||||
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 = todayRange
|
||||
} else {
|
||||
const projects = fp(await parseAllSessions(periodInfo.range, pf))
|
||||
currentData = buildPeriodData(periodInfo.label, projects)
|
||||
scanProjects = projects
|
||||
scanRange = periodInfo.range
|
||||
}
|
||||
console.log(renderMenubarFormat(todayData, weekData, thirtyDayData, monthData, todayProviders))
|
||||
|
||||
// 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) {
|
||||
const todayRangeForProviders: DateRange = { start: todayStart, end: now }
|
||||
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
|
||||
const rangeStartStr = toDateString(periodInfo.range.start)
|
||||
const allDaysForProviders = [
|
||||
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
|
||||
...todayDaysForProviders.filter(d => d.date >= rangeStartStr),
|
||||
]
|
||||
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(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY))
|
||||
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
|
||||
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions({ start: todayStart, end: now }, 'all')))
|
||||
const fullHistory = [...allCacheDays, ...allTodayDaysForHistory]
|
||||
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') {
|
||||
const todayData = buildPeriodData('today', await parseAllSessions(getDateRange('today').range, pf))
|
||||
const monthData = buildPeriodData('month', await parseAllSessions(getDateRange('month').range, pf))
|
||||
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()
|
||||
console.log(JSON.stringify({
|
||||
currency: code,
|
||||
|
|
@ -154,7 +478,7 @@ program
|
|||
return
|
||||
}
|
||||
|
||||
const monthProjects = await parseAllSessions(getDateRange('month').range, pf)
|
||||
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
|
||||
console.log(renderStatusBar(monthProjects))
|
||||
})
|
||||
|
||||
|
|
@ -162,18 +486,32 @@ program
|
|||
.command('today')
|
||||
.description('Today\'s usage dashboard')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', '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', parseInt)
|
||||
.action(async (opts) => {
|
||||
await renderDashboard('today', opts.provider, opts.refresh)
|
||||
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: all, claude, codex, cursor', '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', parseInt)
|
||||
.action(async (opts) => {
|
||||
await renderDashboard('month', opts.provider, opts.refresh)
|
||||
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
|
||||
|
|
@ -182,13 +520,16 @@ program
|
|||
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
|
||||
.option('-o, --output <path>', 'Output file path')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
const pf = opts.provider
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
||||
const periods: PeriodExport[] = [
|
||||
{ label: 'Today', projects: await parseAllSessions(getDateRange('today').range, pf) },
|
||||
{ label: '7 Days', projects: await parseAllSessions(getDateRange('week').range, pf) },
|
||||
{ label: '30 Days', projects: await parseAllSessions(getDateRange('30days').range, pf) },
|
||||
{ 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)) {
|
||||
|
|
@ -200,29 +541,37 @@ program
|
|||
const outputPath = opts.output ?? `${defaultName}.${opts.format}`
|
||||
|
||||
let savedPath: string
|
||||
if (opts.format === 'json') {
|
||||
savedPath = await exportJson(periods, outputPath)
|
||||
} else {
|
||||
savedPath = await exportCsv(periods, outputPath)
|
||||
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('install-menubar')
|
||||
.description('Install macOS menu bar plugin (SwiftBar/xbar)')
|
||||
.action(async () => {
|
||||
const result = await installMenubar()
|
||||
console.log(result)
|
||||
})
|
||||
|
||||
program
|
||||
.command('uninstall-menubar')
|
||||
.description('Remove macOS menu bar plugin')
|
||||
.action(async () => {
|
||||
const result = await uninstallMenubar()
|
||||
console.log(result)
|
||||
.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
|
||||
|
|
@ -326,4 +675,16 @@ program
|
|||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
})
|
||||
|
||||
program.parse()
|
||||
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: all, claude, codex, cursor', 'all')
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
const { range, label } = getDateRange(opts.period)
|
||||
const projects = await parseAllSessions(range, opts.provider)
|
||||
await runOptimize(projects, label, range)
|
||||
})
|
||||
|
||||
program.parse()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue