@tiens.nguyen/gu-cli 1.0.768 → 1.0.769

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.
@@ -516,6 +516,23 @@ export const CLIENT_EXPERT_MODELS = {
516
516
  * is written — while Kimi is registered as "openai" so `/model` lists it and asks for the key
517
517
  * only if the user actually switches. One slot per kind, so these cannot collide.
518
518
  */
519
+ /** The row setup writes for an agent server that differs from the coder's and has a key (#301). */
520
+ export const AGENT_ROW_ID = "ollama-agent";
521
+ /** The implicit id of the coder's row — setup writes it without one; the API names it by kind. */
522
+ export const CODER_ROW_ID = "ollama";
523
+
524
+ /**
525
+ * Which row a key typed at setup binds to (#301): the coder's row for the coder's server (or for
526
+ * the agent's when they share one), the agent row otherwise. "" when neither server was used.
527
+ */
528
+ export function rowIdForKey(models, url) {
529
+ const M = models ?? {};
530
+ const codeUrl = M.codeUrl || M.url;
531
+ if (url && url === codeUrl) return CODER_ROW_ID;
532
+ if (url && url === M.url) return AGENT_ROW_ID;
533
+ return "";
534
+ }
535
+
519
536
  export function codingBackendsForClientExpert(models = CLIENT_EXPERT_MODELS) {
520
537
  const M = models ?? CLIENT_EXPERT_MODELS;
521
538
  // The chat model is offered too, so `/model` can drop to the fast one for simple work, plus
@@ -525,17 +542,33 @@ export function codingBackendsForClientExpert(models = CLIENT_EXPERT_MODELS) {
525
542
  // SHARED_ALT_CODE_MODEL is the seeded default for the shared box. Deduped and stripped of the
526
543
  // primary coder, because a `/model` list that offers the model you are already on — twice —
527
544
  // is a list nobody trusts.
528
- const offered = [M.chat, ...(M.altCoders ?? [SHARED_ALT_CODE_MODEL])].filter(
545
+ //
546
+ // THE CODER'S OWN SERVER (#301). Setup has asked "Coding-model server" separately since #220 and
547
+ // returned it as codeUrl, but this row was always built on M.url — the AGENT's server — so a
548
+ // coder chosen on a second box was silently configured on the first. The agent model is offered
549
+ // on the coder's row only when it lives on that same server; elsewhere it is not there to offer.
550
+ const codeUrl = M.codeUrl || M.url;
551
+ const sameServer = codeUrl === M.url;
552
+ const offered = [...(sameServer ? [M.chat] : []), ...(M.altCoders ?? [SHARED_ALT_CODE_MODEL])].filter(
529
553
  (name, i, all) => name && name !== M.code && all.indexOf(name) === i,
530
554
  );
555
+ // AN AGENT SERVER WITH A KEY NEEDS A ROW OF ITS OWN (#301). The API finds the agent's (and the
556
+ // embedder's) key by matching agentModelUrl to a row's URL — no row, no key. Only when the key
557
+ // exists: a keyless agent box needs no row, and one would add a server to /model for nothing.
558
+ // Explicit id, because two id-less ollama rows would both claim the implicit id "ollama".
559
+ const agentKeyed =
560
+ !sameServer && Array.isArray(M.keys) && M.keys.some((k) => k?.url === M.url && k?.key);
531
561
  return [
532
562
  {
533
563
  kind: "ollama",
534
564
  enabled: true,
535
- url: M.url,
565
+ url: codeUrl,
536
566
  defaultModel: M.code,
537
567
  models: offered,
538
568
  },
569
+ ...(agentKeyed
570
+ ? [{ id: AGENT_ROW_ID, kind: "ollama", enabled: true, url: M.url, defaultModel: M.chat, models: [] }]
571
+ : []),
539
572
  {
540
573
  kind: "openai",
541
574
  enabled: true,
@@ -627,8 +660,14 @@ export function backfillCodingModels(existing = {}) {
627
660
  * model names to a list that already exists; see its docstring for why that is not "overwriting
628
661
  * a model the user has since chosen".
629
662
  */
630
- export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS } = {}) {
663
+ export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS, skipModels = false } = {}) {
631
664
  const patch = {};
665
+ // #301: "Skip for now" (or Esc) at setup — NO model field is written: not the shared box, not a
666
+ // half. The bar then says what is unset and /model sets it. Only the non-model defaults below.
667
+ if (skipModels) {
668
+ if (typeof existing.agentSaveFullResponse !== "boolean") patch.agentSaveFullResponse = true;
669
+ return patch;
670
+ }
632
671
  // `models` is either the seeded shared-Ollama defaults (nobody was asked) or the box the user
633
672
  // chose at setup (task #220). Everything below is written in terms of M, so the two paths
634
673
  // cannot drift into configuring different fields.
@@ -638,7 +677,10 @@ export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS } =
638
677
  Boolean(existing.agentModelUrl?.trim()) ||
639
678
  Boolean(existing.agentModel?.trim()) ||
640
679
  Boolean(existing.agentModelName?.trim());
641
- if (!hasAgent) {
680
+ // A ROLE SKIPPED AT SETUP IS LEFT UNSET, not half-written (#301): an agent URL with no model
681
+ // name is the "default_model" sentinel on the wire, and a coder row with an empty model is a coder
682
+ // that answers nothing. Only the roles that were actually chosen are written.
683
+ if (!hasAgent && M.chat) {
642
684
  patch.agentModelUrl = M.url;
643
685
  const name = M.chat;
644
686
  // agentModelNAME is the field that matters. With a direct agent URL the API reads
@@ -664,17 +706,17 @@ export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS } =
664
706
  (Array.isArray(existing.agentCodingBackends) && existing.agentCodingBackends.length > 0) ||
665
707
  Boolean(existing.agentCodingModelUrl?.trim()) ||
666
708
  Boolean(existing.agentCodingModelName?.trim());
667
- if (!hasCoder) {
709
+ if (!hasCoder && M.code) {
668
710
  patch.agentCodingBackends = codingBackendsForClientExpert(M);
669
711
  patch.agentCodingDefaultKind = "ollama";
670
712
  // Legacy mirror, so an older worker resolves the same coder.
671
713
  patch.agentCodingModelKind = "ollama";
672
- patch.agentCodingModelUrl = M.url;
714
+ patch.agentCodingModelUrl = M.codeUrl || M.url;
673
715
  patch.agentCodingModelName = M.code;
674
716
  // NO api key is stored. The default coder is kind "ollama", which the API does not require
675
717
  // one for — and a placeholder here would be SENT to Kimi the moment someone switched with
676
718
  // `/model`, failing at the provider with an auth error nobody expected.
677
- } else {
719
+ } else if (hasCoder) {
678
720
  // Already configured — so the seed above will never run again on this machine. Add any
679
721
  // newly-offered shared-Ollama models to the list it was seeded with, without changing which
680
722
  // coder it uses. See backfillCodingModels.
@@ -752,6 +794,8 @@ export async function seedSettings({
752
794
  // question was skipped or declined. Passed straight to settingsPatch so there is exactly one
753
795
  // place that decides what a Client Expert machine is configured with.
754
796
  models = CLIENT_EXPERT_MODELS,
797
+ // #301: the model questions were skipped — write no model field at all.
798
+ skipModels = false,
755
799
  } = {}) {
756
800
  const { MongoClient } = loadMongoDriver(apiDir);
757
801
  const client = new MongoClient(mongoUri, { serverSelectionTimeoutMS: 8000 });
@@ -759,7 +803,7 @@ export async function seedSettings({
759
803
  try {
760
804
  const col = client.db(dbName).collection("settings");
761
805
  const existing = (await col.findOne({ userId })) ?? {};
762
- const patch = settingsPatch({ existing, models });
806
+ const patch = settingsPatch({ existing, models, skipModels });
763
807
  if (Object.keys(patch).length > 0) {
764
808
  await col.updateOne(
765
809
  { userId },
@@ -773,6 +817,43 @@ export async function seedSettings({
773
817
  }
774
818
  }
775
819
 
820
+ /**
821
+ * Save the API keys typed at setup (#301), each bound to its server's row, through the local API
822
+ * — the same POST /coding-keys the terminal uses, so the first key for a server becomes its
823
+ * default by the API's own rule (#245) rather than by anything written here. Runs AFTER
824
+ * seedSettings: the API refuses a key for a row that does not exist yet.
825
+ *
826
+ * Returns [{ host, ok }]. Never throws and never prints the key: a key that failed to save is
827
+ * reported by host, and can be added again with ctrl+k in /model.
828
+ */
829
+ export async function saveSetupKeys({ apiBase = LOCAL_API_BASE, workerKey, models, fetchImpl = fetch } = {}) {
830
+ const out = [];
831
+ for (const { url, key } of Array.isArray(models?.keys) ? models.keys : []) {
832
+ let host = url;
833
+ try {
834
+ host = new URL(url).host;
835
+ } catch {
836
+ /* keep the url */
837
+ }
838
+ const backendId = rowIdForKey(models, url);
839
+ if (!key || !backendId) continue;
840
+ let ok = false;
841
+ try {
842
+ const r = await fetchImpl(`${apiBase}/api/worker/coding-keys`, {
843
+ method: "POST",
844
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
845
+ body: JSON.stringify({ name: host, apiKey: key, backendId }),
846
+ signal: AbortSignal.timeout(10000),
847
+ });
848
+ ok = r.ok;
849
+ } catch {
850
+ ok = false;
851
+ }
852
+ out.push({ host, ok });
853
+ }
854
+ return out;
855
+ }
856
+
776
857
  /**
777
858
  * THE NEW OLLAMA SERVER'S ROWS, MERGED INTO WHAT IS ALREADY THERE — never replacing it (#43).
778
859
  *
@@ -29,6 +29,7 @@ import {
29
29
  probeLocalApi,
30
30
  writeWorkerEnv,
31
31
  seedSettings,
32
+ saveSetupKeys,
32
33
  modelsAlreadyConfigured,
33
34
  CLIENT_EXPERT_MODELS,
34
35
  LOCAL_API_PORT,
@@ -37,6 +38,9 @@ import {
37
38
  LOCAL_MONGO_URI,
38
39
  } from "./client-expert-api.mjs";
39
40
  import { askOllamaSetup, localOllamaDefault } from "./ollama-setup.mjs";
41
+ import { SKIP_ALL, createHinter, modelSetupMenu, parseModelSetupChoice } from "./model-setup-ui.mjs";
42
+ import { presetFilesIn } from "./presets-transfer.mjs";
43
+ import { readdirSync, statSync, readFileSync } from "node:fs";
40
44
  import { parseOllamaTags, formatSize } from "./agent-model-command.mjs";
41
45
  import {
42
46
  planSetup,
@@ -66,15 +70,46 @@ class SkipModelQuestion extends Error {}
66
70
  * (this returns []), and those two get different messages because they need different fixes.
67
71
  * A short timeout, because a box that has not answered in eight seconds is not going to.
68
72
  */
69
- async function fetchOllamaTags(url) {
70
- const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
71
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
73
+ async function fetchOllamaTags(url, { apiKey = "" } = {}) {
74
+ const res = await fetch(url, {
75
+ signal: AbortSignal.timeout(8000),
76
+ // #301: a key typed for this server. Absent → the request is exactly what it always was.
77
+ ...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
78
+ });
79
+ if (!res.ok) {
80
+ // The status travels with the error: 401/403 is "needs a key", which the caller asks for.
81
+ const err = new Error(`HTTP ${res.status}`);
82
+ err.status = res.status;
83
+ throw err;
84
+ }
72
85
  // parseOllamaTags is tolerant by design — HTML from a proxy, an empty body and a shape from a
73
86
  // future Ollama all become []. That is "reachable but not usable", which the caller reports as
74
87
  // such rather than as a network failure.
75
88
  return parseOllamaTags(await res.json().catch(() => null));
76
89
  }
77
90
 
91
+ /** How many /save-preset files are in the home folder — the up-front question names the count. */
92
+ function countSavedPresets() {
93
+ try {
94
+ const home = homedir();
95
+ const entries = [];
96
+ for (const name of readdirSync(home)) {
97
+ if (!name.toLowerCase().endsWith(".json")) continue;
98
+ const file = join(home, name);
99
+ try {
100
+ const st = statSync(file);
101
+ if (!st.isFile() || st.size > 64 * 1024) continue; // a preset is tiny; never slurp a big file
102
+ entries.push({ name, text: readFileSync(file, "utf8"), mtimeMs: st.mtimeMs });
103
+ } catch {
104
+ /* unreadable — not a preset we can offer */
105
+ }
106
+ }
107
+ return presetFilesIn(entries).length;
108
+ } catch {
109
+ return 0;
110
+ }
111
+ }
112
+
78
113
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
79
114
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
80
115
  // The brand accent, shared with the REPL — see accent.mjs.
@@ -339,7 +374,7 @@ export async function checkMongoConnection(uri) {
339
374
  * Returns { uri, custom } to continue with, or { exit: true } when the user declines — the
340
375
  * caller ends setup with the `gu clear` → `gu` hint.
341
376
  */
342
- export async function askMongoCredentials(ask) {
377
+ export async function askMongoCredentials(ask, askSecret = null) {
343
378
  const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
344
379
  console.log(dim(` MongoDB is answering at ${host}:${port}.`));
345
380
  console.log(dim(" If that is a MongoDB this machine already had, it may have its OWN"));
@@ -352,7 +387,12 @@ export async function askMongoCredentials(ask) {
352
387
  // continues: a typo found here costs ten seconds; found at the seed step it costs the run.
353
388
  for (let attempt = 0; attempt < 3; attempt += 1) {
354
389
  const username = String(await ask(" MongoDB username: ") ?? "").trim();
355
- const password = String(await ask(" MongoDB password: ") ?? "");
390
+ // MASKED when a masked reader is available (#300): a password typed in the clear stays in the
391
+ // scrollback, and in anything copied from it.
392
+ const rawPw = typeof askSecret === "function"
393
+ ? await askSecret(" MongoDB password: ")
394
+ : await ask(" MongoDB password: ");
395
+ const password = rawPw === SKIP_ALL ? "" : String(rawPw ?? "");
356
396
  const uri = buildMongoUri({ username, password, host, port });
357
397
  process.stdout.write(dim(" checking the connection…"));
358
398
  const verdict = await checkMongoConnection(uri);
@@ -589,7 +629,14 @@ export function markInterruptedOnExit(getState, stepId) {
589
629
  };
590
630
  }
591
631
 
592
- export async function runClientExpertSetup({ ask }) {
632
+ export async function runClientExpertSetup({
633
+ ask,
634
+ // #301/#300: a MASKED line reader (API keys, the MongoDB password). Absent → no key is asked.
635
+ askSecret = null,
636
+ // #301: an Esc-aware reader for the model questions — Esc resolves it with SKIP_ALL. Only the
637
+ // model questions use it: Esc elsewhere (the MongoDB questions) must not mean "skip models".
638
+ askStep = ask,
639
+ }) {
593
640
  const machine = await probeMachine();
594
641
  const gate = clientExpertAvailable({ workerEnvExists: false, ...machine });
595
642
  if (!gate.available) {
@@ -707,7 +754,7 @@ export async function runClientExpertSetup({ ask }) {
707
754
  console.log(dim(" using the MongoDB credentials from the previous run"));
708
755
  }
709
756
  } else if (!process.env.GONEXT_LOCAL_MONGO_URI) {
710
- const credsChoice = await askMongoCredentials(ask);
757
+ const credsChoice = await askMongoCredentials(ask, askSecret);
711
758
  if (credsChoice.exit) {
712
759
  console.log("\n " + dim("No working MongoDB credentials — setup stops here."));
713
760
  console.log(dim(" To start over: `gu clear`, then `gu` again.\n"));
@@ -902,6 +949,27 @@ export async function runClientExpertSetup({ ask }) {
902
949
  console.log(dim(" server this machine uses."));
903
950
  throw new SkipModelQuestion();
904
951
  }
952
+ // ASK ONCE, UP FRONT (#301): guided / load a preset / skip. Enter = guided, so today's flow is
953
+ // unchanged for anyone who just presses Enter; the other two are one digit away instead of an
954
+ // escape hunt through four questions.
955
+ const presets = countSavedPresets();
956
+ console.log("");
957
+ const [title, ...rows] = modelSetupMenu({ presets });
958
+ console.log(" " + bold(title));
959
+ for (const r of rows) console.log(dim(" " + r));
960
+ let path = null;
961
+ for (let t = 0; t < 3 && !path; t++) {
962
+ path = parseModelSetupChoice(await askStep(" choose [1]: "));
963
+ if (path === "preset" && presets === 0) {
964
+ console.log(dim(" No presets saved yet. Save one later with /save-preset — pick 1 or 3 for now."));
965
+ path = null;
966
+ }
967
+ }
968
+ path = path ?? "guided";
969
+ if (path !== "guided") {
970
+ chosenModels = { skipped: true, keys: [], loadPreset: path === "preset" };
971
+ throw new SkipModelQuestion();
972
+ }
905
973
  console.log("");
906
974
  console.log(" " + bold("Which Ollama server should this machine use?"));
907
975
  // THIS MACHINE FIRST, when it has one. Client Expert is the mode whose whole promise is
@@ -923,10 +991,12 @@ export async function runClientExpertSetup({ ask }) {
923
991
  ),
924
992
  );
925
993
  console.log(dim(" ollama.local, http://box:11434 — and pick from what it actually has."));
926
- chosenModels = await askOllamaSetup(ask, {
994
+ chosenModels = await askOllamaSetup(askStep, {
927
995
  defaultUrl,
928
996
  fetchTags: fetchOllamaTags,
929
997
  formatSize,
998
+ askSecret,
999
+ hint: createHinter(),
930
1000
  // THE SHIPPED DEFAULT WINS WHEN THE BOX HAS IT. Without this a fresh machine falls back to
931
1001
  // a size heuristic, and on a box with several similar builds that picks by size alone —
932
1002
  // observed on ollama2, where gemma4-quadro:latest and gemma4-quadro-49k:latest are
@@ -939,18 +1009,38 @@ export async function runClientExpertSetup({ ask }) {
939
1009
  // The skip is not a failure and says nothing extra; it has already printed its own line.
940
1010
  if (!(e instanceof SkipModelQuestion)) {
941
1011
  console.log(dim(` (could not ask: ${e.message} — using the shared Ollama)`));
1012
+ chosenModels = null;
942
1013
  }
943
- chosenModels = null;
1014
+ // A SkipModelQuestion keeps whatever it set: undefined (already configured) or the skip.
1015
+ chosenModels = chosenModels ?? null;
944
1016
  }
1017
+ // #301: skipped on purpose (up front, "s" at both servers, or Esc) → no model field is written.
1018
+ const skipModels = Boolean(chosenModels?.skipped);
1019
+ const loadPreset = Boolean(chosenModels?.loadPreset);
945
1020
 
946
1021
  try {
947
1022
  const written = await seedSettings({
948
1023
  apiDir,
949
1024
  mongoUri,
950
1025
  userId: creds.userId,
951
- ...(chosenModels ? { models: chosenModels } : {}),
1026
+ ...(chosenModels && !skipModels ? { models: chosenModels } : {}),
1027
+ skipModels,
952
1028
  });
953
- if (Object.keys(written).length === 0) {
1029
+ // #301: the keys typed for the servers, saved through the API now that their rows exist.
1030
+ // Reported by host, never by value.
1031
+ if (!skipModels && chosenModels?.keys?.length) {
1032
+ for (const r of await saveSetupKeys({ workerKey: creds.workerKey, models: chosenModels })) {
1033
+ console.log(r.ok
1034
+ ? " " + cyan("✓ API key stored") + dim(` for ${r.host} — the server's default key`)
1035
+ : " " + yellow("⚠ could not store the key") + dim(` for ${r.host} — add it in /model with ctrl+k`));
1036
+ }
1037
+ }
1038
+ if (skipModels) {
1039
+ console.log("");
1040
+ console.log(" " + cyan("✓ Model setup skipped") + dim(loadPreset
1041
+ ? " — the preset picker opens as soon as gu starts"
1042
+ : " — set them any time with /model (or /load-presets)"));
1043
+ } else if (Object.keys(written).length === 0) {
954
1044
  console.log(" " + cyan("✓ Models and RAG") + dim(" — already configured, left alone"));
955
1045
  } else {
956
1046
  console.log(
@@ -997,5 +1087,7 @@ export async function runClientExpertSetup({ ask }) {
997
1087
  dim("\n machine. The API restarts with `gu-cli api start`; its log is") +
998
1088
  dim(`\n ${API_LOG_FILE}\n`)
999
1089
  );
1000
- return { complete: true, stoppedAt: null };
1090
+ // #301: the caller opens /load-presets once the terminal exists — the picker, the plan and the
1091
+ // confirmation are that command's, not rebuilt here.
1092
+ return { complete: true, stoppedAt: null, ...(loadPreset ? { loadPreset: true } : {}) };
1001
1093
  }
package/gu-repl.mjs CHANGED
@@ -96,6 +96,7 @@ import {
96
96
  physicalRows,
97
97
  inputPhysicalRows,
98
98
  buildFooterRows,
99
+ pasteHint,
99
100
  footerDrawSeq,
100
101
  footerEraseSeq,
101
102
  clearBlockSeq,
@@ -632,6 +633,10 @@ function reloadWorkerEnv() {
632
633
  // first-run branch also runs one — without this the same walkthrough printed twice in a single
633
634
  // run, MongoDB instructions and all. Reported live on Windows 11.
634
635
  let clientExpertRanThisProcess = false;
636
+ // #301: setup chose "Load a saved preset" — main() opens /load-presets before the first prompt.
637
+ // Declared HERE, above the first-run block: that block runs at module top level, before any
638
+ // declaration further down exists.
639
+ let openPresetsAtStart = false;
635
640
 
636
641
  if (!workerKey || !apiBase) {
637
642
  if (process.stdin.isTTY && process.stdout.isTTY) {
@@ -728,7 +733,12 @@ if (!workerKey || !apiBase) {
728
733
  await saveState({ ...(await loadState()), chosenMode: mode0 ?? "client" });
729
734
  if (mode0 === "client-expert") {
730
735
  clientExpertRanThisProcess = true;
731
- const setup = await runClientExpertSetup({ ask: ask0 });
736
+ // #301/#300: Esc-aware and masked readers on the same rl0 — the model questions offer
737
+ // Esc, and a key or a password must not echo.
738
+ const { setupPrompts } = await import("./setup-prompts.mjs");
739
+ const sp = setupPrompts(rl0, { style: accent });
740
+ const setup = await runClientExpertSetup({ ask: ask0, askStep: sp.askStep, askSecret: sp.askSecret });
741
+ if (setup.loadPreset) openPresetsAtStart = true;
732
742
  // STOP when setup cannot go further.
733
743
  //
734
744
  // This used to fall through to the hosted sign-in, on the theory that "this machine
@@ -2376,6 +2386,9 @@ const footerRows = () =>
2376
2386
  identity: identityLineText(),
2377
2387
  token: tokenLineText(),
2378
2388
  mode: modeLineText(),
2389
+ // How to paste here (issue #67): the platform's text key, plus gu's image key when the coder
2390
+ // can take an image.
2391
+ modeHint: pasteHint({ images: coderTakesImages() }),
2379
2392
  // Its own row (#54), sized to the window so the fix it names is never the part cut off.
2380
2393
  notice: imageNotice
2381
2394
  ? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
@@ -5231,15 +5244,35 @@ async function reconfigureOllamaServer() {
5231
5244
 
5232
5245
  /** Load a box's models THROUGH THE PROXY (#160) — a bare fetch is what fails on a restricted
5233
5246
  * network, and it fails as "no models", which is the wrong story entirely. */
5234
- const load = async (n) => {
5247
+ const load = async (n, key = "") => {
5235
5248
  const res = await withPendingStatus(`loading models from ${n.host}`, () =>
5236
- proxyFetch(os.tagsUrlFor(n), { signal: AbortSignal.timeout(8000) })
5249
+ proxyFetch(os.tagsUrlFor(n), {
5250
+ signal: AbortSignal.timeout(8000),
5251
+ // #301: the key typed for this server; absent → the request is what it always was.
5252
+ ...(key ? { headers: { Authorization: `Bearer ${key}` } } : {}),
5253
+ })
5237
5254
  );
5238
5255
  // The status is kept: a 404 here means "answered, but not an Ollama" (#43), not "unreachable".
5239
5256
  if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { httpStatus: res.status });
5240
5257
  return am.parseOllamaTags(await res.json().catch(() => null));
5241
5258
  };
5242
5259
 
5260
+ // #301: the way out, said under every server question (full once, short after), and the keys
5261
+ // typed for servers, by host — saved against their rows once the settings are written.
5262
+ const ui = await import("./model-setup-ui.mjs");
5263
+ const hint = ui.createHinter();
5264
+ const typedKeys = new Map();
5265
+ const askKeyFor = async (host) => {
5266
+ if (typedKeys.has(host)) return typedKeys.get(host); // same server for both roles → asked once
5267
+ console.log(dim(` ${hint()}`));
5268
+ // askSecret: masked, kept out of history; Esc throws PromptCancelled → /model is cancelled
5269
+ // with nothing changed, which is what "skip all" means here.
5270
+ const k = (await askSecret(` API key for ${host} (Enter for none): `)).trim();
5271
+ typedKeys.set(host, k);
5272
+ console.log(GUTTER + dim(ui.keyConfirmation(host, k)));
5273
+ return k;
5274
+ };
5275
+
5243
5276
  /** Ask for one server and load it. Returns {normalized, models} or null (declined/failed). */
5244
5277
  const askServer = async (label, dflt, { require = false, short = "server", role = "coder" } = {}) => {
5245
5278
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -5251,13 +5284,15 @@ async function reconfigureOllamaServer() {
5251
5284
  // — normalizeOllamaUrl adds it — and displayUrl round-trips, so what is shown can be fed
5252
5285
  // straight back as the default.
5253
5286
  console.log(dim(` ${label} — Enter keeps ${os.displayUrl(dflt)}`));
5287
+ console.log(dim(` ${hint()}`));
5254
5288
  // THE ROW MUST SAY WHICH SERVER IT IS ASKING FOR. It said a bare "server:" — chosen to
5255
5289
  // fit the frozen row, which traded away the one thing the label was for. Two questions
5256
5290
  // in a row, both reading "server:", and the reader cannot tell which one they are
5257
5291
  // answering (reported: "it should say 'Agent + Embedder server' then 'Coding Server',
5258
5292
  // it only says 'server:'"). `short` is the role in the fewest words that still name it.
5259
5293
  const typed = (await ask(` ${short}: `)).trim();
5260
- if (/^(no|n|skip|-)$/i.test(typed)) return null;
5294
+ // "s" is the advertised skip (#301); the older words still work.
5295
+ if (ui.readStepAnswer(typed).kind === "skip") return null;
5261
5296
  // EVERY READING OF A BARE HOSTNAME, in order. Typing "ollama2.gomarsic.cc" means
5262
5297
  // https://…:443 behind a proxy, while "192.168.1.40" means http://…:11434 — one guess
5263
5298
  // is right for one of them and produces a timeout that looks like a dead server for the
@@ -5270,15 +5305,33 @@ async function reconfigureOllamaServer() {
5270
5305
  let n = null;
5271
5306
  let models = null;
5272
5307
  let lastErr = null;
5273
- for (const cand of candidates) {
5274
- try {
5275
- models = await load(cand);
5276
- n = cand;
5277
- if (candidates.length > 1) console.log(dim(` using ${os.displayUrl(cand.url)}`));
5278
- break;
5279
- } catch (err) {
5280
- lastErr = err;
5308
+ // #301: a TYPED public address may need a key — asked before listing, so a server that
5309
+ // needs one can list its models. A LAN box is never asked.
5310
+ let key = "";
5311
+ if (ui.shouldAskKey({ typed, host: candidates[0].host })) key = await askKeyFor(candidates[0].host);
5312
+ const tryAll = async () => {
5313
+ for (const cand of candidates) {
5314
+ try {
5315
+ models = await load(cand, key);
5316
+ n = cand;
5317
+ if (candidates.length > 1) console.log(dim(` using ${os.displayUrl(cand.url)}`));
5318
+ return;
5319
+ } catch (err) {
5320
+ lastErr = err;
5321
+ }
5281
5322
  }
5323
+ };
5324
+ await tryAll();
5325
+ // Refused for want of credentials: ask now, whatever the address looked like, and list again.
5326
+ if (!n && ui.needsKeyAfterStatus(lastErr?.httpStatus) && !key) {
5327
+ console.log(yellow(` ${candidates[0].host} needs an API key to list its models.`));
5328
+ typedKeys.delete(candidates[0].host);
5329
+ key = await askKeyFor(candidates[0].host);
5330
+ if (key) await tryAll();
5331
+ }
5332
+ if (!n && ui.needsKeyAfterStatus(lastErr?.httpStatus)) {
5333
+ console.log(red(` ${candidates[0].host} refused the request (HTTP ${lastErr.httpStatus})${key ? " — that key was not accepted" : ""}.`));
5334
+ continue;
5282
5335
  }
5283
5336
  if (!n) {
5284
5337
  if (lastErr?.httpStatus === 404) {
@@ -5305,7 +5358,7 @@ async function reconfigureOllamaServer() {
5305
5358
  const need = os.checkRequired(models);
5306
5359
  if (!need.ok) {
5307
5360
  for (const l of os.describeNotReady(n.host, need.missing)) console.log(yellow(` ${l}`));
5308
- console.log(dim(" (or type 'no' to skip — you can set it later with /model)"));
5361
+ console.log(dim(" (or type s to skip — you can set it later with /model)"));
5309
5362
  continue;
5310
5363
  }
5311
5364
  console.log(dim(` ✓ ${n.host} has the ${os.REQUIRED_AGENT_PREFIX} family and the embedder`));
@@ -5328,10 +5381,10 @@ async function reconfigureOllamaServer() {
5328
5381
  if (agentOnProvider) {
5329
5382
  const host = hostOf(String(settings.agentModelUrl ?? ""));
5330
5383
  console.log(dim(` The agent model is on ${host} (OpenAI-compatible) — kept as it is; change it with /agent-model.`));
5331
- console.log(dim(" Enter keeps the current address; type 'no' to skip."));
5384
+ console.log(dim(" Enter keeps the current address."));
5332
5385
  } else {
5333
5386
  console.log(dim(" TWO servers may be used: the agent + embedder on one, the coder on another."));
5334
- console.log(dim(" Enter keeps the current address; type 'no' to skip a role."));
5387
+ console.log(dim(" Enter keeps the current address."));
5335
5388
  agent = await askServer("Agent + embedding server", curAgent, {
5336
5389
  require: true,
5337
5390
  short: "agent + embed server",
@@ -5476,6 +5529,32 @@ async function reconfigureOllamaServer() {
5476
5529
  console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
5477
5530
  return;
5478
5531
  }
5532
+ // #301: THE KEYS TYPED FOR THE SERVERS, bound to their rows now that the rows exist (the API
5533
+ // refuses a key for a row it does not have). Before the cloud check below, so a key typed for
5534
+ // the Ollama cloud is not asked for a second time. Reported by host, never by value.
5535
+ {
5536
+ const used = [agent?.normalized?.url, coder?.normalized?.url].filter(Boolean);
5537
+ // By HOSTNAME: the key was asked for urlCandidates' host, which carries no port, while a URL's
5538
+ // .host does — box.example.com:8443 would otherwise never match its own key.
5539
+ const hostnameOf = (u) => { try { return new URL(u).hostname; } catch { return ""; } };
5540
+ const pending = [...typedKeys].filter(([host, k]) => k && used.some((u) => hostnameOf(u) === host));
5541
+ if (pending.length) {
5542
+ const fresh = await withPendingStatus("storing the key", fetchWorkerSettings);
5543
+ const ps = await import("./coding-provider-setup.mjs");
5544
+ for (const [host, k] of pending) {
5545
+ const url = used.find((u) => hostnameOf(u) === host);
5546
+ const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), url);
5547
+ if (!found) {
5548
+ console.log(yellow(` no server row holds ${host} yet — add its key with ctrl+k in /model.`));
5549
+ continue;
5550
+ }
5551
+ const ok = await withPendingStatus("saving the key", () => addCodingKey(host, k, found.id));
5552
+ console.log(ok
5553
+ ? GUTTER + dim(`✓ key stored for ${host} — the server's default key`)
5554
+ : red(` couldn't store the key for ${host} — add it with ctrl+k.`));
5555
+ }
5556
+ }
5557
+ }
5479
5558
  // THE OLLAMA CLOUD NEEDS ITS OWN KEY. Reported: choosing https://ollama.com here listed its 20 models
5480
5559
  // (that list is public) and saved, without a key — and the next turn failed "REJECTED the coding API
5481
5560
  // key", having borrowed an unrelated sk-… key. Asked for now, bound to the row just saved.
@@ -10869,8 +10948,18 @@ async function main() {
10869
10948
  // recorded, so once the choice was saved this block was skipped and the plan sat on disk
10870
10949
  // untouched. Picking it up here is what makes the sentence true.
10871
10950
  const { runClientExpertSetup } = await import("./client-expert-run.mjs");
10872
- const setup = await runClientExpertSetup({ ask });
10951
+ const { SKIP_ALL } = await import("./model-setup-ui.mjs");
10952
+ // #301: keys (and the MongoDB password) through the REPL's own masked prompt; a cancelled
10953
+ // one means "skip the rest", not a crash of the setup.
10954
+ const toSkipAll = (e) => (e instanceof PromptCancelled ? SKIP_ALL : Promise.reject(e));
10955
+ const setup = await runClientExpertSetup({
10956
+ ask,
10957
+ // Esc at a model question throws PromptCancelled here; in setup it means "skip all".
10958
+ askStep: (q) => ask(q).catch(toSkipAll),
10959
+ askSecret: (q) => askSecret(q).catch(toSkipAll),
10960
+ });
10873
10961
  if (setup?.complete) reloadWorkerEnv();
10962
+ if (setup?.loadPreset) openPresetsAtStart = true;
10874
10963
  }
10875
10964
  } catch {
10876
10965
  /* never block the REPL on the mode question — the banner still shows what is configured */
@@ -11621,6 +11710,17 @@ async function main() {
11621
11710
  // rows it pushes up are real output rather than half-drawn setup.
11622
11711
  syncBarPinning();
11623
11712
 
11713
+ // #301: setup's "Load a saved preset" — the picker opens now that the terminal exists. The whole
11714
+ // /load-presets flow (the list, the plan, the confirmation, the key) is that command's own.
11715
+ if (openPresetsAtStart) {
11716
+ openPresetsAtStart = false;
11717
+ try {
11718
+ await runLoadPresetsCommand("/load-presets");
11719
+ } catch (e) {
11720
+ console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /load-presets)`));
11721
+ }
11722
+ }
11723
+
11624
11724
  for (;;) {
11625
11725
  stopPendingStatus(); // nothing from a previous turn may still be animating (task #140)
11626
11726
  // The bar and the input line live in the frozen rows at the bottom of the window (task
@@ -0,0 +1,114 @@
1
+ /**
2
+ * THE WAY OUT OF THE MODEL QUESTIONS, and the key question that follows a typed server (#301).
3
+ *
4
+ * Reported: at "Agent + embedding server [https://ollama1.gomarsic.cc]:" there was no visible way
5
+ * to NOT choose. Enter takes the default server; a decline existed ("n", "no", "skip", "-") but no
6
+ * prompt said so, and one hint even claimed "press Enter with no address to skip", which was the
7
+ * opposite of what Enter does. Some people want no model setup at all and to load a saved preset.
8
+ *
9
+ * The decisions, agreed with the user (2026-09-29):
10
+ * · ASK ONCE, UP FRONT — guided / load a preset / skip. Enter = guided, so today's flow is one
11
+ * keypress away and unchanged.
12
+ * · TWO WAYS OUT, NOT FIVE — "s" skips a step, Esc skips all model setup. The old words still
13
+ * work, silently.
14
+ * · FULL HINT ONCE, THEN SHORT — a hint under every prompt is noise by the fourth.
15
+ * · A KEY ONLY WHEN ONE MAY BE NEEDED — a LAN Ollama box never needs one, a public https
16
+ * address may, and a server that answers 401/403 certainly does.
17
+ * · CONFIRM THE KEY WITHOUT SHOWING IT — a masked prompt gives no sign it took anything.
18
+ *
19
+ * PURE. Everything here is strings in, strings out, so the whole table is a test.
20
+ */
21
+ import { hostLocality } from "./coding-provider-setup.mjs";
22
+
23
+ /**
24
+ * What an Esc-aware prompt resolves to when Esc is pressed. A string no one can type (it starts
25
+ * with NUL), so it can travel through every `ask` that returns strings without a new type.
26
+ */
27
+ export const SKIP_ALL = "\u0000skip-all";
28
+
29
+ /** Esc, as readline reports it: name "escape" (meta:true on most terminals — accept either). */
30
+ export function isEscapeKey(key) {
31
+ return Boolean(key) && (key.name === "escape" || (key.meta && key.sequence === "\x1b"));
32
+ }
33
+
34
+ /**
35
+ * One answer at a model prompt → what it means.
36
+ * { kind: "all" } Esc: skip all model setup
37
+ * { kind: "skip" } "s" (or the older n / no / skip / -): skip this step
38
+ * { kind: "answer", text } anything else, including "" (= the default)
39
+ */
40
+ export function readStepAnswer(raw) {
41
+ if (raw === SKIP_ALL) return { kind: "all" };
42
+ const text = String(raw ?? "").trim();
43
+ if (/^(s|n|no|skip|-)$/i.test(text)) return { kind: "skip" };
44
+ return { kind: "answer", text };
45
+ }
46
+
47
+ export const FULL_HINT = "Enter = use this · s = skip this step · Esc = skip all model setup";
48
+ export const SHORT_HINT = "s skip · esc skip all";
49
+
50
+ /** A hinter: the full hint the first time it is asked for, the short one after. */
51
+ export function createHinter() {
52
+ let shown = false;
53
+ return () => {
54
+ if (shown) return SHORT_HINT;
55
+ shown = true;
56
+ return FULL_HINT;
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Should typing THIS address be followed by "API key for <host>"?
62
+ *
63
+ * Only for an address that was TYPED (Enter on the default is today's path and stays one key),
64
+ * and only when the host is public: a LAN box (192.168.x, 10.x, *.local, a bare hostname,
65
+ * localhost) is an Ollama that takes no key, and asking there is a question with one answer.
66
+ * A server that answers 401/403 is asked regardless — see needsKeyAfterStatus.
67
+ */
68
+ export function shouldAskKey({ typed, host }) {
69
+ if (!String(typed ?? "").trim()) return false;
70
+ return hostLocality(host) === "public";
71
+ }
72
+
73
+ /** The server refused to list its models without credentials. */
74
+ export function needsKeyAfterStatus(status) {
75
+ return status === 401 || status === 403;
76
+ }
77
+
78
+ /** The last 4 characters, in the bar's own style — enough to tell keys apart, not to use one. */
79
+ export function keyTail(key) {
80
+ const k = String(key ?? "").trim();
81
+ return k.length > 4 ? `····${k.slice(-4)}` : "····";
82
+ }
83
+
84
+ /** The line printed after the masked prompt, so the person knows whether it took anything. */
85
+ export function keyConfirmation(host, key) {
86
+ return String(key ?? "").trim()
87
+ ? `✓ key saved for ${host} (${keyTail(key)})`
88
+ : `no key for ${host}`;
89
+ }
90
+
91
+ /**
92
+ * The up-front question. `presets` = how many saved presets there are: with none, option 2 still
93
+ * shows (so the feature is discoverable) but says how to make one instead of offering nothing.
94
+ */
95
+ export function modelSetupMenu({ presets = 0 } = {}) {
96
+ return [
97
+ "How do you want to set up the models?",
98
+ " 1 Guided — pick the servers and models (Enter)",
99
+ presets > 0
100
+ ? ` 2 Load a saved preset (${presets} saved)`
101
+ : " 2 Load a saved preset (none saved yet — /save-preset makes one)",
102
+ " 3 Skip for now — set them later with /model",
103
+ ];
104
+ }
105
+
106
+ /** An answer to the up-front question → "guided" | "preset" | "skip", or null to ask again. */
107
+ export function parseModelSetupChoice(raw) {
108
+ if (raw === SKIP_ALL) return "skip";
109
+ const t = String(raw ?? "").trim().toLowerCase();
110
+ if (t === "" || t === "1" || t === "guided" || t === "g") return "guided";
111
+ if (t === "2" || t === "preset" || t === "p") return "preset";
112
+ if (t === "3" || t === "s" || t === "skip" || t === "n" || t === "no" || t === "-") return "skip";
113
+ return null;
114
+ }
package/ollama-setup.mjs CHANGED
@@ -21,6 +21,14 @@
21
21
  import { isLikelyEmbedder } from "./agent-model-command.mjs";
22
22
  import { EMBED_CHOICES } from "./embed-model-command.mjs";
23
23
  import { isCancelWord } from "./coding-provider-setup.mjs";
24
+ import {
25
+ SKIP_ALL,
26
+ createHinter,
27
+ keyConfirmation,
28
+ needsKeyAfterStatus,
29
+ readStepAnswer,
30
+ shouldAskKey,
31
+ } from "./model-setup-ui.mjs";
24
32
 
25
33
  /** Ollama's native port, used when someone types a bare host or an IP with no port. */
26
34
  export const OLLAMA_DEFAULT_PORT = 11434;
@@ -649,21 +657,83 @@ export async function askOllamaSetup(
649
657
  // Injected so the tests can drive it; defaults to a real request with a short deadline,
650
658
  // because this runs while someone is waiting at a prompt.
651
659
  probeStatus = defaultProbeStatus,
660
+ // #301: the MASKED prompt for an API key. Without one no key is ever asked — a secret is
661
+ // never read through a prompt that echoes it.
662
+ askSecret = null,
663
+ // #301: the hint under each model prompt — full the first time, short after.
664
+ hint = createHinter(),
652
665
  } = {},
653
666
  ) {
667
+ // #301: every key typed for a server, by its HOSTNAME. "" = asked, left empty. Not by URL: the
668
+ // key is asked before listing, for the first reading of what was typed, and a different reading
669
+ // (another port or scheme) may be the one that answers.
670
+ const keys = new Map();
671
+ const SKIPPED = { skipped: true, keys: [] };
672
+ // #301: a step SKIPPED ON PURPOSE, as against a server that never answered. Nothing chosen
673
+ // because of a skip means "leave the models unset"; nothing chosen because nothing answered
674
+ // stays null, and the caller falls back to the shared box as it always has.
675
+ let declined = false;
676
+ // Dim, like the other guidance lines under a question; one line, above the prompt it explains.
677
+ const say = (h) => log(` \x1b[2m${h}\x1b[0m`);
678
+
679
+ /**
680
+ * The key question for a TYPED address, before its models are listed (so a server that needs
681
+ * one can list them). Returns the key, "" for none, or SKIP_ALL.
682
+ */
683
+ const askKeyFor = async (host) => {
684
+ if (keys.has(host)) return keys.get(host); // same server for both roles → asked once
685
+ if (typeof askSecret !== "function") return "";
686
+ say(hint());
687
+ const raw = await askSecret(` API key for ${host} (Enter for none): `);
688
+ if (raw === SKIP_ALL) return SKIP_ALL;
689
+ const key = String(raw ?? "").trim();
690
+ keys.set(host, key);
691
+ log(` ${keyConfirmation(host, key)}`);
692
+ return key;
693
+ };
694
+
695
+ /**
696
+ * List the models of what was typed (or the default), asking for a key when the address is
697
+ * public or the server refuses without one. { ok, normalized, models } | { failed } | SKIP_ALL.
698
+ */
699
+ const loadServer = async (typed, fallbackUrl, reuse) => {
700
+ const raw = typed || fallbackUrl;
701
+ const first = urlCandidates(raw)[0];
702
+ if (reuse && first && reuse.normalized.url === first.url) return reuse;
703
+ let key = "";
704
+ if (first && shouldAskKey({ typed, host: first.host })) {
705
+ key = await askKeyFor(first.host);
706
+ if (key === SKIP_ALL) return SKIP_ALL;
707
+ }
708
+ let tried = await loadFirstThatAnswers(raw, fetchTags, log, key);
709
+ // A server that refused for want of credentials: ask now, whatever the address looked like,
710
+ // and list again with the key. Once — a second refusal is a wrong key, reported as such.
711
+ if (tried?.failed && needsKeyAfterStatus(tried.status) && !key && first) {
712
+ log(` ${first.host} needs an API key to list its models.`);
713
+ keys.delete(first.host);
714
+ key = await askKeyFor(first.host);
715
+ if (key === SKIP_ALL) return SKIP_ALL;
716
+ if (key) tried = await loadFirstThatAnswers(raw, fetchTags, log, key);
717
+ }
718
+ return tried;
719
+ };
720
+
654
721
  // ---- SERVER 1: the agent model and the embedder -------------------------------------
655
722
  // These two are REQUIRED and not a free choice (see REQUIRED_AGENT_MODEL). A box without
656
723
  // them is "not ready" rather than "wrong": it may still be a perfectly good coder host, which
657
724
  // is why this refusal does not end the flow.
658
725
  let agent = null;
659
726
  for (let attempt = 0; attempt < attempts && !agent; attempt++) {
660
- const typed = (await ask(` Agent + embedding server [${displayUrl(defaultUrl)}]: `)).trim();
661
- if (isDecline(typed)) break;
662
- const tried = await loadFirstThatAnswers(typed || defaultUrl, fetchTags, log);
727
+ say(hint());
728
+ const a = readStepAnswer(await ask(` Agent + embedding server [${displayUrl(defaultUrl)}]: `));
729
+ if (a.kind === "all") return SKIPPED;
730
+ if (a.kind === "skip") { declined = true; break; }
731
+ const tried = await loadServer(a.text, defaultUrl, null);
732
+ if (tried === SKIP_ALL) return SKIPPED;
663
733
  if (!tried || tried.failed) {
664
734
  // One explanation, the best one available: the provider message when we recognise the
665
735
  // address, otherwise the reachability advice we were going to give anyway.
666
- const explained = await explainIfOpenAi(typed || defaultUrl, probeStatus, log, describeOpenAiAgent);
736
+ const explained = await explainIfOpenAi(a.text || defaultUrl, probeStatus, log, describeOpenAiAgent);
667
737
  if (!explained) for (const l of tried?.failed ?? []) log(` ${l}`);
668
738
  continue;
669
739
  }
@@ -676,7 +746,9 @@ export async function askOllamaSetup(
676
746
  const need = checkRequired(models);
677
747
  if (!need.ok) {
678
748
  for (const l of describeNotReady(normalized.host, need.missing)) log(` ${l}`);
679
- log(" (or press Enter with no address to skip — you can set it later with /model)");
749
+ // #301: this used to tell people to press Enter to skip — but Enter takes the DEFAULT
750
+ // server. "s" is the skip.
751
+ log(" (or type s to skip — you can set it later with /model)");
680
752
  continue;
681
753
  }
682
754
  log(` ✓ ${normalized.host} has the ${REQUIRED_AGENT_PREFIX} family and the embedder`);
@@ -690,18 +762,21 @@ export async function askOllamaSetup(
690
762
  const coderDefault = agent?.normalized?.url ?? defaultUrl;
691
763
  let coder = null;
692
764
  for (let attempt = 0; attempt < attempts && !coder; attempt++) {
693
- const typed = (await ask(` Coding-model server [${displayUrl(coderDefault)}]: `)).trim();
694
- if (isDecline(typed)) break;
765
+ say(hint());
766
+ const a = readStepAnswer(await ask(` Coding-model server [${displayUrl(coderDefault)}]: `));
767
+ if (a.kind === "all") return SKIPPED;
768
+ if (a.kind === "skip") { declined = true; break; }
695
769
  // Same box as the agent server → reuse the list we already have rather than fetching twice.
696
- const same = normalizeOllamaUrl(typed || coderDefault);
770
+ const same = normalizeOllamaUrl(a.text || coderDefault);
697
771
  const tried =
698
772
  agent && same && same.url === agent.normalized.url
699
773
  ? { normalized: same, models: agent.models }
700
- : await loadFirstThatAnswers(typed || coderDefault, fetchTags, log);
774
+ : await loadServer(a.text, coderDefault, agent);
775
+ if (tried === SKIP_ALL) return SKIPPED;
701
776
  if (!tried || tried.failed) {
702
777
  // One explanation, the best one available: the provider message when we recognise the
703
778
  // address, otherwise the reachability advice we were going to give anyway.
704
- const explained = await explainIfOpenAi(typed || coderDefault, probeStatus, log, describeOpenAiCoder);
779
+ const explained = await explainIfOpenAi(a.text || coderDefault, probeStatus, log, describeOpenAiCoder);
705
780
  if (!explained) for (const l of tried?.failed ?? []) log(` ${l}`);
706
781
  continue;
707
782
  }
@@ -718,7 +793,7 @@ export async function askOllamaSetup(
718
793
  // A real answer, not a failure. The machine finishes setup unconfigured, the bar shows the
719
794
  // roles as unset, and the first question says so instead of hanging — which is the entire
720
795
  // reason declining is allowed to be this easy.
721
- if (!agent && !coder) return null;
796
+ if (!agent && !coder) return declined ? SKIPPED : null;
722
797
 
723
798
  // ---- PICK THE MODELS -----------------------------------------------------------------
724
799
  // THE AGENT IS CHOSEN, not assumed (user, 2026-09-06). The gate above proved the family is
@@ -729,7 +804,6 @@ export async function askOllamaSetup(
729
804
  // Seed the embedder the box ACTUALLY has: the shipped default when present, else whichever
730
805
  // accepted embedder passed the gate — a qwen3-embedding-only box must not be told it now
731
806
  // embeds with a model it never had.
732
- const embed = agent ? defaultEmbedModel(agent.models) || REQUIRED_EMBED_MODEL : null;
733
807
  let chat = "";
734
808
  if (agent) {
735
809
  const cands = agentCandidates(agent.models);
@@ -741,13 +815,18 @@ export async function askOllamaSetup(
741
815
  log(" AGENT model — routes every turn and writes the plain replies");
742
816
  for (const row of renderModelMenu(cands, { defaultModel: agentDefault, formatSize })) log(row);
743
817
  for (let t = 0; t < attempts && !chat; t++) {
744
- const answer = await ask(` agent [${agentDefault}]: `);
745
- chat = parseMenuChoice(answer, cands, agentDefault) ?? "";
818
+ say(hint());
819
+ const a = readStepAnswer(await ask(` agent [${agentDefault}]: `));
820
+ if (a.kind === "all") return SKIPPED;
821
+ // #301: "s" here skips the AGENT role — its server was chosen, its model is not.
822
+ if (a.kind === "skip") { agent = null; break; }
823
+ chat = parseMenuChoice(a.text, cands, agentDefault) ?? "";
746
824
  if (!chat) log(` no model called that — type its number, or Enter for ${agentDefault}`);
747
825
  }
748
- chat = chat || agentDefault;
826
+ if (agent) chat = chat || agentDefault;
749
827
  }
750
828
  }
829
+ const embed = agent ? defaultEmbedModel(agent.models) || REQUIRED_EMBED_MODEL : null;
751
830
  let code = "";
752
831
  let extras = [];
753
832
  if (coder) {
@@ -756,20 +835,37 @@ export async function askOllamaSetup(
756
835
  log(" CODING model — writes the code; worth waiting for");
757
836
  for (const row of renderModelMenu(coder.models, { defaultModel: roles.code, formatSize })) log(row);
758
837
  for (let t = 0; t < attempts && !code; t++) {
759
- const answer = await ask(` code [${roles.code}]: `);
760
- code = parseMenuChoice(answer, coder.models, roles.code) ?? "";
838
+ say(hint());
839
+ const a = readStepAnswer(await ask(` code [${roles.code}]: `));
840
+ if (a.kind === "all") return SKIPPED;
841
+ if (a.kind === "skip") { coder = null; break; }
842
+ code = parseMenuChoice(a.text, coder.models, roles.code) ?? "";
761
843
  if (!code) log(` no model called that — type its number, or Enter for ${roles.code}`);
762
844
  }
763
- code = code || roles.code;
845
+ if (coder) code = code || roles.code;
764
846
 
765
- const rest = coder.models.filter((m) => !isLikelyEmbedder(m) && m.model !== code);
847
+ const rest = coder ? coder.models.filter((m) => !isLikelyEmbedder(m) && m.model !== code) : [];
766
848
  if (rest.length) {
767
849
  log("");
768
850
  log(" Also offer in /model? (numbers separated by commas, or Enter for none)");
769
851
  for (const row of renderModelMenu(rest, { formatSize })) log(row);
770
- extras = parseMultiChoice(await ask(" also: "), rest);
852
+ say(hint());
853
+ const a = readStepAnswer(await ask(" also: "));
854
+ if (a.kind === "all") return SKIPPED;
855
+ extras = a.kind === "skip" ? [] : parseMultiChoice(a.text, rest);
771
856
  }
772
857
  }
858
+ // Both roles skipped at the model menus: as much a decision as skipping both servers.
859
+ if (!agent && !coder) return SKIPPED;
860
+
861
+ // #301: the keys typed, only for the servers actually used, never the empty ones — each under
862
+ // the URL that ANSWERED, which is the one the settings rows are written with.
863
+ const used = [agent?.normalized, coder?.normalized].filter(Boolean);
864
+ const keyList = [];
865
+ for (const [host, key] of keys) {
866
+ const n = key && used.find((u) => u.host === host);
867
+ if (n && !keyList.some((k) => k.url === n.url)) keyList.push({ url: n.url, key });
868
+ }
773
869
 
774
870
  // HALF-CONFIGURED IS A REAL STATE and must be representable. Someone with an agent box but no
775
871
  // coder yet, or a coder but no agent box, has made progress worth keeping — forcing both or
@@ -786,6 +882,7 @@ export async function askOllamaSetup(
786
882
  ...(embed ? { embed } : {}),
787
883
  altCoders: (extras ?? []).filter((n) => n !== code),
788
884
  ready: Boolean(agent && coder),
885
+ ...(keyList.length ? { keys: keyList } : {}),
789
886
  };
790
887
  }
791
888
 
@@ -848,7 +945,7 @@ async function explainIfOpenAi(raw, probeStatus, log, describe) {
848
945
  return true;
849
946
  }
850
947
 
851
- async function loadFirstThatAnswers(raw, fetchTags, log) {
948
+ async function loadFirstThatAnswers(raw, fetchTags, log, apiKey = "") {
852
949
  const candidates = urlCandidates(raw);
853
950
  if (!candidates.length) {
854
951
  return { failed: ["that is not a server address — try 192.168.1.40, ollama.local, or a full URL"] };
@@ -856,7 +953,10 @@ async function loadFirstThatAnswers(raw, fetchTags, log) {
856
953
  let last = null;
857
954
  for (const normalized of candidates) {
858
955
  try {
859
- const models = await fetchTags(tagsUrlFor(normalized));
956
+ // #301: the key goes along only when there is one — a keyless call stays byte-identical.
957
+ const models = apiKey
958
+ ? await fetchTags(tagsUrlFor(normalized), { apiKey })
959
+ : await fetchTags(tagsUrlFor(normalized));
860
960
  // Say WHICH reading answered, but only when we had to guess — otherwise it is noise
861
961
  // repeating what the user just typed.
862
962
  if (candidates.length > 1) log(` using ${displayUrl(normalized.url)}`);
@@ -869,6 +969,11 @@ async function loadFirstThatAnswers(raw, fetchTags, log) {
869
969
  // NOT PRINTED HERE ANY MORE. "check that Ollama is running there" is exactly the wrong thing
870
970
  // to say about a correct address for a hosted provider, and the caller is the only one that
871
971
  // knows whether it found a better explanation. It prints one message or the other, never both.
872
- return { failed: [`could not reach ${candidates[0].host}: ${networkReason(last)}`,
972
+ // #301: an HTTP status (401/403 = needs a key) travels with the failure so the caller can ask.
973
+ const status = Number(last?.status ?? last?.httpStatus) || 0;
974
+ if (needsKeyAfterStatus(status)) {
975
+ return { status, failed: [`${candidates[0].host} refused the request (HTTP ${status}) — ${apiKey ? "that key was not accepted" : "it needs an API key"}.`] };
976
+ }
977
+ return { status, failed: [`could not reach ${candidates[0].host}: ${networkReason(last)}`,
873
978
  "check the address, or that Ollama is running there and listening on the network."] };
874
979
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.768",
3
+ "version": "1.0.769",
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",
@@ -50,6 +50,8 @@
50
50
  "gu-cli.mjs",
51
51
  "gu-repl.mjs",
52
52
  "step-tokens.mjs",
53
+ "model-setup-ui.mjs",
54
+ "setup-prompts.mjs",
53
55
  "terminal-hover.mjs",
54
56
  "terminal-layout.mjs",
55
57
  "terminal-viewport.mjs",
@@ -0,0 +1,82 @@
1
+ /**
2
+ * THE FIRST-RUN PROMPTS THAT PLAIN rl.question CANNOT BE (#301, #300).
3
+ *
4
+ * First-run setup runs before the REPL exists, on its own readline (gu-repl.mjs `rl0`), and every
5
+ * question there was `rl0.question` — which echoes. Two things need more than that:
6
+ *
7
+ * · ESC. The model questions offer "Esc = skip all model setup". Esc submits nothing in readline,
8
+ * so the pending question is cancelled through its AbortSignal and resolved with SKIP_ALL.
9
+ * · A SECRET. An API key, or the MongoDB password (#300), must never reach the screen or the
10
+ * scrollback. Readline's echo and line refresh are muted for the question, one "•" per
11
+ * character is drawn instead (the only proof a keystroke registered), and the answer is taken
12
+ * back out of readline's history — which keeps every submitted line, so the secret would
13
+ * otherwise come back in plain text on the next press of Up.
14
+ *
15
+ * The REPL has its own askSecret with the same guarantees (task #127/#191); this is the version for
16
+ * the one stretch of the program that runs before it.
17
+ */
18
+ import { SKIP_ALL, isEscapeKey } from "./model-setup-ui.mjs";
19
+
20
+ /**
21
+ * One question on `rl`. Options: `secret` (mask + keep out of history), `esc` (Esc → SKIP_ALL).
22
+ * `style` decorates the label (the accent colour). Resolves with the answer string, or SKIP_ALL.
23
+ */
24
+ export function askOn(rl, q, { secret = false, esc = false, style = (s) => s, input = process.stdin, output = process.stdout } = {}) {
25
+ return new Promise((resolve) => {
26
+ const shown = style(q);
27
+ const ac = new AbortController();
28
+ const origWrite = rl._writeToOutput;
29
+ const origRefresh = rl._refreshLine;
30
+ let done = false;
31
+
32
+ const finish = (value) => {
33
+ if (done) return;
34
+ done = true;
35
+ input.removeListener?.("keypress", onKey);
36
+ if (secret) {
37
+ rl._writeToOutput = origWrite;
38
+ rl._refreshLine = origRefresh;
39
+ if (Array.isArray(rl.history) && value && rl.history[0] === value) rl.history.shift();
40
+ rl.historyIndex = -1;
41
+ }
42
+ // A muted Enter printed no newline, and an Esc left the caret on the prompt's row.
43
+ if (secret || value === SKIP_ALL) output.write("\n");
44
+ resolve(value);
45
+ };
46
+
47
+ // Runs AFTER readline's own keypress handler (registered when rl was created), so rl.line is
48
+ // already the updated text when the bullets are drawn.
49
+ function onKey(_s, key) {
50
+ if (done) return;
51
+ if (esc && isEscapeKey(key)) {
52
+ ac.abort();
53
+ // The cancelled question leaves what was typed in rl.line; the next question must not
54
+ // start with it.
55
+ rl.line = "";
56
+ rl.cursor = 0;
57
+ finish(SKIP_ALL);
58
+ return;
59
+ }
60
+ if (secret && output.isTTY) {
61
+ output.write(`\r\x1b[2K${shown}${"•".repeat(String(rl.line ?? "").length)}`);
62
+ }
63
+ }
64
+
65
+ input.on?.("keypress", onKey);
66
+ // question() draws the label synchronously — mute only AFTER, or the label is muted too.
67
+ rl.question(shown, { signal: ac.signal }, (a) => finish(String(a ?? "")));
68
+ if (secret) {
69
+ rl._writeToOutput = () => {};
70
+ rl._refreshLine = () => {};
71
+ }
72
+ });
73
+ }
74
+
75
+ /** The three readers setup takes: plain, Esc-aware (model questions), masked (keys, passwords). */
76
+ export function setupPrompts(rl, { style = (s) => s, input = process.stdin, output = process.stdout } = {}) {
77
+ return {
78
+ ask: (q) => askOn(rl, q, { style, input, output }),
79
+ askStep: (q) => askOn(rl, q, { esc: true, style, input, output }),
80
+ askSecret: (q) => askOn(rl, q, { secret: true, esc: true, style, input, output }),
81
+ };
82
+ }
@@ -468,6 +468,27 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
468
468
  * `identity` is optional so callers that have nothing to put there (and the tests that predate
469
469
  * it) keep producing the same rows as before.
470
470
  */
471
+ /**
472
+ * HOW TO PASTE, for this platform (issue #67). Two different things share the word: TEXT is pasted
473
+ * by the terminal app (gu only sees the result), an IMAGE by gu's own keys (#284).
474
+ *
475
+ * macOS text ⌘V image ctrl+v — a terminal's ⌘V sends nothing for an image
476
+ * Windows text ctrl+v image alt+v — Windows Terminal keeps ctrl+v for itself
477
+ * Linux text ctrl+shift+v image ctrl+v
478
+ *
479
+ * The image half only when the coder can take one (a Claude coder); offering a key that then
480
+ * answers "images need a Claude coder" would be a hint that leads to a warning.
481
+ */
482
+ export function pasteHint({ platform = process.platform, images = false } = {}) {
483
+ const text = platform === "darwin" ? "⌘V" : platform === "win32" ? "ctrl+v" : "ctrl+shift+v";
484
+ if (!images) return `${text} to paste`;
485
+ const image = platform === "win32" ? "alt+v" : "ctrl+v";
486
+ return `${text} paste · ${image} image`;
487
+ }
488
+
489
+ /** At least this many spaces between the mode text and the hint, or the hint is left out. */
490
+ const HINT_MIN_GAP = 3;
491
+
471
492
  export function buildFooterRows({
472
493
  width,
473
494
  gutter = " ",
@@ -475,16 +496,25 @@ export function buildFooterRows({
475
496
  identity = "",
476
497
  token = "",
477
498
  mode = "",
499
+ // Right-aligned on the mode row (issue #67), dim. DROPPED WHOLE when it does not fit: every
500
+ // footer row must stay one physical row (#109/#132/#138), and half a key name is worse than none.
501
+ modeHint = "",
478
502
  notice = "",
479
503
  missing = "",
480
504
  }) {
481
505
  const w = Math.max(20, width || 80);
506
+ const modeLeft = gutter + mode;
507
+ const hintGap = modeHint ? w - 1 - visibleLen(modeLeft) - visibleLen(modeHint) : -1;
508
+ const modeRow =
509
+ hintGap >= HINT_MIN_GAP
510
+ ? modeLeft + " ".repeat(hintGap) + dim(modeHint)
511
+ : clipVisible(modeLeft, w - 1);
482
512
  return [
483
513
  dim("─".repeat(w)),
484
514
  ...(rag ? [clipVisible(gutter + rag, w - 1)] : []),
485
515
  ...(identity ? [clipVisible(gutter + identity, w - 1)] : []),
486
516
  clipVisible(gutter + token, w - 1),
487
- clipVisible(gutter + mode, w - 1),
517
+ modeRow,
488
518
  // A passing notice (the image-paste warning, #54) gets its OWN row, under the mode row — it
489
519
  // used to be appended to that row and was clipped at the right edge ("…pick one with /mo").
490
520
  // Present only while it shows; paintBar re-cuts the frozen zone when the count changes.