diff --git a/src/core/parser-cursor.test.ts b/src/core/parser-cursor.test.ts new file mode 100644 index 0000000..fc24e22 --- /dev/null +++ b/src/core/parser-cursor.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fs from "fs"; +import * as cp from "child_process"; + +vi.mock("fs"); +vi.mock("child_process"); + +// Must mock before importing the module under test +const mockExecSync = vi.mocked(cp.execSync); +const mockReaddirSync = vi.mocked(fs.readdirSync); +const mockExistsSync = vi.mocked(fs.existsSync); +const mockStatSync = vi.mocked(fs.statSync); + +describe("parseCursorSessions", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Simulate a valid storage root + mockStatSync.mockReturnValue({ isDirectory: () => true } as fs.Stats); + mockReaddirSync.mockReturnValue(["abc123"] as unknown as fs.Dirent[]); + mockExistsSync.mockReturnValue(true); + }); + + it("parses a session with user+assistant bubbles", async () => { + const composerMeta = JSON.stringify({ + composerId: "comp-1", + name: "My Session", + createdAt: 1700000000000, + lastUpdatedAt: 1700001000000, + selectedModel: "claude-3-5-sonnet", + }); + const userBubble = JSON.stringify({ bubbleId: "b1", type: 1, text: "Hello?" }); + const aiBubble = JSON.stringify({ bubbleId: "b2", type: 2, text: "Hi there!", timingMs: 800, codeBlocks: [] }); + + mockExecSync + .mockReturnValueOnce(`composerData:comp-1\x1f${composerMeta}\n` as unknown as Buffer) + .mockReturnValueOnce(`bubbleId:comp-1:b1\x1f${userBubble}\nbubbleId:comp-1:b2\x1f${aiBubble}\n` as unknown as Buffer); + + const { parseCursorSessions } = await import("./parser-cursor.js"); + const sessions = await parseCursorSessions(); + + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("cursor"); + expect(sessions[0].model).toBe("claude-3-5-sonnet"); + expect(sessions[0].messages).toHaveLength(1); + expect(sessions[0].messages[0].prompt).toBe("Hello?"); + expect(sessions[0].messages[0].response).toBe("Hi there!"); + expect(sessions[0].messages[0].durationMs).toBe(800); + }); + + it("returns empty array when no databases found", async () => { + mockStatSync.mockImplementation(() => { throw new Error("ENOENT"); }); + const { parseCursorSessions } = await import("./parser-cursor.js"); + const sessions = await parseCursorSessions(); + expect(sessions).toHaveLength(0); + }); + + it("skips composers with no bubbles", async () => { + const composerMeta = JSON.stringify({ composerId: "comp-empty", name: "Empty" }); + mockExecSync + .mockReturnValueOnce(`composerData:comp-empty\x1f${composerMeta}\n` as unknown as Buffer) + .mockReturnValueOnce("" as unknown as Buffer); + + const { parseCursorSessions } = await import("./parser-cursor.js"); + const sessions = await parseCursorSessions(); + expect(sessions).toHaveLength(0); + }); + + it("returns empty array when sqlite3 is not available", async () => { + const composerMeta = JSON.stringify({ composerId: "comp-1", name: "Test" }); + mockExecSync + .mockReturnValueOnce(`composerData:comp-1\x1f${composerMeta}\n` as unknown as Buffer) + .mockImplementationOnce(() => { throw new Error("sqlite3: command not found"); }); + + const { parseCursorSessions } = await import("./parser-cursor.js"); + const sessions = await parseCursorSessions(); + expect(sessions).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/src/core/parser-cursor.ts b/src/core/parser-cursor.ts new file mode 100644 index 0000000..3968e05 --- /dev/null +++ b/src/core/parser-cursor.ts @@ -0,0 +1,242 @@ +import * as fs from "fs"; +import * as path from "path"; +import { execSync } from "child_process"; +import { Session, Message } from "./session.js"; +import { assertTrustedPath } from "./parser-shared.js"; + +// --------------------------------------------------------------------------- +// Path resolution +// --------------------------------------------------------------------------- + +/** + * Cursor stores ALL chat data in a single global SQLite database. + * Per-workspace `state.vscdb` files only contain UI state, not conversations. + */ +function getCursorGlobalDb(): string | null { + let base: string; + + if (process.platform === "win32") { + base = process.env.APPDATA ?? ""; + if (!base) return null; + return path.join(base, "Cursor", "User", "globalStorage", "state.vscdb"); + } else if (process.platform === "darwin") { + base = process.env.HOME ?? ""; + if (!base) return null; + return path.join(base, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb"); + } else { + // Linux / WSL + base = process.env.HOME ?? ""; + const linuxPath = base ? path.join(base, ".config", "Cursor", "User", "globalStorage", "state.vscdb") : null; + if (linuxPath && fs.existsSync(linuxPath)) return linuxPath; + // WSL: try Windows path + const wslUser = process.env.USER ?? ""; + if (wslUser) { + const wslPath = `/mnt/c/Users/${wslUser}/AppData/Roaming/Cursor/User/globalStorage/state.vscdb`; + if (fs.existsSync(wslPath)) return wslPath; + } + return linuxPath; + } +} + +// --------------------------------------------------------------------------- +// SQLite helpers +// --------------------------------------------------------------------------- + +function runSqlite(dbPath: string, sql: string): string { + try { + return execSync(`sqlite3 "${dbPath}" ${JSON.stringify(sql)}`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + timeout: 30_000, + }).trim(); + } catch { + return ""; + } +} + +function queryRows(dbPath: string, sql: string): string[] { + const raw = runSqlite(dbPath, sql); + return raw ? raw.split("\n").filter(Boolean) : []; +} + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + +interface ComposerMeta { + composerId: string; + name?: string; + createdAt?: number; + lastUpdatedAt?: number; + model?: string; + totalLinesAdded?: number; + totalLinesRemoved?: number; + subtitle?: string; +} + +interface LexicalNode { + text?: string; + children?: LexicalNode[]; +} + +// --------------------------------------------------------------------------- +// Parsers +// --------------------------------------------------------------------------- + +function parseComposerMeta(value: string): ComposerMeta | null { + try { + const d = JSON.parse(value); + if (!d.composerId) return null; + return { + composerId: d.composerId, + name: d.name || undefined, + createdAt: d.createdAt || undefined, + lastUpdatedAt: d.lastUpdatedAt || undefined, + model: d.modelConfig?.model || d.model || undefined, + totalLinesAdded: d.totalLinesAdded || 0, + totalLinesRemoved: d.totalLinesRemoved || 0, + subtitle: d.subtitle || undefined, + }; + } catch { + return null; + } +} + +/** Extract plain text from a Lexical editor JSON tree */ +function extractLexicalText(nodeJson: string | undefined): string { + if (!nodeJson) return ""; + try { + const root: LexicalNode = JSON.parse(nodeJson); + const collect = (node: LexicalNode): string => { + let t = node.text ?? ""; + for (const child of node.children ?? []) t += collect(child); + return t; + }; + return collect((root as { root?: LexicalNode }).root ?? root).trim(); + } catch { + return ""; + } +} + +interface ParsedBubble { + type: 1 | 2; + text: string; + createdAt?: string; + linesAdded: number; +} + +function parseBubble(value: string): ParsedBubble | null { + try { + const d = JSON.parse(value); + const type: 1 | 2 = d.type === 1 ? 1 : 2; + + let text = ""; + if (type === 1) { + // User bubble: extract from Lexical richText + text = extractLexicalText(d.richText) || d.text || ""; + } else { + // Assistant bubble: full response text is not persisted; use thinking as proxy + text = d.thinking?.text || d.text || ""; + } + + // Count lines added from suggested diffs + let linesAdded = 0; + for (const diff of d.assistantSuggestedDiffs ?? []) { + const content: string = diff.newFileContent ?? diff.content ?? ""; + if (content) linesAdded += content.split("\n").length; + } + for (const block of d.suggestedCodeBlocks ?? []) { + const content: string = block.content ?? ""; + if (content) linesAdded += content.split("\n").length; + } + + return { type, text, createdAt: d.createdAt, linesAdded }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Main parser +// --------------------------------------------------------------------------- + +export async function parseCursorSessions(): Promise { + const dbPath = getCursorGlobalDb(); + if (!dbPath || !fs.existsSync(dbPath)) return []; + + assertTrustedPath(dbPath); + + // 1. Load all composer metadata in one query + const metaRows = queryRows( + dbPath, + "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'" + ); + + const sessions: Session[] = []; + + for (const row of metaRows) { + // SQLite default column separator is | + const pipeIdx = row.indexOf("|"); + if (pipeIdx === -1) continue; + const key = row.slice(0, pipeIdx); + const value = row.slice(pipeIdx + 1); + + const meta = parseComposerMeta(value); + if (!meta) continue; + + // 2. Load all bubbles for this composer + const bubbleRows = queryRows( + dbPath, + `SELECT value FROM cursorDiskKV WHERE key LIKE 'bubbleId:${meta.composerId}:%'` + ); + + if (bubbleRows.length === 0) continue; + + const bubbles: ParsedBubble[] = []; + for (const bRow of bubbleRows) { + const b = parseBubble(bRow); + if (b) bubbles.push(b); + } + + // 3. Pair user→assistant turns + const messages: Message[] = []; + let linesOfCode = 0; + let i = 0; + while (i < bubbles.length) { + const cur = bubbles[i]; + if (cur.type === 1) { + const next = i + 1 < bubbles.length && bubbles[i + 1].type === 2 ? bubbles[i + 1] : null; + messages.push({ + prompt: cur.text, + response: next?.text ?? "", + model: meta.model, + }); + linesOfCode += next?.linesAdded ?? 0; + i += next ? 2 : 1; + } else { + i++; + } + } + + if (messages.length === 0) continue; + + // Prefer session-level line counts if available + const totalLoc = (meta.totalLinesAdded ?? 0) > 0 + ? meta.totalLinesAdded! + : linesOfCode; + + sessions.push({ + id: meta.composerId, + title: meta.name || meta.subtitle || meta.composerId, + source: "cursor", + model: meta.model ?? "unknown", + startedAt: meta.createdAt ? new Date(meta.createdAt) : undefined, + endedAt: meta.lastUpdatedAt ? new Date(meta.lastUpdatedAt) : undefined, + messages, + linesOfCode: totalLoc, + filePath: dbPath, + }); + } + + return sessions; +} \ No newline at end of file diff --git a/src/core/parser-harnesses.ts b/src/core/parser-harnesses.ts index 76b33e6..c2472fa 100644 --- a/src/core/parser-harnesses.ts +++ b/src/core/parser-harnesses.ts @@ -10,6 +10,7 @@ import { Workspace, Session } from './types'; import { findClaudeDirs, parseClaudeSessions, parseClaudeSessionsAsync } from './parser-claude'; import { findCodexDirs, parseCodexSessions } from './parser-codex'; import { findOpenCodeDirs, parseOpenCodeSessions } from './parser-opencode'; +import { parseCursorSessions } from './parser-cursor'; type WorkspaceMap = Map; @@ -69,6 +70,20 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [ } }, }, + { + name: 'Cursor', + collectSync(_ctx) { + // Cursor sessions are async-only (sqlite3 CLI); collectAsync handles them. + }, + async collectAsync(ctx, reportDetail) { + reportDetail?.('Scanning Cursor workspace databases...'); + const sessions = await parseCursorSessions(); + for (const session of sessions) { + addSession(ctx.workspaces, ctx.sessions, session, session.filePath ?? ''); + } + reportDetail?.(`Found ${sessions.length} Cursor session(s)`); + }, + }, ]; export interface ExternalHarnessProgressHandlers { @@ -78,15 +93,10 @@ export interface ExternalHarnessProgressHandlers { yieldToLoop?: () => Promise; } -/** Returns true if any external-harness (Claude Code, Codex, OpenCode) session +/** Returns true if any external-harness (Claude Code, Codex, OpenCode, Cursor) session * source exists on disk. The dashboard uses this so it does not abort when the - * only available logs come from a non-VS Code harness — e.g. a headless - * Remote-SSH host that has Claude Code sessions under `~/.claude/projects` but - * no VS Code workspace storage or Copilot directories. */ + * only available logs come from a non-VS Code harness. */ export function hasExternalHarnessSources(): boolean { - // Without a home directory the find* helpers would join against an empty - // string and probe relative paths (e.g. `.claude/projects`) under the current - // working directory, which could report false positives. Bail out instead. if (!process.env.HOME && !process.env.USERPROFILE) return false; return findClaudeDirs().length > 0 || findCodexDirs().length > 0 || findOpenCodeDirs().length > 0; } @@ -98,14 +108,12 @@ export function collectExternalHarnessesSync(workspaces: WorkspaceMap, sessions: } } -/** Harness values set on sessions by external harness collectors. - * The cache reconciliation in parser.ts uses this set to identify and - * refresh cached external-harness sessions, so every value the collectors - * can produce must be listed here. */ +/** Harness values set on sessions by external harness collectors. */ export const EXTERNAL_HARNESS_SET = new Set([ 'Claude', 'Codex', 'OpenCode', + 'Cursor', ]); export async function collectExternalHarnessesAsync( @@ -133,4 +141,4 @@ export async function collectExternalHarnessesAsync( if (handlers.yieldToLoop) await handlers.yieldToLoop(); } -} +} \ No newline at end of file diff --git a/src/core/parser-shared.ts b/src/core/parser-shared.ts index c50d14e..09c2933 100644 --- a/src/core/parser-shared.ts +++ b/src/core/parser-shared.ts @@ -62,6 +62,18 @@ function getTrustedRoots(): string[] { roots.push(path.resolve(home, '.claude')); roots.push(path.resolve(home, '.codex')); roots.push(path.resolve(home, '.local', 'share', 'opencode')); + // Cursor IDE + if (process.platform === 'win32') { + const appdata = process.env.APPDATA || ''; + if (appdata) roots.push(path.resolve(appdata, 'Cursor', 'User', 'workspaceStorage')); + } else if (process.platform === 'darwin') { + roots.push(path.resolve(home, 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage')); + } else { + roots.push(path.resolve(home, '.config', 'Cursor', 'User', 'workspaceStorage')); + // WSL: Windows Cursor data + const wslUser = process.env.USER || ''; + if (wslUser) roots.push('/mnt/c/Users/' + wslUser + '/AppData/Roaming/Cursor/User/workspaceStorage'); + } roots.push(path.resolve(home, '.config', 'github-copilot')); }