Merge pull request #300: Track multiple provider plans
Some checks are pending
CI / semgrep (push) Waiting to run

# Conflicts:
#	CHANGELOG.md
#	src/main.ts
This commit is contained in:
iamtoruk 2026-05-16 10:49:05 -07:00
commit dcbf6dcfbf
9 changed files with 799 additions and 114 deletions

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, 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 { patchStdoutForWindows } from './ink-win.js'
@ -163,7 +163,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)
@ -175,15 +198,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}>
@ -204,22 +219,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>
@ -686,7 +702,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>
@ -694,7 +710,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>
@ -707,11 +723,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[]
@ -728,7 +744,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
const [optimizeLoading, setOptimizeLoading] = useState(false)
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.
@ -807,9 +823,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 {
@ -943,7 +959,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>
)
@ -958,13 +974,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>
)
}
@ -973,16 +989,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

@ -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, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type Plan, type PlanId, type PlanProvider } from './config.js'
import { clampResetDay, getPlanUsageOrNull, 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))
}
@ -328,7 +369,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)
}
@ -539,16 +580,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
}
@ -778,45 +816,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
}
@ -827,7 +877,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
}
@ -840,8 +890,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
}
@ -857,12 +911,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,
@ -882,6 +931,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

@ -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,21 +129,84 @@ 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 = {
start: periodStart,
end: today,
}
const provider = plan.provider === 'all' ? 'all' : plan.provider
const projects = await parseAllSessions(range, provider)
const projects = await parseAllSessions(range, plan.provider)
return getPlanUsageFromProjects(plan, projects, today)
}
export async function getPlanUsageOrNull(today = new Date()): Promise<PlanUsage | null> {
const plan = await readPlan()
if (!isActivePlan(plan)) return null
return getPlanUsage(plan, today)
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)
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 {