diff --git a/CHANGELOG.md b/CHANGELOG.md index e7dd43d..9248c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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) diff --git a/README.md b/README.md index b370022..2042cff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/cli.ts b/src/cli.ts index 4ebfe33..de1d673 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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> + +function toJsonPlanSummaryMap(planUsages: PlanUsage[]): JsonPlanSummaryMap { + const summaries: JsonPlanSummaryMap = {} + for (const usage of planUsages) { + summaries[usage.plan.provider] = toJsonPlanSummary(usage) + } + return summaries +} + +async function attachPlanSummaries(payload: T): Promise { + 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>): 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 & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period) - const planUsage = await getPlanUsageOrNull() - if (planUsage) { - report.plan = toJsonPlanSummary(planUsage) - } + const report: ReturnType & { 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 ', '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('--provider ', 'Provider scope: all, claude, codex, cursor') .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 }) => { 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, diff --git a/src/config.ts b/src/config.ts index 12fec8f..53e9624 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 & Partial> +export type PlanConfigMap = Partial> +export type PlanMap = Partial> + export type CodeburnConfig = { currency?: { code: string symbol?: string } plan?: Plan + plans?: PlanConfigMap modelAliases?: Record } @@ -53,19 +59,79 @@ export async function saveConfig(config: CodeburnConfig): Promise { } export async function readPlan(): Promise { - 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 { + return normalizePlans(await readConfig()) } export async function savePlan(plan: Plan): Promise { 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 { +export async function clearPlan(provider?: PlanProvider): Promise { 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) } diff --git a/src/dashboard.tsx b/src/dashboard.tsx index b46dbcc..19f6908 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -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 ( @@ -194,22 +209,23 @@ function Overview({ projects, label, width, planUsage }: { projects: ProjectSumm {formatTokens(totalInput)} in {formatTokens(totalOutput)} out {formatTokens(totalCacheRead)} cached {formatTokens(totalCacheWrite)} written - {planUsage && ( + {activePlanUsages.length > 0 && ( <> - - {planLabel} - - {renderPlanBar(planUsage.percentUsed, PLAN_BAR_WIDTH)} - - {planPct} - - - {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).`} - + {activePlanUsages.map(planUsage => { + const color = planColor(planUsage) + return ( + + + {planLabel(planUsage)}: {formatCost(planUsage.spentApiEquivalentUsd)} API-equivalent vs {formatCost(planUsage.budgetUsd)} plan + + {renderPlanBar(planUsage.percentUsed, PLAN_BAR_WIDTH)} + + {planUsage.percentUsed.toFixed(1)}% + + {planStatusText(planUsage)} + + ) + })} )} @@ -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; planUsage?: PlanUsage }) { +function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map; planUsages?: 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]}. @@ -679,7 +695,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets, const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14) return ( - + @@ -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('dashboard') const [optimizeResult, setOptimizeResult] = useState(null) const [projectBudgets, setProjectBudgets] = useState>(new Map()) - const [planUsage, setPlanUsage] = useState(initialPlanUsage) + const [planUsages, setPlanUsages] = useState(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, ? setView('dashboard')} /> : view === 'optimize' && optimizeResult ? - : } + : } {view !== 'compare' && } ) @@ -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 ( - + ) } @@ -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( - + ) 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 index 7811734..ac38771 100644 --- a/src/plan-usage.ts +++ b/src/plan-usage.ts @@ -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 => 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 => 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 => project !== null) +} + export async function getPlanUsage(plan: Plan, today = new Date()): Promise { const { periodStart } = computePeriodFromResetDay(plan.resetDay, today) const range: DateRange = { @@ -138,9 +180,34 @@ export async function getPlanUsage(plan: Plan, today = new Date()): Promise { - 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 { + 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 { diff --git a/tests/cli-plan.test.ts b/tests/cli-plan.test.ts index b146f2a..2941f1a 100644 --- a/tests/cli-plan.test.ts +++ b/tests/cli-plan.test.ts @@ -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) }) diff --git a/tests/plan-usage.test.ts b/tests/plan-usage.test.ts index ec281b5..b8a81c6 100644 --- a/tests/plan-usage.test.ts +++ b/tests/plan-usage.test.ts @@ -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 }) + } + }) }) diff --git a/tests/plans.test.ts b/tests/plans.test.ts index 7a358ac..4ed7cc8 100644 --- a/tests/plans.test.ts +++ b/tests/plans.test.ts @@ -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']