@tiens.nguyen/gu-cli 1.0.791 → 1.0.793

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
@@ -110,6 +110,8 @@ import {
110
110
  workspaceScopeWarning,
111
111
  isLocalApiBase,
112
112
  modeRow,
113
+ nothingToAnswerWith,
114
+ NO_MODEL_LINES,
113
115
  resizeRepaintTarget,
114
116
  workspaceSyncQuestion,
115
117
  workspaceSyncLabel,
@@ -128,6 +130,7 @@ import {
128
130
  } from "./terminal-layout.mjs";
129
131
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
130
132
  import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
133
+ import { clearLogsNoticeText } from "./clear-logs.mjs";
131
134
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
132
135
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
133
136
  import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
@@ -664,6 +667,26 @@ if (!workerKey || !apiBase) {
664
667
  input: process.stdin,
665
668
  output: process.stdout,
666
669
  });
670
+ // Ctrl+C AND Ctrl+D MUST END THE RUN CLEANLY (task #333). readline swallows Ctrl+C in raw
671
+ // mode: with no listener it only pauses, the pending question never settles, and Node
672
+ // exits with "Warning: Detected unsettled top-level await" and a stack line — at the
673
+ // models menu of a first install. It also meant the "stopped during <step>" record
674
+ // (markInterruptedOnExit listens on the PROCESS) never fired for a Ctrl+C typed here.
675
+ let rl0Done = false;
676
+ const { stopFirstRun } = await import("./first-run-steps.mjs");
677
+ const endFirstRun = (reason) =>
678
+ stopFirstRun({
679
+ reason,
680
+ hasProcessListener: process.listenerCount("SIGINT") > 0,
681
+ emitSigint: () => process.emit("SIGINT"),
682
+ log: (t) => console.log(t),
683
+ exit: (code) => process.exit(code),
684
+ style: { yellow, dim },
685
+ });
686
+ rl0.on("SIGINT", () => endFirstRun("interrupt"));
687
+ rl0.on("close", () => {
688
+ if (!rl0Done) endFirstRun("eof");
689
+ });
667
690
  const ask0 = (q) =>
668
691
  new Promise((res) => rl0.question(accent(q), (a) => res(String(a || ""))));
669
692
  let typed = "";
@@ -681,7 +704,7 @@ if (!workerKey || !apiBase) {
681
704
  const {
682
705
  probeMachine, askMode, runClientExpertSetup, loadState, saveState,
683
706
  } = await import("./client-expert-run.mjs");
684
- const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode, promptForLicenseToken } =
707
+ const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode, licenseUnverifiedLine, promptForLicenseToken } =
685
708
  await import("./client-expert-setup.mjs");
686
709
  const machine0 = await probeMachine();
687
710
  const gate0 = clientExpertAvailable({ workerEnvExists: false, ...machine0 });
@@ -700,7 +723,9 @@ if (!workerKey || !apiBase) {
700
723
  // (task #13). Format is checked first (no network), then offline signature, then the
701
724
  // live API. Empty Enter declines → the private default. Unreachable API still opens
702
725
  // the menu (soft licence: an outage never blocks a paid user).
726
+ const { setupPrompts: tokenPrompts } = await import("./setup-prompts.mjs");
703
727
  const pick = await promptForLicenseToken(ask0, licenseToken0, {
728
+ askSecret: tokenPrompts(rl0, { style: accent }).askSecret,
704
729
  validateOpts: {
705
730
  hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
706
731
  },
@@ -709,7 +734,7 @@ if (!workerKey || !apiBase) {
709
734
  if (pick.declined || !pick.token) {
710
735
  plan0 = resolveFirstRunMode({ licenseToken: "", expertAvailable: gate0.available });
711
736
  } else if (pick.result && pick.result.reason === "unverified") {
712
- console.log(dim("\n Couldn't reach the license server to confirm the token — showing the menu anyway."));
737
+ console.log(dim("\n " + licenseUnverifiedLine(pick.result.detail)));
713
738
  }
714
739
  }
715
740
  let mode0;
@@ -781,6 +806,7 @@ if (!workerKey || !apiBase) {
781
806
  typed = (await ask0(" worker key (or Enter to sign in): ")).trim();
782
807
  }
783
808
  } finally {
809
+ rl0Done = true;
784
810
  rl0.close();
785
811
  }
786
812
  if (stopAfterSetup) {
@@ -891,6 +917,7 @@ const COMMANDS = [
891
917
  { name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
892
918
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
893
919
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
920
+ { 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" },
894
921
  { 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" },
895
922
  // ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
896
923
  // for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
@@ -1462,6 +1489,11 @@ function applyClick({ row, col }) {
1462
1489
  void runPricesUpdate();
1463
1490
  return;
1464
1491
  }
1492
+ if (logsRowTarget && row === updateRowAbs &&
1493
+ col - 1 >= logsRowTarget.from && col - 1 < logsRowTarget.to) {
1494
+ void runClearLogs();
1495
+ return;
1496
+ }
1465
1497
  if (agentsRowTarget && row === updateRowAbs &&
1466
1498
  col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
1467
1499
  void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
@@ -1592,6 +1624,9 @@ let imageNoticeTimer = null;
1592
1624
  // its own model is unset, and choosing that model (/agent-model, /embed-model, /setup, or the
1593
1625
  // API seeding one) clears only that one.
1594
1626
  let agentMissing = false; // no agent model chosen → agent falls back to the coding model
1627
+ // NO MODEL AT ALL (#99): neither an agent model nor a coder — there is no server to send a
1628
+ // question to. Seeded from the startup payload; re-checked before a question is refused.
1629
+ let noModelAtAll = false;
1595
1630
  let embedMissing = false; // no embed model chosen → RAG is off until /embed-model picks one
1596
1631
  // One footer row; the two halves are independent so a model that IS set is never named.
1597
1632
  // ONE SHORT ROW EACH (#317): joined, they ran past 80 columns and were cut mid-word.
@@ -2270,7 +2305,7 @@ async function refreshCliToken() {
2270
2305
  let state = await ct.readState(dir);
2271
2306
  const now = new Date();
2272
2307
  const noticeNow = () =>
2273
- ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
2308
+ ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site, why: state.lastAttemptWhy ?? "" });
2274
2309
  tokenNotice = noticeNow();
2275
2310
 
2276
2311
  const early = Boolean(tokenNotice) && !startupRenewDone;
@@ -2289,7 +2324,8 @@ async function refreshCliToken() {
2289
2324
  // A reply is only kept if it checks out here — a proxy's HTML page or another server's token
2290
2325
  // must not replace a good one.
2291
2326
  const fresh = r.ok && ct.verifyCliToken(r.token, pk).ok ? r.token : state.token;
2292
- state = { token: fresh, lastAttemptAt: now.toISOString() };
2327
+ // WHY it failed is kept with the attempt, for the bar (#102).
2328
+ state = { token: fresh, lastAttemptAt: now.toISOString(), lastAttemptWhy: r.ok ? "" : String(r.why ?? "") };
2293
2329
  await ct.writeState(dir, state).catch(() => {});
2294
2330
  tokenNotice = noticeNow();
2295
2331
  }
@@ -2680,6 +2716,9 @@ function zoneNotice(width) {
2680
2716
  if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2681
2717
  const agents = agentsIndicator(otherAgents);
2682
2718
  if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
2719
+ // LAST, so it is the first to give way on a narrow window: housekeeping, never urgent (#329).
2720
+ const logs = clearLogsNoticeText(logsBytes);
2721
+ if (logs) items.push({ key: "logs", clickable: true, paint: dim, text: logs });
2683
2722
  return noticeRow(items, { width, gutter: GUTTER });
2684
2723
  }
2685
2724
 
@@ -2760,6 +2799,7 @@ function frozenZone() {
2760
2799
  updateRowTarget = notice?.spans.update ?? null;
2761
2800
  agentsRowTarget = notice?.spans.agents ?? null;
2762
2801
  pricesRowTarget = notice?.spans.prices ?? null;
2802
+ logsRowTarget = notice?.spans.logs ?? null;
2763
2803
  jumpSpan = notice?.spans.jump ?? null;
2764
2804
  return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
2765
2805
  }
@@ -2829,7 +2869,7 @@ function paintBar() {
2829
2869
  // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2830
2870
  const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2831
2871
  jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2832
- updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
2872
+ updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || logsRowTarget ? noticeAbs : 0;
2833
2873
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2834
2874
  // is parked on the input line right after, so there is nothing to preserve here.
2835
2875
  ui(
@@ -2991,6 +3031,8 @@ let updateRowAbs = 0;
2991
3031
  let agentsRowTarget = null;
2992
3032
  // …and "new prices updated <date>" (#323).
2993
3033
  let pricesRowTarget = null;
3034
+ // …and "clear logs · 175 MB" (#329), shown only once the logs are worth a row.
3035
+ let logsRowTarget = null;
2994
3036
  // A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
2995
3037
  // bracketed-paste and mouse-reporting teardowns.
2996
3038
  process.on("exit", () => {
@@ -7071,6 +7113,16 @@ function writePresetFile(pr, file, name, parsed) {
7071
7113
  const v = pr.validatePresetFile(JSON.stringify(out));
7072
7114
  console.log(GUTTER + `Preset "${name}" saved to ${file}:`);
7073
7115
  if (v.ok && v.full) for (const l of pr.fullPresetSummary(v.value, v.codingDefault)) console.log(GUTTER + dim(l));
7116
+ // SAY WHAT IS ACTUALLY IN IT (reported 2026-10-02). This line used to read "It CONTAINS API
7117
+ // keys" under a summary that said "no key" four times: the pasted text held key NAMES only, and
7118
+ // the person carried the file to another machine believing the keys were inside.
7119
+ const n = pr.presetKeyCounts(out);
7120
+ if (!n.withValue) {
7121
+ console.log(GUTTER + yellow("It contains NO API keys") + dim(n.nameOnly ? ` — only the names of ${n.nameOnly}. Using it will ask you to paste each one.` : "."));
7122
+ console.log(GUTTER + dim("To carry keys: on the computer that HAS them run /open-presets and copy (keys included)."));
7123
+ return;
7124
+ }
7125
+ if (n.nameOnly) console.log(GUTTER + yellow(`${n.nameOnly} of its keys ${n.nameOnly === 1 ? "is" : "are"} a name only`) + dim(" — their values are not in the file."));
7074
7126
  } else {
7075
7127
  writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset: parsed.preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
7076
7128
  console.log(GUTTER + `Preset "${name}" saved to ${file}`);
@@ -7879,6 +7931,128 @@ async function runPricesUpdate() {
7879
7931
  }
7880
7932
  }
7881
7933
 
7934
+ // ---------------------------------------------------------------------------------------------
7935
+ // "CLEAR LOGS" (#329): a quiet item at the right of the notice row once gu's logs are worth a row,
7936
+ // and /clear-logs for where there is no mouse. A click deletes nothing: it MEASURES — the logs,
7937
+ // and beside them the two things that are actually big, the RAG indexes and the downloaded API
7938
+ // versions — shows that with a total, then asks about each. What each "clear" may and may not
7939
+ // touch is decided in clear-logs.mjs; this is the terminal half only.
7940
+ // ---------------------------------------------------------------------------------------------
7941
+ let logsBytes = 0; // what the notice shows; only the logs are measured for it (a handful of stats)
7942
+ let clearLogsBusy = false;
7943
+
7944
+ async function refreshLogsSize() {
7945
+ try {
7946
+ const { surveyLogs } = await import("./clear-logs.mjs");
7947
+ const was = clearLogsNoticeText(logsBytes);
7948
+ logsBytes = (await surveyLogs()).bytes;
7949
+ if (clearLogsNoticeText(logsBytes) !== was && barPinned()) paintBar();
7950
+ } catch {
7951
+ /* a housekeeping notice is never worth an error */
7952
+ }
7953
+ }
7954
+
7955
+ async function runClearLogs() {
7956
+ // ONE AT A TIME, and never over a turn or another picker — reached from a fire-and-forget click
7957
+ // (see showOtherAgents for what a second picker over the first one does).
7958
+ if (clearLogsBusy || listPickerActive || approvalActive || following) return;
7959
+ clearLogsBusy = true;
7960
+ try {
7961
+ const cl = await import("./clear-logs.mjs");
7962
+ const { LOCAL_API_PORT } = await import("./client-expert-api.mjs");
7963
+ const width = Math.max(20, process.stdout.columns || 80);
7964
+ console.log("\n" + GUTTER + dim("Measuring ~/.gonext…"));
7965
+ // Which API version is ANSWERING decides what "old" means — ask it, do not infer it.
7966
+ const runningVersion = await fetch(`http://127.0.0.1:${LOCAL_API_PORT}/api/health`, { signal: AbortSignal.timeout(2000) })
7967
+ .then((r) => r.json())
7968
+ .then((j) => String(j?.version ?? ""))
7969
+ .catch(() => "");
7970
+ const survey = await cl.surveyDisk({ runningVersion });
7971
+ console.log("");
7972
+ for (const row of cl.summaryRows(survey, { width: width - GUTTER.length - 1 })) {
7973
+ console.log(GUTTER + (row.startsWith("Total") ? bold(row) : row));
7974
+ }
7975
+ const freed = [];
7976
+ // A question never wraps: at 80 columns over SSH the picker hint goes on a row of its own.
7977
+ const question = (q) => {
7978
+ const hint = `(${PICK_HINT}):`;
7979
+ console.log("");
7980
+ if (2 + q.length + 2 + hint.length < width) console.log(dim(` ${q} ${hint}`));
7981
+ else console.log(dim(` ${q}\n ${hint}`));
7982
+ };
7983
+ const cancelled = () => {
7984
+ console.log(dim(` (stopped — nothing ${freed.length ? "further " : ""}removed)\n`));
7985
+ paintBar();
7986
+ };
7987
+
7988
+ // 1. THE LOGS — what was asked for, so Yes is the first row.
7989
+ if (survey.logs.bytes > 0) {
7990
+ question(`Clear the logs — ${cl.humanSize(survey.logs.bytes)}?`);
7991
+ const go = await pickFromList(["Yes — clear the logs" + dim(" · the worker and the API keep running"), "No"], 0);
7992
+ if (go < 0) return cancelled();
7993
+ if (go === 0) {
7994
+ const r = await cl.clearLogs(survey.logs);
7995
+ freed.push({ what: "logs", ...r });
7996
+ console.log(GUTTER + accent(`✓ logs cleared — ${cl.humanSize(r.freed)} freed`) +
7997
+ (r.failed.length ? yellow(` (still in use, left alone: ${r.failed.join(", ")})`) : ""));
7998
+ } else {
7999
+ console.log(GUTTER + dim("logs kept"));
8000
+ }
8001
+ } else {
8002
+ console.log("\n" + GUTTER + dim("No logs to clear."));
8003
+ }
8004
+
8005
+ // 2. RAG — costs a re-index, so No is the first row.
8006
+ if (survey.rag.indexes.length) {
8007
+ const gone = survey.rag.indexes.filter((i) => i.missing);
8008
+ const goneBytes = gone.reduce((n, i) => n + i.bytes, 0);
8009
+ const choices = [{ id: "no", label: "No — keep them" }];
8010
+ if (gone.length && gone.length < survey.rag.indexes.length) {
8011
+ choices.push({ id: "gone", label: `Only the ${gone.length} whose folder no longer exists — ${cl.humanSize(goneBytes)}` });
8012
+ }
8013
+ choices.push({ id: "all", label: `Yes — all ${survey.rag.indexes.length}` + dim(" · a folder is indexed again when it is next used") });
8014
+ question(`Also clear the RAG indexes — ${cl.humanSize(survey.rag.bytes)}?`);
8015
+ const pick = await pickFromList(choices.map((c) => c.label), 0);
8016
+ if (pick < 0) return cancelled();
8017
+ if (choices[pick].id !== "no") {
8018
+ const r = await cl.clearRagIndexes(choices[pick].id === "gone" ? gone : survey.rag.indexes);
8019
+ freed.push({ what: "rag", ...r });
8020
+ console.log(GUTTER + accent(`✓ ${r.removed} RAG index${r.removed === 1 ? "" : "es"} cleared — ${cl.humanSize(r.freed)} freed`) +
8021
+ (r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
8022
+ } else {
8023
+ console.log(GUTTER + dim("RAG indexes kept"));
8024
+ }
8025
+ }
8026
+
8027
+ // 3. THE API — only ever the OLDER versions; the one in use is not on offer.
8028
+ if (survey.api.old.length) {
8029
+ const n = survey.api.old.length;
8030
+ question(`Also remove the ${n} older API version${n === 1 ? "" : "s"} — ${cl.humanSize(survey.api.oldBytes)}?`);
8031
+ const go = await pickFromList(["No — keep them", `Yes — remove the ${n} older one${n === 1 ? "" : "s"}` +
8032
+ dim(survey.api.inUse ? ` · ${survey.api.inUse}, the one in use, stays` : "")], 0);
8033
+ if (go < 0) return cancelled();
8034
+ if (go === 1) {
8035
+ console.log(GUTTER + dim("Removing…"));
8036
+ const r = await cl.clearOldApiVersions(survey.api.old);
8037
+ freed.push({ what: "api", ...r });
8038
+ console.log(GUTTER + accent(`✓ ${r.removed} older API version${r.removed === 1 ? "" : "s"} removed — ${cl.humanSize(r.freed)} freed`) +
8039
+ (r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
8040
+ } else {
8041
+ console.log(GUTTER + dim("older API versions kept"));
8042
+ }
8043
+ }
8044
+
8045
+ const total = freed.reduce((n, f) => n + f.freed, 0);
8046
+ console.log("\n" + GUTTER + (freed.length ? bold(`${cl.humanSize(total)} freed in all.`) : dim("Nothing removed.")) + "\n");
8047
+ } catch (e) {
8048
+ console.log(GUTTER + red(`couldn't finish — ${e?.message ?? e}`) + "\n");
8049
+ } finally {
8050
+ clearLogsBusy = false;
8051
+ await refreshLogsSize();
8052
+ if (barPinned()) paintBar();
8053
+ }
8054
+ }
8055
+
7882
8056
  /**
7883
8057
  * `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
7884
8058
  *
@@ -10308,8 +10482,13 @@ async function tryStartLocalApi() {
10308
10482
  );
10309
10483
  const version = versions.pop();
10310
10484
  if (!version) return false; // nothing installed — setup never got that far
10485
+ const portBefore = ce.LOCAL_API_PORT;
10311
10486
  await ce.startLocalApi({ apiDir: join(base, version) });
10312
- return (await ce.waitForApi({ timeoutMs: 30_000 })).ok;
10487
+ const up = (await ce.waitForApi({ timeoutMs: 30_000 })).ok;
10488
+ // The port can move here (#337: the saved one now belongs to another program). worker.env
10489
+ // was re-pointed; THIS terminal still holds the old address until it reads the file again.
10490
+ if (ce.LOCAL_API_PORT !== portBefore) reloadWorkerEnv();
10491
+ return up;
10313
10492
  } catch {
10314
10493
  return false;
10315
10494
  }
@@ -10641,6 +10820,22 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
10641
10820
  // from the gu service with it. Asked for HERE, before anything is sent, with the steps — rather
10642
10821
  // than letting the turn fail and leaving the user to decode why.
10643
10822
  if (!skipAsk && !(await ensureTokenForTurn())) return;
10823
+ // NOTHING TO ANSWER WITH (#99). On an account with no models at all the question used to be
10824
+ // sent, sat at "queued — waiting for the worker…", and ended in "✗ Job not found." — the job
10825
+ // had no model server in it, so nothing could read or claim it. Say what is missing instead.
10826
+ // Asked again first: /model or /agent-model may have set one since gu started.
10827
+ if (!skipAsk && noModelAtAll) {
10828
+ try {
10829
+ noModelAtAll = nothingToAnswerWith((await fetchAgentPayload([{ role: "user", content: "." }]))?.payload);
10830
+ } catch {
10831
+ /* cannot ask → keep what startup saw */
10832
+ }
10833
+ if (noModelAtAll) {
10834
+ for (const l of NO_MODEL_LINES) console.log(GUTTER + (l === NO_MODEL_LINES[0] ? red("✗ ") + l : dim(" " + l)));
10835
+ console.log("");
10836
+ return;
10837
+ }
10838
+ }
10644
10839
  inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
10645
10840
  // Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
10646
10841
  // the next thing to draw itself is the turn's ticker — which stays silent until the daemon
@@ -12129,6 +12324,7 @@ async function main() {
12129
12324
  // private (task #13); empty Enter declines to the private default.
12130
12325
  const { DEFAULT_API_BASE } = await import("./device-login.mjs");
12131
12326
  const pick1 = await promptForLicenseToken(ask, licenseToken1, {
12327
+ askSecret,
12132
12328
  validateOpts: {
12133
12329
  hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
12134
12330
  },
@@ -12358,6 +12554,7 @@ async function main() {
12358
12554
  // the local setup answers — OR them in, never clear a local "missing" on a maybe, because
12359
12555
  // private mode's local API can itself be unconfigured.
12360
12556
  if (p.agentMissing === true) agentMissing = true;
12557
+ noModelAtAll = nothingToAnswerWith(p);
12361
12558
  if (p.ragEnabled !== true) embedMissing = true;
12362
12559
  // A RESTORED PICK MUST STILL EXIST. The stored id names a model on the box this folder was
12363
12560
  // last pointed at — and the box can change (`/model` → change the Ollama server), a model
@@ -12451,7 +12648,14 @@ async function main() {
12451
12648
  // Task #136/#154: mode changes WHERE things run — too important to leave implicit.
12452
12649
  // The precedence lives in modeRow() with its tests; see the note there.
12453
12650
  ...(() => {
12454
- const m = modeRow({ clientExpert: isClientExpert(), clientMode: clientModeBanner });
12651
+ const m = modeRow({
12652
+ clientExpert: isClientExpert(),
12653
+ clientMode: clientModeBanner,
12654
+ modelUrls: [p.agentBaseURL],
12655
+ // The coder IN USE in this folder, not the account default (the raw payload field
12656
+ // is the default only — key-choice-regression.test.mjs).
12657
+ coderWhere: activeCoderWhere,
12658
+ });
12455
12659
  if (!m) return [];
12456
12660
  const name = m.label === "client expert" ? accent(m.label + " ") : m.label + " ";
12457
12661
  return [lbl("mode") + name + dim(m.detail)];
@@ -12548,6 +12752,8 @@ async function main() {
12548
12752
  confirm: (q, defYes) => askYesNo(q, defYes),
12549
12753
  // A client installs ONLY the framework — never mlx-lm/hf.
12550
12754
  modelHost: clientModeBanner ? false : undefined,
12755
+ // The yes two lines up IS the answer — do not ask the same thing again (#333).
12756
+ agreed: ["agentLibs"],
12551
12757
  });
12552
12758
  // The worker reads worker.env ONCE at startup, so a daemon that is already
12553
12759
  // running still has the OLD python — the packages would look "not installed"
@@ -12844,6 +13050,8 @@ async function main() {
12844
13050
  // #323: gu's newest published price + cache tables — same cadence, same silent failure.
12845
13051
  void refreshPriceUpdate();
12846
13052
  setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
13053
+ void refreshLogsSize();
13054
+ setInterval(() => void refreshLogsSize(), UPDATE_CHECK_MS).unref();
12847
13055
  // The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
12848
13056
  startupTokenRefresh = refreshCliToken();
12849
13057
  setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
@@ -13304,6 +13512,10 @@ async function main() {
13304
13512
  await runPricesCommand(line);
13305
13513
  continue;
13306
13514
  }
13515
+ if (line === "/clear-logs") {
13516
+ await runClearLogs();
13517
+ continue;
13518
+ }
13307
13519
  if (line === "/tokens") {
13308
13520
  // Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
13309
13521
  // this answers "and what displaced it", which is the part that tells you whether RAG is
package/install-path.mjs CHANGED
@@ -56,11 +56,33 @@ if (isEntryPoint) {
56
56
  main();
57
57
  } catch (e) {
58
58
  // A postinstall that throws uninstalls the package. Report, swallow, succeed.
59
- console.log(`gu-cli: PATH setup skipped (${e.message}) — see \`gu-cli doctor\` if \`gu\` is not found.`);
59
+ console.log(`gu-cli: PATH setup skipped (${e.message}) — run \`gu-cli fix-path\` if \`gu\` is not found.`);
60
60
  }
61
61
  process.exit(0);
62
62
  }
63
63
 
64
+ /**
65
+ * The npm global prefix this package is installed under, from where its own files are — or "".
66
+ *
67
+ * For `gu-cli fix-path` (task #333): the same PATH repair as this postinstall, WITHOUT npm
68
+ * running it. npm 11 already prints "install scripts not yet covered by allowScripts" for this
69
+ * package on every install; it still runs the script today (checked on 11.19.1), but the day it
70
+ * stops, a custom-prefix user is back to "gu: command not found" with nothing to run. A global
71
+ * install lives at <prefix>/lib/node_modules/@tiens.nguyen/gu-cli; anything else (a checkout,
72
+ * `npm link`, a project's node_modules) is not one, and gets no PATH edit.
73
+ */
74
+ export function globalPrefixOf(packageDir) {
75
+ const parts = resolve(String(packageDir ?? "")).split("/");
76
+ const n = parts.length;
77
+ if (n < 5 || parts[n - 3] !== "node_modules" || parts[n - 4] !== "lib") return "";
78
+ if (!parts[n - 2].startsWith("@")) return "";
79
+ // A project-local install is <project>/node_modules/… — never <x>/lib/node_modules, but a
80
+ // nested one could be; a prefix inside another node_modules tree is not a global prefix.
81
+ const prefix = parts.slice(0, n - 4);
82
+ if (prefix.includes("node_modules")) return "";
83
+ return prefix.join("/") || "/";
84
+ }
85
+
64
86
  function main() {
65
87
  if (platform() === "win32") return; // registry PATH + PowerShell alias: not here
66
88
  if (process.env.CI) return; // no user shell to help in CI
package/model-doctor.mjs CHANGED
@@ -130,6 +130,17 @@ export function isExternallyManaged(output) {
130
130
  return /externally[\s-]?managed/i.test(String(output ?? ""));
131
131
  }
132
132
 
133
+ /**
134
+ * Is there no pip on this python at all? (task #333)
135
+ *
136
+ * A stock Ubuntu 24.04 ships python3 WITHOUT pip (python3-pip is a separate package), so the
137
+ * first install said "/usr/bin/python3: No module named pip" and gave up — although
138
+ * python3-venv was installed and a venv brings its own pip. Same remedy as PEP 668: a venv.
139
+ */
140
+ export function isPipMissing(output) {
141
+ return /No module named pip\b/i.test(String(output ?? ""));
142
+ }
143
+
133
144
  /** Run pip once. Resolves { code, err } — never rejects, so the caller owns the policy. */
134
145
  function runPip(python, pkgs, extraArgs = []) {
135
146
  return new Promise((resolve) => {
@@ -190,11 +201,16 @@ async function pipInstall(pkgs, log) {
190
201
  log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
191
202
  let r = await runPip(python3Bin(), pkgs);
192
203
  if (r.code === 0) return;
193
- if (!isExternallyManaged(r.err)) {
204
+ const noPip = isPipMissing(r.err);
205
+ if (!noPip && !isExternallyManaged(r.err)) {
194
206
  throw new Error(`pip install ${pkgs.join(" ")} exited ${r.code}`);
195
207
  }
196
208
 
197
- log(" this python is managed by your OS (PEP 668) — it will not accept packages directly.");
209
+ log(
210
+ noPip
211
+ ? " this python has no pip — using a private virtualenv, which brings its own."
212
+ : " this python is managed by your OS (PEP 668) — it will not accept packages directly."
213
+ );
198
214
  const venvPy = await ensureVenv(log);
199
215
  if (venvPy) {
200
216
  r = await runPip(venvPy, pkgs);
@@ -208,6 +224,13 @@ async function pipInstall(pkgs, log) {
208
224
  }
209
225
  }
210
226
 
227
+ // Without pip AND without venv nothing here can install anything; --break-system-packages
228
+ // would only fail again with the same "No module named pip". Say what to install instead.
229
+ if (noPip) {
230
+ throw new Error(
231
+ "this python has no pip and no venv module — on Debian/Ubuntu: sudo apt install -y python3-venv, then gu doctor"
232
+ );
233
+ }
211
234
  log(" falling back to --break-system-packages (no virtualenv available)");
212
235
  r = await runPip(python3Bin(), pkgs, ["--break-system-packages"]);
213
236
  if (r.code === 0) return;
@@ -1246,7 +1269,13 @@ export async function ensureClaudeSdk({ log, confirm }) {
1246
1269
  return (await pyHasModule("anthropic")) ? "installed" : "failed";
1247
1270
  }
1248
1271
 
1249
- export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels() }) {
1272
+ /**
1273
+ * `agreed` names packages the caller has ALREADY asked about (task #333): `gu` asks "Install the
1274
+ * agent framework now?" and then called this, which asked "Install the agent framework
1275
+ * (smolagents + openai) — required to run the agent?" — the same question twice in a row on
1276
+ * every first install. A yes that was just given is not asked for again.
1277
+ */
1278
+ export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels(), agreed = [] }) {
1250
1279
  const out = {};
1251
1280
  log("");
1252
1281
  log(" Checking the agent's dependencies…");
@@ -1264,7 +1293,7 @@ export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxMode
1264
1293
  out[key] = "present";
1265
1294
  return;
1266
1295
  }
1267
- if (!(await confirm(question, true))) {
1296
+ if (!agreed.includes(key) && !(await confirm(question, true))) {
1268
1297
  out[key] = "skipped";
1269
1298
  log(` Skipped ${pkgs.join(" ")} — install later: ${python3Bin()} -m pip install -U ${pkgs.join(" ")}`);
1270
1299
  return;
package/node-version.mjs CHANGED
@@ -17,7 +17,7 @@ export const MIN_NODE_MAJOR = 18;
17
17
  /** The message for a too-old runtime, or "" when this one is fine. Pure, so it is testable. */
18
18
  export function nodeVersionProblem(version = process.version) {
19
19
  const major = Number.parseInt(String(version).replace(/^v/, "").split(".")[0], 10);
20
- if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) return "";
20
+ if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) {return "";}
21
21
  return (
22
22
  `gu needs Node ${MIN_NODE_MAJOR} or newer — this is ${version}.\n` +
23
23
  ` Node ${major} has no global fetch, which the setup and update paths rely on.\n` +
@@ -30,10 +30,58 @@ export function nodeVersionProblem(version = process.version) {
30
30
  );
31
31
  }
32
32
 
33
+ /**
34
+ * LOCAL MODE NEEDS MORE THAN GU DOES (task #333). The Local API ships the mongodb 7 driver, whose
35
+ * floor is Node 20.19 — and setup loads that driver IN THIS PROCESS to seed the database, then
36
+ * starts the API with this same node. On Node 18 the first sign was "Could not prepare the
37
+ * database: crypto is not defined", halfway through a first install, after MongoDB and the API
38
+ * were already in place. Found on a stock Ubuntu 24.04, whose own `nodejs` package is 18.19.
39
+ *
40
+ * Kept apart from MIN_NODE_MAJOR on purpose: the CLI itself (Cloud mode) still runs on 18.
41
+ */
42
+ export const LOCAL_API_MIN_NODE = "20.19.0";
43
+
44
+ /** True when `version` is at least `min` (both "x.y.z", a leading v allowed). */
45
+ export function nodeAtLeast(version, min) {
46
+ const parts = (v) => String(v).replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
47
+ const a = parts(version);
48
+ const b = parts(min);
49
+ for (let i = 0; i < 3; i++) {
50
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) {return (a[i] ?? 0) > (b[i] ?? 0);}
51
+ }
52
+ return true;
53
+ }
54
+
55
+ /** Why Local mode cannot run on this node, with what to do — or "" when it can. Pure. */
56
+ export function localModeNodeProblem(version = process.version, platform = process.platform) {
57
+ if (nodeAtLeast(version, LOCAL_API_MIN_NODE)) {return "";}
58
+ const how =
59
+ platform === "win32"
60
+ ? [" winget install OpenJS.NodeJS.LTS (or: nvm install lts with nvm-windows)"]
61
+ : platform === "darwin"
62
+ ? [" brew install node (or: nvm install --lts)"]
63
+ : [
64
+ " nvm install --lts && nvm alias default lts/*",
65
+ " or NodeSource:",
66
+ " curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -",
67
+ " sudo apt install -y nodejs",
68
+ ];
69
+ return (
70
+ `Local mode needs Node ${LOCAL_API_MIN_NODE} or newer — this is ${version}.\n` +
71
+ " Its API uses the MongoDB 7 driver, which does not run on older Node.\n" +
72
+ (platform === "linux" ? " (Ubuntu 24.04's own nodejs package is 18.)\n" : "") +
73
+ "\n" +
74
+ how.join("\n") +
75
+ "\n\n" +
76
+ " Then reinstall gu with that Node: npm i -g @tiens.nguyen/gu-cli\n" +
77
+ " Nothing was installed or changed."
78
+ );
79
+ }
80
+
33
81
  /** Print the problem and exit, or return quietly. */
34
82
  export function requireSupportedNode() {
35
83
  const problem = nodeVersionProblem();
36
- if (!problem) return;
84
+ if (!problem) {return;}
37
85
  // console.error, not the REPL's own writer: this runs before any of that exists.
38
86
  console.error("\n" + problem + "\n");
39
87
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.791",
3
+ "version": "1.0.793",
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",
@@ -77,6 +77,7 @@
77
77
  "poll-errors.mjs",
78
78
  "node-version.mjs",
79
79
  "clear-state.mjs",
80
+ "clear-logs.mjs",
80
81
  "platform-tools.mjs",
81
82
  "job-pools.mjs",
82
83
  "agent-stop.mjs",