mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
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:
parent
d1eb13fb91
commit
b72e51e538
5 changed files with 367 additions and 13 deletions
15
CHANGELOG.md
15
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
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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[]) {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
||||
|
|
|
|||
262
tests/providers/claude-config-dirs.test.ts
Normal file
262
tests/providers/claude-config-dirs.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue