From c5bf09921b671c73d1b0996428bc4ac278fd6b3b Mon Sep 17 00:00:00 2001
From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
Date: Thu, 18 Jun 2026 12:39:22 +0200
Subject: [PATCH] fix(cli): validate --provider and make non-TTY report
deterministic (#501)
Two CLI polish fixes found during a full command sweep:
- Validate --provider against the known provider set (report, status, today,
month, export, optimize, compare, models). An unknown provider previously
produced a silently empty report; it now exits 1 with a clear message
listing valid values, matching how --period and --format already behave.
Provider names are exposed via a lazy allProviderNames() helper so importing
the providers module never depends on every provider object being defined.
- Make the non-interactive report/today/month render deterministic: yield a
tick so ink flushes the one-shot frame to stdout before unmounting, instead
of unmounting synchronously which could race the flush and drop the output.
Adds CLI provider-validation tests (rejection, valid name, all sentinel, and
a drift guard that allProviderNames covers every loadable provider).
---
src/dashboard.tsx | 4 ++
src/main.ts | 18 +++++++
src/providers/index.ts | 16 ++++++
tests/cli-provider-validation.test.ts | 72 +++++++++++++++++++++++++++
4 files changed, 110 insertions(+)
create mode 100644 tests/cli-provider-validation.test.ts
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index 243aa8d..d942f9d 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -1085,6 +1085,10 @@ export async function renderDashboard(period: Period = 'week', provider: string
await waitUntilExit()
} else {
const { unmount } = render(, { patchConsole: false })
+ // Non-interactive one-shot output: ink schedules the frame through a
+ // throttled render, so yield a tick to let it flush to stdout before
+ // unmounting. Unmounting synchronously can race the flush and drop output.
+ await new Promise(resolve => setTimeout(resolve, 0))
unmount()
}
}
diff --git a/src/main.ts b/src/main.ts
index 1fad67b..f92c07f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -4,6 +4,7 @@ import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
+import { allProviderNames } from './providers/index.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { toDateString } from './daily-cache.js'
@@ -122,6 +123,15 @@ function assertFormat(value: string, allowed: readonly string[], command: string
}
}
+function assertProvider(value: string, command: string): void {
+ const names = allProviderNames()
+ if (value === 'all' || names.includes(value)) return
+ process.stderr.write(
+ `codeburn ${command}: unknown provider "${value}". Valid values: all, ${names.join(', ')}.\n`
+ )
+ process.exit(1)
+}
+
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise {
await loadPricing()
const { range, label } = getDateRange(period)
@@ -421,6 +431,7 @@ program
.option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'report')
+ assertProvider(opts.provider, 'report')
let customRange: DateRange | null = null
let daySelection: ReturnType = null
try {
@@ -473,6 +484,7 @@ program
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.action(async (opts) => {
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
+ assertProvider(opts.provider, 'status')
if (opts.day && (opts.from || opts.to)) {
process.stderr.write('error: --day cannot be combined with --from or --to\n')
process.exit(1)
@@ -553,6 +565,7 @@ program
.option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'today')
+ assertProvider(opts.provider, 'today')
if (opts.format === 'json') {
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
return
@@ -570,6 +583,7 @@ program
.option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'month')
+ assertProvider(opts.provider, 'month')
if (opts.format === 'json') {
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
return
@@ -589,6 +603,7 @@ program
.option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
.action(async (opts) => {
assertFormat(opts.format, ['csv', 'json'], 'export')
+ assertProvider(opts.provider, 'export')
await loadPricing()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
@@ -1036,6 +1051,7 @@ program
.option('--format ', 'Output format: text, json', 'text')
.action(async (opts) => {
assertFormat(opts.format, ['text', 'json'], 'optimize')
+ assertProvider(opts.provider, 'optimize')
await loadPricing()
const { range, label } = getDateRange(opts.period)
const projects = await parseAllSessions(range, opts.provider)
@@ -1048,6 +1064,7 @@ program
.option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'all')
.option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.action(async (opts) => {
+ assertProvider(opts.provider, 'compare')
await loadPricing()
const { range } = getDateRange(opts.period)
await renderCompare(range, opts.provider)
@@ -1067,6 +1084,7 @@ program
.option('--no-totals', 'Suppress the footer totals row')
.option('--format ', 'Output format: table, markdown, json, csv', 'table')
.action(async (opts) => {
+ assertProvider(opts.provider, 'models')
const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js')
await loadPricing()
diff --git a/src/providers/index.ts b/src/providers/index.ts
index 93af972..f52886b 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -154,6 +154,22 @@ async function loadCrush(): Promise {
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode]
+// Lazily loaded providers, listed by name so --provider validation works even
+// when an optional module fails to load. Must stay in sync with getAllProviders.
+const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway']
+
+// Canonical set of every provider name (core + lazy), used to validate the
+// --provider CLI flag. Computed lazily so importing this module never depends on
+// every provider object being defined at load time (e.g. under test mocks).
+let allProviderNamesCache: string[] | undefined
+export function allProviderNames(): readonly string[] {
+ allProviderNamesCache ??= [
+ ...coreProviders.map(p => p.name),
+ ...lazyProviderNames,
+ ].sort()
+ return allProviderNamesCache
+}
+
export async function getAllProviders(): Promise {
const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw] = await Promise.all([
loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(),
diff --git a/tests/cli-provider-validation.test.ts b/tests/cli-provider-validation.test.ts
new file mode 100644
index 0000000..4fa9089
--- /dev/null
+++ b/tests/cli-provider-validation.test.ts
@@ -0,0 +1,72 @@
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { spawnSync } from 'node:child_process'
+
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { allProviderNames, getAllProviders } from '../src/providers/index.js'
+
+let homes: string[] = []
+
+afterEach(async () => {
+ while (homes.length > 0) {
+ const h = homes.pop()
+ if (h) await rm(h, { recursive: true, force: true })
+ }
+})
+
+async function makeHome(): Promise {
+ const home = await mkdtemp(join(tmpdir(), 'codeburn-provider-cli-'))
+ homes.push(home)
+ return home
+}
+
+function runCli(args: string[], home: string) {
+ return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
+ cwd: process.cwd(),
+ env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
+ encoding: 'utf-8',
+ timeout: 30_000,
+ })
+}
+
+describe('allProviderNames', () => {
+ it('is non-empty, sorted, and excludes the "all" sentinel', () => {
+ const names = allProviderNames()
+ expect(names.length).toBeGreaterThan(0)
+ expect([...names]).toEqual([...names].sort())
+ expect(names).not.toContain('all')
+ })
+
+ it('covers every loadable provider (guards against lazy-list drift)', async () => {
+ const loaded = (await getAllProviders()).map(p => p.name)
+ const names = allProviderNames()
+ for (const name of loaded) {
+ expect(names).toContain(name)
+ }
+ })
+})
+
+describe('codeburn --provider validation', () => {
+ it('rejects an unknown provider with a clear error and exit 1', async () => {
+ const home = await makeHome()
+ const res = runCli(['report', '--provider', 'claud', '-p', 'today'], home)
+ expect(res.status).toBe(1)
+ expect(res.stderr).toContain('unknown provider "claud"')
+ expect(res.stderr).toContain('Valid values: all,')
+ })
+
+ it('accepts a valid provider', async () => {
+ const home = await makeHome()
+ const res = runCli(['status', '--provider', 'claude', '--format', 'json'], home)
+ expect(res.status).toBe(0)
+ expect(res.stderr).not.toContain('unknown provider')
+ })
+
+ it('accepts the "all" sentinel', async () => {
+ const home = await makeHome()
+ const res = runCli(['status', '--provider', 'all', '--format', 'json'], home)
+ expect(res.status).toBe(0)
+ })
+})