diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1911ed..2e98de1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,29 @@ jobs: - name: Typecheck run: npm run typecheck + + # The main matrix has no Windows runner and most of the suite assumes POSIX + # paths, so this job runs only the cursor-agent launch tests, which spawn a + # real node.exe through the installer layout (#27). + windows-bin: + name: windows-latest / node 22 / cursor-agent launch + runs-on: windows-latest + timeout-minutes: 10 + + defaults: + run: + working-directory: plugins/cursor + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22' + cache: npm + cache-dependency-path: plugins/cursor/package-lock.json + + - run: npm ci + + - name: Test + run: npx vitest run tests/windows-bin.test.mjs diff --git a/README.md b/README.md index b0a43e1..bbf9836 100644 --- a/README.md +++ b/README.md @@ -473,7 +473,7 @@ The task file stays in `tasks/` as a durable record — the contract between pla | Env var | Purpose | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `CURSOR_API_KEY` | Forwarded to `cursor-agent`. Optional — `cursor-agent login` is usually enough. | -| `CURSOR_AGENT_BIN` | Override binary path (used by the test suite). | +| `CURSOR_AGENT_BIN` | Override binary path (used by the test suite). On Windows it may point at the `cursor-agent.cmd`/`.ps1` shim; the install next to it is launched directly. | | `CURSOR_PLUGIN_CC_HOME` | Override the jobs-registry root. Default: an existing `~/.cursor-plugin-cc` if present, else Claude Code's plugin data dir (`CLAUDE_PLUGIN_DATA/state`), else `~/.cursor-plugin-cc`. | | `CURSOR_PLUGIN_CC_DEFAULT_MODEL` | Default `--model` when none is passed. Accepts the same aliases as `--model` (e.g. `composer`, `opus`). Falls back to `auto`. | diff --git a/plugins/cursor/scripts/lib/cursor.mjs b/plugins/cursor/scripts/lib/cursor.mjs index 3fc481e..919c91f 100644 --- a/plugins/cursor/scripts/lib/cursor.mjs +++ b/plugins/cursor/scripts/lib/cursor.mjs @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; -import { createWriteStream } from 'node:fs'; +import { createWriteStream, existsSync, readdirSync } from 'node:fs'; +import { join, win32 } from 'node:path'; import { createInterface } from 'node:readline'; import { parseLine } from './parse.mjs'; import { run } from './run.mjs'; @@ -75,29 +76,172 @@ export function resolveModel(input) { return MODEL_ALIASES[key] ?? input.trim(); } -/** @type {string|null} */ +/** + * How to launch cursor-agent: an executable plus the arguments that precede + * cursor-agent's own. `args` is empty except on Windows, where the CLI is + * `node.exe index.js` (see resolveWindowsInstall). + * + * @typedef {Object} CursorAgentBin + * @property {string} command + * @property {string[]} args + */ + +/** @type {CursorAgentBin|null} */ let cachedBin = null; /** - * @returns {Promise} + * @returns {Promise} */ export async function resolveBin() { if (cachedBin) return cachedBin; const override = process.env.CURSOR_AGENT_BIN?.trim(); if (override && override.length > 0) { - cachedBin = override; + cachedBin = (process.platform === 'win32' && resolveWindowsPath(override)) || { + command: override, + args: [], + }; + return cachedBin; + } + const found = process.platform === 'win32' ? await findOnWindows() : await findOnPath(); + if (found) { + cachedBin = found; return cachedBin; } + throw new Error( + 'cursor-agent not found on PATH. Install from https://cursor.com/install or run /cursor:setup.', + ); +} + +/** + * @param {CursorAgentBin} bin + * @returns {string} + */ +export function describeBin(bin) { + return [bin.command, ...bin.args].join(' '); +} + +/** + * Run cursor-agent with `args` through the resolved launcher. + * + * @param {string[]} args + * @param {import('./run.mjs').RunOpts} [opts] + * @returns {Promise} + */ +export async function runAgent(args, opts) { + const bin = await resolveBin(); + return run(bin.command, [...bin.args, ...args], opts); +} + +/** + * @returns {Promise} + */ +async function findOnPath() { for (const candidate of ['cursor-agent', 'agent']) { const res = await run('which', [candidate]); if (res.exitCode === 0 && res.stdout.trim()) { - cachedBin = res.stdout.trim(); - return cachedBin; + return { command: res.stdout.trim(), args: [] }; } } - throw new Error( - 'cursor-agent not found on PATH. Install from https://cursor.com/install or run /cursor:setup.', - ); + return null; +} + +/** + * Windows has no `which`, and the official installer puts only `.cmd`/`.ps1` + * shims on PATH. `where` finds a shim and the runnable CLI is resolved from + * the install beside it. The default install location is the fallback for a + * PATH inherited from before the install — the installer only updates PATH + * for shells started afterwards. + * + * Both `where` and spawn() look in the current directory before PATH, so a + * repository could plant its own `where.exe` or `cursor-agent.exe`. Hence the + * absolute `where.exe` and the `$PATH:` pattern, which searches PATH only. + * + * @returns {Promise} + */ +async function findOnWindows() { + const where = win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'where.exe'); + for (const candidate of ['cursor-agent', 'agent']) { + const res = await run(where, [`$PATH:${candidate}`]); + if (res.exitCode !== 0) continue; + for (const line of res.stdout.split(/\r?\n/)) { + const found = resolveWindowsPath(line.trim()); + if (found) return found; + } + } + const localAppData = process.env.LOCALAPPDATA; + return localAppData ? resolveWindowsInstall(join(localAppData, 'cursor-agent')) : null; +} + +/** + * Since Node's CVE-2024-27980 fix, spawn() rejects `.cmd`/`.bat` files unless + * it goes through a shell (EINVAL), so a shim path is translated into the + * install it launches. An `.exe` is spawnable as is; anything else is not a + * Windows launcher. + * + * @param {string} path + * @returns {CursorAgentBin|null} + */ +export function resolveWindowsPath(path) { + const ext = win32.extname(path).toLowerCase(); + if (ext === '.exe') return { command: path, args: [] }; + if (ext === '.cmd' || ext === '.bat' || ext === '.ps1') { + return resolveWindowsInstall(win32.dirname(path)); + } + return null; +} + +// Version directory names the official cursor-agent.ps1 accepts: the legacy +// YYYY.MM.DD-commit form and YYYY.MM.DD-HH-MM-SS-commit with a build time. +const VERSION_DIR_RE = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:-(\d{2})-(\d{2})-(\d{2}))?-[a-f0-9]+$/; + +/** + * Newest first, non-version names dropped. cursor-agent.ps1 ranks by date + * only; same-day builds are additionally ordered by build time here, with the + * legacy form counting as midnight. + * + * @param {string[]} names + * @returns {string[]} + */ +export function sortVersionDirs(names) { + /** @type {{name: string, key: number[]}[]} */ + const versions = []; + for (const name of names) { + const match = VERSION_DIR_RE.exec(name); + if (match) versions.push({ name, key: match.slice(1, 7).map((part) => Number(part ?? 0)) }); + } + versions.sort((a, b) => { + for (let i = 0; i < a.key.length; i++) { + if (a.key[i] !== b.key[i]) return b.key[i] - a.key[i]; + } + return 0; + }); + return versions.map((v) => v.name); +} + +/** + * Mirror of what the official cursor-agent.ps1 shim runs: a `node.exe` next + * to the shim if there is one, otherwise the newest `versions\\`. + * Unlike the shim, a version directory without `node.exe` and `index.js` + * (an interrupted update) is skipped instead of being fatal. + * + * @param {string} root Directory holding the shims, e.g. %LOCALAPPDATA%\cursor-agent. + * @returns {CursorAgentBin|null} + */ +export function resolveWindowsInstall(root) { + const dirs = [root]; + try { + const entries = readdirSync(join(root, 'versions'), { withFileTypes: true }); + const names = entries.filter((e) => e.isDirectory()).map((e) => e.name); + for (const name of sortVersionDirs(names)) dirs.push(join(root, 'versions', name)); + } catch { + // No versions directory — only a node.exe beside the shim can work. + } + for (const dir of dirs) { + const node = join(dir, 'node.exe'); + const entry = join(dir, 'index.js'); + if (existsSync(node) && existsSync(entry)) return { command: node, args: [entry] }; + } + return null; } /** @@ -158,8 +302,7 @@ export function buildArgs(opts) { */ export async function runHeadless(opts) { const bin = await resolveBin(); - const args = buildArgs(opts); - const child = spawn(bin, args, { + const child = spawn(bin.command, [...bin.args, ...buildArgs(opts)], { cwd: opts.cwd ?? process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], env: process.env, @@ -286,8 +429,7 @@ export async function runHeadless(opts) { */ export async function authStatus() { try { - const bin = await resolveBin(); - const res = await run(bin, ['status'], { timeoutMs: 5_000 }); + const res = await runAgent(['status'], { timeoutMs: 5_000 }); const text = `${res.stdout}\n${res.stderr}`.toLowerCase(); const loggedIn = res.exitCode === 0 && @@ -306,10 +448,9 @@ export async function authStatus() { */ export async function listModels() { try { - const bin = await resolveBin(); - const res = await run(bin, ['--list-models'], { timeoutMs: 10_000 }); + const res = await runAgent(['--list-models'], { timeoutMs: 10_000 }); if (res.exitCode !== 0) { - const fallback = await run(bin, ['models'], { timeoutMs: 10_000 }); + const fallback = await runAgent(['models'], { timeoutMs: 10_000 }); return fallback.stdout .split('\n') .map((l) => l.trim()) @@ -336,8 +477,7 @@ export async function listModels() { */ export async function listConfiguredMcps() { try { - const bin = await resolveBin(); - const res = await run(bin, ['mcp', 'list'], { timeoutMs: 5_000 }); + const res = await runAgent(['mcp', 'list'], { timeoutMs: 5_000 }); if (res.exitCode !== 0) return []; // Strip ANSI control sequences — cursor-agent writes them even under `run`. // eslint-disable-next-line no-control-regex @@ -374,8 +514,7 @@ export async function listConfiguredMcps() { */ export async function listSessions(cwd = process.cwd()) { try { - const bin = await resolveBin(); - const res = await run(bin, ['ls', '--output-format', 'json'], { + const res = await runAgent(['ls', '--output-format', 'json'], { cwd, timeoutMs: 5_000, }); diff --git a/plugins/cursor/scripts/setup.mjs b/plugins/cursor/scripts/setup.mjs index 8c38e93..bfc16cc 100644 --- a/plugins/cursor/scripts/setup.mjs +++ b/plugins/cursor/scripts/setup.mjs @@ -4,10 +4,16 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseCommandArgv } from './lib/args.mjs'; import { getConfig, setConfigValue } from './lib/config.mjs'; -import { authStatus, listConfiguredMcps, listModels, resolveBin } from './lib/cursor.mjs'; +import { + authStatus, + describeBin, + listConfiguredMcps, + listModels, + resolveBin, + runAgent, +} from './lib/cursor.mjs'; import { repoRoot } from './lib/git.mjs'; import { ensureDir, jobsDir, pluginHome } from './lib/paths.mjs'; -import { run } from './lib/run.mjs'; function pluginRoot() { const envRoot = process.env.CLAUDE_PLUGIN_ROOT; @@ -47,10 +53,10 @@ async function gatherDoctor() { /** @type {Array<[string, {ok: boolean, detail: string}]>} */ const checks = []; - let bin = ''; + let bin = null; try { bin = await resolveBin(); - checks.push(['cursor-agent binary', { ok: true, detail: bin }]); + checks.push(['cursor-agent binary', { ok: true, detail: describeBin(bin) }]); } catch (err) { checks.push([ 'cursor-agent binary', @@ -58,7 +64,7 @@ async function gatherDoctor() { ]); } if (bin) { - const ver = await run(bin, ['--version'], { timeoutMs: 5_000 }); + const ver = await runAgent(['--version'], { timeoutMs: 5_000 }); checks.push([ 'cursor-agent version', { ok: ver.exitCode === 0, detail: (ver.stdout || ver.stderr).trim() }, @@ -236,7 +242,7 @@ async function baseCheck() { const lines = ['### /cursor:setup\n']; try { const bin = await resolveBin(); - lines.push(`- ✓ \`cursor-agent\` at \`${bin}\``); + lines.push(`- ✓ \`cursor-agent\` at \`${describeBin(bin)}\``); const auth = await authStatus(); lines.push( auth.loggedIn diff --git a/plugins/cursor/tests/windows-bin.test.mjs b/plugins/cursor/tests/windows-bin.test.mjs new file mode 100644 index 0000000..b67a6b4 --- /dev/null +++ b/plugins/cursor/tests/windows-bin.test.mjs @@ -0,0 +1,231 @@ +import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + resolveWindowsInstall, + resolveWindowsPath, + sortVersionDirs, +} from '../scripts/lib/cursor.mjs'; +import { run } from '../scripts/lib/run.mjs'; +import { makeTempHome } from './helpers.mjs'; + +// Layout written by the official Windows installer (cursor.com/install?win32=true): +// %LOCALAPPDATA%\cursor-agent\cursor-agent.{cmd,ps1} <- on PATH +// %LOCALAPPDATA%\cursor-agent\versions\\node.exe, index.js, ... +// There is no cursor-agent.exe; the shims run the newest version's node.exe. + +const SHIM_CMD = [ + '@echo off', + 'set "SCRIPT_DIR=%~dp0"', + '%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%cursor-agent.ps1" %*', + '', +].join('\r\n'); + +// Stand-in for cursor-agent's index.js: echoes the arguments it received. +const ECHO_ENTRY = 'process.stdout.write(JSON.stringify(process.argv.slice(2)));\n'; + +/** + * @param {string} root + * @param {Record} versions + * @param {{nodeSource?: string}} [opts] Copy this file as node.exe; empty file otherwise. + */ +function makeInstall(root, versions, opts = {}) { + mkdirSync(join(root, 'versions'), { recursive: true }); + writeFileSync(join(root, 'cursor-agent.cmd'), SHIM_CMD); + writeFileSync(join(root, 'cursor-agent.ps1'), '# shim\n'); + for (const [name, files] of Object.entries(versions)) { + const dir = join(root, 'versions', name); + mkdirSync(dir, { recursive: true }); + if (files.node !== false) { + if (opts.nodeSource) copyFileSync(opts.nodeSource, join(dir, 'node.exe')); + else writeFileSync(join(dir, 'node.exe'), ''); + } + if (files.entry !== false) writeFileSync(join(dir, 'index.js'), ECHO_ENTRY); + } +} + +describe('sortVersionDirs', () => { + it('orders version directories newest first by date', () => { + expect( + sortVersionDirs(['2026.05.28-418efe5', '2026.09.15-d2fe57e', '2026.08.11-e8db854']), + ).toEqual(['2026.09.15-d2fe57e', '2026.08.11-e8db854', '2026.05.28-418efe5']); + }); + + it('compares date parts numerically, not as strings', () => { + expect(sortVersionDirs(['2026.9.2-aaaaaaa', '2026.10.1-bbbbbbb'])).toEqual([ + '2026.10.1-bbbbbbb', + '2026.9.2-aaaaaaa', + ]); + }); + + it('orders same-day builds by build time, legacy names counting as midnight', () => { + expect( + sortVersionDirs([ + '2026.09.15-08-00-00-aaaaaaa', + '2026.09.15-bbbbbbb', + '2026.09.15-17-30-05-ccccccc', + ]), + ).toEqual(['2026.09.15-17-30-05-ccccccc', '2026.09.15-08-00-00-aaaaaaa', '2026.09.15-bbbbbbb']); + }); + + it('drops names the official shim would not treat as versions', () => { + expect( + sortVersionDirs(['latest', '2026.09.15', 'tmp-2026.09.15-abc', '2026.09.15-d2fe57e']), + ).toEqual(['2026.09.15-d2fe57e']); + }); +}); + +describe('resolveWindowsInstall', () => { + let tmp; + beforeEach(() => { + tmp = makeTempHome(); + }); + afterEach(() => tmp.cleanup()); + + it('runs the newest version through its bundled node.exe', () => { + const root = join(tmp.dir, 'cursor-agent'); + makeInstall(root, { '2026.08.11-e8db854': {}, '2026.09.15-d2fe57e': {} }); + const dir = join(root, 'versions', '2026.09.15-d2fe57e'); + expect(resolveWindowsInstall(root)).toEqual({ + command: join(dir, 'node.exe'), + args: [join(dir, 'index.js')], + }); + }); + + it('skips a newest version left incomplete by an interrupted update', () => { + const root = join(tmp.dir, 'cursor-agent'); + makeInstall(root, { '2026.08.11-e8db854': {}, '2026.09.15-d2fe57e': { node: false } }); + expect(resolveWindowsInstall(root)?.command).toBe( + join(root, 'versions', '2026.08.11-e8db854', 'node.exe'), + ); + }); + + it('prefers a node.exe sitting next to the shim, as the shim does', () => { + const root = join(tmp.dir, 'versions', '2026.09.15-d2fe57e'); + makeInstall(root, {}); + writeFileSync(join(root, 'node.exe'), ''); + writeFileSync(join(root, 'index.js'), ECHO_ENTRY); + expect(resolveWindowsInstall(root)).toEqual({ + command: join(root, 'node.exe'), + args: [join(root, 'index.js')], + }); + }); + + it('returns null when nothing runnable is installed', () => { + expect(resolveWindowsInstall(join(tmp.dir, 'missing'))).toBeNull(); + const root = join(tmp.dir, 'cursor-agent'); + makeInstall(root, { '2026.09.15-d2fe57e': { entry: false } }); + expect(resolveWindowsInstall(root)).toBeNull(); + }); +}); + +describe('resolveWindowsPath', () => { + let tmp; + beforeEach(() => { + tmp = makeTempHome(); + }); + afterEach(() => tmp.cleanup()); + + it('translates .cmd, .bat and .ps1 shims into the install beside them', () => { + const root = join(tmp.dir, 'cursor-agent'); + makeInstall(root, { '2026.09.15-d2fe57e': {} }); + const expected = join(root, 'versions', '2026.09.15-d2fe57e', 'node.exe'); + for (const shim of ['cursor-agent.cmd', 'agent.CMD', 'cursor-agent.bat', 'cursor-agent.ps1']) { + expect(resolveWindowsPath(join(root, shim))?.command).toBe(expected); + } + }); + + it('spawns an .exe as is', () => { + expect(resolveWindowsPath('C:\\Tools\\cursor-agent.exe')).toEqual({ + command: 'C:\\Tools\\cursor-agent.exe', + args: [], + }); + }); + + it('rejects paths that are not Windows launchers', () => { + expect(resolveWindowsPath('C:\\Tools\\cursor-agent')).toBeNull(); + expect(resolveWindowsPath('')).toBeNull(); + }); + + it('returns null for a shim with no install beside it', () => { + expect(resolveWindowsPath(join(tmp.dir, 'cursor-agent.cmd'))).toBeNull(); + }); +}); + +// End to end against a real node.exe. Runs only on Windows (CI: windows-bin job). +describe.runIf(process.platform === 'win32')('Windows cursor-agent launch (#27)', () => { + let tmp; + let root; + const version = '2026.09.15-d2fe57e'; + const saved = { + PATH: process.env.PATH, + LOCALAPPDATA: process.env.LOCALAPPDATA, + CURSOR_AGENT_BIN: process.env.CURSOR_AGENT_BIN, + }; + + beforeAll(() => { + tmp = makeTempHome(); + root = join(tmp.dir, 'LocalAppData', 'cursor-agent'); + makeInstall( + root, + { '2026.08.11-e8db854': {}, [version]: {} }, + { nodeSource: process.execPath }, + ); + }, 120_000); + + afterAll(() => tmp.cleanup()); + + beforeEach(() => { + vi.resetModules(); + delete process.env.CURSOR_AGENT_BIN; + }); + + afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + async function freshCursorModule() { + return import('../scripts/lib/cursor.mjs'); + } + + it('cannot spawn the .cmd shim without a shell — the original failure', async () => { + let failure = ''; + try { + const res = await run(join(root, 'cursor-agent.cmd'), ['--version']); + failure = res.exitCode === -1 ? res.stderr : ''; + } catch (err) { + failure = String(err); + } + expect(failure).toMatch(/EINVAL/); + }); + + it('finds the shim on PATH via where.exe and launches node.exe index.js', async () => { + process.env.PATH = `${root};${saved.PATH}`; + process.env.LOCALAPPDATA = join(tmp.dir, 'elsewhere'); + const { resolveBin, runAgent } = await freshCursorModule(); + const bin = await resolveBin(); + expect(basename(dirname(bin.command))).toBe(version); + const res = await runAgent(['--version']); + expect(res.exitCode).toBe(0); + expect(JSON.parse(res.stdout)).toEqual(['--version']); + }); + + it('falls back to %LOCALAPPDATA%\\cursor-agent when PATH predates the install', async () => { + process.env.LOCALAPPDATA = dirname(root); + const { resolveBin, runAgent } = await freshCursorModule(); + const bin = await resolveBin(); + expect(basename(dirname(bin.command))).toBe(version); + const res = await runAgent(['status']); + expect(JSON.parse(res.stdout)).toEqual(['status']); + }); + + it('accepts CURSOR_AGENT_BIN pointing at the shim', async () => { + process.env.CURSOR_AGENT_BIN = join(root, 'cursor-agent.cmd'); + const { runAgent } = await freshCursorModule(); + const res = await runAgent(['models']); + expect(JSON.parse(res.stdout)).toEqual(['models']); + }); +});