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 CodeBurn macOS menubar app 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>(new Map()) + const [planUsage, setPlanUsage] = useState(initialPlanUsage) const { columns } = useWindowSize() const { dashWidth } = getLayout(columns) const multipleProviders = detectedProviders.length > 1 @@ -605,6 +647,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, ).size const compareAvailable = modelCount >= 2 const debounceRef = useRef | null>(null) + const reloadGenerationRef = useRef(0) const findingCount = optimizeResult?.findings.length ?? 0 useEffect(() => { @@ -648,11 +691,28 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, }, [projects, period, optimizeAvailable]) const reloadData = useCallback(async (p: Period, prov: string) => { + const generation = ++reloadGenerationRef.current setLoading(true) - const range = getDateRange(p) - const data = filterProjectsByName(await parseAllSessions(range, prov), projectFilter, excludeFilter) - setProjects(data) - setLoading(false) + setOptimizeResult(null) + try { + const range = getDateRange(p) + const data = await parseAllSessions(range, prov) + if (reloadGenerationRef.current !== generation) return + + const filteredProjects = filterProjectsByName(data, projectFilter, excludeFilter) + if (reloadGenerationRef.current !== generation) return + + setProjects(filteredProjects) + const usage = await getPlanUsageOrNullForProjects(filteredProjects) + if (reloadGenerationRef.current !== generation) return + setPlanUsage(usage ?? undefined) + } catch (error) { + console.error(error) + } finally { + if (reloadGenerationRef.current === generation) { + setLoading(false) + } + } }, [projectFilter, excludeFilter]) useEffect(() => { @@ -721,19 +781,19 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, ? setView('dashboard')} /> : view === 'optimize' && optimizeResult ? - : } + : } {view !== 'compare' && } ) } -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 }) + } + }) +})