Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
## Unreleased

### Added (CLI)
- **Multiple Claude config directories.** Set `CLAUDE_CONFIG_DIRS` to an
OS-delimited list of paths (`:`-separated on POSIX, `;`-separated on
Windows) to scan more than one Claude data directory in a single run.
Sessions across every configured directory roll up into one project row
per project, so a user with `~/.claude-work` and `~/.claude-personal`
who works on the same repo from both accounts sees one combined row
rather than two split rows. `~` is expanded; missing or unreadable
directories in the list are skipped instead of aborting the scan; if
every listed entry is unreadable a one-line hint is written to stderr
so a misplaced delimiter does not silently produce zero rows.
Precedence: `CLAUDE_CONFIG_DIRS` > `CLAUDE_CONFIG_DIR` > `~/.claude`.
As part of this change `~` and `~/foo` are now also expanded in
`CLAUDE_CONFIG_DIR` (previously the value was passed through verbatim,
which only worked when the shell expanded `~` before exporting).
Closes #208.
- **`codeburn models` command.** Per-model breakdown across all providers,
one row per (provider, model), sorted by cost. Each row carries Input,
Output, Cache Write, Cache Read, Total, and Cost columns plus a Top Task
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ The `--provider` flag filters any command to a single provider: `codeburn report

**Roo Code and KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory and extracts token usage from `api_req_started` entries.

**Claude with multiple config directories.** If you run Claude Code under more than one account or profile (e.g. `~/.claude-work` and `~/.claude-personal`), point `CLAUDE_CONFIG_DIRS` at all of them at once: `CLAUDE_CONFIG_DIRS=~/.claude-work:~/.claude-personal codeburn`. Sessions across every directory are merged into one row per project so the totals reflect all your Claude usage in one place. Use `:` on POSIX, `;` on Windows. Missing or unreadable directories in the list are skipped.

Adding a new provider is a single file. See `src/providers/codex.ts` for an example.

## Features
Expand Down Expand Up @@ -385,6 +387,7 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke
| Variable | Description |
|----------|-------------|
| `CLAUDE_CONFIG_DIR` | Override Claude Code data directory (default: `~/.claude`) |
| `CLAUDE_CONFIG_DIRS` | OS-delimited list of Claude data directories to scan together (e.g. `~/.claude-work:~/.claude-personal`). Sessions merge into one row per project. Overrides `CLAUDE_CONFIG_DIR` when set. |
| `CODEX_HOME` | Override Codex data directory (default: `~/.codex`) |
| `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) |
| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) |
Expand Down
10 changes: 9 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,15 @@ const sessionCache = new Map<string, { data: ProjectSummary[]; ts: number }>()

function cacheKey(dateRange?: DateRange, providerFilter?: string): string {
const s = dateRange ? `${dateRange.start.getTime()}:${dateRange.end.getTime()}` : 'none'
return `${s}:${providerFilter ?? 'all'}`
// Include the Claude config-dir env so a config change in a long-lived
// process (menubar / GNOME extension / test workers) does not return
// stale data keyed under a previous configuration.
const claudeEnv = (process.env['CLAUDE_CONFIG_DIRS'] ?? '') + '|' + (process.env['CLAUDE_CONFIG_DIR'] ?? '')
return `${s}:${providerFilter ?? 'all'}:${claudeEnv}`
}

export function clearSessionCache(): void {
sessionCache.clear()
}

function cachePut(key: string, data: ProjectSummary[]) {
Expand Down
90 changes: 78 additions & 12 deletions src/providers/claude.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { basename, delimiter as pathDelimiter, join, resolve } from 'path'
import { homedir } from 'os'

import type { Provider, SessionSource, SessionParser } from './types.js'
Expand All @@ -19,12 +19,42 @@ const shortNames: Record<string, string> = {
'claude-3-5-haiku': 'Haiku 3.5',
}

function getClaudeDir(): string {
return process.env['CLAUDE_CONFIG_DIR'] || join(homedir(), '.claude')
function expandHome(p: string): string {
if (p === '~') return homedir()
if (p.startsWith('~/') || p.startsWith('~\\')) return join(homedir(), p.slice(2))
return p
}

function getProjectsDir(): string {
return join(getClaudeDir(), 'projects')
/// Returns every Claude config dir to scan, in priority order with duplicates
/// removed (resolved-path equality). Precedence: `CLAUDE_CONFIG_DIRS` (a
/// `path.delimiter`-separated list, ":" on POSIX, ";" on Windows), then
/// `CLAUDE_CONFIG_DIR` (single dir), then `~/.claude`. Sessions from every
/// returned dir are merged into one ProjectSummary per project name in
/// `src/parser.ts:scanProjectDirs`, so two dirs holding the same sanitized
/// project slug naturally aggregate (issue #208 option 1).
function getClaudeConfigDirs(): string[] {
const multi = process.env['CLAUDE_CONFIG_DIRS']
if (multi !== undefined && multi !== '') {
const dirs = multi
.split(pathDelimiter)
.map(s => s.trim())
.filter(s => s.length > 0)
.map(s => resolve(expandHome(s)))
if (dirs.length > 0) {
const seen = new Set<string>()
const out: string[] = []
for (const d of dirs) {
if (!seen.has(d)) {
seen.add(d)
out.push(d)
}
}
return out
}
}
const single = process.env['CLAUDE_CONFIG_DIR']
if (single !== undefined && single !== '') return [resolve(expandHome(single))]
return [join(homedir(), '.claude')]
}

function getDesktopSessionsDir(): string {
Expand Down Expand Up @@ -77,21 +107,57 @@ export const claude: Provider = {

async discoverSessions(): Promise<SessionSource[]> {
const sources: SessionSource[] = []
const seenProjectDirs = new Set<string>()
const configDirs = getClaudeConfigDirs()
let anyDirReadable = false

const projectsDir = getProjectsDir()
try {
const entries = await readdir(projectsDir)
for (const claudeDir of configDirs) {
const projectsDir = join(claudeDir, 'projects')
let entries: string[]
try {
entries = await readdir(projectsDir)
anyDirReadable = true
} catch {
// Missing or unreadable dir is not fatal: a user can configure both
// a real and a stale path in CLAUDE_CONFIG_DIRS without breaking.
continue
}
for (const dirName of entries) {
const dirPath = join(projectsDir, dirName)
// Resolve before deduping so two CLAUDE_CONFIG_DIRS entries that
// reach the same projects/<slug> directory (via symlinks or
// overlapping configs) emit only one SessionSource.
const resolved = resolve(dirPath)
if (seenProjectDirs.has(resolved)) continue
const dirStat = await stat(dirPath).catch(() => null)
if (dirStat?.isDirectory()) {
sources.push({ path: dirPath, project: dirName, provider: 'claude' })
}
if (!dirStat?.isDirectory()) continue
seenProjectDirs.add(resolved)
// `project: dirName` is identical across config dirs for the same
// sanitized slug, which is exactly what makes the parser merge
// their sessions into a single ProjectSummary.
sources.push({ path: dirPath, project: dirName, provider: 'claude' })
}
} catch {}
}

// If the user explicitly set CLAUDE_CONFIG_DIRS and every entry was
// unreadable, emit a one-line stderr hint. Catches the most common
// misconfiguration: a Windows user typing `:` (POSIX delimiter) when
// the platform expects `;`, which produces a single bogus path that
// silently resolves to nothing on disk.
const explicitMulti = process.env['CLAUDE_CONFIG_DIRS']
if (!anyDirReadable && explicitMulti !== undefined && explicitMulti !== '' && configDirs.length > 0) {
process.stderr.write(
`codeburn: CLAUDE_CONFIG_DIRS was set but no listed directory could be read. ` +
`Tried: ${configDirs.join(', ')}. ` +
`Use "${pathDelimiter}" as the separator on this platform.\n`,
)
}

const desktopDirs = await findDesktopProjectDirs(getDesktopSessionsDir())
for (const dirPath of desktopDirs) {
const resolved = resolve(dirPath)
if (seenProjectDirs.has(resolved)) continue
seenProjectDirs.add(resolved)
sources.push({ path: dirPath, project: basename(dirPath), provider: 'claude' })
}

Expand Down
Loading
Loading