From 7363c48c4d8bce0f766f0f8d09228a0e2470948c Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sat, 9 May 2026 22:03:35 -0700 Subject: [PATCH] Support CLAUDE_CONFIG_DIRS for scanning multiple Claude data dirs (#208) 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. --- CHANGELOG.md | 15 ++ README.md | 3 + src/parser.ts | 10 +- src/providers/claude.ts | 90 ++++++- tests/providers/claude-config-dirs.test.ts | 262 +++++++++++++++++++++ 5 files changed, 367 insertions(+), 13 deletions(-) create mode 100644 tests/providers/claude-config-dirs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e472a43a..c1be6fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 19656792..c29ce9fb 100644 --- a/README.md +++ b/README.md @@ -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`) | diff --git a/src/parser.ts b/src/parser.ts index ccde9a57..50fa648d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -623,7 +623,15 @@ const sessionCache = new Map() 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[]) { diff --git a/src/providers/claude.ts b/src/providers/claude.ts index cb8caef8..43c06a5d 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -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 = { '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() + 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 { const sources: SessionSource[] = [] + const seenProjectDirs = new Set() + 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/ 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' }) } diff --git a/tests/providers/claude-config-dirs.test.ts b/tests/providers/claude-config-dirs.test.ts new file mode 100644 index 00000000..a5561adc --- /dev/null +++ b/tests/providers/claude-config-dirs.test.ts @@ -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 { + 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 { + 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)['account']).toBeUndefined() + expect((matches[0]! as Record)['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) + }) +})