vexp-cli 2.2.1 → 2.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/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import * as fs from "fs";
8
8
  import * as net from "net";
9
9
  import { checkbox, confirm } from "@inquirer/prompts";
10
10
  import { getBinaryPath, getInstalledVersion, getMcpServerPath, binaryEnv } from "./binary.js";
11
- import { detectAgents, getAgentList, configureSelectedAgents } from "./agent-config.js";
11
+ import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName } from "./agent-config.js";
12
12
  import { CLI_VERSION } from "./version.js";
13
13
  import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, } from "./license.js";
14
14
  import { checkForUpdate } from "./update-check.js";
@@ -18,6 +18,7 @@ import { runServe } from "./serve.js";
18
18
  import { runDoctor } from "./doctor.js";
19
19
  import { socketPathFor } from "./socket-path.js";
20
20
  import { isTraceEnabled } from "./trace.js";
21
+ import { resolveParentWorkspace, listWorkspaceRepos, addRepoToWorkspace, } from "./workspace-repos.js";
21
22
  const program = new Command();
22
23
  program
23
24
  .name("vexp")
@@ -54,8 +55,11 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
54
55
  return;
55
56
  if (AUTOSTART_SKIP.has(actionCmd.name()))
56
57
  return;
57
- const workspaceRoot = findConfiguredWorkspace();
58
- if (!workspaceRoot)
58
+ // Effective workspace: inside a connected secondary repo this resolves to
59
+ // the parent, so we boot (or reuse) the parent daemon instead of spawning
60
+ // a conflicting one for the child.
61
+ const eff = findEffectiveWorkspace();
62
+ if (!eff)
59
63
  return;
60
64
  let binaryPath;
61
65
  try {
@@ -66,7 +70,7 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
66
70
  }
67
71
  // Full bootstrap: daemon + MCP + autostart-if-needed. All three steps are
68
72
  // internally idempotent — no duplicate processes across invocations.
69
- await ensureBootstrap(workspaceRoot, binaryPath);
73
+ await ensureBootstrap(eff.root, binaryPath);
70
74
  });
71
75
  // ────────────────────────────────────────────────────
72
76
  // Resolve the platform binary (no network — bundled via npm)
@@ -83,7 +87,7 @@ function ensureBinary() {
83
87
  // Tier limits are now enforced directly by the Rust daemon via license.jwt verification.
84
88
  // Track whether we're in interactive REPL mode (don't call process.exit)
85
89
  let replMode = false;
86
- function runBinary(binaryPath, args) {
90
+ function runBinary(binaryPath, args, options) {
87
91
  if (replMode) {
88
92
  // In REPL mode, run synchronously and don't exit the process.
89
93
  // CRITICAL: stdin must be "ignore" (not "inherit") — inheriting stdin
@@ -94,6 +98,7 @@ function runBinary(binaryPath, args) {
94
98
  // CLI, never stdin, so ignoring it is safe.
95
99
  const result = spawnSync(binaryPath, args, {
96
100
  stdio: ["ignore", "inherit", "inherit"],
101
+ cwd: options?.cwd,
97
102
  env: binaryEnv(binaryPath),
98
103
  });
99
104
  if (result.error) {
@@ -103,6 +108,7 @@ function runBinary(binaryPath, args) {
103
108
  }
104
109
  const child = spawn(binaryPath, args, {
105
110
  stdio: "inherit",
111
+ cwd: options?.cwd,
106
112
  env: binaryEnv(binaryPath),
107
113
  });
108
114
  child.on("exit", (code) => {
@@ -143,12 +149,16 @@ function runBinaryAsync(binaryPath, args, options) {
143
149
  // ────────────────────────────────────────────────────
144
150
  /**
145
151
  * Walk up from cwd looking for `.vexp/manifest.json` — the marker that
146
- * `vexp setup` has been run on this tree. Returns null if none found.
152
+ * `vexp setup` has been run on this tree. A `.vexp/parent_workspace.json`
153
+ * counts too: a freshly-connected secondary repo has the parent link before
154
+ * its first index writes a manifest, and it must already resolve to the
155
+ * parent workspace instead of looking unconfigured. Returns null if none found.
147
156
  */
148
157
  function findConfiguredWorkspace(startDir = process.cwd()) {
149
158
  let current = path.resolve(startDir);
150
159
  while (true) {
151
- if (fs.existsSync(path.join(current, ".vexp", "manifest.json")))
160
+ if (fs.existsSync(path.join(current, ".vexp", "manifest.json")) ||
161
+ fs.existsSync(path.join(current, ".vexp", "parent_workspace.json")))
152
162
  return current;
153
163
  const parent = path.dirname(current);
154
164
  if (parent === current)
@@ -156,6 +166,22 @@ function findConfiguredWorkspace(startDir = process.cwd()) {
156
166
  current = parent;
157
167
  }
158
168
  }
169
+ /**
170
+ * Workspace the CLI should actually operate on. When cwd is inside a
171
+ * secondary repo of a multi-repo workspace (it carries a valid
172
+ * `.vexp/parent_workspace.json`), every daemon interaction targets the PARENT:
173
+ * spawning a second daemon in the child made both fight over the same repo
174
+ * DBs (double watchers, duplicate LLM in VRAM) until the user killed
175
+ * everything. `parent` is set only when redirected, so callers can tell the
176
+ * user what happened.
177
+ */
178
+ function findEffectiveWorkspace(startDir = process.cwd()) {
179
+ const ws = findConfiguredWorkspace(startDir);
180
+ if (!ws)
181
+ return null;
182
+ const parent = resolveParentWorkspace(ws);
183
+ return parent ? { root: parent.parentRoot, parent } : { root: ws };
184
+ }
159
185
  /** True if the Unix socket at `socketPath` accepts a connection within `timeoutMs`. */
160
186
  function isSocketAlive(socketPath, timeoutMs = 300) {
161
187
  return new Promise((resolve) => {
@@ -486,7 +512,13 @@ program
486
512
  const args = ["daemon-cmd", action];
487
513
  if (opts.follow)
488
514
  args.push("--follow");
489
- runBinary(binaryPath, args);
515
+ // Inside a connected secondary repo, daemon commands target the PARENT
516
+ // workspace daemon — the child must never run its own competing daemon.
517
+ const eff = findEffectiveWorkspace();
518
+ if (eff?.parent) {
519
+ console.log(chalk.cyan(`This repo is part of workspace "${eff.parent.workspaceName}" — targeting the parent daemon (${shortPath(eff.root)})`));
520
+ }
521
+ runBinary(binaryPath, args, eff?.parent ? { cwd: eff.root } : undefined);
490
522
  });
491
523
  program
492
524
  .command("doctor")
@@ -555,8 +587,24 @@ program
555
587
  const detectedNames = new Set(detected.map((a) => a.agent));
556
588
  let selectedNames;
557
589
  if (typeof opts.agents === "string") {
558
- // Explicit list from --agents flag
559
- selectedNames = opts.agents.split(",").map((s) => s.trim());
590
+ // Explicit list from --agents flag. Resolve every name BEFORE writing
591
+ // anything: matching is exact downstream, so an unrecognised name used
592
+ // to configure nothing while setup still reported success — the worst
593
+ // possible outcome for the flag whose whole point is automation.
594
+ const requested = opts.agents.split(",").map((s) => s.trim()).filter(Boolean);
595
+ const unknown = requested.filter((n) => resolveAgentName(n) === null);
596
+ if (unknown.length > 0 || requested.length === 0) {
597
+ console.error(chalk.red(`\n✖ Unknown agent: ${unknown.map((u) => `"${u}"`).join(", ") || "(empty --agents list)"}`));
598
+ for (const u of unknown) {
599
+ const hint = suggestAgentName(u);
600
+ if (hint)
601
+ console.error(chalk.yellow(` Did you mean: ${hint}?`));
602
+ }
603
+ console.error(chalk.dim(` Valid agents: ${allAgents.map((a) => a.agent).join(", ")}`));
604
+ console.error(chalk.dim(" Names are matched ignoring case and punctuation.\n"));
605
+ process.exit(1);
606
+ }
607
+ selectedNames = requested.map((n) => resolveAgentName(n));
560
608
  console.log(chalk.dim(` Agents (from flag): ${selectedNames.join(", ")}`));
561
609
  }
562
610
  else {
@@ -981,10 +1029,10 @@ await checkForUpdate();
981
1029
  // ── Sub-menu: Setup & Config ──
982
1030
  const MENU_CONFIG = [
983
1031
  { key: "1", label: "init", hint: "Create .vexp/ and run first index", description: "Creates .vexp/ directory, runs a full index, installs git hooks.\n Run once when adding vexp to a new project." },
984
- { key: "2", label: "index", hint: "Re-index the repository", description: "Scans all source files, builds the AST graph (nodes + edges),\n and saves it to .vexp/index.db.\n Output: file count, node count, edge count, time elapsed." },
1032
+ { key: "2", label: "index", hint: "Re-index the workspace (all connected repos)", description: "Scans all source files, builds the AST graph (nodes + edges),\n and saves it to .vexp/index.db. In a multi-repo workspace every\n connected repo is re-indexed, not just the current one.\n Output: file count, node count, edge count, time elapsed." },
985
1033
  { key: "3", label: "setup", hint: "Full setup (index + agents + hooks)", description: "One-command bootstrap: indexes the codebase, detects AI agents,\n writes MCP configs, installs git hooks." },
986
1034
  { key: "4", label: "setup-agents", hint: "Choose which AI agents to configure", description: "Select agents (Claude, Cursor, Windsurf, Copilot, …) to configure.\n Creates config directories and writes MCP settings for each." },
987
- { key: "5", label: "daemon-cmd", hint: "Manage daemon (start/stop/status/logs)", description: "Controls the background daemon:\n start → launches the daemon (indexes + watches for changes)\n stop → stops the running daemon\n status → shows node/edge/file counts\n logs → prints .vexp/vexp.log" },
1035
+ { key: "5", label: "daemon-cmd", hint: "Manage daemon (start/stop/restart/status/logs)", description: "Controls the background daemon:\n start → launches the daemon (indexes + watches for changes)\n stop → stops the running daemon\n restart → stop + start (reloads workspace.json, indexes new repos)\n status → shows node/edge/file counts\n logs → prints .vexp/vexp.log" },
988
1036
  { key: "6", label: "setup-llm", hint: "Configure local LLM compression (vexp-devmind)", description: "Install, update, or disable the optional local LLM (vexp-devmind).\n Runs entirely on your machine (~3.5 GB download).\n Enables smart prompt preprocessing and higher-quality memory compression." },
989
1037
  ];
990
1038
  // ── Sub-menu: Explore & Analyze ──
@@ -999,6 +1047,11 @@ const MENU_SAVINGS = [
999
1047
  { key: "1", label: "summary", hint: "View savings summary", description: "Shows total tokens saved, average saving %, and breakdown by tool.\n Choose a period: 7 / 14 / 30 / 60 / 90 days." },
1000
1048
  { key: "2", label: "export", hint: "Export report to .vexp/token-savings-report.md", description: "Generates a markdown report with breakdown by tool and daily trend,\n then saves it to .vexp/token-savings-report.md." },
1001
1049
  ];
1050
+ // ── Sub-menu: Repos & Workspace ──
1051
+ const MENU_REPOS = [
1052
+ { key: "1", label: "list", hint: "Show repos connected to this workspace", description: "Lists the repositories of this multi-repo workspace\n (.vexp/workspace.json): alias, path, primary marker." },
1053
+ { key: "2", label: "add", hint: "Connect another repository to this workspace", description: "Adds a repository to .vexp/workspace.json and links it back via\n .vexp/parent_workspace.json — same writes as the VS Code\n \"vexp: Add Repository\" command. Restart the daemon to index it." },
1054
+ ];
1002
1055
  // ── Sub-menu: License ──
1003
1056
  const MENU_LICENSE = [
1004
1057
  { key: "1", label: "status", hint: "Show current license plan and limits", description: "Displays your current plan (free / pro / team), node limits,\n repo limits, and license expiry." },
@@ -1040,12 +1093,18 @@ async function printBanner() {
1040
1093
  // banner says "not running", but the Rust daemon-cmd status probes the
1041
1094
  // socket and reports "running". Using the socket here makes the banner
1042
1095
  // agree with status.
1043
- const ws = findConfiguredWorkspace();
1044
- if (!ws) {
1096
+ const eff = findEffectiveWorkspace();
1097
+ if (!eff) {
1045
1098
  console.log(chalk.dim(" ○ Daemon: no workspace configured here"));
1046
1099
  console.log(chalk.dim(" → type '1 > 3' to run full setup, or `vexp setup`"));
1047
1100
  }
1048
1101
  else {
1102
+ // Connected secondary repo: every daemon interaction targets the parent
1103
+ // workspace — say so up front, since the status below is the PARENT's.
1104
+ if (eff.parent) {
1105
+ console.log(chalk.cyan(` ◆ Workspace: this repo is part of "${eff.parent.workspaceName}" — using the parent daemon (${shortPath(eff.root)})`));
1106
+ }
1107
+ const ws = eff.root;
1049
1108
  const socketAlive = await isSocketAlive(socketPathFor(ws));
1050
1109
  if (socketAlive) {
1051
1110
  // Display PID + uptime when we can read them; otherwise a plain "running".
@@ -1081,7 +1140,7 @@ async function printBanner() {
1081
1140
  }
1082
1141
  else if (installed) {
1083
1142
  console.log(chalk.yellow(" ○ LLM: installed but not active"));
1084
- console.log(chalk.dim(" → type '1 > 6 > start' to start it, or `vexp setup-llm --start`"));
1143
+ console.log(chalk.dim(" → type '1 > 6 > start' to start it, or `vexp setup-llm --enable`"));
1085
1144
  }
1086
1145
  else {
1087
1146
  console.log(chalk.dim(" ○ LLM: not installed"));
@@ -1098,6 +1157,7 @@ function printMainMenu() {
1098
1157
  console.log(` ${chalk.cyan.bold("2")} ${chalk.green("Explore & Analyze".padEnd(20))} ${chalk.dim("Query context, skeletons, impact, flow")}`);
1099
1158
  console.log(` ${chalk.cyan.bold("3")} ${chalk.green("Token Savings".padEnd(20))} ${chalk.dim("View savings stats, export report")}`);
1100
1159
  console.log(` ${chalk.cyan.bold("4")} ${chalk.green("License".padEnd(20))} ${chalk.dim("View or activate your license")}`);
1160
+ console.log(` ${chalk.cyan.bold("5")} ${chalk.green("Repos & Workspace".padEnd(20))} ${chalk.dim("List / connect workspace repositories")}`);
1101
1161
  console.log(` ${chalk.cyan.bold("q")} ${chalk.red("Exit".padEnd(20))} ${chalk.dim("Quit")}`);
1102
1162
  console.log("");
1103
1163
  }
@@ -1120,11 +1180,13 @@ async function interactiveMode() {
1120
1180
  // `vexp setup` still get a working state (daemon + MCP :7821 up, OS
1121
1181
  // autostart installed for the next reboot). All steps are idempotent so
1122
1182
  // running this alongside `vexp setup` never creates duplicates.
1123
- const workspaceRoot = findConfiguredWorkspace();
1124
- if (workspaceRoot) {
1183
+ const eff = findEffectiveWorkspace();
1184
+ if (eff) {
1125
1185
  try {
1126
1186
  const binaryPath = getBinaryPath();
1127
- await ensureBootstrap(workspaceRoot, binaryPath);
1187
+ // Effective root: in a connected secondary repo this boots the PARENT
1188
+ // daemon (which serves this repo too) instead of a conflicting child one.
1189
+ await ensureBootstrap(eff.root, binaryPath);
1128
1190
  }
1129
1191
  catch { /* banner will reflect actual state */ }
1130
1192
  }
@@ -1139,6 +1201,7 @@ async function interactiveMode() {
1139
1201
  explore: chalk.cyan.bold(" vexp/explore> "),
1140
1202
  savings: chalk.cyan.bold(" vexp/savings> "),
1141
1203
  license: chalk.cyan.bold(" vexp/license> "),
1204
+ repos: chalk.cyan.bold(" vexp/repos> "),
1142
1205
  };
1143
1206
  while (true) {
1144
1207
  const input = (await ask(rl, prompts[currentMenu])).trim().toLowerCase();
@@ -1174,8 +1237,13 @@ async function interactiveMode() {
1174
1237
  printSubMenu("License", MENU_LICENSE);
1175
1238
  continue;
1176
1239
  }
1240
+ if (input === "5" || input === "repos") {
1241
+ currentMenu = "repos";
1242
+ printSubMenu("Repos & Workspace", MENU_REPOS);
1243
+ continue;
1244
+ }
1177
1245
  // Allow direct command names from any sub-menu
1178
- const allItems = [...MENU_CONFIG, ...MENU_EXPLORE, ...MENU_SAVINGS, ...MENU_LICENSE];
1246
+ const allItems = [...MENU_CONFIG, ...MENU_EXPLORE, ...MENU_SAVINGS, ...MENU_LICENSE, ...MENU_REPOS];
1179
1247
  const direct = allItems.find((m) => m.label === input);
1180
1248
  if (direct) {
1181
1249
  await executeCommand(direct.label, rl);
@@ -1183,12 +1251,12 @@ async function interactiveMode() {
1183
1251
  printMainMenu();
1184
1252
  continue;
1185
1253
  }
1186
- console.log(chalk.dim(" Choose 1-4 or type a command name. 'q' to exit.\n"));
1254
+ console.log(chalk.dim(" Choose 1-5 or type a command name. 'q' to exit.\n"));
1187
1255
  continue;
1188
1256
  }
1189
1257
  // ── Sub-menus ──
1190
- const menuMap = { config: MENU_CONFIG, explore: MENU_EXPLORE, savings: MENU_SAVINGS, license: MENU_LICENSE };
1191
- const titles = { config: "Setup & Config", explore: "Explore & Analyze", savings: "Token Savings", license: "License" };
1258
+ const menuMap = { config: MENU_CONFIG, explore: MENU_EXPLORE, savings: MENU_SAVINGS, license: MENU_LICENSE, repos: MENU_REPOS };
1259
+ const titles = { config: "Setup & Config", explore: "Explore & Analyze", savings: "Token Savings", license: "License", repos: "Repos & Workspace" };
1192
1260
  if (input === "b" || input === "back") {
1193
1261
  currentMenu = "main";
1194
1262
  printMainMenu();
@@ -1225,7 +1293,27 @@ async function executeCommand(label, rl) {
1225
1293
  }
1226
1294
  case "index": {
1227
1295
  const binaryPath = ensureBinary();
1228
- runBinary(binaryPath, ["index"]);
1296
+ // Workspace-aware: re-index every connected repo, not just the cwd
1297
+ // one. From a connected child this targets the parent workspace, so
1298
+ // the result matches what the daemon serves.
1299
+ const eff = findEffectiveWorkspace();
1300
+ const repos = eff ? listWorkspaceRepos(eff.root) : [];
1301
+ if (!eff || repos.length === 0) {
1302
+ runBinary(binaryPath, ["index"]);
1303
+ break;
1304
+ }
1305
+ if (eff.parent) {
1306
+ console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — indexing all its repos`));
1307
+ }
1308
+ console.log(chalk.dim(` Indexing ${repos.length} workspace repo(s)…\n`));
1309
+ for (const r of repos) {
1310
+ if (!r.exists) {
1311
+ console.log(chalk.yellow(` ⚠ Skipping "${r.alias}" — directory not found: ${shortPath(r.resolvedPath)}`));
1312
+ continue;
1313
+ }
1314
+ console.log(chalk.bold(` ● ${r.alias}`));
1315
+ runBinary(binaryPath, ["index", r.resolvedPath]);
1316
+ }
1229
1317
  break;
1230
1318
  }
1231
1319
  case "setup": {
@@ -1239,15 +1327,23 @@ async function executeCommand(label, rl) {
1239
1327
  case "daemon-cmd": {
1240
1328
  // Sticky sub-sub-menu: stay here until user types 'b' (back).
1241
1329
  const binaryPath = ensureBinary();
1242
- const valid = new Set(["start", "stop", "status", "logs"]);
1330
+ const valid = new Set(["start", "stop", "restart", "status", "logs"]);
1331
+ // Inside a connected secondary repo every action targets the parent
1332
+ // workspace daemon — running one here would fight over the same DBs.
1333
+ const eff = findEffectiveWorkspace();
1334
+ if (eff?.parent) {
1335
+ console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — daemon commands target the parent (${shortPath(eff.root)})`));
1336
+ }
1337
+ const daemonCwd = eff?.parent ? { cwd: eff.root } : undefined;
1243
1338
  const printDaemonMenu = () => {
1244
1339
  console.log("");
1245
1340
  console.log(chalk.cyan(" ── Daemon ──"));
1246
- console.log(` ${chalk.bold("start")} ${chalk.dim("Launch the daemon for this workspace")}`);
1247
- console.log(` ${chalk.bold("stop")} ${chalk.dim("Stop the running daemon")}`);
1248
- console.log(` ${chalk.bold("status")} ${chalk.dim("Show node/edge/file counts and uptime")}`);
1249
- console.log(` ${chalk.bold("logs")} ${chalk.dim("Tail .vexp/vexp.log")}`);
1250
- console.log(` ${chalk.dim("b")} ${chalk.dim("Back to Setup & Config")}`);
1341
+ console.log(` ${chalk.bold("start")} ${chalk.dim("Launch the daemon for this workspace")}`);
1342
+ console.log(` ${chalk.bold("stop")} ${chalk.dim("Stop the running daemon")}`);
1343
+ console.log(` ${chalk.bold("restart")} ${chalk.dim("Stop + start (reloads workspace.json, indexes new repos)")}`);
1344
+ console.log(` ${chalk.bold("status")} ${chalk.dim("Show node/edge/file counts and uptime")}`);
1345
+ console.log(` ${chalk.bold("logs")} ${chalk.dim("Tail .vexp/vexp.log")}`);
1346
+ console.log(` ${chalk.dim("b")} ${chalk.dim("Back to Setup & Config")}`);
1251
1347
  console.log("");
1252
1348
  };
1253
1349
  printDaemonMenu();
@@ -1258,11 +1354,11 @@ async function executeCommand(label, rl) {
1258
1354
  if (action === "b" || action === "back")
1259
1355
  break;
1260
1356
  if (valid.has(action)) {
1261
- runBinary(binaryPath, ["daemon-cmd", action]);
1357
+ runBinary(binaryPath, ["daemon-cmd", action], daemonCwd);
1262
1358
  printDaemonMenu();
1263
1359
  continue;
1264
1360
  }
1265
- console.log(chalk.dim(" Unknown action. Use start, stop, status, logs, or 'b' to go back."));
1361
+ console.log(chalk.dim(" Unknown action. Use start, stop, restart, status, logs, or 'b' to go back."));
1266
1362
  }
1267
1363
  break;
1268
1364
  }
@@ -1383,6 +1479,79 @@ async function executeCommand(label, rl) {
1383
1479
  }
1384
1480
  break;
1385
1481
  }
1482
+ // ── Repos & Workspace commands ──
1483
+ case "list": {
1484
+ const eff = findEffectiveWorkspace();
1485
+ if (!eff) {
1486
+ console.log(chalk.yellow(" No configured workspace here — run setup first (1 > 3, or `vexp setup`)."));
1487
+ break;
1488
+ }
1489
+ if (eff.parent) {
1490
+ console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — showing the repos of ${shortPath(eff.root)}`));
1491
+ }
1492
+ const repos = listWorkspaceRepos(eff.root);
1493
+ if (repos.length === 0) {
1494
+ console.log(chalk.dim(" Single-repo workspace (no .vexp/workspace.json yet)."));
1495
+ console.log(chalk.dim(" Use 'add' to connect another repository."));
1496
+ break;
1497
+ }
1498
+ console.log("");
1499
+ for (const r of repos) {
1500
+ const marker = r.isPrimary ? chalk.cyan(" (primary)") : "";
1501
+ const missing = r.exists ? "" : chalk.red(" [missing]");
1502
+ console.log(` ${chalk.green("●")} ${chalk.bold(r.alias.padEnd(20))} ${chalk.dim(shortPath(r.resolvedPath))}${marker}${missing}`);
1503
+ }
1504
+ console.log(chalk.dim(`\n ${repos.length} repo(s) in workspace "${path.basename(eff.root)}"`));
1505
+ break;
1506
+ }
1507
+ case "add": {
1508
+ const eff = findEffectiveWorkspace();
1509
+ if (!eff) {
1510
+ console.log(chalk.yellow(" No configured workspace here — run setup first (1 > 3, or `vexp setup`)."));
1511
+ break;
1512
+ }
1513
+ if (eff.parent) {
1514
+ console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — the repository will be added to ${shortPath(eff.root)}`));
1515
+ }
1516
+ const repoPath = (await ask(rl, chalk.cyan(" Path of the repository to connect: "))).trim();
1517
+ if (!repoPath)
1518
+ break;
1519
+ const limits = readLicenseLimits();
1520
+ const result = addRepoToWorkspace(eff.root, repoPath, limits.maxRepos);
1521
+ if (result.status === "invalid") {
1522
+ console.log(chalk.red(` ✗ ${result.reason}`));
1523
+ break;
1524
+ }
1525
+ if (result.status === "limit") {
1526
+ console.log(chalk.yellow(` Your ${limits.plan} plan supports up to ${result.maxRepos} repo${result.maxRepos === 1 ? "" : "s"}.`));
1527
+ console.log(chalk.dim(" Upgrade at https://vexp.dev/#pricing to add more repositories."));
1528
+ break;
1529
+ }
1530
+ if (result.status === "exists") {
1531
+ console.log(chalk.dim(` "${result.alias}" is already part of this workspace.`));
1532
+ break;
1533
+ }
1534
+ console.log(chalk.green(` ✓ Added "${result.alias}" to workspace "${path.basename(eff.root)}"`));
1535
+ // A live 2.2.3+ daemon hot-reloads workspace.json (2s poll): the new
1536
+ // repo is indexed and watched automatically, no restart needed. Only
1537
+ // when the daemon is down do we offer to start it.
1538
+ if (await isSocketAlive(socketPathFor(eff.root))) {
1539
+ console.log(chalk.dim(` The running daemon will detect the change and index "${result.alias}" automatically within seconds.`));
1540
+ }
1541
+ else {
1542
+ const yn = (await ask(rl, chalk.cyan(` Start the daemon now to index "${result.alias}"? [Y/n]: `))).trim().toLowerCase();
1543
+ if (yn === "" || yn === "y" || yn === "yes") {
1544
+ const binaryPath = ensureBinary();
1545
+ console.log(chalk.dim(" Starting vexp daemon…\n"));
1546
+ runBinary(binaryPath, ["daemon-cmd", "start"], { cwd: eff.root });
1547
+ console.log(chalk.green(` ✓ Now indexing "${result.alias}".`));
1548
+ }
1549
+ else {
1550
+ console.log(chalk.dim(` Run 'vexp daemon-cmd start' in ${shortPath(eff.root)} later to index it.`));
1551
+ }
1552
+ }
1553
+ break;
1554
+ }
1386
1555
  // ── License commands ──
1387
1556
  case "status": {
1388
1557
  const limits = readLicenseLimits();