vexp-cli 2.0.18 → 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 +145 -54
  2. package/package.json +7 -6
@@ -89,6 +89,7 @@ const VEXP_TOOLS = [
89
89
  "get_session_context",
90
90
  "search_memory",
91
91
  "save_observation",
92
+ "get_token_savings",
92
93
  ];
93
94
  // ---------------------------------------------------------------------------
94
95
  // Agent detectors — mirrors VS Code extension's AGENT_DETECTORS
@@ -397,6 +398,102 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
397
398
  // ---------------------------------------------------------------------------
398
399
  // File helpers — ported from agent-auto-config.ts
399
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
+ }
400
497
  function appendOrCreate(filePath, content, version) {
401
498
  if (!fs.existsSync(filePath)) {
402
499
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -432,16 +529,13 @@ function appendOrCreate(filePath, content, version) {
432
529
  }
433
530
  return "updated";
434
531
  }
435
- function mergeJsonConfig(filePath, mergePayload, version) {
436
- let existing = {};
437
- if (fs.existsSync(filePath)) {
438
- try {
439
- existing = JSON.parse(fs.readFileSync(filePath, "utf-8"));
440
- }
441
- catch {
442
- // Malformed JSON: overwrite
443
- }
532
+ export function mergeJsonConfig(filePath, mergePayload, version) {
533
+ const read = readJsonConfigSafe(filePath);
534
+ if (!read.ok) {
535
+ warnUnparseable(filePath);
536
+ return "skipped";
444
537
  }
538
+ const existing = read.data;
445
539
  // Build the merged result first, then compare to avoid unnecessary writes.
446
540
  const merged = { ...existing };
447
541
  for (const [key, value] of Object.entries(mergePayload)) {
@@ -466,6 +560,8 @@ function mergeJsonConfig(filePath, mergePayload, version) {
466
560
  if (mergedStr === existingStr)
467
561
  return "skipped";
468
562
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
563
+ if (read.existed)
564
+ backupConfig(filePath);
469
565
  fs.writeFileSync(filePath, mergedStr, "utf-8");
470
566
  return "merged";
471
567
  }
@@ -476,16 +572,13 @@ function mergeJsonConfig(filePath, mergePayload, version) {
476
572
  * Write MCP config JSON for agents that use mcpServers format (Cursor, Windsurf).
477
573
  * Returns true if a new entry was written.
478
574
  */
479
- function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot) {
480
- let existing = {};
481
- if (fs.existsSync(mcpConfigPath)) {
482
- try {
483
- existing = JSON.parse(fs.readFileSync(mcpConfigPath, "utf-8"));
484
- }
485
- catch {
486
- // Malformed JSON: overwrite
487
- }
575
+ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot) {
576
+ const read = readJsonConfigSafe(mcpConfigPath);
577
+ if (!read.ok) {
578
+ warnUnparseable(mcpConfigPath);
579
+ return false;
488
580
  }
581
+ const existing = read.data;
489
582
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
490
583
  const targetCmd = useNode ? "node" : binaryPath;
491
584
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
@@ -516,6 +609,8 @@ function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, w
516
609
  };
517
610
  existing.mcpServers = servers;
518
611
  fs.mkdirSync(path.dirname(mcpConfigPath), { recursive: true });
612
+ if (read.existed)
613
+ backupConfig(mcpConfigPath);
519
614
  fs.writeFileSync(mcpConfigPath, JSON.stringify(existing, null, 2), "utf-8");
520
615
  return true;
521
616
  }
@@ -610,16 +705,13 @@ function configureCodexGlobal(binaryPath, mcpServerPath, workspaceRoot) {
610
705
  * Write .vscode/mcp.json for GitHub Copilot.
611
706
  * VS Code uses "servers" (not "mcpServers") + "type": "stdio".
612
707
  */
613
- function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
614
- let existing = {};
615
- if (fs.existsSync(p)) {
616
- try {
617
- existing = JSON.parse(fs.readFileSync(p, "utf-8"));
618
- }
619
- catch {
620
- // Malformed JSON: overwrite
621
- }
708
+ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
709
+ const read = readJsonConfigSafe(p);
710
+ if (!read.ok) {
711
+ warnUnparseable(p);
712
+ return false;
622
713
  }
714
+ const existing = read.data;
623
715
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
624
716
  const targetCmd = useNode ? "node" : binaryPath;
625
717
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
@@ -644,22 +736,21 @@ function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
644
736
  };
645
737
  existing.servers = servers;
646
738
  fs.mkdirSync(path.dirname(p), { recursive: true });
739
+ if (read.existed)
740
+ backupConfig(p);
647
741
  fs.writeFileSync(p, JSON.stringify(existing, null, 2), "utf-8");
648
742
  return true;
649
743
  }
650
744
  /**
651
745
  * Write context_servers in .zed/settings.json for Zed.
652
746
  */
653
- function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
654
- let settings = {};
655
- if (fs.existsSync(p)) {
656
- try {
657
- settings = JSON.parse(fs.readFileSync(p, "utf-8"));
658
- }
659
- catch {
660
- // Malformed JSON: overwrite
661
- }
747
+ export function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
748
+ const read = readJsonConfigSafe(p);
749
+ if (!read.ok) {
750
+ warnUnparseable(p);
751
+ return false;
662
752
  }
753
+ const settings = read.data;
663
754
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
664
755
  const targetCmd = useNode ? "node" : binaryPath;
665
756
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
@@ -693,6 +784,8 @@ function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
693
784
  }
694
785
  settings.agent = { ...agent, tool_permissions: { ...tp, tools } };
695
786
  fs.mkdirSync(path.dirname(p), { recursive: true });
787
+ if (read.existed)
788
+ backupConfig(p);
696
789
  fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
697
790
  return true;
698
791
  }
@@ -700,20 +793,17 @@ function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
700
793
  * Configure vexp MCP in ~/.claude.json (user-scope) using the Rust binary.
701
794
  * Returns true if config was written/updated.
702
795
  */
703
- function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot) {
796
+ export function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot) {
704
797
  const home = os.homedir();
705
798
  if (!home)
706
799
  return false;
707
800
  const configPath = path.join(home, ".claude.json");
708
- let config = {};
709
- if (fs.existsSync(configPath)) {
710
- try {
711
- config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
712
- }
713
- catch {
714
- // Malformed JSON: overwrite
715
- }
801
+ const read = readJsonConfigSafe(configPath);
802
+ if (!read.ok) {
803
+ warnUnparseable(configPath);
804
+ return false;
716
805
  }
806
+ const config = read.data;
717
807
  const beforeServers = config.mcpServers;
718
808
  const existing = beforeServers?.["vexp"];
719
809
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
@@ -735,6 +825,8 @@ function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot) {
735
825
  ...(desiredEnv ? { env: desiredEnv } : {}),
736
826
  };
737
827
  config.mcpServers = servers;
828
+ if (read.existed)
829
+ backupConfig(configPath);
738
830
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
739
831
  return true;
740
832
  }
@@ -746,7 +838,7 @@ function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot) {
746
838
  * Creates .claude/hooks/vexp-guard.sh and merges hook config into .claude/settings.json.
747
839
  * Returns the action taken, or null if nothing was written.
748
840
  */
749
- function installClaudeCodeHook(workspaceRoot) {
841
+ export function installClaudeCodeHook(workspaceRoot) {
750
842
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
751
843
  const hookPath = path.join(hookDir, "vexp-guard.sh");
752
844
  const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
@@ -760,15 +852,12 @@ function installClaudeCodeHook(workspaceRoot) {
760
852
  }
761
853
  fs.writeFileSync(hookPath, VEXP_GUARD_HOOK, { mode: 0o755 });
762
854
  // 2. Merge hook config into .claude/settings.json
763
- let settings = {};
764
- if (fs.existsSync(settingsPath)) {
765
- try {
766
- settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
767
- }
768
- catch {
769
- // Malformed JSON: overwrite
770
- }
855
+ const read = readJsonConfigSafe(settingsPath);
856
+ if (!read.ok) {
857
+ warnUnparseable(settingsPath);
858
+ return existed ? "updated" : "created";
771
859
  }
860
+ const settings = read.data;
772
861
  const hooks = (settings.hooks ?? {});
773
862
  const existingPreToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
774
863
  // Aggressively remove ALL vexp-related hook entries (old format, stale matchers, malformed)
@@ -805,6 +894,8 @@ function installClaudeCodeHook(workspaceRoot) {
805
894
  ],
806
895
  });
807
896
  settings.hooks = { ...hooks, PreToolUse: filtered };
897
+ if (read.existed)
898
+ backupConfig(settingsPath);
808
899
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
809
900
  return existed ? "updated" : "created";
810
901
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexp-cli",
3
- "version": "2.0.18",
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.18",
103
- "@vexp/core-linux-arm64": "2.0.18",
104
- "@vexp/core-darwin-x64": "2.0.18",
105
- "@vexp/core-darwin-arm64": "2.0.18",
106
- "@vexp/core-win32-x64": "2.0.18"
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
  }