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).
This commit is contained in:
Resham Joshi 2026-06-18 12:39:22 +02:00 committed by GitHub
parent 15536ed71b
commit c5bf09921b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 110 additions and 0 deletions

View file

@ -1085,6 +1085,10 @@ export async function renderDashboard(period: Period = 'week', provider: string
await waitUntilExit()
} else {
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} label={label} dayMode={initialDay != null} />, { 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<void>(resolve => setTimeout(resolve, 0))
unmount()
}
}

View file

@ -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<void> {
await loadPricing()
const { range, label } = getDateRange(period)
@ -421,6 +431,7 @@ program
.option('--refresh <seconds>', '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<typeof parseDayFlag> = 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 <seconds>', '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 <seconds>', '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 <name>', '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 <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 <period>', 'Analysis period: today, week, 30days, month, all', 'all')
.option('--provider <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 <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()

View file

@ -154,6 +154,22 @@ async function loadCrush(): Promise<Provider | null> {
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<Provider[]> {
const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw] = await Promise.all([
loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(),

View file

@ -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<string> {
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)
})
})