@tiens.nguyen/gu-cli 1.0.862 → 1.0.864

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/gu-cli.mjs CHANGED
@@ -139,7 +139,8 @@ Usage:
139
139
  gu-cli workspace revert [runId] # undo an agent run's file edits from its backups
140
140
  gu-cli mongo migrate-from-docker # macOS: move a Docker MongoDB to native (Homebrew)
141
141
  gu-cli clear [--scratch|--keep-history] [--yes] # back to a fresh machine (asks first)
142
- gu-cli support # write a masked health report for the support team
142
+ gu-cli support # send the support team a request with gu's logs (masked) attached
143
+ gu-cli support --no-send # only make the logs zip (~/.gonext/support/) to email yourself
143
144
  gu-cli fix-path [--check] # put npm's global folder on PATH (Windows: check, then ask)
144
145
  # (what the install does, if npm skipped it)
145
146
  gu-cli fix-powershell [--check] # Windows: drop PowerShell's built-in \`gu\` alias
@@ -1111,14 +1112,45 @@ if (args[0] === "fix-path") {
1111
1112
  // #94: under `npx @tiens.nguyen/gu-cli fix-path` this file runs from npx's cache; fixPathPrefix
1112
1113
  // then falls back to npm's global prefix when gu is installed there.
1113
1114
  const prefix = fixPathPrefix({ here });
1115
+ // #221: besides the PATH line, look for what an old or second install left behind — broken
1116
+ // `gu` links, a second copy from another npm, a profile line pinned to a versioned Homebrew
1117
+ // folder. Each is explained and asked about; `--check` only reports.
1118
+ const scanEnvironment = async () => {
1119
+ const { checkEnvironment, applyFix } = await import("./env-check.mjs");
1120
+ const { homedir } = await import("node:os");
1121
+ const findings = checkEnvironment({ pathDirs: (process.env.PATH || "").split(":"), home: homedir(), here });
1122
+ if (!findings.length) return;
1123
+ console.log("\n Also found:");
1124
+ const { spawnSync } = await import("node:child_process");
1125
+ let rl = null;
1126
+ const check = args.includes("--check") || !process.stdin.isTTY;
1127
+ for (const f of findings) {
1128
+ console.log(` • ${f.text}`);
1129
+ if (!f.fix || check) {
1130
+ if (f.fix) console.log(` Fix: ${f.fix.label} (run \`gu-cli fix-path\` in a terminal to be asked)`);
1131
+ continue;
1132
+ }
1133
+ rl ??= (await import("node:readline/promises")).createInterface({ input: process.stdin, output: process.stdout });
1134
+ // Removing another gu install is the one that costs something: default No.
1135
+ const dflt = f.fix.kind === "uninstall-copy" ? "N" : "Y";
1136
+ const a = (await rl.question(` ${f.fix.label}? [${dflt === "Y" ? "Y/n" : "y/N"}] `)).trim().toLowerCase();
1137
+ if (!(a === "y" || a === "yes" || (a === "" && dflt === "Y"))) continue;
1138
+ const r = applyFix(f.fix, { run: (cmd, a2) => spawnSync(cmd, a2, { encoding: "utf8" }) });
1139
+ console.log(r.ok ? " Done." : ` Could not: ${r.why}`);
1140
+ }
1141
+ rl?.close();
1142
+ };
1114
1143
  if (!prefix) {
1115
1144
  console.log(` gu is not installed globally with npm (${here}) — there is no PATH to fix.`);
1116
1145
  console.log(" Install it with: npm install -g @tiens.nguyen/gu-cli");
1146
+ await scanEnvironment();
1117
1147
  process.exit(0);
1118
1148
  }
1149
+ const { stableBinDir } = await import("./env-check.mjs");
1119
1150
  const binDir = join(prefix, "bin");
1120
- if ((process.env.PATH || "").split(":").includes(binDir)) {
1151
+ if ((process.env.PATH || "").split(":").some((d) => d === binDir || d === stableBinDir(binDir))) {
1121
1152
  console.log(` Nothing to fix — ${binDir} is already on PATH.`);
1153
+ await scanEnvironment();
1122
1154
  process.exit(0);
1123
1155
  }
1124
1156
  const { spawnSync } = await import("node:child_process");
@@ -1143,9 +1175,10 @@ if (args[0] === "fix-path") {
1143
1175
  if (has) console.log(` Your shell profile already adds ${binDir} to PATH — open a new terminal.`);
1144
1176
  else {
1145
1177
  console.log(` ${binDir} is not on your PATH and was not added to a shell profile. Add this line to it:`);
1146
- console.log(` export PATH="${binDir}:$PATH"`);
1178
+ console.log(` export PATH="${stableBinDir(binDir)}:$PATH"`);
1147
1179
  }
1148
1180
  }
1181
+ await scanEnvironment();
1149
1182
  process.exit(r.status ?? 0);
1150
1183
  }
1151
1184
 
@@ -1159,91 +1192,111 @@ if (args[0] === "fix-powershell") {
1159
1192
  process.exit(0);
1160
1193
  }
1161
1194
  const { execFileSync } = await import("node:child_process");
1162
- const ps = (script) =>
1163
- execFileSync("powershell", ["-NoProfile", "-Command", script], { encoding: "utf8" }).trim();
1164
-
1165
- let profilePath = "";
1166
- try {
1167
- profilePath = ps("$PROFILE");
1168
- } catch (e) {
1169
- console.error(` Could not ask PowerShell where your profile is: ${e.message}`);
1170
- process.exit(1);
1171
- }
1172
- const { readFileSync, writeFileSync, mkdirSync, existsSync } = await import("node:fs");
1173
- const current = existsSync(profilePath) ? readFileSync(profilePath, "utf8") : "";
1174
- const next = pa.profileWithFix(current);
1175
- // ASKED IN EVERY PATH, because "already fixed" was reported on the very machine whose policy
1176
- // stops the profile loading — the same "said Done, changed nothing" mistake one branch along.
1177
- const blockedBy = () => {
1195
+ const runPs = (host, script) =>
1196
+ execFileSync(host, ["-NoProfile", "-Command", script], {
1197
+ encoding: "utf8",
1198
+ stdio: ["ignore", "pipe", "ignore"],
1199
+ }).trim();
1200
+
1201
+ // EVERY installed PowerShell has its OWN profile: Windows PowerShell 5.1 (`powershell`) and
1202
+ // PowerShell 7 (`pwsh`, VS Code's usual terminal) read different files. Asking only
1203
+ // `powershell` wrote the fix where a pwsh terminal never looked.
1204
+ const found = [];
1205
+ for (const host of pa.POWERSHELL_HOSTS) {
1178
1206
  try {
1179
- return pa.profileBlockedBy(ps("Get-ExecutionPolicy"));
1207
+ found.push({ host, profilePath: runPs(host, "$PROFILE.CurrentUserAllHosts") });
1180
1208
  } catch {
1181
- return "";
1182
- }
1183
- };
1184
- if (next === null) {
1185
- console.log(` Already fixed — ${profilePath} removes the alias.`);
1186
- const blocked = blockedBy();
1187
- if (blocked) {
1188
- console.log("");
1189
- for (const l of pa.policyAdvice(blocked)) console.log(" " + l);
1190
- process.exit(1);
1209
+ /* that PowerShell is not installed */
1191
1210
  }
1192
- console.log(" Open a NEW PowerShell window for it to take effect.");
1193
- process.exit(0);
1194
- }
1195
- if (args.includes("--check")) {
1196
- console.log(" Not fixed yet. `gu` in PowerShell is still Get-Unique.");
1197
- const blocked = blockedBy();
1198
- if (blocked) for (const l of pa.policyAdvice(blocked)) console.log(" " + l);
1199
- console.log(` Run: gu-cli fix-powershell (edits ${profilePath})`);
1200
- process.exit(1);
1201
1211
  }
1202
- console.log("");
1203
- for (const l of pa.shadowedAdvice()) console.log(" " + l);
1204
- console.log("");
1205
- console.log(` This appends ONE line to ${profilePath}:`);
1206
- console.log(" " + pa.PROFILE_LINE);
1207
- const rlp = (await import("node:readline")).createInterface({
1208
- input: process.stdin,
1209
- output: process.stdout,
1210
- });
1211
- const answer = await new Promise((r) => rlp.question("\n Append it? [y/N]: ", r));
1212
- rlp.close();
1213
- if (!/^y(es)?$/i.test(String(answer).trim())) {
1214
- console.log(" " + pa.MEANWHILE);
1215
- process.exit(0);
1216
- }
1217
- try {
1218
- const dir = profilePath.replace(/[\\/][^\\/]*$/, "");
1219
- if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true });
1220
- writeFileSync(profilePath, next, "utf8");
1221
- } catch (e) {
1222
- console.error(` Could not write ${profilePath}: ${e.message}`);
1212
+ const targets = pa.uniqueProfiles(found);
1213
+ if (targets.length === 0) {
1214
+ console.error(" Could not ask PowerShell where your profile is (neither `powershell` nor `pwsh` answered).");
1223
1215
  process.exit(1);
1224
1216
  }
1225
- console.log(` Done — ${profilePath} updated.`);
1226
- // VERIFY THE FIX CAN ACTUALLY RUN. Writing the line is not the same as PowerShell loading it:
1227
- // under Restricted or AllSigned the profile is a script it refuses to execute, and the first
1228
- // real run of this said "Done" to a machine where `gu` stayed shadowed.
1229
- const policy = blockedBy();
1230
- if (policy) {
1217
+ const { readFileSync, writeFileSync, mkdirSync, existsSync } = await import("node:fs");
1218
+ const checkOnly = args.includes("--check");
1219
+ let rlp = null;
1220
+ let worst = 0;
1221
+
1222
+ for (const { host, profilePath } of targets) {
1231
1223
  console.log("");
1232
- for (const l of pa.policyAdvice(policy)) console.log(" " + l);
1233
- process.exit(1);
1224
+ console.log(` ${pa.hostLabel(host)} — ${profilePath}`);
1225
+ const current = existsSync(profilePath) ? readFileSync(profilePath, "utf8") : "";
1226
+ const next = pa.profileWithFix(current);
1227
+ // ASKED IN EVERY PATH, because "already fixed" was reported on the very machine whose policy
1228
+ // stops the profile loading — the same "said Done, changed nothing" mistake one branch along.
1229
+ const blockedBy = () => {
1230
+ try {
1231
+ return pa.profileBlockedBy(runPs(host, "Get-ExecutionPolicy"));
1232
+ } catch {
1233
+ return "";
1234
+ }
1235
+ };
1236
+ if (next === null) {
1237
+ console.log(" Already fixed — the profile removes the alias.");
1238
+ const blocked = blockedBy();
1239
+ if (blocked) {
1240
+ for (const l of pa.policyAdvice(blocked)) console.log(" " + l);
1241
+ worst = 1;
1242
+ }
1243
+ continue;
1244
+ }
1245
+ if (checkOnly) {
1246
+ console.log(" Not fixed yet. `gu` in this PowerShell is still Get-Unique.");
1247
+ const blocked = blockedBy();
1248
+ if (blocked) for (const l of pa.policyAdvice(blocked)) console.log(" " + l);
1249
+ worst = 1;
1250
+ continue;
1251
+ }
1252
+ for (const l of pa.shadowedAdvice()) console.log(" " + l);
1253
+ console.log(` This appends ONE line to ${profilePath}:`);
1254
+ console.log(" " + pa.PROFILE_LINE);
1255
+ rlp ??= (await import("node:readline")).createInterface({
1256
+ input: process.stdin,
1257
+ output: process.stdout,
1258
+ });
1259
+ const answer = await new Promise((r) => rlp.question("\n Append it? [y/N]: ", r));
1260
+ if (!/^y(es)?$/i.test(String(answer).trim())) {
1261
+ console.log(" " + pa.MEANWHILE);
1262
+ continue;
1263
+ }
1264
+ try {
1265
+ const dir = profilePath.replace(/[\\/][^\\/]*$/, "");
1266
+ if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true });
1267
+ writeFileSync(profilePath, next, "utf8");
1268
+ } catch (e) {
1269
+ console.error(` Could not write ${profilePath}: ${e.message}`);
1270
+ worst = 1;
1271
+ continue;
1272
+ }
1273
+ console.log(` Done — ${profilePath} updated.`);
1274
+ // VERIFY THE FIX CAN ACTUALLY RUN. Writing the line is not the same as PowerShell loading it:
1275
+ // under Restricted or AllSigned the profile is a script it refuses to execute, and the first
1276
+ // real run of this said "Done" to a machine where `gu` stayed shadowed.
1277
+ const policy = blockedBy();
1278
+ if (policy) {
1279
+ for (const l of pa.policyAdvice(policy)) console.log(" " + l);
1280
+ worst = 1;
1281
+ }
1234
1282
  }
1235
- console.log(" Open a NEW PowerShell window, then `gu` runs this CLI.");
1236
- process.exit(0);
1283
+ rlp?.close();
1284
+ if (worst === 0 && !checkOnly) console.log("\n Open a NEW PowerShell window (in VS Code too), then `gu` runs this CLI.");
1285
+ process.exit(worst);
1237
1286
  }
1238
1287
 
1239
1288
  if (args[0] === "support") {
1240
- // One redacted report for the support team (see support-report.mjs). Changes nothing but its own file.
1241
- const sr = await import("./support-report.mjs");
1242
- const { lines, file } = await sr.writeSupportReport({ version: WORKER_VERSION });
1243
- for (const l of lines) console.log(l);
1244
- console.log("");
1245
- console.log(` Saved to ${file} — send that file (or this text) to the support team. Secrets are masked.`);
1246
- process.exit(0);
1289
+ // #220: the masked logs + health report as one zip, shown, then sent to the support team with a
1290
+ // reference back (support-command.mjs). `--report` is the old text-only report.
1291
+ const { runSupportCommand } = await import("./support-command.mjs");
1292
+ let rl = null;
1293
+ const ask = async (q) => {
1294
+ rl ??= (await import("node:readline/promises")).createInterface({ input: process.stdin, output: process.stdout });
1295
+ return rl.question(q).catch(() => "");
1296
+ };
1297
+ const code = await runSupportCommand({ args: args.slice(1), version: WORKER_VERSION, ask });
1298
+ rl?.close();
1299
+ process.exit(code);
1247
1300
  }
1248
1301
 
1249
1302
  if (args[0] === "clear" || args[0] === "reset") {
package/gu-repl.mjs CHANGED
@@ -105,6 +105,8 @@ import {
105
105
  physicalRows,
106
106
  inputPhysicalRows,
107
107
  buildFooterRows,
108
+ buildFooter,
109
+ SUPPORT_LINK_TEXT,
108
110
  pasteHint,
109
111
  hintRows as questionHintRows,
110
112
  footerDrawSeq,
@@ -707,6 +709,33 @@ let openPresetsAtStart = false;
707
709
  // #132 follow-up: a hosted provider picked in first-run setup; its key and model are asked once the terminal exists.
708
710
  let addProviderAtStart = null;
709
711
 
712
+ // #220: a gu that STOPS ON AN ERROR says where help is — a start that failed (exit 1 before the
713
+ // prompt: no key, API unreachable, MongoDB down…) or a crash. One dim line, never during a session
714
+ // that started fine, where /support is in the command list.
715
+ let supportHintAtExit = true;
716
+ const SUPPORT_HINT = "Need help? Run `gu support` — it sends the support team gu's logs (secrets masked).";
717
+ process.on("exit", (code) => {
718
+ if (code !== 1 || !supportHintAtExit) return;
719
+ try {
720
+ process.stderr.write("\n" + dim(SUPPORT_HINT) + "\n");
721
+ } catch {
722
+ /* stderr gone: nothing to tell */
723
+ }
724
+ });
725
+ function crashed(e) {
726
+ try {
727
+ releaseScreenAtExit();
728
+ } catch {
729
+ /* the screen may not be ours yet */
730
+ }
731
+ console.error(e?.stack || String(e));
732
+ supportHintAtExit = true;
733
+ process.exit(1);
734
+ }
735
+ process.on("uncaughtException", crashed);
736
+ process.on("unhandledRejection", crashed);
737
+
738
+
710
739
  if (!workerKey || !apiBase) {
711
740
  if (process.stdin.isTTY && process.stdout.isTTY) {
712
741
  try {
@@ -977,6 +1006,7 @@ const COMMANDS = [
977
1006
  { name: "/shells", desc: "long commands the agent left running in the background — status, runtime, command, output; stop one" },
978
1007
  { name: "/budget", desc: "short view of your API provider budgets and balances (Pro) — the same items as the web /providers page" },
979
1008
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
1009
+ { name: "/support", desc: "send the gu support team a request with gu's logs attached (secrets masked) — shows what goes first, then asks" },
980
1010
  { name: "/clear-logs", desc: "show what gu's logs, RAG indexes and old API versions take on disk, then ask what to clear — the logs first" },
981
1011
  { name: "/prices", usage: "/prices [update]", desc: "the price table and the cache table, and what the last turn and this session cost — \"update\" applies gu's newest published tables" },
982
1012
  // ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
@@ -1573,6 +1603,12 @@ function applyClick({ row, col }) {
1573
1603
  void runClearLogs();
1574
1604
  return;
1575
1605
  }
1606
+ // The footer's "? support" link (#220): the footer is the last rows of the screen, so count from the bottom.
1607
+ if (supportLinkTarget && !zonePanel && row === viewportRows() - supportLinkTarget.fromEnd + 1 &&
1608
+ col - 1 >= supportLinkTarget.from && col - 1 < supportLinkTarget.to) {
1609
+ void runSupport();
1610
+ return;
1611
+ }
1576
1612
  if (agentsRowTarget && row === updateRowAbs &&
1577
1613
  col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
1578
1614
  void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
@@ -2488,6 +2524,8 @@ async function refreshAvailableUpdates() {
2488
2524
  * paid to see the warning go.
2489
2525
  */
2490
2526
  let tokenNotice = null;
2527
+ /** #219: true while the token question is on screen — the bar must not repeat its warning. */
2528
+ let tokenQuestionOpen = false;
2491
2529
  let tokenBusy = false;
2492
2530
  let startupRenewDone = false;
2493
2531
  /**
@@ -2866,8 +2904,12 @@ const compactLockNote = () =>
2866
2904
  at: lastPromptBreakdown?.compactedAt,
2867
2905
  })
2868
2906
  : "";
2869
- const footerRows = () =>
2870
- buildFooterRows({
2907
+ // #220: where the footer's "? support" link is on screen (set by frozenZone, read by applyClick).
2908
+ let supportLinkTarget = null;
2909
+ const footerBuilt = () =>
2910
+ buildFooter({
2911
+ link: SUPPORT_LINK_TEXT,
2912
+ linkPaint: (t) => dim("\x1b[4m" + t + "\x1b[24m"),
2871
2913
  width: process.stdout.columns || 80,
2872
2914
  gutter: GUTTER,
2873
2915
  rag: REPL_VERBOSE && (lastRagFile || lastRagState) ? ragLineText() : "",
@@ -2893,6 +2935,7 @@ const footerRows = () =>
2893
2935
  // where the red rows above mean something is not set up at all.
2894
2936
  missing: [...missingNoticeRows().map((r) => red(r)), ...fallbackNoticeRows().map((r) => yellow(r))],
2895
2937
  });
2938
+ const footerRows = () => footerBuilt().rows;
2896
2939
  // An ask()'s hint lines (#305), drawn in yellow under its input while it waits. Plain text.
2897
2940
  let askHint = [];
2898
2941
  const askHintRows = (width) => questionHintRows(askHint, { width, gutter: GUTTER, paint: yellow });
@@ -3006,9 +3049,11 @@ function zoneNotice(width) {
3006
3049
  const up = updateRow({ ...availableUpdates, width: 400 });
3007
3050
  if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
3008
3051
  }
3009
- if (priceUpdate) items.push({ key: "prices", clickable: true, paint: accent, text: `${priceUpdateText()} (click)` });
3010
- if (limitsUpdate) items.push({ key: "limits", clickable: true, paint: accent, text: `${limitsUpdate.label} (click)` });
3011
- if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
3052
+ // While the token question is open it is the ONLY thing on the screen (#222): the housekeeping
3053
+ // notices (new prices, new token limits) wait, and the token notice would repeat the question.
3054
+ if (priceUpdate && !tokenQuestionOpen) items.push({ key: "prices", clickable: true, paint: accent, text: `${priceUpdateText()} (click)` });
3055
+ if (limitsUpdate && !tokenQuestionOpen) items.push({ key: "limits", clickable: true, paint: accent, text: `${limitsUpdate.label} (click)` });
3056
+ if (tokenNotice?.text && !tokenQuestionOpen) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
3012
3057
  const agents = agentsIndicator(otherAgents);
3013
3058
  if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
3014
3059
  const shells = shellsNotice(shellEntries, { cwd: process.cwd() });
@@ -3123,7 +3168,10 @@ function frozenZone() {
3123
3168
  selection: secretInput ? null : inputSelection,
3124
3169
  highlight: highlightRow,
3125
3170
  });
3126
- const rows = status.concat(notice ? [notice.row] : [], zone.rows, askHintRows(width), footerRows());
3171
+ const footer = footerBuilt();
3172
+ const rows = status.concat(notice ? [notice.row] : [], zone.rows, askHintRows(width), footer.rows);
3173
+ // The footer is the LAST rows of the zone, so the link's row counts from the end.
3174
+ supportLinkTarget = footer.link ? { fromEnd: footer.rows.length - footer.link.rowIndex, from: footer.link.from, to: footer.link.to } : null;
3127
3175
  // The separator is `status.length (+1 for the notice row)` rows down — everything that writes to
3128
3176
  // or hit-tests a row by index counts from here.
3129
3177
  const sep = status.length + (notice ? 1 : 0);
@@ -8585,6 +8633,83 @@ async function refreshLogsSize() {
8585
8633
  }
8586
8634
  }
8587
8635
 
8636
+ // /support (#220): gu's logs, masked and zipped, sent to the support team with a reference back.
8637
+ // What is collected and how it is sent is support-bundle.mjs; this is the terminal half only.
8638
+ let supportBusy = false;
8639
+ async function runSupport() {
8640
+ if (following) {
8641
+ console.log("\n" + GUTTER + dim("A turn is running - /support is available when it finishes (Ctrl-C stops it)."));
8642
+ return;
8643
+ }
8644
+ if (supportBusy || listPickerActive || approvalActive || askWaiting) return;
8645
+ supportBusy = true;
8646
+ try {
8647
+ const sb = await import("./support-bundle.mjs");
8648
+ const width = Math.max(20, process.stdout.columns || 80);
8649
+ const tilde = (p) => (p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p);
8650
+ const note = (l) => console.log(GUTTER + l);
8651
+ console.log("\n" + GUTTER + dim("Collecting gu's logs (secrets are masked)…"));
8652
+ const bundle = await sb.prepareSupportBundle({ version: REPL_VERSION });
8653
+ const where = tilde(bundle.file);
8654
+ const fallback = (opts = {}) => sb.supportFallbackLines({ file: where, ...opts });
8655
+ console.log("");
8656
+ note(bold("What would be sent") + dim(` · ${sb.bundleSize(bundle)} zip`));
8657
+ for (const row of sb.bundleSummaryLines(bundle)) note(" " + dim(row));
8658
+ note(dim("saved at ") + dim(where));
8659
+ console.log("");
8660
+ // The answers are typed in the input row, which empties after Enter: echo them so the
8661
+ // scrollback still shows what was sent.
8662
+ const typedMessage = (await ask("What went wrong? (Enter to skip): ")).trim();
8663
+ const message = sb.supportMessage(typedMessage);
8664
+ note(dim("what went wrong: ") + (typedMessage || dim("(not said)")));
8665
+ let email = await sb.defaultReplyEmail();
8666
+ const typed = (await ask(`Email for the answer${email ? ` [${email}]` : ""}: `)).trim();
8667
+ if (typed) email = typed;
8668
+ if (!sb.isEmail(email)) {
8669
+ note(yellow("No reply email — the support team could not answer you. Nothing sent."));
8670
+ for (const l of fallback()) note(dim(l));
8671
+ console.log("");
8672
+ return;
8673
+ }
8674
+ note(dim("answer to: ") + email);
8675
+ // A question never wraps: at 80 columns over SSH the picker hint goes on a row of its own.
8676
+ const q = `Send it to the gu support team (${sb.SUPPORT_EMAIL})?`;
8677
+ const hint = `(${PICK_HINT}):`;
8678
+ console.log("");
8679
+ if (3 + q.length + 2 + hint.length < width) console.log(dim(` ${q} ${hint}`));
8680
+ else console.log(dim(` ${q}\n ${hint}`));
8681
+ const go = await pickFromList(["Yes — send the request and the logs", "No — keep the zip, I will email it myself"], 0);
8682
+ if (go !== 0) {
8683
+ for (const l of fallback()) note(dim(l));
8684
+ console.log("");
8685
+ return;
8686
+ }
8687
+ note(dim("Sending…"));
8688
+ const r = await sb.sendSupportRequest({ base: sb.supportApiBase(), message, email, version: REPL_VERSION, zip: bundle.zip });
8689
+ if (!r.ok) {
8690
+ const [first, ...rest] = fallback({ why: r.why });
8691
+ note(yellow(first));
8692
+ for (const l of rest) note(l);
8693
+ console.log("");
8694
+ return;
8695
+ }
8696
+ note(accent(`✓ Sent — your reference is ${bold(r.ref)}`));
8697
+ note(dim(`the answer goes to ${email}`));
8698
+ if (r.uploaded) note(dim(`logs attached · to add more, email ${sb.SUPPORT_EMAIL} with ${r.ref}`));
8699
+ else {
8700
+ note(yellow(`The logs did not go with it (${r.uploadWhy}).`));
8701
+ for (const l of fallback({ ref: r.ref })) note(l);
8702
+ }
8703
+ console.log("");
8704
+ } catch (e) {
8705
+ if (wasPromptCancelled(e)) console.log(dim(" (cancelled — nothing sent)\n"));
8706
+ else console.log(GUTTER + red(`couldn't finish — ${e?.message ?? e}`) + "\n");
8707
+ } finally {
8708
+ supportBusy = false;
8709
+ if (barPinned()) paintBar();
8710
+ }
8711
+ }
8712
+
8588
8713
  async function runClearLogs() {
8589
8714
  // ONE AT A TIME, and never over a turn or another picker — reached from a fire-and-forget click
8590
8715
  // (see showOtherAgents for what a second picker over the first one does).
@@ -11744,12 +11869,12 @@ async function localScaffoldState() {
11744
11869
  /** Print the startup scaffold line — deferred while a turn owns the transcript. */
11745
11870
  function announceScaffold() {
11746
11871
  if (!scaffoldNotice || following) return;
11747
- const { kind, text, steps } = scaffoldNotice;
11872
+ const { kind, text } = scaffoldNotice;
11748
11873
  scaffoldNotice = null;
11749
11874
  if (kind === "link") {
11750
11875
  // A setup step, said calmly (#292): the headline, then the steps on their own lines.
11876
+ // ONE yellow line (#222): the steps are the token question's own, behind "?" there.
11751
11877
  console.log(GUTTER + yellow("! " + text));
11752
- for (const s of steps ?? []) console.log(GUTTER + " " + dim(s));
11753
11878
  } else {
11754
11879
  console.log(GUTTER + (kind === "ok" ? accent("✓ " + text) : kind === "warn" ? yellow("⚠ " + text) : red("✗ " + text)));
11755
11880
  }
@@ -11780,13 +11905,15 @@ async function ensureTokenForTurn() {
11780
11905
  * (Esc does nothing), one Backspace clears the default for a paste, and a valid token turns the
11781
11906
  * row back into ">> ". → true once a token is saved.
11782
11907
  */
11783
- async function askForCliToken(ct, dir, { why = "", overLimit = false } = {}) {
11908
+ async function askForCliToken(ct, dir, { why = "", overLimit = false, short = "" } = {}) {
11784
11909
  console.log("");
11785
11910
  tokenPromptActive = true; // Ctrl+C now quits gu (see onInterrupt) — task #17
11911
+ tokenQuestionOpen = true;
11786
11912
  try {
11787
- return await askForCliTokenInner(ct, dir, why, overLimit);
11913
+ return await askForCliTokenInner(ct, dir, why, overLimit, short);
11788
11914
  } finally {
11789
11915
  tokenPromptActive = false;
11916
+ tokenQuestionOpen = false;
11790
11917
  }
11791
11918
  }
11792
11919
 
@@ -11804,7 +11931,7 @@ function quitFromTokenPrompt() {
11804
11931
  process.exit(0);
11805
11932
  }
11806
11933
 
11807
- async function askForCliTokenInner(ct, dir, why, overLimit = false) {
11934
+ async function askForCliTokenInner(ct, dir, why, overLimit = false, short = "") {
11808
11935
  const hostedBase = await tokenHostedBase();
11809
11936
  const pk = ct.publicKey();
11810
11937
  const site = ct.siteUrl();
@@ -11816,6 +11943,7 @@ async function askForCliTokenInner(ct, dir, why, overLimit = false) {
11816
11943
  hostedBase,
11817
11944
  why,
11818
11945
  overLimit, // #209: the computer limit, not a missing plan — no "paste a Pro token"
11946
+ short, // #219: the one short yellow line; the rest is behind "?"
11819
11947
  // #315: a token is only good if THIS computer may use it — renew it from here (the server then
11820
11948
  // says how much free time the computer has), save what came back, and run the licence rule.
11821
11949
  accept: async (token) => {
@@ -11893,7 +12021,7 @@ async function askTokenAtStart() {
11893
12021
  return;
11894
12022
  }
11895
12023
  }
11896
- await askForCliToken(ct, dir, { why: d.why, overLimit: d.reason === "over-limit" });
12024
+ await askForCliToken(ct, dir, { why: d.why, overLimit: d.reason === "over-limit", short: d.short ?? "" });
11897
12025
  }
11898
12026
  /** The token refresh started at launch — askTokenAtStart waits for it (see there). */
11899
12027
  let startupTokenRefresh = null;
@@ -14220,6 +14348,15 @@ async function main() {
14220
14348
  process.exit(1);
14221
14349
  }
14222
14350
  console.log(wrapGutter("Ask about this repo. Type / to see commands (Tab completes). Ctrl-C aborts a running turn.", dim) + "\n");
14351
+ // #221: gu runs, but its own npm folder is not on PATH (npm skipped the postinstall that adds it).
14352
+ try {
14353
+ const { pathNoticeLine } = await import("./env-check.mjs");
14354
+ const note = pathNoticeLine({ prefix: globalPrefixOf(DIR), pathEnv: process.env.PATH || "", ci: Boolean(process.env.CI) });
14355
+ if (note) console.log(wrapGutter(note, yellow) + "\n");
14356
+ } catch {
14357
+ /* a hint, never a reason to fail to start */
14358
+ }
14359
+ supportHintAtExit = false; // started: from here /support is in the command list
14223
14360
  // Ask whether anything is newer — in the BACKGROUND, so a slow registry never delays the
14224
14361
  // prompt (task #181). The answer paints itself onto the separator row when it arrives, and
14225
14362
  // the interval keeps a long-lived session honest. unref'd: a pending timer must not be the
@@ -14788,6 +14925,10 @@ async function main() {
14788
14925
  await runClearLogs();
14789
14926
  continue;
14790
14927
  }
14928
+ if (line === "/support") {
14929
+ await runSupport();
14930
+ continue;
14931
+ }
14791
14932
  if (line === "/budget") {
14792
14933
  // #205: the short terminal version of web-v2's /providers budgets. The server judges Pro,
14793
14934
  // the account email and "has items" (api/src/providers/terminalBudget.ts).
package/install-path.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * - NEVER fail the install. Every path through this file ends in exit 0; npm treats a
22
22
  * postinstall failure as a failed install and the user gets nothing.
23
23
  * - NEVER run on Windows (PATH there lives in the registry, and `gu`'s PowerShell
24
- * alias problem is powershell-alias.mjs's job, explicitly interactive).
24
+ * alias problem is install.ps1's job (asks once) and `gu-cli fix-powershell`'s, both interactive).
25
25
  * - NEVER touch an rc file twice: the marker pair makes re-runs no-ops.
26
26
  * - Under `sudo npm i -g`, write to the REAL user's home (SUDO_USER) and chown the
27
27
  * file back, or the user's next login shell reads a root-owned rc file.
@@ -30,6 +30,7 @@ import { appendFileSync, existsSync, readFileSync, chownSync, writeFileSync } fr
30
30
  import { homedir, platform, tmpdir } from "node:os";
31
31
  import { join, resolve } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
+ import { stableBinDir } from "./env-check.mjs";
33
34
 
34
35
  const BEGIN = "# >>> gu-cli PATH >>>";
35
36
  const END = "# <<< gu-cli PATH <<<";
@@ -121,12 +122,15 @@ function main() {
121
122
  const binDir = join(prefix, "bin");
122
123
  if (resolve(binDir).startsWith(resolve(tmpdir()))) return; // preflight's throwaway prefix
123
124
 
124
- const onPath = (process.env.PATH || "").split(":").some((p) => p === binDir);
125
+ // On PATH under EITHER spelling: Homebrew's npm can report its prefix as the versioned Cellar
126
+ // folder while PATH holds the stable <brew prefix>/bin — the same folder (#221).
127
+ const stable = stableBinDir(binDir);
128
+ const onPath = (process.env.PATH || "").split(":").some((p) => p === binDir || p === stable);
125
129
  if (onPath) return; // nothing to fix — most installs
126
130
 
127
131
  const home = realUserHome();
128
132
  const targets = rcFilesFor(home);
129
- const block = `${BEGIN}\n# npm's global bin dir was not on PATH; added by @tiens.nguyen/gu-cli postinstall.\nexport PATH="${binDir}:$PATH"\n${END}\n`;
133
+ const block = `${BEGIN}\n# npm's global bin dir was not on PATH; added by @tiens.nguyen/gu-cli postinstall.\nexport PATH="${stable}:$PATH"\n${END}\n`;
130
134
 
131
135
  let wrote = null;
132
136
  for (const rc of targets) {
@@ -134,12 +138,12 @@ function main() {
134
138
  }
135
139
 
136
140
  if (wrote) {
137
- console.log(`gu-cli: added ${binDir} to PATH in ${wrote}.`);
141
+ console.log(`gu-cli: added ${stable} to PATH in ${wrote}.`);
138
142
  console.log(`gu-cli: open a NEW terminal to use \`gu\`, or run this once in the current one:`);
139
- console.log(` export PATH="${binDir}:$PATH"`);
143
+ console.log(` export PATH="${stable}:$PATH"`);
140
144
  } else if (targets.length === 0) {
141
- console.log(`gu-cli: ${binDir} is not on your PATH and no shell profile was found to update.`);
142
- console.log(` export PATH="${binDir}:$PATH"`);
145
+ console.log(`gu-cli: ${stable} is not on your PATH and no shell profile was found to update.`);
146
+ console.log(` export PATH="${stable}:$PATH"`);
143
147
  }
144
148
  }
145
149
 
package/mongo-native.mjs CHANGED
@@ -246,6 +246,21 @@ export function captureOutput(cmd, args, timeoutMs = 10000) {
246
246
  });
247
247
  }
248
248
 
249
+ /**
250
+ * Which of Homebrew's output lines are worth showing (#221). A MongoDB install prints ~100 lines
251
+ * (every library it pours, a mug per bottle) over several minutes; shown whole they bury the one
252
+ * that matters. Kept: the `==>` stage lines ("Installing mongodb-community", "Downloading…"), what
253
+ * gu itself prints (`$ brew …`), and anything that looks like a failure or warning.
254
+ */
255
+ export function brewLineWorthShowing(line) {
256
+ const l = String(line ?? "").trim();
257
+ if (!l) return false;
258
+ if (l.startsWith("$ ")) return true;
259
+ if (/\b(error|fail(ed|ure)?|warning|cannot|denied|not found|conflict)\b/i.test(l)) return true;
260
+ if (l.startsWith("==>")) return !/^==> (Pouring|Fetching downloads? for)\b/.test(l);
261
+ return false;
262
+ }
263
+
249
264
  /**
250
265
  * Homebrew steps, one after another, output shown. → true when all succeeded. HOMEBREW_NO_AUTO_UPDATE
251
266
  * keeps `brew install` from first updating every formula on the machine — minutes nobody asked for.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.862",
3
+ "version": "1.0.864",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -89,6 +89,9 @@
89
89
  "node-version.mjs",
90
90
  "clear-state.mjs",
91
91
  "support-report.mjs",
92
+ "env-check.mjs",
93
+ "support-bundle.mjs",
94
+ "support-command.mjs",
92
95
  "clear-logs.mjs",
93
96
  "platform-tools.mjs",
94
97
  "job-pools.mjs",