ai_api / src /lib /cloudAgent /agents /cursor.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
7.19 kB
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<string, CloudAgentStatus> = {
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<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${credentials.apiKey}`,
};
if (withBody) headers["Content-Type"] = "application/json";
return headers;
}
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const source: Record<string, unknown> = { repository: params.source.repoUrl };
if (params.source.branch) source.ref = params.source.branch;
const body: Record<string, unknown> = {
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<GetStatusResult> {
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<string, unknown>) => ({
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<string, unknown>) || {};
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<void> {
throw new Error("Cursor Cloud Agents run autonomously and do not support plan approval");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
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<string, unknown>) => {
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
);
}
}