ocx-cursor 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hiddenest
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # OpenCodex Cursor Bridge
2
+
3
+ [![npm version](https://img.shields.io/npm/v/ocx-cursor.svg)](https://www.npmjs.com/package/ocx-cursor)
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
+
6
+ Use active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through its custom OpenAI endpoint. The package runs a local gateway and keeps Cursor's custom model list in sync.
7
+
8
+ ## Requirements
9
+
10
+ - macOS with Cursor installed at `/Applications/Cursor.app`
11
+ - Node.js 22.5 or newer
12
+ - OpenCodex installed, signed in, and running
13
+ - An HTTPS hostname that forwards to `http://127.0.0.1:10101`
14
+
15
+ Launch Cursor and sign in once before setup. Cursor creates the Safe Storage key that the installer uses to encrypt the gateway API key.
16
+
17
+ ## Set up an HTTPS endpoint
18
+
19
+ Cursor's custom OpenAI endpoint must use HTTPS. Point a tunnel or reverse proxy at the local gateway on port `10101`.
20
+
21
+ This Cloudflare Tunnel config maps `cursor-api.example.com` to the gateway:
22
+
23
+ ```yaml
24
+ # ~/.cloudflared/config.yml
25
+ tunnel: YOUR_TUNNEL_ID
26
+ credentials-file: /Users/YOU/.cloudflared/YOUR_TUNNEL_ID.json
27
+
28
+ ingress:
29
+ - hostname: cursor-api.example.com
30
+ service: http://127.0.0.1:10101
31
+ - service: http_status:404
32
+ ```
33
+
34
+ Create the DNS route and run the tunnel:
35
+
36
+ ```bash
37
+ cloudflared tunnel route dns YOUR_TUNNEL_NAME cursor-api.example.com
38
+ cloudflared tunnel run YOUR_TUNNEL_NAME
39
+ ```
40
+
41
+ The package does not install or manage the tunnel.
42
+
43
+ ## Install
44
+
45
+ Quit Cursor, confirm that OpenCodex is running, then run:
46
+
47
+ ```bash
48
+ npx ocx-cursor init \
49
+ --base-url https://cursor-api.example.com/v1
50
+ ```
51
+
52
+ `init` performs these actions:
53
+
54
+ 1. Generates a gateway API key in `~/.opencodex/cursor-bridge/secret`.
55
+ 2. Stores the key in Cursor with macOS Safe Storage encryption.
56
+ 3. Registers the HTTPS URL as Cursor's OpenAI base URL.
57
+ 4. Installs the `com.opencodex.cursor-bridge` LaunchAgent.
58
+ 5. Adds active OpenCodex models to Cursor under `opencodex/*`.
59
+
60
+ The installer links `ocx-cursor` into `~/.local/bin`. Add that directory to `PATH` if your shell does not include it:
61
+
62
+ ```bash
63
+ export PATH="$HOME/.local/bin:$PATH"
64
+ ```
65
+
66
+ Open Cursor after `init` finishes. Models with known reasoning controls show an effort value in the picker. Use `Shift+Command+/` to cycle it.
67
+
68
+ ## Commands
69
+
70
+ | Command | Purpose |
71
+ | --- | --- |
72
+ | `ocx-cursor init --base-url URL` | Configure Cursor, install the service, and sync models. Cursor must be closed. |
73
+ | `ocx-cursor install` | Reinstall or restart the LaunchAgent without changing Cursor's API settings. |
74
+ | `ocx-cursor sync` | Refresh the active model catalog. The service queues the update while Cursor runs. |
75
+ | `ocx-cursor status` | Show service health, model count, and pending sync state. |
76
+ | `ocx-cursor uninstall` | Remove the LaunchAgent, command link, and bridge home directory. |
77
+
78
+ `uninstall` leaves Cursor's custom endpoint and model records in its state database.
79
+
80
+ ## Model mapping
81
+
82
+ The bridge maps source model IDs to Cursor aliases:
83
+
84
+ ```text
85
+ anthropic/claude-sonnet-5 -> opencodex/claude-sonnet-5
86
+ gpt-5.6-sol -> opencodex/gpt-5.6-sol
87
+ ```
88
+
89
+ The catalog includes models returned by OpenCodex's active `/v1/models` endpoint. It excludes the OpenCodex `cursor/*` provider to avoid duplicating Cursor's own models.
90
+
91
+ Cursor removes custom effort metadata from its database during startup. The LaunchAgent writes that metadata back after Cursor exits, once all Cursor Helper processes have stopped.
92
+
93
+ ## Configuration
94
+
95
+ | Variable | Default | Purpose |
96
+ | --- | --- | --- |
97
+ | `OCX_CURSOR_BASE_URL` | Stored Cursor URL | HTTPS endpoint used by `init` when `--base-url` is absent. |
98
+ | `OCX_CURSOR_HOME` | `~/.opencodex/cursor-bridge` | Service state, API key, catalog, and logs. |
99
+ | `OCX_CURSOR_HOST` | `127.0.0.1` | Local gateway bind address. |
100
+ | `OCX_CURSOR_PORT` | `10101` | Local gateway port. |
101
+ | `OCX_BIN` | `~/.local/bin/ocx` | OpenCodex CLI path. |
102
+
103
+ The gateway accepts these routes:
104
+
105
+ - `GET /v1/models`
106
+ - `POST /v1/chat/completions`
107
+ - `POST /v1/responses` and `/v1/responses/compact`
108
+ - `POST /v1/messages`
109
+
110
+ The gateway requires its generated bearer token on each `/v1/*` request. It binds to loopback unless you change `OCX_CURSOR_HOST`.
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ npm test
116
+ npm run check
117
+ npm pack --dry-run
118
+ ```
119
+
120
+ The code uses `node:sqlite`, so development requires Node.js 22.5 or newer.
121
+
122
+ ## Compatibility
123
+
124
+ The package writes Cursor's local state database and uses Cursor's Safe Storage format. Cursor does not document either interface. A Cursor update can change them.
125
+
126
+ The current release supports macOS. It does not configure Windows Credential Manager, Linux keyrings, or system services outside launchd.
127
+
128
+ ## License
129
+
130
+ MIT
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import process from "node:process";
5
+ import { configureCursorOpenAI, storedCursorOpenAIBaseUrl } from "../src/cursor-config.mjs";
6
+ import { cursorIsRunning } from "../src/cursor-state.mjs";
7
+ import { installService, prepareInstallSecret, serviceStatus, uninstallService } from "../src/install.mjs";
8
+ import { cursorOpenAIBaseUrl, pendingFile } from "../src/paths.mjs";
9
+ import { runService } from "../src/service.mjs";
10
+ import { loadCatalogSnapshot, syncNow } from "../src/sync.mjs";
11
+
12
+ const usage = `OpenCodex Cursor Bridge
13
+
14
+ Usage:
15
+ ocx-cursor init --base-url <https-url>
16
+ Configure Cursor, install the service, and sync models
17
+ ocx-cursor install Install and start the macOS companion service
18
+ ocx-cursor sync Sync active OpenCodex models into Cursor
19
+ ocx-cursor status Show service and model-sync status
20
+ ocx-cursor uninstall Stop and remove the companion service
21
+ ocx-cursor service Run the service in the foreground (internal)
22
+ `;
23
+
24
+ function argumentValue(name) {
25
+ const index = process.argv.indexOf(name);
26
+ return index === -1 ? "" : String(process.argv[index + 1] || "");
27
+ }
28
+
29
+ function normalizedBaseUrl(value) {
30
+ if (!value) {
31
+ throw new Error("Pass --base-url https://your-domain.example/v1 or set OCX_CURSOR_BASE_URL");
32
+ }
33
+ let url;
34
+ try {
35
+ url = new URL(value);
36
+ } catch {
37
+ throw new Error(`Invalid Cursor OpenAI base URL: ${value}`);
38
+ }
39
+ if (url.protocol !== "https:") throw new Error("Cursor OpenAI base URL must use HTTPS");
40
+ url.pathname = url.pathname.replace(/\/$/, "");
41
+ if (!url.pathname.endsWith("/v1")) throw new Error("Cursor OpenAI base URL must end with /v1");
42
+ return url.toString().replace(/\/$/, "");
43
+ }
44
+
45
+ async function pendingCount() {
46
+ try {
47
+ const value = JSON.parse(await readFile(pendingFile, "utf8"));
48
+ return Array.isArray(value) ? value.length : 0;
49
+ } catch {
50
+ return 0;
51
+ }
52
+ }
53
+
54
+ function printSync(result) {
55
+ const effort = `${result.effortCount} with effort selector`;
56
+ if (result.status === "queued") {
57
+ process.stdout.write(`Queued ${result.modelCount} active models (${effort}); they will be applied after Cursor quits.\n`);
58
+ } else {
59
+ process.stdout.write(`Synced ${result.modelCount} active models (${effort}).\nBackup: ${result.backupPath}\n`);
60
+ }
61
+ }
62
+
63
+ async function main() {
64
+ const command = process.argv[2] || "help";
65
+ if (["help", "--help", "-h"].includes(command)) {
66
+ process.stdout.write(usage);
67
+ return;
68
+ }
69
+ if (["--version", "-v"].includes(command)) {
70
+ const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
71
+ process.stdout.write(`${pkg.version}\n`);
72
+ return;
73
+ }
74
+
75
+ if (command === "install") {
76
+ const installed = await installService();
77
+ process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${installed.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
78
+ printSync(await syncNow());
79
+ process.stdout.write("Endpoint: http://127.0.0.1:10101/v1\n");
80
+ return;
81
+ }
82
+ if (command === "init") {
83
+ if (cursorIsRunning()) {
84
+ throw new Error("Quit Cursor before running ocx-cursor init so model variants and effort selectors can be applied");
85
+ }
86
+ const baseUrl = normalizedBaseUrl(
87
+ argumentValue("--base-url") || cursorOpenAIBaseUrl || storedCursorOpenAIBaseUrl(),
88
+ );
89
+ const prepared = await prepareInstallSecret();
90
+ const configured = await configureCursorOpenAI({
91
+ secret: prepared.secret,
92
+ baseUrl,
93
+ });
94
+ process.stdout.write(configured.changed
95
+ ? `Configured Cursor OpenAI endpoint: ${baseUrl}\nBackup: ${configured.backupPath}\n`
96
+ : `Cursor OpenAI endpoint is already configured: ${baseUrl}\n`);
97
+ const installed = await installService();
98
+ process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${installed.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
99
+ printSync(await syncNow());
100
+ return;
101
+ }
102
+ if (command === "sync") {
103
+ printSync(await syncNow());
104
+ return;
105
+ }
106
+ if (command === "status") {
107
+ const [status, catalog, pending] = await Promise.all([
108
+ serviceStatus(),
109
+ loadCatalogSnapshot(),
110
+ pendingCount(),
111
+ ]);
112
+ process.stdout.write([
113
+ `Installed: ${status.installed ? "yes" : "no"}`,
114
+ `Service: ${status.loaded ? "running" : "stopped"}`,
115
+ `Gateway: ${status.health?.status === "ok" ? `healthy (${status.health.models} models)` : "unavailable"}`,
116
+ `Catalog: ${catalog.length} active models`,
117
+ `Pending Cursor sync: ${pending ? `${pending} models` : "none"}`,
118
+ `Home: ${status.installRoot}`,
119
+ ].join("\n") + "\n");
120
+ return;
121
+ }
122
+ if (command === "uninstall") {
123
+ await uninstallService();
124
+ process.stdout.write("Uninstalled OpenCodex Cursor Bridge.\n");
125
+ return;
126
+ }
127
+ if (command === "service") {
128
+ await runService();
129
+ return;
130
+ }
131
+ throw new Error(`Unknown command: ${command}\n\n${usage}`);
132
+ }
133
+
134
+ main().catch((error) => {
135
+ process.stderr.write(`${error.message || error}\n`);
136
+ process.exitCode = 1;
137
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "ocx-cursor",
3
+ "version": "0.1.0",
4
+ "description": "OpenCodex companion service for Cursor custom models",
5
+ "keywords": [
6
+ "opencodex",
7
+ "cursor",
8
+ "llm",
9
+ "proxy"
10
+ ],
11
+ "homepage": "https://github.com/hiddenest/opencodex-cursor-bridge#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/hiddenest/opencodex-cursor-bridge/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/hiddenest/opencodex-cursor-bridge.git"
18
+ },
19
+ "author": "hiddenest",
20
+ "type": "module",
21
+ "bin": {
22
+ "ocx-cursor": "bin/ocx-cursor.mjs"
23
+ },
24
+ "files": [
25
+ "bin",
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "test": "node --test",
32
+ "check": "node --check bin/ocx-cursor.mjs && find src -maxdepth 1 -name '*.mjs' -print0 | xargs -0 -n1 node --check",
33
+ "prepublishOnly": "npm test && npm run check"
34
+ },
35
+ "engines": {
36
+ "node": ">=22.5"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "license": "MIT"
42
+ }
@@ -0,0 +1,117 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { managedPrefix, opencodexConfigFile, opencodexServiceTokenFile } from "./paths.mjs";
6
+
7
+ export const allowedEfforts = ["low", "medium", "high", "xhigh", "max"];
8
+ export const effortLabels = {
9
+ low: "Low",
10
+ medium: "Medium",
11
+ high: "High",
12
+ xhigh: "Extra High",
13
+ max: "Max",
14
+ };
15
+
16
+ export function opencodexEndpoint() {
17
+ let config = {};
18
+ try {
19
+ config = JSON.parse(execFileSync("/bin/cat", [opencodexConfigFile], { encoding: "utf8" }));
20
+ } catch {}
21
+ const port = Number(config.port || 10100);
22
+ const configuredHost = String(config.hostname || "127.0.0.1");
23
+ const host = configuredHost === "0.0.0.0" || configuredHost === "::" ? "127.0.0.1" : configuredHost;
24
+ return { host, port };
25
+ }
26
+
27
+ export function aliasFor(sourceId) {
28
+ return sourceId.startsWith("anthropic/claude-")
29
+ ? `${managedPrefix}${sourceId.slice("anthropic/".length)}`
30
+ : `${managedPrefix}${sourceId}`;
31
+ }
32
+
33
+ export function inferredEfforts(sourceId) {
34
+ const modelId = sourceId.split("/").at(-1);
35
+ if (/^claude-(?:fable-5|sonnet-5|opus-(?:5|4-[78]))$/.test(modelId)) return [...allowedEfforts];
36
+ if (/^claude-(?:opus-4-6|sonnet-4-6)$/.test(modelId)) return ["low", "medium", "high", "max"];
37
+ if (/^gpt-5\.6-(?:luna|sol|terra)$/.test(modelId)) return [...allowedEfforts];
38
+ if (/^gpt-5\.[45](?:$|-)/.test(modelId)) return ["low", "medium", "high", "xhigh"];
39
+ return [];
40
+ }
41
+
42
+ export function sanitizeEfforts(sourceId, configured) {
43
+ const values = Array.isArray(configured) ? configured : inferredEfforts(sourceId);
44
+ return allowedEfforts.filter((effort) => values.includes(effort));
45
+ }
46
+
47
+ export function normalizeActiveCatalog(configured, active) {
48
+ const configuredById = new Map(configured
49
+ .filter((model) => typeof model.provider === "string" && typeof model.model === "string")
50
+ .map((model) => [`${model.provider}/${model.model}`, model]));
51
+ const models = new Map();
52
+
53
+ for (const model of active) {
54
+ if (typeof model?.id !== "string" || model.owned_by === "opencodex" || model.id.startsWith("cursor/")) continue;
55
+ const configuredModel = configuredById.get(model.id);
56
+ const provider = model.id.includes("/") ? model.id.split("/", 1)[0] : String(model.owned_by || "openai").toLowerCase();
57
+ const inputModalities = Array.isArray(configuredModel?.inputModalities)
58
+ ? configuredModel.inputModalities
59
+ : Array.isArray(model.capabilities?.input_modalities)
60
+ ? model.capabilities.input_modalities
61
+ : ["text", "image"];
62
+ models.set(model.id, {
63
+ alias: aliasFor(model.id),
64
+ sourceId: model.id,
65
+ provider,
66
+ contextWindow: Number.isFinite(configuredModel?.contextWindow)
67
+ ? configuredModel.contextWindow
68
+ : model.capabilities?.context_length,
69
+ maxOutputTokens: model.capabilities?.max_output_tokens,
70
+ inputModalities,
71
+ reasoningEfforts: sanitizeEfforts(model.id, configuredModel?.reasoningEfforts ?? model.capabilities?.reasoning_effort),
72
+ });
73
+ }
74
+
75
+ return [...models.values()].sort((left, right) => left.alias.localeCompare(right.alias));
76
+ }
77
+
78
+ async function optionalServiceToken() {
79
+ try {
80
+ return (await readFile(opencodexServiceTokenFile, "utf8")).trim();
81
+ } catch {
82
+ return "";
83
+ }
84
+ }
85
+
86
+ export function configuredModels(ocxBin = process.env.OCX_BIN || join(homedir(), ".local", "bin", "ocx")) {
87
+ let executable = ocxBin;
88
+ try {
89
+ execFileSync(executable, ["--version"], { stdio: "ignore" });
90
+ } catch {
91
+ executable = "/opt/homebrew/bin/ocx";
92
+ }
93
+ const output = execFileSync(executable, ["models", "--json"], { encoding: "utf8" });
94
+ const payload = JSON.parse(output);
95
+ if (!Array.isArray(payload.models)) throw new Error("ocx models --json returned an invalid catalog");
96
+ return payload.models;
97
+ }
98
+
99
+ export async function activeModels(fetchImpl = fetch) {
100
+ const { host, port } = opencodexEndpoint();
101
+ const token = await optionalServiceToken();
102
+ const response = await fetchImpl(`http://${host}:${port}/v1/models`, {
103
+ headers: token ? { authorization: `Bearer ${token}` } : {},
104
+ signal: AbortSignal.timeout(5000),
105
+ });
106
+ if (!response.ok) throw new Error(`OpenCodex /v1/models returned HTTP ${response.status}`);
107
+ const payload = await response.json();
108
+ if (!Array.isArray(payload.data)) throw new Error("OpenCodex /v1/models returned an invalid catalog");
109
+ return payload.data;
110
+ }
111
+
112
+ export async function buildActiveCatalog(options = {}) {
113
+ return normalizeActiveCatalog(
114
+ configuredModels(options.ocxBin),
115
+ await activeModels(options.fetchImpl),
116
+ );
117
+ }
@@ -0,0 +1,96 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createCipheriv, pbkdf2Sync } from "node:crypto";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { backup, DatabaseSync } from "node:sqlite";
5
+ import { cursorIsRunning } from "./cursor-state.mjs";
6
+ import { cursorDatabaseFile, installRoot } from "./paths.mjs";
7
+
8
+ const applicationStorageKey = "src.vs.platform.reactivestorage.browser.reactiveStorageServiceImpl.persistentStorage.applicationUser";
9
+ const openAISecretKey = "secret://cursorAuth/openAIKey";
10
+
11
+ function cursorSafeStoragePassword() {
12
+ try {
13
+ return execFileSync("security", [
14
+ "find-generic-password",
15
+ "-w",
16
+ "-a", "Cursor Key",
17
+ "-s", "Cursor Safe Storage",
18
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
19
+ } catch {
20
+ throw new Error("Cursor Safe Storage key was not found. Launch and sign in to Cursor once, then rerun init.");
21
+ }
22
+ }
23
+
24
+ export function encryptedCursorSecret(secret, password) {
25
+ const key = pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
26
+ const cipher = createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
27
+ const encrypted = Buffer.concat([Buffer.from("v10"), cipher.update(secret, "utf8"), cipher.final()]);
28
+ return JSON.stringify({ type: "Buffer", data: [...encrypted] });
29
+ }
30
+
31
+ export function applyOpenAISettings(state, baseUrl) {
32
+ state.useOpenAIKey = true;
33
+ state.openAIBaseUrl = baseUrl;
34
+ return state;
35
+ }
36
+
37
+ export function storedCursorOpenAIBaseUrl(databasePath = cursorDatabaseFile) {
38
+ try {
39
+ const database = new DatabaseSync(databasePath, { readOnly: true });
40
+ const row = database.prepare("SELECT value FROM ItemTable WHERE key = ?").get(applicationStorageKey);
41
+ database.close();
42
+ const value = JSON.parse(row?.value || "{}").openAIBaseUrl;
43
+ return typeof value === "string" ? value : "";
44
+ } catch {
45
+ return "";
46
+ }
47
+ }
48
+
49
+ export async function configureCursorOpenAI(options) {
50
+ const databasePath = options.databasePath || cursorDatabaseFile;
51
+ const backupDirectory = options.backupDirectory || installRoot;
52
+ const password = options.safeStoragePassword || cursorSafeStoragePassword();
53
+ const encryptedSecret = encryptedCursorSecret(options.secret, password);
54
+ const database = new DatabaseSync(databasePath);
55
+ const stateRow = database.prepare("SELECT value FROM ItemTable WHERE key = ?").get(applicationStorageKey);
56
+ const secretRow = database.prepare("SELECT value FROM ItemTable WHERE key = ?").get(openAISecretKey);
57
+ if (!stateRow?.value) {
58
+ database.close();
59
+ throw new Error("Cursor application user storage was not found");
60
+ }
61
+
62
+ const state = JSON.parse(stateRow.value);
63
+ const alreadyConfigured = state.useOpenAIKey === true
64
+ && state.openAIBaseUrl === options.baseUrl
65
+ && secretRow?.value === encryptedSecret;
66
+ if (alreadyConfigured) {
67
+ database.close();
68
+ return { changed: false, backupPath: null };
69
+ }
70
+ if (options.cursorRunning ?? cursorIsRunning()) {
71
+ database.close();
72
+ throw new Error("Quit Cursor before running ocx-cursor init so its API settings can be updated safely");
73
+ }
74
+
75
+ applyOpenAISettings(state, options.baseUrl);
76
+ await mkdir(backupDirectory, { recursive: true });
77
+ const timestamp = new Date().toISOString().replaceAll(":", "-");
78
+ const backupPath = `${backupDirectory}/cursor-state-before-api-init-${timestamp}.vscdb`;
79
+ await backup(database, backupPath);
80
+
81
+ database.exec("BEGIN IMMEDIATE");
82
+ try {
83
+ database.prepare("UPDATE ItemTable SET value = ? WHERE key = ?").run(JSON.stringify(state), applicationStorageKey);
84
+ database.prepare(`
85
+ INSERT INTO ItemTable (key, value) VALUES (?, ?)
86
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
87
+ `).run(openAISecretKey, encryptedSecret);
88
+ database.exec("COMMIT");
89
+ } catch (error) {
90
+ database.exec("ROLLBACK");
91
+ throw error;
92
+ } finally {
93
+ database.close();
94
+ }
95
+ return { changed: true, backupPath };
96
+ }
@@ -0,0 +1,182 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { backup, DatabaseSync } from "node:sqlite";
4
+ import { effortLabels } from "./catalog.mjs";
5
+ import { cursorDatabaseFile, installRoot, managedPrefix, pendingFile } from "./paths.mjs";
6
+
7
+ const storageKey = "src.vs.platform.reactivestorage.browser.reactiveStorageServiceImpl.persistentStorage.applicationUser";
8
+
9
+ export function cursorIsRunning() {
10
+ try {
11
+ execFileSync("pgrep", ["-f", "/Applications/Cursor.app/Contents/"], { stdio: "ignore" });
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function defaultEffort(model) {
19
+ const modelId = model.sourceId.split("/").at(-1);
20
+ if (/^(?:gpt|gemini)-/.test(modelId) && model.reasoningEfforts.includes("medium")) return "medium";
21
+ if (model.reasoningEfforts.includes("high")) return "high";
22
+ if (model.reasoningEfforts.includes("medium")) return "medium";
23
+ return model.reasoningEfforts[0];
24
+ }
25
+
26
+ function parameterIdFor(model) {
27
+ return model.provider === "anthropic" ? "effort" : "reasoning";
28
+ }
29
+
30
+ function effortDefinition(model) {
31
+ const parameterId = parameterIdFor(model);
32
+ return {
33
+ id: parameterId,
34
+ name: "Effort",
35
+ markdownTooltip: "Effort the model uses to generate its response.",
36
+ parameterType: {
37
+ enumParameter: {
38
+ values: model.reasoningEfforts.map((value) => ({ value, displayName: effortLabels[value], modelPickerBadges: [] })),
39
+ },
40
+ },
41
+ isCycleableByHotkey: true,
42
+ };
43
+ }
44
+
45
+ function effortVariants(model) {
46
+ const parameterId = parameterIdFor(model);
47
+ const selectedDefault = defaultEffort(model);
48
+ return model.reasoningEfforts.map((effort) => {
49
+ const displayName = `${model.alias} <span style="color: var(--cursor-text-tertiary);">${effortLabels[effort]}</span>`;
50
+ const isDefault = effort === selectedDefault;
51
+ return {
52
+ parameterValues: [{ id: parameterId, value: effort }],
53
+ displayName,
54
+ displayNameOutsidePicker: displayName,
55
+ isMaxMode: false,
56
+ ...(isDefault ? { isDefaultMaxConfig: true, isDefaultNonMaxConfig: true } : {}),
57
+ variantStringRepresentation: `${model.alias}[${parameterId}=${effort}]`,
58
+ legacySlug: `${model.alias}-${effort}`,
59
+ };
60
+ });
61
+ }
62
+
63
+ export function cursorModel(model) {
64
+ const hasEffort = model.reasoningEfforts.length > 0;
65
+ return {
66
+ name: model.alias,
67
+ defaultOn: false,
68
+ supportsAgent: true,
69
+ degradationStatus: 0,
70
+ supportsThinking: hasEffort,
71
+ supportsImages: model.inputModalities.includes("image"),
72
+ supportsMaxMode: true,
73
+ supportsNonMaxMode: true,
74
+ serverModelName: model.alias,
75
+ isRecommendedForBackgroundComposer: false,
76
+ supportsPlanMode: true,
77
+ supportsSandboxing: true,
78
+ isUserAdded: true,
79
+ inputboxShortModelName: model.alias,
80
+ idAliases: [],
81
+ namedModelSectionIndex: 1,
82
+ cloudAgentEffortModes: [],
83
+ parameterDefinitions: hasEffort ? [effortDefinition(model)] : [],
84
+ variants: hasEffort ? effortVariants(model) : [],
85
+ legacySlugs: model.reasoningEfforts.map((effort) => `${model.alias}-${effort}`),
86
+ modelPickerBadges: [],
87
+ };
88
+ }
89
+
90
+ function syncSelectedModel(state, catalog) {
91
+ const composer = state.aiSettings?.modelConfig?.composer;
92
+ if (!composer || !Array.isArray(composer.selectedModels)) return;
93
+ const byAlias = new Map(catalog.map((model) => [model.alias, model]));
94
+ for (const selected of composer.selectedModels) {
95
+ const model = byAlias.get(selected.modelId);
96
+ if (!model || model.reasoningEfforts.length === 0) continue;
97
+ const parameterId = parameterIdFor(model);
98
+ const current = Array.isArray(selected.parameters)
99
+ ? selected.parameters.find(({ id }) => id === parameterId)?.value
100
+ : undefined;
101
+ selected.parameters = [{
102
+ id: parameterId,
103
+ value: model.reasoningEfforts.includes(current) ? current : defaultEffort(model),
104
+ }];
105
+ }
106
+ }
107
+
108
+ export function applyCatalogToState(state, catalog) {
109
+ const aliases = catalog.map(({ alias }) => alias);
110
+ const keepUnmanaged = (value) => typeof value !== "string" || !value.startsWith(managedPrefix);
111
+ const existingModels = Array.isArray(state.availableDefaultModels2) ? state.availableDefaultModels2 : [];
112
+ state.availableDefaultModels2 = [
113
+ ...existingModels.filter((model) => !(model?.isUserAdded && typeof model.name === "string" && model.name.startsWith(managedPrefix))),
114
+ ...catalog.map(cursorModel),
115
+ ];
116
+ state.aiSettings ||= {};
117
+ state.aiSettings.userAddedModels = [
118
+ ...(Array.isArray(state.aiSettings.userAddedModels) ? state.aiSettings.userAddedModels.filter(keepUnmanaged) : []),
119
+ ...aliases,
120
+ ];
121
+ state.aiSettings.modelOverrideEnabled = [
122
+ ...(Array.isArray(state.aiSettings.modelOverrideEnabled) ? state.aiSettings.modelOverrideEnabled.filter(keepUnmanaged) : []),
123
+ ...aliases,
124
+ ];
125
+ syncSelectedModel(state, catalog);
126
+ return state;
127
+ }
128
+
129
+ export async function syncCursorDatabase(catalog, options = {}) {
130
+ if (cursorIsRunning()) throw new Error("Quit Cursor before syncing OpenCodex models");
131
+ const databasePath = options.databasePath || cursorDatabaseFile;
132
+ const backupDirectory = options.backupDirectory || installRoot;
133
+ await mkdir(backupDirectory, { recursive: true });
134
+
135
+ const database = new DatabaseSync(databasePath);
136
+ const row = database.prepare("SELECT value FROM ItemTable WHERE key = ?").get(storageKey);
137
+ if (!row?.value) {
138
+ database.close();
139
+ throw new Error("Cursor application user storage was not found");
140
+ }
141
+ const state = applyCatalogToState(JSON.parse(row.value), catalog);
142
+ let backupPath = null;
143
+ if (options.createBackup !== false) {
144
+ const timestamp = new Date().toISOString().replaceAll(":", "-");
145
+ backupPath = `${backupDirectory}/cursor-state-before-model-sync-${timestamp}.vscdb`;
146
+ await backup(database, backupPath);
147
+ }
148
+
149
+ database.exec("BEGIN IMMEDIATE");
150
+ try {
151
+ database.prepare("UPDATE ItemTable SET value = ? WHERE key = ?").run(JSON.stringify(state), storageKey);
152
+ database.exec("COMMIT");
153
+ } catch (error) {
154
+ database.exec("ROLLBACK");
155
+ throw error;
156
+ } finally {
157
+ database.close();
158
+ }
159
+ return {
160
+ modelCount: catalog.length,
161
+ effortCount: catalog.filter(({ reasoningEfforts }) => reasoningEfforts.length > 0).length,
162
+ backupPath,
163
+ };
164
+ }
165
+
166
+ export async function writePendingCatalog(catalog, file = pendingFile) {
167
+ await mkdir(installRoot, { recursive: true });
168
+ await writeFile(file, `${JSON.stringify(catalog, null, 2)}\n`, { mode: 0o600 });
169
+ }
170
+
171
+ export async function readPendingCatalog(file = pendingFile) {
172
+ try {
173
+ const catalog = JSON.parse(await readFile(file, "utf8"));
174
+ return Array.isArray(catalog) ? catalog : null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ export async function clearPendingCatalog(file = pendingFile) {
181
+ await rm(file, { force: true });
182
+ }
@@ -0,0 +1,195 @@
1
+ import http from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ import { timingSafeEqual } from "node:crypto";
4
+ import { activeModels, allowedEfforts, opencodexEndpoint } from "./catalog.mjs";
5
+ import { gatewayHost, gatewayPort, managedPrefix, opencodexServiceTokenFile } from "./paths.mjs";
6
+
7
+ const maxBodyBytes = 24 * 1024 * 1024;
8
+ const allowedRoutes = new Set([
9
+ "GET /v1/models",
10
+ "POST /v1/responses",
11
+ "POST /v1/responses/compact",
12
+ "POST /v1/chat/completions",
13
+ "POST /v1/messages",
14
+ ]);
15
+
16
+ function isRecord(value) {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+
20
+ function suppliedEffort(payload, variantText) {
21
+ const variantEffort = variantText
22
+ ?.split(",")
23
+ .map((value) => value.split("=", 2))
24
+ .find(([key]) => key === "effort" || key === "reasoning")?.[1];
25
+ return variantEffort
26
+ || payload.reasoning_effort
27
+ || payload.reasoning?.effort
28
+ || payload.output_config?.effort
29
+ || payload.reasoningEffort;
30
+ }
31
+
32
+ export function rewriteModelAliasBody(body, catalog) {
33
+ if (!body?.length) return body;
34
+ let payload;
35
+ try {
36
+ payload = JSON.parse(body.toString("utf8"));
37
+ } catch {
38
+ return body;
39
+ }
40
+ if (!isRecord(payload) || typeof payload.model !== "string" || !payload.model.startsWith(managedPrefix)) return body;
41
+
42
+ const variant = /^(opencodex\/.+?)\[([^\]]+)\]$/.exec(payload.model);
43
+ let alias = variant?.[1] || payload.model;
44
+ let effort = suppliedEffort(payload, variant?.[2]);
45
+ if (!allowedEfforts.includes(effort)) {
46
+ const legacy = catalog.find((model) => model.reasoningEfforts?.some((value) => payload.model === `${model.alias}-${value}`));
47
+ if (legacy) {
48
+ effort = legacy.reasoningEfforts.find((value) => payload.model === `${legacy.alias}-${value}`);
49
+ alias = legacy.alias;
50
+ }
51
+ }
52
+
53
+ const catalogModel = catalog.find((model) => model.alias === alias);
54
+ const fallbackSourceId = alias.slice(managedPrefix.length);
55
+ payload.model = catalogModel?.sourceId
56
+ || (fallbackSourceId.startsWith("claude-") ? `anthropic/${fallbackSourceId}` : fallbackSourceId);
57
+ if (allowedEfforts.includes(effort)) payload.reasoning_effort = effort;
58
+ delete payload.reasoningEffort;
59
+ return Buffer.from(JSON.stringify(payload));
60
+ }
61
+
62
+ export function enrichModelList(active, catalog) {
63
+ const existing = new Set(active.map((model) => model?.id));
64
+ const aliases = catalog
65
+ .filter(({ alias }) => !existing.has(alias))
66
+ .map((model) => ({
67
+ id: model.alias,
68
+ object: "model",
69
+ created: 0,
70
+ owned_by: "opencodex",
71
+ api_types: [model.provider === "anthropic" ? "anthropic_messages" : "chat_completions"],
72
+ capabilities: {
73
+ ...(model.contextWindow ? { context_length: model.contextWindow } : {}),
74
+ ...(model.maxOutputTokens ? { max_output_tokens: model.maxOutputTokens } : {}),
75
+ output_modalities: ["text"],
76
+ input_modalities: model.inputModalities,
77
+ supports_tool_use: true,
78
+ supports_streaming: true,
79
+ supports_reasoning: model.reasoningEfforts.length > 0,
80
+ supports_vision: model.inputModalities.includes("image"),
81
+ reasoning_effort: model.reasoningEfforts,
82
+ },
83
+ }));
84
+ return { object: "list", data: [...active, ...aliases] };
85
+ }
86
+
87
+ async function serviceToken() {
88
+ try {
89
+ return (await readFile(opencodexServiceTokenFile, "utf8")).trim();
90
+ } catch {
91
+ return "";
92
+ }
93
+ }
94
+
95
+ function authorized(req, expected) {
96
+ const supplied = String(req.headers.authorization || "").replace(/^Bearer\s+/i, "").trim();
97
+ const actual = Buffer.from(supplied);
98
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
99
+ }
100
+
101
+ function json(res, status, body) {
102
+ res.writeHead(status, {
103
+ "content-type": "application/json; charset=utf-8",
104
+ "cache-control": "no-store",
105
+ });
106
+ res.end(JSON.stringify(body));
107
+ }
108
+
109
+ async function readBody(req) {
110
+ const chunks = [];
111
+ let size = 0;
112
+ for await (const chunk of req) {
113
+ size += chunk.length;
114
+ if (size > maxBodyBytes) throw Object.assign(new Error("request too large"), { status: 413 });
115
+ chunks.push(chunk);
116
+ }
117
+ return Buffer.concat(chunks);
118
+ }
119
+
120
+ function upstreamHeaders(req, body, token, upstream) {
121
+ const headers = { ...req.headers };
122
+ delete headers.authorization;
123
+ delete headers["x-api-key"];
124
+ delete headers["x-opencodex-api-key"];
125
+ delete headers.cookie;
126
+ delete headers.origin;
127
+ delete headers["cf-access-jwt-assertion"];
128
+ if (token) headers.authorization = `Bearer ${token}`;
129
+ headers.host = `${upstream.host}:${upstream.port}`;
130
+ if (body) headers["content-length"] = String(body.length);
131
+ else delete headers["content-length"];
132
+ headers["cache-control"] = "no-store";
133
+ return headers;
134
+ }
135
+
136
+ export function startGateway(options) {
137
+ const host = options.host || gatewayHost;
138
+ const port = options.port || gatewayPort;
139
+ const expected = Buffer.from(options.secret);
140
+ let activeRequests = 0;
141
+
142
+ const server = http.createServer(async (req, res) => {
143
+ const started = Date.now();
144
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
145
+ const route = `${req.method} ${url.pathname}`;
146
+ const catalog = options.getCatalog();
147
+
148
+ if (route === "GET /healthz") {
149
+ return json(res, 200, { service: "opencodex-cursor-bridge", status: "ok", models: catalog.length, pid: process.pid });
150
+ }
151
+ if (!authorized(req, expected)) return json(res, 401, { error: { message: "invalid bridge API key", type: "authentication_error" } });
152
+ if (!allowedRoutes.has(route)) return json(res, 404, { error: { message: "route not exposed", type: "not_found" } });
153
+ if (activeRequests >= 8) return json(res, 429, { error: { message: "bridge concurrency limit reached", type: "rate_limit_error" } });
154
+
155
+ activeRequests += 1;
156
+ res.once("close", () => { activeRequests = Math.max(0, activeRequests - 1); });
157
+ try {
158
+ if (route === "GET /v1/models") {
159
+ return json(res, 200, enrichModelList(await activeModels(), catalog));
160
+ }
161
+
162
+ const upstream = opencodexEndpoint();
163
+ const body = rewriteModelAliasBody(await readBody(req), catalog);
164
+ const token = await serviceToken();
165
+ const upstreamReq = http.request({
166
+ hostname: upstream.host,
167
+ port: upstream.port,
168
+ method: req.method,
169
+ path: `${url.pathname}${url.search}`,
170
+ headers: upstreamHeaders(req, body, token, upstream),
171
+ }, (upstreamRes) => {
172
+ const headers = { ...upstreamRes.headers, "cache-control": "no-store" };
173
+ delete headers["set-cookie"];
174
+ res.writeHead(upstreamRes.statusCode || 502, headers);
175
+ upstreamRes.pipe(res);
176
+ upstreamRes.on("end", () => {
177
+ process.stdout.write(`${route} ${upstreamRes.statusCode || 502} ${Date.now() - started}ms\n`);
178
+ });
179
+ });
180
+ upstreamReq.on("error", (error) => {
181
+ if (!res.headersSent) json(res, 502, { error: { message: "OpenCodex upstream unavailable", type: "upstream_error" } });
182
+ else res.destroy(error);
183
+ });
184
+ upstreamReq.end(body);
185
+ } catch (error) {
186
+ if (!res.headersSent) json(res, error.status || 500, { error: { message: error.message || "bridge error", type: "bridge_error" } });
187
+ else res.destroy(error);
188
+ }
189
+ });
190
+
191
+ server.listen(port, host, () => {
192
+ process.stdout.write(`OpenCodex Cursor Bridge listening on http://${host}:${port}\n`);
193
+ });
194
+ return server;
195
+ }
@@ -0,0 +1,203 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { access, chmod, copyFile, cp, lstat, mkdir, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import {
7
+ cliLinkFile,
8
+ installRoot,
9
+ installedPackageRoot,
10
+ launchAgentFile,
11
+ legacyLaunchAgentFile,
12
+ legacyServiceLabel,
13
+ secretFile,
14
+ serviceLabel,
15
+ stderrFile,
16
+ stdoutFile,
17
+ } from "./paths.mjs";
18
+
19
+ const sourcePackageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
20
+ const launchDomain = `gui/${process.getuid()}`;
21
+
22
+ function xml(value) {
23
+ return String(value)
24
+ .replaceAll("&", "&amp;")
25
+ .replaceAll("<", "&lt;")
26
+ .replaceAll(">", "&gt;")
27
+ .replaceAll('"', "&quot;");
28
+ }
29
+
30
+ function commandOutput(command, args) {
31
+ try {
32
+ return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
33
+ } catch {
34
+ return "";
35
+ }
36
+ }
37
+
38
+ function bootout(label) {
39
+ try {
40
+ execFileSync("launchctl", ["bootout", `${launchDomain}/${label}`], { stdio: "ignore" });
41
+ } catch {}
42
+ }
43
+
44
+ async function bootstrap(plist) {
45
+ for (let attempt = 0; attempt < 5; attempt += 1) {
46
+ try {
47
+ execFileSync("launchctl", ["bootstrap", launchDomain, plist], { stdio: "ignore" });
48
+ return;
49
+ } catch (error) {
50
+ if (attempt === 4) throw error;
51
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 200));
52
+ }
53
+ }
54
+ }
55
+
56
+ async function exists(path) {
57
+ try {
58
+ await access(path);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ async function runningLegacyPlist() {
66
+ if (await exists(legacyLaunchAgentFile)) return legacyLaunchAgentFile;
67
+ const output = commandOutput("launchctl", ["print", `${launchDomain}/${legacyServiceLabel}`]);
68
+ return output.match(/^\s*path = (.+)$/m)?.[1]?.trim() || legacyLaunchAgentFile;
69
+ }
70
+
71
+ async function migrateSecret(legacyPlist) {
72
+ if (await exists(secretFile)) return "preserved";
73
+ if (legacyPlist && await exists(legacyPlist)) {
74
+ const stdoutPath = commandOutput("plutil", ["-extract", "StandardOutPath", "raw", "-o", "-", legacyPlist]);
75
+ if (stdoutPath) {
76
+ const candidate = join(dirname(stdoutPath), "ocx-cursor-gateway.secret");
77
+ if (await exists(candidate)) {
78
+ await copyFile(candidate, secretFile);
79
+ await chmod(secretFile, 0o600);
80
+ return "migrated";
81
+ }
82
+ }
83
+ }
84
+ const secret = `ocx_cursor_${randomBytes(32).toString("hex")}`;
85
+ await writeFile(secretFile, `${secret}\n`, { mode: 0o600, flag: "wx" });
86
+ return "created";
87
+ }
88
+
89
+ export async function prepareInstallSecret() {
90
+ await mkdir(installRoot, { recursive: true });
91
+ const legacyPlist = await runningLegacyPlist();
92
+ const secretStatus = await migrateSecret(legacyPlist);
93
+ return {
94
+ legacyPlist,
95
+ secretStatus,
96
+ secret: (await readFile(secretFile, "utf8")).trim(),
97
+ };
98
+ }
99
+
100
+ async function copyPackage() {
101
+ if (resolve(sourcePackageRoot) === resolve(installedPackageRoot)) return;
102
+ const temporary = `${installedPackageRoot}.tmp-${process.pid}`;
103
+ await rm(temporary, { recursive: true, force: true });
104
+ await cp(sourcePackageRoot, temporary, {
105
+ recursive: true,
106
+ filter: (source) => !source.includes("/node_modules/") && !source.includes("/.git/"),
107
+ });
108
+ await rm(installedPackageRoot, { recursive: true, force: true });
109
+ await rename(temporary, installedPackageRoot);
110
+ }
111
+
112
+ async function installCliLink() {
113
+ const target = join(installedPackageRoot, "bin", "ocx-cursor.mjs");
114
+ await mkdir(dirname(cliLinkFile), { recursive: true });
115
+ try {
116
+ const stat = await lstat(cliLinkFile);
117
+ if (!stat.isSymbolicLink() || resolve(dirname(cliLinkFile), await readlink(cliLinkFile)) !== resolve(target)) {
118
+ throw new Error(`${cliLinkFile} already exists and is not managed by this package`);
119
+ }
120
+ await rm(cliLinkFile);
121
+ } catch (error) {
122
+ if (error.code !== "ENOENT") throw error;
123
+ }
124
+ await symlink(target, cliLinkFile);
125
+ }
126
+
127
+ function launchAgent(nodePath) {
128
+ const program = join(installedPackageRoot, "bin", "ocx-cursor.mjs");
129
+ return `<?xml version="1.0" encoding="UTF-8"?>
130
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
131
+ <plist version="1.0">
132
+ <dict>
133
+ <key>Label</key>
134
+ <string>${xml(serviceLabel)}</string>
135
+ <key>ProgramArguments</key>
136
+ <array>
137
+ <string>${xml(nodePath)}</string>
138
+ <string>${xml(program)}</string>
139
+ <string>service</string>
140
+ </array>
141
+ <key>EnvironmentVariables</key>
142
+ <dict>
143
+ <key>PATH</key>
144
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
145
+ </dict>
146
+ <key>RunAtLoad</key>
147
+ <true/>
148
+ <key>KeepAlive</key>
149
+ <true/>
150
+ <key>StandardOutPath</key>
151
+ <string>${xml(stdoutFile)}</string>
152
+ <key>StandardErrorPath</key>
153
+ <string>${xml(stderrFile)}</string>
154
+ </dict>
155
+ </plist>
156
+ `;
157
+ }
158
+
159
+ export async function installService() {
160
+ await mkdir(dirname(launchAgentFile), { recursive: true });
161
+ const { legacyPlist, secretStatus } = await prepareInstallSecret();
162
+ await copyPackage();
163
+ await installCliLink();
164
+
165
+ const nodePath = (await exists("/opt/homebrew/bin/node")) ? "/opt/homebrew/bin/node" : process.execPath;
166
+ await writeFile(launchAgentFile, launchAgent(nodePath), { mode: 0o644 });
167
+ bootout(serviceLabel);
168
+ bootout(legacyServiceLabel);
169
+ await bootstrap(launchAgentFile);
170
+
171
+ if (legacyPlist.startsWith(dirname(legacyLaunchAgentFile)) && await exists(legacyPlist)) {
172
+ const disabled = `${legacyPlist}.disabled-by-${serviceLabel}`;
173
+ await rm(disabled, { force: true });
174
+ await rename(legacyPlist, disabled);
175
+ }
176
+ return { installRoot, launchAgentFile, cliLinkFile, secretStatus };
177
+ }
178
+
179
+ export async function uninstallService() {
180
+ bootout(serviceLabel);
181
+ await rm(launchAgentFile, { force: true });
182
+ try {
183
+ if (resolve(dirname(cliLinkFile), await readlink(cliLinkFile)).startsWith(resolve(installRoot))) {
184
+ await rm(cliLinkFile, { force: true });
185
+ }
186
+ } catch {}
187
+ await rm(installRoot, { recursive: true, force: true });
188
+ }
189
+
190
+ export async function serviceStatus(fetchImpl = fetch) {
191
+ const launchctl = commandOutput("launchctl", ["print", `${launchDomain}/${serviceLabel}`]);
192
+ let health = null;
193
+ try {
194
+ const response = await fetchImpl("http://127.0.0.1:10101/healthz", { signal: AbortSignal.timeout(1000) });
195
+ if (response.ok) health = await response.json();
196
+ } catch {}
197
+ return {
198
+ installed: await exists(launchAgentFile),
199
+ loaded: launchctl.includes("state = running") || launchctl.includes("state = active"),
200
+ health,
201
+ installRoot,
202
+ };
203
+ }
package/src/paths.mjs ADDED
@@ -0,0 +1,22 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export const serviceLabel = "com.opencodex.cursor-bridge";
5
+ export const legacyServiceLabel = "com.local.opencodex.cursor-gateway";
6
+ export const installRoot = process.env.OCX_CURSOR_HOME || join(homedir(), ".opencodex", "cursor-bridge");
7
+ export const installedPackageRoot = join(installRoot, "package");
8
+ export const cliLinkFile = join(homedir(), ".local", "bin", "ocx-cursor");
9
+ export const secretFile = join(installRoot, "secret");
10
+ export const catalogFile = join(installRoot, "catalog.json");
11
+ export const pendingFile = join(installRoot, "pending-sync.json");
12
+ export const stdoutFile = join(installRoot, "service.log");
13
+ export const stderrFile = join(installRoot, "service.error.log");
14
+ export const launchAgentFile = join(homedir(), "Library", "LaunchAgents", `${serviceLabel}.plist`);
15
+ export const legacyLaunchAgentFile = join(homedir(), "Library", "LaunchAgents", `${legacyServiceLabel}.plist`);
16
+ export const cursorDatabaseFile = join(homedir(), "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
17
+ export const opencodexConfigFile = join(homedir(), ".opencodex", "config.json");
18
+ export const opencodexServiceTokenFile = join(homedir(), ".opencodex", "service-api-token");
19
+ export const gatewayPort = Number(process.env.OCX_CURSOR_PORT || "10101");
20
+ export const gatewayHost = process.env.OCX_CURSOR_HOST || "127.0.0.1";
21
+ export const cursorOpenAIBaseUrl = process.env.OCX_CURSOR_BASE_URL || "";
22
+ export const managedPrefix = "opencodex/";
@@ -0,0 +1,93 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { buildActiveCatalog } from "./catalog.mjs";
4
+ import {
5
+ cursorIsRunning,
6
+ readPendingCatalog,
7
+ } from "./cursor-state.mjs";
8
+ import { startGateway } from "./gateway.mjs";
9
+ import { installRoot, secretFile } from "./paths.mjs";
10
+ import { applyOrQueueCatalog, loadCatalogSnapshot, saveCatalogSnapshot } from "./sync.mjs";
11
+
12
+ export async function loadOrCreateSecret() {
13
+ await mkdir(installRoot, { recursive: true });
14
+ try {
15
+ return (await readFile(secretFile, "utf8")).trim();
16
+ } catch {
17
+ const secret = `ocx_cursor_${randomBytes(32).toString("hex")}`;
18
+ await writeFile(secretFile, `${secret}\n`, { mode: 0o600, flag: "wx" });
19
+ await chmod(secretFile, 0o600);
20
+ return secret;
21
+ }
22
+ }
23
+
24
+ export async function runService(options = {}) {
25
+ let catalog = await loadCatalogSnapshot();
26
+ let refreshPromise = null;
27
+ let cursorSyncPromise = null;
28
+ let wasCursorRunning = cursorIsRunning();
29
+ let stopped = false;
30
+
31
+ const refresh = async () => {
32
+ if (refreshPromise) return refreshPromise;
33
+ refreshPromise = (async () => {
34
+ try {
35
+ const next = await buildActiveCatalog(options);
36
+ if (JSON.stringify(next) === JSON.stringify(catalog)) return;
37
+ catalog = next;
38
+ await saveCatalogSnapshot(catalog);
39
+ const result = await applyOrQueueCatalog(catalog, { createBackup: false });
40
+ process.stdout.write(`${result.status === "queued" ? "Queued" : "Synced"} ${result.modelCount} Cursor models\n`);
41
+ } catch (error) {
42
+ process.stderr.write(`Catalog refresh failed: ${error.message}\n`);
43
+ } finally {
44
+ refreshPromise = null;
45
+ }
46
+ })();
47
+ return refreshPromise;
48
+ };
49
+
50
+ const secret = await loadOrCreateSecret();
51
+ if (catalog.length === 0) {
52
+ try {
53
+ catalog = await buildActiveCatalog(options);
54
+ await saveCatalogSnapshot(catalog);
55
+ } catch (error) {
56
+ process.stderr.write(`Initial catalog load failed: ${error.message}\n`);
57
+ }
58
+ }
59
+
60
+ const server = startGateway({ secret, getCatalog: () => catalog, host: options.host, port: options.port });
61
+ const refreshTimer = setInterval(refresh, options.refreshIntervalMs || 15_000);
62
+ const pendingTimer = setInterval(() => {
63
+ if (cursorSyncPromise) return;
64
+ if (cursorIsRunning()) {
65
+ wasCursorRunning = true;
66
+ return;
67
+ }
68
+ cursorSyncPromise = (async () => {
69
+ const pending = await readPendingCatalog();
70
+ const next = pending || (wasCursorRunning ? catalog : null);
71
+ if (!next) return;
72
+ const result = await applyOrQueueCatalog(next, { createBackup: false });
73
+ wasCursorRunning = false;
74
+ process.stdout.write(`Synced ${result.modelCount} Cursor models after shutdown\n`);
75
+ })().catch((error) => {
76
+ process.stderr.write(`Cursor shutdown sync failed: ${error.message}\n`);
77
+ }).finally(() => {
78
+ cursorSyncPromise = null;
79
+ });
80
+ }, options.cursorPollIntervalMs || 2_000);
81
+
82
+ await refresh();
83
+ const stop = () => {
84
+ if (stopped) return;
85
+ stopped = true;
86
+ clearInterval(refreshTimer);
87
+ clearInterval(pendingTimer);
88
+ server.close(() => process.exit(0));
89
+ };
90
+ process.on("SIGTERM", stop);
91
+ process.on("SIGINT", stop);
92
+ return { server, stop, getCatalog: () => catalog, refresh };
93
+ }
package/src/sync.mjs ADDED
@@ -0,0 +1,45 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { buildActiveCatalog } from "./catalog.mjs";
3
+ import {
4
+ clearPendingCatalog,
5
+ cursorIsRunning,
6
+ syncCursorDatabase,
7
+ writePendingCatalog,
8
+ } from "./cursor-state.mjs";
9
+ import { catalogFile, installRoot } from "./paths.mjs";
10
+
11
+ export async function loadCatalogSnapshot() {
12
+ try {
13
+ const value = JSON.parse(await readFile(catalogFile, "utf8"));
14
+ return Array.isArray(value) ? value : [];
15
+ } catch {
16
+ return [];
17
+ }
18
+ }
19
+
20
+ export async function saveCatalogSnapshot(catalog) {
21
+ await mkdir(installRoot, { recursive: true });
22
+ const temporary = `${catalogFile}.tmp-${process.pid}`;
23
+ await writeFile(temporary, `${JSON.stringify(catalog, null, 2)}\n`, { mode: 0o600 });
24
+ await rename(temporary, catalogFile);
25
+ }
26
+
27
+ export async function applyOrQueueCatalog(catalog, options = {}) {
28
+ if (cursorIsRunning()) {
29
+ await writePendingCatalog(catalog);
30
+ return {
31
+ status: "queued",
32
+ modelCount: catalog.length,
33
+ effortCount: catalog.filter(({ reasoningEfforts }) => reasoningEfforts.length > 0).length,
34
+ };
35
+ }
36
+ const result = await syncCursorDatabase(catalog, options);
37
+ await clearPendingCatalog();
38
+ return { status: "synced", ...result };
39
+ }
40
+
41
+ export async function syncNow(options = {}) {
42
+ const catalog = await buildActiveCatalog(options);
43
+ await saveCatalogSnapshot(catalog);
44
+ return { catalog, ...(await applyOrQueueCatalog(catalog)) };
45
+ }