Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
22 changes: 13 additions & 9 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -58,7 +59,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 |
Expand Down Expand Up @@ -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.
Expand Down
75 changes: 75 additions & 0 deletions cli/src/commands/__tests__/cursor-stop.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
69 changes: 69 additions & 0 deletions cli/src/commands/__tests__/install-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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', () => {
Expand Down Expand Up @@ -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<string, Array<{ command: string }>>;
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<string, unknown[]>;
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<string, Array<{ command: string }>>;
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();
});
});
67 changes: 67 additions & 0 deletions cli/src/commands/cursor-stop.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<string> {
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);
});
}
Loading