vexp-cli 2.0.11 → 2.0.13

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.
@@ -16,6 +16,66 @@ import { VEXP_GUARD_HOOK } from "./hook-template.js";
16
16
  // ---------------------------------------------------------------------------
17
17
  const VEXP_MARKER_RE = /<!-- vexp(?:\s+v[\d.]+)? -->/;
18
18
  const VEXP_MARKER_END = "<!-- /vexp -->";
19
+ // Matches LEGACY vexp entry keys ONLY — "vexp-mcp", "vexp-core", "vexp-stdio",
20
+ // "vexp_old", etc. Explicitly excludes the canonical "vexp" key so that idempotency
21
+ // checks (command+args+env identical?) work: we only remove siblings, not the entry
22
+ // we're about to upsert.
23
+ const LEGACY_VEXP_KEY_RE = /^vexp[_-][a-z0-9_-]+$/i;
24
+ // Matches TOML sections for LEGACY vexp keys only: [mcp_servers.vexp-mcp],
25
+ // [mcp_servers.vexp_old], etc. Explicitly excludes the canonical
26
+ // [mcp_servers.vexp] to preserve idempotency.
27
+ // Stops at the next top-level header (`\n[` followed by a letter/underscore)
28
+ // so that array-valued TOML keys like `args = ["--legacy"]` do not prematurely
29
+ // terminate the section.
30
+ const LEGACY_VEXP_TOML_SECTION_RE = /\n?\[mcp_servers\.vexp[_-][a-z0-9_-]+(?:\.[a-z_]+)?\][\s\S]*?(?=\n\[[A-Za-z_]|$)/gi;
31
+ // Matches the canonical [mcp_servers.vexp] section (and its .http_headers
32
+ // sub-section), used when we need to overwrite it with new URL/token.
33
+ const CANONICAL_VEXP_TOML_SECTION_RE = /\n?\[mcp_servers\.vexp(?:\.[a-z_]+)?\][\s\S]*?(?=\n\[[A-Za-z_]|$)/gi;
34
+ /**
35
+ * FNV-1a 64-bit hash (first 8 hex chars) of the workspace root.
36
+ * Duplicates the helper in packages/vexp-mcp/src/daemon-client.ts:fnvHash
37
+ * to avoid a runtime dependency from the CLI on the MCP package.
38
+ * MUST stay in lockstep with that function.
39
+ */
40
+ function workspaceHash(workspaceRoot) {
41
+ const input = workspaceRoot.toLowerCase();
42
+ let hash = BigInt("0xcbf29ce484222325");
43
+ const prime = BigInt("0x100000001b3");
44
+ const mask = BigInt("0xffffffffffffffff");
45
+ const bytes = Buffer.from(input, "utf-8");
46
+ for (const byte of bytes) {
47
+ hash ^= BigInt(byte);
48
+ hash = (hash * prime) & mask;
49
+ }
50
+ return hash.toString(16).padStart(16, "0").slice(0, 8);
51
+ }
52
+ /**
53
+ * Remove every legacy vexp entry from a JSON-shaped MCP config object.
54
+ * `key` selects which container to scrub: "mcpServers" (Claude Code,
55
+ * Cursor, Windsurf), "servers" (.vscode/mcp.json), "context_servers" (Zed).
56
+ * Returns the list of removed keys for logging.
57
+ */
58
+ function stripLegacyVexpEntries(config, key) {
59
+ const container = config[key];
60
+ if (!container || typeof container !== "object")
61
+ return [];
62
+ const removed = [];
63
+ for (const k of Object.keys(container)) {
64
+ if (LEGACY_VEXP_KEY_RE.test(k)) {
65
+ delete container[k];
66
+ removed.push(`${key}.${k}`);
67
+ }
68
+ }
69
+ return removed;
70
+ }
71
+ /** Emit a one-line removal notice to stdout (chalk-less to avoid churn). */
72
+ function logRemoval(removed) {
73
+ if (removed.length === 0)
74
+ return;
75
+ for (const entry of removed) {
76
+ process.stdout.write(` · Removed legacy entry: ${entry}\n`);
77
+ }
78
+ }
19
79
  // All vexp MCP tools (all read-only — safe to pre-approve).
20
80
  const VEXP_TOOLS = [
21
81
  "run_pipeline",
@@ -426,29 +486,35 @@ function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, w
426
486
  // Malformed JSON: overwrite
427
487
  }
428
488
  }
429
- const servers = existing.mcpServers;
430
489
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
431
490
  const targetCmd = useNode ? "node" : binaryPath;
432
491
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
433
492
  const targetEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
434
- if (servers && "vexp" in servers) {
435
- const cur = servers["vexp"];
436
- if (cur?.["command"] === targetCmd &&
437
- Array.isArray(cur?.["args"]) &&
438
- JSON.stringify(cur["args"]) === JSON.stringify(targetArgs) &&
439
- JSON.stringify(cur["env"]) === JSON.stringify(targetEnv)) {
440
- return false; // Already up to date
441
- }
493
+ const beforeServers = existing.mcpServers;
494
+ const previousVexp = beforeServers?.["vexp"];
495
+ const currentCmd = previousVexp?.["command"];
496
+ const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
497
+ const prevEnv = previousVexp?.env;
498
+ const identical = currentCmd === targetCmd &&
499
+ prevArgs !== undefined &&
500
+ JSON.stringify(prevArgs) === JSON.stringify(targetArgs) &&
501
+ JSON.stringify(prevEnv) === JSON.stringify(targetEnv);
502
+ // Legacy cleanup: remove any vexp/vexp-* keys; if the canonical 'vexp'
503
+ // entry points at a command path that no longer exists on disk, drop it
504
+ // too so we don't keep a stale pointer.
505
+ const removed = stripLegacyVexpEntries(existing, "mcpServers");
506
+ logRemoval(removed);
507
+ if (identical && removed.length === 0) {
508
+ return false; // Already up to date and no legacy to clean
442
509
  }
443
- existing.mcpServers = {
444
- ...(servers ?? {}),
445
- vexp: {
446
- command: targetCmd,
447
- args: targetArgs,
448
- ...(targetEnv ? { env: targetEnv } : {}),
449
- ...(alwaysAllow && alwaysAllow.length > 0 ? { alwaysAllow } : {}),
450
- },
510
+ const servers = existing.mcpServers ?? {};
511
+ servers["vexp"] = {
512
+ command: targetCmd,
513
+ args: targetArgs,
514
+ ...(targetEnv ? { env: targetEnv } : {}),
515
+ ...(alwaysAllow && alwaysAllow.length > 0 ? { alwaysAllow } : {}),
451
516
  };
517
+ existing.mcpServers = servers;
452
518
  fs.mkdirSync(path.dirname(mcpConfigPath), { recursive: true });
453
519
  fs.writeFileSync(mcpConfigPath, JSON.stringify(existing, null, 2), "utf-8");
454
520
  return true;
@@ -508,20 +574,34 @@ function configureCodexGlobal(binaryPath, mcpServerPath, workspaceRoot) {
508
574
  const configPath = path.join(home, ".codex", "config.toml");
509
575
  const mcpPort = readMcpPort(workspaceRoot);
510
576
  const token = resolveCodexToken();
511
- const newSection = `\n[mcp_servers.vexp]\nurl = "http://127.0.0.1:${mcpPort}/mcp"\ntool_timeout_sec = 120\n\n[mcp_servers.vexp.http_headers]\nAuthorization = "Bearer ${token}"\n`;
577
+ // Per-workspace URL path lets the single MCP server on :port route
578
+ // each codex request to the correct daemon in multi-workspace setups.
579
+ // Falls back to /mcp if no workspace is known (e.g. global bootstrap).
580
+ const wsHash = workspaceRoot ? workspaceHash(workspaceRoot) : "";
581
+ const urlPath = wsHash ? `/ws/${wsHash}/mcp` : "/mcp";
582
+ const desiredUrl = `http://127.0.0.1:${mcpPort}${urlPath}`;
583
+ const newSection = `\n[mcp_servers.vexp]\nurl = "${desiredUrl}"\ntool_timeout_sec = 120\n\n[mcp_servers.vexp.http_headers]\nAuthorization = "Bearer ${token}"\n`;
512
584
  let content = "";
513
585
  if (fs.existsSync(configPath)) {
514
586
  content = fs.readFileSync(configPath, "utf-8");
515
587
  }
516
588
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
517
- if (content.includes("[mcp_servers.vexp]")) {
518
- // Check if already up to date (same URL and token)
519
- if (content.includes(`url = "http://127.0.0.1:${mcpPort}/mcp"`) &&
520
- content.includes(`Authorization = "Bearer ${token}"`))
521
- return false;
522
- // Remove old section(s) handles both stdio (command=) and http (url=) formats
523
- content = content.replace(/\n?\[mcp_servers\.vexp(?:\.http_headers)?\][^\[]*/g, "");
589
+ // Step 1: strip LEGACY vexp-* sections (not the canonical one).
590
+ const beforeLegacy = content.length;
591
+ content = content.replace(LEGACY_VEXP_TOML_SECTION_RE, "");
592
+ const legacyStripped = content.length !== beforeLegacy;
593
+ if (legacyStripped) {
594
+ process.stdout.write(` · Removed legacy codex entries from ${configPath}\n`);
524
595
  }
596
+ // Step 2: check if the canonical section already has the desired URL+token.
597
+ // If yes and no legacy was stripped, this is a true no-op.
598
+ const canonicalOk = content.includes(`url = "${desiredUrl}"`) &&
599
+ content.includes(`Authorization = "Bearer ${token}"`);
600
+ if (canonicalOk && !legacyStripped) {
601
+ return false;
602
+ }
603
+ // Step 3: rewrite canonical section (strip it if present, then append).
604
+ content = content.replace(CANONICAL_VEXP_TOML_SECTION_RE, "");
525
605
  content += newSection;
526
606
  fs.writeFileSync(configPath, content, "utf-8");
527
607
  return true;
@@ -540,29 +620,29 @@ function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
540
620
  // Malformed JSON: overwrite
541
621
  }
542
622
  }
543
- const servers = existing.servers;
544
623
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
545
624
  const targetCmd = useNode ? "node" : binaryPath;
546
625
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
547
626
  const targetEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
548
- if (servers && "vexp" in servers) {
549
- const cur = servers["vexp"];
550
- if (cur?.["command"] === targetCmd &&
551
- Array.isArray(cur?.["args"]) &&
552
- JSON.stringify(cur["args"]) === JSON.stringify(targetArgs) &&
553
- JSON.stringify(cur["env"]) === JSON.stringify(targetEnv)) {
554
- return false; // Already up to date
555
- }
556
- }
557
- existing.servers = {
558
- ...(servers ?? {}),
559
- vexp: {
560
- type: "stdio",
561
- command: targetCmd,
562
- args: targetArgs,
563
- ...(targetEnv ? { env: targetEnv } : {}),
564
- },
627
+ const beforeServers = existing.servers;
628
+ const previousVexp = beforeServers?.["vexp"];
629
+ const prevArgs = Array.isArray(previousVexp?.["args"]) ? previousVexp?.["args"] : undefined;
630
+ const identical = previousVexp?.["command"] === targetCmd &&
631
+ prevArgs !== undefined &&
632
+ JSON.stringify(prevArgs) === JSON.stringify(targetArgs) &&
633
+ JSON.stringify(previousVexp?.env) === JSON.stringify(targetEnv);
634
+ const removed = stripLegacyVexpEntries(existing, "servers");
635
+ logRemoval(removed);
636
+ if (identical && removed.length === 0)
637
+ return false;
638
+ const servers = existing.servers ?? {};
639
+ servers["vexp"] = {
640
+ type: "stdio",
641
+ command: targetCmd,
642
+ args: targetArgs,
643
+ ...(targetEnv ? { env: targetEnv } : {}),
565
644
  };
645
+ existing.servers = servers;
566
646
  fs.mkdirSync(path.dirname(p), { recursive: true });
567
647
  fs.writeFileSync(p, JSON.stringify(existing, null, 2), "utf-8");
568
648
  return true;
@@ -580,31 +660,30 @@ function writeZedMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot) {
580
660
  // Malformed JSON: overwrite
581
661
  }
582
662
  }
583
- const cs = settings.context_servers;
584
663
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
585
664
  const targetCmd = useNode ? "node" : binaryPath;
586
665
  const targetArgs = useNode ? [mcpServerPath] : ["mcp"];
587
666
  const targetEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
588
- if (cs && "vexp" in cs) {
589
- const cur = cs["vexp"];
590
- const curCmd = cur?.["command"];
591
- if (curCmd?.["path"] === targetCmd &&
592
- Array.isArray(curCmd?.["args"]) &&
593
- JSON.stringify(curCmd["args"]) === JSON.stringify(targetArgs) &&
594
- JSON.stringify(curCmd["env"]) === JSON.stringify(targetEnv)) {
595
- return false; // Already up to date
596
- }
597
- }
598
- settings.context_servers = {
599
- ...(cs ?? {}),
600
- vexp: {
601
- command: {
602
- path: targetCmd,
603
- args: targetArgs,
604
- ...(targetEnv ? { env: targetEnv } : {}),
605
- },
667
+ const beforeCs = settings.context_servers;
668
+ const previousVexp = beforeCs?.["vexp"];
669
+ const curCmd = previousVexp?.["command"];
670
+ const identical = curCmd?.["path"] === targetCmd &&
671
+ Array.isArray(curCmd?.["args"]) &&
672
+ JSON.stringify(curCmd["args"]) === JSON.stringify(targetArgs) &&
673
+ JSON.stringify(curCmd["env"]) === JSON.stringify(targetEnv);
674
+ const removed = stripLegacyVexpEntries(settings, "context_servers");
675
+ logRemoval(removed);
676
+ if (identical && removed.length === 0)
677
+ return false;
678
+ const cs = settings.context_servers ?? {};
679
+ cs["vexp"] = {
680
+ command: {
681
+ path: targetCmd,
682
+ args: targetArgs,
683
+ ...(targetEnv ? { env: targetEnv } : {}),
606
684
  },
607
685
  };
686
+ settings.context_servers = cs;
608
687
  // Pre-approve all vexp tools
609
688
  const agent = (settings.agent ?? {});
610
689
  const tp = (agent.tool_permissions ?? {});
@@ -635,27 +714,27 @@ function configureClaudeCodeGlobal(binaryPath, mcpServerPath, workspaceRoot) {
635
714
  // Malformed JSON: overwrite
636
715
  }
637
716
  }
638
- const servers = config.mcpServers;
639
- const existing = servers?.["vexp"];
717
+ const beforeServers = config.mcpServers;
718
+ const existing = beforeServers?.["vexp"];
640
719
  const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
641
720
  const desiredCommand = useNode ? "node" : binaryPath;
642
721
  const desiredArgs = useNode ? [mcpServerPath] : ["mcp"];
643
722
  const desiredEnv = workspaceRoot ? { VEXP_WORKSPACE: workspaceRoot } : undefined;
644
- // Skip if already configured with identical command+args+env
645
- if (existing?.["command"] === desiredCommand &&
723
+ const identical = existing?.["command"] === desiredCommand &&
646
724
  Array.isArray(existing?.["args"]) &&
647
725
  JSON.stringify(existing["args"]) === JSON.stringify(desiredArgs) &&
648
- JSON.stringify(existing["env"]) === JSON.stringify(desiredEnv)) {
726
+ JSON.stringify(existing?.env) === JSON.stringify(desiredEnv);
727
+ const removed = stripLegacyVexpEntries(config, "mcpServers");
728
+ logRemoval(removed);
729
+ if (identical && removed.length === 0)
649
730
  return false;
650
- }
651
- config.mcpServers = {
652
- ...(servers ?? {}),
653
- vexp: {
654
- command: desiredCommand,
655
- args: desiredArgs,
656
- ...(desiredEnv ? { env: desiredEnv } : {}),
657
- },
731
+ const servers = config.mcpServers ?? {};
732
+ servers["vexp"] = {
733
+ command: desiredCommand,
734
+ args: desiredArgs,
735
+ ...(desiredEnv ? { env: desiredEnv } : {}),
658
736
  };
737
+ config.mcpServers = servers;
659
738
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
660
739
  return true;
661
740
  }