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 +7 -3
- package/bin/ocx-cursor.mjs +29 -1
- package/package.json +1 -1
- package/src/catalog.mjs +1 -0
- package/src/cursor-patch.mjs +125 -25
- package/src/cursor-state.mjs +11 -0
- package/src/install.mjs +57 -18
- package/src/paths.mjs +2 -1
- package/src/service.mjs +26 -4
- package/src/sync.mjs +8 -4
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
|
|
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` |
|
|
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:
|
package/bin/ocx-cursor.mjs
CHANGED
|
@@ -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
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)
|
package/src/cursor-patch.mjs
CHANGED
|
@@ -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-
|
|
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[
|
|
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}
|
|
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
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
}))
|
|
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;
|
package/src/cursor-state.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
37
|
-
|
|
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
|
|