ocx-cursor 0.3.2 → 0.4.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/README.md CHANGED
@@ -212,6 +212,7 @@ The gateway API key is generated during `init` and stored in Cursor with macOS S
212
212
  | `ocx-cursor install` | Reinstall or restart the LaunchAgent without changing Cursor's API settings. |
213
213
  | `ocx-cursor update` | Download the latest `ocx-cursor` release from npm, reinstall the service, and sync models. |
214
214
  | `ocx-cursor sync` | Refresh the active model catalog. The service queues the update while Cursor runs. |
215
+ | `ocx-cursor launch` | Experimentally launch Cursor with live effort and Fast metadata injection. Keep the command running. |
215
216
  | `ocx-cursor status` | Show service health, model count, and pending sync state. |
216
217
  | `ocx-cursor uninstall` | Remove the LaunchAgent, command link, and bridge home directory. |
217
218
 
@@ -232,6 +233,16 @@ The catalog includes models returned by OpenCodex's active `/v1/models` endpoint
232
233
 
233
234
  Cursor removes custom effort metadata from its database during startup. The LaunchAgent writes that metadata back after Cursor exits, once all Cursor Helper processes have stopped.
234
235
 
236
+ ### Experimental live model metadata
237
+
238
+ `ocx-cursor launch` starts Cursor with a random loopback-only debugging port and keeps `opencodex/*` effort and Fast metadata in the live model catalog:
239
+
240
+ ```bash
241
+ ocx-cursor launch
242
+ ```
243
+
244
+ Keep the command running for the lifetime of Cursor. This does not modify `Cursor.app`, but it depends on Cursor's private workbench code and currently supports Cursor 3.14.7. The command stops with a compatibility error when it cannot find the expected catalog hook.
245
+
235
246
  ## Configuration
236
247
 
237
248
  | Variable | Default | Purpose |
@@ -10,6 +10,7 @@ import { cursorOpenAIBaseUrl, pendingFile } from "../src/paths.mjs";
10
10
  import { runService } from "../src/service.mjs";
11
11
  import { normalizeBaseUrl, testTunnel } from "../src/setup.mjs";
12
12
  import { loadCatalogSnapshot, syncNow } from "../src/sync.mjs";
13
+ import { findAvailableDebugPort, launchCursorForInjection, runRuntimeInjector } from "../src/runtime-injector.mjs";
13
14
 
14
15
  const usage = `OpenCodex Cursor Bridge
15
16
 
@@ -20,6 +21,10 @@ Usage:
20
21
  ocx-cursor install Install and start the macOS companion service
21
22
  ocx-cursor update Install the latest companion release and restart it
22
23
  ocx-cursor sync Sync active OpenCodex models into Cursor
24
+ ocx-cursor launch [--port <port>]
25
+ Launch Cursor with the experimental runtime model hook
26
+ ocx-cursor inject --port <port> [--reload]
27
+ Attach the experimental hook to a debug-enabled Cursor
23
28
  ocx-cursor status Show service and model-sync status
24
29
  ocx-cursor uninstall Stop and remove the companion service
25
30
  ocx-cursor service Run the service in the foreground (internal)
@@ -119,6 +124,30 @@ async function main() {
119
124
  printSync(await syncNow());
120
125
  return;
121
126
  }
127
+ if (command === "launch") {
128
+ if (cursorIsRunning()) throw new Error("Quit Cursor before running ocx-cursor launch");
129
+ const requestedPort = argumentValue("--port");
130
+ const port = requestedPort ? Number(requestedPort) : await findAvailableDebugPort();
131
+ const pid = launchCursorForInjection(port);
132
+ process.stdout.write(`Launched Cursor ${pid} with runtime injection on 127.0.0.1:${port}. Keep this command running.\n`);
133
+ await runRuntimeInjector({
134
+ port,
135
+ onInjection: (count) => process.stdout.write(`Injected ${count} OpenCodex model definitions.\n`),
136
+ onError: (error) => process.stderr.write(`Runtime injection failed: ${error.message}\n`),
137
+ });
138
+ return;
139
+ }
140
+ if (command === "inject") {
141
+ const port = Number(argumentValue("--port"));
142
+ process.stdout.write(`Attaching runtime injection to Cursor on 127.0.0.1:${port}. Keep this command running.\n`);
143
+ await runRuntimeInjector({
144
+ port,
145
+ reloadExisting: process.argv.includes("--reload"),
146
+ onInjection: (count) => process.stdout.write(`Injected ${count} OpenCodex model definitions.\n`),
147
+ onError: (error) => process.stderr.write(`Runtime injection failed: ${error.message}\n`),
148
+ });
149
+ return;
150
+ }
122
151
  if (command === "status") {
123
152
  const [status, catalog, pending] = await Promise.all([
124
153
  serviceStatus(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocx-cursor",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "OpenCodex companion service for Cursor custom models",
5
5
  "keywords": [
6
6
  "opencodex",
@@ -0,0 +1,209 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createServer } from "node:net";
4
+ import { cursorModel } from "./cursor-state.mjs";
5
+ import { loadCatalogSnapshot } from "./sync.mjs";
6
+
7
+ export const cursorBinary = "/Applications/Cursor.app/Contents/MacOS/Cursor";
8
+ export const cursorWorkbenchBundle = "/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js";
9
+ export const cursorWorkbenchUrl = "vscode-file://vscode-app/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js";
10
+
11
+ const catalogStorageAnchor = "c=c.map(z=>XTt(z)),bp(()=>{this._reactiveStorageService.setApplicationUserPersistentStorage(\"availableDefaultModels2\",c)";
12
+ const breakpointOffset = "c=c.map(z=>XTt(z)),".length;
13
+
14
+ export function findCatalogHookLocation(source) {
15
+ const index = source.indexOf(catalogStorageAnchor);
16
+ if (index === -1) throw new Error("This Cursor version does not contain the supported model catalog hook");
17
+ if (source.indexOf(catalogStorageAnchor, index + 1) !== -1) {
18
+ throw new Error("Cursor model catalog hook is ambiguous");
19
+ }
20
+ const lines = source.slice(0, index + breakpointOffset).split("\n");
21
+ return {
22
+ lineNumber: lines.length - 1,
23
+ columnNumber: lines.at(-1).length,
24
+ };
25
+ }
26
+
27
+ export function buildRuntimeModelPatches(catalog) {
28
+ return Object.fromEntries(catalog.map((model) => {
29
+ const value = cursorModel(model);
30
+ return [value.name, value];
31
+ }));
32
+ }
33
+
34
+ export function catalogPatchExpression(patches) {
35
+ return `(() => {
36
+ const patches = ${JSON.stringify(patches)};
37
+ let count = 0;
38
+ for (const model of c) {
39
+ const patch = patches[model.name];
40
+ if (!patch) continue;
41
+ count += 1;
42
+ Object.assign(model, patch);
43
+ }
44
+ return count;
45
+ })()`;
46
+ }
47
+
48
+ class CdpConnection {
49
+ constructor(url) {
50
+ this.url = url;
51
+ this.nextId = 0;
52
+ this.pending = new Map();
53
+ this.listeners = new Set();
54
+ }
55
+
56
+ async open() {
57
+ this.socket = new WebSocket(this.url);
58
+ this.socket.onmessage = ({ data }) => this.handleMessage(JSON.parse(data));
59
+ await new Promise((resolve, reject) => {
60
+ this.socket.onopen = resolve;
61
+ this.socket.onerror = reject;
62
+ });
63
+ this.socket.onclose = () => {
64
+ for (const { reject } of this.pending.values()) reject(new Error("Cursor debugger disconnected"));
65
+ this.pending.clear();
66
+ };
67
+ return this;
68
+ }
69
+
70
+ call(method, params = {}) {
71
+ return new Promise((resolve, reject) => {
72
+ const id = ++this.nextId;
73
+ this.pending.set(id, { resolve, reject });
74
+ this.socket.send(JSON.stringify({ id, method, params }));
75
+ });
76
+ }
77
+
78
+ onEvent(listener) {
79
+ this.listeners.add(listener);
80
+ }
81
+
82
+ handleMessage(message) {
83
+ if (message.id) {
84
+ const pending = this.pending.get(message.id);
85
+ if (!pending) return;
86
+ this.pending.delete(message.id);
87
+ if (message.error) pending.reject(new Error(message.error.message));
88
+ else pending.resolve(message.result);
89
+ return;
90
+ }
91
+ for (const listener of this.listeners) listener(message);
92
+ }
93
+
94
+ close() {
95
+ this.socket?.close();
96
+ }
97
+ }
98
+
99
+ async function cursorTargets(port) {
100
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, {
101
+ signal: AbortSignal.timeout(1000),
102
+ });
103
+ if (!response.ok) throw new Error(`Cursor debugger returned HTTP ${response.status}`);
104
+ return await response.json();
105
+ }
106
+
107
+ async function attachCatalogHook(target, options) {
108
+ const connection = await new CdpConnection(target.webSocketDebuggerUrl).open();
109
+ let breakpointId;
110
+ connection.onEvent((message) => {
111
+ if (message.method !== "Debugger.paused" || !message.params.hitBreakpoints.includes(breakpointId)) return;
112
+ const frame = message.params.callFrames[0];
113
+ void (async () => {
114
+ try {
115
+ const patches = buildRuntimeModelPatches(await loadCatalogSnapshot());
116
+ const result = await connection.call("Debugger.evaluateOnCallFrame", {
117
+ callFrameId: frame.callFrameId,
118
+ expression: catalogPatchExpression(patches),
119
+ returnByValue: true,
120
+ });
121
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || "Catalog injection failed");
122
+ options.onInjection?.(Number(result.result.value || 0), target);
123
+ } catch (error) {
124
+ options.onError?.(error, target);
125
+ } finally {
126
+ await connection.call("Debugger.resume").catch(() => {});
127
+ }
128
+ })();
129
+ });
130
+
131
+ await connection.call("Debugger.enable");
132
+ const breakpoint = await connection.call("Debugger.setBreakpointByUrl", {
133
+ url: cursorWorkbenchUrl,
134
+ lineNumber: options.location.lineNumber,
135
+ columnNumber: options.location.columnNumber,
136
+ });
137
+ breakpointId = breakpoint.breakpointId;
138
+ if (options.reload) await connection.call("Page.reload");
139
+ return connection;
140
+ }
141
+
142
+ export async function runRuntimeInjector(options = {}) {
143
+ const port = Number(options.port);
144
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("A valid Cursor debugging port is required");
145
+ const source = await readFile(options.bundleFile || cursorWorkbenchBundle, "utf8");
146
+ const location = findCatalogHookLocation(source);
147
+ const connections = new Map();
148
+ let foundTarget = false;
149
+ let missingPolls = 0;
150
+
151
+ try {
152
+ while (missingPolls < (options.maxMissingPolls || 10)) {
153
+ let targets = [];
154
+ try {
155
+ targets = await cursorTargets(port);
156
+ missingPolls = 0;
157
+ } catch {
158
+ missingPolls += 1;
159
+ }
160
+
161
+ for (const target of targets.filter(({ type }) => type === "page")) {
162
+ if (connections.has(target.id)) continue;
163
+ const connection = await attachCatalogHook(target, {
164
+ location,
165
+ reload: options.reloadExisting === true && !foundTarget,
166
+ onInjection: options.onInjection,
167
+ onError: options.onError,
168
+ });
169
+ connections.set(target.id, connection);
170
+ foundTarget = true;
171
+ }
172
+
173
+ for (const [id, connection] of connections) {
174
+ if (targets.some((target) => target.id === id)) continue;
175
+ connection.close();
176
+ connections.delete(id);
177
+ }
178
+ await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs || 500));
179
+ }
180
+ } finally {
181
+ for (const connection of connections.values()) connection.close();
182
+ }
183
+
184
+ if (!foundTarget) throw new Error(`No Cursor renderer appeared on debugging port ${port}`);
185
+ }
186
+
187
+ export function launchCursorForInjection(port, options = {}) {
188
+ const child = spawn(options.cursorBinary || cursorBinary, [
189
+ "--remote-debugging-address=127.0.0.1",
190
+ `--remote-debugging-port=${port}`,
191
+ ], {
192
+ detached: true,
193
+ stdio: "ignore",
194
+ });
195
+ child.unref();
196
+ return child.pid;
197
+ }
198
+
199
+ export async function findAvailableDebugPort() {
200
+ const server = createServer();
201
+ await new Promise((resolve, reject) => {
202
+ server.once("error", reject);
203
+ server.listen(0, "127.0.0.1", resolve);
204
+ });
205
+ const address = server.address();
206
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
207
+ if (!address || typeof address === "string") throw new Error("Could not allocate a Cursor debugging port");
208
+ return address.port;
209
+ }