@cursorx/cli 0.0.3

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.
@@ -0,0 +1,558 @@
1
+ /* global URL, Headers, Request, Response, process */
2
+ // CursorX BYOK fetch monkey-patch — installed by T-304 applyPatch().
3
+ // Runs inside Cursor's own runtime (Node ESM in bootstrap-fork.js + main.js,
4
+ // Electron renderer in workbench.desktop.main.js) at module-init time.
5
+ //
6
+ // Contract:
7
+ // - Intercepts ONLY requests targeting BYOK_HOSTS (Cursor's chat/Tab/agent endpoints
8
+ // per spike final §2.2). Rewrites URL to ${CURSORX_ROUTER_URL}/<path> + adds
9
+ // X-CursorX-Original-Host header. Body, method, and ALL other headers are forwarded
10
+ // untouched — the Router's adapter layer is responsible for re-signing.
11
+ // - For every other host (cursor.com auth, api3.cursor.sh metrics, repo42, *.updates,
12
+ // and any non-Cursor origin) → STRICT PASSTHROUGH. Authorization / X-Api-Key / body
13
+ // are never read, copied, or logged. (security.md §5 / spike §2.4 red line.)
14
+ // - If CURSORX_ROUTER_URL is unset AND ~/.cursorx/runtime.json has no port → passthrough.
15
+ // Fail-open at the patch layer is the SAFE direction here: it preserves Cursor's
16
+ // normal behavior; intercept-failure is the user-visible signal.
17
+ // - T-307 BYOK switch: if Router wrote `byok:false` to runtime.json (snapshot of
18
+ // routes.json), strict passthrough for ALL hosts (including BYOK_HOSTS). Default
19
+ // (byok absent OR true) ⇒ intercept BYOK_HOSTS normally. v0.1 limitation: hook
20
+ // reads runtime.json ONCE at first fetch (cached for process lifetime); toggling
21
+ // BYOK from Extension takes effect on NEXT Cursor restart — consistent with RQ-01
22
+ // (byok=false ≡ patchStatus=paused).
23
+ // - T-308 Router URL: falls back to `http://127.0.0.1:${runtime.json.port}` derived
24
+ // from the Router's startup-time runtime.json snapshot when env / globalThis are unset.
25
+ //
26
+ // Cross-context notes:
27
+ // - File is plain ESM `.js`; Cursor's package.json declares "type":"module" so this
28
+ // loads in both Node and renderer contexts. No bundler / transpile step.
29
+ // - Idempotent install: globalThis.__CURSORX_FETCH_PATCHED__ guards double-wrap.
30
+ // - All Node built-ins lazy-loaded via dynamic require under try/catch so the hook
31
+ // never throws into Cursor's stack if a context (e.g. renderer with sandbox on)
32
+ // forbids fs access.
33
+
34
+ const BYOK_HOSTS = new Set([
35
+ // Spike final §2.2 — Cursor BYOK-eligible upstream hosts.
36
+ // (Minify variable names like XBt/Ssc/CFr/Csc/kFr/Q9p drift across Cursor versions —
37
+ // see T-304-P2 spike 2026-05-24 §2; we route by hostname Set, not byte offset.)
38
+ "api2.cursor.sh", // chat
39
+ "api4.cursor.sh", // Cursor Tab / autocomplete
40
+ "agent.api5.cursor.sh", // Agent main
41
+ "agentn.api5.cursor.sh", // Agent backup
42
+ "agent-gcpp-uswest.api5.cursor.sh", // Agent region
43
+ ]);
44
+
45
+ // Paths under BYOK hosts that MUST passthrough — Squirrel updates and any future
46
+ // non-BYOK endpoint colocated on a BYOK host.
47
+ const PASSTHROUGH_PATH_PATTERNS = [
48
+ /^\/updates(\/|$)/, // Squirrel auto-update (spike §2.4)
49
+ ];
50
+
51
+ // T-401 — Path matcher for Cursor's model-list RPC. Phase A grep found
52
+ // `aiserver.v1.AvailableModelsResponse` protobuf + `_setAvailableModels` setter +
53
+ // `refreshAPIKeyModels` caller; Phase B sandbox capture confirmed all aiserver.v1
54
+ // RPCs use POST + application/json (NOT protobuf binary). The actual model-list
55
+ // RPC service/method name is dynamic-concatenated and not statically grep-able;
56
+ // we cover plausible names with a permissive matcher and ALSO fall open if the
57
+ // response body doesn't look like AvailableModelsResponse (§wrapModelListResponse).
58
+ //
59
+ // Patterns covered:
60
+ // /aiserver.v1.AiSettingsService/GetAvailableModels
61
+ // /aiserver.v1.AiSettingsService/RefreshAvailableModels
62
+ // /aiserver.v1.AiSettingsService/ListModels
63
+ // /aiserver.v1.SettingsService/GetAvailableModels
64
+ // /aiserver.v1.ModelService/ListModels
65
+ // /aiserver.v1.ModelService/GetModels
66
+ const MODEL_LIST_PATH_RE =
67
+ /^\/aiserver\.v1\.[A-Za-z0-9_]*(?:Settings|Model|Ai)[A-Za-z0-9_]*Service\/[A-Za-z0-9_]*(?:Available|List|Get)[A-Za-z0-9_]*Models?$/;
68
+
69
+ // T-307 / T-308 — lazy-loaded runtime.json reader (async to stay safe across
70
+ // Cursor's three runtime contexts — Node ESM bootstrap-fork.js + main.js, and
71
+ // the Electron renderer workbench.desktop.main.js — using dynamic `import()` so
72
+ // `node:fs/promises` etc. resolve only when the runtime supports them).
73
+ // Cached forever on first call (process-lifetime). Read failure ⇒ null cache.
74
+ // We deliberately do NOT watch the file: Cursor runtime is not a place to
75
+ // install fs.watch; toggling BYOK in Extension requires Cursor restart, which
76
+ // matches RQ-01 (byok=false ≡ patchStatus=paused, an explicit restart paradigm).
77
+ let _runtimeCache; // { port?: number, byok?: boolean } | null | undefined
78
+ async function readRuntimeOnce() {
79
+ if (_runtimeCache !== undefined) return _runtimeCache;
80
+ _runtimeCache = null;
81
+ try {
82
+ const fs = await import("node:fs/promises");
83
+ let file;
84
+ // Test seam: vitest sets this to a fixture path so we can assert read
85
+ // semantics without touching the user's real ~/.cursorx/runtime.json.
86
+ if (
87
+ typeof globalThis !== "undefined" &&
88
+ typeof globalThis.__CURSORX_RUNTIME_FILE_FOR_TESTS__ === "string"
89
+ ) {
90
+ file = globalThis.__CURSORX_RUNTIME_FILE_FOR_TESTS__;
91
+ } else {
92
+ const os = await import("node:os");
93
+ const path = await import("node:path");
94
+ file = path.join(os.homedir(), ".cursorx", "runtime.json");
95
+ }
96
+ const raw = await fs.readFile(file, "utf-8");
97
+ const parsed = JSON.parse(raw);
98
+ if (parsed && typeof parsed === "object") {
99
+ const out = {};
100
+ if (typeof parsed.port === "number") out.port = parsed.port;
101
+ if (typeof parsed.byok === "boolean") out.byok = parsed.byok;
102
+ _runtimeCache = out;
103
+ }
104
+ } catch {
105
+ // ENOENT / parse fail / permission / dynamic-import-failed ⇒ null cache;
106
+ // getters fall open.
107
+ }
108
+ return _runtimeCache;
109
+ }
110
+
111
+ async function getRouterUrl() {
112
+ // Cursor's renderer also exposes process.env (Electron contextIsolation off by default
113
+ // in Cursor 3.2.16; if Cursor flips this on a future version, globalThis fallback fires).
114
+ try {
115
+ if (
116
+ typeof process !== "undefined" &&
117
+ process &&
118
+ process.env &&
119
+ typeof process.env.CURSORX_ROUTER_URL === "string" &&
120
+ process.env.CURSORX_ROUTER_URL.length > 0
121
+ ) {
122
+ return process.env.CURSORX_ROUTER_URL;
123
+ }
124
+ } catch {
125
+ /* renderer-context process access may throw under sandbox; fall through */
126
+ }
127
+ try {
128
+ if (
129
+ typeof globalThis !== "undefined" &&
130
+ typeof globalThis.__CURSORX_ROUTER_URL__ === "string" &&
131
+ globalThis.__CURSORX_ROUTER_URL__.length > 0
132
+ ) {
133
+ return globalThis.__CURSORX_ROUTER_URL__;
134
+ }
135
+ } catch {
136
+ /* defensive */
137
+ }
138
+ // T-308 — fall back to runtime.json port snapshot written by Router at startup.
139
+ const runtime = await readRuntimeOnce();
140
+ if (runtime && typeof runtime.port === "number" && runtime.port > 0) {
141
+ return `http://127.0.0.1:${runtime.port}`;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ // T-307 — explicit BYOK toggle (false ⇒ strict passthrough; absent / true ⇒ intercept).
147
+ // Fallback chain: env → globalThis → runtime.json. Default = true (fail-open).
148
+ async function isByokEnabled() {
149
+ try {
150
+ if (
151
+ typeof process !== "undefined" &&
152
+ process &&
153
+ process.env &&
154
+ typeof process.env.CURSORX_BYOK_ENABLED === "string"
155
+ ) {
156
+ const v = process.env.CURSORX_BYOK_ENABLED.toLowerCase();
157
+ if (v === "false" || v === "0") return false;
158
+ if (v === "true" || v === "1") return true;
159
+ }
160
+ } catch {
161
+ /* defensive */
162
+ }
163
+ try {
164
+ if (
165
+ typeof globalThis !== "undefined" &&
166
+ typeof globalThis.__CURSORX_BYOK_ENABLED__ === "boolean"
167
+ ) {
168
+ return globalThis.__CURSORX_BYOK_ENABLED__;
169
+ }
170
+ } catch {
171
+ /* defensive */
172
+ }
173
+ const runtime = await readRuntimeOnce();
174
+ if (runtime && typeof runtime.byok === "boolean") return runtime.byok;
175
+ return true; // Fail-open: when unknown, behave as if BYOK is on.
176
+ }
177
+
178
+ function shouldIntercept(urlObj) {
179
+ if (!BYOK_HOSTS.has(urlObj.hostname)) return false;
180
+ for (const pattern of PASSTHROUGH_PATH_PATTERNS) {
181
+ if (pattern.test(urlObj.pathname)) return false;
182
+ }
183
+ return true;
184
+ }
185
+
186
+ // T-401 — matchesModelListPath: per the spike Phase B finding, model-list RPC
187
+ // returns AvailableModelsResponse over POST + application/json. Static grep
188
+ // can't pin the exact service/method (dynamic-concatenated), so we cover the
189
+ // plausible name space and combine with a response-shape guard downstream.
190
+ function matchesModelListPath(pathname) {
191
+ if (typeof pathname !== "string") return false;
192
+ return MODEL_LIST_PATH_RE.test(pathname);
193
+ }
194
+
195
+ // T-401 / T-402 — cursorXModelToCursorDetails: map a CursorX Model (FR-31 9
196
+ // fields: id / apiModel / displayName / shortName / providerId /
197
+ // contextTokenLimit / autoContextMaxTokens / capabilities / defaultOn) to the
198
+ // Cursor AvailableModels ModelDetails JSON shape. The shape is reverse-engineered
199
+ // from Cursor 3.5.x bundled `tBt` hardcoded fallback array (Phase A §2.3) and
200
+ // `workbench.desktop.main.js` minified ModelDetails class field decls
201
+ // (T-402 Phase A 2026-05-24 read-only grep). Notes:
202
+ // - `name` ← `id` (Cursor uses `name` as the unique model identifier).
203
+ // - `serverModelName` ← `apiModel` (string Cursor passes through to upstream).
204
+ // - `isUserAdded: true` is the keystone — without it Cursor's
205
+ // getValidatedDefaultModel may treat the injected model as a stale default
206
+ // and prune it (Phase A grep showed `userAddedModels` is the recognized
207
+ // extension surface for user-supplied models).
208
+ // - `supportsMaxMode` defaults false — `maxMode` is Cursor internal and PRD
209
+ // §7 doesn't declare it for v0.1; `supportsNonMaxMode: true` keeps the
210
+ // injected model selectable in the model picker.
211
+ // - T-402 (FR-32 2026-05-24): capability field map (caps.* → Cursor flag):
212
+ // agent → supportsAgent, cmdK → supportsCmdK, plan → supportsPlanMode,
213
+ // thinking → supportsThinking, images → supportsImages,
214
+ // sandboxing → supportsSandboxing. Cursor's bundled defaults (tBt):
215
+ // supportsAgent/PlanMode/Sandboxing/Images=true, CmdK/Thinking=false. We
216
+ // always emit explicit booleans (`=== true` gate) so missing fields fall
217
+ // to false rather than inheriting tBt's "true" defaults — this keeps the
218
+ // injected model's behavior consistent with what the user declared in
219
+ // providers.json (UI source of truth).
220
+ // - RQ-03 decision: thinking/images/sandboxing capabilities are SCHEMA-ONLY
221
+ // in v0.1 from the Router/Adapter perspective (no special routing); they
222
+ // still map to Cursor's protocol flags here because Cursor's UI consumes
223
+ // these flags directly (e.g. supportsCmdK gates the Cmd+K entry visibility
224
+ // per workbench.desktop.main.js pruning logic). v0.2 work (FR-77) will
225
+ // extend Router/Adapter behavior, not this mapping.
226
+ function cursorXModelToCursorDetails(model) {
227
+ const caps =
228
+ model && typeof model.capabilities === "object" && model.capabilities
229
+ ? model.capabilities
230
+ : {};
231
+ return {
232
+ name: String(model.id),
233
+ serverModelName: String(model.apiModel),
234
+ clientDisplayName: String(model.displayName),
235
+ inputboxShortModelName: String(model.shortName),
236
+ defaultOn: model.defaultOn === true,
237
+ supportsAgent: caps.agent === true,
238
+ supportsCmdK: caps.cmdK === true,
239
+ supportsPlanMode: caps.plan === true,
240
+ supportsSandboxing: caps.sandboxing === true,
241
+ supportsThinking: caps.thinking === true,
242
+ supportsImages: caps.images === true,
243
+ supportsMaxMode: false,
244
+ supportsNonMaxMode: true,
245
+ isRecommendedForBackgroundComposer: false,
246
+ isUserAdded: true,
247
+ parameterDefinitions: [],
248
+ variants: [],
249
+ legacySlugs: [],
250
+ idAliases: [],
251
+ };
252
+ }
253
+
254
+ // T-401 — wrapModelListResponse: take Cursor's upstream AvailableModelsResponse
255
+ // JSON and merge in CursorX-defined models. Fail-open at every step: if the
256
+ // response shape doesn't match what we expect, or any step throws, we return
257
+ // the original response untouched so Cursor's model picker continues working
258
+ // (Cursor will fall back to its hardcoded `tBt` defaults + upstream models).
259
+ async function wrapModelListResponse(response, cursorXModels) {
260
+ if (
261
+ !response ||
262
+ typeof response !== "object" ||
263
+ typeof response.status !== "number"
264
+ ) {
265
+ return response;
266
+ }
267
+ if (response.status < 200 || response.status >= 300) return response;
268
+ if (!Array.isArray(cursorXModels) || cursorXModels.length === 0)
269
+ return response;
270
+
271
+ let contentType;
272
+ try {
273
+ contentType = response.headers?.get?.("content-type");
274
+ } catch {
275
+ return response;
276
+ }
277
+ if (!/application\/json/i.test(contentType || "")) return response;
278
+
279
+ let originalBody;
280
+ try {
281
+ originalBody = await response.clone().json();
282
+ } catch {
283
+ return response;
284
+ }
285
+ if (!originalBody || typeof originalBody !== "object") return response;
286
+
287
+ // Shape guard: AvailableModelsResponse has models[] and model_names[]
288
+ // (Phase A §2.2 proto descriptor). If either is missing, this is NOT the
289
+ // model-list RPC despite the URL match — passthrough.
290
+ if (
291
+ !Array.isArray(originalBody.models) ||
292
+ !Array.isArray(originalBody.model_names)
293
+ ) {
294
+ return response;
295
+ }
296
+
297
+ const existingNames = new Set();
298
+ for (const m of originalBody.models) {
299
+ if (m && typeof m === "object" && typeof m.name === "string") {
300
+ existingNames.add(m.name);
301
+ }
302
+ }
303
+
304
+ const injectedDetails = [];
305
+ const injectedNames = [];
306
+ for (const model of cursorXModels) {
307
+ if (
308
+ !model ||
309
+ typeof model !== "object" ||
310
+ typeof model.id !== "string" ||
311
+ typeof model.apiModel !== "string" ||
312
+ typeof model.displayName !== "string" ||
313
+ typeof model.shortName !== "string"
314
+ ) {
315
+ continue;
316
+ }
317
+ if (existingNames.has(model.id)) continue;
318
+ try {
319
+ const details = cursorXModelToCursorDetails(model);
320
+ injectedDetails.push(details);
321
+ injectedNames.push(model.id);
322
+ existingNames.add(model.id);
323
+ } catch {
324
+ // per-model fail-open: skip but keep going.
325
+ }
326
+ }
327
+
328
+ if (injectedDetails.length === 0) return response;
329
+
330
+ const patched = {
331
+ ...originalBody,
332
+ models: [...originalBody.models, ...injectedDetails],
333
+ model_names: [...originalBody.model_names, ...injectedNames],
334
+ };
335
+
336
+ let json;
337
+ try {
338
+ json = JSON.stringify(patched);
339
+ } catch {
340
+ return response;
341
+ }
342
+
343
+ // Rebuild Response: keep status / preserve other headers; drop
344
+ // content-length so the runtime recomputes against the new body.
345
+ let headers;
346
+ try {
347
+ headers = new Headers(response.headers);
348
+ headers.delete("content-length");
349
+ headers.delete("content-encoding"); // body is now plain JSON, not whatever upstream encoded
350
+ } catch {
351
+ return response;
352
+ }
353
+
354
+ try {
355
+ return new Response(json, {
356
+ status: response.status,
357
+ statusText: response.statusText,
358
+ headers,
359
+ });
360
+ } catch {
361
+ return response;
362
+ }
363
+ }
364
+
365
+ // T-401 — fetchCursorXModels: pull the CursorX-defined models from the local
366
+ // Router's GET /models endpoint. Uses `realFetch` (NOT our patched fetch) to
367
+ // avoid recursing into our own interception logic. Fail-open at every step.
368
+ async function fetchCursorXModels(realFetch) {
369
+ const routerUrl = await getRouterUrl();
370
+ if (!routerUrl) return null;
371
+
372
+ let normalizedRouter;
373
+ try {
374
+ normalizedRouter = routerUrl.replace(/\/+$/, "");
375
+ } catch {
376
+ return null;
377
+ }
378
+
379
+ let resp;
380
+ try {
381
+ resp = await realFetch(normalizedRouter + "/models", {
382
+ method: "GET",
383
+ headers: { Accept: "application/json" },
384
+ });
385
+ } catch {
386
+ return null;
387
+ }
388
+
389
+ if (
390
+ !resp ||
391
+ typeof resp.status !== "number" ||
392
+ resp.status < 200 ||
393
+ resp.status >= 300
394
+ ) {
395
+ return null;
396
+ }
397
+
398
+ let body;
399
+ try {
400
+ body = await resp.json();
401
+ } catch {
402
+ return null;
403
+ }
404
+
405
+ if (!body || !Array.isArray(body.models)) return null;
406
+ return body.models;
407
+ }
408
+
409
+ function rewriteUrl(originalUrlString, routerUrlString) {
410
+ const orig = new URL(originalUrlString);
411
+ const router = new URL(routerUrlString);
412
+ // Preserve path + query exactly; router.origin replaces orig.origin.
413
+ return new URL(orig.pathname + orig.search, router.origin).toString();
414
+ }
415
+
416
+ function extractUrlString(input) {
417
+ if (typeof input === "string") return input;
418
+ if (input && typeof input === "object") {
419
+ if (typeof input.url === "string") return input.url;
420
+ if (input instanceof URL) return input.toString();
421
+ }
422
+ return null;
423
+ }
424
+
425
+ function patchFetch(realFetch) {
426
+ return async function cursorxFetch(input, init) {
427
+ // T-307 — BYOK off ⇒ strict passthrough for ALL hosts (including BYOK_HOSTS).
428
+ // Checked FIRST so we never touch URL parsing / header building when paused.
429
+ if (!(await isByokEnabled())) return realFetch(input, init);
430
+
431
+ const routerUrl = await getRouterUrl();
432
+ if (!routerUrl) return realFetch(input, init);
433
+
434
+ const originalUrl = extractUrlString(input);
435
+ if (!originalUrl) return realFetch(input, init);
436
+
437
+ let urlObj;
438
+ try {
439
+ urlObj = new URL(originalUrl);
440
+ } catch {
441
+ return realFetch(input, init);
442
+ }
443
+
444
+ if (!shouldIntercept(urlObj)) return realFetch(input, init);
445
+
446
+ // T-401 — model-list RPC branch: passthrough request, mutate response
447
+ // to inject CursorX-defined models. Distinct from chat URL rewrite below
448
+ // because Cursor expects an AvailableModelsResponse shape, NOT the
449
+ // Router's GET /models shape. Fail-open at every step.
450
+ if (matchesModelListPath(urlObj.pathname)) {
451
+ const upstreamResp = await realFetch(input, init);
452
+ try {
453
+ const cursorXModels = await fetchCursorXModels(realFetch);
454
+ if (cursorXModels) {
455
+ return await wrapModelListResponse(upstreamResp, cursorXModels);
456
+ }
457
+ } catch {
458
+ // Any failure → return original upstream response untouched.
459
+ }
460
+ return upstreamResp;
461
+ }
462
+
463
+ let rewritten;
464
+ try {
465
+ rewritten = rewriteUrl(originalUrl, routerUrl);
466
+ } catch {
467
+ // Malformed CURSORX_ROUTER_URL → fail-open: passthrough untouched.
468
+ return realFetch(input, init);
469
+ }
470
+
471
+ // Build headers: preserve original (Authorization included; Router decides handling).
472
+ let headers;
473
+ try {
474
+ headers = new Headers();
475
+ // Copy from init.headers first (init overrides Request defaults per fetch spec).
476
+ if (init && init.headers) {
477
+ const initHeaders = new Headers(init.headers);
478
+ initHeaders.forEach((v, k) => headers.set(k, v));
479
+ } else if (input && typeof input === "object" && input.headers) {
480
+ const reqHeaders = new Headers(input.headers);
481
+ reqHeaders.forEach((v, k) => headers.set(k, v));
482
+ }
483
+ headers.set("X-CursorX-Original-Host", urlObj.hostname);
484
+ } catch {
485
+ // Header construction failure → passthrough untouched.
486
+ return realFetch(input, init);
487
+ }
488
+
489
+ const finalInit = { ...(init || {}), headers };
490
+
491
+ if (
492
+ input &&
493
+ typeof input === "object" &&
494
+ typeof input.url === "string" &&
495
+ typeof Request !== "undefined" &&
496
+ input instanceof Request
497
+ ) {
498
+ // Rebuild Request preserving body/method (clone() is required for streaming bodies).
499
+ const cloned = input.clone();
500
+ return realFetch(
501
+ new Request(rewritten, {
502
+ method: cloned.method,
503
+ headers,
504
+ body: cloned.body,
505
+ mode: cloned.mode,
506
+ credentials: cloned.credentials,
507
+ cache: cloned.cache,
508
+ redirect: cloned.redirect,
509
+ referrer: cloned.referrer,
510
+ integrity: cloned.integrity,
511
+ keepalive: cloned.keepalive,
512
+ signal: cloned.signal,
513
+ }),
514
+ );
515
+ }
516
+
517
+ return realFetch(rewritten, finalInit);
518
+ };
519
+ }
520
+
521
+ // Install — idempotent.
522
+ try {
523
+ if (
524
+ typeof globalThis !== "undefined" &&
525
+ typeof globalThis.fetch === "function" &&
526
+ !globalThis.__CURSORX_FETCH_PATCHED__
527
+ ) {
528
+ const real = globalThis.fetch.bind(globalThis);
529
+ globalThis.fetch = patchFetch(real);
530
+ globalThis.__CURSORX_FETCH_PATCHED__ = true;
531
+ }
532
+ } catch {
533
+ // Never throw out of the hook — fail-open keeps Cursor's existing behavior intact.
534
+ }
535
+
536
+ // Exported for unit tests (when imported as a module from vitest); harmless side-effect
537
+ // in Cursor runtime since these are property accesses on `export`, which is a no-op when
538
+ // the file is loaded by Cursor for its side-effect only.
539
+ export const __test__ = {
540
+ BYOK_HOSTS,
541
+ PASSTHROUGH_PATH_PATTERNS,
542
+ MODEL_LIST_PATH_RE,
543
+ shouldIntercept,
544
+ rewriteUrl,
545
+ extractUrlString,
546
+ patchFetch,
547
+ getRouterUrl,
548
+ isByokEnabled,
549
+ matchesModelListPath,
550
+ cursorXModelToCursorDetails,
551
+ wrapModelListResponse,
552
+ fetchCursorXModels,
553
+ // Test-only: reset the runtime.json cache so suites can re-trigger reads with
554
+ // different fixtures. Not part of the production contract.
555
+ _resetRuntimeCacheForTests: () => {
556
+ _runtimeCache = undefined;
557
+ },
558
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@cursorx/cli",
3
+ "version": "0.0.3",
4
+ "description": "Local installer for CursorX — custom models in Cursor with your own API keys, via a 127.0.0.1 router. Unofficial; not affiliated with Cursor.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "bin": {
11
+ "cursorx": "./dist/cli.js"
12
+ },
13
+ "main": "./dist/cli.js",
14
+ "files": [
15
+ "dist/",
16
+ "vsix/",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "keywords": [
24
+ "cursor",
25
+ "openai",
26
+ "anthropic",
27
+ "router",
28
+ "custom-model",
29
+ "unofficial"
30
+ ],
31
+ "dependencies": {
32
+ "@napi-rs/keyring": "^1.3.0"
33
+ },
34
+ "devDependencies": {
35
+ "esbuild": "^0.24.0",
36
+ "@cursorx/shared": "0.1.0-dev"
37
+ },
38
+ "scripts": {
39
+ "build": "node build.mjs",
40
+ "build:release": "node build.mjs --release",
41
+ "typecheck": "tsc --noEmit",
42
+ "fetch-vsix": "node scripts/fetch-release-vsix.mjs"
43
+ }
44
+ }
package/vsix/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # Bundled CursorX extension VSIX (T-909 / FR-128~130, plan A)
2
+
3
+ `cursorx install` installs the CursorX control-panel extension by picking the vsix
4
+ here that matches the user's platform and running
5
+ `<resourcesAppPath>/bin/cursor{.cmd} --install-extension <vsix> --force`.
6
+
7
+ The extension bundles a native module (`@napi-rs/keyring` → a per-platform `.node`
8
+ binary), so each vsix only works on the OS/arch it was packaged on. CI
9
+ (`.github/workflows/package-vsix.yml`) builds one vsix per platform on a real
10
+ runner of that platform.
11
+
12
+ **This directory ships inside the npm package** (`package.json` → `files: ["vsix/"]`)
13
+ so install never reaches the network for the extension (security.md §1: zero new
14
+ outbound). It is populated at release time — NOT committed with binaries — by:
15
+
16
+ ```
17
+ pnpm --filter @cursorx/cli fetch-vsix # latest successful "Package VSIX" run
18
+ pnpm --filter @cursorx/cli fetch-vsix -- --tag v0.0.1 # from a release
19
+ ```
20
+
21
+ Expected files after fetch (3 bundled targets):
22
+
23
+ - `cursorx-<version>-win32-x64.vsix`
24
+ - `cursorx-<version>-darwin-arm64.vsix`
25
+ - `cursorx-<version>-darwin-x64.vsix`
26
+
27
+ `darwin-x64` (Intel macOS) is **cross-built** on the arm64 macOS runner — the hosted
28
+ Intel macOS (`macos-13`) runner is unavailable, so CI fetches the official
29
+ `@napi-rs/keyring-darwin-x64` prebuilt via `npm install --os=darwin --cpu=x64`
30
+ (pending Intel real-machine load confirmation). Linux is not bundled — Linux users
31
+ install the vsix manually (Cursor → Install from VSIX). Any platform without a
32
+ bundled vsix degrades to a best-effort warning, never an install failure (FR-129).