1import * as FileSystem from "effect/FileSystem";
2
3import type { AgentOptions, RunResult } from "@cursor/sdk";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7
8import { type ProviderSetupError, TextGenerationError } from "@t3tools/contracts";
9import type { CursorSettings } from "../settings.ts";
10
11import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
12import { cursorSdkModelSelection } from "./sdkModel.ts";
13import type { CursorAuth } from "./auth.ts";
14import * as CursorSdk from "./CursorSdk.ts";
15import * as HostProcess from "@t3tools/shared/HostProcess";
16
17const CURSOR_TIMEOUT_MS = 180_000;
18
19const isTextGenerationError = Schema.is(TextGenerationError);
20
21function cursorSdkResultDetail(result: RunResult): string {
22 switch (result.status) {
23 case "cancelled":
24 return "Cursor SDK request was cancelled.";
25 case "error":
26 return "Cursor SDK request finished with an error.";
27 case "finished":
28 return "Cursor SDK returned empty output.";
29 }
30}
31
32/**
33 * The SDK throws this when `sandboxOptions.enabled` is set and local sandboxing
34 * is unavailable. That happens on hosts that cannot launch `cursorsandbox`, and
35 * also after an unsandboxed run caches "unsupported" for the process.
36 */
37function cursorSandboxUnsupported(cause: unknown): boolean {
38 const seen = new Set<object>();
39 let current = cause;
40 while (typeof current === "object" && current !== null && !seen.has(current)) {
41 seen.add(current);
42 if (current instanceof Error && current.message.includes("sandboxing is not supported")) {
43 return true;
44 }
45 current = Reflect.get(current, "cause");
46 }
47 return false;
48}
49
50/**
51 * Build a Cursor text-generation closure bound to a specific `CursorSettings`
52 * payload. See `makeCodexAdapter` for the overall per-instance rationale.
53 */
54export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* (
55 cursorSettings: CursorSettings,
56 environment?: NodeJS.ProcessEnv,
57 resolveApiKey?: Effect.Effect<string, ProviderSetupError>,
58 withAccess?: CursorAuth["withAccess"],
59) {
60 const fs = yield* FileSystem.FileSystem;
61 const { Agent, SqliteLocalAgentStore } = yield* CursorSdk.CursorSdk;
62 const resolvedEnvironment = environment ?? process.env;
63
64 const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) =>
65 Effect.gen(function* () {
66 if (!cursorSettings.enabled) {
67 return yield* new TextGenerationError({
68 operation,
69 detail: "Cursor is disabled in T3 Code settings.",
70 });
71 }
72
73 const apiKey = resolveApiKey
74 ? yield* resolveApiKey
75 : resolvedEnvironment.CURSOR_API_KEY?.trim();
76 if (!apiKey) {
77 return yield* new TextGenerationError({
78 operation,
79 detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
80 });
81 }
82
83 return apiKey;
84 });
85
86 // Ignores `cwd`: the agent runs in an empty temp directory, away from the project.
87 const runCursorJson: TextGenerationOperations.Runner = (input) => {
88 const { operation, prompt, modelSelection } = input;
89 return Effect.gen(function* () {
90 const apiKey = yield* resolveCursorApiKey(operation);
91 // The SDK loads sandbox.json independently of settingSources and lets it
92 // expand the writable paths. Its public API cannot override that policy.
93 if (yield* fs.exists(`${yield* HostProcess.HomeDirectory}/.cursor/sandbox.json`)) {
94 return yield* new TextGenerationError({
95 operation,
96 detail:
97 "Cursor text generation cannot enforce workspace isolation with a custom ~/.cursor/sandbox.json. Use another text-generation provider.",
98 });
99 }
100 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" });
101 // The SDK keeps SQLite handles open after `agent.close()` and only releases
102 // them from a store the caller disposes. Its state lives outside `cwd` so the
103 // agent's workspace stays empty.
104 const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-state-" });
105 const store = yield* Effect.acquireRelease(
106 Effect.tryPromise(() => SqliteLocalAgentStore.open({ workspaceRef: cwd, stateRoot })),
107 (store) => Effect.tryPromise(() => store.dispose()).pipe(Effect.ignore({ log: true })),
108 );
109 const agentOptions = {
110 apiKey,
111 mode: "plan",
112 model: cursorSdkModelSelection(modelSelection),
113 local: {
114 cwd,
115 store,
116 autoReview: false,
117 sandboxOptions: { enabled: true },
118 settingSources: [],
119 enableAgentRetries: true,
120 },
121 } satisfies AgentOptions;
122 const createCursorAgent = (sandboxEnabled: boolean) =>
123 Effect.tryPromise((signal) =>
124 Agent.create(
125 sandboxEnabled
126 ? agentOptions
127 : {
128 ...agentOptions,
129 local: {
130 ...agentOptions.local,
131 sandboxOptions: { enabled: false },
132 },
133 },
134 ).then((agent) => {
135 if (signal.aborted) agent.close();
136 return agent;
137 }),
138 );
139
140 const request = Effect.gen(function* () {
141 // Prefer the sandbox. When the SDK refuses it, the empty temp directory
142 // and empty setting sources still keep this run off the user's project.
143 const agent = yield* Effect.acquireRelease(
144 createCursorAgent(true).pipe(
145 Effect.catchIf(cursorSandboxUnsupported, () => createCursorAgent(false)),
146 ),
147 (agent) =>
148 Effect.tryPromise(() => agent[Symbol.asyncDispose]()).pipe(
149 Effect.timeout("5 seconds"),
150 Effect.ignore({ log: true }),
151 ),
152 { interruptible: true },
153 );
154 const run = yield* Effect.tryPromise((signal) =>
155 agent.send(prompt).then((run) => {
156 if (signal.aborted) void run.cancel().catch(() => undefined);
157 return run;
158 }),
159 );
160 yield* Effect.addFinalizer(() =>
161 run.status === "running"
162 ? Effect.tryPromise(() => run.cancel()).pipe(
163 Effect.timeout("5 seconds"),
164 Effect.ignore({ log: true }),
165 )
166 : Effect.void,
167 );
168 return yield* Effect.tryPromise(() => run.wait());
169 }).pipe(Effect.scoped);
170 const promptResult = yield* request.pipe(
171 Effect.timeoutOption(CURSOR_TIMEOUT_MS),
172 Effect.flatMap(
173 Option.match({
174 onNone: () =>
175 Effect.fail(
176 new TextGenerationError({
177 operation,
178 detail: "Cursor SDK request timed out.",
179 }),
180 ),
181 onSome: (value) => Effect.succeed(value),
182 }),
183 ),
184 );
185
186 const rawResult = promptResult.result?.trim() ?? "";
187 if (promptResult.status !== "finished" || !rawResult) {
188 return yield* new TextGenerationError({
189 operation,
190 detail: cursorSdkResultDetail(promptResult),
191 });
192 }
193
194 return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult);
195 }).pipe(
196 // Scope this call inside `withAccess`, so sign-out closes the agent before its store.
197 Effect.scoped,
198 (effect) => (withAccess ? withAccess(effect) : effect),
199 Effect.scoped,
200 Effect.mapError((cause) =>
201 isTextGenerationError(cause)
202 ? cause
203 : new TextGenerationError({
204 operation,
205 detail: "Cursor SDK text generation failed.",
206 cause,
207 }),
208 ),
209 );
210 };
211
212 return TextGenerationOperations.fromRunner(
213 "CursorTextGeneration",
214 TextGenerationOperations.withoutInvestigation("Cursor", runCursorJson),
215 );
216});