cursor-keys 1.3.0 → 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 +3 -2
- package/bin/cursor-keys.mjs +3 -0
- package/package.json +1 -1
- package/src/commands.mjs +59 -1
- package/src/profile.mjs +41 -1
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ One window, one profile, one key. Each project gets an isolated `--user-data-dir
|
|
|
103
103
|
|
|
104
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.
|
|
105
105
|
|
|
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. The sign-in session itself is the one thing that does carry over, see [Security](#security). `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.
|
|
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.
|
|
107
107
|
|
|
108
108
|
## "Which key am I on?" in three layers
|
|
109
109
|
|
|
@@ -118,6 +118,7 @@ Settings flow one way, main to profiles: `sync` re-copies your main `settings.js
|
|
|
118
118
|
| `init [--root DIR] [--all]` | Discover git repos and register them |
|
|
119
119
|
| `add <name> [--path] [--color] [--clone main]` | Create a project profile |
|
|
120
120
|
| `clone <name> [--from main]` | Copy sign-in + settings + model config from a donor profile |
|
|
121
|
+
| `prune [name]` | Delete AI chat history from profile DBs, reclaim disk space |
|
|
121
122
|
| `set-key <name>` | Inject the project API key (hidden prompt or stdin, encrypted) |
|
|
122
123
|
| `color <name> [#hex]` | Show or set the project title bar color |
|
|
123
124
|
| `open [name]` | Launch Cursor with the profile (auto-detect from cwd) |
|
|
@@ -163,7 +164,7 @@ Full threat model in [SECURITY.md](SECURITY.md). The short version:
|
|
|
163
164
|
|
|
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.
|
|
165
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.
|
|
166
|
-
- `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.
|
|
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).
|
|
167
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`.
|
|
168
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).
|
|
169
170
|
|
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
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":
|
|
@@ -849,16 +852,71 @@ async function cmdClone(args) {
|
|
|
849
852
|
}
|
|
850
853
|
|
|
851
854
|
const scrubbed = cloneStateDb(donorRoot, project.profileDir);
|
|
855
|
+
const dbBytes = fs.statSync(profileStateDb(project.profileDir)).size;
|
|
852
856
|
console.log(ok(`Cloned state from ${from} into ${name}`));
|
|
853
857
|
console.log(
|
|
854
858
|
`${dim}Scrubbed ${scrubbed} donor secret row(s) from the copy (API keys, MCP OAuth, extension secrets); sign-in retained.${reset}`,
|
|
855
859
|
);
|
|
856
860
|
console.log(
|
|
857
|
-
`${dim}Settings and model config carried over. Now set the project key:${reset}`,
|
|
861
|
+
`${dim}AI chat/agent history pruned, state DB is ${formatBytes(dbBytes)}. Settings and model config carried over. Now set the project key:${reset}`,
|
|
858
862
|
);
|
|
859
863
|
console.log(` cursor-keys set-key ${name}`);
|
|
860
864
|
}
|
|
861
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
|
+
|
|
862
920
|
async function cmdInstallExtension() {
|
|
863
921
|
const repoRoot = path.resolve(
|
|
864
922
|
path.dirname(fileURLToPath(import.meta.url)),
|
package/src/profile.mjs
CHANGED
|
@@ -88,6 +88,15 @@ export function symlinkSharedUserFiles(profileDir) {
|
|
|
88
88
|
*/
|
|
89
89
|
export const CLONE_SCRUB_LIKE_PATTERNS = ["secret://%", "mcpOAuth.%"];
|
|
90
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
|
+
|
|
91
100
|
/**
|
|
92
101
|
* @param {string} donorProfileDir - main Cursor or another profile root (parent of User)
|
|
93
102
|
* @param {string} targetProfileDir
|
|
@@ -136,7 +145,38 @@ export function cloneStateDb(donorProfileDir, targetProfileDir) {
|
|
|
136
145
|
stdio: "ignore",
|
|
137
146
|
});
|
|
138
147
|
|
|
139
|
-
|
|
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 };
|
|
140
180
|
}
|
|
141
181
|
|
|
142
182
|
/**
|