@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.
- package/LICENSE +133 -0
- package/README.md +99 -0
- package/dist/cli.js +2 -0
- package/dist/fingerprints/free-tier-gate-3.4.13.json +92 -0
- package/dist/fingerprints/free-tier-gate-3.4.16.json +92 -0
- package/dist/fingerprints/free-tier-gate-3.4.17.json +92 -0
- package/dist/fingerprints/free-tier-gate-3.4.20.json +92 -0
- package/dist/fingerprints/free-tier-gate-3.5.17.json +92 -0
- package/dist/fingerprints/free-tier-gate-3.5.33.json +92 -0
- package/dist/fingerprints/free-tier-gate-3.5.38.json +92 -0
- package/dist/fingerprints/free-tier-gate-3.6.21.json +92 -0
- package/dist/fingerprints/free-tier-gate-3.6.31.json +92 -0
- package/dist/hooks/loaderHook.js +558 -0
- package/package.json +44 -0
- package/vsix/README.md +32 -0
- package/vsix/cursorx-0.0.3-darwin-arm64.vsix +0 -0
- package/vsix/cursorx-0.0.3-darwin-x64.vsix +0 -0
- package/vsix/cursorx-0.0.3-win32-x64.vsix +0 -0
|
@@ -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).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|