vexp-cli 2.0.19 → 2.0.20

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.
Files changed (2) hide show
  1. package/dist/agent-config.js +144 -54
  2. package/package.json +7 -6
@@ -398,6 +398,102 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
398
398
  // ---------------------------------------------------------------------------
399
399
  // File helpers — ported from agent-auto-config.ts
400
400
  // ---------------------------------------------------------------------------
401
+ /**
402
+ * Parse JSON that may use JSONC syntax — `//` and slash-star comments and
403
+ * trailing commas. VS Code mcp.json and Zed settings.json are JSONC, so a raw
404
+ * JSON.parse throws on perfectly valid user files. Strips comments and trailing
405
+ * commas (preserving string contents) before parsing.
406
+ */
407
+ export function parseJsonc(text) {
408
+ let out = "";
409
+ let inString = false;
410
+ let inLineComment = false;
411
+ let inBlockComment = false;
412
+ for (let i = 0; i < text.length; i++) {
413
+ const c = text[i];
414
+ const next = text[i + 1];
415
+ if (inLineComment) {
416
+ if (c === "\n") {
417
+ inLineComment = false;
418
+ out += c;
419
+ }
420
+ continue;
421
+ }
422
+ if (inBlockComment) {
423
+ if (c === "*" && next === "/") {
424
+ inBlockComment = false;
425
+ i++;
426
+ }
427
+ continue;
428
+ }
429
+ if (inString) {
430
+ out += c;
431
+ if (c === "\\") {
432
+ out += next ?? "";
433
+ i++;
434
+ }
435
+ else if (c === '"')
436
+ inString = false;
437
+ continue;
438
+ }
439
+ if (c === '"') {
440
+ inString = true;
441
+ out += c;
442
+ continue;
443
+ }
444
+ if (c === "/" && next === "/") {
445
+ inLineComment = true;
446
+ i++;
447
+ continue;
448
+ }
449
+ if (c === "/" && next === "*") {
450
+ inBlockComment = true;
451
+ i++;
452
+ continue;
453
+ }
454
+ out += c;
455
+ }
456
+ out = out.replace(/,(\s*[}\]])/g, "$1");
457
+ return JSON.parse(out);
458
+ }
459
+ /**
460
+ * Read a JSON/JSONC config file without ever risking user data. When the file
461
+ * exists but cannot be parsed (even as JSONC), returns ok:false so callers skip
462
+ * the write instead of silently clobbering the user's config.
463
+ */
464
+ export function readJsonConfigSafe(filePath) {
465
+ if (!fs.existsSync(filePath))
466
+ return { data: {}, ok: true, existed: false };
467
+ let raw;
468
+ try {
469
+ raw = fs.readFileSync(filePath, "utf-8");
470
+ }
471
+ catch {
472
+ return { data: {}, ok: false, existed: true };
473
+ }
474
+ if (raw.trim() === "")
475
+ return { data: {}, ok: true, existed: true };
476
+ try {
477
+ const parsed = parseJsonc(raw);
478
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
479
+ return { data: parsed, ok: true, existed: true };
480
+ }
481
+ }
482
+ catch { /* fall through to ok:false */ }
483
+ return { data: {}, ok: false, existed: true };
484
+ }
485
+ /** Copy an existing config to <file>.vexp-bak before overwriting it. */
486
+ function backupConfig(filePath) {
487
+ try {
488
+ if (fs.existsSync(filePath))
489
+ fs.copyFileSync(filePath, `${filePath}.vexp-bak`);
490
+ }
491
+ catch { /* non-fatal */ }
492
+ }
493
+ /** Warn that we refused to touch an unparseable config file. */
494
+ function warnUnparseable(filePath) {
495
+ process.stderr.write(` ⚠ ${filePath} could not be parsed — leaving it untouched. Fix the file or add vexp manually, then re-run setup.\n`);
496
+ }
401
497
  function appendOrCreate(filePath, content, version) {
402
498
  if (!fs.existsSync(filePath)) {
403
499
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -433,16 +529,13 @@ function appendOrCreate(filePath, content, version) {
433
529
  }
434
530
  return "updated";
435
531
  }
436
- function mergeJsonConfig(filePath, mergePayload, version) {
437
- let existing = {};
438
- if (fs.existsSync(filePath)) {
439
- try {
440
- existing = JSON.parse(fs.readFileSync(filePath, "utf-8"));
441
- }
442
- catch {
443
- // Malformed JSON: overwrite
444
- }
532
+ export function mergeJsonConfig(filePath, mergePayload, version) {
533
+ const read = readJsonConfigSafe(filePath);
534
+ if (!read.ok) {
535
+ warnUnparseable(filePath);
536
+ return "skipped";
445
537
  }
538
+ const existing = read.data;
446
539
  // Build the merged result first, then compare to avoid unnecessary writes.
447
540
  const merged = { ...existing };
448
541
  for (const [key, value] of Object.entries(mergePayload)) {
@@ -467,6 +560,8 @@ function mergeJsonConfig(filePath, mergePayload, version) {
467
560
  if (mergedStr === existingStr)
468
561
  return "skipped";
469
562
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
563
+ if (read.existed)
564
+ backupConfig(filePath);
470
565
  fs.writeFileSync(filePath, mergedStr, "utf-8");
471
566
  return "merged";
472
567
  }
@@ -477,16 +572,13 @@ function mergeJsonConfig(filePath, mergePayload, version) {
477
572
  * Write MCP config JSON for agents that use mcpServers format (Cursor, Windsurf).
478
573
  * Returns true if a new entry was written.
479
574
  */
480
- function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot) {
481
- let existing = {};
482
- if (fs.existsSync(mcpConfigPath)) {
483
- try {
484
- existing = JSON.parse(fs.readFileSync(mcpConfigPath, "utf-8"));
485
- }
486
- catch {
487
- // Malformed JSON: overwrite
488
- }
575
+ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot) {
576
+ const read = readJsonConfigSafe(mcpConfigPath);
577
+ if (!read.ok) {
578
+ warnUnparseable(mcpConfigPath);
579
+ return false;
489
580
  }
581
+ const existing = read.data;
490
582
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
491
583
  const targetCmd = useNode ? "node" : binaryPath;
492
584
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
@@ -517,6 +609,8 @@ function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, w
517
609
  };
518
610
  existing.mcpServers = servers;
519
611
  fs.mkdirSync(path.dirname(mcpConfigPath), { recursive: true });
612
+ if (read.existed)
613
+ backupConfig(mcpConfigPath);
520
614
  fs.writeFileSync(mcpConfigPath, JSON.stringify(existing, null, 2), "utf-8");
521
615
  return true;
522
616
  }
@@ -611,16 +705,13 @@ function configureCodexGlobal(binaryPath, mcpServerPath, workspaceRoot) {
611
705
  * Write .vscode/mcp.json for GitHub Copilot.
612
706
  * VS Code uses "servers" (not "mcpServers") + "type": "stdio".
613
707
  */
614
- function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
615
- let existing = {};
616
- if (fs.existsSync(p)) {
617
- try {
618
- existing = JSON.parse(fs.readFileSync(p, "utf-8"));
619
- }
620
- catch {
621
- // Malformed JSON: overwrite
622
- }
708
+ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
709
+ const read = readJsonConfigSafe(p);
710
+ if (!read.ok) {
711
+ warnUnparseable(p);
712
+ return false;
623
713
  }
714
+ const existing = read.data;
624
715
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
625
716
  const targetCmd = useNode ? "node" : binaryPath;
626
717
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
@@ -645,22 +736,21 @@ function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
645
736
  };
646
737
  existing.servers = servers;
647
738
  fs.mkdirSync(path.dirname(p), { recursive: true });
739
+ if (read.existed)
740
+ backupConfig(p);
648
741
  fs.writeFileSync(p, JSON.stringify(existing, null, 2), "utf-8");
649
742
  return true;
650
743
  }
651
744
  /**
652
745
  * Write context_servers in .zed/settings.json for Zed.
653
746
  */
654
- function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
655
- let settings = {};
656
- if (fs.existsSync(p)) {
657
- try {
658
- settings = JSON.parse(fs.readFileSync(p, "utf-8"));
659
- }
660
- catch {
661
- // Malformed JSON: overwrite
662
- }
747
+ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
748
+ const read = readJsonConfigSafe(p);
749
+ if (!read.ok) {
750
+ warnUnparseable(p);
751
+ return false;
663
752
  }
753
+ const settings = read.data;
664
754
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
665
755
  const targetCmd = useNode ? "node" : binaryPath;
666
756
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
@@ -694,6 +784,8 @@ function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
694
784
  }
695
785
  settings.agent = { ...agent, tool_permissions: { ...tp, tools } };
696
786
  fs.mkdirSync(path.dirname(p), { recursive: true });
787
+ if (read.existed)
788
+ backupConfig(p);
697
789
  fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
698
790
  return true;
699
791
  }
@@ -701,20 +793,17 @@ function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
701
793
  * Configure vexp MCP in ~/.claude.json (user-scope) using the Rust binary.
702
794
  * Returns true if config was written/updated.
703
795
  */
704
- function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot) {
796
+ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot) {
705
797
  const home = os.homedir();
706
798
  if (!home)
707
799
  return false;
708
800
  const configPath = path.join(home, ".claude.json");
709
- let config = {};
710
- if (fs.existsSync(configPath)) {
711
- try {
712
- config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
713
- }
714
- catch {
715
- // Malformed JSON: overwrite
716
- }
801
+ const read = readJsonConfigSafe(configPath);
802
+ if (!read.ok) {
803
+ warnUnparseable(configPath);
804
+ return false;
717
805
  }
806
+ const config = read.data;
718
807
  const beforeServers = config.mcpServers;
719
808
  const existing = beforeServers?.["vexp"];
720
809
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
@@ -736,6 +825,8 @@ function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot) {
736
825
  ...(desiredEnv ? { env: desiredEnv } : {}),
737
826
  };
738
827
  config.mcpServers = servers;
828
+ if (read.existed)
829
+ backupConfig(configPath);
739
830
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
740
831
  return true;
741
832
  }
@@ -747,7 +838,7 @@ function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot) {
747
838
  * Creates .claude/hooks/vexp-guard.sh and merges hook config into .claude/settings.json.
748
839
  * Returns the action taken, or null if nothing was written.
749
840
  */
750
- function installClaudeCodeHook(workspaceRoot) {
841
+ export function installClaudeCodeHook(workspaceRoot) {
751
842
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
752
843
  const hookPath = path.join(hookDir, "vexp-guard.sh");
753
844
  const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
@@ -761,15 +852,12 @@ function installClaudeCodeHook(workspaceRoot) {
761
852
  }
762
853
  fs.writeFileSync(hookPath, VEXP_GUARD_HOOK, { mode: 0o755 });
763
854
  // 2. Merge hook config into .claude/settings.json
764
- let settings = {};
765
- if (fs.existsSync(settingsPath)) {
766
- try {
767
- settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
768
- }
769
- catch {
770
- // Malformed JSON: overwrite
771
- }
855
+ const read = readJsonConfigSafe(settingsPath);
856
+ if (!read.ok) {
857
+ warnUnparseable(settingsPath);
858
+ return existed ? "updated" : "created";
772
859
  }
860
+ const settings = read.data;
773
861
  const hooks = (settings.hooks ?? {});
774
862
  const existingPreToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
775
863
  // Aggressively remove ALL vexp-related hook entries (old format, stale matchers, malformed)
@@ -806,6 +894,8 @@ function installClaudeCodeHook(workspaceRoot) {
806
894
  ],
807
895
  });
808
896
  settings.hooks = { ...hooks, PreToolUse: filtered };
897
+ if (read.existed)
898
+ backupConfig(settingsPath);
809
899
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
810
900
  return existed ? "updated" : "created";
811
901
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexp-cli",
3
- "version": "2.0.19",
3
+ "version": "2.0.20",
4
4
  "description": "Vexp — Context Engine for AI Coding Agents. Pre-indexes your codebase into a dependency graph and delivers ranked context to any MCP-compatible agent. 58% lower cost per task, 90% fewer tool calls (SWE-bench Verified). Works with Claude Code, Cursor, Copilot, Windsurf, Codex, Cline, Aider, and 12+ agents. Local-first. Your code never leaves your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "scripts": {
10
10
  "build": "tsc",
11
11
  "dev": "tsx src/cli.ts",
12
+ "test": "node --import tsx --test test/*.test.ts",
12
13
  "clean": "rm -rf dist",
13
14
  "clean:artifacts": "node -e \"const fs=require('fs'),p=require('path');function w(d){if(!fs.existsSync(d))return;for(const e of fs.readdirSync(d,{withFileTypes:true})){const f=p.join(d,e.name);if(e.isDirectory())w(f);else if(/\\.(map|d\\.ts|d\\.ts\\.map)$/.test(e.name))fs.unlinkSync(f);}}w('dist');w('mcp');\"",
14
15
  "prepublishOnly": "npm run build && npm run clean:artifacts"
@@ -99,10 +100,10 @@
99
100
  "node": ">=20.0.0"
100
101
  },
101
102
  "optionalDependencies": {
102
- "@vexp/core-linux-x64": "2.0.19",
103
- "@vexp/core-linux-arm64": "2.0.19",
104
- "@vexp/core-darwin-x64": "2.0.19",
105
- "@vexp/core-darwin-arm64": "2.0.19",
106
- "@vexp/core-win32-x64": "2.0.19"
103
+ "@vexp/core-linux-x64": "2.0.20",
104
+ "@vexp/core-linux-arm64": "2.0.20",
105
+ "@vexp/core-darwin-x64": "2.0.20",
106
+ "@vexp/core-darwin-arm64": "2.0.20",
107
+ "@vexp/core-win32-x64": "2.0.20"
107
108
  }
108
109
  }