Skip to content

Commit 4f1fbcd

Browse files
committed
fix(provider): respect explicit settingSources opt-out and harden rule writes
Address PR #56 review findings on the rules-channel system prompt: - never force-append "project" to an explicitly configured settingSources (deliberate hardening opt-out); degrade to inline "message" delivery with a one-time warning instead - wrap the rule write in try/catch: a failed write (read-only checkout) degrades to "message" mode for the turn rather than failing it - add a generated-by sentinel to opencode.mdc frontmatter; a user-owned (sentinel-less) file is never overwritten or deleted - thread one canonical cwd through the provider options and dispose so write and cleanup can't diverge and orphan the rule file - make .cursor/rules/.gitignore ignore itself - skip the rewrite when the rule content is unchanged (no sync fs write on the stream hot path)
1 parent f25bc07 commit 4f1fbcd

6 files changed

Lines changed: 463 additions & 35 deletions

File tree

src/plugin/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ export const CursorPlugin: Plugin = async (input) => {
3939
// (reflecting mid-session enable/disable) rather than the startup snapshot.
4040
const client = input?.client;
4141
const directory = input?.directory;
42+
// Canonical working directory for the generated system-prompt rule: the
43+
// provider writes `.cursor/rules/opencode.mdc` under this path and dispose
44+
// cleans it up from the same path. The config hook threads it into the
45+
// provider options (respecting a user-configured `cwd` option) so write and
46+
// cleanup can never diverge.
47+
let resolvedCwd = directory ?? process.cwd();
4248
let forwardMcp = true;
4349
let userMcp: Record<string, McpServerConfig> = {};
4450
// OAuth servers we've already warned about, so the toast fires once per
@@ -94,12 +100,21 @@ export const CursorPlugin: Plugin = async (input) => {
94100
? { ...userMcp, ...translateMcpServers(config.mcp) }
95101
: userMcp;
96102

103+
// One canonical cwd for the provider's rule write and our dispose
104+
// cleanup: an explicit user option wins, else the plugin directory.
105+
const optionCwd = existingOptions["cwd"];
106+
resolvedCwd =
107+
(typeof optionCwd === "string" ? optionCwd : undefined) ??
108+
directory ??
109+
process.cwd();
110+
97111
config.provider[PROVIDER_ID] = {
98112
name: "Cursor",
99113
npm: providerNpm(),
100114
...existing,
101115
options: {
102116
...existingOptions,
117+
cwd: resolvedCwd,
103118
...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}),
104119
},
105120
models: { ...toOpencodeModels(models), ...(existing.models ?? {}) },
@@ -215,7 +230,9 @@ export const CursorPlugin: Plugin = async (input) => {
215230
dispose: async () => {
216231
// Best-effort: drop the generated system-prompt rule so it doesn't
217232
// linger in the user's workspace / Cursor IDE after the session ends.
218-
if (directory) removeSystemRule(directory);
233+
// Uses the same canonical cwd the provider wrote to; sentinel-guarded,
234+
// so a user-owned opencode.mdc is never deleted.
235+
removeSystemRule(resolvedCwd);
219236
},
220237
};
221238
};

src/provider/language-model.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
promptToCursorMessage,
2020
type SystemPromptMode,
2121
} from "./message-map.js";
22-
import { extractSystemText, writeSystemRule } from "./system-rule.js";
22+
import { extractSystemText, resolveSystemDelivery } from "./system-rule.js";
2323
import { streamAgentTurn, type CursorEvent } from "./agent-events.js";
2424
import {
2525
cursorEventsToContent,
@@ -97,6 +97,15 @@ export class CursorLanguageModel implements LanguageModelV3 {
9797
this.provider = config.providerName;
9898
}
9999

100+
/** Messages already emitted, so degradation warnings fire once, not per turn. */
101+
private readonly warned = new Set<string>();
102+
103+
private warnOnce(message: string): void {
104+
if (this.warned.has(message)) return;
105+
this.warned.add(message);
106+
console.warn(`[${this.provider}] ${message}`);
107+
}
108+
100109
private requireApiKey(): string {
101110
const apiKey = resolveCursorApiKey(this.config.apiKey);
102111
if (!apiKey) {
@@ -196,16 +205,18 @@ export class CursorLanguageModel implements LanguageModelV3 {
196205

197206
// In "rules" mode (default), deliver opencode's system prompt through
198207
// Cursor's authoritative rules channel instead of the user transcript.
199-
const systemMode: SystemPromptMode = this.config.systemPrompt ?? "rules";
200-
let settingSources = this.config.settingSources;
201-
if (systemMode === "rules") {
202-
const systemText = extractSystemText(options.prompt);
203-
if (writeSystemRule(this.config.cwd, systemText)) {
204-
settingSources = settingSources?.includes("project")
205-
? settingSources
206-
: [...(settingSources ?? []), "project"];
207-
}
208-
}
208+
// Degrades to inline "message" delivery when the user explicitly opted
209+
// out of the "project" settings layer, when the rule file is user-owned,
210+
// or when the write fails (read-only checkout etc.).
211+
const delivery = resolveSystemDelivery({
212+
mode: this.config.systemPrompt ?? "rules",
213+
settingSources: this.config.settingSources,
214+
cwd: this.config.cwd,
215+
systemText: extractSystemText(options.prompt),
216+
warn: (message) => this.warnOnce(message),
217+
});
218+
const systemMode: SystemPromptMode = delivery.mode;
219+
const settingSources = delivery.settingSources;
209220

210221
const acquired = await acquireAgent({
211222
apiKey: this.requireApiKey(),

src/provider/system-rule.ts

Lines changed: 117 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,21 @@ import {
77
} from "node:fs";
88
import { join } from "node:path";
99
import type { LanguageModelV3Prompt } from "@ai-sdk/provider";
10+
import type { SettingSource } from "@cursor/sdk";
11+
import type { SystemPromptMode } from "./message-map.js";
1012

1113
/** Location of the generated rule, relative to the agent's cwd. */
1214
const RULES_DIR = join(".cursor", "rules");
1315
const RULE_FILE = "opencode.mdc";
1416
const IGNORE_FILE = ".gitignore";
1517

18+
/**
19+
* Frontmatter sentinel marking the rule as generated by this plugin. Only
20+
* files carrying it are ever overwritten or deleted, so a user-owned
21+
* `.cursor/rules/opencode.mdc` is never clobbered.
22+
*/
23+
const SENTINEL = "generated: opencode-cursor";
24+
1625
/** Concatenate every system-message body from an AI-SDK prompt (trimmed). */
1726
export function extractSystemText(prompt: LanguageModelV3Prompt): string {
1827
const parts: string[] = [];
@@ -22,42 +31,135 @@ export function extractSystemText(prompt: LanguageModelV3Prompt): string {
2231
return parts.join("\n\n").trim();
2332
}
2433

34+
/** Outcome of a {@link writeSystemRule} attempt. */
35+
export type SystemRuleWrite =
36+
/** Rule file created or updated. */
37+
| "written"
38+
/** Existing generated rule already has this content; write skipped. */
39+
| "unchanged"
40+
/** No system text to deliver; nothing written. */
41+
| "empty"
42+
/** A user-owned (sentinel-less) opencode.mdc exists; left untouched. */
43+
| "blocked";
44+
45+
/** True when the file carries the generated-by sentinel in its frontmatter. */
46+
function isGenerated(content: string): boolean {
47+
if (!content.startsWith("---")) return false;
48+
const end = content.indexOf("\n---", 3);
49+
const frontmatter = end === -1 ? content : content.slice(0, end);
50+
return frontmatter.split(/\r?\n/).includes(SENTINEL);
51+
}
52+
2553
/**
2654
* Write opencode's system prompt to `<cwd>/.cursor/rules/opencode.mdc` as an
2755
* always-applied Cursor project rule. Cursor loads this through its authoritative
2856
* rules channel (`settingSources` including "project"), so opencode's controlling
2957
* instructions reach the agent without being flattened into the untrusted
30-
* user-message transcript (which injection-hardened models reject). Returns true
31-
* when a rule was written; a no-op (false) for empty text.
58+
* user-message transcript (which injection-hardened models reject).
59+
*
60+
* The file carries a generated-by sentinel; a pre-existing sentinel-less file
61+
* is treated as user-owned and never overwritten ("blocked"). An existing
62+
* generated rule with identical content is left as-is ("unchanged") to keep
63+
* sync fs writes off the stream hot path. May throw on fs errors (read-only
64+
* checkout etc.) — callers should degrade gracefully.
3265
*/
33-
export function writeSystemRule(cwd: string, systemText: string): boolean {
34-
if (!systemText) return false;
66+
export function writeSystemRule(
67+
cwd: string,
68+
systemText: string,
69+
): SystemRuleWrite {
70+
if (!systemText) return "empty";
3571
const dir = join(cwd, RULES_DIR);
72+
const path = join(dir, RULE_FILE);
73+
const body = `---\nalwaysApply: true\n${SENTINEL}\n---\n\n${systemText}\n`;
74+
const existing = existsSync(path) ? readFileSync(path, "utf8") : undefined;
75+
if (existing !== undefined) {
76+
if (!isGenerated(existing)) return "blocked";
77+
if (existing === body) return "unchanged";
78+
}
3679
mkdirSync(dir, { recursive: true });
37-
writeFileSync(
38-
join(dir, RULE_FILE),
39-
`---\nalwaysApply: true\n---\n\n${systemText}\n`,
40-
"utf8",
41-
);
80+
writeFileSync(path, body, "utf8");
4281
ensureGitIgnored(dir);
43-
return true;
82+
return "written";
4483
}
4584

46-
/** Keep the generated rule out of git via `.cursor/rules/.gitignore`. */
85+
/**
86+
* Keep the generated rule out of git via `.cursor/rules/.gitignore` (which
87+
* also ignores itself so it doesn't pollute `git status`).
88+
*/
4789
function ensureGitIgnored(dir: string): void {
4890
const path = join(dir, IGNORE_FILE);
4991
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
50-
if (existing.split(/\r?\n/).includes(RULE_FILE)) return;
92+
const lines = existing.split(/\r?\n/);
93+
const missing = [RULE_FILE, IGNORE_FILE].filter(
94+
(entry) => !lines.includes(entry),
95+
);
96+
if (missing.length === 0) return;
5197
const prefix =
5298
existing && !existing.endsWith("\n") ? `${existing}\n` : existing;
53-
writeFileSync(path, `${prefix}${RULE_FILE}\n`, "utf8");
99+
writeFileSync(path, `${prefix}${missing.join("\n")}\n`, "utf8");
54100
}
55101

56-
/** Remove the generated rule (best-effort); used on plugin dispose. */
102+
/**
103+
* Remove the generated rule (best-effort); used on plugin dispose. Only
104+
* deletes files carrying the generated-by sentinel — a user-owned
105+
* opencode.mdc is left in place.
106+
*/
57107
export function removeSystemRule(cwd: string): void {
58108
try {
59-
rmSync(join(cwd, RULES_DIR, RULE_FILE));
109+
const path = join(cwd, RULES_DIR, RULE_FILE);
110+
if (isGenerated(readFileSync(path, "utf8"))) rmSync(path);
60111
} catch {
61112
// best effort — already gone or never written
62113
}
63114
}
115+
116+
/** How the system prompt will be delivered for this turn. */
117+
export interface SystemDelivery {
118+
mode: SystemPromptMode;
119+
settingSources: SettingSource[] | undefined;
120+
}
121+
122+
/**
123+
* Decide how opencode's system prompt reaches the Cursor agent for one turn.
124+
*
125+
* In "rules" mode this writes the rule file and enables the `project`
126+
* settings layer — but ONLY when the user did not explicitly configure
127+
* `settingSources` without "project" (a deliberate hardening opt-out: the
128+
* project layer also loads the repo's `.cursor/mcp.json`, hooks, and other
129+
* rules). On an opt-out, a failed write (read-only checkout etc.), or a
130+
* user-owned rule file, it degrades to inline "message" delivery for the
131+
* turn and reports the reason via `warn`. Never throws.
132+
*/
133+
export function resolveSystemDelivery(options: {
134+
mode: SystemPromptMode;
135+
settingSources: SettingSource[] | undefined;
136+
cwd: string;
137+
systemText: string;
138+
warn: (message: string) => void;
139+
}): SystemDelivery {
140+
const { mode, settingSources, cwd, systemText, warn } = options;
141+
if (mode !== "rules") return { mode, settingSources };
142+
if (settingSources && !settingSources.includes("project")) {
143+
warn(
144+
'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.',
145+
);
146+
return { mode: "message", settingSources };
147+
}
148+
let result: SystemRuleWrite;
149+
try {
150+
result = writeSystemRule(cwd, systemText);
151+
} catch (error) {
152+
warn(
153+
`failed to write .cursor/rules/${RULE_FILE} (${error instanceof Error ? error.message : String(error)}); delivering the system prompt inline ("message" mode) for this turn.`,
154+
);
155+
return { mode: "message", settingSources };
156+
}
157+
if (result === "blocked") {
158+
warn(
159+
`.cursor/rules/${RULE_FILE} exists but was not generated by opencode-cursor; leaving it untouched and delivering the system prompt inline ("message" mode).`,
160+
);
161+
return { mode: "message", settingSources };
162+
}
163+
if (result === "empty") return { mode, settingSources };
164+
return { mode, settingSources: settingSources ?? ["project"] };
165+
}

0 commit comments

Comments
 (0)