From 534aee66aee9723f7e56cd9379e1e0efde5e899b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 10 May 2026 17:21:04 -0700 Subject: [PATCH] Add cursor_cloud adapter for Cursor SDK + Cloud Agents API v1 (#5664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - There are many adapter types, one per agent-runtime product (Claude, Codex, OpenCode, Cursor local CLI, etc.) > - Cursor shipped a public TypeScript SDK on 2026-04-29 that exposes Cursor's full hosted-agent platform (cloud VMs, harness, MCP, skills, hooks) > - Paperclip had no first-class adapter for this — agents that wanted to use Cursor's managed cloud runtime had to fall back to the local CLI adapter, which loses the cloud session, streaming, and durable run model > - This PR adds a new `cursor_cloud` adapter built directly on `@cursor/sdk`, with Paperclip's heartbeat mapped to Cursor's durable-agent + per-run model > - The benefit is that any Paperclip agent can now drive a Cursor cloud agent across heartbeats with native session reuse, streaming, and cancellation, while Paperclip remains the source of truth for issue/task state ## What Changed - New built-in adapter package `packages/adapters/cursor-cloud` (15 files, ~1.7k LOC) backed by `@cursor/sdk` ^1.0.12 - `src/server/execute.ts` — SDK-first lifecycle: `Agent.create` / `Agent.resume` / `Agent.getRun` / `agent.send` / `run.stream` / `run.wait`, with session reuse keyed on the (runtime env type, env name, repo set) tuple - `src/server/session.ts` — codec for `cursorAgentId` + `latestRunId` + repo metadata, persisted in `runtime.sessionParams` - `src/server/test.ts` — environment probe via `Cursor.me()` and optional model validation via `Cursor.models.list()` - `src/ui/parse-stdout.ts` + `src/cli/format-event.ts` — normalize Cursor SDK message types (`status`, `thinking`, `assistant`, `user`, `tool_call`, `tool_result`, `result`) into Paperclip transcript events for the UI and CLI - Registrations: `packages/shared/src/constants.ts`, `packages/adapter-utils/src/session-compaction.ts`, `server/src/adapters/{registry,builtin-adapter-types}.ts`, `ui/src/adapters/{registry,adapter-display-registry}.ts` + `ui/src/adapters/cursor-cloud/index.ts`, `cli/src/adapters/registry.ts`, plus workspace deps in `cli`/`server`/`ui` `package.json` - `ui/src/components/AgentConfigForm.tsx` — hide local-Cursor `mode`/thinking-effort field for `cursor_cloud` (different config surface) - 11 vitest tests covering execute paths (fresh create, matching-resume, active-run reattach, non-finished result), session codec round-trip, transcript parsing, and config building ## Verification Reviewer steps: ```bash pnpm install pnpm --filter @paperclipai/adapter-cursor-cloud typecheck # → clean pnpm vitest run packages/adapters/cursor-cloud # → 11/11 passing ``` End-to-end check against a real Cursor cloud agent (requires `CURSOR_API_KEY` and Cursor GitHub-app install on the target repo): 1. Create a `cursor_cloud` agent in Paperclip with `repoUrl` set to the test repo, `repoStartingRef: main`, and `env.CURSOR_API_KEY` set 2. Trigger a heartbeat → adapter calls `Agent.create({ cloud: { env: { type: "cloud" }, repos: [...] } })`, streams events, terminates on `finished` 3. Trigger a second heartbeat → adapter calls `Agent.resume` or `agent.send` follow-up depending on prior-run state, reusing `cursorAgentId` 4. The Paperclip UI/CLI transcript reflects Cursor `status` / `thinking` / `assistant` events as they stream 5. Cancellation from Paperclip maps to `run.cancel()` or Cloud API v1 `cancelRun` for cross-heartbeat cancellation A direct-SDK smoke run against a real repo (devinfoley/my_test_project @ main) confirmed: `Cursor.me()` ok → `Agent.create` → `agent.send` → `run.stream()` (30 events) → terminal status `finished` in ~11s. ## Risks - **New adapter, additive only.** No existing adapter or registry is replaced; current `cursor` local-CLI adapter is untouched. Default behavior of any existing agent is unchanged. - **External dependency on `@cursor/sdk`.** Cursor's SDK is v1.0.x and may evolve. Mocked unit tests cover the public surface used here; if the SDK breaks compatibility we update the adapter independently. - **Cost/budget.** `cursor_cloud` runs on Cursor's billed cloud VMs; operators must understand they are spending money outside Paperclip's budget controls when they enable this adapter. Same shape as other API-billed adapters. - **No webhook support in V1.** The SDK already provides stream/wait/cancel/reattach, so V1 does not require a public callback URL. If a future use case needs out-of-band wakes, we add a Cloud API v1 webhook bridge as a separate change. This is called out in the issue plan document. - **Lockfile.** Per repo policy, `pnpm-lock.yaml` is intentionally not in this PR — CI's lockfile workflow will update it on merge given the manifest changes. ## Model Used - Provider: Anthropic Claude (via Claude Code / Paperclip `claude_local` adapter) - Model: `claude-opus-4-7` (Claude Opus 4.7), knowledge cutoff January 2026 - Mode: standard tool-use with extended reasoning - Context: ~200k token window - Capabilities used: code generation, multi-file edits, shell/test execution, GitHub PR workflow ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass (11/11 in `packages/adapters/cursor-cloud`) - [x] I have added or updated tests where applicable (4 new test files, 11 cases) - [ ] If this change affects the UI, I have included before/after screenshots (the only UI change is hiding the local-Cursor mode field on the `cursor_cloud` adapter — happy to attach a screenshot if the reviewer wants one) - [x] I have updated relevant documentation to reflect my changes (issue plan document supersedes the pre-SDK design; tracked in PAPA-203) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- Dockerfile | 1 + cli/package.json | 1 + cli/src/adapters/registry.ts | 7 + .../adapter-utils/src/session-compaction.ts | 6 + packages/adapters/cursor-cloud/package.json | 61 ++ .../cursor-cloud/src/cli/format-event.ts | 42 ++ .../adapters/cursor-cloud/src/cli/index.ts | 1 + packages/adapters/cursor-cloud/src/index.ts | 34 + .../cursor-cloud/src/server/execute.test.ts | 348 ++++++++++ .../cursor-cloud/src/server/execute.ts | 607 ++++++++++++++++++ .../adapters/cursor-cloud/src/server/index.ts | 70 ++ .../cursor-cloud/src/server/session.test.ts | 31 + .../cursor-cloud/src/server/session.ts | 61 ++ .../adapters/cursor-cloud/src/server/test.ts | 118 ++++ .../cursor-cloud/src/ui/build-config.test.ts | 85 +++ .../cursor-cloud/src/ui/build-config.ts | 67 ++ .../adapters/cursor-cloud/src/ui/index.ts | 2 + .../cursor-cloud/src/ui/parse-stdout.test.ts | 143 +++++ .../cursor-cloud/src/ui/parse-stdout.ts | 186 ++++++ packages/adapters/cursor-cloud/tsconfig.json | 9 + packages/shared/src/constants.ts | 1 + scripts/release-package-manifest.json | 5 + server/package.json | 1 + server/src/adapters/builtin-adapter-types.ts | 1 + server/src/adapters/registry.ts | 23 + ui/package.json | 1 + ui/src/adapters/adapter-display-registry.ts | 5 + ui/src/adapters/cursor-cloud/index.ts | 14 + ui/src/adapters/registry.ts | 2 + ui/src/components/AgentConfigForm.tsx | 2 +- vitest.config.ts | 1 + 31 files changed, 1935 insertions(+), 1 deletion(-) create mode 100644 packages/adapters/cursor-cloud/package.json create mode 100644 packages/adapters/cursor-cloud/src/cli/format-event.ts create mode 100644 packages/adapters/cursor-cloud/src/cli/index.ts create mode 100644 packages/adapters/cursor-cloud/src/index.ts create mode 100644 packages/adapters/cursor-cloud/src/server/execute.test.ts create mode 100644 packages/adapters/cursor-cloud/src/server/execute.ts create mode 100644 packages/adapters/cursor-cloud/src/server/index.ts create mode 100644 packages/adapters/cursor-cloud/src/server/session.test.ts create mode 100644 packages/adapters/cursor-cloud/src/server/session.ts create mode 100644 packages/adapters/cursor-cloud/src/server/test.ts create mode 100644 packages/adapters/cursor-cloud/src/ui/build-config.test.ts create mode 100644 packages/adapters/cursor-cloud/src/ui/build-config.ts create mode 100644 packages/adapters/cursor-cloud/src/ui/index.ts create mode 100644 packages/adapters/cursor-cloud/src/ui/parse-stdout.test.ts create mode 100644 packages/adapters/cursor-cloud/src/ui/parse-stdout.ts create mode 100644 packages/adapters/cursor-cloud/tsconfig.json create mode 100644 ui/src/adapters/cursor-cloud/index.ts diff --git a/Dockerfile b/Dockerfile index ec82a186..10a5ee64 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ COPY packages/mcp-server/package.json packages/mcp-server/ COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ +COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ diff --git a/cli/package.json b/cli/package.json index c10ce615..2dffbeee 100644 --- a/cli/package.json +++ b/cli/package.json @@ -40,6 +40,7 @@ "@paperclipai/adapter-acpx-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 59799cf4..d7d16f17 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -3,6 +3,7 @@ import { printAcpxStreamEvent } from "@paperclipai/adapter-acpx-local/cli"; import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; +import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli"; import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; @@ -40,6 +41,11 @@ const cursorLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCursorStreamEvent, }; +const cursorCloudCLIAdapter: CLIAdapterModule = { + type: "cursor_cloud", + formatStdoutEvent: printCursorCloudEvent, +}; + const geminiLocalCLIAdapter: CLIAdapterModule = { type: "gemini_local", formatStdoutEvent: printGeminiStreamEvent, @@ -58,6 +64,7 @@ const adaptersByType = new Map( openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, + cursorCloudCLIAdapter, geminiLocalCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index c42cbf8f..1de7f3d6 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -40,6 +40,7 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ "acpx_local", "claude_local", "codex_local", + "cursor_cloud", "cursor", "gemini_local", "hermes_local", @@ -63,6 +64,11 @@ export const ADAPTER_SESSION_MANAGEMENT: Record; + streamMessages?: unknown[]; + streamError?: Error | null; +}; + +type MockAgentOptions = { + agentId?: string; + sendRun?: ReturnType; +}; + +const { createMock, resumeMock, getRunMock } = vi.hoisted(() => ({ + createMock: vi.fn(), + resumeMock: vi.fn(), + getRunMock: vi.fn(), +})); + +vi.mock("@cursor/sdk", () => ({ + Agent: { + create: createMock, + resume: resumeMock, + getRun: getRunMock, + }, +})); + +function createMockRun(options: MockRunOptions = {}) { + const runId = options.id ?? "run-123"; + const agentId = options.agentId ?? "agent-123"; + const status = options.status ?? "finished"; + const waitResult = options.waitResult ?? { + id: runId, + status, + result: "Done\nWith detail", + model: { id: "gpt-5.4" }, + durationMs: 1234, + }; + const streamMessages = options.streamMessages ?? []; + const streamError = options.streamError ?? null; + + return { + id: runId, + agentId, + status, + result: typeof waitResult.result === "string" ? waitResult.result : null, + model: waitResult.model ?? null, + durationMs: waitResult.durationMs ?? null, + git: waitResult.git ?? null, + supports(capability: string) { + return capability === "stream" || capability === "wait"; + }, + async *stream() { + for (const message of streamMessages) yield message; + if (streamError) throw streamError; + }, + async wait() { + return waitResult; + }, + }; +} + +function createMockSdkAgent(options: MockAgentOptions = {}) { + const sendRun = options.sendRun ?? createMockRun(); + return { + agentId: options.agentId ?? sendRun.agentId, + send: vi.fn(async () => sendRun), + [Symbol.asyncDispose]: vi.fn(async () => {}), + }; +} + +function createContext( + overrides: Partial = {}, +): AdapterExecutionContext & { + logs: Array<{ stream: "stdout" | "stderr"; chunk: string }>; + meta: Record[]; +} { + const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + const meta: Record[] = []; + const agent = overrides.agent ?? { + id: "agent-1", + companyId: "company-1", + name: "Cursor Cloud Agent", + adapterType: "cursor_cloud", + adapterConfig: {}, + }; + const runtime = overrides.runtime ?? { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }; + const config = overrides.config ?? { + env: { + CURSOR_API_KEY: "cursor-secret", + EXTRA_FLAG: "1", + }, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoStartingRef: "main", + runtimeEnvType: "cloud", + promptTemplate: "Do the work for {{agent.name}}", + model: "gpt-5.4", + }; + const context = overrides.context ?? { + taskId: "issue-1", + issueId: "issue-1", + wakeReason: "issue_commented", + }; + + const base: AdapterExecutionContext = { + runId: "run-heartbeat-1", + agent, + runtime, + config, + context, + authToken: "paperclip-run-jwt", + onLog: async (stream, chunk) => { + logs.push({ stream, chunk }); + }, + onMeta: async (entry) => { + meta.push(entry as unknown as Record); + }, + }; + + return { + ...base, + ...overrides, + logs, + meta, + }; +} + +describe("cursor_cloud execute", () => { + beforeEach(() => { + createMock.mockReset(); + resumeMock.mockReset(); + getRunMock.mockReset(); + }); + + it("creates a fresh Cursor agent and injects Paperclip env without CURSOR_API_KEY", async () => { + const run = createMockRun({ + agentId: "agent-fresh", + streamMessages: [ + { + type: "assistant", + message: { + content: [{ type: "text", text: "Working" }], + }, + }, + ], + }); + const sdkAgent = createMockSdkAgent({ agentId: "agent-fresh", sendRun: run }); + createMock.mockResolvedValue(sdkAgent); + const ctx = createContext(); + + const result = await execute(ctx); + + expect(createMock).toHaveBeenCalledTimes(1); + expect(resumeMock).not.toHaveBeenCalled(); + expect(getRunMock).not.toHaveBeenCalled(); + expect(createMock.mock.calls[0]?.[0]).toMatchObject({ + apiKey: "cursor-secret", + name: "Paperclip Cursor Cloud Agent", + model: { id: "gpt-5.4" }, + cloud: { + env: { type: "cloud" }, + repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }], + }, + }); + expect(createMock.mock.calls[0]?.[0]?.cloud?.envVars).toMatchObject({ + EXTRA_FLAG: "1", + PAPERCLIP_RUN_ID: "run-heartbeat-1", + PAPERCLIP_TASK_ID: "issue-1", + PAPERCLIP_WAKE_REASON: "issue_commented", + PAPERCLIP_API_KEY: "paperclip-run-jwt", + }); + expect(createMock.mock.calls[0]?.[0]?.cloud?.envVars).not.toHaveProperty("CURSOR_API_KEY"); + + expect(result).toMatchObject({ + exitCode: 0, + errorMessage: null, + sessionId: "agent-fresh", + model: "gpt-5.4", + summary: "Done", + sessionParams: { + cursorAgentId: "agent-fresh", + latestRunId: "run-123", + runtime: "cloud", + envType: "cloud", + repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }], + }, + }); + expect(ctx.logs.map((entry) => entry.chunk)).toEqual( + expect.arrayContaining([ + expect.stringContaining('"type":"cursor_cloud.init"'), + expect.stringContaining('"type":"cursor_cloud.message"'), + expect.stringContaining('"type":"cursor_cloud.result"'), + ]), + ); + }); + + it("resumes a matching saved session when no active run can be reattached", async () => { + getRunMock.mockResolvedValue(createMockRun({ status: "finished" })); + const resumedRun = createMockRun({ id: "run-resumed", agentId: "agent-resumed" }); + const sdkAgent = createMockSdkAgent({ agentId: "agent-resumed", sendRun: resumedRun }); + resumeMock.mockResolvedValue(sdkAgent); + const ctx = createContext({ + runtime: { + sessionId: null, + sessionDisplayId: "agent-previous", + taskKey: null, + sessionParams: { + cursorAgentId: "agent-previous", + latestRunId: "run-previous", + runtime: "cloud", + envType: "cloud", + repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }], + }, + }, + }); + + const result = await execute(ctx); + + expect(getRunMock).toHaveBeenCalledWith("run-previous", { + runtime: "cloud", + agentId: "agent-previous", + apiKey: "cursor-secret", + }); + expect(resumeMock).toHaveBeenCalledTimes(1); + expect(createMock).not.toHaveBeenCalled(); + expect(sdkAgent.send).toHaveBeenCalledTimes(1); + expect(result.sessionId).toBe("agent-resumed"); + }); + + it("reattaches to an active run, drains it, then sends the heartbeat as a follow-up", async () => { + const attachedRun = createMockRun({ + id: "run-attached", + agentId: "agent-attached", + status: "running", + waitResult: { + id: "run-attached", + status: "finished", + result: "Prior result", + model: { id: "gpt-5.4" }, + }, + streamMessages: [ + { + type: "status", + status: "running", + message: "Still working", + }, + ], + }); + getRunMock.mockResolvedValue(attachedRun); + const followUpRun = createMockRun({ + id: "run-followup", + agentId: "agent-attached", + waitResult: { + id: "run-followup", + status: "finished", + result: "Follow-up result", + model: { id: "gpt-5.4" }, + }, + }); + const sdkAgent = createMockSdkAgent({ agentId: "agent-attached", sendRun: followUpRun }); + resumeMock.mockResolvedValue(sdkAgent); + const ctx = createContext({ + runtime: { + sessionId: null, + sessionDisplayId: "agent-attached", + taskKey: null, + sessionParams: { + cursorAgentId: "agent-attached", + latestRunId: "run-attached", + runtime: "cloud", + envType: "cloud", + repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }], + }, + }, + }); + + const result = await execute(ctx); + + expect(getRunMock).toHaveBeenCalledTimes(1); + expect(createMock).not.toHaveBeenCalled(); + expect(resumeMock).toHaveBeenCalledTimes(1); + expect(sdkAgent.send).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + exitCode: 0, + sessionId: "agent-attached", + summary: "Follow-up result", + resultJson: { + cursorRunId: "run-followup", + }, + }); + const logChunks = ctx.logs.map((entry) => entry.chunk); + expect(logChunks).toEqual( + expect.arrayContaining([ + expect.stringContaining("Reattached to existing Cursor run run-attached."), + expect.stringContaining("Prior Cursor run run-attached finished"), + expect.stringContaining("Started Cursor run run-followup."), + expect.stringContaining('"runId":"run-attached"'), + expect.stringContaining('"runId":"run-followup"'), + ]), + ); + expect(ctx.meta[0]?.context).toMatchObject({ + cursorCloud: { + canReuseSession: true, + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + }); + }); + + it("maps non-finished Cursor results to failing Paperclip runs", async () => { + const cancelledRun = createMockRun({ + id: "run-cancelled", + agentId: "agent-cancelled", + status: "cancelled", + waitResult: { + id: "run-cancelled", + status: "cancelled", + result: "", + model: { id: "gpt-5.4" }, + }, + }); + const sdkAgent = createMockSdkAgent({ agentId: "agent-cancelled", sendRun: cancelledRun }); + createMock.mockResolvedValue(sdkAgent); + const ctx = createContext(); + + const result = await execute(ctx); + + expect(result).toMatchObject({ + exitCode: 1, + errorMessage: "Cursor run cancelled", + sessionId: "agent-cancelled", + resultJson: { + status: "cancelled", + cursorAgentId: "agent-cancelled", + cursorRunId: "run-cancelled", + }, + }); + }); +}); diff --git a/packages/adapters/cursor-cloud/src/server/execute.ts b/packages/adapters/cursor-cloud/src/server/execute.ts new file mode 100644 index 00000000..d2695a93 --- /dev/null +++ b/packages/adapters/cursor-cloud/src/server/execute.ts @@ -0,0 +1,607 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + Agent, + type AgentOptions, + type ModelSelection, + type Run, + type RunResult, + type SDKAgent, + type SDKMessage, +} from "@cursor/sdk"; +import type { AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta } from "@paperclipai/adapter-utils"; +import { + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + asBoolean, + asString, + buildPaperclipEnv, + joinPromptSections, + parseObject, + readPaperclipIssueWorkModeFromContext, + renderPaperclipWakePrompt, + renderTemplate, + stringifyPaperclipWakePayload, +} from "@paperclipai/adapter-utils/server-utils"; + +type CursorCloudSession = { + cursorAgentId: string; + latestRunId?: string; + runtime: "cloud"; + envType?: "cloud" | "pool" | "machine"; + envName?: string; + repos: Array<{ url: string; startingRef?: string; prUrl?: string }>; +}; + +type CursorCloudEvent = + | { type: "cursor_cloud.init"; sessionId: string; agentId: string; runId?: string; model?: string } + | { type: "cursor_cloud.status"; status: string; message?: string } + | { type: "cursor_cloud.message"; message: SDKMessage } + | { + type: "cursor_cloud.result"; + status: string; + result?: string; + model?: string; + durationMs?: number; + git?: unknown; + error?: string; + }; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asStringEnvMap(value: unknown): Record { + const parsed = parseObject(value); + const env: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") { + env[key] = entry; + } else if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) { + const rec = entry as Record; + if (rec.type === "plain" && typeof rec.value === "string") env[key] = rec.value; + } + } + return env; +} + +function normalizeEnvType(raw: string): "cloud" | "pool" | "machine" { + const value = raw.trim().toLowerCase(); + if (value === "pool" || value === "machine") return value; + return "cloud"; +} + +function trimNullable(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function toModelSelection(rawModel: string): ModelSelection | undefined { + const model = rawModel.trim(); + return model ? { id: model } : undefined; +} + +function toSummary(result: RunResult): string | null { + const direct = trimNullable(result.result); + if (direct) return firstNonEmptyLine(direct); + return null; +} + +function formatRunError(err: unknown): string { + if (err instanceof Error && err.message.trim().length > 0) return err.message.trim(); + return String(err); +} + +function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record): Record { + const { runId, agent, context, authToken } = ctx; + const env: Record = { + ...configEnv, + ...buildPaperclipEnv(agent), + PAPERCLIP_RUN_ID: runId, + }; + + const wakeTaskId = trimNullable(context.taskId) ?? trimNullable(context.issueId); + const wakeReason = trimNullable(context.wakeReason); + const wakeCommentId = trimNullable(context.wakeCommentId) ?? trimNullable(context.commentId); + const approvalId = trimNullable(context.approvalId); + const approvalStatus = trimNullable(context.approvalStatus); + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); + + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; + if (!trimNullable(env.PAPERCLIP_API_KEY) && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + + const workspace = parseObject(context.paperclipWorkspace); + const workspaceMappings: Array<[string, unknown]> = [ + ["PAPERCLIP_WORKSPACE_CWD", workspace.cwd], + ["PAPERCLIP_WORKSPACE_SOURCE", workspace.source], + ["PAPERCLIP_WORKSPACE_ID", workspace.workspaceId], + ["PAPERCLIP_WORKSPACE_REPO_URL", workspace.repoUrl], + ["PAPERCLIP_WORKSPACE_REPO_REF", workspace.repoRef], + ["PAPERCLIP_WORKSPACE_BRANCH", workspace.branch], + ["PAPERCLIP_WORKSPACE_WORKTREE_PATH", workspace.worktreePath], + ["AGENT_HOME", workspace.agentHome], + ]; + for (const [key, value] of workspaceMappings) { + const normalized = trimNullable(value); + if (normalized) env[key] = normalized; + } + + delete env.CURSOR_API_KEY; + return env; +} + +async function buildInstructionsPrefix( + config: Record, + onLog: AdapterExecutionContext["onLog"], +): Promise<{ prefix: string; notes: string[]; chars: number }> { + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + if (!instructionsFilePath) { + return { prefix: "", notes: [], chars: 0 }; + } + + try { + const contents = await fs.readFile(instructionsFilePath, "utf8"); + const instructionsDir = `${path.dirname(instructionsFilePath)}/`; + const prefix = `${contents.trim()}\n\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsDir}.\n`; + return { + prefix, + chars: prefix.length, + notes: [ + `Loaded agent instructions from ${instructionsFilePath}`, + `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, + ], + }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stderr", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); + return { + prefix: "", + chars: 0, + notes: [ + `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ], + }; + } +} + +function renderPaperclipEnvNote(env: Record): string { + const keys = Object.keys(env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(); + if (keys.length === 0) return ""; + return [ + "Paperclip runtime note:", + `The following PAPERCLIP_* environment variables are available in the cloud agent shell: ${keys.join(", ")}`, + "Use them directly instead of assuming they are absent.", + ].join("\n"); +} + +function readSession(params: Record | null): CursorCloudSession | null { + if (!params) return null; + const record = asRecord(params); + if (!record) return null; + const cursorAgentId = + trimNullable(record.cursorAgentId) ?? + trimNullable(record.agentId) ?? + trimNullable(record.sessionId); + if (!cursorAgentId) return null; + const latestRunId = trimNullable(record.latestRunId) ?? trimNullable(record.runId) ?? undefined; + const envType = trimNullable(record.envType); + const envName = trimNullable(record.envName); + const reposValue = Array.isArray(record.repos) ? record.repos : []; + const repos = reposValue + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)) + .map((entry) => ({ + url: asString(entry.url, "").trim(), + startingRef: trimNullable(entry.startingRef) ?? undefined, + prUrl: trimNullable(entry.prUrl) ?? undefined, + })) + .filter((entry) => entry.url.length > 0); + return { + cursorAgentId, + ...(latestRunId ? { latestRunId } : {}), + runtime: "cloud", + ...(envType ? { envType: normalizeEnvType(envType) } : {}), + ...(envName ? { envName } : {}), + repos, + }; +} + +function sessionMatches( + session: CursorCloudSession | null, + envType: "cloud" | "pool" | "machine", + envName: string | null, + repos: Array<{ url: string; startingRef?: string; prUrl?: string }>, +): boolean { + if (!session) return false; + if ((session.envType ?? "cloud") !== envType) return false; + if ((session.envName ?? null) !== envName) return false; + if (session.repos.length !== repos.length) return false; + return session.repos.every((repo, index) => { + const next = repos[index]; + return repo.url === next.url + && (repo.startingRef ?? null) === (next.startingRef ?? null) + && (repo.prUrl ?? null) === (next.prUrl ?? null); + }); +} + +function buildAgentOptions(input: { + apiKey: string; + name: string; + model?: ModelSelection; + envType: "cloud" | "pool" | "machine"; + envName: string | null; + repos: Array<{ url: string; startingRef?: string; prUrl?: string }>; + workOnCurrentBranch: boolean; + autoCreatePR: boolean; + skipReviewerRequest: boolean; + envVars: Record; +}): AgentOptions { + return { + apiKey: input.apiKey, + name: input.name, + ...(input.model ? { model: input.model } : {}), + cloud: { + env: { + type: input.envType, + ...(input.envName ? { name: input.envName } : {}), + }, + repos: input.repos, + workOnCurrentBranch: input.workOnCurrentBranch, + autoCreatePR: input.autoCreatePR, + skipReviewerRequest: input.skipReviewerRequest, + envVars: input.envVars, + }, + }; +} + +function eventLine(event: CursorCloudEvent): string { + return `${JSON.stringify(event)}\n`; +} + +async function emitMessage(onLog: AdapterExecutionContext["onLog"], message: SDKMessage) { + await onLog("stdout", eventLine({ type: "cursor_cloud.message", message })); +} + +async function emitStatus(onLog: AdapterExecutionContext["onLog"], status: string, message?: string) { + await onLog("stdout", eventLine({ type: "cursor_cloud.status", status, ...(message ? { message } : {}) })); +} + +async function streamRun(run: Run, onLog: AdapterExecutionContext["onLog"]) { + if (!run.supports("stream")) return; + for await (const message of run.stream()) { + await emitMessage(onLog, message); + } +} + +async function getAttachedRun(input: { + apiKey: string; + session: CursorCloudSession | null; +}): Promise { + const latestRunId = input.session?.latestRunId; + const cursorAgentId = input.session?.cursorAgentId; + if (!latestRunId || !cursorAgentId) return null; + try { + const run = await Agent.getRun(latestRunId, { + runtime: "cloud", + agentId: cursorAgentId, + apiKey: input.apiKey, + }); + return run.status === "running" ? run : null; + } catch { + return null; + } +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta } = ctx; + const envConfig = asStringEnvMap(config.env); + const apiKey = asString(envConfig.CURSOR_API_KEY, "").trim(); + if (!apiKey) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "CURSOR_API_KEY is required for cursor_cloud.", + provider: "cursor", + biller: "cursor", + billingType: "api", + clearSession: false, + }; + } + + const workspace = parseObject(context.paperclipWorkspace); + const repoUrl = + asString(config.repoUrl, "").trim() || + asString(workspace.repoUrl, "").trim(); + if (!repoUrl) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "cursor_cloud requires repoUrl in adapterConfig or workspace context.", + provider: "cursor", + biller: "cursor", + billingType: "api", + clearSession: false, + }; + } + + const repoStartingRef = + trimNullable(config.repoStartingRef) ?? + trimNullable(workspace.repoRef) ?? + undefined; + const repoPullRequestUrl = trimNullable(config.repoPullRequestUrl) ?? undefined; + const envType = normalizeEnvType(asString(config.runtimeEnvType, "cloud")); + const envName = trimNullable(config.runtimeEnvName); + const workOnCurrentBranch = asBoolean(config.workOnCurrentBranch, false); + const autoCreatePR = asBoolean(config.autoCreatePR, false); + const skipReviewerRequest = asBoolean(config.skipReviewerRequest, false); + const model = toModelSelection(asString(config.model, "")); + const repos = [{ + url: repoUrl, + ...(repoStartingRef ? { startingRef: repoStartingRef } : {}), + ...(repoPullRequestUrl ? { prUrl: repoPullRequestUrl } : {}), + }]; + const remoteEnv = buildWakeEnv(ctx, envConfig); + const session = readSession(runtime.sessionParams) ?? (runtime.sessionId + ? { + cursorAgentId: runtime.sessionId, + runtime: "cloud" as const, + repos, + } + : null); + const canReuseSession = sessionMatches(session, envType, envName, repos); + const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE); + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const instructions = await buildInstructionsPrefix(config, onLog); + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canReuseSession }); + const renderedBootstrapPrompt = + !canReuseSession && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const renderedPrompt = + canReuseSession && wakePrompt.length > 0 + ? "" + : renderTemplate(promptTemplate, templateData).trim(); + const paperclipEnvNote = renderPaperclipEnvNote(remoteEnv); + const prompt = joinPromptSections([ + instructions.prefix, + renderedBootstrapPrompt, + wakePrompt, + paperclipEnvNote, + renderedPrompt, + ]); + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const finalPrompt = joinPromptSections([prompt, sessionHandoffNote]); + + const agentOptions = buildAgentOptions({ + apiKey, + name: `Paperclip ${agent.name}`, + model, + envType, + envName, + repos, + workOnCurrentBranch, + autoCreatePR, + skipReviewerRequest, + envVars: remoteEnv, + }); + + const commandNotes = [ + ...instructions.notes, + canReuseSession + ? `Reusing Cursor cloud agent session ${session?.cursorAgentId ?? "unknown"}` + : "Creating a new Cursor cloud agent session", + `Repository: ${repoUrl}${repoStartingRef ? ` @ ${repoStartingRef}` : ""}`, + `Runtime target: ${envType}${envName ? ` (${envName})` : ""}`, + ]; + + if (onMeta) { + const meta: AdapterInvocationMeta = { + adapterType: "cursor_cloud", + command: "@cursor/sdk", + commandNotes, + prompt: finalPrompt, + promptMetrics: { + promptChars: finalPrompt.length, + instructionsChars: instructions.chars, + bootstrapPromptChars: renderedBootstrapPrompt.length, + wakePromptChars: wakePrompt.length, + heartbeatPromptChars: renderedPrompt.length, + }, + context: { + cursorCloud: { + envType, + envName, + repoUrl, + repoStartingRef, + repoPullRequestUrl, + canReuseSession, + }, + }, + }; + await onMeta(meta); + } + + let sdkAgent: SDKAgent | null = null; + let run: Run | null = null; + let streamError: string | null = null; + try { + const attachedRun = canReuseSession + ? await getAttachedRun({ apiKey, session }) + : null; + + if (attachedRun) { + await emitStatus(onLog, "running", `Reattached to existing Cursor run ${attachedRun.id}.`); + await onLog("stdout", eventLine({ + type: "cursor_cloud.init", + sessionId: attachedRun.agentId, + agentId: attachedRun.agentId, + runId: attachedRun.id, + ...(model?.id ? { model: model.id } : {}), + })); + const priorStreamPromise = streamRun(attachedRun, onLog).catch((err) => { + streamError = formatRunError(err); + }); + if (attachedRun.supports("wait")) await attachedRun.wait(); + await priorStreamPromise; + streamError = null; + await emitStatus( + onLog, + "running", + `Prior Cursor run ${attachedRun.id} finished; sending heartbeat follow-up so this wake's context is not dropped.`, + ); + } + + sdkAgent = canReuseSession && session + ? await Agent.resume(session.cursorAgentId, agentOptions) + : await Agent.create(agentOptions); + run = await sdkAgent.send(finalPrompt, { + ...(model ? { model } : {}), + }); + await onLog("stdout", eventLine({ + type: "cursor_cloud.init", + sessionId: sdkAgent.agentId, + agentId: sdkAgent.agentId, + runId: run.id, + ...(model?.id ? { model: model.id } : {}), + })); + await emitStatus(onLog, "running", `Started Cursor run ${run.id}.`); + + const streamPromise = streamRun(run, onLog).catch((err) => { + streamError = formatRunError(err); + }); + const result = run.supports("wait") + ? await run.wait() + : { + id: run.id, + status: run.status === "running" ? "error" : run.status, + result: run.result, + model: run.model, + durationMs: run.durationMs, + git: run.git, + }; + await streamPromise; + + const modelId = result.model?.id ?? model?.id ?? null; + await onLog("stdout", eventLine({ + type: "cursor_cloud.result", + status: result.status, + ...(result.result ? { result: result.result } : {}), + ...(modelId ? { model: modelId } : {}), + ...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}), + ...(result.git ? { git: result.git } : {}), + ...(streamError ? { error: streamError } : {}), + })); + + const nextSession: CursorCloudSession = { + cursorAgentId: run.agentId, + latestRunId: result.id, + runtime: "cloud", + envType, + ...(envName ? { envName } : {}), + repos, + }; + const isError = result.status !== "finished"; + return { + exitCode: isError ? 1 : 0, + signal: null, + timedOut: false, + errorMessage: isError ? (trimNullable(result.result) ?? streamError ?? `Cursor run ${result.status}`) : null, + sessionId: run.agentId, + sessionDisplayId: run.agentId, + sessionParams: nextSession, + provider: "cursor", + biller: "cursor", + billingType: "api", + model: modelId, + costUsd: null, + summary: toSummary(result), + resultJson: { + status: result.status, + cursorAgentId: run.agentId, + cursorRunId: result.id, + envType, + envName, + repos, + ...(result.result ? { result: result.result } : {}), + ...(result.git ? { git: result.git } : {}), + ...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}), + ...(streamError ? { streamError } : {}), + }, + clearSession: false, + }; + } catch (err) { + const reason = formatRunError(err); + if (run) { + await onLog("stdout", eventLine({ + type: "cursor_cloud.result", + status: "error", + error: reason, + })); + } + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: reason, + sessionId: session?.cursorAgentId ?? null, + sessionDisplayId: session?.cursorAgentId ?? null, + sessionParams: session, + provider: "cursor", + biller: "cursor", + billingType: "api", + costUsd: null, + clearSession: false, + resultJson: { + status: "error", + ...(run ? { cursorRunId: run.id } : {}), + ...(session?.cursorAgentId ? { cursorAgentId: session.cursorAgentId } : {}), + error: reason, + }, + }; + } finally { + if (sdkAgent) { + try { + await sdkAgent[Symbol.asyncDispose](); + } catch { + // Best effort only. + } + } + } +} diff --git a/packages/adapters/cursor-cloud/src/server/index.ts b/packages/adapters/cursor-cloud/src/server/index.ts new file mode 100644 index 00000000..7fea7f7b --- /dev/null +++ b/packages/adapters/cursor-cloud/src/server/index.ts @@ -0,0 +1,70 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { sessionCodec } from "./session.js"; + +import type { AdapterConfigSchema } from "@paperclipai/adapter-utils"; + +export function getConfigSchema(): AdapterConfigSchema { + return { + fields: [ + { + key: "repoUrl", + label: "Repository URL", + type: "text", + required: true, + hint: "Git repository URL Cursor should open for this agent.", + }, + { + key: "repoStartingRef", + label: "Starting ref", + type: "text", + hint: "Optional branch, tag, or SHA Cursor should start from.", + }, + { + key: "repoPullRequestUrl", + label: "Pull request URL", + type: "text", + hint: "Optional PR URL when attaching the agent to an existing review branch.", + }, + { + key: "runtimeEnvType", + label: "Cursor runtime", + type: "select", + default: "cloud", + options: [ + { value: "cloud", label: "Cursor hosted" }, + { value: "pool", label: "Self-hosted pool" }, + { value: "machine", label: "Named machine" }, + ], + hint: "Choose where Cursor should execute the remote agent.", + }, + { + key: "runtimeEnvName", + label: "Runtime name", + type: "text", + hint: "Optional pool or machine name when targeting a non-default runtime.", + }, + { + key: "workOnCurrentBranch", + label: "Work on current branch", + type: "toggle", + default: false, + hint: "Tell Cursor to continue on the current branch instead of making a new one.", + }, + { + key: "autoCreatePR", + label: "Auto-create PR", + type: "toggle", + default: false, + hint: "Allow Cursor to automatically create a pull request for the work.", + }, + { + key: "skipReviewerRequest", + label: "Skip reviewer request", + type: "toggle", + default: false, + hint: "Suppress reviewer requests on auto-created pull requests.", + }, + ], + }; +} diff --git a/packages/adapters/cursor-cloud/src/server/session.test.ts b/packages/adapters/cursor-cloud/src/server/session.test.ts new file mode 100644 index 00000000..eedff71e --- /dev/null +++ b/packages/adapters/cursor-cloud/src/server/session.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { sessionCodec } from "./session.js"; + +describe("cursorCloud sessionCodec", () => { + it("normalizes legacy and current session identifiers", () => { + expect( + sessionCodec.deserialize({ + agentId: "agent-123", + runId: "run-456", + envType: "pool", + envName: "trusted", + repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }], + }), + ).toEqual({ + cursorAgentId: "agent-123", + latestRunId: "run-456", + runtime: "cloud", + envType: "pool", + envName: "trusted", + repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }], + }); + }); + + it("drops invalid session payloads and exposes the display id", () => { + expect(sessionCodec.deserialize({ latestRunId: "run-1" })).toBeNull(); + expect(sessionCodec.getDisplayId?.({ + cursorAgentId: "agent-789", + latestRunId: "run-101", + })).toBe("agent-789"); + }); +}); diff --git a/packages/adapters/cursor-cloud/src/server/session.ts b/packages/adapters/cursor-cloud/src/server/session.ts new file mode 100644 index 00000000..0d3b57c0 --- /dev/null +++ b/packages/adapters/cursor-cloud/src/server/session.ts @@ -0,0 +1,61 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readRepos(value: unknown): Array<{ url: string; startingRef?: string; prUrl?: string }> { + if (!Array.isArray(value)) return []; + const repos: Array<{ url: string; startingRef?: string; prUrl?: string }> = []; + for (const entry of value) { + const repo = asRecord(entry); + if (!repo) continue; + const url = readString(repo.url); + if (!url) continue; + const startingRef = readString(repo.startingRef); + const prUrl = readString(repo.prUrl); + repos.push({ + url, + ...(startingRef ? { startingRef } : {}), + ...(prUrl ? { prUrl } : {}), + }); + } + return repos; +} + +function normalize(raw: unknown): Record | null { + const record = asRecord(raw); + if (!record) return null; + const cursorAgentId = + readString(record.cursorAgentId) ?? + readString(record.agentId) ?? + readString(record.sessionId); + if (!cursorAgentId) return null; + const latestRunId = readString(record.latestRunId) ?? readString(record.runId); + const runtime = readString(record.runtime) ?? "cloud"; + const envType = readString(record.envType); + const envName = readString(record.envName); + const repos = readRepos(record.repos); + return { + cursorAgentId, + ...(latestRunId ? { latestRunId } : {}), + runtime, + ...(envType ? { envType } : {}), + ...(envName ? { envName } : {}), + ...(repos.length > 0 ? { repos } : {}), + }; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize: normalize, + serialize: normalize, + getDisplayId(params) { + const normalized = normalize(params); + return normalized ? String(normalized.cursorAgentId) : null; + }, +}; diff --git a/packages/adapters/cursor-cloud/src/server/test.ts b/packages/adapters/cursor-cloud/src/server/test.ts new file mode 100644 index 00000000..03091ad1 --- /dev/null +++ b/packages/adapters/cursor-cloud/src/server/test.ts @@ -0,0 +1,118 @@ +import { Cursor } from "@cursor/sdk"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function asStringEnvMap(value: unknown): Record { + const parsed = parseObject(value); + const env: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") { + env[key] = entry; + } else if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) { + const rec = entry as Record; + if (rec.type === "plain" && typeof rec.value === "string") env[key] = rec.value; + } + } + return env; +} + +function looksLikeRepoUrl(value: string): boolean { + return /^(https?:\/\/|git@)/i.test(value.trim()); +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const env = asStringEnvMap(config.env); + const apiKey = asString(env.CURSOR_API_KEY, "").trim(); + const repoUrl = asString(config.repoUrl, "").trim(); + const model = asString(config.model, "").trim(); + + if (!apiKey) { + checks.push({ + code: "cursor_cloud_api_key_missing", + level: "error", + message: "CURSOR_API_KEY is required.", + hint: "Add CURSOR_API_KEY under environment variables for this adapter.", + }); + } + + if (!repoUrl) { + checks.push({ + code: "cursor_cloud_repo_missing", + level: "error", + message: "repoUrl is required.", + hint: "Set the repository URL Cursor should open for this agent.", + }); + } else if (!looksLikeRepoUrl(repoUrl)) { + checks.push({ + code: "cursor_cloud_repo_invalid", + level: "error", + message: "repoUrl must be an http(s) or git SSH repository URL.", + detail: repoUrl, + }); + } else { + checks.push({ + code: "cursor_cloud_repo_present", + level: "info", + message: `Repository configured: ${repoUrl}`, + }); + } + + if (apiKey) { + try { + const me = await Cursor.me({ apiKey }); + checks.push({ + code: "cursor_cloud_auth_ok", + level: "info", + message: "Cursor API key is valid.", + detail: me.userEmail ? `Authenticated as ${me.userEmail}.` : `API key: ${me.apiKeyName}`, + }); + } catch (err) { + checks.push({ + code: "cursor_cloud_auth_failed", + level: "error", + message: err instanceof Error ? err.message : "Failed to validate Cursor API key.", + }); + } + } + + if (apiKey && model) { + try { + const models = await Cursor.models.list({ apiKey }); + const match = models.find((entry) => entry.id === model); + checks.push({ + code: match ? "cursor_cloud_model_ok" : "cursor_cloud_model_unknown", + level: match ? "info" : "warn", + message: match + ? `Model "${model}" is available to the authenticated Cursor account.` + : `Model "${model}" was not found in the authenticated Cursor model list.`, + }); + } catch (err) { + checks.push({ + code: "cursor_cloud_model_probe_failed", + level: "warn", + message: err instanceof Error ? err.message : "Failed to validate model availability.", + }); + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/cursor-cloud/src/ui/build-config.test.ts b/packages/adapters/cursor-cloud/src/ui/build-config.test.ts new file mode 100644 index 00000000..d3257212 --- /dev/null +++ b/packages/adapters/cursor-cloud/src/ui/build-config.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { buildCursorCloudConfig } from "./build-config.js"; + +function makeValues(overrides: Partial = {}): CreateConfigValues { + return { + adapterType: "cursor_cloud", + cwd: "", + instructionsFilePath: "", + promptTemplate: "", + model: "", + thinkingEffort: "", + chrome: false, + dangerouslySkipPermissions: false, + search: false, + fastMode: false, + dangerouslyBypassSandbox: false, + command: "", + args: "", + extraArgs: "", + envVars: "", + envBindings: {}, + url: "", + bootstrapPrompt: "", + payloadTemplateJson: "", + workspaceStrategyType: "project_primary", + workspaceBaseRef: "", + workspaceBranchTemplate: "", + worktreeParentDir: "", + runtimeServicesJson: "", + maxTurnsPerRun: 1000, + heartbeatEnabled: false, + intervalSec: 300, + adapterSchemaValues: {}, + ...overrides, + }; +} + +describe("buildCursorCloudConfig", () => { + it("persists schema values and top-level prompt fields", () => { + const config = buildCursorCloudConfig( + makeValues({ + instructionsFilePath: ".cursor/AGENTS.md", + promptTemplate: "hello {{agent.name}}", + bootstrapPrompt: "bootstrap", + model: "gpt-5.4", + adapterSchemaValues: { + repoUrl: "https://github.com/paperclipai/paperclip.git", + runtimeEnvType: "pool", + runtimeEnvName: "trusted-workers", + autoCreatePR: true, + }, + }), + ); + + expect(config).toMatchObject({ + instructionsFilePath: ".cursor/AGENTS.md", + promptTemplate: "hello {{agent.name}}", + bootstrapPromptTemplate: "bootstrap", + model: "gpt-5.4", + repoUrl: "https://github.com/paperclipai/paperclip.git", + runtimeEnvType: "pool", + runtimeEnvName: "trusted-workers", + autoCreatePR: true, + }); + }); + + it("merges structured env bindings over legacy envVars text", () => { + const config = buildCursorCloudConfig( + makeValues({ + envVars: ["CURSOR_API_KEY=legacy-key", "PLAIN=value", "INVALID KEY=nope"].join("\n"), + envBindings: { + CURSOR_API_KEY: { type: "secret_ref", secretId: "secret-1", version: "latest" }, + STRUCTURED_ONLY: "from-binding", + }, + }), + ); + + expect(config.env).toEqual({ + CURSOR_API_KEY: { type: "secret_ref", secretId: "secret-1", version: "latest" }, + PLAIN: { type: "plain", value: "value" }, + STRUCTURED_ONLY: { type: "plain", value: "from-binding" }, + }); + }); +}); diff --git a/packages/adapters/cursor-cloud/src/ui/build-config.ts b/packages/adapters/cursor-cloud/src/ui/build-config.ts new file mode 100644 index 00000000..3e378040 --- /dev/null +++ b/packages/adapters/cursor-cloud/src/ui/build-config.ts @@ -0,0 +1,67 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildCursorCloudConfig(values: CreateConfigValues): Record { + const config: Record = { + ...(values.adapterSchemaValues ?? {}), + }; + if (values.instructionsFilePath) config.instructionsFilePath = values.instructionsFilePath; + if (values.promptTemplate) config.promptTemplate = values.promptTemplate; + if (values.bootstrapPrompt) config.bootstrapPromptTemplate = values.bootstrapPrompt; + if (values.model?.trim()) config.model = values.model.trim(); + + const env = parseEnvBindings(values.envBindings); + const legacy = parseEnvVars(values.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) { + config.env = env; + } + + return config; +} diff --git a/packages/adapters/cursor-cloud/src/ui/index.ts b/packages/adapters/cursor-cloud/src/ui/index.ts new file mode 100644 index 00000000..130ece14 --- /dev/null +++ b/packages/adapters/cursor-cloud/src/ui/index.ts @@ -0,0 +1,2 @@ +export { buildCursorCloudConfig } from "./build-config.js"; +export { parseCursorCloudStdoutLine } from "./parse-stdout.js"; diff --git a/packages/adapters/cursor-cloud/src/ui/parse-stdout.test.ts b/packages/adapters/cursor-cloud/src/ui/parse-stdout.test.ts new file mode 100644 index 00000000..812cf817 --- /dev/null +++ b/packages/adapters/cursor-cloud/src/ui/parse-stdout.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { parseCursorCloudStdoutLine } from "./parse-stdout.js"; + +const ts = "2026-05-10T05:10:00.000Z"; + +describe("parseCursorCloudStdoutLine", () => { + it("parses init and status events", () => { + expect( + parseCursorCloudStdoutLine( + JSON.stringify({ type: "cursor_cloud.init", sessionId: "agent-123", model: "gpt-5.4" }), + ts, + ), + ).toEqual([{ kind: "init", ts, sessionId: "agent-123", model: "gpt-5.4" }]); + + expect( + parseCursorCloudStdoutLine( + JSON.stringify({ type: "cursor_cloud.status", status: "running", message: "Reattached" }), + ts, + ), + ).toEqual([{ kind: "system", ts, text: "running: Reattached" }]); + }); + + it("parses assistant text and tool lifecycle SDK messages", () => { + const assistantLine = JSON.stringify({ + type: "cursor_cloud.message", + message: { + type: "assistant", + message: { + content: [ + { type: "text", text: "Working on it." }, + { type: "tool_use", id: "tool-1", name: "read_file", input: { path: "README.md" } }, + ], + }, + }, + }); + expect(parseCursorCloudStdoutLine(assistantLine, ts)).toEqual([ + { kind: "assistant", ts, text: "Working on it." }, + { kind: "tool_call", ts, name: "read_file", toolUseId: "tool-1", input: { path: "README.md" } }, + ]); + + const toolStartLine = JSON.stringify({ + type: "cursor_cloud.message", + message: { + type: "tool_call", + id: "call-1", + name: "bash", + status: "running", + args: { command: "pwd" }, + }, + }); + expect(parseCursorCloudStdoutLine(toolStartLine, ts)).toEqual([ + { kind: "tool_call", ts, name: "bash", toolUseId: "call-1", input: { command: "pwd" } }, + ]); + + const toolEndLine = JSON.stringify({ + type: "cursor_cloud.message", + message: { + type: "tool_call", + id: "call-1", + name: "bash", + status: "completed", + result: { stdout: "/repo" }, + }, + }); + expect(parseCursorCloudStdoutLine(toolEndLine, ts)).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "call-1", + toolName: "bash", + content: JSON.stringify({ stdout: "/repo" }, null, 2), + isError: false, + }, + ]); + }); + + it("parses standalone tool_result SDK messages", () => { + const line = JSON.stringify({ + type: "cursor_cloud.message", + message: { + type: "tool_result", + call_id: "call-9", + name: "read_file", + result: { contents: "file body" }, + }, + }); + expect(parseCursorCloudStdoutLine(line, ts)).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "call-9", + toolName: "read_file", + content: JSON.stringify({ contents: "file body" }, null, 2), + isError: false, + }, + ]); + + const errorLine = JSON.stringify({ + type: "cursor_cloud.message", + message: { + type: "tool_result", + call_id: "call-10", + name: "bash", + is_error: true, + content: "exit 1", + }, + }); + expect(parseCursorCloudStdoutLine(errorLine, ts)).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "call-10", + toolName: "bash", + content: "exit 1", + isError: true, + }, + ]); + }); + + it("parses result events and preserves unknown lines as stdout", () => { + expect( + parseCursorCloudStdoutLine( + JSON.stringify({ type: "cursor_cloud.result", status: "finished", result: "Done", model: "gpt-5.4" }), + ts, + ), + ).toEqual([ + { + kind: "result", + ts, + text: "Done", + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "finished", + isError: false, + errors: [], + }, + ]); + + expect(parseCursorCloudStdoutLine("plain text", ts)).toEqual([{ kind: "stdout", ts, text: "plain text" }]); + }); +}); diff --git a/packages/adapters/cursor-cloud/src/ui/parse-stdout.ts b/packages/adapters/cursor-cloud/src/ui/parse-stdout.ts new file mode 100644 index 00000000..f3a73c05 --- /dev/null +++ b/packages/adapters/cursor-cloud/src/ui/parse-stdout.ts @@ -0,0 +1,186 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function parseAssistantMessage(message: Record, ts: string): TranscriptEntry[] { + const content = Array.isArray(message.content) ? message.content : []; + const entries: TranscriptEntry[] = []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + if (type === "text") { + const text = asString(part.text).trim(); + if (text) entries.push({ kind: "assistant", ts, text }); + continue; + } + if (type === "tool_use") { + entries.push({ + kind: "tool_call", + ts, + name: asString(part.name, "tool"), + toolUseId: asString(part.id) || undefined, + input: part.input ?? {}, + }); + } + } + return entries; +} + +function parseSdkMessage(messageRaw: unknown, ts: string): TranscriptEntry[] { + const message = asRecord(messageRaw); + if (!message) return []; + const type = asString(message.type); + + if (type === "assistant") { + const body = asRecord(message.message); + return body ? parseAssistantMessage(body, ts) : []; + } + + if (type === "user") { + const body = asRecord(message.message); + const content = Array.isArray(body?.content) ? body.content : []; + const text = content + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)) + .map((entry) => asString(entry.text).trim()) + .filter(Boolean) + .join("\n"); + return text ? [{ kind: "user", ts, text }] : []; + } + + if (type === "thinking") { + const text = asString(message.text).trim(); + return text ? [{ kind: "thinking", ts, text }] : []; + } + + if (type === "tool_call") { + const toolUseId = asString(message.call_id, asString(message.id, "tool_call")); + const status = asString(message.status).toLowerCase(); + if (status === "running") { + return [{ + kind: "tool_call", + ts, + name: asString(message.name, "tool"), + toolUseId, + input: message.args ?? {}, + }]; + } + if (status === "completed" || status === "error") { + return [{ + kind: "tool_result", + ts, + toolUseId, + toolName: asString(message.name, "tool"), + content: stringifyUnknown(message.result ?? message.args ?? {}), + isError: status === "error", + }]; + } + return []; + } + + if (type === "tool_result") { + const toolUseId = asString(message.call_id, asString(message.id, "tool_result")); + const isError = + message.is_error === true || + asString(message.status).toLowerCase() === "error"; + return [{ + kind: "tool_result", + ts, + toolUseId, + toolName: asString(message.name, "tool"), + content: stringifyUnknown(message.result ?? message.content ?? message.output ?? {}), + isError, + }]; + } + + if (type === "status") { + const status = asString(message.status); + const statusMessage = asString(message.message); + return [{ + kind: "system", + ts, + text: `status: ${status}${statusMessage ? ` - ${statusMessage}` : ""}`, + }]; + } + + if (type === "task") { + const text = asString(message.text).trim(); + return text ? [{ kind: "system", ts, text }] : []; + } + + return []; +} + +export function parseCursorCloudStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type); + if (type === "cursor_cloud.init") { + const sessionId = asString(parsed.sessionId, asString(parsed.agentId)); + return [{ + kind: "init", + ts, + model: asString(parsed.model, "cursor_cloud"), + sessionId, + }]; + } + + if (type === "cursor_cloud.status") { + return [{ + kind: "system", + ts, + text: `${asString(parsed.status, "status")}${parsed.message ? `: ${asString(parsed.message)}` : ""}`, + }]; + } + + if (type === "cursor_cloud.message") { + return parseSdkMessage(parsed.message, ts); + } + + if (type === "cursor_cloud.result") { + const status = asString(parsed.status, "error"); + return [{ + kind: "result", + ts, + text: asString(parsed.result), + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: status, + isError: status !== "finished", + errors: parsed.error ? [asString(parsed.error)] : [], + }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/cursor-cloud/tsconfig.json b/packages/adapters/cursor-cloud/tsconfig.json new file mode 100644 index 00000000..8fea361a --- /dev/null +++ b/packages/adapters/cursor-cloud/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 3b018371..ea93a3b6 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -33,6 +33,7 @@ export const AGENT_ADAPTER_TYPES = [ "acpx_local", "claude_local", "codex_local", + "cursor_cloud", "gemini_local", "opencode_local", "pi_local", diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index 2443c278..3caca80f 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -19,6 +19,11 @@ "name": "@paperclipai/adapter-codex-local", "publishFromCi": true }, + { + "dir": "packages/adapters/cursor-cloud", + "name": "@paperclipai/adapter-cursor-cloud", + "publishFromCi": false + }, { "dir": "packages/adapters/cursor-local", "name": "@paperclipai/adapter-cursor-local", diff --git a/server/package.json b/server/package.json index 72808b7e..2ea875b8 100644 --- a/server/package.json +++ b/server/package.json @@ -47,6 +47,7 @@ "@paperclipai/adapter-acpx-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts index 3028ae0c..a30ed5cc 100644 --- a/server/src/adapters/builtin-adapter-types.ts +++ b/server/src/adapters/builtin-adapter-types.ts @@ -5,6 +5,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([ "acpx_local", "claude_local", "codex_local", + "cursor_cloud", "cursor", "gemini_local", "openclaw_gateway", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index baca83f4..425428f7 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -56,6 +56,13 @@ import { models as cursorModels, modelProfiles as cursorModelProfiles, } from "@paperclipai/adapter-cursor-local"; +import { + execute as cursorCloudExecute, + getConfigSchema as getCursorCloudConfigSchema, + sessionCodec as cursorCloudSessionCodec, + testEnvironment as cursorCloudTestEnvironment, +} from "@paperclipai/adapter-cursor-cloud/server"; +import { agentConfigurationDoc as cursorCloudAgentConfigurationDoc } from "@paperclipai/adapter-cursor-cloud"; import { execute as geminiExecute, listGeminiSkills, @@ -304,6 +311,21 @@ const cursorLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: cursorAgentConfigurationDoc, }; +const cursorCloudAdapter: ServerAdapterModule = { + type: "cursor_cloud", + execute: cursorCloudExecute, + testEnvironment: cursorCloudTestEnvironment, + sessionCodec: cursorCloudSessionCodec, + sessionManagement: getAdapterSessionManagement("cursor_cloud") ?? undefined, + models: [], + supportsLocalAgentJwt: false, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, + agentConfigurationDoc: cursorCloudAgentConfigurationDoc, + getConfigSchema: getCursorCloudConfigSchema, +}; + const geminiLocalAdapter: ServerAdapterModule = { type: "gemini_local", execute: geminiExecute, @@ -457,6 +479,7 @@ function registerBuiltInAdapters() { codexLocalAdapter, openCodeLocalAdapter, piLocalAdapter, + cursorCloudAdapter, cursorLocalAdapter, geminiLocalAdapter, openclawGatewayAdapter, diff --git a/ui/package.json b/ui/package.json index 6160df07..65caa25e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -37,6 +37,7 @@ "@paperclipai/adapter-acpx-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index d75da557..948a3c8d 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -98,6 +98,11 @@ const adapterDisplayMap: Record = { description: "Local Cursor agent", icon: MousePointer2, }, + cursor_cloud: { + label: "Cursor Cloud", + description: "Managed remote Cursor agent", + icon: MousePointer2, + }, openclaw_gateway: { label: "OpenClaw Gateway", description: "Invoke OpenClaw via gateway protocol", diff --git a/ui/src/adapters/cursor-cloud/index.ts b/ui/src/adapters/cursor-cloud/index.ts new file mode 100644 index 00000000..12b26365 --- /dev/null +++ b/ui/src/adapters/cursor-cloud/index.ts @@ -0,0 +1,14 @@ +import type { UIAdapterModule } from "../types"; +import { SchemaConfigFields } from "../schema-config-fields"; +import { + buildCursorCloudConfig, + parseCursorCloudStdoutLine, +} from "@paperclipai/adapter-cursor-cloud/ui"; + +export const cursorCloudUIAdapter: UIAdapterModule = { + type: "cursor_cloud", + label: "Cursor Cloud", + parseStdoutLine: parseCursorCloudStdoutLine, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildCursorCloudConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index d53eaaae..0e2f27a3 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -2,6 +2,7 @@ import type { UIAdapterModule } from "./types"; import { acpxLocalUIAdapter } from "./acpx-local"; import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; +import { cursorCloudUIAdapter } from "./cursor-cloud"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; @@ -53,6 +54,7 @@ function registerBuiltInUIAdapters() { acpxLocalUIAdapter, claudeLocalUIAdapter, codexLocalUIAdapter, + cursorCloudUIAdapter, geminiLocalUIAdapter, hermesLocalUIAdapter, openCodeLocalUIAdapter, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 078468a4..59c967c8 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -576,7 +576,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : adapterType === "opencode_local" ? eff("adapterConfig", "variant", String(config.variant ?? "")) : eff("adapterConfig", "effort", String(config.effort ?? "")); - const showThinkingEffort = adapterType !== "gemini_local"; + const showThinkingEffort = adapterType !== "gemini_local" && adapterType !== "cursor_cloud"; const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; diff --git a/vitest.config.ts b/vitest.config.ts index 85fd42d4..42b8e879 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "packages/adapters/acpx-local", "packages/adapters/claude-local", "packages/adapters/codex-local", + "packages/adapters/cursor-cloud", "packages/adapters/cursor-local", "packages/adapters/gemini-local", "packages/adapters/opencode-local",