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 +68 -152
- package/bin/ocx-cursor.mjs +22 -24
- package/package.json +1 -1
- package/src/catalog.mjs +1 -1
- package/src/cursor-patch.mjs +231 -0
- package/src/cursor-state.mjs +45 -3
- package/src/gateway.mjs +10 -0
- package/src/install.mjs +4 -1
- package/src/paths.mjs +19 -0
- package/src/service.mjs +10 -0
- package/src/setup.mjs +20 -11
package/README.md
CHANGED
|
@@ -3,20 +3,18 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/ocx-cursor)
|
|
4
4
|
[](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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
98
|
+
Check the service before opening Cursor:
|
|
178
99
|
|
|
179
100
|
```bash
|
|
180
101
|
ocx-cursor status
|
|
181
|
-
curl
|
|
102
|
+
curl http://127.0.0.1:10101/healthz
|
|
182
103
|
```
|
|
183
104
|
|
|
184
|
-
The status output should show `Service: running` and `Gateway: healthy`.
|
|
105
|
+
The status output should show `Service: running` and `Gateway: healthy`. Open Cursor after both checks pass.
|
|
185
106
|
|
|
186
|
-
|
|
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
|
-
->
|
|
199
|
-
->
|
|
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
|
|
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,
|
|
212
|
-
| `ocx-cursor install` | Reinstall
|
|
213
|
-
| `ocx-cursor update` | Download the latest
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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` |
|
|
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
|
-
###
|
|
192
|
+
### Cursor returns 401
|
|
268
193
|
|
|
269
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
213
|
+
Quit Cursor and run:
|
|
293
214
|
|
|
294
215
|
```bash
|
|
295
|
-
|
|
216
|
+
ocx-cursor sync
|
|
217
|
+
ocx-cursor status
|
|
296
218
|
```
|
|
297
219
|
|
|
298
|
-
|
|
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
|
-
###
|
|
222
|
+
### Patches are missing after a Cursor update
|
|
307
223
|
|
|
308
|
-
Cursor
|
|
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
|
|
312
|
-
|
|
227
|
+
ocx-cursor install
|
|
228
|
+
tail -n 100 ~/.opencodex/cursor-bridge/service.log
|
|
313
229
|
```
|
|
314
230
|
|
|
315
|
-
|
|
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
|
|
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
|
|
package/bin/ocx-cursor.mjs
CHANGED
|
@@ -2,21 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import process from "node:process";
|
|
5
|
-
import {
|
|
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,
|
|
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 <
|
|
19
|
-
Install the service, test the
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
97
|
+
printCursorPatches(installed);
|
|
98
|
+
process.stdout.write(`Testing endpoint: ${new URL(baseUrl).origin}\n`);
|
|
99
|
+
let endpoint;
|
|
102
100
|
try {
|
|
103
|
-
|
|
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
|
|
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(`
|
|
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
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"
|
|
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
|
+
}
|
package/src/cursor-state.mjs
CHANGED
|
@@ -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
|
-
? `${
|
|
71
|
-
:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
15
|
+
throw new Error(`Invalid endpoint URL: ${value}`);
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
|
|
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("
|
|
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("
|
|
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
|
|
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`, {}, "
|
|
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(`
|
|
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
|
-
}, "
|
|
80
|
+
}, "Endpoint model check");
|
|
72
81
|
if (!Array.isArray(models?.data)) {
|
|
73
|
-
throw new Error(`
|
|
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 };
|