Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ npm-debug.log*

# Build artifacts
*.tsbuildinfo
.worktrees/
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```

Expand All @@ -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/<project>/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.
Expand Down Expand Up @@ -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).
10 changes: 5 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.option('-p, --period <period>', 'Starting period: today, week, month, 30days', 'week')
.option('--provider <provider>', 'Filter by provider: all, claude, codex', 'all')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, gemini', 'all')
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
await renderDashboard(toPeriod(opts.period), opts.provider, opts.refresh)
Expand Down Expand Up @@ -119,7 +119,7 @@ program
.command('status')
.description('Compact status output (today + week + month)')
.option('--format <format>', 'Output format: terminal, menubar, json', 'terminal')
.option('--provider <provider>', 'Filter by provider: all, claude, codex', 'all')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, gemini', 'all')
.action(async (opts) => {
await loadPricing()
const pf = opts.provider
Expand Down Expand Up @@ -157,7 +157,7 @@ program
program
.command('today')
.description('Today\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider: all, claude, codex', 'all')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, gemini', 'all')
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
await renderDashboard('today', opts.provider, opts.refresh)
Expand All @@ -166,7 +166,7 @@ program
program
.command('month')
.description('This month\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider: all, claude, codex', 'all')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, gemini', 'all')
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
await renderDashboard('month', opts.provider, opts.refresh)
Expand All @@ -177,7 +177,7 @@ program
.description('Export usage data to CSV or JSON (includes 1 day, 7 days, 30 days)')
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
.option('-o, --output <path>', 'Output file path')
.option('--provider <provider>', 'Filter by provider: all, claude, codex', 'all')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, gemini', 'all')
.action(async (opts) => {
await loadPricing()
const pf = opts.provider
Expand Down
10 changes: 9 additions & 1 deletion src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const FALLBACK_PRICING: Record<string, ModelCosts> = {
'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 },
Expand Down Expand Up @@ -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
Expand Down
159 changes: 159 additions & 0 deletions src/providers/gemini.ts
Original file line number Diff line number Diff line change
@@ -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<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
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<SessionSource[]> {
const geminiDir = getGeminiDir()
const projectsFile = join(geminiDir, 'projects.json')
const sources: SessionSource[] = []

let projectsData: { projects: Record<string, string> }
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<string>): SessionParser {
return createParser(source, seenKeys)
}
}
3 changes: 2 additions & 1 deletion src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -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<SessionSource[]> {
const filtered = providerFilter && providerFilter !== 'all'
Expand Down
10 changes: 8 additions & 2 deletions tests/provider-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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')
})
})
Loading