diff --git a/.gitignore b/.gitignore index bc8b930..4825295 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ npm-debug.log* # Build artifacts *.tsbuildinfo +.worktrees/ diff --git a/README.md b/README.md index f18b330..0555ef6 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npx codeburn ### Requirements - Node.js 20+ -- Claude Code (`~/.claude/projects/`) and/or Codex (`~/.codex/sessions/`) +- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), and/or Gemini CLI (`~/.gemini/`) ## Usage @@ -58,13 +58,14 @@ Arrow keys switch between Today / 7 Days / 30 Days / Month. Press `q` to quit, ` ## Providers -CodeBurn auto-detects which AI coding tools you use. If both Claude Code and Codex have session data on disk, press `p` in the dashboard to toggle between them. +CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them. ```bash codeburn report # all providers combined (default) codeburn report --provider claude # Claude Code only codeburn report --provider codex # Codex only -codeburn today --provider codex # Codex today +codeburn report --provider gemini # Gemini only +codeburn today --provider gemini # Gemini today codeburn export --provider claude # export Claude data only ``` @@ -77,6 +78,7 @@ The `--provider` flag works on all commands: `report`, `today`, `month`, `status | Claude Code | `~/.claude/projects/` | Supported | | Claude Desktop | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | Supported | | Codex (OpenAI) | `~/.codex/sessions/` | Supported | +| Gemini CLI | `~/.gemini/tmp//chats/` | Supported | | Pi, OpenCode, Amp | -- | Planned (provider plugin system) | Codex tool names are normalized to match Claude's conventions (`exec_command` shows as `Bash`, `read_file` as `Read`, etc.) so the activity classifier and tool breakdown work across providers. @@ -186,3 +188,6 @@ MIT Inspired by [ccusage](https://github.com/ryoppippi/ccusage). Pricing data from [LiteLLM](https://github.com/BerriAI/litellm). Exchange rates from [Frankfurter](https://www.frankfurter.app/). Built by [AgentSeal](https://agentseal.org). +BerriAI/litellm). Exchange rates from [Frankfurter](https://www.frankfurter.app/). + +Built by [AgentSeal](https://agentseal.org). diff --git a/src/cli.ts b/src/cli.ts index fd81791..68d5cc7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -70,7 +70,7 @@ program .command('report', { isDefault: true }) .description('Interactive usage dashboard') .option('-p, --period ', 'Starting period: today, week, month, 30days', 'week') - .option('--provider ', 'Filter by provider: all, claude, codex', 'all') + .option('--provider ', 'Filter by provider: all, claude, codex, gemini', 'all') .option('--refresh ', 'Auto-refresh interval in seconds', parseInt) .action(async (opts) => { await renderDashboard(toPeriod(opts.period), opts.provider, opts.refresh) @@ -119,7 +119,7 @@ program .command('status') .description('Compact status output (today + week + month)') .option('--format ', 'Output format: terminal, menubar, json', 'terminal') - .option('--provider ', 'Filter by provider: all, claude, codex', 'all') + .option('--provider ', 'Filter by provider: all, claude, codex, gemini', 'all') .action(async (opts) => { await loadPricing() const pf = opts.provider @@ -157,7 +157,7 @@ program program .command('today') .description('Today\'s usage dashboard') - .option('--provider ', 'Filter by provider: all, claude, codex', 'all') + .option('--provider ', 'Filter by provider: all, claude, codex, gemini', 'all') .option('--refresh ', 'Auto-refresh interval in seconds', parseInt) .action(async (opts) => { await renderDashboard('today', opts.provider, opts.refresh) @@ -166,7 +166,7 @@ program program .command('month') .description('This month\'s usage dashboard') - .option('--provider ', 'Filter by provider: all, claude, codex', 'all') + .option('--provider ', 'Filter by provider: all, claude, codex, gemini', 'all') .option('--refresh ', 'Auto-refresh interval in seconds', parseInt) .action(async (opts) => { await renderDashboard('month', opts.provider, opts.refresh) @@ -177,7 +177,7 @@ program .description('Export usage data to CSV or JSON (includes 1 day, 7 days, 30 days)') .option('-f, --format ', 'Export format: csv, json', 'csv') .option('-o, --output ', 'Output file path') - .option('--provider ', 'Filter by provider: all, claude, codex', 'all') + .option('--provider ', 'Filter by provider: all, claude, codex, gemini', 'all') .action(async (opts) => { await loadPricing() const pf = opts.provider diff --git a/src/models.ts b/src/models.ts index 1af8859..c1e9b51 100644 --- a/src/models.ts +++ b/src/models.ts @@ -38,6 +38,10 @@ const FALLBACK_PRICING: Record = { 'gpt-4o': { inputCostPerToken: 2.5e-6, outputCostPerToken: 10e-6, cacheWriteCostPerToken: 2.5e-6, cacheReadCostPerToken: 1.25e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, 'gpt-4o-mini': { inputCostPerToken: 0.15e-6, outputCostPerToken: 0.6e-6, cacheWriteCostPerToken: 0.15e-6, cacheReadCostPerToken: 0.075e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, 'gemini-2.5-pro': { inputCostPerToken: 1.25e-6, outputCostPerToken: 10e-6, cacheWriteCostPerToken: 1.25e-6, cacheReadCostPerToken: 0.315e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, + 'gemini-2.0-flash': { inputCostPerToken: 0.1e-6, outputCostPerToken: 0.4e-6, cacheWriteCostPerToken: 0.125e-6, cacheReadCostPerToken: 0.025e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, + 'gemini-2.0-pro': { inputCostPerToken: 1.25e-6, outputCostPerToken: 5e-6, cacheWriteCostPerToken: 1.56e-6, cacheReadCostPerToken: 0.31e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, + 'gemini-3-flash': { inputCostPerToken: 0.1e-6, outputCostPerToken: 0.4e-6, cacheWriteCostPerToken: 0.125e-6, cacheReadCostPerToken: 0.025e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, + 'gemini-3-pro': { inputCostPerToken: 1.25e-6, outputCostPerToken: 5e-6, cacheWriteCostPerToken: 1.56e-6, cacheReadCostPerToken: 0.31e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, 'gpt-5.3-codex': { inputCostPerToken: 2.5e-6, outputCostPerToken: 10e-6, cacheWriteCostPerToken: 2.5e-6, cacheReadCostPerToken: 1.25e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, 'gpt-5.4': { inputCostPerToken: 2.5e-6, outputCostPerToken: 10e-6, cacheWriteCostPerToken: 2.5e-6, cacheReadCostPerToken: 1.25e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, 'gpt-5.4-mini': { inputCostPerToken: 0.4e-6, outputCostPerToken: 1.6e-6, cacheWriteCostPerToken: 0.4e-6, cacheReadCostPerToken: 0.2e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, @@ -175,13 +179,17 @@ export function getShortModelName(model: string): string { 'claude-3-5-sonnet': 'Sonnet 3.5', 'claude-haiku-4-5': 'Haiku 4.5', 'claude-3-5-haiku': 'Haiku 3.5', + 'gemini-2.0-flash': 'Gemini 2.0 Flash', + 'gemini-2.0-pro': 'Gemini 2.0 Pro', + 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'gemini-3-flash': 'Gemini 3 Flash', + 'gemini-3-pro': 'Gemini 3 Pro', 'gpt-4o-mini': 'GPT-4o Mini', 'gpt-4o': 'GPT-4o', 'gpt-5.4-mini': 'GPT-5.4 Mini', 'gpt-5.4': 'GPT-5.4', 'gpt-5.3-codex': 'GPT-5.3 Codex', 'gpt-5': 'GPT-5', - 'gemini-2.5-pro': 'Gemini 2.5 Pro', } for (const [key, name] of Object.entries(shortNames)) { if (canonical.startsWith(key)) return name diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts new file mode 100644 index 0000000..71aea5a --- /dev/null +++ b/src/providers/gemini.ts @@ -0,0 +1,159 @@ +import { readdir, readFile, stat } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' + +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import { calculateCost } from '../models.js' + +function getGeminiDir(): string { + return join(homedir(), '.gemini') +} + +type GeminiSession = { + sessionId: string + messages: Array<{ + id: string + timestamp: string + type: 'user' | 'gemini' + content: string | Array<{ text?: string }> + tokens?: { + input: number + output: number + cached: number + thoughts: number + tool: number + total: number + } + model?: string + toolCalls?: Array<{ name: string }> + }> +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + let content: string + try { + content = await readFile(source.path, 'utf-8') + } catch { + return + } + + let session: GeminiSession + try { + session = JSON.parse(content) + } catch { + return + } + + let lastUserMessage = '' + for (const msg of session.messages) { + if (msg.type === 'user') { + lastUserMessage = typeof msg.content === 'string' + ? msg.content + : msg.content.map(c => c.text || '').join(' ') + continue + } + + if (msg.type === 'gemini' && msg.tokens) { + const messageId = msg.id || 'unknown' + const dedupKey = `gemini:${session.sessionId}:${messageId}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const inputTokens = Math.max(0, msg.tokens.input - msg.tokens.cached) + const outputTokens = msg.tokens.output + const reasoningTokens = msg.tokens.thoughts || 0 + const cachedInputTokens = msg.tokens.cached || 0 + const model = msg.model || 'gemini-3-flash' + + const costUSD = calculateCost( + model, + inputTokens, + outputTokens + reasoningTokens, + 0, + cachedInputTokens, + 0 + ) + + yield { + provider: 'gemini', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: cachedInputTokens, + cachedInputTokens, + reasoningTokens, + webSearchRequests: 0, + costUSD, + tools: (msg.toolCalls || []).map(tc => tc.name), + timestamp: msg.timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: lastUserMessage, + sessionId: session.sessionId + } + } + } + } + } +} + +export const gemini: Provider = { + name: 'gemini', + displayName: 'Gemini', + + modelDisplayName(model: string): string { + return model + .replace(/-preview$/, '') + .replace(/^gemini-/, 'Gemini ') + .replace(/-/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()) + .replace(/Gemini /i, 'Gemini ') + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + const geminiDir = getGeminiDir() + const projectsFile = join(geminiDir, 'projects.json') + const sources: SessionSource[] = [] + + let projectsData: { projects: Record } + try { + const content = await readFile(projectsFile, 'utf-8') + projectsData = JSON.parse(content) + } catch { + return sources + } + + for (const [projectPath, projectName] of Object.entries(projectsData.projects)) { + const chatsDir = join(geminiDir, 'tmp', projectName, 'chats') + try { + const files = await readdir(chatsDir) + for (const file of files) { + if (file.endsWith('.json')) { + const filePath = join(chatsDir, file) + const s = await stat(filePath).catch(() => null) + if (s?.isFile()) { + sources.push({ + path: filePath, + project: projectName, + provider: 'gemini' + }) + } + } + } + } catch {} + } + + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 77fed65..d8fb63d 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,8 +1,9 @@ import { claude } from './claude.js' import { codex } from './codex.js' +import { gemini } from './gemini.js' import type { Provider, SessionSource } from './types.js' -export const providers: Provider[] = [claude, codex] +export const providers: Provider[] = [claude, codex, gemini] export async function discoverAllSessions(providerFilter?: string): Promise { const filtered = providerFilter && providerFilter !== 'all' diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 2aacd41..95213bc 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest' import { providers } from '../src/providers/index.js' describe('provider registry', () => { - it('has claude and codex providers', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'codex']) + it('has claude, codex and gemini providers', () => { + expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'gemini']) }) it('claude tool display names are identity', () => { @@ -32,4 +32,10 @@ describe('provider registry', () => { expect(claude.modelDisplayName('claude-opus-4-6-20260205')).toBe('Opus 4.6') expect(claude.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') }) + + it('gemini model display names are human-readable', () => { + const gemini = providers.find(p => p.name === 'gemini')! + expect(gemini.modelDisplayName('gemini-3-flash-preview')).toBe('Gemini 3 Flash') + expect(gemini.modelDisplayName('gemini-2.0-pro')).toBe('Gemini 2.0 Pro') + }) }) diff --git a/tests/providers/gemini.test.ts b/tests/providers/gemini.test.ts new file mode 100644 index 0000000..1846e1c --- /dev/null +++ b/tests/providers/gemini.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from 'vitest' +import { gemini } from '../../src/providers/gemini.js' +import { readFile } from 'fs/promises' + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { + ...actual as any, + readFile: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + } +}) + +describe('Gemini Provider', () => { + it('should parse gemini session correctly', async () => { + const mockSession = { + sessionId: 'test-session', + messages: [ + { + type: 'user', + content: [ + { text: 'hello' } + ] + }, + { + id: 'msg-1', + timestamp: '2026-04-15T12:00:00Z', + type: 'gemini', + model: 'gemini-3-flash-preview', + tokens: { + input: 100, + output: 50, + cached: 20, + thoughts: 10, + tool: 0, + total: 160 + }, + toolCalls: [ + { name: 'read_file' } + ] + } + ] + } + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockSession)) + + const parser = gemini.createSessionParser({ path: 'fake.json', project: 'test', provider: 'gemini' }, new Set()) + const calls = [] + for await (const call of parser.parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0] + expect(call.provider).toBe('gemini') + expect(call.model).toBe('gemini-3-flash-preview') + expect(call.inputTokens).toBe(80) // 100 - 20 + expect(call.outputTokens).toBe(50) + expect(call.reasoningTokens).toBe(10) + expect(call.cachedInputTokens).toBe(20) + expect(call.tools).toEqual(['read_file']) + expect(call.userMessage).toBe('hello') + expect(call.sessionId).toBe('test-session') + }) + + it('should handle multiple messages and deduplication', async () => { + const mockSession = { + sessionId: 'test-session-2', + messages: [ + { type: 'user', content: 'first' }, + { + id: 'msg-1', + timestamp: '2026-04-15T12:00:00Z', + type: 'gemini', + tokens: { input: 10, output: 5, cached: 0, thoughts: 0 }, + }, + { type: 'user', content: 'second' }, + { + id: 'msg-2', + timestamp: '2026-04-15T12:01:00Z', + type: 'gemini', + tokens: { input: 20, output: 10, cached: 0, thoughts: 0 }, + } + ] + } + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockSession)) + + const seenKeys = new Set() + const parser = gemini.createSessionParser({ path: 'fake2.json', project: 'test', provider: 'gemini' }, seenKeys) + const calls = [] + for await (const call of parser.parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(2) + expect(calls[0].userMessage).toBe('first') + expect(calls[1].userMessage).toBe('second') + expect(seenKeys.size).toBe(2) + + // Second parse with same seenKeys should yield nothing + const parser2 = gemini.createSessionParser({ path: 'fake2.json', project: 'test', provider: 'gemini' }, seenKeys) + const calls2 = [] + for await (const call of parser2.parse()) { + calls2.push(call) + } + expect(calls2).toHaveLength(0) + }) +})