vexp-cli 2.7.0 → 3.0.1

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, resolveAgentName, suggestAgentName, setGuardMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets } from "./agent-config.js";
11
+ import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode, setInterventionMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets } from "./agent-config.js";
12
12
  import { CLI_VERSION } from "./version.js";
13
13
  import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, tryOnlineRefresh, } from "./license.js";
14
14
  import { checkForUpdate } from "./update-check.js";
@@ -17,6 +17,7 @@ import { installAutostart, uninstallAutostart, autostartStatus, migrateClaudeUnp
17
17
  import { runServe } from "./serve.js";
18
18
  import { runDoctor } from "./doctor.js";
19
19
  import { socketPathFor } from "./socket-path.js";
20
+ import { mutableOutput, askSecretOn } from "./secret-prompt.js";
20
21
  import { isTraceEnabled } from "./trace.js";
21
22
  import { resolveParentWorkspace, listWorkspaceRepos, addRepoToWorkspace, } from "./workspace-repos.js";
22
23
  const program = new Command();
@@ -633,9 +634,12 @@ program
633
634
  .option("--dry-run", "Show what would be configured without writing files")
634
635
  .option("--personal", "Personal mode: index locally without writing agent configs or git hooks to the shared repo")
635
636
  .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default setup removes them)")
637
+ .option("--interventions", "Install the stop gate and the edit-time coupling hint (opt-in since 2.8: each buys tokens with turns, and a turn costs the whole transcript)")
636
638
  .action(async (dir, opts) => {
637
639
  const workspaceRoot = path.resolve(dir ?? process.cwd());
638
640
  setGuardMode(opts.guardStrict ? "strict" : "off");
641
+ setInterventionMode(opts.interventions ? "on" : "off");
642
+ setInterventionMode(opts.interventions ? "on" : "off");
639
643
  console.log(chalk.bold(`\nvexp setup — ${workspaceRoot}\n`));
640
644
  // Step 1: Ensure binary
641
645
  const spinner1 = ora("Checking vexp binary...").start();
@@ -889,6 +893,7 @@ program
889
893
  .command("setup-agents [dir]")
890
894
  .description("Configure AI coding agents to use vexp MCP (interactive multi-select)")
891
895
  .option("--guard-strict", "Install the Grep/Glob deny hooks (opt-in since 2.3; default removes them)")
896
+ .option("--interventions", "Install the stop gate and the edit-time coupling hint (opt-in since 2.8: each buys tokens with turns, and a turn costs the whole transcript)")
892
897
  .action(async (dir, opts) => {
893
898
  setGuardMode(opts.guardStrict ? "strict" : "off");
894
899
  await runSetupAgents(dir);
@@ -1039,17 +1044,16 @@ async function runSetupInteractive(rl) {
1039
1044
  // ────────────────────────────────────────────────────
1040
1045
  program
1041
1046
  .command("activate [key]")
1042
- .description("Activate a vexp Pro/Team license key")
1047
+ .description("Activate a vexp Pro/Team license key (omit the key to enter it without it reaching shell history)")
1043
1048
  .action(async (key) => {
1044
1049
  if (!key) {
1045
1050
  console.log(chalk.cyan("Get your license key at https://vexp.dev/#pricing\n"));
1046
- const readline = await import("readline");
1047
- const rl = readline.createInterface({
1051
+ const { promptSecret } = await import("./secret-prompt.js");
1052
+ key = await promptSecret("License key: ", {
1048
1053
  input: process.stdin,
1049
1054
  output: process.stdout,
1055
+ isTTY: process.stdin.isTTY === true,
1050
1056
  });
1051
- key = await new Promise((resolve) => rl.question("License key: ", resolve));
1052
- rl.close();
1053
1057
  }
1054
1058
  try {
1055
1059
  const claims = activateLicense(key.trim());
@@ -1370,7 +1374,10 @@ async function interactiveMode() {
1370
1374
  }
1371
1375
  await printBanner();
1372
1376
  printMainMenu();
1373
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1377
+ // Built over a mutable stdout so `activate` can hide the key being typed:
1378
+ // this interface lives for the whole session and closing it exits.
1379
+ const outCtl = mutableOutput(process.stdout);
1380
+ const rl = readline.createInterface({ input: process.stdin, output: outCtl.stream, terminal: true });
1374
1381
  rl.on("close", () => { console.log(""); process.exit(0); });
1375
1382
  let currentMenu = "main";
1376
1383
  const prompts = {
@@ -1424,7 +1431,7 @@ async function interactiveMode() {
1424
1431
  const allItems = [...MENU_CONFIG, ...MENU_EXPLORE, ...MENU_SAVINGS, ...MENU_LICENSE, ...MENU_REPOS];
1425
1432
  const direct = allItems.find((m) => m.label === input);
1426
1433
  if (direct) {
1427
- await executeCommand(direct.label, rl);
1434
+ await executeCommand(direct.label, rl, outCtl);
1428
1435
  // After a direct command, reprint the main menu so options stay visible.
1429
1436
  printMainMenu();
1430
1437
  continue;
@@ -1453,14 +1460,14 @@ async function interactiveMode() {
1453
1460
  }
1454
1461
  // Show description before executing
1455
1462
  console.log(chalk.dim(`\n ${item.description}\n`));
1456
- await executeCommand(item.label, rl);
1463
+ await executeCommand(item.label, rl, outCtl);
1457
1464
  console.log("");
1458
1465
  // Sticky sub-menu: reprint the current sub-menu after every command so
1459
1466
  // the user always sees the available options without typing '?'.
1460
1467
  printSubMenu(titles[currentMenu], items);
1461
1468
  }
1462
1469
  }
1463
- async function executeCommand(label, rl) {
1470
+ async function executeCommand(label, rl, outCtl) {
1464
1471
  try {
1465
1472
  switch (label) {
1466
1473
  // ── Config commands ──
@@ -1750,7 +1757,7 @@ async function executeCommand(label, rl) {
1750
1757
  break;
1751
1758
  }
1752
1759
  case "activate": {
1753
- const key = await ask(rl, chalk.cyan(" License key: "));
1760
+ const key = await askSecretOn(rl, chalk.cyan(" License key: "), outCtl, process.stdout);
1754
1761
  if (key.trim()) {
1755
1762
  try {
1756
1763
  const claims = activateLicense(key.trim());
package/dist/doctor.js CHANGED
@@ -113,6 +113,53 @@ function isAlive(pid) {
113
113
  * the daemon watches the tree live and reconciles it against disk every five
114
114
  * minutes — and the remedy was unusable. A user spent a round trip on it.
115
115
  */
116
+ /**
117
+ * Which IDE owns a running daemon, from its executable path.
118
+ *
119
+ * A daemon started by an IDE extension runs the binary bundled INSIDE that
120
+ * extension's directory, and the extension host owns its lifecycle. Telling
121
+ * that user to run `vexp daemon-cmd restart` sends them in a circle: the kill
122
+ * lands, the same extension host respawns the same old binary, and nothing
123
+ * changes. The window has to reload so the host picks up the new extension.
124
+ * A user lost time on exactly this and worked it out himself.
125
+ *
126
+ * Matching is on the IDE's own extensions directory, which is where the
127
+ * bundled core lives (`<ide>/extensions/<publisher>.vexp-<ver>/binaries/...`).
128
+ * Remote/WSL installs use `.vscode-server`; forks use their own dot-dir.
129
+ */
130
+ export function daemonOwnerIde(exePath) {
131
+ if (!exePath)
132
+ return null;
133
+ const p = exePath.replace(/\\/g, "/").toLowerCase();
134
+ const ides = [
135
+ [/\/\.vscode-server-insiders\/extensions\//, "VS Code Insiders (Remote)"],
136
+ [/\/\.vscode-server\/extensions\//, "VS Code (Remote/WSL)"],
137
+ [/\/\.vscode-insiders\/extensions\//, "VS Code Insiders"],
138
+ [/\/\.vscode-oss\/extensions\//, "VSCodium"],
139
+ [/\/\.vscode\/extensions\//, "VS Code"],
140
+ [/\/\.cursor-server\/extensions\//, "Cursor (Remote)"],
141
+ [/\/\.cursor\/extensions\//, "Cursor"],
142
+ [/\/\.windsurf-server\/extensions\//, "Windsurf (Remote)"],
143
+ [/\/\.windsurf\/extensions\//, "Windsurf"],
144
+ [/\/\.trae\/extensions\//, "Trae"],
145
+ [/\/\.antigravity\/extensions\//, "Antigravity"],
146
+ ];
147
+ for (const [re, label] of ides) {
148
+ if (re.test(p))
149
+ return label;
150
+ }
151
+ return null;
152
+ }
153
+ /** The remedy line for a daemon that is behind the installed engine. */
154
+ export function staleDaemonRemedy(exePath) {
155
+ const ide = daemonOwnerIde(exePath);
156
+ if (ide) {
157
+ return (`this daemon belongs to ${ide} (${exePath}) — 'vexp daemon-cmd restart' will NOT fix it: ` +
158
+ `the extension host respawns the same build.\n` +
159
+ ` reload the ${ide} window instead (Command Palette → "Developer: Reload Window").`);
160
+ }
161
+ return `run 'vexp daemon-cmd restart' to upgrade it now.`;
162
+ }
116
163
  export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
117
164
  const ourHooksDir = path.join(repoRoot, ".git", "hooks");
118
165
  if (!hooksPath) {
@@ -222,10 +269,12 @@ export async function runDoctor() {
222
269
  const bundled = out.trim().split(/\s+/).pop();
223
270
  const running = st.daemon_version;
224
271
  if (bundled && running && bundled !== running) {
225
- line(WARN, `daemon is v${running} but the installed binary is v${bundled} — this workspace is still served by the OLD version. Run 'vexp daemon-cmd restart' to upgrade it now.`);
272
+ const exe = st.daemon_exe;
273
+ line(WARN, `daemon is v${running} but the installed binary is v${bundled} — this workspace is still served by the OLD version.\n ${staleDaemonRemedy(exe)}`);
226
274
  }
227
275
  else if (st.binary_stale === true) {
228
- line(WARN, `daemon is running a deleted executable (upgraded on disk) — run 'vexp daemon-cmd restart' to load the new build.`);
276
+ const exe = st.daemon_exe;
277
+ line(WARN, `daemon is running a deleted executable (upgraded on disk).\n ${staleDaemonRemedy(exe)}`);
229
278
  }
230
279
  }
231
280
  catch { /* best-effort */ }
@@ -501,10 +550,15 @@ export async function runDoctor() {
501
550
  console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
502
551
  {
503
552
  const sPath = path.join(ws.root, ".claude", "settings.json");
553
+ // `optIn` hooks are not written by a default setup, so their absence is
554
+ // the expected state and must not read as a fault. The verification gate
555
+ // buys correctness with TURNS, and a turn costs the whole transcript
556
+ // (65,521 tokens measured over 13 bench sessions); `vexp setup
557
+ // --interventions` installs it for anyone who wants that trade.
504
558
  const wanted = [
505
559
  { event: "UserPromptSubmit", marker: "vexp-hint", label: "orientation" },
506
- { event: "Stop", marker: "vexp-verify", label: "verification gate" },
507
560
  { event: "SessionStart", marker: "vexp-restore", label: "context restore" },
561
+ { event: "Stop", marker: "vexp-verify", label: "verification gate", optIn: true },
508
562
  ];
509
563
  let settings = null;
510
564
  try {
@@ -533,7 +587,12 @@ export async function runDoctor() {
533
587
  .flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
534
588
  .find((h) => typeof h?.command === "string" && h.command.includes(w.marker));
535
589
  if (!hook) {
536
- line(WARN, `${w.event} (${w.label}) not installed — re-run 'vexp setup' to write it.`);
590
+ if (w.optIn) {
591
+ line(OK, `${w.event} (${w.label}) not installed — opt-in since 2.8; 'vexp setup --interventions' adds it.`);
592
+ }
593
+ else {
594
+ line(WARN, `${w.event} (${w.label}) not installed — re-run 'vexp setup' to write it.`);
595
+ }
537
596
  continue;
538
597
  }
539
598
  const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
@@ -393,6 +393,139 @@ VEXP_BIN="__VEXP_BIN__"
393
393
  "$VEXP_BIN" prompt-hint 2>/dev/null
394
394
  exit 0
395
395
  `;
396
+ /**
397
+ * v5: the coupling, delivered on the edit that needs it (PostToolUse).
398
+ *
399
+ * The only evidence about WHY an agent fails a multi-file task says its plan
400
+ * was incomplete, not its patch. That is the reverse-dependency question, and
401
+ * nothing in a context window answers it. This puts the answer on Edit and
402
+ * Write, which every agent uses in every session — against 4% that ever call
403
+ * a vexp tool.
404
+ *
405
+ * PostToolUse rather than PreToolUse because a deny on an edit stops real
406
+ * work, and after rather than before because the channel that reaches the
407
+ * model is `additionalContext`, verified empirically on Claude Code: it
408
+ * arrives as a system-reminder naming the hook that produced it. It adds no
409
+ * turn, which the 2.9 gate experiment showed to be worth +17% and nothing.
410
+ *
411
+ * FAIL-OPEN like every other hook: no binary, no daemon, nothing to say —
412
+ * exit 0 silent.
413
+ */
414
+ export const VEXP_EDIT_HINT_HOOK = `#!/bin/bash
415
+ # vexp-edit-hint: what else references the file you just changed. Fails open.
416
+ VEXP_BIN="__VEXP_BIN__"
417
+ [ -x "$VEXP_BIN" ] || exit 0
418
+ "$VEXP_BIN" edit-hint 2>/dev/null
419
+ exit 0
420
+ `;
421
+ export const VEXP_READ_HINT_HOOK = `#!/bin/bash
422
+ # vexp-read-hint: answer a whole-file read of a large file with its skeleton.
423
+ # The only mechanism vexp has that SUBTRACTS tokens. Fails open.
424
+ VEXP_BIN="__VEXP_BIN__"
425
+ [ -x "$VEXP_BIN" ] || exit 0
426
+ "$VEXP_BIN" read-hint 2>/dev/null
427
+ exit 0
428
+ `;
429
+ export const VEXP_BASH_CAP_HOOK = `#!/bin/bash
430
+ # vexp-bash-cap: bound the output of a shell command that has no bound of its
431
+ # own. The other door: narrowing reads alone just moved the work here. Fails
432
+ # open, and never touches a build or a test.
433
+ VEXP_BIN="__VEXP_BIN__"
434
+ [ -x "$VEXP_BIN" ] || exit 0
435
+ "$VEXP_BIN" bash-cap 2>/dev/null
436
+ exit 0
437
+ `;
438
+ /**
439
+ * opencode / Kilo compression plugin.
440
+ *
441
+ * Their `tool.execute.before` can MUTATE the arguments, not only refuse them —
442
+ * the documented example is `output.args.command = escape(output.args.command)`
443
+ * — which is the whole mechanism. Same two doors as everywhere else: a
444
+ * whole-file read becomes that file's skeleton, and a shell command with no
445
+ * bound of its own gets one.
446
+ *
447
+ * Fails open at every step. A plugin that throws takes the tool call with it,
448
+ * and a compression that breaks a session is worse than no compression.
449
+ */
450
+ export const VEXP_OPENCODE_COMPRESS = `import { spawn } from "child_process";
451
+
452
+ const BIN = "__VEXP_BIN__";
453
+
454
+ function ask(sub, payload) {
455
+ return new Promise((resolve) => {
456
+ let done = false;
457
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
458
+ let child;
459
+ try {
460
+ child = spawn(BIN, [sub], { stdio: ["pipe", "pipe", "ignore"] });
461
+ } catch { return finish(null); }
462
+ const timer = setTimeout(() => { try { child.kill(); } catch {} finish(null); }, 8000);
463
+ let out = "";
464
+ child.stdout.on("data", (d) => { out += d.toString(); });
465
+ child.on("error", () => { clearTimeout(timer); finish(null); });
466
+ child.on("close", () => {
467
+ clearTimeout(timer);
468
+ try { finish(JSON.parse(out.trim() || "null")); } catch { finish(null); }
469
+ });
470
+ try { child.stdin.end(JSON.stringify(payload)); } catch { clearTimeout(timer); finish(null); }
471
+ });
472
+ }
473
+
474
+ function patchOf(r) {
475
+ return (r && r.hookSpecificOutput && r.hookSpecificOutput.updatedInput) || null;
476
+ }
477
+
478
+ export const VexpCompress = async () => ({
479
+ "tool.execute.before": async (input, output) => {
480
+ const args = output && output.args;
481
+ if (!args) return;
482
+ try {
483
+ const tool = String(input.tool || "").toLowerCase();
484
+ if (tool === "bash" && typeof args.command === "string") {
485
+ const p = patchOf(await ask("bash-cap", {
486
+ tool_name: "Bash",
487
+ tool_input: { command: args.command },
488
+ session_id: input.sessionID || "",
489
+ }));
490
+ if (p && typeof p.command === "string") args.command = p.command;
491
+ return;
492
+ }
493
+ if (tool === "read") {
494
+ const key = args.filePath !== undefined ? "filePath"
495
+ : args.file_path !== undefined ? "file_path"
496
+ : args.path !== undefined ? "path" : null;
497
+ if (!key || typeof args[key] !== "string") return;
498
+ // An agent that already asked for a range knows what it wants.
499
+ if (args.offset !== undefined || args.limit !== undefined) return;
500
+ const p = patchOf(await ask("read-hint", {
501
+ tool_name: "Read",
502
+ tool_input: { file_path: args[key] },
503
+ session_id: input.sessionID || "",
504
+ }));
505
+ if (p && typeof p.file_path === "string") args[key] = p.file_path;
506
+ }
507
+ } catch {
508
+ // Never let compression break a tool call.
509
+ }
510
+ },
511
+ });
512
+ `;
513
+ /** Bake the binary path into the opencode/Kilo compression plugin. */
514
+ export function vexpOpencodeCompressPlugin(binaryPath) {
515
+ return VEXP_OPENCODE_COMPRESS.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
516
+ }
517
+ /** Bake the binary path into the read-hint hook script. */
518
+ export function bakeReadHintHook(binaryPath) {
519
+ return VEXP_READ_HINT_HOOK.replace("__VEXP_BIN__", binaryPath);
520
+ }
521
+ /** Bake the binary path into the bash-cap hook script. */
522
+ export function bakeBashCapHook(binaryPath) {
523
+ return VEXP_BASH_CAP_HOOK.replace("__VEXP_BIN__", binaryPath);
524
+ }
525
+ /** Bake the binary path into the edit-hint hook script. */
526
+ export function bakeEditHintHook(binaryPath) {
527
+ return VEXP_EDIT_HINT_HOOK.replace("__VEXP_BIN__", binaryPath);
528
+ }
396
529
  /** Bake the binary path into the hint hook script. */
397
530
  export function vexpHintHookScript(binaryPath) {
398
531
  return VEXP_HINT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
@@ -517,6 +650,37 @@ export const VexpHint = async ({ directory, client }) => {
517
650
  }).catch(() => resolve(""));
518
651
  });
519
652
  return {
653
+ // v5: the coupling on the edit, for opencode and Kilo.
654
+ //
655
+ // Their guard plugin uses "tool.execute.before" (verified in our own
656
+ // tests), so ".after" follows the pattern. If that event does not exist
657
+ // the handler is simply never called — inert, never harmful, which is the
658
+ // same fail-open contract as every other surface here.
659
+ "tool.execute.after": async (input, output) => {
660
+ try {
661
+ const tool = String((input && input.tool) || "");
662
+ if (!/^(edit|write|patch|multiedit)$/i.test(tool)) return;
663
+ const args = (input && input.args) || {};
664
+ const file =
665
+ args.filePath || args.file_path || args.path || (output && output.filePath);
666
+ if (!file) return;
667
+ const payload = JSON.stringify({
668
+ tool_name: "Edit",
669
+ session_id: (input && input.sessionID) || "",
670
+ tool_input: { file_path: String(file) },
671
+ });
672
+ const raw = await runVexp(["edit-hint"], { cwd: directory, input: payload, timeout: 5000 });
673
+ if (!raw) return;
674
+ const parsed = JSON.parse(raw);
675
+ const text = parsed?.hookSpecificOutput?.additionalContext;
676
+ if (!text) return;
677
+ if (output && Array.isArray(output.parts)) {
678
+ output.parts.push({ type: "text", text: String(text) });
679
+ }
680
+ } catch (e) {
681
+ /* fail open */
682
+ }
683
+ },
520
684
  "chat.message": async (input, output) => {
521
685
  try {
522
686
  const text = (output.parts || [])
@@ -540,7 +704,19 @@ export const VexpHint = async ({ directory, client }) => {
540
704
  });
541
705
  if (!out || !out.trim()) return;
542
706
  const hint = JSON.parse(out).hookSpecificOutput?.additionalContext;
543
- if (hint) output.parts.push({ type: "text", text: hint });
707
+ // Never push a bare {type,text} part: Kilo 7.4.x validates every part
708
+ // against a schema requiring id/sessionID/messageID before save, so an
709
+ // injected bare part poisons the whole user message (43/43
710
+ // InvalidDurableEvent, prompt dies in both the extension and the CLI -
711
+ // Kilo field report, 2026-08). Appending onto the user's own text part
712
+ // rides its already-valid identity on every opencode/Kilo version.
713
+ if (hint) {
714
+ const texts = (output.parts || []).filter(
715
+ (p) => p && p.type === "text" && typeof p.text === "string"
716
+ );
717
+ const target = texts[texts.length - 1];
718
+ if (target) target.text = target.text + "\\n\\n" + hint;
719
+ }
544
720
  } catch (e) { /* fail open */ }
545
721
  },
546
722
  event: async ({ event }) => {
package/dist/license.js CHANGED
@@ -22,8 +22,12 @@ const VEXP_WEB_ORIGIN = process.env.VEXP_WEB_ORIGIN || "https://vexp.dev";
22
22
  const GRACE_MS = 14 * 24 * 60 * 60 * 1000;
23
23
  // Opportunistic refresh cadence: don't hit the network more than once per 24h.
24
24
  const REFRESH_BACKOFF_MS = 24 * 60 * 60 * 1000;
25
- // 3 second timeout on validate calls — any slower falls back silently.
26
- const VALIDATE_TIMEOUT_MS = 3000;
25
+ // 10 second timeout on validate calls — any slower falls back silently.
26
+ // Was 3s: a serverless cold start plus slow DNS (WSL resolvers routinely
27
+ // take seconds) blew that budget, so the refresh that keeps the rolling
28
+ // fresh.jwt alive quietly never landed and users fell back to the long
29
+ // JWT — or, once that lapsed, to the free tier.
30
+ const VALIDATE_TIMEOUT_MS = 10_000;
27
31
  function getLicensePath() {
28
32
  return path.join(vexpHomeDir(), ".vexp", "license.jwt");
29
33
  }
@@ -0,0 +1,62 @@
1
+ import { Writable } from "node:stream";
2
+ import * as readline from "node:readline";
3
+ export function mutableOutput(base) {
4
+ let muted = false;
5
+ const stream = new Writable({
6
+ write(chunk, enc, cb) {
7
+ if (!muted)
8
+ base.write(chunk);
9
+ cb();
10
+ },
11
+ });
12
+ // readline consults these to decide how to render; without them it treats
13
+ // the wrapper as a dumb pipe and the prompt never appears.
14
+ Object.defineProperty(stream, "isTTY", { get: () => base.isTTY === true });
15
+ Object.defineProperty(stream, "columns", { get: () => base.columns });
16
+ Object.defineProperty(stream, "rows", { get: () => base.rows });
17
+ return { stream, setMuted: (m) => { muted = m; } };
18
+ }
19
+ /** Prompt for a secret on a fresh readline interface. */
20
+ export async function promptSecret(promptText, io) {
21
+ const { input, output, isTTY } = io;
22
+ if (!isTTY) {
23
+ // Piped: read the line with terminal mode OFF so readline cannot echo it.
24
+ const rl = readline.createInterface({ input, terminal: false });
25
+ output.write(promptText);
26
+ const line = await new Promise((resolve) => {
27
+ let settled = false;
28
+ rl.once("line", (l) => { settled = true; resolve(l); });
29
+ rl.once("close", () => { if (!settled)
30
+ resolve(""); });
31
+ });
32
+ rl.close();
33
+ output.write("\n");
34
+ return line;
35
+ }
36
+ const ctl = mutableOutput(output);
37
+ const rl = readline.createInterface({ input, output: ctl.stream, terminal: true });
38
+ try {
39
+ return await askSecretOn(rl, promptText, ctl, output);
40
+ }
41
+ finally {
42
+ rl.close();
43
+ }
44
+ }
45
+ /**
46
+ * Prompt for a secret on an EXISTING interface — the interactive shell owns
47
+ * one readline for the whole session and closing it exits the process, so the
48
+ * shell mutes its own output instead of building a second one.
49
+ *
50
+ * The interface must have been created over `ctl.stream`.
51
+ */
52
+ export async function askSecretOn(rl, promptText, ctl, output) {
53
+ const answer = await new Promise((resolve) => {
54
+ // question() writes the prompt synchronously, so muting immediately after
55
+ // hides the keystrokes and nothing else.
56
+ rl.question(promptText, (a) => resolve(a));
57
+ ctl.setMuted(true);
58
+ });
59
+ ctl.setMuted(false);
60
+ output.write("\n");
61
+ return answer;
62
+ }