feat: add cursor local adapter across server ui and cli
This commit is contained in:
@@ -36,6 +36,7 @@
|
|||||||
"@clack/prompts": "^0.10.0",
|
"@clack/prompts": "^0.10.0",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
||||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||||
|
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||||
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
||||||
import { processCLIAdapter } from "./process/index.js";
|
import { processCLIAdapter } from "./process/index.js";
|
||||||
@@ -21,13 +22,18 @@ const opencodeLocalCLIAdapter: CLIAdapterModule = {
|
|||||||
formatStdoutEvent: printOpenCodeStreamEvent,
|
formatStdoutEvent: printOpenCodeStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||||
|
type: "cursor",
|
||||||
|
formatStdoutEvent: printCursorStreamEvent,
|
||||||
|
};
|
||||||
|
|
||||||
const openclawCLIAdapter: CLIAdapterModule = {
|
const openclawCLIAdapter: CLIAdapterModule = {
|
||||||
type: "openclaw",
|
type: "openclaw",
|
||||||
formatStdoutEvent: printOpenClawStreamEvent,
|
formatStdoutEvent: printOpenClawStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, opencodeLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
[claudeLocalCLIAdapter, codexLocalCLIAdapter, opencodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function getCLIAdapter(type: string): CLIAdapterModule {
|
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||||
|
|||||||
7
packages/adapters/cursor-local/CHANGELOG.md
Normal file
7
packages/adapters/cursor-local/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# @paperclipai/adapter-cursor-local
|
||||||
|
|
||||||
|
## 0.2.7
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Added initial `cursor` adapter package for local Cursor CLI execution
|
||||||
50
packages/adapters/cursor-local/package.json
Normal file
50
packages/adapters/cursor-local/package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "@paperclipai/adapter-cursor-local",
|
||||||
|
"version": "0.2.7",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./server": "./src/server/index.ts",
|
||||||
|
"./ui": "./src/ui/index.ts",
|
||||||
|
"./cli": "./src/cli/index.ts"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./server": {
|
||||||
|
"types": "./dist/server/index.d.ts",
|
||||||
|
"import": "./dist/server/index.js"
|
||||||
|
},
|
||||||
|
"./ui": {
|
||||||
|
"types": "./dist/ui/index.d.ts",
|
||||||
|
"import": "./dist/ui/index.js"
|
||||||
|
},
|
||||||
|
"./cli": {
|
||||||
|
"types": "./dist/cli/index.d.ts",
|
||||||
|
"import": "./dist/cli/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"skills"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
226
packages/adapters/cursor-local/src/cli/format-event.ts
Normal file
226
packages/adapters/cursor-local/src/cli/format-event.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import pc from "picocolors";
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown, fallback = 0): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? 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 printAssistantMessage(messageRaw: unknown): void {
|
||||||
|
if (typeof messageRaw === "string") {
|
||||||
|
const text = messageRaw.trim();
|
||||||
|
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = asRecord(messageRaw);
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
const directText = asString(message.text).trim();
|
||||||
|
if (directText) console.log(pc.green(`assistant: ${directText}`));
|
||||||
|
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = asRecord(partRaw);
|
||||||
|
if (!part) continue;
|
||||||
|
const type = asString(part.type).trim();
|
||||||
|
|
||||||
|
if (type === "output_text" || type === "text") {
|
||||||
|
const text = asString(part.text).trim();
|
||||||
|
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "thinking") {
|
||||||
|
const text = asString(part.text).trim();
|
||||||
|
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_call") {
|
||||||
|
const name = asString(part.name, asString(part.tool, "tool"));
|
||||||
|
console.log(pc.yellow(`tool_call: ${name}`));
|
||||||
|
const input = part.input ?? part.arguments ?? part.args;
|
||||||
|
if (input !== undefined) {
|
||||||
|
try {
|
||||||
|
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||||
|
} catch {
|
||||||
|
console.log(pc.gray(String(input)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_result") {
|
||||||
|
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||||
|
const contentText =
|
||||||
|
asString(part.output) ||
|
||||||
|
asString(part.text) ||
|
||||||
|
asString(part.result) ||
|
||||||
|
stringifyUnknown(part.output ?? part.result ?? part.text ?? part);
|
||||||
|
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||||
|
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printLegacyToolEvent(part: Record<string, unknown>): void {
|
||||||
|
const tool = asString(part.tool, "tool");
|
||||||
|
const callId = asString(part.callID, asString(part.id, ""));
|
||||||
|
const state = asRecord(part.state);
|
||||||
|
const status = asString(state?.status);
|
||||||
|
const input = state?.input;
|
||||||
|
const output = asString(state?.output).replace(/\s+$/, "");
|
||||||
|
const metadata = asRecord(state?.metadata);
|
||||||
|
const exit = asNumber(metadata?.exit, NaN);
|
||||||
|
const isError =
|
||||||
|
status === "failed" ||
|
||||||
|
status === "error" ||
|
||||||
|
status === "cancelled" ||
|
||||||
|
(Number.isFinite(exit) && exit !== 0);
|
||||||
|
|
||||||
|
console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`));
|
||||||
|
if (input !== undefined) {
|
||||||
|
try {
|
||||||
|
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||||
|
} catch {
|
||||||
|
console.log(pc.gray(String(input)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status || output) {
|
||||||
|
const summary = [
|
||||||
|
"tool_result",
|
||||||
|
status ? `status=${status}` : "",
|
||||||
|
Number.isFinite(exit) ? `exit=${exit}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
console.log((isError ? pc.red : pc.cyan)(summary));
|
||||||
|
if (output) {
|
||||||
|
console.log((isError ? pc.red : pc.gray)(output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printCursorStreamEvent(raw: string, _debug: boolean): void {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
let parsed: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
console.log(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = asString(parsed.type);
|
||||||
|
|
||||||
|
if (type === "system") {
|
||||||
|
const subtype = asString(parsed.subtype);
|
||||||
|
if (subtype === "init") {
|
||||||
|
const sessionId =
|
||||||
|
asString(parsed.session_id) ||
|
||||||
|
asString(parsed.sessionId) ||
|
||||||
|
asString(parsed.sessionID);
|
||||||
|
const model = asString(parsed.model);
|
||||||
|
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
console.log(pc.blue(`Cursor init${details ? ` (${details})` : ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(pc.blue(`system: ${subtype || "event"}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "assistant") {
|
||||||
|
printAssistantMessage(parsed.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "result") {
|
||||||
|
const usage = asRecord(parsed.usage);
|
||||||
|
const input = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
||||||
|
const output = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
|
||||||
|
const cached = asNumber(
|
||||||
|
usage?.cached_input_tokens,
|
||||||
|
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
|
||||||
|
);
|
||||||
|
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
|
||||||
|
const subtype = asString(parsed.subtype, "result");
|
||||||
|
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
|
||||||
|
|
||||||
|
console.log(pc.blue(`result: subtype=${subtype}`));
|
||||||
|
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||||
|
const resultText = asString(parsed.result).trim();
|
||||||
|
if (resultText) console.log((isError ? pc.red : pc.green)(`assistant: ${resultText}`));
|
||||||
|
const errors = Array.isArray(parsed.errors) ? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean) : [];
|
||||||
|
if (errors.length > 0) console.log(pc.red(`errors: ${errors.join(" | ")}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || line;
|
||||||
|
console.log(pc.red(`error: ${message}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibility with older stream-json event shapes.
|
||||||
|
if (type === "step_start") {
|
||||||
|
const sessionId = asString(parsed.sessionID);
|
||||||
|
console.log(pc.blue(`step started${sessionId ? ` (session: ${sessionId})` : ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "text") {
|
||||||
|
const part = asRecord(parsed.part);
|
||||||
|
const text = asString(part?.text);
|
||||||
|
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_use") {
|
||||||
|
const part = asRecord(parsed.part);
|
||||||
|
if (part) {
|
||||||
|
printLegacyToolEvent(part);
|
||||||
|
} else {
|
||||||
|
console.log(pc.yellow("tool_use"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "step_finish") {
|
||||||
|
const part = asRecord(parsed.part);
|
||||||
|
const tokens = asRecord(part?.tokens);
|
||||||
|
const cache = asRecord(tokens?.cache);
|
||||||
|
const reason = asString(part?.reason, "step_finish");
|
||||||
|
const input = asNumber(tokens?.input);
|
||||||
|
const output = asNumber(tokens?.output);
|
||||||
|
const cached = asNumber(cache?.read);
|
||||||
|
const cost = asNumber(part?.cost);
|
||||||
|
console.log(pc.blue(`step finished: reason=${reason}`));
|
||||||
|
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
1
packages/adapters/cursor-local/src/cli/index.ts
Normal file
1
packages/adapters/cursor-local/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { printCursorStreamEvent } from "./format-event.js";
|
||||||
44
packages/adapters/cursor-local/src/index.ts
Normal file
44
packages/adapters/cursor-local/src/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export const type = "cursor";
|
||||||
|
export const label = "Cursor CLI (local)";
|
||||||
|
export const DEFAULT_CURSOR_LOCAL_MODEL = "gpt-5";
|
||||||
|
|
||||||
|
export const models = [
|
||||||
|
{ id: DEFAULT_CURSOR_LOCAL_MODEL, label: DEFAULT_CURSOR_LOCAL_MODEL },
|
||||||
|
{ id: "gpt-5-mini", label: "gpt-5-mini" },
|
||||||
|
{ id: "sonnet-4", label: "sonnet-4" },
|
||||||
|
{ id: "sonnet-4-thinking", label: "sonnet-4-thinking" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const agentConfigurationDoc = `# cursor agent configuration
|
||||||
|
|
||||||
|
Adapter: cursor
|
||||||
|
|
||||||
|
Use when:
|
||||||
|
- You want Paperclip to run Cursor Agent CLI locally as the agent runtime
|
||||||
|
- You want Cursor chat session resume across heartbeats via --resume
|
||||||
|
- You want structured stream output in run logs via --output-format stream-json
|
||||||
|
|
||||||
|
Don't use when:
|
||||||
|
- You need webhook-style external invocation (use openclaw or http)
|
||||||
|
- You only need one-shot shell commands (use process)
|
||||||
|
- Cursor Agent CLI is not installed on the machine
|
||||||
|
|
||||||
|
Core fields:
|
||||||
|
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||||
|
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||||
|
- promptTemplate (string, optional): run prompt template
|
||||||
|
- model (string, optional): Cursor model id (for example gpt-5)
|
||||||
|
- mode (string, optional): Cursor execution mode passed as --mode (plan|ask)
|
||||||
|
- command (string, optional): defaults to "agent"
|
||||||
|
- extraArgs (string[], optional): additional CLI args
|
||||||
|
- env (object, optional): KEY=VALUE environment variables
|
||||||
|
|
||||||
|
Operational fields:
|
||||||
|
- timeoutSec (number, optional): run timeout in seconds
|
||||||
|
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Runs are executed with: agent -p --output-format stream-json ...
|
||||||
|
- Prompts are passed as a final positional argument.
|
||||||
|
- Sessions are resumed with --resume when stored session cwd matches current cwd.
|
||||||
|
`;
|
||||||
351
packages/adapters/cursor-local/src/server/execute.ts
Normal file
351
packages/adapters/cursor-local/src/server/execute.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||||
|
import {
|
||||||
|
asString,
|
||||||
|
asNumber,
|
||||||
|
asStringArray,
|
||||||
|
parseObject,
|
||||||
|
buildPaperclipEnv,
|
||||||
|
redactEnvForLogs,
|
||||||
|
ensureAbsoluteDirectory,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
ensurePathInEnv,
|
||||||
|
renderTemplate,
|
||||||
|
runChildProcess,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||||
|
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||||
|
|
||||||
|
function firstNonEmptyLine(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean) ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||||
|
const raw = env[key];
|
||||||
|
return typeof raw === "string" && raw.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCursorBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||||
|
return hasNonEmptyEnvValue(env, "CURSOR_API_KEY") || hasNonEmptyEnvValue(env, "OPENAI_API_KEY")
|
||||||
|
? "api"
|
||||||
|
: "subscription";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProviderFromModel(model: string): string | null {
|
||||||
|
const trimmed = model.trim().toLowerCase();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const slash = trimmed.indexOf("/");
|
||||||
|
if (slash > 0) return trimmed.slice(0, slash);
|
||||||
|
if (trimmed.includes("sonnet") || trimmed.includes("claude")) return "anthropic";
|
||||||
|
if (trimmed.startsWith("gpt") || trimmed.startsWith("o")) return "openai";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMode(rawMode: string): "plan" | "ask" | null {
|
||||||
|
const mode = rawMode.trim().toLowerCase();
|
||||||
|
if (mode === "plan" || mode === "ask") return mode;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
|
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||||
|
|
||||||
|
const promptTemplate = asString(
|
||||||
|
config.promptTemplate,
|
||||||
|
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||||
|
);
|
||||||
|
const command = asString(config.command, "agent");
|
||||||
|
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||||
|
const mode = normalizeMode(asString(config.mode, ""));
|
||||||
|
|
||||||
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||||
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||||
|
const workspaceSource = asString(workspaceContext.source, "");
|
||||||
|
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||||
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||||
|
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||||
|
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||||
|
? context.paperclipWorkspaces.filter(
|
||||||
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const configuredCwd = asString(config.cwd, "");
|
||||||
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||||
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||||
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||||
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
|
|
||||||
|
const envConfig = parseObject(config.env);
|
||||||
|
const hasExplicitApiKey =
|
||||||
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||||
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
|
env.PAPERCLIP_RUN_ID = runId;
|
||||||
|
const wakeTaskId =
|
||||||
|
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||||
|
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||||
|
null;
|
||||||
|
const wakeReason =
|
||||||
|
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||||
|
? context.wakeReason.trim()
|
||||||
|
: null;
|
||||||
|
const wakeCommentId =
|
||||||
|
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||||
|
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||||
|
null;
|
||||||
|
const approvalId =
|
||||||
|
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||||
|
? context.approvalId.trim()
|
||||||
|
: null;
|
||||||
|
const approvalStatus =
|
||||||
|
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||||
|
? context.approvalStatus.trim()
|
||||||
|
: null;
|
||||||
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||||
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
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 (effectiveWorkspaceCwd) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||||
|
}
|
||||||
|
if (workspaceSource) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||||
|
}
|
||||||
|
if (workspaceId) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||||
|
}
|
||||||
|
if (workspaceRepoUrl) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||||
|
}
|
||||||
|
if (workspaceRepoRef) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||||
|
}
|
||||||
|
if (workspaceHints.length > 0) {
|
||||||
|
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(envConfig)) {
|
||||||
|
if (typeof v === "string") env[k] = v;
|
||||||
|
}
|
||||||
|
if (!hasExplicitApiKey && authToken) {
|
||||||
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
|
}
|
||||||
|
const billingType = resolveCursorBillingType(env);
|
||||||
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
|
||||||
|
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||||
|
const graceSec = asNumber(config.graceSec, 20);
|
||||||
|
const extraArgs = (() => {
|
||||||
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
|
return asStringArray(config.args);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const canResumeSession =
|
||||||
|
runtimeSessionId.length > 0 &&
|
||||||
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||||
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
|
if (runtimeSessionId && !canResumeSession) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||||
|
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||||
|
let instructionsPrefix = "";
|
||||||
|
if (instructionsFilePath) {
|
||||||
|
try {
|
||||||
|
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||||
|
instructionsPrefix =
|
||||||
|
`${instructionsContents}\n\n` +
|
||||||
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||||
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||||
|
);
|
||||||
|
} 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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const commandNotes = (() => {
|
||||||
|
if (!instructionsFilePath) return [] as string[];
|
||||||
|
if (instructionsPrefix.length > 0) {
|
||||||
|
return [
|
||||||
|
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||||
|
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||||
|
];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||||
|
agentId: agent.id,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
runId,
|
||||||
|
company: { id: agent.companyId },
|
||||||
|
agent,
|
||||||
|
run: { id: runId, source: "on_demand" },
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
const prompt = `${instructionsPrefix}${renderedPrompt}`;
|
||||||
|
|
||||||
|
const buildArgs = (resumeSessionId: string | null) => {
|
||||||
|
const args = ["-p", "--output-format", "stream-json", "--workspace", cwd];
|
||||||
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||||
|
if (model) args.push("--model", model);
|
||||||
|
if (mode) args.push("--mode", mode);
|
||||||
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
args.push(prompt);
|
||||||
|
return args;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAttempt = async (resumeSessionId: string | null) => {
|
||||||
|
const args = buildArgs(resumeSessionId);
|
||||||
|
if (onMeta) {
|
||||||
|
await onMeta({
|
||||||
|
adapterType: "cursor",
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
commandNotes,
|
||||||
|
commandArgs: args.map((value, idx) => {
|
||||||
|
if (idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
||||||
|
return value;
|
||||||
|
}),
|
||||||
|
env: redactEnvForLogs(env),
|
||||||
|
prompt,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = await runChildProcess(runId, command, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onLog,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
proc,
|
||||||
|
parsed: parseCursorJsonl(proc.stdout),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerFromModel = resolveProviderFromModel(model);
|
||||||
|
|
||||||
|
const toResult = (
|
||||||
|
attempt: {
|
||||||
|
proc: {
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: string | null;
|
||||||
|
timedOut: boolean;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
parsed: ReturnType<typeof parseCursorJsonl>;
|
||||||
|
},
|
||||||
|
clearSessionOnMissingSession = false,
|
||||||
|
): AdapterExecutionResult => {
|
||||||
|
if (attempt.proc.timedOut) {
|
||||||
|
return {
|
||||||
|
exitCode: attempt.proc.exitCode,
|
||||||
|
signal: attempt.proc.signal,
|
||||||
|
timedOut: true,
|
||||||
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||||
|
clearSession: clearSessionOnMissingSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
||||||
|
const resolvedSessionParams = resolvedSessionId
|
||||||
|
? ({
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
cwd,
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||||
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||||
|
const fallbackErrorMessage =
|
||||||
|
parsedError ||
|
||||||
|
stderrLine ||
|
||||||
|
`Cursor exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: attempt.proc.exitCode,
|
||||||
|
signal: attempt.proc.signal,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage:
|
||||||
|
(attempt.proc.exitCode ?? 0) === 0
|
||||||
|
? null
|
||||||
|
: fallbackErrorMessage,
|
||||||
|
usage: attempt.parsed.usage,
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
sessionParams: resolvedSessionParams,
|
||||||
|
sessionDisplayId: resolvedSessionId,
|
||||||
|
provider: providerFromModel,
|
||||||
|
model,
|
||||||
|
billingType,
|
||||||
|
costUsd: attempt.parsed.costUsd,
|
||||||
|
resultJson: {
|
||||||
|
stdout: attempt.proc.stdout,
|
||||||
|
stderr: attempt.proc.stderr,
|
||||||
|
},
|
||||||
|
summary: attempt.parsed.summary,
|
||||||
|
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = await runAttempt(sessionId);
|
||||||
|
if (
|
||||||
|
sessionId &&
|
||||||
|
!initial.proc.timedOut &&
|
||||||
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
|
isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||||
|
) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
|
);
|
||||||
|
const retry = await runAttempt(null);
|
||||||
|
return toResult(retry, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResult(initial);
|
||||||
|
}
|
||||||
64
packages/adapters/cursor-local/src/server/index.ts
Normal file
64
packages/adapters/cursor-local/src/server/index.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
export { execute } from "./execute.js";
|
||||||
|
export { testEnvironment } from "./test.js";
|
||||||
|
export { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||||
|
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
function readNonEmptyString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionCodec: AdapterSessionCodec = {
|
||||||
|
deserialize(raw: unknown) {
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
const sessionId =
|
||||||
|
readNonEmptyString(record.sessionId) ??
|
||||||
|
readNonEmptyString(record.session_id) ??
|
||||||
|
readNonEmptyString(record.sessionID);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const cwd =
|
||||||
|
readNonEmptyString(record.cwd) ??
|
||||||
|
readNonEmptyString(record.workdir) ??
|
||||||
|
readNonEmptyString(record.folder);
|
||||||
|
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||||
|
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||||
|
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
...(cwd ? { cwd } : {}),
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(repoUrl ? { repoUrl } : {}),
|
||||||
|
...(repoRef ? { repoRef } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
serialize(params: Record<string, unknown> | null) {
|
||||||
|
if (!params) return null;
|
||||||
|
const sessionId =
|
||||||
|
readNonEmptyString(params.sessionId) ??
|
||||||
|
readNonEmptyString(params.session_id) ??
|
||||||
|
readNonEmptyString(params.sessionID);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const cwd =
|
||||||
|
readNonEmptyString(params.cwd) ??
|
||||||
|
readNonEmptyString(params.workdir) ??
|
||||||
|
readNonEmptyString(params.folder);
|
||||||
|
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||||
|
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||||
|
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
...(cwd ? { cwd } : {}),
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(repoUrl ? { repoUrl } : {}),
|
||||||
|
...(repoRef ? { repoRef } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getDisplayId(params: Record<string, unknown> | null) {
|
||||||
|
if (!params) return null;
|
||||||
|
return (
|
||||||
|
readNonEmptyString(params.sessionId) ??
|
||||||
|
readNonEmptyString(params.session_id) ??
|
||||||
|
readNonEmptyString(params.sessionID)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
161
packages/adapters/cursor-local/src/server/parse.ts
Normal file
161
packages/adapters/cursor-local/src/server/parse.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
|
function asErrorText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
const rec = parseObject(value);
|
||||||
|
const message =
|
||||||
|
asString(rec.message, "") ||
|
||||||
|
asString(rec.error, "") ||
|
||||||
|
asString(rec.code, "") ||
|
||||||
|
asString(rec.detail, "");
|
||||||
|
if (message) return message;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(rec);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAssistantText(message: unknown): string[] {
|
||||||
|
if (typeof message === "string") {
|
||||||
|
const trimmed = message.trim();
|
||||||
|
return trimmed ? [trimmed] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = parseObject(message);
|
||||||
|
const direct = asString(rec.text, "").trim();
|
||||||
|
const lines: string[] = direct ? [direct] : [];
|
||||||
|
const content = Array.isArray(rec.content) ? rec.content : [];
|
||||||
|
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = parseObject(partRaw);
|
||||||
|
const type = asString(part.type, "").trim();
|
||||||
|
if (type === "output_text" || type === "text") {
|
||||||
|
const text = asString(part.text, "").trim();
|
||||||
|
if (text) lines.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionId(event: Record<string, unknown>): string | null {
|
||||||
|
return (
|
||||||
|
asString(event.session_id, "").trim() ||
|
||||||
|
asString(event.sessionId, "").trim() ||
|
||||||
|
asString(event.sessionID, "").trim() ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCursorJsonl(stdout: string) {
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
const messages: string[] = [];
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
let totalCostUsd = 0;
|
||||||
|
const usage = {
|
||||||
|
inputTokens: 0,
|
||||||
|
cachedInputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const event = parseJson(line);
|
||||||
|
if (!event) continue;
|
||||||
|
|
||||||
|
const foundSession = readSessionId(event);
|
||||||
|
if (foundSession) sessionId = foundSession;
|
||||||
|
|
||||||
|
const type = asString(event.type, "").trim();
|
||||||
|
|
||||||
|
if (type === "assistant") {
|
||||||
|
messages.push(...collectAssistantText(event.message));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "result") {
|
||||||
|
const usageObj = parseObject(event.usage);
|
||||||
|
usage.inputTokens += asNumber(
|
||||||
|
usageObj.input_tokens,
|
||||||
|
asNumber(usageObj.inputTokens, 0),
|
||||||
|
);
|
||||||
|
usage.cachedInputTokens += asNumber(
|
||||||
|
usageObj.cached_input_tokens,
|
||||||
|
asNumber(usageObj.cachedInputTokens, asNumber(usageObj.cache_read_input_tokens, 0)),
|
||||||
|
);
|
||||||
|
usage.outputTokens += asNumber(
|
||||||
|
usageObj.output_tokens,
|
||||||
|
asNumber(usageObj.outputTokens, 0),
|
||||||
|
);
|
||||||
|
totalCostUsd += asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, 0)));
|
||||||
|
|
||||||
|
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
|
||||||
|
const resultText = asString(event.result, "").trim();
|
||||||
|
if (resultText && messages.length === 0) {
|
||||||
|
messages.push(resultText);
|
||||||
|
}
|
||||||
|
if (isError) {
|
||||||
|
const resultError = asErrorText(event.error ?? event.message ?? event.result).trim();
|
||||||
|
if (resultError) errorMessage = resultError;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
const message = asErrorText(event.message ?? event.error ?? event.detail).trim();
|
||||||
|
if (message) errorMessage = message;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "system") {
|
||||||
|
const subtype = asString(event.subtype, "").trim().toLowerCase();
|
||||||
|
if (subtype === "error") {
|
||||||
|
const message = asErrorText(event.message ?? event.error ?? event.detail).trim();
|
||||||
|
if (message) errorMessage = message;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibility with older stream-json shapes.
|
||||||
|
if (type === "text") {
|
||||||
|
const part = parseObject(event.part);
|
||||||
|
const text = asString(part.text, "").trim();
|
||||||
|
if (text) messages.push(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "step_finish") {
|
||||||
|
const part = parseObject(event.part);
|
||||||
|
const tokens = parseObject(part.tokens);
|
||||||
|
const cache = parseObject(tokens.cache);
|
||||||
|
usage.inputTokens += asNumber(tokens.input, 0);
|
||||||
|
usage.cachedInputTokens += asNumber(cache.read, 0);
|
||||||
|
usage.outputTokens += asNumber(tokens.output, 0);
|
||||||
|
totalCostUsd += asNumber(part.cost, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
summary: messages.join("\n\n").trim(),
|
||||||
|
usage,
|
||||||
|
costUsd: totalCostUsd > 0 ? totalCostUsd : null,
|
||||||
|
errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCursorUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||||
|
const haystack = `${stdout}\n${stderr}`
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return /unknown\s+(session|chat)|session\s+.*\s+not\s+found|chat\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|could\s+not\s+resume/i.test(
|
||||||
|
haystack,
|
||||||
|
);
|
||||||
|
}
|
||||||
200
packages/adapters/cursor-local/src/server/test.ts
Normal file
200
packages/adapters/cursor-local/src/server/test.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import type {
|
||||||
|
AdapterEnvironmentCheck,
|
||||||
|
AdapterEnvironmentTestContext,
|
||||||
|
AdapterEnvironmentTestResult,
|
||||||
|
} from "@paperclipai/adapter-utils";
|
||||||
|
import {
|
||||||
|
asString,
|
||||||
|
parseObject,
|
||||||
|
ensureAbsoluteDirectory,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
ensurePathInEnv,
|
||||||
|
runChildProcess,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import path from "node:path";
|
||||||
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||||
|
import { parseCursorJsonl } from "./parse.js";
|
||||||
|
|
||||||
|
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 isNonEmpty(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonEmptyLine(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean) ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandLooksLike(command: string, expected: string): boolean {
|
||||||
|
const base = path.basename(command).toLowerCase();
|
||||||
|
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||||
|
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||||
|
if (!raw) return null;
|
||||||
|
const clean = raw.replace(/\s+/g, " ").trim();
|
||||||
|
const max = 240;
|
||||||
|
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURSOR_AUTH_REQUIRED_RE =
|
||||||
|
/(?:authentication\s+required|not\s+authenticated|not\s+logged\s+in|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|cursor[_\s-]?api[_\s-]?key|run\s+'?agent\s+login'?\s+first|api(?:[_\s-]?key)?(?:\s+is)?\s+required)/i;
|
||||||
|
|
||||||
|
export async function testEnvironment(
|
||||||
|
ctx: AdapterEnvironmentTestContext,
|
||||||
|
): Promise<AdapterEnvironmentTestResult> {
|
||||||
|
const checks: AdapterEnvironmentCheck[] = [];
|
||||||
|
const config = parseObject(ctx.config);
|
||||||
|
const command = asString(config.command, "agent");
|
||||||
|
const cwd = asString(config.cwd, process.cwd());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_cwd_valid",
|
||||||
|
level: "info",
|
||||||
|
message: `Working directory is valid: ${cwd}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_cwd_invalid",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||||
|
detail: cwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const envConfig = parseObject(config.env);
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
|
if (typeof value === "string") env[key] = value;
|
||||||
|
}
|
||||||
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
|
try {
|
||||||
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_command_resolvable",
|
||||||
|
level: "info",
|
||||||
|
message: `Command is executable: ${command}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_command_unresolvable",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Command is not executable",
|
||||||
|
detail: command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const configCursorApiKey = env.CURSOR_API_KEY;
|
||||||
|
const hostCursorApiKey = process.env.CURSOR_API_KEY;
|
||||||
|
if (isNonEmpty(configCursorApiKey) || isNonEmpty(hostCursorApiKey)) {
|
||||||
|
const source = isNonEmpty(configCursorApiKey) ? "adapter config env" : "server environment";
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_api_key_present",
|
||||||
|
level: "info",
|
||||||
|
message: "CURSOR_API_KEY is set for Cursor authentication.",
|
||||||
|
detail: `Detected in ${source}.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_api_key_missing",
|
||||||
|
level: "warn",
|
||||||
|
message: "CURSOR_API_KEY is not set. Cursor runs may fail until authentication is configured.",
|
||||||
|
hint: "Set CURSOR_API_KEY in adapter env or run `agent login`.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRunProbe =
|
||||||
|
checks.every((check) => check.code !== "cursor_cwd_invalid" && check.code !== "cursor_command_unresolvable");
|
||||||
|
if (canRunProbe) {
|
||||||
|
if (!commandLooksLike(command, "agent")) {
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_hello_probe_skipped_custom_command",
|
||||||
|
level: "info",
|
||||||
|
message: "Skipped hello probe because command is not `agent`.",
|
||||||
|
detail: command,
|
||||||
|
hint: "Use the `agent` CLI command to run the automatic installation and auth probe.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||||
|
const args = ["-p", "--mode", "ask", "--output-format", "json", "--workspace", cwd];
|
||||||
|
if (model) args.push("--model", model);
|
||||||
|
args.push("Respond with hello.");
|
||||||
|
|
||||||
|
const probe = await runChildProcess(
|
||||||
|
`cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec: 45,
|
||||||
|
graceSec: 5,
|
||||||
|
onLog: async () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const parsed = parseCursorJsonl(probe.stdout);
|
||||||
|
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||||
|
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||||
|
|
||||||
|
if (probe.timedOut) {
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_hello_probe_timed_out",
|
||||||
|
level: "warn",
|
||||||
|
message: "Cursor hello probe timed out.",
|
||||||
|
hint: "Retry the probe. If this persists, verify `agent -p --mode ask --output-format json \"Respond with hello.\"` manually.",
|
||||||
|
});
|
||||||
|
} else if ((probe.exitCode ?? 1) === 0) {
|
||||||
|
const summary = parsed.summary.trim();
|
||||||
|
const hasHello = /\bhello\b/i.test(summary);
|
||||||
|
checks.push({
|
||||||
|
code: hasHello ? "cursor_hello_probe_passed" : "cursor_hello_probe_unexpected_output",
|
||||||
|
level: hasHello ? "info" : "warn",
|
||||||
|
message: hasHello
|
||||||
|
? "Cursor hello probe succeeded."
|
||||||
|
: "Cursor probe ran but did not return `hello` as expected.",
|
||||||
|
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||||
|
...(hasHello
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
hint: "Try `agent -p --mode ask --output-format json \"Respond with hello.\"` manually to inspect full output.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (CURSOR_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_hello_probe_auth_required",
|
||||||
|
level: "warn",
|
||||||
|
message: "Cursor CLI is installed, but authentication is not ready.",
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
hint: "Run `agent login` or configure CURSOR_API_KEY in adapter env/shell, then retry the probe.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_hello_probe_failed",
|
||||||
|
level: "error",
|
||||||
|
message: "Cursor hello probe failed.",
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
hint: "Run `agent -p --mode ask --output-format json \"Respond with hello.\"` manually in this working directory to debug.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapterType: ctx.adapterType,
|
||||||
|
status: summarizeStatus(checks),
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
81
packages/adapters/cursor-local/src/ui/build-config.ts
Normal file
81
packages/adapters/cursor-local/src/ui/build-config.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||||
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||||
|
|
||||||
|
function parseCommaArgs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvVars(text: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
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<string, unknown> {
|
||||||
|
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||||
|
const env: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMode(value: string): "plan" | "ask" | null {
|
||||||
|
const mode = value.trim().toLowerCase();
|
||||||
|
if (mode === "plan" || mode === "ask") return mode;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
|
const ac: Record<string, unknown> = {};
|
||||||
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
|
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||||
|
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||||
|
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
|
||||||
|
const mode = normalizeMode(v.thinkingEffort);
|
||||||
|
if (mode) ac.mode = mode;
|
||||||
|
ac.timeoutSec = 0;
|
||||||
|
ac.graceSec = 15;
|
||||||
|
const env = parseEnvBindings(v.envBindings);
|
||||||
|
const legacy = parseEnvVars(v.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) ac.env = env;
|
||||||
|
if (v.command) ac.command = v.command;
|
||||||
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
|
return ac;
|
||||||
|
}
|
||||||
2
packages/adapters/cursor-local/src/ui/index.ts
Normal file
2
packages/adapters/cursor-local/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { parseCursorStdoutLine } from "./parse-stdout.js";
|
||||||
|
export { buildCursorLocalConfig } from "./build-config.js";
|
||||||
240
packages/adapters/cursor-local/src/ui/parse-stdout.ts
Normal file
240
packages/adapters/cursor-local/src/ui/parse-stdout.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
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<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown, fallback = 0): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? 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(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||||
|
if (typeof messageRaw === "string") {
|
||||||
|
const text = messageRaw.trim();
|
||||||
|
return text ? [{ kind: "assistant", ts, text }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = asRecord(messageRaw);
|
||||||
|
if (!message) return [];
|
||||||
|
|
||||||
|
const entries: TranscriptEntry[] = [];
|
||||||
|
const directText = asString(message.text).trim();
|
||||||
|
if (directText) {
|
||||||
|
entries.push({ kind: "assistant", ts, text: directText });
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = asRecord(partRaw);
|
||||||
|
if (!part) continue;
|
||||||
|
const type = asString(part.type).trim();
|
||||||
|
|
||||||
|
if (type === "output_text" || type === "text") {
|
||||||
|
const text = asString(part.text).trim();
|
||||||
|
if (text) entries.push({ kind: "assistant", ts, text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "thinking") {
|
||||||
|
const text = asString(part.text).trim();
|
||||||
|
if (text) entries.push({ kind: "thinking", ts, text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_call") {
|
||||||
|
entries.push({
|
||||||
|
kind: "tool_call",
|
||||||
|
ts,
|
||||||
|
name: asString(part.name, asString(part.tool, "tool")),
|
||||||
|
input: part.input ?? part.arguments ?? part.args ?? {},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_result") {
|
||||||
|
const toolUseId =
|
||||||
|
asString(part.tool_use_id) ||
|
||||||
|
asString(part.toolUseId) ||
|
||||||
|
asString(part.call_id) ||
|
||||||
|
asString(part.id) ||
|
||||||
|
"tool_result";
|
||||||
|
const contentText =
|
||||||
|
asString(part.output) ||
|
||||||
|
asString(part.text) ||
|
||||||
|
asString(part.result) ||
|
||||||
|
stringifyUnknown(part.output ?? part.result ?? part.text ?? part);
|
||||||
|
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||||
|
entries.push({
|
||||||
|
kind: "tool_result",
|
||||||
|
ts,
|
||||||
|
toolUseId,
|
||||||
|
content: contentText,
|
||||||
|
isError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCursorStdoutLine(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 === "system") {
|
||||||
|
const subtype = asString(parsed.subtype);
|
||||||
|
if (subtype === "init") {
|
||||||
|
const sessionId =
|
||||||
|
asString(parsed.session_id) ||
|
||||||
|
asString(parsed.sessionId) ||
|
||||||
|
asString(parsed.sessionID);
|
||||||
|
return [{ kind: "init", ts, model: asString(parsed.model, "cursor"), sessionId }];
|
||||||
|
}
|
||||||
|
return [{ kind: "system", ts, text: subtype ? `system: ${subtype}` : "system" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "assistant") {
|
||||||
|
const entries = parseAssistantMessage(parsed.message, ts);
|
||||||
|
return entries.length > 0 ? entries : [{ kind: "assistant", ts, text: asString(parsed.result) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "result") {
|
||||||
|
const usage = asRecord(parsed.usage);
|
||||||
|
const inputTokens = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
||||||
|
const outputTokens = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
|
||||||
|
const cachedTokens = asNumber(
|
||||||
|
usage?.cached_input_tokens,
|
||||||
|
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
|
||||||
|
);
|
||||||
|
const subtype = asString(parsed.subtype, "result");
|
||||||
|
const errors = Array.isArray(parsed.errors)
|
||||||
|
? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const errorText = asString(parsed.error).trim();
|
||||||
|
if (errorText) errors.push(errorText);
|
||||||
|
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
|
||||||
|
|
||||||
|
return [{
|
||||||
|
kind: "result",
|
||||||
|
ts,
|
||||||
|
text: asString(parsed.result),
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
cachedTokens,
|
||||||
|
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
||||||
|
subtype,
|
||||||
|
isError,
|
||||||
|
errors,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || line;
|
||||||
|
return [{ kind: "stderr", ts, text: message }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibility with older stream-json event shapes.
|
||||||
|
if (type === "step_start") {
|
||||||
|
const sessionId = asString(parsed.sessionID);
|
||||||
|
return [{ kind: "system", ts, text: `step started${sessionId ? ` (${sessionId})` : ""}` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "text") {
|
||||||
|
const part = asRecord(parsed.part);
|
||||||
|
const text = asString(part?.text).trim();
|
||||||
|
if (!text) return [];
|
||||||
|
return [{ kind: "assistant", ts, text }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_use") {
|
||||||
|
const part = asRecord(parsed.part);
|
||||||
|
const toolUseId = asString(part?.callID, asString(part?.id, "tool_use"));
|
||||||
|
const toolName = asString(part?.tool, "tool");
|
||||||
|
const state = asRecord(part?.state);
|
||||||
|
const input = state?.input ?? {};
|
||||||
|
const output = asString(state?.output).trim();
|
||||||
|
const status = asString(state?.status).trim();
|
||||||
|
const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN);
|
||||||
|
const isError =
|
||||||
|
status === "failed" ||
|
||||||
|
status === "error" ||
|
||||||
|
status === "cancelled" ||
|
||||||
|
(Number.isFinite(exitCode) && exitCode !== 0);
|
||||||
|
|
||||||
|
const entries: TranscriptEntry[] = [
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts,
|
||||||
|
name: toolName,
|
||||||
|
input,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (status || output) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (status) lines.push(`status: ${status}`);
|
||||||
|
if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`);
|
||||||
|
if (output) {
|
||||||
|
if (lines.length > 0) lines.push("");
|
||||||
|
lines.push(output);
|
||||||
|
}
|
||||||
|
entries.push({
|
||||||
|
kind: "tool_result",
|
||||||
|
ts,
|
||||||
|
toolUseId,
|
||||||
|
content: lines.join("\n").trim() || "tool completed",
|
||||||
|
isError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "step_finish") {
|
||||||
|
const part = asRecord(parsed.part);
|
||||||
|
const tokens = asRecord(part?.tokens);
|
||||||
|
const cache = asRecord(tokens?.cache);
|
||||||
|
const reason = asString(part?.reason);
|
||||||
|
return [{
|
||||||
|
kind: "result",
|
||||||
|
ts,
|
||||||
|
text: reason,
|
||||||
|
inputTokens: asNumber(tokens?.input),
|
||||||
|
outputTokens: asNumber(tokens?.output),
|
||||||
|
cachedTokens: asNumber(cache?.read),
|
||||||
|
costUsd: asNumber(part?.cost),
|
||||||
|
subtype: reason || "step_finish",
|
||||||
|
isError: reason === "error" || reason === "failed",
|
||||||
|
errors: [],
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
8
packages/adapters/cursor-local/tsconfig.json
Normal file
8
packages/adapters/cursor-local/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export const AGENT_STATUSES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type AgentStatus = (typeof AGENT_STATUSES)[number];
|
export type AgentStatus = (typeof AGENT_STATUSES)[number];
|
||||||
|
|
||||||
export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "opencode_local", "openclaw"] as const;
|
export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "opencode_local", "cursor", "openclaw"] as const;
|
||||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||||
|
|
||||||
export const AGENT_ROLES = [
|
export const AGENT_ROLES = [
|
||||||
|
|||||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -32,6 +32,9 @@ importers:
|
|||||||
'@paperclipai/adapter-codex-local':
|
'@paperclipai/adapter-codex-local':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/codex-local
|
version: link:../packages/adapters/codex-local
|
||||||
|
'@paperclipai/adapter-cursor-local':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/adapters/cursor-local
|
||||||
'@paperclipai/adapter-openclaw':
|
'@paperclipai/adapter-openclaw':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/openclaw
|
version: link:../packages/adapters/openclaw
|
||||||
@@ -105,6 +108,19 @@ importers:
|
|||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/adapters/cursor-local:
|
||||||
|
dependencies:
|
||||||
|
'@paperclipai/adapter-utils':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../adapter-utils
|
||||||
|
picocolors:
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/adapters/openclaw:
|
packages/adapters/openclaw:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@paperclipai/adapter-utils':
|
'@paperclipai/adapter-utils':
|
||||||
@@ -177,6 +193,9 @@ importers:
|
|||||||
'@paperclipai/adapter-codex-local':
|
'@paperclipai/adapter-codex-local':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/codex-local
|
version: link:../packages/adapters/codex-local
|
||||||
|
'@paperclipai/adapter-cursor-local':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/adapters/cursor-local
|
||||||
'@paperclipai/adapter-openclaw':
|
'@paperclipai/adapter-openclaw':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/openclaw
|
version: link:../packages/adapters/openclaw
|
||||||
@@ -280,6 +299,9 @@ importers:
|
|||||||
'@paperclipai/adapter-codex-local':
|
'@paperclipai/adapter-codex-local':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/codex-local
|
version: link:../packages/adapters/codex-local
|
||||||
|
'@paperclipai/adapter-cursor-local':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/adapters/cursor-local
|
||||||
'@paperclipai/adapter-openclaw':
|
'@paperclipai/adapter-openclaw':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/openclaw
|
version: link:../packages/adapters/openclaw
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@aws-sdk/client-s3": "^3.888.0",
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { sessionCodec as claudeSessionCodec } from "@paperclipai/adapter-claude-local/server";
|
import { sessionCodec as claudeSessionCodec } from "@paperclipai/adapter-claude-local/server";
|
||||||
import { sessionCodec as codexSessionCodec, isCodexUnknownSessionError } from "@paperclipai/adapter-codex-local/server";
|
import { sessionCodec as codexSessionCodec, isCodexUnknownSessionError } from "@paperclipai/adapter-codex-local/server";
|
||||||
|
import {
|
||||||
|
sessionCodec as cursorSessionCodec,
|
||||||
|
isCursorUnknownSessionError,
|
||||||
|
} from "@paperclipai/adapter-cursor-local/server";
|
||||||
import {
|
import {
|
||||||
sessionCodec as opencodeSessionCodec,
|
sessionCodec as opencodeSessionCodec,
|
||||||
isOpenCodeUnknownSessionError,
|
isOpenCodeUnknownSessionError,
|
||||||
@@ -60,6 +64,24 @@ describe("adapter session codecs", () => {
|
|||||||
});
|
});
|
||||||
expect(opencodeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("opencode-session-1");
|
expect(opencodeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("opencode-session-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes cursor session params with cwd", () => {
|
||||||
|
const parsed = cursorSessionCodec.deserialize({
|
||||||
|
session_id: "cursor-session-1",
|
||||||
|
cwd: "/tmp/cursor",
|
||||||
|
});
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
sessionId: "cursor-session-1",
|
||||||
|
cwd: "/tmp/cursor",
|
||||||
|
});
|
||||||
|
|
||||||
|
const serialized = cursorSessionCodec.serialize(parsed);
|
||||||
|
expect(serialized).toEqual({
|
||||||
|
sessionId: "cursor-session-1",
|
||||||
|
cwd: "/tmp/cursor",
|
||||||
|
});
|
||||||
|
expect(cursorSessionCodec.getDisplayId?.(serialized ?? null)).toBe("cursor-session-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("codex resume recovery detection", () => {
|
describe("codex resume recovery detection", () => {
|
||||||
@@ -101,3 +123,26 @@ describe("opencode resume recovery detection", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cursor resume recovery detection", () => {
|
||||||
|
it("detects unknown session errors from cursor output", () => {
|
||||||
|
expect(
|
||||||
|
isCursorUnknownSessionError(
|
||||||
|
"",
|
||||||
|
"Error: unknown session id abc",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isCursorUnknownSessionError(
|
||||||
|
"",
|
||||||
|
"chat abc not found",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isCursorUnknownSessionError(
|
||||||
|
"{\"type\":\"result\",\"subtype\":\"success\"}",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { testEnvironment } from "@paperclipai/adapter-cursor-local/server";
|
||||||
|
|
||||||
|
describe("cursor environment diagnostics", () => {
|
||||||
|
it("creates a missing working directory when cwd is absolute", async () => {
|
||||||
|
const cwd = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`paperclip-cursor-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
"workspace",
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||||
|
|
||||||
|
const result = await testEnvironment({
|
||||||
|
companyId: "company-1",
|
||||||
|
adapterType: "cursor",
|
||||||
|
config: {
|
||||||
|
command: process.execPath,
|
||||||
|
cwd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.checks.some((check) => check.code === "cursor_cwd_valid")).toBe(true);
|
||||||
|
expect(result.checks.some((check) => check.level === "error")).toBe(false);
|
||||||
|
const stats = await fs.stat(cwd);
|
||||||
|
expect(stats.isDirectory()).toBe(true);
|
||||||
|
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
184
server/src/__tests__/cursor-local-adapter.test.ts
Normal file
184
server/src/__tests__/cursor-local-adapter.test.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { isCursorUnknownSessionError, parseCursorJsonl } from "@paperclipai/adapter-cursor-local/server";
|
||||||
|
import { parseCursorStdoutLine } from "@paperclipai/adapter-cursor-local/ui";
|
||||||
|
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||||
|
|
||||||
|
describe("cursor parser", () => {
|
||||||
|
it("extracts session, summary, usage, cost, and terminal error message", () => {
|
||||||
|
const stdout = [
|
||||||
|
JSON.stringify({ type: "system", subtype: "init", session_id: "chat_123", model: "gpt-5" }),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "output_text", text: "hello" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
session_id: "chat_123",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 100,
|
||||||
|
cached_input_tokens: 25,
|
||||||
|
output_tokens: 40,
|
||||||
|
},
|
||||||
|
total_cost_usd: 0.001,
|
||||||
|
result: "Task complete",
|
||||||
|
}),
|
||||||
|
JSON.stringify({ type: "error", message: "model access denied" }),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const parsed = parseCursorJsonl(stdout);
|
||||||
|
expect(parsed.sessionId).toBe("chat_123");
|
||||||
|
expect(parsed.summary).toBe("hello");
|
||||||
|
expect(parsed.usage).toEqual({
|
||||||
|
inputTokens: 100,
|
||||||
|
cachedInputTokens: 25,
|
||||||
|
outputTokens: 40,
|
||||||
|
});
|
||||||
|
expect(parsed.costUsd).toBeCloseTo(0.001, 6);
|
||||||
|
expect(parsed.errorMessage).toBe("model access denied");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cursor stale session detection", () => {
|
||||||
|
it("treats missing/unknown session messages as an unknown session error", () => {
|
||||||
|
expect(isCursorUnknownSessionError("", "unknown session id chat_123")).toBe(true);
|
||||||
|
expect(isCursorUnknownSessionError("", "chat abc not found")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cursor ui stdout parser", () => {
|
||||||
|
it("parses assistant, thinking, and tool lifecycle events", () => {
|
||||||
|
const ts = "2026-03-05T00:00:00.000Z";
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseCursorStdoutLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "output_text", text: "I will run a command." },
|
||||||
|
{ type: "thinking", text: "Checking repository state" },
|
||||||
|
{ type: "tool_call", name: "bash", input: { command: "ls -1" } },
|
||||||
|
{ type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{ kind: "assistant", ts, text: "I will run a command." },
|
||||||
|
{ kind: "thinking", ts, text: "Checking repository state" },
|
||||||
|
{ kind: "tool_call", ts, name: "bash", input: { command: "ls -1" } },
|
||||||
|
{ kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses result usage and errors", () => {
|
||||||
|
const ts = "2026-03-05T00:00:00.000Z";
|
||||||
|
expect(
|
||||||
|
parseCursorStdoutLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
result: "Done",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 5,
|
||||||
|
cached_input_tokens: 2,
|
||||||
|
},
|
||||||
|
total_cost_usd: 0.00042,
|
||||||
|
is_error: false,
|
||||||
|
}),
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: "result",
|
||||||
|
ts,
|
||||||
|
text: "Done",
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 5,
|
||||||
|
cachedTokens: 2,
|
||||||
|
costUsd: 0.00042,
|
||||||
|
subtype: "success",
|
||||||
|
isError: false,
|
||||||
|
errors: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function stripAnsi(value: string): string {
|
||||||
|
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("cursor cli formatter", () => {
|
||||||
|
it("prints init, assistant, tool, and result events", () => {
|
||||||
|
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
printCursorStreamEvent(
|
||||||
|
JSON.stringify({ type: "system", subtype: "init", session_id: "chat_abc", model: "gpt-5" }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
printCursorStreamEvent(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "output_text", text: "hello" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
printCursorStreamEvent(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "tool_call", name: "bash", input: { command: "ls -1" } }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
printCursorStreamEvent(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "tool_result", output: "AGENTS.md", status: "ok" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
printCursorStreamEvent(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
result: "Done",
|
||||||
|
usage: { input_tokens: 10, output_tokens: 5, cached_input_tokens: 2 },
|
||||||
|
total_cost_usd: 0.00042,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = spy.mock.calls
|
||||||
|
.map((call) => call.map((v) => String(v)).join(" "))
|
||||||
|
.map(stripAnsi);
|
||||||
|
|
||||||
|
expect(lines).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"Cursor init (session: chat_abc, model: gpt-5)",
|
||||||
|
"assistant: hello",
|
||||||
|
"tool_call: bash",
|
||||||
|
"tool_result",
|
||||||
|
"AGENTS.md",
|
||||||
|
"result: subtype=success",
|
||||||
|
"tokens: in=10 out=5 cached=2 cost=$0.000420",
|
||||||
|
"assistant: Done",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
sessionCodec as codexSessionCodec,
|
sessionCodec as codexSessionCodec,
|
||||||
} from "@paperclipai/adapter-codex-local/server";
|
} from "@paperclipai/adapter-codex-local/server";
|
||||||
import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclipai/adapter-codex-local";
|
import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclipai/adapter-codex-local";
|
||||||
|
import {
|
||||||
|
execute as cursorExecute,
|
||||||
|
testEnvironment as cursorTestEnvironment,
|
||||||
|
sessionCodec as cursorSessionCodec,
|
||||||
|
} from "@paperclipai/adapter-cursor-local/server";
|
||||||
|
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
||||||
import {
|
import {
|
||||||
execute as opencodeExecute,
|
execute as opencodeExecute,
|
||||||
testEnvironment as opencodeTestEnvironment,
|
testEnvironment as opencodeTestEnvironment,
|
||||||
@@ -60,6 +66,16 @@ const opencodeLocalAdapter: ServerAdapterModule = {
|
|||||||
agentConfigurationDoc: opencodeAgentConfigurationDoc,
|
agentConfigurationDoc: opencodeAgentConfigurationDoc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cursorLocalAdapter: ServerAdapterModule = {
|
||||||
|
type: "cursor",
|
||||||
|
execute: cursorExecute,
|
||||||
|
testEnvironment: cursorTestEnvironment,
|
||||||
|
sessionCodec: cursorSessionCodec,
|
||||||
|
models: cursorModels,
|
||||||
|
supportsLocalAgentJwt: true,
|
||||||
|
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
||||||
|
};
|
||||||
|
|
||||||
const openclawAdapter: ServerAdapterModule = {
|
const openclawAdapter: ServerAdapterModule = {
|
||||||
type: "openclaw",
|
type: "openclaw",
|
||||||
execute: openclawExecute,
|
execute: openclawExecute,
|
||||||
@@ -70,7 +86,7 @@ const openclawAdapter: ServerAdapterModule = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||||
[claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
[claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
DEFAULT_CODEX_LOCAL_MODEL,
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||||
|
|
||||||
export function agentRoutes(db: Db) {
|
export function agentRoutes(db: Db) {
|
||||||
@@ -43,6 +44,7 @@ export function agentRoutes(db: Db) {
|
|||||||
claude_local: "instructionsFilePath",
|
claude_local: "instructionsFilePath",
|
||||||
codex_local: "instructionsFilePath",
|
codex_local: "instructionsFilePath",
|
||||||
opencode_local: "instructionsFilePath",
|
opencode_local: "instructionsFilePath",
|
||||||
|
cursor: "instructionsFilePath",
|
||||||
};
|
};
|
||||||
const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
|
const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
|
||||||
|
|
||||||
@@ -196,6 +198,9 @@ export function agentRoutes(db: Db) {
|
|||||||
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
|
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
|
||||||
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||||
}
|
}
|
||||||
|
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||||
|
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
|||||||
{ path: ["timeoutSec"], value: 0 },
|
{ path: ["timeoutSec"], value: 0 },
|
||||||
{ path: ["graceSec"], value: 15 },
|
{ path: ["graceSec"], value: 15 },
|
||||||
],
|
],
|
||||||
|
cursor: [
|
||||||
|
{ path: ["timeoutSec"], value: 0 },
|
||||||
|
{ path: ["graceSec"], value: 15 },
|
||||||
|
],
|
||||||
claude_local: [
|
claude_local: [
|
||||||
{ path: ["timeoutSec"], value: 0 },
|
{ path: ["timeoutSec"], value: 0 },
|
||||||
{ path: ["graceSec"], value: 15 },
|
{ path: ["graceSec"], value: 15 },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@mdxeditor/editor": "^3.52.4",
|
"@mdxeditor/editor": "^3.52.4",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
|
|||||||
47
ui/src/adapters/cursor/config-fields.tsx
Normal file
47
ui/src/adapters/cursor/config-fields.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { AdapterConfigFieldsProps } from "../types";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
DraftInput,
|
||||||
|
} from "../../components/agent-config-primitives";
|
||||||
|
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
|
const instructionsFileHint =
|
||||||
|
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the prompt at runtime.";
|
||||||
|
|
||||||
|
export function CursorLocalConfigFields({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
eff,
|
||||||
|
mark,
|
||||||
|
}: AdapterConfigFieldsProps) {
|
||||||
|
return (
|
||||||
|
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
isCreate
|
||||||
|
? values!.instructionsFilePath ?? ""
|
||||||
|
: eff(
|
||||||
|
"adapterConfig",
|
||||||
|
"instructionsFilePath",
|
||||||
|
String(config.instructionsFilePath ?? ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ instructionsFilePath: v })
|
||||||
|
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="/absolute/path/to/AGENTS.md"
|
||||||
|
/>
|
||||||
|
<ChoosePathButton />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/adapters/cursor/index.ts
Normal file
12
ui/src/adapters/cursor/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { UIAdapterModule } from "../types";
|
||||||
|
import { parseCursorStdoutLine } from "@paperclipai/adapter-cursor-local/ui";
|
||||||
|
import { CursorLocalConfigFields } from "./config-fields";
|
||||||
|
import { buildCursorLocalConfig } from "@paperclipai/adapter-cursor-local/ui";
|
||||||
|
|
||||||
|
export const cursorLocalUIAdapter: UIAdapterModule = {
|
||||||
|
type: "cursor",
|
||||||
|
label: "Cursor CLI (local)",
|
||||||
|
parseStdoutLine: parseCursorStdoutLine,
|
||||||
|
ConfigFields: CursorLocalConfigFields,
|
||||||
|
buildAdapterConfig: buildCursorLocalConfig,
|
||||||
|
};
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { UIAdapterModule } from "./types";
|
import type { UIAdapterModule } from "./types";
|
||||||
import { claudeLocalUIAdapter } from "./claude-local";
|
import { claudeLocalUIAdapter } from "./claude-local";
|
||||||
import { codexLocalUIAdapter } from "./codex-local";
|
import { codexLocalUIAdapter } from "./codex-local";
|
||||||
|
import { cursorLocalUIAdapter } from "./cursor";
|
||||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||||
import { openClawUIAdapter } from "./openclaw";
|
import { openClawUIAdapter } from "./openclaw";
|
||||||
import { processUIAdapter } from "./process";
|
import { processUIAdapter } from "./process";
|
||||||
import { httpUIAdapter } from "./http";
|
import { httpUIAdapter } from "./http";
|
||||||
|
|
||||||
const adaptersByType = new Map<string, UIAdapterModule>(
|
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||||
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
|
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function getUIAdapter(type: string): UIAdapterModule {
|
export function getUIAdapter(type: string): UIAdapterModule {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
DEFAULT_CODEX_LOCAL_MODEL,
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -140,6 +141,12 @@ const opencodeVariantOptions = [
|
|||||||
{ id: "max", label: "Max" },
|
{ id: "max", label: "Max" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const cursorModeOptions = [
|
||||||
|
{ id: "", label: "Auto" },
|
||||||
|
{ id: "plan", label: "Plan" },
|
||||||
|
{ id: "ask", label: "Ask" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const claudeThinkingEffortOptions = [
|
const claudeThinkingEffortOptions = [
|
||||||
{ id: "", label: "Auto" },
|
{ id: "", label: "Auto" },
|
||||||
{ id: "low", label: "Low" },
|
{ id: "low", label: "Low" },
|
||||||
@@ -267,7 +274,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
const isLocal =
|
const isLocal =
|
||||||
adapterType === "claude_local" ||
|
adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local" ||
|
adapterType === "codex_local" ||
|
||||||
adapterType === "opencode_local";
|
adapterType === "opencode_local" ||
|
||||||
|
adapterType === "cursor";
|
||||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||||
|
|
||||||
// Fetch adapter models for the effective adapter type
|
// Fetch adapter models for the effective adapter type
|
||||||
@@ -329,12 +337,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
const thinkingEffortKey =
|
const thinkingEffortKey =
|
||||||
adapterType === "codex_local"
|
adapterType === "codex_local"
|
||||||
? "modelReasoningEffort"
|
? "modelReasoningEffort"
|
||||||
|
: adapterType === "cursor"
|
||||||
|
? "mode"
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? "variant"
|
? "variant"
|
||||||
: "effort";
|
: "effort";
|
||||||
const thinkingEffortOptions =
|
const thinkingEffortOptions =
|
||||||
adapterType === "codex_local"
|
adapterType === "codex_local"
|
||||||
? codexThinkingEffortOptions
|
? codexThinkingEffortOptions
|
||||||
|
: adapterType === "cursor"
|
||||||
|
? cursorModeOptions
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? opencodeVariantOptions
|
? opencodeVariantOptions
|
||||||
: claudeThinkingEffortOptions;
|
: claudeThinkingEffortOptions;
|
||||||
@@ -346,6 +358,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
"modelReasoningEffort",
|
"modelReasoningEffort",
|
||||||
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
|
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
|
||||||
)
|
)
|
||||||
|
: adapterType === "cursor"
|
||||||
|
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||||
@@ -466,12 +480,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
||||||
nextValues.dangerouslyBypassSandbox =
|
nextValues.dangerouslyBypassSandbox =
|
||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||||
|
} else if (t === "cursor") {
|
||||||
|
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||||
} else if (t === "opencode_local") {
|
} else if (t === "opencode_local") {
|
||||||
nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||||
}
|
}
|
||||||
set!(nextValues);
|
set!(nextValues);
|
||||||
} else {
|
} else {
|
||||||
// Clear all adapter config and explicitly blank out model + both effort keys
|
// Clear all adapter config and explicitly blank out model + effort/mode keys
|
||||||
// so the old adapter's values don't bleed through via eff()
|
// so the old adapter's values don't bleed through via eff()
|
||||||
setOverlay((prev) => ({
|
setOverlay((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -480,12 +496,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
model:
|
model:
|
||||||
t === "codex_local"
|
t === "codex_local"
|
||||||
? DEFAULT_CODEX_LOCAL_MODEL
|
? DEFAULT_CODEX_LOCAL_MODEL
|
||||||
|
: t === "cursor"
|
||||||
|
? DEFAULT_CURSOR_LOCAL_MODEL
|
||||||
: t === "opencode_local"
|
: t === "opencode_local"
|
||||||
? DEFAULT_OPENCODE_LOCAL_MODEL
|
? DEFAULT_OPENCODE_LOCAL_MODEL
|
||||||
: "",
|
: "",
|
||||||
effort: "",
|
effort: "",
|
||||||
modelReasoningEffort: "",
|
modelReasoningEffort: "",
|
||||||
variant: "",
|
variant: "",
|
||||||
|
mode: "",
|
||||||
...(t === "codex_local"
|
...(t === "codex_local"
|
||||||
? {
|
? {
|
||||||
dangerouslyBypassApprovalsAndSandbox:
|
dangerouslyBypassApprovalsAndSandbox:
|
||||||
@@ -584,6 +603,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
placeholder={
|
placeholder={
|
||||||
adapterType === "codex_local"
|
adapterType === "codex_local"
|
||||||
? "codex"
|
? "codex"
|
||||||
|
: adapterType === "cursor"
|
||||||
|
? "agent"
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? "opencode"
|
? "opencode"
|
||||||
: "claude"
|
: "claude"
|
||||||
@@ -855,7 +876,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||||||
|
|
||||||
/* ---- Internal sub-components ---- */
|
/* ---- Internal sub-components ---- */
|
||||||
|
|
||||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local", "cursor"]);
|
||||||
|
|
||||||
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||||
@@ -864,7 +885,6 @@ const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean
|
|||||||
label: adapterLabels[t] ?? t,
|
label: adapterLabels[t] ?? t,
|
||||||
comingSoon: !ENABLED_ADAPTER_TYPES.has(t),
|
comingSoon: !ENABLED_ADAPTER_TYPES.has(t),
|
||||||
})),
|
})),
|
||||||
{ value: "cursor", label: "Cursor", comingSoon: true },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function AdapterTypeDropdown({
|
function AdapterTypeDropdown({
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const adapterLabels: Record<string, string> = {
|
|||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
DEFAULT_CODEX_LOCAL_MODEL
|
DEFAULT_CODEX_LOCAL_MODEL
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
@@ -51,6 +52,7 @@ type AdapterType =
|
|||||||
| "claude_local"
|
| "claude_local"
|
||||||
| "codex_local"
|
| "codex_local"
|
||||||
| "opencode_local"
|
| "opencode_local"
|
||||||
|
| "cursor"
|
||||||
| "process"
|
| "process"
|
||||||
| "http"
|
| "http"
|
||||||
| "openclaw";
|
| "openclaw";
|
||||||
@@ -153,11 +155,13 @@ export function OnboardingWizard() {
|
|||||||
enabled: onboardingOpen && step === 2
|
enabled: onboardingOpen && step === 2
|
||||||
});
|
});
|
||||||
const isLocalAdapter =
|
const isLocalAdapter =
|
||||||
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local";
|
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local" || adapterType === "cursor";
|
||||||
const effectiveAdapterCommand =
|
const effectiveAdapterCommand =
|
||||||
command.trim() ||
|
command.trim() ||
|
||||||
(adapterType === "codex_local"
|
(adapterType === "codex_local"
|
||||||
? "codex"
|
? "codex"
|
||||||
|
: adapterType === "cursor"
|
||||||
|
? "agent"
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? "opencode"
|
? "opencode"
|
||||||
: "claude");
|
: "claude");
|
||||||
@@ -219,6 +223,8 @@ export function OnboardingWizard() {
|
|||||||
model:
|
model:
|
||||||
adapterType === "codex_local"
|
adapterType === "codex_local"
|
||||||
? model || DEFAULT_CODEX_LOCAL_MODEL
|
? model || DEFAULT_CODEX_LOCAL_MODEL
|
||||||
|
: adapterType === "cursor"
|
||||||
|
? model || DEFAULT_CURSOR_LOCAL_MODEL
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? model || DEFAULT_OPENCODE_LOCAL_MODEL
|
? model || DEFAULT_OPENCODE_LOCAL_MODEL
|
||||||
: model,
|
: model,
|
||||||
@@ -598,8 +604,7 @@ export function OnboardingWizard() {
|
|||||||
value: "cursor" as const,
|
value: "cursor" as const,
|
||||||
label: "Cursor",
|
label: "Cursor",
|
||||||
icon: MousePointer2,
|
icon: MousePointer2,
|
||||||
desc: "Cursor AI agent",
|
desc: "Local Cursor agent"
|
||||||
comingSoon: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "process" as const,
|
value: "process" as const,
|
||||||
@@ -633,6 +638,8 @@ export function OnboardingWizard() {
|
|||||||
setAdapterType(nextType);
|
setAdapterType(nextType);
|
||||||
if (nextType === "codex_local" && !model) {
|
if (nextType === "codex_local" && !model) {
|
||||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||||
|
} else if (nextType === "cursor" && !model) {
|
||||||
|
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
|
||||||
} else if (nextType === "opencode_local" && !model) {
|
} else if (nextType === "opencode_local" && !model) {
|
||||||
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
|
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
|
||||||
}
|
}
|
||||||
@@ -656,7 +663,8 @@ export function OnboardingWizard() {
|
|||||||
{/* Conditional adapter fields */}
|
{/* Conditional adapter fields */}
|
||||||
{(adapterType === "claude_local" ||
|
{(adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local" ||
|
adapterType === "codex_local" ||
|
||||||
adapterType === "opencode_local") && (
|
adapterType === "opencode_local" ||
|
||||||
|
adapterType === "cursor") && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
@@ -789,7 +797,9 @@ export function OnboardingWizard() {
|
|||||||
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-[11px] space-y-1.5">
|
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-[11px] space-y-1.5">
|
||||||
<p className="font-medium">Manual debug</p>
|
<p className="font-medium">Manual debug</p>
|
||||||
<p className="text-muted-foreground font-mono break-all">
|
<p className="text-muted-foreground font-mono break-all">
|
||||||
{adapterType === "codex_local"
|
{adapterType === "cursor"
|
||||||
|
? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"`
|
||||||
|
: adapterType === "codex_local"
|
||||||
? `${effectiveAdapterCommand} exec --json -`
|
? `${effectiveAdapterCommand} exec --json -`
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? `${effectiveAdapterCommand} run --format json \"Respond with hello.\"`
|
? `${effectiveAdapterCommand} run --format json \"Respond with hello.\"`
|
||||||
@@ -799,13 +809,20 @@ export function OnboardingWizard() {
|
|||||||
Prompt:{" "}
|
Prompt:{" "}
|
||||||
<span className="font-mono">Respond with hello.</span>
|
<span className="font-mono">Respond with hello.</span>
|
||||||
</p>
|
</p>
|
||||||
{adapterType === "codex_local" || adapterType === "opencode_local" ? (
|
{adapterType === "cursor" || adapterType === "codex_local" || adapterType === "opencode_local" ? (
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
If auth fails, set{" "}
|
If auth fails, set{" "}
|
||||||
<span className="font-mono">OPENAI_API_KEY</span> in
|
<span className="font-mono">
|
||||||
|
{adapterType === "cursor" ? "CURSOR_API_KEY" : "OPENAI_API_KEY"}
|
||||||
|
</span>{" "}
|
||||||
|
in
|
||||||
env or run{" "}
|
env or run{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{adapterType === "codex_local" ? "codex login" : "opencode auth login"}
|
{adapterType === "cursor"
|
||||||
|
? "agent login"
|
||||||
|
: adapterType === "codex_local"
|
||||||
|
? "codex login"
|
||||||
|
: "opencode auth login"}
|
||||||
</span>.
|
</span>.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const adapterLabels: Record<string, string> = {
|
|||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const adapterLabels: Record<string, string> = {
|
|||||||
claude_local: "Claude",
|
claude_local: "Claude",
|
||||||
codex_local: "Codex",
|
codex_local: "Codex",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
|
cursor: "Cursor",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ const adapterLabels: Record<string, string> = {
|
|||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "opencode_local"]);
|
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "opencode_local", "cursor"]);
|
||||||
|
|
||||||
function dateTime(value: string) {
|
function dateTime(value: string) {
|
||||||
return new Date(value).toLocaleString();
|
return new Date(value).toLocaleString();
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ const adapterLabels: Record<string, string> = {
|
|||||||
claude_local: "Claude",
|
claude_local: "Claude",
|
||||||
codex_local: "Codex",
|
codex_local: "Codex",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
|
cursor: "Cursor",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|||||||
Reference in New Issue
Block a user