Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/plugin-entry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/**
* OpenCode-only entrypoint.
* OpenCode plugin entrypoint (dual V1/V2).
*
* OpenCode 1.x loads plugins as an async factory function (or an object with a
* `server` field); OpenCode 2.x expects an object with `id` + `setup`. Exporting
* both keeps a single package working across both major versions.
*
* When cursor-acp is removed from the `plugin` array in opencode.json,
* this entrypoint turns into a no-op so users can disable the plugin
Expand All @@ -8,6 +12,7 @@
import type { Plugin } from "@opencode-ai/plugin";
import { shouldEnableCursorPlugin } from "./plugin-toggle.js";
import { createLogger } from "./utils/logger.js";
import { createV2Setup } from "./plugin-v2.js";

const log = createLogger("plugin-entry");

Expand All @@ -25,4 +30,8 @@ const CursorPluginEntry: Plugin = async (input) => {
return mod.CursorPlugin(input);
};

export default CursorPluginEntry;
export default {
id: "open-cursor",
server: CursorPluginEntry,
setup: createV2Setup(),
};
256 changes: 256 additions & 0 deletions src/plugin-v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/**
* OpenCode V2 plugin entrypoint.
*
* OpenCode 2.x uses a different plugin API: the default export must be an
* object with an `id` and a `setup` function, instead of the V1 async factory
* function. This module provides that V2 shape while reusing the same proxy,
* MCP bridge, and tool machinery as the V1 plugin.
*
* The V1 and V2 entrypoints are combined in plugin-entry.ts as a dual export
* ({ id, server, setup }) so a single package works on both OpenCode 1.x and
* 2.x — mirroring how oh-my-opencode-slim does it.
*/
import { shouldEnableCursorPlugin } from "./plugin-toggle.js";
import { createLogger } from "./utils/logger.js";
import {
CURSOR_PROVIDER_ID,
buildAvailableToolsSystemMessage,
buildToolHookEntries,
ensureCursorProxyServer,
resolveWorkspaceDirectory,
ensurePluginDirectory,
setStoredApiKey,
} from "./plugin.js";
import { readMcpConfigs } from "./mcp/config.js";
import { McpClientManager } from "./mcp/client-manager.js";
import {
buildMcpToolHookEntries,
buildMcpToolDefinitions,
namespaceMcpTool,
} from "./mcp/tool-bridge.js";
import { autoRefreshModels } from "./models/sync.js";
import { ToolRegistry as CoreRegistry } from "./tools/core/registry.js";
import { registerDefaultTools } from "./tools/defaults.js";
import { ToolRouter } from "./tools/router.js";
import { SkillLoader } from "./tools/skills/loader.js";
import { SkillResolver } from "./tools/skills/resolver.js";
import { LocalExecutor } from "./tools/executors/local.js";
import { executeWithChain } from "./tools/core/executor.js";
import { buildLocalFallbackTools, shouldRegisterNativeToolHook } from "./plugin.js";
import { createOpencodeClient } from "@opencode-ai/sdk";
import { TOOL_HOOK_EXCLUSIONS, TOOL_LOOP_MODE } from "./plugin.js";

const log = createLogger("plugin-v2");

/** Convert a V1-style tool entry (with zod args) into a V2 tool definition. */
function v2ToolFromV1(
name: string,
entry: any,
jsonSchema?: Record<string, unknown>,
): { description: string; input: Record<string, unknown>; execute: (args: any, ctx: any) => Promise<unknown> } {
const description = typeof entry?.description === "string" ? entry.description : name;
// Prefer the original JSON schema when available; fall back to an empty object.
const input =
jsonSchema && typeof jsonSchema === "object"
? jsonSchema
: { type: "object", properties: {} };
const v1Execute = typeof entry?.execute === "function" ? entry.execute : async () => ({ content: "" });

return {
description,
input,
async execute(args: any, executeCtx: any) {
const result = await v1Execute(args, executeCtx);
// V1 tools return a string; V2 expects { content } or { output, content }.
if (typeof result === "string") {
return { content: result };
}
return result ?? { content: "" };
},
};
}

export function createV2Setup() {
return async (ctx: any) => {
const state = shouldEnableCursorPlugin();
if (!state.enabled) {
log.info("Plugin disabled in OpenCode config; skipping initialization", {
configPath: state.configPath,
reason: state.reason,
});
return;
}

const workspaceDirectory = resolveWorkspaceDirectory(ctx.worktree, ctx.directory);
log.debug("V2 plugin initializing", {
directory: ctx.directory,
worktree: ctx.worktree,
workspaceDirectory,
cwd: process.cwd(),
});

await ensurePluginDirectory();
autoRefreshModels().catch(() => {});

// MCP bridge: connect to MCP servers and collect their tools.
const mcpManager = new McpClientManager();
let mcpToolEntries: Record<string, any> = {};
let mcpToolDefs: any[] = [];
let mcpToolSummaries: Array<{ serverName: string; toolName: string; callName?: string; description?: string; params?: string[] }> = [];
const mcpEnabled = process.env.CURSOR_ACP_MCP_BRIDGE !== "false";

if (mcpEnabled) {
try {
const configs = readMcpConfigs();
if (configs.length > 0) {
await Promise.allSettled(configs.map((c) => mcpManager.connectServer(c)));
const tools = mcpManager.listTools();
if (tools.length > 0) {
mcpToolEntries = buildMcpToolHookEntries(tools, mcpManager);
mcpToolDefs = buildMcpToolDefinitions(tools);
mcpToolSummaries = tools.map((t: any) => ({
serverName: t.serverName,
toolName: t.name,
callName: namespaceMcpTool(t.serverName, t.name),
description: t.description,
params: t.inputSchema
? Object.keys((t.inputSchema as any).properties ?? {})
: undefined,
}));
}
}
} catch (err) {
log.debug("MCP bridge init failed", { error: String(err) });
}
}

// Tools (skills) discovery/execution wiring (same as V1).
const toolsEnabled = process.env.CURSOR_ACP_ENABLE_OPENCODE_TOOLS !== "false";
const legacyProxyToolPathsEnabled = toolsEnabled && TOOL_LOOP_MODE === "proxy-exec";
const serverClient = legacyProxyToolPathsEnabled
? createOpencodeClient({ baseUrl: ctx.serverUrl?.toString(), directory: workspaceDirectory })
: null;

const localRegistry = new CoreRegistry();
registerDefaultTools(localRegistry);
const localExec = new LocalExecutor(localRegistry);
const executorChain: any[] = [localExec];
const toolsByName = new Map<string, any>();
const skillLoader = new SkillLoader();
let skillResolver: SkillResolver | null = null;

const router = legacyProxyToolPathsEnabled
? new ToolRouter({
execute: (toolId: string, args: any) => executeWithChain(executorChain, toolId, args),
toolsByName,
resolveName: (name: string) => skillResolver?.resolve(name),
})
: null;

let lastToolNames: string[] = [];
let lastToolMap: Array<{ id: string; name: string }> = [];

const refreshTools = async () => {
toolsByName.clear();
const toolEntries: any[] = [];
const localTools = buildLocalFallbackTools(localRegistry, TOOL_LOOP_MODE);
for (const asTool of localTools) {
toolsByName.set(asTool.name, asTool);
toolEntries.push({ type: "function", function: { name: asTool.name, parameters: {} } });
}
const skills = skillLoader.load([...localTools]);
skillResolver = new SkillResolver(skills);
lastToolNames = toolEntries.map((e) => e.function.name);
lastToolMap = localTools.map((t: any) => ({ id: t.id, name: t.name }));
return toolEntries;
};

const proxyBaseURL = await ensureCursorProxyServer(workspaceDirectory, router ?? undefined);
log.debug("Proxy server started", { baseURL: proxyBaseURL });

// Register the cursor-acp provider + auth via catalog/integration transforms.
await ctx.catalog.transform((catalog: any) => {
catalog.provider.update(CURSOR_PROVIDER_ID, (p: any) => {
p.name = "Cursor";
p.api = {
type: "aisdk",
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: proxyBaseURL },
};
});
});

await ctx.integration.transform((integrations: any) => {
integrations.method.update({
integrationID: CURSOR_PROVIDER_ID,
method: { type: "key", label: "Cursor API Key (cursor.com/settings)" },
});
});

// Register local + MCP tools as V2 tools (best-effort).
try {
const toolHookEntries = buildToolHookEntries(localRegistry, workspaceDirectory);
const allEntries = { ...toolHookEntries, ...mcpToolEntries };

// Map registry tool names back to their JSON schemas.
const schemaByName = new Map<string, Record<string, unknown>>();
for (const t of localRegistry.list()) {
schemaByName.set(t.name, t.parameters);
}

await ctx.tool.transform((tools: any) => {
for (const [name, entry] of Object.entries(allEntries)) {
const def = v2ToolFromV1(name, entry, schemaByName.get(name));
tools.add(name, def, { codemode: true });
}
});
} catch (err) {
log.debug("Tool registration failed", { error: String(err) });
}

// Chat-params equivalent: force baseURL + inject MCP tool defs, and append
// the available-tools system message on every turn.
await ctx.session.hook("context", async (event: any) => {
// V1 filled this from auth.loader. In V2 resolve the active integration
// connection before every Cursor turn so the local proxy gets the key.
try {
const connection = await ctx.integration.connection.active(CURSOR_PROVIDER_ID);
const credential = connection && await ctx.integration.connection.resolve(connection);
if (credential?.type === "key") setStoredApiKey(credential.key);
} catch (err) {
log.debug("Could not resolve Cursor API key", { error: String(err) });
}

const modelRef = event.model;
const isCursor = modelRef?.providerID === CURSOR_PROVIDER_ID;
if (!isCursor) return;

if (toolsEnabled && TOOL_LOOP_MODE === "opencode") {
const existingTools = event.tools;
if (existingTools == null) {
const refreshed = await refreshTools();
event.tools = refreshed;
}
}

if (mcpToolDefs.length > 0) {
// Surface MCP tools through the tools record so the model can call them.
const current = event.tools && typeof event.tools === "object" ? event.tools : {};
for (const def of mcpToolDefs) {
const fname = def?.function?.name;
if (fname && !(fname in current)) {
current[fname] = def;
}
}
event.tools = current;
}

const systemMessage = buildAvailableToolsSystemMessage(
lastToolNames, lastToolMap, mcpToolDefs, mcpToolSummaries,
);
if (systemMessage) {
event.system.push({ type: "text", text: systemMessage });
}
});
};
}
14 changes: 9 additions & 5 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export async function ensurePluginDirectory(): Promise<void> {
}
}

const CURSOR_PROVIDER_ID = "cursor-acp";
export const CURSOR_PROVIDER_ID = "cursor-acp";
const CURSOR_PROVIDER_PREFIX = `${CURSOR_PROVIDER_ID}/`;

export function shouldProcessModel(model: string | undefined): boolean {
Expand All @@ -198,6 +198,10 @@ const REUSE_EXISTING_PROXY = process.env.CURSOR_ACP_REUSE_EXISTING_PROXY !== "fa

// Stored API key from auth loader (OpenCode auth store)
let storedApiKey: string | undefined;

export function setStoredApiKey(apiKey: string | undefined): void {
storedApiKey = apiKey;
}
let cursorAgentAvailabilityCache: boolean | undefined;

function getGlobalKey(): string {
Expand Down Expand Up @@ -805,7 +809,7 @@ function parseToolLoopMode(value: string | undefined): { mode: ToolLoopMode; val
}

const TOOL_LOOP_MODE_RAW = process.env.CURSOR_ACP_TOOL_LOOP_MODE;
const { mode: TOOL_LOOP_MODE, valid: TOOL_LOOP_MODE_VALID } = parseToolLoopMode(TOOL_LOOP_MODE_RAW);
export const { mode: TOOL_LOOP_MODE, valid: TOOL_LOOP_MODE_VALID } = parseToolLoopMode(TOOL_LOOP_MODE_RAW);
const PROVIDER_BOUNDARY_MODE_RAW = process.env.CURSOR_ACP_PROVIDER_BOUNDARY;
const {
mode: PROVIDER_BOUNDARY_MODE,
Expand Down Expand Up @@ -1188,7 +1192,7 @@ async function findFirstAllowedToolCallInOutput(
return { toolCall: null, terminationMessage: null };
}

async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: ToolRouter): Promise<string> {
export async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: ToolRouter): Promise<string> {
const key = getGlobalKey();
const g = globalThis as any;
const normalizedWorkspace = normalizeWorkspaceForCompare(workspaceDirectory);
Expand Down Expand Up @@ -2669,7 +2673,7 @@ function applyToolContextDefaults(
/**
* Build tool hook entries from local registry
*/
const TOOL_HOOK_EXCLUSIONS = new Set(["grep"]);
export const TOOL_HOOK_EXCLUSIONS = new Set(["grep"]);
const OPENCODE_NATIVE_TOOL_HOOK_EXCLUSIONS = new Set(["edit", "write"]);

// Exported for mode-boundary tests without initializing the full plugin runtime.
Expand Down Expand Up @@ -2726,7 +2730,7 @@ export function buildLocalFallbackTools(
});
}

function buildToolHookEntries(registry: CoreRegistry, fallbackBaseDir?: string): Record<string, any> {
export function buildToolHookEntries(registry: CoreRegistry, fallbackBaseDir?: string): Record<string, any> {
const entries: Record<string, any> = {};
const sessionWorkspaceBySession = new Map<string, string>();
const tools = registry.list();
Expand Down
Loading