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 +11 -0
- package/bin/ocx-cursor.mjs +29 -0
- package/package.json +1 -1
- package/src/runtime-injector.mjs +209 -0
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 |
|
package/bin/ocx-cursor.mjs
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|