ocx-cursor 1.0.0 → 1.0.2

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
@@ -6,7 +6,7 @@
6
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
- > 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.
9
+ > The installer edits two private workbench files inside `Cursor.app`. This invalidates Cursor's vendor signature, so the bridge removes the app's quarantine attribute after each patch to prevent macOS from reporting a corrupt installation. The bridge saves the original files under `~/.opencodex/cursor-bridge/cursor-app-backups`. Reinstall Cursor to restore a vendor-signed app.
10
10
 
11
11
  ## Requirements
12
12
 
@@ -125,14 +125,18 @@ The patch keeps the model metadata that Cursor would discard. It leaves Cursor's
125
125
 
126
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.
127
127
 
128
+ On macOS, the patch monitor pauses while Cursor's `ShipIt` updater is running. After Cursor and the updater stop, it waits until all patch targets remain unchanged for five seconds before writing them. This prevents the bridge from modifying `Cursor.app` while an in-app update is replacing it.
129
+
128
130
  ## Commands
129
131
 
130
132
  | Command | Action |
131
133
  | --- | --- |
132
134
  | `ocx-cursor init --base-url URL` | Install the service, test the HTTPS endpoint, configure Cursor, and sync models. |
133
135
  | `ocx-cursor install` | Reinstall the service and check the two metadata patches. |
136
+ | `ocx-cursor stop` | Stop the service without removing its configuration or state. |
137
+ | `ocx-cursor start` | Start the installed service. |
134
138
  | `ocx-cursor update` | Install the current npm release and restart the service. |
135
- | `ocx-cursor sync` | Read the active OpenCodex catalog and update Cursor. |
139
+ | `ocx-cursor sync` | With Cursor closed, pause the service, verify the app patch, sync the active catalog, and restart the service. |
136
140
  | `ocx-cursor status` | Print service, gateway, catalog, and pending-sync status. |
137
141
  | `ocx-cursor uninstall` | Remove the service, command link, and bridge state. |
138
142
 
@@ -159,7 +163,7 @@ The bridge reads `~/.opencodex/service-api-token` when OpenCodex uses service au
159
163
  | Cursor returns 401 | Quit Cursor and rerun `npx ocx-cursor init --base-url https://YOUR_HOST/v1`. |
160
164
  | The hostname returns 502 | Run `ocx-cursor status`, then check that the tunnel points to port `10101`. |
161
165
  | 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`. |
166
+ | Models or controls are missing | Quit Cursor and run `ocx-cursor sync`. It repairs the foreground app patch before writing model metadata. |
163
167
  | A Cursor update removed the patch | Quit Cursor and run `ocx-cursor install`. |
164
168
 
165
169
  Read the service logs:
@@ -5,7 +5,7 @@ import process from "node:process";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { configureCursorOpenAI, storedCursorOpenAIBaseUrl } from "../src/cursor-config.mjs";
7
7
  import { cursorIsRunning } from "../src/cursor-state.mjs";
8
- import { installService, prepareInstallSecret, serviceStatus, uninstallService, updateService } from "../src/install.mjs";
8
+ import { installService, prepareInstallSecret, repairCursorMetadataPatch, serviceStatus, startService, stopService, uninstallService, updateService } from "../src/install.mjs";
9
9
  import { cursorOpenAIBaseUrl, pendingFile } from "../src/paths.mjs";
10
10
  import { runService } from "../src/service.mjs";
11
11
  import { normalizeBaseUrl, testEndpoint } from "../src/setup.mjs";
@@ -19,6 +19,8 @@ Usage:
19
19
  Install the service, test the endpoint, configure Cursor,
20
20
  and sync models
21
21
  ocx-cursor install Install and start the macOS companion service
22
+ ocx-cursor stop Stop the companion service without removing its state
23
+ ocx-cursor start Start the installed companion service
22
24
  ocx-cursor update Install the latest companion release and restart it
23
25
  ocx-cursor sync Sync active OpenCodex models into Cursor
24
26
  ocx-cursor launch [--port <port>]
@@ -71,6 +73,10 @@ function printSync(result) {
71
73
  }
72
74
 
73
75
  function printCursorPatches(result) {
76
+ if (!result.cursorInstalled) {
77
+ process.stdout.write("Cursor is not installed; app patches will be applied after it is installed.\n");
78
+ return;
79
+ }
74
80
  if (result.cursorPatches === null) {
75
81
  process.stdout.write("Cursor app patches will be applied after Cursor quits.\n");
76
82
  return;
@@ -131,7 +137,29 @@ async function main() {
131
137
  await updateService();
132
138
  return;
133
139
  }
140
+ if (command === "stop") {
141
+ stopService();
142
+ process.stdout.write("Stopped OpenCodex Cursor Bridge.\n");
143
+ return;
144
+ }
145
+ if (command === "start") {
146
+ await startService();
147
+ process.stdout.write("Started OpenCodex Cursor Bridge.\n");
148
+ return;
149
+ }
134
150
  if (command === "sync") {
151
+ if (!cursorIsRunning()) {
152
+ stopService();
153
+ try {
154
+ const cursorPatches = await repairCursorMetadataPatch();
155
+ const changed = cursorPatches.filter(({ status }) => status === "patched").length;
156
+ process.stdout.write(`Verified ${cursorPatches.length} Cursor metadata patches (${changed} changed).\n`);
157
+ printSync(await syncNow());
158
+ } finally {
159
+ await startService();
160
+ }
161
+ return;
162
+ }
135
163
  printSync(await syncNow());
136
164
  return;
137
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocx-cursor",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Use OpenCodex models in Cursor through an authenticated HTTPS gateway",
5
5
  "keywords": [
6
6
  "opencodex",
package/src/catalog.mjs CHANGED
@@ -83,6 +83,7 @@ export function normalizeActiveCatalog(configured, active, fastModelIds = new Se
83
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
+ if (provider === "cursor") continue;
86
87
  const inputModalities = Array.isArray(configuredModel?.inputModalities)
87
88
  ? configuredModel.inputModalities
88
89
  : Array.isArray(model.capabilities?.input_modalities)
@@ -1,7 +1,9 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { createHash } from "node:crypto";
2
3
  import { chmod, copyFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
- import { basename, join } from "node:path";
4
+ import { basename, dirname, join } from "node:path";
4
5
  import {
6
+ cursorAppPath,
5
7
  cursorBundleFiles,
6
8
  cursorGlassWorkbenchFile,
7
9
  cursorLocalRuntimeFiles,
@@ -10,8 +12,9 @@ import {
10
12
  cursorWorkbenchFile,
11
13
  } from "./paths.mjs";
12
14
 
13
- export const cursorPatchMarker = "/*ocx-cursor-model-metadata-v5*/";
15
+ export const cursorPatchMarker = "/*ocx-cursor-model-metadata-v6*/";
14
16
  export const legacyCursorPatchMarker = "/*ocx-cursor-model-metadata*/";
17
+ export const cursorByokRoutingPatchMarker = "/*ocx-cursor-byok-model-routing-v1*/";
15
18
  export const cursorLocalModeDisabled = "localMode:!1";
16
19
  export const cursorLocalModeEnabled = "localMode:!0";
17
20
  export const cursorLocalRuntimePatchMarker = "/*ocx-cursor-local-model-display-v2*/";
@@ -24,7 +27,8 @@ export function isCursorModelMetadataBundle(file) {
24
27
  }
25
28
 
26
29
  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;
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;
30
+ const previousCatalogInjection = /\/\*ocx-cursor-model-metadata(?:-v[2-5])?\*\/(?<catalog>[A-Za-z_$][\w$]*)=[\s\S]*?,(?=(?<batch>[A-Za-z_$][\w$]*)\(\(\)=>\{this\._reactiveStorageService\.setApplicationUserPersistentStorage\("availableDefaultModels2",\k<catalog>\))/g;
31
+ const byokModelRouting = /function (?<functionName>[A-Za-z_$][\w$]*)\((?<model>[A-Za-z_$][\w$]*),(?<settings>[A-Za-z_$][\w$]*)\)\{return (?<isClaude>[A-Za-z_$][\w$]*)\(\k<model>\)\?\k<settings>\.useClaudeKey\?"anthropic":void 0:(?<isGemini>[A-Za-z_$][\w$]*)\(\k<model>\)\?\k<settings>\.useGoogleKey\?"google":void 0:\k<settings>\.useOpenAIKey\?"openai":void 0\}/g;
28
32
  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;
29
33
  const localRuntimeVisionCapability = /"boolean"==typeof (?<object>[A-Za-z_$][\w$]*)\.supports_vision\?\{supports_vision:\k<object>\.supports_vision\}:\{\}\)/g;
30
34
  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;
@@ -32,6 +36,14 @@ const localRuntimeProviderPickerInput = /supportsReasoning:(?<reasoning>!0===\(n
32
36
  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;
33
37
  const localRuntimeBuilderExport = /buildBottlerocketPickerModels:\(\)=>(?<builder>[A-Za-z_$][\w$]*)/g;
34
38
  const transientPatchErrorCodes = new Set(["EACCES", "EBUSY", "EPERM"]);
39
+ const directoryPatchQueues = new Map();
40
+
41
+ export function clearCursorAppQuarantine(options = {}) {
42
+ const execute = options.execFileSync || execFileSync;
43
+ execute("/usr/bin/xattr", ["-dr", "com.apple.quarantine", options.appPath || cursorAppPath], {
44
+ stdio: "ignore",
45
+ });
46
+ }
35
47
 
36
48
  function replaceSingleMatch(source, pattern, replacement, label) {
37
49
  const matches = [...source.matchAll(pattern)];
@@ -41,13 +53,34 @@ function replaceSingleMatch(source, pattern, replacement, label) {
41
53
  return `${source.slice(0, match.index)}${value}${source.slice(match.index + match[0].length)}`;
42
54
  }
43
55
 
56
+ async function withWritableDirectory(file, callback) {
57
+ const directory = dirname(file);
58
+ const previous = directoryPatchQueues.get(directory) || Promise.resolve();
59
+ const current = previous.catch(() => {}).then(async () => {
60
+ const directoryMode = (await stat(directory)).mode & 0o777;
61
+ const restoreDirectoryMode = (directoryMode & 0o200) === 0;
62
+ if (restoreDirectoryMode) await chmod(directory, directoryMode | 0o200);
63
+ try {
64
+ return await callback();
65
+ } finally {
66
+ if (restoreDirectoryMode) await chmod(directory, directoryMode);
67
+ }
68
+ });
69
+ directoryPatchQueues.set(directory, current);
70
+ try {
71
+ return await current;
72
+ } finally {
73
+ if (directoryPatchQueues.get(directory) === current) directoryPatchQueues.delete(directory);
74
+ }
75
+ }
76
+
44
77
  function metadataInjection(catalog) {
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}}),`;
78
+ return `${cursorPatchMarker}${catalog}=[...${catalog},...(this._reactiveStorageService.applicationUserPersistentStorage.availableDefaultModels2??[]).filter(ocxCursorStoredModel=>typeof ocxCursorStoredModel?.name==="string"&&ocxCursorStoredModel.name.startsWith("opencodex/")&&!${catalog}.some(ocxCursorFreshModel=>ocxCursorFreshModel?.name===ocxCursorStoredModel.name))].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}}),`;
46
79
  }
47
80
 
48
- export function patchCursorWorkbenchSource(source) {
81
+ function patchCursorModelMetadataSource(source) {
49
82
  if (source.includes(cursorPatchMarker)) return { status: "already-patched", source };
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*/")) {
83
+ 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*/") || source.includes("/*ocx-cursor-model-metadata-v5*/")) {
51
84
  const matches = [...source.matchAll(previousCatalogInjection)];
52
85
  if (matches.length !== 1) {
53
86
  throw new Error(`Expected one legacy Cursor model metadata hook, found ${matches.length}`);
@@ -70,6 +103,24 @@ export function patchCursorWorkbenchSource(source) {
70
103
  return { status: "patched", source: patched };
71
104
  }
72
105
 
106
+ export function patchCursorByokModelRoutingSource(source) {
107
+ if (source.includes(cursorByokRoutingPatchMarker)) return { status: "already-patched", source };
108
+ const patched = replaceSingleMatch(source, byokModelRouting, (match) => {
109
+ const { functionName, model, settings, isClaude, isGemini } = match.groups;
110
+ return `function ${functionName}(${model},${settings}){${cursorByokRoutingPatchMarker}return ${isClaude}(${model})?${settings}.useClaudeKey?"anthropic":void 0:${isGemini}(${model})?${settings}.useGoogleKey?"google":void 0:${settings}.useOpenAIKey&&(${model}.startsWith("opencodex/")||${settings}.aiSettings?.userAddedModels?.includes(${model}))?"openai":void 0}`;
111
+ }, "BYOK model routing hook");
112
+ return { status: "patched", source: patched };
113
+ }
114
+
115
+ export function patchCursorWorkbenchSource(source) {
116
+ const metadata = patchCursorModelMetadataSource(source);
117
+ const routing = patchCursorByokModelRoutingSource(metadata.source);
118
+ return {
119
+ status: metadata.status === "patched" || routing.status === "patched" ? "patched" : "already-patched",
120
+ source: routing.source,
121
+ };
122
+ }
123
+
73
124
  export function patchCursorLocalModeSource(source) {
74
125
  const disabledCount = source.split(cursorLocalModeDisabled).length - 1;
75
126
  if (disabledCount > 1) {
@@ -214,9 +265,11 @@ async function writePatchedFile(file, source, patched, backupDirectory) {
214
265
 
215
266
  const mode = (await stat(file)).mode & 0o777;
216
267
  const temporary = `${file}.ocx-cursor-${process.pid}`;
217
- await writeFile(temporary, patched.source, { mode });
218
- await chmod(temporary, mode);
219
- await rename(temporary, file);
268
+ await withWritableDirectory(file, async () => {
269
+ await writeFile(temporary, patched.source, { mode });
270
+ await chmod(temporary, mode);
271
+ await rename(temporary, file);
272
+ });
220
273
  return { status: patched.status, signature: await cursorWorkbenchSignature(file), backupPath };
221
274
  }
222
275
 
@@ -310,33 +363,80 @@ export function startCursorModelMetadataPatchMonitor(options = {}) {
310
363
 
311
364
  export function startCursorPatchMonitor(options = {}) {
312
365
  const intervalMs = options.intervalMs || 250;
366
+ const stableMs = options.stableMs || 0;
313
367
  const bundleFiles = options.file ? [options.file] : (options.bundleFiles || options.files || cursorBundleFiles);
314
368
  const runtimeFiles = options.file ? [] : (options.localRuntimeFiles || cursorLocalRuntimeFiles);
315
369
  const files = [...bundleFiles, ...runtimeFiles];
316
370
  const localRuntimeFiles = new Set(runtimeFiles);
317
371
  const lastSignatures = new Map();
372
+ let stableFingerprint = null;
373
+ let stableSince = 0;
318
374
  let patchPromise = null;
319
375
  let stopped = false;
320
376
 
321
- const check = async () => {
322
- if (stopped || patchPromise || options.shouldPatch?.() === false) return patchPromise;
323
- patchPromise = Promise.all(files.map(async (file) => {
324
- let signature;
377
+ const resetStability = () => {
378
+ stableFingerprint = null;
379
+ stableSince = 0;
380
+ };
381
+
382
+ const filesAreStable = async () => {
383
+ if (stableMs === 0) return true;
384
+ const signatures = await Promise.all(files.map(async (file) => {
325
385
  try {
326
- signature = await cursorWorkbenchSignature(file);
386
+ return await cursorWorkbenchSignature(file);
327
387
  } catch {
328
- return;
329
- }
330
- if (signature === lastSignatures.get(file)) return;
331
- try {
332
- const result = await ensureCursorPatchFile(file, localRuntimeFiles, options);
333
- lastSignatures.set(file, result.signature);
334
- options.onResult?.(result);
335
- } catch (error) {
336
- if (!transientPatchErrorCodes.has(error.code)) lastSignatures.set(file, signature);
337
- options.onError?.(error, file);
388
+ return null;
338
389
  }
339
- })).finally(() => {
390
+ }));
391
+ if (signatures.some((signature) => signature === null)) {
392
+ resetStability();
393
+ return false;
394
+ }
395
+ const fingerprint = signatures.join("|");
396
+ const now = (options.now || Date.now)();
397
+ if (fingerprint !== stableFingerprint) {
398
+ stableFingerprint = fingerprint;
399
+ stableSince = now;
400
+ return false;
401
+ }
402
+ return now - stableSince >= stableMs;
403
+ };
404
+
405
+ const check = async () => {
406
+ if (stopped || patchPromise) return patchPromise;
407
+ if (options.shouldPatch?.() === false) {
408
+ resetStability();
409
+ lastSignatures.clear();
410
+ return null;
411
+ }
412
+ patchPromise = (async () => {
413
+ if (!await filesAreStable()) return [];
414
+ let retryAfterStabilityDelay = false;
415
+ const results = await Promise.all(files.map(async (file) => {
416
+ let signature;
417
+ try {
418
+ signature = await cursorWorkbenchSignature(file);
419
+ } catch {
420
+ return;
421
+ }
422
+ if (signature === lastSignatures.get(file)) return;
423
+ try {
424
+ const result = await ensureCursorPatchFile(file, localRuntimeFiles, options);
425
+ lastSignatures.set(file, result.signature);
426
+ options.onResult?.(result);
427
+ return result;
428
+ } catch (error) {
429
+ if (transientPatchErrorCodes.has(error.code)) retryAfterStabilityDelay = true;
430
+ else lastSignatures.set(file, signature);
431
+ options.onError?.(error, file);
432
+ }
433
+ }));
434
+ if (retryAfterStabilityDelay) resetStability();
435
+ const patched = results.filter((result) => result?.status === "patched");
436
+ if (patched.length > 0) await options.onPatched?.(patched);
437
+ if (results.length === files.length && results.every(Boolean)) await options.onReady?.(results);
438
+ return results;
439
+ })().finally(() => {
340
440
  patchPromise = null;
341
441
  });
342
442
  return patchPromise;
@@ -64,6 +64,17 @@ export function cursorIsRunning() {
64
64
  }
65
65
  }
66
66
 
67
+ export function cursorUpdateIsRunning(execute = execFileSync) {
68
+ try {
69
+ execute("pgrep", ["-f", "(com\\.todesktop\\.230313mzl4w4u92|co\\.anysphere\\.cursor[^ ]*)\\.ShipIt"], {
70
+ stdio: "ignore",
71
+ });
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
67
78
  export function defaultEffort(model) {
68
79
  const modelId = model.sourceId.split("/").at(-1);
69
80
  if (/^(?:gpt|gemini)-/.test(modelId) && model.reasoningEfforts.includes("medium")) return "medium";
package/src/install.mjs CHANGED
@@ -4,10 +4,11 @@ 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 { ensureCursorModelMetadataPatched } from "./cursor-patch.mjs";
7
+ import { clearCursorAppQuarantine, ensureCursorModelMetadataPatched } from "./cursor-patch.mjs";
8
8
  import { cursorIsRunning } from "./cursor-state.mjs";
9
9
  import {
10
10
  cliLinkFile,
11
+ cursorAppPath,
11
12
  installRoot,
12
13
  installedPackageRoot,
13
14
  launchAgentFile,
@@ -39,16 +40,16 @@ function commandOutput(command, args) {
39
40
  }
40
41
  }
41
42
 
42
- function bootout(label) {
43
+ function bootout(label, execute = execFileSync) {
43
44
  try {
44
- execFileSync("launchctl", ["bootout", `${launchDomain}/${label}`], { stdio: "ignore" });
45
+ execute("launchctl", ["bootout", `${launchDomain}/${label}`], { stdio: "ignore" });
45
46
  } catch {}
46
47
  }
47
48
 
48
- async function bootstrap(plist) {
49
+ async function bootstrap(plist, execute = execFileSync) {
49
50
  for (let attempt = 0; attempt < 5; attempt += 1) {
50
51
  try {
51
- execFileSync("launchctl", ["bootstrap", launchDomain, plist], { stdio: "ignore" });
52
+ execute("launchctl", ["bootstrap", launchDomain, plist], { stdio: "ignore" });
52
53
  return;
53
54
  } catch (error) {
54
55
  if (attempt === 4) throw error;
@@ -168,27 +169,50 @@ function launchAgent(nodePath) {
168
169
  export async function installService() {
169
170
  await mkdir(dirname(launchAgentFile), { recursive: true });
170
171
  const { legacyPlist, secretStatus } = await prepareInstallSecret();
171
- const cursorPatches = cursorIsRunning()
172
- ? null
173
- : await Promise.all(cursorModelMetadataFiles.map(async (file) => ({
174
- file,
175
- ...await ensureCursorModelMetadataPatched({ file }),
176
- })));
177
- await copyPackage();
178
- await installCliLink();
179
-
180
- const nodePath = (await exists("/opt/homebrew/bin/node")) ? "/opt/homebrew/bin/node" : process.execPath;
181
- await writeFile(launchAgentFile, launchAgent(nodePath), { mode: 0o644 });
182
172
  bootout(serviceLabel);
183
173
  bootout(legacyServiceLabel);
184
- await bootstrap(launchAgentFile);
174
+ let cursorInstalled;
175
+ let cursorPatches;
176
+ try {
177
+ cursorInstalled = await exists(cursorAppPath);
178
+ cursorPatches = cursorIsRunning() || !cursorInstalled
179
+ ? null
180
+ : await repairCursorMetadataPatch();
181
+ await copyPackage();
182
+ await installCliLink();
183
+
184
+ const nodePath = (await exists("/opt/homebrew/bin/node")) ? "/opt/homebrew/bin/node" : process.execPath;
185
+ await writeFile(launchAgentFile, launchAgent(nodePath), { mode: 0o644 });
186
+ await bootstrap(launchAgentFile);
187
+ } catch (error) {
188
+ if (await exists(launchAgentFile)) await bootstrap(launchAgentFile).catch(() => {});
189
+ throw error;
190
+ }
185
191
 
186
192
  if (legacyPlist.startsWith(dirname(legacyLaunchAgentFile)) && await exists(legacyPlist)) {
187
193
  const disabled = `${legacyPlist}.disabled-by-${serviceLabel}`;
188
194
  await rm(disabled, { force: true });
189
195
  await rename(legacyPlist, disabled);
190
196
  }
191
- return { installRoot, launchAgentFile, cliLinkFile, secretStatus, cursorPatches };
197
+ return { installRoot, launchAgentFile, cliLinkFile, secretStatus, cursorInstalled, cursorPatches };
198
+ }
199
+
200
+ export async function repairCursorMetadataPatch(options = {}) {
201
+ const running = options.cursorRunning ?? cursorIsRunning();
202
+ if (running) throw new Error("Quit Cursor before repairing its metadata patch");
203
+ const appPath = options.cursorAppPath || cursorAppPath;
204
+ const pathExists = options.exists || exists;
205
+ if (!await pathExists(appPath)) throw new Error("Cursor is not installed in /Applications");
206
+
207
+ const patchFile = options.ensureCursorModelMetadataPatched || ensureCursorModelMetadataPatched;
208
+ const files = options.files || cursorModelMetadataFiles;
209
+ const patches = await Promise.all(files.map(async (file) => ({
210
+ file,
211
+ ...await patchFile({ file }),
212
+ })));
213
+ const clearQuarantine = options.clearCursorAppQuarantine || clearCursorAppQuarantine;
214
+ clearQuarantine({ appPath });
215
+ return patches;
192
216
  }
193
217
 
194
218
  export async function updateService(options = {}) {
@@ -209,6 +233,21 @@ export async function updateService(options = {}) {
209
233
  }
210
234
  }
211
235
 
236
+ export function stopService(options = {}) {
237
+ bootout(serviceLabel, options.execFileSync || execFileSync);
238
+ }
239
+
240
+ export async function startService(options = {}) {
241
+ const plist = options.launchAgentFile || launchAgentFile;
242
+ const pathExists = options.exists || exists;
243
+ if (!await pathExists(plist)) {
244
+ throw new Error("OpenCodex Cursor Bridge is not installed; run ocx-cursor install first");
245
+ }
246
+ const execute = options.execFileSync || execFileSync;
247
+ bootout(serviceLabel, execute);
248
+ await bootstrap(plist, execute);
249
+ }
250
+
212
251
  export async function uninstallService() {
213
252
  bootout(serviceLabel);
214
253
  await rm(launchAgentFile, { force: true });
package/src/paths.mjs CHANGED
@@ -14,7 +14,8 @@ 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";
17
+ export const cursorAppPath = "/Applications/Cursor.app";
18
+ export const cursorAppOutDirectory = join(cursorAppPath, "Contents", "Resources", "app", "out");
18
19
  export const cursorWorkbenchFile = join(cursorAppOutDirectory, "vs", "workbench", "workbench.desktop.main.js");
19
20
  export const cursorGlassWorkbenchFile = join(cursorAppOutDirectory, "vs", "workbench", "workbench.glass.main.js");
20
21
  export const cursorModelMetadataFiles = [cursorWorkbenchFile, cursorGlassWorkbenchFile];
package/src/service.mjs CHANGED
@@ -1,9 +1,10 @@
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 { startCursorModelMetadataPatchMonitor } from "./cursor-patch.mjs";
4
+ import { clearCursorAppQuarantine, startCursorModelMetadataPatchMonitor } from "./cursor-patch.mjs";
5
5
  import {
6
6
  cursorIsRunning,
7
+ cursorUpdateIsRunning,
7
8
  readPendingCatalog,
8
9
  } from "./cursor-state.mjs";
9
10
  import { startGateway } from "./gateway.mjs";
@@ -27,6 +28,7 @@ export async function runService(options = {}) {
27
28
  let refreshPromise = null;
28
29
  let cursorSyncPromise = null;
29
30
  let wasCursorRunning = cursorIsRunning();
31
+ let cursorPatchReady = false;
30
32
  let stopped = false;
31
33
 
32
34
  const refresh = async () => {
@@ -37,7 +39,10 @@ export async function runService(options = {}) {
37
39
  if (JSON.stringify(next) === JSON.stringify(catalog)) return;
38
40
  catalog = next;
39
41
  await saveCatalogSnapshot(catalog);
40
- const result = await applyOrQueueCatalog(catalog, { createBackup: false });
42
+ const result = await applyOrQueueCatalog(catalog, {
43
+ createBackup: false,
44
+ defer: !cursorPatchReady,
45
+ });
41
46
  process.stdout.write(`${result.status === "queued" ? "Queued" : "Synced"} ${result.modelCount} Cursor models\n`);
42
47
  } catch (error) {
43
48
  process.stderr.write(`Catalog refresh failed: ${error.message}\n`);
@@ -61,19 +66,36 @@ export async function runService(options = {}) {
61
66
  const server = startGateway({ secret, getCatalog: () => catalog, host: options.host, port: options.port });
62
67
  const patchMonitor = startCursorModelMetadataPatchMonitor({
63
68
  intervalMs: options.cursorPatchIntervalMs,
64
- shouldPatch: () => !cursorIsRunning(),
69
+ stableMs: options.cursorPatchStableMs ?? 5_000,
70
+ shouldPatch: () => !cursorIsRunning() && !cursorUpdateIsRunning(),
65
71
  onResult: (result) => {
66
72
  if (result.status === "patched") process.stdout.write(`Patched Cursor app file ${result.file} (backup: ${result.backupPath})\n`);
67
73
  },
68
- onError: (error, file) => process.stderr.write(`Cursor patch failed for ${file}: ${error.message}\n`),
74
+ onPatched: () => {
75
+ try {
76
+ clearCursorAppQuarantine();
77
+ } catch (error) {
78
+ process.stderr.write(`Cursor quarantine removal failed: ${error.message}\n`);
79
+ }
80
+ },
81
+ onReady: () => {
82
+ cursorPatchReady = true;
83
+ wasCursorRunning = true;
84
+ },
85
+ onError: (error, file) => {
86
+ cursorPatchReady = false;
87
+ process.stderr.write(`Cursor patch failed for ${file}: ${error.message}\n`);
88
+ },
69
89
  });
70
90
  const refreshTimer = setInterval(refresh, options.refreshIntervalMs || 15_000);
71
91
  const pendingTimer = setInterval(() => {
72
92
  if (cursorSyncPromise) return;
73
93
  if (cursorIsRunning()) {
74
94
  wasCursorRunning = true;
95
+ cursorPatchReady = false;
75
96
  return;
76
97
  }
98
+ if (!cursorPatchReady) return;
77
99
  cursorSyncPromise = (async () => {
78
100
  const pending = await readPendingCatalog();
79
101
  const next = pending || (wasCursorRunning ? catalog : null);
package/src/sync.mjs CHANGED
@@ -25,16 +25,20 @@ export async function saveCatalogSnapshot(catalog) {
25
25
  }
26
26
 
27
27
  export async function applyOrQueueCatalog(catalog, options = {}) {
28
- if (cursorIsRunning()) {
29
- await writePendingCatalog(catalog);
28
+ const running = options.cursorRunning ?? cursorIsRunning();
29
+ const queueCatalog = options.writePendingCatalog || writePendingCatalog;
30
+ if (options.defer || running) {
31
+ await queueCatalog(catalog);
30
32
  return {
31
33
  status: "queued",
32
34
  modelCount: catalog.length,
33
35
  effortCount: catalog.filter(({ reasoningEfforts }) => reasoningEfforts.length > 0).length,
34
36
  };
35
37
  }
36
- const result = await syncCursorDatabase(catalog, options);
37
- await clearPendingCatalog();
38
+ const syncDatabase = options.syncCursorDatabase || syncCursorDatabase;
39
+ const clearPending = options.clearPendingCatalog || clearPendingCatalog;
40
+ const result = await syncDatabase(catalog, options);
41
+ await clearPending();
38
42
  return { status: "synced", ...result };
39
43
  }
40
44