ocx-cursor 0.4.0 → 0.5.1

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,328 +3,114 @@
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 your active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through a local OpenAI-compatible gateway.
7
7
 
8
- ## Requirements
8
+ > [!WARNING]
9
+ > This package edits private JavaScript files inside `Cursor.app`. The changes invalidate Cursor's vendor signature, so Cursor may report that the installation is corrupt. The installer saves original files under `~/.opencodex/cursor-bridge/cursor-app-backups`. Reinstall Cursor to restore a signed app.
9
10
 
10
- - macOS with Cursor installed at `/Applications/Cursor.app`
11
- - Node.js 22.5 or newer
12
- - A domain using Cloudflare DNS, or another HTTPS reverse proxy
11
+ ## Quick start
13
12
 
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.
13
+ You need macOS, Node.js 22.5 or newer, and Cursor at `/Applications/Cursor.app`.
15
14
 
16
- ## Setup
17
-
18
- The commands below use `cursor-api.example.com`. Replace it with a hostname under your own Cloudflare-managed domain.
19
-
20
- ### 1. Install OpenCodex
21
-
22
- [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.
23
-
24
- Install OpenCodex without `sudo` from a user-owned Node.js installation:
15
+ Install and configure OpenCodex:
25
16
 
26
17
  ```bash
27
18
  npm install --global @bitkyc08/opencodex
28
19
  ocx init
29
- ```
30
-
31
- The bridge service looks for `ocx` at `~/.local/bin/ocx` and `/opt/homebrew/bin/ocx`. If `command -v ocx` prints another path, link it into `~/.local/bin`:
32
-
33
- ```bash
34
- mkdir -p ~/.local/bin
35
- if [[ "$(command -v ocx)" != "$HOME/.local/bin/ocx" ]]; then
36
- ln -sf "$(command -v ocx)" ~/.local/bin/ocx
37
- fi
38
- ```
39
-
40
- `ocx init` opens the interactive provider setup. You can also open the dashboard and add or sign in to a provider there:
41
-
42
- ```bash
43
- ocx gui
44
- ```
45
-
46
- OAuth-backed providers can also be connected from the terminal:
47
-
48
- ```bash
49
- ocx login <provider>
50
- ```
51
-
52
- Install OpenCodex as a login service, then check it:
53
-
54
- ```bash
55
20
  ocx service install
56
21
  ocx service status
57
22
  ```
58
23
 
59
- OpenCodex listens on `http://127.0.0.1:10100` by default. Confirm that its model endpoint responds:
60
-
61
- ```bash
62
- curl http://127.0.0.1:10100/v1/models
63
- ```
64
-
65
- If you configured OpenCodex with a service API token, include that token:
66
-
67
- ```bash
68
- curl \
69
- --header "Authorization: Bearer $(cat ~/.opencodex/service-api-token)" \
70
- http://127.0.0.1:10100/v1/models
71
- ```
72
-
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:
80
-
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/):
24
+ Check the configured models:
87
25
 
88
26
  ```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
27
+ ocx models --json
128
28
  ```
129
29
 
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
135
-
136
- Quit Cursor completely, confirm that OpenCodex is running, then initialize the bridge:
30
+ Launch Cursor and sign in once, then quit it. Run the bridge installer:
137
31
 
138
32
  ```bash
139
33
  npx ocx-cursor init
34
+ ~/.local/bin/ocx-cursor status
140
35
  ```
141
36
 
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:
163
-
164
- 1. Generates a gateway API key in `~/.opencodex/cursor-bridge/secret`.
165
- 2. Installs the `com.opencodex.cursor-bridge` LaunchAgent.
166
- 3. Tests the Cloudflare Tunnel and OpenCodex model endpoint.
167
- 4. Stores the key in Cursor with macOS Safe Storage encryption.
168
- 5. Registers the HTTPS URL as Cursor's OpenAI base URL.
169
- 6. Adds active OpenCodex models to Cursor under `opencodex/*`.
37
+ `init` registers `http://127.0.0.1:10101/v1` in Cursor. You do not need a domain or tunnel. Open Cursor after the status command reports `Service: running` and `Gateway: healthy`.
170
38
 
171
- The installer links `ocx-cursor` into `~/.local/bin`. Add that directory to `PATH` if your shell does not include it:
39
+ Add the installed command to your shell path if needed:
172
40
 
173
41
  ```bash
174
42
  export PATH="$HOME/.local/bin:$PATH"
175
43
  ```
176
44
 
177
- Check the local gateway and public hostname before opening Cursor:
178
-
179
- ```bash
180
- ocx-cursor status
181
- curl https://cursor-api.example.com/healthz
182
- ```
183
-
184
- The status output should show `Service: running` and `Gateway: healthy`. The public health endpoint should return JSON with `"status":"ok"`.
185
-
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.
187
-
188
- ### Fast mode
45
+ ## What init changes
189
46
 
190
- OpenAI models whose OpenCodex catalog advertises the `priority` service tier show a Fast toggle in Cursor. Fast is off by default. When enabled, the bridge sends `service_tier: "priority"` to OpenCodex, which increases generation speed and consumes more usage.
191
-
192
- Fast requires an OpenCodex version that preserves `service_tier` on its Chat Completions compatibility endpoint. Models without the `priority` tier, including Anthropic subscription models, do not receive the toggle.
47
+ | Area | Change |
48
+ | --- | --- |
49
+ | Cursor settings | Stores the gateway key with macOS Safe Storage and sets the local base URL. |
50
+ | Cursor model state | Adds active models under `opencodex/*` with names, effort choices, and Fast metadata. |
51
+ | `Cursor.app` | Enables local-agent mode in eight bundles and patches two local-agent runtimes. |
52
+ | Login service | Installs `com.opencodex.cursor-bridge`, refreshes models, and reapplies patches after Cursor updates. |
193
53
 
194
- ## How requests are routed
54
+ Requests stay on loopback until OpenCodex sends them to your configured provider:
195
55
 
196
56
  ```text
197
- 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
202
- -> configured provider
57
+ Cursor -> 127.0.0.1:10101 -> OpenCodex on 127.0.0.1:10100 -> provider
203
58
  ```
204
59
 
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.
206
-
207
- ## Commands
208
-
209
- | Command | Purpose |
210
- | --- | --- |
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. |
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. |
216
- | `ocx-cursor status` | Show service health, model count, and pending sync state. |
217
- | `ocx-cursor uninstall` | Remove the LaunchAgent, command link, and bridge home directory. |
218
-
219
- `uninstall` leaves Cursor's custom endpoint and model records in its state database.
220
-
221
- `update` preserves the existing gateway API key and Cursor endpoint. It updates only this companion package; update OpenCodex separately with your package manager.
60
+ The gateway requires its generated bearer token on every `/v1/*` request. It binds to `127.0.0.1` unless you change `OCX_CURSOR_HOST`.
222
61
 
223
- ## Model mapping
62
+ ## Model behavior
224
63
 
225
- The bridge maps source model IDs to Cursor aliases:
64
+ The bridge reads OpenCodex's active `/v1/models` response and creates stable Cursor aliases:
226
65
 
227
66
  ```text
228
- anthropic/claude-sonnet-5 -> opencodex/claude-sonnet-5
229
- gpt-5.6-sol -> opencodex/gpt-5.6-sol
67
+ anthropic/claude-sonnet-5 -> opencodex/claude-sonnet-5
68
+ gpt-5.6-sol -> opencodex/gpt-5.6-sol
230
69
  ```
231
70
 
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.
71
+ Cursor shows readable names such as `GPT 5.6 Sol`. Reasoning models receive an effort selector. Models with OpenCodex's `priority` service tier receive a Fast toggle.
233
72
 
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.
73
+ Cursor can send `strict: true` with function schemas that fail strict validation. The gateway changes the flag to `false` before forwarding those requests. It leaves tool names, descriptions, and arguments intact.
235
74
 
236
- ### Experimental live model metadata
75
+ ## Commands and configuration
237
76
 
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
-
246
- ## Configuration
247
-
248
- | Variable | Default | Purpose |
249
- | --- | --- | --- |
250
- | `OCX_CURSOR_BASE_URL` | Stored Cursor URL | Prompt default, or endpoint for non-interactive `init`, when `--base-url` is absent. |
251
- | `OCX_CURSOR_HOME` | `~/.opencodex/cursor-bridge` | Service state, API key, catalog, and logs. |
252
- | `OCX_CURSOR_HOST` | `127.0.0.1` | Local gateway bind address. |
253
- | `OCX_CURSOR_PORT` | `10101` | Local gateway port. |
254
- | `OCX_BIN` | `~/.local/bin/ocx` | OpenCodex CLI path. |
77
+ | Command | Purpose |
78
+ | --- | --- |
79
+ | `ocx-cursor init` | Install the service, configure Cursor, patch the app, and sync models. Cursor must be closed. |
80
+ | `ocx-cursor install` | Reinstall the service and check app patches. Running Cursor defers app changes until exit. |
81
+ | `ocx-cursor update` | Install the newest npm release and run `install`. |
82
+ | `ocx-cursor sync` | Refresh the active model list. |
83
+ | `ocx-cursor status` | Show service health and pending model sync. |
84
+ | `ocx-cursor uninstall` | Remove the service, command link, and bridge state. |
255
85
 
256
- The gateway accepts these routes:
86
+ `uninstall` does not restore Cursor settings or patched app files. Reinstall Cursor if you want a clean vendor build.
257
87
 
258
- - `GET /v1/models`
259
- - `POST /v1/chat/completions`
260
- - `POST /v1/responses` and `/v1/responses/compact`
261
- - `POST /v1/messages`
88
+ | Variable | Default |
89
+ | --- | --- |
90
+ | `OCX_CURSOR_BASE_URL` | `http://127.0.0.1:10101/v1` |
91
+ | `OCX_CURSOR_HOME` | `~/.opencodex/cursor-bridge` |
92
+ | `OCX_CURSOR_HOST` | `127.0.0.1` |
93
+ | `OCX_CURSOR_PORT` | `10101` |
94
+ | `OCX_BIN` | `~/.local/bin/ocx` |
262
95
 
263
- The gateway requires its generated bearer token on each `/v1/*` request. It binds to loopback unless you change `OCX_CURSOR_HOST`.
96
+ The bridge reads an OpenCodex service token from `~/.opencodex/service-api-token` when that file exists.
264
97
 
265
98
  ## Troubleshooting
266
99
 
267
- ### The public hostname returns 502
268
-
269
- The tunnel is running, but it cannot reach the local bridge. Check the bridge and its logs:
100
+ | Symptom | Fix |
101
+ | --- | --- |
102
+ | Cursor returns 401 | Quit Cursor and rerun `npx ocx-cursor init`. |
103
+ | Models or effort choices are missing | Quit Cursor, run `ocx-cursor sync`, then check `ocx-cursor status`. |
104
+ | A Cursor update removed the patches | Quit Cursor and run `ocx-cursor install`. |
105
+ | The gateway is unavailable | Run `ocx-cursor status` and inspect the logs below. |
106
+ | The bridge cannot find `ocx` | Run `ln -sf "$(command -v ocx)" ~/.local/bin/ocx`. |
270
107
 
271
108
  ```bash
272
- ocx-cursor status
273
- tail -n 100 ~/.opencodex/cursor-bridge/service.error.log
274
109
  tail -n 100 ~/.opencodex/cursor-bridge/service.log
110
+ tail -n 100 ~/.opencodex/cursor-bridge/service.error.log
275
111
  ```
276
112
 
277
- Confirm that the tunnel ingress points to `http://127.0.0.1:10101`, then restart the bridge if needed:
278
-
279
- ```bash
280
- ocx-cursor install
281
- ```
282
-
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
- ```
291
-
292
- If you installed the login service, inspect it with:
293
-
294
- ```bash
295
- launchctl print "gui/$(id -u)/com.cloudflare.cloudflared"
296
- ```
297
-
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
- ```
305
-
306
- ### Models or effort options are missing
307
-
308
- Cursor must be closed before its local model database can be changed. Quit Cursor and run:
309
-
310
- ```bash
311
- ocx-cursor sync
312
- ocx-cursor status
313
- ```
314
-
315
- If the status shows a pending sync, wait until every Cursor Helper process has exited. The service applies the queued catalog automatically.
316
-
317
- ### OpenCodex models cannot be loaded
318
-
319
- Check OpenCodex first:
320
-
321
- ```bash
322
- ocx status
323
- ocx models --json
324
- curl http://127.0.0.1:10100/v1/models
325
- ```
326
-
327
- If `ocx models --json` works in your shell but fails in the bridge, check that `ocx` is available at `~/.local/bin/ocx` or `/opt/homebrew/bin/ocx`. The macOS LaunchAgent does not load your interactive shell profile.
113
+ The patcher stops when a Cursor update no longer matches its known code structure. It reports the file and mismatch in `service.error.log` without replacing that file.
328
114
 
329
115
  ## Development
330
116
 
@@ -334,14 +120,4 @@ npm run check
334
120
  npm pack --dry-run
335
121
  ```
336
122
 
337
- The code uses `node:sqlite`, so development requires Node.js 22.5 or newer.
338
-
339
- ## Compatibility
340
-
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.
342
-
343
- The current release supports macOS. It does not configure Windows Credential Manager, Linux keyrings, or system services outside launchd.
344
-
345
- ## License
346
-
347
- MIT
123
+ The project uses the MIT license.
@@ -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.1",
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,323 @@
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-v4*/";
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
+ export const cursorLocalRuntimeCapabilitiesPatchMarker = "/*ocx-cursor-local-model-capabilities-v3*/";
18
+ export const legacyCursorLocalRuntimeCapabilitiesPatchMarker = "/*ocx-cursor-local-model-capabilities-v2*/";
19
+
20
+ export function isCursorModelMetadataBundle(file) {
21
+ return file === cursorWorkbenchFile || file === cursorGlassWorkbenchFile;
22
+ }
23
+
24
+ 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;
25
+ const previousCatalogInjection = /\/\*ocx-cursor-model-metadata(?:-v[23])?\*\/(?<catalog>[A-Za-z_$][\w$]*)=\k<catalog>\.map\(ocxCursorModel=>\{.*?\}\),(?=(?<batch>[A-Za-z_$][\w$]*)\(\(\)=>\{this\._reactiveStorageService\.setApplicationUserPersistentStorage\("availableDefaultModels2",\k<catalog>\))/g;
26
+ 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;
27
+ const localRuntimeVisionCapability = /"boolean"==typeof (?<object>[A-Za-z_$][\w$]*)\.supports_vision\?\{supports_vision:\k<object>\.supports_vision\}:\{\}\)/g;
28
+ const localRuntimePickerInput = /toPickerInput\((?<model>[A-Za-z_$][\w$]*)\)\{var [A-Za-z_$][\w$]*;const [A-Za-z_$][\w$]*=this\.modelMetadataById\.get\(\k<model>\.modelId\),(?<capabilities>[A-Za-z_$][\w$]*)=.*?;return\{.*?supportsReasoning:[^,]+,supportsVision:/g;
29
+ const localRuntimeProviderPickerInput = /supportsReasoning:(?<reasoning>!0===\(null===\((?<capability>[A-Za-z_$][\w$]*)=(?<model>[A-Za-z_$][\w$]*)\.capabilities\)\|\|void 0===\k<capability>\?void 0:\k<capability>\.supports_reasoning\)),supportsVision:/g;
30
+ const localRuntimeRequestParameters = /function\((?<payload>[A-Za-z_$][\w$]*),(?<model>[A-Za-z_$][\w$]*),(?<parameters>[A-Za-z_$][\w$]*),(?<apiType>[A-Za-z_$][\w$]*),(?<route>[A-Za-z_$][\w$]*),(?<extended>[A-Za-z_$][\w$]*)=!1\)\{(?=const [A-Za-z_$][\w$]*=function\([A-Za-z_$][\w$]*\)\{var [A-Za-z_$][\w$]*;const [A-Za-z_$][\w$]*=.*?\["reasoning","effort","thought_level"\]\.includes)/g;
31
+ const localRuntimeBuilderExport = /buildBottlerocketPickerModels:\(\)=>(?<builder>[A-Za-z_$][\w$]*)/g;
32
+ const transientPatchErrorCodes = new Set(["EACCES", "EBUSY", "EPERM"]);
33
+
34
+ function replaceSingleMatch(source, pattern, replacement, label) {
35
+ const matches = [...source.matchAll(pattern)];
36
+ if (matches.length !== 1) throw new Error(`Expected one Cursor ${label}, found ${matches.length}`);
37
+ const match = matches[0];
38
+ const value = typeof replacement === "function" ? replacement(match) : replacement;
39
+ return `${source.slice(0, match.index)}${value}${source.slice(match.index + match[0].length)}`;
40
+ }
41
+
42
+ function metadataInjection(catalog) {
43
+ 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 ocxCursorDefinitions=Array.isArray(ocxCursorModel.parameterDefinitions)&&ocxCursorModel.parameterDefinitions.length>0?ocxCursorModel.parameterDefinitions:ocxCursorStored?.parameterDefinitions??[];const ocxCursorVariantSource=Array.isArray(ocxCursorModel.variants)&&ocxCursorModel.variants.length>0?ocxCursorModel.variants:ocxCursorStored?.variants??[];const ocxCursorVariants=ocxCursorVariantSource.map(ocxCursorVariant=>({...ocxCursorVariant,displayName:ocxCursorPrettyLabel(ocxCursorVariant.displayName),displayNameOutsidePicker:ocxCursorPrettyLabel(ocxCursorVariant.displayNameOutsidePicker)}));const ocxCursorLegacySlugs=Array.isArray(ocxCursorModel.legacySlugs)&&ocxCursorModel.legacySlugs.length>0?ocxCursorModel.legacySlugs:ocxCursorStored?.legacySlugs??[];return{...ocxCursorModel,clientDisplayName:ocxCursorDisplayName,inputboxShortModelName:ocxCursorDisplayName,parameterDefinitions:ocxCursorDefinitions,variants:ocxCursorVariants,legacySlugs:ocxCursorLegacySlugs,supportsThinking:void 0!==ocxCursorModel.supportsThinking?ocxCursorModel.supportsThinking:ocxCursorStored?.supportsThinking}}),`;
44
+ }
45
+
46
+ export function patchCursorWorkbenchSource(source) {
47
+ if (source.includes(cursorPatchMarker)) return { status: "already-patched", source };
48
+ if (source.includes(legacyCursorPatchMarker) || source.includes("/*ocx-cursor-model-metadata-v2*/") || source.includes("/*ocx-cursor-model-metadata-v3*/")) {
49
+ const matches = [...source.matchAll(previousCatalogInjection)];
50
+ if (matches.length !== 1) {
51
+ throw new Error(`Expected one legacy Cursor model metadata hook, found ${matches.length}`);
52
+ }
53
+ const match = matches[0];
54
+ const replacement = metadataInjection(match.groups.catalog);
55
+ return {
56
+ status: "patched",
57
+ source: `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`,
58
+ };
59
+ }
60
+ const matches = [...source.matchAll(catalogNormalization)];
61
+ if (matches.length !== 1) {
62
+ throw new Error(`Expected one Cursor model catalog storage hook, found ${matches.length}`);
63
+ }
64
+ const match = matches[0];
65
+ const { normalization, catalog } = match.groups;
66
+ const injection = `${normalization},${metadataInjection(catalog)}`;
67
+ const patched = `${source.slice(0, match.index)}${injection}${source.slice(match.index + match[0].length)}`;
68
+ return { status: "patched", source: patched };
69
+ }
70
+
71
+ export function patchCursorLocalModeSource(source) {
72
+ const disabledCount = source.split(cursorLocalModeDisabled).length - 1;
73
+ if (disabledCount > 1) {
74
+ throw new Error(`Expected at most one disabled Cursor localMode flag, found ${disabledCount}`);
75
+ }
76
+ if (disabledCount === 1) {
77
+ return {
78
+ status: "patched",
79
+ source: source.replace(cursorLocalModeDisabled, cursorLocalModeEnabled),
80
+ };
81
+ }
82
+ if (source.includes(cursorLocalModeEnabled)) return { status: "already-patched", source };
83
+ throw new Error("Cursor localMode build flag was not found");
84
+ }
85
+
86
+ export function patchCursorBundleSource(source, options = {}) {
87
+ const localMode = patchCursorLocalModeSource(source);
88
+ const metadata = options.preserveCatalogMetadata
89
+ ? patchCursorWorkbenchSource(localMode.source)
90
+ : { status: "already-patched", source: localMode.source };
91
+ return {
92
+ status: localMode.status === "patched" || metadata.status === "patched"
93
+ ? "patched"
94
+ : "already-patched",
95
+ source: metadata.source,
96
+ };
97
+ }
98
+
99
+ function patchCursorLocalRuntimeDisplayName(source) {
100
+ if (source.includes(cursorLocalRuntimePatchMarker)) return source;
101
+ const matches = [...source.matchAll(localModelConstructor)];
102
+ if (matches.length !== 1) {
103
+ throw new Error(`Expected one Cursor local model constructor, found ${matches.length}`);
104
+ }
105
+ const match = matches[0];
106
+ const { functionName, modelType } = match.groups;
107
+ 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:[]})}`;
108
+ return `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`;
109
+ }
110
+
111
+ function patchCursorLocalRuntimeCapabilities(source) {
112
+ source = replaceSingleMatch(source, localRuntimeVisionCapability, (match) => {
113
+ const object = match.groups.object;
114
+ return `${match[0].slice(0, -1)},"boolean"==typeof ${object}.supports_fast?{supports_fast:${object}.supports_fast}:{})`;
115
+ }, "local model capability parser");
116
+
117
+ source = replaceSingleMatch(source, localRuntimePickerInput, (match) => {
118
+ const capabilities = match.groups.capabilities;
119
+ return match[0].replace(
120
+ ",supportsVision:",
121
+ `,reasoningEfforts:Array.isArray(${capabilities}?.reasoning_effort)?${capabilities}.reasoning_effort:void 0,supportsFast:!0===${capabilities}?.supports_fast,supportsVision:`,
122
+ );
123
+ }, "local picker input mapper");
124
+
125
+ source = patchCursorLocalRuntimeProviderCapabilities(source);
126
+
127
+ source = replaceSingleMatch(source, localRuntimeRequestParameters, (match) => {
128
+ const { payload, parameters } = match.groups;
129
+ return `${match[0]}const ocxCursorFast=${parameters}?.find(ocxCursorParameter=>ocxCursorParameter.id==="fast")?.value;if(ocxCursorFast==="true")${payload}.service_tier="priority";else if(ocxCursorFast==="false")delete ${payload}.service_tier;`;
130
+ }, "local request parameter mapper");
131
+
132
+ const exportMatches = [...source.matchAll(localRuntimeBuilderExport)];
133
+ if (exportMatches.length !== 1) {
134
+ throw new Error(`Expected one Cursor local picker builder export, found ${exportMatches.length}`);
135
+ }
136
+ const builderName = exportMatches[0].groups.builder;
137
+ const builderStart = source.indexOf(`function ${builderName}(`, exportMatches[0].index);
138
+ if (builderStart === -1) throw new Error("Cursor local picker builder was not found");
139
+ const builderHead = source.slice(builderStart, builderStart + 1_000);
140
+ const modelBuilderName = /\.push\((?<name>[A-Za-z_$][\w$]*)\(/.exec(builderHead)?.groups.name;
141
+ if (!modelBuilderName) throw new Error("Cursor local picker model builder was not found");
142
+
143
+ const modelBuilderStart = source.indexOf(`function ${modelBuilderName}(`, builderStart);
144
+ const modelBuilderSource = source.slice(modelBuilderStart, modelBuilderStart + 6_000);
145
+ const signature = new RegExp(`function ${modelBuilderName}\\((?<model>[A-Za-z_$][\\w$]*),(?<tier>[A-Za-z_$][\\w$]*)\\)\\{var [^;]+;const (?<effort>[A-Za-z_$][\\w$]*)=function\\((?<inner>[A-Za-z_$][\\w$]*)\\)\\{`).exec(modelBuilderSource);
146
+ if (!signature) throw new Error("Cursor local picker model effort builder was not found");
147
+ const { model, effort } = signature.groups;
148
+ const effortPrefix = `const ${effort}=function(`;
149
+ const effortIndex = modelBuilderSource.indexOf(effortPrefix);
150
+ const effortReplacement = `const ${effort}=Array.isArray(${model}.reasoningEfforts)&&${model}.reasoningEfforts.length>0?{param:"reasoning_effort",values:${model}.reasoningEfforts,defaultValue:${model}.reasoningEfforts.includes("medium")?"medium":${model}.reasoningEfforts.includes("high")?"high":${model}.reasoningEfforts[0]}:function(`;
151
+ let patchedBuilder = `${modelBuilderSource.slice(0, effortIndex)}${effortReplacement}${modelBuilderSource.slice(effortIndex + effortPrefix.length)}`;
152
+
153
+ const definitions = new RegExp(`(?<name>[A-Za-z_$][\\w$]*)=\\[\\.\\.\\.void 0!==${effort}\\?`).exec(patchedBuilder)?.groups.name;
154
+ const parameterClass = /new (?<name>[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\(\{id:"reasoning"/.exec(patchedBuilder)?.groups.name;
155
+ if (!definitions || !parameterClass) throw new Error("Cursor local picker parameter definition builder was not found");
156
+ const definitionsEnd = patchedBuilder.indexOf("];var ", patchedBuilder.indexOf(`${definitions}=[`));
157
+ if (definitionsEnd === -1) throw new Error("Cursor local picker parameter definitions were not terminated");
158
+ const fastDefinition = `];${model}.supportsFast&&${definitions}.push(new ${parameterClass}({id:"fast",name:"Fast",markdownTooltip:"Use priority processing with increased usage.",parameterType:{booleanParameter:{values:[{value:"false"},{value:"true",displayName:"Fast",increasesModelCost:!0}]}},isCycleableByHotkey:!1}))`;
159
+ patchedBuilder = `${patchedBuilder.slice(0, definitionsEnd)}${fastDefinition}${patchedBuilder.slice(definitionsEnd + 1)}`;
160
+
161
+ const variantMatch = new RegExp(`variants:(?<call>[A-Za-z_$][\\w$]*\\(${effort},(?<context>[A-Za-z_$][\\w$]*),(?<display>[A-Za-z_$][\\w$]*),(?<tier>[A-Za-z_$][\\w$]*)\\))`).exec(patchedBuilder);
162
+ if (!variantMatch) throw new Error("Cursor local picker variant builder was not found");
163
+ const variantReplacement = `variants:ocxCursorFastVariants(${variantMatch.groups.call},${model}.supportsFast,${variantMatch.groups.display})`;
164
+ patchedBuilder = `${patchedBuilder.slice(0, variantMatch.index)}${variantReplacement}${patchedBuilder.slice(variantMatch.index + variantMatch[0].length)}`;
165
+
166
+ source = `${source.slice(0, modelBuilderStart)}${patchedBuilder}${source.slice(modelBuilderStart + modelBuilderSource.length)}`;
167
+ const helper = `${cursorLocalRuntimeCapabilitiesPatchMarker}function ocxCursorFastVariants(e,t,n){if(!t)return e;const r=e.length>0?e:[{parameterValues:[],displayName:n,displayNameOutsidePicker:n,isMaxMode:!1,isDefaultNonMaxConfig:!0,isDefaultMaxConfig:!0}];return r.flatMap(e=>[{...e,parameterValues:[...(e.parameterValues??[]),{id:"fast",value:"false"}]},{...e,parameterValues:[...(e.parameterValues??[]),{id:"fast",value:"true"}],displayName:e.displayName+" Fast",displayNameOutsidePicker:(e.displayNameOutsidePicker??e.displayName)+" Fast",isDefaultNonMaxConfig:!1,isDefaultMaxConfig:!1}])}`;
168
+ return `${source.slice(0, builderStart)}${helper}${source.slice(builderStart)}`;
169
+ }
170
+
171
+ function patchCursorLocalRuntimeProviderCapabilities(source) {
172
+ return replaceSingleMatch(source, localRuntimeProviderPickerInput, (match) => {
173
+ const { model, reasoning } = match.groups;
174
+ return `supportsReasoning:${reasoning},reasoningEfforts:Array.isArray(${model}.capabilities?.reasoning_effort)?${model}.capabilities.reasoning_effort:void 0,supportsFast:!0===${model}.capabilities?.supports_fast,supportsVision:`;
175
+ }, "standalone local provider picker mapper");
176
+ }
177
+
178
+ export function patchCursorLocalRuntimeSource(source) {
179
+ if (source.includes(cursorLocalRuntimeCapabilitiesPatchMarker)) return { status: "already-patched", source };
180
+ if (source.includes(legacyCursorLocalRuntimeCapabilitiesPatchMarker)) {
181
+ const upgraded = patchCursorLocalRuntimeProviderCapabilities(source).replace(
182
+ legacyCursorLocalRuntimeCapabilitiesPatchMarker,
183
+ cursorLocalRuntimeCapabilitiesPatchMarker,
184
+ );
185
+ return { status: "patched", source: upgraded };
186
+ }
187
+ const displayPatched = patchCursorLocalRuntimeDisplayName(source);
188
+ return { status: "patched", source: patchCursorLocalRuntimeCapabilities(displayPatched) };
189
+ }
190
+
191
+ export async function cursorWorkbenchSignature(file = cursorWorkbenchFile) {
192
+ const value = await stat(file);
193
+ return `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}`;
194
+ }
195
+
196
+ async function writePatchedFile(file, source, patched, backupDirectory) {
197
+ if (patched.status === "already-patched") {
198
+ return { status: patched.status, signature: await cursorWorkbenchSignature(file), backupPath: null };
199
+ }
200
+
201
+ await mkdir(backupDirectory, { recursive: true });
202
+ const digest = createHash("sha256").update(source).digest("hex").slice(0, 16);
203
+ const backupPath = join(backupDirectory, `${basename(file)}.${digest}.bak`);
204
+ await copyFile(file, backupPath);
205
+
206
+ const mode = (await stat(file)).mode & 0o777;
207
+ const temporary = `${file}.ocx-cursor-${process.pid}`;
208
+ await writeFile(temporary, patched.source, { mode });
209
+ await chmod(temporary, mode);
210
+ await rename(temporary, file);
211
+ return { status: patched.status, signature: await cursorWorkbenchSignature(file), backupPath };
212
+ }
213
+
214
+ export async function ensureCursorWorkbenchPatched(options = {}) {
215
+ const file = options.file || cursorWorkbenchFile;
216
+ const backupDirectory = options.backupDirectory || cursorPatchBackupDirectory;
217
+ const source = await readFile(file, "utf8");
218
+ const patched = patchCursorBundleSource(source, {
219
+ preserveCatalogMetadata: options.preserveCatalogMetadata ?? true,
220
+ });
221
+ return await writePatchedFile(file, source, patched, backupDirectory);
222
+ }
223
+
224
+ export async function ensureCursorLocalRuntimesPatched(options = {}) {
225
+ const files = options.files || cursorLocalRuntimeFiles;
226
+ const backupDirectory = options.backupDirectory || cursorPatchBackupDirectory;
227
+ return await Promise.all(files.map(async (file) => {
228
+ const source = await readFile(file, "utf8");
229
+ return {
230
+ file,
231
+ ...await writePatchedFile(file, source, patchCursorLocalRuntimeSource(source), backupDirectory),
232
+ };
233
+ }));
234
+ }
235
+
236
+ export async function ensureCursorBundlesPatched(options = {}) {
237
+ const files = options.files || cursorBundleFiles;
238
+ return await Promise.all(files.map(async (file) => ({
239
+ file,
240
+ ...await ensureCursorWorkbenchPatched({
241
+ ...options,
242
+ file,
243
+ preserveCatalogMetadata: isCursorModelMetadataBundle(file),
244
+ }),
245
+ })));
246
+ }
247
+
248
+ export async function ensureCursorAppPatched(options = {}) {
249
+ const [bundles, runtimes] = await Promise.all([
250
+ ensureCursorBundlesPatched({
251
+ ...options,
252
+ files: options.bundleFiles,
253
+ }),
254
+ ensureCursorLocalRuntimesPatched({
255
+ ...options,
256
+ files: options.localRuntimeFiles,
257
+ }),
258
+ ]);
259
+ return [...bundles, ...runtimes];
260
+ }
261
+
262
+ async function ensureCursorPatchFile(file, localRuntimeFiles, options) {
263
+ if (localRuntimeFiles.has(file)) {
264
+ const [result] = await ensureCursorLocalRuntimesPatched({
265
+ ...options,
266
+ files: [file],
267
+ });
268
+ return result;
269
+ }
270
+ return {
271
+ file,
272
+ ...await ensureCursorWorkbenchPatched({
273
+ ...options,
274
+ file,
275
+ preserveCatalogMetadata: options.file ? true : isCursorModelMetadataBundle(file),
276
+ }),
277
+ };
278
+ }
279
+
280
+ export function startCursorPatchMonitor(options = {}) {
281
+ const intervalMs = options.intervalMs || 250;
282
+ const bundleFiles = options.file ? [options.file] : (options.bundleFiles || options.files || cursorBundleFiles);
283
+ const runtimeFiles = options.file ? [] : (options.localRuntimeFiles || cursorLocalRuntimeFiles);
284
+ const files = [...bundleFiles, ...runtimeFiles];
285
+ const localRuntimeFiles = new Set(runtimeFiles);
286
+ const lastSignatures = new Map();
287
+ let patchPromise = null;
288
+ let stopped = false;
289
+
290
+ const check = async () => {
291
+ if (stopped || patchPromise || options.shouldPatch?.() === false) return patchPromise;
292
+ patchPromise = Promise.all(files.map(async (file) => {
293
+ let signature;
294
+ try {
295
+ signature = await cursorWorkbenchSignature(file);
296
+ } catch {
297
+ return;
298
+ }
299
+ if (signature === lastSignatures.get(file)) return;
300
+ try {
301
+ const result = await ensureCursorPatchFile(file, localRuntimeFiles, options);
302
+ lastSignatures.set(file, result.signature);
303
+ options.onResult?.(result);
304
+ } catch (error) {
305
+ if (!transientPatchErrorCodes.has(error.code)) lastSignatures.set(file, signature);
306
+ options.onError?.(error, file);
307
+ }
308
+ })).finally(() => {
309
+ patchPromise = null;
310
+ });
311
+ return patchPromise;
312
+ };
313
+
314
+ const timer = setInterval(check, intervalMs);
315
+ void check();
316
+ return {
317
+ check,
318
+ stop() {
319
+ stopped = true;
320
+ clearInterval(timer);
321
+ },
322
+ };
323
+ }
@@ -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 };