diff --git a/docs/providers/crush.md b/docs/providers/crush.md index b2930020..241274da 100644 --- a/docs/providers/crush.md +++ b/docs/providers/crush.md @@ -4,7 +4,7 @@ Charmbracelet's Crush TUI coding agent. - **Source:** `src/providers/crush.ts` - **Loading:** lazy (`src/providers/index.ts`). Lazy because Crush ships per-project SQLite databases and we use `node:sqlite` to read them. -- **Test:** `tests/providers/crush.test.ts` (10 tests, fixture-based) +- **Test:** `tests/providers/crush.test.ts` (12 tests, fixture-based) ## Where it reads from @@ -15,7 +15,7 @@ Crush keeps a global registry that lists every project it has touched, and a sep | Registry (project list) | `$CRUSH_GLOBAL_DATA/projects.json`, otherwise `$XDG_DATA_HOME/crush/projects.json`, otherwise `~/.local/share/crush/projects.json` (Linux/macOS) or `%LOCALAPPDATA%/crush/projects.json` (Windows). | | Per-project db | `//crush.db` where `data_dir` defaults to `.crush`. | -The registry shape is an object keyed by project id (modern Crush) or an array (older builds and tokscale's sample fixtures). The parser accepts both. +Current Crush writes an object with a `projects` array. The parser also accepts a bare array and an object keyed by project id for compatibility. ## Storage format diff --git a/src/providers/crush.ts b/src/providers/crush.ts index 0223dfdc..0d9d3ccc 100644 --- a/src/providers/crush.ts +++ b/src/providers/crush.ts @@ -54,13 +54,14 @@ async function loadRegistry(path: string): Promise { } catch { return [] } - // Crush writes projects.json as an object keyed by project id. Older builds - // (and tokscale's sample fixtures) emit an array. Accept both shapes. let entries: unknown[] if (Array.isArray(parsed)) { entries = parsed } else if (parsed && typeof parsed === 'object') { - entries = Object.values(parsed) + const registry = parsed as Record + // Crush has used a {"projects": [...]} wrapper since the registry began. + // Array and keyed-object forms remain for compatibility with existing users. + entries = Array.isArray(registry['projects']) ? registry['projects'] : Object.values(registry) } else { return [] } diff --git a/tests/providers/crush.test.ts b/tests/providers/crush.test.ts index b86f5358..fab03bb0 100644 --- a/tests/providers/crush.test.ts +++ b/tests/providers/crush.test.ts @@ -116,9 +116,17 @@ function insertMessage(db: TestDb, sessionId: string, role: string, model: strin `).run(id, sessionId, role, model, 1_700_000_000, 1_700_000_000) } -async function writeRegistry(globalDataDir: string, entries: Record): Promise { +type RegistryEntry = { + path: string + data_dir: string + last_accessed?: string +} + +type Registry = RegistryEntry[] | Record | { projects: RegistryEntry[] } + +async function writeRegistry(globalDataDir: string, registry: Registry): Promise { await mkdir(globalDataDir, { recursive: true }) - await writeFile(join(globalDataDir, 'projects.json'), JSON.stringify(entries)) + await writeFile(join(globalDataDir, 'projects.json'), JSON.stringify(registry)) } async function collect(parser: { parse(): AsyncGenerator }): Promise { @@ -270,7 +278,7 @@ describe('crush provider', () => { expect(second).toHaveLength(0) }) - it('accepts an array-shaped projects.json (legacy format)', async () => { + it('accepts an array-shaped projects.json for compatibility', async () => { if (!isSqliteAvailable()) return const projectDir = join(tmpRoot, 'project-e') @@ -280,16 +288,59 @@ describe('crush provider', () => { }) const globalData = join(tmpRoot, 'crush-global') - await mkdir(globalData, { recursive: true }) - await writeFile( - join(globalData, 'projects.json'), - JSON.stringify([{ path: projectDir, data_dir: '.crush' }]), - ) + await writeRegistry(globalData, [{ path: projectDir, data_dir: '.crush' }]) process.env['CRUSH_GLOBAL_DATA'] = globalData const p = createCrushProvider() const sources = await p.discoverSessions() expect(sources).toHaveLength(1) + expect(sources[0]!.path).toBe(`${dbPath}:sess-arr`) + }) + + it('accepts an object keyed by project id for compatibility', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-f') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-keyed', cost: 0.01, promptTokens: 1, completionTokens: 1, createdAt: 1_700_000_900 }) + }) + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + project: { path: projectDir, data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + expect(sources).toHaveLength(1) + expect(sources[0]!.path).toBe(`${dbPath}:sess-keyed`) + }) + + it('accepts the current Crush projects wrapper', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-g') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-wrapped', cost: 0.01, promptTokens: 1, completionTokens: 1, createdAt: 1_700_001_000 }) + }) + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + projects: [{ + path: projectDir, + data_dir: '.crush', + last_accessed: '2025-12-15T12:34:56.789012Z', + }], + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + expect(sources).toHaveLength(1) + expect(sources[0]!.path).toBe(`${dbPath}:sess-wrapped`) }) it('ignores registry entries whose db is missing', async () => {