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 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
 
@@ -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.0",
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": {
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
- return scrubClonedSecrets(dest);
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
  /**