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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ Supported provider names today:
- `acpx`: any ACP-compatible coding agent (Codex / Claude / Pi / Gemini / ...) via openclaw/acpx
- `grok`: local Grok Build CLI
- `opencode`: local OpenCode CLI
- `cursor`: local Cursor Agent CLI (experimental; `doctor` is enabled by default)
- `mock`: deterministic test provider
- `mock-fail`: failure test provider

Expand Down
61 changes: 60 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Provider names today:
- `grok`: shells out to the xAI Grok Build CLI in headless mode (`grok --prompt-file`)
- `opencode`: shells out to `opencode run --format json`
- `pi`: shells out to `pi -p` (non-interactive print mode)
- `cursor`: shells out to `cursor-agent -p --output-format json`
- `mock`: deterministic provider for tests and fixtures
- `mock-fail`: failure provider for tests

Expand Down Expand Up @@ -195,7 +196,65 @@ review and revalidate, but enforcement depends on pi honoring the tool allowlist
For write operations during `fix`, the agent has full filesystem and shell access.
For untrusted code, run `clawpatch fix --provider pi` inside an isolated checkout.

## Cursor

The `cursor` provider shells out to the local Cursor Agent CLI in headless print
mode. It is experimental and disabled for `map`, `review`, `fix`, and
`revalidate` by default while HITL verification is incomplete.

Verify local availability:

```bash
cursor-agent --version
clawpatch doctor --provider cursor
```

Experimental provider selection:

```bash
CURSOR_API_KEY=... CLAWPATCH_CURSOR_EXPERIMENTAL=1 clawpatch review --provider cursor
CURSOR_API_KEY=... CLAWPATCH_CURSOR_EXPERIMENTAL=1 CLAWPATCH_PROVIDER=cursor clawpatch review
CURSOR_API_KEY=... CLAWPATCH_CURSOR_EXPERIMENTAL=1 CLAWPATCH_CURSOR_ALLOW_WRITE=1 clawpatch fix --finding <id> --provider cursor --model <model>
clawpatch doctor --provider cursor
```

How the Cursor provider works:

- Headless mode: `cursor-agent --trust -p --output-format json --workspace <root>`
- Read-only operations: also pass Cursor's documented `--mode ask`
- Output: parses Cursor's `type: "result"` JSON envelope and then extracts the
Clawpatch JSON object from the `result` text
- Prompt delivery: writes the full Clawpatch prompt to Cursor's stdin
- Model selection: passes `--model <model>` when configured
- Model names: pass Cursor model ids, for example `composer-2.5` for Composer
2.5 without fast mode
- Reasoning effort and `skipGitRepoCheck`: not mapped to Cursor CLI flags
- Authentication: experimental execution uses the host user environment and
passes `CURSOR_API_KEY` through when present. Prefer API-key auth for headless
runs; relying on the user's Cursor login can touch the macOS login keychain.
Clawpatch also sets `NO_OPEN_BROWSER=1` to reduce browser prompts during
headless runs.
- Read-only guard: map, review, and revalidate pass `--mode ask` and include
read-only instructions in the prompt. Clawpatch does not set
`CURSOR_CONFIG_DIR`, because that can bypass the user's existing Cursor auth
profile and trigger browser login prompts.
- Timeout: 300 seconds by default, override with
`CLAWPATCH_CURSOR_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS`
- Advisory handling: semver-like Cursor CLI versions below `2.5.0` are blocked
for CVE-2026-26268 / GHSA-8pcm-8jpx-hv8r. Date-formatted CLI builds are
allowed only when Clawpatch can verify a semver Cursor app version from the
local macOS app bundle.

Permission caveat: Cursor's print mode is documented as having access to tools,
including write and shell. Clawpatch therefore keeps Cursor execution behind
`CLAWPATCH_CURSOR_EXPERIMENTAL=1`, uses `--mode ask` for read-only operations,
and separately requires `CLAWPATCH_CURSOR_ALLOW_WRITE=1` for `fix`. The
implementation uses `--trust` for the explicit trusted-workspace path and never
uses `--force` or `--yolo`. Complete HITL verification before promoting this to
default provider support, especially for ambient rules, MCP configuration,
positional prompt exposure, timeout behavior, and any claimed read-only mode.

Direct OpenAI API, local-model, and multi-model panel providers are not
implemented yet. The `acpx` provider is the generic route for ACP-compatible
agents; the `grok`, `opencode`, and `pi` providers are direct integrations
agents; the `grok`, `opencode`, `pi`, and `cursor` providers are direct integrations
for local CLIs.
188 changes: 182 additions & 6 deletions src/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import { REVIEW_PROMPT_FILE_CHAR_LIMIT, buildReviewPromptBundle } from "./prompt.js";
import {
REVIEW_PROMPT_FILE_CHAR_LIMIT,
buildFixPrompt,
buildReviewPromptBundle,
} from "./prompt.js";
import { defaultConfig } from "./config.js";
import { fixtureRoot, writeFixture } from "./test-helpers.js";
import type { FeatureRecord, ProjectRecord } from "./types.js";
import type { FeatureRecord, FindingRecord, ProjectRecord } from "./types.js";

describe("review prompt provenance", () => {
it("records included, omitted, and truncated review prompt context", async () => {
Expand All @@ -21,13 +25,32 @@ describe("review prompt provenance", () => {
});

expect(bundle.prompt).toContain("Prompt context:");
expect(bundle.prompt).toContain("--- src/index.ts");
expect(bundle.prompt).toContain("--- tests/index.test.ts");
expect(bundle.prompt).toContain("--- src/index.ts (owned, lines 1-1)");
expect(bundle.prompt).toContain("1 | export const value = 1;");
expect(bundle.prompt).toContain("--- tests/index.test.ts (context, lines 1-1)");
expect(bundle.prompt).not.toContain("--- src/extra.ts");
expect(bundle.prompt).toContain("Valid evidence paths are exactly:");
expect(bundle.prompt).toContain("- src/index.ts");
expect(bundle.prompt).toContain("- tests/index.test.ts");
expect(bundle.prompt).not.toContain("- src/extra.ts");
expect(bundle.prompt).not.toContain('"analysisHistory"');
expect(bundle.prompt).not.toContain('"lock"');
expect(bundle.manifest.includedFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "src/index.ts", role: "owned", truncated: false }),
expect.objectContaining({ path: "docs/large.md", role: "context", truncated: true }),
expect.objectContaining({
path: "src/index.ts",
role: "owned",
includedStartLine: 1,
includedEndLine: 1,
truncated: false,
}),
expect.objectContaining({
path: "docs/large.md",
role: "context",
includedStartLine: 1,
includedEndLine: expect.any(Number),
truncated: true,
}),
]),
);
expect(bundle.manifest.omittedFiles).toEqual([
Expand All @@ -51,6 +74,123 @@ describe("review prompt provenance", () => {
expect(bundle.manifest.includedFiles).toEqual(
expect.arrayContaining([expect.objectContaining({ path: "docs/large.md", truncated: true })]),
);
expect(bundle.prompt).toContain("--- docs/large.md (context, lines 1-1, truncated)");
expect(bundle.prompt).toContain("...[truncated after line 1]");
});

it("includes linked tests as valid review evidence", async () => {
const root = await fixtureRoot("clawpatch-prompt-linked-tests-");
await writeFixture(root, "src/index.ts", "export const value = 1;\n");
await writeFixture(root, "src/extra.ts", "export const extra = 1;\n");
await writeFixture(root, "tests/index.test.ts", "expect(1).toBe(1);\n");
await writeFixture(root, "docs/large.md", "docs\n");
const linkedTestFeature = {
...feature(),
contextFiles: [],
tests: [{ path: "tests/index.test.ts", command: null }],
};

const bundle = await buildReviewPromptBundle(
root,
project(root),
linkedTestFeature,
defaultConfig(),
);

expect(bundle.prompt).toContain("--- tests/index.test.ts (test, lines 1-1)");
expect(bundle.prompt).toContain("- tests/index.test.ts");
expect(bundle.manifest.includedFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "tests/index.test.ts", role: "test" }),
]),
);
});

it("does not list duplicate-skipped included files as omitted", async () => {
const root = await fixtureRoot("clawpatch-prompt-duplicate-context-");
await writeFixture(root, "src/index.ts", "export const value = 1;\n");
await writeFixture(root, "src/context-one.ts", "export const one = 1;\n");
await writeFixture(root, "src/context-two.ts", "export const two = 1;\n");
await writeFixture(root, "src/context-three.ts", "export const three = 1;\n");
const duplicateFeature = {
...feature(),
ownedFiles: [{ path: "src/index.ts", reason: "primary" }],
contextFiles: [
{ path: "src/index.ts", reason: "duplicate owned file" },
{ path: "src/context-one.ts", reason: "context" },
{ path: "src/context-two.ts", reason: "context" },
{ path: "src/context-three.ts", reason: "overflow" },
],
};

const bundle = await buildReviewPromptBundle(root, project(root), duplicateFeature, {
...defaultConfig(),
review: {
...defaultConfig().review,
maxOwnedFiles: 1,
maxContextFiles: 2,
},
});

expect(bundle.prompt).toContain("--- src/context-two.ts (context, lines 1-1)");
expect(bundle.manifest.omittedFiles).toEqual([
{ path: "src/context-three.ts", role: "context", reason: "maxContextFiles" },
]);
});

it("de-duplicates equivalent prompt paths before applying limits", async () => {
const root = await fixtureRoot("clawpatch-prompt-normalized-duplicates-");
await writeFixture(root, "src/index.ts", "export const value = 1;\n");
await writeFixture(root, "src/next.ts", "export const next = 1;\n");
const duplicateFeature = {
...feature(),
ownedFiles: [
{ path: "src/index.ts", reason: "primary" },
{ path: "./src/index.ts", reason: "duplicate spelling" },
{ path: "src/next.ts", reason: "next real file" },
],
contextFiles: [],
};

const bundle = await buildReviewPromptBundle(root, project(root), duplicateFeature, {
...defaultConfig(),
review: {
...defaultConfig().review,
maxOwnedFiles: 2,
},
});

expect(bundle.manifest.includedFiles.map((file) => file.path)).toEqual([
"src/index.ts",
"src/next.ts",
]);
expect(bundle.manifest.omittedFiles).toEqual([]);
});

it("includes fix evidence paths that differ only by normalized spelling", async () => {
const root = await fixtureRoot("clawpatch-fix-prompt-normalized-evidence-");
await writeFixture(root, "src/index.ts", "export const value = 1;\n");
await writeFixture(root, "src/other.ts", "export const other = 1;\n");
const normalizedFeature = {
...feature(),
entrypoints: [],
ownedFiles: [{ path: "./src/index.ts", reason: "primary" }],
contextFiles: [],
tests: [],
};

const prompt = await buildFixPrompt(root, finding("src/index.ts"), normalizedFeature, {
...defaultConfig(),
review: {
...defaultConfig().review,
maxOwnedFiles: 0,
maxContextFiles: 0,
},
});

expect(prompt).toContain("--- ./src/index.ts");
expect(prompt).toContain("export const value = 1;");
expect(prompt).not.toContain("--- src/other.ts");
});
});

Expand Down Expand Up @@ -109,3 +249,39 @@ function feature(): FeatureRecord {
updatedAt: now,
};
}

function finding(path: string): FindingRecord {
const now = new Date().toISOString();
return {
schemaVersion: 1,
findingId: "fnd_prompt",
featureId: "feat_prompt",
title: "Prompt finding",
category: "bug",
severity: "medium",
confidence: "high",
triage: "confirmed-bug",
evidence: [
{
path,
startLine: 1,
endLine: 1,
symbol: null,
quote: null,
},
],
reasoning: "The file needs a fix.",
reproduction: null,
recommendation: "Fix the file.",
whyTestsDoNotAlreadyCoverThis: "",
suggestedRegressionTest: null,
minimumFixScope: "src/index.ts",
status: "open",
history: [],
signature: "sig_prompt",
linkedPatchAttemptIds: [],
createdByRunId: "run_prompt",
createdAt: now,
updatedAt: now,
};
}
Loading