ocx-cursor 0.4.0 → 0.5.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
@@ -3,20 +3,18 @@
3
3
  [![npm version](https://img.shields.io/npm/v/ocx-cursor.svg)](https://www.npmjs.com/package/ocx-cursor)
4
4
  [![CI](https://github.com/hiddenest/opencodex-cursor-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/hiddenest/opencodex-cursor-bridge/actions/workflows/ci.yml)
5
5
 
6
- Use active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through its custom OpenAI endpoint. The package runs a local gateway, registers the endpoint in Cursor, and keeps Cursor's custom model list in sync.
6
+ Use active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through its custom OpenAI endpoint. The package runs a loopback-only gateway, registers it in Cursor, and keeps Cursor's custom model list in sync.
7
7
 
8
8
  ## Requirements
9
9
 
10
10
  - macOS with Cursor installed at `/Applications/Cursor.app`
11
11
  - Node.js 22.5 or newer
12
- - A domain using Cloudflare DNS, or another HTTPS reverse proxy
12
+ - OpenCodex installed and configured
13
13
 
14
14
  Launch Cursor and sign in once before setup. Cursor creates the Safe Storage key that the installer uses to encrypt the gateway API key.
15
15
 
16
16
  ## Setup
17
17
 
18
- The commands below use `cursor-api.example.com`. Replace it with a hostname under your own Cloudflare-managed domain.
19
-
20
18
  ### 1. Install OpenCodex
21
19
 
22
20
  [OpenCodex](https://github.com/lidge-jun/opencodex) requires Node.js 18 or newer and bundles its own Bun runtime. This bridge requires Node.js 22.5 or newer, so install Node.js 22 or later before continuing.
@@ -37,32 +35,22 @@ if [[ "$(command -v ocx)" != "$HOME/.local/bin/ocx" ]]; then
37
35
  fi
38
36
  ```
39
37
 
40
- `ocx init` opens the interactive provider setup. You can also open the dashboard and add or sign in to a provider there:
38
+ `ocx init` opens the interactive provider setup. You can also configure providers through the dashboard or terminal:
41
39
 
42
40
  ```bash
43
41
  ocx gui
44
- ```
45
-
46
- OAuth-backed providers can also be connected from the terminal:
47
-
48
- ```bash
49
42
  ocx login <provider>
50
43
  ```
51
44
 
52
- Install OpenCodex as a login service, then check it:
45
+ Install OpenCodex as a login service, then verify it:
53
46
 
54
47
  ```bash
55
48
  ocx service install
56
49
  ocx service status
57
- ```
58
-
59
- OpenCodex listens on `http://127.0.0.1:10100` by default. Confirm that its model endpoint responds:
60
-
61
- ```bash
62
50
  curl http://127.0.0.1:10100/v1/models
63
51
  ```
64
52
 
65
- If you configured OpenCodex with a service API token, include that token:
53
+ If you configured OpenCodex with a service API token, include it in the request:
66
54
 
67
55
  ```bash
68
56
  curl \
@@ -70,68 +58,9 @@ curl \
70
58
  http://127.0.0.1:10100/v1/models
71
59
  ```
72
60
 
73
- See the [OpenCodex documentation](https://lidge-jun.github.io/opencodex/) for provider-specific login and model configuration.
74
-
75
- ### 2. Create a Cloudflare Tunnel
76
-
77
- Cursor requires an HTTPS custom OpenAI endpoint. A [Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/) can publish the bridge on HTTPS without opening an inbound port on your router.
78
-
79
- Add your domain to Cloudflare and point its nameservers to Cloudflare. Then install `cloudflared` on the Mac that runs OpenCodex and Cursor:
61
+ See the [OpenCodex documentation](https://lidge-jun.github.io/opencodex/) for provider-specific setup.
80
62
 
81
- ```bash
82
- brew install cloudflared
83
- cloudflared tunnel login
84
- ```
85
-
86
- The login command opens Cloudflare in your browser and writes `~/.cloudflared/cert.pem`. Create a [locally-managed named tunnel](https://developers.cloudflare.com/tunnel/advanced/local-management/create-local-tunnel/):
87
-
88
- ```bash
89
- cloudflared tunnel create ocx-cursor
90
- cloudflared tunnel list
91
- ```
92
-
93
- Copy the tunnel UUID printed by the command. It also creates a credentials file named `<TUNNEL_UUID>.json` under `~/.cloudflared`.
94
-
95
- Create `~/.cloudflared/config.yml`. Replace the UUID, macOS username, and hostname in this example:
96
-
97
- ```yaml
98
- # ~/.cloudflared/config.yml
99
- tunnel: YOUR_TUNNEL_UUID
100
- credentials-file: /Users/YOUR_MACOS_USERNAME/.cloudflared/YOUR_TUNNEL_UUID.json
101
-
102
- ingress:
103
- - hostname: cursor-api.example.com
104
- service: http://127.0.0.1:10101
105
- - service: http_status:404
106
- ```
107
-
108
- Create the DNS CNAME for the public hostname. The command adds the record to Cloudflare, so you do not need to create it separately in the dashboard:
109
-
110
- ```bash
111
- cloudflared tunnel route dns ocx-cursor cursor-api.example.com
112
- ```
113
-
114
- Validate the configuration and start the tunnel in the foreground:
115
-
116
- ```bash
117
- cloudflared tunnel ingress validate
118
- cloudflared tunnel ingress rule https://cursor-api.example.com
119
- cloudflared tunnel run ocx-cursor
120
- ```
121
-
122
- The public hostname returns `502 Bad Gateway` until the bridge is installed in the next step. Keep this terminal open while testing.
123
-
124
- For login-time startup on macOS, stop the foreground process and install the `cloudflared` launch agent:
125
-
126
- ```bash
127
- cloudflared service install
128
- ```
129
-
130
- Use `sudo cloudflared service install` only if you want a system launch daemon that starts at boot. That mode reads its configuration from `/etc/cloudflared`, not your home directory. See Cloudflare's [macOS service guide](https://developers.cloudflare.com/tunnel/advanced/local-management/as-a-service/macos/) for the required file locations.
131
-
132
- This package does not create, modify, or remove the Cloudflare Tunnel.
133
-
134
- ### 3. Install the Cursor bridge
63
+ ### 2. Install the Cursor bridge
135
64
 
136
65
  Quit Cursor completely, confirm that OpenCodex is running, then initialize the bridge:
137
66
 
@@ -139,51 +68,47 @@ Quit Cursor completely, confirm that OpenCodex is running, then initialize the b
139
68
  npx ocx-cursor init
140
69
  ```
141
70
 
142
- Enter the Cloudflare Tunnel hostname when prompted. The `https://` prefix and `/v1` suffix are optional:
143
-
144
- ```text
145
- Cloudflare Tunnel URL (for example, https://cursor-api.example.com): https://cursor-api.example.com
146
- ```
147
-
148
- The installer starts the local bridge and tests two routes through Cloudflare before it changes Cursor:
149
-
150
- - `GET /healthz` confirms that the hostname reaches this bridge.
151
- - Authenticated `GET /v1/models` confirms that request headers reach the bridge and OpenCodex responds.
152
-
153
- If either check fails, `init` stops before writing Cursor's API settings. The local bridge stays running so you can fix the tunnel and rerun the command.
154
-
155
- For scripts and unattended setup, pass the URL directly:
156
-
157
- ```bash
158
- npx ocx-cursor init \
159
- --base-url https://cursor-api.example.com/v1
160
- ```
161
-
162
- `init` performs these actions:
71
+ No tunnel or public domain is required. `init` uses `http://127.0.0.1:10101/v1` and performs these actions:
163
72
 
164
73
  1. Generates a gateway API key in `~/.opencodex/cursor-bridge/secret`.
165
74
  2. Installs the `com.opencodex.cursor-bridge` LaunchAgent.
166
- 3. Tests the Cloudflare Tunnel and OpenCodex model endpoint.
75
+ 3. Tests the local gateway and OpenCodex model endpoint.
167
76
  4. Stores the key in Cursor with macOS Safe Storage encryption.
168
- 5. Registers the HTTPS URL as Cursor's OpenAI base URL.
77
+ 5. Registers the local gateway as Cursor's OpenAI base URL.
169
78
  6. Adds active OpenCodex models to Cursor under `opencodex/*`.
79
+ 7. Enables Cursor's local-agent mode and installs the model metadata patches described below.
80
+
81
+ `init` patches ten JavaScript files inside `Cursor.app`:
170
82
 
171
- The installer links `ocx-cursor` into `~/.local/bin`. Add that directory to `PATH` if your shell does not include it:
83
+ - Eight Cursor bundles get the local-mode build flag.
84
+ - The Desktop and Glass workbench bundles retain OpenCodex display names, effort choices, and Fast metadata when Cursor refreshes its server catalog.
85
+ - Both local-agent runtimes display names such as `GPT 5.6 Sol` while keeping `opencodex/gpt-5.6-sol` as the request ID.
86
+
87
+ Original files are copied to `~/.opencodex/cursor-bridge/cursor-app-backups` before they are changed. The LaunchAgent watches the same files and reapplies the patches after a Cursor update, once Cursor has quit.
88
+
89
+ > [!WARNING]
90
+ > Patching `Cursor.app` invalidates its vendor code signature. Cursor may show “Your Cursor installation appears to be corrupt.” This is expected for this integration. Reinstall Cursor to restore an unmodified, vendor-signed app.
91
+
92
+ The installer links `ocx-cursor` into `~/.local/bin`. Add that directory to `PATH` if needed:
172
93
 
173
94
  ```bash
174
95
  export PATH="$HOME/.local/bin:$PATH"
175
96
  ```
176
97
 
177
- Check the local gateway and public hostname before opening Cursor:
98
+ Check the service before opening Cursor:
178
99
 
179
100
  ```bash
180
101
  ocx-cursor status
181
- curl https://cursor-api.example.com/healthz
102
+ curl http://127.0.0.1:10101/healthz
182
103
  ```
183
104
 
184
- The status output should show `Service: running` and `Gateway: healthy`. The public health endpoint should return JSON with `"status":"ok"`.
105
+ The status output should show `Service: running` and `Gateway: healthy`. Open Cursor after both checks pass.
185
106
 
186
- Open Cursor after both checks pass. Models with known reasoning controls show an effort value in the picker. Use `Shift+Command+/` to cycle it.
107
+ To use an explicit HTTPS reverse proxy instead, pass its URL directly. This is optional and not used by the default setup:
108
+
109
+ ```bash
110
+ npx ocx-cursor init --base-url https://cursor-api.example.com/v1
111
+ ```
187
112
 
188
113
  ### Fast mode
189
114
 
@@ -195,30 +120,28 @@ Fast requires an OpenCodex version that preserves `service_tier` on its Chat Com
195
120
 
196
121
  ```text
197
122
  Cursor
198
- -> https://cursor-api.example.com/v1
199
- -> Cloudflare Tunnel
200
- -> OpenCodex Cursor Bridge on 127.0.0.1:10101
201
- -> OpenCodex on 127.0.0.1:10100
123
+ -> OpenCodex Cursor Bridge on http://127.0.0.1:10101/v1
124
+ -> OpenCodex on http://127.0.0.1:10100
202
125
  -> configured provider
203
126
  ```
204
127
 
205
- The gateway API key is generated during `init` and stored in Cursor with macOS Safe Storage encryption. The bridge requires this bearer token on every `/v1/*` request. Keep the bridge bound to `127.0.0.1`; `cloudflared` can reach it without exposing port `10101` to the local network.
128
+ The gateway API key is generated during `init` and stored in Cursor with macOS Safe Storage encryption. The bridge requires this bearer token on every `/v1/*` request and binds to `127.0.0.1` by default.
206
129
 
207
130
  ## Commands
208
131
 
209
132
  | Command | Purpose |
210
133
  | --- | --- |
211
- | `ocx-cursor init [--base-url URL]` | Install the service, prompt for and test the tunnel, configure Cursor, and sync models. Cursor must be closed. |
212
- | `ocx-cursor install` | Reinstall or restart the LaunchAgent without changing Cursor's API settings. |
213
- | `ocx-cursor update` | Download the latest `ocx-cursor` release from npm, reinstall the service, and sync models. |
134
+ | `ocx-cursor init [--base-url URL]` | Install the service, test the endpoint, configure Cursor, and sync models. Cursor must be closed. |
135
+ | `ocx-cursor install` | Reinstall the LaunchAgent and verify app patches, or queue them until Cursor quits. |
136
+ | `ocx-cursor update` | Download the latest release from npm, reinstall the service, verify patches, and sync models. |
214
137
  | `ocx-cursor sync` | Refresh the active model catalog. The service queues the update while Cursor runs. |
215
138
  | `ocx-cursor launch` | Experimentally launch Cursor with live effort and Fast metadata injection. Keep the command running. |
216
139
  | `ocx-cursor status` | Show service health, model count, and pending sync state. |
217
140
  | `ocx-cursor uninstall` | Remove the LaunchAgent, command link, and bridge home directory. |
218
141
 
219
- `uninstall` leaves Cursor's custom endpoint and model records in its state database.
142
+ `uninstall` leaves Cursor's custom endpoint, model records, and app patches in place. Reinstall Cursor if you want to restore its original signed files.
220
143
 
221
- `update` preserves the existing gateway API key and Cursor endpoint. It updates only this companion package; update OpenCodex separately with your package manager.
144
+ `update` preserves the existing gateway API key and Cursor endpoint. If Cursor is running, app patches and model sync are queued until it quits. The command updates only this companion package; update OpenCodex separately with your package manager.
222
145
 
223
146
  ## Model mapping
224
147
 
@@ -229,9 +152,13 @@ anthropic/claude-sonnet-5 -> opencodex/claude-sonnet-5
229
152
  gpt-5.6-sol -> opencodex/gpt-5.6-sol
230
153
  ```
231
154
 
232
- The catalog includes models returned by OpenCodex's active `/v1/models` endpoint. It excludes the OpenCodex `cursor/*` provider to avoid duplicating Cursor's own models.
155
+ The catalog includes every model returned by OpenCodex's active `/v1/models` endpoint, including models from the `cursor/*` provider. Cursor-backed aliases use the `opencodex/cursor/*` prefix so they remain distinct from Cursor's built-in entries.
156
+
157
+ Cursor removes custom effort metadata from its database during startup. The companion LaunchAgent patches the Desktop and Glass catalog storage steps so `opencodex/*` display names, effort, and Fast metadata survive server refreshes. It monitors the installed bundles and local-agent runtimes, then reapplies their patches when Cursor replaces the app during an update.
158
+
159
+ `init`, `install`, and `update` also patch Cursor's local model runtimes so provider refreshes keep human-readable names such as `GPT 5.6 Sol` while requests continue using stable `opencodex/*` model IDs.
233
160
 
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.
161
+ Cursor sends some built-in function tools with `strict: true` even when their JSON schema is not valid for strict mode. The gateway changes that flag to `false` for OpenCodex requests, preventing errors such as `additionalProperties is required to be supplied and to be false` without changing tool arguments.
235
162
 
236
163
  ### Experimental live model metadata
237
164
 
@@ -241,16 +168,16 @@ Cursor removes custom effort metadata from its database during startup. The Laun
241
168
  ocx-cursor launch
242
169
  ```
243
170
 
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.
171
+ 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 supports only known Cursor builds.
245
172
 
246
173
  ## Configuration
247
174
 
248
175
  | Variable | Default | Purpose |
249
176
  | --- | --- | --- |
250
- | `OCX_CURSOR_BASE_URL` | Stored Cursor URL | Prompt default, or endpoint for non-interactive `init`, when `--base-url` is absent. |
177
+ | `OCX_CURSOR_BASE_URL` | `http://127.0.0.1:10101/v1` | Endpoint used by `init` when `--base-url` is absent. |
251
178
  | `OCX_CURSOR_HOME` | `~/.opencodex/cursor-bridge` | Service state, API key, catalog, and logs. |
252
179
  | `OCX_CURSOR_HOST` | `127.0.0.1` | Local gateway bind address. |
253
- | `OCX_CURSOR_PORT` | `10101` | Local gateway port. |
180
+ | `OCX_CURSOR_PORT` | `10101` | Local gateway port. Also changes the default Cursor endpoint. |
254
181
  | `OCX_BIN` | `~/.local/bin/ocx` | OpenCodex CLI path. |
255
182
 
256
183
  The gateway accepts these routes:
@@ -260,59 +187,48 @@ The gateway accepts these routes:
260
187
  - `POST /v1/responses` and `/v1/responses/compact`
261
188
  - `POST /v1/messages`
262
189
 
263
- The gateway requires its generated bearer token on each `/v1/*` request. It binds to loopback unless you change `OCX_CURSOR_HOST`.
264
-
265
190
  ## Troubleshooting
266
191
 
267
- ### The public hostname returns 502
192
+ ### Cursor returns 401
268
193
 
269
- The tunnel is running, but it cannot reach the local bridge. Check the bridge and its logs:
194
+ Quit Cursor and run `init` again. It preserves the bridge key, tests the local endpoint, and writes the matching encrypted value back to Cursor:
270
195
 
271
196
  ```bash
272
- ocx-cursor status
273
- tail -n 100 ~/.opencodex/cursor-bridge/service.error.log
274
- tail -n 100 ~/.opencodex/cursor-bridge/service.log
197
+ npx ocx-cursor init
275
198
  ```
276
199
 
277
- Confirm that the tunnel ingress points to `http://127.0.0.1:10101`, then restart the bridge if needed:
200
+ ### The local gateway is unavailable
201
+
202
+ Check the service and logs, then reinstall it if needed:
278
203
 
279
204
  ```bash
205
+ ocx-cursor status
206
+ tail -n 100 ~/.opencodex/cursor-bridge/service.error.log
207
+ tail -n 100 ~/.opencodex/cursor-bridge/service.log
280
208
  ocx-cursor install
281
209
  ```
282
210
 
283
- ### Cloudflare returns error 1016
284
-
285
- The DNS record exists, but no tunnel connector is online. Check the named tunnel and start it:
286
-
287
- ```bash
288
- cloudflared tunnel info ocx-cursor
289
- cloudflared tunnel run ocx-cursor
290
- ```
211
+ ### Models or effort options are missing
291
212
 
292
- If you installed the login service, inspect it with:
213
+ Quit Cursor and run:
293
214
 
294
215
  ```bash
295
- launchctl print "gui/$(id -u)/com.cloudflare.cloudflared"
216
+ ocx-cursor sync
217
+ ocx-cursor status
296
218
  ```
297
219
 
298
- ### Cursor returns 401
299
-
300
- Run `init` again while Cursor is closed. It preserves the bridge key, retests the tunnel, and writes the matching encrypted value back to Cursor:
301
-
302
- ```bash
303
- npx ocx-cursor init
304
- ```
220
+ If the status shows a pending sync, wait until every Cursor Helper process has exited. The service applies the queued catalog automatically.
305
221
 
306
- ### Models or effort options are missing
222
+ ### Patches are missing after a Cursor update
307
223
 
308
- Cursor must be closed before its local model database can be changed. Quit Cursor and run:
224
+ Quit every Cursor window and wait for its Helper processes to stop. The LaunchAgent detects replaced app files and reapplies local mode, metadata, and display-name patches. Check the result with:
309
225
 
310
226
  ```bash
311
- ocx-cursor sync
312
- ocx-cursor status
227
+ ocx-cursor install
228
+ tail -n 100 ~/.opencodex/cursor-bridge/service.log
313
229
  ```
314
230
 
315
- If the status shows a pending sync, wait until every Cursor Helper process has exited. The service applies the queued catalog automatically.
231
+ The patcher stops without modifying a file when a Cursor release no longer matches its known code structure. Check `service.error.log` in that case.
316
232
 
317
233
  ### OpenCodex models cannot be loaded
318
234
 
@@ -338,7 +254,7 @@ The code uses `node:sqlite`, so development requires Node.js 22.5 or newer.
338
254
 
339
255
  ## Compatibility
340
256
 
341
- The package writes Cursor's local state database and uses Cursor's Safe Storage format. Cursor does not document either interface. A Cursor update can change them.
257
+ The package writes Cursor's local state database, uses Cursor's Safe Storage format, and modifies private code inside `Cursor.app`. Cursor does not document these interfaces. A Cursor update can change them.
342
258
 
343
259
  The current release supports macOS. It does not configure Windows Credential Manager, Linux keyrings, or system services outside launchd.
344
260
 
@@ -2,21 +2,20 @@
2
2
 
3
3
  import { readFile } from "node:fs/promises";
4
4
  import process from "node:process";
5
- import { createInterface } from "node:readline/promises";
6
- import { configureCursorOpenAI, storedCursorOpenAIBaseUrl } from "../src/cursor-config.mjs";
5
+ import { configureCursorOpenAI } from "../src/cursor-config.mjs";
7
6
  import { cursorIsRunning } from "../src/cursor-state.mjs";
8
7
  import { installService, prepareInstallSecret, serviceStatus, uninstallService, updateService } from "../src/install.mjs";
9
- import { cursorOpenAIBaseUrl, pendingFile } from "../src/paths.mjs";
8
+ import { cursorLocalBaseUrl, cursorOpenAIBaseUrl, pendingFile } from "../src/paths.mjs";
10
9
  import { runService } from "../src/service.mjs";
11
- import { normalizeBaseUrl, testTunnel } from "../src/setup.mjs";
10
+ import { normalizeBaseUrl, testEndpoint } from "../src/setup.mjs";
12
11
  import { loadCatalogSnapshot, syncNow } from "../src/sync.mjs";
13
12
  import { findAvailableDebugPort, launchCursorForInjection, runRuntimeInjector } from "../src/runtime-injector.mjs";
14
13
 
15
14
  const usage = `OpenCodex Cursor Bridge
16
15
 
17
16
  Usage:
18
- ocx-cursor init [--base-url <https-url>]
19
- Install the service, test the tunnel, configure Cursor,
17
+ ocx-cursor init [--base-url <url>]
18
+ Install the service, test the endpoint, configure Cursor,
20
19
  and sync models
21
20
  ocx-cursor install Install and start the macOS companion service
22
21
  ocx-cursor update Install the latest companion release and restart it
@@ -37,19 +36,7 @@ function argumentValue(name) {
37
36
 
38
37
  async function requestedBaseUrl() {
39
38
  const supplied = argumentValue("--base-url");
40
- const fallback = cursorOpenAIBaseUrl || storedCursorOpenAIBaseUrl();
41
- if (supplied) return normalizeBaseUrl(supplied);
42
- if (!process.stdin.isTTY || !process.stdout.isTTY) return normalizeBaseUrl(fallback);
43
-
44
- const prompt = fallback
45
- ? `Cloudflare Tunnel URL [${fallback}]: `
46
- : "Cloudflare Tunnel URL (for example, https://cursor-api.example.com): ";
47
- const readline = createInterface({ input: process.stdin, output: process.stdout });
48
- try {
49
- return normalizeBaseUrl((await readline.question(prompt)).trim() || fallback);
50
- } finally {
51
- readline.close();
52
- }
39
+ return normalizeBaseUrl(supplied || cursorOpenAIBaseUrl || cursorLocalBaseUrl);
53
40
  }
54
41
 
55
42
  async function pendingCount() {
@@ -70,6 +57,15 @@ function printSync(result) {
70
57
  }
71
58
  }
72
59
 
60
+ function printCursorPatches(result) {
61
+ if (result.cursorPatches === null) {
62
+ process.stdout.write("Cursor app patches will be applied after Cursor quits.\n");
63
+ return;
64
+ }
65
+ const changed = result.cursorPatches.filter(({ status }) => status === "patched").length;
66
+ process.stdout.write(`Verified ${result.cursorPatches.length} Cursor app patches (${changed} changed).\n`);
67
+ }
68
+
73
69
  async function main() {
74
70
  const command = process.argv[2] || "help";
75
71
  if (["help", "--help", "-h"].includes(command)) {
@@ -85,6 +81,7 @@ async function main() {
85
81
  if (command === "install") {
86
82
  const installed = await installService();
87
83
  process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${installed.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
84
+ printCursorPatches(installed);
88
85
  printSync(await syncNow());
89
86
  process.stdout.write("Endpoint: http://127.0.0.1:10101/v1\n");
90
87
  return;
@@ -97,14 +94,15 @@ async function main() {
97
94
  const prepared = await prepareInstallSecret();
98
95
  const installed = await installService();
99
96
  process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${prepared.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
100
- process.stdout.write(`Testing Cloudflare Tunnel: ${new URL(baseUrl).origin}\n`);
101
- let tunnel;
97
+ printCursorPatches(installed);
98
+ process.stdout.write(`Testing endpoint: ${new URL(baseUrl).origin}\n`);
99
+ let endpoint;
102
100
  try {
103
- tunnel = await testTunnel(baseUrl, prepared.secret);
101
+ endpoint = await testEndpoint(baseUrl, prepared.secret);
104
102
  } catch (error) {
105
- throw new Error(`${error.message}\nThe bridge service is running locally. Fix the tunnel and rerun ocx-cursor init.`);
103
+ throw new Error(`${error.message}\nThe bridge service is running locally. Fix the endpoint and rerun ocx-cursor init.`);
106
104
  }
107
- process.stdout.write(`Tunnel is ready (${tunnel.modelCount} models reachable).\n`);
105
+ process.stdout.write(`Endpoint is ready (${endpoint.modelCount} models reachable).\n`);
108
106
  const configured = await configureCursorOpenAI({
109
107
  secret: prepared.secret,
110
108
  baseUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocx-cursor",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "OpenCodex companion service for Cursor custom models",
5
5
  "keywords": [
6
6
  "opencodex",
package/src/catalog.mjs CHANGED
@@ -80,7 +80,7 @@ export function normalizeActiveCatalog(configured, active, fastModelIds = new Se
80
80
  const models = new Map();
81
81
 
82
82
  for (const model of active) {
83
- if (typeof model?.id !== "string" || model.owned_by === "opencodex" || model.id.startsWith("cursor/")) continue;
83
+ if (typeof model?.id !== "string" || model.owned_by === "opencodex") continue;
84
84
  const configuredModel = configuredById.get(model.id);
85
85
  const provider = model.id.includes("/") ? model.id.split("/", 1)[0] : String(model.owned_by || "openai").toLowerCase();
86
86
  const inputModalities = Array.isArray(configuredModel?.inputModalities)
@@ -0,0 +1,231 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, copyFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
+ import {
5
+ cursorBundleFiles,
6
+ cursorGlassWorkbenchFile,
7
+ cursorLocalRuntimeFiles,
8
+ cursorPatchBackupDirectory,
9
+ cursorWorkbenchFile,
10
+ } from "./paths.mjs";
11
+
12
+ export const cursorPatchMarker = "/*ocx-cursor-model-metadata-v3*/";
13
+ export const legacyCursorPatchMarker = "/*ocx-cursor-model-metadata*/";
14
+ export const cursorLocalModeDisabled = "localMode:!1";
15
+ export const cursorLocalModeEnabled = "localMode:!0";
16
+ export const cursorLocalRuntimePatchMarker = "/*ocx-cursor-local-model-display*/";
17
+
18
+ export function isCursorModelMetadataBundle(file) {
19
+ return file === cursorWorkbenchFile || file === cursorGlassWorkbenchFile;
20
+ }
21
+
22
+ const catalogNormalization = /(?<normalization>\b(?<catalog>[A-Za-z_$][\w$]*)=\k<catalog>\.map\((?<item>[A-Za-z_$][\w$]*)=>(?<plain>[A-Za-z_$][\w$]*)\(\k<item>\)\)),(?=(?<batch>[A-Za-z_$][\w$]*)\(\(\)=>\{this\._reactiveStorageService\.setApplicationUserPersistentStorage\("availableDefaultModels2",\k<catalog>\))/g;
23
+ const previousCatalogInjection = /\/\*ocx-cursor-model-metadata(?:-v2)?\*\/(?<catalog>[A-Za-z_$][\w$]*)=\k<catalog>\.map\(ocxCursorModel=>\{.*?\}\),(?=(?<batch>[A-Za-z_$][\w$]*)\(\(\)=>\{this\._reactiveStorageService\.setApplicationUserPersistentStorage\("availableDefaultModels2",\k<catalog>\))/g;
24
+ const localModelConstructor = /function (?<functionName>[A-Za-z_$][\w$]*)\(e,t\)\{return new (?<modelType>[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*)\(\{modelId:e,displayModelId:e,displayName:null!=t\?t:e,displayNameShort:null!=t\?t:e,aliases:\[\]\}\)\}/g;
25
+ const transientPatchErrorCodes = new Set(["EACCES", "EBUSY", "EPERM"]);
26
+
27
+ function metadataInjection(catalog) {
28
+ return `${cursorPatchMarker}${catalog}=${catalog}.map(ocxCursorModel=>{if(!ocxCursorModel.name.startsWith("opencodex/"))return ocxCursorModel;const ocxCursorStored=(this._reactiveStorageService.applicationUserPersistentStorage.availableDefaultModels2??[]).find(ocxCursorCandidate=>ocxCursorCandidate?.name===ocxCursorModel.name);const ocxCursorDisplayWords={claude:"Claude",codex:"Codex",composer:"Composer",deepseek:"DeepSeek",fable:"Fable",fast:"Fast",flash:"Flash",gpt:"GPT",grok:"Grok",hy3:"HY3",kimi:"Kimi",luna:"Luna",max:"Max",mimo:"MiMo",mini:"Mini",opus:"Opus",pro:"Pro",qwen:"Qwen",sol:"Sol",sonnet:"Sonnet",spark:"Spark",terra:"Terra"};const ocxCursorDisplayName=ocxCursorModel.name.split("/").at(-1).split("-").map(ocxCursorWord=>{if(ocxCursorDisplayWords[ocxCursorWord])return ocxCursorDisplayWords[ocxCursorWord];const ocxCursorAttached=/^(qwen|kimi|gpt|claude|grok)(\\d+(?:\\.\\d+)*)$/.exec(ocxCursorWord);if(ocxCursorAttached)return ocxCursorDisplayWords[ocxCursorAttached[1]]+" "+ocxCursorAttached[2];if(/^v\\d/i.test(ocxCursorWord))return"V"+ocxCursorWord.slice(1);if(/^k\\d/i.test(ocxCursorWord))return"K"+ocxCursorWord.slice(1);if(/^\\d/.test(ocxCursorWord))return ocxCursorWord;return ocxCursorWord.slice(0,1).toUpperCase()+ocxCursorWord.slice(1)}).join(" ");const ocxCursorPrettyLabel=ocxCursorValue=>typeof ocxCursorValue==="string"?ocxCursorValue.split(ocxCursorModel.name).join(ocxCursorDisplayName):ocxCursorValue;const ocxCursorVariants=(ocxCursorStored?.variants??ocxCursorModel.variants??[]).map(ocxCursorVariant=>({...ocxCursorVariant,displayName:ocxCursorPrettyLabel(ocxCursorVariant.displayName),displayNameOutsidePicker:ocxCursorPrettyLabel(ocxCursorVariant.displayNameOutsidePicker)}));return{...ocxCursorModel,clientDisplayName:ocxCursorDisplayName,inputboxShortModelName:ocxCursorDisplayName,parameterDefinitions:ocxCursorStored?.parameterDefinitions??ocxCursorModel.parameterDefinitions??[],variants:ocxCursorVariants,legacySlugs:ocxCursorStored?.legacySlugs??ocxCursorModel.legacySlugs??[],supportsThinking:ocxCursorStored?.supportsThinking??ocxCursorModel.supportsThinking}}),`;
29
+ }
30
+
31
+ export function patchCursorWorkbenchSource(source) {
32
+ if (source.includes(cursorPatchMarker)) return { status: "already-patched", source };
33
+ if (source.includes(legacyCursorPatchMarker) || source.includes("/*ocx-cursor-model-metadata-v2*/")) {
34
+ const matches = [...source.matchAll(previousCatalogInjection)];
35
+ if (matches.length !== 1) {
36
+ throw new Error(`Expected one legacy Cursor model metadata hook, found ${matches.length}`);
37
+ }
38
+ const match = matches[0];
39
+ const replacement = metadataInjection(match.groups.catalog);
40
+ return {
41
+ status: "patched",
42
+ source: `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`,
43
+ };
44
+ }
45
+ const matches = [...source.matchAll(catalogNormalization)];
46
+ if (matches.length !== 1) {
47
+ throw new Error(`Expected one Cursor model catalog storage hook, found ${matches.length}`);
48
+ }
49
+ const match = matches[0];
50
+ const { normalization, catalog } = match.groups;
51
+ const injection = `${normalization},${metadataInjection(catalog)}`;
52
+ const patched = `${source.slice(0, match.index)}${injection}${source.slice(match.index + match[0].length)}`;
53
+ return { status: "patched", source: patched };
54
+ }
55
+
56
+ export function patchCursorLocalModeSource(source) {
57
+ const disabledCount = source.split(cursorLocalModeDisabled).length - 1;
58
+ if (disabledCount > 1) {
59
+ throw new Error(`Expected at most one disabled Cursor localMode flag, found ${disabledCount}`);
60
+ }
61
+ if (disabledCount === 1) {
62
+ return {
63
+ status: "patched",
64
+ source: source.replace(cursorLocalModeDisabled, cursorLocalModeEnabled),
65
+ };
66
+ }
67
+ if (source.includes(cursorLocalModeEnabled)) return { status: "already-patched", source };
68
+ throw new Error("Cursor localMode build flag was not found");
69
+ }
70
+
71
+ export function patchCursorBundleSource(source, options = {}) {
72
+ const localMode = patchCursorLocalModeSource(source);
73
+ const metadata = options.preserveCatalogMetadata
74
+ ? patchCursorWorkbenchSource(localMode.source)
75
+ : { status: "already-patched", source: localMode.source };
76
+ return {
77
+ status: localMode.status === "patched" || metadata.status === "patched"
78
+ ? "patched"
79
+ : "already-patched",
80
+ source: metadata.source,
81
+ };
82
+ }
83
+
84
+ export function patchCursorLocalRuntimeSource(source) {
85
+ if (source.includes(cursorLocalRuntimePatchMarker)) return { status: "already-patched", source };
86
+ const matches = [...source.matchAll(localModelConstructor)];
87
+ if (matches.length !== 1) {
88
+ throw new Error(`Expected one Cursor local model constructor, found ${matches.length}`);
89
+ }
90
+ const match = matches[0];
91
+ const { functionName, modelType } = match.groups;
92
+ const replacement = `function ${functionName}(e,t){${cursorLocalRuntimePatchMarker}const ocxCursorDisplayWords={claude:"Claude",codex:"Codex",composer:"Composer",deepseek:"DeepSeek",fable:"Fable",fast:"Fast",flash:"Flash",gpt:"GPT",grok:"Grok",hy3:"HY3",kimi:"Kimi",luna:"Luna",max:"Max",mimo:"MiMo",mini:"Mini",opus:"Opus",pro:"Pro",qwen:"Qwen",sol:"Sol",sonnet:"Sonnet",spark:"Spark",terra:"Terra"};const ocxCursorDisplayName=null!=t?t:e.startsWith("opencodex/")?e.split("/").at(-1).split("-").map(ocxCursorWord=>{if(ocxCursorDisplayWords[ocxCursorWord])return ocxCursorDisplayWords[ocxCursorWord];const ocxCursorAttached=/^(qwen|kimi|gpt|claude|grok)(\\d+(?:\\.\\d+)*)$/.exec(ocxCursorWord);if(ocxCursorAttached)return ocxCursorDisplayWords[ocxCursorAttached[1]]+" "+ocxCursorAttached[2];if(/^v\\d/i.test(ocxCursorWord))return"V"+ocxCursorWord.slice(1);if(/^k\\d/i.test(ocxCursorWord))return"K"+ocxCursorWord.slice(1);if(/^\\d/.test(ocxCursorWord))return ocxCursorWord;return ocxCursorWord.slice(0,1).toUpperCase()+ocxCursorWord.slice(1)}).join(" "):e;return new ${modelType}({modelId:e,displayModelId:e,displayName:ocxCursorDisplayName,displayNameShort:ocxCursorDisplayName,aliases:[]})}`;
93
+ return {
94
+ status: "patched",
95
+ source: `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`,
96
+ };
97
+ }
98
+
99
+ export async function cursorWorkbenchSignature(file = cursorWorkbenchFile) {
100
+ const value = await stat(file);
101
+ return `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}`;
102
+ }
103
+
104
+ async function writePatchedFile(file, source, patched, backupDirectory) {
105
+ if (patched.status === "already-patched") {
106
+ return { status: patched.status, signature: await cursorWorkbenchSignature(file), backupPath: null };
107
+ }
108
+
109
+ await mkdir(backupDirectory, { recursive: true });
110
+ const digest = createHash("sha256").update(source).digest("hex").slice(0, 16);
111
+ const backupPath = join(backupDirectory, `${basename(file)}.${digest}.bak`);
112
+ await copyFile(file, backupPath);
113
+
114
+ const mode = (await stat(file)).mode & 0o777;
115
+ const temporary = `${file}.ocx-cursor-${process.pid}`;
116
+ await writeFile(temporary, patched.source, { mode });
117
+ await chmod(temporary, mode);
118
+ await rename(temporary, file);
119
+ return { status: patched.status, signature: await cursorWorkbenchSignature(file), backupPath };
120
+ }
121
+
122
+ export async function ensureCursorWorkbenchPatched(options = {}) {
123
+ const file = options.file || cursorWorkbenchFile;
124
+ const backupDirectory = options.backupDirectory || cursorPatchBackupDirectory;
125
+ const source = await readFile(file, "utf8");
126
+ const patched = patchCursorBundleSource(source, {
127
+ preserveCatalogMetadata: options.preserveCatalogMetadata ?? true,
128
+ });
129
+ return await writePatchedFile(file, source, patched, backupDirectory);
130
+ }
131
+
132
+ export async function ensureCursorLocalRuntimesPatched(options = {}) {
133
+ const files = options.files || cursorLocalRuntimeFiles;
134
+ const backupDirectory = options.backupDirectory || cursorPatchBackupDirectory;
135
+ return await Promise.all(files.map(async (file) => {
136
+ const source = await readFile(file, "utf8");
137
+ return {
138
+ file,
139
+ ...await writePatchedFile(file, source, patchCursorLocalRuntimeSource(source), backupDirectory),
140
+ };
141
+ }));
142
+ }
143
+
144
+ export async function ensureCursorBundlesPatched(options = {}) {
145
+ const files = options.files || cursorBundleFiles;
146
+ return await Promise.all(files.map(async (file) => ({
147
+ file,
148
+ ...await ensureCursorWorkbenchPatched({
149
+ ...options,
150
+ file,
151
+ preserveCatalogMetadata: isCursorModelMetadataBundle(file),
152
+ }),
153
+ })));
154
+ }
155
+
156
+ export async function ensureCursorAppPatched(options = {}) {
157
+ const [bundles, runtimes] = await Promise.all([
158
+ ensureCursorBundlesPatched({
159
+ ...options,
160
+ files: options.bundleFiles,
161
+ }),
162
+ ensureCursorLocalRuntimesPatched({
163
+ ...options,
164
+ files: options.localRuntimeFiles,
165
+ }),
166
+ ]);
167
+ return [...bundles, ...runtimes];
168
+ }
169
+
170
+ async function ensureCursorPatchFile(file, localRuntimeFiles, options) {
171
+ if (localRuntimeFiles.has(file)) {
172
+ const [result] = await ensureCursorLocalRuntimesPatched({
173
+ ...options,
174
+ files: [file],
175
+ });
176
+ return result;
177
+ }
178
+ return {
179
+ file,
180
+ ...await ensureCursorWorkbenchPatched({
181
+ ...options,
182
+ file,
183
+ preserveCatalogMetadata: options.file ? true : isCursorModelMetadataBundle(file),
184
+ }),
185
+ };
186
+ }
187
+
188
+ export function startCursorPatchMonitor(options = {}) {
189
+ const intervalMs = options.intervalMs || 250;
190
+ const bundleFiles = options.file ? [options.file] : (options.bundleFiles || options.files || cursorBundleFiles);
191
+ const runtimeFiles = options.file ? [] : (options.localRuntimeFiles || cursorLocalRuntimeFiles);
192
+ const files = [...bundleFiles, ...runtimeFiles];
193
+ const localRuntimeFiles = new Set(runtimeFiles);
194
+ const lastSignatures = new Map();
195
+ let patchPromise = null;
196
+ let stopped = false;
197
+
198
+ const check = async () => {
199
+ if (stopped || patchPromise || options.shouldPatch?.() === false) return patchPromise;
200
+ patchPromise = Promise.all(files.map(async (file) => {
201
+ let signature;
202
+ try {
203
+ signature = await cursorWorkbenchSignature(file);
204
+ } catch {
205
+ return;
206
+ }
207
+ if (signature === lastSignatures.get(file)) return;
208
+ try {
209
+ const result = await ensureCursorPatchFile(file, localRuntimeFiles, options);
210
+ lastSignatures.set(file, result.signature);
211
+ options.onResult?.(result);
212
+ } catch (error) {
213
+ if (!transientPatchErrorCodes.has(error.code)) lastSignatures.set(file, signature);
214
+ options.onError?.(error, file);
215
+ }
216
+ })).finally(() => {
217
+ patchPromise = null;
218
+ });
219
+ return patchPromise;
220
+ };
221
+
222
+ const timer = setInterval(check, intervalMs);
223
+ void check();
224
+ return {
225
+ check,
226
+ stop() {
227
+ stopped = true;
228
+ clearInterval(timer);
229
+ },
230
+ };
231
+ }
@@ -5,6 +5,45 @@ import { effortLabels } from "./catalog.mjs";
5
5
  import { cursorDatabaseFile, installRoot, managedPrefix, pendingFile } from "./paths.mjs";
6
6
 
7
7
  const storageKey = "src.vs.platform.reactivestorage.browser.reactiveStorageServiceImpl.persistentStorage.applicationUser";
8
+ const displayWords = new Map([
9
+ ["claude", "Claude"],
10
+ ["codex", "Codex"],
11
+ ["composer", "Composer"],
12
+ ["deepseek", "DeepSeek"],
13
+ ["fable", "Fable"],
14
+ ["fast", "Fast"],
15
+ ["flash", "Flash"],
16
+ ["gpt", "GPT"],
17
+ ["grok", "Grok"],
18
+ ["hy3", "HY3"],
19
+ ["kimi", "Kimi"],
20
+ ["luna", "Luna"],
21
+ ["max", "Max"],
22
+ ["mimo", "MiMo"],
23
+ ["mini", "Mini"],
24
+ ["opus", "Opus"],
25
+ ["pro", "Pro"],
26
+ ["qwen", "Qwen"],
27
+ ["sol", "Sol"],
28
+ ["sonnet", "Sonnet"],
29
+ ["spark", "Spark"],
30
+ ["terra", "Terra"],
31
+ ]);
32
+
33
+ function displayWord(value) {
34
+ const known = displayWords.get(value);
35
+ if (known) return known;
36
+ const attachedVersion = /^(qwen|kimi|gpt|claude|grok)(\d+(?:\.\d+)*)$/.exec(value);
37
+ if (attachedVersion) return `${displayWords.get(attachedVersion[1])} ${attachedVersion[2]}`;
38
+ if (/^v\d/i.test(value)) return `V${value.slice(1)}`;
39
+ if (/^k\d/i.test(value)) return `K${value.slice(1)}`;
40
+ if (/^\d/.test(value)) return value;
41
+ return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
42
+ }
43
+
44
+ export function displayNameFor(model) {
45
+ return model.sourceId.split("/").at(-1).split("-").map(displayWord).join(" ");
46
+ }
8
47
 
9
48
  export function cursorIsRunning() {
10
49
  try {
@@ -62,13 +101,14 @@ function fastDefinition() {
62
101
  function modelVariants(model) {
63
102
  const parameterId = parameterIdFor(model);
64
103
  const selectedDefault = defaultEffort(model);
104
+ const modelDisplayName = displayNameFor(model);
65
105
  const efforts = model.reasoningEfforts.length > 0 ? model.reasoningEfforts : [null];
66
106
  const fastValues = model.supportsFast ? ["false", "true"] : [null];
67
107
  return efforts.flatMap((effort) => fastValues.map((fast) => {
68
108
  const labels = [effort ? effortLabels[effort] : null, fast === "true" ? "Fast" : null].filter(Boolean);
69
109
  const displayName = labels.length > 0
70
- ? `${model.alias} <span style="color: var(--cursor-text-tertiary);">${labels.join(" ")}</span>`
71
- : model.alias;
110
+ ? `${modelDisplayName} <span style="color: var(--cursor-text-tertiary);">${labels.join(" ")}</span>`
111
+ : modelDisplayName;
72
112
  const isDefault = (effort === null || effort === selectedDefault) && fast !== "true";
73
113
  const parameters = [
74
114
  ...(effort ? [{ id: parameterId, value: effort }] : []),
@@ -91,8 +131,10 @@ export function cursorModel(model) {
91
131
  const hasEffort = model.reasoningEfforts.length > 0;
92
132
  const hasVariants = hasEffort || model.supportsFast;
93
133
  const variants = hasVariants ? modelVariants(model) : [];
134
+ const displayName = displayNameFor(model);
94
135
  return {
95
136
  name: model.alias,
137
+ clientDisplayName: displayName,
96
138
  defaultOn: false,
97
139
  supportsAgent: true,
98
140
  degradationStatus: 0,
@@ -105,7 +147,7 @@ export function cursorModel(model) {
105
147
  supportsPlanMode: true,
106
148
  supportsSandboxing: true,
107
149
  isUserAdded: true,
108
- inputboxShortModelName: model.alias,
150
+ inputboxShortModelName: displayName,
109
151
  idAliases: [],
110
152
  namedModelSectionIndex: 1,
111
153
  cloudAgentEffortModes: [],
package/src/gateway.mjs CHANGED
@@ -36,6 +36,15 @@ function suppliedFast(variantText) {
36
36
  .find(([key]) => key === "fast")?.[1];
37
37
  }
38
38
 
39
+ function disableStrictFunctionTools(tools) {
40
+ if (!Array.isArray(tools)) return;
41
+ for (const tool of tools) {
42
+ if (!isRecord(tool) || tool.type !== "function") continue;
43
+ const definition = isRecord(tool.function) ? tool.function : tool;
44
+ if (definition.strict === true) definition.strict = false;
45
+ }
46
+ }
47
+
39
48
  export function rewriteModelAliasBody(body, catalog) {
40
49
  if (!body?.length) return body;
41
50
  let payload;
@@ -71,6 +80,7 @@ export function rewriteModelAliasBody(body, catalog) {
71
80
  if (catalogModel?.supportsFast && fast === "true") payload.service_tier = "priority";
72
81
  if (catalogModel?.supportsFast && fast === "false") delete payload.service_tier;
73
82
  delete payload.reasoningEffort;
83
+ disableStrictFunctionTools(payload.tools);
74
84
  return Buffer.from(JSON.stringify(payload));
75
85
  }
76
86
 
package/src/install.mjs CHANGED
@@ -4,6 +4,8 @@ import { access, chmod, copyFile, cp, lstat, mkdir, mkdtemp, readFile, readlink,
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { ensureCursorAppPatched } from "./cursor-patch.mjs";
8
+ import { cursorIsRunning } from "./cursor-state.mjs";
7
9
  import {
8
10
  cliLinkFile,
9
11
  installRoot,
@@ -165,6 +167,7 @@ function launchAgent(nodePath) {
165
167
  export async function installService() {
166
168
  await mkdir(dirname(launchAgentFile), { recursive: true });
167
169
  const { legacyPlist, secretStatus } = await prepareInstallSecret();
170
+ const cursorPatches = cursorIsRunning() ? null : await ensureCursorAppPatched();
168
171
  await copyPackage();
169
172
  await installCliLink();
170
173
 
@@ -179,7 +182,7 @@ export async function installService() {
179
182
  await rm(disabled, { force: true });
180
183
  await rename(legacyPlist, disabled);
181
184
  }
182
- return { installRoot, launchAgentFile, cliLinkFile, secretStatus };
185
+ return { installRoot, launchAgentFile, cliLinkFile, secretStatus, cursorPatches };
183
186
  }
184
187
 
185
188
  export async function updateService(options = {}) {
package/src/paths.mjs CHANGED
@@ -14,6 +14,24 @@ export const stderrFile = join(installRoot, "service.error.log");
14
14
  export const launchAgentFile = join(homedir(), "Library", "LaunchAgents", `${serviceLabel}.plist`);
15
15
  export const legacyLaunchAgentFile = join(homedir(), "Library", "LaunchAgents", `${legacyServiceLabel}.plist`);
16
16
  export const cursorDatabaseFile = join(homedir(), "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
17
+ export const cursorAppOutDirectory = "/Applications/Cursor.app/Contents/Resources/app/out";
18
+ export const cursorWorkbenchFile = join(cursorAppOutDirectory, "vs", "workbench", "workbench.desktop.main.js");
19
+ export const cursorGlassWorkbenchFile = join(cursorAppOutDirectory, "vs", "workbench", "workbench.glass.main.js");
20
+ export const cursorLocalRuntimeFiles = [
21
+ "/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-local-agent-runtime/dist/main.js",
22
+ "/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js",
23
+ ];
24
+ export const cursorBundleFiles = [
25
+ join(cursorAppOutDirectory, "main.js"),
26
+ join(cursorAppOutDirectory, "vs", "code", "electron-utility", "alwaysLocalSingleton", "alwaysLocalSingletonMain.js"),
27
+ join(cursorAppOutDirectory, "vs", "code", "electron-utility", "mcpProcess", "mcpProcessMain.js"),
28
+ join(cursorAppOutDirectory, "vs", "code", "electron-utility", "sharedProcess", "sharedProcessMain.js"),
29
+ join(cursorAppOutDirectory, "vs", "workbench", "api", "node", "extensionHostProcess.js"),
30
+ join(cursorAppOutDirectory, "vs", "workbench", "api", "worker", "extensionHostWorkerMain.js"),
31
+ cursorWorkbenchFile,
32
+ cursorGlassWorkbenchFile,
33
+ ];
34
+ export const cursorPatchBackupDirectory = join(installRoot, "cursor-app-backups");
17
35
  export const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
18
36
  export const codexConfigFile = join(codexHome, "config.toml");
19
37
  export const defaultCodexCatalogFile = join(codexHome, "opencodex-catalog.json");
@@ -21,5 +39,6 @@ export const opencodexConfigFile = join(homedir(), ".opencodex", "config.json");
21
39
  export const opencodexServiceTokenFile = join(homedir(), ".opencodex", "service-api-token");
22
40
  export const gatewayPort = Number(process.env.OCX_CURSOR_PORT || "10101");
23
41
  export const gatewayHost = process.env.OCX_CURSOR_HOST || "127.0.0.1";
42
+ export const cursorLocalBaseUrl = `http://127.0.0.1:${gatewayPort}/v1`;
24
43
  export const cursorOpenAIBaseUrl = process.env.OCX_CURSOR_BASE_URL || "";
25
44
  export const managedPrefix = "opencodex/";
package/src/service.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { buildActiveCatalog } from "./catalog.mjs";
4
+ import { startCursorPatchMonitor } from "./cursor-patch.mjs";
4
5
  import {
5
6
  cursorIsRunning,
6
7
  readPendingCatalog,
@@ -58,6 +59,14 @@ export async function runService(options = {}) {
58
59
  }
59
60
 
60
61
  const server = startGateway({ secret, getCatalog: () => catalog, host: options.host, port: options.port });
62
+ const patchMonitor = startCursorPatchMonitor({
63
+ intervalMs: options.cursorPatchIntervalMs,
64
+ shouldPatch: () => !cursorIsRunning(),
65
+ onResult: (result) => {
66
+ if (result.status === "patched") process.stdout.write(`Patched Cursor app file ${result.file} (backup: ${result.backupPath})\n`);
67
+ },
68
+ onError: (error, file) => process.stderr.write(`Cursor patch failed for ${file}: ${error.message}\n`),
69
+ });
61
70
  const refreshTimer = setInterval(refresh, options.refreshIntervalMs || 15_000);
62
71
  const pendingTimer = setInterval(() => {
63
72
  if (cursorSyncPromise) return;
@@ -85,6 +94,7 @@ export async function runService(options = {}) {
85
94
  stopped = true;
86
95
  clearInterval(refreshTimer);
87
96
  clearInterval(pendingTimer);
97
+ patchMonitor.stop();
88
98
  server.close(() => process.exit(0));
89
99
  };
90
100
  process.on("SIGTERM", stop);
package/src/setup.mjs CHANGED
@@ -1,24 +1,33 @@
1
1
  export function normalizeBaseUrl(value) {
2
2
  if (!value) {
3
- throw new Error("Enter your Cloudflare Tunnel URL or pass --base-url https://your-domain.example/v1");
3
+ throw new Error("Enter an endpoint URL or pass --base-url http://127.0.0.1:10101/v1");
4
4
  }
5
5
 
6
- const candidate = value.includes("://") ? value : `https://${value}`;
6
+ const trimmed = value.trim();
7
+ const loopbackWithoutScheme = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::|\/|$)/i.test(trimmed);
8
+ const candidate = trimmed.includes("://")
9
+ ? trimmed
10
+ : `${loopbackWithoutScheme ? "http" : "https"}://${trimmed}`;
7
11
  let url;
8
12
  try {
9
13
  url = new URL(candidate);
10
14
  } catch {
11
- throw new Error(`Invalid Cloudflare Tunnel URL: ${value}`);
15
+ throw new Error(`Invalid endpoint URL: ${value}`);
12
16
  }
13
17
 
14
- if (url.protocol !== "https:") throw new Error("Cloudflare Tunnel URL must use HTTPS");
18
+ const isLoopback = url.hostname === "localhost"
19
+ || url.hostname === "[::1]"
20
+ || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
21
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) {
22
+ throw new Error("Endpoint URL must use HTTPS unless it points to localhost");
23
+ }
15
24
  if (url.username || url.password || url.search || url.hash) {
16
- throw new Error("Cloudflare Tunnel URL cannot contain credentials, a query, or a fragment");
25
+ throw new Error("Endpoint URL cannot contain credentials, a query, or a fragment");
17
26
  }
18
27
 
19
28
  const pathname = url.pathname.replace(/\/+$/, "");
20
29
  if (pathname && pathname !== "/v1") {
21
- throw new Error("Cloudflare Tunnel URL must be a hostname or end with /v1");
30
+ throw new Error("Endpoint URL must be an origin or end with /v1");
22
31
  }
23
32
  url.pathname = "/v1";
24
33
  return url.toString().replace(/\/$/, "");
@@ -43,7 +52,7 @@ async function checkedJson(fetchImpl, url, options, label) {
43
52
  }
44
53
  }
45
54
 
46
- export async function testTunnel(baseUrl, secret, options = {}) {
55
+ export async function testEndpoint(baseUrl, secret, options = {}) {
47
56
  baseUrl = normalizeBaseUrl(baseUrl);
48
57
  const fetchImpl = options.fetchImpl || fetch;
49
58
  const origin = new URL(baseUrl).origin;
@@ -52,7 +61,7 @@ export async function testTunnel(baseUrl, secret, options = {}) {
52
61
  let healthError;
53
62
  for (let attempt = 0; attempt < attempts; attempt += 1) {
54
63
  try {
55
- health = await checkedJson(fetchImpl, `${origin}/healthz`, {}, "Tunnel health check");
64
+ health = await checkedJson(fetchImpl, `${origin}/healthz`, {}, "Endpoint health check");
56
65
  break;
57
66
  } catch (error) {
58
67
  healthError = error;
@@ -63,14 +72,14 @@ export async function testTunnel(baseUrl, secret, options = {}) {
63
72
  }
64
73
  if (!health) throw healthError;
65
74
  if (health?.service !== "opencodex-cursor-bridge" || health?.status !== "ok") {
66
- throw new Error(`Tunnel health check reached an unexpected service at ${origin}/healthz`);
75
+ throw new Error(`Endpoint health check reached an unexpected service at ${origin}/healthz`);
67
76
  }
68
77
 
69
78
  const models = await checkedJson(fetchImpl, `${baseUrl}/models`, {
70
79
  headers: { authorization: `Bearer ${secret}` },
71
- }, "Tunnel model check");
80
+ }, "Endpoint model check");
72
81
  if (!Array.isArray(models?.data)) {
73
- throw new Error(`Tunnel model check returned an invalid catalog from ${baseUrl}/models`);
82
+ throw new Error(`Endpoint model check returned an invalid catalog from ${baseUrl}/models`);
74
83
  }
75
84
 
76
85
  return { health, modelCount: models.data.length };