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 { clearSessionCache, filterProjectsByClaudeConfigSource, 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 () => { clearSessionCache() 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 () => { clearSessionCache() 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'])) expect(new Set(ourSources.map(s => s.sourceKind))).toEqual(new Set(['claude-config'])) expect(new Set(ourSources.map(s => s.sourceLabel))).toEqual(new Set(['claude-work', 'claude-personal'])) expect(ourSources.every(s => typeof s.sourceId === 'string' && s.sourceId.startsWith('claude-config:'))).toBe(true) }) 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 provider — config.json claudeConfigDirs (menubar-driven)', () => { async function writeConfigJson(value: unknown): Promise { const dir = join(process.env['HOME']!, '.config', 'codeburn') await mkdir(dir, { recursive: true }) await writeFile(join(dir, 'config.json'), JSON.stringify({ claudeConfigDirs: value })) } it('honors claudeConfigDirs from config.json when no env var is set', async () => { const work = await makeConfigDir('claude-work', ['-Users-you-app']) const personal = await makeConfigDir('claude-personal', ['-Users-you-app']) await writeConfigJson([work, personal]) 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')) }) it('lets env CLAUDE_CONFIG_DIRS override config.json', async () => { const fromEnv = await makeConfigDir('claude-env', ['-Users-you-app']) const fromFile = await makeConfigDir('claude-file', ['-Users-you-app']) await writeConfigJson([fromFile]) process.env['CLAUDE_CONFIG_DIRS'] = fromEnv const sources = await claude.discoverSessions() expect(sources.some(s => s.path === join(fromEnv, 'projects', '-Users-you-app'))).toBe(true) expect(sources.every(s => !s.path.startsWith(fromFile))).toBe(true) }) it('lets env CLAUDE_CONFIG_DIR override config.json', async () => { const fromEnv = await makeConfigDir('claude-env', ['-Users-you-app']) const fromFile = await makeConfigDir('claude-file', ['-Users-you-app']) await writeConfigJson([fromFile]) process.env['CLAUDE_CONFIG_DIR'] = fromEnv const sources = await claude.discoverSessions() expect(sources.some(s => s.path === join(fromEnv, 'projects', '-Users-you-app'))).toBe(true) expect(sources.every(s => !s.path.startsWith(fromFile))).toBe(true) }) it('falls back to ~/.claude when config.json claudeConfigDirs is empty', async () => { const homeDir = process.env['HOME']! await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true }) await writeConfigJson([]) const sources = await claude.discoverSessions() expect(sources.some(s => s.path === join(homeDir, '.claude', 'projects', '-Users-you-app'))).toBe(true) }) it('expands ~ in config.json entries', async () => { const homeDir = process.env['HOME']! await mkdir(join(homeDir, 'cfg-claude', 'projects', '-Users-you-app'), { recursive: true }) await writeConfigJson(['~/cfg-claude']) const sources = await claude.discoverSessions() expect(sources.some(s => s.path === join(homeDir, 'cfg-claude', 'projects', '-Users-you-app'))).toBe(true) }) it('ignores non-string and blank entries in config.json', async () => { const real = await makeConfigDir('claude-real', ['-Users-you-app']) await writeConfigJson([real, 42, '', ' ', null]) const sources = await claude.discoverSessions() expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true) }) it('falls back to ~/.claude when claudeConfigDirs is not an array', async () => { const homeDir = process.env['HOME']! await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true }) await writeConfigJson('not-an-array') const sources = await claude.discoverSessions() expect(sources.some(s => s.path === join(homeDir, '.claude', 'projects', '-Users-you-app'))).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() }) it('keeps source metadata on merged sessions so one Claude config can be selected', 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-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 merged = projects.find(p => p.project === slug) expect(merged).toBeDefined() expect(merged!.sessions).toHaveLength(2) const sourceIds = new Map(merged!.sessions.map(s => [s.source?.label, s.source?.id])) const workSourceId = sourceIds.get('claude-work') expect(workSourceId).toBeDefined() const workOnly = filterProjectsByClaudeConfigSource(projects, workSourceId!) expect(workOnly).toHaveLength(1) expect(workOnly[0]!.project).toBe(slug) expect(workOnly[0]!.totalApiCalls).toBe(1) expect(workOnly[0]!.sessions.map(s => s.sessionId)).toEqual(['sess-work']) expect(workOnly[0]!.sessions[0]!.source?.label).toBe('claude-work') }) // Documents the path-aware merge behavior: the mergedMap in parseAllSessions // now keys by normalized cwd path (crossProviderKey), not by slug. Two dirs // can share the same slug but have different underlying cwds — those stay // separate because they represent genuinely different repositories. In real // Claude usage different cwds always produce different slugs anyway, so this // scenario is contrived, but the test pins the new behavior explicitly. it('keeps sessions with the same slug but different cwds as separate projects', 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) // Different cwds → different crossProviderKey → two separate project rows. expect(matches).toHaveLength(2) expect(matches.every(m => m.totalApiCalls === 1)).toBe(true) }) })