fix(providers): accept Crush's {"projects":[...]} registry shape (#1510)

Crush's project registry has used a ProjectList wrapper since it was introduced, so loadRegistry parsed a real registry as zero entries and reported no usage. Accept the wrapper alongside the array and keyed-object shapes the parser already handles, and correct the registry-format description in the provider docs.
This commit is contained in:
Perry Story 2026-09-23 11:50:42 -04:00 • committed by GitHub
parent 4d0f02a51c
commit 598e77a808
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 65 additions and 13 deletions

View file

@ -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 | `<project.path>/<project.data_dir>/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

View file

@ -54,13 +54,14 @@ async function loadRegistry(path: string): Promise<ProjectEntry[]> {
} 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<string, unknown>
// 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 []
}

View file

@ -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<string, { path: string; data_dir: string }>): Promise<void> {
type RegistryEntry = {
path: string
data_dir: string
last_accessed?: string
}
type Registry = RegistryEntry[] | Record<string, RegistryEntry> | { projects: RegistryEntry[] }
async function writeRegistry(globalDataDir: string, registry: Registry): Promise<void> {
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<ParsedProviderCall> }): Promise<ParsedProviderCall[]> {
@ -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 () => {