vexp-cli 3.2.4 → 3.3.0

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,9 +8,11 @@ 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, resolveAgentName, suggestAgentName, setGuardMode, setInterventionMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets } from "./agent-config.js";
11
+ import { getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode, setInterventionMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets, takeCodexHookApprovals, codexHookApprovalStep, planAgentSetup, nonInteractiveAgentChoice, setupAgentsCommand } from "./agent-config.js";
12
+ import { recordAgentConsent } from "./agent-consent.js";
12
13
  import { CLI_VERSION } from "./version.js";
13
- import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, tryOnlineRefresh, } from "./license.js";
14
+ import { deactivateLicense, readLicenseLimits, readDeviceBlocked, refreshFromDisk, } from "./license.js";
15
+ import { runActivation, planLabel as licensePlanLabel, isLifetimePlan, printCheckInStatus, } from "./license-activate.js";
14
16
  import { checkForUpdate } from "./update-check.js";
15
17
  import { ensureMcpHttpServer, mcpHttpStatus } from "./mcp-supervisor.js";
16
18
  import { installAutostart, uninstallAutostart, autostartStatus, migrateClaudeUnpinIfNeeded } from "./autostart.js";
@@ -67,6 +69,13 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
67
69
  return;
68
70
  if (AUTOSTART_SKIP.has(actionCmd.name()))
69
71
  return;
72
+ // `capsule --explain` runs in the engine's own process, read-only, and
73
+ // promises to write nothing. Booting a daemon for it installed git hooks,
74
+ // wrote .vexp/.gitignore, synced the index and left a daemon running on a
75
+ // workspace whose daemon was down (review of the explain stream,
76
+ // 2026-09-25).
77
+ if (actionCmd.name() === "capsule" && (actionCmd.opts().explain ?? []).length > 0)
78
+ return;
70
79
  // Effective workspace: inside a connected secondary repo this resolves to
71
80
  // the parent, so we boot (or reuse) the parent daemon instead of spawning
72
81
  // a conflicting one for the child.
@@ -430,6 +439,10 @@ program
430
439
  .option("--repos <repos>", "Comma-separated repo aliases")
431
440
  .option("--max-tokens <n>", "Max tokens", "8000")
432
441
  .option("--format <fmt>", "Output format: text, json", "text")
442
+ .option("--explain <path>", "Follow a file through every stage of the retrieval and say where it left and why " +
443
+ "(never a candidate, not admitted, cut at the ten, under the floor, past the pivot cap, published at rank k). " +
444
+ "A repo-relative path, a path suffix, or a file name (every indexed file of that name). " +
445
+ "Repeatable. Writes nothing: the next real answer is unchanged", (value, previous) => previous.concat([value]), [])
433
446
  .action(async (query, opts) => {
434
447
  const binaryPath = ensureBinary();
435
448
  const args = ["capsule", query, "--max-tokens", opts.maxTokens];
@@ -438,6 +451,9 @@ program
438
451
  // The Rust binary's clap arg is `--format <json|markdown>` (there is no
439
452
  // `--json` flag). Map the CLI's "text"/"json" to the binary's vocabulary.
440
453
  args.push("--format", opts.format === "json" ? "json" : "markdown");
454
+ // One flag per file, in the order given: the binary expands a file name.
455
+ for (const p of opts.explain)
456
+ args.push("--explain", p);
441
457
  runBinary(binaryPath, args);
442
458
  });
443
459
  program
@@ -714,11 +730,40 @@ program
714
730
  .action(async (opts) => {
715
731
  await runDoctor({ report: opts.report });
716
732
  });
733
+ /**
734
+ * How the agent lists name an agent. Pre-selected (●) are only the agents
735
+ * the project itself shows and the ones chosen for it before; a tool vexp
736
+ * finds only on this machine is labelled so and waits for a pick. Enter used
737
+ * to set up everything found, which put Codex and Antigravity into the
738
+ * project of a user who ran neither (field report, 2026-09-24).
739
+ */
740
+ /**
741
+ * The one step vexp cannot take for the user: approving its Codex hook. Said
742
+ * whenever this run wrote the hook for the first time or changed its
743
+ * definition, because until then Codex does not run it
744
+ * (codexHookApprovalStep).
745
+ */
746
+ function printCodexHookApprovals() {
747
+ for (const root of takeCodexHookApprovals()) {
748
+ console.log("");
749
+ console.log(chalk.yellow(` ! ${codexHookApprovalStep(root)}`));
750
+ }
751
+ }
752
+ function agentMark(plan, agent) {
753
+ const pre = plan.configure.includes(agent);
754
+ const kind = plan.kinds[agent];
755
+ if (kind === "project")
756
+ return chalk.green(" ● detected");
757
+ if (kind === "machine") {
758
+ return pre ? chalk.green(" ● (found on this machine, chosen for this project)") : chalk.dim(" (found on this machine)");
759
+ }
760
+ return pre ? chalk.green(" ● (chosen for this project)") : "";
761
+ }
717
762
  program
718
763
  .command("setup [dir]")
719
764
  .description("Complete one-command setup: index, detect agents, configure MCP")
720
765
  .option("--no-index", "Skip indexing (if already indexed)")
721
- .option("--agents [list]", "Filter agents to configure (comma-separated). Without value: auto-detect all")
766
+ .option("--agents [list]", "Agents to configure (comma-separated). Without it: the ones this project shows, plus the ones chosen for it before")
722
767
  .option("--dry-run", "Show what would be configured without writing files")
723
768
  .option("--personal", "Personal mode: index locally without writing agent configs or git hooks to the shared repo")
724
769
  .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default setup removes them)")
@@ -743,6 +788,9 @@ program
743
788
  }
744
789
  // Set once the agents are known: only editor-hosted ones need a restart.
745
790
  let editorHosted = false;
791
+ // Whether any agent was set up: a run that set up none must not end on
792
+ // "Your AI coding agents are configured".
793
+ let agentsWritten = false;
746
794
  const isPersonal = !!opts.personal;
747
795
  if (isPersonal) {
748
796
  console.log(chalk.cyan(" Personal mode: skipping agent configs and git hooks\n"));
@@ -783,9 +831,13 @@ program
783
831
  if (!isPersonal) {
784
832
  // Step 3: Select agents
785
833
  const allAgents = getAgentList();
786
- const detected = detectAgents(workspaceRoot);
787
- const detectedNames = new Set(detected.map((a) => a.agent));
834
+ const plan = planAgentSetup(workspaceRoot);
788
835
  let selectedNames;
836
+ // What the user picked (the flag, the list, Enter on the list) is
837
+ // remembered as "yes" for this project, so the extension sets up and
838
+ // refreshes the same agents instead of asking again. The non-TTY
839
+ // default is not a pick: nobody chose anything there.
840
+ let picked = false;
789
841
  if (typeof opts.agents === "string") {
790
842
  // Explicit list from --agents flag. Resolve every name BEFORE writing
791
843
  // anything: matching is exact downstream, so an unrecognised name used
@@ -805,6 +857,7 @@ program
805
857
  process.exit(1);
806
858
  }
807
859
  selectedNames = requested.map((n) => resolveAgentName(n));
860
+ picked = true;
808
861
  console.log(chalk.dim(` Agents (from flag): ${selectedNames.join(", ")}`));
809
862
  }
810
863
  else if (!process.stdin.isTTY) {
@@ -813,31 +866,39 @@ program
813
866
  // reading that line — cannot answer a prompt: it hangs until it is
814
867
  // killed, which is worse than failing (field report, 2026-09-07: a
815
868
  // tester followed doctor's advice and had to abandon the command).
816
- // Configure what is detected, and say that is what happened.
817
- selectedNames = allAgents.filter((a) => detectedNames.has(a.agent)).map((a) => a.agent);
818
- if (selectedNames.length === 0) {
869
+ // Configure what the project shows (or was chosen for it), and say
870
+ // that is what happened. A tool found only on this machine is named,
871
+ // never set up: that default put Codex and Antigravity into the
872
+ // project of a user who ran neither (field report, 2026-09-24).
873
+ // Exit 1 only when no agent was found at all (nonInteractiveAgentChoice).
874
+ const choice = nonInteractiveAgentChoice(plan, dir);
875
+ selectedNames = choice.selected;
876
+ for (const note of choice.notes)
877
+ console.log(chalk.dim(` ${note}`));
878
+ if (choice.noAgentFound) {
819
879
  console.error(chalk.yellow(" No AI agent detected here and stdin is not a terminal — nothing to configure."));
820
- console.error(chalk.dim(" Name them explicitly: vexp setup --agents \"Claude Code,Codex\""));
880
+ console.error(chalk.dim(` Name them explicitly: ${setupAgentsCommand(["Claude Code", "Codex"], dir)}`));
821
881
  process.exit(1);
822
882
  }
823
- console.log(chalk.dim(` Agents (detected, non-interactive): ${selectedNames.join(", ")}`));
883
+ if (selectedNames.length > 0) {
884
+ console.log(chalk.dim(` Agents (detected, non-interactive): ${selectedNames.join(", ")}`));
885
+ }
824
886
  }
825
887
  else {
826
888
  // Interactive selection
827
889
  console.log(chalk.bold("\n Select AI agents to configure:\n"));
828
890
  for (let i = 0; i < allAgents.length; i++) {
829
- const isDetected = detectedNames.has(allAgents[i].agent);
830
- const marker = isDetected ? chalk.green(" ● detected") : "";
831
- console.log(` ${chalk.cyan(String(i + 1).padStart(2))}. ${allAgents[i].agent}${marker}`);
891
+ console.log(` ${chalk.cyan(String(i + 1).padStart(2))}. ${allAgents[i].agent}${agentMark(plan, allAgents[i].agent)}`);
832
892
  }
833
893
  const readline = await import("readline");
834
894
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
835
- const answer = await new Promise((resolve) => rl.question(chalk.cyan("\n Select (numbers separated by spaces, Enter for detected): "), resolve));
895
+ const answer = await new Promise((resolve) => rl.question(chalk.cyan("\n Select (numbers separated by spaces, Enter for the ● ones): "), resolve));
836
896
  rl.close();
837
897
  const trimmed = answer.trim();
898
+ picked = true;
838
899
  if (trimmed === "") {
839
- // Enter with no input → use detected agents
840
- selectedNames = detected.map((a) => a.agent);
900
+ // Enter with no input → the pre-selected agents
901
+ selectedNames = allAgents.filter((a) => plan.configure.includes(a.agent)).map((a) => a.agent);
841
902
  if (selectedNames.length === 0) {
842
903
  console.log(chalk.yellow("\n No agents detected. Skipping agent configuration."));
843
904
  console.log(chalk.dim(" Tip: run 'vexp setup-agents' to choose agents manually.\n"));
@@ -871,6 +932,10 @@ program
871
932
  const version = (await getInstalledVersion()) ?? CLI_VERSION;
872
933
  const mcpServerPath = getMcpServerPath();
873
934
  const result = configureSelectedAgents(workspaceRoot, binaryPath, version, selectedNames, mcpServerPath);
935
+ if (picked)
936
+ for (const name of selectedNames)
937
+ recordAgentConsent(workspaceRoot, name, "yes");
938
+ agentsWritten = true;
874
939
  spinner4.succeed("Agent configurations written");
875
940
  // Only editor-hosted agents need the host restarted. Telling a
876
941
  // Codex-only or Claude-Code-only user to restart VS Code invites
@@ -932,6 +997,7 @@ program
932
997
  for (const f of unreachable)
933
998
  console.log(chalk.yellow(` ${f}`));
934
999
  }
1000
+ printCodexHookApprovals();
935
1001
  }
936
1002
  }
937
1003
  }
@@ -997,7 +1063,7 @@ program
997
1063
  console.log("MCP tools are available locally via your agent's user-scope config.");
998
1064
  }
999
1065
  else {
1000
- console.log("Your AI coding agents are configured to use vexp MCP tools.");
1066
+ console.log(agentsWritten ? "Your AI coding agents are configured to use vexp MCP tools." : "No AI agent was set up for this project (see above).");
1001
1067
  console.log("The vexp daemon is running and ready to serve context.");
1002
1068
  }
1003
1069
  console.log("");
@@ -1026,18 +1092,16 @@ async function runSetupAgents(dir) {
1026
1092
  console.log(chalk.bold(" Setup AI Coding Agents"));
1027
1093
  console.log(chalk.dim(" Use ↑↓ to navigate, Space to toggle, Enter to confirm\n"));
1028
1094
  const agents = getAgentList();
1095
+ // The same detection and answers `vexp setup` uses (see agentMark): an
1096
+ // agent found only on this machine is shown, labelled, not pre-checked.
1097
+ const plan = planAgentSetup(workspaceRoot);
1029
1098
  const selectedNames = await checkbox({
1030
1099
  message: "Select agents to configure",
1031
- choices: agents.map((a) => {
1032
- const detected = fs.existsSync(path.join(workspaceRoot, a.detectPath));
1033
- return {
1034
- name: detected
1035
- ? `${a.agent} ${chalk.green("● detected")}`
1036
- : a.agent,
1037
- value: a.agent,
1038
- checked: detected,
1039
- };
1040
- }),
1100
+ choices: agents.map((a) => ({
1101
+ name: `${a.agent}${agentMark(plan, a.agent)}`,
1102
+ value: a.agent,
1103
+ checked: plan.configure.includes(a.agent),
1104
+ })),
1041
1105
  theme: { prefix: chalk.cyan(" ") },
1042
1106
  });
1043
1107
  if (selectedNames.length === 0) {
@@ -1047,6 +1111,8 @@ async function runSetupAgents(dir) {
1047
1111
  const spinner = ora(" Writing agent configurations...").start();
1048
1112
  const version = (await getInstalledVersion()) ?? CLI_VERSION;
1049
1113
  const result = configureSelectedAgents(workspaceRoot, binaryPath, version, selectedNames);
1114
+ for (const name of selectedNames)
1115
+ recordAgentConsent(workspaceRoot, name, "yes");
1050
1116
  spinner.succeed("Agent configurations written");
1051
1117
  console.log("");
1052
1118
  for (const agent of result.agents) {
@@ -1067,6 +1133,7 @@ async function runSetupAgents(dir) {
1067
1133
  console.log(chalk.dim(` → ${mcpFile}`));
1068
1134
  }
1069
1135
  }
1136
+ printCodexHookApprovals();
1070
1137
  console.log(chalk.green(`\n ✓ ${result.agents.length} agent(s) configured\n`));
1071
1138
  }
1072
1139
  /** Full setup from REPL — reuses the existing readline to avoid stdin conflicts. */
@@ -1094,19 +1161,16 @@ async function runSetupInteractive(rl) {
1094
1161
  rl.resume();
1095
1162
  // Step 2: Select agents
1096
1163
  const allAgents = getAgentList();
1097
- const detected = detectAgents(workspaceRoot);
1098
- const detectedNames = new Set(detected.map((a) => a.agent));
1164
+ const plan = planAgentSetup(workspaceRoot);
1099
1165
  console.log(chalk.bold("\n Select AI agents to configure:\n"));
1100
1166
  for (let i = 0; i < allAgents.length; i++) {
1101
- const isDetected = detectedNames.has(allAgents[i].agent);
1102
- const marker = isDetected ? chalk.green(" ● detected") : "";
1103
- console.log(` ${chalk.cyan(String(i + 1).padStart(2))}. ${allAgents[i].agent}${marker}`);
1167
+ console.log(` ${chalk.cyan(String(i + 1).padStart(2))}. ${allAgents[i].agent}${agentMark(plan, allAgents[i].agent)}`);
1104
1168
  }
1105
- const answer = await ask(rl, chalk.cyan("\n Select (numbers separated by spaces, Enter for detected): "));
1169
+ const answer = await ask(rl, chalk.cyan("\n Select (numbers separated by spaces, Enter for the ● ones): "));
1106
1170
  const trimmed = answer.trim();
1107
1171
  let selectedNames;
1108
1172
  if (trimmed === "") {
1109
- selectedNames = detected.map((a) => a.agent);
1173
+ selectedNames = allAgents.filter((a) => plan.configure.includes(a.agent)).map((a) => a.agent);
1110
1174
  if (selectedNames.length === 0) {
1111
1175
  console.log(chalk.yellow("\n No agents detected. Skipping."));
1112
1176
  return;
@@ -1121,6 +1185,8 @@ async function runSetupInteractive(rl) {
1121
1185
  if (selectedNames.length > 0) {
1122
1186
  const version = (await getInstalledVersion()) ?? CLI_VERSION;
1123
1187
  const result = configureSelectedAgents(workspaceRoot, binaryPath, version, selectedNames);
1188
+ for (const name of selectedNames)
1189
+ recordAgentConsent(workspaceRoot, name, "yes");
1124
1190
  console.log("");
1125
1191
  for (const agent of result.agents) {
1126
1192
  const actionLabel = agent.action === "created" ? chalk.green("created")
@@ -1132,6 +1198,7 @@ async function runSetupInteractive(rl) {
1132
1198
  if (result.mcpConfigs.length > 0) {
1133
1199
  console.log(chalk.dim(`\n MCP configs: ${result.mcpConfigs.join(", ")}`));
1134
1200
  }
1201
+ printCodexHookApprovals();
1135
1202
  console.log(chalk.green(`\n ✓ Setup complete — ${result.agents.length} agent(s) configured\n`));
1136
1203
  }
1137
1204
  // Login autostart — opt-in only. The daemon already cold-starts on demand,
@@ -1176,30 +1243,17 @@ program
1176
1243
  isTTY: process.stdin.isTTY === true,
1177
1244
  });
1178
1245
  }
1179
- try {
1180
- const claims = activateLicense(key.trim());
1181
- console.log(chalk.green("\n✓ License activated!"));
1182
- console.log(` Plan: ${claims.plan}`);
1183
- console.log(` Email: ${claims.sub}`);
1184
- console.log(` Expires: ${new Date(claims.exp * 1000).toLocaleDateString()}`);
1185
- // Register this machine NOW, while the user is watching, instead of
1186
- // whenever a later command happens to fall outside the 24h refresh
1187
- // window. The dashboard tells people to "open vexp on any machine to
1188
- // register it" and activating IS that — a tier-4 user read 0 of 10
1189
- // devices immediately after activating and reasonably thought something
1190
- // had failed. Best effort by design: the refresh has its own short
1191
- // timeout and never throws, so an offline activation still succeeds and
1192
- // simply registers later.
1193
- try {
1194
- await tryOnlineRefresh(key.trim());
1195
- }
1196
- catch { /* offline or server down: registration happens on a later run */ }
1197
- console.log("");
1198
- }
1199
- catch (err) {
1200
- console.error(chalk.red(`✗ ${err instanceof Error ? err.message : err}`));
1246
+ // Register this machine NOW, while the user is watching, and say whether
1247
+ // it worked. The dashboard tells people to "open vexp on any machine to
1248
+ // register it" and activating IS that — a tier-4 user read 0 of 10
1249
+ // devices immediately after activating and reasonably thought something
1250
+ // had failed. An offline activation still succeeds and registers later;
1251
+ // the line printed says which of the two happened. Same helper as the
1252
+ // interactive menu, so the two can never drift apart again.
1253
+ const report = await runActivation(key, { indent: "" });
1254
+ console.log("");
1255
+ if (!report.ok)
1201
1256
  process.exit(1);
1202
- }
1203
1257
  });
1204
1258
  program
1205
1259
  .command("deactivate")
@@ -1211,17 +1265,18 @@ program
1211
1265
  program
1212
1266
  .command("license")
1213
1267
  .description("Show current license status")
1214
- .action(() => {
1215
- const limits = readLicenseLimits();
1268
+ .action(async () => {
1269
+ // Check in first (throttled to once a day), so `vexp license` is also the
1270
+ // way to register this machine and the date printed below is current.
1271
+ const outcome = await refreshFromDisk();
1272
+ const limits = readLicenseLimits({ refresh: false });
1216
1273
  // AppSumo lifetime tiers print as the raw internal string ("tier3") with a
1217
1274
  // "Renews" date one month out — which is the local token's expiry, not the
1218
1275
  // licence's. It rolls forward by itself on every check-in. A tier-3 buyer
1219
1276
  // read that as a monthly deadline and re-activated his key by hand every
1220
1277
  // month for nothing, and he was right to be alarmed by what it said.
1221
- const ltd = /^tier[1-4]$/.test(limits.plan);
1222
- const planLabel = ltd
1223
- ? `Lifetime · Tier ${limits.plan.slice(4)}`
1224
- : limits.plan;
1278
+ const ltd = isLifetimePlan(limits.plan);
1279
+ const planLabel = licensePlanLabel(limits.plan);
1225
1280
  console.log(chalk.bold("\nvexp License Status\n"));
1226
1281
  console.log(` Plan: ${planLabel}`);
1227
1282
  if (limits.email)
@@ -1238,11 +1293,12 @@ program
1238
1293
  `current one valid to ${limits.renewsAt.toLocaleDateString()})`
1239
1294
  : ` Renews: ${limits.renewsAt.toLocaleDateString()}`);
1240
1295
  }
1241
- const blocked = readDeviceBlocked();
1296
+ printCheckInStatus(outcome, " ");
1297
+ const blocked = outcome.kind === "device_limit_exceeded" ? null : readDeviceBlocked();
1242
1298
  if (blocked) {
1243
1299
  console.log("");
1244
1300
  console.log(chalk.yellow(` ⚠ This machine is not registered (${blocked.maxDevices}/${blocked.maxDevices} slots used).`));
1245
- console.log(chalk.dim(` Free a slot at ${blocked.manageUrl} and run a vexp command\n to re-register this device.`));
1301
+ console.log(chalk.dim(` Free a slot at ${blocked.manageUrl} and run \`vexp license\`\n to re-register this device.`));
1246
1302
  }
1247
1303
  console.log("");
1248
1304
  });
@@ -1300,34 +1356,81 @@ program
1300
1356
  }
1301
1357
  });
1302
1358
  program
1303
- .command("use <workspacePath>")
1304
- .description("Switch codex (global MCP config) to point at a different configured workspace")
1305
- .action(async (workspacePath) => {
1306
- const resolved = path.resolve(workspacePath);
1307
- const manifest = path.join(resolved, ".vexp", "manifest.json");
1308
- if (!fs.existsSync(manifest)) {
1309
- console.error(chalk.red(`No .vexp/manifest.json at ${resolved}. Run 'vexp setup' there first.`));
1310
- process.exit(1);
1311
- }
1359
+ .command("use [workspacePath]")
1360
+ .description("Set Codex up for a configured workspace; --pin makes every Codex session use its index, --unpin undoes that")
1361
+ .option("--pin", "pin Codex's global vexp entry to this workspace, whatever folder a session starts in")
1362
+ .option("--unpin", "drop the pin: Codex starts vexp in each session's own folder (the default)")
1363
+ .action(async (workspacePath, opts) => {
1312
1364
  const binaryPath = ensureBinary();
1313
1365
  const mcpServerPath = getMcpServerPath();
1314
1366
  // Import lazily so configureCodexGlobal's side effects (TOML rewrite + legacy cleanup) only fire here.
1315
- const { configureSelectedAgents, codexGlobalSectionIsUserManaged } = await import("./agent-config.js");
1367
+ const { configureSelectedAgents, configureCodexGlobal, codexGlobalSectionIsUserManaged, codexGlobalExplicitPin } = await import("./agent-config.js");
1316
1368
  // A hand-written [mcp_servers.vexp] is never rewritten (no-clobber), and
1317
1369
  // this command used to print its success line anyway — the user then
1318
1370
  // "repointed" Codex and watched it launch from the old cwd (field
1319
1371
  // report, 2026-09-19). Say what did not happen and how to do it by hand.
1320
1372
  if (codexGlobalSectionIsUserManaged()) {
1321
1373
  console.error(chalk.yellow(`~/.codex/config.toml has a hand-written [mcp_servers.vexp] section, and vexp never rewrites one: Codex keeps launching it exactly as written, so nothing changed.`));
1322
- console.error(chalk.dim(` Point it yourself — under [mcp_servers.vexp] add cwd = ${JSON.stringify(resolved)}, and under [mcp_servers.vexp.env] add VEXP_WORKSPACE = ${JSON.stringify(resolved)} — or delete the section and run 'vexp use' again.`));
1374
+ if (opts.pin && workspacePath) {
1375
+ const resolved = path.resolve(workspacePath);
1376
+ console.error(chalk.dim(` Point it yourself — under [mcp_servers.vexp] add cwd = ${JSON.stringify(resolved)}, and under [mcp_servers.vexp.env] add VEXP_WORKSPACE = ${JSON.stringify(resolved)} — or delete the section and run 'vexp use' again.`));
1377
+ }
1378
+ else {
1379
+ console.error(chalk.dim(" Delete the section and run 'vexp use' again to have vexp manage it."));
1380
+ }
1381
+ process.exit(1);
1382
+ }
1383
+ if (opts.unpin) {
1384
+ // The HTTP transport routes to one workspace's daemon by URL: there is
1385
+ // no project-free form of it to go back to.
1386
+ if ((process.env.VEXP_CODEX_TRANSPORT ?? "direct").toLowerCase() === "http") {
1387
+ console.error(chalk.yellow("VEXP_CODEX_TRANSPORT=http: the HTTP entry always names one project. Unset it to use the direct transport, which follows each Codex session's folder."));
1388
+ process.exit(1);
1389
+ }
1390
+ configureCodexGlobal(binaryPath, mcpServerPath, undefined, { unpin: true });
1391
+ console.log(chalk.green("OK: codex now starts vexp in each session's own folder"));
1392
+ return;
1393
+ }
1394
+ if (!workspacePath) {
1395
+ console.error(chalk.red("Name the workspace ('vexp use <path>', add --pin to pin Codex to it), or pass --unpin."));
1396
+ process.exit(1);
1397
+ }
1398
+ const resolved = path.resolve(workspacePath);
1399
+ const manifest = path.join(resolved, ".vexp", "manifest.json");
1400
+ if (!fs.existsSync(manifest)) {
1401
+ console.error(chalk.red(`No .vexp/manifest.json at ${resolved}. Run 'vexp setup' there first.`));
1323
1402
  process.exit(1);
1324
1403
  }
1325
1404
  const version = (await getInstalledVersion()) ?? CLI_VERSION;
1326
- // "Codex" is a no-op file-wise in non-detect mode but the shared flow
1327
- // also writes codex global config via configureCodexGlobal — pass an
1328
- // empty agent list so only the MCP side runs.
1405
+ // Codex's entry names no project any more: Codex starts vexp in each
1406
+ // session's folder, and pinning the project set up last broke every
1407
+ // other one. Setting Codex up here therefore pins nothing: doctor sends
1408
+ // Codex users here, and a machine-wide pin is not what they asked for. A
1409
+ // pin is --pin, and a pin made that way follows this command to the next
1410
+ // project named, as 'vexp use' always did.
1411
+ const pin = opts.pin || codexGlobalExplicitPin() !== undefined;
1329
1412
  configureSelectedAgents(resolved, binaryPath, version, ["Codex"], mcpServerPath);
1330
- console.log(chalk.green(`✓ codex now points at ${resolved}`));
1413
+ if (pin)
1414
+ configureCodexGlobal(binaryPath, mcpServerPath, resolved, { pin: true });
1415
+ // Setting Codex up for a project is a choice of Codex for it: remembered,
1416
+ // so the extension keeps it set up there instead of asking.
1417
+ recordAgentConsent(resolved, "Codex", "yes");
1418
+ // The HTTP entry is a URL routed to one workspace's daemon: whatever
1419
+ // was asked, it now names this project for every session.
1420
+ const http = (process.env.VEXP_CODEX_TRANSPORT ?? "direct").toLowerCase() === "http";
1421
+ if (pin) {
1422
+ console.log(chalk.green(`OK: codex now points at ${resolved}`));
1423
+ console.log(chalk.dim(" every Codex session, in any folder, queries this project's index; 'vexp use --unpin' undoes it"));
1424
+ }
1425
+ else if (http) {
1426
+ console.log(chalk.green(`OK: codex set up for ${resolved}`));
1427
+ console.log(chalk.dim(" VEXP_CODEX_TRANSPORT=http: the HTTP entry routes every Codex session, in any folder, to this project's index. Unset it to use the direct transport, which follows each session's folder."));
1428
+ }
1429
+ else {
1430
+ console.log(chalk.green(`OK: codex set up for ${resolved}`));
1431
+ console.log(chalk.dim(` Codex starts vexp in each session's own folder; 'vexp use ${JSON.stringify(resolved)} --pin' would make every session use this project instead`));
1432
+ }
1433
+ printCodexHookApprovals();
1331
1434
  });
1332
1435
  // ────────────────────────────────────────────────────
1333
1436
  // Version
@@ -1352,7 +1455,7 @@ const MENU_CONFIG = [
1352
1455
  ];
1353
1456
  // ── Sub-menu: Explore & Analyze ──
1354
1457
  const MENU_EXPLORE = [
1355
- { key: "1", label: "capsule", hint: "Generate context capsule for a task", description: "Given a natural-language query, returns the most relevant files\n and symbols as a compressed context capsule (markdown).\n 70-90% token savings vs reading full files." },
1458
+ { key: "1", label: "capsule", hint: "Generate context capsule for a task", description: "Given a natural-language query, returns the most relevant files\n and symbols as a compressed context capsule (markdown),\n much smaller than reading the full files." },
1356
1459
  { key: "2", label: "skeleton", hint: "Show a file's token-efficient skeleton", description: "Prints a compact outline of a file (functions, classes, signatures).\n Detail levels: minimal / standard / detailed." },
1357
1460
  { key: "3", label: "impact", hint: "Impact graph for a symbol", description: "Shows every symbol that depends on a given function/class\n (direct + transitive).\n Output: tree, list, or mermaid diagram." },
1358
1461
  { key: "4", label: "flow", hint: "Logic flow between two symbols", description: "Finds execution paths (call chains) from symbol A to symbol B.\n Output: up to N shortest paths with cost." },
@@ -1370,7 +1473,7 @@ const MENU_REPOS = [
1370
1473
  // ── Sub-menu: License ──
1371
1474
  const MENU_LICENSE = [
1372
1475
  { 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." },
1373
- { 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" },
1476
+ { key: "2", label: "activate", hint: "Activate your license key", description: "Enter your license key (Pro, Team or lifetime) to unlock your plan.\n Find your key at https://vexp.dev/account" },
1374
1477
  { key: "3", label: "deactivate", hint: "Remove current license", description: "Removes the active license. Free plan limits will apply." },
1375
1478
  ];
1376
1479
  /** Format a duration as a compact uptime string (e.g. "2h 14m", "47s"). */
@@ -1872,11 +1975,12 @@ async function executeCommand(label, rl, outCtl) {
1872
1975
  }
1873
1976
  // ── License commands ──
1874
1977
  case "status": {
1875
- const limits = readLicenseLimits();
1978
+ const outcome = await refreshFromDisk();
1979
+ const limits = readLicenseLimits({ refresh: false });
1876
1980
  // Same wording as `vexp license`: a lifetime tier must not be shown as
1877
1981
  // a raw "tier3" with a monthly renewal date it does not have.
1878
- const ltd = /^tier[1-4]$/.test(limits.plan);
1879
- console.log(` Plan: ${ltd ? `Lifetime · Tier ${limits.plan.slice(4)}` : limits.plan}`);
1982
+ const ltd = isLifetimePlan(limits.plan);
1983
+ console.log(` Plan: ${licensePlanLabel(limits.plan)}`);
1880
1984
  if (limits.email)
1881
1985
  console.log(` Email: ${limits.email}`);
1882
1986
  console.log(` Max nodes: ${limits.maxNodes === 0 ? "unlimited" : limits.maxNodes.toLocaleString()}`);
@@ -1890,21 +1994,19 @@ async function executeCommand(label, rl, outCtl) {
1890
1994
  ? ` Renewal: none — lifetime licence (token auto-refreshes, valid to ${limits.renewsAt.toLocaleDateString()})`
1891
1995
  : ` Renews: ${limits.renewsAt.toLocaleDateString()}`);
1892
1996
  }
1997
+ printCheckInStatus(outcome, " ");
1998
+ const blocked = outcome.kind === "device_limit_exceeded" ? null : readDeviceBlocked();
1999
+ if (blocked) {
2000
+ console.log(chalk.yellow(` This machine is not registered (${blocked.maxDevices}/${blocked.maxDevices} slots used). Free a slot at ${blocked.manageUrl}.`));
2001
+ }
1893
2002
  break;
1894
2003
  }
1895
2004
  case "activate": {
1896
2005
  const key = await askSecretOn(rl, chalk.cyan(" License key: "), outCtl, process.stdout);
1897
2006
  if (key.trim()) {
1898
- try {
1899
- const claims = activateLicense(key.trim());
1900
- console.log(chalk.green("\n ✓ License activated!"));
1901
- console.log(` Plan: ${claims.plan}`);
1902
- console.log(` Email: ${claims.sub}`);
1903
- console.log(` Expires: ${new Date(claims.exp * 1000).toLocaleDateString()}`);
1904
- }
1905
- catch (err) {
1906
- console.error(chalk.red(` ✗ ${err instanceof Error ? err.message : err}`));
1907
- }
2007
+ // Awaits the check-in exactly like `vexp activate`: this path used
2008
+ // to write the key and stop, so the device never registered.
2009
+ await runActivation(key, { indent: " " });
1908
2010
  }
1909
2011
  break;
1910
2012
  }