vexp-cli 2.0.4 → 2.0.6

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
@@ -5,8 +5,9 @@ import ora from "ora";
5
5
  import { spawn, spawnSync, execFileSync } from "child_process";
6
6
  import * as path from "path";
7
7
  import * as fs from "fs";
8
+ import * as net from "net";
8
9
  import { checkbox } from "@inquirer/prompts";
9
- import { getBinaryPath, getInstalledVersion, getMcpServerPath } from "./binary.js";
10
+ import { getBinaryPath, getInstalledVersion, getMcpServerPath, binaryEnv } from "./binary.js";
10
11
  import { detectAgents, getAgentList, configureSelectedAgents } from "./agent-config.js";
11
12
  import { CLI_VERSION } from "./version.js";
12
13
  import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, } from "./license.js";
@@ -17,6 +18,34 @@ program
17
18
  .description("Vexp — Graph-RAG context engine for AI coding agents")
18
19
  .version(CLI_VERSION);
19
20
  // ────────────────────────────────────────────────────
21
+ // Auto-start daemon before any action that needs it
22
+ // ────────────────────────────────────────────────────
23
+ //
24
+ // Commands that either manage the daemon themselves (setup, daemon-cmd)
25
+ // or don't need it (license, version, activate, deactivate) are skipped.
26
+ // Everything else triggers a lightweight probe + silent spawn if the
27
+ // workspace was already configured (marker: .vexp/manifest.json).
28
+ const AUTOSTART_SKIP = new Set([
29
+ "setup", "daemon-cmd", "activate", "deactivate", "license", "version",
30
+ ]);
31
+ program.hook("preAction", async (_thisCmd, actionCmd) => {
32
+ if (process.env.VEXP_NO_AUTOSTART === "1")
33
+ return;
34
+ if (AUTOSTART_SKIP.has(actionCmd.name()))
35
+ return;
36
+ const workspaceRoot = findConfiguredWorkspace();
37
+ if (!workspaceRoot)
38
+ return;
39
+ let binaryPath;
40
+ try {
41
+ binaryPath = getBinaryPath();
42
+ }
43
+ catch {
44
+ return;
45
+ }
46
+ await ensureDaemonRunning(workspaceRoot, binaryPath);
47
+ });
48
+ // ────────────────────────────────────────────────────
20
49
  // Resolve the platform binary (no network — bundled via npm)
21
50
  // ────────────────────────────────────────────────────
22
51
  function ensureBinary() {
@@ -34,7 +63,7 @@ let replMode = false;
34
63
  function runBinary(binaryPath, args) {
35
64
  if (replMode) {
36
65
  // In REPL mode, run synchronously and don't exit the process
37
- const result = spawnSync(binaryPath, args, { stdio: "inherit" });
66
+ const result = spawnSync(binaryPath, args, { stdio: "inherit", env: binaryEnv(binaryPath) });
38
67
  if (result.error) {
39
68
  console.error(chalk.red(`Error: ${result.error.message}`));
40
69
  }
@@ -42,6 +71,7 @@ function runBinary(binaryPath, args) {
42
71
  }
43
72
  const child = spawn(binaryPath, args, {
44
73
  stdio: "inherit",
74
+ env: binaryEnv(binaryPath),
45
75
  });
46
76
  child.on("exit", (code) => {
47
77
  process.exit(code ?? 0);
@@ -56,6 +86,8 @@ function runBinaryAsync(binaryPath, args, options) {
56
86
  return new Promise((resolve, reject) => {
57
87
  const child = spawn(binaryPath, args, {
58
88
  stdio: options?.silent ? "pipe" : "inherit",
89
+ cwd: options?.cwd,
90
+ env: binaryEnv(binaryPath),
59
91
  });
60
92
  child.on("exit", (code) => {
61
93
  if (code === 0)
@@ -67,6 +99,160 @@ function runBinaryAsync(binaryPath, args, options) {
67
99
  });
68
100
  }
69
101
  // ────────────────────────────────────────────────────
102
+ // Background services (daemon + MCP HTTP bridge)
103
+ // ────────────────────────────────────────────────────
104
+ /**
105
+ * Walk up from cwd looking for `.vexp/manifest.json` — the marker that
106
+ * `vexp setup` has been run on this tree. Returns null if none found.
107
+ */
108
+ function findConfiguredWorkspace(startDir = process.cwd()) {
109
+ let current = path.resolve(startDir);
110
+ while (true) {
111
+ if (fs.existsSync(path.join(current, ".vexp", "manifest.json")))
112
+ return current;
113
+ const parent = path.dirname(current);
114
+ if (parent === current)
115
+ return null;
116
+ current = parent;
117
+ }
118
+ }
119
+ /** Quick TCP probe — true if something is already listening on 127.0.0.1:port. */
120
+ function isPortInUse(port) {
121
+ return new Promise((resolve) => {
122
+ const sock = net.createConnection({ port, host: "127.0.0.1" });
123
+ const timer = setTimeout(() => { sock.destroy(); resolve(false); }, 300);
124
+ sock.on("connect", () => { clearTimeout(timer); sock.destroy(); resolve(true); });
125
+ sock.on("error", () => { clearTimeout(timer); resolve(false); });
126
+ });
127
+ }
128
+ /** True if the Unix socket at `socketPath` accepts a connection within `timeoutMs`. */
129
+ function isSocketAlive(socketPath, timeoutMs = 300) {
130
+ return new Promise((resolve) => {
131
+ if (!fs.existsSync(socketPath)) {
132
+ resolve(false);
133
+ return;
134
+ }
135
+ const sock = net.createConnection({ path: socketPath });
136
+ const timer = setTimeout(() => { sock.destroy(); resolve(false); }, timeoutMs);
137
+ sock.on("connect", () => { clearTimeout(timer); sock.destroy(); resolve(true); });
138
+ sock.on("error", () => { clearTimeout(timer); resolve(false); });
139
+ });
140
+ }
141
+ /** Check if the PID recorded in .vexp/daemon.pid is a live process. */
142
+ function isDaemonPidAlive(workspaceRoot) {
143
+ const pidFile = path.join(workspaceRoot, ".vexp", "daemon.pid");
144
+ try {
145
+ const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
146
+ if (!pid || isNaN(pid))
147
+ return { alive: false };
148
+ try {
149
+ process.kill(pid, 0); // signal 0 = existence check, no actual signal
150
+ return { alive: true, pid };
151
+ }
152
+ catch {
153
+ return { alive: false };
154
+ }
155
+ }
156
+ catch {
157
+ return { alive: false };
158
+ }
159
+ }
160
+ /**
161
+ * Ensure the vexp-core daemon is running for the given workspace. Fast when
162
+ * it's already up (single socket probe). Idempotent lato Rust (PID file
163
+ * check + `kill -0`), so re-invoking when up is a no-op.
164
+ *
165
+ * Silent on success. On failure prints a one-liner pointing at the log.
166
+ * Honors VEXP_NO_AUTOSTART=1 as opt-out.
167
+ */
168
+ async function ensureDaemonRunning(workspaceRoot, binaryPath) {
169
+ if (process.env.VEXP_NO_AUTOSTART === "1")
170
+ return;
171
+ const socketPath = path.join(workspaceRoot, ".vexp", "daemon.sock");
172
+ if (await isSocketAlive(socketPath))
173
+ return;
174
+ try {
175
+ await runBinaryAsync(binaryPath, ["daemon-cmd", "start"], {
176
+ silent: true,
177
+ cwd: workspaceRoot,
178
+ });
179
+ }
180
+ catch {
181
+ const logPath = path.join(workspaceRoot, ".vexp", "daemon.log");
182
+ console.error(chalk.yellow(` ⚠ Could not auto-start vexp daemon — see ${logPath}`));
183
+ }
184
+ }
185
+ /**
186
+ * Start the vexp daemon and MCP HTTP server in the background.
187
+ * Used by both the non-interactive `setup` command and the REPL setup flow.
188
+ * Both services detach via setsid (Rust) / Node detached+unref, so they
189
+ * survive terminal close and VSCode restart.
190
+ */
191
+ async function startBackgroundServices(binaryPath, workspaceRoot) {
192
+ // Start daemon
193
+ const spinnerDaemon = ora("Starting vexp daemon...").start();
194
+ try {
195
+ await runBinaryAsync(binaryPath, ["daemon-cmd", "start"], { silent: true, cwd: workspaceRoot });
196
+ const socketPath = path.join(workspaceRoot, ".vexp", "daemon.sock");
197
+ if (fs.existsSync(socketPath)) {
198
+ spinnerDaemon.succeed("vexp daemon started");
199
+ }
200
+ else {
201
+ const logPath = path.join(workspaceRoot, ".vexp", "daemon.log");
202
+ spinnerDaemon.warn(`Daemon socket not ready — check ${logPath}`);
203
+ }
204
+ }
205
+ catch {
206
+ spinnerDaemon.warn("Daemon already running or failed to start (non-fatal)");
207
+ }
208
+ // Start MCP HTTP server (needed for Codex and HTTP-based agents).
209
+ // Detect collisions: VS Code plugin may have already bound :7821.
210
+ const mcpPath = getMcpServerPath();
211
+ if (mcpPath) {
212
+ const spinnerMcp = ora("Starting MCP HTTP server...").start();
213
+ if (await isPortInUse(7821)) {
214
+ spinnerMcp.succeed("MCP HTTP server already running on port 7821");
215
+ }
216
+ else {
217
+ try {
218
+ const { randomUUID } = await import("crypto");
219
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
220
+ let mcpToken = "";
221
+ const tokenPath = home ? path.join(home, ".vexp", "mcp.token") : "";
222
+ if (tokenPath) {
223
+ try {
224
+ mcpToken = fs.readFileSync(tokenPath, "utf-8").trim();
225
+ }
226
+ catch { }
227
+ }
228
+ if (!mcpToken) {
229
+ mcpToken = randomUUID();
230
+ if (tokenPath) {
231
+ fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
232
+ fs.writeFileSync(tokenPath, mcpToken, { mode: 0o600 });
233
+ }
234
+ }
235
+ const mcpChild = spawn("node", [mcpPath, "--http", "--port=7821"], {
236
+ detached: true,
237
+ stdio: "ignore",
238
+ env: {
239
+ ...process.env,
240
+ VEXP_WORKSPACE: workspaceRoot,
241
+ VEXP_HTTP: "1",
242
+ VEXP_PORT: "7821",
243
+ VEXP_MCP_TOKEN: mcpToken,
244
+ },
245
+ });
246
+ mcpChild.unref();
247
+ spinnerMcp.succeed("MCP HTTP server started (port 7821)");
248
+ }
249
+ catch {
250
+ spinnerMcp.warn("MCP HTTP server failed to start (non-fatal)");
251
+ }
252
+ }
253
+ }
254
+ }
255
+ // ────────────────────────────────────────────────────
70
256
  // Commands
71
257
  // ────────────────────────────────────────────────────
72
258
  program
@@ -185,7 +371,7 @@ program
185
371
  });
186
372
  program
187
373
  .command("compress <file>")
188
- .description("Compress a markdown/text file using caveman-style prose compression (backup to <name>.original.md)")
374
+ .description("Compress a markdown/text file using prose compression (backup to <name>.original.md)")
189
375
  .option("--level <lvl>", "Compression level: off | lite | full | ultra", "full")
190
376
  .action(async (file, opts) => {
191
377
  const binaryPath = ensureBinary();
@@ -307,55 +493,8 @@ program
307
493
  else {
308
494
  console.log(chalk.dim(" Skipping indexing (--no-index)"));
309
495
  }
310
- // Step 2.5: Start daemon in background (survives terminal close)
311
- const spinnerDaemon = ora("Starting vexp daemon...").start();
312
- try {
313
- await runBinaryAsync(binaryPath, ["daemon-cmd", "start"], { silent: true });
314
- spinnerDaemon.succeed("vexp daemon started");
315
- }
316
- catch {
317
- spinnerDaemon.warn("Daemon already running or failed to start (non-fatal)");
318
- }
319
- // Step 2.6: Start MCP HTTP server (needed for Codex and HTTP-based agents)
320
- const mcpPath = getMcpServerPath();
321
- if (mcpPath) {
322
- const spinnerMcp = ora("Starting MCP HTTP server...").start();
323
- try {
324
- const { randomUUID } = await import("crypto");
325
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
326
- let mcpToken = "";
327
- const tokenPath = home ? path.join(home, ".vexp", "mcp.token") : "";
328
- if (tokenPath) {
329
- try {
330
- mcpToken = fs.readFileSync(tokenPath, "utf-8").trim();
331
- }
332
- catch { }
333
- }
334
- if (!mcpToken) {
335
- mcpToken = randomUUID();
336
- if (tokenPath) {
337
- fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
338
- fs.writeFileSync(tokenPath, mcpToken, { mode: 0o600 });
339
- }
340
- }
341
- const mcpChild = spawn("node", [mcpPath, "--http", "--port=7821"], {
342
- detached: true,
343
- stdio: "ignore",
344
- env: {
345
- ...process.env,
346
- VEXP_WORKSPACE: workspaceRoot,
347
- VEXP_HTTP: "1",
348
- VEXP_PORT: "7821",
349
- VEXP_MCP_TOKEN: mcpToken,
350
- },
351
- });
352
- mcpChild.unref();
353
- spinnerMcp.succeed("MCP HTTP server started (port 7821)");
354
- }
355
- catch {
356
- spinnerMcp.warn("MCP HTTP server failed to start (non-fatal)");
357
- }
358
- }
496
+ // Step 2.5: Start daemon + MCP HTTP server (survive terminal close)
497
+ await startBackgroundServices(binaryPath, workspaceRoot);
359
498
  // In personal mode, make .vexp/ fully gitignored
360
499
  if (isPersonal) {
361
500
  const vexpGitignore = path.join(workspaceRoot, ".vexp", ".gitignore");
@@ -530,6 +669,8 @@ async function runSetupInteractive(rl) {
530
669
  // Step 1: Index
531
670
  console.log(chalk.dim(" Indexing codebase...\n"));
532
671
  runBinary(binaryPath, ["init", workspaceRoot]);
672
+ // Step 1.5: Start daemon + MCP HTTP server (parity with non-interactive setup)
673
+ await startBackgroundServices(binaryPath, workspaceRoot);
533
674
  // Step 2: Select agents
534
675
  const allAgents = getAgentList();
535
676
  const detected = detectAgents(workspaceRoot);
@@ -671,6 +812,21 @@ const MENU_LICENSE = [
671
812
  { key: "2", label: "activate", hint: "Activate a Pro/Team license key", description: "Enter a license key to unlock Pro or Team features.\n Get your key at https://vexp.dev/#pricing" },
672
813
  { key: "3", label: "deactivate", hint: "Remove current license", description: "Removes the active license. Free plan limits will apply." },
673
814
  ];
815
+ /** Format a duration as a compact uptime string (e.g. "2h 14m", "47s"). */
816
+ function formatUptime(seconds) {
817
+ if (seconds < 60)
818
+ return `${Math.floor(seconds)}s`;
819
+ if (seconds < 3600)
820
+ return `${Math.floor(seconds / 60)}m`;
821
+ if (seconds < 86400)
822
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
823
+ return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
824
+ }
825
+ /** Format a workspace path for display: shorten $HOME → ~. */
826
+ function shortPath(p) {
827
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
828
+ return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
829
+ }
674
830
  function printBanner() {
675
831
  const version = CLI_VERSION;
676
832
  console.log("");
@@ -682,21 +838,52 @@ function printBanner() {
682
838
  console.log(chalk.dim(" ╚═══╝ ╚══════╝╚═╝ ╚═╝╚═╝") + chalk.dim(` v${version}`));
683
839
  console.log("");
684
840
  console.log(chalk.dim(" Context Engine for AI Coding Agents"));
685
- // One-liner LLM status
841
+ console.log("");
842
+ // ── Daemon status line (3 states, symmetric to LLM) ──
843
+ const ws = findConfiguredWorkspace();
844
+ if (!ws) {
845
+ console.log(chalk.dim(" ○ Daemon: no workspace configured here"));
846
+ console.log(chalk.dim(" → type '1 > 3' to run full setup, or `vexp setup`"));
847
+ }
848
+ else {
849
+ const { alive, pid } = isDaemonPidAlive(ws);
850
+ if (alive && pid) {
851
+ // Derive uptime from daemon.pid mtime (portable, no /proc parsing).
852
+ let uptime = "";
853
+ try {
854
+ const st = fs.statSync(path.join(ws, ".vexp", "daemon.pid"));
855
+ uptime = `, uptime ${formatUptime((Date.now() - st.mtimeMs) / 1000)}`;
856
+ }
857
+ catch { /* non-fatal */ }
858
+ console.log(chalk.green(` ● Daemon: running (PID ${pid}${uptime}) — ${shortPath(ws)}`));
859
+ }
860
+ else {
861
+ console.log(chalk.yellow(" ○ Daemon: not running"));
862
+ console.log(chalk.dim(" → type '1 > 5 > start' to launch it, or `vexp daemon-cmd start`"));
863
+ }
864
+ }
865
+ // ── LLM status line (3 states: active / installed-not-active / not installed) ──
686
866
  try {
687
867
  const llmBin = ensureBinary();
688
868
  const stdout = execFileSync(llmBin, ["setup-llm", "--status"], {
689
869
  timeout: 5000,
690
870
  encoding: "utf-8",
691
871
  stdio: ["pipe", "pipe", "pipe"],
872
+ env: binaryEnv(llmBin),
692
873
  });
693
- const operational = /Operational:\s*true/.test(stdout);
874
+ const operational = /Operational:\s*true/i.test(stdout);
875
+ const installed = /Installed:\s*true/i.test(stdout);
694
876
  const model = stdout.match(/Model:\s*(.+)/)?.[1]?.trim() ?? "";
695
877
  if (operational) {
696
- console.log(chalk.green(` ● LLM: ${model} (active)`));
878
+ console.log(chalk.green(` ● LLM: ${model || "local"} (active)`));
879
+ }
880
+ else if (installed) {
881
+ console.log(chalk.yellow(" ○ LLM: installed but not active"));
882
+ console.log(chalk.dim(" → type '1 > 6 > start' to start it, or `vexp setup-llm --start`"));
697
883
  }
698
884
  else {
699
- console.log(chalk.dim(" ○ LLM: not configured — type 'setup-llm' or press 1 > 6"));
885
+ console.log(chalk.dim(" ○ LLM: not installed"));
886
+ console.log(chalk.dim(" → type '1 > 6 > install' to download (~3.5 GB), or `vexp setup-llm --install`"));
700
887
  }
701
888
  }
702
889
  catch {
@@ -727,6 +914,17 @@ function ask(rl, prompt) {
727
914
  }
728
915
  async function interactiveMode() {
729
916
  const readline = await import("readline");
917
+ // Auto-start the daemon (if workspace configured) before printing the
918
+ // banner, so the banner's Daemon line reflects the correct final state.
919
+ // Silent when already running or no workspace configured.
920
+ const workspaceRoot = findConfiguredWorkspace();
921
+ if (workspaceRoot) {
922
+ try {
923
+ const binaryPath = getBinaryPath();
924
+ await ensureDaemonRunning(workspaceRoot, binaryPath);
925
+ }
926
+ catch { /* banner will reflect actual state */ }
927
+ }
730
928
  printBanner();
731
929
  printMainMenu();
732
930
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });