diff --git a/plugins/cursor/scripts/browser.mjs b/plugins/cursor/scripts/browser.mjs index 162caef..cc78a39 100644 --- a/plugins/cursor/scripts/browser.mjs +++ b/plugins/cursor/scripts/browser.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { collapseCommandArgv, parseArgv, parseTimeout } from './lib/args.mjs'; +import { collapsePromptArgv, parseArgv, parseTimeout } from './lib/args.mjs'; import { listConfiguredMcps, resolveModel, runHeadless } from './lib/cursor.mjs'; import { isGitRepo, repoRoot } from './lib/git.mjs'; import { id as newId } from './lib/id.mjs'; @@ -83,6 +83,14 @@ function usedBannedHttpClient(events) { return [...hits]; } +function stripSurroundingQuotes(token) { + const first = token[0]; + if ((first === '"' || first === "'") && token.length >= 2 && token.endsWith(first)) { + return token.slice(1, -1); + } + return token; +} + function looksLikeUrl(token) { return ( /^https?:\/\//i.test(token) || /^localhost(:\d+)?(\/|$)/i.test(token) || /^\/\//.test(token) @@ -112,15 +120,21 @@ function parseFlags(argv) { flags['mcpCheck'] === false; const timeout = parseTimeout(flags['timeout']); const model = typeof flags['model'] === 'string' ? flags['model'] : undefined; + // `collapsePromptArgv` keeps everything after the leading flags as ONE + // verbatim positional. Split off the first span only when it looks like a + // URL; the rest stays untouched so flag-like words inside the description + // (`--config`, `--no-index`, …) survive. + const rest = positional.join(' ').trim(); let url; - let descTokens = []; - if (positional.length > 0 && positional[0] && looksLikeUrl(positional[0])) { - url = normaliseUrl(positional[0]); - descTokens = positional.slice(1); - } else { - descTokens = positional.slice(); + let description = rest; + const m = rest.match(/^(\S+)([\s\S]*)$/); + if (m && m[1]) { + const first = stripSurroundingQuotes(m[1]); + if (looksLikeUrl(first)) { + url = normaliseUrl(first); + description = (m[2] ?? '').trim(); + } } - const description = descTokens.join(' ').trim(); return { url, description, @@ -160,7 +174,7 @@ async function preflightMcp() { * @returns {Promise} */ export async function main(rawArgv) { - const flags = parseFlags(collapseCommandArgv(rawArgv)); + const flags = parseFlags(collapsePromptArgv(rawArgv, BOOLEAN_FLAGS)); if (flags.description.length === 0) { process.stderr.write( 'Error: no test description. Usage: `/cursor:browser [] `. URL is optional — if omitted, Cursor discovers it from `list_pages` / package.json / common ports. Examples: `/cursor:browser http://localhost:3000 "login flow works"` or `/cursor:browser "check the home page loads without console errors"`.\n', diff --git a/plugins/cursor/scripts/delegate.mjs b/plugins/cursor/scripts/delegate.mjs index 072d5a1..ec2edbe 100644 --- a/plugins/cursor/scripts/delegate.mjs +++ b/plugins/cursor/scripts/delegate.mjs @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { openSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { collapseCommandArgv, parseArgv, parseTimeout } from './lib/args.mjs'; +import { collapsePromptArgv, parseArgv, parseTimeout } from './lib/args.mjs'; import { resolveModel, runHeadless } from './lib/cursor.mjs'; import { isGitRepo, repoRoot } from './lib/git.mjs'; import { id as newId } from './lib/id.mjs'; @@ -229,7 +229,7 @@ async function runWorker(jobId, flags, prompt, root) { * @returns {Promise} */ export async function main(rawArgv) { - const flags = parseFlags(collapseCommandArgv(rawArgv)); + const flags = parseFlags(collapsePromptArgv(rawArgv, BOOLEAN_FLAGS)); if (flags.worker) { // The prompt is handed over verbatim via env to avoid a second collapse diff --git a/plugins/cursor/scripts/lib/args.mjs b/plugins/cursor/scripts/lib/args.mjs index d794483..38b3c36 100644 --- a/plugins/cursor/scripts/lib/args.mjs +++ b/plugins/cursor/scripts/lib/args.mjs @@ -211,6 +211,140 @@ export function parseCommandArgv(rawArgv, booleans = []) { return parseArgv(collapseCommandArgv(rawArgv), booleans); } +/** + * Scan a packed command string for a LEADING run of flag tokens and return + * them together with the raw, untouched remainder. + * + * Flag spans are recognised only at the start of the string: the first span + * that does not begin with `--` — or an explicit `--` span — ends flag + * parsing, and `rest` is the original substring from that point on (quotes, + * backslashes, whitespace and flag-like words all survive verbatim). + * + * A non-boolean flag that consumes the following span as its value is folded + * into a single `--name=value` token, so `parseArgv` re-derives the same + * flag/value pairing through its inline-value path and never re-decides + * whether the value span "looks like a flag". + * + * @param {string} input + * @param {string[]} [booleans] Flag names that never consume a value span. + * @returns {{tokens: string[], rest: string}} + */ +export function splitLeadingFlags(input, booleans = []) { + const booleanSet = new Set(); + for (const b of booleans) { + booleanSet.add(b); + booleanSet.add(kebabToCamel(b)); + } + /** @type {string[]} */ + const tokens = []; + let i = 0; + const isWs = (ch) => ch === ' ' || ch === '\t' || ch === '\n'; + const skipWs = () => { + while (i < input.length && isWs(input[i])) i += 1; + }; + // Read one whitespace-delimited span with the same quote/escape rules as + // splitArgString, advancing `i` past it. Returns the unquoted span text. + const readSpan = () => { + let cur = ''; + /** @type {'"'|"'"|null} */ + let quote = null; + let escape = false; + while (i < input.length) { + const ch = input[i]; + if (escape) { + cur += ch; + escape = false; + i += 1; + continue; + } + if (ch === '\\' && quote !== "'") { + escape = true; + i += 1; + continue; + } + if (quote) { + if (ch === quote) quote = null; + else cur += ch; + i += 1; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i += 1; + continue; + } + if (isWs(ch)) break; + cur += ch; + i += 1; + } + if (escape) cur += '\\'; + return cur; + }; + + skipWs(); + while (i < input.length) { + const spanStart = i; + const span = readSpan(); + if (span === '--') { + // Explicit delimiter: everything after it is the verbatim body. This is + // also the escape hatch for a body that itself begins with `--`. + skipWs(); + return { tokens, rest: input.slice(i) }; + } + if (!span.startsWith('--') || span.length <= 2) { + return { tokens, rest: input.slice(spanStart) }; + } + const eq = span.indexOf('='); + const name = span.slice(2, eq === -1 ? undefined : eq); + const takesValue = + eq === -1 && + !name.startsWith('no-') && + !booleanSet.has(name) && + !booleanSet.has(kebabToCamel(name)); + skipWs(); + if (takesValue && i < input.length && !(input[i] === '-' && input[i + 1] === '-')) { + tokens.push(`${span}=${readSpan()}`); + skipWs(); + continue; + } + tokens.push(span); + } + return { tokens, rest: '' }; +} + +/** + * Prompt-style argv collapse for commands whose trailing operand is free text + * (a task brief). Contract: flags come BEFORE the task text. Only a leading + * run of flag tokens is parsed as flags; the first non-flag span ends flag + * parsing and everything from there on is kept as ONE verbatim positional — + * flag-like words (`--config`, `--no-index`, …), quotes and backslashes inside + * the body survive untouched. A flag typed AFTER the task text becomes part of + * the body (this differs from `collapseCommandArgv`, which re-tokenises the + * whole string and used to silently consume such words out of long briefs). + * A body that itself starts with `--` can be forced verbatim with an extra + * delimiter: `delegate.mjs [flags] -- -- ""`. + * + * Handles both invocation shapes: + * - direct CLI: real argv tokens, flags before `--`, body as one quoted arg + * - slash command: `-- "$ARGUMENTS"` where the user's flags and text arrive + * packed in a single string + * + * @param {string[]} rawArgv + * @param {string[]} [booleans] + * @returns {string[]} Token array for `parseArgv`: leading flag tokens, then + * `['--', body]` when a body is present. + */ +export function collapsePromptArgv(rawArgv, booleans = []) { + const delimiterIdx = rawArgv.indexOf('--'); + const firstHalf = delimiterIdx === -1 ? [] : rawArgv.slice(0, delimiterIdx); + const userRaw = (delimiterIdx === -1 ? rawArgv : rawArgv.slice(delimiterIdx + 1)) + .join(' ') + .trim(); + if (userRaw.length === 0) return [...firstHalf]; + const { tokens, rest } = splitLeadingFlags(userRaw, booleans); + return rest.length > 0 ? [...firstHalf, ...tokens, '--', rest] : [...firstHalf, ...tokens]; +} + /** * Normalise a `--timeout` flag value (which may be a number, a numeric string, * or junk) into a positive integer number of seconds, falling back to diff --git a/plugins/cursor/scripts/review.mjs b/plugins/cursor/scripts/review.mjs index 97cf63d..da822a3 100644 --- a/plugins/cursor/scripts/review.mjs +++ b/plugins/cursor/scripts/review.mjs @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { openSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { collapseCommandArgv, parseArgv, parseTimeout } from './lib/args.mjs'; +import { collapsePromptArgv, parseArgv, parseTimeout } from './lib/args.mjs'; import { resolveModel, runHeadless } from './lib/cursor.mjs'; import { collectReviewContext, isGitRepo, repoRoot } from './lib/git.mjs'; import { id as newId } from './lib/id.mjs'; @@ -170,7 +170,7 @@ async function foreground(flags, context, jobId, root) { return result.exitCode; } -function spawnBackground(jobId, argv, root) { +function spawnBackground(jobId, argv, root, extraEnv = {}) { const selfPath = fileURLToPath(import.meta.url); // Base capture logs on the resolved repo root so they share the job's // jobs// dir, and forward that root to the worker. @@ -181,7 +181,12 @@ function spawnBackground(jobId, argv, root) { const child = spawn(process.execPath, [selfPath, '--worker', jobId, ...argv], { detached: true, stdio: ['ignore', out, err], - env: { ...process.env, CURSOR_PLUGIN_CC_WORKER: '1', CURSOR_PLUGIN_CC_REPO_ROOT: root }, + env: { + ...process.env, + CURSOR_PLUGIN_CC_WORKER: '1', + CURSOR_PLUGIN_CC_REPO_ROOT: root, + ...extraEnv, + }, }); child.unref(); return child.pid ?? -1; @@ -221,13 +226,16 @@ async function runWorker(jobId, flags, root) { * @returns {Promise} */ export async function main(rawArgv) { - const flags = parseFlags(collapseCommandArgv(rawArgv)); + const flags = parseFlags(collapsePromptArgv(rawArgv, BOOLEAN_FLAGS)); // `cursor-agent --force` auto-approves any read tool the reviewer wants to // run for extra context; the prompt forbids writes and a post-flight check // flags any file the run touched anyway. flags.force = true; if (flags.worker) { + // The focus text is handed over verbatim via env to avoid a second + // collapse mangling it (same contract as delegate.mjs). + flags.focus = process.env.CURSOR_PLUGIN_CC_PROMPT ?? flags.focus; const root = process.env.CURSOR_PLUGIN_CC_REPO_ROOT ?? (await repoRoot(process.cwd())); await runWorker(flags.worker, flags, root); return 0; @@ -272,8 +280,8 @@ export async function main(rawArgv) { if (flags.base) forwarded.push('--base', flags.base); forwarded.push('--scope', flags.scope); forwarded.push('--timeout', String(flags.timeout)); - if (flags.focus) forwarded.push('--', flags.focus); - const pid = spawnBackground(jobId, forwarded, root); + const extraEnv = flags.focus ? { CURSOR_PLUGIN_CC_PROMPT: flags.focus } : {}; + const pid = spawnBackground(jobId, forwarded, root, extraEnv); updateJob(root, jobId, { pid }); process.stdout.write( `Review job \`${jobId}\` started in background (model \`${model}\`, pid ${pid}) — ${context.label}.\n`, diff --git a/plugins/cursor/tests/args.test.mjs b/plugins/cursor/tests/args.test.mjs index 881353f..a5358c6 100644 --- a/plugins/cursor/tests/args.test.mjs +++ b/plugins/cursor/tests/args.test.mjs @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { collapseArguments, parseArgv, splitArgString } from '../scripts/lib/args.mjs'; +import { + collapseArguments, + collapsePromptArgv, + parseArgv, + splitArgString, + splitLeadingFlags, +} from '../scripts/lib/args.mjs'; describe('splitArgString', () => { it('splits on whitespace', () => { @@ -77,6 +83,135 @@ describe('parseArgv', () => { }); }); +// The delegate prompt path: flags lead, the first non-flag span starts the +// verbatim body. Regression for flag-like words (`--config`, `--no-index`, +// `%~dp0\…`) being silently consumed out of long briefs (2026-08-19). +const DELEGATE_BOOLEANS = [ + 'background', + 'wait', + 'fresh', + 'force', + 'cloud', + 'git-check', + 'help', + 'resume', +]; + +describe('splitLeadingFlags', () => { + it('stops at the first non-flag span and returns the raw remainder', () => { + const brief = + 'Run installer --config custom.yaml --duration 30 --platform win --no-index then expand %~dp0\\bin and report.'; + const r = splitLeadingFlags(brief, DELEGATE_BOOLEANS); + expect(r.tokens).toEqual([]); + expect(r.rest).toBe(brief); + }); + + it('folds a consumed value into a single --name=value token', () => { + const r = splitLeadingFlags('--model opus --timeout 60 fix the bug', DELEGATE_BOOLEANS); + expect(r.tokens).toEqual(['--model=opus', '--timeout=60']); + expect(r.rest).toBe('fix the bug'); + }); + + it('boolean flags never consume the first body word', () => { + const r = splitLeadingFlags('--background fix the bug', DELEGATE_BOOLEANS); + expect(r.tokens).toEqual(['--background']); + expect(r.rest).toBe('fix the bug'); + }); + + it('keeps quotes and backslashes in the body untouched', () => { + const body = 'say "hello world" and expand %~dp0\\bin'; + const r = splitLeadingFlags(`--fresh ${body}`, DELEGATE_BOOLEANS); + expect(r.tokens).toEqual(['--fresh']); + expect(r.rest).toBe(body); + }); + + it('an explicit -- span forces the rest verbatim even when it starts with a flag', () => { + const r = splitLeadingFlags('--model opus -- --weird leading body', DELEGATE_BOOLEANS); + expect(r.tokens).toEqual(['--model=opus']); + expect(r.rest).toBe('--weird leading body'); + }); + + it('inline =value and --no-* forms take no extra span', () => { + const r = splitLeadingFlags('--resume=chat_abc --no-force follow up', DELEGATE_BOOLEANS); + expect(r.tokens).toEqual(['--resume=chat_abc', '--no-force']); + expect(r.rest).toBe('follow up'); + }); +}); + +describe('collapsePromptArgv', () => { + const brief = + 'Goal: build the tool. Run installer --config custom.yaml --duration 30 --platform win --no-index then expand %~dp0\\bin and report.'; + + it('direct CLI shape: quoted brief after -- survives verbatim', () => { + const argv = ['--model', 'grok', '--timeout', '3600', '--background', '--', brief]; + const r = parseArgv(collapsePromptArgv(argv, DELEGATE_BOOLEANS), DELEGATE_BOOLEANS); + expect(r.flags['model']).toBe('grok'); + expect(r.flags['timeout']).toBe(3600); + expect(r.flags['background']).toBe(true); + expect(r.positional.join(' ').trim()).toBe(brief); + expect(r.flags['config']).toBeUndefined(); + expect(r.flags['no-index']).toBeUndefined(); + }); + + it('slash shape: leading flags inside the packed $ARGUMENTS string are parsed, body kept raw', () => { + const argv = ['--', `--background --model grok ${brief}`]; + const r = parseArgv(collapsePromptArgv(argv, DELEGATE_BOOLEANS), DELEGATE_BOOLEANS); + expect(r.flags['background']).toBe(true); + expect(r.flags['model']).toBe('grok'); + expect(r.positional.join(' ').trim()).toBe(brief); + }); + + it('resume shape: unshifted --resume does not eat the follow-up text', () => { + const argv = ['--resume', '--', 'řekni mi něco o teto službě']; + const r = parseArgv(collapsePromptArgv(argv, DELEGATE_BOOLEANS), DELEGATE_BOOLEANS); + expect(r.flags['resume']).toBe(true); + expect(r.positional.join(' ').trim()).toBe('řekni mi něco o teto službě'); + }); + + it('resume shape: --resume= packed with follow-up extracts the id', () => { + const argv = ['--', '--resume=chat_abc follow up']; + const r = parseArgv(collapsePromptArgv(argv, DELEGATE_BOOLEANS), DELEGATE_BOOLEANS); + expect(r.flags['resume']).toBe('chat_abc'); + expect(r.positional.join(' ').trim()).toBe('follow up'); + }); + + it('empty body yields flags only', () => { + const r = parseArgv( + collapsePromptArgv(['--resume', '--', ''], DELEGATE_BOOLEANS), + DELEGATE_BOOLEANS, + ); + expect(r.flags['resume']).toBe(true); + expect(r.positional).toEqual([]); + }); +}); + +// The review prompt path: same contract as delegate — flags lead, the focus +// text is one verbatim trailing operand. Regression for flag-like words in a +// focus brief being consumed as flags (2026-08-19). +const REVIEW_BOOLEANS = ['background', 'wait', 'adversarial', 'git-check', 'help']; + +describe('collapsePromptArgv (review shape)', () => { + it('slash shape: leading flags parsed, focus with flag-like words kept raw', () => { + const focus = 'focus on the --config flag handling and --no-index paths'; + const argv = ['--wait', '--', `--scope branch ${focus}`]; + const r = parseArgv(collapsePromptArgv(argv, REVIEW_BOOLEANS), REVIEW_BOOLEANS); + expect(r.flags['wait']).toBe(true); + expect(r.flags['scope']).toBe('branch'); + expect(r.positional.join(' ').trim()).toBe(focus); + expect(r.flags['config']).toBeUndefined(); + expect(r.flags['no-index']).toBeUndefined(); + }); + + it('worker re-spawn shape without a body parses flags only', () => { + const argv = ['--worker', 'abc123', '--scope', 'auto', '--timeout', '1800']; + const r = parseArgv(collapsePromptArgv(argv, REVIEW_BOOLEANS), REVIEW_BOOLEANS); + expect(r.flags['worker']).toBe('abc123'); + expect(r.flags['scope']).toBe('auto'); + expect(r.flags['timeout']).toBe(1800); + expect(r.positional).toEqual([]); + }); +}); + describe('collapseArguments', () => { it('returns empty for empty input', () => { expect(collapseArguments('')).toEqual([]); diff --git a/plugins/cursor/tests/browser.test.mjs b/plugins/cursor/tests/browser.test.mjs index f994b99..ae28d12 100644 --- a/plugins/cursor/tests/browser.test.mjs +++ b/plugins/cursor/tests/browser.test.mjs @@ -64,6 +64,24 @@ describe('browser', () => { expect(job.prompt).toContain('(discover)'); }); + it('flag-like words inside the description survive verbatim', async () => { + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + const code = await browserMain([ + '--no-git-check', + '--skip-mcp-check', + '--', + 'http://localhost:3000 verify the --config panel renders and --no-index mode works', + ]); + expect(code).toBe(0); + } finally { + outSpy.mockRestore(); + } + const job = listJobs(tmp.dir)[0]; + expect(job.prompt).toContain('http://localhost:3000'); + expect(job.prompt).toContain('--config panel renders and --no-index mode works'); + }); + it('happy path: chrome-devtools MCP calls → status done', async () => { const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); try {