@tiens.nguyen/gu-cli 1.0.768 → 1.0.770

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/agent-stop.mjs CHANGED
@@ -41,11 +41,28 @@ export const STOP_GRACE_MS = 3000;
41
41
  * Stop a child process: SIGTERM now, SIGKILL after `graceMs` unless it has exited.
42
42
  * → a function that cancels the pending SIGKILL (call it from the child's `close`).
43
43
  */
44
- export function stopGracefully(child, { graceMs = STOP_GRACE_MS, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
45
- try {
46
- child.kill("SIGTERM");
47
- } catch {
48
- /* already gone */
44
+ export function stopGracefully(child, {
45
+ graceMs = STOP_GRACE_MS,
46
+ setTimer = setTimeout,
47
+ clearTimer = clearTimeout,
48
+ platform = process.platform,
49
+ // WINDOWS HAS NO SIGTERM (task #302): child.kill("SIGTERM") there IS TerminateProcess, which no
50
+ // handler sees — so a stop could land in the middle of the agent writing a file. There, ask
51
+ // first (the agent watches GONEXT_STOP_FILE and stops at a safe point) and kill after the grace.
52
+ requestStop = null,
53
+ } = {}) {
54
+ if (platform === "win32" && typeof requestStop === "function") {
55
+ try {
56
+ requestStop();
57
+ } catch {
58
+ /* could not ask — the kill after the grace still stops it */
59
+ }
60
+ } else {
61
+ try {
62
+ child.kill("SIGTERM");
63
+ } catch {
64
+ /* already gone */
65
+ }
49
66
  }
50
67
  const t = setTimer(() => {
51
68
  if (child.exitCode === null && child.signalCode === null) {
@@ -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
  }