diff --git a/README.md b/README.md index 769943d..8f33e7c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@

Linux macOS + Windows

No prompt limits. No broken streams. Full thinking + tool support in OpenCode. Your Cursor subscription, properly integrated. @@ -11,14 +12,20 @@ No prompt limits. No broken streams. Full thinking + tool support in OpenCode. Y ### Option A — One-line installer +**macOS/Linux:** ```bash curl -fsSL https://raw.githubusercontent.com/Nomadcxx/opencode-cursor/main/install.sh | bash ``` +**Windows (PowerShell):** +```powershell +iwr https://raw.githubusercontent.com/Nomadcxx/opencode-cursor/main/install.ps1 -UseBasicParsing | iex +``` +
Option B — Add to opencode.json -Add to `~/.config/opencode/opencode.json`: +Add to `~/.config/opencode/opencode.json` (or `%USERPROFILE%\.config\opencode\opencode.json` on Windows): ```json { @@ -147,7 +154,7 @@ The plugin bridges MCP (Model Context Protocol) servers into Cursor models via a ### Configure MCP servers -Add to `~/.config/opencode/opencode.json`: +Add to `~/.config/opencode/opencode.json` (or `%USERPROFILE%\.config\opencode\opencode.json` on Windows): ```json { @@ -221,7 +228,7 @@ THERE is currently not a single perfect plugin for cursor in opencode, my advice | | open-cursor | [yet-another-opencode-cursor-auth](https://github.com/Yukaii/yet-another-opencode-cursor-auth) | [opencode-cursor-auth](https://github.com/POSO-PocketSolutions/opencode-cursor-auth) | [cursor-opencode-auth](https://github.com/R44VC0RP/cursor-opencode-auth) | | ----------------- | :------------------------: | :--------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------: | :----------------------------------------------------------------------: | | **Architecture** | HTTP proxy via cursor-agent | Direct Connect-RPC | HTTP proxy via cursor-agent | Direct Connect-RPC/protobuf | -| **Platform** | Linux, macOS | Linux, macOS | Linux, macOS | macOS only (Keychain) | +| **Platform** | Linux, macOS, Windows | Linux, macOS | Linux, macOS | macOS only (Keychain) | | **Max Prompt** | Unlimited (HTTP body) | Unknown | ~128KB (ARG_MAX) | Unknown | | **Streaming** | ✓ SSE | ✓ SSE | Undocumented | ✓ | | **Error Parsing** | ✓ (quota/auth/model) | ✗ | ✗ | Debug logging | @@ -263,4 +270,4 @@ flowchart LR ## License -BSD-3-Clause +BSD-3-Clause \ No newline at end of file diff --git a/package.json b/package.json index b3ec0ce..d501d57 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@rama_nigg/open-cursor", "version": "2.3.17", "description": "No prompt limits. No broken streams. Full thinking + tool support. Your Cursor subscription, properly integrated.", - "type": "module", + "type": "commonjs", "main": "dist/plugin-entry.js", "module": "src/plugin-entry.ts", "scripts": { @@ -13,8 +13,7 @@ "test:integration": "bun test tests/integration", "test:ci:unit": "bun test tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/cli/model-discovery.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/provider-tool-schema-compat.test.ts tests/unit/provider-tool-loop-guard.test.ts tests/unit/plugin.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/competitive/edge.test.ts", "test:ci:integration": "bun test tests/integration/comprehensive.test.ts tests/integration/tools-router.integration.test.ts tests/integration/stream-router.integration.test.ts tests/integration/opencode-loop.integration.test.ts", - "discover": "bun run src/cli/discover.ts", - "prepublishOnly": "bun run build" + "discover": "bun run src/cli/discover.ts",\n "prepublishOnly": "bun run build" }, "bin": { "open-cursor": "dist/cli/opencode-cursor.js", diff --git a/src/auth.ts b/src/auth.ts index 776ea2c..68ac05b 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -6,6 +6,7 @@ import { homedir, platform } from "os"; import { join } from "path"; import { createLogger } from "./utils/logger"; import { stripAnsi } from "./utils/errors"; +import { resolveCursorAgentBinary } from "./utils/binary"; const log = createLogger("auth"); @@ -75,8 +76,9 @@ export async function startCursorOAuth(): Promise<{ return new Promise((resolve, reject) => { log.info("Starting cursor-cli login process"); - const proc = spawn("cursor-agent", ["login"], { + const proc = spawn(resolveCursorAgentBinary(), ["login"], { stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32" }); let stdout = ""; diff --git a/src/cli/model-discovery.ts b/src/cli/model-discovery.ts index 413a71a..110ce54 100644 --- a/src/cli/model-discovery.ts +++ b/src/cli/model-discovery.ts @@ -1,5 +1,6 @@ import { execFileSync } from "child_process"; import { stripAnsi } from "../utils/errors.js"; +import { resolveCursorAgentBinary } from "../utils/binary.js"; const MODEL_DISCOVERY_TIMEOUT_MS = 5000; @@ -31,10 +32,8 @@ export function parseCursorModelsOutput(output: string): DiscoveredModel[] { } export function discoverModelsFromCursorAgent(): DiscoveredModel[] { - const raw = execFileSync("cursor-agent", ["models"], { + const raw = execFileSync(resolveCursorAgentBinary(), ["models"], { encoding: "utf8", - killSignal: "SIGTERM", - stdio: ["ignore", "pipe", "pipe"], timeout: MODEL_DISCOVERY_TIMEOUT_MS, }); const models = parseCursorModelsOutput(raw); diff --git a/src/cli/opencode-cursor.ts b/src/cli/opencode-cursor.ts index a21188a..1baec4a 100644 --- a/src/cli/opencode-cursor.ts +++ b/src/cli/opencode-cursor.ts @@ -18,6 +18,7 @@ import { discoverModelsFromCursorAgent, fallbackModels, } from "./model-discovery.js"; +import { resolveCursorAgentBinary } from "../utils/binary.js"; const BRANDING_HEADER = ` ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄ ▄▄ ▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ @@ -70,12 +71,12 @@ export function checkBun(): CheckResult { export function checkCursorAgent(): CheckResult { try { - const output = execFileSync("cursor-agent", ["--version"], { encoding: "utf8" }).trim(); + const output = execFileSync(resolveCursorAgentBinary(), ["--version"], { encoding: "utf8" }).trim(); const version = output.split("\n")[0] || "installed"; - return { name: "cursor-agent", passed: true, message: version }; + return { name: resolveCursorAgentBinary(), passed: true, message: version }; } catch { return { - name: "cursor-agent", + name: resolveCursorAgentBinary(), passed: false, message: "not found - install with: curl -fsS https://cursor.com/install | bash", }; @@ -86,7 +87,7 @@ export function checkCursorAgentLogin(): CheckResult { try { // cursor-agent stores credentials in ~/.cursor-agent or similar // Try running a command that requires auth - execFileSync("cursor-agent", ["models"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + execFileSync(resolveCursorAgentBinary(), ["models"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); return { name: "cursor-agent login", passed: true, message: "logged in" }; } catch { return { diff --git a/src/client/simple.ts b/src/client/simple.ts index fe4d1f6..ae39b47 100644 --- a/src/client/simple.ts +++ b/src/client/simple.ts @@ -7,6 +7,7 @@ import { type StreamJsonEvent, } from '../streaming/types.js'; import { createLogger } from '../utils/logger.js'; +import { resolveCursorAgentBinary } from '../utils/binary.js'; export interface CursorClientConfig { timeout?: number; @@ -30,7 +31,7 @@ export class SimpleCursorClient { timeout: 30000, maxRetries: 3, streamOutput: true, - cursorAgentPath: process.env.CURSOR_AGENT_EXECUTABLE || 'cursor-agent', + cursorAgentPath: resolveCursorAgentBinary(), ...config }; @@ -78,7 +79,8 @@ export class SimpleCursorClient { const child = spawn(this.config.cursorAgentPath, args, { cwd, - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + shell: process.platform === 'win32' }); if (prompt) { @@ -189,7 +191,8 @@ export class SimpleCursorClient { return new Promise((resolve, reject) => { const child = spawn(this.config.cursorAgentPath, args, { cwd, - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + shell: process.platform === 'win32' }); let stdoutBuffer = ''; diff --git a/src/models/discovery.ts b/src/models/discovery.ts index 4845369..7341117 100644 --- a/src/models/discovery.ts +++ b/src/models/discovery.ts @@ -1,4 +1,5 @@ import type { ModelInfo, DiscoveryConfig } from "./types.js"; +import { resolveCursorAgentBinary } from "../utils/binary.js"; interface CacheEntry { models: ModelInfo[]; @@ -51,7 +52,7 @@ export class ModelDiscoveryService { private async queryViaCLI(): Promise { try { const bunAny = (globalThis as any).Bun; - const proc = bunAny.spawn(["cursor-agent", "models", "--json"], { + const proc = bunAny.spawn([resolveCursorAgentBinary(), "models", "--json"], { timeout: 5000, stdout: "pipe", stderr: "pipe" @@ -78,7 +79,7 @@ export class ModelDiscoveryService { private async queryViaHelp(): Promise { try { const bunAny = (globalThis as any).Bun; - const proc = bunAny.spawn(["cursor-agent", "--help"], { + const proc = bunAny.spawn([resolveCursorAgentBinary(), "--help"], { timeout: 5000, stdout: "pipe", stderr: "pipe" diff --git a/src/plugin-toggle.ts b/src/plugin-toggle.ts index 5b8ace4..14d2fa8 100644 --- a/src/plugin-toggle.ts +++ b/src/plugin-toggle.ts @@ -33,6 +33,12 @@ export function isCursorPluginEnabledInConfig(config: unknown): boolean { const configObject = config as { plugin?: unknown; provider?: unknown }; + if (configObject.provider && typeof configObject.provider === "object") { + if (CURSOR_PROVIDER_ID in (configObject.provider as Record)) { + return true; + } + } + if (Array.isArray(configObject.plugin)) { return configObject.plugin.some((entry) => matchesPlugin(entry)); } diff --git a/src/plugin.ts b/src/plugin.ts index 608f14c..f1eead6 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -13,6 +13,7 @@ import { parseStreamJsonLine } from "./streaming/parser.js"; import { extractText, extractThinking, isAssistantText, isThinking } from "./streaming/types.js"; import { createLogger } from "./utils/logger"; import { RequestPerf } from "./utils/perf"; +import { resolveCursorAgentBinary } from "./utils/binary"; import { parseAgentError, formatErrorForUser, stripAnsi } from "./utils/errors"; import { buildPromptFromMessages } from "./proxy/prompt-builder.js"; import { @@ -184,7 +185,7 @@ function canonicalizePathForCompare(pathValue: string): string { normalizedPath = resolvedPath; } - if (process.platform === "darwin") { + if (process.platform === "darwin" || process.platform === "win32") { return normalizedPath.toLowerCase(); } @@ -258,11 +259,7 @@ export function isReusableProxyHealthPayload(payload: any, workspaceDirectory: s if (!payload || payload.ok !== true) { return false; } - if (typeof payload.workspaceDirectory !== "string" || payload.workspaceDirectory.length === 0) { - // Legacy proxies that do not expose workspace cannot be safely reused. - return false; - } - return normalizeWorkspaceForCompare(payload.workspaceDirectory) === normalizeWorkspaceForCompare(workspaceDirectory); + return true; } const FORCE_TOOL_MODE = process.env.CURSOR_ACP_FORCE !== "false"; @@ -563,7 +560,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: if (url.pathname === "/v1/models" || url.pathname === "/models") { try { const bunAny = globalThis as any; - const proc = bunAny.Bun.spawn(["cursor-agent", "models"], { + const proc = bunAny.Bun.spawn([resolveCursorAgentBinary(), "models"], { stdout: "pipe", stderr: "pipe", }); @@ -655,14 +652,15 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: }); } + const requestWorkspace = req.headers.get("x-cursor-acp-workspace") || workspaceDirectory; const cmd = [ - "cursor-agent", + resolveCursorAgentBinary(), "--print", "--output-format", "stream-json", "--stream-partial-output", "--workspace", - workspaceDirectory, + requestWorkspace, "--model", model, ]; @@ -1047,7 +1045,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: if (url.pathname === "/v1/models" || url.pathname === "/models") { try { const { execSync } = await import("child_process"); - const output = execSync("cursor-agent models", { encoding: "utf-8", timeout: 30000 }); + const output = execSync(resolveCursorAgentBinary() + " models", { encoding: "utf-8", timeout: 30000 }); const clean = stripAnsi(output); const models: Array<{ id: string; object: string; created: number; owned_by: string }> = []; for (const line of clean.split("\n")) { @@ -1113,14 +1111,15 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: msgRoles: msgSummary.join(","), }); + const requestWorkspace = req.headers["x-cursor-acp-workspace"] || workspaceDirectory; const cmd = [ - "cursor-agent", + resolveCursorAgentBinary(), "--print", "--output-format", "stream-json", "--stream-partial-output", "--workspace", - workspaceDirectory, + requestWorkspace, "--model", model, ]; @@ -1128,7 +1127,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: cmd.push("--force"); } - const child = spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(cmd[0], cmd.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32" + }); // Write prompt to stdin to avoid E2BIG error child.stdin.write(prompt); @@ -1963,6 +1965,11 @@ export const CursorPlugin: Plugin = async ({ $, directory, worktree, client, ser const toolHookEntries = buildToolHookEntries(localRegistry, workspaceDirectory); return { + "chat.headers": async (input: any, output: any) => { + if (input.model?.providerID === "cursor-acp") { + output.headers["x-cursor-acp-workspace"] = workspaceDirectory; + } + }, tool: { ...toolHookEntries, ...mcpToolEntries }, auth: { provider: CURSOR_PROVIDER_ID, @@ -2008,7 +2015,7 @@ export const CursorPlugin: Plugin = async ({ $, directory, worktree, client, ser output, proxyBaseURL, CURSOR_PROXY_DEFAULT_BASE_URL, - "cursor-agent", + resolveCursorAgentBinary(), ), ); diff --git a/src/tools/defaults.ts b/src/tools/defaults.ts index 419602f..4c1e3fa 100644 --- a/src/tools/defaults.ts +++ b/src/tools/defaults.ts @@ -40,7 +40,7 @@ export function registerDefaultTools(registry: ToolRegistry): void { return new Promise((resolve, reject) => { const proc = spawn(command, { - shell: process.env.SHELL || "/bin/bash", + shell: process.env.SHELL || (process.platform === "win32" ? "cmd.exe" : "/bin/bash"), cwd, }); @@ -265,6 +265,8 @@ export function registerDefaultTools(registry: ToolRegistry): void { const path = args.path as string; const include = args.include as string | undefined; + if (process.platform === "win32") { return await nodeFallbackGrep(pattern, path, include); } + const grepArgs = ["-r", "-n"]; if (include) { grepArgs.push(`--include=${include}`); @@ -374,6 +376,7 @@ export function registerDefaultTools(registry: ToolRegistry): void { const path = resolvePathArg(args, "glob"); const cwd = path || "."; const normalizedPattern = pattern.replace(/\\/g, "/"); + if (process.platform === "win32") { return await nodeFallbackGlob(normalizedPattern, cwd); } const isPathPattern = normalizedPattern.includes("/"); const findArgs = [cwd, "-type", "f"]; if (isPathPattern) { @@ -703,3 +706,139 @@ function coerceToString(value: unknown): string | null { export function getDefaultToolNames(): string[] { return ["bash", "read", "write", "edit", "grep", "ls", "glob", "mkdir", "rm", "stat"]; } + +async function nodeFallbackGrep(pattern: string, searchPath: string, include?: string): Promise { + const fs = await import("fs/promises"); + const path = await import("path"); + + const results: string[] = []; + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch (e) { + try { + regex = new RegExp(pattern.replace(/[.*+?^\\$\\{}()|[\\]\\\\]/g, '\\\\$&')); + } catch { + return "Invalid regex pattern"; + } + } + + let includeRegex: RegExp | undefined; + if (include) { + const incPattern = include.replace(/\\./g, '\\\\.').replace(/\\*/g, '.*'); + includeRegex = new RegExp(`^${incPattern}$`); + } + + async function walk(dir: string) { + if (results.length >= 100) return; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (results.length >= 100) return; + + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (!['node_modules', '.git', 'dist', 'build'].includes(entry.name)) { + await walk(fullPath); + } + } else if (entry.isFile()) { + if (includeRegex && !includeRegex.test(entry.name)) { + continue; + } + + try { + const content = await fs.readFile(fullPath, 'utf-8'); + const lines = content.split('\\n'); + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + results.push(`${fullPath}:${i + 1}:${lines[i]}`); + if (results.length >= 100) break; + } + } + } catch { + // ignore unreadable files + } + } + } + } catch { + // ignore + } + } + + try { + const stat = await fs.stat(searchPath); + if (stat.isFile()) { + try { + const content = await fs.readFile(searchPath, 'utf-8'); + const lines = content.split('\\n'); + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + results.push(`${searchPath}:${i + 1}:${lines[i]}`); + if (results.length >= 100) break; + } + } + } catch {} + } else { + await walk(searchPath); + } + } catch { + return "Path not found"; + } + + return results.join('\\n') || "No matches found"; +} + +async function nodeFallbackGlob(pattern: string, searchPath: string): Promise { + const fs = await import("fs/promises"); + const path = await import("path"); + + const results: string[] = []; + + const isPathPattern = pattern.includes("/"); + let regexPattern = pattern + .replace(/\\./g, '\\\\.') + .replace(/\\*\\*/g, '.*') + .replace(/\\*/g, '[^/]*'); + + if (!isPathPattern) { + regexPattern = `^${regexPattern}$`; + } else if (!regexPattern.startsWith('.*')) { + regexPattern = `.*${regexPattern}$`; + } + + let regex: RegExp; + try { + regex = new RegExp(regexPattern); + } catch { + return "Invalid pattern"; + } + + async function walk(dir: string) { + if (results.length >= 50) return; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (results.length >= 50) return; + + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (!['node_modules', '.git', 'dist', 'build'].includes(entry.name)) { + await walk(fullPath); + } + } else if (entry.isFile()) { + const matchTarget = isPathPattern ? fullPath.replace(/\\\\/g, '/') : entry.name; + if (regex.test(matchTarget)) { + results.push(fullPath); + } + } + } + } catch { + // ignore + } + } + + await walk(searchPath); + return results.join('\\n') || "No files found"; +} diff --git a/src/tools/executors/cli.ts b/src/tools/executors/cli.ts index 77e0942..770f1bf 100644 --- a/src/tools/executors/cli.ts +++ b/src/tools/executors/cli.ts @@ -16,6 +16,7 @@ export class CliExecutor implements IToolExecutor { const { spawn } = await import("node:child_process"); const child = spawn("opencode", ["tool", "run", toolId, "--json", JSON.stringify(args)], { stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32" }); const stdoutChunks: Buffer[] = []; diff --git a/src/utils/binary.ts b/src/utils/binary.ts new file mode 100644 index 0000000..8f9b5ef --- /dev/null +++ b/src/utils/binary.ts @@ -0,0 +1,32 @@ +import { existsSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; + +export function resolveCursorAgentBinary(): string { + const envOverride = process.env.CURSOR_AGENT_EXECUTABLE; + if (envOverride && envOverride.length > 0) { + return envOverride; + } + + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"); + const knownPath = join(localAppData, "cursor-agent", "cursor-agent.cmd"); + if (existsSync(knownPath)) { + return knownPath; + } + return "cursor-agent.cmd"; + } + + const home = homedir(); + const knownPaths = [ + join(home, ".cursor-agent", "cursor-agent"), + "/usr/local/bin/cursor-agent", + ]; + for (const p of knownPaths) { + if (existsSync(p)) { + return p; + } + } + + return "cursor-agent"; +}