diff --git a/README.md b/README.md index 5e9dbfd..cb4ccbb 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,23 @@ No prompt limits. No broken streams. Full thinking + tool support in OpenCode. Y ### Option A — One-line installer +**Linux & macOS:** ```bash curl -fsSL https://raw.githubusercontent.com/Nomadcxx/opencode-cursor/main/install.sh | bash ``` +**Windows:** +```powershell +# Windows installer coming soon. +# In the meantime, use Option C (npm install): +npm install -g @rama_nigg/open-cursor +open-cursor install +``` +
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 { @@ -188,7 +198,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 | diff --git a/docs/superpowers/plans/2026-03-17-windows-support.md b/docs/superpowers/plans/2026-03-17-windows-support.md new file mode 100644 index 0000000..7e76a49 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-windows-support.md @@ -0,0 +1,1091 @@ +# Windows Support + Multi-Instance Workspace Fix — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add proper Windows platform support by cherry-picking the useful parts of PR #52 and rewriting the broken parts correctly, crediting the original contributor. + +**Architecture:** Five independent tasks committed separately. Binary resolution is extracted into `src/utils/binary.ts` with dependency injection for testability. Windows path comparison is fixed with a one-line change to `canonicalizePathForCompare`. Node fallback grep/glob are rewritten from scratch and exported for direct testing. + +**Tech Stack:** TypeScript, Bun, Node.js child_process, bun:test + +**Spec:** `docs/superpowers/specs/2026-03-17-windows-support-design.md` + +**Credit:** All commits include `Co-authored-by: Finn Birich ` + +--- + +## File Structure + +| File | Action | Purpose | +|------|--------|---------| +| `src/utils/binary.ts` | **Create** | `resolveCursorAgentBinary(deps?)` — centralised cursor-agent path resolution | +| `tests/unit/utils/binary.test.ts` | **Create** | 9 cases covering all resolution branches | +| `src/plugin.ts` | **Modify** | `canonicalizePathForCompare` win32 lowercase; import binary; `execSync`→`execFileSync`; Node spawn shell flag | +| `src/auth.ts` | **Modify** | Import binary; add shell flag to spawn | +| `src/client/simple.ts` | **Modify** | Import binary; add shell flag to both spawns | +| `src/cli/opencode-cursor.ts` | **Modify** | Import binary; fix `checkCursorAgent()` to call once | +| `src/cli/model-discovery.ts` | **Modify** | Import binary; make `killSignal` platform-conditional | +| `src/models/discovery.ts` | **Modify** | Import binary; update Bun.spawn binary arg | +| `src/tools/executors/cli.ts` | **Modify** | Add shell flag to opencode spawn | +| `src/tools/defaults.ts` | **Modify** | Add `nodeFallbackGrep`/`nodeFallbackGlob` (exported, rewritten); add win32 guards | +| `tests/tools/node-fallbacks.test.ts` | **Create** | 12 cases for both fallback functions | +| `tests/unit/plugin-proxy-reuse.test.ts` | **Modify** | Add 3 Windows path comparison cases | +| `src/plugin-toggle.ts` | **Modify** | Add provider-based detection (from PR) | +| `tests/unit/plugin-toggle.test.ts` | **Modify** | Add 2 cases for new provider branch | +| `package.json` | **Modify** | Revert `"type"` to `"module"`, fix `prepublishOnly` | +| `README.md` | **Modify** | Add Windows badge, PowerShell install, update table, fix EOF newline | + +--- + +## Chunk 1: Binary resolution + spawn fixes + +### Task 1: Binary resolution module + +**Files:** +- Create: `src/utils/binary.ts` +- Create: `tests/unit/utils/binary.test.ts` + +- [ ] **Step 1: Create `tests/unit/utils/binary.test.ts` with all 9 failing tests** + +```typescript +// tests/unit/utils/binary.test.ts +import { describe, test, expect } from "bun:test"; +import { resolveCursorAgentBinary } from "../../../src/utils/binary.js"; + +const neverExists = () => false; + +describe("resolveCursorAgentBinary", () => { + test("env override takes priority and skips filesystem checks", () => { + const result = resolveCursorAgentBinary({ + env: { CURSOR_AGENT_EXECUTABLE: "/custom/cursor-agent" }, + existsSync: neverExists, + }); + expect(result).toBe("/custom/cursor-agent"); + }); + + test("empty env override falls through to platform logic", () => { + const result = resolveCursorAgentBinary({ + platform: "linux", + env: { CURSOR_AGENT_EXECUTABLE: "" }, + existsSync: neverExists, + homedir: () => "/home/user", + }); + expect(result).toBe("cursor-agent"); + }); + + test("win32: known path exists -> returns full .cmd path", () => { + const result = resolveCursorAgentBinary({ + platform: "win32", + env: { LOCALAPPDATA: "C:\\Users\\user\\AppData\\Local" }, + existsSync: (p) => p.endsWith("cursor-agent.cmd"), + homedir: () => "C:\\Users\\user", + }); + expect(result).toBe("C:\\Users\\user\\AppData\\Local\\cursor-agent\\cursor-agent.cmd"); + }); + + test("win32: known path missing -> falls back to bare cursor-agent.cmd", () => { + const result = resolveCursorAgentBinary({ + platform: "win32", + env: { LOCALAPPDATA: "C:\\Users\\user\\AppData\\Local" }, + existsSync: neverExists, + homedir: () => "C:\\Users\\user", + }); + expect(result).toBe("cursor-agent.cmd"); + }); + + test("win32: LOCALAPPDATA missing -> constructs from homedir, falls back to bare", () => { + const result = resolveCursorAgentBinary({ + platform: "win32", + env: {}, + existsSync: neverExists, + homedir: () => "C:\\Users\\user", + }); + expect(result).toBe("cursor-agent.cmd"); + }); + + test("linux: first known path exists -> returns ~/.cursor-agent path", () => { + const result = resolveCursorAgentBinary({ + platform: "linux", + env: {}, + existsSync: (p) => p.includes(".cursor-agent"), + homedir: () => "/home/user", + }); + expect(result).toBe("/home/user/.cursor-agent/cursor-agent"); + }); + + test("linux: first missing, second exists -> returns /usr/local/bin path", () => { + const result = resolveCursorAgentBinary({ + platform: "linux", + env: {}, + existsSync: (p) => p === "/usr/local/bin/cursor-agent", + homedir: () => "/home/user", + }); + expect(result).toBe("/usr/local/bin/cursor-agent"); + }); + + test("linux: neither path exists -> falls back to bare cursor-agent", () => { + const result = resolveCursorAgentBinary({ + platform: "linux", + env: {}, + existsSync: neverExists, + homedir: () => "/home/user", + }); + expect(result).toBe("cursor-agent"); + }); + + test("darwin: neither path exists -> falls back to cursor-agent (not .cmd)", () => { + const result = resolveCursorAgentBinary({ + platform: "darwin", + env: {}, + existsSync: neverExists, + homedir: () => "/Users/user", + }); + expect(result).toBe("cursor-agent"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they all fail (module not found)** + +```bash +cd /home/nomadx/opencode-cursor +bun test tests/unit/utils/binary.test.ts 2>&1 | head -20 +``` + +Expected: `Cannot find module '../../../src/utils/binary.js'` + +- [ ] **Step 3: Create `src/utils/binary.ts`** + +```typescript +// src/utils/binary.ts +import { existsSync as fsExistsSync } from "fs"; +import { join } from "path"; +import { homedir as osHomedir } from "os"; +import { createLogger } from "./logger.js"; + +const log = createLogger("binary"); + +export type BinaryDeps = { + platform?: NodeJS.Platform; + env?: Record; + existsSync?: (path: string) => boolean; + homedir?: () => string; +}; + +export function resolveCursorAgentBinary(deps: BinaryDeps = {}): string { + const platform = deps.platform ?? process.platform; + const env = deps.env ?? process.env; + const checkExists = deps.existsSync ?? fsExistsSync; + const home = (deps.homedir ?? osHomedir)(); + + const envOverride = env.CURSOR_AGENT_EXECUTABLE; + if (envOverride && envOverride.length > 0) { + return envOverride; + } + + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA ?? join(home, "AppData", "Local"); + const knownPath = join(localAppData, "cursor-agent", "cursor-agent.cmd"); + if (checkExists(knownPath)) { + return knownPath; + } + log.warn("cursor-agent not found at known Windows path, falling back to PATH", { checkedPath: knownPath }); + return "cursor-agent.cmd"; + } + + const knownPaths = [ + join(home, ".cursor-agent", "cursor-agent"), + "/usr/local/bin/cursor-agent", + ]; + for (const p of knownPaths) { + if (checkExists(p)) { + return p; + } + } + + log.warn("cursor-agent not found at known paths, falling back to PATH", { checkedPaths: knownPaths }); + return "cursor-agent"; +} +``` + +- [ ] **Step 4: Run tests to verify all 9 pass** + +```bash +bun test tests/unit/utils/binary.test.ts +``` + +Expected: `9 pass, 0 fail` + +- [ ] **Step 5: Run full unit test suite to confirm no regressions** + +```bash +bun test tests/unit/ 2>&1 | tail -5 +``` + +Expected: all existing tests still pass + +- [ ] **Step 6: Commit** + +```bash +git add src/utils/binary.ts tests/unit/utils/binary.test.ts +git commit -m "feat: add resolveCursorAgentBinary for cross-platform binary resolution" +``` + +--- + +### Task 2: Windows spawn compatibility + execFileSync fix + +**Files:** +- Modify: `src/auth.ts` +- Modify: `src/client/simple.ts` +- Modify: `src/cli/opencode-cursor.ts` +- Modify: `src/cli/model-discovery.ts` +- Modify: `src/models/discovery.ts` +- Modify: `src/tools/executors/cli.ts` +- Modify: `src/plugin.ts` (Node spawn + execSync→execFileSync + Bun.spawn binary) + +> **Context:** `Bun.spawn()` (used in `src/plugin.ts` lines ~573 and ~681, and `src/models/discovery.ts`) has a different API from Node's `spawn()` and handles `.cmd` files natively on Windows. Only Node `spawn()` calls need `shell: process.platform === "win32"`. Do NOT add `shell` to Bun.spawn calls. + +- [ ] **Step 1: Update `src/auth.ts`** + +At line 4, add import: +```typescript +import { resolveCursorAgentBinary } from "./utils/binary.js"; +``` + +At line 78, change: +```typescript +// Before: +const proc = spawn("cursor-agent", ["login"], { + stdio: ["pipe", "pipe", "pipe"], +}); + +// After: +const proc = spawn(resolveCursorAgentBinary(), ["login"], { + stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32", +}); +``` + +- [ ] **Step 2: Update `src/client/simple.ts`** + +Add import at top (near other imports): +```typescript +import { resolveCursorAgentBinary } from '../utils/binary.js'; +``` + +In the constructor config default, change: +```typescript +// Before: +cursorAgentPath: process.env.CURSOR_AGENT_EXECUTABLE || 'cursor-agent', + +// After: +cursorAgentPath: resolveCursorAgentBinary(), +``` + +At line ~79 (`executePromptStream` spawn): +```typescript +// Before: +const child = spawn(this.config.cursorAgentPath, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'] +}); + +// After: +const child = spawn(this.config.cursorAgentPath, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + shell: process.platform === 'win32', +}); +``` + +At line ~190 (`executePrompt` spawn): +```typescript +// Before: +const child = spawn(this.config.cursorAgentPath, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'] +}); + +// After: +const child = spawn(this.config.cursorAgentPath, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + shell: process.platform === 'win32', +}); +``` + +- [ ] **Step 3: Update `src/cli/opencode-cursor.ts`** + +Add import (near other imports at top): +```typescript +import { resolveCursorAgentBinary } from "../utils/binary.js"; +``` + +Replace the entire `checkCursorAgent()` function: +```typescript +export function checkCursorAgent(): CheckResult { + const binary = resolveCursorAgentBinary(); + try { + const output = execFileSync(binary, ["--version"], { encoding: "utf8" }).trim(); + const version = output.split("\n")[0] || "installed"; + return { name: "cursor-agent", passed: true, message: version }; + } catch { + return { + name: "cursor-agent", + passed: false, + message: "not found - install with: curl -fsS https://cursor.com/install | bash", + }; + } +} +``` + +Also update `checkCursorAgentLogin()` at line ~89: +```typescript +// Before: +execFileSync("cursor-agent", ["models"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + +// After: +execFileSync(resolveCursorAgentBinary(), ["models"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +``` + +- [ ] **Step 4: Update `src/cli/model-discovery.ts`** + +Add import: +```typescript +import { resolveCursorAgentBinary } from "../utils/binary.js"; +``` + +Update the `execFileSync` call in `discoverModelsFromCursorAgent()`: +```typescript +// Before: +const raw = execFileSync("cursor-agent", ["models"], { + encoding: "utf8", + killSignal: "SIGTERM", + stdio: ["ignore", "pipe", "pipe"], + timeout: MODEL_DISCOVERY_TIMEOUT_MS, +}); + +// After: +const raw = execFileSync(resolveCursorAgentBinary(), ["models"], { + encoding: "utf8", + ...(process.platform !== "win32" && { killSignal: "SIGTERM" as const }), + stdio: ["ignore", "pipe", "pipe"], + timeout: MODEL_DISCOVERY_TIMEOUT_MS, +}); +``` + +- [ ] **Step 5: Update `src/models/discovery.ts`** + +Add import: +```typescript +import { resolveCursorAgentBinary } from "../utils/binary.js"; +``` + +Update `queryViaCLI()` at line ~52: +```typescript +// Before: +const proc = bunAny.spawn(["cursor-agent", "models", "--json"], { + +// After: +const proc = bunAny.spawn([resolveCursorAgentBinary(), "models", "--json"], { +``` + +Update `queryViaHelp()` at line ~79: +```typescript +// Before: +const proc = bunAny.spawn(["cursor-agent", "--help"], { + +// After: +const proc = bunAny.spawn([resolveCursorAgentBinary(), "--help"], { +``` + +- [ ] **Step 6: Update `src/tools/executors/cli.ts`** + +```typescript +// Before: +const child = spawn("opencode", ["tool", "run", toolId, "--json", JSON.stringify(args)], { + stdio: ["ignore", "pipe", "pipe"], +}); + +// After: +const child = spawn("opencode", ["tool", "run", toolId, "--json", JSON.stringify(args)], { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", +}); +``` + +- [ ] **Step 7: Update `src/plugin.ts` — import + Bun.spawn binary args + Node spawn shell flag + execSync fix** + +Add import (near other imports at top): +```typescript +import { resolveCursorAgentBinary } from "./utils/binary.js"; +``` + +Update Bun.spawn in the `/v1/models` handler (line ~573): +```typescript +// Before: +const proc = bunAny.Bun.spawn(["cursor-agent", "models"], { + +// After: +const proc = bunAny.Bun.spawn([resolveCursorAgentBinary(), "models"], { +``` + +Update Bun.spawn in the main request handler (line ~681): +```typescript +// Before: +const proc = bunAny.Bun.spawn(["cursor-agent", "--print", ... + +// Note: this is an object-syntax Bun.spawn call. Update the cmd array: +// Before (inside the cmd array): +"cursor-agent", + +// After: +resolveCursorAgentBinary(), +``` + +Fix the Node models endpoint `execSync` → `execFileSync` (line ~1058): +```typescript +// Before: +const { execSync } = await import("child_process"); +const output = execSync("cursor-agent models", { encoding: "utf-8", timeout: 30000 }); + +// After: +const { execFileSync } = await import("child_process"); +const output = execFileSync(resolveCursorAgentBinary(), ["models"], { encoding: "utf-8", timeout: 30000 }); +``` + +Update the Node path `spawn` call (line ~1140): +```typescript +// Before: +const child = spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "pipe", "pipe"] }); + +// After: +const child = spawn(cmd[0], cmd.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32", +}); +``` + +Also update the `cmd` array construction to use `resolveCursorAgentBinary()` instead of `"cursor-agent"` (there are two `cmd` arrays in the Node handler — around lines ~1119 and ~1655). Look for `"cursor-agent", "--print"` and replace the first element. + +Also update the Bun path `/v1/models` Bun.spawn for model discovery (line ~560): +```typescript +// Before: +const proc = bunAny.Bun.spawn(["cursor-agent", "models"], { + +// After: +const proc = bunAny.Bun.spawn([resolveCursorAgentBinary(), "models"], { +``` + +Also update the `proxyBaseURL` call near line ~2015 in the plugin registration that passes `"cursor-agent"` as a string argument to a helper function — change to `resolveCursorAgentBinary()`. + +- [ ] **Step 8: Run full unit test suite** + +```bash +bun test tests/unit/ 2>&1 | tail -10 +``` + +Expected: all tests pass + +- [ ] **Step 9: Run build to check TypeScript compilation** + +```bash +bun run build 2>&1 | tail -10 +``` + +Expected: no errors + +- [ ] **Step 10: Commit** + +```bash +git add src/auth.ts src/client/simple.ts src/cli/opencode-cursor.ts src/cli/model-discovery.ts src/models/discovery.ts src/tools/executors/cli.ts src/plugin.ts +git commit -m "feat: add Windows spawn compatibility and centralise binary resolution" +``` + +--- + +## Chunk 2: Path comparison fix + Node fallback tools + +### Task 3: Windows path comparison fix + +**Files:** +- Modify: `src/plugin.ts` (one line in `canonicalizePathForCompare`) +- Modify: `tests/unit/plugin-proxy-reuse.test.ts` (add 2 cases) + +- [ ] **Step 1: Add 2 tests to `tests/unit/plugin-proxy-reuse.test.ts`** + +Append inside the `describe("proxy health reuse guard", ...)` block: + +```typescript + test("normalizeWorkspaceForCompare produces consistent results for the same input", () => { + // The one-line fix adds win32 to the toLowerCase() branch in canonicalizePathForCompare. + // The toLowerCase() branch cannot be exercised from Linux CI (process.platform !== "win32"). + // This test validates the stable cross-platform contract: same path → same normalized form. + const workspace = process.cwd(); // use a real path so realpathSync.native succeeds + const a = normalizeWorkspaceForCompare(workspace); + const b = normalizeWorkspaceForCompare(workspace); + expect(a).toBe(b); + expect(typeof a).toBe("string"); + expect(a.length).toBeGreaterThan(0); + }); + + test("rejects workspace mismatch after normalisation", () => { + expect( + isReusableProxyHealthPayload( + { ok: true, workspaceDirectory: "/tmp/project-a" }, + "/tmp/project-b", + ), + ).toBe(false); + }); +``` + +> **Note on Windows coverage:** The `toLowerCase()` for `win32` in `canonicalizePathForCompare` cannot be exercised from Linux CI since `process.platform` is always `"linux"` there. The fix is a one-liner and its correctness is verified by code review. End-to-end validation requires a Windows runner. + +- [ ] **Step 2: Run tests to verify they pass (these test existing logic)** + +```bash +bun test tests/unit/plugin-proxy-reuse.test.ts +``` + +Expected: all 7 tests pass (the 2 new ones test invariants that already hold) + +- [ ] **Step 3: Apply the one-line fix in `src/plugin.ts`** + +Find `canonicalizePathForCompare` (line ~194): + +```typescript +// Before: + if (process.platform === "darwin") { + return normalizedPath.toLowerCase(); + } + +// After: + if (process.platform === "darwin" || process.platform === "win32") { + return normalizedPath.toLowerCase(); + } +``` + +- [ ] **Step 4: Run tests again to confirm still passing** + +```bash +bun test tests/unit/plugin-proxy-reuse.test.ts +``` + +Expected: all 8 tests pass + +- [ ] **Step 5: Run full unit suite** + +```bash +bun test tests/unit/ 2>&1 | tail -5 +``` + +Expected: all pass + +- [ ] **Step 6: Commit** + +```bash +git add src/plugin.ts tests/unit/plugin-proxy-reuse.test.ts +git commit -m "fix: canonicalizePathForCompare case-insensitive on win32 for correct multi-instance proxy reuse" +``` + +--- + +### Task 4: Node fallback grep/glob + +**Files:** +- Modify: `src/tools/defaults.ts` (add exported fallback functions + win32 guards) +- Create: `tests/tools/node-fallbacks.test.ts` + +> **Context:** The grep tool handler is registered around line ~259 in `defaults.ts`. The glob tool handler is around line ~365. Both currently use `execFile("grep", ...)` and `execFile("find", ...)` respectively. We add a Windows guard at the top of each handler that calls our new fallback functions. The fallback functions live at the bottom of the file and are exported. + +- [ ] **Step 1: Create `tests/tools/node-fallbacks.test.ts` with all 12 failing tests** + +```typescript +// tests/tools/node-fallbacks.test.ts +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { nodeFallbackGrep, nodeFallbackGlob } from "../../src/tools/defaults.js"; + +let tmpDir: string; + +beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), "fallback-test-")); + + // Create structure: + // tmpDir/ + // a.ts (contains "hello world") + // b.ts (contains "goodbye world") + // sub/ + // c.ts (contains "hello again") + // d.js (contains "irrelevant") + // node_modules/ + // pkg/ + // e.ts (contains "hello hidden" — should be SKIPPED) + + writeFileSync(join(tmpDir, "a.ts"), "hello world\nfoo bar\n"); + writeFileSync(join(tmpDir, "b.ts"), "goodbye world\n"); + mkdirSync(join(tmpDir, "sub")); + writeFileSync(join(tmpDir, "sub", "c.ts"), "hello again\n"); + writeFileSync(join(tmpDir, "sub", "d.js"), "irrelevant\n"); + mkdirSync(join(tmpDir, "node_modules", "pkg"), { recursive: true }); + writeFileSync(join(tmpDir, "node_modules", "pkg", "e.ts"), "hello hidden\n"); +}); + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +// --- nodeFallbackGrep --- + +describe("nodeFallbackGrep", () => { + test("finds match in a single file", async () => { + const result = await nodeFallbackGrep("hello", join(tmpDir, "a.ts")); + expect(result).toContain("hello world"); + expect(result).toContain("a.ts:1:"); + }); + + test("finds matches across directory tree", async () => { + const result = await nodeFallbackGrep("hello", tmpDir); + expect(result).toContain("a.ts"); + expect(result).toContain("sub"); + // Both a.ts and sub/c.ts contain "hello" + const lines = result.split("\n").filter(Boolean); + expect(lines.length).toBeGreaterThanOrEqual(2); + }); + + test("returns No matches found when pattern does not match", async () => { + const result = await nodeFallbackGrep("zzznomatch", tmpDir); + expect(result).toBe("No matches found"); + }); + + test("returns Invalid regex pattern for bad regex", async () => { + const result = await nodeFallbackGrep("[unclosed", tmpDir); + expect(result).toBe("Invalid regex pattern"); + }); + + test("include filter restricts to matching filenames", async () => { + const result = await nodeFallbackGrep("hello", tmpDir, "*.ts"); + // Should match a.ts and sub/c.ts but NOT sub/d.js + expect(result).not.toContain("d.js"); + expect(result).toContain(".ts"); + }); + + test("skips node_modules directory", async () => { + const result = await nodeFallbackGrep("hello", tmpDir); + // e.ts inside node_modules should NOT appear + expect(result).not.toContain("node_modules"); + }); + + test("returns Path not found for non-existent path", async () => { + const result = await nodeFallbackGrep("hello", join(tmpDir, "nonexistent")); + expect(result).toBe("Path not found"); + }); +}); + +// --- nodeFallbackGlob --- + +describe("nodeFallbackGlob", () => { + test("*.ts pattern matches only .ts files in root", async () => { + const result = await nodeFallbackGlob("*.ts", tmpDir); + const files = result.split("\n").filter(Boolean); + expect(files.some(f => f.endsWith("a.ts"))).toBe(true); + expect(files.some(f => f.endsWith("b.ts"))).toBe(true); + // d.js should NOT appear + expect(files.some(f => f.endsWith("d.js"))).toBe(false); + }); + + test("**/*.ts pattern matches .ts files in subdirectories", async () => { + const result = await nodeFallbackGlob("**/*.ts", tmpDir); + const files = result.split("\n").filter(Boolean); + expect(files.some(f => f.includes("sub") && f.endsWith("c.ts"))).toBe(true); + }); + + test("returns No files found when pattern does not match", async () => { + const result = await nodeFallbackGlob("*.xyz", tmpDir); + expect(result).toBe("No files found"); + }); + + test("skips node_modules directory", async () => { + const result = await nodeFallbackGlob("**/*.ts", tmpDir); + expect(result).not.toContain("node_modules"); + }); + + test("returns No files found for non-existent search path", async () => { + const result = await nodeFallbackGlob("*.ts", join(tmpDir, "nonexistent")); + expect(result).toBe("No files found"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they all fail (functions not exported yet)** + +```bash +bun test tests/tools/node-fallbacks.test.ts 2>&1 | head -20 +``` + +Expected: import errors or `nodeFallbackGrep is not a function` + +- [ ] **Step 3: Add `nodeFallbackGrep` and `nodeFallbackGlob` to the bottom of `src/tools/defaults.ts`** + +Append at the very end of the file (after the existing `getDefaultToolNames` function): + +```typescript +const FALLBACK_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); +const fallbackLog = createLogger("tools:fallback"); + +export async function nodeFallbackGrep( + pattern: string, + searchPath: string, + include?: string, +): Promise { + const fs = await import("fs/promises"); + const path = await import("path"); + + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch { + 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}$`); + } + + const results: string[] = []; + + async function walk(dir: string): Promise { + if (results.length >= 100) return; + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err: any) { + if (err?.code !== "ENOENT" && err?.code !== "EACCES") { + fallbackLog.error("Unexpected error reading directory", { dir, code: err?.code, message: err?.message }); + } + return; + } + for (const entry of entries) { + if (results.length >= 100) return; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!FALLBACK_SKIP_DIRS.has(entry.name)) { + await walk(fullPath); + } + } else if (entry.isFile()) { + if (includeRegex && !includeRegex.test(entry.name)) continue; + let content: string; + try { + content = await fs.readFile(fullPath, "utf-8"); + } catch (err: any) { + if (err?.code !== "ENOENT" && err?.code !== "EACCES") { + fallbackLog.error("Unexpected error reading file", { path: fullPath, code: err?.code, message: err?.message }); + } + continue; + } + 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; + } + } + } + } + } + + let stat; + try { + stat = await fs.stat(searchPath); + } catch { + return "Path not found"; + } + + if (stat.isFile()) { + let content: string; + try { + content = await fs.readFile(searchPath, "utf-8"); + } catch (err: any) { + fallbackLog.error("Unexpected error reading file", { path: searchPath, code: err?.code, message: err?.message }); + return "Path not found"; + } + 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; + } + } + } else { + await walk(searchPath); + } + + return results.join("\n") || "No matches found"; +} + +export 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("/"); + + // Handle ** before * so double-star → .* and single-star → [^/]* + let regexPattern = pattern + .replace(/\./g, "\\.") + .replace(/\*\*/g, "\x00") // placeholder for ** + .replace(/\*/g, "[^/]*") + .replace(/\x00/g, ".*"); // restore ** as .* + + let regex: RegExp; + try { + regex = isPathPattern + ? new RegExp(`${regexPattern}$`) + : new RegExp(`^${regexPattern}$`); + } catch { + return "No files found"; + } + + async function walk(dir: string): Promise { + if (results.length >= 50) return; + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err: any) { + if (err?.code !== "ENOENT" && err?.code !== "EACCES") { + fallbackLog.error("Unexpected error reading directory", { dir, code: err?.code, message: err?.message }); + } + return; + } + for (const entry of entries) { + if (results.length >= 50) return; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!FALLBACK_SKIP_DIRS.has(entry.name)) { + await walk(fullPath); + } + } else if (entry.isFile()) { + const matchTarget = isPathPattern + ? fullPath.replace(/\\/g, "/") + : entry.name; + if (regex.test(matchTarget)) { + results.push(fullPath); + } + } + } + } + + await walk(searchPath); + return results.join("\n") || "No files found"; +} +``` + +- [ ] **Step 4: Add win32 guards to grep and glob handlers in `src/tools/defaults.ts`** + +In the grep handler (around line ~268, just before `const grepArgs = ["-r", "-n"];`): +```typescript + if (process.platform === "win32") { + return nodeFallbackGrep(pattern, path, include); + } +``` + +In the glob handler (around line ~376, just before `const isPathPattern = normalizedPattern.includes("/");`): +```typescript + if (process.platform === "win32") { + return nodeFallbackGlob(normalizedPattern, cwd); + } +``` + +- [ ] **Step 5: Run fallback tests** + +```bash +bun test tests/tools/node-fallbacks.test.ts +``` + +Expected: `12 pass, 0 fail` + +- [ ] **Step 6: Run full test suite** + +```bash +bun test tests/ 2>&1 | tail -10 +``` + +Expected: all pass + +- [ ] **Step 7: Commit** + +```bash +git add src/tools/defaults.ts tests/tools/node-fallbacks.test.ts +git commit -m "feat: add Windows-compatible Node.js fallback grep/glob for platforms without grep/find" +``` + +--- + +## Chunk 3: Miscellaneous + plugin-toggle + +### Task 5: Miscellaneous fixes, plugin-toggle, and README + +**Files:** +- Modify: `package.json` +- Modify: `src/plugin-toggle.ts` +- Modify: `tests/unit/plugin-toggle.test.ts` +- Modify: `README.md` + +- [ ] **Step 1: Revert `package.json` changes from PR #52** + +In `package.json`: +```json +// Before (PR introduced): +"type": "commonjs", + +// After (revert to original): +"type": "module", +``` + +Also fix the `prepublishOnly` script if it has a literal `\n` injected — it should be: +```json +"prepublishOnly": "bun run build" +``` + +(Check by looking at the current file — if `prepublishOnly` is correct already, skip this.) + +- [ ] **Step 2: Add provider detection to `src/plugin-toggle.ts`** + +In `isCursorPluginEnabledInConfig`, add the provider check block just before the existing `if (Array.isArray(configObject.plugin))` check: + +```typescript + if (configObject.provider && typeof configObject.provider === "object") { + if (CURSOR_PROVIDER_ID in (configObject.provider as Record)) { + return true; + } + } +``` + +Full updated function: +```typescript +export function isCursorPluginEnabledInConfig(config: unknown): boolean { + if (!config || typeof config !== "object") { + return true; + } + + 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)); + } + + return true; +} +``` + +- [ ] **Step 3: Add 2 tests to `tests/unit/plugin-toggle.test.ts`** + +Add inside the `describe("plugin toggle", ...)` block: + +```typescript + it("enables plugin when provider object contains cursor-acp key (no plugin array)", () => { + expect(isCursorPluginEnabledInConfig({ provider: { "cursor-acp": { model: "claude" } } })).toBe(true); + }); + + it("enables plugin via fallthrough when provider has only other providers (no plugin array)", () => { + // Fallthrough — no plugin array, no cursor-acp in provider, returns true by default + expect(isCursorPluginEnabledInConfig({ provider: { "other-provider": {} } })).toBe(true); + }); +``` + +- [ ] **Step 4: Run plugin-toggle tests** + +```bash +bun test tests/unit/plugin-toggle.test.ts +``` + +Expected: all tests pass (including the 2 new ones) + +- [ ] **Step 5: Update `README.md`** + +Make the following changes to `README.md`: + +1. Add Windows badge after the macOS badge in the badges section: +```html +Windows +``` + +2. Add Windows install section before the `
` block: +```markdown +**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 +``` +``` + +3. Update config path note in two places (Option B section and MCP section): +``` +~/.config/opencode/opencode.json (or %USERPROFILE%\.config\opencode\opencode.json on Windows) +``` + +4. Update the comparison table **Platform** row for open-cursor: +``` +Linux, macOS, Windows +``` + +5. Ensure file ends with a newline character. + +- [ ] **Step 6: Run full test suite one final time** + +```bash +bun test tests/ 2>&1 | tail -10 +``` + +Expected: all tests pass + +- [ ] **Step 7: Run build** + +```bash +bun run build 2>&1 | tail -5 +``` + +Expected: no errors + +- [ ] **Step 8: Commit** + +```bash +git add package.json src/plugin-toggle.ts tests/unit/plugin-toggle.test.ts README.md +git commit -m "feat: add Windows platform support (binary resolution, spawn, path compare, fallback tools)" +``` + +--- + +## Final verification + +- [ ] Run the full CI test suite: + +```bash +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 2>&1 | tail -10 +``` + +Expected: all pass + +- [ ] Verify `bun run build` produces no errors diff --git a/docs/superpowers/specs/2026-03-17-windows-support-design.md b/docs/superpowers/specs/2026-03-17-windows-support-design.md new file mode 100644 index 0000000..9f63ea4 --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-windows-support-design.md @@ -0,0 +1,178 @@ +# Windows Support + Multi-Instance Workspace Fix — Design Spec + +**Date:** 2026-03-17 +**PR reference:** #52 (intisy / Finn Birich) — cherry-picking useful parts, rewriting broken parts +**Issue:** Windows platform support + proxy reuse regression on Windows multi-instance setups + +--- + +## Problem + +PR #52 introduces Windows support with several critical bugs: + +1. `package.json` `"type"` changed to `"commonjs"` — breaks all ESM consumers of the dist +2. `isReusableProxyHealthPayload` gutted to `return true` — cross-workspace proxy hijack regression, breaks 3 existing tests +3. `execSync(binary + " models")` string concat — command injection vector on paths with spaces +4. `nodeFallbackGrep`/`nodeFallbackGlob` have broken regex escaping, literal `\n` strings instead of newlines, and empty `catch {}` blocks +5. `chat.headers` hook used — does not exist in the OpenCode plugin SDK (`Hooks` interface only defines `event`, `auth`, `chat.message`, `chat.params`, `permission.ask`, `tool.execute.before`, `tool.execute.after`) +6. Zero test coverage on all new code + +The actual multi-instance workspace bug on Windows is caused by `canonicalizePathForCompare` not lowercasing on `win32` (Windows filesystem is case-insensitive). The one-line fix is sufficient — no proxy architecture changes required. + +--- + +## Approach: Simple fix (Option A) + +Each OpenCode instance owns its own proxy. The workspace guard in `isReusableProxyHealthPayload` stays intact. With path comparison fixed for Windows, the existing multi-instance logic works correctly on all platforms. + +Rejected: "shared proxy with per-request workspace header" (Option B) — requires `chat.headers` hook which does not exist in the SDK, and adds unsanitised header → CLI arg attack surface. + +--- + +## Components + +### 1. Binary resolution — `src/utils/binary.ts` (new file, borrow from PR) + +Centralises cursor-agent binary resolution. Called from all spawn/exec sites instead of hardcoding `"cursor-agent"`. + +**Resolution priority:** +1. `CURSOR_AGENT_EXECUTABLE` env var (if non-empty) +2. Windows: `%LOCALAPPDATA%\cursor-agent\cursor-agent.cmd` if exists +3. Unix: `~/.cursor-agent/cursor-agent` if exists, then `/usr/local/bin/cursor-agent` if exists +4. Fallback: bare `"cursor-agent.cmd"` on Windows, `"cursor-agent"` on Unix (PATH lookup) + +**Requirements:** +- Import `createLogger("binary")` and log at `warn` level whenever a fallback path is taken, including which known path was checked and missed +- Pure function, no side effects beyond `existsSync` calls + +### 2. Windows spawn compatibility + +Add `shell: process.platform === "win32"` to every **Node.js** `spawn()` call that invokes cursor-agent or opencode: +- `src/auth.ts` — `spawn(resolveCursorAgentBinary(), ["login"], ...)` +- `src/client/simple.ts` — two `spawn()` calls +- `src/plugin.ts` — one `spawn()` call in the Node handler (line ~1140) +- `src/tools/executors/cli.ts` — `spawn("opencode", ...)` + +Note: `src/plugin.ts` also contains `Bun.spawn()` calls (Bun path, lines ~573 and ~681). `Bun.spawn()` has a different API and does not take a `shell` option. Bun handles `.cmd` files natively on Windows — no changes needed to the Bun spawn calls. + +Fix command injection in `src/plugin.ts` Node models endpoint: +```typescript +// Before (vulnerable): +execSync(resolveCursorAgentBinary() + " models", { encoding: "utf-8", timeout: 30000 }) + +// After (safe): +execFileSync(resolveCursorAgentBinary(), ["models"], { encoding: "utf-8", timeout: 30000 }) +``` + +Verify `stdio: ["ignore", "pipe", "pipe"]` remains in `src/cli/model-discovery.ts` (PR dropped it; current `main` already has it restored — ensure it is not dropped again). Make `killSignal` platform-conditional: +```typescript +killSignal: process.platform === "win32" ? undefined : "SIGTERM" +``` + +### 3. Windows path comparison — the actual multi-instance fix + +**One line change** in `canonicalizePathForCompare` in `src/plugin.ts`: + +```typescript +// Before: +if (process.platform === "darwin") { + return normalizedPath.toLowerCase(); +} + +// After: +if (process.platform === "darwin" || process.platform === "win32") { + return normalizedPath.toLowerCase(); +} +``` + +`resolve()` and `realpathSync.native` are already platform-aware and normalise slash direction on Windows. Lowercasing handles case-insensitive filesystem comparison. No other changes to path comparison logic. + +`isReusableProxyHealthPayload` is **not modified**. + +### 4. Node fallback grep/glob — `src/tools/defaults.ts` (rewrite) + +Windows lacks `grep` and `find`. The PR's direction is correct but the implementation is buggy. + +**`nodeFallbackGrep(pattern, searchPath, include?)`** +- Export the function for direct unit testing +- Regex construction: `new RegExp(pattern)` first; on failure, escape with `/[.*+?^${}()|[\]\\]/g` → `'\\$&'` (standard JS metachar escape) +- Line splitting: `content.split('\n')` (newline char, not the two-char string `\n`) +- `include` filter: escape literal dots with `/\./g` → `'\\.'`, then `*` → `.*` +- Catch blocks: distinguish `ENOENT`/`EACCES` (skip silently) from unexpected errors (log via `createLogger("tools:fallback")` at `error` level). No empty `catch {}` blocks. +- 100-result cap, skip `node_modules`, `.git`, `dist`, `build` + +**`nodeFallbackGlob(pattern, searchPath)`** +- Export the function for direct unit testing +- Handle `**` before `*` in pattern transformation so `**` → `.*` and `*` → `[^/]*` +- Backslash normalisation: `fullPath.replace(/\\/g, '/')` (single backslash regex `/\\/g`) +- Catch blocks: same discriminated pattern as grep fallback +- 50-result cap, same directory skip list + +Both functions are only called when `process.platform === "win32"` — the existing `grep`/`find` paths are unchanged on Linux/macOS. + +### 5. Miscellaneous + +**`package.json`:** +- Revert `"type"` back to `"module"` +- Revert `prepublishOnly` script (PR injected a literal `\n` into it) +- Do not bump version (that happens at release) + +**`src/plugin-toggle.ts`:** +- Keep the provider detection addition from PR (clean, correct, low risk) + +**`src/cli/opencode-cursor.ts` — `checkCursorAgent()`:** +- Call `resolveCursorAgentBinary()` once into a `binary` local variable +- Use `binary` for `execFileSync` call +- Keep hardcoded `"cursor-agent"` string as the display `name` in both return paths + +**`README.md`:** +- Keep Windows badge, PowerShell install snippet, updated comparison table platform column +- Restore missing newline at end of file + +--- + +## Tests + +### `tests/unit/utils/binary.test.ts` (new) + +Mock `existsSync` and `process.platform`. Cases: +- `CURSOR_AGENT_EXECUTABLE` set → returns env value without filesystem check +- `CURSOR_AGENT_EXECUTABLE` empty string → falls through to platform logic +- `win32` + known path exists → returns `.cmd` path +- `win32` + known path missing → returns `"cursor-agent.cmd"` +- `win32` + `LOCALAPPDATA` env missing → constructs fallback from `homedir()` +- Linux + first known path exists → returns that path +- Linux + first missing, second exists → returns second path +- Linux + neither exists → returns `"cursor-agent"` +- macOS + neither exists → returns `"cursor-agent"` (not `"cursor-agent.cmd"`) + +### `tests/tools/node-fallbacks.test.ts` (new) + +Test `nodeFallbackGrep` and `nodeFallbackGlob` against a real temp directory (no mocks needed — pure Node fs): +- `nodeFallbackGrep`: match in single file, match across tree, no match, invalid regex, include filter, `node_modules` skipped, 100-result cap +- `nodeFallbackGlob`: `*.ts` pattern, `**/*.ts` pattern, no match, 50-result cap, `node_modules` skipped + +### `tests/unit/plugin-proxy-reuse.test.ts` (additions) + +- Windows-style backslash path and forward-slash equivalent compare as equal +- Windows paths with different cases compare as equal +- Mixed case + backslash path compares equal to lowercase forward-slash equivalent + +### `tests/unit/plugin-toggle.test.ts` (additions) + +- `{ provider: { "cursor-acp": {} } }` with no `plugin` key → `true` (provider branch fires) +- `{ provider: { "other-provider": {} } }` with no `plugin` key → `true` (fallthrough, not provider branch) + +--- + +## Credit + +Commit message includes `Co-authored-by: Finn Birich `. PR description credits @intisy and references PR #52. + +--- + +## Out of scope + +- Shared proxy / per-request workspace header (Option B) — deferred, requires SDK hook verification first +- Windows installer script (`install.ps1`) — separate concern, not part of plugin code +- Any changes to the tool loop guard or schema injection (separate PR #51 work) diff --git a/package-lock.json b/package-lock.json index 87d3adc..c6fce48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rama_nigg/open-cursor", - "version": "2.3.19", + "version": "2.3.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rama_nigg/open-cursor", - "version": "2.3.19", + "version": "2.3.20", "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/src/auth.ts b/src/auth.ts index 776ea2c..a97b8ce 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.js"; 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..f5ced4e 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,9 +32,9 @@ 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", + ...(process.platform !== "win32" && { killSignal: "SIGTERM" as const }), stdio: ["ignore", "pipe", "pipe"], timeout: MODEL_DISCOVERY_TIMEOUT_MS, }); diff --git a/src/cli/opencode-cursor.ts b/src/cli/opencode-cursor.ts index a21188a..8f9e52a 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,7 +71,7 @@ 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 }; } catch { @@ -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..dd11588 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..f4f81c4 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)); } @@ -63,7 +69,7 @@ export function shouldEnableCursorPlugin(env: EnvLike = process.env): { return { enabled, configPath, - reason: enabled ? "enabled_in_plugin_array_or_legacy" : "disabled_in_plugin_array", + reason: enabled ? "enabled" : "disabled_in_plugin_array", }; } catch { return { diff --git a/src/plugin.ts b/src/plugin.ts index ad3b2b2..600108f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -52,6 +52,7 @@ import { parseToolLoopMaxRepeat, type ToolLoopGuard, } from "./provider/tool-loop-guard.js"; +import { resolveCursorAgentBinary } from "./utils/binary.js"; const log = createLogger("plugin"); @@ -191,7 +192,7 @@ function canonicalizePathForCompare(pathValue: string): string { normalizedPath = resolvedPath; } - if (process.platform === "darwin") { + if (process.platform === "darwin" || process.platform === "win32") { return normalizedPath.toLowerCase(); } @@ -258,7 +259,11 @@ type ProxyRuntimeState = { }; export function normalizeWorkspaceForCompare(pathValue: string): string { - return resolve(pathValue); + const resolved = resolve(pathValue); + if (process.platform === "darwin" || process.platform === "win32") { + return resolved.toLowerCase(); + } + return resolved; } export function isReusableProxyHealthPayload(payload: any, workspaceDirectory: string): boolean { @@ -570,7 +575,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", }); @@ -664,7 +669,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } const cmd = [ - "cursor-agent", + resolveCursorAgentBinary(), "--print", "--output-format", "stream-json", @@ -1054,8 +1059,8 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: // Dynamic model discovery via cursor-agent models (Node.js handler) 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 { execFileSync } = await import("child_process"); + const output = execFileSync(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")) { @@ -1123,7 +1128,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: }); const cmd = [ - "cursor-agent", + resolveCursorAgentBinary(), "--print", "--output-format", "stream-json", @@ -1137,7 +1142,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); diff --git a/src/tools/defaults.ts b/src/tools/defaults.ts index 419602f..f03b16f 100644 --- a/src/tools/defaults.ts +++ b/src/tools/defaults.ts @@ -1,4 +1,5 @@ import type { ToolRegistry } from "./core/registry.js"; +import { createLogger } from "../utils/logger.js"; /** * Register default OpenCode tools in the registry @@ -265,6 +266,10 @@ export function registerDefaultTools(registry: ToolRegistry): void { const path = args.path as string; const include = args.include as string | undefined; + if (process.platform === "win32") { + return nodeFallbackGrep(pattern, path, include); + } + const grepArgs = ["-r", "-n"]; if (include) { grepArgs.push(`--include=${include}`); @@ -374,6 +379,11 @@ export function registerDefaultTools(registry: ToolRegistry): void { const path = resolvePathArg(args, "glob"); const cwd = path || "."; const normalizedPattern = pattern.replace(/\\/g, "/"); + + if (process.platform === "win32") { + return nodeFallbackGlob(normalizedPattern, cwd); + } + const isPathPattern = normalizedPattern.includes("/"); const findArgs = [cwd, "-type", "f"]; if (isPathPattern) { @@ -703,3 +713,159 @@ function coerceToString(value: unknown): string | null { export function getDefaultToolNames(): string[] { return ["bash", "read", "write", "edit", "grep", "ls", "glob", "mkdir", "rm", "stat"]; } + +const FALLBACK_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); +const fallbackLog = createLogger("tools:fallback"); + +export async function nodeFallbackGrep( + pattern: string, + searchPath: string, + include?: string, +): Promise { + const fs = await import("fs/promises"); + const path = await import("path"); + + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch { + return "Invalid regex pattern"; + } + + let includeRegex: RegExp | undefined; + if (include) { + const incPattern = include.replace(/\./g, "\\.").replace(/\?/g, ".").replace(/\*/g, ".*"); + includeRegex = new RegExp(`^${incPattern}$`); + } + + const results: string[] = []; + + async function walk(dir: string): Promise { + if (results.length >= 100) return; + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err: any) { + if (err?.code !== "ENOENT" && err?.code !== "EACCES") { + fallbackLog.error("Unexpected error reading directory", { dir, code: err?.code, message: err?.message }); + } + return; + } + for (const entry of entries) { + if (results.length >= 100) return; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!FALLBACK_SKIP_DIRS.has(entry.name)) { + await walk(fullPath); + } + } else if (entry.isFile()) { + if (includeRegex && !includeRegex.test(entry.name)) continue; + let content: string; + try { + content = await fs.readFile(fullPath, "utf-8"); + } catch (err: any) { + if (err?.code !== "ENOENT" && err?.code !== "EACCES") { + fallbackLog.error("Unexpected error reading file", { path: fullPath, code: err?.code, message: err?.message }); + } + continue; + } + 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; + } + } + } + } + } + + let stat; + try { + stat = await fs.stat(searchPath); + } catch { + return "Path not found"; + } + + if (stat.isFile()) { + let content: string; + try { + content = await fs.readFile(searchPath, "utf-8"); + } catch (err: any) { + if (err?.code !== "ENOENT" && err?.code !== "EACCES") { + fallbackLog.error("Unexpected error reading file", { path: searchPath, code: err?.code, message: err?.message }); + } + return "Path not found"; + } + 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; + } + } + } else { + await walk(searchPath); + } + + return results.join("\n") || "No matches found"; +} + +export 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("/"); + + // Handle ** before * so double-star → .* and single-star → [^/]* + let regexPattern = pattern + .replace(/\./g, "\\.") + .replace(/\*\*/g, "\x00") // placeholder for ** + .replace(/\*/g, "[^/]*") + .replace(/\x00/g, ".*"); // restore ** as .* + + let regex: RegExp; + try { + regex = isPathPattern + ? new RegExp(`${regexPattern}$`) + : new RegExp(`^${regexPattern}$`); + } catch { + return "No files found"; + } + + async function walk(dir: string): Promise { + if (results.length >= 50) return; + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err: any) { + if (err?.code !== "ENOENT" && err?.code !== "EACCES") { + fallbackLog.error("Unexpected error reading directory", { dir, code: err?.code, message: err?.message }); + } + return; + } + for (const entry of entries) { + if (results.length >= 50) return; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!FALLBACK_SKIP_DIRS.has(entry.name)) { + await walk(fullPath); + } + } else if (entry.isFile()) { + const matchTarget = isPathPattern + ? fullPath.replace(/\\/g, "/") + : entry.name; + if (regex.test(matchTarget)) { + results.push(fullPath); + } + } + } + } + + 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..e1944fb 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..a33b45e --- /dev/null +++ b/src/utils/binary.ts @@ -0,0 +1,50 @@ +// src/utils/binary.ts +import { existsSync as fsExistsSync } from "fs"; +import * as pathModule from "path"; +import { homedir as osHomedir } from "os"; +import { createLogger } from "./logger.js"; + +const log = createLogger("binary"); + +export type BinaryDeps = { + platform?: NodeJS.Platform; + env?: Record; + existsSync?: (path: string) => boolean; + homedir?: () => string; +}; + +export function resolveCursorAgentBinary(deps: BinaryDeps = {}): string { + const platform = deps.platform ?? process.platform; + const env = deps.env ?? process.env; + const checkExists = deps.existsSync ?? fsExistsSync; + const home = (deps.homedir ?? osHomedir)(); + + const envOverride = env.CURSOR_AGENT_EXECUTABLE; + if (envOverride && envOverride.length > 0) { + return envOverride; + } + + if (platform === "win32") { + const pathJoin = pathModule.win32.join; + const localAppData = env.LOCALAPPDATA ?? pathJoin(home, "AppData", "Local"); + const knownPath = pathJoin(localAppData, "cursor-agent", "cursor-agent.cmd"); + if (checkExists(knownPath)) { + return knownPath; + } + log.warn("cursor-agent not found at known Windows path, falling back to PATH", { checkedPath: knownPath }); + return "cursor-agent.cmd"; + } + + const knownPaths = [ + pathModule.join(home, ".cursor-agent", "cursor-agent"), + "/usr/local/bin/cursor-agent", + ]; + for (const p of knownPaths) { + if (checkExists(p)) { + return p; + } + } + + log.warn("cursor-agent not found at known paths, falling back to PATH", { checkedPaths: knownPaths }); + return "cursor-agent"; +} diff --git a/tests/tools/node-fallbacks.test.ts b/tests/tools/node-fallbacks.test.ts new file mode 100644 index 0000000..216bd08 --- /dev/null +++ b/tests/tools/node-fallbacks.test.ts @@ -0,0 +1,116 @@ +// tests/tools/node-fallbacks.test.ts +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { nodeFallbackGrep, nodeFallbackGlob } from "../../src/tools/defaults.js"; + +let tmpDir: string; + +beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), "fallback-test-")); + + // Create structure: + // tmpDir/ + // a.ts (contains "hello world") + // b.ts (contains "goodbye world") + // sub/ + // c.ts (contains "hello again") + // d.js (contains "irrelevant") + // node_modules/ + // pkg/ + // e.ts (contains "hello hidden" — should be SKIPPED) + + writeFileSync(join(tmpDir, "a.ts"), "hello world\nfoo bar\n"); + writeFileSync(join(tmpDir, "b.ts"), "goodbye world\n"); + mkdirSync(join(tmpDir, "sub")); + writeFileSync(join(tmpDir, "sub", "c.ts"), "hello again\n"); + writeFileSync(join(tmpDir, "sub", "d.js"), "irrelevant\n"); + mkdirSync(join(tmpDir, "node_modules", "pkg"), { recursive: true }); + writeFileSync(join(tmpDir, "node_modules", "pkg", "e.ts"), "hello hidden\n"); +}); + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +// --- nodeFallbackGrep --- + +describe("nodeFallbackGrep", () => { + test("finds match in a single file", async () => { + const result = await nodeFallbackGrep("hello", join(tmpDir, "a.ts")); + expect(result).toContain("hello world"); + expect(result).toContain("a.ts:1:"); + }); + + test("finds matches across directory tree", async () => { + const result = await nodeFallbackGrep("hello", tmpDir); + expect(result).toContain("a.ts"); + expect(result).toContain("sub"); + // Both a.ts and sub/c.ts contain "hello" + const lines = result.split("\n").filter(Boolean); + expect(lines.length).toBeGreaterThanOrEqual(2); + }); + + test("returns No matches found when pattern does not match", async () => { + const result = await nodeFallbackGrep("zzznomatch", tmpDir); + expect(result).toBe("No matches found"); + }); + + test("returns Invalid regex pattern for bad regex", async () => { + const result = await nodeFallbackGrep("[unclosed", tmpDir); + expect(result).toBe("Invalid regex pattern"); + }); + + test("include filter restricts to matching filenames", async () => { + const result = await nodeFallbackGrep("hello", tmpDir, "*.ts"); + // Should match a.ts and sub/c.ts but NOT sub/d.js + expect(result).not.toContain("d.js"); + expect(result).toContain(".ts"); + }); + + test("skips node_modules directory", async () => { + const result = await nodeFallbackGrep("hello", tmpDir); + // e.ts inside node_modules should NOT appear + expect(result).not.toContain("node_modules"); + }); + + test("returns Path not found for non-existent path", async () => { + const result = await nodeFallbackGrep("hello", join(tmpDir, "nonexistent")); + expect(result).toBe("Path not found"); + }); +}); + +// --- nodeFallbackGlob --- + +describe("nodeFallbackGlob", () => { + test("*.ts pattern matches only .ts files in root", async () => { + const result = await nodeFallbackGlob("*.ts", tmpDir); + const files = result.split("\n").filter(Boolean); + expect(files.some(f => f.endsWith("a.ts"))).toBe(true); + expect(files.some(f => f.endsWith("b.ts"))).toBe(true); + // d.js should NOT appear + expect(files.some(f => f.endsWith("d.js"))).toBe(false); + }); + + test("**/*.ts pattern matches .ts files in subdirectories", async () => { + const result = await nodeFallbackGlob("**/*.ts", tmpDir); + const files = result.split("\n").filter(Boolean); + expect(files.some(f => f.includes("sub") && f.endsWith("c.ts"))).toBe(true); + }); + + test("returns No files found when pattern does not match", async () => { + const result = await nodeFallbackGlob("*.xyz", tmpDir); + expect(result).toBe("No files found"); + }); + + test("skips node_modules directory", async () => { + const result = await nodeFallbackGlob("**/*.ts", tmpDir); + expect(result).not.toContain("node_modules"); + }); + + test("returns No files found for non-existent search path", async () => { + const result = await nodeFallbackGlob("*.ts", join(tmpDir, "nonexistent")); + expect(result).toBe("No files found"); + }); +}); diff --git a/tests/unit/plugin-proxy-reuse.test.ts b/tests/unit/plugin-proxy-reuse.test.ts index a6b9b5c..31a9eed 100644 --- a/tests/unit/plugin-proxy-reuse.test.ts +++ b/tests/unit/plugin-proxy-reuse.test.ts @@ -32,4 +32,24 @@ describe("proxy health reuse guard", () => { expect(normalized.length).toBeGreaterThan(0); }); + test("normalizeWorkspaceForCompare produces consistent results for the same input", () => { + // The win32 toLowerCase() branch cannot be exercised from Linux CI (process.platform !== "win32"). + // This test validates the cross-platform contract: same path → same normalized form. + const workspace = process.cwd(); + const a = normalizeWorkspaceForCompare(workspace); + const b = normalizeWorkspaceForCompare(workspace); + expect(a).toBe(b); + expect(typeof a).toBe("string"); + expect(a.length).toBeGreaterThan(0); + }); + + test("rejects workspace mismatch after normalisation", () => { + expect( + isReusableProxyHealthPayload( + { ok: true, workspaceDirectory: "/tmp/project-a" }, + "/tmp/project-b", + ), + ).toBe(false); + }); + }); diff --git a/tests/unit/plugin-toggle.test.ts b/tests/unit/plugin-toggle.test.ts index d735054..4a6c377 100644 --- a/tests/unit/plugin-toggle.test.ts +++ b/tests/unit/plugin-toggle.test.ts @@ -68,4 +68,14 @@ describe("plugin toggle", () => { rmSync(dir, { recursive: true, force: true }); } }); + + it("enables plugin via provider detection even when plugin array does not contain cursor-acp", () => { + // Provider detection fires before plugin array check — cursor-acp in provider overrides a restrictive plugin array + expect(isCursorPluginEnabledInConfig({ provider: { "cursor-acp": {} }, plugin: ["other-plugin"] })).toBe(true); + }); + + it("enables plugin via fallthrough when provider has only other providers (no plugin array)", () => { + // Fallthrough — no plugin array, no cursor-acp in provider, returns true by default + expect(isCursorPluginEnabledInConfig({ provider: { "other-provider": {} } })).toBe(true); + }); }); diff --git a/tests/unit/utils/binary.test.ts b/tests/unit/utils/binary.test.ts new file mode 100644 index 0000000..1c48f8a --- /dev/null +++ b/tests/unit/utils/binary.test.ts @@ -0,0 +1,95 @@ +// tests/unit/utils/binary.test.ts +import { describe, test, expect } from "bun:test"; +import { resolveCursorAgentBinary } from "../../../src/utils/binary.js"; + +const neverExists = () => false; + +describe("resolveCursorAgentBinary", () => { + test("env override takes priority and skips filesystem checks", () => { + const result = resolveCursorAgentBinary({ + env: { CURSOR_AGENT_EXECUTABLE: "/custom/cursor-agent" }, + existsSync: neverExists, + }); + expect(result).toBe("/custom/cursor-agent"); + }); + + test("empty env override falls through to platform logic", () => { + const result = resolveCursorAgentBinary({ + platform: "linux", + env: { CURSOR_AGENT_EXECUTABLE: "" }, + existsSync: neverExists, + homedir: () => "/home/user", + }); + expect(result).toBe("cursor-agent"); + }); + + test("win32: known path exists -> returns full .cmd path", () => { + const result = resolveCursorAgentBinary({ + platform: "win32", + env: { LOCALAPPDATA: "C:\\Users\\user\\AppData\\Local" }, + existsSync: (p) => p.endsWith("cursor-agent.cmd"), + homedir: () => "C:\\Users\\user", + }); + expect(result).toBe("C:\\Users\\user\\AppData\\Local\\cursor-agent\\cursor-agent.cmd"); + }); + + test("win32: known path missing -> falls back to bare cursor-agent.cmd", () => { + const result = resolveCursorAgentBinary({ + platform: "win32", + env: { LOCALAPPDATA: "C:\\Users\\user\\AppData\\Local" }, + existsSync: neverExists, + homedir: () => "C:\\Users\\user", + }); + expect(result).toBe("cursor-agent.cmd"); + }); + + test("win32: LOCALAPPDATA missing -> constructs from homedir, falls back to bare", () => { + const result = resolveCursorAgentBinary({ + platform: "win32", + env: {}, + existsSync: neverExists, + homedir: () => "C:\\Users\\user", + }); + expect(result).toBe("cursor-agent.cmd"); + }); + + test("linux: first known path exists -> returns ~/.cursor-agent path", () => { + const result = resolveCursorAgentBinary({ + platform: "linux", + env: {}, + existsSync: (p) => p.includes(".cursor-agent"), + homedir: () => "/home/user", + }); + expect(result).toBe("/home/user/.cursor-agent/cursor-agent"); + }); + + test("linux: first missing, second exists -> returns /usr/local/bin path", () => { + const result = resolveCursorAgentBinary({ + platform: "linux", + env: {}, + existsSync: (p) => p === "/usr/local/bin/cursor-agent", + homedir: () => "/home/user", + }); + expect(result).toBe("/usr/local/bin/cursor-agent"); + }); + + test("linux: neither path exists -> falls back to bare cursor-agent", () => { + const result = resolveCursorAgentBinary({ + platform: "linux", + env: {}, + existsSync: neverExists, + homedir: () => "/home/user", + }); + expect(result).toBe("cursor-agent"); + }); + + test("darwin: neither path exists -> falls back to cursor-agent (not .cmd)", () => { + const result = resolveCursorAgentBinary({ + platform: "darwin", + env: {}, + existsSync: neverExists, + homedir: () => "/Users/user", + }); + expect(result).toBe("cursor-agent"); + }); +});