From 95e97b19685b9d5d924665434b4ade194a796e95 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:21:51 +0300 Subject: [PATCH] feat(budget): spend budgets with overview display and scriptable --check --- src/budget.ts | 62 ++++++++++ src/config.ts | 6 + src/main.ts | 243 ++++++++++++++++++++++++++++++++++++++- src/overview.ts | 28 ++++- tests/budget.test.ts | 27 +++++ tests/cli-budget.test.ts | 221 +++++++++++++++++++++++++++++++++++ tests/overview.test.ts | 43 +++++++ 7 files changed, 624 insertions(+), 6 deletions(-) create mode 100644 src/budget.ts create mode 100644 tests/budget.test.ts create mode 100644 tests/cli-budget.test.ts diff --git a/src/budget.ts b/src/budget.ts new file mode 100644 index 00000000..e35c563b --- /dev/null +++ b/src/budget.ts @@ -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, + } +} diff --git a/src/config.ts b/src/config.ts index a1e0abab..d570ed5c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,6 +42,12 @@ export type CodeburnConfig = { // can show "saved $X by running locally". Distinct from modelAliases which // rewrites actual spend. localModelSavings?: Record + // 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 diff --git a/src/main.ts b/src/main.ts index 9f0df2ea..1fa11d25 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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' @@ -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' @@ -89,6 +90,29 @@ function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary { type JsonPlanSummaryMap = Partial> +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) { @@ -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 { + 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 { + 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 \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 { + const entries = configuredBudgetEntries(budget) + if (entries.length === 0) { + console.log('\n No budgets configured.') + console.log(' Add one with: codeburn budget --monthly \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, @@ -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 ', 'Set daily spend budget in the active display currency', parseNumber) + .option('--weekly ', 'Set weekly spend budget in the active display currency', parseNumber) + .option('--monthly ', 'Set monthly spend budget in the active display currency', parseNumber) + .option('--list', 'List configured spend budgets') + .option('--remove ', '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 diff --git a/src/overview.ts b/src/overview.ts index bec118a0..6cded447 100644 --- a/src/overview.ts +++ b/src/overview.ts @@ -3,9 +3,10 @@ import { Chalk, type ChalkInstance } from 'chalk' import { homedir } from 'os' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' -import { formatCost as baseCost } from './currency.js' +import { formatCost as baseCost, getCurrency } from './currency.js' import { getShortModelName } from './models.js' import { dateKey } from './day-aggregator.js' +import type { BudgetStatus, BudgetTier } from './budget.js' // Display-only helpers. The shared formatters omit thousands separators and // abbreviate; here we show full, comma-grouped numbers so the tables read like @@ -13,6 +14,10 @@ import { dateKey } from './day-aggregator.js' function formatCost(usd: number): string { return baseCost(usd).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,') } +function formatDisplayCost(amount: number): string { + const { rate } = getCurrency() + return formatCost(rate > 0 ? amount / rate : amount) +} function formatTokens(n: number): string { return Math.round(n).toLocaleString() } @@ -31,6 +36,11 @@ function projectName(p: ProjectSummary): string { } type Col = { header: string; right?: boolean } +type OverviewBudget = { + tier: BudgetTier + status: BudgetStatus + inProgress: boolean +} // Visible width, ignoring ANSI color codes, so padding stays aligned. function vlen(s: string): number { @@ -67,7 +77,7 @@ function renderTable(c: ChalkInstance, cols: Col[], rows: string[][]): string { export function renderOverview( projects: ProjectSummary[], - opts: { label: string; color: boolean }, + opts: { label: string; color: boolean; budget?: OverviewBudget }, ): string { const c = new Chalk(opts.color ? {} : { level: 0 }) const heading = (text: string): string => c.cyan.bold(text) @@ -153,6 +163,20 @@ export function renderOverview( out.push(kv('Calls', calls.toLocaleString() + c.dim(' sessions ') + sessions.toLocaleString())) out.push(kv('Cache hit', `${cacheHit.toFixed(1)}%`)) if (savings > 0) out.push(kv('Savings', formatCost(savings) + c.dim(' (local models)'))) + if (opts.budget) { + const label = opts.budget.tier === 'daily' + ? 'Daily' + : opts.budget.tier === 'weekly' + ? 'Weekly' + : 'Monthly' + const status = opts.budget.status + const pct = `${Math.round(status.pct)}%` + const statusColor = status.state === 'over' ? c.red : status.state === 'warn' ? c.yellow : c.green + const projected = opts.budget.inProgress + ? c.dim(` projected ${formatDisplayCost(status.projected)} by ${opts.budget.tier === 'monthly' ? 'month' : opts.budget.tier === 'weekly' ? 'week' : 'day'} end`) + : '' + out.push(' ' + statusColor(`${label} budget: ${formatDisplayCost(status.spent)} of ${formatDisplayCost(status.budget)} (${pct})`) + projected) + } out.push('') // Tokens breakdown: input / output / cache in (written) / cache out (read) diff --git a/tests/budget.test.ts b/tests/budget.test.ts new file mode 100644 index 00000000..d8857776 --- /dev/null +++ b/tests/budget.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { computeBudgetStatus } from '../src/budget.js' + +describe('computeBudgetStatus', () => { + it('classifies under, warn, and over at the threshold boundaries', () => { + expect(computeBudgetStatus({ spent: 79.99, budget: 100, elapsedDays: 1, totalDays: 1 }).state).toBe('under') + expect(computeBudgetStatus({ spent: 80, budget: 100, elapsedDays: 1, totalDays: 1 }).state).toBe('warn') + expect(computeBudgetStatus({ spent: 99.99, budget: 100, elapsedDays: 1, totalDays: 1 }).state).toBe('warn') + expect(computeBudgetStatus({ spent: 100, budget: 100, elapsedDays: 1, totalDays: 1 }).state).toBe('over') + }) + + it('computes percent and linear projection from elapsed and total days', () => { + const status = computeBudgetStatus({ spent: 30, budget: 120, elapsedDays: 10, totalDays: 30 }) + + expect(status.pct).toBe(25) + expect(status.projected).toBe(90) + }) + + it('rejects invalid numeric inputs', () => { + expect(() => computeBudgetStatus({ spent: -1, budget: 100, elapsedDays: 1, totalDays: 1 })).toThrow(/spent/) + expect(() => computeBudgetStatus({ spent: 1, budget: 0, elapsedDays: 1, totalDays: 1 })).toThrow(/budget/) + expect(() => computeBudgetStatus({ spent: 1, budget: 100, elapsedDays: 0, totalDays: 1 })).toThrow(/elapsedDays/) + expect(() => computeBudgetStatus({ spent: 1, budget: 100, elapsedDays: 1, totalDays: -1 })).toThrow(/totalDays/) + expect(() => computeBudgetStatus({ spent: Number.POSITIVE_INFINITY, budget: 100, elapsedDays: 1, totalDays: 1 })).toThrow(/spent/) + }) +}) diff --git a/tests/cli-budget.test.ts b/tests/cli-budget.test.ts new file mode 100644 index 00000000..cc5b75ff --- /dev/null +++ b/tests/cli-budget.test.ts @@ -0,0 +1,221 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, expect, it } from 'vitest' + +import { getDateRange } from '../src/cli-date.js' + +const CLI_TIMEOUT_MS = 30_000 + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + HOME: home, + USERPROFILE: home, + HOMEPATH: home, + HOMEDRIVE: '', + TZ: 'UTC', + }, + encoding: 'utf-8', + timeout: CLI_TIMEOUT_MS, + }) +} + +async function readConfig(home: string): Promise<{ budget?: { daily?: number; weekly?: number; monthly?: number } }> { + const raw = await readFile(join(home, '.config', 'codeburn', 'config.json'), 'utf-8') + return JSON.parse(raw) as { budget?: { daily?: number; weekly?: number; monthly?: number } } +} + +function timestampFromDate(date: Date, offsetMinutes = 0): string { + return new Date(date.getTime() + offsetMinutes * 60_000) + .toISOString() + .replace(/\.\d+Z$/, 'Z') +} + +function currentMonthTimestamp(offsetMinutes: number): string { + const now = new Date() + const base = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 12, 0, 0)) + return timestampFromDate(base, offsetMinutes) +} + +function userLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + cwd: '/tmp/codeburn-budget-app', + message: { role: 'user', content: 'ship budget check' }, + }) +} + +function assistantLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + cwd: '/tmp/codeburn-budget-app', + message: { + id: `msg-${sessionId}`, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { + input_tokens: 1_000_000, + output_tokens: 100_000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) +} + +async function seedClaudeSpend(home: string, opts: { sessionId: string; timestamp: string }): Promise { + const projectDir = join(home, '.claude', 'projects', 'budget-app') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, `${opts.sessionId}.jsonl`), + [ + userLine(opts.sessionId, opts.timestamp), + assistantLine(opts.sessionId, timestampFromDate(new Date(opts.timestamp), 1)), + ].join('\n') + '\n', + 'utf-8', + ) +} + +async function seedCurrentMonthSpend(home: string): Promise { + await seedClaudeSpend(home, { sessionId: 'budget-session', timestamp: currentMonthTimestamp(0) }) +} + +describe('codeburn budget command', () => { + it('saves, lists, and removes a monthly budget', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-budget-')) + try { + const set = runCli(['budget', '--monthly', '123.45'], home) + expect(set.status, `stderr: ${set.stderr}`).toBe(0) + expect(set.stdout).toContain('monthly') + + const saved = await readConfig(home) + expect(saved.budget?.monthly).toBe(123.45) + + const list = runCli(['budget', '--list'], home) + expect(list.status).toBe(0) + expect(list.stdout).toContain('monthly') + expect(list.stdout).toContain('$123.45') + + const remove = runCli(['budget', '--remove', 'monthly'], home) + expect(remove.status).toBe(0) + + const after = await readConfig(home) + expect(after.budget).toBeUndefined() + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) + + it('rejects an invalid amount without writing a budget', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-budget-')) + try { + const result = runCli(['budget', '--daily', '0'], home) + expect(result.status).toBe(1) + expect(result.stderr).toContain('--daily must be a finite number greater than 0') + + const list = runCli(['budget', '--list'], home) + expect(list.status).toBe(0) + expect(list.stdout).toContain('No budgets configured') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) + + it('exits 1 when the current month is over budget', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-budget-')) + try { + await seedCurrentMonthSpend(home) + expect(runCli(['budget', '--monthly', '0.01'], home).status).toBe(0) + + const result = runCli(['budget', '--check'], home) + expect(result.status, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(1) + expect(result.stdout).toContain('monthly:') + expect(result.stdout).toContain('[OVER]') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) + + it('exits 0 when the current month is under budget or no budget is configured', async () => { + const noneHome = await mkdtemp(join(tmpdir(), 'codeburn-cli-budget-')) + const underHome = await mkdtemp(join(tmpdir(), 'codeburn-cli-budget-')) + try { + const none = runCli(['budget', '--check'], noneHome) + expect(none.status).toBe(0) + expect(none.stdout).toContain('No budgets configured') + + await seedCurrentMonthSpend(underHome) + expect(runCli(['budget', '--monthly', '100000'], underHome).status).toBe(0) + + const under = runCli(['budget', '--check'], underHome) + expect(under.status, `stdout: ${under.stdout}\nstderr: ${under.stderr}`).toBe(0) + expect(under.stdout).toContain('monthly:') + expect(under.stdout).toContain('[OK]') + } finally { + await rm(noneHome, { recursive: true, force: true }) + await rm(underHome, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) + + it('keeps overview budget lines only on unfiltered overviews', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-budget-')) + try { + await seedCurrentMonthSpend(home) + expect(runCli(['budget', '--monthly', '100000'], home).status).toBe(0) + + const unfiltered = runCli(['overview', '-p', 'month', '--no-color'], home) + expect(unfiltered.status, `stdout: ${unfiltered.stdout}\nstderr: ${unfiltered.stderr}`).toBe(0) + expect(unfiltered.stdout).toContain('Monthly budget:') + + for (const filterArgs of [ + ['--provider', 'claude'], + ['--project', 'budget-app'], + ['--exclude', 'not-the-budget-app'], + ]) { + const filtered = runCli(['overview', '-p', 'month', '--no-color', ...filterArgs], home) + expect(filtered.status, `args: ${filterArgs.join(' ')}\nstdout: ${filtered.stdout}\nstderr: ${filtered.stderr}`).toBe(0) + expect(filtered.stdout).toContain('Totals') + expect(filtered.stdout).not.toMatch(/\b(?:Daily|Weekly|Monthly) budget:/) + } + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) + + it('uses the same weekly spend window for budget --check and overview -p week', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-budget-')) + try { + const weekStart = getDateRange('week').range.start + await seedClaudeSpend(home, { + sessionId: 'weekly-boundary-session', + timestamp: timestampFromDate(weekStart, 1), + }) + expect(runCli(['budget', '--weekly', '100000'], home).status).toBe(0) + + const overview = runCli(['overview', '-p', 'week', '--no-color'], home) + expect(overview.status, `stdout: ${overview.stdout}\nstderr: ${overview.stderr}`).toBe(0) + const overviewSpent = overview.stdout.match(/Weekly budget: (\$[0-9,]+\.\d{2}) of/) + expect(overviewSpent?.[1]).toBeDefined() + expect(overviewSpent?.[1]).not.toBe('$0.00') + + const check = runCli(['budget', '--check'], home) + expect(check.status, `stdout: ${check.stdout}\nstderr: ${check.stderr}`).toBe(0) + const checkSpent = check.stdout.match(/weekly:\s+(\$[0-9,]+\.\d{2}) of/) + expect(checkSpent?.[1]).toBe(overviewSpent?.[1]) + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) +}) diff --git a/tests/overview.test.ts b/tests/overview.test.ts index 5f2dd759..597ad748 100644 --- a/tests/overview.test.ts +++ b/tests/overview.test.ts @@ -101,6 +101,49 @@ describe('renderOverview', () => { expect(out).toContain('No usage found for June 2026') }) + it('does not render a budget line when no budget is provided', () => { + const out = renderOverview([makeProject({ + project: 'myproject', + projectPath: '/Users/test/myproject', + cost: 12.5, + calls: 3, + model: 'claude-opus-4-8', + provider: 'claude', + tokens: { input: 1000, output: 200, cacheR: 5000, cacheW: 100 }, + })], { label: 'June 2026', color: false }) + + expect(out).not.toContain('budget:') + }) + + it('renders a configured monthly budget line with percent and projection', () => { + const out = renderOverview([makeProject({ + project: 'myproject', + projectPath: '/Users/test/myproject', + cost: 80, + calls: 3, + model: 'claude-opus-4-8', + provider: 'claude', + tokens: { input: 1000, output: 200, cacheR: 5000, cacheW: 100 }, + })], { + label: 'June 2026', + color: false, + budget: { + tier: 'monthly', + inProgress: true, + status: { + spent: 80, + budget: 120, + pct: 66.6666666667, + projected: 160, + state: 'under', + }, + }, + }) + + expect(out).toContain('Monthly budget: $80.00 of $120.00 (67%)') + expect(out).toContain('projected $160.00 by month end') + }) + it('does not split a slug-only Claude project path into fake path segments', () => { const out = renderOverview([makeProject({ project: 'Projects-Content-OS',