Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 3f7470d

Browse files
tmchowiamtoruk
authored andcommitted
feat(plan): subscription plan tracking with usage progress bar
Adds `codeburn plan set <id>` to configure a subscription plan (Claude Pro, Claude Max, Cursor Pro, or custom). When set, the Overview panel renders an API-equivalent progress bar against subscription price with a projected month-end cost. Closes the loudest demand signal on the repo: issue getagentseal#11 ("Subscription vs API Use") from two independent voices, plus the routing-decision use case raised in getagentseal#12. - src/config.ts: extends CodeburnConfig with Plan, adds readPlan/savePlan/clearPlan - src/plans.ts: presets (claude-pro $20, claude-max $200, cursor-pro $20) - src/plan-usage.ts: getPlanUsage, resetDay-aware period math (1-28), median-of-7-day-trailing projection - src/cli.ts: `codeburn plan [show|set|reset]` subcommand, plan wired into JSON outputs for report/today/month/status (only when active) - src/dashboard.tsx: Plan row in Overview, color-coded (green under 80%, orange near, red over), with days-until-reset - README.md: Plans section with honest framing (API-equivalent vs subscription price, not token allowance) - tests/plan-usage.test.ts, tests/plans.test.ts, tests/cli-plan.test.ts: period math, presets, CLI round-trip Resets respect resetDay across month boundaries. Uses median daily spend (not mean) so one huge day doesn't distort the month-end projection. Fixes getagentseal#11
1 parent 8e39a89 commit 3f7470d

9 files changed

Lines changed: 753 additions & 22 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,22 @@ The currency setting applies everywhere: dashboard, status bar, menu bar widget,
166166

167167
The menu bar widget includes a currency picker with 17 common currencies. For any currency not listed, use the CLI command above.
168168

169+
## Plans (subscription tracking)
170+
171+
If you're on Claude Pro, Claude Max, or Cursor Pro, set your plan so the dashboard shows subscription-relative usage:
172+
173+
```bash
174+
codeburn plan set claude-max # $200/month
175+
codeburn plan set claude-pro # $20/month
176+
codeburn plan set cursor-pro # $20/month
177+
codeburn plan set custom --monthly-usd 150 --provider claude # custom
178+
codeburn plan set none # disable plan view
179+
codeburn plan # show current
180+
codeburn plan reset # remove plan config
181+
```
182+
183+
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.
184+
169185
## Menu Bar
170186

171187
<img src="https://cdn.jsdelivr.net/gh/getagentseal/codeburn@main/assets/menubar-0.8.0.png" alt="CodeBurn macOS menubar app" width="420" />

src/cli.ts

Lines changed: 174 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import { parseDateRangeFlags } from './cli-date.js'
1515
import { runOptimize, scanAndDetect } from './optimize.js'
1616
import { renderCompare } from './compare.js'
1717
import { getAllProviders } from './providers/index.js'
18-
import { readConfig, saveConfig, getConfigFilePath } from './config.js'
18+
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath } from './config.js'
19+
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
20+
import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js'
1921
import { createRequire } from 'node:module'
2022

2123
const require = createRequire(import.meta.url)
@@ -84,11 +86,50 @@ function collect(val: string, acc: string[]): string[] {
8486
return acc
8587
}
8688

89+
function parseNumber(value: string): number {
90+
return Number(value)
91+
}
92+
93+
function parseInteger(value: string): number {
94+
return parseInt(value, 10)
95+
}
96+
97+
type JsonPlanSummary = {
98+
id: 'claude-pro' | 'claude-max' | 'cursor-pro' | 'custom'
99+
budget: number
100+
spent: number
101+
percentUsed: number
102+
status: 'under' | 'near' | 'over'
103+
projectedMonthEnd: number
104+
daysUntilReset: number
105+
periodStart: string
106+
periodEnd: string
107+
}
108+
109+
function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
110+
return {
111+
id: planUsage.plan.id,
112+
budget: convertCost(planUsage.budgetUsd),
113+
spent: convertCost(planUsage.spentApiEquivalentUsd),
114+
percentUsed: Math.round(planUsage.percentUsed * 10) / 10,
115+
status: planUsage.status,
116+
projectedMonthEnd: convertCost(planUsage.projectedMonthUsd),
117+
daysUntilReset: planUsage.daysUntilReset,
118+
periodStart: planUsage.periodStart.toISOString(),
119+
periodEnd: planUsage.periodEnd.toISOString(),
120+
}
121+
}
122+
87123
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
88124
await loadPricing()
89125
const { range, label } = getDateRange(period)
90126
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
91-
console.log(JSON.stringify(buildJsonReport(projects, label, period), null, 2))
127+
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
128+
const planUsage = await getPlanUsageOrNull()
129+
if (planUsage) {
130+
report.plan = toJsonPlanSummary(planUsage)
131+
}
132+
console.log(JSON.stringify(report, null, 2))
92133
}
93134

94135
const program = new Command()
@@ -483,11 +524,21 @@ program
483524
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
484525
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
485526
const { code, rate } = getCurrency()
486-
console.log(JSON.stringify({
527+
const payload: {
528+
currency: string
529+
today: { cost: number; calls: number }
530+
month: { cost: number; calls: number }
531+
plan?: JsonPlanSummary
532+
} = {
487533
currency: code,
488534
today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
489535
month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
490-
}))
536+
}
537+
const planUsage = await getPlanUsageOrNull()
538+
if (planUsage) {
539+
payload.plan = toJsonPlanSummary(planUsage)
540+
}
541+
console.log(JSON.stringify(payload))
491542
return
492543
}
493544

@@ -638,6 +689,125 @@ program
638689
console.log(` Config saved to ${getConfigFilePath()}\n`)
639690
})
640691

692+
program
693+
.command('plan [action] [id]')
694+
.description('Show or configure a subscription plan for overage tracking')
695+
.option('--format <format>', 'Output format: text or json', 'text')
696+
.option('--monthly-usd <n>', 'Monthly plan price in USD (for custom)', parseNumber)
697+
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor', 'all')
698+
.option('--reset-day <n>', 'Day of month plan resets (1-28)', parseInteger, 1)
699+
.action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => {
700+
const mode = action ?? 'show'
701+
702+
if (mode === 'show') {
703+
const plan = await readPlan()
704+
const displayPlan = !plan || plan.id === 'none'
705+
? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
706+
: {
707+
id: plan.id,
708+
monthlyUsd: plan.monthlyUsd,
709+
provider: plan.provider,
710+
resetDay: clampResetDay(plan.resetDay),
711+
setAt: plan.setAt,
712+
}
713+
if (opts?.format === 'json') {
714+
console.log(JSON.stringify(displayPlan))
715+
return
716+
}
717+
if (!plan || plan.id === 'none') {
718+
console.log('\n Plan: none')
719+
console.log(' API-pricing view is active.')
720+
console.log(` Config: ${getConfigFilePath()}\n`)
721+
return
722+
}
723+
console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
724+
console.log(` Budget: $${plan.monthlyUsd}/month`)
725+
console.log(` Provider: ${plan.provider}`)
726+
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
727+
console.log(` Set at: ${plan.setAt}`)
728+
console.log(` Config: ${getConfigFilePath()}\n`)
729+
return
730+
}
731+
732+
if (mode === 'reset') {
733+
await clearPlan()
734+
console.log('\n Plan reset. API-pricing view is active.\n')
735+
return
736+
}
737+
738+
if (mode !== 'set') {
739+
console.error('\n Usage: codeburn plan [set <id> | reset]\n')
740+
process.exitCode = 1
741+
return
742+
}
743+
744+
if (!id || !isPlanId(id)) {
745+
console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`)
746+
process.exitCode = 1
747+
return
748+
}
749+
750+
const resetDay = opts?.resetDay ?? 1
751+
if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) {
752+
console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`)
753+
process.exitCode = 1
754+
return
755+
}
756+
757+
if (id === 'none') {
758+
await clearPlan()
759+
console.log('\n Plan reset. API-pricing view is active.\n')
760+
return
761+
}
762+
763+
if (id === 'custom') {
764+
if (opts?.monthlyUsd === undefined) {
765+
console.error('\n Custom plans require --monthly-usd <positive number>.\n')
766+
process.exitCode = 1
767+
return
768+
}
769+
const monthlyUsd = opts.monthlyUsd
770+
if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) {
771+
console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`)
772+
process.exitCode = 1
773+
return
774+
}
775+
const provider = opts?.provider ?? 'all'
776+
if (!isPlanProvider(provider)) {
777+
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
778+
process.exitCode = 1
779+
return
780+
}
781+
await savePlan({
782+
id: 'custom',
783+
monthlyUsd,
784+
provider,
785+
resetDay,
786+
setAt: new Date().toISOString(),
787+
})
788+
console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`)
789+
console.log(` Config saved to ${getConfigFilePath()}\n`)
790+
return
791+
}
792+
793+
const preset = getPresetPlan(id)
794+
if (!preset) {
795+
console.error(`\n Unknown preset "${id}".\n`)
796+
process.exitCode = 1
797+
return
798+
}
799+
800+
await savePlan({
801+
...preset,
802+
resetDay,
803+
setAt: new Date().toISOString(),
804+
})
805+
console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`)
806+
console.log(` Provider: ${preset.provider}`)
807+
console.log(` Reset day: ${resetDay}`)
808+
console.log(` Config saved to ${getConfigFilePath()}\n`)
809+
})
810+
641811
program
642812
.command('optimize')
643813
.description('Find token waste and get exact fixes')

src/config.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
1-
import { readFile, writeFile, mkdir } from 'fs/promises'
1+
import { readFile, writeFile, mkdir, rename } from 'fs/promises'
22
import { join } from 'path'
33
import { homedir } from 'os'
44

5+
export type PlanId = 'claude-pro' | 'claude-max' | 'cursor-pro' | 'custom' | 'none'
6+
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'all'
7+
8+
export type Plan = {
9+
id: PlanId
10+
monthlyUsd: number
11+
provider: PlanProvider
12+
resetDay?: number
13+
setAt: string
14+
}
15+
516
export type CodeburnConfig = {
617
currency?: {
718
code: string
819
symbol?: string
920
}
21+
plan?: Plan
1022
}
1123

1224
function getConfigDir(): string {
@@ -21,14 +33,37 @@ export async function readConfig(): Promise<CodeburnConfig> {
2133
try {
2234
const raw = await readFile(getConfigPath(), 'utf-8')
2335
return JSON.parse(raw) as CodeburnConfig
24-
} catch {
25-
return {}
36+
} catch (error) {
37+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
38+
return {}
39+
}
40+
throw error
2641
}
2742
}
2843

2944
export async function saveConfig(config: CodeburnConfig): Promise<void> {
3045
await mkdir(getConfigDir(), { recursive: true })
31-
await writeFile(getConfigPath(), JSON.stringify(config, null, 2) + '\n', 'utf-8')
46+
const configPath = getConfigPath()
47+
const tmpPath = `${configPath}.tmp`
48+
await writeFile(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
49+
await rename(tmpPath, configPath)
50+
}
51+
52+
export async function readPlan(): Promise<Plan | undefined> {
53+
const config = await readConfig()
54+
return config.plan
55+
}
56+
57+
export async function savePlan(plan: Plan): Promise<void> {
58+
const config = await readConfig()
59+
config.plan = plan
60+
await saveConfig(config)
61+
}
62+
63+
export async function clearPlan(): Promise<void> {
64+
const config = await readConfig()
65+
delete config.plan
66+
await saveConfig(config)
3267
}
3368

3469
export function getConfigFilePath(): string {

0 commit comments

Comments
 (0)