| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; |
| 2 | import type { |
| 3 | ModelCapabilities, |
| 4 | ProviderOptionDescriptor, |
| 5 | ServerProviderAuth, |
| 6 | ServerProviderModel, |
| 7 | ServerProviderState, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import type { CursorSettings } from "../settings.ts"; |
| 10 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 11 | import * as DateTime from "effect/DateTime"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Option from "effect/Option"; |
| 14 | import * as Result from "effect/Result"; |
| 15 | |
| 16 | import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./sdkModel.ts"; |
| 17 | import { |
| 18 | buildBooleanOptionDescriptor, |
| 19 | buildSelectOptionDescriptor, |
| 20 | buildServerProvider, |
| 21 | providerModelsFromSettings, |
| 22 | type ServerProviderDraft, |
| 23 | } from "@t3tools/provider-core/server/snapshotProbe"; |
| 24 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; |
| 25 | |
| 26 | const CURSOR_PRESENTATION = { |
| 27 | displayName: "Cursor", |
| 28 | supportsConversationRollback: false, |
| 29 | showInteractionModeToggle: true, |
| 30 | } as const; |
| 31 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 32 | optionDescriptors: [], |
| 33 | }); |
| 34 | |
| 35 | const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000; |
| 36 | |
| 37 | export 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 | |
| 76 | function getCursorFallbackModels( |
| 77 | cursorSettings: Pick<CursorSettings, "customModels">, |
| 78 | ): ReadonlyArray<ServerProviderModel> { |
| 79 | return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); |
| 80 | } |
| 81 | |
| 82 | function 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 | |
| 92 | function 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 | |
| 98 | export 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 | |
| 165 | export 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 | |
| 187 | function 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 | |
| 203 | interface CursorProviderProbeResult { |
| 204 | readonly version: string | null; |
| 205 | readonly status: Exclude<ServerProviderState, "disabled">; |
| 206 | readonly auth: ServerProviderAuth; |
| 207 | readonly message?: string; |
| 208 | } |
| 209 | |
| 210 | function 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 | |
| 221 | export 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 | |
| 249 | export 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 | }); |