feat(mac): native Swift menubar app + one-command install

Introduces mac/ with a native SwiftUI menubar app that replaces the
previous SwiftBar plugin entirely. Install via `npx codeburn menubar`,
which downloads the .app from GitHub Releases, strips Gatekeeper
quarantine, and drops it into ~/Applications.

Highlights

- mac/ SwiftUI app: agent tabs, Today/7/30/Month/All period switcher,
  Trend/Forecast/Pulse/Stats/Plan insights, activity + model
  breakdowns, optimize findings, CSV/JSON export, Star-on-GitHub
  banner, live 60s refresh, instant currency switching with offline FX
  cache.
- Security: CodeburnCLI argv-based spawn (no shell interpretation),
  SafeFile symlink guards + O_NOFOLLOW writes, FX rate clamping to
  [0.0001, 1_000_000], keychain filtered to account == "default",
  removed byte-window credential log, in-flight refresh guard, POSIX
  flock on config.json writes, TerminalLauncher validates argv before
  AppleScript interpolation.
- Performance: shared static NumberFormatter (thousands of allocations
  per popover redraw eliminated), concurrent pipe drain with 20 MB cap
  + 60s timeout in DataClient, Observation-tracked reactive UI, 5-min
  payload cache keyed on (period, provider).
- CLI: new `codeburn menubar` subcommand that downloads + installs +
  launches the .app (no clone, no build). New `status --format
  menubar-json` payload builder. `export` rewritten to produce a
  folder of one-table-per-file CSVs with a `.codeburn-export` marker
  so arbitrary -o paths cannot be silently deleted.
- Removed: src/menubar.ts (SwiftBar plugin generator),
  install-menubar / uninstall-menubar subcommands, `status --format
  menubar` directive output, tests/menubar.test.ts,
  tests/security/menubar-injection.test.ts.
- Release: .github/workflows/release-menubar.yml builds universal
  binary, assembles .app, ad-hoc signs, zips, uploads on mac-v* tag
  push. Runs on the free macos-latest runner.

Tests

- 230 TypeScript tests pass
- 10 Swift CapacityEstimator tests pass
- TypeScript typecheck clean
- Swift release build clean
This commit is contained in:
Resham Joshi 2026-04-17 16:55:56 -07:00
parent 69268a9e91
commit 495a254338
46 changed files with 6433 additions and 575 deletions

View file

@ -1,13 +1,17 @@
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing } from './models.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 { runOptimize } from './optimize.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'
@ -16,6 +20,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)
@ -43,7 +54,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)
@ -98,8 +113,10 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
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)
const allInput = totalInput + totalCacheRead + totalCacheWrite
const cacheHitPercent = allInput > 0 ? Math.round((totalCacheRead / allInput) * 1000) / 10 : 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) {
@ -262,6 +279,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)
@ -275,27 +293,148 @@ 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
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
if (opts.format === 'menubar') {
const todayRange = getDateRange('today').range
const todayData = buildPeriodData('Today', fp(await parseAllSessions(todayRange, pf)))
const weekData = buildPeriodData('7 Days', fp(await parseAllSessions(getDateRange('week').range, pf)))
const thirtyDayData = buildPeriodData('30 Days', fp(await parseAllSessions(getDateRange('30days').range, pf)))
const monthData = buildPeriodData('Month', fp(await parseAllSessions(getDateRange('month').range, pf)))
const todayProviders: ProviderCost[] = []
for (const p of await getAllProviders()) {
const data = fp(await parseAllSessions(todayRange, p.name))
const cost = data.reduce((s, proj) => s + proj.totalCostUSD, 0)
if (cost > 0) todayProviders.push({ name: p.displayName, cost })
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
}
@ -374,29 +513,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

View file

@ -12,6 +12,17 @@ type CurrencyState = {
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
const FRANKFURTER_URL = 'https://api.frankfurter.app/latest?from=USD&to='
// Defensive bounds on any fetched FX rate. Outside this band the rate is either a parser bug
// or a tampered Frankfurter response, and we refuse to multiply it into displayed costs.
const MIN_VALID_FX_RATE = 0.0001
const MAX_VALID_FX_RATE = 1_000_000
function isValidRate(value: unknown): value is number {
return typeof value === 'number'
&& Number.isFinite(value)
&& value >= MIN_VALID_FX_RATE
&& value <= MAX_VALID_FX_RATE
}
let active: CurrencyState = { code: 'USD', rate: 1, symbol: '$' }
@ -54,18 +65,22 @@ function getRateCachePath(): string {
async function fetchRate(code: string): Promise<number> {
const response = await fetch(`${FRANKFURTER_URL}${code}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json() as { rates: Record<string, number> }
const rate = data.rates[code]
if (!rate) throw new Error(`No rate returned for ${code}`)
const data = await response.json() as { rates?: Record<string, unknown> }
const rate = data.rates?.[code]
if (!isValidRate(rate)) throw new Error(`Invalid rate returned for ${code}`)
return rate
}
async function loadCachedRate(code: string): Promise<number | null> {
try {
const raw = await readFile(getRateCachePath(), 'utf-8')
const cached = JSON.parse(raw) as { timestamp: number; code: string; rate: number }
if (cached.code !== code) return null
const cached = JSON.parse(raw) as Partial<{ timestamp: number; code: string; rate: number }>
// Validate every field -- a tampered cache file could set rate to a string, null, or
// Infinity and break downstream math silently.
if (typeof cached.code !== 'string' || cached.code !== code) return null
if (typeof cached.timestamp !== 'number' || !Number.isFinite(cached.timestamp)) return null
if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null
if (!isValidRate(cached.rate)) return null
return cached.rate
} catch {
return null

118
src/daily-cache.ts Normal file
View file

@ -0,0 +1,118 @@
import { randomBytes } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, open, readFile, rename, unlink } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
export const DAILY_CACHE_VERSION = 2
const DAILY_CACHE_FILENAME = 'daily-cache.json'
export type DailyEntry = {
date: string
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
editTurns: number
oneShotTurns: number
models: Record<string, {
calls: number
cost: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}>
categories: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }>
providers: Record<string, { calls: number; cost: number }>
}
export type DailyCache = {
version: number
lastComputedDate: string | null
days: DailyEntry[]
}
function getCacheDir(): string {
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
}
function getCachePath(): string {
return join(getCacheDir(), DAILY_CACHE_FILENAME)
}
function emptyCache(): DailyCache {
return { version: DAILY_CACHE_VERSION, lastComputedDate: null, days: [] }
}
function isValidCache(parsed: unknown): parsed is DailyCache {
if (!parsed || typeof parsed !== 'object') return false
const c = parsed as Partial<DailyCache>
if (c.version !== DAILY_CACHE_VERSION) return false
if (!Array.isArray(c.days)) return false
return true
}
export async function loadDailyCache(): Promise<DailyCache> {
const path = getCachePath()
if (!existsSync(path)) return emptyCache()
try {
const raw = await readFile(path, 'utf-8')
const parsed: unknown = JSON.parse(raw)
if (!isValidCache(parsed)) return emptyCache()
return parsed
} catch {
return emptyCache()
}
}
export async function saveDailyCache(cache: DailyCache): Promise<void> {
const dir = getCacheDir()
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
const finalPath = getCachePath()
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
const payload = JSON.stringify(cache)
const handle = await open(tempPath, 'w', 0o600)
try {
await handle.writeFile(payload, { encoding: 'utf-8' })
await handle.sync()
} finally {
await handle.close()
}
try {
await rename(tempPath, finalPath)
} catch (err) {
try { await unlink(tempPath) } catch { /* ignore */ }
throw err
}
}
export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate: string): DailyCache {
const seen = new Set(cache.days.map(d => d.date))
const merged = [...cache.days]
for (const day of incoming) {
if (seen.has(day.date)) continue
seen.add(day.date)
merged.push(day)
}
merged.sort((a, b) => a.date.localeCompare(b.date))
const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate
? cache.lastComputedDate
: newestDate
return { version: DAILY_CACHE_VERSION, lastComputedDate: nextLast, days: merged }
}
export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] {
return cache.days.filter(d => d.date >= start && d.date <= end)
}
let lockChain: Promise<unknown> = Promise.resolve()
export function withDailyCacheLock<T>(fn: () => Promise<T>): Promise<T> {
const next = lockChain.then(() => fn())
lockChain = next.catch(() => undefined)
return next
}

142
src/day-aggregator.ts Normal file
View file

@ -0,0 +1,142 @@
import type { DailyEntry } from './daily-cache.js'
import type { PeriodData } from './menubar-json.js'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
function emptyEntry(date: string): DailyEntry {
return {
date,
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: {},
categories: {},
providers: {},
}
}
function dateKey(iso: string): string {
return iso.slice(0, 10)
}
export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] {
const byDate = new Map<string, DailyEntry>()
const ensure = (date: string): DailyEntry => {
let d = byDate.get(date)
if (!d) { d = emptyEntry(date); byDate.set(date, d) }
return d
}
for (const project of projects) {
for (const session of project.sessions) {
const sessionDate = dateKey(session.firstTimestamp)
ensure(sessionDate).sessions += 1
for (const turn of session.turns) {
if (turn.assistantCalls.length === 0) continue
const turnDate = dateKey(turn.assistantCalls[0]!.timestamp)
const turnDay = ensure(turnDate)
const editTurns = turn.hasEdits ? 1 : 0
const oneShotTurns = turn.hasEdits && turn.retries === 0 ? 1 : 0
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
turnDay.editTurns += editTurns
turnDay.oneShotTurns += oneShotTurns
const cat = turnDay.categories[turn.category] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
cat.turns += 1
cat.cost += turnCost
cat.editTurns += editTurns
cat.oneShotTurns += oneShotTurns
turnDay.categories[turn.category] = cat
for (const call of turn.assistantCalls) {
const callDate = dateKey(call.timestamp)
const callDay = ensure(callDate)
callDay.cost += call.costUSD
callDay.calls += 1
callDay.inputTokens += call.usage.inputTokens
callDay.outputTokens += call.usage.outputTokens
callDay.cacheReadTokens += call.usage.cacheReadInputTokens
callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
const model = callDay.models[call.model] ?? {
calls: 0, cost: 0,
inputTokens: 0, outputTokens: 0,
cacheReadTokens: 0, cacheWriteTokens: 0,
}
model.calls += 1
model.cost += call.costUSD
model.inputTokens += call.usage.inputTokens
model.outputTokens += call.usage.outputTokens
model.cacheReadTokens += call.usage.cacheReadInputTokens
model.cacheWriteTokens += call.usage.cacheCreationInputTokens
callDay.models[call.model] = model
const provider = callDay.providers[call.provider] ?? { calls: 0, cost: 0 }
provider.calls += 1
provider.cost += call.costUSD
callDay.providers[call.provider] = provider
}
}
}
}
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date))
}
export function buildPeriodDataFromDays(days: DailyEntry[], label: string): PeriodData {
let cost = 0, calls = 0, sessions = 0
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number }> = {}
for (const d of days) {
cost += d.cost
calls += d.calls
sessions += d.sessions
inputTokens += d.inputTokens
outputTokens += d.outputTokens
cacheReadTokens += d.cacheReadTokens
cacheWriteTokens += d.cacheWriteTokens
for (const [name, m] of Object.entries(d.models)) {
const acc = modelTotals[name] ?? { calls: 0, cost: 0 }
acc.calls += m.calls
acc.cost += m.cost
modelTotals[name] = acc
}
for (const [cat, c] of Object.entries(d.categories)) {
const acc = catTotals[cat] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
acc.turns += c.turns
acc.cost += c.cost
acc.editTurns += c.editTurns
acc.oneShotTurns += c.oneShotTurns
catTotals[cat] = acc
}
}
return {
label,
cost,
calls,
sessions,
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 })),
}
}

View file

@ -1,8 +1,8 @@
import { writeFile } from 'fs/promises'
import { resolve } from 'path'
import { writeFile, mkdir, readdir, stat, rm } from 'fs/promises'
import { dirname, join, resolve } from 'path'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { getCostColumnHeader, convertCost } from './currency.js'
import { getCurrency, convertCost } from './currency.js'
function escCsv(s: string): string {
const sanitized = /^[=+\-@]/.test(s) ? `'${s}` : s
@ -12,15 +12,47 @@ function escCsv(s: string): string {
return sanitized
}
function buildDailyRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
const daily: Record<string, { cost: number; calls: number; input: number; output: number; cacheRead: number; cacheWrite: number }> = {}
type Row = Record<string, string | number>
function rowsToCsv(rows: Row[]): string {
if (rows.length === 0) return ''
const headers = Object.keys(rows[0])
const lines = [headers.map(escCsv).join(',')]
for (const row of rows) {
lines.push(headers.map(h => escCsv(String(row[h] ?? ''))).join(','))
}
return lines.join('\n') + '\n'
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
function pct(n: number, total: number): number {
return total > 0 ? round2((n / total) * 100) : 0
}
type DailyAgg = {
cost: number
calls: number
input: number
output: number
cacheRead: number
cacheWrite: number
sessions: Set<string>
}
function buildDailyRows(projects: ProjectSummary[], period: string): Row[] {
const daily: Record<string, DailyAgg> = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.timestamp) continue
const day = turn.timestamp.slice(0, 10)
if (!daily[day]) daily[day] = { cost: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
if (!daily[day]) {
daily[day] = { cost: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: new Set() }
}
daily[day].sessions.add(session.sessionId)
for (const call of turn.assistantCalls) {
daily[day].cost += call.costUSD
daily[day].calls++
@ -32,11 +64,13 @@ function buildDailyRows(projects: ProjectSummary[]): Array<Record<string, string
}
}
}
const { code } = getCurrency()
return Object.entries(daily).sort().map(([date, d]) => ({
Period: period,
Date: date,
[getCostColumnHeader()]: convertCost(d.cost),
[`Cost (${code})`]: round2(convertCost(d.cost)),
'API Calls': d.calls,
Sessions: d.sessions.size,
'Input Tokens': d.input,
'Output Tokens': d.output,
'Cache Read Tokens': d.cacheRead,
@ -44,7 +78,7 @@ function buildDailyRows(projects: ProjectSummary[]): Array<Record<string, string
}))
}
function buildActivityRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
function buildActivityRows(projects: ProjectSummary[], period: string): Row[] {
const catTotals: Record<string, { turns: number; cost: number }> = {}
for (const project of projects) {
for (const session of project.sessions) {
@ -55,40 +89,53 @@ function buildActivityRows(projects: ProjectSummary[]): Array<Record<string, str
}
}
}
const totalCost = Object.values(catTotals).reduce((s, d) => s + d.cost, 0)
const { code } = getCurrency()
return Object.entries(catTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({
Period: period,
Activity: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
[getCostColumnHeader()]: convertCost(d.cost),
[`Cost (${code})`]: round2(convertCost(d.cost)),
'Share (%)': pct(d.cost, totalCost),
Turns: d.turns,
}))
}
function buildModelRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
const modelTotals: Record<string, { calls: number; cost: number; input: number; output: number }> = {}
function buildModelRows(projects: ProjectSummary[], period: string): Row[] {
const modelTotals: Record<string, { calls: number; cost: number; input: number; output: number; cacheRead: number; cacheWrite: number }> = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const [model, d] of Object.entries(session.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, input: 0, output: 0 }
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
modelTotals[model].calls += d.calls
modelTotals[model].cost += d.costUSD
modelTotals[model].input += d.tokens.inputTokens
modelTotals[model].output += d.tokens.outputTokens
modelTotals[model].cacheRead += d.tokens.cacheReadInputTokens ?? 0
modelTotals[model].cacheWrite += d.tokens.cacheCreationInputTokens ?? 0
}
}
}
const totalCost = Object.values(modelTotals).reduce((s, d) => s + d.cost, 0)
const { code } = getCurrency()
return Object.entries(modelTotals)
.filter(([name]) => name !== '<synthetic>')
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([model, d]) => ({
Period: period,
Model: model,
[getCostColumnHeader()]: convertCost(d.cost),
[`Cost (${code})`]: round2(convertCost(d.cost)),
'Share (%)': pct(d.cost, totalCost),
'API Calls': d.calls,
'Input Tokens': d.input,
'Output Tokens': d.output,
'Cache Read Tokens': d.cacheRead,
'Cache Write Tokens': d.cacheWrite,
}))
}
function buildToolRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
function buildToolRows(projects: ProjectSummary[]): Row[] {
const toolTotals: Record<string, number> = {}
for (const project of projects) {
for (const session of project.sessions) {
@ -97,12 +144,17 @@ function buildToolRows(projects: ProjectSummary[]): Array<Record<string, string
}
}
}
const total = Object.values(toolTotals).reduce((s, n) => s + n, 0)
return Object.entries(toolTotals)
.sort(([, a], [, b]) => b - a)
.map(([tool, calls]) => ({ Tool: tool, Calls: calls }))
.map(([tool, calls]) => ({
Tool: tool,
Calls: calls,
'Share (%)': pct(calls, total),
}))
}
function buildBashRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
function buildBashRows(projects: ProjectSummary[]): Row[] {
const bashTotals: Record<string, number> = {}
for (const project of projects) {
for (const session of project.sessions) {
@ -111,28 +163,47 @@ function buildBashRows(projects: ProjectSummary[]): Array<Record<string, string
}
}
}
const total = Object.values(bashTotals).reduce((s, n) => s + n, 0)
return Object.entries(bashTotals)
.sort(([, a], [, b]) => b - a)
.map(([cmd, calls]) => ({ Command: cmd, Calls: calls }))
.map(([cmd, calls]) => ({
Command: cmd,
Calls: calls,
'Share (%)': pct(calls, total),
}))
}
function buildProjectRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
return projects.map(p => ({
Project: p.projectPath,
[getCostColumnHeader()]: convertCost(p.totalCostUSD),
'API Calls': p.totalApiCalls,
Sessions: p.sessions.length,
}))
function buildProjectRows(projects: ProjectSummary[]): Row[] {
const { code } = getCurrency()
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
return projects
.slice()
.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
.map(p => ({
Project: p.projectPath,
[`Cost (${code})`]: round2(convertCost(p.totalCostUSD)),
'Share (%)': pct(p.totalCostUSD, total),
'API Calls': p.totalApiCalls,
Sessions: p.sessions.length,
}))
}
function rowsToCsv(rows: Array<Record<string, string | number>>): string {
if (rows.length === 0) return ''
const headers = Object.keys(rows[0])
const lines = [headers.map(escCsv).join(',')]
for (const row of rows) {
lines.push(headers.map(h => escCsv(String(row[h] ?? ''))).join(','))
function buildSessionRows(projects: ProjectSummary[]): Row[] {
const { code } = getCurrency()
const rows: Row[] = []
for (const p of projects) {
for (const s of p.sessions) {
rows.push({
Project: p.projectPath,
'Session ID': s.sessionId,
'Started At': s.firstTimestamp ?? '',
[`Cost (${code})`]: round2(convertCost(s.totalCostUSD)),
'API Calls': s.apiCalls,
Turns: s.turns.length,
})
}
}
return lines.join('\n')
return rows.sort((a, b) => (b[`Cost (${code})`] as number) - (a[`Cost (${code})`] as number))
}
export type PeriodExport = {
@ -140,77 +211,140 @@ export type PeriodExport = {
projects: ProjectSummary[]
}
function buildSummaryRow(period: PeriodExport): Record<string, string | number> {
const cost = period.projects.reduce((s, p) => s + p.totalCostUSD, 0)
const calls = period.projects.reduce((s, p) => s + p.totalApiCalls, 0)
const sessions = period.projects.reduce((s, p) => s + p.sessions.length, 0)
return { Period: period.label, [getCostColumnHeader()]: convertCost(cost), 'API Calls': calls, Sessions: sessions }
function buildSummaryRows(periods: PeriodExport[]): Row[] {
const { code } = getCurrency()
return periods.map(p => {
const cost = p.projects.reduce((s, proj) => s + proj.totalCostUSD, 0)
const calls = p.projects.reduce((s, proj) => s + proj.totalApiCalls, 0)
const sessions = p.projects.reduce((s, proj) => s + proj.sessions.length, 0)
const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0).length
return {
Period: p.label,
[`Cost (${code})`]: round2(convertCost(cost)),
'API Calls': calls,
Sessions: sessions,
Projects: projectCount,
}
})
}
function buildReadme(periods: PeriodExport[]): string {
const { code } = getCurrency()
const generated = new Date().toISOString()
const lines = [
'CodeBurn Usage Export',
'====================',
'',
`Generated: ${generated}`,
`Currency: ${code}`,
`Periods: ${periods.map(p => p.label).join(', ')}`,
'',
'Files',
'-----',
' summary.csv One row per period. Headline totals.',
' daily.csv Day-by-day breakdown, Period column distinguishes the window.',
' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).',
' models.csv Spend per model with token totals and cache usage.',
' projects.csv Spend per project folder (30-day window).',
' sessions.csv One row per session (30-day window) with session IDs and costs.',
' tools.csv Tool invocations and share (30-day window).',
' shell-commands.csv Shell commands executed via Bash tool (30-day window).',
'',
'Notes',
'-----',
' Every cost column is already converted to the active currency. Tokens are raw integer',
' counts from provider telemetry. Share (%) is relative to the period/table total.',
'',
]
return lines.join('\n')
}
/// Sentinel file dropped into every folder we create so we can safely overwrite an older
/// codeburn export without ever deleting a user's unrelated files by accident.
const EXPORT_MARKER_FILE = '.codeburn-export'
async function isCodeburnExportFolder(path: string): Promise<boolean> {
const markerStat = await stat(join(path, EXPORT_MARKER_FILE)).catch(() => null)
return markerStat?.isFile() ?? false
}
async function clearCodeburnExportFolder(path: string): Promise<void> {
const entries = await readdir(path)
for (const entry of entries) {
await rm(join(path, entry), { recursive: true, force: true })
}
}
/// Writes a folder of one-table-per-file CSVs. The outputPath is treated as a directory. If it
/// ends in `.csv` the extension is stripped to form the folder name. Refuses to delete a
/// pre-existing file or a non-codeburn folder, so a typo like `-o ~/.ssh/id_ed25519` can't
/// wipe a sensitive file (prior versions did `rm(path, { force: true })` unconditionally).
export async function exportCsv(periods: PeriodExport[], outputPath: string): Promise<string> {
const allProjects = periods.find(p => p.label === '30 Days')?.projects
?? periods[periods.length - 1].projects
const thirtyDays = periods.find(p => p.label === '30 Days')
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
const parts: string[] = []
parts.push('# Summary')
parts.push(rowsToCsv(periods.map(buildSummaryRow)))
parts.push('')
for (const period of periods) {
parts.push(`# Daily - ${period.label}`)
parts.push(rowsToCsv(buildDailyRows(period.projects)))
parts.push('')
parts.push(`# Activity - ${period.label}`)
parts.push(rowsToCsv(buildActivityRows(period.projects)))
parts.push('')
parts.push(`# Models - ${period.label}`)
parts.push(rowsToCsv(buildModelRows(period.projects)))
parts.push('')
let folder = resolve(outputPath)
if (folder.toLowerCase().endsWith('.csv')) {
folder = folder.slice(0, -4)
}
parts.push('# Tools - All')
parts.push(rowsToCsv(buildToolRows(allProjects)))
parts.push('')
const existingStat = await stat(folder).catch(() => null)
if (existingStat?.isFile()) {
throw new Error(`Refusing to overwrite existing file at ${folder}. Pass a directory path instead.`)
}
if (existingStat?.isDirectory()) {
if (!(await isCodeburnExportFolder(folder))) {
throw new Error(
`Refusing to reuse non-empty directory ${folder}: no ${EXPORT_MARKER_FILE} marker. ` +
`Delete it manually or pick a different -o path.`
)
}
await clearCodeburnExportFolder(folder)
}
await mkdir(folder, { recursive: true })
await writeFile(join(folder, EXPORT_MARKER_FILE), '', 'utf-8')
parts.push('# Shell Commands - All')
parts.push(rowsToCsv(buildBashRows(allProjects)))
parts.push('')
const dailyRows = periods.flatMap(p => buildDailyRows(p.projects, p.label))
const activityRows = periods.flatMap(p => buildActivityRows(p.projects, p.label))
const modelRows = periods.flatMap(p => buildModelRows(p.projects, p.label))
parts.push('# Projects - All')
parts.push(rowsToCsv(buildProjectRows(allProjects)))
parts.push('')
await writeFile(join(folder, 'README.txt'), buildReadme(periods), 'utf-8')
await writeFile(join(folder, 'summary.csv'), rowsToCsv(buildSummaryRows(periods)), 'utf-8')
await writeFile(join(folder, 'daily.csv'), rowsToCsv(dailyRows), 'utf-8')
await writeFile(join(folder, 'activity.csv'), rowsToCsv(activityRows), 'utf-8')
await writeFile(join(folder, 'models.csv'), rowsToCsv(modelRows), 'utf-8')
await writeFile(join(folder, 'projects.csv'), rowsToCsv(buildProjectRows(thirtyDayProjects)), 'utf-8')
await writeFile(join(folder, 'sessions.csv'), rowsToCsv(buildSessionRows(thirtyDayProjects)), 'utf-8')
await writeFile(join(folder, 'tools.csv'), rowsToCsv(buildToolRows(thirtyDayProjects)), 'utf-8')
await writeFile(join(folder, 'shell-commands.csv'), rowsToCsv(buildBashRows(thirtyDayProjects)), 'utf-8')
const fullPath = resolve(outputPath)
await writeFile(fullPath, parts.join('\n'), 'utf-8')
return fullPath
return folder
}
export async function exportJson(periods: PeriodExport[], outputPath: string): Promise<string> {
const allProjects = periods.find(p => p.label === '30 Days')?.projects
?? periods[periods.length - 1].projects
const periodData: Record<string, unknown> = {}
for (const period of periods) {
periodData[period.label] = {
summary: buildSummaryRow(period),
daily: buildDailyRows(period.projects),
activity: buildActivityRows(period.projects),
models: buildModelRows(period.projects),
}
}
const thirtyDays = periods.find(p => p.label === '30 Days')
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
const { code, rate, symbol } = getCurrency()
const data = {
schema: 'codeburn.export.v2',
generated: new Date().toISOString(),
periods: periodData,
tools: buildToolRows(allProjects),
shellCommands: buildBashRows(allProjects),
projects: buildProjectRows(allProjects),
currency: { code, rate, symbol },
summary: buildSummaryRows(periods),
periods: periods.map(p => ({
label: p.label,
daily: buildDailyRows(p.projects, p.label),
activity: buildActivityRows(p.projects, p.label),
models: buildModelRows(p.projects, p.label),
})),
projects: buildProjectRows(thirtyDayProjects),
sessions: buildSessionRows(thirtyDayProjects),
tools: buildToolRows(thirtyDayProjects),
shellCommands: buildBashRows(thirtyDayProjects),
}
const fullPath = resolve(outputPath)
await writeFile(fullPath, JSON.stringify(data, null, 2), 'utf-8')
return fullPath
const target = resolve(outputPath.toLowerCase().endsWith('.json') ? outputPath : `${outputPath}.json`)
await mkdir(dirname(target), { recursive: true })
await writeFile(target, JSON.stringify(data, null, 2), 'utf-8')
return target
}

173
src/menubar-installer.ts Normal file
View file

@ -0,0 +1,173 @@
import { spawn } from 'node:child_process'
import { createWriteStream } from 'node:fs'
import { mkdir, mkdtemp, rename, rm, stat } from 'node:fs/promises'
import { homedir, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Readable } from 'node:stream'
/// Public GitHub repo that hosts signed macOS release builds. `/releases/latest` returns the
/// newest tagged release; we filter its assets list for our zipped .app bundle.
const RELEASE_API = 'https://api.github.com/repos/AgentSeal/codeburn/releases/latest'
const APP_BUNDLE_NAME = 'CodeBurnMenubar.app'
const ASSET_PATTERN = /^CodeBurnMenubar-.*\.zip$/
const APP_PROCESS_NAME = 'CodeBurnMenubar'
const SUPPORTED_OS = 'darwin'
const MIN_MACOS_MAJOR = 14
export type InstallResult = { installedPath: string; launched: boolean }
type ReleaseAsset = { name: string; browser_download_url: string }
type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] }
function userApplicationsDir(): string {
return join(homedir(), 'Applications')
}
async function exists(path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}
async function ensureSupportedPlatform(): Promise<void> {
if (platform() !== SUPPORTED_OS) {
throw new Error(`The menubar app is macOS only (detected: ${platform()}).`)
}
const major = Number((process.env.CODEBURN_FORCE_MACOS_MAJOR ?? '')
|| (await sysProductVersion()).split('.')[0])
if (!Number.isFinite(major) || major < MIN_MACOS_MAJOR) {
throw new Error(`macOS ${MIN_MACOS_MAJOR}+ required (detected ${major}).`)
}
}
async function sysProductVersion(): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn('/usr/bin/sw_vers', ['-productVersion'])
let out = ''
proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
proc.on('error', reject)
proc.on('close', (code) => {
if (code !== 0) reject(new Error(`sw_vers exited with ${code}`))
else resolve(out.trim())
})
})
}
async function fetchLatestReleaseAsset(): Promise<ReleaseAsset> {
const response = await fetch(RELEASE_API, {
headers: {
// Identify the installer so GitHub's abuse heuristics treat us as a known client.
'User-Agent': 'codeburn-menubar-installer',
Accept: 'application/vnd.github+json',
},
})
if (!response.ok) {
throw new Error(`GitHub release lookup failed: HTTP ${response.status}`)
}
const body = await response.json() as ReleaseResponse
const asset = body.assets.find(a => ASSET_PATTERN.test(a.name))
if (!asset) {
throw new Error(
`No ${APP_BUNDLE_NAME} zip found in release ${body.tag_name}. ` +
`Check https://github.com/AgentSeal/codeburn/releases.`
)
}
return asset
}
async function downloadToFile(url: string, destPath: string): Promise<void> {
const response = await fetch(url, {
headers: { 'User-Agent': 'codeburn-menubar-installer' },
redirect: 'follow',
})
if (!response.ok || response.body === null) {
throw new Error(`Download failed: HTTP ${response.status}`)
}
// fetch's ReadableStream needs to be wrapped for Node streams.
const nodeStream = Readable.fromWeb(response.body as never)
await pipeline(nodeStream, createWriteStream(destPath))
}
async function runCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: 'inherit' })
proc.on('error', reject)
proc.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited with status ${code}`))
})
})
}
async function isAppRunning(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME])
proc.on('close', (code) => resolve(code === 0))
proc.on('error', () => resolve(false))
})
}
async function killRunningApp(): Promise<void> {
await new Promise<void>((resolve) => {
const proc = spawn('/usr/bin/pkill', ['-f', APP_PROCESS_NAME])
proc.on('close', () => resolve())
proc.on('error', () => resolve())
})
}
export async function installMenubarApp(options: { force?: boolean } = {}): Promise<InstallResult> {
await ensureSupportedPlatform()
const appsDir = userApplicationsDir()
const targetPath = join(appsDir, APP_BUNDLE_NAME)
const alreadyInstalled = await exists(targetPath)
if (alreadyInstalled && !options.force) {
if (!(await isAppRunning())) {
await runCommand('/usr/bin/open', [targetPath])
}
return { installedPath: targetPath, launched: true }
}
console.log('Looking up the latest CodeBurn Menubar release...')
const asset = await fetchLatestReleaseAsset()
const stagingDir = await mkdtemp(join(tmpdir(), 'codeburn-menubar-'))
try {
const archivePath = join(stagingDir, asset.name)
console.log(`Downloading ${asset.name}...`)
await downloadToFile(asset.browser_download_url, archivePath)
console.log('Unpacking...')
await runCommand('/usr/bin/unzip', ['-q', archivePath, '-d', stagingDir])
const unpackedApp = join(stagingDir, APP_BUNDLE_NAME)
if (!(await exists(unpackedApp))) {
throw new Error(`Archive did not contain ${APP_BUNDLE_NAME}.`)
}
// Clear Gatekeeper's quarantine xattr. Without this, the first launch shows the
// "cannot verify developer" prompt even for a signed + notarized app when the bundle
// was delivered via curl/fetch instead of the Mac App Store.
await runCommand('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', unpackedApp]).catch(() => {})
await mkdir(appsDir, { recursive: true })
if (alreadyInstalled) {
// Kill the running copy before replacing its bundle so `mv` can proceed cleanly and the
// user ends up on the new version.
await killRunningApp()
await rm(targetPath, { recursive: true, force: true })
}
await rename(unpackedApp, targetPath)
console.log('Launching CodeBurn Menubar...')
await runCommand('/usr/bin/open', [targetPath])
return { installedPath: targetPath, launched: true }
} finally {
await rm(stagingDir, { recursive: true, force: true })
}
}

182
src/menubar-json.ts Normal file
View file

@ -0,0 +1,182 @@
/// Rollup of one time window (today / 7 days / 30 days / month / all) used as the canonical
/// input to the menubar payload. Built inside the CLI and also consumed by the day-aggregator
/// when hydrating per-day cache entries.
export type PeriodData = {
label: string
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; calls: number }>
}
export type ProviderCost = {
name: string
cost: number
}
import type { OptimizeResult } from './optimize.js'
const TOP_ACTIVITIES_LIMIT = 20
const TOP_MODELS_LIMIT = 20
const TOP_FINDINGS_LIMIT = 10
const HISTORY_DAYS_LIMIT = 365
const SYNTHETIC_MODEL_NAME = '<synthetic>'
export type DailyModelBreakdown = {
name: string
cost: number
calls: number
inputTokens: number
outputTokens: number
}
export type DailyHistoryEntry = {
date: string
cost: number
calls: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
topModels: DailyModelBreakdown[]
}
export type MenubarPayload = {
generated: string
current: {
label: string
cost: number
calls: number
sessions: number
oneShotRate: number | null
inputTokens: number
outputTokens: number
cacheHitPercent: number
topActivities: Array<{
name: string
cost: number
turns: number
oneShotRate: number | null
}>
topModels: Array<{
name: string
cost: number
calls: number
}>
providers: Record<string, number>
}
optimize: {
findingCount: number
savingsUSD: number
topFindings: Array<{
title: string
impact: 'high' | 'medium' | 'low'
savingsUSD: number
}>
}
history: {
daily: DailyHistoryEntry[]
}
}
function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null {
if (editTurns === 0) return null
return oneShotTurns / editTurns
}
function aggregateOneShotRate(categories: PeriodData['categories']): number | null {
let edits = 0
let oneShots = 0
for (const cat of categories) {
edits += cat.editTurns
oneShots += cat.oneShotTurns
}
if (edits === 0) return null
return oneShots / edits
}
function cacheHitPercent(inputTokens: number, cacheReadTokens: number): number {
const denom = inputTokens + cacheReadTokens
if (denom === 0) return 0
return (cacheReadTokens / denom) * 100
}
function buildTopActivities(categories: PeriodData['categories']): MenubarPayload['current']['topActivities'] {
return categories.slice(0, TOP_ACTIVITIES_LIMIT).map(cat => ({
name: cat.name,
cost: cat.cost,
turns: cat.turns,
oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns),
}))
}
function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] {
return models
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
.slice(0, TOP_MODELS_LIMIT)
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls }))
}
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
if (!optimize || optimize.findings.length === 0) {
return { findingCount: 0, savingsUSD: 0, topFindings: [] }
}
const { findings, costRate } = optimize
const totalSavingsUSD = findings.reduce((s, f) => s + f.tokensSaved * costRate, 0)
const topFindings = findings.slice(0, TOP_FINDINGS_LIMIT).map(f => ({
title: f.title,
impact: f.impact,
savingsUSD: f.tokensSaved * costRate,
}))
return {
findingCount: findings.length,
savingsUSD: totalSavingsUSD,
topFindings,
}
}
function buildProviders(providers: ProviderCost[]): Record<string, number> {
const map: Record<string, number> = {}
for (const p of providers) {
if (p.cost < 0) continue
map[p.name.toLowerCase()] = p.cost
}
return map
}
function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['history'] {
if (!daily || daily.length === 0) return { daily: [] }
const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
const trimmed = sorted.slice(-HISTORY_DAYS_LIMIT)
return { daily: trimmed }
}
export function buildMenubarPayload(
current: PeriodData,
providers: ProviderCost[],
optimize: OptimizeResult | null,
dailyHistory?: DailyHistoryEntry[],
): MenubarPayload {
return {
generated: new Date().toISOString(),
current: {
label: current.label,
cost: current.cost,
calls: current.calls,
sessions: current.sessions,
oneShotRate: aggregateOneShotRate(current.categories),
inputTokens: current.inputTokens,
outputTokens: current.outputTokens,
cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens),
topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models),
providers: buildProviders(providers),
},
optimize: buildOptimize(optimize),
history: buildHistory(dailyHistory),
}
}

View file

@ -1,334 +0,0 @@
import { execFileSync, execSync } from 'child_process'
import { existsSync } from 'fs'
import { chmod, mkdir, unlink, writeFile } from 'fs/promises'
import { homedir, platform } from 'os'
import { join } from 'path'
import { formatCost, formatTokens } from './format.js'
import { getCurrency } from './currency.js'
const PLUGIN_REFRESH = '5m'
const SWIFTBAR_PREFERENCES_DOMAIN = 'com.ameba.SwiftBar'
const SWIFTBAR_PLUGIN_DIRECTORY_KEY = 'PluginDirectory'
const MENUBAR_LABEL_MAX_LENGTH = 14
const MENUBAR_LABEL_ALLOWLIST = /[^A-Za-z0-9 ._/-]/g
// SwiftBar/xbar parse `|` as the metadata separator and interpret ANSI escapes
// on some paths. Replace anything outside a conservative allowlist with `?`
// and truncate before padEnd.
function sanitizeMenubarLabel(name: string): string {
return name.replace(MENUBAR_LABEL_ALLOWLIST, '?').slice(0, MENUBAR_LABEL_MAX_LENGTH)
}
function getSwiftBarPluginDir(): string {
return join(homedir(), 'Library', 'Application Support', 'SwiftBar', 'plugins')
}
function getXbarPluginDir(): string {
return join(homedir(), 'Library', 'Application Support', 'xbar', 'plugins')
}
export function parsePluginDirectoryPreference(value: string): string | undefined {
const pluginDir = value.trim()
if (!pluginDir) return undefined
if (pluginDir === '~') return homedir()
if (pluginDir.startsWith('~/')) return join(homedir(), pluginDir.slice(2))
return pluginDir
}
function getConfiguredSwiftBarPluginDir(): string | undefined {
if (platform() !== 'darwin') return undefined
try {
return parsePluginDirectoryPreference(execFileSync('defaults', [
'read',
SWIFTBAR_PREFERENCES_DOMAIN,
SWIFTBAR_PLUGIN_DIRECTORY_KEY,
], { encoding: 'utf-8' }))
} catch {
return undefined
}
}
function getSwiftBarPluginDirs(): string[] {
const dirs = [getConfiguredSwiftBarPluginDir(), getSwiftBarPluginDir()]
return dirs.filter((dir, index): dir is string => dir !== undefined && dirs.indexOf(dir) === index)
}
export function chooseMenubarPluginDir(
swiftBarPluginDirs: string[],
xbarPluginDir: string,
pathExists: (path: string) => boolean,
): { pluginDir: string; appName: string } {
const preferredSwiftBarDir = swiftBarPluginDirs[0] ?? getSwiftBarPluginDir()
for (const pluginDir of swiftBarPluginDirs) {
if (pathExists(pluginDir)) return { pluginDir, appName: 'SwiftBar' }
}
if (pathExists(xbarPluginDir)) return { pluginDir: xbarPluginDir, appName: 'xbar' }
return { pluginDir: preferredSwiftBarDir, appName: 'SwiftBar' }
}
function getCodeburnBin(): string {
try {
return execSync('which codeburn', { encoding: 'utf-8' }).trim()
} catch {
return 'npx --yes codeburn'
}
}
function generatePlugin(bin: string): string {
const home = homedir()
// Resolve the directory of the node binary used at install time so the
// plugin uses the same Node version codeburn was installed with — even
// when SwiftBar/xbar launch with a minimal PATH that finds an older
// system Node first. Fixes #63.
const nodeBinDir = join(process.execPath, '..')
return `#!/bin/bash
# <xbar.title>CodeBurn</xbar.title>
# <xbar.version>v0.1.0</xbar.version>
# <xbar.author>AgentSeal</xbar.author>
# <xbar.author.github>agentseal</xbar.author.github>
# <xbar.desc>See where your AI coding tokens burn. Tracks cost, activity, and model usage across Claude Code, Cursor, and Codex by task type, tool, MCP server, and project.</xbar.desc>
# <xbar.image>file://${home}/codeburn/assets/logo.png</xbar.image>
# <xbar.abouturl>https://github.com/agentseal/codeburn</xbar.abouturl>
# <xbar.dependencies>node</xbar.dependencies>
export HOME="${home}"
export PATH="${nodeBinDir}:$HOME/.local/bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
${bin} status --format menubar 2>/dev/null || echo "-- | sfimage=flame.fill"
`
}
function miniBar(value: number, max: number, width: number = 10): string {
if (max === 0) return '·'.repeat(width)
const filled = Math.round((value / max) * width)
return '█'.repeat(Math.min(filled, width)) + '·'.repeat(Math.max(width - filled, 0))
}
export type PeriodData = {
label: string
cost: number
calls: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; calls: number }>
}
export type ProviderCost = {
name: string
cost: number
}
export function renderMenubarFormat(
today: PeriodData,
week: PeriodData,
thirtyDays: PeriodData,
month: PeriodData,
todayProviders?: ProviderCost[],
): string {
const lines: string[] = []
lines.push(`${formatCost(today.cost)} | sfimage=flame.fill color=#FF8C42`)
lines.push('---')
lines.push(`CodeBurn | size=15 color=#FF8C42`)
lines.push(`AI Coding Cost Tracker | size=11`)
if (todayProviders && todayProviders.length > 1) {
for (const p of todayProviders) {
lines.push(` ${p.name.padEnd(10)} ${formatCost(p.cost).padStart(10)} | font=Menlo size=11`)
}
}
lines.push('---')
lines.push(`Today ${formatCost(today.cost)} ${today.calls.toLocaleString()} calls | size=14`)
lines.push('---')
const maxCat = Math.max(...today.categories.map(c => c.cost), 0.01)
lines.push(`Activity - Today | size=12 color=#FF8C42`)
for (const cat of today.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, maxCat)
const name = sanitizeMenubarLabel(cat.name).padEnd(14)
lines.push(`${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push('---')
const maxModel = Math.max(...today.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
lines.push(`Models - Today | size=12 color=#FF8C42`)
for (const model of today.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, maxModel)
const name = sanitizeMenubarLabel(model.name).padEnd(14)
lines.push(`${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
const cacheHit = today.inputTokens + today.cacheReadTokens > 0
? ((today.cacheReadTokens / (today.inputTokens + today.cacheReadTokens)) * 100).toFixed(0)
: '0'
lines.push(`Tokens: ${formatTokens(today.inputTokens)} in · ${formatTokens(today.outputTokens)} out · ${cacheHit}% cache hit | font=Menlo size=10`)
lines.push('---')
lines.push(`7 Days ${formatCost(week.cost)} ${week.calls.toLocaleString()} calls | size=14`)
const weekMaxCat = Math.max(...week.categories.map(c => c.cost), 0.01)
const weekMaxModel = Math.max(...week.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
lines.push(`--Activity | size=12 color=#FF8C42`)
for (const cat of week.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, weekMaxCat)
const name = sanitizeMenubarLabel(cat.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push(`-----`)
lines.push(`--Models | size=12 color=#FF8C42`)
for (const model of week.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, weekMaxModel)
const name = sanitizeMenubarLabel(model.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
lines.push(`30 Days ${formatCost(thirtyDays.cost)} ${thirtyDays.calls.toLocaleString()} calls | size=14`)
const tdMaxCat = Math.max(...thirtyDays.categories.map(c => c.cost), 0.01)
const tdMaxModel = Math.max(...thirtyDays.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
lines.push(`--Activity | size=12 color=#FF8C42`)
for (const cat of thirtyDays.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, tdMaxCat)
const name = sanitizeMenubarLabel(cat.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push(`-----`)
lines.push(`--Models | size=12 color=#FF8C42`)
for (const model of thirtyDays.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, tdMaxModel)
const name = sanitizeMenubarLabel(model.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
lines.push(`Month ${formatCost(month.cost)} ${month.calls.toLocaleString()} calls | size=14`)
const monthMaxCat = Math.max(...month.categories.map(c => c.cost), 0.01)
const monthMaxModel = Math.max(...month.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
lines.push(`--Activity | size=12 color=#FF8C42`)
for (const cat of month.categories.slice(0, 8)) {
const bar = miniBar(cat.cost, monthMaxCat)
const name = sanitizeMenubarLabel(cat.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
}
lines.push(`-----`)
lines.push(`--Models | size=12 color=#FF8C42`)
for (const model of month.models.slice(0, 5)) {
if (model.name === '<synthetic>') continue
const bar = miniBar(model.cost, monthMaxModel)
const name = sanitizeMenubarLabel(model.name).padEnd(14)
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
}
lines.push('---')
const home = process.env.HOME ?? '~'
const bin = getCodeburnBin()
// Invoke the resolved `codeburn` binary directly. SwiftBar/xbar deliver
// each `paramN=` value as its own argv entry, so there's no shell
// quoting involved — and we don't ship the user to a `~/codeburn`
// checkout that only exists when running from a dev clone (#32).
lines.push(`Open Full Report | terminal=true shell=${bin} param1=report`)
lines.push(`Export CSV to Desktop | terminal=false shell=${bin} param1=export param2=-o param3=${home}/Desktop/codeburn-report.csv`)
// Currency submenu -- common currencies as clickable items.
// Clicking one runs 'codeburn currency XXX' and refreshes the plugin.
const activeCurrency = getCurrency().code
const currencies = [
{ code: 'USD', name: 'US Dollar' },
{ code: 'GBP', name: 'British Pound' },
{ code: 'EUR', name: 'Euro' },
{ code: 'AUD', name: 'Australian Dollar' },
{ code: 'CAD', name: 'Canadian Dollar' },
{ code: 'NZD', name: 'New Zealand Dollar' },
{ code: 'JPY', name: 'Japanese Yen' },
{ code: 'CHF', name: 'Swiss Franc' },
{ code: 'INR', name: 'Indian Rupee' },
{ code: 'BRL', name: 'Brazilian Real' },
{ code: 'SEK', name: 'Swedish Krona' },
{ code: 'SGD', name: 'Singapore Dollar' },
{ code: 'HKD', name: 'Hong Kong Dollar' },
{ code: 'KRW', name: 'South Korean Won' },
{ code: 'MXN', name: 'Mexican Peso' },
{ code: 'ZAR', name: 'South African Rand' },
{ code: 'DKK', name: 'Danish Krone' },
]
lines.push(`Currency: ${activeCurrency} | size=14`)
for (const { code, name } of currencies) {
const check = code === activeCurrency ? ' *' : ''
// The real CLI subcommand is `codeburn currency [code]` (with `--reset`
// for USD), not `codeburn config currency` — the latter doesn't exist
// and silently fails when SwiftBar runs it. Fixes #27.
if (code === 'USD') {
lines.push(`--${name} (${code})${check} | terminal=false refresh=true shell=${bin} param1=currency param2=--reset`)
} else {
lines.push(`--${name} (${code})${check} | terminal=false refresh=true shell=${bin} param1=currency param2=${code}`)
}
}
lines.push(`Refresh | refresh=true`)
return lines.join('\n')
}
export async function installMenubar(): Promise<string> {
if (platform() !== 'darwin') {
return 'Menu bar integration is only available on macOS. Use `codeburn watch` or `codeburn status` instead.'
}
const bin = getCodeburnBin()
const pluginContent = generatePlugin(bin)
const { pluginDir, appName } = chooseMenubarPluginDir(getSwiftBarPluginDirs(), getXbarPluginDir(), existsSync)
if (!existsSync(pluginDir)) {
await mkdir(pluginDir, { recursive: true })
}
const pluginPath = join(pluginDir, `codeburn.${PLUGIN_REFRESH}.sh`)
await writeFile(pluginPath, pluginContent, 'utf-8')
await chmod(pluginPath, 0o755)
const swiftbarInstalled = existsSync('/Applications/SwiftBar.app') || existsSync(join(homedir(), 'Applications', 'SwiftBar.app'))
const xbarInstalled = existsSync('/Applications/xbar.app') || existsSync(join(homedir(), 'Applications', 'xbar.app'))
const lines: string[] = []
lines.push(`\n Plugin installed to: ${pluginPath}`)
if (swiftbarInstalled || xbarInstalled) {
lines.push(` ${appName} detected - plugin should appear in your menu bar shortly.`)
lines.push(` If not, open ${appName} and refresh plugins.\n`)
} else {
lines.push(`\n To see CodeBurn in your menu bar, install SwiftBar:`)
lines.push(` brew install --cask swiftbar`)
lines.push(`\n Then launch SwiftBar - the plugin will load automatically.\n`)
}
return lines.join('\n')
}
export async function uninstallMenubar(): Promise<string> {
const paths = [
...getSwiftBarPluginDirs().map(dir => join(dir, `codeburn.${PLUGIN_REFRESH}.sh`)),
join(getXbarPluginDir(), `codeburn.${PLUGIN_REFRESH}.sh`),
]
let removed = false
for (const p of paths) {
if (existsSync(p)) {
await unlink(p)
removed = true
}
}
return removed
? '\n Menu bar plugin removed.\n'
: '\n No menu bar plugin found.\n'
}

View file

@ -1,4 +1,4 @@
import { readdir } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { readSessionFile } from './fs-utils.js'
import { calculateCost, getShortModelName } from './models.js'
@ -266,6 +266,15 @@ async function parseSessionFile(
seenMsgIds: Set<string>,
dateRange?: DateRange,
): Promise<SessionSummary | null> {
// Skip files whose mtime is older than the range start. A session file
// can only contain entries up to its last-modified time; if that predates
// the requested range, nothing in this file can match.
if (dateRange) {
try {
const s = await stat(filePath)
if (s.mtimeMs < dateRange.start.getTime()) return null
} catch { /* fall through to normal read; missing stat shouldn't break parsing */ }
}
const content = await readSessionFile(filePath)
if (content === null) return null
const lines = content.split('\n').filter(l => l.trim())
@ -388,6 +397,12 @@ async function parseProviderSources(
const sessionMap = new Map<string, { project: string; turns: ClassifiedTurn[] }>()
for (const source of sources) {
if (dateRange) {
try {
const s = await stat(source.path)
if (s.mtimeMs < dateRange.start.getTime()) continue
} catch { /* fall through; treat unknown stat as "may contain data" */ }
}
const parser = provider.createSessionParser(
{ path: source.path, project: source.project, provider: providerName },
seenKeys,