cursor-keys 1.2.2 → 1.2.3

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
@@ -104,14 +106,14 @@ Settings flow one way, main to profiles: `sync` re-copies your main `settings.js
104
106
  | `wrap [--install]` | zsh function so plain `cursor <path>` auto-picks the profile |
105
107
  | `aliases [--install]` | Per-project shell aliases (also `ck` shortcut + tab completion) |
106
108
  | `completion` | Print zsh tab completion (auto-sourced by `aliases --install`) |
107
- | `install-extension` | Link the Cursor extension into `~/.cursor/extensions` |
109
+ | `install-extension` | Link the Cursor extension (auto-linked on global npm install) |
108
110
  | `rm <name> [--delete]` | Unregister (optionally delete the profile) |
109
111
 
110
112
  `cursor-keys ls` prints registered project names one per line for scripts and tab completion; it is hidden from `--help`.
111
113
 
112
114
  ## Config
113
115
 
114
- `~/.config/cursor-keys/config.json`:
116
+ `~/.config/cursor-keys/config.json` (writes are atomic and merge-on-save, so overlapping runs never clobber registrations):
115
117
 
116
118
  ```json
117
119
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-keys",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
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
@@ -16,7 +16,7 @@ import {
16
16
  loadConfig,
17
17
  pickColorForName,
18
18
  pickUnusedColor,
19
- saveConfig,
19
+ updateConfig,
20
20
  profileStateDb,
21
21
  } from "./config.mjs";
22
22
  import {
@@ -238,8 +238,9 @@ async function cmdConfig(args) {
238
238
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
239
239
  throw new Error(`baseUrl must start with http(s)://: ${value}`);
240
240
  }
241
- config.baseUrl = value;
242
- saveConfig(config);
241
+ updateConfig((c) => {
242
+ c.baseUrl = value;
243
+ });
243
244
  console.log(ok(`baseUrl set: ${value}`));
244
245
  console.log(`${dim}doctor verifies this per profile.${reset}`);
245
246
  }
@@ -304,20 +305,20 @@ async function cmdInit(args) {
304
305
  }
305
306
  }
306
307
 
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;
308
+ updateConfig((config) => {
309
+ for (const repo of selected) {
310
+ const name = path.basename(repo);
311
+ if (config.projects[name]) {
312
+ console.log(warn(`Skip ${name} (already registered)`));
313
+ continue;
314
+ }
315
+ config.projects[name] = {
316
+ path: collapseHome(repo),
317
+ color: pickColorForName(name),
318
+ };
319
+ console.log(ok(`Registered ${name}`));
313
320
  }
314
- config.projects[name] = {
315
- path: collapseHome(repo),
316
- color: pickColorForName(name),
317
- };
318
- console.log(ok(`Registered ${name}`));
319
- }
320
- saveConfig(config);
321
+ });
321
322
  console.log(
322
323
  `\nNext: cursor-keys add <name> --clone main (creates profile + copies main Cursor settings)`,
323
324
  );
@@ -360,10 +361,11 @@ async function cmdAdd(args) {
360
361
  flags.color ||
361
362
  config.projects[name]?.color ||
362
363
  pickUnusedColor(name, usedColors);
363
- config.projects[name] = { path: collapseHome(repoPath), color };
364
- saveConfig(config);
364
+ const saved = updateConfig((c) => {
365
+ c.projects[name] = { path: collapseHome(repoPath), color };
366
+ });
365
367
 
366
- const project = getProject(config, name);
368
+ const project = getProject(saved, name);
367
369
  if (!project) throw new Error("Failed to resolve project");
368
370
 
369
371
  let cloneFrom = null;
@@ -371,7 +373,7 @@ async function cmdAdd(args) {
371
373
  if (flags.clone === "main") {
372
374
  cloneFrom = mainCursorProfileRoot();
373
375
  } else {
374
- const donor = getProject(config, flags.clone);
376
+ const donor = getProject(saved, flags.clone);
375
377
  if (!donor) throw new Error(`Unknown clone donor: ${flags.clone}`);
376
378
  cloneFrom = donor.profileDir;
377
379
  }
@@ -601,10 +603,10 @@ async function cmdAliases(args) {
601
603
  }
602
604
  fs.writeFileSync(zshrc, content, "utf8");
603
605
  console.log(ok(`Updated ${zshrc}`));
604
- if (!content.includes("compinit")) {
606
+ if (!compinitLikelyConfigured(content)) {
605
607
  console.log(
606
608
  warn(
607
- "Note: `compinit` not found in ~/.zshrc. Tab completion needs it; add this line: autoload -Uz compinit && compinit",
609
+ "Note: tab completion needs compinit. If `cursor-keys open <TAB>` does nothing after sourcing ~/.zshrc, add: autoload -Uz compinit && compinit",
608
610
  ),
609
611
  );
610
612
  }
@@ -614,6 +616,22 @@ function sanitizeAlias(name) {
614
616
  return name.replace(/[^a-zA-Z0-9_-]/g, "_");
615
617
  }
616
618
 
619
+ const COMPINIT_HINT_RE = /compinit|oh-my-zsh|prezto|zinit|antigen|zplug|zimfw/;
620
+
621
+ /**
622
+ * Best-effort check that the user's zsh setup loads compinit. Frameworks
623
+ * (oh-my-zsh, prezto, zinit, ...) run it from sourced files rather than
624
+ * ~/.zshrc itself, and Warp injects it via shell integration, so a plain
625
+ * text grep of ~/.zshrc alone gives false positives. Exported for tests.
626
+ */
627
+ export function compinitLikelyConfigured(
628
+ zshrcContent,
629
+ termProgram = process.env.TERM_PROGRAM,
630
+ ) {
631
+ if (COMPINIT_HINT_RE.test(zshrcContent)) return true;
632
+ return termProgram === "WarpTerminal";
633
+ }
634
+
617
635
  function shellEscapeName(name) {
618
636
  return name.replace(/'/g, `'\\''`);
619
637
  }
@@ -680,10 +698,10 @@ function installZshrcBlock(startMarker, endMarker, block) {
680
698
  content = content.trimEnd() + "\n\n" + block + "\n";
681
699
  }
682
700
  fs.writeFileSync(zshrc, content, "utf8");
683
- if (!content.includes("compinit")) {
701
+ if (!compinitLikelyConfigured(content)) {
684
702
  console.log(
685
703
  warn(
686
- "Note: `compinit` not found in ~/.zshrc. Tab completion needs it; add this line: autoload -Uz compinit && compinit",
704
+ "Note: tab completion needs compinit. If `cursor-keys open <TAB>` does nothing after sourcing ~/.zshrc, add: autoload -Uz compinit && compinit",
687
705
  ),
688
706
  );
689
707
  }
@@ -807,12 +825,17 @@ async function cmdInstallExtension() {
807
825
  "..",
808
826
  );
809
827
  const src = path.join(repoRoot, "extension");
810
- if (!fs.existsSync(path.join(src, "package.json"))) {
828
+ const extPkgPath = path.join(src, "package.json");
829
+ if (!fs.existsSync(extPkgPath)) {
811
830
  throw new Error(`Extension source missing: ${src}`);
812
831
  }
832
+ const extPkg = JSON.parse(fs.readFileSync(extPkgPath, "utf8"));
813
833
  const extDir = path.join(os.homedir(), ".cursor", "extensions");
814
834
  fs.mkdirSync(extDir, { recursive: true });
815
- const target = path.join(extDir, "lubu.cursor-keys-1.0.0");
835
+ const target = path.join(
836
+ extDir,
837
+ `${extPkg.publisher || "lubu"}.${extPkg.name}-${extPkg.version}`,
838
+ );
816
839
  fs.rmSync(target, { recursive: true, force: true });
817
840
  fs.symlinkSync(src, target, "dir");
818
841
  console.log(ok(`Extension linked: ${target}`));
@@ -900,8 +923,9 @@ async function cmdRm(args) {
900
923
  if (!config.projects[name]) throw new Error(`Unknown project: ${name}`);
901
924
 
902
925
  const p = getProject(config, name);
903
- delete config.projects[name];
904
- saveConfig(config);
926
+ updateConfig((c) => {
927
+ delete c.projects[name];
928
+ });
905
929
  console.log(ok(`Removed ${name} from config`));
906
930
 
907
931
  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",
@@ -40,25 +49,38 @@ export function defaultConfig() {
40
49
  }
41
50
 
42
51
  export function loadConfig() {
43
- if (!fs.existsSync(CONFIG_FILE)) {
52
+ const file = configFilePath();
53
+ if (!fs.existsSync(file)) {
44
54
  return defaultConfig();
45
55
  }
46
- const raw = fs.readFileSync(CONFIG_FILE, "utf8");
56
+ const raw = fs.readFileSync(file, "utf8");
47
57
  const data = JSON.parse(raw);
48
58
  return validateConfig(data);
49
59
  }
50
60
 
51
61
  export function saveConfig(config) {
52
62
  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
- );
63
+ const file = configFilePath();
64
+ fs.mkdirSync(path.dirname(file), { recursive: true });
65
+ // Atomic write: a crash or concurrent reader never sees a torn file.
66
+ const tmp = `${file}.tmp-${process.pid}`;
67
+ fs.writeFileSync(tmp, JSON.stringify(validated, null, 2) + "\n", "utf8");
68
+ fs.renameSync(tmp, file);
59
69
  return validated;
60
70
  }
61
71
 
72
+ /**
73
+ * Load fresh, mutate, save: the only safe way to change config. Keeps the
74
+ * lost-update window (two concurrent runs dropping each other's projects)
75
+ * down to the few ms inside this function instead of a whole command run.
76
+ * @param {(config: ReturnType<typeof loadConfig>) => void} mutate
77
+ */
78
+ export function updateConfig(mutate) {
79
+ const config = loadConfig();
80
+ mutate(config);
81
+ return saveConfig(config);
82
+ }
83
+
62
84
  /**
63
85
  * @param {unknown} data
64
86
  */