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**.
@@ -44,10 +44,17 @@ 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 — 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 (
setFilterCli(e.target.value as "" | "claude" | "codex" | "copilot")}
+ onChange={(e) => {
+ const v = e.target.value;
+ setFilterCli(v === "" || isKnownCli(v) ? v : "");
+ }}
className="h-7 rounded-md border border-border bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/40 transition-shadow"
aria-label="Filter by CLI"
>
diff --git a/app/project/[name]/page.tsx b/app/project/[name]/page.tsx
index a48ebbf3..12e1c8b4 100644
--- a/app/project/[name]/page.tsx
+++ b/app/project/[name]/page.tsx
@@ -2,6 +2,8 @@
import { Suspense } from "react";
import { resolveProjectPath, getCachedSessionFiles, type SessionFile } from "@/lib/projects";
import { getCachedCodexSessionsByEncodedName } from "@/lib/codex-projects";
+import { getCachedCopilotSessionsByEncodedName } from "@/lib/copilot-projects";
+import { getCachedCursorSessionsByEncodedName } from "@/lib/cursor-projects";
import { logWarn } from "@/lib/logger";
import { decodeFolderName } from "@/lib/paths";
import { notFound } from "next/navigation";
@@ -23,7 +25,8 @@ interface ProjectPageProps {
export default async function ProjectPage({ params }: ProjectPageProps) {
const { name } = await params;
// Resolve under ~/.claude/projects/. Validation may throw RangeError; on bad input
- // we still want to try Codex, since a Codex-only cwd never escapes this check.
+ // we still want to try the external CLIs, since a non-Claude-only cwd never
+ // escapes this check.
let claudeProjectPath: string | null = null;
try {
claudeProjectPath = resolveProjectPath(name);
@@ -39,18 +42,30 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
claudeSessions = await getCachedSessionFiles(claudeProjectPath);
}
// Note: decodeFolderName is lossy when cwds contain `-` (every `-` becomes `/`),
- // so we look up Codex sessions by re-encoding each session's cwd and matching the slug.
- const codex = await getCachedCodexSessionsByEncodedName(name);
+ // so each external CLI looks up sessions by re-encoding cwd and matching the slug.
+ const [codex, copilot, cursor] = await Promise.all([
+ getCachedCodexSessionsByEncodedName(name),
+ getCachedCopilotSessionsByEncodedName(name),
+ getCachedCursorSessionsByEncodedName(name),
+ ]);
const codexSessions = codex.sessions;
+ const copilotSessions = copilot.sessions;
+ const cursorSessions = cursor.sessions;
- if (!claudeExists && codexSessions.length === 0) {
+ if (
+ !claudeExists &&
+ codexSessions.length === 0 &&
+ copilotSessions.length === 0 &&
+ cursorSessions.length === 0
+ ) {
notFound();
}
- // Prefer the canonical Codex cwd when available — `decodeFolderName(name)` is
- // ambiguous for cwds containing `-` (every `-` becomes `/`). Codex transcripts
- // record the literal cwd, so they round-trip correctly.
- const canonicalRoot = codex.cwd ?? decodedName;
+ // Prefer a canonical cwd recovered from any external store when available —
+ // `decodeFolderName(name)` is ambiguous for cwds containing `-` (every `-`
+ // becomes `/`). Each external transcript records the literal cwd, so they
+ // round-trip correctly. First non-null wins (Codex → Copilot → Cursor).
+ const canonicalRoot = codex.cwd ?? copilot.cwd ?? cursor.cwd ?? decodedName;
// Project header metadata
let lastModified: Date | null = null;
@@ -64,18 +79,24 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
logWarn(`Failed to get stats for project ${decodedName}:`, error);
}
}
- const newestCodex = codexSessions[0]?.lastModified ?? null;
- if (newestCodex && (!lastModified || newestCodex.getTime() > lastModified.getTime())) {
- lastModified = newestCodex;
- lastModifiedFormatted = formatDate(newestCodex);
+ const newestExternal = [codexSessions[0], copilotSessions[0], cursorSessions[0]]
+ .filter((s): s is SessionFile => !!s)
+ .map((s) => s.lastModified)
+ .reduce((acc, d) => (!acc || d.getTime() > acc.getTime() ? d : acc), null);
+ if (newestExternal && (!lastModified || newestExternal.getTime() > lastModified.getTime())) {
+ lastModified = newestExternal;
+ lastModifiedFormatted = formatDate(newestExternal);
}
- const sessionFiles: SessionFile[] = [...claudeSessions, ...codexSessions].sort(
- (a, b) => b.lastModified.getTime() - a.lastModified.getTime(),
- );
+ const sessionFiles: SessionFile[] = [
+ ...claudeSessions,
+ ...codexSessions,
+ ...copilotSessions,
+ ...cursorSessions,
+ ].sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
// Path line: prefer the Claude storage dir if present (matches existing UX);
- // otherwise show the canonical Codex cwd.
+ // otherwise show the canonical cwd recovered from the first external store.
const displayPath = claudeExists && claudeProjectPath ? claudeProjectPath : canonicalRoot;
return (
diff --git a/app/project/[name]/session/[sessionId]/page.tsx b/app/project/[name]/session/[sessionId]/page.tsx
index 6388e958..9a645434 100644
--- a/app/project/[name]/session/[sessionId]/page.tsx
+++ b/app/project/[name]/session/[sessionId]/page.tsx
@@ -5,6 +5,7 @@ import { notFound } from "next/navigation";
import { getCachedSessionLog, type LogEntry } from "@/lib/log-entries";
import { getCachedCodexSessionLog } from "@/lib/codex-sessions";
import { getCachedCopilotSessionLog } from "@/lib/copilot-sessions";
+import { getCachedCursorSessionLog } from "@/lib/cursor-sessions";
import { decodeFolderName } from "@/lib/paths";
import { baseSessionId } from "@/lib/utils/session-id";
import { resolveProjectPath, UUID_RE } from "@/lib/projects";
@@ -36,7 +37,7 @@ export default async function SessionPage({ params }: SessionPageProps) {
let entries: LogEntry[] | null = null;
let rawLines: Record[] | null = null;
let error: string | null = null;
- let cli: "claude" | "codex" | "copilot" = "claude";
+ let cli: "claude" | "codex" | "copilot" | "cursor" = "claude";
let externalCwd: string | undefined;
try {
@@ -47,9 +48,9 @@ export default async function SessionPage({ params }: SessionPageProps) {
} catch (e) {
const isNotFound = (e as NodeJS.ErrnoException).code === "ENOENT";
if (isNotFound) {
- // Fall back to Codex transcripts. Codex stores files at
- // ~/.codex/sessions////.jsonl,
- // so the [name] segment is irrelevant — we look up by sessionId.
+ // Fall back through external stores in order: Codex → Copilot → Cursor.
+ // Each store keys by sessionId rather than the project slug, so the
+ // [name] segment is irrelevant on these branches.
const codex = await getCachedCodexSessionLog(decodedSessionId);
if (codex) {
entries = codex.entries;
@@ -57,8 +58,6 @@ export default async function SessionPage({ params }: SessionPageProps) {
externalCwd = codex.cwd;
cli = "codex";
} else {
- // Final fallback: Copilot CLI stores sessions at
- // ~/.copilot/session-state//events.jsonl.
const copilot = await getCachedCopilotSessionLog(decodedSessionId);
if (copilot) {
entries = copilot.entries;
@@ -66,7 +65,15 @@ export default async function SessionPage({ params }: SessionPageProps) {
externalCwd = copilot.cwd;
cli = "copilot";
} else {
- error = "Session log file not found.";
+ const cursor = await getCachedCursorSessionLog(decodedSessionId);
+ if (cursor) {
+ entries = cursor.entries;
+ rawLines = cursor.rawLines;
+ externalCwd = cursor.cwd;
+ cli = "cursor";
+ } else {
+ error = "Session log file not found.";
+ }
}
}
} else {
@@ -81,7 +88,9 @@ export default async function SessionPage({ params }: SessionPageProps) {
? `OpenAI Codex${externalCwd ? ` · ${externalCwd}` : ""}`
: cli === "copilot"
? `GitHub Copilot${externalCwd ? ` · ${externalCwd}` : ""}`
- : decodedName;
+ : cli === "cursor"
+ ? `Cursor Agent${externalCwd ? ` · ${externalCwd}` : ""}`
+ : decodedName;
return (
@@ -140,7 +149,12 @@ export default async function SessionPage({ params }: SessionPageProps) {
entries={entries}
projectName={
isExternal
- ? (externalCwd ?? (cli === "codex" ? "OpenAI Codex" : "GitHub Copilot"))
+ ? (externalCwd ??
+ (cli === "codex"
+ ? "OpenAI Codex"
+ : cli === "copilot"
+ ? "GitHub Copilot"
+ : "Cursor Agent"))
: decodedName
}
sessionId={decodedSessionId}
diff --git a/assets/logos/cursor-dark.svg b/assets/logos/cursor-dark.svg
new file mode 100644
index 00000000..37df44af
--- /dev/null
+++ b/assets/logos/cursor-dark.svg
@@ -0,0 +1 @@
+Cursor Agent
diff --git a/assets/logos/cursor-light.svg b/assets/logos/cursor-light.svg
new file mode 100644
index 00000000..e06b9fde
--- /dev/null
+++ b/assets/logos/cursor-light.svg
@@ -0,0 +1 @@
+Cursor Agent
diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs
index 2ab7d8c7..913d016f 100755
--- a/bin/failproofai.mjs
+++ b/bin/failproofai.mjs
@@ -43,7 +43,7 @@ const hookIdx = args.indexOf("--hook");
if (hookIdx >= 0) {
if (!args[hookIdx + 1]) {
console.error("Error: Missing event type after --hook");
- console.error("Usage: failproofai --hook [--cli ]");
+ console.error("Usage: failproofai --hook [--cli ]");
process.exit(1);
}
const eventType = args[hookIdx + 1];
@@ -52,7 +52,7 @@ if (hookIdx >= 0) {
// Default cli=claude preserves back-compat for hooks installed before
// multi-CLI support landed.
const cli =
- cliArg && (cliArg === "claude" || cliArg === "codex" || cliArg === "copilot")
+ cliArg && (cliArg === "claude" || cliArg === "codex" || cliArg === "copilot" || cliArg === "cursor")
? cliArg
: "claude";
try {
@@ -105,17 +105,19 @@ COMMANDS
policies, p List all available policies and their status
policies --install, -i Enable policies in agent CLI settings
[names...] Specific policy names to enable
- --cli claude|codex|copilot Agent CLI(s) to install for; space-separated
- (e.g. --cli claude codex copilot) or repeated.
+ --cli claude|codex|copilot|cursor
+ Agent CLI(s) to install for; space-separated
+ (e.g. --cli claude codex copilot cursor) or repeated.
Default: detect installed CLIs and prompt.
--scope user|project|local Config scope to write to (default: user)
- (Codex / Copilot support user|project only)
+ (Codex / Copilot / Cursor support user|project only)
--beta Include beta policies
--custom, -c Path to a JS file of custom policies
policies --uninstall, -u Disable policies or remove hooks
[names...] Specific policy names to disable
- --cli claude|codex|copilot Agent CLI(s) to uninstall from
+ --cli claude|codex|copilot|cursor
+ Agent CLI(s) to uninstall from
--scope user|project|local|all Config scope to remove from (default: user)
--beta Remove only beta policies
--custom, -c Clear the customPoliciesPath from config
@@ -142,12 +144,14 @@ EXAMPLES
failproofai policies --install block-sudo sanitize-api-keys --scope project
failproofai policies --install --cli codex --scope project
failproofai policies --install --cli copilot --scope project
- failproofai policies --install --cli claude codex copilot
+ failproofai policies --install --cli cursor --scope project
+ failproofai policies --install --cli claude codex copilot cursor
failproofai policies --install --custom ./my-policies.js
failproofai policies -i -c ./my-policies.js
failproofai policies --uninstall block-sudo
failproofai policies --uninstall --cli codex
failproofai policies --uninstall --cli copilot
+ failproofai policies --uninstall --cli cursor
failproofai policies --uninstall --custom
LINKS
@@ -187,19 +191,21 @@ USAGE
OPTIONS (install)
[names...] Specific policy names to enable (omit for interactive)
- --cli claude|codex|copilot Agent CLI(s) to install for; space-separated
- (e.g. --cli claude codex copilot) or repeated.
+ --cli claude|codex|copilot|cursor
+ Agent CLI(s) to install for; space-separated
+ (e.g. --cli claude codex copilot cursor) or repeated.
Omit to detect installed CLIs and prompt (or
auto-pick if only one is found).
--scope user|project|local Config scope to write to (default: user)
- (Codex / Copilot support user|project only)
+ (Codex / Copilot / Cursor support user|project only)
--beta Include beta policies
--custom, -c Path to a JS file of custom policies
(skips interactive prompt; validates file first)
OPTIONS (uninstall)
[names...] Specific policy names to disable (omit to remove hooks)
- --cli claude|codex|copilot Agent CLI(s) to uninstall from
+ --cli claude|codex|copilot|cursor
+ Agent CLI(s) to uninstall from
--scope user|project|local|all Config scope to remove from (default: user)
--beta Remove only beta policies
--custom, -c Clear the customPoliciesPath from config
@@ -210,12 +216,14 @@ EXAMPLES
failproofai policies --install block-sudo sanitize-api-keys
failproofai policies --install --cli codex --scope project
failproofai policies --install --cli copilot --scope project
- failproofai policies --install --cli claude codex copilot
+ failproofai policies --install --cli cursor --scope project
+ failproofai policies --install --cli claude codex copilot cursor
failproofai policies --install --custom ./my-policies.js
failproofai policies -i -c ./my-policies.js
failproofai policies --uninstall block-sudo
failproofai policies --uninstall --cli codex
failproofai policies --uninstall --cli copilot
+ failproofai policies --uninstall --cli cursor
failproofai policies -u
failproofai policies --uninstall --custom
`.trimStart());
@@ -247,7 +255,7 @@ EXAMPLES
// --cli claude codex copilot
// --cli claude --cli codex
// Values are consumed greedily until the next flag or end of argv.
- const VALID_CLIS = new Set(["claude", "codex", "copilot"]);
+ const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor"]);
const cliFlagValues = [];
const cliConsumedIdxs = new Set();
const cliFlagIdxs = subArgs.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0);
@@ -264,7 +272,7 @@ EXAMPLES
consumed++;
}
if (consumed === 0) {
- throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot (or any subset)");
+ throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor (or any subset)");
}
}
@@ -327,7 +335,7 @@ EXAMPLES
}
// --cli accepts one or more space-separated values; same parser as install.
- const VALID_CLIS = new Set(["claude", "codex", "copilot"]);
+ const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor"]);
const cliFlagValues = [];
const cliConsumedIdxs = new Set();
const cliFlagIdxs = subArgs.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0);
@@ -344,7 +352,7 @@ EXAMPLES
consumed++;
}
if (consumed === 0) {
- throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot (or any subset)");
+ throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor (or any subset)");
}
}
diff --git a/docs/configuration.mdx b/docs/configuration.mdx
index 9b1b3fa5..fd3f1b09 100644
--- a/docs/configuration.mdx
+++ b/docs/configuration.mdx
@@ -195,17 +195,19 @@ The `policies --install` and `policies --uninstall` commands write to your agent
- **Claude Code**: `~/.claude/settings.json` (user), `/.claude/settings.json` (project), `/.claude/settings.local.json` (local)
- **OpenAI Codex**: `~/.codex/hooks.json` (user), `/.codex/hooks.json` (project) — Codex doesn't have a `local` scope
- **GitHub Copilot CLI _(beta)_**: `~/.copilot/hooks/failproofai.json` (user), `/.github/hooks/failproofai.json` (project) — Copilot has no `local` scope. Hook entries use Copilot's OS-keyed `bash`/`powershell` command fields with `timeoutSec`; the file carries a top-level `version: 1` marker. Copilot CLI support is **beta** while we verify the `events.jsonl` record schema (which the public docs do not specify) against more real-world sessions.
+ - **Cursor Agent _(beta)_**: `~/.cursor/hooks.json` (user), `/.cursor/hooks.json` (project) — Cursor has no `local` scope. Hook entries use the Claude-shaped `{type, command, timeout}` form (no `bash`/`powershell` split), but stored under camelCase event keys (`preToolUse`, `beforeSubmitPrompt`, …) in a flat array per Cursor's [hooks schema](https://cursor.com/docs/hooks); the file carries a top-level `version: 1` marker. The handler canonicalizes camelCase → PascalCase via `CURSOR_EVENT_MAP` so existing builtin policies fire unchanged. Cursor Agent support is **beta** while we verify Cursor's transcript on-disk format (not specified in the public docs) against more real-world installs.
- **`policies-config.json`** — tells failproofai which policies to evaluate and with what params (shared across all agent CLIs)
-Pass `--cli claude|codex|copilot` to target a specific agent (space-separated or repeated for any subset):
+Pass `--cli claude|codex|copilot|cursor` to target a specific agent (space-separated or repeated for any subset):
```bash
failproofai policies --install --cli codex --scope project
failproofai policies --install --cli copilot --scope project
-failproofai policies --install --cli claude codex copilot
+failproofai policies --install --cli cursor --scope project
+failproofai policies --install --cli claude codex copilot cursor
```
-When `--cli` is omitted, `failproofai` detects which agent CLIs are installed (`which claude` / `which codex` / `which copilot`):
+When `--cli` is omitted, `failproofai` detects which agent CLIs are installed (`which claude` / `which codex` / `which copilot` / `which cursor-agent`):
- **One CLI detected** — auto-selects that CLI without prompting.
- **Multiple CLIs detected** in an interactive terminal — shows an arrow-key single-select prompt: when two CLIs are present the choices are `Both`, ` only`, ` only`; with three CLIs the first option becomes `All` (↑↓ to move, Enter to select, ^C to quit).
diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx
index 65448f7b..22dc64e9 100644
--- a/docs/dashboard.mdx
+++ b/docs/dashboard.mdx
@@ -24,11 +24,11 @@ The dashboard reads directly from the filesystem - your Claude Code project fold
### Projects
-Lists all Claude Code, OpenAI Codex, and GitHub Copilot CLI _(beta)_ projects found on your machine. Claude projects are discovered from `~/.claude/projects/` (or the path set by `CLAUDE_PROJECTS_PATH`); Codex projects are discovered by scanning every transcript under `~/.codex/sessions////*.jsonl` and grouping by the `cwd` recorded in each session's first record; Copilot CLI projects are discovered by scanning each `~/.copilot/session-state//workspace.yaml` (configurable via `COPILOT_HOME`) and grouping by its `cwd` field. A project that has been used by multiple CLIs renders as a single row with all matching badges. Use the **CLI** dropdown above the table to filter by a specific agent CLI; the URL preserves your selection as `?cli=claude|codex|copilot`.
+Lists all Claude Code, OpenAI Codex, GitHub Copilot CLI _(beta)_, and Cursor Agent _(beta)_ projects found on your machine. Claude projects are discovered from `~/.claude/projects/` (or the path set by `CLAUDE_PROJECTS_PATH`); Codex projects are discovered by scanning every transcript under `~/.codex/sessions////*.jsonl` and grouping by the `cwd` recorded in each session's first record; Copilot CLI projects are discovered by scanning each `~/.copilot/session-state//workspace.yaml` (configurable via `COPILOT_HOME`) and grouping by its `cwd` field; Cursor Agent projects are discovered by scanning per-session metadata under `~/.cursor/agent-sessions//` (configurable via `CURSOR_HOME`, with `conversations/` and `sessions/` probed as fallbacks) for a `cwd` scalar in `meta.json` / `session.json` / `workspace.yaml`. A project that has been used by multiple CLIs renders as a single row with all matching badges. Use the **CLI** dropdown above the table to filter by a specific agent CLI; the URL preserves your selection as `?cli=claude|codex|copilot|cursor`.
Each project shows:
- Project name (derived from the folder path)
-- A CLI badge — `Claude Code` (orange), `OpenAI Codex` (purple), and/or `GitHub Copilot` (blue)
+- A CLI badge — `Claude Code` (orange), `OpenAI Codex` (purple), `GitHub Copilot` (blue), and/or `Cursor Agent` (emerald)
- Date of most recent session activity
Click a project to see its sessions.
@@ -47,7 +47,7 @@ Click a session to open the session viewer.
### Session viewer
-The session viewer answers the key question for autonomous agents: what did the agent do, and did it stay on track? A CLI badge beside the header indicates whether the session is a Claude Code or OpenAI Codex transcript. It shows a timeline of everything that happened in a session:
+The session viewer answers the key question for autonomous agents: what did the agent do, and did it stay on track? A CLI badge beside the header indicates whether the session is a Claude Code, OpenAI Codex, GitHub Copilot CLI, or Cursor Agent transcript. It shows a timeline of everything that happened in a session:
- **Messages** - Claude's text responses and user prompts
- **Tool calls** - Every tool Claude invoked, with its input and output
@@ -70,9 +70,9 @@ A two-tab page for managing policies and reviewing activity.
- Full paginated history of every hook event that has fired across all sessions
- - Filter by decision, event type, CLI (Claude Code / OpenAI Codex / GitHub Copilot _(beta)_), policy name, or session ID
- - Each row shows: timestamp, policy name, decision, CLI badge (orange = Claude Code, purple = OpenAI Codex, blue = GitHub Copilot), tool name, session ID, and the reason for deny/instruct decisions
- - Click a session ID to open its transcript — the viewer auto-detects which CLI fired the hook (Claude `~/.claude/projects/…`, Codex `~/.codex/sessions/…`, Copilot CLI `~/.copilot/session-state//events.jsonl`) and renders the matching CLI badge in the header
+ - Filter by decision, event type, CLI (Claude Code / OpenAI Codex / GitHub Copilot _(beta)_ / Cursor Agent _(beta)_), policy name, or session ID
+ - Each row shows: timestamp, policy name, decision, CLI badge (orange = Claude Code, purple = OpenAI Codex, blue = GitHub Copilot, emerald = Cursor Agent), tool name, session ID, and the reason for deny/instruct decisions
+ - Click a session ID to open its transcript — the viewer auto-detects which CLI fired the hook (Claude `~/.claude/projects/…`, Codex `~/.codex/sessions/…`, Copilot CLI `~/.copilot/session-state//events.jsonl`, Cursor Agent `~/.cursor/agent-sessions//events.jsonl`) and renders the matching CLI badge in the header
diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx
index 8959a91d..5633a4b5 100644
--- a/docs/getting-started.mdx
+++ b/docs/getting-started.mdx
@@ -37,14 +37,15 @@ bun add -g failproofai
failproofai policies --install
```
- This writes hook entries into your installed agent CLIs (Claude Code's `~/.claude/settings.json`, OpenAI Codex's `~/.codex/hooks.json`, or GitHub Copilot CLI's `~/.copilot/hooks/failproofai.json`). When more than one is present you'll be prompted; pass `--cli claude codex copilot` (any subset) to skip the prompt.
+ This writes hook entries into your installed agent CLIs (Claude Code's `~/.claude/settings.json`, OpenAI Codex's `~/.codex/hooks.json`, GitHub Copilot CLI's `~/.copilot/hooks/failproofai.json`, or Cursor Agent's `~/.cursor/hooks.json`). When more than one is present you'll be prompted; pass `--cli claude codex copilot cursor` (any subset) to skip the prompt.
- GitHub Copilot CLI support is **beta** — install with `--cli copilot`.
+ GitHub Copilot CLI and Cursor Agent support are **beta** — install with `--cli copilot` or `--cli cursor`.
```bash
failproofai policies --install --scope project
failproofai policies --install --cli codex --scope project
failproofai policies --install --cli copilot --scope project
+ failproofai policies --install --cli cursor --scope project
failproofai policies --install block-sudo block-rm-rf sanitize-api-keys
```
diff --git a/lib/cli-registry.ts b/lib/cli-registry.ts
index 1c37b990..ea42dd7f 100644
--- a/lib/cli-registry.ts
+++ b/lib/cli-registry.ts
@@ -5,9 +5,10 @@
* This module is **client-safe** — it only exports plain string metadata. The
* server-side project / session providers live in their own files
* (`lib/codex-projects.ts`, `lib/codex-sessions.ts`, `lib/copilot-projects.ts`,
- * `lib/copilot-sessions.ts`) and are imported lazily by `lib/projects.ts` and
- * the session viewer page so Turbopack doesn't drag Node-only deps
- * (`fs/promises`, `os`) into client bundles.
+ * `lib/copilot-sessions.ts`, `lib/cursor-projects.ts`, `lib/cursor-sessions.ts`)
+ * and are imported lazily by `lib/projects.ts` and the session viewer page so
+ * Turbopack doesn't drag Node-only deps (`fs/promises`, `os`) into client
+ * bundles.
*
* Adding a new agent CLI = three steps:
* 1. Extend `INTEGRATION_TYPES` in `src/hooks/types.ts` (server-side hook contract).
@@ -24,7 +25,7 @@
import type { IntegrationType } from "@/src/hooks/types";
/** Canonical CLI ids the registry knows about. Mirrors `INTEGRATION_TYPES`. */
-export const KNOWN_CLI_IDS = ["claude", "codex", "copilot"] as const satisfies readonly IntegrationType[];
+export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor"] as const satisfies readonly IntegrationType[];
export type CliId = (typeof KNOWN_CLI_IDS)[number];
/** Per-CLI metadata consumed by the dashboard. */
@@ -51,6 +52,11 @@ const CLI_ENTRIES: Record = {
label: "GitHub Copilot",
badgeClasses: "bg-blue-500/10 text-blue-400 border-blue-500/20",
},
+ cursor: {
+ id: "cursor",
+ label: "Cursor Agent",
+ badgeClasses: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
+ },
};
export function getCliEntry(id: string): CliEntry | undefined {
diff --git a/lib/cursor-projects.ts b/lib/cursor-projects.ts
new file mode 100644
index 00000000..52b7e976
--- /dev/null
+++ b/lib/cursor-projects.ts
@@ -0,0 +1,240 @@
+/**
+ * Cursor Agent CLI project discovery.
+ *
+ * Cursor stores per-session state under `~/.cursor/` (configurable via
+ * CURSOR_HOME). The exact subdirectory layout is not yet documented in
+ * cursor.com/docs/hooks; we probe the candidates Cursor has historically
+ * shipped (`agent-sessions/`, `conversations/`, `sessions/`) so this works
+ * across installs without a hard-coded version pin. Every candidate is a
+ * tolerant `safeReaddir` — a missing directory yields `[]`, never an
+ * exception.
+ *
+ * Each session directory is expected to contain at minimum a metadata file
+ * carrying the session's `cwd`. When a JSONL transcript is also present we
+ * surface the session under `/projects` keyed by encoded cwd so multiple
+ * stores naturally merge in `lib/projects.ts`.
+ *
+ * Refs: https://cursor.com/docs/hooks (env: CURSOR_PROJECT_DIR,
+ * CURSOR_TRANSCRIPT_PATH; transcript format intentionally unspecified).
+ */
+import { readdir, readFile, stat } from "node:fs/promises";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { encodeFolderName } from "./paths";
+import type { ProjectFolder, SessionFile } from "./projects";
+import { runtimeCache } from "./runtime-cache";
+import { batchAll } from "./concurrency";
+import { formatDate } from "./format-date";
+import { logWarn } from "./logger";
+
+/** Subdirectories under `~/.cursor/` that may carry per-session state. */
+const SESSION_ROOT_CANDIDATES = ["agent-sessions", "conversations", "sessions"] as const;
+
+/** Filenames that may carry session-level metadata (cwd, model, …). */
+const META_FILE_CANDIDATES = ["meta.json", "session.json", "workspace.json", "workspace.yaml"] as const;
+
+/** Filenames that may carry the JSONL transcript. */
+const TRANSCRIPT_FILE_CANDIDATES = ["events.jsonl", "transcript.jsonl", "messages.jsonl"] as const;
+
+function getCursorHome(): string {
+ return process.env.CURSOR_HOME || join(homedir(), ".cursor");
+}
+
+interface CursorSessionMeta {
+ metaPath: string;
+ transcriptPath: string | null;
+ sessionId: string;
+ cwd: string;
+ fileMtime: Date;
+ hasTranscript: boolean;
+}
+
+async function safeReaddir(dir: string) {
+ try {
+ return await readdir(dir, { withFileTypes: true });
+ } catch {
+ return null;
+ }
+}
+
+async function statMtime(path: string): Promise {
+ try {
+ return (await stat(path)).mtime;
+ } catch {
+ return null;
+ }
+}
+
+/** Parse a flat scalar for `cwd` from JSON-or-YAML metadata. Tolerant on purpose:
+ * Cursor's metadata format is unspecified, so a permissive regex handles either
+ * shape (`"cwd": "/foo"` in JSON or `cwd: /foo` in YAML) without a parser dep. */
+function parseCwdFromMetaText(text: string): string | undefined {
+ // JSON shape: "cwd": "/path"
+ const json = text.match(/"cwd"\s*:\s*"([^"]+)"/);
+ if (json) return json[1];
+ // YAML shape: cwd: /path
+ const yaml = text.match(/^\s*cwd\s*:\s*(.+?)\s*$/m);
+ if (yaml) return yaml[1].replace(/^['"]|['"]$/g, "");
+ return undefined;
+}
+
+/** First file that exists from `candidates`, joined under `dir`. */
+async function findFirstExisting(dir: string, candidates: readonly string[]): Promise {
+ for (const name of candidates) {
+ const path = join(dir, name);
+ if ((await statMtime(path)) !== null) return path;
+ }
+ return null;
+}
+
+async function scanCursorSessions(): Promise {
+ const home = getCursorHome();
+ const allCandidates: { sessionId: string; dir: string }[] = [];
+ for (const sub of SESSION_ROOT_CANDIDATES) {
+ const root = join(home, sub);
+ const entries = await safeReaddir(root);
+ if (!entries) continue;
+ for (const e of entries) {
+ if (!e.isDirectory()) continue;
+ allCandidates.push({ sessionId: e.name, dir: join(root, e.name) });
+ }
+ }
+ if (allCandidates.length === 0) return [];
+
+ const settled = await batchAll(
+ allCandidates.map((c) => async (): Promise => {
+ const metaPath = await findFirstExisting(c.dir, META_FILE_CANDIDATES);
+ if (!metaPath) return null;
+ let metaText: string;
+ try {
+ metaText = await readFile(metaPath, "utf-8");
+ } catch {
+ return null;
+ }
+ const cwd = parseCwdFromMetaText(metaText);
+ if (!cwd) return null;
+ const transcriptPath = await findFirstExisting(c.dir, TRANSCRIPT_FILE_CANDIDATES);
+ const transcriptMtime = transcriptPath ? await statMtime(transcriptPath) : null;
+ const metaMtime = await statMtime(metaPath);
+ const fileMtime =
+ transcriptMtime && metaMtime
+ ? new Date(Math.max(transcriptMtime.getTime(), metaMtime.getTime()))
+ : transcriptMtime ?? metaMtime ?? new Date(0);
+ return {
+ metaPath,
+ transcriptPath,
+ sessionId: c.sessionId,
+ cwd,
+ fileMtime,
+ hasTranscript: transcriptPath !== null,
+ };
+ }),
+ 16,
+ );
+ return settled
+ .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled")
+ .map((r) => r.value)
+ .filter((v): v is CursorSessionMeta => v !== null);
+}
+
+const cachedScan = runtimeCache(scanCursorSessions, 30);
+
+/** Returns one ProjectFolder per unique cwd discovered in Cursor transcripts. */
+export async function getCursorProjects(): Promise {
+ let metas: CursorSessionMeta[];
+ try {
+ metas = await cachedScan();
+ } catch (error) {
+ logWarn("Failed to scan Cursor sessions:", error);
+ return [];
+ }
+
+ // Skip metadata-only sessions whose `/projects` row would click through to an
+ // empty session list (metasToSessionFiles also filters on hasTranscript).
+ const byCwd = new Map();
+ for (const m of metas) {
+ if (!m.hasTranscript) continue;
+ const existing = byCwd.get(m.cwd);
+ if (!existing || m.fileMtime.getTime() > existing.latest.getTime()) {
+ byCwd.set(m.cwd, { latest: m.fileMtime, cwd: m.cwd });
+ }
+ }
+
+ const folders: ProjectFolder[] = [];
+ for (const { cwd, latest } of byCwd.values()) {
+ folders.push({
+ name: encodeFolderName(cwd),
+ path: cwd,
+ isDirectory: true,
+ lastModified: latest,
+ lastModifiedFormatted: formatDate(latest),
+ cli: ["cursor"],
+ });
+ }
+ folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
+ return folders;
+}
+
+function metasToSessionFiles(metas: CursorSessionMeta[]): SessionFile[] {
+ const files: SessionFile[] = metas
+ .filter((m) => m.hasTranscript && m.transcriptPath)
+ .map((m) => ({
+ name: m.sessionId,
+ path: m.transcriptPath!,
+ lastModified: m.fileMtime,
+ lastModifiedFormatted: formatDate(m.fileMtime),
+ sessionId: m.sessionId,
+ cli: "cursor",
+ }));
+ files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
+ return files;
+}
+
+/** Returns SessionFile entries for every Cursor transcript whose cwd matches `cwd` exactly. */
+export async function getCursorSessionsForCwd(cwd: string): Promise {
+ let metas: CursorSessionMeta[];
+ try {
+ metas = await cachedScan();
+ } catch (error) {
+ logWarn("Failed to scan Cursor sessions:", error);
+ return [];
+ }
+ return metasToSessionFiles(metas.filter((m) => m.cwd === cwd));
+}
+
+export interface CursorProjectByName {
+ cwd: string | null;
+ sessions: SessionFile[];
+}
+
+/**
+ * Looks up Cursor sessions for a project URL slug. `decodeFolderName` is lossy,
+ * so we re-encode each session's cwd and match in that direction. Returns both
+ * the canonical cwd and the matching sessions.
+ */
+export async function getCursorSessionsByEncodedName(name: string): Promise {
+ let metas: CursorSessionMeta[];
+ try {
+ metas = await cachedScan();
+ } catch (error) {
+ logWarn("Failed to scan Cursor sessions:", error);
+ return { cwd: null, sessions: [] };
+ }
+ const matches = metas.filter((m) => m.hasTranscript && encodeFolderName(m.cwd) === name);
+ return {
+ cwd: matches[0]?.cwd ?? null,
+ sessions: metasToSessionFiles(matches),
+ };
+}
+
+export const getCachedCursorProjects = runtimeCache(getCursorProjects, 30);
+export const getCachedCursorSessionsForCwd = runtimeCache(
+ (cwd: string) => getCursorSessionsForCwd(cwd),
+ 30,
+ { maxSize: 50 },
+);
+export const getCachedCursorSessionsByEncodedName = runtimeCache(
+ (name: string) => getCursorSessionsByEncodedName(name),
+ 30,
+ { maxSize: 50 },
+);
diff --git a/lib/cursor-sessions.ts b/lib/cursor-sessions.ts
new file mode 100644
index 00000000..983730d4
--- /dev/null
+++ b/lib/cursor-sessions.ts
@@ -0,0 +1,347 @@
+/**
+ * Cursor Agent CLI session transcript discovery + JSONL parser.
+ *
+ * Cursor stores per-session state under `~/.cursor///`
+ * (subdir is one of `agent-sessions/`, `conversations/`, or `sessions/` —
+ * see `lib/cursor-projects.ts` for the rationale). Each session directory
+ * is expected to contain a JSONL transcript. The on-disk format is not
+ * fully specified by Cursor's docs — the parser below handles the common
+ * `{ type, data, timestamp }` shape and gracefully preserves unknown record
+ * types as generic system entries so nothing is silently dropped.
+ *
+ * If a future Cursor release tightens the format, extend
+ * `parseCursorLog()` rather than fanning out new modules; the discovery
+ * helpers here key only on filenames + sessionIds.
+ *
+ * Refs: https://cursor.com/docs/hooks
+ */
+import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
+import { readFile } from "node:fs/promises";
+import { join, resolve, sep } from "node:path";
+import { homedir } from "node:os";
+import { runtimeCache } from "./runtime-cache";
+import {
+ baseEntry,
+ formatTimestamp,
+ type LogEntry,
+ type UserEntry,
+ type AssistantEntry,
+ type GenericEntry,
+ type QueueOperationEntry,
+ type ContentBlock,
+ type ToolUseBlock,
+ type LogSource,
+} from "./log-entries";
+import { formatDuration } from "./format-duration";
+
+// ── Paths ──
+
+/** Subdirectories under `~/.cursor/` that may carry per-session transcripts. */
+const SESSION_ROOT_CANDIDATES = ["agent-sessions", "conversations", "sessions"] as const;
+
+/** Filenames that may carry the JSONL transcript inside a session dir. */
+const TRANSCRIPT_FILE_CANDIDATES = ["events.jsonl", "transcript.jsonl", "messages.jsonl"] as const;
+
+/** Root directory for Cursor session state, honoring CURSOR_HOME. */
+export function getCursorHome(): string {
+ return process.env.CURSOR_HOME || join(homedir(), ".cursor");
+}
+
+/** Locate the session directory for `sessionId` by probing each candidate root.
+ * Returns null on path-traversal sessionIds or if no directory is found. */
+export function getCursorSessionDir(sessionId: string): string | null {
+ if (!sessionId) return null;
+ const home = resolve(getCursorHome());
+ for (const sub of SESSION_ROOT_CANDIDATES) {
+ const root = resolve(home, sub);
+ const candidate = resolve(root, sessionId);
+ // Containment check: equality with root means sessionId resolved to root itself.
+ if (candidate === root || !candidate.startsWith(`${root}${sep}`)) continue;
+ if (existsSync(candidate)) return candidate;
+ }
+ return null;
+}
+
+/** Locate the JSONL transcript for a session by probing each filename candidate. */
+export function findCursorTranscript(sessionId: string): string | null {
+ const dir = getCursorSessionDir(sessionId);
+ if (!dir) return null;
+ for (const name of TRANSCRIPT_FILE_CANDIDATES) {
+ const candidate = join(dir, name);
+ if (existsSync(candidate)) return candidate;
+ }
+ return null;
+}
+
+// ── Parser ──
+//
+// The parser handles the common JSONL shape `{ type, data, timestamp }` and
+// degrades gracefully for unknown record types. Field names are intentionally
+// aligned with Copilot's parser (`session.start`, `user.message`,
+// `assistant.message`, `tool.execution_start`, `tool.execution_complete`)
+// since Cursor's hook payloads share most of the snake_case naming. If a real
+// transcript format diverges materially, this module is the single place to
+// adapt — the dashboard renders whatever LogEntry[] the parser produces.
+
+interface CursorRecord {
+ type?: string;
+ data?: Record;
+ id?: string;
+ timestamp?: string;
+ parentId?: string | null;
+}
+
+interface CursorParseResult {
+ entries: LogEntry[];
+ rawLines: Record[];
+ /** Working directory pulled from the first session-start record, when available. */
+ cwd?: string;
+}
+
+interface CursorToolResult {
+ content?: string;
+ detailedContent?: string;
+}
+
+/**
+ * Parse a Cursor JSONL transcript into `LogEntry[]` plus the raw lines.
+ * Yields to the event loop every 200 lines so big transcripts don't block
+ * the request.
+ */
+export async function parseCursorLog(
+ fileContent: string,
+ source: LogSource = "session",
+): Promise {
+ const lines = fileContent.split("\n").filter((line) => line.trim() !== "");
+ const entries: LogEntry[] = [];
+ const rawLines: Record[] = [];
+ const toolUseById = new Map();
+ const toolUseStartMs = new Map();
+ let cwd: string | undefined;
+ let seenSessionStart = false;
+
+ for (let i = 0; i < lines.length; i++) {
+ if (i > 0 && i % 200 === 0) await new Promise((r) => setImmediate(r));
+
+ const line = lines[i];
+ let raw: CursorRecord;
+ try {
+ raw = JSON.parse(line) as CursorRecord;
+ } catch {
+ continue;
+ }
+
+ const rawCopy = { ...(raw as Record), _source: source };
+ rawLines.push(rawCopy);
+
+ const timestampStr = raw.timestamp;
+ if (!timestampStr) continue;
+ const date = new Date(timestampStr);
+ if (Number.isNaN(date.getTime())) continue;
+ const timestamp = date.toISOString();
+
+ const recType = raw.type;
+ const data = raw.data ?? {};
+
+ // Cursor variants: "session.start", "sessionStart", "session_start" — accept all.
+ if (recType === "session.start" || recType === "sessionStart" || recType === "session_start") {
+ const ctx = (data.context ?? data) as { cwd?: unknown; workspace_roots?: unknown };
+ const c = ctx.cwd;
+ if (typeof c === "string" && !cwd) cwd = c;
+ // Fallback to workspace_roots[0] (Cursor stdin field).
+ if (!cwd && Array.isArray(ctx.workspace_roots) && typeof ctx.workspace_roots[0] === "string") {
+ cwd = ctx.workspace_roots[0] as string;
+ }
+ const label: QueueOperationEntry["label"] = seenSessionStart ? "Session Resumed" : "Session Started";
+ seenSessionStart = true;
+ entries.push({
+ type: "queue-operation",
+ ...baseEntry(rawCopy, timestamp, date, source),
+ label,
+ } satisfies QueueOperationEntry);
+ continue;
+ }
+
+ if (recType === "user.message" || recType === "userMessage") {
+ const text = (data.content as string) ?? (data.text as string) ?? "";
+ if (!text) continue;
+ entries.push({
+ type: "user",
+ ...baseEntry(rawCopy, timestamp, date, source),
+ message: { role: "user", content: text },
+ } satisfies UserEntry);
+ continue;
+ }
+
+ if (recType === "system.message" || recType === "systemMessage") {
+ entries.push({
+ type: "system",
+ ...baseEntry(rawCopy, timestamp, date, source),
+ raw: rawCopy,
+ } satisfies GenericEntry);
+ continue;
+ }
+
+ if (recType === "assistant.message" || recType === "assistantMessage") {
+ const text = (data.content as string) ?? (data.text as string) ?? "";
+ if (!text) {
+ entries.push({
+ type: "system",
+ ...baseEntry(rawCopy, timestamp, date, source),
+ raw: rawCopy,
+ } satisfies GenericEntry);
+ continue;
+ }
+ const blocks: ContentBlock[] = [{ type: "text", text }];
+ entries.push({
+ type: "assistant",
+ ...baseEntry(rawCopy, timestamp, date, source),
+ message: { role: "assistant", content: blocks },
+ } satisfies AssistantEntry);
+ continue;
+ }
+
+ if (
+ recType === "tool.execution_start" ||
+ recType === "tool.executionStart" ||
+ recType === "preToolUse"
+ ) {
+ const callId = (data.toolCallId as string) ?? (data.tool_use_id as string);
+ const name = (data.toolName as string) ?? (data.tool_name as string) ?? "tool";
+ const args = ((data.arguments ?? data.tool_input) as Record) ?? {};
+ const id = callId ?? `${date.getTime()}-${name}`;
+ const toolUse: ToolUseBlock = {
+ type: "tool_use",
+ id,
+ name,
+ input: args,
+ };
+ const entry: AssistantEntry = {
+ type: "assistant",
+ ...baseEntry(rawCopy, timestamp, date, source),
+ message: { role: "assistant", content: [toolUse] },
+ };
+ entries.push(entry);
+ if (callId) {
+ toolUseById.set(callId, toolUse);
+ toolUseStartMs.set(callId, date.getTime());
+ }
+ continue;
+ }
+
+ if (
+ recType === "tool.execution_complete" ||
+ recType === "tool.executionComplete" ||
+ recType === "postToolUse"
+ ) {
+ const callId = (data.toolCallId as string) ?? (data.tool_use_id as string);
+ const block = callId ? toolUseById.get(callId) : undefined;
+ if (block) {
+ const startMs = toolUseStartMs.get(callId!) ?? date.getTime();
+ const result = (data.result as CursorToolResult | undefined) ?? {};
+ const reportedMs = data.duration as number | undefined;
+ const durationMs =
+ typeof reportedMs === "number" && reportedMs >= 0
+ ? reportedMs
+ : Math.max(0, date.getTime() - startMs);
+ const content =
+ result.detailedContent ?? result.content ?? (data.tool_output as string) ?? "";
+ block.result = {
+ timestamp,
+ timestampFormatted: formatTimestamp(date),
+ content: typeof content === "string" ? content : JSON.stringify(content),
+ durationMs,
+ durationFormatted: formatDuration(durationMs),
+ };
+ continue;
+ }
+ // Orphan tool result — preserve as system.
+ entries.push({
+ type: "system",
+ ...baseEntry(rawCopy, timestamp, date, source),
+ raw: rawCopy,
+ } satisfies GenericEntry);
+ continue;
+ }
+
+ // Unknown record type — preserve raw so nothing is silently dropped.
+ entries.push({
+ type: "system",
+ ...baseEntry(rawCopy, timestamp, date, source),
+ raw: rawCopy,
+ } satisfies GenericEntry);
+ }
+
+ if (entries.length > 500) await new Promise((r) => setImmediate(r));
+ entries.sort((a, b) => a.timestampMs - b.timestampMs);
+
+ return { entries, rawLines, cwd };
+}
+
+// ── Public loader ──
+
+export interface CursorSessionLogData {
+ entries: LogEntry[];
+ rawLines: Record[];
+ cwd?: string;
+ filePath: string;
+}
+
+export async function getCursorSessionLog(sessionId: string): Promise {
+ const filePath = findCursorTranscript(sessionId);
+ if (!filePath) return null;
+ let fileContent: string;
+ try {
+ fileContent = await readFile(filePath, "utf-8");
+ } catch {
+ return null;
+ }
+ const { entries, rawLines, cwd } = await parseCursorLog(fileContent, "session");
+ return { entries, rawLines, cwd, filePath };
+}
+
+export const getCachedCursorSessionLog = runtimeCache(
+ (sessionId: string) => getCursorSessionLog(sessionId),
+ 60,
+ { maxSize: 50 },
+);
+
+// ── Test helpers ──
+
+/** For tests: read raw stat of the transcript path, returning null on miss. */
+export function _statTranscript(sessionId: string): { mtimeMs: number } | null {
+ const path = findCursorTranscript(sessionId);
+ if (!path) return null;
+ try {
+ const s = statSync(path);
+ return { mtimeMs: s.mtimeMs };
+ } catch {
+ return null;
+ }
+}
+
+/** For tests: list session IDs found in any candidate session-state subdir. */
+export function _listSessionIds(): string[] {
+ const home = getCursorHome();
+ const ids: string[] = [];
+ for (const sub of SESSION_ROOT_CANDIDATES) {
+ try {
+ const entries = readdirSync(join(home, sub), { withFileTypes: true });
+ for (const e of entries) if (e.isDirectory()) ids.push(e.name);
+ } catch {
+ // missing sub — skip
+ }
+ }
+ return ids;
+}
+
+/** Surface a sync read variant used by lower-level code paths. */
+export function readCursorTranscriptSync(sessionId: string): string | null {
+ const path = findCursorTranscript(sessionId);
+ if (!path) return null;
+ try {
+ return readFileSync(path, "utf-8");
+ } catch {
+ return null;
+ }
+}
diff --git a/lib/projects.ts b/lib/projects.ts
index 8df8ed11..89860132 100644
--- a/lib/projects.ts
+++ b/lib/projects.ts
@@ -16,7 +16,7 @@ import { formatDate } from "./format-date";
export const UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
export const PATH_TRAVERSAL_RE = /(^|[\\/])\.\.($|[\\/])/;
-export type ProjectCli = "claude" | "codex" | "copilot";
+export type ProjectCli = "claude" | "codex" | "copilot" | "cursor";
export interface ProjectFolder {
name: string;
@@ -130,14 +130,14 @@ function mergeProjectFolders(...sources: ProjectFolder[][]): ProjectFolder[] {
}
export async function getProjectFolders(): Promise {
- // Lazy imports keep `lib/codex-projects.ts` and `lib/copilot-projects.ts`
- // out of the dep graph for callers that only need Claude helpers (e.g. CLI
- // codepaths).
- const [{ getCodexProjects }, { getCopilotProjects }] = await Promise.all([
+ // Lazy imports keep the per-CLI project providers out of the dep graph for
+ // callers that only need Claude helpers (e.g. CLI codepaths).
+ const [{ getCodexProjects }, { getCopilotProjects }, { getCursorProjects }] = await Promise.all([
import("./codex-projects"),
import("./copilot-projects"),
+ import("./cursor-projects"),
]);
- const [claude, codex, copilot] = await Promise.all([
+ const [claude, codex, copilot, cursor] = await Promise.all([
getClaudeProjectFolders(),
getCodexProjects().catch((error) => {
logError("Error reading Codex projects:", error);
@@ -147,8 +147,12 @@ export async function getProjectFolders(): Promise {
logError("Error reading Copilot projects:", error);
return [] as ProjectFolder[];
}),
+ getCursorProjects().catch((error) => {
+ logError("Error reading Cursor projects:", error);
+ return [] as ProjectFolder[];
+ }),
]);
- return mergeProjectFolders(claude, codex, copilot);
+ return mergeProjectFolders(claude, codex, copilot, cursor);
}
/**
diff --git a/package.json b/package.json
index c9cd762c..74da32ef 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "failproofai",
- "version": "0.0.10-beta.0",
+ "version": "0.0.10-beta.1",
"description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
"bin": {
"failproofai": "./dist/cli.mjs"
diff --git a/src/hooks/builtin-policies.ts b/src/hooks/builtin-policies.ts
index d0fe7480..860efa67 100644
--- a/src/hooks/builtin-policies.ts
+++ b/src/hooks/builtin-policies.ts
@@ -12,11 +12,11 @@ import { hookLogWarn } from "./hook-logger";
/**
* Whether `resolved` lives under an agent CLI's home directory
- * (~/.claude/, ~/.codex/, or ~/.copilot/). Used to whitelist agent self-reads
- * of their own config and transcripts.
+ * (~/.claude/, ~/.codex/, ~/.copilot/, or ~/.cursor/). Used to whitelist
+ * agent self-reads of their own config and transcripts.
*/
function isAgentInternalPath(resolved: string): boolean {
- for (const dir of [".claude", ".codex", ".copilot"]) {
+ for (const dir of [".claude", ".codex", ".copilot", ".cursor"]) {
const root = join(homedir(), dir);
if (resolved === root || resolved.startsWith(root + "/")) return true;
}
@@ -25,9 +25,10 @@ function isAgentInternalPath(resolved: string): boolean {
/**
* Whether `resolved` is a settings/hooks file for an agent CLI:
- * • Claude Code: `.claude/settings.json`, `.claude/settings.local.json`, etc.
- * • Codex: `.codex/hooks.json`
- * • Copilot CLI: `.copilot/hooks/*.json`, `.github/hooks/*.json`
+ * • Claude Code: `.claude/settings.json`, `.claude/settings.local.json`, etc.
+ * • Codex: `.codex/hooks.json`
+ * • Copilot CLI: `.copilot/hooks/*.json`, `.github/hooks/*.json`
+ * • Cursor Agent: `.cursor/hooks.json`
* These must NEVER be edited by the agent itself — that would let it disable
* its own protections.
*/
@@ -36,6 +37,7 @@ function isAgentSettingsFile(resolved: string): boolean {
if (/[\\/]\.codex[\\/]hooks\.json$/.test(resolved)) return true;
if (/[\\/]\.copilot[\\/]hooks[\\/][^/\\]+\.json$/.test(resolved)) return true;
if (/[\\/]\.github[\\/]hooks[\\/][^/\\]+\.json$/.test(resolved)) return true;
+ if (/[\\/]\.cursor[\\/]hooks\.json$/.test(resolved)) return true;
return false;
}
diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts
index 6adab17f..09e6427d 100644
--- a/src/hooks/handler.ts
+++ b/src/hooks/handler.ts
@@ -5,8 +5,14 @@
* ~/.failproofai/policies-config.json, evaluates matching policies, persists
* activity to disk, and returns the appropriate exit code + stdout response.
*/
-import type { HookEventType, IntegrationType, SessionMetadata, CodexHookEventType } from "./types";
-import { CODEX_EVENT_MAP } from "./types";
+import type {
+ HookEventType,
+ IntegrationType,
+ SessionMetadata,
+ CodexHookEventType,
+ CursorHookEventType,
+} from "./types";
+import { CODEX_EVENT_MAP, CURSOR_EVENT_MAP } from "./types";
import type { PolicyFunction, PolicyResult } from "./policy-types";
import { readMergedHooksConfig } from "./hooks-config";
import { registerBuiltinPolicies } from "./builtin-policies";
@@ -22,16 +28,21 @@ import { hookLogInfo, hookLogWarn } from "./hook-logger";
/**
* Canonicalize an event name to PascalCase. Codex sends snake_case event names
- * on stdin and as the --hook arg; Claude Code sends PascalCase. Copilot CLI is
- * installed in "VS Code compatible" PascalCase mode (see integrations.ts), so
- * its events arrive PascalCase already. The internal registry, builtin
- * policies, and policy.match.events all key on PascalCase.
+ * on stdin and as the --hook arg; Cursor sends camelCase (`preToolUse`,
+ * `beforeSubmitPrompt`); Claude Code sends PascalCase. Copilot CLI is installed
+ * in "VS Code compatible" PascalCase mode (see integrations.ts), so its events
+ * arrive PascalCase already. The internal registry, builtin policies, and
+ * policy.match.events all key on PascalCase.
*/
function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType {
if (cli === "codex") {
const mapped = CODEX_EVENT_MAP[raw as CodexHookEventType];
if (mapped) return mapped;
}
+ if (cli === "cursor") {
+ const mapped = CURSOR_EVENT_MAP[raw as CursorHookEventType];
+ if (mapped) return mapped;
+ }
// claude / copilot / unknown — already PascalCase, pass through.
// HOOK_EVENT_TYPES type-checks downstream.
return raw as HookEventType;
diff --git a/src/hooks/install-prompt.ts b/src/hooks/install-prompt.ts
index baf6e9bf..a266785e 100644
--- a/src/hooks/install-prompt.ts
+++ b/src/hooks/install-prompt.ts
@@ -60,13 +60,13 @@ export async function resolveTargetClis(
// Uninstall flow: no agent CLIs detected — nothing to remove from. Default to
// claude so removeHooks operates over Claude's scopes (no-op if no settings file).
console.log(
- "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot). " +
+ "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent). " +
"Defaulting to Claude Code; nothing will be removed if no settings file exists.\x1B[0m",
);
return ["claude"];
}
console.log(
- "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot). " +
+ "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent). " +
"Defaulting to Claude Code; hooks will activate when an agent is installed.\x1B[0m",
);
return ["claude"];
diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts
index fbdc32df..351e613e 100644
--- a/src/hooks/integrations.ts
+++ b/src/hooks/integrations.ts
@@ -18,6 +18,8 @@ import {
CODEX_EVENT_MAP,
COPILOT_HOOK_EVENT_TYPES,
COPILOT_HOOK_SCOPES,
+ CURSOR_HOOK_EVENT_TYPES,
+ CURSOR_HOOK_SCOPES,
FAILPROOFAI_HOOK_MARKER,
INTEGRATION_TYPES,
type IntegrationType,
@@ -509,12 +511,147 @@ export const copilot: Integration = {
},
};
+// ── Cursor Agent CLI integration ───────────────────────────────────────────
+//
+// Cursor's hooks.json schema is a FLAT array of hook entries per event —
+// `{ hooks: { preToolUse: [{ command, type, timeout, ... }] } }` — without
+// the Claude-style `{ hooks: [...] }` matcher wrapper. The settings file
+// carries `version: 1` like Codex/Copilot. Differences from Claude:
+// • Settings paths: ~/.cursor/hooks.json (user) and /.cursor/hooks.json (project)
+// • Event keys are camelCase (`preToolUse`, `beforeSubmitPrompt`, …); we
+// canonicalize to PascalCase in handler.ts before policy lookup
+// • Stdout decision shape differs (`{permission, user_message, agent_message,
+// additional_context}`); the Cursor branch in policy-evaluator.ts emits it
+// • No "local" scope
+// • Detected via the `cursor-agent` binary (preferred) or `agent` (legacy alias)
+//
+// Ref: https://cursor.com/docs/hooks (Schema section).
+
+interface CursorSettingsFile {
+ version?: number;
+ /** Flat array of hook entries per event — NOT wrapped in `{ hooks: [...] }`. */
+ hooks?: Record>>;
+ [key: string]: unknown;
+}
+
+export const cursor: Integration = {
+ id: "cursor",
+ displayName: "Cursor Agent",
+ scopes: CURSOR_HOOK_SCOPES,
+ eventTypes: CURSOR_HOOK_EVENT_TYPES,
+
+ getSettingsPath(scope, cwd) {
+ const base = cwd ? resolve(cwd) : process.cwd();
+ switch (scope) {
+ case "user":
+ return resolve(homedir(), ".cursor", "hooks.json");
+ case "project":
+ return resolve(base, ".cursor", "hooks.json");
+ case "local":
+ // Cursor has no "local" scope; CLI rejects --cli cursor --scope local
+ // before reaching here, but fall back to project so callers don't crash.
+ return resolve(base, ".cursor", "hooks.json");
+ }
+ },
+
+ readSettings(settingsPath) {
+ const raw = readJsonFile(settingsPath);
+ if (raw.version === undefined) raw.version = 1;
+ return raw;
+ },
+
+ writeSettings(settingsPath, settings) {
+ writeJsonFile(settingsPath, settings);
+ },
+
+ buildHookEntry(binaryPath, eventType, scope) {
+ const command =
+ scope === "project"
+ ? `npx -y failproofai --hook ${eventType} --cli cursor`
+ : `"${binaryPath}" --hook ${eventType} --cli cursor`;
+ // `timeout` is documented as ms in Cursor's schema (matches Claude).
+ return {
+ type: "command",
+ command,
+ timeout: 60_000,
+ [FAILPROOFAI_HOOK_MARKER]: true,
+ };
+ },
+
+ isFailproofaiHook: isMarkedHook,
+
+ writeHookEntries(settings, binaryPath, scope) {
+ const s = settings as CursorSettingsFile;
+ if (s.version === undefined) s.version = 1;
+ if (!s.hooks) s.hooks = {};
+
+ for (const eventType of CURSOR_HOOK_EVENT_TYPES) {
+ const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry;
+ const existing = s.hooks[eventType];
+ const entries: Array> = existing ?? [];
+ if (!existing) s.hooks[eventType] = entries;
+
+ // Idempotent: replace an existing failproofai-marked entry; otherwise append.
+ const idx = entries.findIndex((h) => isMarkedHook(h as Record));
+ if (idx >= 0) {
+ entries[idx] = hookEntry;
+ } else {
+ entries.push(hookEntry);
+ }
+ }
+ },
+
+ removeHooksFromFile(settingsPath) {
+ const settings = this.readSettings(settingsPath) as CursorSettingsFile;
+ if (!settings.hooks) return 0;
+
+ let removed = 0;
+ for (const eventType of Object.keys(settings.hooks)) {
+ const entries = settings.hooks[eventType];
+ if (!Array.isArray(entries)) continue;
+ const before = entries.length;
+ const filtered = entries.filter((h) => !isMarkedHook(h as Record));
+ removed += before - filtered.length;
+ if (filtered.length === 0) {
+ delete settings.hooks[eventType];
+ } else {
+ settings.hooks[eventType] = filtered;
+ }
+ }
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
+
+ this.writeSettings(settingsPath, settings as Record);
+ return removed;
+ },
+
+ hooksInstalledInSettings(scope, cwd) {
+ const settingsPath = this.getSettingsPath(scope, cwd);
+ if (!existsSync(settingsPath)) return false;
+ try {
+ const settings = this.readSettings(settingsPath) as CursorSettingsFile;
+ if (!settings.hooks) return false;
+ for (const entries of Object.values(settings.hooks)) {
+ if (!Array.isArray(entries)) continue;
+ if (entries.some((h) => isMarkedHook(h as Record))) return true;
+ }
+ } catch {
+ // Corrupt settings — treat as not installed
+ }
+ return false;
+ },
+
+ detectInstalled() {
+ return binaryExists("cursor-agent") || binaryExists("agent");
+ },
+};
+
// ── Registry ────────────────────────────────────────────────────────────────
const INTEGRATIONS: Record = {
claude: claudeCode,
codex,
copilot,
+ cursor,
};
export function getIntegration(id: IntegrationType): Integration {
diff --git a/src/hooks/policy-evaluator.ts b/src/hooks/policy-evaluator.ts
index b91482c4..b2859426 100644
--- a/src/hooks/policy-evaluator.ts
+++ b/src/hooks/policy-evaluator.ts
@@ -119,13 +119,36 @@ export async function evaluatePolicies(
hookLogInfo(`deny by "${policy.name}": ${reason}`);
const displayTool = ctx.toolName ?? "unknown tool";
+ const blockedMessage = `Blocked ${displayTool} by failproofai because: ${reason}, as per the policy configured by the user`;
+
+ // Cursor's hook protocol expects a flat `{permission, user_message,
+ // agent_message}` shape for any blocking decision, regardless of which
+ // event triggered it. Branch ahead of the per-event handlers below so
+ // PreToolUse / PostToolUse / PermissionRequest all flow through the
+ // Cursor-shaped response.
+ // Ref: https://cursor.com/docs/hooks (Stdout Response Format).
+ if (session?.cli === "cursor") {
+ const response = {
+ permission: "deny",
+ user_message: blockedMessage,
+ agent_message: blockedMessage,
+ };
+ return {
+ exitCode: 0,
+ stdout: JSON.stringify(response),
+ stderr: "",
+ policyName: policy.name,
+ reason,
+ decision: "deny",
+ };
+ }
if (eventType === "PreToolUse") {
const response = {
hookSpecificOutput: {
hookEventName: eventType,
permissionDecision: "deny",
- permissionDecisionReason: `Blocked ${displayTool} by failproofai because: ${reason}, as per the policy configured by the user`,
+ permissionDecisionReason: blockedMessage,
},
};
return {
@@ -188,7 +211,7 @@ export async function evaluatePolicies(
};
}
- // Other event types: exit 2
+ // Other event types (Cursor case already handled above): exit 2
return {
exitCode: 2,
stdout: "",
@@ -220,6 +243,40 @@ export async function evaluatePolicies(
const combined = instructEntries.map((e) => e.reason).join("\n");
const policyNames = instructEntries.map((e) => e.policyName);
+ // Cursor's hook protocol uses a flat `{permission, additional_context}`
+ // shape for non-Stop and `{followup_message}` for Stop/SubagentStop.
+ // Branch first so the rest of the function only handles Claude-shaped
+ // responses. Ref: https://cursor.com/docs/hooks (Stdout Response Format).
+ if (session?.cli === "cursor") {
+ if (eventType === "Stop") {
+ const response = {
+ followup_message: `Instruction from failproofai: ${combined}`,
+ };
+ return {
+ exitCode: 0,
+ stdout: JSON.stringify(response),
+ stderr: "",
+ policyName: policyNames[0],
+ policyNames,
+ reason: combined,
+ decision: "instruct",
+ };
+ }
+ const response = {
+ permission: "allow",
+ additional_context: `Instruction from failproofai: ${combined}`,
+ };
+ return {
+ exitCode: 0,
+ stdout: JSON.stringify(response),
+ stderr: "",
+ policyName: policyNames[0],
+ policyNames,
+ reason: combined,
+ decision: "instruct",
+ };
+ }
+
if (eventType === "Stop") {
// Stop hook: exitCode 2 blocks Claude from stopping.
// Reason goes to stderr so Claude Code receives it as context.
@@ -258,6 +315,28 @@ export async function evaluatePolicies(
if (allowEntries.length > 0) {
const combined = allowEntries.map((e) => e.reason).join("\n");
const policyNames = allowEntries.map((e) => e.policyName);
+
+ // Cursor: emit the flat shape; allow-with-info maps to
+ // `{permission: "allow", additional_context}`.
+ if (session?.cli === "cursor") {
+ const response = {
+ permission: "allow",
+ additional_context: `Note from failproofai: ${combined}`,
+ };
+ const stderrMsg = allowEntries
+ .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
+ .join("\n");
+ return {
+ exitCode: 0,
+ stdout: JSON.stringify(response),
+ stderr: stderrMsg + "\n",
+ policyName: policyNames[0],
+ policyNames,
+ reason: combined,
+ decision: "allow",
+ };
+ }
+
const supportsHookSpecificOutput =
eventType === "PreToolUse" ||
eventType === "PostToolUse" ||
diff --git a/src/hooks/resolve-permission-mode.ts b/src/hooks/resolve-permission-mode.ts
index a083d072..2f3df158 100644
--- a/src/hooks/resolve-permission-mode.ts
+++ b/src/hooks/resolve-permission-mode.ts
@@ -18,6 +18,10 @@
* • GitHub Copilot CLI: no documented permission-mode equivalent on the
* hook payload today; falls back to "default". Revisit when Copilot's
* hook protocol exposes one.
+ *
+ * • Cursor Agent CLI: no permission-mode field in the hook payload (Cursor's
+ * `loop_limit` is per-hook, not per-session). Falls back to "default" via
+ * the same final branch as Copilot.
*/
import { readFileSync } from "node:fs";
import { findCodexTranscript } from "../../lib/codex-sessions";
@@ -36,7 +40,7 @@ export function resolvePermissionMode(
return resolveCodexMode(sessionId) ?? "default";
}
- // copilot, unknown integrations, or codex without a sessionId
+ // copilot, cursor, unknown integrations, or codex without a sessionId
return "default";
}
diff --git a/src/hooks/types.ts b/src/hooks/types.ts
index 2e34cb2c..97e4ab8d 100644
--- a/src/hooks/types.ts
+++ b/src/hooks/types.ts
@@ -5,7 +5,7 @@
export const HOOK_SCOPES = ["user", "project", "local"] as const;
export type HookScope = (typeof HOOK_SCOPES)[number];
-export const INTEGRATION_TYPES = ["claude", "codex", "copilot"] as const;
+export const INTEGRATION_TYPES = ["claude", "codex", "copilot", "cursor"] as const;
export type IntegrationType = (typeof INTEGRATION_TYPES)[number];
export const CODEX_HOOK_SCOPES = ["user", "project"] as const;
@@ -55,6 +55,43 @@ export const COPILOT_HOOK_EVENT_TYPES = [
] as const;
export type CopilotHookEventType = (typeof COPILOT_HOOK_EVENT_TYPES)[number];
+// ── Cursor Agent CLI ───────────────────────────────────────────────────────
+//
+// Cursor delivers events under camelCase keys (`preToolUse`, `postToolUse`,
+// `beforeSubmitPrompt`, …) per https://cursor.com/docs/hooks. The handler
+// maps each one to the PascalCase canonical form via CURSOR_EVENT_MAP before
+// looking up policies. We install the same 6-event parity set as Copilot so
+// every existing builtin policy fires; Cursor-specific events
+// (`beforeShellExecution`, `afterFileEdit`, `subagentStart`, …) can be added
+// later without touching the handler.
+//
+// Settings paths:
+// user → ~/.cursor/hooks.json
+// project → /.cursor/hooks.json
+// Settings file carries `version: 1` like Codex/Copilot.
+
+export const CURSOR_HOOK_SCOPES = ["user", "project"] as const;
+export type CursorHookScope = (typeof CURSOR_HOOK_SCOPES)[number];
+
+export const CURSOR_HOOK_EVENT_TYPES = [
+ "sessionStart",
+ "sessionEnd",
+ "beforeSubmitPrompt",
+ "preToolUse",
+ "postToolUse",
+ "stop",
+] as const;
+export type CursorHookEventType = (typeof CURSOR_HOOK_EVENT_TYPES)[number];
+
+export const CURSOR_EVENT_MAP: Record = {
+ sessionStart: "SessionStart",
+ sessionEnd: "SessionEnd",
+ beforeSubmitPrompt: "UserPromptSubmit",
+ preToolUse: "PreToolUse",
+ postToolUse: "PostToolUse",
+ stop: "Stop",
+};
+
export const HOOK_EVENT_TYPES = [
"SessionStart",
"SessionEnd",
@@ -107,7 +144,7 @@ export interface SessionMetadata {
cwd?: string;
permissionMode?: string;
hookEventName?: string;
- /** Which agent CLI fired this hook (claude | codex | copilot). Set by handler.ts from --cli. */
+ /** Which agent CLI fired this hook (claude | codex | copilot | cursor). Set by handler.ts from --cli. */
cli?: IntegrationType;
}
From 69da6f6618fbc28f8838111f9fa8c2a6f4e20ca7 Mon Sep 17 00:00:00 2001
From: NiveditJain
Date: Wed, 29 Apr 2026 16:59:08 -0700
Subject: [PATCH 3/3] [luv-245] fix: address CodeRabbit review feedback
Five fixes from the bot review on PR #245:
1. .cursor/hooks.json: drop the `__failproofai_hook__` marker from the
static dev config so the file matches Cursor's documented hook entry
schema ({type, command, timeout}). The marker is still written by
`cursor.buildHookEntry` for dynamically installed hooks; the legacy
command-substring fallback in `isMarkedHook` identifies these static
entries during uninstall.
2. CHANGELOG.md: fold the new Cursor entries into the existing Unreleased
subsections (Features / Fixes / Docs) instead of creating a duplicate
`### Features` heading. Resolves the MD024 markdownlint complaint.
3. lib/cursor-projects.ts: parse meta.json with `JSON.parse` first so
escape sequences in JSON strings (Windows paths like
`C:\\Users\\alice\\repo`) are decoded. Falls back to the YAML-ish regex
only when the file isn't valid JSON.
4. lib/cursor-projects.ts: replace `findFirstExisting` (returns first
existing path) with `findFirstUsableMeta` (returns first path whose
parsed cwd is non-empty). Stops a stale `meta.json` from shadowing a
valid `workspace.yaml` and dropping the session.
5. lib/cursor-sessions.ts: validate `data.content` / `data.text` with
`typeof === "string"` before treating them as text in the user.message
and assistant.message branches. Avoids surfacing non-string values via
the `as string` casts.
All 1228 unit + 235 e2e tests still pass. Lint clean (one pre-existing
unrelated ` ` warning).
Co-Authored-By: Claude Opus 4.7
---
.cursor/hooks.json | 18 ++++-------
CHANGELOG.md | 6 ++--
lib/cursor-projects.ts | 70 +++++++++++++++++++++++++++---------------
lib/cursor-sessions.ts | 14 +++++++--
4 files changed, 66 insertions(+), 42 deletions(-)
diff --git a/.cursor/hooks.json b/.cursor/hooks.json
index c550d67f..6cc9637b 100644
--- a/.cursor/hooks.json
+++ b/.cursor/hooks.json
@@ -5,48 +5,42 @@
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook sessionStart --cli cursor",
- "timeout": 60000,
- "__failproofai_hook__": true
+ "timeout": 60000
}
],
"sessionEnd": [
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook sessionEnd --cli cursor",
- "timeout": 60000,
- "__failproofai_hook__": true
+ "timeout": 60000
}
],
"beforeSubmitPrompt": [
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook beforeSubmitPrompt --cli cursor",
- "timeout": 60000,
- "__failproofai_hook__": true
+ "timeout": 60000
}
],
"preToolUse": [
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook preToolUse --cli cursor",
- "timeout": 60000,
- "__failproofai_hook__": true
+ "timeout": 60000
}
],
"postToolUse": [
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook postToolUse --cli cursor",
- "timeout": 60000,
- "__failproofai_hook__": true
+ "timeout": 60000
}
],
"stop": [
{
"type": "command",
"command": "bun bin/failproofai.mjs --hook stop --cli cursor",
- "timeout": 60000,
- "__failproofai_hook__": true
+ "timeout": 60000
}
]
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3907beab..f8c74035 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,15 +4,13 @@
### Features
- Add GitHub Copilot CLI integration (beta) across hooks, activity dashboard, session fallback, and `/projects` listing. Also ships this repo's own `.github/hooks/failproofai.json` so contributors developing failproofai with the GitHub Copilot CLI get hooks active automatically, mirroring the existing `.claude/settings.json` and `.codex/hooks.json` (#236)
+- 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).
### Fixes
- `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: 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).
diff --git a/lib/cursor-projects.ts b/lib/cursor-projects.ts
index 52b7e976..0d0e9e5c 100644
--- a/lib/cursor-projects.ts
+++ b/lib/cursor-projects.ts
@@ -65,21 +65,51 @@ async function statMtime(path: string): Promise {
}
}
-/** Parse a flat scalar for `cwd` from JSON-or-YAML metadata. Tolerant on purpose:
- * Cursor's metadata format is unspecified, so a permissive regex handles either
- * shape (`"cwd": "/foo"` in JSON or `cwd: /foo` in YAML) without a parser dep. */
+/** Parse a flat scalar for `cwd` from JSON-or-YAML metadata. Tries `JSON.parse`
+ * first so escape sequences in JSON strings (e.g. Windows `C:\\Users\\...`) are
+ * decoded, then falls back to a permissive YAML-ish regex when the file isn't
+ * valid JSON. Tolerant on purpose: Cursor's metadata format is unspecified. */
function parseCwdFromMetaText(text: string): string | undefined {
- // JSON shape: "cwd": "/path"
- const json = text.match(/"cwd"\s*:\s*"([^"]+)"/);
- if (json) return json[1];
- // YAML shape: cwd: /path
+ try {
+ const parsed = JSON.parse(text) as { cwd?: unknown };
+ if (typeof parsed.cwd === "string" && parsed.cwd.length > 0) return parsed.cwd;
+ } catch {
+ // Not JSON — fall through to the YAML-ish parser.
+ }
+ // YAML shape: `cwd: /path` (optionally quoted).
const yaml = text.match(/^\s*cwd\s*:\s*(.+?)\s*$/m);
- if (yaml) return yaml[1].replace(/^['"]|['"]$/g, "");
+ if (yaml) {
+ const stripped = yaml[1].replace(/^['"]|['"]$/g, "");
+ if (stripped.length > 0) return stripped;
+ }
return undefined;
}
-/** First file that exists from `candidates`, joined under `dir`. */
-async function findFirstExisting(dir: string, candidates: readonly string[]): Promise {
+/** Try each candidate under `dir`; for metadata files we keep probing until one
+ * yields a usable `cwd` (a stale `meta.json` shouldn't shadow a valid
+ * `workspace.yaml`). Returns `{path, cwd}` for the first usable file, or null. */
+async function findFirstUsableMeta(
+ dir: string,
+ candidates: readonly string[],
+): Promise<{ path: string; cwd: string } | null> {
+ for (const name of candidates) {
+ const path = join(dir, name);
+ if ((await statMtime(path)) === null) continue;
+ let text: string;
+ try {
+ text = await readFile(path, "utf-8");
+ } catch {
+ continue;
+ }
+ const cwd = parseCwdFromMetaText(text);
+ if (cwd) return { path, cwd };
+ }
+ return null;
+}
+
+/** First existing path from `candidates` under `dir`. Used for transcript files
+ * where existence is the only check we can perform without parsing JSONL. */
+async function findFirstExistingPath(dir: string, candidates: readonly string[]): Promise {
for (const name of candidates) {
const path = join(dir, name);
if ((await statMtime(path)) !== null) return path;
@@ -103,28 +133,20 @@ async function scanCursorSessions(): Promise {
const settled = await batchAll(
allCandidates.map((c) => async (): Promise => {
- const metaPath = await findFirstExisting(c.dir, META_FILE_CANDIDATES);
- if (!metaPath) return null;
- let metaText: string;
- try {
- metaText = await readFile(metaPath, "utf-8");
- } catch {
- return null;
- }
- const cwd = parseCwdFromMetaText(metaText);
- if (!cwd) return null;
- const transcriptPath = await findFirstExisting(c.dir, TRANSCRIPT_FILE_CANDIDATES);
+ const meta = await findFirstUsableMeta(c.dir, META_FILE_CANDIDATES);
+ if (!meta) return null;
+ const transcriptPath = await findFirstExistingPath(c.dir, TRANSCRIPT_FILE_CANDIDATES);
const transcriptMtime = transcriptPath ? await statMtime(transcriptPath) : null;
- const metaMtime = await statMtime(metaPath);
+ const metaMtime = await statMtime(meta.path);
const fileMtime =
transcriptMtime && metaMtime
? new Date(Math.max(transcriptMtime.getTime(), metaMtime.getTime()))
: transcriptMtime ?? metaMtime ?? new Date(0);
return {
- metaPath,
+ metaPath: meta.path,
transcriptPath,
sessionId: c.sessionId,
- cwd,
+ cwd: meta.cwd,
fileMtime,
hasTranscript: transcriptPath !== null,
};
diff --git a/lib/cursor-sessions.ts b/lib/cursor-sessions.ts
index 983730d4..ce39dbe0 100644
--- a/lib/cursor-sessions.ts
+++ b/lib/cursor-sessions.ts
@@ -163,7 +163,12 @@ export async function parseCursorLog(
}
if (recType === "user.message" || recType === "userMessage") {
- const text = (data.content as string) ?? (data.text as string) ?? "";
+ const text =
+ typeof data.content === "string"
+ ? data.content
+ : typeof data.text === "string"
+ ? data.text
+ : "";
if (!text) continue;
entries.push({
type: "user",
@@ -183,7 +188,12 @@ export async function parseCursorLog(
}
if (recType === "assistant.message" || recType === "assistantMessage") {
- const text = (data.content as string) ?? (data.text as string) ?? "";
+ const text =
+ typeof data.content === "string"
+ ? data.content
+ : typeof data.text === "string"
+ ? data.text
+ : "";
if (!text) {
entries.push({
type: "system",