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.
package/dist/cli.js CHANGED
@@ -12,11 +12,21 @@ import { detectAgents, getAgentList, configureSelectedAgents } from "./agent-con
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";
15
+ import { ensureMcpHttpServer, mcpHttpStatus } from "./mcp-supervisor.js";
16
+ import { installAutostart, installAutostartIfNeeded, uninstallAutostart, autostartStatus } from "./autostart.js";
17
+ import { runServe } from "./serve.js";
18
+ import { isTraceEnabled } from "./trace.js";
15
19
  const program = new Command();
16
20
  program
17
21
  .name("vexp")
18
22
  .description("Vexp — Graph-RAG context engine for AI coding agents")
19
- .version(CLI_VERSION);
23
+ .version(CLI_VERSION)
24
+ .option("-i, --trace", "Verbose trace mode: print every step with timing (IPs/tokens redacted)");
25
+ // Propagate -i / --trace to spawned children via env var so subprocess
26
+ // (daemon, MCP, serve) inherits the trace flag.
27
+ if (isTraceEnabled()) {
28
+ process.env.VEXP_TRACE = "1";
29
+ }
20
30
  // ────────────────────────────────────────────────────
21
31
  // Auto-start daemon before any action that needs it
22
32
  // ────────────────────────────────────────────────────
@@ -27,6 +37,7 @@ program
27
37
  // workspace was already configured (marker: .vexp/manifest.json).
28
38
  const AUTOSTART_SKIP = new Set([
29
39
  "setup", "daemon-cmd", "activate", "deactivate", "license", "version",
40
+ "serve", "autostart", "use",
30
41
  ]);
31
42
  program.hook("preAction", async (_thisCmd, actionCmd) => {
32
43
  if (process.env.VEXP_NO_AUTOSTART === "1")
@@ -43,7 +54,9 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
43
54
  catch {
44
55
  return;
45
56
  }
46
- await ensureDaemonRunning(workspaceRoot, binaryPath);
57
+ // Full bootstrap: daemon + MCP + autostart-if-needed. All three steps are
58
+ // internally idempotent — no duplicate processes across invocations.
59
+ await ensureBootstrap(workspaceRoot, binaryPath);
47
60
  });
48
61
  // ────────────────────────────────────────────────────
49
62
  // Resolve the platform binary (no network — bundled via npm)
@@ -133,15 +146,6 @@ function findConfiguredWorkspace(startDir = process.cwd()) {
133
146
  current = parent;
134
147
  }
135
148
  }
136
- /** Quick TCP probe — true if something is already listening on 127.0.0.1:port. */
137
- function isPortInUse(port) {
138
- return new Promise((resolve) => {
139
- const sock = net.createConnection({ port, host: "127.0.0.1" });
140
- const timer = setTimeout(() => { sock.destroy(); resolve(false); }, 300);
141
- sock.on("connect", () => { clearTimeout(timer); sock.destroy(); resolve(true); });
142
- sock.on("error", () => { clearTimeout(timer); resolve(false); });
143
- });
144
- }
145
149
  /** True if the Unix socket at `socketPath` accepts a connection within `timeoutMs`. */
146
150
  function isSocketAlive(socketPath, timeoutMs = 300) {
147
151
  return new Promise((resolve) => {
@@ -199,6 +203,33 @@ async function ensureDaemonRunning(workspaceRoot, binaryPath) {
199
203
  console.error(chalk.yellow(` ⚠ Could not auto-start vexp daemon — see ${logPath}`));
200
204
  }
201
205
  }
206
+ /**
207
+ * Full lazy bootstrap for any `vexp` invocation. Each step is already
208
+ * idempotent on its own — calling this from multiple places (preAction,
209
+ * REPL, setup) can never spawn duplicates:
210
+ * - `ensureDaemonRunning` → socket probe, skips spawn if alive
211
+ * - `ensureMcpHttpServer` → ~/.vexp/mcp.pid + port probe, no-op if live
212
+ * - `installAutostartIfNeeded` → marker ~/.vexp/autostart-installed
213
+ *
214
+ * The point of this wrapper is to ensure users who upgraded via
215
+ * `npm update` and then just typed `vexp` (never re-running `setup`) still
216
+ * get: daemon up, MCP :7821 up, OS autostart installed for future reboots.
217
+ *
218
+ * Honors VEXP_NO_AUTOSTART=1 as a global bypass.
219
+ */
220
+ async function ensureBootstrap(workspaceRoot, binaryPath) {
221
+ if (process.env.VEXP_NO_AUTOSTART === "1")
222
+ return;
223
+ await ensureDaemonRunning(workspaceRoot, binaryPath);
224
+ try {
225
+ await ensureMcpHttpServer({ owner: "cli" });
226
+ }
227
+ catch { /* non-fatal */ }
228
+ try {
229
+ installAutostartIfNeeded();
230
+ }
231
+ catch { /* non-fatal */ }
232
+ }
202
233
  /**
203
234
  * Start the vexp daemon and MCP HTTP server in the background.
204
235
  * Used by both the non-interactive `setup` command and the REPL setup flow.
@@ -222,52 +253,28 @@ async function startBackgroundServices(binaryPath, workspaceRoot) {
222
253
  catch {
223
254
  spinnerDaemon.warn("Daemon already running or failed to start (non-fatal)");
224
255
  }
225
- // Start MCP HTTP server (needed for Codex and HTTP-based agents).
226
- // Detect collisions: VS Code plugin may have already bound :7821.
227
- const mcpPath = getMcpServerPath();
228
- if (mcpPath) {
229
- const spinnerMcp = ora("Starting MCP HTTP server...").start();
230
- if (await isPortInUse(7821)) {
231
- spinnerMcp.succeed("MCP HTTP server already running on port 7821");
256
+ // Start MCP HTTP server via the shared supervisor singleton across
257
+ // CLI, VS Code plugin, and `vexp serve` (tracked by ~/.vexp/mcp.pid).
258
+ // Workspace-agnostic: routing is done via URL path /ws/<hash>/mcp.
259
+ const spinnerMcp = ora("Starting MCP HTTP server...").start();
260
+ try {
261
+ const result = await ensureMcpHttpServer({ owner: "cli" });
262
+ if (!result) {
263
+ spinnerMcp.warn("MCP HTTP server not available (missing mcp-server bundle)");
264
+ }
265
+ else if (result.pid === -1) {
266
+ spinnerMcp.warn(`Port ${result.port} already bound by an unknown process`);
267
+ }
268
+ else if (result.started) {
269
+ spinnerMcp.succeed(`MCP HTTP server started (pid=${result.pid}, port=${result.port})`);
232
270
  }
233
271
  else {
234
- try {
235
- const { randomUUID } = await import("crypto");
236
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
237
- let mcpToken = "";
238
- const tokenPath = home ? path.join(home, ".vexp", "mcp.token") : "";
239
- if (tokenPath) {
240
- try {
241
- mcpToken = fs.readFileSync(tokenPath, "utf-8").trim();
242
- }
243
- catch { }
244
- }
245
- if (!mcpToken) {
246
- mcpToken = randomUUID();
247
- if (tokenPath) {
248
- fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
249
- fs.writeFileSync(tokenPath, mcpToken, { mode: 0o600 });
250
- }
251
- }
252
- const mcpChild = spawn("node", [mcpPath, "--http", "--port=7821"], {
253
- detached: true,
254
- stdio: "ignore",
255
- env: {
256
- ...process.env,
257
- VEXP_WORKSPACE: workspaceRoot,
258
- VEXP_HTTP: "1",
259
- VEXP_PORT: "7821",
260
- VEXP_MCP_TOKEN: mcpToken,
261
- },
262
- });
263
- mcpChild.unref();
264
- spinnerMcp.succeed("MCP HTTP server started (port 7821)");
265
- }
266
- catch {
267
- spinnerMcp.warn("MCP HTTP server failed to start (non-fatal)");
268
- }
272
+ spinnerMcp.succeed(`MCP HTTP server already running (pid=${result.pid}, port=${result.port})`);
269
273
  }
270
274
  }
275
+ catch (err) {
276
+ spinnerMcp.warn(`MCP HTTP server failed to start: ${err instanceof Error ? err.message : err}`);
277
+ }
271
278
  }
272
279
  // ────────────────────────────────────────────────────
273
280
  // Commands
@@ -602,6 +609,22 @@ program
602
609
  }
603
610
  }
604
611
  }
612
+ // Step 5: Install login autostart (per-user, no admin required)
613
+ if (process.env.VEXP_NO_AUTOSTART_INSTALL !== "1") {
614
+ const spinnerAuto = ora("Installing login autostart...").start();
615
+ try {
616
+ const r = installAutostart();
617
+ if (r.installed) {
618
+ spinnerAuto.succeed(`Login autostart installed (${r.mechanism})`);
619
+ }
620
+ else {
621
+ spinnerAuto.warn("Autostart not installed on this platform");
622
+ }
623
+ }
624
+ catch (err) {
625
+ spinnerAuto.warn(`Autostart install failed: ${err instanceof Error ? err.message : err}`);
626
+ }
627
+ }
605
628
  // Final summary
606
629
  console.log("");
607
630
  console.log(chalk.green.bold("✓ vexp setup complete!"));
@@ -743,6 +766,15 @@ async function runSetupInteractive(rl) {
743
766
  }
744
767
  console.log(chalk.green(`\n ✓ Setup complete — ${result.agents.length} agent(s) configured\n`));
745
768
  }
769
+ // Login autostart — opt-out via env
770
+ if (process.env.VEXP_NO_AUTOSTART_INSTALL !== "1") {
771
+ try {
772
+ const r = installAutostart();
773
+ if (r.installed)
774
+ console.log(chalk.green(` ✓ Login autostart installed (${r.mechanism})`));
775
+ }
776
+ catch { /* non-fatal */ }
777
+ }
746
778
  }
747
779
  // ────────────────────────────────────────────────────
748
780
  // License management
@@ -804,6 +836,80 @@ program
804
836
  console.log("");
805
837
  });
806
838
  // ────────────────────────────────────────────────────
839
+ // Background supervisor + OS autostart
840
+ // ────────────────────────────────────────────────────
841
+ program
842
+ .command("serve")
843
+ .description("Run the login supervisor: resurrect known daemons + keep MCP HTTP alive (used by autostart)")
844
+ .action(async () => {
845
+ await runServe();
846
+ });
847
+ program
848
+ .command("autostart <action>")
849
+ .description("Manage login autostart: install | uninstall | status")
850
+ .action(async (action) => {
851
+ if (action === "install") {
852
+ const r = installAutostart();
853
+ if (r.installed) {
854
+ console.log(chalk.green(`✓ Autostart installed via ${r.mechanism}`));
855
+ if (r.path)
856
+ console.log(chalk.dim(` ${r.path}`));
857
+ }
858
+ else {
859
+ console.log(chalk.yellow("⚠ Autostart not installed (unsupported platform or insufficient permissions)"));
860
+ }
861
+ }
862
+ else if (action === "uninstall") {
863
+ uninstallAutostart();
864
+ console.log(chalk.yellow("✓ Autostart removed (daemons already running are not killed)"));
865
+ }
866
+ else if (action === "status") {
867
+ const s = autostartStatus();
868
+ const mcp = mcpHttpStatus();
869
+ console.log(chalk.bold("\nvexp Autostart Status\n"));
870
+ if (s.installed) {
871
+ console.log(` Installed: ${chalk.green("yes")} (${s.mechanism})`);
872
+ if (s.path)
873
+ console.log(` Path: ${s.path}`);
874
+ }
875
+ else {
876
+ console.log(` Installed: ${chalk.dim("no")}`);
877
+ }
878
+ if (mcp.running) {
879
+ console.log(` MCP: ${chalk.green(`running pid=${mcp.pid} port=${mcp.port}`)} (${mcp.owner})`);
880
+ }
881
+ else {
882
+ console.log(` MCP: ${chalk.dim("not running")}`);
883
+ }
884
+ console.log("");
885
+ }
886
+ else {
887
+ console.error(chalk.red(`Unknown action: ${action}. Use install | uninstall | status`));
888
+ process.exit(1);
889
+ }
890
+ });
891
+ program
892
+ .command("use <workspacePath>")
893
+ .description("Switch codex (global MCP config) to point at a different configured workspace")
894
+ .action(async (workspacePath) => {
895
+ const resolved = path.resolve(workspacePath);
896
+ const manifest = path.join(resolved, ".vexp", "manifest.json");
897
+ if (!fs.existsSync(manifest)) {
898
+ console.error(chalk.red(`No .vexp/manifest.json at ${resolved}. Run 'vexp setup' there first.`));
899
+ process.exit(1);
900
+ }
901
+ const binaryPath = ensureBinary();
902
+ const mcpServerPath = getMcpServerPath();
903
+ // Import lazily so configureCodexGlobal's side effects (TOML rewrite + legacy cleanup) only fire here.
904
+ const { configureSelectedAgents } = await import("./agent-config.js");
905
+ const version = (await getInstalledVersion()) ?? CLI_VERSION;
906
+ // "Codex" is a no-op file-wise in non-detect mode but the shared flow
907
+ // also writes codex global config via configureCodexGlobal — pass an
908
+ // empty agent list so only the MCP side runs.
909
+ configureSelectedAgents(resolved, binaryPath, version, ["Codex"], mcpServerPath);
910
+ console.log(chalk.green(`✓ codex now points at ${resolved}`));
911
+ });
912
+ // ────────────────────────────────────────────────────
807
913
  // Version
808
914
  // ────────────────────────────────────────────────────
809
915
  program
@@ -857,7 +963,7 @@ function shortPath(p) {
857
963
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
858
964
  return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
859
965
  }
860
- function printBanner() {
966
+ async function printBanner() {
861
967
  const version = CLI_VERSION;
862
968
  console.log("");
863
969
  console.log(chalk.cyan.bold(" ██╗ ██╗███████╗██╗ ██╗██████╗"));
@@ -869,23 +975,33 @@ function printBanner() {
869
975
  console.log("");
870
976
  console.log(chalk.dim(" Context Engine for AI Coding Agents"));
871
977
  console.log("");
872
- // ── Daemon status line (3 states, symmetric to LLM) ──
978
+ // ── Daemon status line ──
979
+ //
980
+ // Source of truth is the socket (same signal as `daemon-cmd status`).
981
+ // Falling back to `.vexp/daemon.pid` + `kill(pid,0)` produces inconsistent
982
+ // UX after reboot: the file persists with a dead pid, `kill` fails, the
983
+ // banner says "not running", but the Rust daemon-cmd status probes the
984
+ // socket and reports "running". Using the socket here makes the banner
985
+ // agree with status.
873
986
  const ws = findConfiguredWorkspace();
874
987
  if (!ws) {
875
988
  console.log(chalk.dim(" ○ Daemon: no workspace configured here"));
876
989
  console.log(chalk.dim(" → type '1 > 3' to run full setup, or `vexp setup`"));
877
990
  }
878
991
  else {
879
- const { alive, pid } = isDaemonPidAlive(ws);
880
- if (alive && pid) {
881
- // Derive uptime from daemon.pid mtime (portable, no /proc parsing).
992
+ const socketPath = path.join(ws, ".vexp", "daemon.sock");
993
+ const socketAlive = await isSocketAlive(socketPath);
994
+ if (socketAlive) {
995
+ // Display PID + uptime when we can read them; otherwise a plain "running".
996
+ const { pid } = isDaemonPidAlive(ws);
882
997
  let uptime = "";
883
998
  try {
884
999
  const st = fs.statSync(path.join(ws, ".vexp", "daemon.pid"));
885
1000
  uptime = `, uptime ${formatUptime((Date.now() - st.mtimeMs) / 1000)}`;
886
1001
  }
887
1002
  catch { /* non-fatal */ }
888
- console.log(chalk.green(` ● Daemon: running (PID ${pid}${uptime}) ${shortPath(ws)}`));
1003
+ const pidPart = pid ? `PID ${pid}${uptime}` : "socket alive";
1004
+ console.log(chalk.green(` ● Daemon: running (${pidPart}) — ${shortPath(ws)}`));
889
1005
  }
890
1006
  else {
891
1007
  console.log(chalk.yellow(" ○ Daemon: not running"));
@@ -944,18 +1060,19 @@ function ask(rl, prompt) {
944
1060
  }
945
1061
  async function interactiveMode() {
946
1062
  const readline = await import("readline");
947
- // Auto-start the daemon (if workspace configured) before printing the
948
- // banner, so the banner's Daemon line reflects the correct final state.
949
- // Silent when already running or no workspace configured.
1063
+ // Full bootstrap so users who upgraded the CLI without re-running
1064
+ // `vexp setup` still get a working state (daemon + MCP :7821 up, OS
1065
+ // autostart installed for the next reboot). All steps are idempotent so
1066
+ // running this alongside `vexp setup` never creates duplicates.
950
1067
  const workspaceRoot = findConfiguredWorkspace();
951
1068
  if (workspaceRoot) {
952
1069
  try {
953
1070
  const binaryPath = getBinaryPath();
954
- await ensureDaemonRunning(workspaceRoot, binaryPath);
1071
+ await ensureBootstrap(workspaceRoot, binaryPath);
955
1072
  }
956
1073
  catch { /* banner will reflect actual state */ }
957
1074
  }
958
- printBanner();
1075
+ await printBanner();
959
1076
  printMainMenu();
960
1077
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
961
1078
  rl.on("close", () => { console.log(""); process.exit(0); });
@@ -1006,34 +1123,39 @@ async function interactiveMode() {
1006
1123
  const direct = allItems.find((m) => m.label === input);
1007
1124
  if (direct) {
1008
1125
  await executeCommand(direct.label, rl);
1126
+ // After a direct command, reprint the main menu so options stay visible.
1127
+ printMainMenu();
1009
1128
  continue;
1010
1129
  }
1011
1130
  console.log(chalk.dim(" Choose 1-4 or type a command name. 'q' to exit.\n"));
1012
1131
  continue;
1013
1132
  }
1014
1133
  // ── Sub-menus ──
1134
+ const menuMap = { config: MENU_CONFIG, explore: MENU_EXPLORE, savings: MENU_SAVINGS, license: MENU_LICENSE };
1135
+ const titles = { config: "Setup & Config", explore: "Explore & Analyze", savings: "Token Savings", license: "License" };
1015
1136
  if (input === "b" || input === "back") {
1016
1137
  currentMenu = "main";
1017
1138
  printMainMenu();
1018
1139
  continue;
1019
1140
  }
1020
1141
  if (input === "?" || input === "help" || input === "menu") {
1021
- const menus = { config: MENU_CONFIG, explore: MENU_EXPLORE, savings: MENU_SAVINGS, license: MENU_LICENSE };
1022
- const titles = { config: "Setup & Config", explore: "Explore & Analyze", savings: "Token Savings", license: "License" };
1023
- printSubMenu(titles[currentMenu], menus[currentMenu]);
1142
+ printSubMenu(titles[currentMenu], menuMap[currentMenu]);
1024
1143
  continue;
1025
1144
  }
1026
- const menuMap = { config: MENU_CONFIG, explore: MENU_EXPLORE, savings: MENU_SAVINGS, license: MENU_LICENSE };
1027
1145
  const items = menuMap[currentMenu];
1028
1146
  const item = items.find((m) => m.key === input || m.label === input);
1029
1147
  if (!item) {
1030
1148
  console.log(chalk.dim(` Unknown option. Type a number (1-${items.length}), 'b' to go back, or 'q' to exit.\n`));
1149
+ printSubMenu(titles[currentMenu], items);
1031
1150
  continue;
1032
1151
  }
1033
1152
  // Show description before executing
1034
1153
  console.log(chalk.dim(`\n ${item.description}\n`));
1035
1154
  await executeCommand(item.label, rl);
1036
1155
  console.log("");
1156
+ // Sticky sub-menu: reprint the current sub-menu after every command so
1157
+ // the user always sees the available options without typing '?'.
1158
+ printSubMenu(titles[currentMenu], items);
1037
1159
  }
1038
1160
  }
1039
1161
  async function executeCommand(label, rl) {
@@ -1059,49 +1181,80 @@ async function executeCommand(label, rl) {
1059
1181
  break;
1060
1182
  }
1061
1183
  case "daemon-cmd": {
1062
- const action = await ask(rl, chalk.cyan(" Action (start/stop/status/logs): "));
1063
- const valid = ["start", "stop", "status", "logs"];
1064
- if (valid.includes(action.trim())) {
1065
- const binaryPath = ensureBinary();
1066
- runBinary(binaryPath, ["daemon-cmd", action.trim()]);
1067
- }
1068
- else {
1069
- console.log(chalk.dim(" Cancelled."));
1184
+ // Sticky sub-sub-menu: stay here until user types 'b' (back).
1185
+ const binaryPath = ensureBinary();
1186
+ const valid = new Set(["start", "stop", "status", "logs"]);
1187
+ const printDaemonMenu = () => {
1188
+ console.log("");
1189
+ console.log(chalk.cyan(" ── Daemon ──"));
1190
+ console.log(` ${chalk.bold("start")} ${chalk.dim("Launch the daemon for this workspace")}`);
1191
+ console.log(` ${chalk.bold("stop")} ${chalk.dim("Stop the running daemon")}`);
1192
+ console.log(` ${chalk.bold("status")} ${chalk.dim("Show node/edge/file counts and uptime")}`);
1193
+ console.log(` ${chalk.bold("logs")} ${chalk.dim("Tail .vexp/vexp.log")}`);
1194
+ console.log(` ${chalk.dim("b")} ${chalk.dim("Back to Setup & Config")}`);
1195
+ console.log("");
1196
+ };
1197
+ printDaemonMenu();
1198
+ while (true) {
1199
+ const action = (await ask(rl, chalk.cyan(" daemon> "))).trim().toLowerCase();
1200
+ if (!action)
1201
+ continue;
1202
+ if (action === "b" || action === "back")
1203
+ break;
1204
+ if (valid.has(action)) {
1205
+ runBinary(binaryPath, ["daemon-cmd", action]);
1206
+ printDaemonMenu();
1207
+ continue;
1208
+ }
1209
+ console.log(chalk.dim(" Unknown action. Use start, stop, status, logs, or 'b' to go back."));
1070
1210
  }
1071
1211
  break;
1072
1212
  }
1073
1213
  case "setup-llm": {
1074
- console.log("");
1075
- console.log(chalk.cyan(" Local LLM compression (vexp-devmind)"));
1076
- console.log(chalk.dim(" All processing runs locally — no data leaves your machine.\n"));
1077
- console.log(` ${chalk.bold("1")} Install / Reinstall (~3.5 GB download)`);
1078
- console.log(` ${chalk.bold("2")} Start (enable without re-downloading)`);
1079
- console.log(` ${chalk.bold("3")} Check for updates (no download if up to date)`);
1080
- console.log(` ${chalk.bold("4")} Status (show current config + hardware)`);
1081
- console.log(` ${chalk.bold("5")} Disable (keep files, stop using LLM)`);
1082
- console.log(` ${chalk.dim("b")} ${chalk.dim("Cancel")}`);
1083
- console.log("");
1084
- const llmChoice = await ask(rl, chalk.cyan(" Choose: "));
1214
+ // Sticky sub-sub-menu.
1085
1215
  const llmBinary = ensureBinary();
1086
- switch (llmChoice.trim()) {
1087
- case "1":
1088
- console.log(chalk.dim(" Starting LLM setup (this may take several minutes)…\n"));
1089
- runBinary(llmBinary, ["setup-llm", "--install"]);
1090
- break;
1091
- case "2":
1092
- runBinary(llmBinary, ["setup-llm", "--enable"]);
1093
- break;
1094
- case "3":
1095
- runBinary(llmBinary, ["setup-llm", "--check-updates"]);
1096
- break;
1097
- case "4":
1098
- runBinary(llmBinary, ["setup-llm", "--status"]);
1099
- break;
1100
- case "5":
1101
- runBinary(llmBinary, ["setup-llm", "--disable"]);
1216
+ const printLlmMenu = () => {
1217
+ console.log("");
1218
+ console.log(chalk.cyan(" ── Local LLM (vexp-devmind) ──"));
1219
+ console.log(chalk.dim(" All processing runs locally — no data leaves your machine."));
1220
+ console.log("");
1221
+ console.log(` ${chalk.bold("1")} Install / Reinstall ${chalk.dim("(~3.5 GB download)")}`);
1222
+ console.log(` ${chalk.bold("2")} Start ${chalk.dim("(enable without re-downloading)")}`);
1223
+ console.log(` ${chalk.bold("3")} Check for updates ${chalk.dim("(no download if up to date)")}`);
1224
+ console.log(` ${chalk.bold("4")} Status ${chalk.dim("(current config + hardware)")}`);
1225
+ console.log(` ${chalk.bold("5")} Disable ${chalk.dim("(keep files, stop using LLM)")}`);
1226
+ console.log(` ${chalk.dim("b")} ${chalk.dim("Back to Setup & Config")}`);
1227
+ console.log("");
1228
+ };
1229
+ printLlmMenu();
1230
+ while (true) {
1231
+ const c = (await ask(rl, chalk.cyan(" llm> "))).trim().toLowerCase();
1232
+ if (!c)
1233
+ continue;
1234
+ if (c === "b" || c === "back")
1102
1235
  break;
1103
- default:
1104
- console.log(chalk.dim(" Cancelled."));
1236
+ switch (c) {
1237
+ case "1":
1238
+ console.log(chalk.dim(" Starting LLM setup (this may take several minutes)…\n"));
1239
+ runBinary(llmBinary, ["setup-llm", "--install"]);
1240
+ break;
1241
+ case "2":
1242
+ runBinary(llmBinary, ["setup-llm", "--enable"]);
1243
+ break;
1244
+ case "3":
1245
+ runBinary(llmBinary, ["setup-llm", "--check-updates"]);
1246
+ break;
1247
+ case "4":
1248
+ runBinary(llmBinary, ["setup-llm", "--status"]);
1249
+ break;
1250
+ case "5":
1251
+ runBinary(llmBinary, ["setup-llm", "--disable"]);
1252
+ break;
1253
+ default:
1254
+ console.log(chalk.dim(" Unknown option. Use 1-5 or 'b' to go back."));
1255
+ continue;
1256
+ }
1257
+ printLlmMenu();
1105
1258
  }
1106
1259
  break;
1107
1260
  }