import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter as pathDelimiter, join } from 'node:path' import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' function runCli(args: string[], home: string, extraEnv: Record = {}) { return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { cwd: process.cwd(), env: { ...process.env, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), HOME: home, TZ: 'UTC', ...extraEnv, }, encoding: 'utf-8', timeout: 30_000, }) } function userLine(sessionId: string, timestamp: string): string { return JSON.stringify({ type: 'user', sessionId, timestamp, message: { role: 'user', content: 'do the thing' }, }) } function assistantLine(sessionId: string, timestamp: string, messageId: string): string { return JSON.stringify({ type: 'assistant', sessionId, timestamp, message: { id: messageId, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [ { type: 'text', text: 'done' }, { type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } }, ], usage: { input_tokens: 500, output_tokens: 50 }, }, }) } describe('codeburn status --format menubar-json', () => { it('returns valid MenubarPayload with expected top-level fields', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-')) try { const projectDir = join(home, '.claude', 'projects', 'myapp') await mkdir(projectDir, { recursive: true }) const now = new Date() const h = now.getUTCHours() const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000) const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z') const ts4 = new Date(base.getTime() + 180_000).toISOString().replace(/\.\d+Z$/, 'Z') await writeFile( join(projectDir, 'session.jsonl'), [ userLine('s1', ts1), assistantLine('s1', ts2, 'msg-1'), userLine('s1', ts3), assistantLine('s1', ts4, 'msg-2'), ].join('\n'), ) const result = runCli([ 'status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize', ], home) expect(result.status, `stderr: ${result.stderr}`).toBe(0) const payload = JSON.parse(result.stdout) as Record expect(payload).toHaveProperty('generated') expect(payload).toHaveProperty('current') expect(payload).toHaveProperty('optimize') expect(payload).toHaveProperty('history') const current = payload['current'] as Record expect(current['cost']).toBeGreaterThan(0) expect(current['calls']).toBe(2) expect(current['sessions']).toBe(1) expect(current).toHaveProperty('oneShotRate') expect(current).toHaveProperty('topActivities') expect(current).toHaveProperty('topModels') expect(current).toHaveProperty('providers') const history = payload['history'] as { daily: unknown[] } expect(Array.isArray(history.daily)).toBe(true) } finally { await rm(home, { recursive: true, force: true }) } }) it('filters menubar payloads to a selected review day with --day', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-day-')) try { const projectDir = join(home, '.claude', 'projects', 'myapp') await mkdir(projectDir, { recursive: true }) await writeFile( join(projectDir, 'session.jsonl'), [ userLine('before', '2026-04-09T23:58:00Z'), assistantLine('before', '2026-04-09T23:59:00Z', 'msg-before'), userLine('selected', '2026-04-10T11:59:00Z'), assistantLine('selected', '2026-04-10T12:00:00Z', 'msg-selected'), userLine('after', '2026-04-11T00:00:00Z'), assistantLine('after', '2026-04-11T00:01:00Z', 'msg-after'), ].join('\n'), ) const result = runCli([ 'status', '--format', 'menubar-json', '--day', '2026-04-10', '--provider', 'all', '--no-optimize', ], home) expect(result.status, `stderr: ${result.stderr}`).toBe(0) const payload = JSON.parse(result.stdout) as { current: { label: string calls: number sessions: number topProjects: Array<{ sessions: number; sessionDetails: Array<{ date: string }> }> } } expect(payload.current.label).toBe('Day (2026-04-10)') expect(payload.current.calls).toBe(1) expect(payload.current.sessions).toBe(1) expect(payload.current.topProjects).toHaveLength(1) expect(payload.current.topProjects[0]?.sessions).toBe(1) expect(payload.current.topProjects[0]?.sessionDetails.map(s => s.date)).toEqual(['2026-04-10']) } finally { await rm(home, { recursive: true, force: true }) } }) it('filters the whole menubar payload to a selected Claude config source', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-')) try { const work = join(home, 'claude-work') const personal = join(home, 'claude-personal') const slug = 'shared-app' await mkdir(join(work, 'projects', slug), { recursive: true }) await mkdir(join(personal, 'projects', slug), { recursive: true }) await writeFile( join(work, 'projects', slug, 'work.jsonl'), [ userLine('work', '2026-04-10T11:59:00Z'), assistantLine('work', '2026-04-10T12:00:00Z', 'msg-work'), ].join('\n'), ) await writeFile( join(personal, 'projects', slug, 'personal.jsonl'), [ userLine('personal', '2026-04-10T12:59:00Z'), assistantLine('personal', '2026-04-10T13:00:00Z', 'msg-personal'), ].join('\n'), ) const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) } const allResult = runCli([ 'status', '--format', 'menubar-json', '--period', 'all', '--provider', 'all', '--no-optimize', ], home, env) expect(allResult.status, `stderr: ${allResult.stderr}`).toBe(0) const allPayload = JSON.parse(allResult.stdout) as { current: { calls: number; sessions: number } claudeConfigs?: { selectedId: string | null; options: Array<{ id: string; label: string; path: string }> } } expect(allPayload.current.calls).toBe(2) expect(allPayload.current.sessions).toBe(2) expect(allPayload.claudeConfigs?.selectedId).toBeNull() expect(allPayload.claudeConfigs?.options.map(o => o.label).sort()).toEqual(['claude-personal', 'claude-work']) const workSourceId = allPayload.claudeConfigs!.options.find(o => o.label === 'claude-work')!.id const workResult = runCli([ 'status', '--format', 'menubar-json', '--period', 'all', '--provider', 'all', '--claude-config-source', workSourceId, '--no-optimize', ], home, env) expect(workResult.status, `stderr: ${workResult.stderr}`).toBe(0) const workPayload = JSON.parse(workResult.stdout) as { current: { calls: number; sessions: number; providers: Record } history: { daily: Array<{ calls: number }> } claudeConfigs?: { selectedId: string | null } } expect(workPayload.claudeConfigs?.selectedId).toBe(workSourceId) expect(workPayload.current.calls).toBe(1) expect(workPayload.current.sessions).toBe(1) expect(Object.keys(workPayload.current.providers)).toEqual(['claude']) expect(workPayload.history.daily.reduce((sum, d) => sum + d.calls, 0)).toBe(1) const invalidResult = runCli([ 'status', '--format', 'menubar-json', '--period', 'all', '--provider', 'all', '--claude-config-source', 'claude-config:missing', '--no-optimize', ], home, env) expect(invalidResult.status, `stderr: ${invalidResult.stderr}`).toBe(0) const invalidPayload = JSON.parse(invalidResult.stdout) as { current: { calls: number } claudeConfigs?: { selectedId: string | null } } expect(invalidPayload.current.calls).toBe(2) expect(invalidPayload.claudeConfigs?.selectedId).toBeNull() } finally { await rm(home, { recursive: true, force: true }) } }) it('keeps idle Claude config options visible for the selected period', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-idle-')) try { const work = join(home, 'claude-work') const personal = join(home, 'claude-personal') await mkdir(join(work, 'projects', 'app'), { recursive: true }) await mkdir(join(personal, 'projects', 'app'), { recursive: true }) await writeFile( join(work, 'projects', 'app', 'work.jsonl'), [ userLine('work', '2026-04-10T11:59:00Z'), assistantLine('work', '2026-04-10T12:00:00Z', 'msg-work'), ].join('\n'), ) await writeFile( join(personal, 'projects', 'app', 'personal.jsonl'), [ userLine('personal', '2026-04-09T11:59:00Z'), assistantLine('personal', '2026-04-09T12:00:00Z', 'msg-personal'), ].join('\n'), ) const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) } const allResult = runCli([ 'status', '--format', 'menubar-json', '--day', '2026-04-10', '--provider', 'all', '--no-optimize', ], home, env) expect(allResult.status, `stderr: ${allResult.stderr}`).toBe(0) const allPayload = JSON.parse(allResult.stdout) as { current: { calls: number } claudeConfigs?: { options: Array<{ id: string; label: string }> } } expect(allPayload.current.calls).toBe(1) expect(allPayload.claudeConfigs?.options.map(o => o.label).sort()).toEqual(['claude-personal', 'claude-work']) const personalSourceId = allPayload.claudeConfigs!.options.find(o => o.label === 'claude-personal')!.id const personalResult = runCli([ 'status', '--format', 'menubar-json', '--day', '2026-04-10', '--provider', 'all', '--claude-config-source', personalSourceId, '--no-optimize', ], home, env) expect(personalResult.status, `stderr: ${personalResult.stderr}`).toBe(0) const personalPayload = JSON.parse(personalResult.stdout) as { current: { calls: number; sessions: number; providers: Record } claudeConfigs?: { selectedId: string | null } } expect(personalPayload.claudeConfigs?.selectedId).toBe(personalSourceId) expect(personalPayload.current.calls).toBe(0) expect(personalPayload.current.sessions).toBe(0) expect(personalPayload.current.providers).toEqual({ claude: 0 }) } finally { await rm(home, { recursive: true, force: true }) } }) it('rejects --claude-config-source combined with a non-Claude --provider', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cfg-provider-')) try { const work = join(home, 'claude-work') const personal = join(home, 'claude-personal') await mkdir(join(work, 'projects', 'app'), { recursive: true }) await mkdir(join(personal, 'projects', 'app'), { recursive: true }) const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) } const result = runCli([ 'status', '--format', 'menubar-json', '--period', 'all', '--provider', 'codex', '--claude-config-source', 'claude-config:whatever', '--no-optimize', ], home, env) expect(result.status).not.toBe(0) expect(result.stderr).toContain('--claude-config-source cannot be combined with --provider codex') // 'claude' is allowed (a config scopes Claude usage). const okClaude = runCli([ 'status', '--format', 'menubar-json', '--period', 'all', '--provider', 'claude', '--no-optimize', ], home, env) expect(okClaude.status, `stderr: ${okClaude.stderr}`).toBe(0) } finally { await rm(home, { recursive: true, force: true }) } }) it('surfaces Claude Desktop sessions as their own bucket so config sum equals All', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-desktop-')) try { const work = join(home, 'claude-work') const personal = join(home, 'claude-personal') await mkdir(join(work, 'projects', 'app'), { recursive: true }) await mkdir(join(personal, 'projects', 'app'), { recursive: true }) await writeFile(join(work, 'projects', 'app', 'w.jsonl'), [userLine('w', '2026-04-10T11:59:00Z'), assistantLine('w', '2026-04-10T12:00:00Z', 'mw')].join('\n')) await writeFile(join(personal, 'projects', 'app', 'p.jsonl'), [userLine('p', '2026-04-10T12:59:00Z'), assistantLine('p', '2026-04-10T13:00:00Z', 'mp')].join('\n')) // A fake Claude Desktop sessions tree. const desktop = join(home, 'desktop-sessions') const dProj = join(desktop, 'appid', 'ws', 'local_s1', '.claude', 'projects', 'space') await mkdir(dProj, { recursive: true }) await writeFile(join(dProj, 'd.jsonl'), [userLine('d', '2026-04-10T13:59:00Z'), assistantLine('d', '2026-04-10T14:00:00Z', 'md')].join('\n')) const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter), CODEBURN_DESKTOP_SESSIONS_DIR: desktop, } const result = runCli([ 'status', '--format', 'menubar-json', '--period', 'all', '--provider', 'all', '--no-optimize', ], home, env) expect(result.status, `stderr: ${result.stderr}`).toBe(0) const payload = JSON.parse(result.stdout) as { current: { calls: number } claudeConfigs?: { options: Array<{ id: string; label: string }> } } // 3 sessions total: work + personal + desktop. expect(payload.current.calls).toBe(3) // The selector lists all three, including a Claude Desktop bucket. const labels = payload.claudeConfigs?.options.map(o => o.label).sort() expect(labels).toContain('Claude Desktop') expect(labels).toHaveLength(3) } finally { await rm(home, { recursive: true, force: true }) } }) it('includes LingTai TUI usage and activity categories in menubar payloads', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-lingtai-')) try { const lingtaiHome = join(home, '.lingtai') const agentDir = join(lingtaiHome, 'agent') await mkdir(join(agentDir, 'logs'), { recursive: true }) await writeFile(join(agentDir, '.agent.json'), JSON.stringify({ agent_id: 'agent-1', agent_name: 'LingTai Agent', llm: { model: 'gpt-4o' }, })) const now = new Date() const h = now.getUTCHours() const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000) const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z') await writeFile( join(agentDir, 'logs', 'token_ledger.jsonl'), [ JSON.stringify({ source: 'main', ts: ts1, input: 1000, cached: 100, output: 100, model: 'gpt-4o' }), JSON.stringify({ source: 'tc_wake', ts: ts2, input: 2000, cached: 500, output: 200, model: 'gpt-4o' }), JSON.stringify({ source: 'summarize_apriori', ts: ts3, input: 1500, cached: 300, output: 150, model: 'gpt-4o' }), ].join('\n') + '\n', ) const result = runCli([ 'status', '--format', 'menubar-json', '--period', 'today', '--provider', 'lingtai-tui', '--no-optimize', ], home, { LINGTAI_HOME: lingtaiHome, LINGTAI_TUI_GLOBAL_DIR: join(home, '.lingtai-tui'), }) expect(result.status, `stderr: ${result.stderr}`).toBe(0) const payload = JSON.parse(result.stdout) as { current: { cost: number calls: number providers: Record topActivities: Array<{ name: string; turns: number }> } } expect(payload.current.cost).toBeGreaterThan(0) expect(payload.current.calls).toBe(3) expect(payload.current.providers['lingtai tui']).toBeGreaterThan(0) expect(payload.current.topActivities.map(a => a.name).sort()).toEqual([ 'Conversation', 'Delegation', 'Planning', ]) } finally { await rm(home, { recursive: true, force: true }) } }) it('attaches combined local-only usage for --scope combined and omits it for local scope', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-')) try { const projectDir = join(home, '.claude', 'projects', 'myapp') await mkdir(projectDir, { recursive: true }) const now = new Date() const h = now.getUTCHours() const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000) const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') await writeFile( join(projectDir, 'session.jsonl'), [ userLine('s1', ts1), assistantLine('s1', ts2, 'msg-1'), ].join('\n'), ) const combinedResult = runCli([ 'status', '--format', 'menubar-json', '--scope', 'combined', '--period', 'today', '--provider', 'all', '--no-optimize', ], home) expect(combinedResult.status, `stderr: ${combinedResult.stderr}`).toBe(0) const payload = JSON.parse(combinedResult.stdout) as { current: { cost: number calls: number sessions: number inputTokens: number outputTokens: number } history: { daily: Array<{ cacheWriteTokens?: number; cacheReadTokens?: number }> } combined?: { perDevice: Array<{ id: string local: boolean cost: number calls: number sessions: number inputTokens: number outputTokens: number cacheCreateTokens: number cacheReadTokens: number totalTokens: number }> combined: { cost: number calls: number sessions: number inputTokens: number outputTokens: number cacheCreateTokens: number cacheReadTokens: number totalTokens: number deviceCount: number reachableCount: number } } } expect(payload.combined).toBeDefined() expect(payload.combined!.perDevice).toHaveLength(1) const local = payload.combined!.perDevice[0]! const cacheCreateTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheWriteTokens ?? 0), 0) const cacheReadTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheReadTokens ?? 0), 0) const totalTokens = payload.current.inputTokens + payload.current.outputTokens + cacheCreateTokens + cacheReadTokens expect(local).toMatchObject({ id: 'local', local: true, cost: payload.current.cost, calls: payload.current.calls, sessions: payload.current.sessions, inputTokens: payload.current.inputTokens, outputTokens: payload.current.outputTokens, cacheCreateTokens, cacheReadTokens, totalTokens, }) expect(payload.combined!.combined).toEqual({ cost: payload.current.cost, calls: payload.current.calls, sessions: payload.current.sessions, inputTokens: payload.current.inputTokens, outputTokens: payload.current.outputTokens, cacheCreateTokens, cacheReadTokens, totalTokens, deviceCount: 1, reachableCount: 1, }) const localResult = runCli([ 'status', '--format', 'menubar-json', '--scope', 'local', '--period', 'today', '--provider', 'all', '--no-optimize', ], home) expect(localResult.status, `stderr: ${localResult.stderr}`).toBe(0) expect(JSON.parse(localResult.stdout)).not.toHaveProperty('combined') } finally { await rm(home, { recursive: true, force: true }) } }) it.each([ ['--provider', ['--provider', 'claude']], ['--project', ['--project', 'x']], ['--exclude', ['--exclude', 'y']], ])('rejects combined scope with filtered local payloads from %s', async (_name, filterArgs) => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-filter-')) try { const result = runCli([ 'status', '--format', 'menubar-json', '--scope', 'combined', ...filterArgs, '--no-optimize', ], home) expect(result.status).toBe(1) expect(result.stderr).toContain('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)') } finally { await rm(home, { recursive: true, force: true }) } }) it('rejects invalid menubar-json scope values', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-scope-')) try { const result = runCli([ 'status', '--format', 'menubar-json', '--scope', 'remote', '--no-optimize', ], home) expect(result.status).toBe(1) expect(result.stderr).toContain('unknown scope "remote"') } finally { await rm(home, { recursive: true, force: true }) } }) it('still emits a valid combined menubar payload when the remotes store is corrupt', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-corrupt-remotes-')) try { const projectDir = join(home, '.claude', 'projects', 'myapp') await mkdir(projectDir, { recursive: true }) const now = new Date() const h = now.getUTCHours() const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000) const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') await writeFile(join(projectDir, 'session.jsonl'), [userLine('s1', ts1), assistantLine('s1', ts2, 'msg-1')].join('\n')) // Corrupt the remotes store the combined path reads. The menubar must // still get a valid payload (combined degrades to local-only). const sharingDir = join(home, '.config', 'codeburn', 'sharing') await mkdir(sharingDir, { recursive: true }) await writeFile(join(sharingDir, 'remote-devices.json'), '{ this is : not valid json ]') const result = runCli([ 'status', '--format', 'menubar-json', '--scope', 'combined', '--period', 'today', '--no-optimize', ], home) expect(result.status, `stderr: ${result.stderr}`).toBe(0) const payload = JSON.parse(result.stdout) as { current: { cost: number } combined?: { perDevice: unknown[]; combined: { deviceCount: number; reachableCount: number } } } expect(payload.combined).toBeDefined() expect(payload.combined!.perDevice).toHaveLength(1) expect(payload.combined!.combined.deviceCount).toBe(1) expect(payload.combined!.combined.reachableCount).toBe(1) } finally { await rm(home, { recursive: true, force: true }) } }) })