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