diff --git a/CHANGELOG.md b/CHANGELOG.md index 503b0d9..bc450f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -_No unreleased changes._ +- **Fixed: Cursor agent rejecting turns as "prompt injection" / "gaslighting."** + The provider flattened opencode's system prompt into the user-message transcript; + Cursor's agent (which has its own system prompt) treated that as an injection + attempt. opencode's system prompt is now delivered through Cursor's authoritative + rules channel — written to a git-ignored `.cursor/rules/opencode.mdc` and loaded + via `settingSources` — so opencode keeps control without being flagged. New + `systemPrompt` option: `"rules"` (default), `"message"` (legacy inline), `"omit"`. ## [0.4.4] — 2026-06-24 diff --git a/README.md b/README.md index 86a1ba8..0d60bba 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,9 @@ The plugin also registers two **delegation tools**: > - Set `sandbox: true` in `provider.cursor.options` to run Cursor's tools in Cursor's sandbox. > - Use `cursor_delegate` instead of the provider path — it is gated by opencode's `permission` > config. +> - By default opencode's system prompt is delivered via a git-ignored Cursor +> rule (`systemPrompt: "rules"`), not inlined into the message stream. See +> [System prompt](#system-prompt). See [SECURITY.md](./SECURITY.md) for the full threat model. @@ -140,6 +143,7 @@ See [SECURITY.md](./SECURITY.md) for the full threat model. | `forwardMcp` | `true` | Forward opencode's configured MCP servers to the Cursor agent | | `mcpServers` | — | Extra MCP servers (Cursor `McpServerConfig` shape); merged with forwarded ones | | `toolDisplay` | `"blocks"` | How Cursor's internal tool activity is shown — see [Tool display](#tool-display) | +| `systemPrompt` | `"rules"` | How opencode's system prompt reaches the agent — see [System prompt](#system-prompt) | | Environment variable | Default | Meaning | | --- | --- | --- | @@ -190,6 +194,31 @@ To set controls statically per model: } } } } ``` +## System prompt + +opencode drives the Cursor agent the way it drives any provider — through its +**system prompt**. But the Cursor SDK has no system-prompt input (an agent, not a +raw model), and flattening opencode's system prompt into the message stream makes +injection-hardened Cursor models reject it as a prompt-injection attempt. + +So by default (`systemPrompt: "rules"`) the plugin writes opencode's system prompt +to `/.cursor/rules/opencode.mdc` (`alwaysApply: true`, git-ignored) and loads +the `project` settings layer, delivering it through Cursor's **authoritative rules +channel**. Cursor treats rules as system-level instructions, so opencode stays in +control and nothing is flagged. + +Tradeoffs to know: + +- A project rule also applies to **your own Cursor IDE** open on this repo. The + plugin removes the file when the session disposes (best-effort). +- Enabling the `project` layer also loads other `.cursor/` config (`.cursor/mcp.json`, + `.cursor/agents`, hooks). + +Alternatives: + +- `systemPrompt: "message"` — legacy inline delivery (may be rejected as injection). +- `systemPrompt: "omit"` — don't forward the system prompt at all. + ## MCP servers With `forwardMcp: true` (default), the Cursor agent uses the same MCP servers configured in diff --git a/docs/superpowers/plans/2026-07-02-system-prompt-via-cursor-rules.md b/docs/superpowers/plans/2026-07-02-system-prompt-via-cursor-rules.md new file mode 100644 index 0000000..f6ebaf4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-system-prompt-via-cursor-rules.md @@ -0,0 +1,586 @@ +# opencode Controls Cursor via the Rules Channel — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver opencode's system prompt to the Cursor agent through Cursor's **authoritative rules channel** (`.cursor/rules/*.mdc` loaded via `settingSources`) instead of flattening it into the user-message transcript — so opencode genuinely controls the Cursor agent AND the content is no longer rejected as a prompt-injection attempt. + +**Architecture:** Add a `systemPrompt` provider option (`"rules"` default | `"message"` legacy | `"omit"`). In `"rules"` mode the plugin writes opencode's system prompt to a git-ignored `/.cursor/rules/opencode.mdc` with `alwaysApply: true`, ensures the Cursor agent loads the `project` settings layer, and stops emitting the system prompt into the flattened transcript. Rules load from disk at `Agent.create`, so both the in-process and sidecar backends work unchanged (no RPC protocol change). + +**Tech Stack:** TypeScript, `@ai-sdk/provider` (`LanguageModelV3Prompt`), `@cursor/sdk` 1.0.x (`settingSources`, local agents), Node `fs`, Vitest, tsup. + +--- + +## Why this design (verified findings) + +- **The intent:** opencode-cursor makes Cursor a **provider** — "you chat with them the same way you use any other provider" (`README.md:7`). A provider is steered by the host's system prompt. So "opencode controls Cursor" requires opencode's system prompt to reach Cursor authoritatively. +- **The SDK has no system-prompt input** — confirmed through the latest `@cursor/sdk@1.0.22`. `AgentOptions` / `LocalAgentOptions` / `SendOptions` expose `model`, `mode`, `mcpServers`, `agents`, `settingSources`, `customTools`, `sandboxOptions`, `autoReview` — **no** `systemPrompt`/`instructions` field. The SDK hands you a complete agent (Composer with its own harness prompt), not a raw model. +- **Cursor's documented channel for system-level instructions is rules.** Cursor docs: *"Rules provide system-level instructions to Agent … included at the start of the model context."* A `.cursor/rules/*.mdc` file with `alwaysApply: true` is applied to every session. Loaded via `local.settingSources` including `"project"`. +- **Current bug:** `src/provider/message-map.ts:47-49` flattens opencode's `role:"system"` message into the body of a `role:"user"` message. Cursor's agent already has its own system prompt, so this arrives as a rival, injection-shaped directive through the *untrusted user channel* — and Cursor's injection-hardened models reject it (the "you're prompt-injecting / gaslighting me" behavior). Cursor also strips authoritative-looking tags (`rules`, `system_reminder`, …) from message content, so in-message delivery can never be made authoritative. +- **Rules load from disk → no sidecar change.** `acquireAgent` (`src/provider/session-pool.ts:101-136`) already threads `settingSources` into `createOptions.local` for both backends (`src/provider/agent-backend.ts`). We only need to (a) write the file and (b) ensure `settingSources` includes `"project"`. +- **Session reuse is unaffected:** `classifyTurn`/`fingerprint` hash `options.prompt` (`src/provider/transcript-fingerprint.ts:80-92`), not what we send. The rule content equals the system prompt; when the system prompt changes, `systemHash` changes and the pool already forces a fresh agent — which re-reads the rewritten rule at create time. + +### Known tradeoffs (documented, not blockers) + +- A `project` rule at `.cursor/rules/opencode.mdc` **also applies to the user's own Cursor IDE** in that repo. Inherent to the only authoritative channel Cursor exposes. Mitigated by best-effort cleanup on dispose + the `"message"`/`"omit"` escapes. +- Enabling the `"project"` settings layer to load our rule **also loads other project `.cursor/` config** (`.cursor/mcp.json`, `.cursor/agents`, hooks). Documented; `forwardMcp` already handles opencode's MCP set, so users who don't want project MCP can keep `systemPrompt: "message"`/`"omit"`. +- Concurrent opencode sessions in the same repo share one `opencode.mdc` (last write wins; content is opencode's own prompt, so overlap is benign). Cleanup on dispose is best-effort. + +## File Structure + +- Create: `src/provider/system-rule.ts` — extract system text; write/remove the `.cursor/rules/opencode.mdc` rule; keep it git-ignored. +- Modify: `src/provider/message-map.ts` — `SystemPromptMode` type; `systemPrompt` param; only emit `# System` in `"message"` mode. +- Modify: `src/provider/language-model.ts` — `systemPrompt` in `CursorModelConfig`; in `"rules"` mode write the rule + add `"project"` to `settingSources`; pass mode to `promptToCursorMessage`. +- Modify: `src/provider/index.ts` — `systemPrompt` in `CursorProviderOptions`; default `"rules"`. +- Modify: `src/plugin/index.ts` — `dispose` hook removes the generated rule (best-effort). +- Test: `test/system-rule.test.ts` (new); `test/message-map.test.ts` (update). +- Modify: `README.md`, `CHANGELOG.md`. + +--- + +### Task 1: `system-rule.ts` — write opencode's prompt as a Cursor rule + +**Files:** +- Create: `src/provider/system-rule.ts` +- Test: `test/system-rule.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `test/system-rule.test.ts`: + +```ts +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; +import { + extractSystemText, + writeSystemRule, + removeSystemRule, +} from "../src/provider/system-rule.js"; + +const dirs: string[] = []; +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "cursor-rule-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe("extractSystemText", () => { + it("concatenates all system messages, trimmed", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "system", content: "You are opencode." }, + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "system", content: "Be terse." }, + ]; + expect(extractSystemText(prompt)).toBe("You are opencode.\n\nBe terse."); + }); + it("returns empty string when there is no system message", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + ]; + expect(extractSystemText(prompt)).toBe(""); + }); +}); + +describe("writeSystemRule", () => { + it("writes an always-applied .mdc rule and git-ignores it", () => { + const cwd = tmp(); + const wrote = writeSystemRule(cwd, "Follow project conventions."); + expect(wrote).toBe(true); + const rulePath = join(cwd, ".cursor", "rules", "opencode.mdc"); + const body = readFileSync(rulePath, "utf8"); + expect(body).toBe( + "---\nalwaysApply: true\n---\n\nFollow project conventions.\n", + ); + const ignore = readFileSync( + join(cwd, ".cursor", "rules", ".gitignore"), + "utf8", + ); + expect(ignore.split(/\r?\n/)).toContain("opencode.mdc"); + }); + it("is a no-op for empty text", () => { + const cwd = tmp(); + expect(writeSystemRule(cwd, "")).toBe(false); + expect(existsSync(join(cwd, ".cursor", "rules", "opencode.mdc"))).toBe( + false, + ); + }); + it("does not duplicate the .gitignore entry on rewrite", () => { + const cwd = tmp(); + writeSystemRule(cwd, "a"); + writeSystemRule(cwd, "b"); + const ignore = readFileSync( + join(cwd, ".cursor", "rules", ".gitignore"), + "utf8", + ); + expect(ignore.match(/opencode\.mdc/g)).toHaveLength(1); + }); +}); + +describe("removeSystemRule", () => { + it("deletes the generated rule and tolerates a missing file", () => { + const cwd = tmp(); + writeSystemRule(cwd, "x"); + removeSystemRule(cwd); + expect(existsSync(join(cwd, ".cursor", "rules", "opencode.mdc"))).toBe( + false, + ); + expect(() => removeSystemRule(cwd)).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run test/system-rule.test.ts` +Expected: FAIL — `../src/provider/system-rule.js` does not exist. + +- [ ] **Step 3: Implement `src/provider/system-rule.ts`** + +```ts +import { + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; + +/** Location of the generated rule, relative to the agent's cwd. */ +const RULES_DIR = join(".cursor", "rules"); +const RULE_FILE = "opencode.mdc"; +const IGNORE_FILE = ".gitignore"; + +/** Concatenate every system-message body from an AI-SDK prompt (trimmed). */ +export function extractSystemText(prompt: LanguageModelV3Prompt): string { + const parts: string[] = []; + for (const message of prompt) { + if (message.role === "system") parts.push(message.content); + } + return parts.join("\n\n").trim(); +} + +/** + * Write opencode's system prompt to `/.cursor/rules/opencode.mdc` as an + * always-applied Cursor project rule. Cursor loads this through its authoritative + * rules channel (`settingSources` including "project"), so opencode's controlling + * instructions reach the agent without being flattened into the untrusted + * user-message transcript (which injection-hardened models reject). Returns true + * when a rule was written; a no-op (false) for empty text. + */ +export function writeSystemRule(cwd: string, systemText: string): boolean { + if (!systemText) return false; + const dir = join(cwd, RULES_DIR); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, RULE_FILE), + `---\nalwaysApply: true\n---\n\n${systemText}\n`, + "utf8", + ); + ensureGitIgnored(dir); + return true; +} + +/** Keep the generated rule out of git via `.cursor/rules/.gitignore`. */ +function ensureGitIgnored(dir: string): void { + const path = join(dir, IGNORE_FILE); + const existing = existsSync(path) ? readFileSync(path, "utf8") : ""; + if (existing.split(/\r?\n/).includes(RULE_FILE)) return; + const prefix = existing && !existing.endsWith("\n") ? `${existing}\n` : existing; + writeFileSync(path, `${prefix}${RULE_FILE}\n`, "utf8"); +} + +/** Remove the generated rule (best-effort); used on plugin dispose. */ +export function removeSystemRule(cwd: string): void { + try { + rmSync(join(cwd, RULES_DIR, RULE_FILE)); + } catch { + // best effort — already gone or never written + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `npx vitest run test/system-rule.test.ts` +Expected: PASS (all cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/provider/system-rule.ts test/system-rule.test.ts +git commit -m "feat(provider): materialize opencode system prompt as a Cursor rule" +``` + +--- + +### Task 2: `systemPrompt` mode in `promptToCursorMessage` + +**Files:** +- Modify: `src/provider/message-map.ts` +- Test: `test/message-map.test.ts` + +- [ ] **Step 1: Update tests (fail first)** + +In `test/message-map.test.ts`, REPLACE the first test (`it("flattens a multi-role conversation into a transcript", ...)`, lines 9-22) with these three: + +```ts + it("omits the system prompt from the transcript by default (rules mode)", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "system", content: "Be concise." }, + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + { role: "user", content: [{ type: "text", text: "What is 2+2?" }] }, + ]; + const msg = promptToCursorMessage(prompt); + // System prompt is delivered via the Cursor rules channel, not inline. + expect(msg.text).not.toContain("# System"); + expect(msg.text).not.toContain("Be concise."); + expect(msg.text).toContain("# User\nHello"); + expect(msg.text).toContain("# Assistant\nHi there"); + expect(msg.text).toContain("# User\nWhat is 2+2?"); + expect(msg.images).toBeUndefined(); + }); + + it("includes the system prompt inline in 'message' (legacy) mode", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "system", content: "Be concise." }, + { role: "user", content: [{ type: "text", text: "Hi" }] }, + ]; + const msg = promptToCursorMessage(prompt, "message"); + expect(msg.text).toContain("# System\nBe concise."); + }); + + it("omits the system prompt in 'omit' mode", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "system", content: "Be concise." }, + { role: "user", content: [{ type: "text", text: "Hi" }] }, + ]; + const msg = promptToCursorMessage(prompt, "omit"); + expect(msg.text).not.toContain("Be concise."); + expect(msg.text).toBe("# User\nHi"); + }); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run test/message-map.test.ts` +Expected: FAIL — default still emits `# System`; `promptToCursorMessage(prompt, "message")` is an arity error. + +- [ ] **Step 3: Implement** + +In `src/provider/message-map.ts`, add after the imports (after line 2): + +```ts +/** + * How opencode's system prompt reaches the Cursor agent. + * - "rules" (default): delivered out-of-band as a Cursor project rule + * (see system-rule.ts) and therefore omitted from this flattened transcript. + * - "message": legacy — inlined as a `# System` block in the transcript. + * Injection-hardened Cursor models may reject this; kept for back-compat. + * - "omit": dropped entirely (no rule, no inline). + */ +export type SystemPromptMode = "rules" | "message" | "omit"; +``` + +Change the signature (lines 38-40): + +```ts +export function promptToCursorMessage( + prompt: LanguageModelV3Prompt, + systemPrompt: SystemPromptMode = "rules", +): SDKUserMessage { +``` + +Replace the `case "system":` block (lines 47-49): + +```ts + case "system": + // Only inline in legacy "message" mode. In "rules" mode the system + // prompt is delivered via .cursor/rules (authoritative, not flagged); + // in "omit" mode it is dropped. + if (systemPrompt === "message") { + lines.push(`# System\n${message.content}`); + } + break; +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `npx vitest run test/message-map.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/provider/message-map.ts test/message-map.test.ts +git commit -m "feat(provider): systemPrompt modes (rules|message|omit) in transcript mapping" +``` + +--- + +### Task 3: Wire the rules channel into the turn (language-model.ts) + +**Files:** +- Modify: `src/provider/language-model.ts` + +Behavior covered by Task 1/2 unit tests + typecheck; this is wiring. + +- [ ] **Step 1: Imports + config field** + +In `src/provider/language-model.ts`, update the message-map import (line 17): + +```ts +import { + latestUserMessage, + promptToCursorMessage, + type SystemPromptMode, +} from "./message-map.js"; +``` + +Add below it: + +```ts +import { extractSystemText, writeSystemRule } from "./system-rule.js"; +``` + +Add to `CursorModelConfig` (after the `toolDisplay` field, ~line 66): + +```ts + /** + * How opencode's system prompt reaches the Cursor agent (see + * {@link SystemPromptMode}). Defaults to "rules". + */ + systemPrompt?: SystemPromptMode; +``` + +- [ ] **Step 2: Materialize the rule + enable the project layer** + +In `agentRun`, immediately BEFORE the `const acquired = await acquireAgent({` call (currently line 187), insert: + +```ts + // In "rules" mode (default), deliver opencode's system prompt through + // Cursor's authoritative rules channel instead of the user transcript. + const systemMode: SystemPromptMode = this.config.systemPrompt ?? "rules"; + let settingSources = this.config.settingSources; + if (systemMode === "rules") { + const systemText = extractSystemText(options.prompt); + if (writeSystemRule(this.config.cwd, systemText)) { + settingSources = + settingSources?.includes("project") + ? settingSources + : [...(settingSources ?? []), "project"]; + } + } +``` + +Then, in that same `acquireAgent({ ... })` call, replace the existing settingSources spread (currently lines 192-194): + +```ts + ...(settingSources ? { settingSources } : {}), +``` + +- [ ] **Step 3: Pass the mode to the transcript mapper** + +Replace the message construction (currently lines 208-211): + +```ts + const message = acquired.resumed + ? (latestUserMessage(options.prompt) ?? + promptToCursorMessage(options.prompt, systemMode)) + : promptToCursorMessage(options.prompt, systemMode); +``` + +- [ ] **Step 4: Typecheck** + +Run: `npm run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/provider/language-model.ts +git commit -m "feat(provider): deliver system prompt via Cursor rules in rules mode" +``` + +--- + +### Task 4: Public option + dispose cleanup + +**Files:** +- Modify: `src/provider/index.ts` +- Modify: `src/plugin/index.ts` + +- [ ] **Step 1: Add the option in `provider/index.ts`** + +Add import after line 18 (`import type { ToolDisplay } ...`): + +```ts +import type { SystemPromptMode } from "./message-map.js"; +``` + +Add to `CursorProviderOptions` (after the `toolDisplay` field, ~line 67): + +```ts + /** + * How opencode's system prompt reaches the Cursor agent: + * - "rules" (default): written to `/.cursor/rules/opencode.mdc` + * (git-ignored, alwaysApply) and loaded via the `project` settings layer — + * Cursor's authoritative channel, so it is not rejected as prompt injection. + * Note: a project rule also applies to your own Cursor IDE in this repo, and + * enabling the project layer also loads other `.cursor/` config. + * - "message": legacy inline `# System` block (may be flagged as injection). + * - "omit": not forwarded at all. + */ + systemPrompt?: SystemPromptMode; +``` + +Add the default into the `config` object in `createCursor` (alongside `toolDisplay`, ~line 96): + +```ts + systemPrompt: options.systemPrompt ?? "rules", +``` + +- [ ] **Step 2: Remove the rule on dispose** + +In `src/plugin/index.ts`, add the import near the top (after line 12): + +```ts +import { removeSystemRule } from "../provider/system-rule.js"; +``` + +In the returned hooks object (the `return { auth: {...}, config: ..., provider: ..., ... }`), add a `dispose` hook. Add this property to that returned object: + +```ts + dispose: async () => { + // Best-effort: drop the generated system-prompt rule so it doesn't + // linger in the user's workspace / Cursor IDE after the session ends. + if (directory) removeSystemRule(directory); + }, +``` + +- [ ] **Step 3: Typecheck + full test suite** + +Run: `npm run typecheck && npm test` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/provider/index.ts src/plugin/index.ts +git commit -m "feat(provider): systemPrompt option (default rules) + dispose cleanup" +``` + +--- + +### Task 5: Docs + Changelog + +**Files:** +- Modify: `README.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: README config-table row** + +In `README.md`, add after the `toolDisplay` row (line 126): + +``` +| `systemPrompt` | `"rules"` | How opencode's system prompt reaches the agent — see [System prompt](#system-prompt) | +``` + +- [ ] **Step 2: README "System prompt" section** + +Insert immediately BEFORE `## MCP servers` (line 177): + +```markdown +## System prompt + +opencode drives the Cursor agent the way it drives any provider — through its +**system prompt**. But the Cursor SDK has no system-prompt input (an agent, not a +raw model), and flattening opencode's system prompt into the message stream makes +injection-hardened Cursor models reject it as a prompt-injection attempt. + +So by default (`systemPrompt: "rules"`) the plugin writes opencode's system prompt +to `/.cursor/rules/opencode.mdc` (`alwaysApply: true`, git-ignored) and loads +the `project` settings layer, delivering it through Cursor's **authoritative rules +channel**. Cursor treats rules as system-level instructions, so opencode stays in +control and nothing is flagged. + +Tradeoffs to know: + +- A project rule also applies to **your own Cursor IDE** open on this repo. The + plugin removes the file when the session disposes (best-effort). +- Enabling the `project` layer also loads other `.cursor/` config (`.cursor/mcp.json`, + `.cursor/agents`, hooks). + +Alternatives: + +- `systemPrompt: "message"` — legacy inline delivery (may be rejected as injection). +- `systemPrompt: "omit"` — don't forward the system prompt at all. + +* * * +``` + +- [ ] **Step 3: README security bullet** + +In `## Security`, after the existing option bullets (ending line 108): + +``` +> - By default opencode's system prompt is delivered via a git-ignored Cursor +> rule (`systemPrompt: "rules"`), not inlined into the message stream. See +> [System prompt](#system-prompt). +``` + +- [ ] **Step 4: CHANGELOG** + +In `CHANGELOG.md`, replace line 7 (`_No unreleased changes._`) with: + +```markdown +- **Fixed: Cursor agent rejecting turns as "prompt injection" / "gaslighting."** + The provider flattened opencode's system prompt into the user-message transcript; + Cursor's agent (which has its own system prompt) treated that as an injection + attempt. opencode's system prompt is now delivered through Cursor's authoritative + rules channel — written to a git-ignored `.cursor/rules/opencode.mdc` and loaded + via `settingSources` — so opencode keeps control without being flagged. New + `systemPrompt` option: `"rules"` (default), `"message"` (legacy inline), `"omit"`. +``` + +- [ ] **Step 5: Commit** + +```bash +git add README.md CHANGELOG.md +git commit -m "docs: document systemPrompt rules-channel delivery" +``` + +--- + +### Task 6: Full verification + +- [ ] **Step 1: Gate commands (must be green)** + +Run: `npm run typecheck && npm test && npm run build` +Expected: typecheck clean; all Vitest files pass; tsup build succeeds. + +- [ ] **Step 2: No stray inline system forwarding by default** + +Run: `rg -n "# System" src` +Expected: only the `"message"`-mode branch in `src/provider/message-map.ts`. + +- [ ] **Step 3 (optional): E2E smoke** + +Run: `bash scripts/integration-test.sh` +Expected: `opencode models` lists `cursor/*`. Skip if opencode CLI isn't installed. + +--- + +## Self-review notes + +- Types consistent: `SystemPromptMode` defined in `message-map.ts`, imported by `language-model.ts` and `provider/index.ts`; default `"rules"` applied in `createCursor` and defensively in `agentRun`. +- No sidecar/RPC changes — rules load from disk at `Agent.create`, exercised identically by both backends. +- Session pooling untouched: fingerprint reads `options.prompt`; rule content tracks the system prompt, which already drives fresh-vs-resume. +- Escape hatches (`"message"`, `"omit"`) preserve prior behavior for anyone who needs it. diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 9a7011c..6eb5ad3 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -11,6 +11,7 @@ import { } from "./mcp-config.js"; import { buildCursorTools } from "./cursor-tools.js"; import { warnIfStale } from "../version-check.js"; +import { removeSystemRule } from "../provider/system-rule.js"; function apiKeyFromAuth(auth: Auth | undefined): string | undefined { return auth?.type === "api" ? auth.key : undefined; @@ -47,6 +48,12 @@ export const CursorPlugin: Plugin = async (input) => { // (reflecting mid-session enable/disable) rather than the startup snapshot. const client = input?.client; const directory = input?.directory; + // Canonical working directory for the generated system-prompt rule: the + // provider writes `.cursor/rules/opencode.mdc` under this path and dispose + // cleans it up from the same path. The config hook threads it into the + // provider options (respecting a user-configured `cwd` option) so write and + // cleanup can never diverge. + let resolvedCwd = directory ?? process.cwd(); let forwardMcp = true; let userMcp: Record = {}; // OAuth servers we've already warned about, so the toast fires once per @@ -102,12 +109,21 @@ export const CursorPlugin: Plugin = async (input) => { ? { ...userMcp, ...translateMcpServers(config.mcp) } : userMcp; + // One canonical cwd for the provider's rule write and our dispose + // cleanup: an explicit user option wins, else the plugin directory. + const optionCwd = existingOptions["cwd"]; + resolvedCwd = + (typeof optionCwd === "string" ? optionCwd : undefined) ?? + directory ?? + process.cwd(); + config.provider[PROVIDER_ID] = { name: "Cursor", npm: providerNpm(), ...existing, options: { ...existingOptions, + cwd: resolvedCwd, ...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}), }, models: { ...toOpencodeModels(models), ...(existing.models ?? {}) }, @@ -219,6 +235,14 @@ export const CursorPlugin: Plugin = async (input) => { defaultCwd: () => input?.directory ?? process.cwd(), }), }, + + dispose: async () => { + // Best-effort: drop the generated system-prompt rule so it doesn't + // linger in the user's workspace / Cursor IDE after the session ends. + // Uses the same canonical cwd the provider wrote to; sentinel-guarded, + // so a user-owned opencode.mdc is never deleted. + removeSystemRule(resolvedCwd); + }, }; }; diff --git a/src/provider/index.ts b/src/provider/index.ts index a9a49ea..de224ae 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -16,6 +16,7 @@ import { type CursorModelConfig, } from "./language-model.js"; import type { ToolDisplay } from "./stream-map.js"; +import type { SystemPromptMode } from "./message-map.js"; export interface CursorProviderOptions { /** @@ -65,6 +66,17 @@ export interface CursorProviderOptions { * hosts (works everywhere). */ toolDisplay?: ToolDisplay; + /** + * How opencode's system prompt reaches the Cursor agent: + * - "rules" (default): written to `/.cursor/rules/opencode.mdc` + * (git-ignored, alwaysApply) and loaded via the `project` settings layer — + * Cursor's authoritative channel, so it is not rejected as prompt injection. + * Note: a project rule also applies to your own Cursor IDE in this repo, and + * enabling the project layer also loads other `.cursor/` config. + * - "message": legacy inline `# System` block (may be flagged as injection). + * - "omit": not forwarded at all. + */ + systemPrompt?: SystemPromptMode; } /** @@ -94,6 +106,7 @@ export function createCursor(options: CursorProviderOptions = {}): ProviderV3 { ...(options.agents ? { agents: options.agents } : {}), session: options.session ?? "auto", toolDisplay: options.toolDisplay ?? "blocks", + systemPrompt: options.systemPrompt ?? "rules", }; const notImplemented = (kind: string, modelId: string): never => { diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index 7c36b1e..60261fb 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -19,7 +19,9 @@ import { latestUserMessage, promptToCursorMessage, trailingUserMessages, + type SystemPromptMode, } from "./message-map.js"; +import { extractSystemText, resolveSystemDelivery } from "./system-rule.js"; import { sendAgentTurnSilently, streamAgentTurn, @@ -77,6 +79,11 @@ export interface CursorModelConfig { * Defaults to `"blocks"`. */ toolDisplay?: ToolDisplay; + /** + * How opencode's system prompt reaches the Cursor agent (see + * {@link SystemPromptMode}). Defaults to "rules". + */ + systemPrompt?: SystemPromptMode; } /** @@ -102,6 +109,15 @@ export class CursorLanguageModel implements LanguageModelV3 { this.provider = config.providerName; } + /** Messages already emitted, so degradation warnings fire once, not per turn. */ + private readonly warned = new Set(); + + private warnOnce(message: string): void { + if (this.warned.has(message)) return; + this.warned.add(message); + console.warn(`[${this.provider}] ${message}`); + } + private requireApiKey(): string { const apiKey = resolveCursorApiKey(this.config.apiKey); if (!apiKey) { @@ -235,6 +251,21 @@ export class CursorLanguageModel implements LanguageModelV3 { } } + // In "rules" mode (default), deliver opencode's system prompt through + // Cursor's authoritative rules channel instead of the user transcript. + // Degrades to inline "message" delivery when the user explicitly opted + // out of the "project" settings layer, when the rule file is user-owned, + // or when the write fails (read-only checkout etc.). + const delivery = resolveSystemDelivery({ + mode: this.config.systemPrompt ?? "rules", + settingSources: this.config.settingSources, + cwd: this.config.cwd, + systemText: extractSystemText(options.prompt), + warn: (message) => this.warnOnce(message), + }); + const systemMode: SystemPromptMode = delivery.mode; + const settingSources = delivery.settingSources; + // Shared acquire params. The retry path reuses this verbatim (minus // resumeAgentId) so a fresh agent can never drift from the first attempt's // config (sandbox, settingSources, MCP, etc.). @@ -243,9 +274,7 @@ export class CursorLanguageModel implements LanguageModelV3 { modelSelection, mode, cwd: this.config.cwd, - ...(this.config.settingSources - ? { settingSources: this.config.settingSources } - : {}), + ...(settingSources ? { settingSources } : {}), ...(this.config.sandbox !== undefined ? { sandbox: this.config.sandbox } : {}), @@ -311,8 +340,8 @@ export class CursorLanguageModel implements LanguageModelV3 { // only the new turn; otherwise send the full transcript. const message = acquired.resumed ? (latestUserMessage(options.prompt) ?? - promptToCursorMessage(options.prompt)) - : promptToCursorMessage(options.prompt); + promptToCursorMessage(options.prompt, systemMode)) + : promptToCursorMessage(options.prompt, systemMode); try { for await (const event of streamAgentTurn(acquired.agent, message, { mode, @@ -365,7 +394,7 @@ export class CursorLanguageModel implements LanguageModelV3 { throw retryErr; } try { - const replay = promptToCursorMessage(options.prompt); + const replay = promptToCursorMessage(options.prompt, systemMode); yield* streamAgentTurn(retry.agent, replay, { mode, abortSignal: options.abortSignal, diff --git a/src/provider/message-map.ts b/src/provider/message-map.ts index 199fe5b..5651e04 100644 --- a/src/provider/message-map.ts +++ b/src/provider/message-map.ts @@ -5,6 +5,16 @@ import type { } from "@ai-sdk/provider"; import type { SDKUserMessage } from "@cursor/sdk"; +/** + * How opencode's system prompt reaches the Cursor agent. + * - "rules" (default): delivered out-of-band as a Cursor project rule + * (see system-rule.ts) and therefore omitted from this flattened transcript. + * - "message": legacy — inlined as a `# System` block in the transcript. + * Injection-hardened Cursor models may reject this; kept for back-compat. + * - "omit": dropped entirely (no rule, no inline). + */ +export type SystemPromptMode = "rules" | "message" | "omit"; + /** * Caps on inlined tool payloads in the flattened transcript. Tool outputs are * INCLUDED (truncated) rather than dropped so a fresh/diverged agent still sees @@ -45,13 +55,19 @@ function truncate(text: string, cap: number): string { */ export function promptToCursorMessage( prompt: LanguageModelV3Prompt, + systemPrompt: SystemPromptMode = "rules", ): SDKUserMessage { const lines: string[] = []; prompt.forEach((message) => { switch (message.role) { case "system": - lines.push(`# System\n${message.content}`); + // Only inline in legacy "message" mode. In "rules" mode the system + // prompt is delivered via .cursor/rules (authoritative, not flagged); + // in "omit" mode it is dropped. + if (systemPrompt === "message") { + lines.push(`# System\n${message.content}`); + } break; case "user": { const text: string[] = []; diff --git a/src/provider/system-rule.ts b/src/provider/system-rule.ts new file mode 100644 index 0000000..a7241d4 --- /dev/null +++ b/src/provider/system-rule.ts @@ -0,0 +1,165 @@ +import { + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; +import type { SettingSource } from "@cursor/sdk"; +import type { SystemPromptMode } from "./message-map.js"; + +/** Location of the generated rule, relative to the agent's cwd. */ +const RULES_DIR = join(".cursor", "rules"); +const RULE_FILE = "opencode.mdc"; +const IGNORE_FILE = ".gitignore"; + +/** + * Frontmatter sentinel marking the rule as generated by this plugin. Only + * files carrying it are ever overwritten or deleted, so a user-owned + * `.cursor/rules/opencode.mdc` is never clobbered. + */ +const SENTINEL = "generated: opencode-cursor"; + +/** Concatenate every system-message body from an AI-SDK prompt (trimmed). */ +export function extractSystemText(prompt: LanguageModelV3Prompt): string { + const parts: string[] = []; + for (const message of prompt) { + if (message.role === "system") parts.push(message.content); + } + return parts.join("\n\n").trim(); +} + +/** Outcome of a {@link writeSystemRule} attempt. */ +export type SystemRuleWrite = + /** Rule file created or updated. */ + | "written" + /** Existing generated rule already has this content; write skipped. */ + | "unchanged" + /** No system text to deliver; nothing written. */ + | "empty" + /** A user-owned (sentinel-less) opencode.mdc exists; left untouched. */ + | "blocked"; + +/** True when the file carries the generated-by sentinel in its frontmatter. */ +function isGenerated(content: string): boolean { + if (!content.startsWith("---")) return false; + const end = content.indexOf("\n---", 3); + const frontmatter = end === -1 ? content : content.slice(0, end); + return frontmatter.split(/\r?\n/).includes(SENTINEL); +} + +/** + * Write opencode's system prompt to `/.cursor/rules/opencode.mdc` as an + * always-applied Cursor project rule. Cursor loads this through its authoritative + * rules channel (`settingSources` including "project"), so opencode's controlling + * instructions reach the agent without being flattened into the untrusted + * user-message transcript (which injection-hardened models reject). + * + * The file carries a generated-by sentinel; a pre-existing sentinel-less file + * is treated as user-owned and never overwritten ("blocked"). An existing + * generated rule with identical content is left as-is ("unchanged") to keep + * sync fs writes off the stream hot path. May throw on fs errors (read-only + * checkout etc.) — callers should degrade gracefully. + */ +export function writeSystemRule( + cwd: string, + systemText: string, +): SystemRuleWrite { + if (!systemText) return "empty"; + const dir = join(cwd, RULES_DIR); + const path = join(dir, RULE_FILE); + const body = `---\nalwaysApply: true\n${SENTINEL}\n---\n\n${systemText}\n`; + const existing = existsSync(path) ? readFileSync(path, "utf8") : undefined; + if (existing !== undefined) { + if (!isGenerated(existing)) return "blocked"; + if (existing === body) return "unchanged"; + } + mkdirSync(dir, { recursive: true }); + writeFileSync(path, body, "utf8"); + ensureGitIgnored(dir); + return "written"; +} + +/** + * Keep the generated rule out of git via `.cursor/rules/.gitignore` (which + * also ignores itself so it doesn't pollute `git status`). + */ +function ensureGitIgnored(dir: string): void { + const path = join(dir, IGNORE_FILE); + const existing = existsSync(path) ? readFileSync(path, "utf8") : ""; + const lines = existing.split(/\r?\n/); + const missing = [RULE_FILE, IGNORE_FILE].filter( + (entry) => !lines.includes(entry), + ); + if (missing.length === 0) return; + const prefix = + existing && !existing.endsWith("\n") ? `${existing}\n` : existing; + writeFileSync(path, `${prefix}${missing.join("\n")}\n`, "utf8"); +} + +/** + * Remove the generated rule (best-effort); used on plugin dispose. Only + * deletes files carrying the generated-by sentinel — a user-owned + * opencode.mdc is left in place. + */ +export function removeSystemRule(cwd: string): void { + try { + const path = join(cwd, RULES_DIR, RULE_FILE); + if (isGenerated(readFileSync(path, "utf8"))) rmSync(path); + } catch { + // best effort — already gone or never written + } +} + +/** How the system prompt will be delivered for this turn. */ +export interface SystemDelivery { + mode: SystemPromptMode; + settingSources: SettingSource[] | undefined; +} + +/** + * Decide how opencode's system prompt reaches the Cursor agent for one turn. + * + * In "rules" mode this writes the rule file and enables the `project` + * settings layer — but ONLY when the user did not explicitly configure + * `settingSources` without "project" (a deliberate hardening opt-out: the + * project layer also loads the repo's `.cursor/mcp.json`, hooks, and other + * rules). On an opt-out, a failed write (read-only checkout etc.), or a + * user-owned rule file, it degrades to inline "message" delivery for the + * turn and reports the reason via `warn`. Never throws. + */ +export function resolveSystemDelivery(options: { + mode: SystemPromptMode; + settingSources: SettingSource[] | undefined; + cwd: string; + systemText: string; + warn: (message: string) => void; +}): SystemDelivery { + const { mode, settingSources, cwd, systemText, warn } = options; + if (mode !== "rules") return { mode, settingSources }; + if (settingSources && !settingSources.includes("project")) { + warn( + 'systemPrompt "rules" needs the "project" settings layer, but settingSources was explicitly configured without it; delivering the system prompt inline ("message" mode) instead. Add "project" to settingSources or set systemPrompt: "message" to silence this.', + ); + return { mode: "message", settingSources }; + } + let result: SystemRuleWrite; + try { + result = writeSystemRule(cwd, systemText); + } catch (error) { + warn( + `failed to write .cursor/rules/${RULE_FILE} (${error instanceof Error ? error.message : String(error)}); delivering the system prompt inline ("message" mode) for this turn.`, + ); + return { mode: "message", settingSources }; + } + if (result === "blocked") { + warn( + `.cursor/rules/${RULE_FILE} exists but was not generated by opencode-cursor; leaving it untouched and delivering the system prompt inline ("message" mode).`, + ); + return { mode: "message", settingSources }; + } + if (result === "empty") return { mode, settingSources }; + return { mode, settingSources: settingSources ?? ["project"] }; +} diff --git a/test/language-model-system.test.ts b/test/language-model-system.test.ts new file mode 100644 index 0000000..8a79077 --- /dev/null +++ b/test/language-model-system.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"; +import type { SDKUserMessage } from "@cursor/sdk"; + +const acquireAgent = vi.fn(); +const streamAgentTurn = vi.fn(); + +// Mock the agent runtime boundary so no live Cursor agent is ever spawned. +vi.mock("../src/provider/session-pool.js", () => ({ + acquireAgent: (...args: unknown[]) => acquireAgent(...args), + getSessionRecord: () => undefined, +})); +vi.mock("../src/provider/agent-events.js", () => ({ + streamAgentTurn: (...args: unknown[]) => streamAgentTurn(...args), +})); + +const { CursorLanguageModel } = await import( + "../src/provider/language-model.js" +); +type ModelConfig = ConstructorParameters[1]; + +const dirs: string[] = []; +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "cursor-lm-")); + dirs.push(d); + return d; +} + +let warnSpy: ReturnType; + +beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + acquireAgent.mockImplementation(async () => ({ + agent: {}, + resumed: false, + release: () => {}, + })); + streamAgentTurn.mockImplementation(() => (async function* () {})()); +}); + +afterEach(() => { + warnSpy.mockRestore(); + acquireAgent.mockReset(); + streamAgentTurn.mockReset(); + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +function model(config: Partial & { cwd: string }) { + return new CursorLanguageModel("composer-2.5", { + providerName: "cursor", + apiKey: "test-key", + mode: "agent", + session: false, + ...config, + } as ModelConfig); +} + +const options = { + prompt: [ + { role: "system", content: "SYS PROMPT" }, + { role: "user", content: [{ type: "text", text: "hi" }] }, + ], +} as unknown as LanguageModelV3CallOptions; + +function acquiredSettingSources(): unknown { + expect(acquireAgent).toHaveBeenCalledOnce(); + return (acquireAgent.mock.calls[0]![0] as Record)[ + "settingSources" + ]; +} + +function sentMessage(): SDKUserMessage { + expect(streamAgentTurn).toHaveBeenCalledOnce(); + return streamAgentTurn.mock.calls[0]![1] as SDKUserMessage; +} + +function rulePath(cwd: string): string { + return join(cwd, ".cursor", "rules", "opencode.mdc"); +} + +describe("CursorLanguageModel system prompt delivery (rules mode)", () => { + it("writes the rule and enables the project layer when settingSources is not configured", async () => { + const cwd = tmp(); + await model({ cwd }).doGenerate(options); + expect(acquiredSettingSources()).toEqual(["project"]); + expect(readFileSync(rulePath(cwd), "utf8")).toContain("SYS PROMPT"); + expect(sentMessage().text).not.toContain("# System"); + }); + + it("keeps user-configured settingSources that already include project", async () => { + const cwd = tmp(); + await model({ cwd, settingSources: ["user", "project"] }).doGenerate( + options, + ); + expect(acquiredSettingSources()).toEqual(["user", "project"]); + expect(existsSync(rulePath(cwd))).toBe(true); + }); + + it("respects an explicit settingSources opt-out of project: falls back to message mode without mutating the list", async () => { + const cwd = tmp(); + await model({ cwd, settingSources: ["user"] }).doGenerate(options); + expect(acquiredSettingSources()).toEqual(["user"]); + expect(existsSync(rulePath(cwd))).toBe(false); + expect(sentMessage().text).toContain("# System\nSYS PROMPT"); + expect(warnSpy).toHaveBeenCalled(); + }); + + it("warns only once for a repeated settingSources opt-out", async () => { + const cwd = tmp(); + const m = model({ cwd, settingSources: ["user"] }); + await m.doGenerate(options); + await m.doGenerate(options); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("degrades to message mode for the turn when the rule write fails", async () => { + const cwd = tmp(); + // Make the write fail: `.cursor` exists as a FILE, so mkdir/write throws. + writeFileSync(join(cwd, ".cursor"), "not a dir", "utf8"); + await model({ cwd }).doGenerate(options); + expect(acquiredSettingSources()).toBeUndefined(); + expect(sentMessage().text).toContain("# System\nSYS PROMPT"); + expect(warnSpy).toHaveBeenCalled(); + }); + + it("degrades to message mode when opencode.mdc is user-owned (no sentinel)", async () => { + const cwd = tmp(); + const dir = join(cwd, ".cursor", "rules"); + mkdirSync(dir, { recursive: true }); + const userBody = "---\nalwaysApply: true\n---\n\nMy own rule.\n"; + writeFileSync(join(dir, "opencode.mdc"), userBody, "utf8"); + await model({ cwd }).doGenerate(options); + expect(readFileSync(rulePath(cwd), "utf8")).toBe(userBody); + expect(acquiredSettingSources()).toBeUndefined(); + expect(sentMessage().text).toContain("# System\nSYS PROMPT"); + expect(warnSpy).toHaveBeenCalled(); + }); +}); + +describe("CursorLanguageModel system prompt delivery (message/omit modes)", () => { + it("message mode inlines the system prompt and never writes the rule file", async () => { + const cwd = tmp(); + await model({ cwd, systemPrompt: "message" }).doGenerate(options); + expect(existsSync(rulePath(cwd))).toBe(false); + expect(acquiredSettingSources()).toBeUndefined(); + expect(sentMessage().text).toContain("# System\nSYS PROMPT"); + }); + + it("omit mode drops the system prompt and never writes the rule file", async () => { + const cwd = tmp(); + await model({ cwd, systemPrompt: "omit" }).doGenerate(options); + expect(existsSync(rulePath(cwd))).toBe(false); + expect(acquiredSettingSources()).toBeUndefined(); + expect(sentMessage().text).not.toContain("SYS PROMPT"); + }); + + it("message mode leaves user-configured settingSources untouched", async () => { + const cwd = tmp(); + await model({ + cwd, + systemPrompt: "message", + settingSources: ["user"], + }).doGenerate(options); + expect(acquiredSettingSources()).toEqual(["user"]); + expect(existsSync(rulePath(cwd))).toBe(false); + }); +}); diff --git a/test/message-map.test.ts b/test/message-map.test.ts index 19e567e..ee14f55 100644 --- a/test/message-map.test.ts +++ b/test/message-map.test.ts @@ -24,7 +24,7 @@ const PNG_BYTES = Buffer.from( ); describe("promptToCursorMessage", () => { - it("flattens a multi-role conversation into a transcript", () => { + it("omits the system prompt from the transcript by default (rules mode)", () => { const prompt: LanguageModelV3Prompt = [ { role: "system", content: "Be concise." }, { role: "user", content: [{ type: "text", text: "Hello" }] }, @@ -32,13 +32,34 @@ describe("promptToCursorMessage", () => { { role: "user", content: [{ type: "text", text: "What is 2+2?" }] }, ]; const msg = promptToCursorMessage(prompt); - expect(msg.text).toContain("# System\nBe concise."); + // System prompt is delivered via the Cursor rules channel, not inline. + expect(msg.text).not.toContain("# System"); + expect(msg.text).not.toContain("Be concise."); expect(msg.text).toContain("# User\nHello"); expect(msg.text).toContain("# Assistant\nHi there"); expect(msg.text).toContain("# User\nWhat is 2+2?"); expect(msg.images).toBeUndefined(); }); + it("includes the system prompt inline in 'message' (legacy) mode", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "system", content: "Be concise." }, + { role: "user", content: [{ type: "text", text: "Hi" }] }, + ]; + const msg = promptToCursorMessage(prompt, "message"); + expect(msg.text).toContain("# System\nBe concise."); + }); + + it("omits the system prompt in 'omit' mode", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "system", content: "Be concise." }, + { role: "user", content: [{ type: "text", text: "Hi" }] }, + ]; + const msg = promptToCursorMessage(prompt, "omit"); + expect(msg.text).not.toContain("Be concise."); + expect(msg.text).toBe("# User\nHi"); + }); + // Cursor's LOCAL SDK agent (the only backend the chat path uses) cannot // accept images in any form: `{url}` throws "URL images are only supported // for cloud SDK agents", and `{data,mimeType}` base64 fails the run with an diff --git a/test/plugin-system-rule.test.ts b/test/plugin-system-rule.test.ts new file mode 100644 index 0000000..15e1a19 --- /dev/null +++ b/test/plugin-system-rule.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeSystemRule } from "../src/provider/system-rule.js"; + +// Keep the config hook offline: no live model discovery. +vi.mock("../src/model-discovery.js", () => ({ + discoverModels: async () => ({ models: [], source: "fallback" }), + toOpencodeModels: () => ({}), +})); + +const { default: plugin } = await import("../src/plugin/index.js"); + +const dirs: string[] = []; +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "cursor-plugin-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +function rulePath(cwd: string): string { + return join(cwd, ".cursor", "rules", "opencode.mdc"); +} + +describe("CursorPlugin system-rule cwd threading", () => { + it("threads the plugin directory into the provider options as cwd", async () => { + const dir = tmp(); + const hooks = await plugin({ directory: dir } as never); + const config = { provider: {}, mcp: {} } as never; + await hooks.config!(config); + const options = (config as { provider: Record }> }) + .provider["cursor"]!.options!; + expect(options["cwd"]).toBe(dir); + }); + + it("keeps a user-configured provider cwd option and cleans up there on dispose", async () => { + const dir = tmp(); + const customCwd = tmp(); + const hooks = await plugin({ directory: dir } as never); + const config = { + provider: { cursor: { options: { cwd: customCwd } } }, + mcp: {}, + } as never; + await hooks.config!(config); + const options = (config as { provider: Record }> }) + .provider["cursor"]!.options!; + expect(options["cwd"]).toBe(customCwd); + + writeSystemRule(customCwd, "sys"); + expect(existsSync(rulePath(customCwd))).toBe(true); + await hooks.dispose!(); + expect(existsSync(rulePath(customCwd))).toBe(false); + }); + + it("removes the generated rule from the plugin directory on dispose", async () => { + const dir = tmp(); + const hooks = await plugin({ directory: dir } as never); + const config = { provider: {}, mcp: {} } as never; + await hooks.config!(config); + + writeSystemRule(dir, "sys"); + expect(existsSync(rulePath(dir))).toBe(true); + await hooks.dispose!(); + expect(existsSync(rulePath(dir))).toBe(false); + }); +}); diff --git a/test/system-rule.test.ts b/test/system-rule.test.ts new file mode 100644 index 0000000..f2578cf --- /dev/null +++ b/test/system-rule.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, + chmodSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; +import { + extractSystemText, + writeSystemRule, + removeSystemRule, +} from "../src/provider/system-rule.js"; + +const dirs: string[] = []; +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "cursor-rule-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) { + // Undo any read-only bits set by tests so cleanup can delete the tree. + try { + chmodSync(join(d, ".cursor", "rules", "opencode.mdc"), 0o644); + } catch { + // not present + } + try { + chmodSync(join(d, ".cursor", "rules", ".gitignore"), 0o644); + } catch { + // not present + } + rmSync(d, { recursive: true, force: true }); + } +}); + +describe("extractSystemText", () => { + it("concatenates all system messages, trimmed", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "system", content: "You are opencode." }, + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "system", content: "Be terse." }, + ]; + expect(extractSystemText(prompt)).toBe("You are opencode.\n\nBe terse."); + }); + it("returns empty string when there is no system message", () => { + const prompt: LanguageModelV3Prompt = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + ]; + expect(extractSystemText(prompt)).toBe(""); + }); +}); + +describe("writeSystemRule", () => { + it("writes an always-applied .mdc rule with a generated-by sentinel and git-ignores it", () => { + const cwd = tmp(); + const wrote = writeSystemRule(cwd, "Follow project conventions."); + expect(wrote).toBe("written"); + const rulePath = join(cwd, ".cursor", "rules", "opencode.mdc"); + const body = readFileSync(rulePath, "utf8"); + expect(body).toBe( + "---\nalwaysApply: true\ngenerated: opencode-cursor\n---\n\nFollow project conventions.\n", + ); + const ignore = readFileSync( + join(cwd, ".cursor", "rules", ".gitignore"), + "utf8", + ); + expect(ignore.split(/\r?\n/)).toContain("opencode.mdc"); + }); + it("git-ignores the generated .gitignore itself", () => { + const cwd = tmp(); + writeSystemRule(cwd, "x"); + const ignore = readFileSync( + join(cwd, ".cursor", "rules", ".gitignore"), + "utf8", + ); + expect(ignore.split(/\r?\n/)).toContain(".gitignore"); + }); + it("is a no-op for empty text", () => { + const cwd = tmp(); + expect(writeSystemRule(cwd, "")).toBe("empty"); + expect(existsSync(join(cwd, ".cursor", "rules", "opencode.mdc"))).toBe( + false, + ); + }); + it("does not duplicate the .gitignore entry on rewrite", () => { + const cwd = tmp(); + writeSystemRule(cwd, "a"); + writeSystemRule(cwd, "b"); + const ignore = readFileSync( + join(cwd, ".cursor", "rules", ".gitignore"), + "utf8", + ); + expect(ignore.match(/opencode\.mdc/g)).toHaveLength(1); + }); + it("overwrites the rule body on rewrite", () => { + const cwd = tmp(); + writeSystemRule(cwd, "first"); + expect(writeSystemRule(cwd, "second")).toBe("written"); + const body = readFileSync( + join(cwd, ".cursor", "rules", "opencode.mdc"), + "utf8", + ); + expect(body).toBe( + "---\nalwaysApply: true\ngenerated: opencode-cursor\n---\n\nsecond\n", + ); + expect(body).not.toContain("first"); + }); + it("skips the rewrite when the content is unchanged", () => { + const cwd = tmp(); + writeSystemRule(cwd, "same"); + // Make the file read-only: an actual rewrite would throw EACCES, so a + // clean "unchanged" return proves the write was skipped. + chmodSync(join(cwd, ".cursor", "rules", "opencode.mdc"), 0o444); + expect(writeSystemRule(cwd, "same")).toBe("unchanged"); + }); + it("refuses to overwrite a pre-existing user-owned opencode.mdc", () => { + const cwd = tmp(); + const dir = join(cwd, ".cursor", "rules"); + mkdirSync(dir, { recursive: true }); + const userBody = "---\nalwaysApply: true\n---\n\nMy own rule.\n"; + writeFileSync(join(dir, "opencode.mdc"), userBody, "utf8"); + expect(writeSystemRule(cwd, "overwrite attempt")).toBe("blocked"); + expect(readFileSync(join(dir, "opencode.mdc"), "utf8")).toBe(userBody); + }); + it("preserves a pre-existing .gitignore and appends the rule", () => { + const cwd = tmp(); + const dir = join(cwd, ".cursor", "rules"); + mkdirSync(dir, { recursive: true }); + // Pre-existing entry with no trailing newline exercises the prefix path. + writeFileSync(join(dir, ".gitignore"), "other.txt", "utf8"); + writeSystemRule(cwd, "x"); + const ignore = readFileSync(join(dir, ".gitignore"), "utf8"); + const lines = ignore.split(/\r?\n/); + expect(lines).toContain("other.txt"); + expect(lines).toContain("opencode.mdc"); + }); + it("does not re-append when the rule is already git-ignored", () => { + const cwd = tmp(); + const dir = join(cwd, ".cursor", "rules"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".gitignore"), "opencode.mdc\n.gitignore\n", "utf8"); + writeSystemRule(cwd, "x"); + const ignore = readFileSync(join(dir, ".gitignore"), "utf8"); + expect(ignore.match(/opencode\.mdc/g)).toHaveLength(1); + }); +}); + +describe("removeSystemRule", () => { + it("deletes the generated rule and tolerates a missing file", () => { + const cwd = tmp(); + writeSystemRule(cwd, "x"); + removeSystemRule(cwd); + expect(existsSync(join(cwd, ".cursor", "rules", "opencode.mdc"))).toBe( + false, + ); + expect(() => removeSystemRule(cwd)).not.toThrow(); + }); + it("leaves a user-owned opencode.mdc (no sentinel) in place", () => { + const cwd = tmp(); + const dir = join(cwd, ".cursor", "rules"); + mkdirSync(dir, { recursive: true }); + const userBody = "---\nalwaysApply: true\n---\n\nMy own rule.\n"; + writeFileSync(join(dir, "opencode.mdc"), userBody, "utf8"); + removeSystemRule(cwd); + expect(readFileSync(join(dir, "opencode.mdc"), "utf8")).toBe(userBody); + }); +});