Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions plugins/cursor/scripts/browser.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -160,7 +174,7 @@ async function preflightMcp() {
* @returns {Promise<number>}
*/
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>] <what to verify...>`. 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',
Expand Down
4 changes: 2 additions & 2 deletions plugins/cursor/scripts/delegate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -229,7 +229,7 @@ async function runWorker(jobId, flags, prompt, root) {
* @returns {Promise<number>}
*/
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
Expand Down
134 changes: 134 additions & 0 deletions plugins/cursor/scripts/lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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] -- -- "<body>"`.
*
* 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
Expand Down
20 changes: 14 additions & 6 deletions plugins/cursor/scripts/review.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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/<repo-hash>/ dir, and forward that root to the worker.
Expand All @@ -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;
Expand Down Expand Up @@ -221,13 +226,16 @@ async function runWorker(jobId, flags, root) {
* @returns {Promise<number>}
*/
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;
Expand Down Expand Up @@ -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`,
Expand Down
Loading