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/LICENSE +21 -0
- package/README.md +129 -2
- package/bin/cursor-keys.mjs +68 -0
- package/extension/LICENSE +21 -0
- package/extension/extension.js +248 -0
- package/extension/package.json +35 -0
- package/package.json +35 -4
- package/src/ansi.mjs +55 -0
- package/src/commands.mjs +819 -0
- package/src/config.mjs +176 -0
- package/src/detect.mjs +214 -0
- package/src/jsonc.mjs +109 -0
- package/src/keychain.mjs +136 -0
- package/src/profile.mjs +191 -0
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
|
+
}
|
package/src/keychain.mjs
ADDED
|
@@ -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
|
+
}
|