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