Track multiple provider plans

This commit is contained in:
ozymandiashh 2026-05-11 16:33:33 +03:00
parent d9acd8c4cd
commit a04c0cbfaa
9 changed files with 802 additions and 112 deletions

View file

@ -1,5 +1,20 @@
# Changelog
## Unreleased
### Added (CLI)
- **Multiple subscription plans can be tracked at the same time.**
`codeburn plan set` now stores plans in a provider-keyed `plans` map, so
setting a Codex custom plan no longer overwrites an existing Claude plan.
`codeburn plan reset --provider <name>` removes only that provider's plan,
while `codeburn plan reset` remains a full reset. The dashboard and JSON
outputs include one overage summary per active provider plan. Aggregate
`all` plans remain mutually exclusive with provider-specific plans to avoid
double-counted overage rows. Existing single-plan `plan` config files
continue to load as a backward-compatible fallback, and subsequent writes
save the new `plans` map format. Preset plans now reject mismatched
`--provider` scopes instead of silently ignoring them. Closes #299.
## 0.9.8 - 2026-05-10
### Added (CLI)

View file

@ -255,13 +255,14 @@ Requires a git repository. Run from your project directory.
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 custom --monthly-usd 200 --provider codex # ChatGPT Pro-style custom plan
codeburn plan reset --provider codex # remove one provider plan
codeburn plan set none # disable plan view
codeburn plan # show current
codeburn plan # show configured plans
codeburn plan reset # remove plan config
```
Subscription tracking for Claude Pro, Claude Max, and Cursor Pro. The dashboard shows a progress bar of API-equivalent cost against your plan price. Supports custom plans. 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.
Subscription tracking for Claude Pro, Claude Max, Cursor Pro, and custom provider plans. Plans are stored per provider, so you can track Claude and Codex/Cursor subscriptions at the same time; the dashboard shows one overage line per active provider plan. A legacy/custom `all` plan remains a single aggregate plan and is replaced when you add a provider-specific plan, avoiding double-counted overage rows. Existing single-plan config is still read as a fallback. 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.
### Currency

View file

@ -16,9 +16,9 @@ import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js'
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js'
import { clearPlan, readConfig, readPlans, saveConfig, savePlan, getConfigFilePath, type Plan, type PlanId, type PlanProvider } from './config.js'
import { clampResetDay, getPlanUsages, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
@ -51,6 +51,7 @@ function parseInteger(value: string): number {
type JsonPlanSummary = {
id: PlanId
provider: PlanProvider
budget: number
spent: number
percentUsed: number
@ -64,6 +65,7 @@ type JsonPlanSummary = {
function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
return {
id: planUsage.plan.id,
provider: planUsage.plan.provider,
budget: convertCost(planUsage.budgetUsd),
spent: convertCost(planUsage.spentApiEquivalentUsd),
percentUsed: Math.round(planUsage.percentUsed * 10) / 10,
@ -75,6 +77,49 @@ function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
}
}
type JsonPlanSummaryMap = Partial<Record<PlanProvider, JsonPlanSummary>>
function toJsonPlanSummaryMap(planUsages: PlanUsage[]): JsonPlanSummaryMap {
const summaries: JsonPlanSummaryMap = {}
for (const usage of planUsages) {
summaries[usage.plan.provider] = toJsonPlanSummary(usage)
}
return summaries
}
async function attachPlanSummaries<T extends object>(payload: T): Promise<T & { plan?: JsonPlanSummary; plans?: JsonPlanSummaryMap }> {
const planUsages = await getPlanUsages()
if (planUsages.length > 0) {
return {
...payload,
plan: toJsonPlanSummary(planUsages[0]!),
plans: toJsonPlanSummaryMap(planUsages),
}
}
return payload
}
function planLabel(plan: Plan): string {
const name = planDisplayName(plan.id)
return plan.id === 'custom' ? `${name} (${plan.provider})` : name
}
function toPlanDisplay(plan: Plan) {
return {
id: plan.id,
monthlyUsd: plan.monthlyUsd,
provider: plan.provider,
resetDay: clampResetDay(plan.resetDay),
setAt: plan.setAt || null,
}
}
function sortedPlans(plans: Partial<Record<PlanProvider, Plan>>): Plan[] {
return PLAN_PROVIDERS
.map(provider => plans[provider])
.filter((plan): plan is Plan => plan !== undefined)
}
function assertFormat(value: string, allowed: readonly string[], command: string): void {
if (!allowed.includes(value)) {
process.stderr.write(
@ -88,11 +133,7 @@ async function runJsonReport(period: Period, provider: string, project: string[]
await loadPricing()
const { range, label } = getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
const planUsage = await getPlanUsageOrNull()
if (planUsage) {
report.plan = toJsonPlanSummary(planUsage)
}
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary; plans?: JsonPlanSummaryMap } = await attachPlanSummaries(buildJsonReport(projects, label, period))
console.log(JSON.stringify(report, null, 2))
}
@ -329,7 +370,7 @@ program
opts.project,
opts.exclude,
)
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(projects, label, 'custom')), null, 2))
} else {
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
}
@ -528,16 +569,13 @@ program
today: { cost: number; calls: number }
month: { cost: number; calls: number }
plan?: JsonPlanSummary
plans?: JsonPlanSummaryMap
} = {
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))
console.log(JSON.stringify(await attachPlanSummaries(payload)))
return
}
@ -764,45 +802,57 @@ program
.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('--provider <name>', 'Provider scope: all, claude, codex, cursor')
.option('--reset-day <n>', 'Day of month plan resets (1-28)', parseInteger, 1)
.action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => {
assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan')
const mode = action ?? 'show'
const providerOption = opts?.provider
if (providerOption !== undefined && !isPlanProvider(providerOption)) {
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${providerOption}".\n`)
process.exitCode = 1
return
}
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,
}
const plans = sortedPlans(await readPlans())
.filter(plan => plan.id !== 'none')
.filter(plan => !providerOption || providerOption === 'all' || plan.provider === providerOption)
if (opts?.format === 'json') {
console.log(JSON.stringify(displayPlan))
if (plans.length === 0) {
console.log(JSON.stringify({ id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }))
return
}
console.log(JSON.stringify({
...toPlanDisplay(plans[0]!),
plans: Object.fromEntries(plans.map(plan => [plan.provider, toPlanDisplay(plan)])),
}))
return
}
if (!plan || plan.id === 'none') {
if (plans.length === 0) {
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(`\n Plans: ${plans.length}`)
for (const plan of plans) {
console.log(` ${plan.provider}: ${planLabel(plan)} (${plan.id})`)
console.log(` Budget: $${plan.monthlyUsd}/month`)
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
if (plan.setAt) 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')
await clearPlan(providerOption)
if (providerOption) {
console.log(`\n Plan reset for ${providerOption}.\n`)
} else {
console.log('\n Plan reset. API-pricing view is active.\n')
}
return
}
@ -813,7 +863,7 @@ program
}
if (!id || !isPlanId(id)) {
console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`)
console.error(`\n Plan id must be one of: ${PLAN_IDS.join(', ')}; got "${id ?? ''}".\n`)
process.exitCode = 1
return
}
@ -826,8 +876,12 @@ program
}
if (id === 'none') {
await clearPlan()
console.log('\n Plan reset. API-pricing view is active.\n')
await clearPlan(providerOption)
if (providerOption) {
console.log(`\n Plan reset for ${providerOption}.\n`)
} else {
console.log('\n Plan reset. API-pricing view is active.\n')
}
return
}
@ -843,12 +897,7 @@ program
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
}
const provider = providerOption ?? 'all'
await savePlan({
id: 'custom',
monthlyUsd,
@ -868,6 +917,18 @@ program
return
}
if (providerOption === 'all') {
console.error(`\n ${id} is a ${preset.provider} plan; omit --provider or use --provider ${preset.provider}.\n`)
process.exitCode = 1
return
}
if (providerOption && providerOption !== preset.provider) {
console.error(`\n ${id} is a ${preset.provider} plan; use --provider ${preset.provider} or omit --provider.\n`)
process.exitCode = 1
return
}
await savePlan({
...preset,
resetDay,

View file

@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, rename } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import { randomBytes } from 'crypto'
import { PLAN_PROVIDERS } from './plans.js'
export type PlanId = 'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'custom' | 'none'
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'all'
@ -14,12 +15,17 @@ export type Plan = {
setAt: string
}
export type PlanConfig = Omit<Plan, 'provider' | 'setAt'> & Partial<Pick<Plan, 'provider' | 'setAt'>>
export type PlanConfigMap = Partial<Record<PlanProvider, PlanConfig>>
export type PlanMap = Partial<Record<PlanProvider, Plan>>
export type CodeburnConfig = {
currency?: {
code: string
symbol?: string
}
plan?: Plan
plans?: PlanConfigMap
modelAliases?: Record<string, string>
}
@ -53,19 +59,79 @@ export async function saveConfig(config: CodeburnConfig): Promise<void> {
}
export async function readPlan(): Promise<Plan | undefined> {
const config = await readConfig()
return config.plan
const plans = await readPlans()
for (const provider of PLAN_PROVIDERS) {
const plan = plans[provider]
if (plan) return plan
}
return undefined
}
function planFromConfig(provider: PlanProvider, plan: PlanConfig | undefined): Plan | undefined {
if (!plan) return undefined
return {
...plan,
provider,
setAt: plan.setAt ?? '',
}
}
function normalizePlans(config: CodeburnConfig): PlanMap {
const plans: PlanMap = {}
if (config.plans && Object.keys(config.plans).length > 0) {
for (const provider of PLAN_PROVIDERS) {
const plan = planFromConfig(provider, config.plans[provider])
if (plan) plans[provider] = plan
}
if (plans.all && PLAN_PROVIDERS.some(provider => provider !== 'all' && plans[provider])) {
delete plans.all
}
return plans
}
if (config.plan) {
plans[config.plan.provider] = config.plan
}
return plans
}
export async function readPlans(): Promise<PlanMap> {
return normalizePlans(await readConfig())
}
export async function savePlan(plan: Plan): Promise<void> {
const config = await readConfig()
config.plan = plan
const plans = normalizePlans(config)
if (plan.provider === 'all') {
config.plans = { all: plan }
} else {
delete plans.all
plans[plan.provider] = plan
config.plans = plans
}
delete config.plan
await saveConfig(config)
}
export async function clearPlan(): Promise<void> {
export async function clearPlan(provider?: PlanProvider): Promise<void> {
const config = await readConfig()
if (provider) {
const plans = normalizePlans(config)
delete plans[provider]
if (Object.keys(plans).length > 0) {
config.plans = plans
} else {
delete config.plans
}
delete config.plan
await saveConfig(config)
return
}
delete config.plan
delete config.plans
await saveConfig(config)
}

View file

@ -12,7 +12,7 @@ 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 { getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
import { getPlanUsages, type PlanUsage } from './plan-usage.js'
import { planDisplayName } from './plans.js'
import { getDateRange, PERIODS, PERIOD_LABELS, type Period, formatDateRangeLabel } from './cli-date.js'
import { join } from 'path'
@ -153,7 +153,30 @@ function renderPlanBar(percentUsed: number, width: number): string {
return `${'▓'.repeat(width)}${'▶'.repeat(chevrons)}`
}
function Overview({ projects, label, width, planUsage }: { projects: ProjectSummary[]; label: string; width: number; planUsage?: PlanUsage }) {
function planLabel(planUsage: PlanUsage): string {
const name = planDisplayName(planUsage.plan.id)
return planUsage.plan.id === 'custom' ? `${name} (${planUsage.plan.provider})` : name
}
function planColor(planUsage: PlanUsage): string {
return planUsage.status === 'over'
? '#F55B5B'
: planUsage.status === 'near'
? ORANGE
: '#5BF58C'
}
function planStatusText(planUsage: PlanUsage): string {
if (planUsage.status === 'under') {
return `Well within plan. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
}
if (planUsage.status === 'near') {
return `Approaching plan limit. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
}
return `${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x your subscription value. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
}
function Overview({ projects, label, width, planUsages }: { projects: ProjectSummary[]; label: string; width: number; planUsages?: 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)
@ -165,15 +188,7 @@ function Overview({ projects, label, width, planUsage }: { projects: ProjectSumm
const allInputTokens = totalInput + totalCacheRead + totalCacheWrite
const cacheHit = allInputTokens > 0
? (totalCacheRead / allInputTokens) * 100 : 0
const planLabel = planUsage ? `${planDisplayName(planUsage.plan.id)}: ${formatCost(planUsage.spentApiEquivalentUsd)} API-equivalent vs ${formatCost(planUsage.budgetUsd)} plan` : ''
const planPct = planUsage ? `${planUsage.percentUsed.toFixed(1)}%` : ''
const planColor = planUsage
? planUsage.status === 'over'
? '#F55B5B'
: planUsage.status === 'near'
? ORANGE
: '#5BF58C'
: DIM
const activePlanUsages = planUsages ?? []
return (
<Box flexDirection="column" borderStyle="round" borderColor={PANEL_COLORS.overview} paddingX={1} width={width}>
@ -194,22 +209,23 @@ function Overview({ projects, label, width, planUsage }: { projects: ProjectSumm
<Text dimColor wrap="truncate-end">
{formatTokens(totalInput)} in {formatTokens(totalOutput)} out {formatTokens(totalCacheRead)} cached {formatTokens(totalCacheWrite)} written
</Text>
{planUsage && (
{activePlanUsages.length > 0 && (
<>
<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: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
: planUsage.status === 'near'
? `Approaching plan limit. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
: `${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x your subscription value. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`}
</Text>
{activePlanUsages.map(planUsage => {
const color = planColor(planUsage)
return (
<React.Fragment key={planUsage.plan.provider}>
<Text wrap="truncate-end">
<Text color={color}>{planLabel(planUsage)}: {formatCost(planUsage.spentApiEquivalentUsd)} API-equivalent vs {formatCost(planUsage.budgetUsd)} plan</Text>
<Text> </Text>
<Text color={color}>{renderPlanBar(planUsage.percentUsed, PLAN_BAR_WIDTH)}</Text>
<Text> </Text>
<Text bold color={color}>{planUsage.percentUsed.toFixed(1)}%</Text>
</Text>
<Text dimColor wrap="truncate-end">{planStatusText(planUsage)}</Text>
</React.Fragment>
)
})}
</>
)}
</Box>
@ -671,7 +687,7 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children
return <>{children}</>
}
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsage }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsage?: PlanUsage }) {
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: 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>
@ -679,7 +695,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} planUsage={planUsage} />
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} planUsages={planUsages} />
<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>
@ -692,11 +708,11 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
)
}
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsage, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel }: {
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel }: {
initialProjects: ProjectSummary[]
initialPeriod: Period
initialProvider: string
initialPlanUsage?: PlanUsage
initialPlanUsages?: PlanUsage[]
refreshSeconds?: number
projectFilter?: string[]
excludeFilter?: string[]
@ -712,7 +728,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 [planUsages, setPlanUsages] = useState<PlanUsage[]>(initialPlanUsages ?? [])
// Cursor for the OptimizeView's findings window. Reset whenever the user
// leaves the optimize view OR the underlying findings change so a long
// findings list never strands the user past the new array length.
@ -783,9 +799,9 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
if (reloadGenerationRef.current !== generation) return
setProjects(filteredProjects)
const usage = await getPlanUsageOrNull()
const usage = await getPlanUsages()
if (reloadGenerationRef.current !== generation) return
setPlanUsage(usage ?? undefined)
setPlanUsages(usage)
} catch (error) {
console.error(error)
} finally {
@ -891,7 +907,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
: view === 'optimize' && optimizeResult
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsage={planUsage} />}
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} />}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} />}
</Box>
)
@ -906,13 +922,13 @@ function CustomRangeBanner({ label, width }: { label: string; width: number }) {
)
}
function StaticDashboard({ projects, period, activeProvider, planUsage }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsage?: PlanUsage }) {
function StaticDashboard({ projects, period, activeProvider, planUsages }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: 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} planUsage={planUsage} />
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} />
</Box>
)
}
@ -921,16 +937,16 @@ export async function renderDashboard(period: Period = 'week', provider: string
await loadPricing()
const range = customRange ?? getPeriodRange(period)
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const planUsage = await getPlanUsageOrNull()
const planUsages = await getPlanUsages()
const isTTY = process.stdin.isTTY && process.stdout.isTTY
patchStdoutForWindows()
if (isTTY) {
const { waitUntilExit } = render(
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsage={planUsage ?? undefined} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} />
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} />
)
await waitUntilExit()
} else {
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsage={planUsage ?? undefined} />, { patchConsole: false })
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} />, { patchConsole: false })
unmount()
}
}

View file

@ -1,5 +1,6 @@
import { readPlan, type Plan } from './config.js'
import { readPlans, type Plan, type PlanMap } from './config.js'
import { parseAllSessions } from './parser.js'
import { PLAN_PROVIDERS } from './plans.js'
import type { DateRange, ProjectSummary } from './types.js'
const MS_PER_DAY = 24 * 60 * 60 * 1000
@ -79,13 +80,15 @@ export function projectMonthEnd(
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)
for (const call of turn.assistantCalls) {
const timestamp = call.timestamp || turn.timestamp
if (!timestamp) continue
const ts = new Date(timestamp)
if (Number.isNaN(ts.getTime())) continue
if (ts < periodStart || ts > today) continue
const dayKey = toLocalDateKey(ts)
dayCosts.set(dayKey, (dayCosts.get(dayKey) ?? 0) + call.costUSD)
}
}
}
}
@ -126,6 +129,45 @@ export function getPlanUsageFromProjects(plan: Plan, projects: ProjectSummary[],
}
}
function getPlanScopedProjects(plan: Plan, projects: ProjectSummary[], today: Date): ProjectSummary[] {
const { periodStart } = computePeriodFromResetDay(plan.resetDay, today)
const provider = plan.provider
// These scoped clones are consumed only by plan usage math; cost/call rollups
// are recomputed below, while unrelated breakdown fields remain unchanged.
return projects
.map(project => {
const sessions = project.sessions
.map(session => {
const turns = session.turns
.map(turn => {
const assistantCalls = turn.assistantCalls.filter(call => {
if (provider !== 'all' && call.provider !== provider) return false
const timestamp = call.timestamp || turn.timestamp
if (!timestamp) return false
const ts = new Date(timestamp)
return !Number.isNaN(ts.getTime()) && ts >= periodStart && ts <= today
})
return assistantCalls.length > 0 ? { ...turn, assistantCalls } : null
})
.filter((turn): turn is NonNullable<typeof turn> => turn !== null)
const totalCostUSD = turns.reduce(
(sum, turn) => sum + turn.assistantCalls.reduce((turnSum, call) => turnSum + call.costUSD, 0),
0,
)
const apiCalls = turns.reduce((sum, turn) => sum + turn.assistantCalls.length, 0)
return apiCalls > 0 ? { ...session, turns, totalCostUSD, apiCalls } : null
})
.filter((session): session is NonNullable<typeof session> => session !== null)
const totalCostUSD = sessions.reduce((sum, session) => sum + session.totalCostUSD, 0)
const totalApiCalls = sessions.reduce((sum, session) => sum + session.apiCalls, 0)
return totalApiCalls > 0 ? { ...project, sessions, totalCostUSD, totalApiCalls } : null
})
.filter((project): project is NonNullable<typeof project> => project !== null)
}
export async function getPlanUsage(plan: Plan, today = new Date()): Promise<PlanUsage> {
const { periodStart } = computePeriodFromResetDay(plan.resetDay, today)
const range: DateRange = {
@ -138,9 +180,34 @@ export async function getPlanUsage(plan: Plan, today = new Date()): Promise<Plan
}
export async function getPlanUsageOrNull(today = new Date()): Promise<PlanUsage | null> {
const plan = await readPlan()
if (!isActivePlan(plan)) return null
return getPlanUsage(plan, today)
return (await getPlanUsages(today))[0] ?? null
}
export function activePlansFromMap(plans: PlanMap): Plan[] {
return PLAN_PROVIDERS
.map(provider => plans[provider])
.filter(isActivePlan)
}
export async function getPlanUsages(today = new Date()): Promise<PlanUsage[]> {
const plans = activePlansFromMap(await readPlans())
if (plans.length === 0) return []
const starts = plans.map(plan => computePeriodFromResetDay(plan.resetDay, today).periodStart.getTime())
const range: DateRange = {
start: new Date(Math.min(...starts)),
end: today,
}
if (plans.length === 1) {
const plan = plans[0]!
const projects = await parseAllSessions(range, plan.provider === 'all' ? 'all' : plan.provider)
return [getPlanUsageFromProjects(plan, projects, today)]
}
const projects = await parseAllSessions(range, 'all')
return plans.map(plan => getPlanUsageFromProjects(plan, getPlanScopedProjects(plan, projects, today), today))
}
export function isActivePlan(plan: Plan | undefined): plan is Plan {

View file

@ -5,6 +5,8 @@ import { spawnSync } from 'node:child_process'
import { describe, it, expect } from 'vitest'
const CLI_PLAN_TIMEOUT_MS = 10_000
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
@ -17,29 +19,119 @@ function runCli(args: string[], home: string) {
}
describe('codeburn plan command', () => {
it('persists plan set and clears on reset', async () => {
it('persists provider-keyed plans 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 setCodexResult = runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home)
expect(setCodexResult.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 config = JSON.parse(configRaw) as { plans?: { claude?: { id?: string; monthlyUsd?: number }; codex?: { id?: string; monthlyUsd?: number } } }
expect(config.plans?.claude?.id).toBe('claude-max')
expect(config.plans?.claude?.monthlyUsd).toBe(200)
expect(config.plans?.codex?.id).toBe('custom')
expect(config.plans?.codex?.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 }
const afterReset = JSON.parse(afterResetRaw) as { plan?: unknown; plans?: unknown }
expect(afterReset.plan).toBeUndefined()
expect(afterReset.plans).toBeUndefined()
} finally {
await rm(home, { recursive: true, force: true })
}
})
}, CLI_PLAN_TIMEOUT_MS)
it('resets one provider without removing other plans', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0)
expect(runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home).status).toBe(0)
expect(runCli(['plan', 'reset', '--provider', 'codex'], home).status).toBe(0)
const configPath = join(home, '.config', 'codeburn', 'config.json')
const configRaw = await readFile(configPath, 'utf-8')
const config = JSON.parse(configRaw) as { plans?: { claude?: { id?: string }; codex?: unknown } }
expect(config.plans?.claude?.id).toBe('claude-max')
expect(config.plans?.codex).toBeUndefined()
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('resets the all-provider plan without removing provider-specific plans', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0)
expect(runCli(['plan', 'reset', '--provider', 'all'], home).status).toBe(0)
const configPath = join(home, '.config', 'codeburn', 'config.json')
const configRaw = await readFile(configPath, 'utf-8')
const config = JSON.parse(configRaw) as { plans?: { claude?: { id?: string }; all?: unknown } }
expect(config.plans?.claude?.id).toBe('claude-max')
expect(config.plans?.all).toBeUndefined()
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('shows all configured plans as json', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0)
expect(runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home).status).toBe(0)
const result = runCli(['plan', '--format', 'json'], home)
expect(result.status).toBe(0)
const payload = JSON.parse(result.stdout) as { id?: string; provider?: string; plans?: { claude?: { id?: string }; codex?: { id?: string } } }
expect(payload.id).toBe('claude-max')
expect(payload.provider).toBe('claude')
expect(payload.plans?.claude?.id).toBe('claude-max')
expect(payload.plans?.codex?.id).toBe('custom')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('filters shown plans by provider', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0)
expect(runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home).status).toBe(0)
const result = runCli(['plan', '--format', 'json', '--provider', 'codex'], home)
expect(result.status).toBe(0)
const payload = JSON.parse(result.stdout) as { id?: string; provider?: string; plans?: unknown }
expect(payload.id).toBe('custom')
expect(payload.provider).toBe('codex')
expect(payload.plans).toMatchObject({ codex: { id: 'custom' } })
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('rejects all-provider scope for preset plans', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
try {
const result = runCli(['plan', 'set', 'claude-max', '--provider', 'all'], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('omit --provider or use --provider claude')
} finally {
await rm(home, { recursive: true, force: true })
}
}, CLI_PLAN_TIMEOUT_MS)
it('shows invalid reset-day value in error output', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
@ -51,5 +143,5 @@ describe('codeburn plan command', () => {
} finally {
await rm(home, { recursive: true, force: true })
}
})
}, CLI_PLAN_TIMEOUT_MS)
})

View file

@ -1,6 +1,12 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { computePeriodFromResetDay, getPlanUsage, getPlanUsageFromProjects } from '../src/plan-usage.js'
import { savePlan } from '../src/config.js'
import { activePlansFromMap, computePeriodFromResetDay, getPlanUsage, getPlanUsageFromProjects, getPlanUsages } from '../src/plan-usage.js'
import type { ProjectSummary } from '../src/types.js'
const { parseAllSessionsMock } = vi.hoisted(() => ({
parseAllSessionsMock: vi.fn(),
@ -119,4 +125,231 @@ describe('getPlanUsage', () => {
expect(usage.budgetUsd).toBe(100)
expect(usage.status).toBe('under')
})
it('projects month-end spend from API call timestamps', () => {
const usage = getPlanUsageFromProjects({
id: 'custom',
monthlyUsd: 100,
provider: 'all',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
}, [
{
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: 10,
totalApiCalls: 1,
sessions: [
{
turns: [
{
timestamp: '2026-03-31T23:59:00.000Z',
assistantCalls: [{ costUSD: 10, timestamp: '2026-04-01T10:00:00.000Z' }],
},
],
},
],
},
] as ProjectSummary[], new Date('2026-04-01T12:00:00.000Z'))
expect(Math.round(usage.projectedMonthUsd)).toBe(300)
})
it('returns active plans in provider display order', () => {
const plans = activePlansFromMap({
codex: {
id: 'custom',
monthlyUsd: 200,
provider: 'codex',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
},
claude: {
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
},
cursor: {
id: 'none',
monthlyUsd: 0,
provider: 'cursor',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
},
})
expect(plans.map(plan => plan.provider)).toEqual(['claude', 'codex'])
})
it('keeps the provider-specific parser filter for one active plan', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-usage-test-'))
const previousHome = process.env['HOME']
process.env['HOME'] = dir
try {
await savePlan({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
})
parseAllSessionsMock.mockResolvedValue([
{
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: 80,
totalApiCalls: 1,
sessions: [],
},
] satisfies ProjectSummary[])
const usages = await getPlanUsages(new Date('2026-04-10T12:00:00.000Z'))
expect(parseAllSessionsMock).toHaveBeenCalledTimes(1)
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'claude',
)
expect(usages).toHaveLength(1)
expect(usages[0]?.spentApiEquivalentUsd).toBe(80)
} finally {
if (previousHome === undefined) {
delete process.env['HOME']
} else {
process.env['HOME'] = previousHome
}
await rm(dir, { recursive: true, force: true })
}
})
it('computes multiple active plan usages from one all-provider parse', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-usage-test-'))
const previousHome = process.env['HOME']
process.env['HOME'] = dir
try {
await savePlan({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
})
await savePlan({
id: 'custom',
monthlyUsd: 100,
provider: 'codex',
resetDay: 1,
setAt: '2026-04-01T00:00:00.000Z',
})
parseAllSessionsMock.mockResolvedValue([
{
project: 'codeburn',
projectPath: '/tmp/codeburn',
totalCostUSD: 150,
totalApiCalls: 2,
sessions: [
{
sessionId: 'session-1',
project: 'codeburn',
firstTimestamp: '2026-04-03T10:00:00.000Z',
lastTimestamp: '2026-04-03T11:00:00.000Z',
totalCostUSD: 150,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 2,
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {},
skillBreakdown: {},
turns: [
{
userMessage: 'work',
timestamp: '2026-04-03T10:00:00.000Z',
sessionId: 'session-1',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [
{
provider: 'claude',
model: 'claude-opus-4-7',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 100,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-04-03T10:00:00.000Z',
bashCommands: [],
deduplicationKey: 'claude-1',
},
{
provider: 'codex',
model: 'gpt-5.5',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 50,
tools: [],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-04-03T11:00:00.000Z',
bashCommands: [],
deduplicationKey: 'codex-1',
},
],
},
],
},
],
},
] satisfies ProjectSummary[])
const usages = await getPlanUsages(new Date('2026-04-10T12:00:00.000Z'))
expect(parseAllSessionsMock).toHaveBeenCalledTimes(1)
expect(parseAllSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
'all',
)
expect(usages.map(usage => usage.plan.provider)).toEqual(['claude', 'codex'])
expect(usages.map(usage => usage.spentApiEquivalentUsd)).toEqual([100, 50])
} finally {
if (previousHome === undefined) {
delete process.env['HOME']
} else {
process.env['HOME'] = previousHome
}
await rm(dir, { recursive: true, force: true })
}
})
})

View file

@ -4,7 +4,7 @@ import { join } from 'node:path'
import { describe, it, expect } from 'vitest'
import { clearPlan, readPlan, savePlan } from '../src/config.js'
import { clearPlan, readPlan, readPlans, saveConfig, savePlan } from '../src/config.js'
import { getPresetPlan, isPlanId, isPlanProvider } from '../src/plans.js'
describe('plan presets', () => {
@ -27,7 +27,7 @@ describe('plan presets', () => {
})
describe('plan config persistence', () => {
it('round-trips savePlan/readPlan and clearPlan', async () => {
it('round-trips per-provider plans and clears one provider at a time', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-'))
const previousHome = process.env['HOME']
process.env['HOME'] = dir
@ -40,17 +40,156 @@ describe('plan config persistence', () => {
resetDay: 12,
setAt: '2026-04-17T12:00:00.000Z',
})
await savePlan({
id: 'custom',
monthlyUsd: 200,
provider: 'codex',
resetDay: 1,
setAt: '2026-04-18T12:00:00.000Z',
})
const plan = await readPlan()
expect(plan).toMatchObject({
const plans = await readPlans()
expect(plans.claude).toMatchObject({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 12,
})
expect(plans.codex).toMatchObject({
id: 'custom',
monthlyUsd: 200,
provider: 'codex',
resetDay: 1,
})
expect(await readPlan()).toMatchObject({ id: 'claude-max', provider: 'claude' })
await clearPlan('codex')
expect((await readPlans()).codex).toBeUndefined()
expect((await readPlans()).claude).toMatchObject({ id: 'claude-max' })
await clearPlan('all')
expect((await readPlans()).claude).toMatchObject({ id: 'claude-max' })
await clearPlan()
expect(await readPlan()).toBeUndefined()
expect(await readPlans()).toEqual({})
} finally {
if (previousHome === undefined) {
delete process.env['HOME']
} else {
process.env['HOME'] = previousHome
}
await rm(dir, { recursive: true, force: true })
}
})
it('reads legacy single-plan config as a provider-keyed plan map', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-'))
const previousHome = process.env['HOME']
process.env['HOME'] = dir
try {
await saveConfig({
plan: {
id: 'cursor-pro',
monthlyUsd: 20,
provider: 'cursor',
resetDay: 3,
setAt: '2026-04-17T12:00:00.000Z',
},
})
const plans = await readPlans()
expect(plans.cursor).toMatchObject({
id: 'cursor-pro',
monthlyUsd: 20,
provider: 'cursor',
resetDay: 3,
})
} finally {
if (previousHome === undefined) {
delete process.env['HOME']
} else {
process.env['HOME'] = previousHome
}
await rm(dir, { recursive: true, force: true })
}
})
it('drops a hand-edited all plan when provider-specific plans are present', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-'))
const previousHome = process.env['HOME']
process.env['HOME'] = dir
try {
await saveConfig({
plans: {
all: {
id: 'custom',
monthlyUsd: 300,
resetDay: 1,
setAt: '2026-04-17T12:00:00.000Z',
},
claude: {
id: 'claude-max',
monthlyUsd: 200,
resetDay: 1,
setAt: '2026-04-18T12:00:00.000Z',
},
},
})
const plans = await readPlans()
expect(plans.all).toBeUndefined()
expect(plans.claude).toMatchObject({ id: 'claude-max', provider: 'claude' })
expect(await readPlan()).toMatchObject({ id: 'claude-max', provider: 'claude' })
} finally {
if (previousHome === undefined) {
delete process.env['HOME']
} else {
process.env['HOME'] = previousHome
}
await rm(dir, { recursive: true, force: true })
}
})
it('does not allow an all-provider plan to overlap provider-specific plans', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-'))
const previousHome = process.env['HOME']
process.env['HOME'] = dir
try {
await savePlan({
id: 'custom',
monthlyUsd: 100,
provider: 'all',
resetDay: 1,
setAt: '2026-04-17T12:00:00.000Z',
})
await savePlan({
id: 'claude-max',
monthlyUsd: 200,
provider: 'claude',
resetDay: 1,
setAt: '2026-04-18T12:00:00.000Z',
})
expect(await readPlans()).toMatchObject({
claude: { id: 'claude-max' },
})
expect((await readPlans()).all).toBeUndefined()
await savePlan({
id: 'custom',
monthlyUsd: 300,
provider: 'all',
resetDay: 1,
setAt: '2026-04-19T12:00:00.000Z',
})
expect(await readPlans()).toMatchObject({
all: { id: 'custom', monthlyUsd: 300 },
})
expect((await readPlans()).claude).toBeUndefined()
} finally {
if (previousHome === undefined) {
delete process.env['HOME']