@tiens.nguyen/gu-cli 1.0.863 → 1.0.865

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-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)
@@ -2868,8 +2904,12 @@ const compactLockNote = () =>
2868
2904
  at: lastPromptBreakdown?.compactedAt,
2869
2905
  })
2870
2906
  : "";
2871
- const footerRows = () =>
2872
- 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"),
2873
2913
  width: process.stdout.columns || 80,
2874
2914
  gutter: GUTTER,
2875
2915
  rag: REPL_VERBOSE && (lastRagFile || lastRagState) ? ragLineText() : "",
@@ -2895,6 +2935,7 @@ const footerRows = () =>
2895
2935
  // where the red rows above mean something is not set up at all.
2896
2936
  missing: [...missingNoticeRows().map((r) => red(r)), ...fallbackNoticeRows().map((r) => yellow(r))],
2897
2937
  });
2938
+ const footerRows = () => footerBuilt().rows;
2898
2939
  // An ask()'s hint lines (#305), drawn in yellow under its input while it waits. Plain text.
2899
2940
  let askHint = [];
2900
2941
  const askHintRows = (width) => questionHintRows(askHint, { width, gutter: GUTTER, paint: yellow });
@@ -3008,9 +3049,11 @@ function zoneNotice(width) {
3008
3049
  const up = updateRow({ ...availableUpdates, width: 400 });
3009
3050
  if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
3010
3051
  }
3011
- if (priceUpdate) items.push({ key: "prices", clickable: true, paint: accent, text: `${priceUpdateText()} (click)` });
3012
- if (limitsUpdate) items.push({ key: "limits", clickable: true, paint: accent, text: `${limitsUpdate.label} (click)` });
3013
- if (tokenNotice?.text && !(tokenQuestionOpen && tokenNotice.kind === "over-limit")) 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 });
3014
3057
  const agents = agentsIndicator(otherAgents);
3015
3058
  if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
3016
3059
  const shells = shellsNotice(shellEntries, { cwd: process.cwd() });
@@ -3125,7 +3168,10 @@ function frozenZone() {
3125
3168
  selection: secretInput ? null : inputSelection,
3126
3169
  highlight: highlightRow,
3127
3170
  });
3128
- 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;
3129
3175
  // The separator is `status.length (+1 for the notice row)` rows down — everything that writes to
3130
3176
  // or hit-tests a row by index counts from here.
3131
3177
  const sep = status.length + (notice ? 1 : 0);
@@ -8587,6 +8633,83 @@ async function refreshLogsSize() {
8587
8633
  }
8588
8634
  }
8589
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
+
8590
8713
  async function runClearLogs() {
8591
8714
  // ONE AT A TIME, and never over a turn or another picker — reached from a fire-and-forget click
8592
8715
  // (see showOtherAgents for what a second picker over the first one does).
@@ -11746,12 +11869,12 @@ async function localScaffoldState() {
11746
11869
  /** Print the startup scaffold line — deferred while a turn owns the transcript. */
11747
11870
  function announceScaffold() {
11748
11871
  if (!scaffoldNotice || following) return;
11749
- const { kind, text, steps } = scaffoldNotice;
11872
+ const { kind, text } = scaffoldNotice;
11750
11873
  scaffoldNotice = null;
11751
11874
  if (kind === "link") {
11752
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.
11753
11877
  console.log(GUTTER + yellow("! " + text));
11754
- for (const s of steps ?? []) console.log(GUTTER + " " + dim(s));
11755
11878
  } else {
11756
11879
  console.log(GUTTER + (kind === "ok" ? accent("✓ " + text) : kind === "warn" ? yellow("⚠ " + text) : red("✗ " + text)));
11757
11880
  }
@@ -14225,6 +14348,15 @@ async function main() {
14225
14348
  process.exit(1);
14226
14349
  }
14227
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
14228
14360
  // Ask whether anything is newer — in the BACKGROUND, so a slow registry never delays the
14229
14361
  // prompt (task #181). The answer paints itself onto the separator row when it arrives, and
14230
14362
  // the interval keeps a long-lived session honest. unref'd: a pending timer must not be the
@@ -14793,6 +14925,10 @@ async function main() {
14793
14925
  await runClearLogs();
14794
14926
  continue;
14795
14927
  }
14928
+ if (line === "/support") {
14929
+ await runSupport();
14930
+ continue;
14931
+ }
14796
14932
  if (line === "/budget") {
14797
14933
  // #205: the short terminal version of web-v2's /providers budgets. The server judges Pro,
14798
14934
  // the account email and "has items" (api/src/providers/terminalBudget.ts).
package/install-path.mjs CHANGED
@@ -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.863",
3
+ "version": "1.0.865",
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",