feat(plan): subscription plan tracking with usage progress bar

Adds `codeburn plan set <id>` to configure a subscription plan (Claude Pro,
Claude Max, Cursor Pro, or custom). When set, the Overview panel renders
an API-equivalent progress bar against subscription price with a
projected month-end cost.

Closes the loudest demand signal on the repo: issue #11 ("Subscription
vs API Use") from two independent voices, plus the routing-decision use
case raised in #12.

- src/config.ts: extends CodeburnConfig with Plan, adds readPlan/savePlan/clearPlan
- src/plans.ts: presets (claude-pro $20, claude-max $200, cursor-pro $20)
- src/plan-usage.ts: getPlanUsage, resetDay-aware period math (1-28),
  median-of-7-day-trailing projection
- src/cli.ts: `codeburn plan [show|set|reset]` subcommand, plan wired
  into JSON outputs for report/today/month/status (only when active)
- src/dashboard.tsx: Plan row in Overview, color-coded (green under 80%,
  orange near, red over), with days-until-reset
- README.md: Plans section with honest framing (API-equivalent vs
  subscription price, not token allowance)
- tests/plan-usage.test.ts, tests/plans.test.ts, tests/cli-plan.test.ts:
  period math, presets, CLI round-trip

Resets respect resetDay across month boundaries. Uses median daily spend
(not mean) so one huge day doesn't distort the month-end projection.

Fixes #11
This commit is contained in:
Trevin Chow 2026-04-17 21:14:52 -07:00 committed by iamtoruk
parent 8e39a89fe0
commit 3f7470d29b
9 changed files with 753 additions and 22 deletions

View file

@ -166,6 +166,22 @@ The currency setting applies everywhere: dashboard, status bar, menu bar widget,
The menu bar widget includes a currency picker with 17 common currencies. For any currency not listed, use the CLI command above.
## Plans (subscription tracking)
If you're on Claude Pro, Claude Max, or Cursor Pro, set your plan so the dashboard shows subscription-relative usage:
```bash
codeburn plan set claude-max # $200/month
codeburn plan set claude-pro # $20/month
codeburn plan set cursor-pro # $20/month
codeburn plan set custom --monthly-usd 150 --provider claude # custom
codeburn plan set none # disable plan view
codeburn plan # show current
codeburn plan reset # remove plan config
```
The progress bar shows API-equivalent cost vs subscription price. Presets use publicly stated plan prices (as of April 2026); they do not model exact token allowances, because vendors do not publish precise consumer-plan limits.
## Menu Bar
<img src="https://cdn.jsdelivr.net/gh/getagentseal/codeburn@main/assets/menubar-0.8.0.png" alt="CodeBurn macOS menubar app" width="420" />

View file

@ -15,7 +15,9 @@ import { parseDateRangeFlags } from './cli-date.js'
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js'
import { readConfig, saveConfig, getConfigFilePath } from './config.js'
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath } 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)
@ -84,11 +86,50 @@ function collect(val: string, acc: string[]): string[] {
return acc
}
function parseNumber(value: string): number {
return Number(value)
}
function parseInteger(value: string): number {
return parseInt(value, 10)
}
type JsonPlanSummary = {
id: 'claude-pro' | 'claude-max' | 'cursor-pro' | 'custom'
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(),
}
}
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)
console.log(JSON.stringify(buildJsonReport(projects, label, period), null, 2))
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()
@ -483,11 +524,21 @@ program
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()
console.log(JSON.stringify({
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
}
@ -638,6 +689,125 @@ program
console.log(` Config saved to ${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 }) => {
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')

View file

@ -1,12 +1,24 @@
import { readFile, writeFile, mkdir } from 'fs/promises'
import { readFile, writeFile, mkdir, rename } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
export type PlanId = 'claude-pro' | 'claude-max' | 'cursor-pro' | 'custom' | 'none'
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'all'
export type Plan = {
id: PlanId
monthlyUsd: number
provider: PlanProvider
resetDay?: number
setAt: string
}
export type CodeburnConfig = {
currency?: {
code: string
symbol?: string
}
plan?: Plan
}
function getConfigDir(): string {
@ -21,14 +33,37 @@ export async function readConfig(): Promise<CodeburnConfig> {
try {
const raw = await readFile(getConfigPath(), 'utf-8')
return JSON.parse(raw) as CodeburnConfig
} catch {
return {}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {}
}
throw error
}
}
export async function saveConfig(config: CodeburnConfig): Promise<void> {
await mkdir(getConfigDir(), { recursive: true })
await writeFile(getConfigPath(), JSON.stringify(config, null, 2) + '\n', 'utf-8')
const configPath = getConfigPath()
const tmpPath = `${configPath}.tmp`
await writeFile(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
await rename(tmpPath, configPath)
}
export async function readPlan(): Promise<Plan | undefined> {
const config = await readConfig()
return config.plan
}
export async function savePlan(plan: Plan): Promise<void> {
const config = await readConfig()
config.plan = plan
await saveConfig(config)
}
export async function clearPlan(): Promise<void> {
const config = await readConfig()
delete config.plan
await saveConfig(config)
}
export function getConfigFilePath(): string {

View file

@ -11,6 +11,8 @@ import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult
import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js'
import { dateKey } from './day-aggregator.js'
import { CompareView } from './compare.js'
import { getPlanUsageOrNullForProjects, type PlanUsage } from './plan-usage.js'
import { planDisplayName } from './plans.js'
import { join } from 'path'
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
@ -29,6 +31,7 @@ const MIN_WIDE = 90
const ORANGE = '#FF8C42'
const DIM = '#555555'
const GOLD = '#FFD700'
const PLAN_BAR_WIDTH = 10
const LANG_DISPLAY_NAMES: Record<string, string> = {
javascript: 'JavaScript', typescript: 'TypeScript', python: 'Python',
@ -154,7 +157,17 @@ function fit(s: string, n: number): string {
return s.length > n ? s.slice(0, n) : s.padEnd(n)
}
function Overview({ projects, label, width }: { projects: ProjectSummary[]; label: string; width: number }) {
function formatUsd(value: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }).format(value)
}
function renderPlanBar(percentUsed: number, width: number): string {
const capped = Math.max(0, Math.min(100, percentUsed))
const filled = Math.round((capped / 100) * width)
return `${'▓'.repeat(filled)}${'░'.repeat(Math.max(0, width - filled))}`
}
function Overview({ projects, label, width, planUsage }: { projects: ProjectSummary[]; label: string; width: number; planUsage?: PlanUsage }) {
const totalCost = 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)
@ -166,6 +179,15 @@ function Overview({ projects, label, width }: { projects: ProjectSummary[]; labe
const allInputTokens = totalInput + totalCacheRead + totalCacheWrite
const cacheHit = allInputTokens > 0
? (totalCacheRead / allInputTokens) * 100 : 0
const planLabel = planUsage ? `${planDisplayName(planUsage.plan.id)} plan: ${formatUsd(planUsage.spentApiEquivalentUsd)} equiv / ${formatUsd(planUsage.budgetUsd)} included` : ''
const planPct = planUsage ? `${planUsage.percentUsed.toFixed(1)}%` : ''
const planColor = planUsage
? planUsage.status === 'over'
? '#F55B5B'
: planUsage.status === 'near'
? ORANGE
: '#5BF58C'
: DIM
return (
<Box flexDirection="column" borderStyle="round" borderColor={PANEL_COLORS.overview} paddingX={1} width={width}>
@ -186,6 +208,24 @@ function Overview({ projects, label, width }: { projects: ProjectSummary[]; labe
<Text dimColor wrap="truncate-end">
{formatTokens(totalInput)} in {formatTokens(totalOutput)} out {formatTokens(totalCacheRead)} cached {formatTokens(totalCacheWrite)} written
</Text>
{planUsage && (
<>
<Text wrap="truncate-end">
<Text color={planColor}>{planLabel}</Text>
<Text> </Text>
<Text color={planColor}>{renderPlanBar(planUsage.percentUsed, PLAN_BAR_WIDTH)}</Text>
<Text> </Text>
<Text bold color={planColor}>{planPct}</Text>
</Text>
<Text dimColor wrap="truncate-end">
{planUsage.status === 'under'
? `Well within plan. Projected month: ${formatUsd(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
: planUsage.status === 'near'
? `Approaching plan limit. Projected month: ${formatUsd(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
: `You're ${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x over subscription value; running on API overage pricing.`}
</Text>
</>
)}
</Box>
)
}
@ -558,7 +598,7 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children
return <>{children}</>
}
function DashboardContent({ projects, period, columns, activeProvider, budgets }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget> }) {
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsage }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsage?: PlanUsage }) {
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
const isCursor = activeProvider === 'cursor'
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text></Panel>
@ -566,7 +606,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets }
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
return (
<Box flexDirection="column" width={dashWidth}>
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} />
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} planUsage={planUsage} />
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
@ -579,10 +619,11 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets }
)
}
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds, projectFilter, excludeFilter }: {
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsage, refreshSeconds, projectFilter, excludeFilter }: {
initialProjects: ProjectSummary[]
initialPeriod: Period
initialProvider: string
initialPlanUsage?: PlanUsage
refreshSeconds?: number
projectFilter?: string[]
excludeFilter?: string[]
@ -596,6 +637,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const [view, setView] = useState<View>('dashboard')
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
const [planUsage, setPlanUsage] = useState<PlanUsage | undefined>(initialPlanUsage)
const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns)
const multipleProviders = detectedProviders.length > 1
@ -605,6 +647,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
).size
const compareAvailable = modelCount >= 2
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reloadGenerationRef = useRef(0)
const findingCount = optimizeResult?.findings.length ?? 0
useEffect(() => {
@ -648,11 +691,28 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
}, [projects, period, optimizeAvailable])
const reloadData = useCallback(async (p: Period, prov: string) => {
const generation = ++reloadGenerationRef.current
setLoading(true)
const range = getDateRange(p)
const data = filterProjectsByName(await parseAllSessions(range, prov), projectFilter, excludeFilter)
setProjects(data)
setLoading(false)
setOptimizeResult(null)
try {
const range = getDateRange(p)
const data = await parseAllSessions(range, prov)
if (reloadGenerationRef.current !== generation) return
const filteredProjects = filterProjectsByName(data, projectFilter, excludeFilter)
if (reloadGenerationRef.current !== generation) return
setProjects(filteredProjects)
const usage = await getPlanUsageOrNullForProjects(filteredProjects)
if (reloadGenerationRef.current !== generation) return
setPlanUsage(usage ?? undefined)
} catch (error) {
console.error(error)
} finally {
if (reloadGenerationRef.current === generation) {
setLoading(false)
}
}
}, [projectFilter, excludeFilter])
useEffect(() => {
@ -721,19 +781,19 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
: view === 'optimize' && optimizeResult
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} />
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} />}
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsage={planUsage} />}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} />}
</Box>
)
}
function StaticDashboard({ projects, period, activeProvider }: { projects: ProjectSummary[]; period: Period; activeProvider?: string }) {
function StaticDashboard({ projects, period, activeProvider, planUsage }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsage?: PlanUsage }) {
const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns)
return (
<Box flexDirection="column" width={dashWidth}>
<PeriodTabs active={period} />
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} />
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsage={planUsage} />
</Box>
)
}
@ -741,15 +801,16 @@ function StaticDashboard({ projects, period, activeProvider }: { projects: Proje
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null): Promise<void> {
await loadPricing()
const range = customRange ?? getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const planUsage = await getPlanUsageOrNullForProjects(filteredProjects)
const isTTY = process.stdin.isTTY && process.stdout.isTTY
if (isTTY) {
const { waitUntilExit } = render(
<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} />
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsage={planUsage ?? undefined} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} />
)
await waitUntilExit()
} else {
const { unmount } = render(<StaticDashboard projects={projects} period={period} activeProvider={provider} />, { patchConsole: false })
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsage={planUsage ?? undefined} />, { patchConsole: false })
unmount()
}
}

154
src/plan-usage.ts Normal file
View file

@ -0,0 +1,154 @@
import { readPlan, type Plan } from './config.js'
import { parseAllSessions } from './parser.js'
import type { DateRange, ProjectSummary } from './types.js'
const MS_PER_DAY = 24 * 60 * 60 * 1000
const PLAN_NEAR_THRESHOLD_PCT = 80
export type PlanStatus = 'under' | 'near' | 'over'
export type PlanUsage = {
plan: Plan
periodStart: Date
periodEnd: Date
spentApiEquivalentUsd: number
budgetUsd: number
percentUsed: number
status: PlanStatus
projectedMonthUsd: number
daysUntilReset: number
}
export function clampResetDay(resetDay: number | undefined): number {
if (!Number.isInteger(resetDay)) return 1
return Math.min(28, Math.max(1, resetDay ?? 1))
}
export function computePeriodFromResetDay(resetDay: number | undefined, today: Date): { periodStart: Date; periodEnd: Date } {
const day = clampResetDay(resetDay)
const year = today.getFullYear()
const month = today.getMonth()
if (today.getDate() >= day) {
return {
periodStart: new Date(year, month, day, 0, 0, 0, 0),
periodEnd: new Date(year, month + 1, day, 0, 0, 0, 0),
}
}
return {
periodStart: new Date(year, month - 1, day, 0, 0, 0, 0),
periodEnd: new Date(year, month, day, 0, 0, 0, 0),
}
}
function median(values: number[]): number {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
if (sorted.length % 2 === 0) {
return (sorted[mid - 1] + sorted[mid]) / 2
}
return sorted[mid]!
}
function toLocalDateKey(d: Date): string {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function toDayIndex(d: Date): number {
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / MS_PER_DAY)
}
function diffCalendarDays(from: Date, to: Date): number {
return toDayIndex(to) - toDayIndex(from)
}
export function projectMonthEnd(
projects: ProjectSummary[],
periodStart: Date,
periodEnd: Date,
today: Date,
spent: number,
): number {
const dayCosts = new Map<string, number>()
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.timestamp) continue
const ts = new Date(turn.timestamp)
if (Number.isNaN(ts.getTime())) continue
if (ts < periodStart || ts > today) continue
const dayKey = toLocalDateKey(ts)
const turnCost = turn.assistantCalls.reduce((sum, call) => sum + call.costUSD, 0)
dayCosts.set(dayKey, (dayCosts.get(dayKey) ?? 0) + turnCost)
}
}
}
const elapsedDays = Math.max(1, diffCalendarDays(periodStart, today) + 1)
const elapsedDailyCosts: number[] = []
for (let i = 0; i < elapsedDays; i++) {
const date = new Date(periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate() + i)
elapsedDailyCosts.push(dayCosts.get(toLocalDateKey(date)) ?? 0)
}
const trailingWindow = elapsedDailyCosts.slice(-7)
const medianDailyCost = median(trailingWindow)
const daysRemaining = Math.max(0, diffCalendarDays(today, periodEnd) - 1)
return spent + medianDailyCost * daysRemaining
}
export function getPlanUsageFromProjects(plan: Plan, projects: ProjectSummary[], today = new Date()): PlanUsage {
const { periodStart, periodEnd } = computePeriodFromResetDay(plan.resetDay, today)
const spent = projects.reduce((sum, p) => sum + p.totalCostUSD, 0)
const budgetUsd = plan.monthlyUsd
const percentUsed = budgetUsd > 0 ? (spent / budgetUsd) * 100 : 0
const status: PlanStatus = percentUsed > 100 ? 'over' : percentUsed >= PLAN_NEAR_THRESHOLD_PCT ? 'near' : 'under'
const projectedMonthUsd = projectMonthEnd(projects, periodStart, periodEnd, today, spent)
const daysUntilReset = Math.max(0, diffCalendarDays(today, periodEnd))
return {
plan,
periodStart,
periodEnd,
spentApiEquivalentUsd: spent,
budgetUsd,
percentUsed,
status,
projectedMonthUsd,
daysUntilReset,
}
}
export async function getPlanUsage(plan: Plan, today = new Date()): Promise<PlanUsage> {
const { periodStart } = computePeriodFromResetDay(plan.resetDay, today)
const range: DateRange = {
start: periodStart,
end: today,
}
const provider = plan.provider === 'all' ? 'all' : plan.provider
const projects = await parseAllSessions(range, provider)
return getPlanUsageFromProjects(plan, projects, today)
}
export async function getPlanUsageOrNull(today = new Date()): Promise<PlanUsage | null> {
const plan = await readPlan()
if (!isActivePlan(plan)) return null
return getPlanUsage(plan, today)
}
export async function getPlanUsageOrNullForProjects(projects: ProjectSummary[], today = new Date()): Promise<PlanUsage | null> {
const plan = await readPlan()
if (!isActivePlan(plan)) return null
return getPlanUsageFromProjects(plan, projects, today)
}
export function isActivePlan(plan: Plan | undefined): plan is Plan {
return Boolean(plan) && plan.id !== 'none' && Number.isFinite(plan.monthlyUsd) && plan.monthlyUsd > 0
}

55
src/plans.ts Normal file
View file

@ -0,0 +1,55 @@
import type { Plan, PlanId, PlanProvider } from './config.js'
export const PLAN_PROVIDERS: PlanProvider[] = ['all', 'claude', 'codex', 'cursor']
export const PLAN_IDS: PlanId[] = ['claude-pro', 'claude-max', 'cursor-pro', 'custom', 'none']
export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'cursor-pro', Omit<Plan, 'setAt'>> = {
'claude-pro': {
id: 'claude-pro',
monthlyUsd: 20,
provider: 'claude',
resetDay: 1,
},
'claude-max': {
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
},
'cursor-pro': {
id: 'cursor-pro',
monthlyUsd: 20,
provider: 'cursor',
resetDay: 1,
},
}
export function isPlanProvider(value: string): value is PlanProvider {
return PLAN_PROVIDERS.includes(value as PlanProvider)
}
export function isPlanId(value: string): value is PlanId {
return PLAN_IDS.includes(value as PlanId)
}
export function getPresetPlan(id: string): Omit<Plan, 'setAt'> | null {
if (id in PRESET_PLANS) {
return PRESET_PLANS[id as keyof typeof PRESET_PLANS]
}
return null
}
export function planDisplayName(id: PlanId): string {
switch (id) {
case 'claude-pro':
return 'Claude Pro'
case 'claude-max':
return 'Claude Max'
case 'cursor-pro':
return 'Cursor Pro'
case 'custom':
return 'Custom'
case 'none':
return 'None'
}
}

55
tests/cli-plan.test.ts Normal file
View file

@ -0,0 +1,55 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, it, expect } from 'vitest'
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
},
encoding: 'utf-8',
})
}
describe('codeburn plan command', () => {
it('persists plan set and clears on reset', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
const setResult = runCli(['plan', 'set', 'claude-max'], home)
expect(setResult.status).toBe(0)
const configPath = join(home, '.config', 'codeburn', 'config.json')
const configRaw = await readFile(configPath, 'utf-8')
const config = JSON.parse(configRaw) as { plan?: { id?: string; monthlyUsd?: number } }
expect(config.plan?.id).toBe('claude-max')
expect(config.plan?.monthlyUsd).toBe(200)
const resetResult = runCli(['plan', 'reset'], home)
expect(resetResult.status).toBe(0)
const afterResetRaw = await readFile(configPath, 'utf-8')
const afterReset = JSON.parse(afterResetRaw) as { plan?: unknown }
expect(afterReset.plan).toBeUndefined()
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('shows invalid reset-day value in error output', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
const result = runCli(['plan', 'set', 'claude-max', '--reset-day', '99'], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('--reset-day must be an integer from 1 to 28; got 99.')
} finally {
await rm(home, { recursive: true, force: true })
}
})
})

122
tests/plan-usage.test.ts Normal file
View file

@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { computePeriodFromResetDay, getPlanUsage, getPlanUsageFromProjects } from '../src/plan-usage.js'
const { parseAllSessionsMock } = vi.hoisted(() => ({
parseAllSessionsMock: vi.fn(),
}))
vi.mock('../src/parser.js', () => ({
parseAllSessions: parseAllSessionsMock,
}))
describe('computePeriodFromResetDay', () => {
it('uses current month when today is on/after reset day', () => {
const { periodStart, periodEnd } = computePeriodFromResetDay(1, new Date('2026-04-17T10:00:00.000Z'))
expect(periodStart.getFullYear()).toBe(2026)
expect(periodStart.getMonth()).toBe(3)
expect(periodStart.getDate()).toBe(1)
expect(periodEnd.getMonth()).toBe(4)
expect(periodEnd.getDate()).toBe(1)
})
it('uses previous month when today is before reset day', () => {
const { periodStart, periodEnd } = computePeriodFromResetDay(15, new Date('2026-04-03T10:00:00.000Z'))
expect(periodStart.getMonth()).toBe(2)
expect(periodStart.getDate()).toBe(15)
expect(periodEnd.getMonth()).toBe(3)
expect(periodEnd.getDate()).toBe(15)
})
it('clamps reset day into 1..28', () => {
const { periodStart } = computePeriodFromResetDay(99, new Date('2026-04-27T10:00:00.000Z'))
expect(periodStart.getDate()).toBe(28)
})
})
describe('getPlanUsage', () => {
beforeEach(() => {
parseAllSessionsMock.mockReset()
})
it('passes provider filter from plan and computes status', async () => {
parseAllSessionsMock.mockResolvedValue([
{
totalCostUSD: 160,
sessions: [],
},
])
const usage = await getPlanUsage({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, new Date('2026-04-10T10:00:00.000Z'))
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'claude',
)
expect(usage.spentApiEquivalentUsd).toBe(160)
expect(usage.percentUsed).toBe(80)
expect(usage.status).toBe('near')
})
it('projects using median daily spend (not mean)', async () => {
const dailyCosts = [1, 100, 1, 100, 1, 100, 1]
const turns = dailyCosts.map((cost, idx) => ({
timestamp: `2026-04-${String(idx + 1).padStart(2, '0')}T12:00:00.000Z`,
assistantCalls: [{ costUSD: cost }],
}))
parseAllSessionsMock.mockResolvedValue([
{
totalCostUSD: dailyCosts.reduce((sum, value) => sum + value, 0),
sessions: [{ turns }],
},
])
const usage = await getPlanUsage({
id: 'custom',
monthlyUsd: 500,
provider: 'all',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, new Date('2026-04-07T12:00:00.000Z'))
// Median(1,100,1,100,1,100,1) = 1, so remaining 23 days adds 23.
expect(Math.round(usage.projectedMonthUsd)).toBe(327)
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'all',
)
})
it('computes plan usage from pre-fetched projects', () => {
const usage = getPlanUsageFromProjects({
id: 'custom',
monthlyUsd: 100,
provider: 'all',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, [
{
totalCostUSD: 40,
sessions: [
{
turns: [
{ timestamp: '2026-04-02T12:00:00.000Z', assistantCalls: [{ costUSD: 20 }] },
{ timestamp: '2026-04-03T12:00:00.000Z', assistantCalls: [{ costUSD: 20 }] },
],
},
],
},
], new Date('2026-04-10T10:00:00.000Z'))
expect(usage.spentApiEquivalentUsd).toBe(40)
expect(usage.budgetUsd).toBe(100)
expect(usage.status).toBe('under')
})
})

63
tests/plans.test.ts Normal file
View file

@ -0,0 +1,63 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it, expect } from 'vitest'
import { clearPlan, readPlan, savePlan } from '../src/config.js'
import { getPresetPlan, isPlanId, isPlanProvider } from '../src/plans.js'
describe('plan presets', () => {
it('resolves builtin presets', () => {
expect(getPresetPlan('claude-pro')).toMatchObject({ id: 'claude-pro', monthlyUsd: 20, provider: 'claude' })
expect(getPresetPlan('claude-max')).toMatchObject({ id: 'claude-max', monthlyUsd: 200, provider: 'claude' })
expect(getPresetPlan('cursor-pro')).toMatchObject({ id: 'cursor-pro', monthlyUsd: 20, provider: 'cursor' })
expect(getPresetPlan('custom')).toBeNull()
})
it('validates ids and providers', () => {
expect(isPlanId('claude-pro')).toBe(true)
expect(isPlanId('none')).toBe(true)
expect(isPlanId('bad-plan')).toBe(false)
expect(isPlanProvider('all')).toBe(true)
expect(isPlanProvider('claude')).toBe(true)
expect(isPlanProvider('invalid')).toBe(false)
})
})
describe('plan config persistence', () => {
it('round-trips savePlan/readPlan and clearPlan', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-'))
const previousHome = process.env['HOME']
process.env['HOME'] = dir
try {
await savePlan({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 12,
setAt: '2026-04-17T12:00:00.000Z',
})
const plan = await readPlan()
expect(plan).toMatchObject({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 12,
})
await clearPlan()
expect(await readPlan()).toBeUndefined()
} finally {
if (previousHome === undefined) {
delete process.env['HOME']
} else {
process.env['HOME'] = previousHome
}
await rm(dir, { recursive: true, force: true })
}
})
})