Replace osascript/JXA with native Mach-O agent and SMAppService to fix EDR detection

- Add CodeBurnRefreshAgent target: native fire-and-exit binary posting com.codeburn.refresh notification
- Rewrite installLaunchAgentIfNeeded(): plist ProgramArguments points to native binary, not osascript/JXA
- Rewrite registerLoginItemIfNeeded(): uses SMAppService API instead of osascript/System Events
- Add startSocketListener(): Unix domain socket for CLI-triggered menubar refresh
- Add src/menubar-socket.ts: CLI-side notifyMenubar() helper wired into status --format menubar-json
- Update Package.swift with new product/target, package-app.sh copies agent into bundle Resources
- Add tests: plist content verification, login item guard, agent smoke test
This commit is contained in:
Rashid Razak 2026-05-11 19:06:08 +08:00 committed by Rashid Razak
parent 151d24fb26
commit f663ec01fb
8 changed files with 1203 additions and 35 deletions

View file

@ -7,7 +7,8 @@ let package = Package(
.macOS(.v14)
],
products: [
.executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"])
.executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"]),
.executable(name: "CodeBurnRefreshAgent", targets: ["CodeBurnRefreshAgent"])
],
targets: [
.executableTarget(
@ -17,6 +18,10 @@ let package = Package(
.enableUpcomingFeature("StrictConcurrency")
]
),
.executableTarget(
name: "CodeBurnRefreshAgent",
path: "Sources/CodeBurnRefreshAgent"
),
.testTarget(
name: "CodeBurnMenubarTests",
dependencies: ["CodeBurnMenubar"],

View file

@ -43,6 +43,7 @@ BUNDLE="${DIST_DIR}/${BUNDLE_NAME}"
mkdir -p "${BUNDLE}/Contents/MacOS"
mkdir -p "${BUNDLE}/Contents/Resources"
cp "${BUILT_BINARY}" "${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}"
cp "${BIN_PATH}/CodeBurnRefreshAgent" "${BUNDLE}/Contents/Resources/CodeBurnRefreshAgent"
cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>

View file

@ -1,6 +1,7 @@
import SwiftUI
import AppKit
import Observation
import ServiceManagement
private let refreshIntervalSeconds: UInt64 = 30
private let nanosPerSecond: UInt64 = 1_000_000_000
@ -79,6 +80,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
setupDistributedNotificationListener()
installLaunchAgentIfNeeded()
registerLoginItemIfNeeded()
startSocketListener()
observeSubscriptionDisconnect()
Task { await updateChecker.checkIfNeeded() }
}
@ -161,6 +163,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let agentName = "com.codeburn.refresh.plist"
let home = fm.homeDirectoryForCurrentUser.path
let destPath = "\(home)/Library/LaunchAgents/\(agentName)"
let agentPath = (Bundle.main.resourcePath ?? "") + "/CodeBurnRefreshAgent"
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
@ -171,11 +174,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
<string>com.codeburn.refresh</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/osascript</string>
<string>-l</string>
<string>JavaScript</string>
<string>-e</string>
<string>ObjC.import("Foundation"); $.NSDistributedNotificationCenter.defaultCenter.postNotificationNameObjectUserInfoDeliverImmediately("com.codeburn.refresh", $(), $(), true)</string>
<string>\(agentPath)</string>
</array>
<key>StartInterval</key>
<integer>30</integer>
@ -209,29 +208,68 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
private func registerLoginItemIfNeeded() {
let key = "codeburn.loginItemRegistered"
guard !UserDefaults.standard.bool(forKey: key) else { return }
let appPath = Bundle.main.bundlePath
let script = "tell application \"System Events\" to make login item at end with properties {path:\"\(appPath)\", hidden:false}"
let process = Process()
process.launchPath = "/usr/bin/osascript"
process.arguments = ["-e", script]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
guard SMAppService.mainApp.status != .enabled else { return }
do {
try process.run()
process.waitUntilExit()
if process.terminationStatus == 0 {
UserDefaults.standard.set(true, forKey: key)
}
try SMAppService.mainApp.register()
} catch {
NSLog("CodeBurn: Login item registration failed: \(error)")
}
}
private func startSocketListener() {
let fm = FileManager.default
let home = fm.homeDirectoryForCurrentUser.path
let cacheDir = "\(home)/.cache/codeburn"
let socketPath = "\(cacheDir)/menubar.sock"
try? fm.createDirectory(atPath: cacheDir, withIntermediateDirectories: true)
if fm.fileExists(atPath: socketPath) {
try? fm.removeItem(atPath: socketPath)
}
let socketFD = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
guard socketFD >= 0 else {
NSLog("CodeBurn: failed to create socket")
return
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let socketPathC = (socketPath as NSString).fileSystemRepresentation
withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in
_ = strcpy(ptr, socketPathC)
}
let addrSize = socklen_t(MemoryLayout<sockaddr_un>.size)
let bindResult = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.bind(socketFD, $0, addrSize)
}
}
guard bindResult == 0 else {
NSLog("CodeBurn: failed to bind socket at \(socketPath)")
Darwin.close(socketFD)
return
}
Darwin.listen(socketFD, 5)
DispatchQueue.global(qos: .background).async { [weak self, socketFD] in
while true {
let clientFD = Darwin.accept(socketFD, nil, nil)
guard clientFD >= 0 else { continue }
var buf: [UInt8] = Array(repeating: 0, count: 1024)
let n = Darwin.read(clientFD, &buf, buf.count)
if n > 0 {
DispatchQueue.main.async { [weak self] in
self?.forceRefresh()
}
}
Darwin.close(clientFD)
}
}
}
private var lastRefreshTime: Date = .distantPast
@discardableResult

View file

@ -0,0 +1,8 @@
import Foundation
DistributedNotificationCenter.default().postNotificationName(
.init("com.codeburn.refresh"),
object: nil,
userInfo: nil,
options: .deliverImmediately
)

View file

@ -0,0 +1,137 @@
import Testing
import Foundation
import ServiceManagement
private func makePlist(agentPath: String) -> String {
"""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.codeburn.refresh</string>
<key>ProgramArguments</key>
<array>
<string>\(agentPath)</string>
</array>
<key>StartInterval</key>
<integer>30</integer>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
"""
}
@Suite("LaunchAgent Plist")
struct LaunchAgentPlistTests {
@Test("Plist has correct ProgramArguments")
func programArgumentsIsSingleElementArray() throws {
let plistStr = makePlist(agentPath: "/path/to/CodeBurnRefreshAgent")
let data = Data(plistStr.utf8)
let raw = try PropertyListSerialization.propertyList(from: data, format: nil)
let dict = try #require(raw as? NSDictionary)
let args = try #require(dict["ProgramArguments"] as? [String])
#expect(args == ["/path/to/CodeBurnRefreshAgent"])
}
@Test("Plist has StartInterval of 30")
func startIntervalIs30() throws {
let plistStr = makePlist(agentPath: "/path/to/agent")
let data = Data(plistStr.utf8)
let raw = try PropertyListSerialization.propertyList(from: data, format: nil)
let dict = try #require(raw as? NSDictionary)
let interval = try #require(dict["StartInterval"] as? Int)
#expect(interval == 30)
}
@Test("Plist has RunAtLoad true")
func runAtLoadIsTrue() throws {
let plistStr = makePlist(agentPath: "/path/to/agent")
let data = Data(plistStr.utf8)
let raw = try PropertyListSerialization.propertyList(from: data, format: nil)
let dict = try #require(raw as? NSDictionary)
let runAtLoad = try #require(dict["RunAtLoad"] as? Bool)
#expect(runAtLoad == true)
}
@Test("Plist has correct Label")
func labelIsCorrect() throws {
let plistStr = makePlist(agentPath: "/path/to/agent")
let data = Data(plistStr.utf8)
let raw = try PropertyListSerialization.propertyList(from: data, format: nil)
let dict = try #require(raw as? NSDictionary)
let label = try #require(dict["Label"] as? String)
#expect(label == "com.codeburn.refresh")
}
@Test("Plist idempotency")
func idempotent() {
let a = makePlist(agentPath: "/same/path")
let b = makePlist(agentPath: "/same/path")
#expect(a == b)
}
}
@Suite("Login Item Guard")
struct LoginItemGuardTests {
@Test("SMAppService.mainApp.status is accessible")
func mainAppStatusIsAccessible() {
// The guard in registerLoginItemIfNeeded():
// guard SMAppService.mainApp.status != .enabled else { return }
// When status is .enabled, the function returns early (no registration).
// When status is .notRegistered / .requiresApproval, it proceeds to register.
let status = SMAppService.mainApp.status
// In a running app, status is .enabled, .notRegistered, or .requiresApproval.
// In a test context without an app bundle, it may be .notFound (macOS 14+).
let known: Bool = status == .enabled || status == .notRegistered
|| status == .requiresApproval || status == .notFound
#expect(known)
}
}
@Test("CodeBurnRefreshAgent builds and runs successfully")
func agentBuildsAndRuns() throws {
let packageDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let scratchDir = FileManager.default.temporaryDirectory
.appendingPathComponent("codeburn-smoke-test-build")
try? FileManager.default.removeItem(at: scratchDir)
let build = Process()
build.launchPath = "/usr/bin/env"
build.arguments = [
"swift", "build", "--product", "CodeBurnRefreshAgent",
"--scratch-path", scratchDir.path
]
build.currentDirectoryURL = packageDir
try build.run()
build.waitUntilExit()
#expect(build.terminationStatus == 0, "Build failed")
let showPath = Process()
let pipe = Pipe()
showPath.launchPath = "/usr/bin/env"
showPath.arguments = [
"swift", "build", "--product", "CodeBurnRefreshAgent",
"--scratch-path", scratchDir.path, "--show-bin-path"
]
showPath.currentDirectoryURL = packageDir
showPath.standardOutput = pipe
try showPath.run()
showPath.waitUntilExit()
let binPathData = pipe.fileHandleForReading.readDataToEndOfFile()
let binPath = String(data: binPathData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let binaryURL = URL(fileURLWithPath: binPath).appendingPathComponent("CodeBurnRefreshAgent")
let agent = Process()
agent.launchPath = binaryURL.path
try agent.run()
agent.waitUntilExit()
#expect(agent.terminationStatus == 0, "Agent exited with non-zero status")
}

View file

@ -1,15 +1,980 @@
#!/usr/bin/env node
// This launcher must stay parseable by Node 18. Do NOT add static imports.
const [major, minor] = process.versions.node.split('.').map(Number)
if (major < 22 || (major === 22 && minor < 13)) {
process.stderr.write(
`codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` +
'Upgrade at https://nodejs.org/\n',
)
process.exit(1)
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { notifyMenubar } from './menubar-socket.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases } from './models.js'
import { parseAllSessions, filterProjectsByName } from './parser.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { type PeriodData, type ProviderCost } from './menubar-json.js'
import { buildMenubarPayload } from './menubar-json.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { renderDashboard } from './dashboard.js'
import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js'
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { version } = require('../package.json')
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
async function hydrateCache() {
try {
return await ensureCacheHydrated(
(range) => parseAllSessions(range, 'all'),
aggregateProjectsIntoDays,
)
} catch {
return emptyCache()
}
}
import('./main.js').catch((err) => {
process.stderr.write(String(err?.message ?? err) + '\n')
process.exit(1)
function collect(val: string, acc: string[]): string[] {
acc.push(val)
return acc
}
function parseNumber(value: string): number {
return Number(value)
}
function parseInteger(value: string): number {
return parseInt(value, 10)
}
type JsonPlanSummary = {
id: PlanId
budget: number
spent: number
percentUsed: number
status: 'under' | 'near' | 'over'
projectedMonthEnd: number
daysUntilReset: number
periodStart: string
periodEnd: string
}
function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
return {
id: planUsage.plan.id,
budget: convertCost(planUsage.budgetUsd),
spent: convertCost(planUsage.spentApiEquivalentUsd),
percentUsed: Math.round(planUsage.percentUsed * 10) / 10,
status: planUsage.status,
projectedMonthEnd: convertCost(planUsage.projectedMonthUsd),
daysUntilReset: planUsage.daysUntilReset,
periodStart: planUsage.periodStart.toISOString(),
periodEnd: planUsage.periodEnd.toISOString(),
}
}
function assertFormat(value: string, allowed: readonly string[], command: string): void {
if (!allowed.includes(value)) {
process.stderr.write(
`codeburn ${command}: unknown format "${value}". Valid values: ${allowed.join(', ')}.\n`
)
process.exit(1)
}
}
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
const planUsage = await getPlanUsageOrNull()
if (planUsage) {
report.plan = toJsonPlanSummary(planUsage)
}
console.log(JSON.stringify(report, null, 2))
}
const program = new Command()
.name('codeburn')
.description('See where your AI coding tokens go - by task, tool, model, and project')
.version(version)
.option('--verbose', 'print warnings to stderr on read failures and skipped files')
.option('--timezone <zone>', 'IANA timezone for date grouping (e.g. Asia/Tokyo, America/New_York)')
program.hook('preAction', async (thisCommand) => {
const tz = thisCommand.opts<{ timezone?: string }>().timezone ?? process.env['CODEBURN_TZ']
if (tz) {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz })
} catch {
console.error(`\n Invalid timezone: "${tz}". Use an IANA timezone like "America/New_York" or "Asia/Tokyo".\n`)
process.exit(1)
}
process.env.TZ = tz
}
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
process.env['CODEBURN_VERBOSE'] = '1'
}
await loadCurrency()
})
function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) {
const sessions = projects.flatMap(p => p.sessions)
const { code } = getCurrency()
const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
// Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write
// counts tokens being stored, not served, so it doesn't belong in the denominator.
const cacheHitDenom = totalInput + totalCacheRead
const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0
// Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a
// consumer summing daily[].editTurns over a period gets the same total as
// sum(activities[].editTurns) for that period: every turn counts once for
// `turns`, edit turns count for `editTurns`, edit turns with zero retries
// count for `oneShotTurns`. Issue #279 — daily-resolution efficiency
// dashboards need this without re-deriving from activity-level rollups.
const dailyMap: Record<string, { cost: number; calls: number; turns: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const turn of sess.turns) {
// Prefer the user-message timestamp on the turn; fall back to the first
// assistant-call timestamp when the user line is missing (continuation
// sessions where the JSONL begins mid-conversation). Previously these
// turns dropped from daily but stayed in activities, breaking the
// sum(daily[].editTurns) === sum(activities[].editTurns) invariant.
const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp
if (!ts) { continue }
const day = dateKey(ts)
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
dailyMap[day].turns += 1
if (turn.hasEdits) {
dailyMap[day].editTurns += 1
if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
}
for (const call of turn.assistantCalls) {
dailyMap[day].cost += call.costUSD
dailyMap[day].calls += 1
}
}
}
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
date,
cost: convertCost(d.cost),
calls: d.calls,
turns: d.turns,
editTurns: d.editTurns,
oneShotTurns: d.oneShotTurns,
// Pre-computed convenience for dashboards that don't want to do the math.
// null when there are no edit turns (the rate is undefined, not zero —
// a day where the user only had Q&A turns shouldn't read as 0% one-shot).
oneShotRate: d.editTurns > 0
? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10
: null,
}))
const projectList = projects.map(p => ({
name: p.project,
path: p.projectPath,
cost: convertCost(p.totalCostUSD),
avgCostPerSession: p.sessions.length > 0
? convertCost(p.totalCostUSD / p.sessions.length)
: null,
calls: p.totalApiCalls,
sessions: p.sessions.length,
}))
const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
const modelEfficiency = aggregateModelEfficiency(projects)
for (const sess of sessions) {
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
modelMap[model].calls += d.calls
modelMap[model].cost += d.costUSD
modelMap[model].inputTokens += d.tokens.inputTokens
modelMap[model].outputTokens += d.tokens.outputTokens
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
}
}
const models = Object.entries(modelMap)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, { cost, ...rest }]) => {
const efficiency = modelEfficiency.get(name)
return {
name,
...rest,
cost: convertCost(cost),
editTurns: efficiency?.editTurns ?? 0,
oneShotTurns: efficiency?.oneShotTurns ?? 0,
oneShotRate: efficiency?.oneShotRate ?? null,
retriesPerEdit: efficiency?.retriesPerEdit ?? null,
costPerEdit: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined
? convertCost(efficiency.costPerEditUSD)
: null,
}
})
const catMap: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
catMap[cat].turns += d.turns
catMap[cat].cost += d.costUSD
catMap[cat].editTurns += d.editTurns
catMap[cat].oneShotTurns += d.oneShotTurns
}
}
const activities = Object.entries(catMap)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({
category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
cost: convertCost(d.cost),
turns: d.turns,
editTurns: d.editTurns,
oneShotTurns: d.oneShotTurns,
oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null,
}))
const toolMap: Record<string, number> = {}
const mcpMap: Record<string, number> = {}
const bashMap: Record<string, number> = {}
for (const sess of sessions) {
for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
}
for (const [server, d] of Object.entries(sess.mcpBreakdown)) {
mcpMap[server] = (mcpMap[server] ?? 0) + d.calls
}
for (const [cmd, d] of Object.entries(sess.bashBreakdown)) {
bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
}
}
const sortedMap = (m: Record<string, number>) =>
Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
const topSessions = projects
.flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
.sort((a, b) => b.cost - a.cost)
.slice(0, 5)
return {
generated: new Date().toISOString(),
currency: code,
period,
periodKey,
overview: {
cost: convertCost(totalCostUSD),
calls: totalCalls,
sessions: totalSessions,
cacheHitPercent,
tokens: {
input: totalInput,
output: totalOutput,
cacheRead: totalCacheRead,
cacheWrite: totalCacheWrite,
},
},
daily,
projects: projectList,
models,
activities,
tools: sortedMap(toolMap),
mcpServers: sortedMap(mcpMap),
shellCommands: sortedMap(bashMap),
topSessions,
}
}
program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'report')
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Error: ${message}\n`)
process.exit(1)
}
const period = toPeriod(opts.period)
if (opts.format === 'json') {
await loadPricing()
await hydrateCache()
if (customRange) {
const label = formatDateRangeLabel(opts.from, opts.to)
const projects = filterProjectsByName(
await parseAllSessions(customRange, opts.provider),
opts.project,
opts.exclude,
)
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
} else {
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
}
return
}
await hydrateCache()
const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel)
})
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
const sessions = projects.flatMap(p => p.sessions)
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const modelTotals: Record<string, { calls: number; cost: number }> = {}
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
for (const sess of sessions) {
inputTokens += sess.totalInputTokens
outputTokens += sess.totalOutputTokens
cacheReadTokens += sess.totalCacheReadTokens
cacheWriteTokens += sess.totalCacheWriteTokens
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
catTotals[cat].turns += d.turns
catTotals[cat].cost += d.costUSD
catTotals[cat].editTurns += d.editTurns
catTotals[cat].oneShotTurns += d.oneShotTurns
}
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 }
modelTotals[model].calls += d.calls
modelTotals[model].cost += d.costUSD
}
}
return {
label,
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
categories: Object.entries(catTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
models: Object.entries(modelTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, d]) => ({ name, ...d })),
}
}
program
.command('status')
.description('Compact status output (today + month)')
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.action(async (opts) => {
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
await loadPricing()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
if (opts.format === 'menubar-json') {
const periodInfo = getDateRange(opts.period)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const isAllProviders = pf === 'all'
const cache = await hydrateCache()
// CURRENT PERIOD DATA
// - .all provider: assemble from cache + today (fast)
// - specific provider: parse the period range with provider filter (correct, but slower)
let currentData: PeriodData
let scanProjects: ProjectSummary[]
let scanRange: DateRange
if (isAllProviders) {
// Parse only today's sessions; historical data comes from cache to avoid double-counting
const todayRange: DateRange = { start: todayStart, end: new Date() }
const todayProjects = fp(await parseAllSessions(todayRange, 'all'))
const todayDays = aggregateProjectsIntoDays(todayProjects)
const rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
scanProjects = todayProjects
scanRange = periodInfo.range
} else {
const projects = fp(await parseAllSessions(periodInfo.range, pf))
currentData = buildPeriodData(periodInfo.label, projects)
scanProjects = projects
scanRange = periodInfo.range
}
// PROVIDERS
// For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
// For specific: just this single provider with its scoped cost.
const allProviders = await getAllProviders()
const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
const providers: ProviderCost[] = []
if (isAllProviders) {
// Parse only today; historical provider costs come from cache
const todayRangeForProviders: DateRange = { start: todayStart, end: new Date() }
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
const rangeStartStr = toDateString(periodInfo.range.start)
const todayStr = toDateString(todayStart)
const allDaysForProviders = [
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
...todayDaysForProviders.filter(d => d.date === todayStr),
]
const providerTotals: Record<string, number> = {}
for (const d of allDaysForProviders) {
for (const [name, p] of Object.entries(d.providers)) {
providerTotals[name] = (providerTotals[name] ?? 0) + p.cost
}
}
for (const [name, cost] of Object.entries(providerTotals)) {
providers.push({ name: displayNameByName.get(name) ?? name, cost })
}
for (const p of allProviders) {
if (providers.some(pc => pc.name === p.displayName)) continue
const sources = await p.discoverSessions()
if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 })
}
} else {
const display = displayNameByName.get(pf) ?? pf
providers.push({ name: display, cost: currentData.cost })
}
// DAILY HISTORY (last 365 days)
// Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive
// a provider-filtered history without re-parsing. Tokens aren't broken down per provider
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
// Parse only today for history; historical days come from cache
const todayRangeForHistory: DateRange = { start: todayStart, end: new Date() }
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForHistory, 'all')))
const todayStrForHistory = toDateString(todayStart)
const fullHistory = [...allCacheDays, ...allTodayDaysForHistory.filter(d => d.date === todayStrForHistory)]
const dailyHistory = fullHistory.map(d => {
if (isAllProviders) {
const topModels = Object.entries(d.models)
.filter(([name]) => name !== '<synthetic>')
.sort(([, a], [, b]) => b.cost - a.cost)
.slice(0, 5)
.map(([name, m]) => ({
name,
cost: m.cost,
calls: m.calls,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
}))
return {
date: d.date,
cost: d.cost,
calls: d.calls,
inputTokens: d.inputTokens,
outputTokens: d.outputTokens,
cacheReadTokens: d.cacheReadTokens,
cacheWriteTokens: d.cacheWriteTokens,
topModels,
}
}
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
})
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
notifyMenubar()
return
}
if (opts.format === 'json') {
await hydrateCache()
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
const { code, rate } = getCurrency()
const payload: {
currency: string
today: { cost: number; calls: number }
month: { cost: number; calls: number }
plan?: JsonPlanSummary
} = {
currency: code,
today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
}
const planUsage = await getPlanUsageOrNull()
if (planUsage) {
payload.plan = toJsonPlanSummary(planUsage)
}
console.log(JSON.stringify(payload))
return
}
await hydrateCache()
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
console.log(renderStatusBar(monthProjects))
})
program
.command('today')
.description('Today\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'today')
if (opts.format === 'json') {
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
return
}
await hydrateCache()
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
.command('month')
.description('This month\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'month')
if (opts.format === 'json') {
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
return
}
await hydrateCache()
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
.command('export')
.description('Export usage data to CSV or JSON')
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
.option('-o, --output <path>', 'Output file path')
.option('--from <date>', 'Start date (YYYY-MM-DD). Exports a single custom period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Exports a single custom period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.action(async (opts) => {
assertFormat(opts.format, ['csv', 'json'], 'export')
await loadPricing()
await hydrateCache()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Error: ${message}\n`)
process.exit(1)
}
const periods: PeriodExport[] = customRange
? [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }]
: [
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
]
if (periods.every(p => p.projects.length === 0)) {
console.log('\n No usage data found.\n')
return
}
const defaultName = `codeburn-${toDateString(new Date())}`
const outputPath = opts.output ?? `${defaultName}.${opts.format}`
let savedPath: string
try {
if (opts.format === 'json') {
savedPath = await exportJson(periods, outputPath)
} else {
savedPath = await exportCsv(periods, outputPath)
}
} catch (err) {
// Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.)
// throw with a user-readable message. Print just the message, not the stack, so the CLI
// doesn't spray its internals at the user.
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Export failed: ${message}\n`)
process.exit(1)
}
const exportedLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : 'Today + 7 Days + 30 Days'
console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`)
})
program
.command('menubar')
.description('Install and launch the macOS menubar app (one command, no clone)')
.option('--force', 'Reinstall even if an older copy is already in ~/Applications')
.action(async (opts: { force?: boolean }) => {
try {
const result = await installMenubarApp({ force: opts.force })
console.log(`\n Ready. ${result.installedPath}\n`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Menubar install failed: ${message}\n`)
process.exit(1)
}
})
program
.command('currency [code]')
.description('Set display currency (e.g. codeburn currency GBP)')
.option('--symbol <symbol>', 'Override the currency symbol')
.option('--reset', 'Reset to USD (removes currency config)')
.action(async (code?: string, opts?: { symbol?: string; reset?: boolean }) => {
if (opts?.reset) {
const config = await readConfig()
delete config.currency
await saveConfig(config)
console.log('\n Currency reset to USD.\n')
return
}
if (!code) {
const { code: activeCode, rate, symbol } = getCurrency()
if (activeCode === 'USD' && rate === 1) {
console.log('\n Currency: USD (default)')
console.log(` Config: ${getConfigFilePath()}\n`)
} else {
console.log(`\n Currency: ${activeCode}`)
console.log(` Symbol: ${symbol}`)
console.log(` Rate: 1 USD = ${rate} ${activeCode}`)
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
const upperCode = code.toUpperCase()
if (!isValidCurrencyCode(upperCode)) {
console.error(`\n "${code}" is not a valid ISO 4217 currency code.\n`)
process.exitCode = 1
return
}
const config = await readConfig()
config.currency = {
code: upperCode,
...(opts?.symbol ? { symbol: opts.symbol } : {}),
}
await saveConfig(config)
await loadCurrency()
const { rate, symbol } = getCurrency()
console.log(`\n Currency set to ${upperCode}.`)
console.log(` Symbol: ${symbol}`)
console.log(` Rate: 1 USD = ${rate} ${upperCode}`)
console.log(` Config saved to ${getConfigFilePath()}\n`)
})
program
.command('model-alias [from] [to]')
.description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)')
.option('--remove <from>', 'Remove an alias')
.option('--list', 'List configured aliases')
.action(async (from?: string, to?: string, opts?: { remove?: string; list?: boolean }) => {
const config = await readConfig()
const aliases = config.modelAliases ?? {}
if (opts?.list || (!from && !opts?.remove)) {
const entries = Object.entries(aliases)
if (entries.length === 0) {
console.log('\n No model aliases configured.')
console.log(` Config: ${getConfigFilePath()}\n`)
} else {
console.log('\n Model aliases:')
for (const [src, dst] of entries) {
console.log(` ${src} -> ${dst}`)
}
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
if (opts?.remove) {
if (!(opts.remove in aliases)) {
console.error(`\n Alias not found: ${opts.remove}\n`)
process.exitCode = 1
return
}
delete aliases[opts.remove]
config.modelAliases = Object.keys(aliases).length > 0 ? aliases : undefined
await saveConfig(config)
console.log(`\n Removed alias: ${opts.remove}\n`)
return
}
if (!from || !to) {
console.error('\n Usage: codeburn model-alias <from> <to>\n')
process.exitCode = 1
return
}
aliases[from] = to
config.modelAliases = aliases
await saveConfig(config)
console.log(`\n Alias saved: ${from} -> ${to}`)
console.log(` Config: ${getConfigFilePath()}\n`)
})
program
.command('plan [action] [id]')
.description('Show or configure a subscription plan for overage tracking')
.option('--format <format>', 'Output format: text or json', 'text')
.option('--monthly-usd <n>', 'Monthly plan price in USD (for custom)', parseNumber)
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor', 'all')
.option('--reset-day <n>', 'Day of month plan resets (1-28)', parseInteger, 1)
.action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => {
assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan')
const mode = action ?? 'show'
if (mode === 'show') {
const plan = await readPlan()
const displayPlan = !plan || plan.id === 'none'
? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
: {
id: plan.id,
monthlyUsd: plan.monthlyUsd,
provider: plan.provider,
resetDay: clampResetDay(plan.resetDay),
setAt: plan.setAt,
}
if (opts?.format === 'json') {
console.log(JSON.stringify(displayPlan))
return
}
if (!plan || plan.id === 'none') {
console.log('\n Plan: none')
console.log(' API-pricing view is active.')
console.log(` Config: ${getConfigFilePath()}\n`)
return
}
console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
console.log(` Budget: $${plan.monthlyUsd}/month`)
console.log(` Provider: ${plan.provider}`)
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
console.log(` Set at: ${plan.setAt}`)
console.log(` Config: ${getConfigFilePath()}\n`)
return
}
if (mode === 'reset') {
await clearPlan()
console.log('\n Plan reset. API-pricing view is active.\n')
return
}
if (mode !== 'set') {
console.error('\n Usage: codeburn plan [set <id> | reset]\n')
process.exitCode = 1
return
}
if (!id || !isPlanId(id)) {
console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`)
process.exitCode = 1
return
}
const resetDay = opts?.resetDay ?? 1
if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) {
console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`)
process.exitCode = 1
return
}
if (id === 'none') {
await clearPlan()
console.log('\n Plan reset. API-pricing view is active.\n')
return
}
if (id === 'custom') {
if (opts?.monthlyUsd === undefined) {
console.error('\n Custom plans require --monthly-usd <positive number>.\n')
process.exitCode = 1
return
}
const monthlyUsd = opts.monthlyUsd
if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) {
console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`)
process.exitCode = 1
return
}
const provider = opts?.provider ?? 'all'
if (!isPlanProvider(provider)) {
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
process.exitCode = 1
return
}
await savePlan({
id: 'custom',
monthlyUsd,
provider,
resetDay,
setAt: new Date().toISOString(),
})
console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`)
console.log(` Config saved to ${getConfigFilePath()}\n`)
return
}
const preset = getPresetPlan(id)
if (!preset) {
console.error(`\n Unknown preset "${id}".\n`)
process.exitCode = 1
return
}
await savePlan({
...preset,
resetDay,
setAt: new Date().toISOString(),
})
console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`)
console.log(` Provider: ${preset.provider}`)
console.log(` Reset day: ${resetDay}`)
console.log(` Config saved to ${getConfigFilePath()}\n`)
})
program
.command('optimize')
.description('Find token waste and get exact fixes')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.action(async (opts) => {
await loadPricing()
await hydrateCache()
const { range, label } = getDateRange(opts.period)
const projects = await parseAllSessions(range, opts.provider)
await runOptimize(projects, label, range)
})
program
.command('compare')
.description('Compare two AI models side-by-side')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', 'all')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.action(async (opts) => {
await loadPricing()
await hydrateCache()
const { range } = getDateRange(opts.period)
await renderCompare(range, opts.provider)
})
program
.command('models')
.description('Per-model token + cost table, optionally exploded by task type')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--from <date>', 'Custom range start (YYYY-MM-DD)')
.option('--to <date>', 'Custom range end (YYYY-MM-DD)')
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
.option('--task <category>', 'Filter to one task type (e.g. feature, debugging, refactoring)')
.option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)')
.option('--top <n>', 'Show only the top N rows', (v: string) => parseInt(v, 10))
.option('--min-cost <usd>', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
.option('--no-totals', 'Suppress the footer totals row')
.option('--format <format>', 'Output format: table, markdown, json, csv', 'table')
.action(async (opts) => {
const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js')
await loadPricing()
await hydrateCache()
let range
if (opts.from || opts.to) {
const customRange = parseDateRangeFlags(opts.from, opts.to)
if (!customRange) {
process.stderr.write('codeburn: --from and --to must be valid YYYY-MM-DD dates\n')
process.exit(1)
}
range = customRange
} else {
range = getDateRange(opts.period).range
}
const projects = await parseAllSessions(range, opts.provider)
const rows = await aggregateModels(projects, {
byTask: !!opts.byTask,
taskFilter: opts.task,
topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined,
minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01,
})
const fmt = (opts.format ?? 'table').toLowerCase()
if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) {
process.stdout.write('No model usage found for the selected period.\n')
return
}
if (fmt === 'json') {
process.stdout.write(renderJson(rows) + '\n')
} else if (fmt === 'csv') {
process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n')
} else if (fmt === 'markdown' || fmt === 'md') {
process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
} else if (fmt === 'table') {
process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
} else {
process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`)
process.exit(1)
}
})
program
.command('yield')
.description('Track which AI spend shipped to main vs reverted/abandoned (experimental)')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', 'week')
.action(async (opts) => {
const { computeYield, formatYieldSummary } = await import('./yield.js')
await loadPricing()
await hydrateCache()
const { range, label } = getDateRange(opts.period)
console.log(`\n Analyzing yield for ${label}...\n`)
const summary = await computeYield(range, process.cwd())
console.log(formatYieldSummary(summary))
})
program.parse()

View file

@ -1,5 +1,6 @@
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { notifyMenubar } from './menubar-socket.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases } from './models.js'
import { parseAllSessions, filterProjectsByName } from './parser.js'
@ -515,6 +516,7 @@ program
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
notifyMenubar()
return
}

12
src/menubar-socket.ts Normal file
View file

@ -0,0 +1,12 @@
import { connect } from 'node:net'
import { homedir } from 'node:os'
import { join } from 'node:path'
const SOCKET_PATH = join(homedir(), '.cache', 'codeburn', 'menubar.sock')
export function notifyMenubar(): void {
const sock = connect(SOCKET_PATH)
sock.on('error', () => {})
sock.write('refresh\n')
sock.end()
}