Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const MS_PER_DAY = 24 * 60 * 60 * 1000

export type BudgetTier = 'daily' | 'weekly' | 'monthly'
export type BudgetState = 'under' | 'warn' | 'over'

export type BudgetStatus = {
spent: number
budget: number
pct: number
projected: number
state: BudgetState
}

export type BudgetStatusInput = {
spent: number
budget: number
elapsedDays: number
totalDays: number
}

function requireFinitePositive(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new RangeError(`${name} must be a finite number greater than 0`)
}
}

function requireFiniteNonNegative(name: string, value: number): void {
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(`${name} must be a finite number greater than or equal to 0`)
}
}

function toDayIndex(d: Date): number {
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / MS_PER_DAY)
}

export function diffCalendarDays(from: Date, to: Date): number {
return toDayIndex(to) - toDayIndex(from)
}

export function daysInMonth(date: Date): number {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
}

export function computeBudgetStatus(input: BudgetStatusInput): BudgetStatus {
requireFiniteNonNegative('spent', input.spent)
requireFinitePositive('budget', input.budget)
requireFinitePositive('elapsedDays', input.elapsedDays)
requireFinitePositive('totalDays', input.totalDays)

const pct = (input.spent / input.budget) * 100
const projected = input.spent * input.totalDays / input.elapsedDays
const state: BudgetState = pct >= 100 ? 'over' : pct >= 80 ? 'warn' : 'under'

return {
spent: input.spent,
budget: input.budget,
pct,
projected,
state,
}
}
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export type CodeburnConfig = {
// can show "saved $X by running locally". Distinct from modelAliases which
// rewrites actual spend.
localModelSavings?: Record<string, string>
// Spend budgets are stored in the configured display currency, not USD.
budget?: {
daily?: number
weekly?: number
monthly?: number
}
// Absolute directory prefixes whose Claude Code sessions are routed through a
// subscription-backed LLM proxy (e.g. GitHub Copilot via ANTHROPIC_BASE_URL;
// tools like claude-code-over-github-copilot / claudegate). The JSONL records
Expand Down
243 changes: 239 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
import { allProviderNames } from './providers/index.js'
import { convertCost } from './currency.js'
import { convertCost, formatCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { toDateString } from './daily-cache.js'
import { dateKey } from './day-aggregator.js'
Expand All @@ -24,12 +24,13 @@ import { loadRemotes, saveRemotes } from './sharing/store.js'
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize } from './optimize.js'
import { renderCompare } from './compare.js'
import { computeBudgetStatus, daysInMonth, diffCalendarDays, type BudgetStatus, type BudgetTier } from './budget.js'
import {
installAntigravityStatusLineHook,
runAgyStatusLineHook,
uninstallAntigravityStatusLineHook,
} from './antigravity-statusline.js'
import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type Plan, type PlanId, type PlanProvider } from './config.js'
import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type CodeburnConfig, type Plan, type PlanId, type PlanProvider } from './config.js'
import { clampResetDay, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js'
import { createRequire } from 'node:module'
Expand Down Expand Up @@ -89,6 +90,29 @@ function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {

type JsonPlanSummaryMap = Partial<Record<PlanProvider, JsonPlanSummary>>

type BudgetCommandOpts = {
daily?: number
weekly?: number
monthly?: number
list?: boolean
remove?: string
check?: boolean
}

type OverviewBudget = {
tier: BudgetTier
status: BudgetStatus
inProgress: boolean
}

type BudgetPeriodInfo = {
tier: BudgetTier
range: DateRange
elapsedDays: number
totalDays: number
inProgress: boolean
}

function toJsonPlanSummaryMap(planUsages: PlanUsage[]): JsonPlanSummaryMap {
const summaries: JsonPlanSummaryMap = {}
for (const usage of planUsages) {
Expand All @@ -114,6 +138,174 @@ function planLabel(plan: Plan): string {
return plan.id === 'custom' ? `${name} (${plan.provider})` : name
}

function formatDisplayCurrencyAmount(amount: number): string {
const { rate } = getCurrency()
return formatCost(rate > 0 ? amount / rate : amount)
}

function isValidBudgetAmount(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
}

function configuredBudgetEntries(budget: CodeburnConfig['budget']): Array<{ tier: BudgetTier; amount: number }> {
const entries: Array<{ tier: BudgetTier; amount: number }> = []
const daily = budget?.daily
const weekly = budget?.weekly
const monthly = budget?.monthly
if (isValidBudgetAmount(daily)) entries.push({ tier: 'daily', amount: daily })
if (isValidBudgetAmount(weekly)) entries.push({ tier: 'weekly', amount: weekly })
if (isValidBudgetAmount(monthly)) entries.push({ tier: 'monthly', amount: monthly })
return entries
}

function budgetTierForOverview(period: Period | undefined, customRange: DateRange | null): BudgetTier | undefined {
if (customRange) return undefined
if (period === 'today') return 'daily'
if (period === 'week') return 'weekly'
if (period === 'month') return 'monthly'
return undefined
}

function budgetAmountForTier(budget: CodeburnConfig['budget'], tier: BudgetTier): number | undefined {
const amount = tier === 'daily'
? budget?.daily
: tier === 'weekly'
? budget?.weekly
: budget?.monthly
return isValidBudgetAmount(amount) ? amount : undefined
}

function periodForBudgetTier(tier: BudgetTier): Extract<Period, 'today' | 'week' | 'month'> {
if (tier === 'daily') return 'today'
if (tier === 'weekly') return 'week'
return 'month'
}

function getBudgetPeriodInfo(tier: BudgetTier): BudgetPeriodInfo {
const { range } = getDateRange(periodForBudgetTier(tier))
const progress = getOverviewBudgetProgress(tier, range)
return { tier, range, ...progress }
}

function getOverviewBudgetProgress(tier: BudgetTier, range: DateRange, today = new Date()): Pick<BudgetPeriodInfo, 'elapsedDays' | 'totalDays' | 'inProgress'> {
if (tier === 'daily') return { elapsedDays: 1, totalDays: 1, inProgress: false }
if (tier === 'weekly') return { elapsedDays: 7, totalDays: 7, inProgress: false }

const totalDays = daysInMonth(today)
const elapsedDays = Math.max(1, Math.min(totalDays, diffCalendarDays(range.start, today) + 1))
return { elapsedDays, totalDays, inProgress: elapsedDays < totalDays }
}

function totalProjectCostUSD(projects: ProjectSummary[]): number {
return projects.reduce((sum, project) => sum + project.totalCostUSD, 0)
}

function buildOverviewBudget(projects: ProjectSummary[], budget: CodeburnConfig['budget'], tier: BudgetTier | undefined, range: DateRange): OverviewBudget | undefined {
if (!tier) return undefined
const amount = budgetAmountForTier(budget, tier)
if (amount === undefined) return undefined
const progress = getOverviewBudgetProgress(tier, range)
return {
tier,
status: computeBudgetStatus({
spent: convertCost(totalProjectCostUSD(projects)),
budget: amount,
elapsedDays: progress.elapsedDays,
totalDays: progress.totalDays,
}),
inProgress: progress.inProgress,
}
}

function isOverviewBudgetFilterActive(opts: { provider: string; project: string[]; exclude: string[] }): boolean {
return opts.provider !== 'all' || opts.project.length > 0 || opts.exclude.length > 0
}

function printBudgetList(budget: CodeburnConfig['budget']): void {
const entries = configuredBudgetEntries(budget)
if (entries.length === 0) {
console.log('\n No budgets configured.')
console.log(` Config: ${getConfigFilePath()}`)
console.log(' Add one with: codeburn budget --monthly <amount>\n')
return
}

console.log('\n Budgets:')
for (const entry of entries) {
console.log(` ${entry.tier}: ${formatDisplayCurrencyAmount(entry.amount)}`)
}
console.log(` Config: ${getConfigFilePath()}\n`)
}

function validateBudgetSetters(opts: BudgetCommandOpts): boolean {
const invalid: Array<{ flag: string; value: number | undefined }> = []
if (opts.daily !== undefined && !isValidBudgetAmount(opts.daily)) invalid.push({ flag: '--daily', value: opts.daily })
if (opts.weekly !== undefined && !isValidBudgetAmount(opts.weekly)) invalid.push({ flag: '--weekly', value: opts.weekly })
if (opts.monthly !== undefined && !isValidBudgetAmount(opts.monthly)) invalid.push({ flag: '--monthly', value: opts.monthly })

if (invalid.length === 0) return true

for (const item of invalid) {
console.error(`\n ${item.flag} must be a finite number greater than 0 (got: ${String(item.value)}).\n`)
}
process.exitCode = 1
return false
}

function assignBudgetSetters(config: CodeburnConfig, opts: BudgetCommandOpts): void {
const budget = { ...(config.budget ?? {}) }
if (opts.daily !== undefined) budget.daily = opts.daily
if (opts.weekly !== undefined) budget.weekly = opts.weekly
if (opts.monthly !== undefined) budget.monthly = opts.monthly
config.budget = budget
}

function removeBudget(config: CodeburnConfig, tier: string): boolean {
if (tier !== 'daily' && tier !== 'weekly' && tier !== 'monthly') {
console.error(`\n Unknown budget period: ${tier}. Use daily, weekly, or monthly.\n`)
process.exitCode = 1
return false
}

const budget = { ...(config.budget ?? {}) }
if (tier === 'daily') delete budget.daily
if (tier === 'weekly') delete budget.weekly
if (tier === 'monthly') delete budget.monthly
config.budget = configuredBudgetEntries(budget).length > 0 ? budget : undefined
return true
}

async function runBudgetCheck(budget: CodeburnConfig['budget']): Promise<void> {
const entries = configuredBudgetEntries(budget)
if (entries.length === 0) {
console.log('\n No budgets configured.')
console.log(' Add one with: codeburn budget --monthly <amount>\n')
return
}

await loadPricing()

let over = false
console.log('')
for (const entry of entries) {
const period = getBudgetPeriodInfo(entry.tier)
const projects = await parseAllSessions(period.range, 'all')
const status = computeBudgetStatus({
spent: convertCost(totalProjectCostUSD(projects)),
budget: entry.amount,
elapsedDays: period.elapsedDays,
totalDays: period.totalDays,
})
const label = status.state === 'over' ? 'OVER' : status.state === 'warn' ? 'WARN' : 'OK'
if (status.state === 'over') over = true
console.log(` ${entry.tier}: ${formatDisplayCurrencyAmount(status.spent)} of ${formatDisplayCurrencyAmount(status.budget)} (${Math.round(status.pct)}%) [${label}]`)
clearSessionCache()
}
console.log('')

if (over) process.exitCode = 1
}

function toPlanDisplay(plan: Plan) {
return {
id: plan.id,
Expand Down Expand Up @@ -569,11 +761,54 @@ program
console.error(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
}
const period = customRange ? undefined : toPeriod(opts.period)
const { range, label } = customRange
? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) }
: getDateRange(toPeriod(opts.period))
: getDateRange(period!)
const projects = filterProjectsByName(await parseAllSessions(range, opts.provider), opts.project, opts.exclude)
process.stdout.write(renderOverview(projects, { label, color: opts.color }))
const config = await readConfig()
const budget = isOverviewBudgetFilterActive(opts)
? undefined
: buildOverviewBudget(projects, config.budget, budgetTierForOverview(period, customRange), range)
process.stdout.write(renderOverview(projects, { label, color: opts.color, budget }))
})

program
.command('budget')
.description('Set spend budgets and check current spend against them')
.option('--daily <amt>', 'Set daily spend budget in the active display currency', parseNumber)
.option('--weekly <amt>', 'Set weekly spend budget in the active display currency', parseNumber)
.option('--monthly <amt>', 'Set monthly spend budget in the active display currency', parseNumber)
.option('--list', 'List configured spend budgets')
.option('--remove <period>', 'Remove one budget: daily, weekly, or monthly')
.option('--check', 'Check current spend and exit 1 if any configured budget is over')
.action(async (opts: BudgetCommandOpts) => {
const config = await readConfig()
const hasSetter = opts.daily !== undefined || opts.weekly !== undefined || opts.monthly !== undefined

if (opts.list || (!hasSetter && !opts.remove && !opts.check)) {
printBudgetList(config.budget)
return
}

if (opts.remove) {
if (!removeBudget(config, opts.remove)) return
await saveConfig(config)
console.log(`\n Removed ${opts.remove} budget.`)
console.log(` Config: ${getConfigFilePath()}\n`)
return
}

if (opts.check) {
await runBudgetCheck(config.budget)
return
}

if (!validateBudgetSetters(opts)) return
assignBudgetSetters(config, opts)
await saveConfig(config)
console.log('\n Budget saved.')
printBudgetList(config.budget)
})

program
Expand Down
Loading
Loading