From a0b48f35e877fc75795cecbb6fe18a32daa97f06 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Wed, 29 Apr 2026 16:05:59 -0700 Subject: [PATCH 1/3] docs: note GitHub Copilot CLI testing is ongoing Update the README beta callout to clarify that GitHub Copilot CLI support is still under active testing. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 3 +++ README.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c3db03..e8bf6301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ - `failproofai policies --uninstall` interactive CLI selector now says "Remove Hooks" / "Choose where to remove from:" instead of "Install Hooks" / "Choose where to install:" (#236) - README: replace the GitHub Copilot logo with the current canonical mark and add a dark-mode variant (`copilot-light.svg` + `copilot-dark.svg` via ``); the previous SVG used outdated path data with a hard-coded black fill that rendered invisibly on GitHub's dark theme (#236) +### Docs +- README: note that GitHub Copilot CLI testing is ongoing in the beta callout (#245) + ## 0.0.9 — 2026-04-28 ### Features diff --git a/README.md b/README.md index 1230c469..6397facf 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The easiest way to manage policies that keep your AI agents reliable, on-task, a + more coming soon

-> Install hooks for one, two, or all three: `failproofai policies --install --cli copilot` (or `--cli claude codex copilot`). Omit `--cli` to auto-detect installed CLIs and prompt. **GitHub Copilot CLI support is in beta.** +> Install hooks for one, two, or all three: `failproofai policies --install --cli copilot` (or `--cli claude codex copilot`). Omit `--cli` to auto-detect installed CLIs and prompt. **GitHub Copilot CLI support is in beta — testing is ongoing.** - **39 Built-in Policies** - Catch common agent failure modes out of the box. Block destructive commands, prevent secret leakage, keep agents inside project boundaries, detect loops, and more. - **Custom Policies** - Write your own reliability rules in JavaScript. Use the `allow`/`deny`/`instruct` API to enforce conventions, prevent drift, gate operations, or integrate with external systems. From b4c1f64b43a644e92f565ecf15c6af9e8b456c7d Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Wed, 29 Apr 2026 16:47:04 -0700 Subject: [PATCH 2/3] [luv-245] feat: add Cursor Agent CLI integration (beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Codex (#220, #226, #232) and Copilot (#236) rollouts for Cursor Agent CLI. Hook install/uninstall, handler canonicalization, policy-evaluator output adapter, path-protection, activity dashboard filter + per-CLI badge, /projects merge, and session viewer fallback. Cursor's hook protocol differs from Claude/Codex/Copilot: - camelCase event keys (preToolUse, beforeSubmitPrompt, …) — handler canonicalizes via CURSOR_EVENT_MAP before policy lookup. - Flat array of hook entries per event (no Claude-style {hooks: [...]} matcher wrapper); writeHookEntries / removeHooksFromFile / hooksInstalledInSettings all walk the flat structure directly. - Stdout decision shape is {permission, user_message, agent_message, additional_context, followup_message} (not hookSpecificOutput). policy-evaluator.ts now branches on session.cli === "cursor" for the deny / instruct / allow-with-info paths and emits the Cursor shape. - No documented permission-mode equivalent; resolve-permission-mode.ts falls into the existing "default" branch (same as Copilot). - Detected via cursor-agent (preferred) or agent (legacy alias). Settings paths: user → ~/.cursor/hooks.json project → /.cursor/hooks.json Path-protection (isAgentInternalPath + isAgentSettingsFile) extended to cover ~/.cursor/ and .cursor/hooks.json so the agent can't disable its own hooks. Frontend: lib/cli-registry.ts adds a "Cursor Agent" entry with an emerald badge; lib/projects.ts merges cursor projects; the project page (app/project/[name]/page.tsx) and session viewer (app/project/[name]/session/[sessionId]/page.tsx) extend the external-CLI fallback chain. The activity feed (hooks-client.tsx) recognizes /.cursor/ transcript paths. Adds lib/cursor-projects.ts + lib/cursor-sessions.ts as scaffold parsers — Cursor's transcript layout is undocumented, so the modules probe candidate subdirs (agent-sessions/, conversations/, sessions/) and metadata files (meta.json/session.json/workspace.{json,yaml}) and gracefully return [] when the directory layout doesn't match. The parser handles dotted-path record types (session.start, user.message, …) and degrades to system entries for unknown types so nothing is silently dropped — same shape as the Copilot parser. Drops the .cursor/hooks.json file at the project root (mirrors the existing .codex/hooks.json and .github/hooks/failproofai.json) so contributors developing failproofai with Cursor get hooks active automatically. Adjusts .gitignore to track that single file while leaving the rest of .cursor/ ignored. Tests: 60 unit suites / 1228 tests pass; 9 e2e suites / 235 tests pass (including the new __tests__/e2e/hooks/cursor-integration.e2e.test.ts which covers PreToolUse deny + agent-settings guard + beforeSubmitPrompt allow + activity tagging + install/uninstall flows). Bumps version to 0.0.10-beta.1. Also fixes a pre-existing parity gap: the project detail page (app/project/[name]/page.tsx) only listed Claude + Codex sessions, missing Copilot (and now Cursor); it now merges all four sources. Co-Authored-By: Claude Opus 4.7 --- .cursor/hooks.json | 53 +++ .gitignore | 3 +- CHANGELOG.md | 6 +- CLAUDE.md | 28 ++ README.md | 11 +- __tests__/components/project-list.test.tsx | 34 ++ __tests__/e2e/helpers/hook-runner.ts | 24 +- __tests__/e2e/helpers/payloads.ts | 74 ++++ .../e2e/hooks/cursor-integration.e2e.test.ts | 222 +++++++++++ __tests__/hooks/handler.test.ts | 50 +++ __tests__/hooks/install-prompt.test.ts | 6 +- __tests__/hooks/integrations.test.ts | 135 ++++++- __tests__/lib/cli-registry.test.ts | 7 +- __tests__/lib/cursor-projects.test.ts | 204 ++++++++++ __tests__/lib/cursor-sessions.test.ts | 293 +++++++++++++++ __tests__/lib/projects.test.ts | 90 ++++- app/policies/hooks-client.tsx | 11 +- app/project/[name]/page.tsx | 53 ++- .../[name]/session/[sessionId]/page.tsx | 32 +- assets/logos/cursor-dark.svg | 1 + assets/logos/cursor-light.svg | 1 + bin/failproofai.mjs | 40 +- docs/configuration.mdx | 8 +- docs/dashboard.mdx | 12 +- docs/getting-started.mdx | 5 +- lib/cli-registry.ts | 14 +- lib/cursor-projects.ts | 240 ++++++++++++ lib/cursor-sessions.ts | 347 ++++++++++++++++++ lib/projects.ts | 18 +- package.json | 2 +- src/hooks/builtin-policies.ts | 14 +- src/hooks/handler.ts | 23 +- src/hooks/install-prompt.ts | 4 +- src/hooks/integrations.ts | 137 +++++++ src/hooks/policy-evaluator.ts | 83 ++++- src/hooks/resolve-permission-mode.ts | 6 +- src/hooks/types.ts | 41 ++- 37 files changed, 2233 insertions(+), 99 deletions(-) create mode 100644 .cursor/hooks.json create mode 100644 __tests__/e2e/hooks/cursor-integration.e2e.test.ts create mode 100644 __tests__/lib/cursor-projects.test.ts create mode 100644 __tests__/lib/cursor-sessions.test.ts create mode 100644 assets/logos/cursor-dark.svg create mode 100644 assets/logos/cursor-light.svg create mode 100644 lib/cursor-projects.ts create mode 100644 lib/cursor-sessions.ts diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 00000000..c550d67f --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "bun bin/failproofai.mjs --hook sessionStart --cli cursor", + "timeout": 60000, + "__failproofai_hook__": true + } + ], + "sessionEnd": [ + { + "type": "command", + "command": "bun bin/failproofai.mjs --hook sessionEnd --cli cursor", + "timeout": 60000, + "__failproofai_hook__": true + } + ], + "beforeSubmitPrompt": [ + { + "type": "command", + "command": "bun bin/failproofai.mjs --hook beforeSubmitPrompt --cli cursor", + "timeout": 60000, + "__failproofai_hook__": true + } + ], + "preToolUse": [ + { + "type": "command", + "command": "bun bin/failproofai.mjs --hook preToolUse --cli cursor", + "timeout": 60000, + "__failproofai_hook__": true + } + ], + "postToolUse": [ + { + "type": "command", + "command": "bun bin/failproofai.mjs --hook postToolUse --cli cursor", + "timeout": 60000, + "__failproofai_hook__": true + } + ], + "stop": [ + { + "type": "command", + "command": "bun bin/failproofai.mjs --hook stop --cli cursor", + "timeout": 60000, + "__failproofai_hook__": true + } + ] + } +} diff --git a/.gitignore b/.gitignore index 06052771..83e0b1bb 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,8 @@ next-env.d.ts !.claude/settings.json # cursor -.cursor +.cursor/* +!.cursor/hooks.json # custom hooks loader temp files *.__failproofai_tmp__.* diff --git a/CHANGELOG.md b/CHANGELOG.md index e8bf6301..3907beab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,12 @@ - `failproofai policies --uninstall` interactive CLI selector now says "Remove Hooks" / "Choose where to remove from:" instead of "Install Hooks" / "Choose where to install:" (#236) - README: replace the GitHub Copilot logo with the current canonical mark and add a dark-mode variant (`copilot-light.svg` + `copilot-dark.svg` via ``); the previous SVG used outdated path data with a hard-coded black fill that rendered invisibly on GitHub's dark theme (#236) +### Features +- Add Cursor Agent CLI integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. New `--cli cursor` flag installs into `~/.cursor/hooks.json` (user) or `/.cursor/hooks.json` (project) using Cursor's flat-array schema with camelCase event keys (`preToolUse`, `beforeSubmitPrompt`, …); the handler canonicalizes to PascalCase via `CURSOR_EVENT_MAP` so existing builtin policies fire unchanged. The policy evaluator emits Cursor's `{permission, user_message, agent_message, additional_context, followup_message}` stdout shape. Path-protection (`isAgentInternalPath` + `isAgentSettingsFile`) covers `~/.cursor/` and `.cursor/hooks.json`. Frontend: `lib/cli-registry.ts` adds a `Cursor Agent` entry with an emerald badge; `lib/projects.ts` merges Cursor projects into `/projects`; `app/project/[name]` and `/session/[id]` extend the external-CLI fallback chain. Also ships this repo's own `.cursor/hooks.json` so contributors using Cursor get hooks active automatically (#245). +- Project page (`/project/[name]`): list Copilot and Cursor sessions alongside Claude + Codex, mirroring the existing merge logic on the projects index. Previously the project detail view only enumerated Claude + Codex transcripts (#245). + ### Docs -- README: note that GitHub Copilot CLI testing is ongoing in the beta callout (#245) +- README: add Cursor Agent to the supported-CLIs intro line and visual list, with light/dark logo variants (`assets/logos/cursor-light.svg` + `cursor-dark.svg`). Note that GitHub Copilot CLI testing is ongoing in the beta callout (#245). ## 0.0.9 — 2026-04-28 diff --git a/CLAUDE.md b/CLAUDE.md index e1992f18..db066159 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,34 @@ which writes a portable `npx -y failproofai --hook ... --cli copilot` command. Same self-reference caveat applies — do **not** install the standard `npx` form from inside this repo. +### Cursor hooks (`.cursor/hooks.json`) + +This repo also ships a `.cursor/hooks.json` for Cursor Agent CLI sessions, +mirroring the `.claude/settings.json`, `.codex/hooks.json`, and +`.github/hooks/failproofai.json` setups. Cursor's hook config goes at the +project root under `.cursor/hooks.json` per the +[Cursor docs](https://cursor.com/docs/hooks). The schema is Cursor's flat +form: `version: 1`, camelCase event keys (`preToolUse`, `beforeSubmitPrompt`, +…), and a flat array of `{type, command, timeout}` entries per event (no +Claude-style `{hooks: [...]}` matcher wrapper). The handler canonicalizes +camelCase → PascalCase via `CURSOR_EVENT_MAP` before policy lookup so the +existing builtin policies fire unchanged. + +Like Codex and Copilot, Cursor does not expose a `$CURSOR_PROJECT_DIR` env +var to the hook command line (only as a process env var inside the hook +itself), and Cursor hooks are spawned with the project root as cwd, so we +use a relative `bun bin/failproofai.mjs --hook ... --cli cursor` path. If +Cursor ever changes that behavior and the hook fails to find the binary, +switch to an absolute path. + +For production users (outside this repo), the recommended Cursor install is: +```bash +failproofai policies --install --cli cursor --scope project +``` +which writes a portable `npx -y failproofai --hook ... --cli cursor` command. +Same self-reference caveat applies — do **not** install the standard `npx` +form from inside this repo. + ## Workflow rules ### One PR per branch diff --git a/README.md b/README.md index 6397facf..851c6186 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ **Translations**: [简体中文](docs/i18n/README.zh.md) | [日本語](docs/i18n/README.ja.md) | [한국어](docs/i18n/README.ko.md) | [Español](docs/i18n/README.es.md) | [Português](docs/i18n/README.pt-br.md) | [Deutsch](docs/i18n/README.de.md) | [Français](docs/i18n/README.fr.md) | [Русский](docs/i18n/README.ru.md) | [हिन्दी](docs/i18n/README.hi.md) | [Türkçe](docs/i18n/README.tr.md) | [Tiếng Việt](docs/i18n/README.vi.md) | [Italiano](docs/i18n/README.it.md) | [العربية](docs/i18n/README.ar.md) | [עברית](docs/i18n/README.he.md) -The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously - for **Claude Code**, **OpenAI Codex**, **GitHub Copilot CLI** _(beta)_ & the **Agents SDK**. +The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously - for **Claude Code**, **OpenAI Codex**, **GitHub Copilot CLI** _(beta)_, **Cursor Agent** _(beta)_ & the **Agents SDK**.

Failproof AI in action @@ -44,10 +44,17 @@ The easiest way to manage policies that keep your AI agents reliable, on-task, a        + + + + Cursor Agent + + +        + more coming soon

-> Install hooks for one, two, or all three: `failproofai policies --install --cli copilot` (or `--cli claude codex copilot`). Omit `--cli` to auto-detect installed CLIs and prompt. **GitHub Copilot CLI support is in beta — testing is ongoing.** +> Install hooks for one or any combination: `failproofai policies --install --cli cursor` (or `--cli claude codex copilot cursor`). Omit `--cli` to auto-detect installed CLIs and prompt. **GitHub Copilot CLI and Cursor Agent support are in beta — testing is ongoing.** - **39 Built-in Policies** - Catch common agent failure modes out of the box. Block destructive commands, prevent secret leakage, keep agents inside project boundaries, detect loops, and more. - **Custom Policies** - Write your own reliability rules in JavaScript. Use the `allow`/`deny`/`instruct` API to enforce conventions, prevent drift, gate operations, or integrate with external systems. diff --git a/__tests__/components/project-list.test.tsx b/__tests__/components/project-list.test.tsx index a74bb21e..2ab64c9c 100644 --- a/__tests__/components/project-list.test.tsx +++ b/__tests__/components/project-list.test.tsx @@ -128,6 +128,39 @@ describe("ProjectList", () => { expect(badgeNodes("GitHub Copilot")).toHaveLength(1); }); + it("renders a Cursor Agent badge for cli=['cursor']", () => { + const folders: ProjectFolder[] = [ + { + name: "-home-u-cursor", + path: "/home/u/cursor", + isDirectory: true, + lastModified: new Date(), + lastModifiedFormatted: "Jun 15, 2024", + cli: ["cursor"], + }, + ]; + render(); + expect(badgeNodes("Cursor Agent")).toHaveLength(1); + }); + + it("renders all four badges when cli=['claude','codex','copilot','cursor']", () => { + const folders: ProjectFolder[] = [ + { + name: "-home-u-quad", + path: "/home/u/quad", + isDirectory: true, + lastModified: new Date(), + lastModifiedFormatted: "Jun 15, 2024", + cli: ["claude", "codex", "copilot", "cursor"], + }, + ]; + render(); + expect(badgeNodes("Claude Code")).toHaveLength(1); + expect(badgeNodes("OpenAI Codex")).toHaveLength(1); + expect(badgeNodes("GitHub Copilot")).toHaveLength(1); + expect(badgeNodes("Cursor Agent")).toHaveLength(1); + }); + it("links to /project/[name]", () => { const folders = makeFolders(1); render(); @@ -166,6 +199,7 @@ describe("ProjectList", () => { "Claude Code", "OpenAI Codex", "GitHub Copilot", + "Cursor Agent", ]); }); diff --git a/__tests__/e2e/helpers/hook-runner.ts b/__tests__/e2e/helpers/hook-runner.ts index 09dc5820..ee75fd73 100644 --- a/__tests__/e2e/helpers/hook-runner.ts +++ b/__tests__/e2e/helpers/hook-runner.ts @@ -40,7 +40,7 @@ export interface HookRunResult { export function runHook( event: string, payload: Record, - opts?: { homeDir?: string; cli?: "claude" | "codex" | "copilot" }, + opts?: { homeDir?: string; cli?: "claude" | "codex" | "copilot" | "cursor" }, ): HookRunResult { const binaryPath = getBinaryPath(); @@ -117,3 +117,25 @@ export function assertStopInstruct(result: HookRunResult): void { expect(result.exitCode).toBe(2); expect(result.stderr).toBeTruthy(); } + +// ── Cursor-shaped assertions ─────────────────────────────────────────────── +// Cursor uses a flat `{permission, user_message, agent_message}` JSON shape +// (no `hookSpecificOutput` wrapper) — see https://cursor.com/docs/hooks. + +export function assertCursorDeny(result: HookRunResult): void { + expect(result.exitCode).toBe(0); + expect(result.parsed?.permission).toBe("deny"); + expect(result.parsed?.user_message).toMatch(/Blocked/i); + expect(result.parsed?.agent_message).toMatch(/Blocked/i); +} + +export function assertCursorInstruct(result: HookRunResult): void { + expect(result.exitCode).toBe(0); + expect(result.parsed?.permission).toBe("allow"); + expect(result.parsed?.additional_context).toMatch(/^Instruction from failproofai:/); +} + +export function assertCursorStopInstruct(result: HookRunResult): void { + expect(result.exitCode).toBe(0); + expect(result.parsed?.followup_message).toMatch(/^Instruction from failproofai:/); +} diff --git a/__tests__/e2e/helpers/payloads.ts b/__tests__/e2e/helpers/payloads.ts index 6db5ca0b..b1e8be1e 100644 --- a/__tests__/e2e/helpers/payloads.ts +++ b/__tests__/e2e/helpers/payloads.ts @@ -178,6 +178,80 @@ export const CodexPayloads = { }, }; +/** + * Cursor Agent CLI-accurate payload factories. Cursor delivers camelCase + * `hook_event_name` (`preToolUse`, `beforeSubmitPrompt`, …) plus snake_case + * fields (`tool_name`, `tool_input`, `cwd`). The failproofai handler + * canonicalizes camelCase → PascalCase via CURSOR_EVENT_MAP for internal + * lookup. Ref: https://cursor.com/docs/hooks (Stdin Payload Schema). + */ +const CURSOR_SESSION_ID = "test-session-cursor-001"; + +export const CursorPayloads = { + preToolUse: { + bash(command: string, cwd: string): Record { + return { + session_id: CURSOR_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "preToolUse", + tool_name: "Bash", + tool_input: { command }, + }; + }, + write(filePath: string, content: string, cwd: string): Record { + return { + session_id: CURSOR_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "preToolUse", + tool_name: "Write", + tool_input: { file_path: filePath, content }, + }; + }, + read(filePath: string, cwd: string): Record { + return { + session_id: CURSOR_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "preToolUse", + tool_name: "Read", + tool_input: { file_path: filePath }, + }; + }, + }, + postToolUse: { + bash(command: string, output: string, cwd: string): Record { + return { + session_id: CURSOR_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "postToolUse", + tool_name: "Bash", + tool_input: { command }, + tool_output: output, + }; + }, + }, + beforeSubmitPrompt(prompt: string, cwd: string): Record { + return { + session_id: CURSOR_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "beforeSubmitPrompt", + prompt, + }; + }, + stop(cwd: string): Record { + return { + session_id: CURSOR_SESSION_ID, + transcript_path: TRANSCRIPT_PATH, + cwd, + hook_event_name: "stop", + }; + }, +}; + /** * Copilot CLI-accurate payload factories. We install Copilot hooks in * "VS Code compatible" PascalCase mode, so Copilot delivers PascalCase diff --git a/__tests__/e2e/hooks/cursor-integration.e2e.test.ts b/__tests__/e2e/hooks/cursor-integration.e2e.test.ts new file mode 100644 index 00000000..bf788663 --- /dev/null +++ b/__tests__/e2e/hooks/cursor-integration.e2e.test.ts @@ -0,0 +1,222 @@ +/** + * E2E: Cursor Agent CLI hook integration. + * + * Exercises the full install → fire → decide flow using the real failproofai + * binary as a subprocess (no mocks). Each test runs against an isolated + * fixture HOME so we don't pollute the user's ~/.cursor/. + */ +import { describe, it, expect } from "vitest"; +import { execSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + runHook, + assertAllow, + assertCursorDeny, +} from "../helpers/hook-runner"; +import { CursorPayloads } from "../helpers/payloads"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); + +function createCursorEnv(): { home: string; cwd: string; cleanup: () => void } { + const home = mkdtempSync(join(tmpdir(), "fp-e2e-cursor-home-")); + const cwd = mkdtempSync(join(tmpdir(), "fp-e2e-cursor-cwd-")); + // Pre-create the .failproofai dir under cwd so the parent-walk finds it. + mkdirSync(resolve(cwd, ".failproofai"), { recursive: true }); + return { + home, + cwd, + cleanup() { + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + }, + }; +} + +function writeConfig(cwd: string, enabledPolicies: string[]): void { + const configPath = resolve(cwd, ".failproofai", "policies-config.json"); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify({ enabledPolicies }, null, 2)); +} + +describe("E2E: Cursor integration — hook protocol", () => { + it("preToolUse: block-sudo emits Cursor's flat {permission:'deny', user_message, agent_message} shape", () => { + const env = createCursorEnv(); + try { + writeConfig(env.cwd, ["block-sudo"]); + const result = runHook( + "preToolUse", + CursorPayloads.preToolUse.bash("sudo apt install foo", env.cwd), + { homeDir: env.home, cli: "cursor" }, + ); + assertCursorDeny(result); + // No hookSpecificOutput wrapper for Cursor — confirm we used the flat shape. + expect(result.parsed?.hookSpecificOutput).toBeUndefined(); + } finally { + env.cleanup(); + } + }); + + it("postToolUse: deny still emits Cursor's flat shape (no additionalContext wrapper)", () => { + const env = createCursorEnv(); + try { + writeConfig(env.cwd, ["sanitize-jwt"]); + // Build a JWT-shaped string at runtime so secret scanners don't flag the source. + const jwtLike = [ + Buffer.from('{"alg":"HS256","typ":"JWT"}').toString("base64url"), + Buffer.from('{"sub":"123456"}').toString("base64url"), + Buffer.from("not-a-real-signature").toString("base64url"), + ].join("."); + const result = runHook( + "postToolUse", + CursorPayloads.postToolUse.bash("echo done", `JWT=${jwtLike}`, env.cwd), + { homeDir: env.home, cli: "cursor" }, + ); + assertCursorDeny(result); + } finally { + env.cleanup(); + } + }); + + it("Bash read of .cursor/hooks.json is denied by the agent-settings guard", () => { + const env = createCursorEnv(); + try { + writeConfig(env.cwd, ["block-read-outside-cwd"]); + const settingsPath = resolve(env.cwd, ".cursor", "hooks.json"); + const result = runHook( + "preToolUse", + CursorPayloads.preToolUse.bash(`cat ${settingsPath}`, env.cwd), + { homeDir: env.home, cli: "cursor" }, + ); + assertCursorDeny(result); + } finally { + env.cleanup(); + } + }); + + it("beforeSubmitPrompt → UserPromptSubmit canonicalization: allow when no policy matches", () => { + const env = createCursorEnv(); + try { + writeConfig(env.cwd, []); + const result = runHook( + "beforeSubmitPrompt", + CursorPayloads.beforeSubmitPrompt("Just a normal user prompt", env.cwd), + { homeDir: env.home, cli: "cursor" }, + ); + assertAllow(result); + } finally { + env.cleanup(); + } + }); + + it("activity entry tags decision with integration: cursor", () => { + const env = createCursorEnv(); + try { + writeConfig(env.cwd, ["block-sudo"]); + runHook( + "preToolUse", + CursorPayloads.preToolUse.bash("sudo cat /etc/passwd", env.cwd), + { homeDir: env.home, cli: "cursor" }, + ); + const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + expect(existsSync(activityPath)).toBe(true); + const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); + const last = JSON.parse(lines[lines.length - 1]) as Record; + expect(last.integration).toBe("cursor"); + expect(last.decision).toBe("deny"); + // Canonical event name lands in the activity entry, not the camelCase wire form. + expect(last.eventType).toBe("PreToolUse"); + } finally { + env.cleanup(); + } + }); +}); + +describe("E2E: Cursor integration — install/uninstall", () => { + it("policies --install --cli cursor --scope project writes .cursor/hooks.json with camelCase keys + flat array", () => { + const env = createCursorEnv(); + try { + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli cursor --scope project`, + { cwd: env.cwd, env: { ...process.env, HOME: env.home, FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH } }, + ); + const hooksPath = resolve(env.cwd, ".cursor", "hooks.json"); + expect(existsSync(hooksPath)).toBe(true); + const settings = JSON.parse(readFileSync(hooksPath, "utf-8")) as Record; + expect(settings.version).toBe(1); + const hooks = settings.hooks as Record; + // Cursor stores under camelCase keys (native form) per cursor.com/docs/hooks. + expect(hooks.preToolUse).toBeDefined(); + expect(hooks.postToolUse).toBeDefined(); + expect(hooks.sessionStart).toBeDefined(); + expect(hooks.sessionEnd).toBeDefined(); + expect(hooks.beforeSubmitPrompt).toBeDefined(); + expect(hooks.stop).toBeDefined(); + // PascalCase keys should not be present. + expect(hooks.PreToolUse).toBeUndefined(); + // Flat array — each entry IS the hook, no `{hooks: [...]}` matcher wrapper. + const preEntries = hooks.preToolUse as Array>; + expect(preEntries[0].type).toBe("command"); + expect(typeof preEntries[0].command).toBe("string"); + expect(preEntries[0].hooks).toBeUndefined(); + expect((preEntries[0].command as string)).toContain("--cli cursor"); + expect((preEntries[0].command as string)).toContain("--hook preToolUse"); + } finally { + env.cleanup(); + } + }); + + it("policies --install --cli cursor --scope local fails with friendly error", () => { + const env = createCursorEnv(); + try { + let err: { status?: number; stderr?: Buffer } | null = null; + try { + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli cursor --scope local`, + { + cwd: env.cwd, + env: { + ...process.env, + HOME: env.home, + FAILPROOFAI_TELEMETRY_DISABLED: "1", + FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH, + }, + stdio: "pipe", + }, + ); + } catch (e) { + err = e as { status?: number; stderr?: Buffer }; + } + expect(err).not.toBeNull(); + const stderr = err?.stderr?.toString() ?? ""; + expect(stderr).toMatch(/local.*not supported.*Cursor/i); + } finally { + env.cleanup(); + } + }); + + it("policies --uninstall --cli cursor removes hooks from the file", () => { + const env = createCursorEnv(); + try { + const baseEnv = { ...process.env, HOME: env.home, FAILPROOFAI_TELEMETRY_DISABLED: "1", FAILPROOFAI_BINARY_OVERRIDE: BINARY_PATH }; + execSync( + `bun ${BINARY_PATH} policies --install block-sudo --cli cursor --scope project`, + { cwd: env.cwd, env: baseEnv }, + ); + const hooksPath = resolve(env.cwd, ".cursor", "hooks.json"); + expect(existsSync(hooksPath)).toBe(true); + + execSync( + `bun ${BINARY_PATH} policies --uninstall --cli cursor --scope project`, + { cwd: env.cwd, env: baseEnv }, + ); + const settings = JSON.parse(readFileSync(hooksPath, "utf-8")) as Record; + expect(settings.hooks).toBeUndefined(); + } finally { + env.cleanup(); + } + }); +}); diff --git a/__tests__/hooks/handler.test.ts b/__tests__/hooks/handler.test.ts index 2fabd4a3..eaf036d5 100644 --- a/__tests__/hooks/handler.test.ts +++ b/__tests__/hooks/handler.test.ts @@ -247,6 +247,56 @@ describe("hooks/handler", () => { ); }); + it("canonicalizes Cursor camelCase event names to PascalCase before evaluating", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, + stdout: "", + stderr: "", + policyName: null, + reason: null, + decision: "allow", + }); + mockStdin(JSON.stringify({ tool_name: "Bash", hook_event_name: "preToolUse" })); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + // Cursor sends the camelCase event name on the --hook arg. + await handleHookEvent("preToolUse", "cursor"); + + // Internal evaluator + activity store key on PascalCase. + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ integration: "cursor", eventType: "PreToolUse" }), + ); + }); + + it("tags telemetry with cli=cursor when invoked with --cli cursor", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, + stdout: '{"permission":"deny","user_message":"Blocked","agent_message":"Blocked"}', + stderr: "", + policyName: "block-sudo", + reason: "sudo blocked", + decision: "deny", + }); + mockStdin(JSON.stringify({ tool_name: "Bash" })); + const { trackHookEvent } = await import("../../src/hooks/hook-telemetry"); + + await handleHookEvent("preToolUse", "cursor"); + + expect(trackHookEvent).toHaveBeenCalledWith( + "test-instance-id", + "hook_policy_triggered", + expect.objectContaining({ cli: "cursor", event_type: "PreToolUse" }), + ); + }); + it("fires telemetry with full payload for instruct decisions", async () => { const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); vi.mocked(evaluatePolicies).mockResolvedValueOnce({ diff --git a/__tests__/hooks/install-prompt.test.ts b/__tests__/hooks/install-prompt.test.ts index 11b52de4..f4d97d42 100644 --- a/__tests__/hooks/install-prompt.test.ts +++ b/__tests__/hooks/install-prompt.test.ts @@ -119,7 +119,7 @@ describe("hooks/install-prompt", () => { ); return { ...actual, - detectInstalledClis: () => ["claude", "codex", "copilot"], + detectInstalledClis: () => ["claude", "codex", "copilot", "cursor"], }; }); vi.resetModules(); @@ -128,8 +128,8 @@ describe("hooks/install-prompt", () => { const uninstallResult = await resolveTargetClis(undefined, "uninstall"); vi.doUnmock("../../src/hooks/integrations"); vi.resetModules(); - expect(installResult).toEqual(["claude", "codex", "copilot"]); - expect(uninstallResult).toEqual(["claude", "codex", "copilot"]); + expect(installResult).toEqual(["claude", "codex", "copilot", "cursor"]); + expect(uninstallResult).toEqual(["claude", "codex", "copilot", "cursor"]); }); }); }); diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts index d055b459..9cf96c05 100644 --- a/__tests__/hooks/integrations.test.ts +++ b/__tests__/hooks/integrations.test.ts @@ -17,6 +17,7 @@ import { claudeCode, codex, copilot, + cursor, getIntegration, listIntegrations, } from "../../src/hooks/integrations"; @@ -24,9 +25,12 @@ import { CODEX_HOOK_EVENT_TYPES, CODEX_EVENT_MAP, COPILOT_HOOK_EVENT_TYPES, + CURSOR_HOOK_EVENT_TYPES, + CURSOR_EVENT_MAP, HOOK_EVENT_TYPES, FAILPROOFAI_HOOK_MARKER, type CodexHookEventType, + type CursorHookEventType, } from "../../src/hooks/types"; let tempDir: string; @@ -40,9 +44,9 @@ afterEach(() => { }); describe("integrations registry", () => { - it("listIntegrations returns claude, codex, and copilot", () => { + it("listIntegrations returns claude, codex, copilot, and cursor", () => { const ids = listIntegrations().map((i) => i.id); - expect(ids).toEqual(["claude", "codex", "copilot"]); + expect(ids).toEqual(["claude", "codex", "copilot", "cursor"]); }); it("getIntegration('claude') returns claudeCode", () => { @@ -57,6 +61,10 @@ describe("integrations registry", () => { expect(getIntegration("copilot")).toBe(copilot); }); + it("getIntegration('cursor') returns cursor", () => { + expect(getIntegration("cursor")).toBe(cursor); + }); + it("getIntegration throws for unknown id", () => { // @ts-expect-error — testing error path expect(() => getIntegration("unknown-cli")).toThrow(); @@ -327,3 +335,126 @@ describe("GitHub Copilot integration", () => { expect(copilot.hooksInstalledInSettings("project", tempDir)).toBe(false); }); }); + +describe("Cursor Agent integration", () => { + it("getSettingsPath maps user → ~/.cursor/hooks.json and project → /.cursor/hooks.json", () => { + expect(cursor.getSettingsPath("project", tempDir)).toBe( + resolve(tempDir, ".cursor", "hooks.json"), + ); + expect(cursor.getSettingsPath("user")).toMatch(/\.cursor\/hooks\.json$/); + }); + + it("scopes are user|project (no local)", () => { + expect(cursor.scopes).toEqual(["user", "project"]); + }); + + it("eventTypes are the camelCase Cursor events", () => { + expect(cursor.eventTypes).toEqual(CURSOR_HOOK_EVENT_TYPES); + expect(cursor.eventTypes).toContain("preToolUse"); + expect(cursor.eventTypes).toContain("postToolUse"); + expect(cursor.eventTypes).toContain("beforeSubmitPrompt"); + expect(cursor.eventTypes).toContain("sessionStart"); + expect(cursor.eventTypes).toContain("sessionEnd"); + expect(cursor.eventTypes).toContain("stop"); + }); + + it("buildHookEntry uses Claude-shaped {command,timeout} with --cli cursor", () => { + const entry = cursor.buildHookEntry("/usr/bin/failproofai", "preToolUse", "user") as Record; + expect(entry.type).toBe("command"); + expect(entry.command).toBe('"/usr/bin/failproofai" --hook preToolUse --cli cursor'); + expect(entry.timeout).toBe(60_000); + expect(entry[FAILPROOFAI_HOOK_MARKER]).toBe(true); + // Cursor entries use the Claude-style `command` field, not Copilot's bash/powershell split. + expect(entry.bash).toBeUndefined(); + expect(entry.powershell).toBeUndefined(); + }); + + it("project scope uses npx -y failproofai (portable)", () => { + const entry = cursor.buildHookEntry("/usr/bin/failproofai", "preToolUse", "project") as Record; + expect(entry.command).toBe("npx -y failproofai --hook preToolUse --cli cursor"); + }); + + it("writeHookEntries stores camelCase event keys with version: 1 in a FLAT array (no matcher wrapper)", () => { + const settings: Record = {}; + cursor.writeHookEntries(settings, "/usr/bin/failproofai", "user"); + const hooks = settings.hooks as Record; + for (const eventType of CURSOR_HOOK_EVENT_TYPES) { + expect(hooks[eventType]).toBeDefined(); + const entries = hooks[eventType] as Array>; + // Flat array: each element IS a hook entry, not a {hooks: [...]} matcher. + expect(entries.length).toBeGreaterThanOrEqual(1); + expect(entries[0].type).toBe("command"); + expect(typeof entries[0].command).toBe("string"); + expect(entries[0].hooks).toBeUndefined(); // no nested matcher wrapper + } + expect(settings.version).toBe(1); + }); + + it("readSettings backfills version: 1 on existing files without it", () => { + const settingsPath = resolve(tempDir, ".cursor", "hooks.json"); + mkdirSync(resolve(tempDir, ".cursor"), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify({ hooks: {} })); + const read = cursor.readSettings(settingsPath); + expect(read.version).toBe(1); + }); + + it("re-running writeHookEntries is idempotent (replaces, doesn't duplicate)", () => { + const settings: Record = {}; + cursor.writeHookEntries(settings, "/usr/bin/failproofai", "user"); + cursor.writeHookEntries(settings, "/different/path/failproofai", "user"); + const hooks = settings.hooks as Record>>; + expect(hooks.preToolUse).toHaveLength(1); + // Second call's binary path should win. + expect(hooks.preToolUse[0].command).toBe('"/different/path/failproofai" --hook preToolUse --cli cursor'); + }); + + it("removeHooksFromFile clears all failproofai entries (returns count)", () => { + const settingsPath = cursor.getSettingsPath("project", tempDir); + const settings: Record = {}; + cursor.writeHookEntries(settings, "/usr/bin/failproofai", "project"); + cursor.writeSettings(settingsPath, settings); + expect(existsSync(settingsPath)).toBe(true); + + const removed = cursor.removeHooksFromFile(settingsPath); + expect(removed).toBe(CURSOR_HOOK_EVENT_TYPES.length); + + const after = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record; + expect(after.hooks).toBeUndefined(); + }); + + it("hooksInstalledInSettings detects installed hooks under camelCase keys", () => { + const settingsPath = cursor.getSettingsPath("project", tempDir); + const settings: Record = {}; + cursor.writeHookEntries(settings, "/usr/bin/failproofai", "project"); + cursor.writeSettings(settingsPath, settings); + + expect(cursor.hooksInstalledInSettings("project", tempDir)).toBe(true); + }); + + it("hooksInstalledInSettings returns false when file is missing", () => { + expect(cursor.hooksInstalledInSettings("project", tempDir)).toBe(false); + }); +}); + +describe("CURSOR_EVENT_MAP", () => { + it("maps every Cursor camelCase event to a PascalCase HookEventType", () => { + expect(CURSOR_EVENT_MAP.preToolUse).toBe("PreToolUse"); + expect(CURSOR_EVENT_MAP.postToolUse).toBe("PostToolUse"); + expect(CURSOR_EVENT_MAP.beforeSubmitPrompt).toBe("UserPromptSubmit"); + expect(CURSOR_EVENT_MAP.sessionStart).toBe("SessionStart"); + expect(CURSOR_EVENT_MAP.sessionEnd).toBe("SessionEnd"); + expect(CURSOR_EVENT_MAP.stop).toBe("Stop"); + }); + + it("CURSOR_EVENT_MAP keys exactly match CURSOR_HOOK_EVENT_TYPES", () => { + const mapKeys = Object.keys(CURSOR_EVENT_MAP).sort(); + const eventTypes = [...CURSOR_HOOK_EVENT_TYPES].sort(); + expect(mapKeys).toEqual(eventTypes); + }); + + // Reference cursor + CursorHookEventType so both stay in scope. + it("CursorHookEventType is exhaustive", () => { + const sample: CursorHookEventType = "preToolUse"; + expect(CURSOR_EVENT_MAP[sample]).toBe("PreToolUse"); + }); +}); diff --git a/__tests__/lib/cli-registry.test.ts b/__tests__/lib/cli-registry.test.ts index c623fdd0..56fc30c5 100644 --- a/__tests__/lib/cli-registry.test.ts +++ b/__tests__/lib/cli-registry.test.ts @@ -12,13 +12,14 @@ import { describe("lib/cli-registry", () => { it("KNOWN_CLI_IDS lists all supported CLIs in stable order", () => { - expect(KNOWN_CLI_IDS).toEqual(["claude", "codex", "copilot"]); + expect(KNOWN_CLI_IDS).toEqual(["claude", "codex", "copilot", "cursor"]); }); it("getCliEntry returns the entry for known ids and undefined for unknown", () => { expect(getCliEntry("claude")?.label).toBe("Claude Code"); expect(getCliEntry("codex")?.label).toBe("OpenAI Codex"); expect(getCliEntry("copilot")?.label).toBe("GitHub Copilot"); + expect(getCliEntry("cursor")?.label).toBe("Cursor Agent"); expect(getCliEntry("unknown")).toBeUndefined(); }); @@ -31,12 +32,14 @@ describe("lib/cli-registry", () => { expect(getCliBadgeClasses("copilot")).toContain("blue"); expect(getCliBadgeClasses("codex")).toContain("purple"); expect(getCliBadgeClasses("claude")).toContain("orange"); + expect(getCliBadgeClasses("cursor")).toContain("emerald"); expect(getCliBadgeClasses("unknown")).toContain("orange"); // falls back to claude }); it("isKnownCli is null/undefined-safe", () => { expect(isKnownCli("claude")).toBe(true); expect(isKnownCli("copilot")).toBe(true); + expect(isKnownCli("cursor")).toBe(true); expect(isKnownCli("nope")).toBe(false); expect(isKnownCli(null)).toBe(false); expect(isKnownCli(undefined)).toBe(false); @@ -50,7 +53,7 @@ describe("lib/cli-registry", () => { it("listExternalCliEntries excludes claude", () => { const ids = listExternalCliEntries().map((c) => c.id); - expect(ids).toEqual(["codex", "copilot"]); + expect(ids).toEqual(["codex", "copilot", "cursor"]); }); it("each CLI has a unique badgeClasses string", () => { diff --git a/__tests__/lib/cursor-projects.test.ts b/__tests__/lib/cursor-projects.test.ts new file mode 100644 index 00000000..c09c2e42 --- /dev/null +++ b/__tests__/lib/cursor-projects.test.ts @@ -0,0 +1,204 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const metaJson = (cwd: string): string => JSON.stringify({ cwd }); +const metaYaml = (cwd: string): string => `cwd: ${cwd}\n`; + +describe("lib/cursor-projects", () => { + let originalHome: string | undefined; + let originalCursorHome: string | undefined; + let fakeHome: string; + let getCursorProjects: typeof import("@/lib/cursor-projects").getCursorProjects; + let getCursorSessionsForCwd: typeof import("@/lib/cursor-projects").getCursorSessionsForCwd; + let getCursorSessionsByEncodedName: typeof import("@/lib/cursor-projects").getCursorSessionsByEncodedName; + + /** + * Write a synthetic cursor session under one of the candidate subdirectories + * (`agent-sessions/`, `conversations/`, `sessions/`). `metaName` lets the + * caller swap between meta.json / session.json / workspace.yaml so we cover + * each fallback the scanner probes. + */ + function writeSession( + sessionId: string, + cwd: string, + opts?: { + sub?: "agent-sessions" | "conversations" | "sessions"; + metaName?: "meta.json" | "session.json" | "workspace.json" | "workspace.yaml"; + events?: string; + eventsName?: "events.jsonl" | "transcript.jsonl" | "messages.jsonl"; + metaMtime?: Date; + eventsMtime?: Date; + }, + ) { + const sub = opts?.sub ?? "agent-sessions"; + const metaName = opts?.metaName ?? "meta.json"; + const dir = join(fakeHome, ".cursor", sub, sessionId); + mkdirSync(dir, { recursive: true }); + const meta = join(dir, metaName); + writeFileSync(meta, metaName.endsWith(".yaml") ? metaYaml(cwd) : metaJson(cwd)); + if (opts?.metaMtime) utimesSync(meta, opts.metaMtime, opts.metaMtime); + if (opts?.events !== undefined) { + const eventsName = opts?.eventsName ?? "events.jsonl"; + const ej = join(dir, eventsName); + writeFileSync(ej, opts.events); + if (opts?.eventsMtime) utimesSync(ej, opts.eventsMtime, opts.eventsMtime); + } + return dir; + } + + beforeEach(async () => { + originalHome = process.env.HOME; + originalCursorHome = process.env.CURSOR_HOME; + fakeHome = mkdtempSync(join(tmpdir(), "cursor-projects-")); + process.env.HOME = fakeHome; + delete process.env.CURSOR_HOME; + vi.resetModules(); + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, homedir: () => fakeHome }; + }); + vi.doMock("os", async () => { + const actual = await vi.importActual("os"); + return { ...actual, homedir: () => fakeHome }; + }); + ({ getCursorProjects, getCursorSessionsForCwd, getCursorSessionsByEncodedName } = await import( + "@/lib/cursor-projects" + )); + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + if (originalCursorHome !== undefined) process.env.CURSOR_HOME = originalCursorHome; + rmSync(fakeHome, { recursive: true, force: true }); + vi.doUnmock("node:os"); + vi.doUnmock("os"); + vi.resetModules(); + }); + + it("returns [] when ~/.cursor/ does not exist", async () => { + const result = await getCursorProjects(); + expect(result).toEqual([]); + }); + + it("groups sessions by cwd into one ProjectFolder each", async () => { + writeSession("11111111-1111-1111-1111-111111111111", "/home/u/proj-a", { events: "{}\n" }); + writeSession("22222222-2222-2222-2222-222222222222", "/home/u/proj-a", { events: "{}\n" }); + + const result = await getCursorProjects(); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("-home-u-proj-a"); + expect(result[0].path).toBe("/home/u/proj-a"); + expect(result[0].cli).toEqual(["cursor"]); + expect(result[0].isDirectory).toBe(true); + }); + + it.each(["agent-sessions", "conversations", "sessions"] as const)( + "scans the %s subdirectory", + async (sub) => { + writeSession("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "/home/u/from-" + sub, { + sub, + events: "{}\n", + }); + const result = await getCursorProjects(); + expect(result).toHaveLength(1); + expect(result[0].path).toBe("/home/u/from-" + sub); + }, + ); + + it.each(["meta.json", "session.json", "workspace.json", "workspace.yaml"] as const)( + "reads cwd from %s", + async (metaName) => { + writeSession("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "/home/u/from-" + metaName, { + metaName, + events: "{}\n", + }); + const result = await getCursorProjects(); + expect(result).toHaveLength(1); + expect(result[0].path).toBe("/home/u/from-" + metaName); + }, + ); + + it.each(["events.jsonl", "transcript.jsonl", "messages.jsonl"] as const)( + "accepts %s as the transcript file", + async (eventsName) => { + writeSession("cccccccc-cccc-cccc-cccc-cccccccccccc", "/home/u/proj", { + events: "{}\n", + eventsName, + }); + const result = await getCursorProjects(); + expect(result).toHaveLength(1); + }, + ); + + it("returns one entry per distinct cwd, sorted newest-first", async () => { + const old = new Date("2024-01-01T00:00:00Z"); + const recent = new Date("2026-06-15T00:00:00Z"); + writeSession("dddddddd-dddd-dddd-dddd-dddddddddddd", "/home/u/old", { + events: "{}\n", + metaMtime: old, + eventsMtime: old, + }); + writeSession("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "/home/u/new", { + events: "{}\n", + metaMtime: recent, + eventsMtime: recent, + }); + + const result = await getCursorProjects(); + expect(result.map((p) => p.path)).toEqual(["/home/u/new", "/home/u/old"]); + }); + + it("skips sessions that have metadata but no transcript file", async () => { + // Session with meta.json only — should not show up in /projects (would + // click through to an empty session list otherwise). + writeSession("ffffffff-ffff-ffff-ffff-ffffffffffff", "/home/u/empty"); + const result = await getCursorProjects(); + expect(result).toEqual([]); + }); + + it("getCursorSessionsForCwd returns matching sessions", async () => { + writeSession("11112222-3333-4444-5555-666677778888", "/home/u/proj", { events: "{}\n" }); + writeSession("aaaabbbb-cccc-dddd-eeee-ffff00001111", "/home/u/other", { events: "{}\n" }); + const matches = await getCursorSessionsForCwd("/home/u/proj"); + expect(matches).toHaveLength(1); + expect(matches[0].sessionId).toBe("11112222-3333-4444-5555-666677778888"); + expect(matches[0].cli).toBe("cursor"); + }); + + it("getCursorSessionsByEncodedName recovers cwd via re-encoding", async () => { + writeSession("12121212-1212-1212-1212-121212121212", "/home/u/proj-with-dash", { events: "{}\n" }); + const slug = "-home-u-proj-with-dash"; + const result = await getCursorSessionsByEncodedName(slug); + expect(result.cwd).toBe("/home/u/proj-with-dash"); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].cli).toBe("cursor"); + }); + + it("returns {cwd: null, sessions: []} for an unknown slug", async () => { + const result = await getCursorSessionsByEncodedName("-nonexistent"); + expect(result.cwd).toBeNull(); + expect(result.sessions).toEqual([]); + }); + + it("honors CURSOR_HOME when set", async () => { + const altHome = mkdtempSync(join(tmpdir(), "cursor-alt-home-")); + try { + process.env.CURSOR_HOME = altHome; + vi.resetModules(); + const { getCursorProjects: getCP } = await import("@/lib/cursor-projects"); + const dir = join(altHome, "agent-sessions", "abcd1234-abcd-1234-abcd-1234abcd1234"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "meta.json"), JSON.stringify({ cwd: "/from/alt-home" })); + writeFileSync(join(dir, "events.jsonl"), "{}\n"); + const result = await getCP(); + expect(result).toHaveLength(1); + expect(result[0].path).toBe("/from/alt-home"); + } finally { + delete process.env.CURSOR_HOME; + rmSync(altHome, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/lib/cursor-sessions.test.ts b/__tests__/lib/cursor-sessions.test.ts new file mode 100644 index 00000000..21c01400 --- /dev/null +++ b/__tests__/lib/cursor-sessions.test.ts @@ -0,0 +1,293 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const line = (obj: Record): string => JSON.stringify(obj); + +describe("lib/cursor-sessions: parseCursorLog", () => { + let parseCursorLog: typeof import("@/lib/cursor-sessions").parseCursorLog; + + beforeEach(async () => { + ({ parseCursorLog } = await import("@/lib/cursor-sessions")); + }); + + it("returns empty for empty input", async () => { + const result = await parseCursorLog(""); + expect(result.entries).toEqual([]); + expect(result.rawLines).toEqual([]); + expect(result.cwd).toBeUndefined(); + }); + + it("emits 'Session Started' for the first session.start, 'Session Resumed' on subsequent", async () => { + const content = [ + line({ + type: "session.start", + data: { context: { cwd: "/r1" } }, + timestamp: "2026-04-29T00:00:00.000Z", + }), + line({ + type: "session.start", + data: { context: { cwd: "/r2" } }, + timestamp: "2026-04-29T00:00:01.000Z", + }), + ].join("\n"); + const { entries, cwd } = await parseCursorLog(content); + const queueOps = entries.filter((e) => e.type === "queue-operation"); + expect(queueOps).toHaveLength(2); + expect(queueOps[0]).toMatchObject({ label: "Session Started" }); + expect(queueOps[1]).toMatchObject({ label: "Session Resumed" }); + expect(cwd).toBe("/r1"); + }); + + it("accepts the camelCase 'sessionStart' record-type variant", async () => { + const content = line({ + type: "sessionStart", + data: { workspace_roots: ["/from-roots"] }, + timestamp: "2026-04-29T00:00:00.000Z", + }); + const { entries, cwd } = await parseCursorLog(content); + expect(entries).toHaveLength(1); + expect(entries[0].type).toBe("queue-operation"); + // Falls back to workspace_roots[0] when context.cwd absent. + expect(cwd).toBe("/from-roots"); + }); + + it("renders user.message as a user entry", async () => { + const content = line({ + type: "user.message", + data: { content: "fix it" }, + timestamp: "2026-04-29T00:00:00.000Z", + }); + const { entries } = await parseCursorLog(content); + expect(entries).toHaveLength(1); + const u = entries[0]; + if (u.type !== "user") throw new Error("expected user"); + expect(u.message.content).toBe("fix it"); + }); + + it("renders assistant.message as an assistant text entry", async () => { + const content = line({ + type: "assistant.message", + data: { content: "4" }, + timestamp: "2026-04-29T00:00:00.000Z", + }); + const { entries } = await parseCursorLog(content); + expect(entries).toHaveLength(1); + const a = entries[0]; + if (a.type !== "assistant") throw new Error("expected assistant"); + const block = a.message.content[0]; + if (block.type !== "text") throw new Error("expected text block"); + expect(block.text).toBe("4"); + }); + + it("pairs tool.execution_start with tool.execution_complete via toolCallId", async () => { + const content = [ + line({ + type: "tool.execution_start", + data: { toolCallId: "call_abc", toolName: "bash", arguments: { command: "ls" } }, + timestamp: "2026-04-29T00:00:00.000Z", + }), + line({ + type: "tool.execution_complete", + data: { + toolCallId: "call_abc", + success: true, + result: { content: "ok\n", detailedContent: "ok\n" }, + duration: 20, + }, + timestamp: "2026-04-29T00:00:01.000Z", + }), + ].join("\n"); + const { entries } = await parseCursorLog(content); + expect(entries).toHaveLength(1); + const a = entries[0]; + if (a.type !== "assistant") throw new Error("expected assistant"); + const block = a.message.content[0]; + if (block.type !== "tool_use") throw new Error("expected tool_use"); + expect(block.name).toBe("bash"); + expect(block.input).toEqual({ command: "ls" }); + expect(block.result?.durationMs).toBe(20); + expect(block.result?.content).toBe("ok\n"); + }); + + it("accepts the camelCase tool variant via tool_use_id + tool_name + tool_input", async () => { + const content = [ + line({ + type: "preToolUse", + data: { tool_use_id: "tu_1", tool_name: "Bash", tool_input: { command: "echo" } }, + timestamp: "2026-04-29T00:00:00.000Z", + }), + line({ + type: "postToolUse", + data: { tool_use_id: "tu_1", tool_output: "echo\n" }, + timestamp: "2026-04-29T00:00:00.250Z", + }), + ].join("\n"); + const { entries } = await parseCursorLog(content); + expect(entries).toHaveLength(1); + const a = entries[0]; + if (a.type !== "assistant") throw new Error("expected assistant"); + const block = a.message.content[0]; + if (block.type !== "tool_use") throw new Error("expected tool_use"); + expect(block.name).toBe("Bash"); + expect(block.input).toEqual({ command: "echo" }); + expect(block.result?.content).toBe("echo\n"); + // No duration field — falls back to timestamp diff. + expect(block.result?.durationMs).toBe(250); + }); + + it("preserves unknown record types as system entries", async () => { + const content = [ + line({ + type: "system.message", + data: { content: "x" }, + timestamp: "2026-04-29T00:00:00.000Z", + }), + line({ + type: "future.unknown_event", + data: {}, + timestamp: "2026-04-29T00:00:01.000Z", + }), + ].join("\n"); + const { entries } = await parseCursorLog(content); + expect(entries).toHaveLength(2); + expect(entries.every((e) => e.type === "system")).toBe(true); + }); + + it("orphan tool.execution_complete is preserved as system", async () => { + const content = line({ + type: "tool.execution_complete", + data: { toolCallId: "ghost", result: { content: "x" } }, + timestamp: "2026-04-29T00:00:00.000Z", + }); + const { entries } = await parseCursorLog(content); + expect(entries).toHaveLength(1); + expect(entries[0].type).toBe("system"); + }); + + it("skips records with missing timestamp or invalid JSON", async () => { + const content = [ + "not json", + line({ type: "user.message", data: { content: "no ts" } }), + line({ type: "user.message", data: { content: "valid" }, timestamp: "2026-04-29T00:00:00.000Z" }), + ].join("\n"); + const { entries, rawLines } = await parseCursorLog(content); + expect(rawLines).toHaveLength(2); + expect(entries).toHaveLength(1); + expect(entries[0].type).toBe("user"); + }); +}); + +describe("lib/cursor-sessions: findCursorTranscript + getCursorSessionLog", () => { + let originalHome: string | undefined; + let originalCursorHome: string | undefined; + let fakeHome: string; + let findCursorTranscript: typeof import("@/lib/cursor-sessions").findCursorTranscript; + let getCursorSessionLog: typeof import("@/lib/cursor-sessions").getCursorSessionLog; + + beforeEach(async () => { + originalHome = process.env.HOME; + originalCursorHome = process.env.CURSOR_HOME; + fakeHome = mkdtempSync(join(tmpdir(), "cursor-sessions-")); + process.env.HOME = fakeHome; + delete process.env.CURSOR_HOME; + vi.resetModules(); + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, homedir: () => fakeHome }; + }); + vi.doMock("os", async () => { + const actual = await vi.importActual("os"); + return { ...actual, homedir: () => fakeHome }; + }); + ({ findCursorTranscript, getCursorSessionLog } = await import("@/lib/cursor-sessions")); + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + if (originalCursorHome !== undefined) process.env.CURSOR_HOME = originalCursorHome; + rmSync(fakeHome, { recursive: true, force: true }); + vi.doUnmock("node:os"); + vi.doUnmock("os"); + vi.resetModules(); + }); + + it("returns null when ~/.cursor/ does not exist", () => { + expect(findCursorTranscript("nope")).toBeNull(); + }); + + it("returns null for empty session id", () => { + expect(findCursorTranscript("")).toBeNull(); + }); + + it("rejects path-traversal session ids that would escape the session-state root", () => { + expect(findCursorTranscript("../../etc")).toBeNull(); + expect(findCursorTranscript("..")).toBeNull(); + expect(findCursorTranscript("/absolute/path")).toBeNull(); + }); + + it.each(["agent-sessions", "conversations", "sessions"])( + "locates events.jsonl under ~/.cursor/%s//", + (sub) => { + const sessionId = "11111111-1111-1111-1111-111111111111"; + const dir = join(fakeHome, ".cursor", sub, sessionId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "events.jsonl"), ""); + expect(findCursorTranscript(sessionId)).toBe(join(dir, "events.jsonl")); + }, + ); + + it("getCursorSessionLog parses the transcript and returns cwd from session.start", async () => { + const sessionId = "33333333-3333-3333-3333-333333333333"; + const dir = join(fakeHome, ".cursor", "agent-sessions", sessionId); + mkdirSync(dir, { recursive: true }); + const event = JSON.stringify({ + type: "session.start", + data: { context: { cwd: "/proj" } }, + timestamp: "2026-04-29T00:00:00.000Z", + }); + writeFileSync(join(dir, "events.jsonl"), event); + const result = await getCursorSessionLog(sessionId); + expect(result).not.toBeNull(); + expect(result!.cwd).toBe("/proj"); + expect(result!.filePath).toBe(join(dir, "events.jsonl")); + }); + + it("getCursorSessionLog returns null when no transcript file is present", async () => { + const sessionId = "55555555-5555-5555-5555-555555555555"; + const dir = join(fakeHome, ".cursor", "agent-sessions", sessionId); + mkdirSync(dir, { recursive: true }); + // No events.jsonl, transcript.jsonl, or messages.jsonl — directory is empty. + const result = await getCursorSessionLog(sessionId); + expect(result).toBeNull(); + }); + + it("getCursorSessionLog returns null when readFile fails for the resolved path", async () => { + // events.jsonl is a directory — existsSync passes, readFile throws EISDIR. + const sessionId = "66666666-6666-6666-6666-666666666666"; + const dir = join(fakeHome, ".cursor", "agent-sessions", sessionId); + mkdirSync(dir, { recursive: true }); + mkdirSync(join(dir, "events.jsonl")); + const result = await getCursorSessionLog(sessionId); + expect(result).toBeNull(); + }); + + it("honors CURSOR_HOME when set", async () => { + const altHome = mkdtempSync(join(tmpdir(), "cursor-alt-home-")); + try { + process.env.CURSOR_HOME = altHome; + vi.resetModules(); + const { findCursorTranscript: ft } = await import("@/lib/cursor-sessions"); + const sessionId = "77777777-7777-7777-7777-777777777777"; + const dir = join(altHome, "agent-sessions", sessionId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "events.jsonl"), ""); + expect(ft(sessionId)).toBe(join(dir, "events.jsonl")); + } finally { + delete process.env.CURSOR_HOME; + rmSync(altHome, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/lib/projects.test.ts b/__tests__/lib/projects.test.ts index eae8f278..37552b39 100644 --- a/__tests__/lib/projects.test.ts +++ b/__tests__/lib/projects.test.ts @@ -19,7 +19,7 @@ vi.mock("@/lib/runtime-cache", () => ({ runtimeCache: vi.fn((fn: (...args: unknown[]) => unknown) => fn), })); -// Default Codex / Copilot stubs return no projects — individual tests override via mockResolvedValueOnce. +// Default Codex / Copilot / Cursor stubs return no projects — individual tests override via mockResolvedValueOnce. vi.mock("@/lib/codex-projects", () => ({ getCodexProjects: vi.fn(async () => []), })); @@ -28,13 +28,19 @@ vi.mock("@/lib/copilot-projects", () => ({ getCopilotProjects: vi.fn(async () => []), })); +vi.mock("@/lib/cursor-projects", () => ({ + getCursorProjects: vi.fn(async () => []), +})); + import { readdir, stat } from "fs/promises"; import { extractSessionId, getProjectFolders, getSessionFiles, type ProjectFolder } from "@/lib/projects"; import { getCodexProjects } from "@/lib/codex-projects"; import { getCopilotProjects } from "@/lib/copilot-projects"; +import { getCursorProjects } from "@/lib/cursor-projects"; const mockGetCodexProjects = vi.mocked(getCodexProjects); const mockGetCopilotProjects = vi.mocked(getCopilotProjects); +const mockGetCursorProjects = vi.mocked(getCursorProjects); const mockReaddir = vi.mocked(readdir); const mockStat = vi.mocked(stat); @@ -247,6 +253,88 @@ describe("getProjectFolders", () => { expect(result[0].lastModified.getTime()).toBe(copilotMtime.getTime()); }); + it("merges Claude + Codex + Copilot + Cursor rows that share an encoded name", async () => { + const claudeMtime = new Date("2024-01-01T00:00:00Z"); + const codexMtime = new Date("2025-01-01T00:00:00Z"); + const copilotMtime = new Date("2026-03-15T00:00:00Z"); + const cursorMtime = new Date("2026-06-15T00:00:00Z"); + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([ + { name: "-home-u-quad", isDirectory: () => true, isFile: () => false } as any, + ] as any); + mockStat.mockResolvedValueOnce({ mtime: claudeMtime } as any); + mockGetCodexProjects.mockResolvedValueOnce([ + { + name: "-home-u-quad", + path: "/home/u/quad", + isDirectory: true, + lastModified: codexMtime, + lastModifiedFormatted: codexMtime.toISOString(), + cli: ["codex"], + } satisfies ProjectFolder, + ]); + mockGetCopilotProjects.mockResolvedValueOnce([ + { + name: "-home-u-quad", + path: "/home/u/quad", + isDirectory: true, + lastModified: copilotMtime, + lastModifiedFormatted: copilotMtime.toISOString(), + cli: ["copilot"], + } satisfies ProjectFolder, + ]); + mockGetCursorProjects.mockResolvedValueOnce([ + { + name: "-home-u-quad", + path: "/home/u/quad", + isDirectory: true, + lastModified: cursorMtime, + lastModifiedFormatted: cursorMtime.toISOString(), + cli: ["cursor"], + } satisfies ProjectFolder, + ]); + + const result = await getProjectFolders(); + expect(result).toHaveLength(1); + expect(result[0].cli).toEqual(["claude", "codex", "copilot", "cursor"]); + // Newest mtime wins (cursor in this case). + expect(result[0].lastModified.getTime()).toBe(cursorMtime.getTime()); + }); + + it("includes Cursor-only projects (no matching Claude/Codex/Copilot folder)", async () => { + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([] as any); + mockGetCursorProjects.mockResolvedValueOnce([ + { + name: "-home-u-cursor-only", + path: "/home/u/cursor-only", + isDirectory: true, + lastModified: new Date("2026-06-15T00:00:00Z"), + lastModifiedFormatted: "2026-06-15T00:00:00.000Z", + cli: ["cursor"], + } satisfies ProjectFolder, + ]); + + const result = await getProjectFolders(); + expect(result).toHaveLength(1); + expect(result[0].cli).toEqual(["cursor"]); + expect(result[0].path).toBe("/home/u/cursor-only"); + }); + + it("falls back gracefully when getCursorProjects rejects", async () => { + mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); + mockReaddir.mockResolvedValueOnce([ + { name: "-home-u-claude", isDirectory: () => true, isFile: () => false } as any, + ] as any); + mockStat.mockResolvedValueOnce({ mtime: new Date("2026-04-01T00:00:00Z") } as any); + mockGetCursorProjects.mockRejectedValueOnce(new Error("scan failed")); + + const result = await getProjectFolders(); + // Claude row still surfaces even though Cursor scan blew up. + expect(result).toHaveLength(1); + expect(result[0].cli).toEqual(["claude"]); + }); + it("includes Copilot-only projects (no matching Claude or Codex folder)", async () => { mockStat.mockResolvedValueOnce({ isDirectory: () => true } as any); mockReaddir.mockResolvedValueOnce([] as any); diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 4572cf2c..4dcd6ed7 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -90,10 +90,12 @@ function SessionCell({ const isCopilot = integration === "copilot" || (transcriptPath?.includes("/.copilot/session-state/") ?? false); - if (isCodex || isCopilot) { + const isCursor = + integration === "cursor" || (transcriptPath?.includes("/.cursor/") ?? false); + if (isCodex || isCopilot || isCursor) { // The session route auto-detects CLI by file location, so [name] only // affects the breadcrumb. Encode the cwd Claude-style when we have it. - const fallbackSeg = isCodex ? "codex" : "copilot"; + const fallbackSeg = isCodex ? "codex" : isCopilot ? "copilot" : "cursor"; const projectSeg = cwd ? encodeCwdForUrl(cwd) : fallbackSeg; return (