cursor-keys 0.0.0-stage → 1.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/src/config.mjs ADDED
@@ -0,0 +1,176 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const DEFAULT_CONFIG_DIR = path.join(
6
+ os.homedir(),
7
+ ".config",
8
+ "cursor-keys",
9
+ );
10
+ export const CONFIG_FILE = path.join(DEFAULT_CONFIG_DIR, "config.json");
11
+ export const DEFAULT_PROFILES_ROOT = path.join(
12
+ os.homedir(),
13
+ ".cursor-profiles",
14
+ );
15
+
16
+ const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
17
+
18
+ /** @param {string} p */
19
+ export function expandHome(p) {
20
+ if (!p || typeof p !== "string") return p;
21
+ if (p === "~") return os.homedir();
22
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
23
+ return p;
24
+ }
25
+
26
+ /** @param {string} p */
27
+ export function collapseHome(p) {
28
+ const home = os.homedir();
29
+ if (p === home) return "~";
30
+ if (p.startsWith(home + path.sep)) return "~" + p.slice(home.length);
31
+ return p;
32
+ }
33
+
34
+ export function defaultConfig() {
35
+ return {
36
+ profilesRoot: collapseHome(DEFAULT_PROFILES_ROOT),
37
+ baseUrl: "https://connect.iobonzai.com/cursor",
38
+ projects: {},
39
+ };
40
+ }
41
+
42
+ export function loadConfig() {
43
+ if (!fs.existsSync(CONFIG_FILE)) {
44
+ return defaultConfig();
45
+ }
46
+ const raw = fs.readFileSync(CONFIG_FILE, "utf8");
47
+ const data = JSON.parse(raw);
48
+ return validateConfig(data);
49
+ }
50
+
51
+ export function saveConfig(config) {
52
+ const validated = validateConfig(config);
53
+ fs.mkdirSync(DEFAULT_CONFIG_DIR, { recursive: true });
54
+ fs.writeFileSync(
55
+ CONFIG_FILE,
56
+ JSON.stringify(validated, null, 2) + "\n",
57
+ "utf8",
58
+ );
59
+ return validated;
60
+ }
61
+
62
+ /**
63
+ * @param {unknown} data
64
+ */
65
+ export function validateConfig(data) {
66
+ if (!data || typeof data !== "object") {
67
+ throw new Error("Invalid config: expected object");
68
+ }
69
+ const profilesRoot =
70
+ typeof data.profilesRoot === "string"
71
+ ? data.profilesRoot
72
+ : collapseHome(DEFAULT_PROFILES_ROOT);
73
+ const baseUrl =
74
+ typeof data.baseUrl === "string" && data.baseUrl
75
+ ? data.baseUrl
76
+ : "https://connect.iobonzai.com/cursor";
77
+ const projects = {};
78
+ if (data.projects && typeof data.projects === "object") {
79
+ for (const [name, entry] of Object.entries(data.projects)) {
80
+ if (!name || typeof entry !== "object" || !entry) continue;
81
+ const repoPath = typeof entry.path === "string" ? entry.path : "";
82
+ const color = typeof entry.color === "string" ? entry.color : "";
83
+ if (!repoPath) throw new Error(`Project "${name}" missing path`);
84
+ if (color && !HEX_COLOR.test(color)) {
85
+ throw new Error(`Project "${name}" invalid color (use #RRGGBB)`);
86
+ }
87
+ projects[name] = {
88
+ path: repoPath,
89
+ color: color || pickColorForName(name),
90
+ };
91
+ }
92
+ }
93
+ return { profilesRoot, baseUrl, projects };
94
+ }
95
+
96
+ /** "https://connect.iobonzai.com/cursor" -> "connect.iobonzai.com/cursor" */
97
+ export function baseUrlFragment(config) {
98
+ return String(config.baseUrl || "").replace(/^https?:\/\//, "");
99
+ }
100
+
101
+ const COLOR_PALETTE = [
102
+ "#4f8cff",
103
+ "#ff6b6b",
104
+ "#51cf66",
105
+ "#ffd43b",
106
+ "#845ef7",
107
+ "#20c997",
108
+ "#ff922b",
109
+ "#e64980",
110
+ "#339af0",
111
+ "#94d82d",
112
+ ];
113
+
114
+ /** Deterministic palette color from project name */
115
+ export function pickColorForName(name) {
116
+ let hash = 0;
117
+ for (let i = 0; i < name.length; i++) {
118
+ hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
119
+ }
120
+ return COLOR_PALETTE[hash % COLOR_PALETTE.length];
121
+ }
122
+
123
+ /** Like pickColorForName, but avoids colors already used by other projects */
124
+ export function pickUnusedColor(name, usedColors) {
125
+ const used = new Set(usedColors);
126
+ const preferred = pickColorForName(name);
127
+ if (!used.has(preferred)) return preferred;
128
+ return COLOR_PALETTE.find((c) => !used.has(c)) || preferred;
129
+ }
130
+
131
+ /**
132
+ * @param {ReturnType<typeof loadConfig>} config
133
+ * @param {string} name
134
+ */
135
+ export function getProject(config, name) {
136
+ const entry = config.projects[name];
137
+ if (!entry) return null;
138
+ return {
139
+ name,
140
+ path: path.resolve(expandHome(entry.path)),
141
+ color: entry.color,
142
+ profileDir: path.join(expandHome(config.profilesRoot), name),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * @param {ReturnType<typeof loadConfig>} config
148
+ * @param {string} cwd
149
+ */
150
+ export function findProjectByPath(config, cwd) {
151
+ const resolved = path.resolve(cwd);
152
+ let best = null;
153
+ let bestLen = 0;
154
+ for (const [name, entry] of Object.entries(config.projects)) {
155
+ const repo = path.resolve(expandHome(entry.path));
156
+ if (resolved === repo || resolved.startsWith(repo + path.sep)) {
157
+ if (repo.length > bestLen) {
158
+ bestLen = repo.length;
159
+ best = name;
160
+ }
161
+ }
162
+ }
163
+ return best ? getProject(config, best) : null;
164
+ }
165
+
166
+ export function profileUserDir(profileDir) {
167
+ return path.join(profileDir, "User");
168
+ }
169
+
170
+ export function profileStateDb(profileDir) {
171
+ return path.join(profileUserDir(profileDir), "globalStorage", "state.vscdb");
172
+ }
173
+
174
+ export function profileSettingsPath(profileDir) {
175
+ return path.join(profileUserDir(profileDir), "settings.json");
176
+ }
package/src/detect.mjs ADDED
@@ -0,0 +1,214 @@
1
+ import { execSync, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export function findCursorCli() {
7
+ // Prefer the app-bundle CLI: ~/.local/bin/cursor is a shim that depends on
8
+ // PATH, which is unreliable from the extension host (minimal GUI PATH).
9
+ const candidates = [
10
+ "/Applications/Cursor.app/Contents/Resources/app/bin/cursor",
11
+ "/usr/local/bin/cursor",
12
+ path.join(os.homedir(), ".local", "bin", "cursor"),
13
+ ];
14
+ for (const c of candidates) {
15
+ if (fs.existsSync(c)) return c;
16
+ }
17
+ const fromPath = spawnSync("which", ["cursor"], { encoding: "utf8" });
18
+ if (fromPath.status === 0 && fromPath.stdout.trim()) {
19
+ return fromPath.stdout.trim();
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export function mainCursorUserDir() {
25
+ if (process.platform === "darwin") {
26
+ return path.join(
27
+ os.homedir(),
28
+ "Library",
29
+ "Application Support",
30
+ "Cursor",
31
+ "User",
32
+ );
33
+ }
34
+ if (process.platform === "win32") {
35
+ const appData =
36
+ process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
37
+ return path.join(appData, "Cursor", "User");
38
+ }
39
+ return path.join(os.homedir(), ".config", "Cursor", "User");
40
+ }
41
+
42
+ export function mainCursorProfileRoot() {
43
+ return path.dirname(mainCursorUserDir());
44
+ }
45
+
46
+ /**
47
+ * Parse ps output for cursor processes and their --user-data-dir
48
+ * @returns {Map<string, number>} profileRoot -> count of processes
49
+ */
50
+ export function listRunningCursorProfiles() {
51
+ const map = new Map();
52
+ let output = "";
53
+ try {
54
+ if (process.platform === "win32") {
55
+ output = execSync(
56
+ "wmic process where \"name='Cursor.exe'\" get CommandLine",
57
+ {
58
+ encoding: "utf8",
59
+ stdio: ["pipe", "pipe", "ignore"],
60
+ },
61
+ );
62
+ } else {
63
+ // ps -eo: ps axo intermittently misses GUI processes in some contexts
64
+ output = execSync("ps -eo command=", {
65
+ encoding: "utf8",
66
+ stdio: ["pipe", "pipe", "ignore"],
67
+ });
68
+ }
69
+ } catch {
70
+ return map;
71
+ }
72
+
73
+ const lines = output.split("\n");
74
+ for (const line of lines) {
75
+ if (!/cursor/i.test(line)) continue;
76
+ // Skip helper processes (renderer/gpu/network); only the main process
77
+ // (no --type= flag) represents a running profile window.
78
+ if (line.includes("--type=")) continue;
79
+ const dir = parseUserDataDirFromCommandLine(line);
80
+ if (dir) {
81
+ const normalized = path.resolve(dir);
82
+ map.set(normalized, (map.get(normalized) || 0) + 1);
83
+ }
84
+ }
85
+ return map;
86
+ }
87
+
88
+ /**
89
+ * @param {string} line
90
+ */
91
+ export function parseUserDataDirFromCommandLine(line) {
92
+ const flag = "--user-data-dir";
93
+ const idx = line.indexOf(flag);
94
+ if (idx === -1) return null;
95
+ let rest = line.slice(idx + flag.length).trim();
96
+ if (rest.startsWith("=")) rest = rest.slice(1).trim();
97
+ if (!rest.length) return null;
98
+ if (rest[0] === '"' || rest[0] === "'") {
99
+ const q = rest[0];
100
+ const end = rest.indexOf(q, 1);
101
+ if (end === -1) return null;
102
+ return rest.slice(1, end);
103
+ }
104
+ const space = rest.search(/\s/);
105
+ return space === -1 ? rest : rest.slice(0, space);
106
+ }
107
+
108
+ export function isCursorRunning() {
109
+ try {
110
+ if (process.platform === "darwin" || process.platform === "linux") {
111
+ execSync("pgrep -x Cursor", { stdio: "ignore" });
112
+ return true;
113
+ }
114
+ } catch {
115
+ return false;
116
+ }
117
+ return listRunningCursorProfiles().size > 0;
118
+ }
119
+
120
+ export function hasSqlite3() {
121
+ try {
122
+ execSync("which sqlite3", { stdio: "ignore" });
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Check if secret key row exists (never reads value)
131
+ * @param {string} stateDbPath
132
+ * @param {string} key
133
+ */
134
+ export function stateDbHasKey(stateDbPath, key) {
135
+ if (!fs.existsSync(stateDbPath) || !hasSqlite3()) return false;
136
+ try {
137
+ const out = execSync(
138
+ `sqlite3 ${shellQuote(stateDbPath)} "SELECT 1 FROM ItemTable WHERE key = ${shellQuote(key)} LIMIT 1;"`,
139
+ { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] },
140
+ );
141
+ return out.trim() === "1";
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * List ItemTable keys matching pattern (names only)
149
+ * @param {string} stateDbPath
150
+ * @param {string} likePattern SQL LIKE pattern
151
+ */
152
+ export function stateDbKeysLike(stateDbPath, likePattern) {
153
+ if (!fs.existsSync(stateDbPath) || !hasSqlite3()) return [];
154
+ try {
155
+ const out = execSync(
156
+ `sqlite3 ${shellQuote(stateDbPath)} "SELECT key FROM ItemTable WHERE key LIKE ${shellQuote(likePattern)};"`,
157
+ { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] },
158
+ );
159
+ return out
160
+ .split("\n")
161
+ .map((l) => l.trim())
162
+ .filter(Boolean);
163
+ } catch {
164
+ return [];
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Check if Bonzai base URL appears in any ItemTable value (substring, no key dump)
170
+ * @param {string} stateDbPath
171
+ * @param {string} urlFragment
172
+ */
173
+ export function stateDbValueContains(stateDbPath, urlFragment) {
174
+ if (!fs.existsSync(stateDbPath) || !hasSqlite3()) return false;
175
+ try {
176
+ const out = execSync(
177
+ `sqlite3 ${shellQuote(stateDbPath)} "SELECT 1 FROM ItemTable WHERE CAST(value AS TEXT) LIKE ${shellQuote("%" + urlFragment + "%")} LIMIT 1;"`,
178
+ { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] },
179
+ );
180
+ return out.trim() === "1";
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * True if the value for `key` is byte-identical in both state DBs.
188
+ * Comparison happens inside SQL; the value never enters this process.
189
+ * @param {string} dbA
190
+ * @param {string} dbB
191
+ * @param {string} key
192
+ */
193
+ export function stateDbValuesMatch(dbA, dbB, key) {
194
+ if (!fs.existsSync(dbA) || !fs.existsSync(dbB) || !hasSqlite3()) {
195
+ return false;
196
+ }
197
+ try {
198
+ const out = execSync(
199
+ `sqlite3 ${shellQuote(dbA)} "ATTACH DATABASE ${shellQuote(dbB)} AS other; SELECT CASE WHEN (SELECT value FROM ItemTable WHERE key = ${shellQuote(key)}) IS NOT NULL AND (SELECT value FROM ItemTable WHERE key = ${shellQuote(key)}) = (SELECT value FROM other.ItemTable WHERE key = ${shellQuote(key)}) THEN 1 ELSE 0 END;"`,
200
+ { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] },
201
+ );
202
+ return out.trim() === "1";
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ function shellQuote(s) {
209
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
210
+ }
211
+
212
+ export const SECRET_OPENAI_KEY = "secret://cursorAuth/openAIKey";
213
+ export const SUBAGENT_OVERRIDES_KEY = "cursor/subagentModelOverrides";
214
+ export const BONZAI_BASE_URL_FRAGMENT = "connect.iobonzai.com/cursor";
package/src/jsonc.mjs ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Strip JSONC comments and trailing commas (respects strings).
3
+ * @param {string} text
4
+ * @returns {string}
5
+ */
6
+ export function stripJsonc(text) {
7
+ let out = "";
8
+ let i = 0;
9
+ const len = text.length;
10
+ let inString = false;
11
+ let stringQuote = "";
12
+ let escape = false;
13
+
14
+ while (i < len) {
15
+ const c = text[i];
16
+
17
+ if (inString) {
18
+ out += c;
19
+ if (escape) {
20
+ escape = false;
21
+ } else if (c === "\\") {
22
+ escape = true;
23
+ } else if (c === stringQuote) {
24
+ inString = false;
25
+ }
26
+ i++;
27
+ continue;
28
+ }
29
+
30
+ if (c === '"' || c === "'") {
31
+ inString = true;
32
+ stringQuote = c;
33
+ out += c;
34
+ i++;
35
+ continue;
36
+ }
37
+
38
+ if (c === "/" && i + 1 < len) {
39
+ const next = text[i + 1];
40
+ if (next === "/") {
41
+ i += 2;
42
+ while (i < len && text[i] !== "\n") i++;
43
+ continue;
44
+ }
45
+ if (next === "*") {
46
+ i += 2;
47
+ while (i + 1 < len && !(text[i] === "*" && text[i + 1] === "/")) i++;
48
+ i += 2;
49
+ continue;
50
+ }
51
+ }
52
+
53
+ out += c;
54
+ i++;
55
+ }
56
+
57
+ return removeTrailingCommas(out);
58
+ }
59
+
60
+ function removeTrailingCommas(json) {
61
+ let out = "";
62
+ let i = 0;
63
+ let inString = false;
64
+ let stringQuote = "";
65
+ let escape = false;
66
+
67
+ while (i < json.length) {
68
+ const c = json[i];
69
+
70
+ if (inString) {
71
+ out += c;
72
+ if (escape) escape = false;
73
+ else if (c === "\\") escape = true;
74
+ else if (c === stringQuote) inString = false;
75
+ i++;
76
+ continue;
77
+ }
78
+
79
+ if (c === '"' || c === "'") {
80
+ inString = true;
81
+ stringQuote = c;
82
+ out += c;
83
+ i++;
84
+ continue;
85
+ }
86
+
87
+ if (c === ",") {
88
+ let j = i + 1;
89
+ while (j < json.length && /\s/.test(json[j])) j++;
90
+ if (json[j] === "}" || json[j] === "]") {
91
+ i++;
92
+ continue;
93
+ }
94
+ }
95
+
96
+ out += c;
97
+ i++;
98
+ }
99
+
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * @param {string} text
105
+ * @returns {unknown}
106
+ */
107
+ export function parseJsonc(text) {
108
+ return JSON.parse(stripJsonc(text));
109
+ }
@@ -0,0 +1,136 @@
1
+ import { execFileSync, execSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+
5
+ const SERVICE_NAMES = ["Cursor Safe Storage", "cursor Safe Storage"];
6
+ export const OPENAI_KEY_ROW = "secret://cursorAuth/openAIKey";
7
+
8
+ function sh(s) {
9
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
10
+ }
11
+
12
+ let cachedPassword = null;
13
+
14
+ /**
15
+ * Reads Cursor's own safeStorage password from the macOS Keychain.
16
+ * May trigger a one-time Keychain permission dialog.
17
+ */
18
+ export function getSafeStoragePassword() {
19
+ if (cachedPassword) return cachedPassword;
20
+ for (const svc of SERVICE_NAMES) {
21
+ try {
22
+ const out = execSync(`security find-generic-password -s ${sh(svc)} -w`, {
23
+ encoding: "utf8",
24
+ stdio: ["pipe", "pipe", "ignore"],
25
+ });
26
+ const pw = out.trim();
27
+ if (pw) {
28
+ cachedPassword = pw;
29
+ return pw;
30
+ }
31
+ } catch {
32
+ /* try next service name */
33
+ }
34
+ }
35
+ throw new Error(
36
+ "Could not read 'Cursor Safe Storage' from Keychain (permission denied?)",
37
+ );
38
+ }
39
+
40
+ /** Chromium OSCrypt (macOS): PBKDF2-SHA1, salt 'saltysalt', 1003 iters, 16-byte key */
41
+ function deriveKey(password) {
42
+ return crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
43
+ }
44
+
45
+ const IV = Buffer.alloc(16, 0x20); // 16 space bytes
46
+
47
+ export function oscryptEncrypt(plaintext, password) {
48
+ const key = deriveKey(password);
49
+ const cipher = crypto.createCipheriv("aes-128-cbc", key, IV);
50
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
51
+ return Buffer.concat([Buffer.from("v10"), ct]);
52
+ }
53
+
54
+ /**
55
+ * @param {number[]|string} data byte array (Cursor's format) or base64 string
56
+ */
57
+ export function oscryptDecrypt(data, password) {
58
+ const raw = Array.isArray(data)
59
+ ? Buffer.from(data)
60
+ : Buffer.from(data, "base64");
61
+ if (raw.subarray(0, 3).toString() !== "v10") {
62
+ throw new Error("Not a v10 OSCrypt blob");
63
+ }
64
+ const key = deriveKey(password);
65
+ const decipher = crypto.createDecipheriv("aes-128-cbc", key, IV);
66
+ return Buffer.concat([
67
+ decipher.update(raw.subarray(3)),
68
+ decipher.final(),
69
+ ]).toString("utf8");
70
+ }
71
+
72
+ /**
73
+ * Verify we can decrypt an existing Cursor secret. Returns only metadata,
74
+ * never the plaintext.
75
+ */
76
+ export function verifyDecryption(stateDbPath) {
77
+ if (!fs.existsSync(stateDbPath)) return { ok: false, reason: "no state db" };
78
+ const row = execFileSync(
79
+ "sqlite3",
80
+ [
81
+ stateDbPath,
82
+ `SELECT value FROM ItemTable WHERE key = '${OPENAI_KEY_ROW}' LIMIT 1;`,
83
+ ],
84
+ { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] },
85
+ ).trim();
86
+ if (!row) return { ok: false, reason: "no key row" };
87
+ try {
88
+ const parsed = JSON.parse(row);
89
+ const password = getSafeStoragePassword();
90
+ const plain = oscryptDecrypt(parsed.data, password);
91
+ return {
92
+ ok: true,
93
+ length: plain.length,
94
+ looksLikeApiKey: plain.startsWith("sk-"),
95
+ };
96
+ } catch (e) {
97
+ return { ok: false, reason: e.message };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Write an API key into a profile's encrypted store, using Cursor's own
103
+ * encryption. The profile's Cursor window must be closed.
104
+ */
105
+ export function setProfileApiKey(stateDbPath, apiKey) {
106
+ if (!fs.existsSync(stateDbPath)) {
107
+ throw new Error(`Profile state db missing: ${stateDbPath}`);
108
+ }
109
+ if (!apiKey || !apiKey.startsWith("sk-")) {
110
+ throw new Error("API key should start with 'sk-'");
111
+ }
112
+ const password = getSafeStoragePassword();
113
+ const buf = oscryptEncrypt(apiKey, password);
114
+ // Cursor stores secrets as Buffer JSON: {"type":"Buffer","data":[bytes...]}
115
+ const value = JSON.stringify({
116
+ type: "Buffer",
117
+ data: Array.from(buf),
118
+ }).replace(/'/g, "''");
119
+ // execFileSync: no shell, so JSON double quotes survive intact
120
+ execFileSync(
121
+ "sqlite3",
122
+ [
123
+ stateDbPath,
124
+ `INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('${OPENAI_KEY_ROW}', '${value}');`,
125
+ ],
126
+ { stdio: "ignore" },
127
+ );
128
+ // round-trip verification
129
+ const check = verifyDecryption(stateDbPath);
130
+ if (!check.ok) {
131
+ throw new Error(
132
+ `Wrote key but round-trip verification failed: ${check.reason}`,
133
+ );
134
+ }
135
+ return check;
136
+ }