ocx-cursor 0.5.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,16 +3,29 @@
3
3
  [![npm version](https://img.shields.io/npm/v/ocx-cursor.svg)](https://www.npmjs.com/package/ocx-cursor)
4
4
  [![CI](https://github.com/hiddenest/opencodex-cursor-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/hiddenest/opencodex-cursor-bridge/actions/workflows/ci.yml)
5
5
 
6
- Use your active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through a local OpenAI-compatible gateway.
6
+ Use your active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through an authenticated HTTPS endpoint on your Mac.
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
+ > The installer edits two private workbench files inside `Cursor.app`. This invalidates Cursor's vendor signature. Cursor may report a corrupt installation. The bridge saves the original files under `~/.opencodex/cursor-bridge/cursor-app-backups`. Reinstall Cursor to restore a signed app.
10
10
 
11
- ## Quick start
11
+ ## Requirements
12
12
 
13
- You need macOS, Node.js 22.5 or newer, and Cursor at `/Applications/Cursor.app`.
13
+ - macOS with Cursor installed at `/Applications/Cursor.app`
14
+ - Node.js 22.5 or newer
15
+ - OpenCodex with at least one active model
16
+ - An HTTPS hostname that forwards to `http://127.0.0.1:10101`
14
17
 
15
- Install and configure OpenCodex:
18
+ Use [Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/) for the HTTPS hostname, or choose another reverse proxy that preserves the `Authorization` header.
19
+
20
+ ## Upgrading from 0.5.x
21
+
22
+ Version 0.5.x enabled Cursor's local-agent mode and edited ten app files. Version 1.0.0 edits two workbench files and leaves local-agent mode disabled.
23
+
24
+ Reinstall Cursor before upgrading so macOS replaces the old app files. Quit the new Cursor installation, then follow the setup below. Your Cursor settings remain under `~/Library/Application Support/Cursor`.
25
+
26
+ ## Getting started
27
+
28
+ Install OpenCodex and start its login service:
16
29
 
17
30
  ```bash
18
31
  npm install --global @bitkyc08/opencodex
@@ -21,96 +34,142 @@ ocx service install
21
34
  ocx service status
22
35
  ```
23
36
 
24
- Check the configured models:
37
+ OpenCodex listens on `http://127.0.0.1:10100`. Check its active models:
25
38
 
26
39
  ```bash
27
40
  ocx models --json
28
41
  ```
29
42
 
30
- Launch Cursor and sign in once, then quit it. Run the bridge installer:
43
+ Install `cloudflared`, sign in, and create a named tunnel:
31
44
 
32
45
  ```bash
33
- npx ocx-cursor init
34
- ~/.local/bin/ocx-cursor status
46
+ brew install cloudflared
47
+ cloudflared tunnel login
48
+ cloudflared tunnel create ocx-cursor
49
+ cloudflared tunnel list
35
50
  ```
36
51
 
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`.
52
+ Create `~/.cloudflared/config.yml`. Replace the UUID, username, and hostname:
38
53
 
39
- Add the installed command to your shell path if needed:
54
+ ```yaml
55
+ tunnel: YOUR_TUNNEL_UUID
56
+ credentials-file: /Users/YOUR_USERNAME/.cloudflared/YOUR_TUNNEL_UUID.json
57
+
58
+ ingress:
59
+ - hostname: cursor-api.example.com
60
+ service: http://127.0.0.1:10101
61
+ - service: http_status:404
62
+ ```
63
+
64
+ Create the DNS record and check the ingress rule:
40
65
 
41
66
  ```bash
42
- export PATH="$HOME/.local/bin:$PATH"
67
+ cloudflared tunnel route dns ocx-cursor cursor-api.example.com
68
+ cloudflared tunnel ingress validate
69
+ cloudflared tunnel ingress rule https://cursor-api.example.com
43
70
  ```
44
71
 
45
- ## What init changes
72
+ Start the tunnel at login with Cloudflare's [macOS service command](https://developers.cloudflare.com/tunnel/advanced/local-management/as-a-service/macos/):
46
73
 
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. |
74
+ ```bash
75
+ cloudflared service install
76
+ ```
53
77
 
54
- Requests stay on loopback until OpenCodex sends them to your configured provider:
78
+ This command reads `~/.cloudflared/config.yml`. `sudo cloudflared service install` starts the tunnel at boot and reads `/etc/cloudflared/config.yml` instead.
79
+
80
+ Launch Cursor once and sign in, then quit it. Install the bridge:
81
+
82
+ ```bash
83
+ npx ocx-cursor init --base-url https://cursor-api.example.com/v1
84
+ ~/.local/bin/ocx-cursor status
85
+ ```
86
+
87
+ Open Cursor after `status` reports a running service and a healthy gateway.
88
+
89
+ ## How requests move
55
90
 
56
91
  ```text
57
- Cursor -> 127.0.0.1:10101 -> OpenCodex on 127.0.0.1:10100 -> provider
92
+ Cursor
93
+ -> https://cursor-api.example.com/v1
94
+ -> Cloudflare Tunnel
95
+ -> OpenCodex Cursor Bridge on 127.0.0.1:10101
96
+ -> OpenCodex on 127.0.0.1:10100
97
+ -> your provider
58
98
  ```
59
99
 
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`.
100
+ The bridge generates a bearer token during `init`, stores it in Cursor with macOS Safe Storage, and checks it on each `/v1/*` request. The gateway binds to `127.0.0.1` unless you set `OCX_CURSOR_HOST`.
61
101
 
62
- ## Model behavior
102
+ The package does not create, edit, or remove your Cloudflare Tunnel.
63
103
 
64
- The bridge reads OpenCodex's active `/v1/models` response and creates stable Cursor aliases:
104
+ ## Models and Cursor metadata
105
+
106
+ The bridge reads the active OpenCodex catalog and adds stable aliases to Cursor:
65
107
 
66
108
  ```text
67
109
  anthropic/claude-sonnet-5 -> opencodex/claude-sonnet-5
68
110
  gpt-5.6-sol -> opencodex/gpt-5.6-sol
69
111
  ```
70
112
 
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.
113
+ Cursor displays provider prefixes where model names overlap. Examples include `Cursor Kimi K3` and `OpenCode Go Kimi K3`. Claude and GPT names stay short.
114
+
115
+ Reasoning models receive an effort selector. Models that advertise OpenCodex's `priority` service tier receive a Fast toggle. The gateway maps those choices to `reasoning_effort` and `service_tier: priority`.
116
+
117
+ The installer patches these files:
118
+
119
+ ```text
120
+ /Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js
121
+ /Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.glass.main.js
122
+ ```
123
+
124
+ The patch keeps the model metadata that Cursor would discard. It leaves Cursor's local-agent mode disabled and does not edit either local-agent runtime.
72
125
 
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.
126
+ The login service refreshes the catalog every 15 seconds. It waits for Cursor to quit before writing the state database or reapplying a patch after a Cursor update.
74
127
 
75
- ## Commands and configuration
128
+ ## Commands
76
129
 
77
- | Command | Purpose |
130
+ | Command | Action |
78
131
  | --- | --- |
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. |
132
+ | `ocx-cursor init --base-url URL` | Install the service, test the HTTPS endpoint, configure Cursor, and sync models. |
133
+ | `ocx-cursor install` | Reinstall the service and check the two metadata patches. |
134
+ | `ocx-cursor update` | Install the current npm release and restart the service. |
135
+ | `ocx-cursor sync` | Read the active OpenCodex catalog and update Cursor. |
136
+ | `ocx-cursor status` | Print service, gateway, catalog, and pending-sync status. |
84
137
  | `ocx-cursor uninstall` | Remove the service, command link, and bridge state. |
85
138
 
86
- `uninstall` does not restore Cursor settings or patched app files. Reinstall Cursor if you want a clean vendor build.
139
+ `uninstall` leaves Cursor's endpoint setting and patched workbench files in place. Reinstall Cursor if you want a signed app without the metadata patch.
140
+
141
+ ## Configuration
87
142
 
88
143
  | Variable | Default |
89
144
  | --- | --- |
90
- | `OCX_CURSOR_BASE_URL` | `http://127.0.0.1:10101/v1` |
145
+ | `OCX_CURSOR_BASE_URL` | Cursor's stored endpoint |
91
146
  | `OCX_CURSOR_HOME` | `~/.opencodex/cursor-bridge` |
92
147
  | `OCX_CURSOR_HOST` | `127.0.0.1` |
93
148
  | `OCX_CURSOR_PORT` | `10101` |
94
149
  | `OCX_BIN` | `~/.local/bin/ocx` |
95
150
 
96
- The bridge reads an OpenCodex service token from `~/.opencodex/service-api-token` when that file exists.
151
+ The gateway exposes `GET /v1/models`, `POST /v1/chat/completions`, `POST /v1/responses`, `POST /v1/responses/compact`, and `POST /v1/messages`.
152
+
153
+ The bridge reads `~/.opencodex/service-api-token` when OpenCodex uses service authentication.
97
154
 
98
155
  ## Troubleshooting
99
156
 
100
- | Symptom | Fix |
157
+ | Symptom | Check |
101
158
  | --- | --- |
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`. |
159
+ | Cursor returns 401 | Quit Cursor and rerun `npx ocx-cursor init --base-url https://YOUR_HOST/v1`. |
160
+ | The hostname returns 502 | Run `ocx-cursor status`, then check that the tunnel points to port `10101`. |
161
+ | Cloudflare returns 1016 | Run `cloudflared tunnel info ocx-cursor` and check the service. |
162
+ | Models or controls are missing | Quit Cursor and run `ocx-cursor sync`. |
163
+ | A Cursor update removed the patch | Quit Cursor and run `ocx-cursor install`. |
164
+
165
+ Read the service logs:
107
166
 
108
167
  ```bash
109
168
  tail -n 100 ~/.opencodex/cursor-bridge/service.log
110
169
  tail -n 100 ~/.opencodex/cursor-bridge/service.error.log
111
170
  ```
112
171
 
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.
172
+ The patcher stops if a Cursor update removes its known catalog hook. It records the file and error without replacing that file.
114
173
 
115
174
  ## Development
116
175
 
@@ -2,10 +2,11 @@
2
2
 
3
3
  import { readFile } from "node:fs/promises";
4
4
  import process from "node:process";
5
- import { configureCursorOpenAI } from "../src/cursor-config.mjs";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { configureCursorOpenAI, storedCursorOpenAIBaseUrl } from "../src/cursor-config.mjs";
6
7
  import { cursorIsRunning } from "../src/cursor-state.mjs";
7
8
  import { installService, prepareInstallSecret, serviceStatus, uninstallService, updateService } from "../src/install.mjs";
8
- import { cursorLocalBaseUrl, cursorOpenAIBaseUrl, pendingFile } from "../src/paths.mjs";
9
+ import { cursorOpenAIBaseUrl, pendingFile } from "../src/paths.mjs";
9
10
  import { runService } from "../src/service.mjs";
10
11
  import { normalizeBaseUrl, testEndpoint } from "../src/setup.mjs";
11
12
  import { loadCatalogSnapshot, syncNow } from "../src/sync.mjs";
@@ -36,7 +37,19 @@ function argumentValue(name) {
36
37
 
37
38
  async function requestedBaseUrl() {
38
39
  const supplied = argumentValue("--base-url");
39
- return normalizeBaseUrl(supplied || cursorOpenAIBaseUrl || cursorLocalBaseUrl);
40
+ const fallback = cursorOpenAIBaseUrl || storedCursorOpenAIBaseUrl();
41
+ if (supplied) return normalizeBaseUrl(supplied);
42
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return normalizeBaseUrl(fallback);
43
+
44
+ const prompt = fallback
45
+ ? `HTTPS endpoint [${fallback}]: `
46
+ : "HTTPS endpoint (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
+ }
40
53
  }
41
54
 
42
55
  async function pendingCount() {
@@ -63,7 +76,7 @@ function printCursorPatches(result) {
63
76
  return;
64
77
  }
65
78
  const changed = result.cursorPatches.filter(({ status }) => status === "patched").length;
66
- process.stdout.write(`Verified ${result.cursorPatches.length} Cursor app patches (${changed} changed).\n`);
79
+ process.stdout.write(`Verified ${result.cursorPatches.length} Cursor metadata patches (${changed} changed).\n`);
67
80
  }
68
81
 
69
82
  async function main() {
@@ -83,7 +96,7 @@ async function main() {
83
96
  process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${installed.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
84
97
  printCursorPatches(installed);
85
98
  printSync(await syncNow());
86
- process.stdout.write("Endpoint: http://127.0.0.1:10101/v1\n");
99
+ process.stdout.write("Local gateway: http://127.0.0.1:10101/v1\n");
87
100
  return;
88
101
  }
89
102
  if (command === "init") {
@@ -100,7 +113,7 @@ async function main() {
100
113
  try {
101
114
  endpoint = await testEndpoint(baseUrl, prepared.secret);
102
115
  } catch (error) {
103
- throw new Error(`${error.message}\nThe bridge service is running locally. Fix the endpoint and rerun ocx-cursor init.`);
116
+ throw new Error(`${error.message}\nThe bridge service is running locally. Fix the HTTPS endpoint and rerun ocx-cursor init.`);
104
117
  }
105
118
  process.stdout.write(`Endpoint is ready (${endpoint.modelCount} models reachable).\n`);
106
119
  const configured = await configureCursorOpenAI({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ocx-cursor",
3
- "version": "0.5.1",
4
- "description": "OpenCodex companion service for Cursor custom models",
3
+ "version": "1.0.0",
4
+ "description": "Use OpenCodex models in Cursor through an authenticated HTTPS gateway",
5
5
  "keywords": [
6
6
  "opencodex",
7
7
  "cursor",
@@ -5,15 +5,17 @@ import {
5
5
  cursorBundleFiles,
6
6
  cursorGlassWorkbenchFile,
7
7
  cursorLocalRuntimeFiles,
8
+ cursorModelMetadataFiles,
8
9
  cursorPatchBackupDirectory,
9
10
  cursorWorkbenchFile,
10
11
  } from "./paths.mjs";
11
12
 
12
- export const cursorPatchMarker = "/*ocx-cursor-model-metadata-v4*/";
13
+ export const cursorPatchMarker = "/*ocx-cursor-model-metadata-v5*/";
13
14
  export const legacyCursorPatchMarker = "/*ocx-cursor-model-metadata*/";
14
15
  export const cursorLocalModeDisabled = "localMode:!1";
15
16
  export const cursorLocalModeEnabled = "localMode:!0";
16
- export const cursorLocalRuntimePatchMarker = "/*ocx-cursor-local-model-display*/";
17
+ export const cursorLocalRuntimePatchMarker = "/*ocx-cursor-local-model-display-v2*/";
18
+ export const legacyCursorLocalRuntimePatchMarker = "/*ocx-cursor-local-model-display*/";
17
19
  export const cursorLocalRuntimeCapabilitiesPatchMarker = "/*ocx-cursor-local-model-capabilities-v3*/";
18
20
  export const legacyCursorLocalRuntimeCapabilitiesPatchMarker = "/*ocx-cursor-local-model-capabilities-v2*/";
19
21
 
@@ -22,7 +24,7 @@ export function isCursorModelMetadataBundle(file) {
22
24
  }
23
25
 
24
26
  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;
27
+ const previousCatalogInjection = /\/\*ocx-cursor-model-metadata(?:-v[234])?\*\/(?<catalog>[A-Za-z_$][\w$]*)=\k<catalog>\.map\(ocxCursorModel=>\{.*?\}\),(?=(?<batch>[A-Za-z_$][\w$]*)\(\(\)=>\{this\._reactiveStorageService\.setApplicationUserPersistentStorage\("availableDefaultModels2",\k<catalog>\))/g;
26
28
  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
29
  const localRuntimeVisionCapability = /"boolean"==typeof (?<object>[A-Za-z_$][\w$]*)\.supports_vision\?\{supports_vision:\k<object>\.supports_vision\}:\{\}\)/g;
28
30
  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;
@@ -40,12 +42,12 @@ function replaceSingleMatch(source, pattern, replacement, label) {
40
42
  }
41
43
 
42
44
  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}}),`;
45
+ 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=ocxCursorStored?.clientDisplayName??((ocxCursorModel.name.startsWith("opencodex/cursor/")?"Cursor ":"")+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
46
  }
45
47
 
46
48
  export function patchCursorWorkbenchSource(source) {
47
49
  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*/")) {
50
+ if (source.includes(legacyCursorPatchMarker) || source.includes("/*ocx-cursor-model-metadata-v2*/") || source.includes("/*ocx-cursor-model-metadata-v3*/") || source.includes("/*ocx-cursor-model-metadata-v4*/")) {
49
51
  const matches = [...source.matchAll(previousCatalogInjection)];
50
52
  if (matches.length !== 1) {
51
53
  throw new Error(`Expected one legacy Cursor model metadata hook, found ${matches.length}`);
@@ -98,13 +100,18 @@ export function patchCursorBundleSource(source, options = {}) {
98
100
 
99
101
  function patchCursorLocalRuntimeDisplayName(source) {
100
102
  if (source.includes(cursorLocalRuntimePatchMarker)) return source;
103
+ if (source.includes(legacyCursorLocalRuntimePatchMarker)) {
104
+ return source
105
+ .replace(legacyCursorLocalRuntimePatchMarker, cursorLocalRuntimePatchMarker)
106
+ .replace('e.startsWith("opencodex/")?e.split("/")', 'e.startsWith("opencodex/")?(e.startsWith("opencodex/cursor/")?"Cursor ":"")+e.split("/")');
107
+ }
101
108
  const matches = [...source.matchAll(localModelConstructor)];
102
109
  if (matches.length !== 1) {
103
110
  throw new Error(`Expected one Cursor local model constructor, found ${matches.length}`);
104
111
  }
105
112
  const match = matches[0];
106
113
  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:[]})}`;
114
+ 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.startsWith("opencodex/cursor/")?"Cursor ":"")+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
115
  return `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`;
109
116
  }
110
117
 
@@ -176,15 +183,17 @@ function patchCursorLocalRuntimeProviderCapabilities(source) {
176
183
  }
177
184
 
178
185
  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(
186
+ const displayPatched = patchCursorLocalRuntimeDisplayName(source);
187
+ if (displayPatched.includes(cursorLocalRuntimeCapabilitiesPatchMarker)) {
188
+ return { status: displayPatched === source ? "already-patched" : "patched", source: displayPatched };
189
+ }
190
+ if (displayPatched.includes(legacyCursorLocalRuntimeCapabilitiesPatchMarker)) {
191
+ const upgraded = patchCursorLocalRuntimeProviderCapabilities(displayPatched).replace(
182
192
  legacyCursorLocalRuntimeCapabilitiesPatchMarker,
183
193
  cursorLocalRuntimeCapabilitiesPatchMarker,
184
194
  );
185
195
  return { status: "patched", source: upgraded };
186
196
  }
187
- const displayPatched = patchCursorLocalRuntimeDisplayName(source);
188
197
  return { status: "patched", source: patchCursorLocalRuntimeCapabilities(displayPatched) };
189
198
  }
190
199
 
@@ -221,6 +230,13 @@ export async function ensureCursorWorkbenchPatched(options = {}) {
221
230
  return await writePatchedFile(file, source, patched, backupDirectory);
222
231
  }
223
232
 
233
+ export async function ensureCursorModelMetadataPatched(options = {}) {
234
+ const file = options.file || cursorWorkbenchFile;
235
+ const backupDirectory = options.backupDirectory || cursorPatchBackupDirectory;
236
+ const source = await readFile(file, "utf8");
237
+ return await writePatchedFile(file, source, patchCursorWorkbenchSource(source), backupDirectory);
238
+ }
239
+
224
240
  export async function ensureCursorLocalRuntimesPatched(options = {}) {
225
241
  const files = options.files || cursorLocalRuntimeFiles;
226
242
  const backupDirectory = options.backupDirectory || cursorPatchBackupDirectory;
@@ -260,6 +276,12 @@ export async function ensureCursorAppPatched(options = {}) {
260
276
  }
261
277
 
262
278
  async function ensureCursorPatchFile(file, localRuntimeFiles, options) {
279
+ if (options.metadataOnly) {
280
+ return {
281
+ file,
282
+ ...await ensureCursorModelMetadataPatched({ ...options, file }),
283
+ };
284
+ }
263
285
  if (localRuntimeFiles.has(file)) {
264
286
  const [result] = await ensureCursorLocalRuntimesPatched({
265
287
  ...options,
@@ -277,6 +299,15 @@ async function ensureCursorPatchFile(file, localRuntimeFiles, options) {
277
299
  };
278
300
  }
279
301
 
302
+ export function startCursorModelMetadataPatchMonitor(options = {}) {
303
+ return startCursorPatchMonitor({
304
+ ...options,
305
+ bundleFiles: options.bundleFiles || cursorModelMetadataFiles,
306
+ localRuntimeFiles: [],
307
+ metadataOnly: true,
308
+ });
309
+ }
310
+
280
311
  export function startCursorPatchMonitor(options = {}) {
281
312
  const intervalMs = options.intervalMs || 250;
282
313
  const bundleFiles = options.file ? [options.file] : (options.bundleFiles || options.files || cursorBundleFiles);
@@ -29,6 +29,11 @@ const displayWords = new Map([
29
29
  ["spark", "Spark"],
30
30
  ["terra", "Terra"],
31
31
  ]);
32
+ const providerDisplayNames = new Map([
33
+ ["cursor", "Cursor"],
34
+ ["opencode-go", "OpenCode Go"],
35
+ ]);
36
+ const unprefixedProviders = new Set(["anthropic", "openai"]);
32
37
 
33
38
  function displayWord(value) {
34
39
  const known = displayWords.get(value);
@@ -42,12 +47,17 @@ function displayWord(value) {
42
47
  }
43
48
 
44
49
  export function displayNameFor(model) {
45
- return model.sourceId.split("/").at(-1).split("-").map(displayWord).join(" ");
50
+ const provider = model.provider || (model.sourceId.includes("/") ? model.sourceId.split("/", 1)[0] : "openai");
51
+ const providerDisplayName = providerDisplayNames.get(provider)
52
+ || provider.split(/[-_]/).map(displayWord).join(" ");
53
+ const displayName = model.sourceId.split("/").at(-1).split("-").map(displayWord).join(" ");
54
+ return unprefixedProviders.has(provider) ? displayName : `${providerDisplayName} ${displayName}`;
46
55
  }
47
56
 
48
57
  export function cursorIsRunning() {
49
58
  try {
50
- execFileSync("pgrep", ["-f", "/Applications/Cursor.app/Contents/"], { stdio: "ignore" });
59
+ // Match the main binary only: orphaned crashpad helpers under Contents/ outlive the app.
60
+ execFileSync("pgrep", ["-f", "/Applications/Cursor.app/Contents/MacOS/Cursor"], { stdio: "ignore" });
51
61
  return true;
52
62
  } catch {
53
63
  return false;
@@ -165,6 +175,9 @@ function syncSelectedModel(state, catalog) {
165
175
  const composer = state.aiSettings?.modelConfig?.composer;
166
176
  if (!composer || !Array.isArray(composer.selectedModels)) return;
167
177
  const byAlias = new Map(catalog.map((model) => [model.alias, model]));
178
+ composer.selectedModels = composer.selectedModels.filter((selected) => (
179
+ typeof selected?.modelId !== "string" || !selected.modelId.startsWith(managedPrefix) || byAlias.has(selected.modelId)
180
+ ));
168
181
  for (const selected of composer.selectedModels) {
169
182
  const model = byAlias.get(selected.modelId);
170
183
  if (!model || (model.reasoningEfforts.length === 0 && !model.supportsFast)) continue;
@@ -187,7 +200,7 @@ export function applyCatalogToState(state, catalog) {
187
200
  const keepUnmanaged = (value) => typeof value !== "string" || !value.startsWith(managedPrefix);
188
201
  const existingModels = Array.isArray(state.availableDefaultModels2) ? state.availableDefaultModels2 : [];
189
202
  state.availableDefaultModels2 = [
190
- ...existingModels.filter((model) => !(model?.isUserAdded && typeof model.name === "string" && model.name.startsWith(managedPrefix))),
203
+ ...existingModels.filter((model) => !(typeof model?.name === "string" && model.name.startsWith(managedPrefix))),
191
204
  ...catalog.map(cursorModel),
192
205
  ];
193
206
  state.aiSettings ||= {};
@@ -199,6 +212,14 @@ export function applyCatalogToState(state, catalog) {
199
212
  ...(Array.isArray(state.aiSettings.modelOverrideEnabled) ? state.aiSettings.modelOverrideEnabled.filter(keepUnmanaged) : []),
200
213
  ...aliases,
201
214
  ];
215
+ if (state.aiSettings.modelParameterPreferences && typeof state.aiSettings.modelParameterPreferences === "object") {
216
+ const activeAliases = new Set(aliases);
217
+ for (const alias of Object.keys(state.aiSettings.modelParameterPreferences)) {
218
+ if (alias.startsWith(managedPrefix) && !activeAliases.has(alias)) {
219
+ delete state.aiSettings.modelParameterPreferences[alias];
220
+ }
221
+ }
222
+ }
202
223
  syncSelectedModel(state, catalog);
203
224
  return state;
204
225
  }
package/src/gateway.mjs CHANGED
@@ -118,10 +118,15 @@ async function serviceToken() {
118
118
  }
119
119
  }
120
120
 
121
- function authorized(req, expected) {
122
- const supplied = String(req.headers.authorization || "").replace(/^Bearer\s+/i, "").trim();
123
- const actual = Buffer.from(supplied);
124
- return actual.length === expected.length && timingSafeEqual(actual, expected);
121
+ export function authorized(req, expected) {
122
+ const supplied = [
123
+ String(req.headers.authorization || "").replace(/^Bearer\s+/i, "").trim(),
124
+ String(req.headers["x-api-key"] || "").trim(),
125
+ ];
126
+ return supplied.some((value) => {
127
+ const actual = Buffer.from(value);
128
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
129
+ });
125
130
  }
126
131
 
127
132
  function json(res, status, body) {
@@ -161,7 +166,7 @@ function upstreamHeaders(req, body, token, upstream) {
161
166
 
162
167
  export function startGateway(options) {
163
168
  const host = options.host || gatewayHost;
164
- const port = options.port || gatewayPort;
169
+ const port = options.port ?? gatewayPort;
165
170
  const expected = Buffer.from(options.secret);
166
171
  let activeRequests = 0;
167
172
 
@@ -179,16 +184,29 @@ export function startGateway(options) {
179
184
  if (activeRequests >= 8) return json(res, 429, { error: { message: "bridge concurrency limit reached", type: "rate_limit_error" } });
180
185
 
181
186
  activeRequests += 1;
182
- res.once("close", () => { activeRequests = Math.max(0, activeRequests - 1); });
187
+ let upstreamReq;
188
+ let downstreamClosed = false;
189
+ const cancelUpstream = () => {
190
+ downstreamClosed = true;
191
+ if (upstreamReq && !upstreamReq.destroyed) upstreamReq.destroy();
192
+ };
193
+ req.once("aborted", cancelUpstream);
194
+ res.once("close", () => {
195
+ activeRequests = Math.max(0, activeRequests - 1);
196
+ if (!res.writableEnded) cancelUpstream();
197
+ });
183
198
  try {
184
199
  if (route === "GET /v1/models") {
185
200
  return json(res, 200, enrichModelList(await activeModels(), catalog));
186
201
  }
187
202
 
188
- const upstream = opencodexEndpoint();
189
- const body = rewriteModelAliasBody(await readBody(req), catalog);
190
- const token = await serviceToken();
191
- const upstreamReq = http.request({
203
+ const upstream = options.upstream || opencodexEndpoint();
204
+ const receivedBody = await readBody(req);
205
+ if (downstreamClosed) return;
206
+ const body = rewriteModelAliasBody(receivedBody, catalog);
207
+ const token = options.serviceToken !== undefined ? options.serviceToken : await serviceToken();
208
+ if (downstreamClosed) return;
209
+ upstreamReq = http.request({
192
210
  hostname: upstream.host,
193
211
  port: upstream.port,
194
212
  method: req.method,
@@ -204,6 +222,7 @@ export function startGateway(options) {
204
222
  });
205
223
  });
206
224
  upstreamReq.on("error", (error) => {
225
+ if (downstreamClosed) return;
207
226
  if (!res.headersSent) json(res, 502, { error: { message: "OpenCodex upstream unavailable", type: "upstream_error" } });
208
227
  else res.destroy(error);
209
228
  });
package/src/install.mjs CHANGED
@@ -4,7 +4,7 @@ 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";
7
+ import { ensureCursorModelMetadataPatched } from "./cursor-patch.mjs";
8
8
  import { cursorIsRunning } from "./cursor-state.mjs";
9
9
  import {
10
10
  cliLinkFile,
@@ -13,6 +13,7 @@ import {
13
13
  launchAgentFile,
14
14
  legacyLaunchAgentFile,
15
15
  legacyServiceLabel,
16
+ cursorModelMetadataFiles,
16
17
  secretFile,
17
18
  serviceLabel,
18
19
  stderrFile,
@@ -167,7 +168,12 @@ function launchAgent(nodePath) {
167
168
  export async function installService() {
168
169
  await mkdir(dirname(launchAgentFile), { recursive: true });
169
170
  const { legacyPlist, secretStatus } = await prepareInstallSecret();
170
- const cursorPatches = cursorIsRunning() ? null : await ensureCursorAppPatched();
171
+ const cursorPatches = cursorIsRunning()
172
+ ? null
173
+ : await Promise.all(cursorModelMetadataFiles.map(async (file) => ({
174
+ file,
175
+ ...await ensureCursorModelMetadataPatched({ file }),
176
+ })));
171
177
  await copyPackage();
172
178
  await installCliLink();
173
179
 
package/src/paths.mjs CHANGED
@@ -17,6 +17,7 @@ export const cursorDatabaseFile = join(homedir(), "Library", "Application Suppor
17
17
  export const cursorAppOutDirectory = "/Applications/Cursor.app/Contents/Resources/app/out";
18
18
  export const cursorWorkbenchFile = join(cursorAppOutDirectory, "vs", "workbench", "workbench.desktop.main.js");
19
19
  export const cursorGlassWorkbenchFile = join(cursorAppOutDirectory, "vs", "workbench", "workbench.glass.main.js");
20
+ export const cursorModelMetadataFiles = [cursorWorkbenchFile, cursorGlassWorkbenchFile];
20
21
  export const cursorLocalRuntimeFiles = [
21
22
  "/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-local-agent-runtime/dist/main.js",
22
23
  "/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js",
@@ -39,6 +40,5 @@ export const opencodexConfigFile = join(homedir(), ".opencodex", "config.json");
39
40
  export const opencodexServiceTokenFile = join(homedir(), ".opencodex", "service-api-token");
40
41
  export const gatewayPort = Number(process.env.OCX_CURSOR_PORT || "10101");
41
42
  export const gatewayHost = process.env.OCX_CURSOR_HOST || "127.0.0.1";
42
- export const cursorLocalBaseUrl = `http://127.0.0.1:${gatewayPort}/v1`;
43
43
  export const cursorOpenAIBaseUrl = process.env.OCX_CURSOR_BASE_URL || "";
44
44
  export const managedPrefix = "opencodex/";
package/src/service.mjs CHANGED
@@ -1,7 +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
+ import { startCursorModelMetadataPatchMonitor } from "./cursor-patch.mjs";
5
5
  import {
6
6
  cursorIsRunning,
7
7
  readPendingCatalog,
@@ -59,7 +59,7 @@ export async function runService(options = {}) {
59
59
  }
60
60
 
61
61
  const server = startGateway({ secret, getCatalog: () => catalog, host: options.host, port: options.port });
62
- const patchMonitor = startCursorPatchMonitor({
62
+ const patchMonitor = startCursorModelMetadataPatchMonitor({
63
63
  intervalMs: options.cursorPatchIntervalMs,
64
64
  shouldPatch: () => !cursorIsRunning(),
65
65
  onResult: (result) => {
package/src/setup.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  export function normalizeBaseUrl(value) {
2
2
  if (!value) {
3
- throw new Error("Enter an endpoint URL or pass --base-url http://127.0.0.1:10101/v1");
3
+ throw new Error("Enter an HTTPS endpoint or pass --base-url https://cursor-api.example.com/v1");
4
4
  }
5
5
 
6
6
  const trimmed = value.trim();