cursor-keys 1.2.4 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -8
- package/bin/cursor-keys.mjs +3 -0
- package/package.json +2 -4
- package/src/commands.mjs +124 -13
- package/src/config.mjs +5 -6
- package/src/detect.mjs +2 -2
- package/src/profile.mjs +85 -7
- package/scripts/postinstall.mjs +0 -58
package/README.md
CHANGED
|
@@ -48,7 +48,29 @@ npm install -g cursor-keys
|
|
|
48
48
|
|
|
49
49
|
Requires Node ≥18, the Cursor CLI (`Shell Command: Install 'cursor' in PATH`), and sqlite3 (preinstalled on macOS). Key injection is macOS-only; everything else is cross-platform shaped.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
The package runs **no install scripts** (`ignore-scripts=true` safe). Link the companion extension once yourself:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cursor-keys install-extension # or install "cursor-keys" from the Open VSX marketplace in Cursor
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Restart Cursor, then `Cmd+Shift+P` → "cursor-keys: Open Project...".
|
|
58
|
+
|
|
59
|
+
## No terminal? Let Cursor's agent set it up
|
|
60
|
+
|
|
61
|
+
Open your project in Cursor, paste this into the agent chat (`Cmd+L`), and it runs the whole setup for you. Your API key goes into a hidden terminal prompt, never into the chat:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
Set up cursor-keys (github.com/L-ubu/cursor-keys) for this project, step by step:
|
|
65
|
+
1. Run `npm install -g cursor-keys`.
|
|
66
|
+
2. Run `cursor-keys config baseUrl <your-gateway-url>` (replace with my gateway URL, e.g. https://gateway.example.com/v1).
|
|
67
|
+
3. Register this workspace: `cursor-keys add <folder-name> --path <this workspace's path>`.
|
|
68
|
+
4. Run `cursor-keys clone <folder-name>` to inherit my Cursor sign-in and settings.
|
|
69
|
+
5. Run `cursor-keys set-key <folder-name>` and ask me to paste the project API key at the hidden terminal prompt. Never write the key into files, chat, or logs.
|
|
70
|
+
6. Run `cursor-keys doctor <folder-name>` and fix what it flags.
|
|
71
|
+
7. Run `cursor-keys aliases --install` and `cursor-keys wrap --install` for the shell extras.
|
|
72
|
+
8. Open the project in its profile with `cursor-keys open <folder-name>`, then explain how I can tell it worked.
|
|
73
|
+
```
|
|
52
74
|
|
|
53
75
|
## Quick start
|
|
54
76
|
|
|
@@ -72,7 +94,7 @@ One-time extras for the full experience:
|
|
|
72
94
|
```bash
|
|
73
95
|
cursor-keys wrap --install # plain `cursor <path>` auto-picks the profile
|
|
74
96
|
cursor-keys aliases --install # per-project aliases, `ck` shortcut, zsh tab completion
|
|
75
|
-
cursor-keys install-extension # status bar + wrong-window guard
|
|
97
|
+
cursor-keys install-extension # status bar + wrong-window guard
|
|
76
98
|
```
|
|
77
99
|
|
|
78
100
|
## How it works
|
|
@@ -81,7 +103,7 @@ One window, one profile, one key. Each project gets an isolated `--user-data-dir
|
|
|
81
103
|
|
|
82
104
|
Settings flow one way, main to profiles: `sync` re-copies your main `settings.json` into every profile and re-applies that profile's title bar color on top. `open` does this automatically before every launch, and open windows hot-reload the file, so tweaks in your main Cursor show up everywhere. Changes made inside a profile window are overwritten on the next sync.
|
|
83
105
|
|
|
84
|
-
`clone` copies your main Cursor state (SQLite backup) so profiles open signed-in with your theme and model config. `set-key` encrypts your project key with Cursor's own safeStorage (Chromium OSCrypt, key from the "Cursor Safe Storage" macOS Keychain item) and writes it into the profile's secret store, round-trip verified. macOS asks for Keychain permission
|
|
106
|
+
`clone` copies your main Cursor state (SQLite backup) so profiles open signed-in with your theme and model config, then scrubs donor secrets (`secret://` rows, MCP OAuth tokens, extension secrets) out of the copy and prunes the AI chat/agent history tables (`cursorDiskKV`, `composerHeaders`). That keeps clones at a few megabytes instead of a full copy of a donor database that can be gigabytes, and stops one project's conversation history from leaking into another project's profile. The sign-in session itself is the one thing that does carry over, see [Security](#security). Already have bulky profiles? `cursor-keys prune [name]` applies the same cleanup to existing ones. `set-key` encrypts your project key with Cursor's own safeStorage (Chromium OSCrypt, key from the "Cursor Safe Storage" macOS Keychain item) and writes it into the profile's secret store, round-trip verified. When macOS asks for Keychain permission, click **Allow**: one run, one prompt. Always Allow also works, but it permanently grants the calling binary access to that Keychain item, which is the same pattern infostealers abuse against browser safe storage.
|
|
85
107
|
|
|
86
108
|
## "Which key am I on?" in three layers
|
|
87
109
|
|
|
@@ -96,7 +118,8 @@ Settings flow one way, main to profiles: `sync` re-copies your main `settings.js
|
|
|
96
118
|
| `init [--root DIR] [--all]` | Discover git repos and register them |
|
|
97
119
|
| `add <name> [--path] [--color] [--clone main]` | Create a project profile |
|
|
98
120
|
| `clone <name> [--from main]` | Copy sign-in + settings + model config from a donor profile |
|
|
99
|
-
| `
|
|
121
|
+
| `prune [name]` | Delete AI chat history from profile DBs, reclaim disk space |
|
|
122
|
+
| `set-key <name>` | Inject the project API key (hidden prompt or stdin, encrypted) |
|
|
100
123
|
| `color <name> [#hex]` | Show or set the project title bar color |
|
|
101
124
|
| `open [name]` | Launch Cursor with the profile (auto-detect from cwd) |
|
|
102
125
|
| `status` | Projects, colors, running windows, key configured |
|
|
@@ -107,7 +130,7 @@ Settings flow one way, main to profiles: `sync` re-copies your main `settings.js
|
|
|
107
130
|
| `wrap [--install]` | zsh function so plain `cursor <path>` auto-picks the profile |
|
|
108
131
|
| `aliases [--install]` | Per-project shell aliases (also `ck` shortcut + tab completion) |
|
|
109
132
|
| `completion` | Print zsh tab completion (auto-sourced by `aliases --install`) |
|
|
110
|
-
| `install-extension` | Link the Cursor extension (
|
|
133
|
+
| `install-extension` | Link the Cursor extension (status bar + wrong-window guard) |
|
|
111
134
|
| `rm <name> [--delete]` | Unregister (optionally delete the profile) |
|
|
112
135
|
|
|
113
136
|
`cursor-keys ls` prints registered project names one per line for scripts and tab completion; it is hidden from `--help`.
|
|
@@ -137,9 +160,13 @@ cursor-keys config get profilesRoot
|
|
|
137
160
|
|
|
138
161
|
## Security
|
|
139
162
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
-
|
|
163
|
+
Full threat model in [SECURITY.md](SECURITY.md). The short version:
|
|
164
|
+
|
|
165
|
+
- Keys go **straight into Cursor's own encrypted store** (`secret://cursorAuth/openAIKey`), encrypted with the same safeStorage mechanism Cursor uses. Never in config files, logs, or stdout. `set-key` takes the key from a hidden prompt or stdin, so it never appears in shell history or a process list.
|
|
166
|
+
- `set-key` reads the "Cursor Safe Storage" Keychain item to encrypt, the same key Cursor itself uses. Nothing leaves your machine. Click **Allow** at the prompt; Always Allow permanently ACLs the calling binary to that item.
|
|
167
|
+
- `clone` copies `state.vscdb` locally via SQLite backup, then **scrubs donor secrets from the copy**: all `secret://` rows (the donor's API key, extension secrets) and MCP OAuth tokens are deleted. MCP servers re-auth once per profile. The **AI history tables are pruned** too, so no chat or agent session data crosses between project profiles (and clones stay megabytes, not gigabytes).
|
|
168
|
+
- What clone deliberately keeps is your Cursor **sign-in session** (so profiles open signed-in). That means N profiles hold N+1 copies of the session tokens, stored exactly as Cursor stores them. Delete profiles you no longer use: `cursor-keys rm <name> --delete`.
|
|
169
|
+
- Supply chain: zero runtime dependencies, **no install scripts**, and releases are published from GitHub Actions with [npm provenance](https://docs.npmjs.com/generating-provenance-statements) over OIDC trusted publishing (no long-lived npm token).
|
|
143
170
|
|
|
144
171
|
## Known limitations
|
|
145
172
|
|
package/bin/cursor-keys.mjs
CHANGED
|
@@ -24,6 +24,9 @@ function printHelp() {
|
|
|
24
24
|
console.log(
|
|
25
25
|
" clone <name> [--from main] Copy sign-in + settings from a donor profile",
|
|
26
26
|
);
|
|
27
|
+
console.log(
|
|
28
|
+
" prune [name] Delete AI chat history, reclaim profile disk space",
|
|
29
|
+
);
|
|
27
30
|
console.log(
|
|
28
31
|
" set-key <name> Inject the project API key (encrypted)",
|
|
29
32
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-keys",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Per-project Cursor profiles so each window uses the correct BYOK API key. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"bin",
|
|
15
15
|
"src",
|
|
16
|
-
"extension"
|
|
17
|
-
"scripts"
|
|
16
|
+
"extension"
|
|
18
17
|
],
|
|
19
18
|
"keywords": [
|
|
20
19
|
"cursor",
|
|
@@ -32,7 +31,6 @@
|
|
|
32
31
|
},
|
|
33
32
|
"scripts": {
|
|
34
33
|
"test": "node --test",
|
|
35
|
-
"postinstall": "node scripts/postinstall.mjs",
|
|
36
34
|
"prepublishOnly": "npm test"
|
|
37
35
|
}
|
|
38
36
|
}
|
package/src/commands.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
needsFirstRunChecklist,
|
|
50
50
|
printFirstRunChecklist,
|
|
51
51
|
profileIntegrityChecks,
|
|
52
|
+
pruneStateDbHistory,
|
|
52
53
|
writeProfileSettings,
|
|
53
54
|
} from "./profile.mjs";
|
|
54
55
|
import { setProfileApiKey } from "./keychain.mjs";
|
|
@@ -94,6 +95,8 @@ export async function runCommand(argv) {
|
|
|
94
95
|
return cmdColor(rest);
|
|
95
96
|
case "clone":
|
|
96
97
|
return cmdClone(rest);
|
|
98
|
+
case "prune":
|
|
99
|
+
return cmdPrune(rest);
|
|
97
100
|
case "rm":
|
|
98
101
|
return cmdRm(rest);
|
|
99
102
|
case "ls":
|
|
@@ -215,7 +218,9 @@ async function cmdConfig(args) {
|
|
|
215
218
|
console.log(
|
|
216
219
|
`${bold}Config${reset} ${dim}(${collapseHome(CONFIG_FILE)})${reset}`,
|
|
217
220
|
);
|
|
218
|
-
console.log(
|
|
221
|
+
console.log(
|
|
222
|
+
` baseUrl: ${config.baseUrl || `${dim}(not set: cursor-keys config baseUrl <url>)${reset}`}`,
|
|
223
|
+
);
|
|
219
224
|
console.log(` profilesRoot: ${config.profilesRoot}`);
|
|
220
225
|
console.log(
|
|
221
226
|
` projects: ${count} registered ${dim}(see cursor-keys status)${reset}`,
|
|
@@ -382,7 +387,7 @@ async function cmdAdd(args) {
|
|
|
382
387
|
}
|
|
383
388
|
}
|
|
384
389
|
|
|
385
|
-
createProfile({
|
|
390
|
+
const { scrubbed } = createProfile({
|
|
386
391
|
profileDir: project.profileDir,
|
|
387
392
|
projectName: name,
|
|
388
393
|
color,
|
|
@@ -392,6 +397,11 @@ async function cmdAdd(args) {
|
|
|
392
397
|
console.log(ok(`Profile ready: ${collapseHome(project.profileDir)}`));
|
|
393
398
|
console.log(` Repo: ${collapseHome(project.path)}`);
|
|
394
399
|
console.log(` Color: ${color} ${blockSwatch(color)}`);
|
|
400
|
+
if (cloneFrom) {
|
|
401
|
+
console.log(
|
|
402
|
+
` ${dim}Scrubbed ${scrubbed} donor secret row(s) from the clone; sign-in retained.${reset}`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
395
405
|
if (shouldShowFirstRunChecklist(project.profileDir)) {
|
|
396
406
|
printFirstRunChecklist(name, config.baseUrl);
|
|
397
407
|
}
|
|
@@ -483,6 +493,26 @@ async function cmdStatus() {
|
|
|
483
493
|
});
|
|
484
494
|
|
|
485
495
|
printTable(["Project", "Color", "Windows", "Key", "Repo"], rows);
|
|
496
|
+
if (!companionExtensionLinked()) {
|
|
497
|
+
console.log(
|
|
498
|
+
`\n${dim}Companion extension not linked: cursor-keys install-extension (status bar + wrong-window guard)${reset}`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Best-effort check that the companion extension is linked into Cursor.
|
|
505
|
+
* Looks for the lubu.cursor-keys-* folder the extension manifest produces.
|
|
506
|
+
*/
|
|
507
|
+
function companionExtensionLinked() {
|
|
508
|
+
try {
|
|
509
|
+
const extDir = path.join(os.homedir(), ".cursor", "extensions");
|
|
510
|
+
return fs
|
|
511
|
+
.readdirSync(extDir)
|
|
512
|
+
.some((d) => d.startsWith("lubu.cursor-keys"));
|
|
513
|
+
} catch {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
486
516
|
}
|
|
487
517
|
|
|
488
518
|
async function cmdCurrent() {
|
|
@@ -562,9 +592,16 @@ async function cmdDoctor(args) {
|
|
|
562
592
|
console.log(
|
|
563
593
|
` ${stateDbHasKey(db, SUBAGENT_OVERRIDES_KEY) ? ok("✓") : warn("✗")} Explore subagent overrides present`,
|
|
564
594
|
);
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
595
|
+
const fragment = baseUrlFragment(config);
|
|
596
|
+
if (fragment) {
|
|
597
|
+
console.log(
|
|
598
|
+
` ${stateDbValueContains(db, fragment) ? ok("✓") : warn("✗")} Base URL (${fragment}) in profile state`,
|
|
599
|
+
);
|
|
600
|
+
} else {
|
|
601
|
+
console.log(
|
|
602
|
+
` ${dim}- baseUrl not set, skipping gateway check: cursor-keys config baseUrl <url>${reset}`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
568
605
|
} else {
|
|
569
606
|
console.log(` ${warn("✗")} Open profile once: cursor-keys open ${name}`);
|
|
570
607
|
}
|
|
@@ -814,14 +851,72 @@ async function cmdClone(args) {
|
|
|
814
851
|
donorRoot = donor.profileDir;
|
|
815
852
|
}
|
|
816
853
|
|
|
817
|
-
cloneStateDb(donorRoot, project.profileDir);
|
|
854
|
+
const scrubbed = cloneStateDb(donorRoot, project.profileDir);
|
|
855
|
+
const dbBytes = fs.statSync(profileStateDb(project.profileDir)).size;
|
|
818
856
|
console.log(ok(`Cloned state from ${from} into ${name}`));
|
|
819
857
|
console.log(
|
|
820
|
-
`${dim}
|
|
858
|
+
`${dim}Scrubbed ${scrubbed} donor secret row(s) from the copy (API keys, MCP OAuth, extension secrets); sign-in retained.${reset}`,
|
|
859
|
+
);
|
|
860
|
+
console.log(
|
|
861
|
+
`${dim}AI chat/agent history pruned, state DB is ${formatBytes(dbBytes)}. Settings and model config carried over. Now set the project key:${reset}`,
|
|
821
862
|
);
|
|
822
863
|
console.log(` cursor-keys set-key ${name}`);
|
|
823
864
|
}
|
|
824
865
|
|
|
866
|
+
function formatBytes(n) {
|
|
867
|
+
if (n >= 1 << 30) return `${(n / (1 << 30)).toFixed(1)} GB`;
|
|
868
|
+
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
|
869
|
+
return `${Math.max(1, Math.round(n / 1024))} KB`;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function cmdPrune(args) {
|
|
873
|
+
const { flags, positional } = parseFlags(args);
|
|
874
|
+
if (flags.help) {
|
|
875
|
+
console.log("Usage: cursor-keys prune [name]");
|
|
876
|
+
console.log(
|
|
877
|
+
"Deletes AI chat/agent history from profile state DBs and reclaims the",
|
|
878
|
+
);
|
|
879
|
+
console.log("space. Sign-in, settings and model config are untouched.");
|
|
880
|
+
console.log("No name: prune every registered project.");
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const config = loadConfig();
|
|
884
|
+
const names = positional[0]
|
|
885
|
+
? [positional[0]]
|
|
886
|
+
: Object.keys(config.projects).sort();
|
|
887
|
+
if (!names.length) {
|
|
888
|
+
console.log("No projects registered. Run: cursor-keys init");
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const running = listRunningCursorProfiles();
|
|
892
|
+
for (const name of names) {
|
|
893
|
+
const project = getProject(config, name);
|
|
894
|
+
if (!project) throw new Error(`Unknown project: ${name}`);
|
|
895
|
+
if (running.has(path.resolve(project.profileDir))) {
|
|
896
|
+
console.log(
|
|
897
|
+
warn(`skip ${name}: profile window is running, close it first`),
|
|
898
|
+
);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const db = profileStateDb(project.profileDir);
|
|
902
|
+
if (!fs.existsSync(db)) {
|
|
903
|
+
console.log(
|
|
904
|
+
warn(`skip ${name}: no state.vscdb (open Cursor once in this profile)`),
|
|
905
|
+
);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
const { beforeBytes, afterBytes } = pruneStateDbHistory(db);
|
|
909
|
+
console.log(
|
|
910
|
+
ok(
|
|
911
|
+
`Pruned ${name}: ${formatBytes(beforeBytes)} -> ${formatBytes(afterBytes)} (freed ${formatBytes(beforeBytes - afterBytes)})`,
|
|
912
|
+
),
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
console.log(
|
|
916
|
+
`${dim}Only AI chat/agent history was removed; sign-in, settings and model config are untouched.${reset}`,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
825
920
|
async function cmdInstallExtension() {
|
|
826
921
|
const repoRoot = path.resolve(
|
|
827
922
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -879,6 +974,14 @@ async function cmdColor(args) {
|
|
|
879
974
|
console.log(`${dim}Open Cursor windows reload settings automatically.${reset}`);
|
|
880
975
|
}
|
|
881
976
|
|
|
977
|
+
/**
|
|
978
|
+
* Read the API key from piped stdin. Only called when stdin is not an
|
|
979
|
+
* interactive TTY; interactive runs get the hidden prompt instead.
|
|
980
|
+
*/
|
|
981
|
+
function readStdinKey() {
|
|
982
|
+
return fs.readFileSync(0, "utf8").trim();
|
|
983
|
+
}
|
|
984
|
+
|
|
882
985
|
function promptHidden(question) {
|
|
883
986
|
return new Promise((resolve) => {
|
|
884
987
|
const rl = readline.createInterface({
|
|
@@ -899,18 +1002,24 @@ function promptHidden(question) {
|
|
|
899
1002
|
async function cmdSetKey(args) {
|
|
900
1003
|
const { flags, positional } = parseFlags(args);
|
|
901
1004
|
if (flags.help) {
|
|
902
|
-
console.log("Usage: cursor-keys set-key <name>
|
|
1005
|
+
console.log("Usage: cursor-keys set-key <name>");
|
|
903
1006
|
console.log(
|
|
904
1007
|
"Writes the key into the profile's encrypted store (Cursor must",
|
|
905
1008
|
);
|
|
906
1009
|
console.log(
|
|
907
|
-
"not have that profile window open).
|
|
1010
|
+
"not have that profile window open). Hidden prompt when interactive;",
|
|
908
1011
|
);
|
|
1012
|
+
console.log("reads stdin when piped. No inline key flag exists:");
|
|
1013
|
+
console.log("shell history and process lists are not secret-safe.");
|
|
909
1014
|
return;
|
|
910
1015
|
}
|
|
911
1016
|
const name = positional[0];
|
|
912
|
-
if (!name)
|
|
913
|
-
|
|
1017
|
+
if (!name) throw new Error("Usage: cursor-keys set-key <name>");
|
|
1018
|
+
if (flags.key !== undefined) {
|
|
1019
|
+
throw new Error(
|
|
1020
|
+
`Inline --key is not accepted: shell history and process lists are not secret-safe. Use the hidden prompt or pipe stdin instead: op read op://vault/item/password | cursor-keys set-key ${name}`,
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
914
1023
|
|
|
915
1024
|
const config = loadConfig();
|
|
916
1025
|
const project = getProject(config, name);
|
|
@@ -926,8 +1035,10 @@ async function cmdSetKey(args) {
|
|
|
926
1035
|
throw new Error(`Close the "${name}" Cursor window first, then retry.`);
|
|
927
1036
|
}
|
|
928
1037
|
|
|
929
|
-
let key
|
|
930
|
-
if (!
|
|
1038
|
+
let key;
|
|
1039
|
+
if (!process.stdin.isTTY) {
|
|
1040
|
+
key = readStdinKey();
|
|
1041
|
+
} else {
|
|
931
1042
|
console.log(
|
|
932
1043
|
`\nPaste the API key for ${bold}${name}${reset} below, then press Enter.`,
|
|
933
1044
|
);
|
package/src/config.mjs
CHANGED
|
@@ -48,7 +48,9 @@ export function collapseHome(p) {
|
|
|
48
48
|
export function defaultConfig() {
|
|
49
49
|
return {
|
|
50
50
|
profilesRoot: collapseHome(DEFAULT_PROFILES_ROOT),
|
|
51
|
-
|
|
51
|
+
// No built-in gateway URL: every install sets its own via
|
|
52
|
+
// `cursor-keys config baseUrl <url>`.
|
|
53
|
+
baseUrl: "",
|
|
52
54
|
projects: {},
|
|
53
55
|
};
|
|
54
56
|
}
|
|
@@ -97,10 +99,7 @@ export function validateConfig(data) {
|
|
|
97
99
|
typeof data.profilesRoot === "string"
|
|
98
100
|
? data.profilesRoot
|
|
99
101
|
: collapseHome(DEFAULT_PROFILES_ROOT);
|
|
100
|
-
const baseUrl =
|
|
101
|
-
typeof data.baseUrl === "string" && data.baseUrl
|
|
102
|
-
? data.baseUrl
|
|
103
|
-
: "https://connect.iobonzai.com/cursor";
|
|
102
|
+
const baseUrl = typeof data.baseUrl === "string" ? data.baseUrl : "";
|
|
104
103
|
const projects = {};
|
|
105
104
|
if (data.projects && typeof data.projects === "object") {
|
|
106
105
|
for (const [name, entry] of Object.entries(data.projects)) {
|
|
@@ -120,7 +119,7 @@ export function validateConfig(data) {
|
|
|
120
119
|
return { profilesRoot, baseUrl, projects };
|
|
121
120
|
}
|
|
122
121
|
|
|
123
|
-
/** "https://
|
|
122
|
+
/** "https://gateway.example.com/v1" -> "gateway.example.com/v1" */
|
|
124
123
|
export function baseUrlFragment(config) {
|
|
125
124
|
return String(config.baseUrl || "").replace(/^https?:\/\//, "");
|
|
126
125
|
}
|
package/src/detect.mjs
CHANGED
|
@@ -166,7 +166,8 @@ export function stateDbKeysLike(stateDbPath, likePattern) {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
|
-
* Check if
|
|
169
|
+
* Check if a URL fragment (e.g. the configured gateway base URL) appears in
|
|
170
|
+
* any ItemTable value (substring, no key dump)
|
|
170
171
|
* @param {string} stateDbPath
|
|
171
172
|
* @param {string} urlFragment
|
|
172
173
|
*/
|
|
@@ -211,4 +212,3 @@ function shellQuote(s) {
|
|
|
211
212
|
|
|
212
213
|
export const SECRET_OPENAI_KEY = "secret://cursorAuth/openAIKey";
|
|
213
214
|
export const SUBAGENT_OVERRIDES_KEY = "cursor/subagentModelOverrides";
|
|
214
|
-
export const BONZAI_BASE_URL_FRAGMENT = "connect.iobonzai.com/cursor";
|
package/src/profile.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import {
|
|
@@ -80,9 +80,27 @@ export function symlinkSharedUserFiles(profileDir) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Row patterns scrubbed from every cloned state DB. Donor secrets (API keys,
|
|
85
|
+
* MCP OAuth tokens, extension secrets) never propagate into project profiles.
|
|
86
|
+
* The Cursor sign-in session (cursorAuth/*) intentionally stays: profiles
|
|
87
|
+
* open signed-in, which is the whole point of clone.
|
|
88
|
+
*/
|
|
89
|
+
export const CLONE_SCRUB_LIKE_PATTERNS = ["secret://%", "mcpOAuth.%"];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Tables wiped from every cloned state DB. Cursor keeps AI agent/chat session
|
|
93
|
+
* data here (agentKv:blob:* blobs), which dominates DB size (gigabytes on a
|
|
94
|
+
* well-used donor) and would otherwise carry one project's conversation
|
|
95
|
+
* history into every other project profile. Everything clone exists for
|
|
96
|
+
* (sign-in, settings, model config) lives in ItemTable and stays.
|
|
97
|
+
*/
|
|
98
|
+
export const CLONE_PRUNE_TABLES = ["cursorDiskKV", "composerHeaders"];
|
|
99
|
+
|
|
83
100
|
/**
|
|
84
101
|
* @param {string} donorProfileDir - main Cursor or another profile root (parent of User)
|
|
85
102
|
* @param {string} targetProfileDir
|
|
103
|
+
* @returns {number} how many donor secret rows were scrubbed from the copy
|
|
86
104
|
*/
|
|
87
105
|
export function cloneStateDb(donorProfileDir, targetProfileDir) {
|
|
88
106
|
const runningProfiles = listRunningCursorProfiles();
|
|
@@ -126,6 +144,60 @@ export function cloneStateDb(donorProfileDir, targetProfileDir) {
|
|
|
126
144
|
execSync(`sqlite3 ${shellQuote(src)} "VACUUM INTO ${shellQuote(dest)}"`, {
|
|
127
145
|
stdio: "ignore",
|
|
128
146
|
});
|
|
147
|
+
|
|
148
|
+
const scrubbed = scrubClonedSecrets(dest);
|
|
149
|
+
pruneStateDbHistory(dest);
|
|
150
|
+
return scrubbed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Delete AI chat/agent history tables from a state DB and reclaim the space
|
|
155
|
+
* with VACUUM. Sign-in, settings, model config and extension state live in
|
|
156
|
+
* ItemTable and are untouched. Missing tables are skipped, so this is safe
|
|
157
|
+
* on any Cursor/VS Code state DB.
|
|
158
|
+
* @param {string} stateDbPath
|
|
159
|
+
* @returns {{ beforeBytes: number, afterBytes: number }}
|
|
160
|
+
*/
|
|
161
|
+
export function pruneStateDbHistory(stateDbPath) {
|
|
162
|
+
const beforeBytes = fs.statSync(stateDbPath).size;
|
|
163
|
+
const tables = execFileSync(
|
|
164
|
+
"sqlite3",
|
|
165
|
+
[stateDbPath, "SELECT name FROM sqlite_master WHERE type='table';"],
|
|
166
|
+
{ encoding: "utf8" },
|
|
167
|
+
)
|
|
168
|
+
.split("\n")
|
|
169
|
+
.map((s) => s.trim())
|
|
170
|
+
.filter(Boolean);
|
|
171
|
+
const present = CLONE_PRUNE_TABLES.filter((t) => tables.includes(t));
|
|
172
|
+
if (present.length) {
|
|
173
|
+
const stmts = present.map((t) => `DELETE FROM "${t}";`).join(" ");
|
|
174
|
+
execFileSync("sqlite3", [stateDbPath, `${stmts} VACUUM;`], {
|
|
175
|
+
stdio: "ignore",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const afterBytes = fs.statSync(stateDbPath).size;
|
|
179
|
+
return { beforeBytes, afterBytes };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Delete donor secret rows from a freshly cloned state DB (keys only, values
|
|
184
|
+
* are never read). Returns the number of deleted rows.
|
|
185
|
+
* @param {string} stateDbPath
|
|
186
|
+
*/
|
|
187
|
+
export function scrubClonedSecrets(stateDbPath) {
|
|
188
|
+
let scrubbed = 0;
|
|
189
|
+
for (const pattern of CLONE_SCRUB_LIKE_PATTERNS) {
|
|
190
|
+
const out = execFileSync(
|
|
191
|
+
"sqlite3",
|
|
192
|
+
[
|
|
193
|
+
stateDbPath,
|
|
194
|
+
`DELETE FROM ItemTable WHERE key LIKE '${pattern}'; SELECT changes();`,
|
|
195
|
+
],
|
|
196
|
+
{ encoding: "utf8" },
|
|
197
|
+
);
|
|
198
|
+
scrubbed += parseInt(out.trim(), 10) || 0;
|
|
199
|
+
}
|
|
200
|
+
return scrubbed;
|
|
129
201
|
}
|
|
130
202
|
|
|
131
203
|
function shellQuote(s) {
|
|
@@ -150,19 +222,23 @@ export function needsFirstRunChecklist(profileDir) {
|
|
|
150
222
|
*/
|
|
151
223
|
export function buildFirstRunChecklist(
|
|
152
224
|
projectName,
|
|
153
|
-
baseUrl = "
|
|
225
|
+
baseUrl = "",
|
|
154
226
|
platform = process.platform,
|
|
155
227
|
) {
|
|
228
|
+
const gateway = baseUrl || "https://gateway.example.com/v1";
|
|
229
|
+
const gatewayStep = baseUrl
|
|
230
|
+
? ""
|
|
231
|
+
: ` cursor-keys config baseUrl <your-gateway-url> # one-time, e.g. ${gateway}\n`;
|
|
156
232
|
if (platform === "darwin") {
|
|
157
233
|
return `
|
|
158
234
|
First-time setup for "${projectName}" · copy-paste in order:
|
|
159
235
|
|
|
160
|
-
cursor-keys clone ${projectName} # inherit sign-in, settings, model config from main Cursor
|
|
236
|
+
${gatewayStep} cursor-keys clone ${projectName} # inherit sign-in, settings, model config from main Cursor
|
|
161
237
|
cursor-keys set-key ${projectName} # inject the project API key (encrypted, hidden prompt)
|
|
162
238
|
cursor-keys doctor ${projectName} # verify the profile
|
|
163
239
|
|
|
164
240
|
Manual alternative: Cursor Settings → Models → API keys, paste the key under
|
|
165
|
-
OpenAI API Key, enable "Override OpenAI Base URL" with: ${
|
|
241
|
+
OpenAI API Key, enable "Override OpenAI Base URL" with: ${gateway}
|
|
166
242
|
|
|
167
243
|
Already inside this profile's window? Close it before running clone or set-key.
|
|
168
244
|
`;
|
|
@@ -171,12 +247,12 @@ Already inside this profile's window? Close it before running clone or set-key.
|
|
|
171
247
|
return `
|
|
172
248
|
First-time setup for "${projectName}" · copy-paste in order:
|
|
173
249
|
|
|
174
|
-
cursor-keys clone ${projectName} # inherit sign-in, settings, model config from main Cursor
|
|
250
|
+
${gatewayStep} cursor-keys clone ${projectName} # inherit sign-in, settings, model config from main Cursor
|
|
175
251
|
cursor-keys open ${projectName} # open once so Cursor creates its state
|
|
176
252
|
cursor-keys doctor ${projectName} # verify the profile
|
|
177
253
|
|
|
178
254
|
Then in that window: Cursor Settings → Models → API keys, paste the key under
|
|
179
|
-
OpenAI API Key, enable "Override OpenAI Base URL" with: ${
|
|
255
|
+
OpenAI API Key, enable "Override OpenAI Base URL" with: ${gateway}
|
|
180
256
|
|
|
181
257
|
Already inside this profile's window? Close it before running clone.
|
|
182
258
|
`;
|
|
@@ -193,9 +269,11 @@ export function createProfile({ profileDir, projectName, color, cloneFrom }) {
|
|
|
193
269
|
fs.mkdirSync(profileDir, { recursive: true });
|
|
194
270
|
writeProfileSettings(profileDir, projectName, color);
|
|
195
271
|
symlinkSharedUserFiles(profileDir);
|
|
272
|
+
let scrubbed = 0;
|
|
196
273
|
if (cloneFrom) {
|
|
197
|
-
cloneStateDb(cloneFrom, profileDir);
|
|
274
|
+
scrubbed = cloneStateDb(cloneFrom, profileDir);
|
|
198
275
|
}
|
|
276
|
+
return { scrubbed };
|
|
199
277
|
}
|
|
200
278
|
|
|
201
279
|
export function profileIntegrityChecks(profileDir) {
|
package/scripts/postinstall.mjs
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// Postinstall: one-time hint + best-effort companion extension install.
|
|
2
|
-
// Never breaks the install: the whole body is guarded and exits 0.
|
|
3
|
-
// Quiet installs: CURSOR_KEYS_NO_POSTINSTALL=1
|
|
4
|
-
import fs from "node:fs";
|
|
5
|
-
import os from "node:os";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
|
|
9
|
-
try {
|
|
10
|
-
if (process.env.CURSOR_KEYS_NO_POSTINSTALL) process.exit(0);
|
|
11
|
-
const yellow = "\x1b[33m";
|
|
12
|
-
const dim = "\x1b[2m";
|
|
13
|
-
const reset = "\x1b[0m";
|
|
14
|
-
|
|
15
|
-
const pkgRoot = path.join(
|
|
16
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
17
|
-
"..",
|
|
18
|
-
);
|
|
19
|
-
const extSrc = path.join(pkgRoot, "extension");
|
|
20
|
-
const extPkgPath = path.join(extSrc, "package.json");
|
|
21
|
-
const cursorDir = path.join(os.homedir(), ".cursor");
|
|
22
|
-
|
|
23
|
-
// Auto-link the companion extension on global installs when Cursor exists.
|
|
24
|
-
let extLine = "";
|
|
25
|
-
const isGlobal = process.env.npm_config_global === "true";
|
|
26
|
-
if (isGlobal && fs.existsSync(cursorDir) && fs.existsSync(extPkgPath)) {
|
|
27
|
-
try {
|
|
28
|
-
const extPkg = JSON.parse(fs.readFileSync(extPkgPath, "utf8"));
|
|
29
|
-
const extDir = path.join(cursorDir, "extensions");
|
|
30
|
-
fs.mkdirSync(extDir, { recursive: true });
|
|
31
|
-
const target = path.join(
|
|
32
|
-
extDir,
|
|
33
|
-
`${extPkg.publisher || "lubu"}.${extPkg.name}-${extPkg.version}`,
|
|
34
|
-
);
|
|
35
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
36
|
-
try {
|
|
37
|
-
fs.symlinkSync(extSrc, target, "dir");
|
|
38
|
-
} catch {
|
|
39
|
-
fs.cpSync(extSrc, target, { recursive: true });
|
|
40
|
-
}
|
|
41
|
-
extLine = `\n Cursor extension linked ${dim}· restart Cursor, then Cmd+Shift+P: "cursor-keys: Open Project..."${reset}`;
|
|
42
|
-
} catch {
|
|
43
|
-
extLine = `\n cursor-keys install-extension link the Cursor extension (status bar + warnings)`;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
console.log(`
|
|
48
|
-
${yellow}cursor-keys installed${reset}
|
|
49
|
-
|
|
50
|
-
cursor-keys --help commands
|
|
51
|
-
cursor-keys aliases --install aliases, 'ck' shortcut, tab completion
|
|
52
|
-
cursor-keys wrap --install plain 'cursor <path>' picks the profile
|
|
53
|
-
${extLine}
|
|
54
|
-
${dim}Quiet installs: CURSOR_KEYS_NO_POSTINSTALL=1${reset}
|
|
55
|
-
`);
|
|
56
|
-
} catch {
|
|
57
|
-
/* postinstall must never break install */
|
|
58
|
-
}
|