diff --git a/README.md b/README.md
index d1d6840..29fa180 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/cli.ts b/src/cli.ts
index 0164f42..71c5508 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -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 {
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 & { 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 ', 'Output format: text or json', 'text')
+ .option('--monthly-usd ', 'Monthly plan price in USD (for custom)', parseNumber)
+ .option('--provider ', 'Provider scope: all, claude, codex, cursor', 'all')
+ .option('--reset-day ', '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 | 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 .\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')
diff --git a/src/config.ts b/src/config.ts
index 19ad456..8af72d6 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -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 {
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 {
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 {
+ const config = await readConfig()
+ return config.plan
+}
+
+export async function savePlan(plan: Plan): Promise {
+ const config = await readConfig()
+ config.plan = plan
+ await saveConfig(config)
+}
+
+export async function clearPlan(): Promise {
+ const config = await readConfig()
+ delete config.plan
+ await saveConfig(config)
}
export function getConfigFilePath(): string {
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index 1b72d65..741a619 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -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 = {
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 (
@@ -186,6 +208,24 @@ function Overview({ projects, label, width }: { projects: ProjectSummary[]; labe
{formatTokens(totalInput)} in {formatTokens(totalOutput)} out {formatTokens(totalCacheRead)} cached {formatTokens(totalCacheWrite)} written
+ {planUsage && (
+ <>
+
+ {planLabel}
+
+ {renderPlanBar(planUsage.percentUsed, PLAN_BAR_WIDTH)}
+
+ {planPct}
+
+
+ {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.`}
+
+ >
+ )}
)
}
@@ -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 }) {
+function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsage }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map; planUsage?: PlanUsage }) {
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
const isCursor = activeProvider === 'cursor'
if (projects.length === 0) return No usage data found for {PERIOD_LABELS[period]}.
@@ -566,7 +606,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets }
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
return (
-
+
@@ -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('dashboard')
const [optimizeResult, setOptimizeResult] = useState(null)
const [projectBudgets, setProjectBudgets] = useState
)
}
-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 (
-
+
)
}
@@ -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 {
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(
-
+
)
await waitUntilExit()
} else {
- const { unmount } = render(, { patchConsole: false })
+ const { unmount } = render(, { patchConsole: false })
unmount()
}
}
diff --git a/src/plan-usage.ts b/src/plan-usage.ts
new file mode 100644
index 0000000..b027e59
--- /dev/null
+++ b/src/plan-usage.ts
@@ -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()
+
+ 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 {
+ 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 {
+ const plan = await readPlan()
+ if (!isActivePlan(plan)) return null
+ return getPlanUsage(plan, today)
+}
+
+export async function getPlanUsageOrNullForProjects(projects: ProjectSummary[], today = new Date()): Promise {
+ 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
+}
diff --git a/src/plans.ts b/src/plans.ts
new file mode 100644
index 0000000..171627b
--- /dev/null
+++ b/src/plans.ts
@@ -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> = {
+ '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 | 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'
+ }
+}
diff --git a/tests/cli-plan.test.ts b/tests/cli-plan.test.ts
new file mode 100644
index 0000000..b146f2a
--- /dev/null
+++ b/tests/cli-plan.test.ts
@@ -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 })
+ }
+ })
+})
diff --git a/tests/plan-usage.test.ts b/tests/plan-usage.test.ts
new file mode 100644
index 0000000..ec281b5
--- /dev/null
+++ b/tests/plan-usage.test.ts
@@ -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')
+ })
+})
diff --git a/tests/plans.test.ts b/tests/plans.test.ts
new file mode 100644
index 0000000..7a358ac
--- /dev/null
+++ b/tests/plans.test.ts
@@ -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 })
+ }
+ })
+})