cursor-keys 1.2.2 → 1.2.4

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
@@ -48,6 +48,8 @@ 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
+ On global install, the companion Cursor extension is linked into `~/.cursor/extensions` automatically and a one-time hint prints (opt out of both: `CURSOR_KEYS_NO_POSTINSTALL=1`). Restart Cursor, then `Cmd+Shift+P` → "cursor-keys: Open Project...".
52
+
51
53
  ## Quick start
52
54
 
53
55
  ```bash
@@ -69,8 +71,8 @@ One-time extras for the full experience:
69
71
 
70
72
  ```bash
71
73
  cursor-keys wrap --install # plain `cursor <path>` auto-picks the profile
72
- cursor-keys aliases --install # per-project shell aliases
73
- cursor-keys install-extension # status bar indicator + wrong-window guard
74
+ cursor-keys aliases --install # per-project aliases, `ck` shortcut, zsh tab completion
75
+ cursor-keys install-extension # status bar + wrong-window guard (auto-linked on global npm install)
74
76
  ```
75
77
 
76
78
  ## How it works
@@ -89,29 +91,30 @@ Settings flow one way, main to profiles: `sync` re-copies your main `settings.js
89
91
 
90
92
  ## Commands
91
93
 
92
- | Command | What it does |
93
- | ---------------------------------------------- | ------------------------------------------------------------ |
94
- | `init [--root DIR] [--all]` | Discover git repos and register them |
95
- | `add <name> [--path] [--color] [--clone main]` | Create a project profile |
96
- | `clone <name> [--from main]` | Copy sign-in + settings + model config from a donor profile |
97
- | `set-key <name> [--key sk-...]` | Inject the project API key (encrypted, verified) |
98
- | `open [name]` | Launch Cursor with the profile (auto-detect from cwd) |
99
- | `status` | Projects, colors, running windows, key configured |
100
- | `current` | Mapping for the current directory |
101
- | `config [get <key> · baseUrl [URL]]` | Show summary, print one value, or set the gateway base URL |
102
- | `sync [name]` | Re-copy main Cursor settings into profiles |
103
- | `doctor [name]` | Health checks: key row, base URL, subagent overrides |
104
- | `wrap [--install]` | zsh function so plain `cursor <path>` auto-picks the profile |
94
+ | Command | What it does |
95
+ | ---------------------------------------------- | --------------------------------------------------------------- |
96
+ | `init [--root DIR] [--all]` | Discover git repos and register them |
97
+ | `add <name> [--path] [--color] [--clone main]` | Create a project profile |
98
+ | `clone <name> [--from main]` | Copy sign-in + settings + model config from a donor profile |
99
+ | `set-key <name> [--key sk-...]` | Inject the project API key (encrypted, verified) |
100
+ | `color <name> [#hex]` | Show or set the project title bar color |
101
+ | `open [name]` | Launch Cursor with the profile (auto-detect from cwd) |
102
+ | `status` | Projects, colors, running windows, key configured |
103
+ | `current` | Mapping for the current directory |
104
+ | `config [get <key> · baseUrl [URL]]` | Show summary, print one value, or set the gateway base URL |
105
+ | `sync [name]` | Re-copy main Cursor settings into profiles |
106
+ | `doctor [name]` | Health checks: key row, base URL, subagent overrides |
107
+ | `wrap [--install]` | zsh function so plain `cursor <path>` auto-picks the profile |
105
108
  | `aliases [--install]` | Per-project shell aliases (also `ck` shortcut + tab completion) |
106
- | `completion` | Print zsh tab completion (auto-sourced by `aliases --install`) |
107
- | `install-extension` | Link the Cursor extension into `~/.cursor/extensions` |
108
- | `rm <name> [--delete]` | Unregister (optionally delete the profile) |
109
+ | `completion` | Print zsh tab completion (auto-sourced by `aliases --install`) |
110
+ | `install-extension` | Link the Cursor extension (auto-linked on global npm install) |
111
+ | `rm <name> [--delete]` | Unregister (optionally delete the profile) |
109
112
 
110
113
  `cursor-keys ls` prints registered project names one per line for scripts and tab completion; it is hidden from `--help`.
111
114
 
112
115
  ## Config
113
116
 
114
- `~/.config/cursor-keys/config.json`:
117
+ `~/.config/cursor-keys/config.json` (writes are atomic and merge-on-save, so overlapping runs never clobber registrations):
115
118
 
116
119
  ```json
117
120
  {
@@ -27,6 +27,9 @@ function printHelp() {
27
27
  console.log(
28
28
  " set-key <name> Inject the project API key (encrypted)",
29
29
  );
30
+ console.log(
31
+ " color <name> [#hex] Show or set the project title bar color",
32
+ );
30
33
  console.log(
31
34
  " open [name] Open Cursor with this project's profile",
32
35
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-keys",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
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": {
@@ -1,20 +1,56 @@
1
- // Postinstall hint: prints once on global install, never breaks the install.
1
+ // Postinstall: one-time hint + best-effort companion extension install.
2
+ // Never breaks the install: the whole body is guarded and exits 0.
2
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
+
3
9
  try {
4
10
  if (process.env.CURSOR_KEYS_NO_POSTINSTALL) process.exit(0);
5
11
  const yellow = "\x1b[33m";
6
12
  const dim = "\x1b[2m";
7
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
+
8
47
  console.log(`
9
48
  ${yellow}cursor-keys installed${reset}
10
49
 
11
50
  cursor-keys --help commands
12
51
  cursor-keys aliases --install aliases, 'ck' shortcut, tab completion
13
52
  cursor-keys wrap --install plain 'cursor <path>' picks the profile
14
-
15
- Inside Cursor: Cmd+Shift+P then "cursor-keys: Open Project..." after
16
- 'cursor-keys install-extension' (or install 'cursor-keys' from Open VSX).
17
-
53
+ ${extLine}
18
54
  ${dim}Quiet installs: CURSOR_KEYS_NO_POSTINSTALL=1${reset}
19
55
  `);
20
56
  } catch {
package/src/commands.mjs CHANGED
@@ -13,10 +13,11 @@ import {
13
13
  findProjectByPath,
14
14
  getConfigValue,
15
15
  getProject,
16
+ isHexColor,
16
17
  loadConfig,
17
18
  pickColorForName,
18
19
  pickUnusedColor,
19
- saveConfig,
20
+ updateConfig,
20
21
  profileStateDb,
21
22
  } from "./config.mjs";
22
23
  import {
@@ -89,6 +90,8 @@ export async function runCommand(argv) {
89
90
  return cmdInstallExtension(rest);
90
91
  case "set-key":
91
92
  return cmdSetKey(rest);
93
+ case "color":
94
+ return cmdColor(rest);
92
95
  case "clone":
93
96
  return cmdClone(rest);
94
97
  case "rm":
@@ -238,8 +241,9 @@ async function cmdConfig(args) {
238
241
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
239
242
  throw new Error(`baseUrl must start with http(s)://: ${value}`);
240
243
  }
241
- config.baseUrl = value;
242
- saveConfig(config);
244
+ updateConfig((c) => {
245
+ c.baseUrl = value;
246
+ });
243
247
  console.log(ok(`baseUrl set: ${value}`));
244
248
  console.log(`${dim}doctor verifies this per profile.${reset}`);
245
249
  }
@@ -304,20 +308,20 @@ async function cmdInit(args) {
304
308
  }
305
309
  }
306
310
 
307
- const config = loadConfig();
308
- for (const repo of selected) {
309
- const name = path.basename(repo);
310
- if (config.projects[name]) {
311
- console.log(warn(`Skip ${name} (already registered)`));
312
- continue;
311
+ updateConfig((config) => {
312
+ for (const repo of selected) {
313
+ const name = path.basename(repo);
314
+ if (config.projects[name]) {
315
+ console.log(warn(`Skip ${name} (already registered)`));
316
+ continue;
317
+ }
318
+ config.projects[name] = {
319
+ path: collapseHome(repo),
320
+ color: pickColorForName(name),
321
+ };
322
+ console.log(ok(`Registered ${name}`));
313
323
  }
314
- config.projects[name] = {
315
- path: collapseHome(repo),
316
- color: pickColorForName(name),
317
- };
318
- console.log(ok(`Registered ${name}`));
319
- }
320
- saveConfig(config);
324
+ });
321
325
  console.log(
322
326
  `\nNext: cursor-keys add <name> --clone main (creates profile + copies main Cursor settings)`,
323
327
  );
@@ -360,10 +364,11 @@ async function cmdAdd(args) {
360
364
  flags.color ||
361
365
  config.projects[name]?.color ||
362
366
  pickUnusedColor(name, usedColors);
363
- config.projects[name] = { path: collapseHome(repoPath), color };
364
- saveConfig(config);
367
+ const saved = updateConfig((c) => {
368
+ c.projects[name] = { path: collapseHome(repoPath), color };
369
+ });
365
370
 
366
- const project = getProject(config, name);
371
+ const project = getProject(saved, name);
367
372
  if (!project) throw new Error("Failed to resolve project");
368
373
 
369
374
  let cloneFrom = null;
@@ -371,7 +376,7 @@ async function cmdAdd(args) {
371
376
  if (flags.clone === "main") {
372
377
  cloneFrom = mainCursorProfileRoot();
373
378
  } else {
374
- const donor = getProject(config, flags.clone);
379
+ const donor = getProject(saved, flags.clone);
375
380
  if (!donor) throw new Error(`Unknown clone donor: ${flags.clone}`);
376
381
  cloneFrom = donor.profileDir;
377
382
  }
@@ -601,10 +606,10 @@ async function cmdAliases(args) {
601
606
  }
602
607
  fs.writeFileSync(zshrc, content, "utf8");
603
608
  console.log(ok(`Updated ${zshrc}`));
604
- if (!content.includes("compinit")) {
609
+ if (!compinitLikelyConfigured(content)) {
605
610
  console.log(
606
611
  warn(
607
- "Note: `compinit` not found in ~/.zshrc. Tab completion needs it; add this line: autoload -Uz compinit && compinit",
612
+ "Note: tab completion needs compinit. If `cursor-keys open <TAB>` does nothing after sourcing ~/.zshrc, add: autoload -Uz compinit && compinit",
608
613
  ),
609
614
  );
610
615
  }
@@ -614,12 +619,28 @@ function sanitizeAlias(name) {
614
619
  return name.replace(/[^a-zA-Z0-9_-]/g, "_");
615
620
  }
616
621
 
622
+ const COMPINIT_HINT_RE = /compinit|oh-my-zsh|prezto|zinit|antigen|zplug|zimfw/;
623
+
624
+ /**
625
+ * Best-effort check that the user's zsh setup loads compinit. Frameworks
626
+ * (oh-my-zsh, prezto, zinit, ...) run it from sourced files rather than
627
+ * ~/.zshrc itself, and Warp injects it via shell integration, so a plain
628
+ * text grep of ~/.zshrc alone gives false positives. Exported for tests.
629
+ */
630
+ export function compinitLikelyConfigured(
631
+ zshrcContent,
632
+ termProgram = process.env.TERM_PROGRAM,
633
+ ) {
634
+ if (COMPINIT_HINT_RE.test(zshrcContent)) return true;
635
+ return termProgram === "WarpTerminal";
636
+ }
637
+
617
638
  function shellEscapeName(name) {
618
639
  return name.replace(/'/g, `'\\''`);
619
640
  }
620
641
 
621
642
  const COMPLETION_COMMANDS =
622
- "init add clone set-key open status current config sync doctor aliases wrap install-extension completion ls rm";
643
+ "init add clone set-key color open status current config sync doctor aliases wrap install-extension completion ls rm";
623
644
 
624
645
  /**
625
646
  * Pure builder for the zsh completion script. Sourced via
@@ -630,7 +651,7 @@ export function completionScript() {
630
651
  return `#compdef cursor-keys ckeys ck
631
652
  _cursor_keys() {
632
653
  local -a projects
633
- if (( CURRENT == 3 )) && [[ \${words[2]} == (open|clone|set-key|doctor|sync|rm) ]]; then
654
+ if (( CURRENT == 3 )) && [[ \${words[2]} == (open|clone|set-key|color|doctor|sync|rm) ]]; then
634
655
  projects=(\${(f)"$(cursor-keys ls 2>/dev/null)"})
635
656
  _describe 'project' projects
636
657
  else
@@ -680,10 +701,10 @@ function installZshrcBlock(startMarker, endMarker, block) {
680
701
  content = content.trimEnd() + "\n\n" + block + "\n";
681
702
  }
682
703
  fs.writeFileSync(zshrc, content, "utf8");
683
- if (!content.includes("compinit")) {
704
+ if (!compinitLikelyConfigured(content)) {
684
705
  console.log(
685
706
  warn(
686
- "Note: `compinit` not found in ~/.zshrc. Tab completion needs it; add this line: autoload -Uz compinit && compinit",
707
+ "Note: tab completion needs compinit. If `cursor-keys open <TAB>` does nothing after sourcing ~/.zshrc, add: autoload -Uz compinit && compinit",
687
708
  ),
688
709
  );
689
710
  }
@@ -807,12 +828,17 @@ async function cmdInstallExtension() {
807
828
  "..",
808
829
  );
809
830
  const src = path.join(repoRoot, "extension");
810
- if (!fs.existsSync(path.join(src, "package.json"))) {
831
+ const extPkgPath = path.join(src, "package.json");
832
+ if (!fs.existsSync(extPkgPath)) {
811
833
  throw new Error(`Extension source missing: ${src}`);
812
834
  }
835
+ const extPkg = JSON.parse(fs.readFileSync(extPkgPath, "utf8"));
813
836
  const extDir = path.join(os.homedir(), ".cursor", "extensions");
814
837
  fs.mkdirSync(extDir, { recursive: true });
815
- const target = path.join(extDir, "lubu.cursor-keys-1.0.0");
838
+ const target = path.join(
839
+ extDir,
840
+ `${extPkg.publisher || "lubu"}.${extPkg.name}-${extPkg.version}`,
841
+ );
816
842
  fs.rmSync(target, { recursive: true, force: true });
817
843
  fs.symlinkSync(src, target, "dir");
818
844
  console.log(ok(`Extension linked: ${target}`));
@@ -820,6 +846,39 @@ async function cmdInstallExtension() {
820
846
  console.log(`Look for ${bold}$(key) <project>${reset} in the status bar.`);
821
847
  }
822
848
 
849
+ async function cmdColor(args) {
850
+ const { flags, positional } = parseFlags(args);
851
+ if (flags.help) {
852
+ console.log("Usage: cursor-keys color <name> [#RRGGBB]");
853
+ console.log("Show or set the project's title bar color.");
854
+ return;
855
+ }
856
+ const name = positional[0];
857
+ if (!name) throw new Error("Usage: cursor-keys color <name> [#RRGGBB]");
858
+ const config = loadConfig();
859
+ const project = getProject(config, name);
860
+ if (!project) throw new Error(`Unknown project: ${name}. Run cursor-keys init or add.`);
861
+
862
+ const hex = positional[1];
863
+ if (!hex) {
864
+ console.log(`${blockSwatch(project.color)} ${project.color}`);
865
+ return;
866
+ }
867
+ if (!isHexColor(hex)) {
868
+ throw new Error(`Invalid color: ${hex} (expected #RRGGBB, e.g. #852e00)`);
869
+ }
870
+
871
+ updateConfig((c) => {
872
+ if (!c.projects[name]) throw new Error(`Unknown project: ${name}`);
873
+ c.projects[name].color = hex;
874
+ });
875
+ if (fs.existsSync(project.profileDir)) {
876
+ writeProfileSettings(project.profileDir, name, hex);
877
+ }
878
+ console.log(ok(`Color for ${name}: `) + `${blockSwatch(hex)} ${hex}`);
879
+ console.log(`${dim}Open Cursor windows reload settings automatically.${reset}`);
880
+ }
881
+
823
882
  function promptHidden(question) {
824
883
  return new Promise((resolve) => {
825
884
  const rl = readline.createInterface({
@@ -900,8 +959,9 @@ async function cmdRm(args) {
900
959
  if (!config.projects[name]) throw new Error(`Unknown project: ${name}`);
901
960
 
902
961
  const p = getProject(config, name);
903
- delete config.projects[name];
904
- saveConfig(config);
962
+ updateConfig((c) => {
963
+ delete c.projects[name];
964
+ });
905
965
  console.log(ok(`Removed ${name} from config`));
906
966
 
907
967
  if (flags.delete && p) {
package/src/config.mjs CHANGED
@@ -8,6 +8,15 @@ export const DEFAULT_CONFIG_DIR = path.join(
8
8
  "cursor-keys",
9
9
  );
10
10
  export const CONFIG_FILE = path.join(DEFAULT_CONFIG_DIR, "config.json");
11
+
12
+ /**
13
+ * Resolved at call time so tests and scripts can point cursor-keys at a
14
+ * scratch config via CURSOR_KEYS_CONFIG_FILE.
15
+ */
16
+ function configFilePath() {
17
+ const override = process.env.CURSOR_KEYS_CONFIG_FILE;
18
+ return override ? expandHome(override) : CONFIG_FILE;
19
+ }
11
20
  export const DEFAULT_PROFILES_ROOT = path.join(
12
21
  os.homedir(),
13
22
  ".cursor-profiles",
@@ -15,6 +24,11 @@ export const DEFAULT_PROFILES_ROOT = path.join(
15
24
 
16
25
  const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
17
26
 
27
+ /** @param {unknown} s */
28
+ export function isHexColor(s) {
29
+ return typeof s === "string" && HEX_COLOR.test(s);
30
+ }
31
+
18
32
  /** @param {string} p */
19
33
  export function expandHome(p) {
20
34
  if (!p || typeof p !== "string") return p;
@@ -40,25 +54,38 @@ export function defaultConfig() {
40
54
  }
41
55
 
42
56
  export function loadConfig() {
43
- if (!fs.existsSync(CONFIG_FILE)) {
57
+ const file = configFilePath();
58
+ if (!fs.existsSync(file)) {
44
59
  return defaultConfig();
45
60
  }
46
- const raw = fs.readFileSync(CONFIG_FILE, "utf8");
61
+ const raw = fs.readFileSync(file, "utf8");
47
62
  const data = JSON.parse(raw);
48
63
  return validateConfig(data);
49
64
  }
50
65
 
51
66
  export function saveConfig(config) {
52
67
  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
- );
68
+ const file = configFilePath();
69
+ fs.mkdirSync(path.dirname(file), { recursive: true });
70
+ // Atomic write: a crash or concurrent reader never sees a torn file.
71
+ const tmp = `${file}.tmp-${process.pid}`;
72
+ fs.writeFileSync(tmp, JSON.stringify(validated, null, 2) + "\n", "utf8");
73
+ fs.renameSync(tmp, file);
59
74
  return validated;
60
75
  }
61
76
 
77
+ /**
78
+ * Load fresh, mutate, save: the only safe way to change config. Keeps the
79
+ * lost-update window (two concurrent runs dropping each other's projects)
80
+ * down to the few ms inside this function instead of a whole command run.
81
+ * @param {(config: ReturnType<typeof loadConfig>) => void} mutate
82
+ */
83
+ export function updateConfig(mutate) {
84
+ const config = loadConfig();
85
+ mutate(config);
86
+ return saveConfig(config);
87
+ }
88
+
62
89
  /**
63
90
  * @param {unknown} data
64
91
  */