ocx-cursor 0.3.2 → 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 +78 -151
- package/bin/ocx-cursor.mjs +51 -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/runtime-injector.mjs +209 -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:
|
|
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/):
|
|
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.
|
|
61
|
+
See the [OpenCodex documentation](https://lidge-jun.github.io/opencodex/) for provider-specific setup.
|
|
133
62
|
|
|
134
|
-
###
|
|
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`:
|
|
82
|
+
|
|
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.
|
|
170
91
|
|
|
171
|
-
The installer links `ocx-cursor` into `~/.local/bin`. Add that directory to `PATH` if
|
|
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,29 +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. |
|
|
138
|
+
| `ocx-cursor launch` | Experimentally launch Cursor with live effort and Fast metadata injection. Keep the command running. |
|
|
215
139
|
| `ocx-cursor status` | Show service health, model count, and pending sync state. |
|
|
216
140
|
| `ocx-cursor uninstall` | Remove the LaunchAgent, command link, and bridge home directory. |
|
|
217
141
|
|
|
218
|
-
`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.
|
|
219
143
|
|
|
220
|
-
`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.
|
|
221
145
|
|
|
222
146
|
## Model mapping
|
|
223
147
|
|
|
@@ -228,18 +152,32 @@ anthropic/claude-sonnet-5 -> opencodex/claude-sonnet-5
|
|
|
228
152
|
gpt-5.6-sol -> opencodex/gpt-5.6-sol
|
|
229
153
|
```
|
|
230
154
|
|
|
231
|
-
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.
|
|
160
|
+
|
|
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.
|
|
232
162
|
|
|
233
|
-
|
|
163
|
+
### Experimental live model metadata
|
|
164
|
+
|
|
165
|
+
`ocx-cursor launch` starts Cursor with a random loopback-only debugging port and keeps `opencodex/*` effort and Fast metadata in the live model catalog:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
ocx-cursor launch
|
|
169
|
+
```
|
|
170
|
+
|
|
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.
|
|
234
172
|
|
|
235
173
|
## Configuration
|
|
236
174
|
|
|
237
175
|
| Variable | Default | Purpose |
|
|
238
176
|
| --- | --- | --- |
|
|
239
|
-
| `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. |
|
|
240
178
|
| `OCX_CURSOR_HOME` | `~/.opencodex/cursor-bridge` | Service state, API key, catalog, and logs. |
|
|
241
179
|
| `OCX_CURSOR_HOST` | `127.0.0.1` | Local gateway bind address. |
|
|
242
|
-
| `OCX_CURSOR_PORT` | `10101` | Local gateway port. |
|
|
180
|
+
| `OCX_CURSOR_PORT` | `10101` | Local gateway port. Also changes the default Cursor endpoint. |
|
|
243
181
|
| `OCX_BIN` | `~/.local/bin/ocx` | OpenCodex CLI path. |
|
|
244
182
|
|
|
245
183
|
The gateway accepts these routes:
|
|
@@ -249,59 +187,48 @@ The gateway accepts these routes:
|
|
|
249
187
|
- `POST /v1/responses` and `/v1/responses/compact`
|
|
250
188
|
- `POST /v1/messages`
|
|
251
189
|
|
|
252
|
-
The gateway requires its generated bearer token on each `/v1/*` request. It binds to loopback unless you change `OCX_CURSOR_HOST`.
|
|
253
|
-
|
|
254
190
|
## Troubleshooting
|
|
255
191
|
|
|
256
|
-
###
|
|
192
|
+
### Cursor returns 401
|
|
257
193
|
|
|
258
|
-
|
|
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:
|
|
259
195
|
|
|
260
196
|
```bash
|
|
261
|
-
ocx-cursor
|
|
262
|
-
tail -n 100 ~/.opencodex/cursor-bridge/service.error.log
|
|
263
|
-
tail -n 100 ~/.opencodex/cursor-bridge/service.log
|
|
197
|
+
npx ocx-cursor init
|
|
264
198
|
```
|
|
265
199
|
|
|
266
|
-
|
|
200
|
+
### The local gateway is unavailable
|
|
201
|
+
|
|
202
|
+
Check the service and logs, then reinstall it if needed:
|
|
267
203
|
|
|
268
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
|
|
269
208
|
ocx-cursor install
|
|
270
209
|
```
|
|
271
210
|
|
|
272
|
-
###
|
|
273
|
-
|
|
274
|
-
The DNS record exists, but no tunnel connector is online. Check the named tunnel and start it:
|
|
275
|
-
|
|
276
|
-
```bash
|
|
277
|
-
cloudflared tunnel info ocx-cursor
|
|
278
|
-
cloudflared tunnel run ocx-cursor
|
|
279
|
-
```
|
|
211
|
+
### Models or effort options are missing
|
|
280
212
|
|
|
281
|
-
|
|
213
|
+
Quit Cursor and run:
|
|
282
214
|
|
|
283
215
|
```bash
|
|
284
|
-
|
|
216
|
+
ocx-cursor sync
|
|
217
|
+
ocx-cursor status
|
|
285
218
|
```
|
|
286
219
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
Run `init` again while Cursor is closed. It preserves the bridge key, retests the tunnel, and writes the matching encrypted value back to Cursor:
|
|
290
|
-
|
|
291
|
-
```bash
|
|
292
|
-
npx ocx-cursor init
|
|
293
|
-
```
|
|
220
|
+
If the status shows a pending sync, wait until every Cursor Helper process has exited. The service applies the queued catalog automatically.
|
|
294
221
|
|
|
295
|
-
###
|
|
222
|
+
### Patches are missing after a Cursor update
|
|
296
223
|
|
|
297
|
-
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:
|
|
298
225
|
|
|
299
226
|
```bash
|
|
300
|
-
ocx-cursor
|
|
301
|
-
|
|
227
|
+
ocx-cursor install
|
|
228
|
+
tail -n 100 ~/.opencodex/cursor-bridge/service.log
|
|
302
229
|
```
|
|
303
230
|
|
|
304
|
-
|
|
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.
|
|
305
232
|
|
|
306
233
|
### OpenCodex models cannot be loaded
|
|
307
234
|
|
|
@@ -327,7 +254,7 @@ The code uses `node:sqlite`, so development requires Node.js 22.5 or newer.
|
|
|
327
254
|
|
|
328
255
|
## Compatibility
|
|
329
256
|
|
|
330
|
-
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.
|
|
331
258
|
|
|
332
259
|
The current release supports macOS. It does not configure Windows Credential Manager, Linux keyrings, or system services outside launchd.
|
|
333
260
|
|
package/bin/ocx-cursor.mjs
CHANGED
|
@@ -2,24 +2,28 @@
|
|
|
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";
|
|
12
|
+
import { findAvailableDebugPort, launchCursorForInjection, runRuntimeInjector } from "../src/runtime-injector.mjs";
|
|
13
13
|
|
|
14
14
|
const usage = `OpenCodex Cursor Bridge
|
|
15
15
|
|
|
16
16
|
Usage:
|
|
17
|
-
ocx-cursor init [--base-url <
|
|
18
|
-
Install the service, test the
|
|
17
|
+
ocx-cursor init [--base-url <url>]
|
|
18
|
+
Install the service, test the endpoint, configure Cursor,
|
|
19
19
|
and sync models
|
|
20
20
|
ocx-cursor install Install and start the macOS companion service
|
|
21
21
|
ocx-cursor update Install the latest companion release and restart it
|
|
22
22
|
ocx-cursor sync Sync active OpenCodex models into Cursor
|
|
23
|
+
ocx-cursor launch [--port <port>]
|
|
24
|
+
Launch Cursor with the experimental runtime model hook
|
|
25
|
+
ocx-cursor inject --port <port> [--reload]
|
|
26
|
+
Attach the experimental hook to a debug-enabled Cursor
|
|
23
27
|
ocx-cursor status Show service and model-sync status
|
|
24
28
|
ocx-cursor uninstall Stop and remove the companion service
|
|
25
29
|
ocx-cursor service Run the service in the foreground (internal)
|
|
@@ -32,19 +36,7 @@ function argumentValue(name) {
|
|
|
32
36
|
|
|
33
37
|
async function requestedBaseUrl() {
|
|
34
38
|
const supplied = argumentValue("--base-url");
|
|
35
|
-
|
|
36
|
-
if (supplied) return normalizeBaseUrl(supplied);
|
|
37
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) return normalizeBaseUrl(fallback);
|
|
38
|
-
|
|
39
|
-
const prompt = fallback
|
|
40
|
-
? `Cloudflare Tunnel URL [${fallback}]: `
|
|
41
|
-
: "Cloudflare Tunnel URL (for example, https://cursor-api.example.com): ";
|
|
42
|
-
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
43
|
-
try {
|
|
44
|
-
return normalizeBaseUrl((await readline.question(prompt)).trim() || fallback);
|
|
45
|
-
} finally {
|
|
46
|
-
readline.close();
|
|
47
|
-
}
|
|
39
|
+
return normalizeBaseUrl(supplied || cursorOpenAIBaseUrl || cursorLocalBaseUrl);
|
|
48
40
|
}
|
|
49
41
|
|
|
50
42
|
async function pendingCount() {
|
|
@@ -65,6 +57,15 @@ function printSync(result) {
|
|
|
65
57
|
}
|
|
66
58
|
}
|
|
67
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
|
+
|
|
68
69
|
async function main() {
|
|
69
70
|
const command = process.argv[2] || "help";
|
|
70
71
|
if (["help", "--help", "-h"].includes(command)) {
|
|
@@ -80,6 +81,7 @@ async function main() {
|
|
|
80
81
|
if (command === "install") {
|
|
81
82
|
const installed = await installService();
|
|
82
83
|
process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${installed.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
|
|
84
|
+
printCursorPatches(installed);
|
|
83
85
|
printSync(await syncNow());
|
|
84
86
|
process.stdout.write("Endpoint: http://127.0.0.1:10101/v1\n");
|
|
85
87
|
return;
|
|
@@ -92,14 +94,15 @@ async function main() {
|
|
|
92
94
|
const prepared = await prepareInstallSecret();
|
|
93
95
|
const installed = await installService();
|
|
94
96
|
process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${prepared.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
printCursorPatches(installed);
|
|
98
|
+
process.stdout.write(`Testing endpoint: ${new URL(baseUrl).origin}\n`);
|
|
99
|
+
let endpoint;
|
|
97
100
|
try {
|
|
98
|
-
|
|
101
|
+
endpoint = await testEndpoint(baseUrl, prepared.secret);
|
|
99
102
|
} catch (error) {
|
|
100
|
-
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.`);
|
|
101
104
|
}
|
|
102
|
-
process.stdout.write(`
|
|
105
|
+
process.stdout.write(`Endpoint is ready (${endpoint.modelCount} models reachable).\n`);
|
|
103
106
|
const configured = await configureCursorOpenAI({
|
|
104
107
|
secret: prepared.secret,
|
|
105
108
|
baseUrl,
|
|
@@ -119,6 +122,30 @@ async function main() {
|
|
|
119
122
|
printSync(await syncNow());
|
|
120
123
|
return;
|
|
121
124
|
}
|
|
125
|
+
if (command === "launch") {
|
|
126
|
+
if (cursorIsRunning()) throw new Error("Quit Cursor before running ocx-cursor launch");
|
|
127
|
+
const requestedPort = argumentValue("--port");
|
|
128
|
+
const port = requestedPort ? Number(requestedPort) : await findAvailableDebugPort();
|
|
129
|
+
const pid = launchCursorForInjection(port);
|
|
130
|
+
process.stdout.write(`Launched Cursor ${pid} with runtime injection on 127.0.0.1:${port}. Keep this command running.\n`);
|
|
131
|
+
await runRuntimeInjector({
|
|
132
|
+
port,
|
|
133
|
+
onInjection: (count) => process.stdout.write(`Injected ${count} OpenCodex model definitions.\n`),
|
|
134
|
+
onError: (error) => process.stderr.write(`Runtime injection failed: ${error.message}\n`),
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (command === "inject") {
|
|
139
|
+
const port = Number(argumentValue("--port"));
|
|
140
|
+
process.stdout.write(`Attaching runtime injection to Cursor on 127.0.0.1:${port}. Keep this command running.\n`);
|
|
141
|
+
await runRuntimeInjector({
|
|
142
|
+
port,
|
|
143
|
+
reloadExisting: process.argv.includes("--reload"),
|
|
144
|
+
onInjection: (count) => process.stdout.write(`Injected ${count} OpenCodex model definitions.\n`),
|
|
145
|
+
onError: (error) => process.stderr.write(`Runtime injection failed: ${error.message}\n`),
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
122
149
|
if (command === "status") {
|
|
123
150
|
const [status, catalog, pending] = await Promise.all([
|
|
124
151
|
serviceStatus(),
|
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/";
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import { cursorModel } from "./cursor-state.mjs";
|
|
5
|
+
import { loadCatalogSnapshot } from "./sync.mjs";
|
|
6
|
+
|
|
7
|
+
export const cursorBinary = "/Applications/Cursor.app/Contents/MacOS/Cursor";
|
|
8
|
+
export const cursorWorkbenchBundle = "/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js";
|
|
9
|
+
export const cursorWorkbenchUrl = "vscode-file://vscode-app/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js";
|
|
10
|
+
|
|
11
|
+
const catalogStorageAnchor = "c=c.map(z=>XTt(z)),bp(()=>{this._reactiveStorageService.setApplicationUserPersistentStorage(\"availableDefaultModels2\",c)";
|
|
12
|
+
const breakpointOffset = "c=c.map(z=>XTt(z)),".length;
|
|
13
|
+
|
|
14
|
+
export function findCatalogHookLocation(source) {
|
|
15
|
+
const index = source.indexOf(catalogStorageAnchor);
|
|
16
|
+
if (index === -1) throw new Error("This Cursor version does not contain the supported model catalog hook");
|
|
17
|
+
if (source.indexOf(catalogStorageAnchor, index + 1) !== -1) {
|
|
18
|
+
throw new Error("Cursor model catalog hook is ambiguous");
|
|
19
|
+
}
|
|
20
|
+
const lines = source.slice(0, index + breakpointOffset).split("\n");
|
|
21
|
+
return {
|
|
22
|
+
lineNumber: lines.length - 1,
|
|
23
|
+
columnNumber: lines.at(-1).length,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildRuntimeModelPatches(catalog) {
|
|
28
|
+
return Object.fromEntries(catalog.map((model) => {
|
|
29
|
+
const value = cursorModel(model);
|
|
30
|
+
return [value.name, value];
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function catalogPatchExpression(patches) {
|
|
35
|
+
return `(() => {
|
|
36
|
+
const patches = ${JSON.stringify(patches)};
|
|
37
|
+
let count = 0;
|
|
38
|
+
for (const model of c) {
|
|
39
|
+
const patch = patches[model.name];
|
|
40
|
+
if (!patch) continue;
|
|
41
|
+
count += 1;
|
|
42
|
+
Object.assign(model, patch);
|
|
43
|
+
}
|
|
44
|
+
return count;
|
|
45
|
+
})()`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class CdpConnection {
|
|
49
|
+
constructor(url) {
|
|
50
|
+
this.url = url;
|
|
51
|
+
this.nextId = 0;
|
|
52
|
+
this.pending = new Map();
|
|
53
|
+
this.listeners = new Set();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async open() {
|
|
57
|
+
this.socket = new WebSocket(this.url);
|
|
58
|
+
this.socket.onmessage = ({ data }) => this.handleMessage(JSON.parse(data));
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
this.socket.onopen = resolve;
|
|
61
|
+
this.socket.onerror = reject;
|
|
62
|
+
});
|
|
63
|
+
this.socket.onclose = () => {
|
|
64
|
+
for (const { reject } of this.pending.values()) reject(new Error("Cursor debugger disconnected"));
|
|
65
|
+
this.pending.clear();
|
|
66
|
+
};
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
call(method, params = {}) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const id = ++this.nextId;
|
|
73
|
+
this.pending.set(id, { resolve, reject });
|
|
74
|
+
this.socket.send(JSON.stringify({ id, method, params }));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onEvent(listener) {
|
|
79
|
+
this.listeners.add(listener);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
handleMessage(message) {
|
|
83
|
+
if (message.id) {
|
|
84
|
+
const pending = this.pending.get(message.id);
|
|
85
|
+
if (!pending) return;
|
|
86
|
+
this.pending.delete(message.id);
|
|
87
|
+
if (message.error) pending.reject(new Error(message.error.message));
|
|
88
|
+
else pending.resolve(message.result);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const listener of this.listeners) listener(message);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
close() {
|
|
95
|
+
this.socket?.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function cursorTargets(port) {
|
|
100
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/list`, {
|
|
101
|
+
signal: AbortSignal.timeout(1000),
|
|
102
|
+
});
|
|
103
|
+
if (!response.ok) throw new Error(`Cursor debugger returned HTTP ${response.status}`);
|
|
104
|
+
return await response.json();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function attachCatalogHook(target, options) {
|
|
108
|
+
const connection = await new CdpConnection(target.webSocketDebuggerUrl).open();
|
|
109
|
+
let breakpointId;
|
|
110
|
+
connection.onEvent((message) => {
|
|
111
|
+
if (message.method !== "Debugger.paused" || !message.params.hitBreakpoints.includes(breakpointId)) return;
|
|
112
|
+
const frame = message.params.callFrames[0];
|
|
113
|
+
void (async () => {
|
|
114
|
+
try {
|
|
115
|
+
const patches = buildRuntimeModelPatches(await loadCatalogSnapshot());
|
|
116
|
+
const result = await connection.call("Debugger.evaluateOnCallFrame", {
|
|
117
|
+
callFrameId: frame.callFrameId,
|
|
118
|
+
expression: catalogPatchExpression(patches),
|
|
119
|
+
returnByValue: true,
|
|
120
|
+
});
|
|
121
|
+
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || "Catalog injection failed");
|
|
122
|
+
options.onInjection?.(Number(result.result.value || 0), target);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
options.onError?.(error, target);
|
|
125
|
+
} finally {
|
|
126
|
+
await connection.call("Debugger.resume").catch(() => {});
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await connection.call("Debugger.enable");
|
|
132
|
+
const breakpoint = await connection.call("Debugger.setBreakpointByUrl", {
|
|
133
|
+
url: cursorWorkbenchUrl,
|
|
134
|
+
lineNumber: options.location.lineNumber,
|
|
135
|
+
columnNumber: options.location.columnNumber,
|
|
136
|
+
});
|
|
137
|
+
breakpointId = breakpoint.breakpointId;
|
|
138
|
+
if (options.reload) await connection.call("Page.reload");
|
|
139
|
+
return connection;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function runRuntimeInjector(options = {}) {
|
|
143
|
+
const port = Number(options.port);
|
|
144
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("A valid Cursor debugging port is required");
|
|
145
|
+
const source = await readFile(options.bundleFile || cursorWorkbenchBundle, "utf8");
|
|
146
|
+
const location = findCatalogHookLocation(source);
|
|
147
|
+
const connections = new Map();
|
|
148
|
+
let foundTarget = false;
|
|
149
|
+
let missingPolls = 0;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
while (missingPolls < (options.maxMissingPolls || 10)) {
|
|
153
|
+
let targets = [];
|
|
154
|
+
try {
|
|
155
|
+
targets = await cursorTargets(port);
|
|
156
|
+
missingPolls = 0;
|
|
157
|
+
} catch {
|
|
158
|
+
missingPolls += 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const target of targets.filter(({ type }) => type === "page")) {
|
|
162
|
+
if (connections.has(target.id)) continue;
|
|
163
|
+
const connection = await attachCatalogHook(target, {
|
|
164
|
+
location,
|
|
165
|
+
reload: options.reloadExisting === true && !foundTarget,
|
|
166
|
+
onInjection: options.onInjection,
|
|
167
|
+
onError: options.onError,
|
|
168
|
+
});
|
|
169
|
+
connections.set(target.id, connection);
|
|
170
|
+
foundTarget = true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const [id, connection] of connections) {
|
|
174
|
+
if (targets.some((target) => target.id === id)) continue;
|
|
175
|
+
connection.close();
|
|
176
|
+
connections.delete(id);
|
|
177
|
+
}
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs || 500));
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
for (const connection of connections.values()) connection.close();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!foundTarget) throw new Error(`No Cursor renderer appeared on debugging port ${port}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function launchCursorForInjection(port, options = {}) {
|
|
188
|
+
const child = spawn(options.cursorBinary || cursorBinary, [
|
|
189
|
+
"--remote-debugging-address=127.0.0.1",
|
|
190
|
+
`--remote-debugging-port=${port}`,
|
|
191
|
+
], {
|
|
192
|
+
detached: true,
|
|
193
|
+
stdio: "ignore",
|
|
194
|
+
});
|
|
195
|
+
child.unref();
|
|
196
|
+
return child.pid;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function findAvailableDebugPort() {
|
|
200
|
+
const server = createServer();
|
|
201
|
+
await new Promise((resolve, reject) => {
|
|
202
|
+
server.once("error", reject);
|
|
203
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
204
|
+
});
|
|
205
|
+
const address = server.address();
|
|
206
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
207
|
+
if (!address || typeof address === "string") throw new Error("Could not allocate a Cursor debugging port");
|
|
208
|
+
return address.port;
|
|
209
|
+
}
|
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 };
|