mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
Track multiple provider plans
This commit is contained in:
parent
d9acd8c4cd
commit
a04c0cbfaa
9 changed files with 802 additions and 112 deletions
15
CHANGELOG.md
15
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 <name>` removes only that provider's plan,
|
||||
while `codeburn plan reset` remains a full reset. The dashboard and JSON
|
||||
outputs include one overage summary per active provider plan. Aggregate
|
||||
`all` plans remain mutually exclusive with provider-specific plans to avoid
|
||||
double-counted overage rows. Existing single-plan `plan` config files
|
||||
continue to load as a backward-compatible fallback, and subsequent writes
|
||||
save the new `plans` map format. Preset plans now reject mismatched
|
||||
`--provider` scopes instead of silently ignoring them. Closes #299.
|
||||
|
||||
## 0.9.8 - 2026-05-10
|
||||
|
||||
### Added (CLI)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
147
src/cli.ts
147
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<Record<PlanProvider, JsonPlanSummary>>
|
||||
|
||||
function toJsonPlanSummaryMap(planUsages: PlanUsage[]): JsonPlanSummaryMap {
|
||||
const summaries: JsonPlanSummaryMap = {}
|
||||
for (const usage of planUsages) {
|
||||
summaries[usage.plan.provider] = toJsonPlanSummary(usage)
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
||||
async function attachPlanSummaries<T extends object>(payload: T): Promise<T & { plan?: JsonPlanSummary; plans?: JsonPlanSummaryMap }> {
|
||||
const planUsages = await getPlanUsages()
|
||||
if (planUsages.length > 0) {
|
||||
return {
|
||||
...payload,
|
||||
plan: toJsonPlanSummary(planUsages[0]!),
|
||||
plans: toJsonPlanSummaryMap(planUsages),
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function planLabel(plan: Plan): string {
|
||||
const name = planDisplayName(plan.id)
|
||||
return plan.id === 'custom' ? `${name} (${plan.provider})` : name
|
||||
}
|
||||
|
||||
function toPlanDisplay(plan: Plan) {
|
||||
return {
|
||||
id: plan.id,
|
||||
monthlyUsd: plan.monthlyUsd,
|
||||
provider: plan.provider,
|
||||
resetDay: clampResetDay(plan.resetDay),
|
||||
setAt: plan.setAt || null,
|
||||
}
|
||||
}
|
||||
|
||||
function sortedPlans(plans: Partial<Record<PlanProvider, Plan>>): Plan[] {
|
||||
return PLAN_PROVIDERS
|
||||
.map(provider => plans[provider])
|
||||
.filter((plan): plan is Plan => plan !== undefined)
|
||||
}
|
||||
|
||||
function assertFormat(value: string, allowed: readonly string[], command: string): void {
|
||||
if (!allowed.includes(value)) {
|
||||
process.stderr.write(
|
||||
|
|
@ -88,11 +133,7 @@ async function runJsonReport(period: Period, provider: string, project: string[]
|
|||
await loadPricing()
|
||||
const { range, label } = getDateRange(period)
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
|
||||
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
|
||||
const planUsage = await getPlanUsageOrNull()
|
||||
if (planUsage) {
|
||||
report.plan = toJsonPlanSummary(planUsage)
|
||||
}
|
||||
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary; plans?: JsonPlanSummaryMap } = await attachPlanSummaries(buildJsonReport(projects, label, period))
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
}
|
||||
|
||||
|
|
@ -329,7 +370,7 @@ program
|
|||
opts.project,
|
||||
opts.exclude,
|
||||
)
|
||||
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
|
||||
console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(projects, label, 'custom')), null, 2))
|
||||
} else {
|
||||
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
|
||||
}
|
||||
|
|
@ -528,16 +569,13 @@ program
|
|||
today: { cost: number; calls: number }
|
||||
month: { cost: number; calls: number }
|
||||
plan?: JsonPlanSummary
|
||||
plans?: JsonPlanSummaryMap
|
||||
} = {
|
||||
currency: code,
|
||||
today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
|
||||
month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
|
||||
}
|
||||
const planUsage = await getPlanUsageOrNull()
|
||||
if (planUsage) {
|
||||
payload.plan = toJsonPlanSummary(planUsage)
|
||||
}
|
||||
console.log(JSON.stringify(payload))
|
||||
console.log(JSON.stringify(await attachPlanSummaries(payload)))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -764,45 +802,57 @@ program
|
|||
.description('Show or configure a subscription plan for overage tracking')
|
||||
.option('--format <format>', 'Output format: text or json', 'text')
|
||||
.option('--monthly-usd <n>', 'Monthly plan price in USD (for custom)', parseNumber)
|
||||
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor', 'all')
|
||||
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor')
|
||||
.option('--reset-day <n>', 'Day of month plan resets (1-28)', parseInteger, 1)
|
||||
.action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => {
|
||||
assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan')
|
||||
const mode = action ?? 'show'
|
||||
const providerOption = opts?.provider
|
||||
if (providerOption !== undefined && !isPlanProvider(providerOption)) {
|
||||
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${providerOption}".\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'show') {
|
||||
const plan = await readPlan()
|
||||
const displayPlan = !plan || plan.id === 'none'
|
||||
? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
|
||||
: {
|
||||
id: plan.id,
|
||||
monthlyUsd: plan.monthlyUsd,
|
||||
provider: plan.provider,
|
||||
resetDay: clampResetDay(plan.resetDay),
|
||||
setAt: plan.setAt,
|
||||
}
|
||||
const plans = sortedPlans(await readPlans())
|
||||
.filter(plan => plan.id !== 'none')
|
||||
.filter(plan => !providerOption || providerOption === 'all' || plan.provider === providerOption)
|
||||
if (opts?.format === 'json') {
|
||||
console.log(JSON.stringify(displayPlan))
|
||||
if (plans.length === 0) {
|
||||
console.log(JSON.stringify({ id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }))
|
||||
return
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
...toPlanDisplay(plans[0]!),
|
||||
plans: Object.fromEntries(plans.map(plan => [plan.provider, toPlanDisplay(plan)])),
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (!plan || plan.id === 'none') {
|
||||
if (plans.length === 0) {
|
||||
console.log('\n Plan: none')
|
||||
console.log(' API-pricing view is active.')
|
||||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
return
|
||||
}
|
||||
console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
|
||||
console.log(` Budget: $${plan.monthlyUsd}/month`)
|
||||
console.log(` Provider: ${plan.provider}`)
|
||||
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
|
||||
console.log(` Set at: ${plan.setAt}`)
|
||||
console.log(`\n Plans: ${plans.length}`)
|
||||
for (const plan of plans) {
|
||||
console.log(` ${plan.provider}: ${planLabel(plan)} (${plan.id})`)
|
||||
console.log(` Budget: $${plan.monthlyUsd}/month`)
|
||||
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
|
||||
if (plan.setAt) console.log(` Set at: ${plan.setAt}`)
|
||||
}
|
||||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'reset') {
|
||||
await clearPlan()
|
||||
console.log('\n Plan reset. API-pricing view is active.\n')
|
||||
await clearPlan(providerOption)
|
||||
if (providerOption) {
|
||||
console.log(`\n Plan reset for ${providerOption}.\n`)
|
||||
} else {
|
||||
console.log('\n Plan reset. API-pricing view is active.\n')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -813,7 +863,7 @@ program
|
|||
}
|
||||
|
||||
if (!id || !isPlanId(id)) {
|
||||
console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`)
|
||||
console.error(`\n Plan id must be one of: ${PLAN_IDS.join(', ')}; got "${id ?? ''}".\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
|
@ -826,8 +876,12 @@ program
|
|||
}
|
||||
|
||||
if (id === 'none') {
|
||||
await clearPlan()
|
||||
console.log('\n Plan reset. API-pricing view is active.\n')
|
||||
await clearPlan(providerOption)
|
||||
if (providerOption) {
|
||||
console.log(`\n Plan reset for ${providerOption}.\n`)
|
||||
} else {
|
||||
console.log('\n Plan reset. API-pricing view is active.\n')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -843,12 +897,7 @@ program
|
|||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const provider = opts?.provider ?? 'all'
|
||||
if (!isPlanProvider(provider)) {
|
||||
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const provider = providerOption ?? 'all'
|
||||
await savePlan({
|
||||
id: 'custom',
|
||||
monthlyUsd,
|
||||
|
|
@ -868,6 +917,18 @@ program
|
|||
return
|
||||
}
|
||||
|
||||
if (providerOption === 'all') {
|
||||
console.error(`\n ${id} is a ${preset.provider} plan; omit --provider or use --provider ${preset.provider}.\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (providerOption && providerOption !== preset.provider) {
|
||||
console.error(`\n ${id} is a ${preset.provider} plan; use --provider ${preset.provider} or omit --provider.\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
await savePlan({
|
||||
...preset,
|
||||
resetDay,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult
|
|||
import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js'
|
||||
import { dateKey } from './day-aggregator.js'
|
||||
import { CompareView } from './compare.js'
|
||||
import { getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
|
||||
import { getPlanUsages, type PlanUsage } from './plan-usage.js'
|
||||
import { planDisplayName } from './plans.js'
|
||||
import { getDateRange, PERIODS, PERIOD_LABELS, type Period, formatDateRangeLabel } from './cli-date.js'
|
||||
import { join } from 'path'
|
||||
|
|
@ -153,7 +153,30 @@ function renderPlanBar(percentUsed: number, width: number): string {
|
|||
return `${'▓'.repeat(width)}${'▶'.repeat(chevrons)}`
|
||||
}
|
||||
|
||||
function Overview({ projects, label, width, planUsage }: { projects: ProjectSummary[]; label: string; width: number; planUsage?: PlanUsage }) {
|
||||
function planLabel(planUsage: PlanUsage): string {
|
||||
const name = planDisplayName(planUsage.plan.id)
|
||||
return planUsage.plan.id === 'custom' ? `${name} (${planUsage.plan.provider})` : name
|
||||
}
|
||||
|
||||
function planColor(planUsage: PlanUsage): string {
|
||||
return planUsage.status === 'over'
|
||||
? '#F55B5B'
|
||||
: planUsage.status === 'near'
|
||||
? ORANGE
|
||||
: '#5BF58C'
|
||||
}
|
||||
|
||||
function planStatusText(planUsage: PlanUsage): string {
|
||||
if (planUsage.status === 'under') {
|
||||
return `Well within plan. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
|
||||
}
|
||||
if (planUsage.status === 'near') {
|
||||
return `Approaching plan limit. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
|
||||
}
|
||||
return `${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x your subscription value. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
|
||||
}
|
||||
|
||||
function Overview({ projects, label, width, planUsages }: { projects: ProjectSummary[]; label: string; width: number; planUsages?: PlanUsage[] }) {
|
||||
const totalCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
||||
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
|
||||
|
|
@ -165,15 +188,7 @@ function Overview({ projects, label, width, planUsage }: { projects: ProjectSumm
|
|||
const allInputTokens = totalInput + totalCacheRead + totalCacheWrite
|
||||
const cacheHit = allInputTokens > 0
|
||||
? (totalCacheRead / allInputTokens) * 100 : 0
|
||||
const planLabel = planUsage ? `${planDisplayName(planUsage.plan.id)}: ${formatCost(planUsage.spentApiEquivalentUsd)} API-equivalent vs ${formatCost(planUsage.budgetUsd)} plan` : ''
|
||||
const planPct = planUsage ? `${planUsage.percentUsed.toFixed(1)}%` : ''
|
||||
const planColor = planUsage
|
||||
? planUsage.status === 'over'
|
||||
? '#F55B5B'
|
||||
: planUsage.status === 'near'
|
||||
? ORANGE
|
||||
: '#5BF58C'
|
||||
: DIM
|
||||
const activePlanUsages = planUsages ?? []
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={PANEL_COLORS.overview} paddingX={1} width={width}>
|
||||
|
|
@ -194,22 +209,23 @@ function Overview({ projects, label, width, planUsage }: { projects: ProjectSumm
|
|||
<Text dimColor wrap="truncate-end">
|
||||
{formatTokens(totalInput)} in {formatTokens(totalOutput)} out {formatTokens(totalCacheRead)} cached {formatTokens(totalCacheWrite)} written
|
||||
</Text>
|
||||
{planUsage && (
|
||||
{activePlanUsages.length > 0 && (
|
||||
<>
|
||||
<Text wrap="truncate-end">
|
||||
<Text color={planColor}>{planLabel}</Text>
|
||||
<Text> </Text>
|
||||
<Text color={planColor}>{renderPlanBar(planUsage.percentUsed, PLAN_BAR_WIDTH)}</Text>
|
||||
<Text> </Text>
|
||||
<Text bold color={planColor}>{planPct}</Text>
|
||||
</Text>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{planUsage.status === 'under'
|
||||
? `Well within plan. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
|
||||
: planUsage.status === 'near'
|
||||
? `Approaching plan limit. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
|
||||
: `${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x your subscription value. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`}
|
||||
</Text>
|
||||
{activePlanUsages.map(planUsage => {
|
||||
const color = planColor(planUsage)
|
||||
return (
|
||||
<React.Fragment key={planUsage.plan.provider}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text color={color}>{planLabel(planUsage)}: {formatCost(planUsage.spentApiEquivalentUsd)} API-equivalent vs {formatCost(planUsage.budgetUsd)} plan</Text>
|
||||
<Text> </Text>
|
||||
<Text color={color}>{renderPlanBar(planUsage.percentUsed, PLAN_BAR_WIDTH)}</Text>
|
||||
<Text> </Text>
|
||||
<Text bold color={color}>{planUsage.percentUsed.toFixed(1)}%</Text>
|
||||
</Text>
|
||||
<Text dimColor wrap="truncate-end">{planStatusText(planUsage)}</Text>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
|
@ -671,7 +687,7 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children
|
|||
return <>{children}</>
|
||||
}
|
||||
|
||||
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsage }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsage?: PlanUsage }) {
|
||||
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[] }) {
|
||||
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
|
||||
const isCursor = activeProvider === 'cursor'
|
||||
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text></Panel>
|
||||
|
|
@ -679,7 +695,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
|
|||
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} planUsage={planUsage} />
|
||||
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} planUsages={planUsages} />
|
||||
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
|
||||
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
|
||||
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
|
||||
|
|
@ -692,11 +708,11 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
|
|||
)
|
||||
}
|
||||
|
||||
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsage, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel }: {
|
||||
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel }: {
|
||||
initialProjects: ProjectSummary[]
|
||||
initialPeriod: Period
|
||||
initialProvider: string
|
||||
initialPlanUsage?: PlanUsage
|
||||
initialPlanUsages?: PlanUsage[]
|
||||
refreshSeconds?: number
|
||||
projectFilter?: string[]
|
||||
excludeFilter?: string[]
|
||||
|
|
@ -712,7 +728,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
const [view, setView] = useState<View>('dashboard')
|
||||
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
|
||||
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
|
||||
const [planUsage, setPlanUsage] = useState<PlanUsage | undefined>(initialPlanUsage)
|
||||
const [planUsages, setPlanUsages] = useState<PlanUsage[]>(initialPlanUsages ?? [])
|
||||
// Cursor for the OptimizeView's findings window. Reset whenever the user
|
||||
// leaves the optimize view OR the underlying findings change so a long
|
||||
// findings list never strands the user past the new array length.
|
||||
|
|
@ -783,9 +799,9 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
if (reloadGenerationRef.current !== generation) return
|
||||
|
||||
setProjects(filteredProjects)
|
||||
const usage = await getPlanUsageOrNull()
|
||||
const usage = await getPlanUsages()
|
||||
if (reloadGenerationRef.current !== generation) return
|
||||
setPlanUsage(usage ?? undefined)
|
||||
setPlanUsages(usage)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
|
|
@ -891,7 +907,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
|
||||
: view === 'optimize' && optimizeResult
|
||||
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
|
||||
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsage={planUsage} />}
|
||||
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} />}
|
||||
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} />}
|
||||
</Box>
|
||||
)
|
||||
|
|
@ -906,13 +922,13 @@ function CustomRangeBanner({ label, width }: { label: string; width: number }) {
|
|||
)
|
||||
}
|
||||
|
||||
function StaticDashboard({ projects, period, activeProvider, planUsage }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsage?: PlanUsage }) {
|
||||
function StaticDashboard({ projects, period, activeProvider, planUsages }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[] }) {
|
||||
const { columns } = useWindowSize()
|
||||
const { dashWidth } = getLayout(columns)
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<PeriodTabs active={period} />
|
||||
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsage={planUsage} />
|
||||
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -921,16 +937,16 @@ export async function renderDashboard(period: Period = 'week', provider: string
|
|||
await loadPricing()
|
||||
const range = customRange ?? getPeriodRange(period)
|
||||
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
|
||||
const planUsage = await getPlanUsageOrNull()
|
||||
const planUsages = await getPlanUsages()
|
||||
const isTTY = process.stdin.isTTY && process.stdout.isTTY
|
||||
patchStdoutForWindows()
|
||||
if (isTTY) {
|
||||
const { waitUntilExit } = render(
|
||||
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsage={planUsage ?? undefined} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} />
|
||||
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} />
|
||||
)
|
||||
await waitUntilExit()
|
||||
} else {
|
||||
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsage={planUsage ?? undefined} />, { patchConsole: false })
|
||||
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} />, { patchConsole: false })
|
||||
unmount()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { readPlan, type Plan } from './config.js'
|
||||
import { readPlans, type Plan, type PlanMap } from './config.js'
|
||||
import { parseAllSessions } from './parser.js'
|
||||
import { PLAN_PROVIDERS } from './plans.js'
|
||||
import type { DateRange, ProjectSummary } from './types.js'
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
|
|
@ -79,13 +80,15 @@ export function projectMonthEnd(
|
|||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const turn of session.turns) {
|
||||
if (!turn.timestamp) continue
|
||||
const ts = new Date(turn.timestamp)
|
||||
if (Number.isNaN(ts.getTime())) continue
|
||||
if (ts < periodStart || ts > today) continue
|
||||
const dayKey = toLocalDateKey(ts)
|
||||
const turnCost = turn.assistantCalls.reduce((sum, call) => sum + call.costUSD, 0)
|
||||
dayCosts.set(dayKey, (dayCosts.get(dayKey) ?? 0) + turnCost)
|
||||
for (const call of turn.assistantCalls) {
|
||||
const timestamp = call.timestamp || turn.timestamp
|
||||
if (!timestamp) continue
|
||||
const ts = new Date(timestamp)
|
||||
if (Number.isNaN(ts.getTime())) continue
|
||||
if (ts < periodStart || ts > today) continue
|
||||
const dayKey = toLocalDateKey(ts)
|
||||
dayCosts.set(dayKey, (dayCosts.get(dayKey) ?? 0) + call.costUSD)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,6 +129,45 @@ export function getPlanUsageFromProjects(plan: Plan, projects: ProjectSummary[],
|
|||
}
|
||||
}
|
||||
|
||||
function getPlanScopedProjects(plan: Plan, projects: ProjectSummary[], today: Date): ProjectSummary[] {
|
||||
const { periodStart } = computePeriodFromResetDay(plan.resetDay, today)
|
||||
const provider = plan.provider
|
||||
|
||||
// These scoped clones are consumed only by plan usage math; cost/call rollups
|
||||
// are recomputed below, while unrelated breakdown fields remain unchanged.
|
||||
return projects
|
||||
.map(project => {
|
||||
const sessions = project.sessions
|
||||
.map(session => {
|
||||
const turns = session.turns
|
||||
.map(turn => {
|
||||
const assistantCalls = turn.assistantCalls.filter(call => {
|
||||
if (provider !== 'all' && call.provider !== provider) return false
|
||||
const timestamp = call.timestamp || turn.timestamp
|
||||
if (!timestamp) return false
|
||||
const ts = new Date(timestamp)
|
||||
return !Number.isNaN(ts.getTime()) && ts >= periodStart && ts <= today
|
||||
})
|
||||
return assistantCalls.length > 0 ? { ...turn, assistantCalls } : null
|
||||
})
|
||||
.filter((turn): turn is NonNullable<typeof turn> => turn !== null)
|
||||
|
||||
const totalCostUSD = turns.reduce(
|
||||
(sum, turn) => sum + turn.assistantCalls.reduce((turnSum, call) => turnSum + call.costUSD, 0),
|
||||
0,
|
||||
)
|
||||
const apiCalls = turns.reduce((sum, turn) => sum + turn.assistantCalls.length, 0)
|
||||
return apiCalls > 0 ? { ...session, turns, totalCostUSD, apiCalls } : null
|
||||
})
|
||||
.filter((session): session is NonNullable<typeof session> => session !== null)
|
||||
|
||||
const totalCostUSD = sessions.reduce((sum, session) => sum + session.totalCostUSD, 0)
|
||||
const totalApiCalls = sessions.reduce((sum, session) => sum + session.apiCalls, 0)
|
||||
return totalApiCalls > 0 ? { ...project, sessions, totalCostUSD, totalApiCalls } : null
|
||||
})
|
||||
.filter((project): project is NonNullable<typeof project> => project !== null)
|
||||
}
|
||||
|
||||
export async function getPlanUsage(plan: Plan, today = new Date()): Promise<PlanUsage> {
|
||||
const { periodStart } = computePeriodFromResetDay(plan.resetDay, today)
|
||||
const range: DateRange = {
|
||||
|
|
@ -138,9 +180,34 @@ export async function getPlanUsage(plan: Plan, today = new Date()): Promise<Plan
|
|||
}
|
||||
|
||||
export async function getPlanUsageOrNull(today = new Date()): Promise<PlanUsage | null> {
|
||||
const plan = await readPlan()
|
||||
if (!isActivePlan(plan)) return null
|
||||
return getPlanUsage(plan, today)
|
||||
return (await getPlanUsages(today))[0] ?? null
|
||||
}
|
||||
|
||||
export function activePlansFromMap(plans: PlanMap): Plan[] {
|
||||
return PLAN_PROVIDERS
|
||||
.map(provider => plans[provider])
|
||||
.filter(isActivePlan)
|
||||
}
|
||||
|
||||
export async function getPlanUsages(today = new Date()): Promise<PlanUsage[]> {
|
||||
const plans = activePlansFromMap(await readPlans())
|
||||
if (plans.length === 0) return []
|
||||
|
||||
const starts = plans.map(plan => computePeriodFromResetDay(plan.resetDay, today).periodStart.getTime())
|
||||
const range: DateRange = {
|
||||
start: new Date(Math.min(...starts)),
|
||||
end: today,
|
||||
}
|
||||
|
||||
if (plans.length === 1) {
|
||||
const plan = plans[0]!
|
||||
const projects = await parseAllSessions(range, plan.provider === 'all' ? 'all' : plan.provider)
|
||||
return [getPlanUsageFromProjects(plan, projects, today)]
|
||||
}
|
||||
|
||||
const projects = await parseAllSessions(range, 'all')
|
||||
|
||||
return plans.map(plan => getPlanUsageFromProjects(plan, getPlanScopedProjects(plan, projects, today), today))
|
||||
}
|
||||
|
||||
export function isActivePlan(plan: Plan | undefined): plan is Plan {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue