diff --git a/README.md b/README.md
index 5e9dbfd..cb4ccbb 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
+
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
+
+```
+
+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");
+ });
+});