Open History to see the last commit
1import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe";
2import type { SDKModel, SDKUser } from "@cursor/sdk";
3import type {
4 ModelCapabilities,
5 ProviderOptionDescriptor,
6 ServerProviderAuth,
7 ServerProviderModel,
8 ServerProviderState,
9} from "@t3tools/contracts";
10import type { CursorSettings } from "../settings.ts";
11import { createModelCapabilities } from "@t3tools/shared/model";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15import * as Result from "effect/Result";
16
17import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./sdkModel.ts";
18import {
19 buildBooleanOptionDescriptor,
20 buildSelectOptionDescriptor,
21 buildServerProvider,
22 COMPACT_SLASH_COMMAND,
23 providerModelsFromSettings,
24 type ServerProviderDraft,
25} from "@t3tools/provider-core/server/snapshotProbe";
26import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
27
28const CURSOR_PRESENTATION = {
29 displayName: "Cursor",
30 supportsConversationRollback: false,
31 showInteractionModeToggle: true,
32} as const;
33const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
34 optionDescriptors: [],
35});
36
37const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000;
38
39export const buildInitialCursorProviderSnapshot = Effect.fn(
40 "CursorProviderStatus.buildInitialCursorProviderSnapshot",
41)(function* (cursorSettings: CursorSettings): Effect.fn.Return<ServerProviderDraft> {
42 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
43 const models = getCursorFallbackModels(cursorSettings);
44
45 if (!cursorSettings.enabled) {
46 return buildServerProvider({
47 presentation: CURSOR_PRESENTATION,
48 enabled: false,
49 checkedAt,
50 models,
51 probe: {
52 installed: false,
53 version: null,
54 status: "warning",
55 auth: { status: "unknown" },
56 message: "Cursor is disabled in T3 Code settings.",
57 },
58 });
59 }
60
61 return buildServerProvider({
62 presentation: CURSOR_PRESENTATION,
63 enabled: true,
64 checkedAt,
65 models,
66 slashCommands: [COMPACT_SLASH_COMMAND],
67 probe: {
68 installed: true,
69 version: null,
70 status: "warning",
71 auth: { status: "unknown" },
72 message: "Checking Cursor SDK availability...",
73 },
74 });
75});
76
77function getCursorFallbackModels(
78 cursorSettings: Pick<CursorSettings, "customModels">,
79): ReadonlyArray<ServerProviderModel> {
80 return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES);
81}
82
83function toTitleCaseWords(value: string): string {
84 const parts: Array<string> = [];
85 for (const part of value.split(/[\s_-]+/g)) {
86 if (part.length > 0) {
87 parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase());
88 }
89 }
90 return parts.join(" ");
91}
92
93function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined {
94 return model.variants
95 ?.find((variant) => variant.isDefault)
96 ?.params.find((parameter) => parameter.id === parameterId)?.value;
97}
98
99export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities {
100 const seen = new Set<string>();
101 const optionDescriptors: Array<ProviderOptionDescriptor> = [];
102 const parameters = (model.parameters ?? [])
103 .map((parameter, index) => ({ parameter, index }))
104 .toSorted(
105 (left, right) =>
106 cursorSdkParameterPriority(left.parameter.id) -
107 cursorSdkParameterPriority(right.parameter.id) || left.index - right.index,
108 );
109 for (const { parameter } of parameters) {
110 const nativeId = parameter.id.trim();
111 const id = cursorSdkProviderOptionId(nativeId);
112 if (!nativeId || !id || seen.has(id)) {
113 continue;
114 }
115 seen.add(id);
116
117 const values = parameter.values.flatMap((entry) => {
118 const value = entry.value.trim();
119 if (!value) {
120 return [];
121 }
122 return [
123 {
124 value,
125 label: entry.displayName?.trim() || value,
126 },
127 ];
128 });
129 if (values.length === 0) {
130 continue;
131 }
132
133 const label = parameter.displayName?.trim() || toTitleCaseWords(id);
134 const defaultValue = cursorSdkDefaultParameterValue(model, nativeId);
135 const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase()));
136 if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) {
137 if (defaultValue === "true" || defaultValue === "false") {
138 optionDescriptors.push(
139 buildBooleanOptionDescriptor({
140 id,
141 label,
142 currentValue: defaultValue === "true",
143 }),
144 );
145 } else {
146 optionDescriptors.push(buildBooleanOptionDescriptor({ id, label }));
147 }
148 continue;
149 }
150
151 optionDescriptors.push(
152 buildSelectOptionDescriptor({
153 id,
154 label,
155 options: values.map((entry) => ({
156 ...entry,
157 ...(entry.value === defaultValue ? { isDefault: true } : {}),
158 })),
159 }),
160 );
161 }
162
163 return createModelCapabilities({ optionDescriptors });
164}
165
166export function buildCursorDiscoveredModelsFromSdk(
167 models: ReadonlyArray<SDKModel>,
168): ReadonlyArray<ServerProviderModel> {
169 const seen = new Set<string>();
170 return models.flatMap((model) => {
171 const slug = model.id.trim();
172 const name = model.displayName.trim();
173 if (!slug || !name || seen.has(slug)) {
174 return [];
175 }
176 seen.add(slug);
177 const aliases = model.aliases?.map((alias) => alias.trim()).filter(Boolean) ?? [];
178 return [
179 {
180 slug,
181 name,
182 ...(aliases.length > 0 ? { aliases } : {}),
183 isCustom: false,
184 capabilities: buildCursorCapabilitiesFromSdkModel(model),
185 } satisfies ServerProviderModel,
186 ];
187 });
188}
189
190function cursorSdkAuth(user: SDKUser | undefined, type: "api-key" | "browser"): ServerProviderAuth {
191 const email = user?.userEmail?.trim();
192 const apiKeyName = user?.apiKeyName.trim();
193 return {
194 status: "authenticated",
195 type,
196 label:
197 type === "browser"
198 ? "Cursor account"
199 : apiKeyName
200 ? `Cursor API key (${apiKeyName})`
201 : "Cursor API key",
202 ...(email ? { email } : {}),
203 };
204}
205
206interface CursorProviderProbeResult {
207 readonly version: string | null;
208 readonly status: Exclude<ServerProviderState, "disabled">;
209 readonly auth: ServerProviderAuth;
210 readonly message?: string;
211}
212
213function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined {
214 const parts: Array<string> = [];
215 for (const message of messages) {
216 const trimmed = message?.trim();
217 if (trimmed) {
218 parts.push(trimmed);
219 }
220 }
221 return parts.length > 0 ? parts.join(" ") : undefined;
222}
223
224export function buildCursorProviderSnapshot(input: {
225 readonly checkedAt: string;
226 readonly cursorSettings: CursorSettings;
227 readonly parsed: CursorProviderProbeResult;
228 readonly discoveredModels?: ReadonlyArray<ServerProviderModel>;
229 readonly discoveryWarning?: string;
230}): ServerProviderDraft {
231 const message = joinProviderMessages(input.parsed.message, input.discoveryWarning);
232 return buildServerProvider({
233 presentation: CURSOR_PRESENTATION,
234 enabled: input.cursorSettings.enabled,
235 checkedAt: input.checkedAt,
236 models: providerModelsFromSettings(
237 input.discoveredModels ?? [],
238 input.cursorSettings.customModels,
239 EMPTY_CAPABILITIES,
240 ),
241 slashCommands: [COMPACT_SLASH_COMMAND],
242 probe: {
243 installed: true,
244 version: input.parsed.version,
245 status:
246 input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status,
247 auth: input.parsed.auth,
248 ...(message ? { message } : {}),
249 },
250 });
251}
252
253export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* (
254 cursorSettings: CursorSettings,
255 environment?: NodeJS.ProcessEnv,
256 authenticationType: "api-key" | "browser" = "api-key",
257): Effect.fn.Return<ServerProviderDraft, never, CursorSdkCatalog.CursorSdkCatalog> {
258 const checkedAt = DateTime.formatIso(yield* DateTime.now);
259 const fallbackModels = getCursorFallbackModels(cursorSettings);
260
261 if (!cursorSettings.enabled) {
262 return buildServerProvider({
263 presentation: CURSOR_PRESENTATION,
264 enabled: false,
265 checkedAt,
266 models: fallbackModels,
267 probe: {
268 installed: false,
269 version: null,
270 status: "warning",
271 auth: { status: "unknown" },
272 message: "Cursor is disabled in T3 Code settings.",
273 },
274 });
275 }
276
277 const sdkApiKey = environment?.CURSOR_API_KEY?.trim();
278 if (!sdkApiKey) {
279 return buildServerProvider({
280 presentation: CURSOR_PRESENTATION,
281 enabled: cursorSettings.enabled,
282 checkedAt,
283 models: fallbackModels,
284 probe: {
285 installed: true,
286 version: null,
287 status: "error",
288 auth: { status: "unauthenticated" },
289 message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
290 },
291 });
292 }
293
294 const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog;
295 const timeoutMs = resolveProviderProbeTimeoutMs(
296 "cursor",
297 CURSOR_SDK_CATALOG_TIMEOUT_MS,
298 environment,
299 );
300 const readCatalog = Effect.suspend(() => sdkCatalog.read(sdkApiKey)).pipe(
301 Effect.timeoutOption(timeoutMs),
302 );
303 // api.cursor.com latency is intermittent; one slow read should not flip the provider to error.
304 const catalogResult = yield* readCatalog.pipe(
305 Effect.filterOrElse(Option.isSome, () => readCatalog),
306 Effect.result,
307 );
308
309 if (Result.isFailure(catalogResult)) {
310 yield* Effect.logWarning("Cursor SDK catalog probe failed", {
311 cause: catalogResult.failure.cause,
312 });
313 const failure = catalogResult.failure;
314 let message = "Cursor SDK catalog request failed. Check server logs for details.";
315 if (failure.authenticationFailure) {
316 message =
317 authenticationType === "browser"
318 ? "Cursor sign-in expired or was rejected. Sign in again in provider settings."
319 : "Cursor SDK authentication failed. Check CURSOR_API_KEY.";
320 } else if (failure.planRequired) {
321 message =
322 authenticationType === "browser"
323 ? "Cursor needs a paid plan to run in T3 Code. Upgrade this account or sign in with a different one."
324 : "Cursor needs a paid plan to run in T3 Code. Upgrade the account that owns CURSOR_API_KEY or use a key from a paid account.";
325 }
326 return buildServerProvider({
327 presentation: CURSOR_PRESENTATION,
328 enabled: cursorSettings.enabled,
329 checkedAt,
330 models: fallbackModels,
331 probe: {
332 installed: true,
333 version: null,
334 status: "error",
335 auth: { status: failure.authenticationFailure ? "unauthenticated" : "unknown" },
336 message,
337 },
338 });
339 }
340
341 if (Option.isNone(catalogResult.success)) {
342 return buildServerProvider({
343 presentation: CURSOR_PRESENTATION,
344 enabled: cursorSettings.enabled,
345 checkedAt,
346 models: fallbackModels,
347 probe: {
348 installed: true,
349 version: null,
350 status: "error",
351 auth: { status: "unknown" },
352 message: `Cursor SDK catalog request timed out after ${timeoutMs}ms.`,
353 },
354 });
355 }
356
357 const snapshot = catalogResult.success.value;
358 const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models);
359 return buildCursorProviderSnapshot({
360 checkedAt,
361 cursorSettings,
362 parsed: {
363 version: null,
364 status: "ready",
365 auth: cursorSdkAuth(snapshot.user, authenticationType),
366 },
367 discoveredModels,
368 ...(discoveredModels.length === 0
369 ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." }
370 : {}),
371 });
372});