From a8ebd179293cf504d5749b1ce380b954ac43c657 Mon Sep 17 00:00:00 2001 From: Stefano Maffeis Date: Tue, 11 Aug 2026 08:34:22 +0200 Subject: [PATCH 1/2] feat(cursor): support modern agent transcripts --- README.md | 2 +- cli/README.md | 2 +- cli/src/providers/__tests__/cursor.test.ts | 114 +++++++++ cli/src/providers/cursor.ts | 280 ++++++++++++++++++++- docs/source-tool-format-analysis.md | 10 +- 5 files changed, 396 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 64eda6d0..66bdfab4 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Don't need a browser? `code-insights stats` gives you the full picture from the | Tool | Data Location | |------|---------------| | Claude Code | `~/.claude/projects/**/*.jsonl` | -| Cursor | Workspace storage SQLite (macOS, Linux, Windows) | +| Cursor | Agent transcript JSONL (`~/.cursor/projects/*/agent-transcripts/`) + legacy workspace SQLite | | Codex CLI | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | | Copilot CLI | `~/.copilot/session-state/{id}/events.jsonl` | | VS Code Copilot Chat | Platform-specific Copilot Chat storage | diff --git a/cli/README.md b/cli/README.md index 140e93c6..530d4a30 100644 --- a/cli/README.md +++ b/cli/README.md @@ -58,7 +58,7 @@ code-insights doctor # diagnose your installation (start here | Tool | Data Location | |------|---------------| | **Claude Code** | `~/.claude/projects/**/*.jsonl` | -| **Cursor** | Workspace storage SQLite (macOS, Linux, Windows) | +| **Cursor** | Agent transcript JSONL (`~/.cursor/projects/*/agent-transcripts/`) + legacy workspace SQLite | | **Codex CLI** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | | **Copilot CLI** | `~/.copilot/session-state/{id}/events.jsonl` | | **VS Code Copilot Chat** | Platform-specific Copilot Chat storage | diff --git a/cli/src/providers/__tests__/cursor.test.ts b/cli/src/providers/__tests__/cursor.test.ts index 6fb73ac0..7ae2ea52 100644 --- a/cli/src/providers/__tests__/cursor.test.ts +++ b/cli/src/providers/__tests__/cursor.test.ts @@ -14,6 +14,7 @@ import { CursorProvider } from '../cursor.js'; // --------------------------------------------------------------------------- const COMPOSER_ID = 'test-composer-abc123'; +const originalCursorAgentHome = process.env.CURSOR_AGENT_HOME; function makeCursorDb(dir: string, composerData: Record): string { const dbPath = path.join(dir, 'state.vscdb'); @@ -31,6 +32,25 @@ function virtualPath(dbPath: string): string { return `${dbPath}#${COMPOSER_ID}`; } +function makeAgentTranscript( + dir: string, + sessionId: string, + records: Array>, + projectSlug = 'Users-test-example-project', +): string { + const transcriptDir = path.join( + dir, + 'projects', + projectSlug, + 'agent-transcripts', + sessionId, + ); + fs.mkdirSync(transcriptDir, { recursive: true }); + const transcriptPath = path.join(transcriptDir, `${sessionId}.jsonl`); + fs.writeFileSync(transcriptPath, records.map(record => JSON.stringify(record)).join('\n')); + return transcriptPath; +} + function userBubble(overrides: Record = {}): Record { return { bubbleId: 'bubble-user-1', type: 1, text: 'How do I fix the login bug?', ...overrides }; } @@ -49,6 +69,11 @@ describe('CursorProvider — parsing accuracy fixes', () => { afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalCursorAgentHome === undefined) { + delete process.env.CURSOR_AGENT_HOME; + } else { + process.env.CURSOR_AGENT_HOME = originalCursorAgentHome; + } }); // ── Timestamps ──────────────────────────────────────────────────────────── @@ -273,3 +298,92 @@ describe('CursorProvider — parsing accuracy fixes', () => { expect(session!.messageCount).toBe(4); }); }); + +describe('CursorProvider — modern Agent transcripts', () => { + let tempDir: string; + const provider = new CursorProvider(); + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-agent-test-')); + process.env.CURSOR_AGENT_HOME = tempDir; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalCursorAgentHome === undefined) { + delete process.env.CURSOR_AGENT_HOME; + } else { + process.env.CURSOR_AGENT_HOME = originalCursorAgentHome; + } + }); + + it('discovers and parses modern JSONL transcripts', async () => { + const transcriptPath = makeAgentTranscript(tempDir, 'session-modern-1', [ + { + role: 'user', + message: { content: [{ type: 'text', text: 'Fix the login flow' }] }, + }, + { + role: 'assistant', + message: { content: [{ type: 'text', text: 'I will inspect the authentication code.' }] }, + }, + { + role: 'assistant', + message: { + content: [ + { type: 'thinking', thinking: 'The session cookie may be stale.' }, + { type: 'tool_use', id: 'tool-1', name: 'Read', input: { path: 'src/auth.ts' } }, + ], + }, + }, + { type: 'turn_ended', status: 'success' }, + ]); + + await expect(provider.discover()).resolves.toContain(transcriptPath); + + const session = await provider.parse(transcriptPath); + expect(session).not.toBeNull(); + expect(session!.id).toBe('cursor:session-modern-1'); + expect(session!.sourceTool).toBe('cursor'); + expect(session!.projectPath).toBe('/Users/test/example/project'); + expect(session!.messages).toHaveLength(3); + expect(session!.messages[0].content).toBe('Fix the login flow'); + expect(session!.messages[2].thinking).toBe('The session cookie may be stale.'); + expect(session!.messages[2].toolCalls).toEqual([ + { id: 'tool-1', name: 'Read', input: { path: 'src/auth.ts' } }, + ]); + expect(session!.toolCallCount).toBe(1); + }); + + it('accepts string content and skips malformed or lifecycle records', async () => { + const transcriptPath = makeAgentTranscript(tempDir, 'session-modern-2', [ + { role: 'user', message: { content: 'First question' } }, + { role: 'assistant', message: { content: 'First answer' } }, + { type: 'turn_ended', status: 'success' }, + ]); + fs.appendFileSync(transcriptPath, '\nnot-json'); + + const session = await provider.parse(transcriptPath); + expect(session).not.toBeNull(); + expect(session!.messageCount).toBe(2); + expect(session!.userMessageCount).toBe(1); + expect(session!.assistantMessageCount).toBe(1); + }); + + it('applies the project filter to modern transcript directories', async () => { + const included = makeAgentTranscript( + tempDir, + 'included-session', + [{ role: 'user', message: { content: 'Included' } }], + 'Users-test-alpha-project', + ); + makeAgentTranscript( + tempDir, + 'excluded-session', + [{ role: 'user', message: { content: 'Excluded' } }], + 'Users-test-beta-project', + ); + + await expect(provider.discover({ projectFilter: 'alpha' })).resolves.toEqual([included]); + }); +}); diff --git a/cli/src/providers/cursor.ts b/cli/src/providers/cursor.ts index 7e0427f3..d200327e 100644 --- a/cli/src/providers/cursor.ts +++ b/cli/src/providers/cursor.ts @@ -9,7 +9,8 @@ import { isVerbose } from './context.js'; /** * Cursor IDE session provider. - * Discovers and parses sessions from Cursor's SQLite databases. + * Discovers and parses sessions from Cursor's SQLite databases and modern + * Agent transcript JSONL files. * * Cursor stores composer conversations in state.vscdb files (SQLite). * One DB can contain multiple sessions (composers), so discover() returns @@ -27,15 +28,13 @@ export class CursorProvider implements SessionProvider { */ async discover(options?: { projectFilter?: string }): Promise { const cursorDataDir = getCursorDataDir(); - if (!cursorDataDir) { - return []; - } - const dbPaths: string[] = []; // 1. Check workspace storage databases - const workspaceStorageDir = path.join(cursorDataDir, 'workspaceStorage'); - if (fs.existsSync(workspaceStorageDir)) { + const workspaceStorageDir = cursorDataDir + ? path.join(cursorDataDir, 'workspaceStorage') + : null; + if (workspaceStorageDir && fs.existsSync(workspaceStorageDir)) { const entries = fs.readdirSync(workspaceStorageDir); for (const entry of entries) { const wsDir = path.join(workspaceStorageDir, entry); @@ -57,8 +56,10 @@ export class CursorProvider implements SessionProvider { } // 2. Check global storage database - const globalDbPath = path.join(cursorDataDir, 'globalStorage', 'state.vscdb'); - if (fs.existsSync(globalDbPath)) { + const globalDbPath = cursorDataDir + ? path.join(cursorDataDir, 'globalStorage', 'state.vscdb') + : null; + if (globalDbPath && fs.existsSync(globalDbPath)) { dbPaths.push(globalDbPath); } @@ -72,6 +73,21 @@ export class CursorProvider implements SessionProvider { } } + // 3. Check modern Agent transcript JSONL files. Cursor writes these under + // ~/.cursor/projects//agent-transcripts//.jsonl. + // Prefer the richer SQLite representation when the same session ID exists + // in both stores. + const legacyComposerIds = new Set( + virtualPaths.map(virtualPath => virtualPath.slice(virtualPath.lastIndexOf('#') + 1)), + ); + const transcriptPaths = discoverAgentTranscripts(options?.projectFilter); + for (const transcriptPath of transcriptPaths) { + const sessionId = path.basename(transcriptPath, '.jsonl'); + if (!legacyComposerIds.has(sessionId)) { + virtualPaths.push(transcriptPath); + } + } + return virtualPaths; } @@ -80,6 +96,10 @@ export class CursorProvider implements SessionProvider { * Virtual path format: `#` */ async parse(virtualPath: string): Promise { + if (virtualPath.endsWith('.jsonl')) { + return parseAgentTranscript(virtualPath); + } + const hashIndex = virtualPath.lastIndexOf('#'); if (hashIndex === -1) return null; @@ -95,6 +115,248 @@ export class CursorProvider implements SessionProvider { // Helper functions // --------------------------------------------------------------------------- +interface AgentTranscriptBlock { + type?: unknown; + text?: unknown; + thinking?: unknown; + id?: unknown; + name?: unknown; + input?: unknown; +} + +interface AgentTranscriptRecord { + role?: unknown; + type?: unknown; + timestamp?: unknown; + createdAt?: unknown; + message?: { + content?: unknown; + }; +} + +function getCursorProjectsDir(): string { + const cursorHome = process.env.CURSOR_AGENT_HOME || path.join(os.homedir(), '.cursor'); + return path.join(cursorHome, 'projects'); +} + +function discoverAgentTranscripts(projectFilter?: string): string[] { + const projectsDir = getCursorProjectsDir(); + if (!fs.existsSync(projectsDir)) return []; + + const transcripts: string[] = []; + for (const projectEntry of fs.readdirSync(projectsDir, { withFileTypes: true })) { + if (!projectEntry.isDirectory()) continue; + + const projectDir = path.join(projectsDir, projectEntry.name); + const projectPath = resolveAgentProjectPath(projectDir); + if (projectFilter) { + const filter = projectFilter.toLowerCase(); + if (!projectPath.toLowerCase().includes(filter) && !projectEntry.name.toLowerCase().includes(filter)) { + continue; + } + } + + const transcriptRoot = path.join(projectDir, 'agent-transcripts'); + if (!fs.existsSync(transcriptRoot)) continue; + + for (const sessionEntry of fs.readdirSync(transcriptRoot, { withFileTypes: true })) { + if (!sessionEntry.isDirectory()) continue; + const transcriptPath = path.join(transcriptRoot, sessionEntry.name, `${sessionEntry.name}.jsonl`); + if (fs.existsSync(transcriptPath)) transcripts.push(transcriptPath); + } + } + + return transcripts; +} + +/** Best-effort decode of Cursor's slash-and-underscore-to-dash project slug. */ +function resolveAgentProjectPath(projectDir: string): string { + const workerLog = path.join(projectDir, 'worker.log'); + if (fs.existsSync(workerLog)) { + try { + const matches = [...fs.readFileSync(workerLog, 'utf-8').matchAll(/workspacePath=(\S+)/g)]; + const latest = matches.at(-1)?.[1]; + if (latest) return latest; + } catch { + // Fall through to slug decoding. + } + } + + const slug = path.basename(projectDir); + const parts = slug.split('-').filter(Boolean); + if (parts.length === 0) return `cursor://project-${slug}`; + + // Resolve greedily against real directories so names containing '-' or '_' + // are preserved whenever the original checkout still exists. + if (parts[0] === 'Users' || parts[0] === 'home') { + let current = path.join(path.parse(os.homedir()).root, parts[0]); + let index = 1; + while (index < parts.length && fs.existsSync(current)) { + let matched: string | null = null; + let nextIndex = index; + for (let end = parts.length; end > index && !matched; end--) { + for (const candidate of [parts.slice(index, end).join('_'), parts.slice(index, end).join('-')]) { + const candidatePath = path.join(current, candidate); + if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isDirectory()) { + matched = candidatePath; + nextIndex = end; + break; + } + } + } + if (!matched) break; + current = matched; + index = nextIndex; + } + if (index === parts.length) return current; + } + + const decoded = parts.join(path.sep); + return parts[0] === 'Users' || parts[0] === 'home' + ? `${path.parse(os.homedir()).root}${decoded}` + : decoded; +} + +function extractAgentText(content: unknown): { + content: string; + thinking: string | null; + toolCalls: ToolCall[]; +} { + if (typeof content === 'string') { + return { content, thinking: null, toolCalls: [] }; + } + + if (!Array.isArray(content)) { + return { content: '', thinking: null, toolCalls: [] }; + } + + const textParts: string[] = []; + const thinkingParts: string[] = []; + const toolCalls: ToolCall[] = []; + for (const rawBlock of content) { + if (!rawBlock || typeof rawBlock !== 'object') continue; + const block = rawBlock as AgentTranscriptBlock; + if (block.type === 'text' && typeof block.text === 'string') { + textParts.push(block.text); + } else if (block.type === 'thinking') { + const thinking = typeof block.thinking === 'string' ? block.thinking : block.text; + if (typeof thinking === 'string') thinkingParts.push(thinking); + } else if (block.type === 'tool_use') { + toolCalls.push({ + id: typeof block.id === 'string' ? block.id : `cursor-tool-${toolCalls.length + 1}`, + name: typeof block.name === 'string' ? block.name : 'unknown', + input: block.input && typeof block.input === 'object' && !Array.isArray(block.input) + ? block.input as Record + : {}, + }); + } + } + + return { + content: textParts.join('\n'), + thinking: thinkingParts.length > 0 ? thinkingParts.join('\n') : null, + toolCalls, + }; +} + +function cleanAgentUserText(text: string): string { + const userQuery = text.match(/\s*([\s\S]*?)\s*<\/user_query>/i); + return userQuery ? userQuery[1].trim() : text.trim(); +} + +function parseAgentTimestamp(value: unknown): Date | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function parseAgentTranscript(transcriptPath: string): ParsedSession | null { + try { + const sessionId = path.basename(transcriptPath, '.jsonl'); + if (!sessionId || !fs.existsSync(transcriptPath)) return null; + + const messages: ParsedMessage[] = []; + const timestamps: Date[] = []; + const lines = fs.readFileSync(transcriptPath, 'utf-8').split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) continue; + + let record: AgentTranscriptRecord; + try { + record = JSON.parse(line) as AgentTranscriptRecord; + } catch { + if (isVerbose()) console.warn(`[cursor] skipping malformed JSONL record in ${transcriptPath}`); + continue; + } + + if (record.role !== 'user' && record.role !== 'assistant') continue; + const extracted = extractAgentText(record.message?.content); + const content = record.role === 'user' ? cleanAgentUserText(extracted.content) : extracted.content; + if (!content && !extracted.thinking && extracted.toolCalls.length === 0) continue; + + const timestamp = parseAgentTimestamp(record.timestamp ?? record.createdAt) ?? new Date(0); + if (timestamp.getTime() > 0) timestamps.push(timestamp); + messages.push({ + id: `cursor:${sessionId}:message:${messages.length + 1}`, + sessionId: `cursor:${sessionId}`, + type: record.role, + content, + thinking: extracted.thinking, + toolCalls: extracted.toolCalls, + toolResults: [], + usage: null, + timestamp, + parentId: null, + }); + } + + if (messages.length === 0) return null; + + const stats = fs.statSync(transcriptPath); + const fallbackStartedAt = stats.birthtimeMs > 0 ? stats.birthtime : stats.mtime; + const startedAt = timestamps.length > 0 ? timestamps[0] : fallbackStartedAt; + const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1] : stats.mtime; + const projectDir = path.dirname(path.dirname(path.dirname(transcriptPath))); + const projectPath = resolveAgentProjectPath(projectDir); + const projectName = projectPath.startsWith('cursor://') + ? projectPath.replace('cursor://', '') + : path.basename(projectPath); + const userMessages = messages.filter(message => message.type === 'user'); + const assistantMessages = messages.filter(message => message.type === 'assistant'); + + const session: ParsedSession = { + id: `cursor:${sessionId}`, + projectPath, + projectName, + summary: null, + generatedTitle: null, + titleSource: null, + sessionCharacter: null, + startedAt, + endedAt, + messageCount: messages.length, + userMessageCount: userMessages.length, + assistantMessageCount: assistantMessages.length, + toolCallCount: messages.reduce((sum, message) => sum + message.toolCalls.length, 0), + compactCount: 0, + autoCompactCount: 0, + slashCommands: [], + gitBranch: null, + claudeVersion: null, + sourceTool: 'cursor', + messages, + }; + + const titleResult = generateTitle(session); + session.generatedTitle = titleResult.title; + session.titleSource = titleResult.source; + session.sessionCharacter = titleResult.character || detectSessionCharacter(session); + return session; + } catch { + return null; + } +} + /** * Find Cursor's data directory based on the current platform. */ diff --git a/docs/source-tool-format-analysis.md b/docs/source-tool-format-analysis.md index b7829022..156a1e93 100644 --- a/docs/source-tool-format-analysis.md +++ b/docs/source-tool-format-analysis.md @@ -139,7 +139,15 @@ Parser is production-quality. Handles all current format features. ## 3. Cursor -### Format +### Formats + +Modern Cursor Agent sessions are exported as JSONL: + +- **Location:** `~/.cursor/projects//agent-transcripts//.jsonl` +- **Records:** `{role, message: {content}}` user/assistant entries plus lifecycle records such as `turn_ended` +- **Content:** text, thinking, and tool-use input blocks; tool outputs may be absent + +The provider also retains support for the legacy Cursor IDE SQLite storage: - **File type:** SQLite (VS Code `state.vscdb`) - **Location:** `~/Library/Application Support/Cursor/User/workspaceStorage//state.vscdb` From 5c75e3eb25eff7d71ba3b6af33f78a0695c39442 Mon Sep 17 00:00:00 2001 From: Stefano Maffeis Date: Tue, 11 Aug 2026 08:53:33 +0200 Subject: [PATCH 2/2] feat(cursor): install automatic sync hook --- README.md | 1 + cli/README.md | 20 ++-- .../commands/__tests__/cursor-stop.test.ts | 75 +++++++++++++ .../commands/__tests__/install-hook.test.ts | 69 ++++++++++++ cli/src/commands/cursor-stop.ts | 67 ++++++++++++ cli/src/commands/install-hook.ts | 100 +++++++++++++++++- cli/src/index.ts | 17 ++- cli/src/utils/hooks-utils.ts | 18 ++++ 8 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 cli/src/commands/__tests__/cursor-stop.test.ts create mode 100644 cli/src/commands/cursor-stop.ts diff --git a/README.md b/README.md index 66bdfab4..c0c8b6f0 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ npx @code-insights/cli npm install -g @code-insights/cli code-insights # sync sessions + open dashboard code-insights install-hook # auto-sync + auto-analyze on session end +code-insights install-hook --cursor # auto-sync Cursor transcripts after completed turns ``` ### Common Commands diff --git a/cli/README.md b/cli/README.md index 530d4a30..3c32a1a8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -32,6 +32,7 @@ npx @code-insights/cli npm install -g @code-insights/cli code-insights # sync sessions + open dashboard code-insights install-hook # auto-sync + auto-analyze on session end +code-insights install-hook --cursor # auto-sync Cursor transcripts after completed turns ``` The dashboard opens at `http://localhost:7890` and shows your sessions, analytics, and LLM-powered insights. @@ -269,22 +270,25 @@ code-insights insights check --analyze code-insights insights check --days 14 ``` -### Auto-Sync & Auto-Analyze Hook +### Automatic Hooks ```bash -# Install Claude Code hooks — auto-sync + auto-analyze when sessions end +# Claude Code: auto-sync + auto-analyze when sessions end code-insights install-hook -# Install only the sync hook (no analysis) -code-insights install-hook --sync-only +# Cursor: auto-sync after each completed Agent turn +# Analysis remains explicit to avoid repeated LLM usage. +code-insights install-hook --cursor -# Install only the analysis hook -code-insights install-hook --analysis-only - -# Remove all hooks +# Remove the Claude Code or Cursor hook code-insights uninstall-hook +code-insights uninstall-hook --cursor ``` +The Cursor hook is stored in `~/.cursor/hooks.json`, preserves unrelated hooks, +and uses Cursor's native `stop` event to sync only the transcript supplied in +the hook payload. + ### Telemetry Anonymous usage telemetry is opt-out. No PII is collected. diff --git a/cli/src/commands/__tests__/cursor-stop.test.ts b/cli/src/commands/__tests__/cursor-stop.test.ts new file mode 100644 index 00000000..3e088234 --- /dev/null +++ b/cli/src/commands/__tests__/cursor-stop.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { syncSingleFile } = vi.hoisted(() => ({ syncSingleFile: vi.fn() })); + +vi.mock('../sync.js', () => ({ syncSingleFile })); + +describe('handleCursorStopPayload', () => { + beforeEach(() => { + syncSingleFile.mockReset(); + }); + + it('syncs the supplied Cursor transcript after a completed stop event', async () => { + const { handleCursorStopPayload } = await import('../cursor-stop.js'); + await handleCursorStopPayload({ + hook_event_name: 'stop', + status: 'completed', + conversation_id: 'conversation-123', + transcript_path: '/Users/test/.cursor/projects/example/agent-transcripts/conversation-123/conversation-123.jsonl', + }, { quiet: true }); + + expect(syncSingleFile).toHaveBeenCalledOnce(); + expect(syncSingleFile).toHaveBeenCalledWith({ + filePath: '/Users/test/.cursor/projects/example/agent-transcripts/conversation-123/conversation-123.jsonl', + sourceTool: 'cursor', + quiet: true, + }); + }); + + it.each(['aborted', 'error', undefined])('ignores non-completed status %s', async (status) => { + const { handleCursorStopPayload } = await import('../cursor-stop.js'); + await handleCursorStopPayload({ + hook_event_name: 'stop', + status, + conversation_id: 'conversation-123', + transcript_path: '/tmp/transcript.jsonl', + }, { quiet: true }); + + expect(syncSingleFile).not.toHaveBeenCalled(); + }); + + it('ignores payloads for another hook event', async () => { + const { handleCursorStopPayload } = await import('../cursor-stop.js'); + await handleCursorStopPayload({ + hook_event_name: 'sessionEnd', + status: 'completed', + conversation_id: 'conversation-123', + transcript_path: '/tmp/transcript.jsonl', + }, { quiet: true }); + + expect(syncSingleFile).not.toHaveBeenCalled(); + }); + + it('requires both conversation_id and transcript_path', async () => { + const { handleCursorStopPayload } = await import('../cursor-stop.js'); + await handleCursorStopPayload({ status: 'completed' }, { quiet: true }); + await handleCursorStopPayload({ + status: 'completed', + conversation_id: 'conversation-123', + }, { quiet: true }); + + expect(syncSingleFile).not.toHaveBeenCalled(); + }); + + it('swallows sync failures so the Cursor hook remains non-blocking', async () => { + syncSingleFile.mockRejectedValueOnce(new Error('sync failed')); + const { handleCursorStopPayload } = await import('../cursor-stop.js'); + + await expect(handleCursorStopPayload({ + hook_event_name: 'stop', + status: 'completed', + conversation_id: 'conversation-123', + transcript_path: '/tmp/transcript.jsonl', + }, { quiet: true })).resolves.toBeUndefined(); + }); +}); diff --git a/cli/src/commands/__tests__/install-hook.test.ts b/cli/src/commands/__tests__/install-hook.test.ts index fdcd2fa1..a9376574 100644 --- a/cli/src/commands/__tests__/install-hook.test.ts +++ b/cli/src/commands/__tests__/install-hook.test.ts @@ -57,6 +57,20 @@ function writeSettings(data: unknown): void { fs.writeFileSync(hooksFile(), JSON.stringify(data)); } +function cursorHooksFile(): string { + return path.join(mockHomeDir, '.cursor', 'hooks.json'); +} + +function readCursorHooks(): Record { + return JSON.parse(fs.readFileSync(cursorHooksFile(), 'utf-8')); +} + +function writeCursorHooks(data: unknown): void { + const dir = path.join(mockHomeDir, '.cursor'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(cursorHooksFile(), JSON.stringify(data)); +} + // ── Tests ───────────────────────────────────────────────────────────────────── describe('installHookCommand', () => { @@ -268,3 +282,58 @@ describe('uninstallHookCommand', () => { expect(settings.hooks).toBeUndefined(); }); }); + +describe('Cursor hook', () => { + it('installs one native stop hook and preserves existing configuration', async () => { + writeCursorHooks({ + version: 1, + customSetting: true, + hooks: { beforeSubmitPrompt: [{ command: 'other-tool validate' }] }, + }); + + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({ cursor: true }); + + const settings = readCursorHooks(); + expect(settings.version).toBe(1); + expect(settings.customSetting).toBe(true); + const hooks = settings.hooks as Record>; + expect(hooks.beforeSubmitPrompt).toEqual([{ command: 'other-tool validate' }]); + expect(hooks.stop).toHaveLength(1); + expect(hooks.stop[0].command).toMatch(/^node .+index\.js cursor-stop -q$/); + }); + + it('does not duplicate the Cursor hook when installed twice', async () => { + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({ cursor: true }); + await installHookCommand({ cursor: true }); + + const settings = readCursorHooks(); + const hooks = settings.hooks as Record; + expect(hooks.stop).toHaveLength(1); + }); + + it('uninstalls only the Code Insights Cursor hook', async () => { + writeCursorHooks({ + version: 1, + hooks: { + stop: [ + { command: 'other-tool stop' }, + { command: 'node /path/code-insights cursor-stop -q' }, + ], + }, + }); + + const { uninstallHookCommand } = await import('../install-hook.js'); + await uninstallHookCommand({ cursor: true }); + + const settings = readCursorHooks(); + const hooks = settings.hooks as Record>; + expect(hooks.stop).toEqual([{ command: 'other-tool stop' }]); + }); + + it('handles a missing Cursor hooks file', async () => { + const { uninstallHookCommand } = await import('../install-hook.js'); + await expect(uninstallHookCommand({ cursor: true })).resolves.toBeUndefined(); + }); +}); diff --git a/cli/src/commands/cursor-stop.ts b/cli/src/commands/cursor-stop.ts new file mode 100644 index 00000000..9108d7ab --- /dev/null +++ b/cli/src/commands/cursor-stop.ts @@ -0,0 +1,67 @@ +import chalk from 'chalk'; +import { syncSingleFile } from './sync.js'; + +export interface CursorStopOptions { + quiet?: boolean; +} + +export interface CursorStopPayload { + conversation_id?: unknown; + transcript_path?: unknown; + status?: unknown; + hook_event_name?: unknown; +} + +export async function handleCursorStopPayload( + payload: CursorStopPayload, + options: CursorStopOptions = {}, +): Promise { + const { quiet = false } = options; + if (payload.hook_event_name && payload.hook_event_name !== 'stop') return; + if (payload.status !== 'completed') return; + + if (typeof payload.conversation_id !== 'string' || !payload.conversation_id) { + if (!quiet) console.error(chalk.red('[Code Insights] cursor-stop: missing conversation_id')); + return; + } + if (typeof payload.transcript_path !== 'string' || !payload.transcript_path) { + if (!quiet) console.error(chalk.red('[Code Insights] cursor-stop: missing transcript_path')); + return; + } + + try { + await syncSingleFile({ + filePath: payload.transcript_path, + sourceTool: 'cursor', + quiet, + }); + } catch { + if (!quiet) console.error(chalk.yellow('[Code Insights] cursor-stop: sync failed')); + } +} + +export async function cursorStopCommand(options: CursorStopOptions = {}): Promise { + const stdinData = await readStdin(); + let payload: CursorStopPayload; + try { + payload = JSON.parse(stdinData) as CursorStopPayload; + } catch { + if (!options.quiet) console.error(chalk.red('[Code Insights] cursor-stop: invalid JSON on stdin')); + return; + } + await handleCursorStopPayload(payload, options); +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + if (process.stdin.isTTY) { + resolve('{}'); + return; + } + let data = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', chunk => { data += chunk; }); + process.stdin.on('end', () => resolve(data.trim())); + process.stdin.on('error', reject); + }); +} diff --git a/cli/src/commands/install-hook.ts b/cli/src/commands/install-hook.ts index 1fc9a04e..7289d050 100644 --- a/cli/src/commands/install-hook.ts +++ b/cli/src/commands/install-hook.ts @@ -5,14 +5,22 @@ import chalk from 'chalk'; import { trackEvent, captureError, classifyError } from '../utils/telemetry.js'; import { HOOKS_FILE, + CURSOR_HOOKS_FILE, CLI_ENTRY, type ClaudeSettings, type HookConfig, + type CursorHooksSettings, getHookCommand, hookAlreadyInstalled, + cursorHookAlreadyInstalled, } from '../utils/hooks-utils.js'; const CLAUDE_SETTINGS_DIR = path.join(os.homedir(), '.claude'); +const CURSOR_SETTINGS_DIR = path.join(os.homedir(), '.cursor'); + +export interface HookCommandOptions { + cursor?: boolean; +} /** * Remove any existing Code Insights Stop hooks (v4.8.x migration). @@ -40,7 +48,12 @@ function removeStopHooks(settings: ClaudeSettings): boolean { * Running install-hook again removes the old Stop hook (v4.8.x hygiene) and * installs a fresh session-end hook. */ -export async function installHookCommand(): Promise { +export async function installHookCommand(options: HookCommandOptions = {}): Promise { + if (options.cursor) { + await installCursorHook(); + return; + } + console.log(chalk.cyan('\nInstall Code Insights Hook\n')); const sessionEndCommand = `node ${CLI_ENTRY} session-end --native -q`; @@ -109,11 +122,65 @@ export async function installHookCommand(): Promise { } } +async function installCursorHook(): Promise { + console.log(chalk.cyan('\nInstall Code Insights Cursor Hook\n')); + const stopCommand = `node ${CLI_ENTRY} cursor-stop -q`; + + console.log(chalk.gray('This will add one Cursor stop hook:\n')); + console.log(chalk.white(' stop hook — Syncs the current Cursor transcript after each completed turn')); + console.log(chalk.gray(' Analysis remains explicit to avoid repeated LLM usage.\n')); + + try { + let settings: CursorHooksSettings = {}; + if (fs.existsSync(CURSOR_HOOKS_FILE)) { + try { + settings = JSON.parse(fs.readFileSync(CURSOR_HOOKS_FILE, 'utf-8')) as CursorHooksSettings; + } catch { + console.log(chalk.yellow('Could not parse existing hooks.json, creating a new one.')); + } + } + + settings.version ??= 1; + settings.hooks ??= {}; + settings.hooks.stop ??= []; + if (!cursorHookAlreadyInstalled(settings.hooks.stop)) { + settings.hooks.stop.push({ command: stopCommand }); + } + + fs.mkdirSync(CURSOR_SETTINGS_DIR, { recursive: true }); + fs.writeFileSync(CURSOR_HOOKS_FILE, JSON.stringify(settings, null, 2)); + + console.log(chalk.green('Cursor hook installed successfully!')); + console.log(chalk.gray(`\nConfiguration saved to: ${CURSOR_HOOKS_FILE}`)); + console.log(chalk.cyan('\nHow it works:')); + console.log(chalk.white(' After a completed Cursor Agent turn, Code Insights syncs that transcript.')); + console.log(chalk.white(' Run analysis explicitly when the session is ready.')); + + trackEvent('cli_install_hook', { + success: true, + provider: 'cursor', + hook_types: 'stop', + sync_installed: true, + analysis_installed: false, + }); + } catch (error) { + console.log(chalk.red(`Failed to install Cursor hook: ${error instanceof Error ? error.message : 'Unknown error'}`)); + const { error_type, error_message } = classifyError(error); + trackEvent('cli_install_hook', { success: false, provider: 'cursor', error_type, error_message }); + captureError(error, { command: 'install_hook_cursor', error_type }); + } +} + /** * Uninstall Code Insights hooks. * Handles both v4.9+ (SessionEnd session-end) and v4.8.x (Stop sync + SessionEnd insights --hook). */ -export async function uninstallHookCommand(): Promise { +export async function uninstallHookCommand(options: HookCommandOptions = {}): Promise { + if (options.cursor) { + await uninstallCursorHook(); + return; + } + console.log(chalk.cyan('\nUninstall Code Insights Hooks\n')); if (!fs.existsSync(HOOKS_FILE)) { @@ -162,3 +229,32 @@ export async function uninstallHookCommand(): Promise { console.error(error instanceof Error ? error.message : 'Unknown error'); } } + +async function uninstallCursorHook(): Promise { + console.log(chalk.cyan('\nUninstall Code Insights Cursor Hook\n')); + + if (!fs.existsSync(CURSOR_HOOKS_FILE)) { + console.log(chalk.yellow('No Cursor hooks file found. Nothing to uninstall.')); + return; + } + + try { + const settings = JSON.parse(fs.readFileSync(CURSOR_HOOKS_FILE, 'utf-8')) as CursorHooksSettings; + if (!settings.hooks?.stop) { + console.log(chalk.yellow('No Code Insights Cursor hook found. Nothing to uninstall.')); + return; + } + + settings.hooks.stop = settings.hooks.stop.filter( + hook => !hook.command.includes('code-insights'), + ); + if (settings.hooks.stop.length === 0) delete settings.hooks.stop; + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + + fs.writeFileSync(CURSOR_HOOKS_FILE, JSON.stringify(settings, null, 2)); + console.log(chalk.green('Cursor hook uninstalled successfully!')); + } catch (error) { + console.log(chalk.red('Failed to uninstall Cursor hook:')); + console.error(error instanceof Error ? error.message : 'Unknown error'); + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts index a1b98be0..462e1586 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -15,6 +15,7 @@ import { telemetryCommand } from './commands/telemetry.js'; import { reflectCommand } from './commands/reflect.js'; import { insightsCommand, insightsCheckCommand } from './commands/insights.js'; import { sessionEndCommand } from './commands/session-end.js'; +import { cursorStopCommand } from './commands/cursor-stop.js'; import { buildQueueCommand } from './commands/queue.js'; import { doctorCommand } from './commands/doctor/index.js'; import { showTelemetryNoticeIfNeeded } from './utils/telemetry.js'; @@ -92,13 +93,15 @@ program program .command('install-hook') - .description('Install Claude Code SessionEnd hook for automatic sync and analysis') - .action(() => installHookCommand()); + .description('Install automatic sync hooks (Claude Code by default)') + .option('--cursor', 'Install Cursor stop hook for automatic transcript sync') + .action((options) => installHookCommand(options)); program .command('uninstall-hook') - .description('Remove Claude Code hooks (sync and analysis)') - .action(uninstallHookCommand); + .description('Remove automatic sync hooks (Claude Code by default)') + .option('--cursor', 'Remove the Code Insights Cursor stop hook') + .action((options) => uninstallHookCommand(options)); program .command('doctor') @@ -143,6 +146,12 @@ program await sessionEndCommand({ native: opts.native ?? true, quiet: opts.quiet, source: opts.source, model: opts.model }); }); +program + .command('cursor-stop') + .description('Internal Cursor stop hook entry point for transcript sync') + .option('-q, --quiet', 'Suppress output') + .action(cursorStopCommand); + // queue command suite — manage the analysis_queue program.addCommand(buildQueueCommand()); diff --git a/cli/src/utils/hooks-utils.ts b/cli/src/utils/hooks-utils.ts index 42bd8b37..9357f1e7 100644 --- a/cli/src/utils/hooks-utils.ts +++ b/cli/src/utils/hooks-utils.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import { fileURLToPath } from 'url'; export const HOOKS_FILE = path.join(os.homedir(), '.claude', 'settings.json'); +export const CURSOR_HOOKS_FILE = path.join(os.homedir(), '.cursor', 'hooks.json'); // Stable path to the CLI entry point — works across npm link, global install, and npx. // Resolved relative to this file's location in utils/ (one level up to src/, then index.js). @@ -24,6 +25,19 @@ export interface HookConfig { hooks: Array; } +export interface CursorHookCommand { + command: string; +} + +export interface CursorHooksSettings { + version?: number; + hooks?: { + stop?: CursorHookCommand[]; + [key: string]: CursorHookCommand[] | undefined; + }; + [key: string]: unknown; +} + /** Extract command string from both old (string) and new ({type, command}) hook formats */ export function getHookCommand(hook: string | { type: string; command: string }): string { return typeof hook === 'string' ? hook : hook.command; @@ -36,6 +50,10 @@ export function hookAlreadyInstalled(hookList: HookConfig[]): boolean { ); } +export function cursorHookAlreadyInstalled(hookList: CursorHookCommand[]): boolean { + return hookList.some(hook => hook.command.includes('code-insights')); +} + /** Read and parse ~/.claude/settings.json. Returns null if missing or unparseable. */ export function loadClaudeSettings(): ClaudeSettings | null { try {