import { CloudAgentBase, type AgentCredentials, type CreateTaskParams, type GetStatusResult, } from "../baseAgent.ts"; import type { CloudAgentActivity, CloudAgentStatus, CloudAgentTask } from "../types.ts"; import { CLOUD_AGENT_STATUS } from "../types.ts"; /** * Cursor Cloud Agent — drives Cursor's Background / Cloud Agents through its official * REST API (api.cursor.com) authenticated with a user or service-account API key. * * This is the API-key path requested in #4227 as a safer alternative to re-using the * Cursor IDE's OAuth session (provider `cursor`, which carries a ban-risk warning). * Like Devin and Jules it is a plain REST adapter — it does NOT pull in the * `@cursor/sdk` package (which ships per-platform native binaries); Cursor's SDK is * itself a thin wrapper over this same REST API. * * NOTE: the endpoint paths and request/response field names follow Cursor's documented * Cloud Agents API (v0). They are pending a live validation run against a real Cursor * API key before this is merged (Rule #18 — external-API integration, see PR notes). * `baseUrl` is overridable per-credential so the version/path can be corrected without * a code change. */ const CURSOR_DEFAULT_BASE_URL = "https://api.cursor.com/v0"; // Cursor returns UPPERCASE status enums that the base `mapStatus()` substring matcher // does not recognize (e.g. FINISHED would fall through to "queued"). Map explicitly, // falling back to the base matcher for anything unforeseen. const CURSOR_STATUS_MAP: Record = { CREATING: CLOUD_AGENT_STATUS.QUEUED, PENDING: CLOUD_AGENT_STATUS.QUEUED, QUEUED: CLOUD_AGENT_STATUS.QUEUED, RUNNING: CLOUD_AGENT_STATUS.RUNNING, FINISHED: CLOUD_AGENT_STATUS.COMPLETED, COMPLETED: CLOUD_AGENT_STATUS.COMPLETED, ERROR: CLOUD_AGENT_STATUS.FAILED, FAILED: CLOUD_AGENT_STATUS.FAILED, CANCELLED: CLOUD_AGENT_STATUS.CANCELLED, EXPIRED: CLOUD_AGENT_STATUS.FAILED, }; export class CursorCloudAgent extends CloudAgentBase { readonly providerId = "cursor-cloud"; readonly baseUrl = CURSOR_DEFAULT_BASE_URL; private resolveBaseUrl(credentials: AgentCredentials): string { return (credentials.baseUrl || this.baseUrl).replace(/\/$/, ""); } private mapCursorStatus(raw: string | undefined | null): CloudAgentStatus { if (!raw) return CLOUD_AGENT_STATUS.QUEUED; return CURSOR_STATUS_MAP[raw.toUpperCase()] ?? this.mapStatus(raw); } private authHeaders(credentials: AgentCredentials, withBody = false): Record { const headers: Record = { Authorization: `Bearer ${credentials.apiKey}`, }; if (withBody) headers["Content-Type"] = "application/json"; return headers; } async createTask( params: CreateTaskParams, credentials: AgentCredentials ): Promise { const taskId = this.generateTaskId(); const source: Record = { repository: params.source.repoUrl }; if (params.source.branch) source.ref = params.source.branch; const body: Record = { prompt: { text: params.prompt }, source, }; if (params.options.autoCreatePr) body.autoCreatePr = true; const response = await fetch(`${this.resolveBaseUrl(credentials)}/agents`, { method: "POST", headers: this.authHeaders(credentials, true), body: JSON.stringify(body), }); if (!response.ok) { const error = await response.text(); throw new Error(`Cursor create agent failed: ${response.status} ${error}`); } const data = await response.json(); return { id: taskId, providerId: this.providerId, externalId: data.id, status: this.mapCursorStatus(data.status), prompt: params.prompt, source: params.source, options: params.options, activities: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } async getStatus(externalId: string, credentials: AgentCredentials): Promise { const response = await fetch( `${this.resolveBaseUrl(credentials)}/agents/${encodeURIComponent(externalId)}`, { headers: this.authHeaders(credentials) } ); if (!response.ok) { const error = await response.text(); throw new Error(`Cursor get agent failed: ${response.status} ${error}`); } const data = await response.json(); const status = this.mapCursorStatus(data.status); const conversation = Array.isArray(data.conversation) ? data.conversation : []; const activities: CloudAgentActivity[] = conversation.map((msg: Record) => ({ id: this.generateActivityId(), type: "message" as const, content: typeof msg.text === "string" ? msg.text : "", timestamp: (msg.createdAt as string) || new Date().toISOString(), })); let result; if (status === CLOUD_AGENT_STATUS.COMPLETED) { const target = (data.target as Record) || {}; result = { prUrl: (target.prUrl as string) || (target.url as string) || undefined, summary: typeof data.summary === "string" ? data.summary : undefined, }; } return { status, externalId, result, activities, error: typeof data.error === "string" ? data.error : undefined, }; } async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise { throw new Error("Cursor Cloud Agents run autonomously and do not support plan approval"); } async sendMessage( externalId: string, message: string, credentials: AgentCredentials ): Promise { const response = await fetch( `${this.resolveBaseUrl(credentials)}/agents/${encodeURIComponent(externalId)}/followup`, { method: "POST", headers: this.authHeaders(credentials, true), body: JSON.stringify({ prompt: { text: message } }), } ); if (!response.ok) { const error = await response.text(); throw new Error(`Cursor follow-up failed: ${response.status} ${error}`); } return { id: this.generateActivityId(), type: "message", content: message, timestamp: new Date().toISOString(), }; } async listSources( credentials: AgentCredentials ): Promise<{ name: string; url: string; branch?: string }[]> { const response = await fetch(`${this.resolveBaseUrl(credentials)}/repositories`, { headers: this.authHeaders(credentials), }); if (!response.ok) return []; const data = await response.json(); const repos = Array.isArray(data?.repositories) ? data.repositories : Array.isArray(data) ? data : []; return repos .map((repo: Record) => { const url = (repo.url as string) || (repo.repository as string) || ""; if (!url) return null; const name = (repo.name as string) || url.split("/").slice(-2).join("/"); return { name, url }; }) .filter((entry: { name: string; url: string } | null): entry is { name: string; url: string } => entry !== null ); } }