@tiens.nguyen/gu-cli 1.0.722 → 1.0.723

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/add-server.mjs CHANGED
@@ -14,7 +14,10 @@
14
14
  * server kind ("dialect": an Ollama's /api/tags vs an OpenAI-compatible /v1/models) lives in
15
15
  * serverTarget() and listRequest().
16
16
  */
17
- import { defaultProviderUrl, ensureV1Root, hostLocality, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
17
+ import { defaultProviderUrl, ensureV1Root, hostLocality, isCancelWord, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
18
+
19
+ // Re-exported: the add-server screen's own name for the rule every URL prompt shares.
20
+ export { isCancelWord };
18
21
  import { agentModelsFromProvider, isLikelyEmbedder, ollamaAgentDefault, ollamaAgentUrl, parseOllamaTags } from "./agent-model-command.mjs";
19
22
 
20
23
  /**
@@ -76,6 +79,9 @@ export function serverRole(role) {
76
79
  export function serverTarget(dialect, typed) {
77
80
  const raw = String(typed ?? "").trim();
78
81
  if (!raw) return { ok: false, why: "empty" };
82
+ // A command word is never a server. Reported: /embed-model showed "on exit" — "exit" typed at
83
+ // the URL prompt had been saved as http://exit, a perfectly valid-looking single-label host.
84
+ if (isCancelWord(raw)) return { ok: false, why: "a command, not a server" };
79
85
  if (/^[a-z][a-z0-9+.-]*:\/\/[^/]*@/i.test(raw)) {
80
86
  return { ok: false, why: "carrying credentials — keys go in the key prompt" };
81
87
  }
@@ -84,9 +84,19 @@ export function hostLocality(hostname) {
84
84
  * addresses" wholesale is meaningless — but 169.254.169.254 and metadata.google.internal are
85
85
  * never a model server, and they are what an SSRF probe asks for first.
86
86
  */
87
+ /**
88
+ * Words that mean "get me out of here" at a prompt, not a hostname. Every URL prompt refuses them
89
+ * as a server. Reported: /embed-model showed "on exit" — "exit" typed at a URL prompt had been
90
+ * saved as http://exit, a perfectly valid-looking single-label host.
91
+ */
92
+ export function isCancelWord(typed) {
93
+ return /^(no|n|skip|-|exit|quit|q|cancel|back|esc)$/i.test(String(typed ?? "").trim());
94
+ }
95
+
87
96
  export function normalizeProviderUrl(raw) {
88
97
  const text = String(raw ?? "").trim();
89
98
  if (!text) return { ok: false, why: "empty" };
99
+ if (isCancelWord(text)) return { ok: false, why: "command" };
90
100
  if (/\s/.test(text)) return { ok: false, why: "spaces" };
91
101
 
92
102
  const hadScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text);
@@ -159,6 +169,8 @@ export function describeUrlProblem(why, typed = "") {
159
169
  return ["type the provider's base URL, e.g. https://api.openai.com/v1"];
160
170
  case "spaces":
161
171
  return ["that has a space in it — a URL cannot"];
172
+ case "command":
173
+ return ["that is a command, not a server — press Esc to cancel, or type the server's URL"];
162
174
  case "scheme":
163
175
  return ["only http:// and https:// addresses work here"];
164
176
  case "credentials":
package/gu-repl.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  * Commands: /exit /model /reset /help Ctrl-C stops following the current run.
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
- import { existsSync, statSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
28
28
  import { spawn } from "node:child_process";
29
29
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
30
30
  // `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
@@ -73,6 +73,10 @@ import {
73
73
  answerBlock,
74
74
  updateRestartNote,
75
75
  describeInstallFailure,
76
+ CLI_UPDATE_SHELL_COMMAND,
77
+ isFreshPublishMiss,
78
+ freshPublishNote,
79
+ lazyModuleNames,
76
80
  inputZoneRows,
77
81
  INPUT_HEAD_ROWS,
78
82
  RAG_STORAGE,
@@ -795,8 +799,8 @@ const COMMANDS = [
795
799
  { name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
796
800
  { name: "/s3", usage: "/s3 [test]", desc: "set up the S3 bucket the RAG index lives in, and check the AWS keys work" },
797
801
  { name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
798
- { name: "/save-settings", usage: "/save-settings [path]", desc: "write this account's coding providers, models and key NAMES to a file (no secrets)" },
799
- { name: "/load-settings", usage: "/load-settings [path]", desc: "read that file back on another machine — asks before it replaces anything" },
802
+ { name: "/save-settings", usage: "/save-settings [name|path]", desc: "write this account's providers, models and key NAMES to a file in your home folder (no secrets) — asks for a name, Enter keeps gu-settings.json" },
803
+ { name: "/load-settings", usage: "/load-settings [name|path]", desc: "read that file back on another machine — picks from the settings files in your home folder, asks before it replaces anything" },
800
804
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
801
805
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
802
806
  { name: "/help", desc: "list these commands" },
@@ -1962,6 +1966,9 @@ function announceCliUpdate() {
1962
1966
  * The picker names the consequence rather than the version, because the version is already on
1963
1967
  * the button and the consequence is what the answer turns on.
1964
1968
  */
1969
+ /** How long to wait before retrying an install that hit a just-published version (ETARGET). */
1970
+ const UPDATE_RETRY_WAIT_MS = 15_000;
1971
+
1965
1972
  async function runUpdate() {
1966
1973
  const { cli, api } = availableUpdates;
1967
1974
  const rows = [];
@@ -2013,8 +2020,17 @@ async function runUpdate() {
2013
2020
  // stderr is captured rather than discarded so the reason can be shown. npm puts the
2014
2021
  // useful line (EPERM, EACCES, ETARGET) there, and "it failed" without it leaves the
2015
2022
  // user with nothing to act on — sudo and "close gu first" are different fixes.
2016
- const r = spawnSync("npm", ["i", "-g", "@tiens.nguyen/gu-cli@latest"],
2017
- { stdio: ["ignore", "ignore", "pipe"], shell: true, encoding: "utf8" });
2023
+ const install = () => spawnSync(CLI_UPDATE_SHELL_COMMAND,
2024
+ { stdio: ["ignore", "ignore", "pipe"], shell: true, encoding: "utf8" });
2025
+ let r = install();
2026
+ // JUST PUBLISHED: the update check sees a new version the moment it lands, npm's cache and
2027
+ // CDN a few minutes later — ETARGET in between. Wait once and retry before calling it a
2028
+ // failure; if npm still has not caught up, say THAT, not a raw npm error.
2029
+ if (isFreshPublishMiss(r)) {
2030
+ await new Promise((res) => setTimeout(res, UPDATE_RETRY_WAIT_MS));
2031
+ r = install();
2032
+ if (isFreshPublishMiss(r)) throw new Error(freshPublishNote(cli));
2033
+ }
2018
2034
  const failed = describeInstallFailure(r);
2019
2035
  if (failed) throw new Error(failed);
2020
2036
  });
@@ -5645,7 +5661,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
5645
5661
  console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
5646
5662
  for (let attempt = 0; attempt < 3 && !target; attempt++) {
5647
5663
  const typed = await ask(" server URL: ");
5648
- if (typed === null || /^(no|n|skip|-)$/i.test(typed.trim())) break;
5664
+ if (typed === null || as.isCancelWord(typed)) break;
5649
5665
  const raw = typed.trim() || dflt;
5650
5666
  if (!raw) break;
5651
5667
  const t = as.serverTarget(dialect, raw);
@@ -6133,20 +6149,31 @@ async function chooseModel() {
6133
6149
  * NOT THE CURRENT CHOICE. Which model this account codes with stays as it is on the machine being
6134
6150
  * loaded into: a file that moves your models should not quietly move your coder too.
6135
6151
  */
6136
- const SETTINGS_FILE_DEFAULT = join(homedir(), "gu-settings.json");
6137
-
6138
- /** The path a command was given, or the default. Quotes stripped: people paste paths with them. */
6139
- function settingsPathFrom(line, word) {
6140
- const typed = line.slice(word.length).trim().replace(/^['"]|['"]$/g, "");
6141
- if (!typed) return SETTINGS_FILE_DEFAULT;
6142
- const expanded = typed.startsWith("~/") ? join(homedir(), typed.slice(2)) : typed;
6143
- return resolve(expanded);
6152
+ /** What followed the command word, or "" — the name/path rules are settingsFilePath's. */
6153
+ function settingsArg(line, word) {
6154
+ return line.slice(word.length).trim();
6144
6155
  }
6145
6156
 
6146
6157
  async function runSaveSettingsCommand(line) {
6147
6158
  const st = await import("./settings-transfer.mjs");
6148
- const file = settingsPathFrom(line, "/save-settings");
6159
+ const home = homedir();
6149
6160
  console.log("");
6161
+ // THE FILE NAME, IN THE DEFAULT FOLDER. The home directory is where it goes; all anyone should
6162
+ // have to type is a name — and Enter keeps gu-settings.json, as before.
6163
+ let typed = settingsArg(line, "/save-settings");
6164
+ if (!typed) {
6165
+ const answer = await ask(` file name in ${home} [${st.DEFAULT_SETTINGS_NAME}]: `);
6166
+ if (answer === null) {
6167
+ console.log(GUTTER + dim(" (cancelled — nothing was written)\n"));
6168
+ return;
6169
+ }
6170
+ typed = answer.trim();
6171
+ }
6172
+ let file = st.settingsFilePath(typed, home);
6173
+ if (!file) {
6174
+ console.log(GUTTER + dim(" (cancelled — nothing was written)\n"));
6175
+ return;
6176
+ }
6150
6177
  const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6151
6178
  if (!settings) {
6152
6179
  console.log(GUTTER + red(` couldn't reach the API at ${apiBase} — nothing was written.\n`));
@@ -6154,18 +6181,23 @@ async function runSaveSettingsCommand(line) {
6154
6181
  }
6155
6182
  const keys = (await fetchCodingKeys()) ?? [];
6156
6183
  const doc = st.buildTransfer({ settings, keys, host: hostname() });
6157
- if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name) {
6158
- console.log(GUTTER + dim(" this account has no coding providers and no agent model set — nothing to save.\n"));
6184
+ if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name && !doc.embedModel.url && !doc.embedModel.name) {
6185
+ console.log(GUTTER + dim(" this account has no coding providers, agent model or embedding model set — nothing to save.\n"));
6159
6186
  return;
6160
6187
  }
6161
- // ASK BEFORE OVERWRITING. The default path is one everybody shares, so the second machine to
6162
- // save would otherwise eat the first machine's file without a word.
6163
- if (existsSync(file)) {
6164
- const ok = (await ask(`${file} exists — overwrite it? [y/N] `)).trim().toLowerCase();
6165
- if (ok !== "y" && ok !== "yes") {
6188
+ // ASK BEFORE OVERWRITING — and let a NEW NAME be the answer. The default path is one everybody
6189
+ // shares, so the second machine to save would otherwise eat the first machine's file; "no" alone
6190
+ // left nowhere to go but typing the whole command again with a path.
6191
+ while (existsSync(file)) {
6192
+ const answer = await ask(` ${file} exists — overwrite it? [y/N], or type a new name: `);
6193
+ const a = String(answer ?? "").trim();
6194
+ if (/^(y|yes)$/i.test(a)) break;
6195
+ const next = a && !/^(n|no)$/i.test(a) ? st.settingsFilePath(a, home) : "";
6196
+ if (!next) {
6166
6197
  console.log(GUTTER + dim(" left it alone.\n"));
6167
6198
  return;
6168
6199
  }
6200
+ file = next;
6169
6201
  }
6170
6202
  try {
6171
6203
  // 0600: it names your providers and your key names. Not secret, but nobody else's business.
@@ -6174,19 +6206,36 @@ async function runSaveSettingsCommand(line) {
6174
6206
  console.log(GUTTER + red(` could not write ${file}: ${e instanceof Error ? e.message : e}\n`));
6175
6207
  return;
6176
6208
  }
6177
- const models = doc.codingBackends.reduce((n, b) => n + b.models.length, 0);
6178
6209
  console.log(GUTTER + accent(` saved to ${file}`));
6179
- console.log(
6180
- GUTTER +
6181
- dim(` ${doc.codingBackends.length} provider${doc.codingBackends.length === 1 ? "" : "s"}, ${models} model${models === 1 ? "" : "s"}, ${doc.keys.length} key name${doc.keys.length === 1 ? "" : "s"} — no secrets.`),
6182
- );
6210
+ // Counted by the tested rule — the default model included (it once read "0 models").
6211
+ console.log(GUTTER + dim(` ${st.transferSummary(doc)}`));
6183
6212
  console.log(GUTTER + dim(" load it on the other machine with /load-settings " + file + "\n"));
6184
6213
  }
6185
6214
 
6186
6215
  async function runLoadSettingsCommand(line) {
6187
6216
  const st = await import("./settings-transfer.mjs");
6188
- const file = settingsPathFrom(line, "/load-settings");
6217
+ const home = homedir();
6189
6218
  console.log("");
6219
+ // A NAME IN THE DEFAULT FOLDER, like /save-settings. With none given and more than one settings
6220
+ // file there, pick from them rather than silently taking gu-settings.json.
6221
+ let typed = settingsArg(line, "/load-settings");
6222
+ if (!typed) {
6223
+ const found = st.settingsFilesIn(listJsonFiles(home));
6224
+ if (found.length > 1) {
6225
+ console.log(dim(` Settings files in ${home} (${PICK_HINT}):`));
6226
+ const i = await pickFromList(found, 0);
6227
+ if (i < 0) {
6228
+ console.log(GUTTER + dim(" (cancelled — nothing changed)\n"));
6229
+ return;
6230
+ }
6231
+ typed = found[i];
6232
+ }
6233
+ }
6234
+ const file = st.settingsFilePath(typed, home);
6235
+ if (!file) {
6236
+ console.log(GUTTER + dim(" (cancelled — nothing changed)\n"));
6237
+ return;
6238
+ }
6190
6239
  let text;
6191
6240
  try {
6192
6241
  text = readFileSync(file, "utf8");
@@ -6231,16 +6280,16 @@ async function runLoadSettingsCommand(line) {
6231
6280
  console.log(GUTTER + red(" the API refused the settings — nothing was changed.\n"));
6232
6281
  return;
6233
6282
  }
6234
- console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}.`));
6283
+ console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}${patch.ragEmbedUrl || patch.ragEmbedModel ? ", the embedding model" : ""}.`));
6235
6284
 
6236
6285
  // THE KEYS, AFTER the settings — each needs the row it belongs to, and the rows only exist
6237
6286
  // once the API has minted their ids.
6238
6287
  const fresh = (await fetchWorkerSettings()) ?? {};
6239
- const rows = Array.isArray(fresh.agentCodingBackends) ? fresh.agentCodingBackends : [];
6240
- const rowIdForUrl = (url) => {
6241
- const hit = rows.find((r) => String(r?.url ?? "").trim().toLowerCase() === String(url).trim().toLowerCase());
6242
- return hit?.id ?? "";
6243
- };
6288
+ const ps = await import("./coding-provider-setup.mjs");
6289
+ const rows = ps.existingBackendRows(fresh);
6290
+ // One server however it is spelled (task #245) — a raw compare missed "…/v1" and saved the key
6291
+ // tied to NO server, where it can never be used.
6292
+ const rowIdForUrl = (url) => ps.providerRowForUrl(rows, url)?.id ?? "";
6244
6293
  for (const k of plan.missingKeys) {
6245
6294
  const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
6246
6295
  const typed = k.key || (await ask(` key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)).trim();
@@ -6248,12 +6297,42 @@ async function runLoadSettingsCommand(line) {
6248
6297
  console.log(GUTTER + dim(` skipped ${k.name} — add it later with ctrl+k.`));
6249
6298
  continue;
6250
6299
  }
6251
- const id = await addCodingKey(k.name, typed, k.backendUrl ? rowIdForUrl(k.backendUrl) : "");
6300
+ const rowId = k.backendUrl ? rowIdForUrl(k.backendUrl) : "";
6301
+ const id = await addCodingKey(k.name, typed, rowId);
6252
6302
  console.log(GUTTER + (id ? accent(` added ${k.name}.`) : red(` could not add ${k.name}.`)));
6303
+ // The key that was its server's DEFAULT on the saving machine is the default here too.
6304
+ if (id && rowId && k.default) await setServerDefaultKey(rowId, id);
6253
6305
  }
6254
6306
  console.log(GUTTER + dim(" pick a coding model with /model.\n"));
6255
6307
  }
6256
6308
 
6309
+ /** The .json files directly in a folder, with enough of each to recognise a settings file. */
6310
+ function listJsonFiles(dir) {
6311
+ try {
6312
+ return readdirSync(dir)
6313
+ .filter((n) => /\.json$/i.test(n))
6314
+ .slice(0, 200)
6315
+ .map((name) => {
6316
+ try {
6317
+ const p = join(dir, name);
6318
+ const st = statSync(p);
6319
+ // The marker is near the top; 4 KB is plenty and keeps a huge JSON from being read whole.
6320
+ if (!st.isFile() || st.size > 5_000_000) return null;
6321
+ const fd = openSync(p, "r");
6322
+ const buf = Buffer.alloc(Math.min(4096, st.size));
6323
+ readSync(fd, buf, 0, buf.length, 0);
6324
+ closeSync(fd);
6325
+ return { name, text: buf.toString("utf8"), mtimeMs: st.mtimeMs };
6326
+ } catch {
6327
+ return null;
6328
+ }
6329
+ })
6330
+ .filter(Boolean);
6331
+ } catch {
6332
+ return [];
6333
+ }
6334
+ }
6335
+
6257
6336
  async function fetchWorkerSettings() {
6258
6337
  try {
6259
6338
  const r = await fetch(`${apiBase}/api/worker/settings`, {
@@ -9925,7 +10004,35 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
9925
10004
  }
9926
10005
 
9927
10006
  // ---------- REPL ----------
10007
+ /**
10008
+ * LOAD EVERY LAZILY-IMPORTED MODULE NOW, so this process never reads one from disk later.
10009
+ *
10010
+ * REPORTED: "✓ gu 1.0.722 installed — Then restart gu", then /embed-model in the SAME terminal
10011
+ * crashed it with ERR_MODULE_NOT_FOUND …/ollama-host-snippets.mjs. The running process was still
10012
+ * 1.0.721; its /embed-model lazily imported ollama-host-snippets.mjs, and npm had just
10013
+ * replaced the package directory with 1.0.722, which no longer ships that file. A module that
10014
+ * still EXISTS is worse, not better: the old REPL silently runs against the new helper, two
10015
+ * versions mixed in one process.
10016
+ *
10017
+ * The ESM loader caches a module by its resolved URL, so once each one is loaded here every later
10018
+ * lazy import of that name gets THIS version back without touching the disk — whatever an in-app update
10019
+ * wrote there. The list is read from this file's own source, so a new lazy import is covered
10020
+ * without anyone remembering to add it. gu-cli.mjs is excluded: importing it RUNS the CLI.
10021
+ * Fire-and-forget: each import is ~5 ms, verified side-effect free (no output, no files).
10022
+ */
10023
+ function preloadLazyModules() {
10024
+ let text = "";
10025
+ try {
10026
+ text = readFileSync(fileURLToPath(import.meta.url), "utf8");
10027
+ } catch {
10028
+ return;
10029
+ }
10030
+ for (const name of lazyModuleNames(text)) import(`./${name}`).catch(() => {});
10031
+ }
10032
+
10033
+
9928
10034
  async function main() {
10035
+ preloadLazyModules();
9929
10036
  // The startup banner (logo + status facts) is printed AFTER the payload fetch below, once the
9930
10037
  // model/coder/RAG are known — see renderBanner (task #131). Nothing prints here so the banner
9931
10038
  // lands as one clean block.
package/ollama-setup.mjs CHANGED
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { isLikelyEmbedder } from "./agent-model-command.mjs";
22
22
  import { EMBED_CHOICES } from "./embed-model-command.mjs";
23
+ import { isCancelWord } from "./coding-provider-setup.mjs";
23
24
 
24
25
  /** Ollama's native port, used when someone types a bare host or an IP with no port. */
25
26
  export const OLLAMA_DEFAULT_PORT = 11434;
@@ -190,6 +191,8 @@ export function describeNotReady(host, missing) {
190
191
  export function normalizeOllamaUrl(raw) {
191
192
  const text = String(raw ?? "").trim();
192
193
  if (!text) return null;
194
+ // "exit", "quit", "cancel"… are commands, never hosts (they were once saved as http://exit).
195
+ if (isCancelWord(text)) return null;
193
196
  // A path, a query, or whitespace inside means this is not a host we can reason about.
194
197
  if (/\s/.test(text)) return null;
195
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.722",
3
+ "version": "1.0.723",
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",
@@ -29,13 +29,100 @@
29
29
  * your models should not also decide which one you are coding with.
30
30
  */
31
31
 
32
- /** Bumped only when an older gu could not read a newer file correctly. */
32
+ import { basename, extname, isAbsolute, join, resolve } from "node:path";
33
+ import { ensureV1Root, isCancelWord, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
34
+
35
+ /** The file name used when none is given — in the default folder (the home directory). */
36
+ export const DEFAULT_SETTINGS_NAME = "gu-settings.json";
37
+
38
+ /**
39
+ * What was typed → the file to use. The DEFAULT FOLDER is the home directory, so a bare NAME is
40
+ * all anyone has to type ("work" → ~/work.json); a real path still works as one.
41
+ * "" → ~/gu-settings.json
42
+ * "work" → ~/work.json (".json" added when there is no extension)
43
+ * "work.json" → ~/work.json
44
+ * "~/dir/x.json" → ~/dir/x.json
45
+ * "./x.json", "/abs" → that path exactly (relative ones from the current folder, as a shell would)
46
+ * Quotes around the whole thing are stripped — people paste paths with them. Returns "" for a
47
+ * command word ("exit", "cancel", …), which the caller treats as a cancel.
48
+ */
49
+ export function settingsFilePath(typed, home) {
50
+ const t = String(typed ?? "").trim().replace(/^['"]|['"]$/g, "").trim();
51
+ if (!t) return join(home, DEFAULT_SETTINGS_NAME);
52
+ if (isCancelWord(t)) return "";
53
+ if (t === "~") return join(home, DEFAULT_SETTINGS_NAME);
54
+ if (t.startsWith("~/")) return withJson(join(home, t.slice(2)));
55
+ // An explicit path is taken exactly as typed — no ".json" guessed onto a name someone spelled out.
56
+ if (isAbsolute(t) || t.startsWith("./") || t.startsWith("../") || /[\\/]/.test(t)) return resolve(t);
57
+ return withJson(join(home, t));
58
+ }
59
+
60
+ const withJson = (p) => (extname(basename(p)) ? p : `${p}.json`);
61
+
62
+ /**
63
+ * The settings files in the default folder, newest first — what /load-settings offers when it is
64
+ * given no name. Only files that ARE gu settings files (the "gu": "settings" marker), so a
65
+ * package.json in the home directory is never offered. `entries` = [{ name, text, mtimeMs }].
66
+ */
67
+ export function settingsFilesIn(entries) {
68
+ return (Array.isArray(entries) ? entries : [])
69
+ .filter((e) => e && /\.json$/i.test(e.name) && /"gu"\s*:\s*"settings"/.test(String(e.text ?? "")))
70
+ .sort((a, b) => (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0))
71
+ .map((e) => e.name);
72
+ }
73
+
74
+ /**
75
+ * Bumped only when an older gu could not read a newer file correctly. Task #245 ADDED optional
76
+ * fields (embedModel, keys[].default) — an older gu ignores them and still loads the rest, so the
77
+ * version stays 1.
78
+ */
33
79
  export const FILE_VERSION = 1;
34
80
 
35
81
  /** The kinds the API's own type allows (CodingBackendKind). */
36
82
  const KINDS = ["", "ollama", "openai", "local"];
37
83
 
38
84
  const isHttpUrl = (v) => typeof v === "string" && /^https?:\/\/\S+$/i.test(v.trim());
85
+
86
+ /** Two spellings of one server are one server — the rule the API and providerRowForUrl use. */
87
+ const sameServerUrl = (a, b) =>
88
+ Boolean(clean(a) && clean(b)) && ensureV1Root(clean(a)).toLowerCase() === ensureV1Root(clean(b)).toLowerCase();
89
+
90
+ /** A server's models, the default counted once: what "N models" means everywhere a person reads it. */
91
+ export function modelsOf(b) {
92
+ return [...new Set([clean(b?.defaultModel), ...(Array.isArray(b?.models) ? b.models.map(clean) : [])].filter(Boolean))];
93
+ }
94
+
95
+ /**
96
+ * The line /save-settings prints. REPORTED: "4 providers, 0 models" for four providers that each
97
+ * had a model — the count read only the EXTRA models and never the default one.
98
+ */
99
+ export function transferSummary(doc) {
100
+ const n = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
101
+ const backends = Array.isArray(doc?.codingBackends) ? doc.codingBackends : [];
102
+ const models = backends.reduce((sum, b) => sum + modelsOf(b).length, 0);
103
+ const parts = [n(backends.length, "provider"), n(models, "model"), n(Array.isArray(doc?.keys) ? doc.keys.length : 0, "key name")];
104
+ if (doc?.agentModel?.name || doc?.agentModel?.url) parts.push("the agent model");
105
+ if (doc?.embedModel?.name || doc?.embedModel?.url) parts.push("the embedding model");
106
+ return `${parts.join(", ")} — no secrets.`;
107
+ }
108
+
109
+ /**
110
+ * A server's models within MAX_MODELS_PER_SERVER (task #245) — the API refuses a row that grows
111
+ * past it, and a file saved before the cap may hold more. The default stays; extras are kept in
112
+ * order up to the cap, or up to what this account's row ALREADY holds (a row stored before the cap
113
+ * keeps what it has). Returns { defaultModel, models, dropped }.
114
+ */
115
+ export function capServerModels(b, keepAtLeast = 0) {
116
+ const all = modelsOf(b);
117
+ const defaultModel = clean(b?.defaultModel) || "";
118
+ const limit = Math.max(MAX_MODELS_PER_SERVER, keepAtLeast);
119
+ const kept = all.slice(0, limit);
120
+ return {
121
+ defaultModel,
122
+ models: kept.filter((m) => m !== defaultModel),
123
+ dropped: all.slice(limit),
124
+ };
125
+ }
39
126
  const isStr = (v) => typeof v === "string";
40
127
  const clean = (v) => (typeof v === "string" ? v.trim() : "");
41
128
 
@@ -64,6 +151,9 @@ export function buildTransfer({ settings = {}, keys = [], now = new Date(), host
64
151
  name: clean(k.name),
65
152
  tail: clean(k.tail),
66
153
  ...(urlForRowId.get(k.backendId) ? { backendUrl: urlForRowId.get(k.backendId) } : {}),
154
+ // Task #245: which key is its server's DEFAULT, so loading makes the same one the default
155
+ // (otherwise it would be whichever key happened to be added first).
156
+ ...(k.default === true ? { default: true } : {}),
67
157
  }));
68
158
  return {
69
159
  gu: "settings",
@@ -74,6 +164,8 @@ export function buildTransfer({ settings = {}, keys = [], now = new Date(), host
74
164
  ...(host ? { savedOn: host } : {}),
75
165
  codingBackends: backends,
76
166
  agentModel: { url: clean(settings.agentModelUrl), name: clean(settings.agentModelName) },
167
+ // Task #245: the third role. Its KEY travels like the others — by name, bound to its server.
168
+ embedModel: { url: clean(settings.ragEmbedUrl), name: clean(settings.ragEmbedModel) },
77
169
  keys: keyList,
78
170
  };
79
171
  }
@@ -150,6 +242,16 @@ export function validateTransfer(text) {
150
242
  if (am?.name !== undefined && !isStr(am.name)) {
151
243
  return { ok: false, problem: "agentModel.name must be text." };
152
244
  }
245
+ const em = doc.embedModel;
246
+ if (em !== undefined && (!em || typeof em !== "object" || Array.isArray(em))) {
247
+ return { ok: false, problem: "embedModel must be an object with a url and a name." };
248
+ }
249
+ if (em?.url !== undefined && em.url !== "" && !isHttpUrl(em.url)) {
250
+ return { ok: false, problem: `embedModel.url is not http(s): ${JSON.stringify(em.url)}.` };
251
+ }
252
+ if (em?.name !== undefined && !isStr(em.name)) {
253
+ return { ok: false, problem: "embedModel.name must be text." };
254
+ }
153
255
  if (doc.keys !== undefined && !Array.isArray(doc.keys)) {
154
256
  return { ok: false, problem: "keys must be a list." };
155
257
  }
@@ -159,6 +261,9 @@ export function validateTransfer(text) {
159
261
  if (!k || typeof k !== "object" || Array.isArray(k)) return { ok: false, problem: `${at} is not an object.` };
160
262
  if (!clean(k.name)) return { ok: false, problem: `${at} has no name — a key is identified by its name.` };
161
263
  if (k.key !== undefined && !isStr(k.key)) return { ok: false, problem: `${at}.key must be text.` };
264
+ if (k.default !== undefined && typeof k.default !== "boolean") {
265
+ return { ok: false, problem: `${at}.default must be true or false.` };
266
+ }
162
267
  if (k.backendUrl !== undefined && k.backendUrl !== "" && !isHttpUrl(k.backendUrl)) {
163
268
  return { ok: false, problem: `${at}.backendUrl is not http(s): ${JSON.stringify(k.backendUrl)}.` };
164
269
  }
@@ -166,13 +271,21 @@ export function validateTransfer(text) {
166
271
  name: clean(k.name),
167
272
  tail: clean(k.tail),
168
273
  ...(clean(k.backendUrl) ? { backendUrl: clean(k.backendUrl) } : {}),
274
+ ...(k.default === true ? { default: true } : {}),
169
275
  // Present only when a person put it there by hand; gu never writes one.
170
276
  ...(clean(k.key) ? { key: clean(k.key) } : {}),
171
277
  });
172
278
  }
173
279
  return {
174
280
  ok: true,
175
- value: { backends, agentModel: { url: clean(am?.url), name: clean(am?.name) }, keys, savedAt: clean(doc.savedAt), savedOn: clean(doc.savedOn) },
281
+ value: {
282
+ backends,
283
+ agentModel: { url: clean(am?.url), name: clean(am?.name) },
284
+ embedModel: { url: clean(em?.url), name: clean(em?.name) },
285
+ keys,
286
+ savedAt: clean(doc.savedAt),
287
+ savedOn: clean(doc.savedOn),
288
+ },
176
289
  // Loading a file someone hand-edited to carry live credentials is allowed, but it is not
177
290
  // allowed to be quiet: that file is now a secret on disk.
178
291
  carriesSecrets: keys.some((k) => k.key),
@@ -190,8 +303,19 @@ export function validateTransfer(text) {
190
303
  export function planLoad(file, current = {}) {
191
304
  const currentRows = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
192
305
  const currentKeys = Array.isArray(current.keys) ? current.keys : [];
193
- const have = new Set(currentKeys.map((k) => clean(k.name).toLowerCase()).filter(Boolean));
194
- const missingKeys = file.keys.filter((k) => !have.has(k.name.toLowerCase()) && !k.key);
306
+ // A KEY BELONGS TO ITS SERVER (task #245): "work" on api.openai.com says nothing about a "work"
307
+ // key for ollama.com. So a same-named key counts as present when it is ON THE SAME SERVER, or
308
+ // tied to NO server (a pre-#245 key — the resolver still falls back to it, so asking for it
309
+ // again would be asking for a key the account already uses). The same name on ANOTHER server
310
+ // is a different key.
311
+ const urlOfRow = (id) => clean(currentRows.find((r) => r?.id === id)?.url);
312
+ const present = (k) =>
313
+ currentKeys.some((c) => {
314
+ if (clean(c.name).toLowerCase() !== k.name.toLowerCase()) return false;
315
+ if (!c.backendId) return true;
316
+ return Boolean(k.backendUrl) && sameServerUrl(urlOfRow(c.backendId), k.backendUrl);
317
+ });
318
+ const missingKeys = file.keys.filter((k) => !k.key && !present(k));
195
319
  const lines = [];
196
320
  lines.push(
197
321
  file.backends.length
@@ -199,12 +323,25 @@ export function planLoad(file, current = {}) {
199
323
  : "no coding providers in the file — the ones on this account stay as they are.",
200
324
  );
201
325
  for (const b of file.backends) {
202
- const models = b.models.length ? `${b.models.length} model${b.models.length === 1 ? "" : "s"}` : "no models";
326
+ const n = modelsOf(b).length;
327
+ const models = n ? `${n} model${n === 1 ? "" : "s"}` : "no models";
203
328
  lines.push(` ${b.kind || "?"} ${b.url || "(no url)"} ${models}${b.defaultModel ? `, default ${b.defaultModel}` : ""}${b.enabled ? "" : " (disabled)"}`);
329
+ const mine = currentRows.find((r) => sameServerUrl(r?.url, b.url));
330
+ const capped = capServerModels(b, mine ? modelsOf(mine).length : 0);
331
+ if (capped.dropped.length) {
332
+ lines.push(` a server offers at most ${MAX_MODELS_PER_SERVER} models — not loading ${capped.dropped.join(", ")}`);
333
+ }
204
334
  }
205
335
  if (file.agentModel.url || file.agentModel.name) {
206
336
  lines.push(`agent model: ${file.agentModel.name || "(server default)"}${file.agentModel.url ? ` at ${file.agentModel.url}` : ""}`);
207
337
  }
338
+ if (file.embedModel?.url || file.embedModel?.name) {
339
+ const same = sameServerUrl(file.embedModel.url, current.ragEmbedUrl) && file.embedModel.name === clean(current.ragEmbedModel);
340
+ lines.push(`embedding model: ${file.embedModel.name || "(unchanged)"}${file.embedModel.url ? ` at ${file.embedModel.url}` : ""}${same ? " (already set)" : ""}`);
341
+ if (!same && file.embedModel.name && file.embedModel.name !== clean(current.ragEmbedModel)) {
342
+ lines.push(" a different embedder: every knowledge base rebuilds on its next search.");
343
+ }
344
+ }
208
345
  if (missingKeys.length) {
209
346
  lines.push(`${missingKeys.length} API key${missingKeys.length === 1 ? "" : "s"} this account does not have — gu will ask for ${missingKeys.length === 1 ? "it" : "them"} after:`);
210
347
  for (const k of missingKeys) lines.push(` ${k.name}${k.tail ? ` (…${k.tail})` : ""}${k.backendUrl ? ` for ${k.backendUrl}` : ""}`);
@@ -230,24 +367,30 @@ export function planLoad(file, current = {}) {
230
367
  export function toSettingsPatch(file, current = {}) {
231
368
  const patch = {};
232
369
  const existing = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
233
- const idFor = (kind, url) =>
234
- existing.find(
235
- (r) => r?.id && r.kind === kind && clean(r.url).toLowerCase() === clean(url).toLowerCase(),
236
- )?.id ?? "";
370
+ // Matched as ONE SERVER however it is spelled (task #245) — "https://ollama.com" and
371
+ // "https://ollama.com/v1" are the same row, and a raw compare would mint a twin and strand the
372
+ // keys bound to the original.
373
+ const mineFor = (kind, url) => existing.find((r) => r?.id && r.kind === kind && sameServerUrl(r.url, url));
237
374
  if (file.backends.length) {
238
375
  patch.agentCodingBackends = file.backends.map((b) => {
239
- const id = idFor(b.kind, b.url);
376
+ const mine = mineFor(b.kind, b.url);
377
+ const capped = capServerModels(b, mine ? modelsOf(mine).length : 0);
240
378
  return {
241
- ...(id ? { id } : {}),
379
+ ...(mine?.id ? { id: mine.id } : {}),
242
380
  kind: b.kind,
243
381
  url: b.url,
244
- defaultModel: b.defaultModel,
245
- models: b.models,
382
+ defaultModel: capped.defaultModel,
383
+ models: capped.models,
246
384
  enabled: b.enabled,
247
385
  };
248
386
  });
249
387
  }
250
388
  if (file.agentModel.url) patch.agentModelUrl = file.agentModel.url;
251
389
  if (file.agentModel.name) patch.agentModelName = file.agentModel.name;
390
+ if (file.embedModel?.url) patch.ragEmbedUrl = file.embedModel.url;
391
+ if (file.embedModel?.name) patch.ragEmbedModel = file.embedModel.name;
392
+ // The embedder's key is resolved through its server's keys (task #245); the pre-#245
393
+ // account-wide pick would only shadow them.
394
+ if (file.embedModel?.url || file.embedModel?.name) patch.ragEmbedKeyId = "";
252
395
  return patch;
253
396
  }
@@ -1715,6 +1715,46 @@ export function describeInstallFailure({ status = 0, stderr = "", error = null }
1715
1715
  return `npm exited ${status}${why ? ` — ${_clipReason(why)}` : ""}`;
1716
1716
  }
1717
1717
 
1718
+ /**
1719
+ * The command the in-app update RUNS — one string, run through a shell.
1720
+ *
1721
+ * ONE STRING, NOT AN ARGS ARRAY. A shell is needed (Windows finds npm as npm.cmd only through
1722
+ * one), and Node 24 warns DEP0190 whenever args are passed WITH `shell: true` — they are joined,
1723
+ * not escaped — and that warning landed in the middle of the transcript on every update.
1724
+ *
1725
+ * --prefer-online: npm REVALIDATES its cached package document instead of trusting it. The update
1726
+ * check reads registry.npmjs.org/<pkg>/latest, which knows a new version the moment it is
1727
+ * published; `npm install` reads the full document from npm's own cache and CDN, which catch up
1728
+ * minutes later. Without this, a version announced seconds after publishing failed with ETARGET.
1729
+ */
1730
+ export const CLI_UPDATE_SHELL_COMMAND = `${CLI_INSTALL_COMMAND} --prefer-online`;
1731
+
1732
+ /**
1733
+ * Did the install fail only because npm has not caught up with a JUST-published version?
1734
+ * (ETARGET / notarget — "No matching version found".) Worth one retry after a short wait, and
1735
+ * worth saying plainly rather than as a raw npm error: nothing is wrong with the release.
1736
+ */
1737
+ export function isFreshPublishMiss({ status = 0, stderr = "" } = {}) {
1738
+ if (status === 0) return false;
1739
+ return /\b(ETARGET|notarget)\b|No matching version found/i.test(String(stderr ?? ""));
1740
+ }
1741
+
1742
+ /** What to say when npm still has not caught up after the retry. */
1743
+ export function freshPublishNote(version = "") {
1744
+ return `gu ${version || "the new version"} was published moments ago and npm's mirrors are still catching up — ` +
1745
+ "try the update again in a few minutes.";
1746
+ }
1747
+
1748
+ /**
1749
+ * The local modules a source text imports LAZILY (a dynamic import of "./name.mjs"), minus the CLI entry
1750
+ * (importing gu-cli.mjs RUNS the CLI). gu-repl preloads these at startup so an in-app update that
1751
+ * replaces the package on disk can never be read into the still-running old process.
1752
+ */
1753
+ export function lazyModuleNames(text) {
1754
+ return [...new Set([...String(text ?? "").matchAll(/import\("\.\/([\w.-]+\.mjs)"\)/g)].map((m) => m[1]))]
1755
+ .filter((m) => m !== "gu-cli.mjs");
1756
+ }
1757
+
1718
1758
  /** One line, bounded — a reason is for reading, and npm's can carry a whole stack frame. */
1719
1759
  function _clipReason(s, max = 160) {
1720
1760
  const one = String(s).replace(/\s+/g, " ").trim();