diff --git a/.cursor/cookbook b/.cursor/cookbook new file mode 160000 index 000000000000..df5f5a05e89a --- /dev/null +++ b/.cursor/cookbook @@ -0,0 +1 @@ +Subproject commit df5f5a05e89a7e63d42fccdc4152c20f5ad97cd3 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000000..6c55ef55aaae --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".cursor/cookbook"] + path = .cursor/cookbook + url = https://github.com/cursor/cookbook.git diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index f4ed359de300..7a6ba4ee816d 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -12,6 +12,7 @@ export const popularProviders = [ "google", "openrouter", "vercel", + "cursor", ] const popularProviderSet = new Set(popularProviders) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 278522555f2d..99289259ba0c 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -365,6 +365,7 @@ export const ProvidersLoginCommand = cmd({ anthropic: 4, openrouter: 5, vercel: 6, + cursor: 7, } const pluginProviders = resolvePluginProviders({ hooks, @@ -374,6 +375,11 @@ export const ProvidersLoginCommand = cmd({ providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), }) const options = [ + { + label: "Cursor (Cookbook / Cloud)", + value: "cursor", + hint: "CURSOR_API_KEY + Cloud Agents API", + }, ...pipe( providers, values(), @@ -463,6 +469,12 @@ export const ProvidersLoginCommand = cmd({ prompts.log.info("Create an api key at https://opencode.ai/auth") } + if (provider === "cursor") { + prompts.log.info("Cursor Cookbook: https://github.com/cursor/cookbook") + prompts.log.info("Create a Cursor API key: https://cursor.com/dashboard/integrations") + prompts.log.info("Docs: https://cursor.com/docs/api/sdk/typescript") + } + if (provider === "vercel") { prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") } @@ -473,6 +485,20 @@ export const ProvidersLoginCommand = cmd({ ) } + if (provider === "cursor") { + const key = await prompts.password({ + message: "Paste your Cursor API key (CURSOR_API_KEY)", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(key)) throw new UI.CancelledError() + await put("cursor", { + type: "api", + key, + }) + prompts.outro("Done") + return + } + const key = await prompts.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fc835cf5ee00..f83ad84b03c0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -28,6 +28,9 @@ import { withStatics } from "@/util/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" +import { defaultCursorModelsDevProvider } from "./cursor/models-dev" +import { fetchCursorModelIds } from "./cursor/fetch-models" +import { CursorCloudAgentLanguageModel } from "./cursor/cloud-agent-language-model" const log = Log.create({ service: "provider" }) @@ -822,6 +825,81 @@ function custom(dep: CustomDep): Record { }, }, }), + cursor: Effect.fnUntraced(function* (input: Info) { + const authRecord = yield* dep.auth("cursor").pipe(Effect.orDie) + const env = yield* dep.env() + const apiKey = (authRecord?.type === "api" ? authRecord.key : undefined) ?? env["CURSOR_API_KEY"] + + return { + autoload: Boolean(apiKey), + options: { + baseURL: "https://api.cursor.com/v1", + ...(typeof input.options?.repoUrl === "string" ? { repoUrl: input.options.repoUrl } : {}), + ...(typeof input.options?.startingRef === "string" ? { startingRef: input.options.startingRef } : {}), + }, + async discoverModels(): Promise> { + if (!apiKey) { + log.info("cursor model discovery skipped: no apiKey") + return {} + } + try { + const ids = await fetchCursorModelIds(apiKey) + const models: Record = {} + for (const id of ids) { + if (input.models[id]) continue + const parsedModel: Model = { + id: ModelID.make(id), + providerID: ProviderID.make("cursor"), + name: id, + family: "", + api: { + id, + url: "https://api.cursor.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200_000, output: 32_000 }, + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: false, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + parsedModel.variants = mapValues( + pickBy(ProviderTransform.variants(parsedModel), (v) => !v.disabled), + (v) => omit(v, ["disabled"]), + ) + models[id] = parsedModel + } + log.info("cursor model discovery complete", { + count: Object.keys(models).length, + models: Object.keys(models), + }) + return models + } catch (e) { + log.warn("cursor model discovery failed", { error: e }) + return {} + } + }, + async getModel(_sdk: unknown, modelID: string, options?: Record) { + const key = typeof options?.apiKey === "string" ? options.apiKey : apiKey ?? "" + return new CursorCloudAgentLanguageModel({ + modelId: modelID, + apiKey: key, + providerOptions: options ?? {}, + }) + }, + } + }), kilo: () => Effect.succeed({ autoload: false, @@ -1089,7 +1167,11 @@ const layer: Layer.Layer< using _ = log.time("state") const bridge = yield* EffectBridge.make() const cfg = yield* config.get() - const modelsDev = yield* Effect.promise(() => ModelsDev.get()) + const modelsDev = yield* Effect.promise(async () => { + const raw = await ModelsDev.get() + if (raw["cursor"]) return raw + return { ...raw, cursor: defaultCursorModelsDevProvider() } + }) const database = mapValues(modelsDev, fromModelsDevProvider) const providers: Record = {} as Record @@ -1326,6 +1408,22 @@ const layer: Layer.Layer< }) } + const cursorProvider = ProviderID.make("cursor") + if (discoveryLoaders[cursorProvider] && providers[cursorProvider] && isProviderAllowed(cursorProvider)) { + yield* Effect.promise(async () => { + try { + const discovered = await discoveryLoaders[cursorProvider]() + for (const [modelID, model] of Object.entries(discovered)) { + if (!providers[cursorProvider].models[modelID]) { + providers[cursorProvider].models[modelID] = model + } + } + } catch (e) { + log.warn("state discovery error", { id: "cursor", error: e }) + } + }) + } + for (const hook of plugins) { const p = hook.provider const models = p?.models @@ -1580,20 +1678,24 @@ const layer: Layer.Layer< const s = yield* InstanceState.get(state) const envs = yield* env.all() const key = `${model.providerID}/${model.id}` - if (s.models.has(key)) return s.models.get(key)! + if (model.providerID !== "cursor" && s.models.has(key)) return s.models.get(key)! return yield* Effect.promise(async () => { const provider = s.providers[model.providerID] const sdk = await resolveSDK(model, s, envs) try { + const mergedOptions = { + ...provider.options, + ...model.options, + ...(model.providerID === "cursor" && provider.key ? { apiKey: provider.key } : {}), + } const language = s.modelLoaders[model.providerID] - ? await s.modelLoaders[model.providerID](sdk, model.api.id, { - ...provider.options, - ...model.options, - }) + ? await s.modelLoaders[model.providerID](sdk, model.api.id, mergedOptions) : sdk.languageModel(model.api.id) - s.models.set(key, language) + if (model.providerID !== "cursor") { + s.models.set(key, language) + } return language } catch (e) { if (e instanceof NoSuchModelError) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index b8b8911858fc..a86df7df619d 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -18,6 +18,7 @@ import { PermissionID } from "@/permission/schema" import { Bus } from "@/bus" import { Wildcard } from "@/util/wildcard" import { SessionID } from "@/session/schema" +import { setActiveCursorSession } from "@/provider/cursor/active-session" import { Auth } from "@/auth" import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -110,6 +111,12 @@ const live: Layer.Layer< .join("\n"), ) + if (input.model.providerID === "cursor") { + system.push( + "Note: Cursor Cloud runs in Cursor's VM with Cursor-side tools; OpenCode tool calls are not forwarded for this provider.", + ) + } + const header = system[0] yield* plugin.trigger( "experimental.chat.system.transform", @@ -193,7 +200,11 @@ const live: Layer.Layer< }, ) - const tools = resolveTools(input) + let tools = resolveTools(input) + const isCursor = input.model.providerID === "cursor" + if (isCursor) { + tools = {} + } // LiteLLM and some Anthropic proxies require the tools parameter to be present // when message history contains tool calls, even if no tools are being used. @@ -211,6 +222,7 @@ const live: Layer.Layer< // during compaction), inject a stub tool to satisfy the validation requirement. // The stub description explicitly tells the model not to call it. if ( + !isCursor && (isLiteLLMProxy || input.model.providerID.includes("github-copilot")) && Object.keys(tools).length === 0 && hasToolCalls(input.messages) @@ -363,7 +375,7 @@ const live: Layer.Layer< providerOptions: ProviderTransform.providerOptions(input.model, params.options), activeTools: Object.keys(tools).filter((x) => x !== "invalid"), tools, - toolChoice: input.toolChoice, + toolChoice: isCursor ? ("none" as const) : input.toolChoice, maxOutputTokens: params.maxOutputTokens, abortSignal: input.abort, headers: { @@ -421,6 +433,13 @@ const live: Layer.Layer< (ctrl) => Effect.sync(() => ctrl.abort()), ) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (input.model.providerID === "cursor") setActiveCursorSession(undefined) + }), + ) + if (input.model.providerID === "cursor") setActiveCursorSession(input.sessionID) + const result = yield* run({ ...input, abort: ctrl.signal }) return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))