dsh-cursor-dev 0.0.0-stage → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/proxy.js ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Outbound HTTPS with the machine's proxy configuration honoured.
3
+ *
4
+ * The usage RPC talks to `api2.cursor.sh`, which is unreachable from some
5
+ * networks unless the request goes through the same proxy the operating system
6
+ * and the browser use. Node's `fetch` ignores the Windows Internet Settings
7
+ * entirely, so a plugin that calls `fetch` directly reports "unreachable" on a
8
+ * machine where the same URL works fine in a browser.
9
+ *
10
+ * The proxy is discovered in the usual order - `HTTPS_PROXY`, `HTTP_PROXY`,
11
+ * `ALL_PROXY`, then the Windows registry (`Internet Settings`) - and applied
12
+ * with the standard `CONNECT` tunnel, so no dependency is involved.
13
+ */
14
+
15
+ import { execFile } from 'node:child_process';
16
+ import http from 'node:http';
17
+ import tls from 'node:tls';
18
+
19
+ const REGISTRY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
20
+
21
+ /** Environment variables consulted for a proxy, most specific first. */
22
+ const PROXY_ENV_KEYS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
23
+ /** Environment variables holding the bypass list. */
24
+ const BYPASS_ENV_KEYS = ['NO_PROXY', 'no_proxy'];
25
+
26
+ /**
27
+ * Read one value out of a `reg query` response.
28
+ *
29
+ * @param text - stdout of `reg query <path>`.
30
+ * @param name - value name to find.
31
+ * @returns the raw value text, or undefined.
32
+ */
33
+ function registryValue(text, name) {
34
+ const pattern = new RegExp(`^\\s*${name}\\s+REG_\\w+\\s+(.*)$`, 'im');
35
+ const match = pattern.exec(text);
36
+ return match === null ? undefined : match[1].trim();
37
+ }
38
+
39
+ /** Run `reg query` and resolve its stdout, or undefined when it cannot run. */
40
+ function queryRegistry(timeoutMs) {
41
+ return new Promise(resolve => {
42
+ try {
43
+ execFile(
44
+ 'reg.exe',
45
+ ['query', REGISTRY_PATH],
46
+ { timeout: timeoutMs, windowsHide: true },
47
+ (error, stdout) => resolve(error === null ? String(stdout) : undefined),
48
+ );
49
+ }
50
+ catch {
51
+ resolve(undefined);
52
+ }
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Parse a proxy setting into `{ host, port }`.
58
+ *
59
+ * Accepts the plain `host:port` form, a `scheme=host:port` list (WinINET uses
60
+ * that shape when the protocols differ), and a full URL. A value without a port
61
+ * gets the scheme's default.
62
+ *
63
+ * @param raw - proxy text from the environment or the registry.
64
+ * @returns the proxy endpoint, or undefined when nothing usable is in there.
65
+ */
66
+ export function parseProxy(raw) {
67
+ if (typeof raw !== 'string') return undefined;
68
+ let text = raw.trim();
69
+ if (text.length === 0) return undefined;
70
+
71
+ // `http=127.0.0.1:7890;https=127.0.0.1:7890` — prefer the HTTPS entry.
72
+ if (text.includes('=') && !text.includes('://')) {
73
+ const entries = new Map();
74
+ for (const part of text.split(';')) {
75
+ const at = part.indexOf('=');
76
+ if (at === -1) continue;
77
+ entries.set(part.slice(0, at).trim().toLowerCase(), part.slice(at + 1).trim());
78
+ }
79
+ text = entries.get('https') ?? entries.get('http') ?? entries.get('socks') ?? [...entries.values()][0] ?? '';
80
+ if (text.length === 0) return undefined;
81
+ }
82
+
83
+ if (!text.includes('://')) text = `http://${text}`;
84
+ let url;
85
+ try {
86
+ url = new URL(text);
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ if (url.hostname.length === 0) return undefined;
92
+ const port = url.port.length > 0 ? Number(url.port) : url.protocol === 'https:' ? 443 : 80;
93
+ if (!Number.isInteger(port) || port <= 0) return undefined;
94
+ return { host: url.hostname, port, scheme: url.protocol.replace(':', '') };
95
+ }
96
+
97
+ /**
98
+ * Decide whether a host must skip the proxy.
99
+ *
100
+ * `NO_PROXY` and WinINET's `ProxyOverride` share a syntax: `*` for everything,
101
+ * a leading dot or bare domain for a suffix, `<local>` for plain host names,
102
+ * and `host:port` entries whose port part is ignored here.
103
+ *
104
+ * @param hostname - target host.
105
+ * @param patterns - comma/semicolon-separated bypass list.
106
+ * @returns whether the host is exempt.
107
+ */
108
+ export function hostBypassed(hostname, patterns) {
109
+ const host = String(hostname ?? '').toLowerCase();
110
+ if (host.length === 0) return false;
111
+ // The proxy on this machine listens on loopback, so proxying loopback would
112
+ // only add a hop through itself.
113
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0') return true;
114
+ if (patterns === undefined || patterns === null) return false;
115
+
116
+ for (const raw of String(patterns).split(/[,;]/)) {
117
+ const entry = raw.trim().toLowerCase();
118
+ if (entry.length === 0) continue;
119
+ if (entry === '*') return true;
120
+ if (entry === '<local>' && !host.includes('.')) return true;
121
+ const withoutPort = entry.includes(':') && entry.split(':').length === 2 && /^\d+$/.test(entry.split(':')[1])
122
+ ? entry.split(':')[0]
123
+ : entry;
124
+ const bare = withoutPort.startsWith('.') ? withoutPort.slice(1) : withoutPort;
125
+ if (host === bare || host.endsWith(`.${bare}`)) return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * Resolve the proxy this machine uses for outbound HTTPS.
132
+ *
133
+ * @param options - `env` overrides the process environment; `platform` and
134
+ * `timeoutMs` exist for tests.
135
+ * @returns `{ host, port, source, bypass }` or undefined when the connection is
136
+ * direct.
137
+ */
138
+ export async function readSystemProxy(options = {}) {
139
+ const env = options.env ?? process.env;
140
+ const bypass = BYPASS_ENV_KEYS.map(key => env[key]).find(value => typeof value === 'string' && value.length > 0);
141
+
142
+ for (const key of PROXY_ENV_KEYS) {
143
+ const parsed = parseProxy(env[key]);
144
+ if (parsed !== undefined) return { ...parsed, source: key, bypass };
145
+ }
146
+
147
+ const platform = options.platform ?? process.platform;
148
+ if (platform !== 'win32') return undefined;
149
+
150
+ const text = await queryRegistry(options.timeoutMs ?? 4000);
151
+ if (text === undefined) return undefined;
152
+ if (registryValue(text, 'ProxyEnable') !== '0x1') return undefined;
153
+ const parsed = parseProxy(registryValue(text, 'ProxyServer'));
154
+ if (parsed === undefined) return undefined;
155
+ return {
156
+ ...parsed,
157
+ source: 'registry:Internet Settings',
158
+ bypass: bypass ?? registryValue(text, 'ProxyOverride'),
159
+ };
160
+ }
161
+
162
+ /** Establish the TCP tunnel a proxied HTTPS request runs through. */
163
+ function openTunnel(proxy, target, timeoutMs, signal) {
164
+ return new Promise((resolve, reject) => {
165
+ const request = http.request({
166
+ host: proxy.host,
167
+ port: proxy.port,
168
+ method: 'CONNECT',
169
+ path: `${target.hostname}:${target.port.length > 0 ? target.port : '443'}`,
170
+ headers: { host: `${target.hostname}:${target.port.length > 0 ? target.port : '443'}` },
171
+ agent: false,
172
+ timeout: timeoutMs,
173
+ });
174
+ const fail = error => {
175
+ request.destroy();
176
+ reject(error);
177
+ };
178
+ request.on('connect', (response, socket) => {
179
+ if (response.statusCode !== 200) {
180
+ socket.destroy();
181
+ reject(new Error(`proxy CONNECT ${proxy.host}:${proxy.port} answered HTTP ${response.statusCode}`));
182
+ return;
183
+ }
184
+ resolve(socket);
185
+ });
186
+ request.on('timeout', () => fail(new Error(`proxy CONNECT ${proxy.host}:${proxy.port} timed out`)));
187
+ request.on('error', error => fail(new Error(`proxy CONNECT ${proxy.host}:${proxy.port} failed: ${error.message}`)));
188
+ if (signal !== undefined) {
189
+ if (signal.aborted) fail(new Error('request aborted'));
190
+ else signal.addEventListener('abort', () => fail(new Error('request aborted')), { once: true });
191
+ }
192
+ request.end();
193
+ });
194
+ }
195
+
196
+ /** Open a TLS socket, directly or through a proxy tunnel. */
197
+ async function openSocket(target, proxy, timeoutMs, signal) {
198
+ if (proxy === undefined) {
199
+ return tls.connect({
200
+ host: target.hostname,
201
+ port: Number(target.port.length > 0 ? target.port : '443'),
202
+ servername: target.hostname,
203
+ signal,
204
+ });
205
+ }
206
+ const tunnel = await openTunnel(proxy, target, timeoutMs, signal);
207
+ return tls.connect({ socket: tunnel, servername: target.hostname, signal });
208
+ }
209
+
210
+ /**
211
+ * One HTTPS request that works behind a mandatory proxy.
212
+ *
213
+ * @param options - `url`, `method`, `headers`, `body`, `timeoutMs`, `signal`,
214
+ * and the `proxy` returned by `readSystemProxy`.
215
+ * @returns `{ status, headers, text }`.
216
+ */
217
+ export async function httpsRequest(options) {
218
+ const target = new URL(options.url);
219
+ const timeoutMs = options.timeoutMs ?? 15000;
220
+ const proxy = options.proxy !== undefined && !hostBypassed(target.hostname, options.proxy.bypass)
221
+ ? options.proxy
222
+ : undefined;
223
+ const port = Number(target.port.length > 0 ? target.port : '443');
224
+
225
+ const socket = await openSocket(target, proxy, timeoutMs, options.signal);
226
+ try {
227
+ return await new Promise((resolve, reject) => {
228
+ // The socket is already the right connection (direct TLS, or TLS
229
+ // inside a proxy tunnel), so the agent hands it straight back
230
+ // instead of dialling `target` itself.
231
+ const agent = new http.Agent({ keepAlive: false, maxSockets: 1 });
232
+ agent.createConnection = () => socket;
233
+ const headers = { connection: 'close', ...options.headers };
234
+ if (options.body !== undefined) headers['content-length'] = Buffer.byteLength(options.body);
235
+ const request = http.request({
236
+ host: target.hostname,
237
+ port,
238
+ path: `${target.pathname}${target.search}`,
239
+ method: options.method ?? 'GET',
240
+ agent,
241
+ headers,
242
+ timeout: timeoutMs,
243
+ });
244
+ const fail = error => {
245
+ request.destroy();
246
+ reject(error);
247
+ };
248
+ request.on('response', response => {
249
+ const chunks = [];
250
+ response.on('data', chunk => chunks.push(chunk));
251
+ response.on('end', () => resolve({
252
+ status: response.statusCode ?? 0,
253
+ headers: response.headers,
254
+ text: Buffer.concat(chunks).toString('utf8'),
255
+ }));
256
+ response.on('error', fail);
257
+ });
258
+ request.on('timeout', () => fail(new Error(`request to ${target.hostname} timed out after ${timeoutMs}ms`)));
259
+ request.on('error', error => fail(new Error(`request to ${target.hostname} failed: ${error.code ?? ''} ${error.message}`)));
260
+ if (options.signal !== undefined) {
261
+ if (options.signal.aborted) fail(new Error('request aborted'));
262
+ else options.signal.addEventListener('abort', () => fail(new Error('request aborted')), { once: true });
263
+ }
264
+ if (options.body !== undefined) request.write(options.body);
265
+ request.end();
266
+ });
267
+ }
268
+ finally {
269
+ socket.destroy();
270
+ }
271
+ }
272
+
273
+ /**
274
+ * A `fetch`-shaped function the usage client can be constructed with.
275
+ *
276
+ * It keeps the callers in `usage.js` unchanged: same arguments, same
277
+ * `Response`-like surface, with the proxy resolved once per process.
278
+ *
279
+ * @param options - `env`, `platform` and `timeoutMs` are forwarded to
280
+ * `readSystemProxy`; `proxy` skips discovery entirely.
281
+ * @returns an async function returning `{ ok, status, text() }`.
282
+ */
283
+ export function proxyAwareFetch(options = {}) {
284
+ // An explicitly supplied `proxy` (including `null` for "direct") replaces
285
+ // discovery; omitting the key asks for it.
286
+ const explicit = Object.hasOwn(options, 'proxy');
287
+ let resolved;
288
+ let ready = false;
289
+ return async (url, init = {}) => {
290
+ if (!ready) {
291
+ ready = true;
292
+ try {
293
+ resolved = explicit ? options.proxy ?? null : (await readSystemProxy(options)) ?? null;
294
+ }
295
+ catch {
296
+ resolved = null;
297
+ }
298
+ }
299
+ const result = await httpsRequest({
300
+ url,
301
+ method: init.method,
302
+ headers: init.headers,
303
+ body: init.body,
304
+ signal: init.signal,
305
+ timeoutMs: options.timeoutMs ?? 15000,
306
+ proxy: resolved ?? undefined,
307
+ });
308
+ return {
309
+ ok: result.status >= 200 && result.status < 300,
310
+ status: result.status,
311
+ text: async () => result.text,
312
+ };
313
+ };
314
+ }
315
+
316
+ /** Human-readable proxy description for a status surface. */
317
+ export function describeProxy(proxy) {
318
+ if (proxy === undefined || proxy === null) return 'direct (no proxy configured)';
319
+ return `${proxy.scheme ?? 'http'}://${proxy.host}:${proxy.port} (${proxy.source})`;
320
+ }
321
+
322
+ /** Settings values that mean "never route a child through a proxy". */
323
+ const DIRECT_PROXY_VALUES = new Set(['none', 'direct', 'off', 'no', 'false']);
324
+
325
+ /**
326
+ * Whether a `proxy` setting asks for a direct connection.
327
+ *
328
+ * Exported so a caller can tell "the operator said direct" apart from "the
329
+ * operator typed something that is not a proxy at all" — `resolveChildProxy`
330
+ * answers `undefined` for both, and only the second deserves a warning.
331
+ *
332
+ * @param value - the `proxy` setting.
333
+ */
334
+ export function proxySettingIsDirect(value) {
335
+ return typeof value === 'string' && DIRECT_PROXY_VALUES.has(value.trim().toLowerCase());
336
+ }
337
+
338
+ /**
339
+ * Environment variables that make a child process reach the network through
340
+ * `proxy`.
341
+ *
342
+ * Only `HTTPS_PROXY` / `HTTP_PROXY` and their lowercase twins are exported, and
343
+ * that is not a style choice — it is what the Cursor CLI actually reads. Measured
344
+ * on one machine with `agent --list-models`:
345
+ *
346
+ * - no variable at all ................... 31 models
347
+ * - `HTTPS_PROXY` ....................... 265 models
348
+ * - `https_proxy` or `HTTP_PROXY` ....... 265 models
349
+ * - `ALL_PROXY` alone .................... 31 models (ignored)
350
+ * - `HTTPS_PROXY` plus `NO_PROXY=<cursor host>` ... 31 models (honoured)
351
+ *
352
+ * Those last two are the whole diagnosis of the "some models are missing"
353
+ * symptom: the CLI lists only what the direct route is allowed to publish, and
354
+ * it never guessed that this machine has a proxy the registry knows about.
355
+ *
356
+ * `NO_PROXY` is deliberately never synthesised here. WinINET's `ProxyOverride`
357
+ * is written in a syntax (`192.168.*`, `<local>`) that no Node proxy agent
358
+ * understands, and the child's one job is to reach the API *through* the proxy.
359
+ * An inherited `NO_PROXY` still reaches the child, because callers spread the
360
+ * parent environment first.
361
+ *
362
+ * @param proxy - a resolved proxy, or undefined for a direct connection.
363
+ * @returns the variables to merge in; empty object for a direct connection.
364
+ */
365
+ export function proxyEnvironment(proxy) {
366
+ if (proxy === undefined || proxy === null) return {};
367
+ const url = `${proxy.scheme ?? 'http'}://${proxy.host}:${proxy.port}`;
368
+ return { HTTPS_PROXY: url, https_proxy: url, HTTP_PROXY: url, http_proxy: url };
369
+ }
370
+
371
+ /**
372
+ * `env` with the proxy variables merged in.
373
+ *
374
+ * A direct connection returns the caller's own object untouched, so a machine
375
+ * with no proxy spawns children with exactly the environment it always had.
376
+ *
377
+ * @param env - the environment to start from.
378
+ * @param proxy - a resolved proxy, or undefined for a direct connection.
379
+ */
380
+ export function withProxyEnvironment(env, proxy) {
381
+ const additions = proxyEnvironment(proxy);
382
+ if (Object.keys(additions).length === 0) return env;
383
+ return { ...env, ...additions };
384
+ }
385
+
386
+ /**
387
+ * Decide which proxy a child process should use.
388
+ *
389
+ * The panel's `proxy` setting drives this, and its three kinds of answer mean
390
+ * genuinely different things:
391
+ *
392
+ * - `''` (the default) is AUTO: ask the machine. This is what makes the full
393
+ * model catalogue appear without anyone configuring anything, and it is why
394
+ * the default is not "direct".
395
+ * - `none` (also `direct`, `off`, `no`) is the escape hatch for a machine
396
+ * whose registry proxy is stale or wrong.
397
+ * - anything else is taken literally as a proxy endpoint, in every shape
398
+ * {@link parseProxy} accepts.
399
+ *
400
+ * @param configured - the `proxy` setting.
401
+ * @param options - `env`, `platform`, `timeoutMs`; forwarded, and used by tests.
402
+ * @returns the resolved proxy, or undefined for a direct connection.
403
+ */
404
+ export async function resolveChildProxy(configured, options = {}) {
405
+ const value = typeof configured === 'string' ? configured.trim() : '';
406
+ if (DIRECT_PROXY_VALUES.has(value.toLowerCase())) return undefined;
407
+ if (value.length === 0) return await readSystemProxy(options);
408
+
409
+ const parsed = parseProxy(value);
410
+ if (parsed === undefined) return undefined;
411
+ const env = options.env ?? process.env;
412
+ const bypass = BYPASS_ENV_KEYS.map(key => env[key]).find(text => typeof text === 'string' && text.length > 0);
413
+ return { ...parsed, source: 'setting', bypass };
414
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Locate the Cursor CLI `agent` executable without reading credentials.
3
+ *
4
+ * Harness subprocess spawn does not use a shell, so on Windows we must resolve
5
+ * `node.exe` (an `.exe`), never the `agent.cmd` shim. The official Win32
6
+ * installer ships `agent.cmd` -> `cursor-agent.ps1` -> bundled `node.exe` +
7
+ * `index.js`, so the usable spawn target is the bundled Node runtime.
8
+ */
9
+ import { existsSync, readdirSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { delimiter, dirname, join } from 'node:path';
12
+ import { cliModelArg } from './models.js';
13
+
14
+ const VERSION_DIR = /^\d{4}\.\d{1,2}\.\d{1,2}(-\d{2}-\d{2}-\d{2})?-[a-f0-9]+$/;
15
+
16
+ /**
17
+ * Whether `path` is a usable spawn target (exists; on win32 must be `.exe`).
18
+ * @param path - candidate file.
19
+ */
20
+ export function isSpawnableAgent(path) {
21
+ if (!existsSync(path))
22
+ return false;
23
+ if (process.platform === 'win32')
24
+ return path.toLowerCase().endsWith('.exe');
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * Sort key for official `versions/` folder names (`YYYY.MM.DD-commit`).
30
+ * @param name - directory name.
31
+ */
32
+ export function versionSortKey(name) {
33
+ const datePart = name.split('-')[0] ?? '';
34
+ const parts = datePart.split('.');
35
+ if (parts.length !== 3)
36
+ return 0;
37
+ const year = parts[0] ?? '';
38
+ const month = (parts[1] ?? '').padStart(2, '0');
39
+ const day = (parts[2] ?? '').padStart(2, '0');
40
+ const value = Number(`${year}${month}${day}`);
41
+ return Number.isFinite(value) ? value : 0;
42
+ }
43
+
44
+ /**
45
+ * Unwrap a Cursor CLI install root to `node.exe` + `index.js`.
46
+ * @param root - `%LOCALAPPDATA%\cursor-agent` or a version directory.
47
+ */
48
+ export function resolveNodeAgentBundle(root) {
49
+ const directNode = join(root, process.platform === 'win32' ? 'node.exe' : 'node');
50
+ const directIndex = join(root, 'index.js');
51
+ if (isSpawnableAgent(directNode) && existsSync(directIndex)) {
52
+ return { command: directNode, entry: directIndex };
53
+ }
54
+ const versionsRoot = join(root, 'versions');
55
+ if (!existsSync(versionsRoot))
56
+ return undefined;
57
+ const dirs = readdirSync(versionsRoot, { withFileTypes: true })
58
+ .filter(entry => entry.isDirectory() && VERSION_DIR.test(entry.name))
59
+ .sort((left, right) => versionSortKey(right.name) - versionSortKey(left.name));
60
+ for (const entry of dirs) {
61
+ const nested = resolveNodeAgentBundle(join(versionsRoot, entry.name));
62
+ if (nested !== undefined)
63
+ return nested;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /**
69
+ * Search PATH for `agent` / `agent.exe`.
70
+ * @param pathEnv - `process.env.PATH`.
71
+ */
72
+ export function searchPathForAgent(pathEnv) {
73
+ if (pathEnv === undefined || pathEnv === '')
74
+ return undefined;
75
+ const names = process.platform === 'win32' ? ['agent.exe'] : ['agent'];
76
+ for (const dir of pathEnv.split(delimiter)) {
77
+ if (dir === '')
78
+ continue;
79
+ for (const name of names) {
80
+ const candidate = join(dir, name);
81
+ if (isSpawnableAgent(candidate))
82
+ return candidate;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * Search PATH for `agent.cmd` so the official Win32 shim can be unwrapped.
90
+ * @param pathEnv - `process.env.PATH`.
91
+ */
92
+ export function searchPathForAgentCmd(pathEnv) {
93
+ if (pathEnv === undefined || pathEnv === '')
94
+ return undefined;
95
+ for (const dir of pathEnv.split(delimiter)) {
96
+ if (dir === '')
97
+ continue;
98
+ const cmd = join(dir, 'agent.cmd');
99
+ if (existsSync(cmd))
100
+ return dirname(cmd);
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /**
106
+ * Well-known install locations from Cursor's official installer.
107
+ * @param env - process env (tests stub LOCALAPPDATA / HOME).
108
+ */
109
+ export function wellKnownAgentPaths(env) {
110
+ const home = env.HOME ?? env.USERPROFILE ?? homedir();
111
+ const local = env.LOCALAPPDATA;
112
+ const found = [];
113
+ if (local !== undefined && local !== '') {
114
+ found.push({ path: join(local, 'cursor-agent'), source: 'localappdata' });
115
+ }
116
+ found.push({ path: join(home, '.local', 'share', 'cursor-agent'), source: 'home-local' });
117
+ return found;
118
+ }
119
+
120
+ /**
121
+ * Split a config `command` string into one or two path tokens.
122
+ * Supports an unquoted path, a quoted path, or `runtime entry` (each optionally
123
+ * quoted) so Windows paths with spaces stay intact.
124
+ * @param text - trimmed config value.
125
+ * @returns one or two path strings.
126
+ */
127
+ export function splitCommandParts(text) {
128
+ const two = /^"([^"]+)"\s+"([^"]+)"$/.exec(text)
129
+ ?? /^"([^"]+)"\s+(\S+)$/.exec(text)
130
+ ?? /^(\S+)\s+"([^"]+)"$/.exec(text)
131
+ ?? /^(\S+)\s+(\S+)$/.exec(text);
132
+ if (two !== null)
133
+ return [two[1], two[2]];
134
+ const one = /^"([^"]+)"$/.exec(text);
135
+ if (one !== null)
136
+ return [one[1]];
137
+ return [text];
138
+ }
139
+
140
+ /**
141
+ * Resolve a plugin-config `command` override into a spawn target.
142
+ *
143
+ * Accepts a spawnable binary, an official install root (or version dir), an
144
+ * `agent.cmd` shim, or `node.exe index.js` as two whitespace-separated paths.
145
+ * Returns `undefined` when the override cannot be used — callers must not fall
146
+ * back to auto-discovery in that case, or a typo would silently be ignored.
147
+ *
148
+ * @param command - raw `Config.command` string.
149
+ * @returns `{command, entry?, source:'config'}` or `undefined`.
150
+ */
151
+ export function resolveConfiguredAgent(command) {
152
+ const text = String(command ?? '').trim();
153
+ if (text === '')
154
+ return undefined;
155
+
156
+ const parts = splitCommandParts(text);
157
+ if (parts.length >= 2) {
158
+ const runtime = parts[0];
159
+ const entry = parts[1];
160
+ if (isSpawnableAgent(runtime) && existsSync(entry))
161
+ return { command: runtime, entry, source: 'config' };
162
+ return undefined;
163
+ }
164
+
165
+ const path = parts[0];
166
+ if (path === undefined || path === '' || !existsSync(path))
167
+ return undefined;
168
+
169
+ if (path.toLowerCase().endsWith('.cmd')) {
170
+ const bundle = resolveNodeAgentBundle(dirname(path));
171
+ if (bundle !== undefined)
172
+ return { ...bundle, source: 'config' };
173
+ return undefined;
174
+ }
175
+
176
+ const bundle = resolveNodeAgentBundle(path);
177
+ if (bundle !== undefined)
178
+ return { ...bundle, source: 'config' };
179
+
180
+ if (isSpawnableAgent(path))
181
+ return { command: path, entry: undefined, source: 'config' };
182
+
183
+ return undefined;
184
+ }
185
+
186
+ /**
187
+ * Resolve the Cursor CLI spawn target, or `undefined` when missing.
188
+ * The returned `command` + `entry` pair is spawned as `[command, entry, ...args]`.
189
+ *
190
+ * When `options.command` is a non-empty string, only that override is tried
191
+ * (`source: 'config'`). Auto-discovery runs only when the override is absent.
192
+ *
193
+ * @param env - process env.
194
+ * @param options - optional `{ command }` from plugin config.
195
+ */
196
+ export function resolveAgent(env = process.env, options = {}) {
197
+ const override = typeof options.command === 'string' ? options.command.trim() : '';
198
+ if (override !== '')
199
+ return resolveConfiguredAgent(override);
200
+
201
+ const fromPath = searchPathForAgent(env.PATH ?? env.Path);
202
+ if (fromPath !== undefined) {
203
+ return { command: fromPath, entry: undefined, source: 'path' };
204
+ }
205
+ const cmdRoot = searchPathForAgentCmd(env.PATH ?? env.Path);
206
+ if (cmdRoot !== undefined) {
207
+ const bundle = resolveNodeAgentBundle(cmdRoot);
208
+ if (bundle !== undefined)
209
+ return { ...bundle, source: 'path-cmd' };
210
+ }
211
+ const local = env.LOCALAPPDATA;
212
+ if (local !== undefined && local !== '') {
213
+ const bundle = resolveNodeAgentBundle(join(local, 'cursor-agent'));
214
+ if (bundle !== undefined)
215
+ return { ...bundle, source: 'localappdata' };
216
+ }
217
+ for (const candidate of wellKnownAgentPaths(env)) {
218
+ const bundle = resolveNodeAgentBundle(candidate.path);
219
+ if (bundle !== undefined)
220
+ return { ...bundle, source: candidate.source };
221
+ if (isSpawnableAgent(candidate.path)) {
222
+ return { command: candidate.path, entry: undefined, source: candidate.source };
223
+ }
224
+ }
225
+ return undefined;
226
+ }
227
+
228
+ /**
229
+ * Build the argv tail for an ACP spawn.
230
+ *
231
+ * `--model` must precede the `acp` subcommand. The flag takes the ALIAS the CLI
232
+ * itself lists (`--list-models`), never a parameterised id and never the
233
+ * protocol-only `default[]` — that is exactly the split `cliModelArg` encodes,
234
+ * and getting it wrong makes the CLI refuse to start rather than fail a turn.
235
+ * An empty result (which is what `auto`, `default[]` and `''` all produce) means
236
+ * the flag is omitted, so the CLI applies the account default and the session's
237
+ * own `set_config_option` remains the single owner of the model choice.
238
+ *
239
+ * @param resolved - `resolveAgent` result.
240
+ * @param model - the requested model, in either name space.
241
+ */
242
+ export function acpArgv(resolved, model) {
243
+ const base = [];
244
+ if (resolved.entry !== undefined)
245
+ base.push(resolved.entry);
246
+ const flag = cliModelArg(model);
247
+ if (flag !== '')
248
+ base.push('--model', flag);
249
+ base.push('acp');
250
+ return base;
251
+ }