Support CLAUDE_CONFIG_DIRS for scanning multiple Claude data dirs (#208) (#288)

Adds an OS-delimited list env var so a user with more than one
Claude account or profile can scan all of them in a single run.
Sessions across every configured dir merge into one ProjectSummary
per project, matching the option-1 design agreed on the issue
thread (no per-account splitting in the data model or the UI).

Format: `CLAUDE_CONFIG_DIRS=~/.claude-work:~/.claude-personal`
on POSIX, `;`-separated on Windows. Precedence is
CLAUDE_CONFIG_DIRS > CLAUDE_CONFIG_DIR > ~/.claude. Empty entries
in the list are skipped, duplicates are deduped on resolved path,
and a missing or unreadable dir does not abort the scan of the
others. If the user explicitly set CLAUDE_CONFIG_DIRS but every
listed entry is unreadable, a one-line stderr hint identifies the
attempted paths and the platform's expected delimiter, so a
Windows user typing the POSIX `:` does not get a silent zero-row
result. `~` is now also expanded in CLAUDE_CONFIG_DIR for
consistency.

Implementation is intentionally narrow: only `claude.ts` changes,
plus a small parser-cache key update so a stale cache from one
config does not bleed into a run with a different config (matters
for the macOS menubar and GNOME extension which run as long-lived
processes). The merge happens for free in
`src/parser.ts:scanProjectDirs`, which keys ProjectSummary entries
by canonical cwd (or the sanitized slug as a fallback). Two
SessionSource entries with the same `project` field land under the
same key and combine their sessions, regardless of which dir they
came from. No new fields on SessionSource / SessionSummary /
ProjectSummary, and no UI changes.

Tests: 12 fixture-based cases covering the unset path (default
~/.claude), single-dir override via CLAUDE_CONFIG_DIR, multi-dir
override via CLAUDE_CONFIG_DIRS, ~ expansion, dedup of repeated
entries, leading/trailing/doubled delimiters, missing dir
tolerated, file-not-directory entry tolerated, empty
CLAUDE_CONFIG_DIRS falls back to single-dir env, and two
parser-level integration tests asserting (a) two sessions from
two dirs sharing one cwd produce one ProjectSummary with combined
totals and no `account`/`accountPath` fields anywhere, and (b)
two sessions sharing a slug but with different canonical cwds
still merge by slug at the project-rollup layer (option 1
behavior pinned so a future refactor cannot quietly swap to
cwd-aware merging without an explicit opt-in).

Supersedes the alternative implementation in #227, which builds
per-account attribution (option 2) instead.
This commit is contained in:
Resham Joshi 2026-05-09 22:04:45 -07:00 committed by GitHub
parent d1eb13fb91
commit b72e51e538
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 367 additions and 13 deletions

View file

@ -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

View file

@ -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
@ -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`) |

View file

@ -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[]) {

View file

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

View file

@ -0,0 +1,262 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { delimiter as pathDelimiter, join } from 'path'
import { tmpdir, homedir } from 'os'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { claude } from '../../src/providers/claude.js'
import { parseAllSessions } from '../../src/parser.js'
let tmpRoot: string
const savedEnv = {
CLAUDE_CONFIG_DIR: process.env['CLAUDE_CONFIG_DIR'],
CLAUDE_CONFIG_DIRS: process.env['CLAUDE_CONFIG_DIRS'],
HOME: process.env['HOME'],
}
beforeEach(async () => {
tmpRoot = await mkdtemp(join(tmpdir(), 'codeburn-claude-multi-'))
// Point HOME at a scratch dir so the default `~/.claude` fallback resolves
// somewhere we control. Without this, a stray `~/.claude` on the test
// machine could leak into discovery.
process.env['HOME'] = join(tmpRoot, 'home')
await mkdir(process.env['HOME'], { recursive: true })
delete process.env['CLAUDE_CONFIG_DIR']
delete process.env['CLAUDE_CONFIG_DIRS']
})
afterEach(async () => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
await rm(tmpRoot, { recursive: true, force: true })
})
async function makeConfigDir(name: string, projectSlugs: string[]): Promise<string> {
const dir = join(tmpRoot, name)
for (const slug of projectSlugs) {
const projectDir = join(dir, 'projects', slug)
await mkdir(projectDir, { recursive: true })
// Discovery only checks for the project subdirectory. A real session
// file is not required; the parser is exercised separately below.
}
return dir
}
async function writeSession(configDir: string, slug: string, sessionId: string, lines: string[]): Promise<void> {
const dir = join(configDir, 'projects', slug)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, `${sessionId}.jsonl`), lines.join('\n'))
}
function summaryLine(sessionId: string, cwd: string): string {
return JSON.stringify({
type: 'summary',
summary: 'test',
leafUuid: 'l',
sessionId,
cwd,
timestamp: '2026-05-09T00:00:00.000Z',
})
}
function userLine(uuid: string, sessionId: string, cwd: string, text: string): string {
return JSON.stringify({
type: 'user',
uuid,
sessionId,
cwd,
timestamp: '2026-05-09T00:00:01.000Z',
message: { role: 'user', content: text },
})
}
function assistantLine(uuid: string, parentUuid: string, sessionId: string, cwd: string): string {
return JSON.stringify({
type: 'assistant',
uuid,
parentUuid,
sessionId,
cwd,
timestamp: '2026-05-09T00:00:02.000Z',
message: {
id: `msg_${uuid}`,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-6',
content: [{ type: 'text', text: 'reply' }],
usage: { input_tokens: 100, output_tokens: 50 },
},
})
}
describe('claude provider — CLAUDE_CONFIG_DIRS discovery', () => {
it('falls back to ~/.claude when no env var is set', async () => {
const homeDir = process.env['HOME']!
await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true })
const sources = await claude.discoverSessions()
const projectDirs = sources.map(s => s.path)
expect(projectDirs).toContain(join(homeDir, '.claude', 'projects', '-Users-you-app'))
})
it('honors CLAUDE_CONFIG_DIR for a single override', async () => {
const dir = await makeConfigDir('claude-work', ['-Users-you-app'])
process.env['CLAUDE_CONFIG_DIR'] = dir
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(dir, 'projects', '-Users-you-app'))).toBe(true)
// The default `~/.claude` should NOT also be scanned when the override is set.
expect(sources.every(s => !s.path.startsWith(join(process.env['HOME']!, '.claude')))).toBe(true)
})
it('CLAUDE_CONFIG_DIRS overrides CLAUDE_CONFIG_DIR and walks every dir in the list', async () => {
const work = await makeConfigDir('claude-work', ['-Users-you-app'])
const personal = await makeConfigDir('claude-personal', ['-Users-you-app'])
const single = await makeConfigDir('claude-other', ['-Users-you-other'])
process.env['CLAUDE_CONFIG_DIR'] = single
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
const sources = await claude.discoverSessions()
const paths = sources.map(s => s.path)
expect(paths).toContain(join(work, 'projects', '-Users-you-app'))
expect(paths).toContain(join(personal, 'projects', '-Users-you-app'))
// CLAUDE_CONFIG_DIR should be ignored once CLAUDE_CONFIG_DIRS is non-empty.
expect(paths.some(p => p.startsWith(single))).toBe(false)
})
it('emits the same project name for the same slug across dirs (so parser merges)', async () => {
const work = await makeConfigDir('claude-work', ['-Users-you-app'])
const personal = await makeConfigDir('claude-personal', ['-Users-you-app'])
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
const sources = await claude.discoverSessions()
const ourSources = sources.filter(s =>
s.path === join(work, 'projects', '-Users-you-app') ||
s.path === join(personal, 'projects', '-Users-you-app'),
)
expect(ourSources).toHaveLength(2)
expect(new Set(ourSources.map(s => s.project))).toEqual(new Set(['-Users-you-app']))
})
it('tolerates a non-existent dir in the list without dropping the real ones', async () => {
const real = await makeConfigDir('claude-real', ['-Users-you-app'])
const fake = join(tmpRoot, 'does-not-exist')
process.env['CLAUDE_CONFIG_DIRS'] = [real, fake].join(pathDelimiter)
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true)
})
it('dedupes when the same dir appears twice in CLAUDE_CONFIG_DIRS', async () => {
const dir = await makeConfigDir('claude-once', ['-Users-you-app'])
process.env['CLAUDE_CONFIG_DIRS'] = [dir, dir].join(pathDelimiter)
const sources = await claude.discoverSessions()
const ourSources = sources.filter(s => s.path === join(dir, 'projects', '-Users-you-app'))
expect(ourSources).toHaveLength(1)
})
it('skips empty entries (leading, trailing, doubled delimiters)', async () => {
const dir = await makeConfigDir('claude-only', ['-Users-you-app'])
process.env['CLAUDE_CONFIG_DIRS'] = `${pathDelimiter}${dir}${pathDelimiter}${pathDelimiter}`
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(dir, 'projects', '-Users-you-app'))).toBe(true)
})
it('expands ~ in CLAUDE_CONFIG_DIR', async () => {
const homeDir = process.env['HOME']!
await mkdir(join(homeDir, 'custom-claude', 'projects', '-Users-you-app'), { recursive: true })
process.env['CLAUDE_CONFIG_DIR'] = '~/custom-claude'
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(homeDir, 'custom-claude', 'projects', '-Users-you-app'))).toBe(true)
})
it('falls back to CLAUDE_CONFIG_DIR when CLAUDE_CONFIG_DIRS is set but empty', async () => {
const single = await makeConfigDir('claude-fallback', ['-Users-you-app'])
process.env['CLAUDE_CONFIG_DIR'] = single
process.env['CLAUDE_CONFIG_DIRS'] = ''
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(single, 'projects', '-Users-you-app'))).toBe(true)
})
it('skips entries that point at a file rather than a directory', async () => {
const real = await makeConfigDir('claude-real', ['-Users-you-app'])
const filePath = join(tmpRoot, 'not-a-dir.txt')
await writeFile(filePath, 'this is not a config dir')
process.env['CLAUDE_CONFIG_DIRS'] = [real, filePath].join(pathDelimiter)
const sources = await claude.discoverSessions()
expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true)
expect(sources.every(s => !s.path.startsWith(filePath))).toBe(true)
})
})
describe('claude parser — multi-dir aggregation (issue #208 option 1)', () => {
it('merges sessions from two config dirs into a single ProjectSummary when the canonical cwd matches', async () => {
const work = await makeConfigDir('claude-work', [])
const personal = await makeConfigDir('claude-personal', [])
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
// Both accounts touch the same real project path. Same cwd -> same merge key.
const slug = '-Users-you-shared-app'
const cwd = '/Users/you/shared-app'
await writeSession(work, slug, 'sess-work', [
summaryLine('sess-work', cwd),
userLine('u1', 'sess-work', cwd, 'hi from work'),
assistantLine('a1', 'u1', 'sess-work', cwd),
])
await writeSession(personal, slug, 'sess-personal', [
summaryLine('sess-personal', cwd),
userLine('u2', 'sess-personal', cwd, 'hi from personal'),
assistantLine('a2', 'u2', 'sess-personal', cwd),
])
const projects = await parseAllSessions(undefined, 'claude')
const matches = projects.filter(p => p.project === slug)
expect(matches).toHaveLength(1)
expect(matches[0]!.totalApiCalls).toBe(2)
// Two sessions, one from each dir, both rolled up.
expect(matches[0]!.sessions.map(s => s.sessionId).sort()).toEqual(['sess-personal', 'sess-work'])
// No `account` or `accountPath` field should appear on the ProjectSummary
// — option 1 explicitly avoids attribution.
expect((matches[0]! as Record<string, unknown>)['account']).toBeUndefined()
expect((matches[0]! as Record<string, unknown>)['accountPath']).toBeUndefined()
})
// Documents the option-1 behavior at the project-merge layer: the final
// mergedMap in parseAllSessions keys by the sanitized project slug. If two
// dirs both contain a slug `-Users-you-app/` whose underlying canonical
// cwds differ, the slug-level merge collapses them into one row. In real
// Claude usage this is unreachable because Claude derives the slug from
// the cwd, so different cwds always produce different slugs. The test
// pins the behavior so a future refactor cannot quietly swap to cwd-aware
// merging without explicitly opting in.
it('merges by sanitized slug even when sessions carry different canonical cwds', async () => {
const work = await makeConfigDir('claude-work', [])
const personal = await makeConfigDir('claude-personal', [])
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
const slug = '-Users-you-app'
await writeSession(work, slug, 'sess-work', [
summaryLine('sess-work', '/Users/you/work-app'),
userLine('u1', 'sess-work', '/Users/you/work-app', 'work'),
assistantLine('a1', 'u1', 'sess-work', '/Users/you/work-app'),
])
await writeSession(personal, slug, 'sess-personal', [
summaryLine('sess-personal', '/Users/you/personal-app'),
userLine('u2', 'sess-personal', '/Users/you/personal-app', 'personal'),
assistantLine('a2', 'u2', 'sess-personal', '/Users/you/personal-app'),
])
const projects = await parseAllSessions(undefined, 'claude')
const matches = projects.filter(p => p.project === slug)
expect(matches).toHaveLength(1)
expect(matches[0]!.totalApiCalls).toBe(2)
})
})