@tiens.nguyen/gu-cli 1.0.814 → 1.0.816

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/README.md CHANGED
@@ -10,7 +10,8 @@ gu # open the REPL in the current folder
10
10
 
11
11
  The package installs three commands: `gu` (the terminal REPL), `gu-cli` and `gu-local-worker`
12
12
  (the same worker CLI). Requires Node.js 18 or newer. The install runs `fix-path` for you; if
13
- npm's global bin folder is not on your PATH afterwards, run `gu-cli fix-path`.
13
+ npm's global bin folder is not on your PATH afterwards, run `npx @tiens.nguyen/gu-cli fix-path`
14
+ (works even when `gu-cli` itself is not found, e.g. when npm skipped the install script).
14
15
  On Windows, PowerShell's built-in `gu` alias (Get-Unique) shadows this CLI — run
15
16
  `gu-cli fix-powershell` to drop it.
16
17
 
@@ -312,8 +312,8 @@ export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.lo
312
312
  log(dim(installing ? "\n It is not installed here. This installs it (Homebrew):\n" : "\n It is installed but not running. This starts it:\n"));
313
313
  for (const s of steps) log(` brew ${s.join(" ")}`);
314
314
  log("");
315
- const yes = await ask(installing ? " Install MongoDB now? [Y/n]: " : " Start MongoDB now? [Y/n]: ");
316
- if (String(yes ?? "").trim().toLowerCase().startsWith("n")) {
315
+ const yes = await readYesNo(ask, installing ? " Install MongoDB now? [Y/n]: " : " Start MongoDB now? [Y/n]: ", true, log);
316
+ if (!yes) {
317
317
  log(dim(`\n Not ${installing ? "installed" : "started"} — run the commands above yourself.`));
318
318
  return { ok: false, explained: true };
319
319
  }
@@ -440,8 +440,8 @@ export async function ensureNativeMongo(ask, mongo, {
440
440
  const needs = platform === "win32"
441
441
  ? "Windows may ask for administrator permission"
442
442
  : isRoot ? "" : "sudo will ask for your password";
443
- const yes = await ask(` ${installing ? "Install" : "Start"} MongoDB now?${needs ? ` (${needs})` : ""} [Y/n]: `);
444
- if (String(yes ?? "").trim().toLowerCase().startsWith("n")) {
443
+ const yes = await readYesNo(ask, ` ${installing ? "Install" : "Start"} MongoDB now?${needs ? ` (${needs})` : ""} [Y/n]: `, true, log);
444
+ if (!yes) {
445
445
  log(dim(`\n Not ${installing ? "installed" : "started"} — run the commands above yourself.`));
446
446
  return { ok: false, explained: true };
447
447
  }
@@ -608,8 +608,7 @@ export async function startMongoInDocker(ask, { platform = process.platform } =
608
608
  console.log(dim(` Simplest path: ${plan.summary}.`));
609
609
  console.log(dim(" Your conversation history lives in that volume and survives a restart."));
610
610
  console.log(dim("\n " + plan.commands[0] + "\n"));
611
- const yes = await ask(" Start MongoDB in Docker now? [Y/n]: ");
612
- if (String(yes ?? "").trim().toLowerCase().startsWith("n")) return false;
611
+ if (!(await readYesNo(ask, " Start MongoDB in Docker now? [Y/n]: ", true))) return false;
613
612
 
614
613
  for (const cmd of plan.commands) {
615
614
  console.log(dim(` $ ${cmd}`));
@@ -672,13 +671,36 @@ export async function checkMongoConnection(uri) {
672
671
  * Returns { uri, custom } to continue with, or { exit: true } when the user declines — the
673
672
  * caller ends setup with the `gu clear` → `gu` hint.
674
673
  */
674
+ /**
675
+ * A [Y/n] / [y/N] answer (GitHub #128). Enter → the default; y/yes → yes; n/no → no. Anything else
676
+ * ("3", "maybe") is NOT taken as an answer: the question is asked again with what to type —
677
+ * "3" at "Install MongoDB now? [Y/n]" used to install MongoDB. A closed input (null) is "no", as
678
+ * it always was. After three unrecognised answers the default is used.
679
+ */
680
+ export function parseYesNo(raw, defaultYes) {
681
+ if (raw === null || raw === undefined) return false;
682
+ const a = String(raw).trim().toLowerCase();
683
+ if (!a) return defaultYes;
684
+ if (a === "y" || a === "yes") return true;
685
+ if (a === "n" || a === "no") return false;
686
+ return null;
687
+ }
688
+
689
+ export async function readYesNo(ask, prompt, defaultYes, say = (l) => console.log(l)) {
690
+ for (let attempt = 0; attempt < 3; attempt += 1) {
691
+ const verdict = parseYesNo(await ask(prompt), defaultYes);
692
+ if (verdict !== null) return verdict;
693
+ say(dim(` Type y or n (Enter = ${defaultYes ? "yes" : "no"}).`));
694
+ }
695
+ return defaultYes;
696
+ }
697
+
675
698
  export async function askMongoCredentials(ask, askSecret = null) {
676
699
  const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
677
700
  console.log(dim(` MongoDB is answering at ${host}:${port}.`));
678
701
  console.log(dim(" If that is a MongoDB this machine already had, it may have its OWN"));
679
702
  console.log(dim(" username and password — which setup does not know, and cannot change."));
680
- const yes = await ask(" Use the default (empty username and password)? [Y/n]: ");
681
- if (!String(yes ?? "").trim().toLowerCase().startsWith("n")) {
703
+ if (await readYesNo(ask, " Use the default (empty username and password)? [Y/n]: ", true)) {
682
704
  return { uri: LOCAL_MONGO_URI, custom: false };
683
705
  }
684
706
  // NO → the instance has its own credentials. Ask for them, and CHECK before any installing
@@ -700,15 +722,13 @@ export async function askMongoCredentials(ask, askSecret = null) {
700
722
  }
701
723
  if (verdict.unverifiable) {
702
724
  console.log("\n " + yellow("⚠ could not verify: ") + dim(verdict.why));
703
- const carryOn = await ask(" Continue with these credentials anyway? [Y/n]: ");
704
- if (!String(carryOn ?? "").trim().toLowerCase().startsWith("n")) {
725
+ if (await readYesNo(ask, " Continue with these credentials anyway? [Y/n]: ", true)) {
705
726
  return { uri, custom: true, unverified: true };
706
727
  }
707
728
  } else {
708
729
  console.log("\n " + red("✗ Check Connection failed") + dim(` — ${verdict.why}`));
709
730
  }
710
- const again = await ask(" Re-enter the username and password? [y/N]: ");
711
- if (!String(again ?? "").trim().toLowerCase().startsWith("y")) return { exit: true };
731
+ if (!(await readYesNo(ask, " Re-enter the username and password? [y/N]: ", false))) return { exit: true };
712
732
  }
713
733
  return { exit: true };
714
734
  }
@@ -4582,6 +4582,99 @@ def _strip_tool_tags(text: str) -> str:
4582
4582
  return "\n".join(out).strip()
4583
4583
 
4584
4584
 
4585
+ def _md_scalar(v) -> str:
4586
+ if v is None or v == "" or v == [] or v == {}:
4587
+ return "none"
4588
+ if isinstance(v, bool):
4589
+ return "yes" if v else "no"
4590
+ return str(v)
4591
+
4592
+
4593
+ def _md_lines(value, depth=0):
4594
+ """Nested dict/list -> markdown bullet lines (no raw braces, quotes or brackets)."""
4595
+ pad = " " * depth
4596
+ lines = []
4597
+ if isinstance(value, dict):
4598
+ for k, v in value.items():
4599
+ label = str(k).replace("_", " ").strip()
4600
+ label = label[:1].upper() + label[1:]
4601
+ if isinstance(v, (dict, list, tuple)) and v:
4602
+ lines.append(f"{pad}- **{label}:**")
4603
+ lines.extend(_md_lines(v, depth + 1))
4604
+ else:
4605
+ lines.append(f"{pad}- **{label}:** {_md_scalar(v)}")
4606
+ else:
4607
+ for item in value:
4608
+ if isinstance(item, (dict, list, tuple)) and item:
4609
+ lines.extend(_md_lines(item, depth + 1))
4610
+ else:
4611
+ lines.append(f"{pad}- {_md_scalar(item)}")
4612
+ return lines
4613
+
4614
+
4615
+ def _fallback_recap(steps, last_text="") -> str:
4616
+ """Task #131: the model ended its turn with no final text (e.g. only reasoning). Build a
4617
+ short readable recap from the steps that did run instead of showing '[No result]'."""
4618
+ tried = []
4619
+ for st in (steps or [])[-8:]:
4620
+ did = str(st.get("did") or "").strip().replace(chr(10), " ")
4621
+ m = re.search(r"([a-z_]+\([^\n]{0,70})", did)
4622
+ label = (m.group(1) if m else did)[:70].strip()
4623
+ if label:
4624
+ tried.append("- " + label)
4625
+ last = str(last_text or "").strip()
4626
+ if not tried and not last:
4627
+ return ""
4628
+ out = ["**Status:** the model finished without writing a summary."]
4629
+ if tried:
4630
+ out += ["", "**What was run**"] + tried
4631
+ if last:
4632
+ out += ["", "**Last result**", last[:600]]
4633
+ return chr(10).join(out)
4634
+
4635
+
4636
+ def _humanize_answer(result) -> str:
4637
+ """Task #131: a final answer that is a dict/list (or a string that is just a JSON /
4638
+ Python-dict literal, as DeepSeek produces) is rendered as readable markdown instead of
4639
+ being shown as its raw repr. Plain prose is returned untouched."""
4640
+ if result is None:
4641
+ return ""
4642
+ data = result
4643
+ if isinstance(result, str):
4644
+ s = result.strip()
4645
+ if len(s) < 2 or s[0] not in "{[" or s[-1] not in "}]":
4646
+ return result
4647
+ data = None
4648
+ try:
4649
+ data = json.loads(s)
4650
+ except Exception: # noqa: BLE001
4651
+ try:
4652
+ import ast
4653
+ data = ast.literal_eval(s)
4654
+ except Exception: # noqa: BLE001
4655
+ return result
4656
+ if not isinstance(data, (dict, list)) or not data:
4657
+ return result
4658
+ elif isinstance(result, tuple):
4659
+ data = list(result)
4660
+ elif not isinstance(result, (dict, list)):
4661
+ return str(result)
4662
+ if isinstance(data, dict):
4663
+ out = []
4664
+ for k, v in data.items():
4665
+ label = str(k).replace("_", " ").strip()
4666
+ label = label[:1].upper() + label[1:]
4667
+ if isinstance(v, (dict, list, tuple)) and v:
4668
+ out.append("")
4669
+ out.append(f"**{label}**")
4670
+ out.extend(_md_lines(v, 0))
4671
+ else:
4672
+ out.append("")
4673
+ out.append(f"**{label}:** {_md_scalar(v)}")
4674
+ return chr(10).join(out).strip()
4675
+ return chr(10).join(_md_lines(data, 0)).strip()
4676
+
4677
+
4585
4678
  _THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
4586
4679
 
4587
4680
  # Malformed variants of smolagents' code-block tags. Weak coding models routinely
@@ -9538,7 +9631,8 @@ def run_agent_chat(cfg):
9538
9631
  "instead of stopping the block. Keep UNRELATED changes in separate blocks. Take a separate "
9539
9632
  "step only when a call depends on a result you have not seen yet (e.g. web_search to "
9540
9633
  "find a URL, THEN fetch_url to read it). You SEE the Observation before the next step. "
9541
- "When you have enough to answer, call final_answer(<your answer>) in a code block. "
9634
+ "When you have enough to answer, call final_answer(<your answer>) in a code block — pass a readable "
9635
+ "markdown/prose recap (what you did, what you verified, what is left), never a dict, list or JSON. "
9542
9636
  "Prefer FEWER steps, and never repeat a tool call that already succeeded.\n\n"
9543
9637
  "{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
9544
9638
  + _skills_mod.prompt_block(_skills)
@@ -14227,7 +14321,9 @@ def run_agent_chat(cfg):
14227
14321
  _emit({"type": "step", "text": _retr})
14228
14322
  _log(f"retrieval summary: {_c(_retr)}")
14229
14323
  _emit({"type": "step", "text": "Composing answer…"})
14230
- final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
14324
+ final_text = (_strip_tool_tags(_humanize_answer(result).strip())
14325
+ or _fallback_recap(_turn_steps, _last_obs.get("text"))
14326
+ or "[No result]")
14231
14327
  _log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
14232
14328
  _emit({"type": "final", "text": final_text})
14233
14329
  # onTurnEnd (task #270): after the answer is out, so a slow hook never delays it.
package/gu-cli.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
13
13
  // `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
14
- import { requireSupportedNode } from "./node-version.mjs";
14
+ import { localModeNodeProblem, requireSupportedNode } from "./node-version.mjs";
15
15
  // The brand accent, shared with the REPL — see accent.mjs.
16
16
  import { accent } from "./accent.mjs";
17
17
  import { paint } from "./theme.mjs";
@@ -1068,13 +1068,16 @@ if (args[0] === "fix-path") {
1068
1068
  console.log(" If `gu` is not found in PowerShell: gu-cli fix-powershell");
1069
1069
  process.exit(0);
1070
1070
  }
1071
- const { globalPrefixOf } = await import("./install-path.mjs");
1071
+ const { fixPathPrefix } = await import("./install-path.mjs");
1072
1072
  const { fileURLToPath } = await import("node:url");
1073
1073
  const { dirname, join } = await import("node:path");
1074
1074
  const here = dirname(fileURLToPath(import.meta.url));
1075
- const prefix = globalPrefixOf(here);
1075
+ // #94: under `npx @tiens.nguyen/gu-cli fix-path` this file runs from npx's cache; fixPathPrefix
1076
+ // then falls back to npm's global prefix when gu is installed there.
1077
+ const prefix = fixPathPrefix({ here });
1076
1078
  if (!prefix) {
1077
- console.log(` This gu is not a global npm install (${here}) — there is no PATH to fix.`);
1079
+ console.log(` gu is not installed globally with npm (${here}) — there is no PATH to fix.`);
1080
+ console.log(" Install it with: npm install -g @tiens.nguyen/gu-cli");
1078
1081
  process.exit(0);
1079
1082
  }
1080
1083
  const binDir = join(prefix, "bin");
@@ -1089,9 +1092,24 @@ if (args[0] === "fix-path") {
1089
1092
  env: { ...env, npm_config_prefix: prefix, npm_config_global: "true" },
1090
1093
  });
1091
1094
  const said = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
1092
- // It prints only when it wrote something. Silence means the line is already in the profile
1093
- // and this terminal simply predates it.
1094
- console.log(said || ` Your shell profile already adds ${binDir} to PATH — open a new terminal.`);
1095
+ // It prints only when it wrote something. Silence USUALLY means the line is already in the
1096
+ // profile — but check (#94): it is also silent when it skipped (e.g. a temp-dir prefix), and
1097
+ // saying "already adds" then is a lie the user acts on.
1098
+ if (said) {
1099
+ console.log(said);
1100
+ } else {
1101
+ const { existsSync, readFileSync } = await import("node:fs");
1102
+ const { homedir } = await import("node:os");
1103
+ const has = [".zshrc", ".bashrc", ".bash_profile", ".profile"].some((f) => {
1104
+ const p = join(homedir(), f);
1105
+ try { return existsSync(p) && readFileSync(p, "utf8").includes("# >>> gu-cli PATH >>>"); } catch { return false; }
1106
+ });
1107
+ if (has) console.log(` Your shell profile already adds ${binDir} to PATH — open a new terminal.`);
1108
+ else {
1109
+ console.log(` ${binDir} is not on your PATH and was not added to a shell profile. Add this line to it:`);
1110
+ console.log(` export PATH="${binDir}:$PATH"`);
1111
+ }
1112
+ }
1095
1113
  process.exit(r.status ?? 0);
1096
1114
  }
1097
1115
 
@@ -1285,14 +1303,24 @@ if (args[0] === "clear" || args[0] === "reset") {
1285
1303
  }
1286
1304
  // The DATABASE first, while ~/.gonext/api still exists — dropping it needs the mongodb
1287
1305
  // driver that ships inside the downloaded API, and clearState is about to delete that folder.
1288
- if (groups.includes("work") && dbItem) {
1306
+ // GitHub #125: the API's mongodb 7 driver needs Node 20.19+. On Node 18 it died with a bare
1307
+ // "crypto is not defined"; say what is wrong and what to do instead.
1308
+ const dropNodeProblem = groups.includes("work") && dbItem ? localModeNodeProblem() : "";
1309
+ if (dropNodeProblem) {
1310
+ console.log(
1311
+ "could not drop the local database — " + dropNodeProblem.split("\n")[0] +
1312
+ "\n Upgrade Node and run `gu clear` again to drop it; the next setup may inherit its settings."
1313
+ );
1314
+ } else if (groups.includes("work") && dbItem) {
1315
+ // GitHub #126: the client is closed even when connect/drop throws — an open client keeps
1316
+ // its sockets and timers alive.
1317
+ let client = null;
1289
1318
  try {
1290
1319
  const ce = await import("./client-expert-api.mjs");
1291
1320
  const { MongoClient } = ce.loadMongoDriver(apiDirForDrop);
1292
- const client = new MongoClient(ce.localMongoUri(), { serverSelectionTimeoutMS: 8000 });
1321
+ client = new MongoClient(ce.localMongoUri(), { serverSelectionTimeoutMS: 8000 });
1293
1322
  await client.connect();
1294
1323
  await client.db(ce.LOCAL_DB_NAME).dropDatabase();
1295
- await client.close();
1296
1324
  console.log("dropped the local database");
1297
1325
  } catch (e) {
1298
1326
  // Said out loud, not swallowed: a machine that keeps its old settings row will look
@@ -1300,10 +1328,18 @@ if (args[0] === "clear" || args[0] === "reset") {
1300
1328
  console.log(
1301
1329
  `could not drop the local database (${e.message}) — the next setup may inherit its settings`
1302
1330
  );
1331
+ } finally {
1332
+ if (client) {await client.close(true).catch(() => {});}
1303
1333
  }
1304
1334
  }
1305
- const removed = await cs.clearState(items);
1306
- console.log(`removed ${removed.length} item(s).`);
1335
+ // GitHub #126: whatever clearState does, gu clear ends here and exits.
1336
+ try {
1337
+ const removed = await cs.clearState(items);
1338
+ console.log(`removed ${removed.length} item(s).`);
1339
+ } catch (e) {
1340
+ console.log(`could not remove everything (${e.message})`);
1341
+ process.exit(1);
1342
+ }
1307
1343
  console.log(
1308
1344
  groups.includes("identity")
1309
1345
  ? "\nNext `gu` starts from the beginning — it will ask which mode to run.\n"
package/gu-repl.mjs CHANGED
@@ -5294,7 +5294,8 @@ async function markInterrupted(cwd, history, task, how) {
5294
5294
  const status = interruptStatus(how);
5295
5295
  if (!status) return;
5296
5296
  last.status = status;
5297
- await saveSession(cwd, history, { unfinishedTask: sessionUnfinishedTask || task });
5297
+ const keep = sessionUnfinishedTask || task;
5298
+ await saveSession(cwd, history, { unfinishedTask: isResumeWord(keep) ? "" : keep }); // #201
5298
5299
  }
5299
5300
 
5300
5301
  async function rememberUnfinishedTask(cwd, task) {
@@ -6028,8 +6029,8 @@ async function reconfigureOllamaServer() {
6028
6029
  // with a single row is a question with one answer.
6029
6030
  let chat = "";
6030
6031
  if (agent) {
6031
- const cands = os.agentCandidates(agent.models);
6032
- const agentDefault = os.defaultAgentModel(
6032
+ // Task #36: the saved agent stays a choice (and the default) even when it is not a gemma4.
6033
+ const { cands, current: agentDefault } = os.agentChoices(
6033
6034
  agent.models,
6034
6035
  String(settings.agentModelName ?? "").trim()
6035
6036
  );
@@ -6239,7 +6240,9 @@ async function reconfigureOllamaServer() {
6239
6240
  // qwen3-embedding-only box) was written to settings above, so REQUIRED_EMBED_MODEL would
6240
6241
  // falsely scream "changed" while the seeded value is exactly what settings now hold.
6241
6242
  const seededEmbed = agent ? (os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL) : "";
6242
- if (agent && String(settings.ragEmbedModel ?? "") !== seededEmbed) {
6243
+ // Only a CHANGE when there was an embedder before — from none it is simply set (task #36).
6244
+ const prevEmbed = String(settings.ragEmbedModel ?? "").trim();
6245
+ if (agent && prevEmbed && prevEmbed !== seededEmbed) {
6243
6246
  console.log(dim(" the embedder changed — existing RAG indexes need rebuilding (/rag)."));
6244
6247
  }
6245
6248
  console.log(dim(" takes effect on your next question.\n"));
@@ -6861,7 +6864,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6861
6864
  rowId = ps.mintProviderId(existing.map((b) => String(b.id ?? "")));
6862
6865
  const { row } = as.rowForSave(role, { id: rowId, kind: target.dialect === "ollama" ? "ollama" : "openai", url: target.url, model, extras });
6863
6866
  rows = [...existing, row];
6864
- } else {
6867
+ } else if (found) {
6868
+ // A keyless agent/embed server with no row needs no row: the role's own setting holds its URL.
6865
6869
  const r = as.rowForSave(role, { existing: found.row, model });
6866
6870
  if (r.full) {
6867
6871
  console.log(yellow(` ${host} already offers ${ps.MAX_MODELS_PER_SERVER} models — remove one in /model → manage providers first.\n`));
@@ -7102,6 +7106,14 @@ async function chooseModel() {
7102
7106
  const chosen = choices[idx];
7103
7107
  // Empty override means "use the account default" — so don't send an override when the
7104
7108
  // user picks the default (keeps the payload clean and lets a later default change win).
7109
+ // GitHub #130: what to go back to if the key prompt for the new coder is cancelled (Esc).
7110
+ const prevCoder = {
7111
+ model: sessionCodingModel,
7112
+ label: sessionCodingLabel,
7113
+ url: sessionCodingUrl,
7114
+ where: activeCoderWhere,
7115
+ tail: sessionApiKeyTail,
7116
+ };
7105
7117
  sessionCodingModel = chosen.id === def ? "" : chosen.id;
7106
7118
  setModelFallback(null); // #343: the warning named the coder that was just replaced
7107
7119
  // Remember the friendly name for the token footer, which should never show a raw
@@ -7143,7 +7155,23 @@ async function chooseModel() {
7143
7155
  // exactly this key (provider-scoped), so mirror it here; a keyless coder shows no tail.
7144
7156
  sessionApiKeyTail = ks.needsKey && !ks.missing && ks.stored ? ks.stored.slice(-4) : "";
7145
7157
  if (!ks.needsKey) keyChoicePending = false; // Ollama or local MLX — nothing to choose
7146
- if (ks.needsKey && ks.missing) await promptForCodingKey(sessionCodingModel);
7158
+ if (ks.needsKey && ks.missing) {
7159
+ try {
7160
+ await promptForCodingKey(sessionCodingModel);
7161
+ } catch (e) {
7162
+ if (!wasPromptCancelled(e)) throw e;
7163
+ // GitHub #130: Esc at the new coder's key prompt cancels the PICK, not just the key —
7164
+ // otherwise gu is left on a coder it cannot use. Restore and re-save the previous one.
7165
+ sessionCodingModel = prevCoder.model;
7166
+ sessionCodingLabel = prevCoder.label;
7167
+ sessionCodingUrl = prevCoder.url;
7168
+ activeCoderWhere = prevCoder.where;
7169
+ sessionApiKeyTail = prevCoder.tail;
7170
+ await saveSessionCoding(resolve(process.cwd()));
7171
+ console.log(dim(" (cancelled — the coding model was not changed)\n"));
7172
+ return;
7173
+ }
7174
+ }
7147
7175
  else if (ks.needsKey) {
7148
7176
  // THIS FOLDER'S KEY, not the account's (task #222). ks.stored comes from GET /coding-key
7149
7177
  // with no job named, which answers with the account default — so after picking "accounting"
@@ -12806,6 +12834,9 @@ async function main() {
12806
12834
  if (chosen === "client-expert") {
12807
12835
  const { runClientExpertSetup } = await import("./client-expert-run.mjs");
12808
12836
  const setup = await runClientExpertSetup({ ask });
12837
+ // GitHub #126: setup refused (too-old Node) and said why — stop here, do not carry on
12838
+ // into a REPL that cannot work and leave gu running after its last line.
12839
+ if (setup?.stoppedAt === "node") process.exit(1);
12809
12840
  if (setup?.complete) reloadWorkerEnv();
12810
12841
  }
12811
12842
  } else if (!st.chosenMode && !gate.available) {
@@ -12829,6 +12860,7 @@ async function main() {
12829
12860
  askStep: (q) => ask(q).catch(toSkipAll),
12830
12861
  askSecret: (q) => askSecret(q).catch(toSkipAll),
12831
12862
  });
12863
+ if (setup?.stoppedAt === "node") process.exit(1); // GitHub #126, as above
12832
12864
  if (setup?.complete) reloadWorkerEnv();
12833
12865
  if (setup?.loadPreset) openPresetsAtStart = true;
12834
12866
  }
@@ -13654,7 +13686,7 @@ async function main() {
13654
13686
  // rl.prompt() still runs for the NON-frozen fallback path, where readline draws the
13655
13687
  // input line itself. With the zone frozen (#141) discardTypedInput has already
13656
13688
  // repainted it, and prompt()'s own _refreshLine lands on the same paintBar().
13657
- rl.prompt();
13689
+ if (!_stdinClosed) rl.prompt(); // ERR_USE_AFTER_CLOSE: a menu may have closed readline
13658
13690
  }
13659
13691
  };
13660
13692
  process.on("SIGINT", onInterrupt);
@@ -13701,6 +13733,9 @@ async function main() {
13701
13733
  enableClickToPosition();
13702
13734
  announceCliUpdate(); // deferred if a 6h refresh landed mid-turn; harmless once shown
13703
13735
  announceScaffold(); // the startup scaffold line, if it arrived while a turn was running
13736
+ // ERR_USE_AFTER_CLOSE (task #36): Escape/EOF inside a menu can close readline; prompt() on a
13737
+ // closed interface throws from here with a Node stack trace. Input is over — leave the loop.
13738
+ if (_stdinClosed) break;
13704
13739
  rl.prompt();
13705
13740
  // #302, found live: prompt() puts the cursor at column 0, and a stopped turn's question is
13706
13741
  // already in the line — so what was typed went IN FRONT of it and Ctrl+U erased nothing.
@@ -13724,6 +13759,10 @@ async function main() {
13724
13759
  // prompt() erases from the caret down, so the row is rewritten in place and the empty
13725
13760
  // submit becomes a visual no-op. Nothing to step back over if the line came from
13726
13761
  // _lineQueue (no keypress, no echo) — hence the `echoed` guard.
13762
+ if (_stdinClosed) {
13763
+ lineRaw = null; // readline closed while we waited — never prompt() it again
13764
+ break;
13765
+ }
13727
13766
  if (process.stdout.isTTY) {
13728
13767
  // Frozen input line: nothing was echoed, so there is nothing to undo — just re-render.
13729
13768
  if (barPinned()) {
@@ -14174,19 +14213,17 @@ async function main() {
14174
14213
  // actually succeeds — saveSession writes the now-empty value then — so a terminal killed
14175
14214
  // mid-retry still has something to resume next time.
14176
14215
  sessionUnfinishedTask = "";
14177
- const { task: taskLine, resumed, nothingToResume } = resumeTask(line, pendingResume);
14216
+ const resumeResult = resumeTask(line, pendingResume);
14217
+ const { resumed, nothingToResume } = resumeResult;
14218
+ // Nothing pending: the bare word goes to the coding model AS TYPED, with the conversation
14219
+ // history, so the model decides what "continue" means from context (no hardcoded reply).
14220
+ // The #201 loop stays closed at the WRITE side: rememberUnfinishedTask, saveSession and
14221
+ // markInterrupted never store a resume word, and unfinishedTaskFromSession rejects one.
14222
+ const taskLine = nothingToResume ? String(line ?? "").trim() : resumeResult.task;
14178
14223
  // #302 part 3: "continue" after a lost connection FOLLOWS the job that may still be running,
14179
14224
  // rather than sending the question again. Anything else typed lets it go.
14180
14225
  const reattach = lostTurn && isResumeWord(line) && lostTurn.taskLine === taskLine ? lostTurn : null;
14181
14226
  lostTurn = null;
14182
- // "continue" with nothing behind it runs NOTHING (task #201). It used to be sent to the
14183
- // model as the literal word, which then got remembered as the unfinished task — so every
14184
- // later "continue" retried "continue". Worse than the loop: with no task the model invents
14185
- // one, and the reported run edited a file and ran migrations nobody asked for.
14186
- if (nothingToResume) {
14187
- console.log(GUTTER + dim("nothing to continue — ask a question first.\n"));
14188
- continue;
14189
- }
14190
14227
  if (resumed) {
14191
14228
  const shown = taskLine.length > 80 ? taskLine.slice(0, 80) + "…" : taskLine;
14192
14229
  console.log(GUTTER + dim(`↻ retrying: ${shown}\n`));
package/install-path.mjs CHANGED
@@ -56,7 +56,7 @@ 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}) — run \`gu-cli fix-path\` if \`gu\` is not found.`);
59
+ console.log(`gu-cli: PATH setup skipped (${e.message}) — run \`npx @tiens.nguyen/gu-cli fix-path\` if \`gu\` is not found.`);
60
60
  }
61
61
  process.exit(0);
62
62
  }
@@ -83,6 +83,27 @@ export function globalPrefixOf(packageDir) {
83
83
  return prefix.join("/") || "/";
84
84
  }
85
85
 
86
+ /**
87
+ * The global prefix `gu-cli fix-path` should repair — or "" (issue #94).
88
+ *
89
+ * WHY A FALLBACK: when npm stops running the postinstall, `gu-cli` is not on PATH either, so the
90
+ * way out is `npx @tiens.nguyen/gu-cli fix-path`. npx runs a COPY from ~/.npm/_npx/<hash>/, which
91
+ * is not a global install, so globalPrefixOf(here) is "". npx (and npm exec) still pass npm's
92
+ * global prefix in the env; use it only when gu really is installed there.
93
+ * Lookup order: where this file lives → npm_config_global_prefix → npm_config_prefix.
94
+ */
95
+ export function fixPathPrefix({ here = "", env = process.env, exists = existsSync } = {}) {
96
+ const own = globalPrefixOf(here);
97
+ if (own) return own;
98
+ for (const p of [env.npm_config_global_prefix, env.npm_config_prefix]) {
99
+ if (!p) continue;
100
+ const prefix = resolve(String(p));
101
+ if (prefix.split("/").includes("node_modules")) continue; // a project, not global
102
+ if (exists(join(prefix, "lib", "node_modules", "@tiens.nguyen", "gu-cli"))) return prefix;
103
+ }
104
+ return "";
105
+ }
106
+
86
107
  function main() {
87
108
  if (platform() === "win32") return; // registry PATH + PowerShell alias: not here
88
109
  if (process.env.CI) return; // no user shell to help in CI
package/ollama-setup.mjs CHANGED
@@ -93,6 +93,22 @@ export function defaultAgentModel(models, preferred = "") {
93
93
  )[0].model;
94
94
  }
95
95
 
96
+ /**
97
+ * The AGENT choices /model offers (task #36): the gemma4 family, PLUS the agent already saved on
98
+ * this machine when this box has it. /agent-model may save any model (qwen3:14b, say); leaving it out
99
+ * here made the gemma4 default the preselected row, so Enter in /model silently replaced it.
100
+ * Returns { cands, current } — current is the row to preselect.
101
+ */
102
+ export function agentChoices(models, saved = "") {
103
+ const list = Array.isArray(models) ? models : [];
104
+ const cands = agentCandidates(list);
105
+ const name = String(saved ?? "").trim();
106
+ const own = name && list.find((m) => m.model === name && !isLikelyEmbedder(m));
107
+ if (own && !cands.some((m) => m.model === name)) cands.unshift(own);
108
+ const current = own ? name : defaultAgentModel(list, name);
109
+ return { cands, current };
110
+ }
111
+
96
112
  /** "nomic-embed-text" and "nomic-embed-text:latest" are the same model to a person. */
97
113
  const sameModel = (a, b) => {
98
114
  const bare = (s) => String(s ?? "").trim().toLowerCase().replace(/:latest$/, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.814",
3
+ "version": "1.0.816",
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",
@@ -14,6 +14,7 @@
14
14
  * when shouldProxy() says so.
15
15
  */
16
16
  import { shouldProxy, proxyUrl, redactSecrets } from "./proxy-config.mjs";
17
+ import { nodeAtLeast } from "./node-version.mjs";
17
18
 
18
19
  /**
19
20
  * One ProxyAgent per dial string, kept for reuse.
@@ -25,6 +26,9 @@ import { shouldProxy, proxyUrl, redactSecrets } from "./proxy-config.mjs";
25
26
  */
26
27
  const agents = new Map();
27
28
 
29
+ /** undici 7's engines floor (GitHub #127). */
30
+ export const UNDICI_MIN_NODE = "20.18.1";
31
+
28
32
  let ProxyAgentCtor;
29
33
  let loadError = "";
30
34
 
@@ -38,6 +42,12 @@ let loadError = "";
38
42
  */
39
43
  async function proxyAgentCtor() {
40
44
  if (ProxyAgentCtor || loadError) return ProxyAgentCtor;
45
+ // GitHub #127: undici 7 needs Node 20.18.1+, while gu itself (Cloud mode) still runs on 18.
46
+ // Name the real cause instead of letting the import fail with an unrelated error.
47
+ if (!nodeAtLeast(process.version, UNDICI_MIN_NODE)) {
48
+ loadError = `the proxy needs Node ${UNDICI_MIN_NODE} or newer — this is ${process.version}`;
49
+ return ProxyAgentCtor;
50
+ }
41
51
  try {
42
52
  ({ ProxyAgent: ProxyAgentCtor } = await import("undici"));
43
53
  } catch (err) {
@@ -1660,6 +1660,11 @@ export function classifyTurnFailure(message) {
1660
1660
  if (/The agent couldn't start|The agent's prompt (could not be loaded|could not be used)|agent's prompt templates could not be loaded/i.test(t)) {
1661
1661
  return "setup";
1662
1662
  }
1663
+ // DEPS — the agent's Python framework is missing on this machine (GitHub #129). Every turn
1664
+ // dies at import, before any model is called; neither retrying nor switching coder helps.
1665
+ if (/No module named ['"]?(smolagents|openai)\b|ModuleNotFoundError:.*(smolagents|openai)/i.test(t)) {
1666
+ return "deps";
1667
+ }
1663
1668
  // CONFIG — the server is up and answering; it just has no model by that name (task #213).
1664
1669
  // Ollama says: model 'gemma4:e4b' not found (and "…, try pulling it first" for embeddings).
1665
1670
  // Checked BEFORE the backend patterns because it arrives as a 404 from a perfectly healthy
@@ -1757,6 +1762,10 @@ export function retryAdvice({ resumable = true, failures = 1, kind = "task", min
1757
1762
  ? `gu's own machinery has failed ${failures} times in a row — not your question, and not the coding model. Check it before retrying: gu-cli api status, and gu-cli start if the worker is not running.`
1758
1763
  : 'that was gu\'s own machinery stopping, not your question — "continue" runs it again';
1759
1764
  }
1765
+ if (kind === "deps") {
1766
+ // GitHub #129: name the missing piece and the one step that fixes it.
1767
+ return 'the agent framework (smolagents + openai) is not installed in this machine\'s Python, so nothing ran — run /doctor to install it, or restart gu and answer yes to "Install the agent framework now?", then "continue" sends your question again.';
1768
+ }
1760
1769
  if (kind === "setup") {
1761
1770
  // Nothing ran, so there is nothing to diagnose in the model or the question — only the step the
1762
1771
  // message above names. "continue" is right AFTER that step, never before.