cursor-keys 1.2.1 → 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
@@ -102,13 +104,16 @@ Settings flow one way, main to profiles: `sync` re-copies your main `settings.js
102
104
  | `sync [name]` | Re-copy main Cursor settings into profiles |
103
105
  | `doctor [name]` | Health checks: key row, base URL, subagent overrides |
104
106
  | `wrap [--install]` | zsh function so plain `cursor <path>` auto-picks the profile |
105
- | `aliases [--install]` | Per-project shell aliases |
106
- | `install-extension` | Link the Cursor extension into `~/.cursor/extensions` |
107
+ | `aliases [--install]` | Per-project shell aliases (also `ck` shortcut + tab completion) |
108
+ | `completion` | Print zsh tab completion (auto-sourced by `aliases --install`) |
109
+ | `install-extension` | Link the Cursor extension (auto-linked on global npm install) |
107
110
  | `rm <name> [--delete]` | Unregister (optionally delete the profile) |
108
111
 
112
+ `cursor-keys ls` prints registered project names one per line for scripts and tab completion; it is hidden from `--help`.
113
+
109
114
  ## Config
110
115
 
111
- `~/.config/cursor-keys/config.json`:
116
+ `~/.config/cursor-keys/config.json` (writes are atomic and merge-on-save, so overlapping runs never clobber registrations):
112
117
 
113
118
  ```json
114
119
  {
@@ -9,12 +9,13 @@ const BANNER = `
9
9
  ${yellow}██║ ██║ ██║██████╔╝███████╗██║ ██║██████╔╝ █████╔╝ █████╗ ╚████╔╝ ███████╗
10
10
  ${yellow}██║ ██║ ██║██╔══██╗╚════██║██║ ██║██╔══██╗ ██╔═██╗ ██╔══╝ ╚██╔╝ ╚════██║
11
11
  ${yellow}╚██████╗╚██████╔╝██║ ██║███████║╚██████╔╝██║ ██║ ██║ ██╗███████╗ ██║ ███████║
12
- ${yellow} ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚══════╝${reset}${dim} per-project Cursor API keys
12
+ ${yellow} ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚══════╝${reset}
13
+ ${dim}per-project Cursor API keys${reset}
13
14
  `;
14
15
 
15
16
  function printHelp() {
16
17
  console.log(BANNER);
17
- console.log(`${bold}Usage:${reset} cursor-keys <command> [options]\n`);
18
+ console.log(`${bold}Usage:${reset} cursor-keys (or ck, ckeys) <command> [options]\n`);
18
19
  console.log(`${bold}Commands:${reset}`);
19
20
  console.log(
20
21
  " init [--root DIR] [--all] Scan for git repos and register projects",
@@ -42,7 +43,10 @@ function printHelp() {
42
43
  console.log(
43
44
  " doctor [name] Verify setup for one or all projects",
44
45
  );
45
- console.log(" aliases [--install] Print or install shell aliases");
46
+ console.log(" aliases [--install] Shell aliases (incl. 'ck' + tab completion)");
47
+ console.log(
48
+ " completion Print zsh tab completion (auto-sourced by aliases)",
49
+ );
46
50
  console.log(
47
51
  " wrap [--install] Make plain `cursor <path>` auto-pick profile",
48
52
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-keys",
3
- "version": "1.2.1",
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": {
@@ -13,7 +13,8 @@
13
13
  "files": [
14
14
  "bin",
15
15
  "src",
16
- "extension"
16
+ "extension",
17
+ "scripts"
17
18
  ],
18
19
  "keywords": [
19
20
  "cursor",
@@ -31,6 +32,7 @@
31
32
  },
32
33
  "scripts": {
33
34
  "test": "node --test",
35
+ "postinstall": "node scripts/postinstall.mjs",
34
36
  "prepublishOnly": "npm test"
35
37
  }
36
38
  }
@@ -0,0 +1,58 @@
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
+ }
package/src/ansi.mjs CHANGED
@@ -26,15 +26,25 @@ export function err(text) {
26
26
  return colorize(text, red);
27
27
  }
28
28
 
29
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
30
+
31
+ export function stripAnsi(s) {
32
+ return String(s).replace(ANSI_RE, "");
33
+ }
34
+
29
35
  export function padEnd(str, len) {
30
36
  const s = String(str);
31
- if (s.length >= len) return s.slice(0, len);
32
- return s + " ".repeat(len - s.length);
37
+ const vis = stripAnsi(s).length;
38
+ if (vis >= len) return s;
39
+ return s + " ".repeat(len - vis);
33
40
  }
34
41
 
35
42
  export function printTable(headers, rows) {
36
43
  const widths = headers.map((h, i) =>
37
- Math.max(h.length, ...rows.map((r) => String(r[i] ?? "").length)),
44
+ Math.max(
45
+ h.length,
46
+ ...rows.map((r) => stripAnsi(String(r[i] ?? "")).length),
47
+ ),
38
48
  );
39
49
  const line = widths.map((w) => "-".repeat(w)).join("-+-");
40
50
  console.log(widths.map((w, i) => padEnd(headers[i], w)).join(" | "));
@@ -51,5 +61,5 @@ export function blockSwatch(hex) {
51
61
  const r = parseInt(hex.slice(1, 3), 16);
52
62
  const g = parseInt(hex.slice(3, 5), 16);
53
63
  const b = parseInt(hex.slice(5, 7), 16);
54
- return `${esc(48)};2;${r};${g};${b}m ${reset}`;
64
+ return `\x1b[48;2;${r};${g};${b}m ${reset}`;
55
65
  }
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 {
@@ -93,6 +93,10 @@ export async function runCommand(argv) {
93
93
  return cmdClone(rest);
94
94
  case "rm":
95
95
  return cmdRm(rest);
96
+ case "ls":
97
+ return cmdLs(rest);
98
+ case "completion":
99
+ return cmdCompletion(rest);
96
100
  default:
97
101
  throw new Error(`Unknown command: ${cmd}. Run cursor-keys --help`);
98
102
  }
@@ -234,8 +238,9 @@ async function cmdConfig(args) {
234
238
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
235
239
  throw new Error(`baseUrl must start with http(s)://: ${value}`);
236
240
  }
237
- config.baseUrl = value;
238
- saveConfig(config);
241
+ updateConfig((c) => {
242
+ c.baseUrl = value;
243
+ });
239
244
  console.log(ok(`baseUrl set: ${value}`));
240
245
  console.log(`${dim}doctor verifies this per profile.${reset}`);
241
246
  }
@@ -300,20 +305,20 @@ async function cmdInit(args) {
300
305
  }
301
306
  }
302
307
 
303
- const config = loadConfig();
304
- for (const repo of selected) {
305
- const name = path.basename(repo);
306
- if (config.projects[name]) {
307
- console.log(warn(`Skip ${name} (already registered)`));
308
- 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}`));
309
320
  }
310
- config.projects[name] = {
311
- path: collapseHome(repo),
312
- color: pickColorForName(name),
313
- };
314
- console.log(ok(`Registered ${name}`));
315
- }
316
- saveConfig(config);
321
+ });
317
322
  console.log(
318
323
  `\nNext: cursor-keys add <name> --clone main (creates profile + copies main Cursor settings)`,
319
324
  );
@@ -356,10 +361,11 @@ async function cmdAdd(args) {
356
361
  flags.color ||
357
362
  config.projects[name]?.color ||
358
363
  pickUnusedColor(name, usedColors);
359
- config.projects[name] = { path: collapseHome(repoPath), color };
360
- saveConfig(config);
364
+ const saved = updateConfig((c) => {
365
+ c.projects[name] = { path: collapseHome(repoPath), color };
366
+ });
361
367
 
362
- const project = getProject(config, name);
368
+ const project = getProject(saved, name);
363
369
  if (!project) throw new Error("Failed to resolve project");
364
370
 
365
371
  let cloneFrom = null;
@@ -367,7 +373,7 @@ async function cmdAdd(args) {
367
373
  if (flags.clone === "main") {
368
374
  cloneFrom = mainCursorProfileRoot();
369
375
  } else {
370
- const donor = getProject(config, flags.clone);
376
+ const donor = getProject(saved, flags.clone);
371
377
  if (!donor) throw new Error(`Unknown clone donor: ${flags.clone}`);
372
378
  cloneFrom = donor.profileDir;
373
379
  }
@@ -569,7 +575,13 @@ async function cmdAliases(args) {
569
575
  const lines = names.map(
570
576
  (n) => `alias ${sanitizeAlias(n)}='cursor-keys open ${shellEscapeName(n)}'`,
571
577
  );
572
- const block = [ZSHRC_MARKER_START, ...lines, ZSHRC_MARKER_END].join("\n");
578
+ const block = [
579
+ ZSHRC_MARKER_START,
580
+ "alias ck='cursor-keys'",
581
+ 'eval "$(cursor-keys completion)" # tab completion',
582
+ ...lines,
583
+ ZSHRC_MARKER_END,
584
+ ].join("\n");
573
585
 
574
586
  if (!flags.install) {
575
587
  console.log(block);
@@ -591,16 +603,87 @@ async function cmdAliases(args) {
591
603
  }
592
604
  fs.writeFileSync(zshrc, content, "utf8");
593
605
  console.log(ok(`Updated ${zshrc}`));
606
+ if (!compinitLikelyConfigured(content)) {
607
+ console.log(
608
+ warn(
609
+ "Note: tab completion needs compinit. If `cursor-keys open <TAB>` does nothing after sourcing ~/.zshrc, add: autoload -Uz compinit && compinit",
610
+ ),
611
+ );
612
+ }
594
613
  }
595
614
 
596
615
  function sanitizeAlias(name) {
597
616
  return name.replace(/[^a-zA-Z0-9_-]/g, "_");
598
617
  }
599
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
+
600
635
  function shellEscapeName(name) {
601
636
  return name.replace(/'/g, `'\\''`);
602
637
  }
603
638
 
639
+ const COMPLETION_COMMANDS =
640
+ "init add clone set-key open status current config sync doctor aliases wrap install-extension completion ls rm";
641
+
642
+ /**
643
+ * Pure builder for the zsh completion script. Sourced via
644
+ * `eval "$(cursor-keys completion)"` from the aliases/wrap zshrc blocks,
645
+ * so updates ship with `npm update` and never go stale on disk.
646
+ */
647
+ export function completionScript() {
648
+ return `#compdef cursor-keys ckeys ck
649
+ _cursor_keys() {
650
+ local -a projects
651
+ if (( CURRENT == 3 )) && [[ \${words[2]} == (open|clone|set-key|doctor|sync|rm) ]]; then
652
+ projects=(\${(f)"$(cursor-keys ls 2>/dev/null)"})
653
+ _describe 'project' projects
654
+ else
655
+ _arguments '1:command:(${COMPLETION_COMMANDS})'
656
+ fi
657
+ }
658
+ compdef _cursor_keys cursor-keys ckeys ck
659
+ `;
660
+ }
661
+
662
+ function cmdCompletion(args) {
663
+ const { flags } = parseFlags(args);
664
+ if (flags.help) {
665
+ console.log("Usage: cursor-keys completion");
666
+ console.log("Prints the zsh tab completion script to stdout.");
667
+ console.log("Auto-sourced by `cursor-keys aliases --install`; no separate install needed.");
668
+ return;
669
+ }
670
+ process.stdout.write(completionScript());
671
+ }
672
+
673
+ /**
674
+ * Hidden command: bare project names, one per line, for completion/scripts.
675
+ */
676
+ function cmdLs(args) {
677
+ const { flags } = parseFlags(args);
678
+ if (flags.help) {
679
+ console.log("Usage: cursor-keys ls");
680
+ console.log("Prints registered project names, one per line (for scripts and tab completion).");
681
+ return;
682
+ }
683
+ const config = loadConfig();
684
+ console.log(Object.keys(config.projects).sort().join("\n"));
685
+ }
686
+
604
687
  function installZshrcBlock(startMarker, endMarker, block) {
605
688
  const zshrc = path.join(os.homedir(), ".zshrc");
606
689
  let content = fs.existsSync(zshrc) ? fs.readFileSync(zshrc, "utf8") : "";
@@ -615,6 +698,13 @@ function installZshrcBlock(startMarker, endMarker, block) {
615
698
  content = content.trimEnd() + "\n\n" + block + "\n";
616
699
  }
617
700
  fs.writeFileSync(zshrc, content, "utf8");
701
+ if (!compinitLikelyConfigured(content)) {
702
+ console.log(
703
+ warn(
704
+ "Note: tab completion needs compinit. If `cursor-keys open <TAB>` does nothing after sourcing ~/.zshrc, add: autoload -Uz compinit && compinit",
705
+ ),
706
+ );
707
+ }
618
708
  return zshrc;
619
709
  }
620
710
 
@@ -735,12 +825,17 @@ async function cmdInstallExtension() {
735
825
  "..",
736
826
  );
737
827
  const src = path.join(repoRoot, "extension");
738
- if (!fs.existsSync(path.join(src, "package.json"))) {
828
+ const extPkgPath = path.join(src, "package.json");
829
+ if (!fs.existsSync(extPkgPath)) {
739
830
  throw new Error(`Extension source missing: ${src}`);
740
831
  }
832
+ const extPkg = JSON.parse(fs.readFileSync(extPkgPath, "utf8"));
741
833
  const extDir = path.join(os.homedir(), ".cursor", "extensions");
742
834
  fs.mkdirSync(extDir, { recursive: true });
743
- 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
+ );
744
839
  fs.rmSync(target, { recursive: true, force: true });
745
840
  fs.symlinkSync(src, target, "dir");
746
841
  console.log(ok(`Extension linked: ${target}`));
@@ -828,8 +923,9 @@ async function cmdRm(args) {
828
923
  if (!config.projects[name]) throw new Error(`Unknown project: ${name}`);
829
924
 
830
925
  const p = getProject(config, name);
831
- delete config.projects[name];
832
- saveConfig(config);
926
+ updateConfig((c) => {
927
+ delete c.projects[name];
928
+ });
833
929
  console.log(ok(`Removed ${name} from config`));
834
930
 
835
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
  */