Skip to content

Commit 8e1ed25

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 f19e94b commit 8e1ed25

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
@@ -48,6 +48,12 @@ export const CursorPlugin: Plugin = async (input) => {
4848
// (reflecting mid-session enable/disable) rather than the startup snapshot.
4949
const client = input?.client;
5050
const directory = input?.directory;
51+
// Canonical working directory for the generated system-prompt rule: the
52+
// provider writes `.cursor/rules/opencode.mdc` under this path and dispose
53+
// cleans it up from the same path. The config hook threads it into the
54+
// provider options (respecting a user-configured `cwd` option) so write and
55+
// cleanup can never diverge.
56+
let resolvedCwd = directory ?? process.cwd();
5157
let forwardMcp = true;
5258
let userMcp: Record<string, McpServerConfig> = {};
5359
// OAuth servers we've already warned about, so the toast fires once per
@@ -103,12 +109,21 @@ export const CursorPlugin: Plugin = async (input) => {
103109
? { ...userMcp, ...translateMcpServers(config.mcp) }
104110
: userMcp;
105111

112+
// One canonical cwd for the provider's rule write and our dispose
113+
// cleanup: an explicit user option wins, else the plugin directory.
114+
const optionCwd = existingOptions["cwd"];
115+
resolvedCwd =
116+
(typeof optionCwd === "string" ? optionCwd : undefined) ??
117+
directory ??
118+
process.cwd();
119+
106120
config.provider[PROVIDER_ID] = {
107121
name: "Cursor",
108122
npm: providerNpm(),
109123
...existing,
110124
options: {
111125
...existingOptions,
126+
cwd: resolvedCwd,
112127
...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}),
113128
},
114129
models: { ...toOpencodeModels(models), ...(existing.models ?? {}) },
@@ -224,7 +239,9 @@ export const CursorPlugin: Plugin = async (input) => {
224239
dispose: async () => {
225240
// Best-effort: drop the generated system-prompt rule so it doesn't
226241
// linger in the user's workspace / Cursor IDE after the session ends.
227-
if (directory) removeSystemRule(directory);
242+
// Uses the same canonical cwd the provider wrote to; sentinel-guarded,
243+
// so a user-owned opencode.mdc is never deleted.
244+
removeSystemRule(resolvedCwd);
228245
},
229246
};
230247
};

‎src/provider/language-model.ts‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
trailingUserMessages,
2222
type SystemPromptMode,
2323
} from "./message-map.js";
24-
import { extractSystemText, writeSystemRule } from "./system-rule.js";
24+
import { extractSystemText, resolveSystemDelivery } from "./system-rule.js";
2525
import {
2626
sendAgentTurnSilently,
2727
streamAgentTurn,
@@ -109,6 +109,15 @@ export class CursorLanguageModel implements LanguageModelV3 {
109109
this.provider = config.providerName;
110110
}
111111

112+
/** Messages already emitted, so degradation warnings fire once, not per turn. */
113+
private readonly warned = new Set<string>();
114+
115+
private warnOnce(message: string): void {
116+
if (this.warned.has(message)) return;
117+
this.warned.add(message);
118+
console.warn(`[${this.provider}] ${message}`);
119+
}
120+
112121
private requireApiKey(): string {
113122
const apiKey = resolveCursorApiKey(this.config.apiKey);
114123
if (!apiKey) {
@@ -244,16 +253,18 @@ export class CursorLanguageModel implements LanguageModelV3 {
244253

245254
// In "rules" mode (default), deliver opencode's system prompt through
246255
// Cursor's authoritative rules channel instead of the user transcript.
247-
const systemMode: SystemPromptMode = this.config.systemPrompt ?? "rules";
248-
let settingSources = this.config.settingSources;
249-
if (systemMode === "rules") {
250-
const systemText = extractSystemText(options.prompt);
251-
if (writeSystemRule(this.config.cwd, systemText)) {
252-
settingSources = settingSources?.includes("project")
253-
? settingSources
254-
: [...(settingSources ?? []), "project"];
255-
}
256-
}
256+
// Degrades to inline "message" delivery when the user explicitly opted
257+
// out of the "project" settings layer, when the rule file is user-owned,
258+
// or when the write fails (read-only checkout etc.).
259+
const delivery = resolveSystemDelivery({
260+
mode: this.config.systemPrompt ?? "rules",
261+
settingSources: this.config.settingSources,
262+
cwd: this.config.cwd,
263+
systemText: extractSystemText(options.prompt),
264+
warn: (message) => this.warnOnce(message),
265+
});
266+
const systemMode: SystemPromptMode = delivery.mode;
267+
const settingSources = delivery.settingSources;
257268

258269
// Shared acquire params. The retry path reuses this verbatim (minus
259270
// resumeAgentId) so a fresh agent can never drift from the first attempt's

‎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)