mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-19 07:43:09 +00:00
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:
parent
69268a9e91
commit
495a254338
46 changed files with 6433 additions and 575 deletions
217
src/cli.ts
217
src/cli.ts
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
118
src/daily-cache.ts
Normal 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
142
src/day-aggregator.ts
Normal 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 })),
|
||||
}
|
||||
}
|
||||
310
src/export.ts
310
src/export.ts
|
|
@ -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
173
src/menubar-installer.ts
Normal 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
182
src/menubar-json.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
334
src/menubar.ts
334
src/menubar.ts
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue