@tiens.nguyen/gu-cli 1.0.763 → 1.0.764

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.
@@ -652,3 +652,159 @@ export function resolveFirstRunMode({ licenseToken, expertAvailable } = {}) {
652
652
  if (expertAvailable) return { ask: false, mode: "client-expert" };
653
653
  return { ask: false, mode: "client" };
654
654
  }
655
+
656
+ /**
657
+ * DOES THIS EVEN LOOK LIKE A GU LICENSE TOKEN? — the cheap, first-step check (task #13).
658
+ *
659
+ * Runs BEFORE any key or network work: a token that fails this is not worth verifying, so the
660
+ * caller can warn and re-ask immediately. Pure, synchronous, never throws.
661
+ *
662
+ * ok:true — trimmed, three parts, `gu1` prefix, and the payload is base64url that
663
+ * parses as a JSON object. Says nothing about the signature or the account.
664
+ * ok:false, why — "empty" | "shape" (not three parts / wrong prefix) | "payload" (the
665
+ * middle part is not decodable JSON). Callers print `why` in the warning.
666
+ *
667
+ * This is deliberately NOT verifyCliToken: that needs the public key and proves the signature;
668
+ * this only answers "is it plausible enough to bother with". validateLicenseToken still runs the
669
+ * full check — this just lets the UI fail fast and re-prompt.
670
+ */
671
+ export function licenseTokenFormat(token) {
672
+ const t = String(token ?? "").trim();
673
+ if (!t) return { ok: false, why: "empty" };
674
+ const parts = t.split(".");
675
+ if (parts.length !== 3 || parts[0] !== "gu1" || !parts[1] || !parts[2]) return { ok: false, why: "shape" };
676
+ try {
677
+ const json = Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
678
+ const payload = JSON.parse(json);
679
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { ok: false, why: "payload" };
680
+ } catch {
681
+ return { ok: false, why: "payload" };
682
+ }
683
+ return { ok: true, why: "ok" };
684
+ }
685
+
686
+ // ---------------------------------------------------------------------------
687
+ // IS THE PRO TOKEN REAL? — checked, not just present (task #12).
688
+ //
689
+ // `--license` used to gate the menu on PRESENCE alone: any non-empty string was "pro". Now a
690
+ // token has to prove itself twice before the menu opens:
691
+ //
692
+ // 1. OFFLINE — the Ed25519 signature is verified against the same public key cli-token.mjs
693
+ // uses. Garbage (`--license foo`) never leaves the machine.
694
+ // 2. LIVE — the token is offered to the hosted API's /api/billing/cli-token/renew, the
695
+ // one call that re-reads the subscription on every request. A token the API will not
696
+ // renew (forged, or for an account that no longer exists) is refused.
697
+ //
698
+ // The token is still NOT stored here — proving it is all this run needs; if the user picks a
699
+ // mode that needs the token again, that path asks/pastes it as before.
700
+ // ---------------------------------------------------------------------------
701
+
702
+ /**
703
+ * One validation attempt. Returns { ok, reason, detail? } — never throws.
704
+ *
705
+ * ok:true — signature verified offline AND the hosted API accepted the token.
706
+ * ok:false, reason: "malformed" | "bad-signature" — failed the offline check (detail = why).
707
+ * ok:false, reason: "rejected" — the API refused it (detail = http-<status>).
708
+ * ok:false, reason: "unverified"— offline check passed but the API could not be reached
709
+ * (detail = timeout | network | no-api | http-5xx | bad-response).
710
+ *
711
+ * `hostedBase` falls back to device-login.mjs DEFAULT_API_BASE when not given; GU_TOKEN_API_BASE
712
+ * (the env the renew loop already honours) wins over both, so staging checks the staging API.
713
+ */
714
+ export async function validateLicenseToken(token, {
715
+ hostedBase,
716
+ fetchImpl = fetch,
717
+ timeoutMs = 8000,
718
+ env = process.env,
719
+ } = {}) {
720
+ const t = String(token ?? "").trim();
721
+ if (!t) return { ok: false, reason: "malformed", detail: "empty" };
722
+ // Format first (task #13): a token that isn't even shaped right never reaches the key check,
723
+ // let alone the network. licenseTokenFormat is the same rule, applied cheap.
724
+ if (!licenseTokenFormat(t).ok) return { ok: false, reason: "malformed", detail: "format" };
725
+ const { verifyCliToken, publicKey } = await import("./cli-token.mjs");
726
+ const offline = verifyCliToken(t, publicKey(env));
727
+ if (!offline.ok) return { ok: false, reason: offline.why === "malformed" ? "malformed" : "bad-signature", detail: offline.why };
728
+ let base = String(hostedBase ?? "").trim() || String(env.GU_TOKEN_API_BASE ?? "").trim();
729
+ if (!base) {
730
+ try {
731
+ ({ DEFAULT_API_BASE: base } = await import("./device-login.mjs"));
732
+ } catch {
733
+ return { ok: false, reason: "unverified", detail: "no-api" };
734
+ }
735
+ }
736
+ base = base.replace(/\/+$/, "");
737
+ try {
738
+ const res = await fetchImpl(`${base}/api/billing/cli-token/renew`, {
739
+ method: "POST",
740
+ headers: { "content-type": "application/json", authorization: `Bearer ${t}` },
741
+ body: "{}",
742
+ signal: AbortSignal.timeout(timeoutMs),
743
+ });
744
+ if (res.status === 401 || res.status === 403) return { ok: false, reason: "rejected", detail: `http-${res.status}` };
745
+ if (!res.ok) return { ok: false, reason: "unverified", detail: `http-${res.status}` };
746
+ const data = await res.json().catch(() => null);
747
+ return typeof data?.token === "string" && data.token
748
+ ? { ok: true }
749
+ : { ok: false, reason: "unverified", detail: "bad-response" };
750
+ } catch (e) {
751
+ return { ok: false, reason: "unverified", detail: e?.name === "TimeoutError" ? "timeout" : "network" };
752
+ }
753
+ }
754
+
755
+ /**
756
+ * What a validation result DOES to the first-run plan — pure, so the policy is a test row:
757
+ *
758
+ * · valid → the menu, exactly as promised.
759
+ * · rejected / bad token → the private default (or cloud): the token was ASKED for and
760
+ * refused, so the caller must SAY it was refused (rejected:true).
761
+ * · unverified → the menu stands: the token proved its signature offline and the soft-licence
762
+ * rule (a network outage never blocks a paid user) applies to the menu too.
763
+ * Callers may show a one-line "couldn't confirm" note.
764
+ */
765
+ export function validateLicenseVerdict({ licenseToken, expertAvailable }, result) {
766
+ const fallback = resolveFirstRunMode({ licenseToken: "", expertAvailable });
767
+ if (result?.ok) return { ask: true, mode: null, rejected: false, unverified: false };
768
+ if (result?.reason === "unverified") return { ask: true, mode: null, rejected: false, unverified: true };
769
+ return { ...fallback, rejected: Boolean(String(licenseToken ?? "").trim()), unverified: false };
770
+ }
771
+
772
+ /**
773
+ * ASK UNTIL THE TOKEN IS REAL, OR THE USER GIVES UP (task #13).
774
+ *
775
+ * The one-shot validate-and-fall-back made an invalid token silently pick private mode for the
776
+ * user. Instead: warn, and let them paste another token. The loop ends three ways:
777
+ *
778
+ * · a token validates (or is unverifiable — soft licence) → { token, result } with ok/!rejected;
779
+ * · empty Enter on the re-ask → the user declines: { token: "", result: null } → the caller
780
+ * falls back to the private default, exactly as a bare `gu` would;
781
+ * · the very first token is already good → returned immediately, no extra prompt.
782
+ *
783
+ * `ask` is the one-question readline helper (gu-repl's ask0/ask). `warn` prints the one-line
784
+ * "that token wasn't accepted" message; injectable so tests stay silent. `validate` is
785
+ * validateLicenseToken by default; injectable for tests. Never throws — a validation that errors
786
+ * is treated as unverifiable, not a crash.
787
+ */
788
+ export async function promptForLicenseToken(ask, initialToken, {
789
+ validate = validateLicenseToken,
790
+ validateOpts = {},
791
+ warn = (msg) => console.log(msg),
792
+ maxTries = 5,
793
+ } = {}) {
794
+ let token = String(initialToken ?? "").trim();
795
+ for (let attempt = 0; attempt < maxTries; attempt += 1) {
796
+ // Format gate first — a malformed token is warned about WITHOUT a network round-trip.
797
+ const fmt = licenseTokenFormat(token);
798
+ if (token && !fmt.ok) {
799
+ warn(` That doesn't look like a gu license token (${fmt.why}). Check you pasted the whole thing.`);
800
+ } else if (token) {
801
+ const result = await validate(token, validateOpts).catch(() => ({ ok: false, reason: "unverified", detail: "error" }));
802
+ if (result.ok || result.reason === "unverified") return { token, result, declined: false };
803
+ warn(` That license token was not accepted (${result.detail ?? result.reason}).`);
804
+ }
805
+ const again = String(await ask(" Paste a pro license token to choose a hosted mode, or press Enter to keep this machine private: ") ?? "").trim();
806
+ if (!again) return { token: "", result: null, declined: true };
807
+ token = again;
808
+ }
809
+ return { token: "", result: null, declined: true };
810
+ }
package/gu-repl.mjs CHANGED
@@ -667,7 +667,7 @@ if (!workerKey || !apiBase) {
667
667
  const {
668
668
  probeMachine, askMode, runClientExpertSetup, loadState, saveState,
669
669
  } = await import("./client-expert-run.mjs");
670
- const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode } =
670
+ const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode, promptForLicenseToken } =
671
671
  await import("./client-expert-setup.mjs");
672
672
  const machine0 = await probeMachine();
673
673
  const gate0 = clientExpertAvailable({ workerEnvExists: false, ...machine0 });
@@ -676,10 +676,28 @@ if (!workerKey || !apiBase) {
676
676
  // nothing shared) and walks the MongoDB/API/models setup straight away. The
677
677
  // three-way menu (hosted client / MLX host / private) is shown only when the run
678
678
  // carries a pro license token: `gu --license <token>`.
679
- const plan0 = resolveFirstRunMode({
680
- licenseToken: parseLicenseFlag(argv),
679
+ const licenseToken0 = parseLicenseFlag(argv);
680
+ let plan0 = resolveFirstRunMode({
681
+ licenseToken: licenseToken0,
681
682
  expertAvailable: gate0.available,
682
683
  });
684
+ if (plan0.ask) {
685
+ // A bad token no longer picks private FOR the user: warn, and let them paste another
686
+ // (task #13). Format is checked first (no network), then offline signature, then the
687
+ // live API. Empty Enter declines → the private default. Unreachable API still opens
688
+ // the menu (soft licence: an outage never blocks a paid user).
689
+ const pick = await promptForLicenseToken(ask0, licenseToken0, {
690
+ validateOpts: {
691
+ hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
692
+ },
693
+ warn: (m) => console.log(dim(`\n${m}`)),
694
+ });
695
+ if (pick.declined || !pick.token) {
696
+ plan0 = resolveFirstRunMode({ licenseToken: "", expertAvailable: gate0.available });
697
+ } else if (pick.result && pick.result.reason === "unverified") {
698
+ console.log(dim("\n Couldn't reach the license server to confirm the token — showing the menu anyway."));
699
+ }
700
+ }
683
701
  let mode0;
684
702
  if (plan0.ask) {
685
703
  mode0 = await askMode(ask0, {
@@ -1481,6 +1499,24 @@ const imageStore = createImageStore();
1481
1499
  // Shown on the bottom row after a refused or failed image paste; clears itself.
1482
1500
  let imageNotice = "";
1483
1501
  let imageNoticeTimer = null;
1502
+
1503
+ // Task #14: SETUP THE USER SKIPPED. In private / Client Expert mode the Agent + embedding
1504
+ // server prompts can now be declined ("No" / empty Enter). That is a real answer, not a
1505
+ // failure — but the machine then has no agent model and/or no embed model, and the agent
1506
+ // silently falls back (agent → coding model) or loses RAG. Say so at the BOTTOM of the screen,
1507
+ // every frame, until a model is chosen. The two warnings are INDEPENDENT: each shows only while
1508
+ // its own model is unset, and choosing that model (/agent-model, /embed-model, /setup, or the
1509
+ // API seeding one) clears only that one.
1510
+ let agentMissing = false; // no agent model chosen → agent falls back to the coding model
1511
+ let embedMissing = false; // no embed model chosen → RAG is off until /embed-model picks one
1512
+ // One footer row; the two halves are independent so a model that IS set is never named.
1513
+ const missingNoticeText = () => {
1514
+ const parts = [];
1515
+ if (agentMissing) parts.push("agent model is not set — using the coding model (/agent-model to choose one)");
1516
+ if (embedMissing) parts.push("no embed model — RAG is off; choose one with /embed-model");
1517
+ return parts.length ? "⚠ " + parts.join(" · ") : "";
1518
+ };
1519
+
1484
1520
  function showImageNotice(text) {
1485
1521
  imageNotice = text;
1486
1522
  if (imageNoticeTimer) clearTimeout(imageNoticeTimer);
@@ -2340,6 +2376,8 @@ const footerRows = () =>
2340
2376
  notice: imageNotice
2341
2377
  ? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
2342
2378
  : "",
2379
+ // Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
2380
+ missing: missingNoticeText() ? red(missingNoticeText()) : "",
2343
2381
  });
2344
2382
  // True only while the MAIN loop's ">> " prompt is waiting for input. The bar belongs to that
2345
2383
  // prompt, so it must never appear under an ask()/askSecret() question or during a turn.
@@ -5301,6 +5339,13 @@ async function reconfigureOllamaServer() {
5301
5339
  role: "coder",
5302
5340
  });
5303
5341
 
5342
+ // Task #14: skipping is a real answer, not a failure — but the bottom bar must keep saying
5343
+ // what it cost. Declining the AGENT + embed server leaves both roles unset → both warnings.
5344
+ if (!agent && !agentOnProvider) {
5345
+ agentMissing = true; // no agent model chosen → the agent falls back to the coding model
5346
+ embedMissing = true; // the embedder lives on the agent server → RAG is off until /embed-model
5347
+ }
5348
+
5304
5349
  if (!agent && !coder) {
5305
5350
  console.log(dim(" nothing set — models stay as they are.\n"));
5306
5351
  return;
@@ -10749,7 +10794,7 @@ async function main() {
10749
10794
  // machine that had been working for months.
10750
10795
  try {
10751
10796
  const { loadState, saveState, askMode } = await import("./client-expert-run.mjs");
10752
- const { clientExpertAvailable, expertPlanPending, shouldAskMode, parseLicenseFlag, resolveFirstRunMode } =
10797
+ const { clientExpertAvailable, expertPlanPending, shouldAskMode, parseLicenseFlag, resolveFirstRunMode, promptForLicenseToken } =
10753
10798
  await import("./client-expert-setup.mjs");
10754
10799
  const st = await loadState();
10755
10800
  const { probeMachine } = await import("./client-expert-run.mjs");
@@ -10766,10 +10811,25 @@ async function main() {
10766
10811
  // Same rule as the first run: the mode menu is a pro-license feature — without
10767
10812
  // `--license <token>` this machine defaults to private (Client Expert) when it can
10768
10813
  // run it, instead of being asked.
10769
- const plan = resolveFirstRunMode({
10770
- licenseToken: parseLicenseFlag(argv),
10814
+ const licenseToken1 = parseLicenseFlag(argv);
10815
+ let plan = resolveFirstRunMode({
10816
+ licenseToken: licenseToken1,
10771
10817
  expertAvailable: gate.available,
10772
10818
  });
10819
+ if (plan.ask) {
10820
+ // Same as the first run: warn and re-ask on a bad token instead of silently picking
10821
+ // private (task #13); empty Enter declines to the private default.
10822
+ const { DEFAULT_API_BASE } = await import("./device-login.mjs");
10823
+ const pick1 = await promptForLicenseToken(ask, licenseToken1, {
10824
+ validateOpts: {
10825
+ hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
10826
+ },
10827
+ warn: (m) => console.log(dim(`\n${m}`)),
10828
+ });
10829
+ if (pick1.declined || !pick1.token) {
10830
+ plan = resolveFirstRunMode({ licenseToken: "", expertAvailable: gate.available });
10831
+ }
10832
+ }
10773
10833
  const chosen = plan.ask
10774
10834
  ? await askMode(ask, {
10775
10835
  expertAvailable: gate.available,
@@ -10969,6 +11029,13 @@ async function main() {
10969
11029
  // #125) when no /model override is active.
10970
11030
  if (p.codingModelId) defaultCodingModel = p.codingModelId;
10971
11031
  if (p.codingBaseURL) defaultCodingUrl = p.codingBaseURL;
11032
+ // Task #14: seed the bottom warnings from the AUTHORITATIVE payload. `agentMissing` is set
11033
+ // by the API when no agent model is configured (and it fell back to the coding model);
11034
+ // `ragEnabled` false means no embed model was chosen. Boot may have already set these from
11035
+ // the local setup answers — OR them in, never clear a local "missing" on a maybe, because
11036
+ // private mode's local API can itself be unconfigured.
11037
+ if (p.agentMissing === true) agentMissing = true;
11038
+ if (p.ragEnabled !== true) embedMissing = true;
10972
11039
  // A RESTORED PICK MUST STILL EXIST. The stored id names a model on the box this folder was
10973
11040
  // last pointed at — and the box can change (`/model` → change the Ollama server), a model
10974
11041
  // can be removed, or the folder can be opened on another machine entirely. Sending a name
@@ -11676,10 +11743,12 @@ async function main() {
11676
11743
  }
11677
11744
  if (line === "/agent-model") {
11678
11745
  await chooseAgentModel();
11746
+ agentMissing = false; // Task #14: an agent model was just chosen → clear its warning
11679
11747
  continue;
11680
11748
  }
11681
11749
  if (line === "/embed-model") {
11682
11750
  await chooseEmbedModel();
11751
+ embedMissing = false; // Task #14: an embed model was just chosen → clear its warning
11683
11752
  continue;
11684
11753
  }
11685
11754
  if (line === "/server") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.763",
3
+ "version": "1.0.764",
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",
@@ -58,6 +58,7 @@
58
58
  "cli-token.mjs",
59
59
  "credential-recovery.mjs",
60
60
  "settings-transfer.mjs",
61
+ "presets-transfer.mjs",
61
62
  "device-login.mjs",
62
63
  "daemon-control.mjs",
63
64
  "log-policy.mjs",
@@ -0,0 +1,215 @@
1
+ /**
2
+ * MODEL PRESETS — the files /save-preset writes and /load-presets reads (#6).
3
+ *
4
+ * HOW A PRESET DIFFERS FROM A SETTINGS FILE. /save-settings asks nothing: it writes the whole
5
+ * account, names the keys, and refuses to carry a single secret. /save-preset is the opposite
6
+ * posture: the user TYPES the preset — a name and the JSON themselves — so whatever they put is
7
+ * by definition something they chose to store, including a live API key. The validator therefore
8
+ * REQUIRES what /save-settings forbids: apiUrl, apiKey, agentModel, codingModel and embedModel
9
+ * are all mandatory, and the file lands 0600 in the same home-folder location.
10
+ *
11
+ * WHAT A PRESET APPLIES. Everything but the embedder maps to a coding backend row: one row holds
12
+ * the URL, the coding model (as defaultModel) and — if the agent model is a different model on
13
+ * the same server — the agent model as an extra model of that row. The embedder is the global
14
+ * ragEmbedUrl/ragEmbedModel pair, exactly like a settings file. The apiKey is stored under the
15
+ * preset's name, bound to that row, and made the row's DEFAULT key so the resolver finds it.
16
+ *
17
+ * THE MARKER. A preset file carries "gu": "preset" (not "settings") so /load-settings never
18
+ * offers it and /load-presets never offers a settings file — two commands, two folders' worth of
19
+ * files, no cross-picking.
20
+ */
21
+
22
+ import { basename, extname, isAbsolute, join, resolve } from "node:path";
23
+ import { ensureV1Root, isCancelWord, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
24
+
25
+ /** The file name used when none is given — in the default folder (the home directory). */
26
+ export const DEFAULT_PRESET_NAME = "gu-preset.json";
27
+
28
+ /** Bumped only when an older gu could not read a newer preset correctly. */
29
+ export const PRESET_FILE_VERSION = 1;
30
+
31
+ const clean = (v) => (typeof v === "string" ? v.trim() : "");
32
+ const isHttpUrl = (v) => typeof v === "string" && /^https?:\/\/\S+$/i.test(v.trim());
33
+
34
+ /** A preset name is a file name: no path separators, no dots that hide it, one word of space. */
35
+ export function isPresetName(name) {
36
+ const n = clean(name);
37
+ return Boolean(n) && n.length <= 60 && !/[\/]/.test(n) && !/^\.+$/.test(n) && !isCancelWord(n);
38
+ }
39
+
40
+ /**
41
+ * What was typed → the file to use. Same rules as settingsFilePath: the DEFAULT FOLDER is the
42
+ * home directory, so a bare NAME is all anyone has to type ("work" → ~/work.json); a real path
43
+ * still works as one. Returns "" for a cancel word, which the caller treats as a cancel.
44
+ */
45
+ export function presetFilePath(typed, home) {
46
+ const t = clean(typed).replace(/^['"]|['"]$/g, "").trim();
47
+ if (!t) return join(home, DEFAULT_PRESET_NAME);
48
+ if (isCancelWord(t)) return "";
49
+ if (t === "~") return join(home, DEFAULT_PRESET_NAME);
50
+ if (t.startsWith("~/")) return withJson(join(home, t.slice(2)));
51
+ if (isAbsolute(t) || t.startsWith("./") || t.startsWith("../") || /[\/]/.test(t)) return resolve(t);
52
+ return withJson(join(home, t));
53
+ }
54
+
55
+ const withJson = (p) => (extname(basename(p)) ? p : `${p}.json`);
56
+
57
+ /**
58
+ * The preset files in the default folder, newest first — what /load-presets offers. Only files
59
+ * that ARE preset files (the "gu": "preset" marker). `entries` = [{ name, text, mtimeMs }], and
60
+ * each name is returned as just the preset name (the .json comes off — it is the name the user
61
+ * typed when saving).
62
+ */
63
+ export function presetFilesIn(entries) {
64
+ return (Array.isArray(entries) ? entries : [])
65
+ .filter((e) => e && /\.json$/i.test(e.name) && /"gu"\s*:\s*"preset"/.test(String(e.text ?? "")))
66
+ .sort((a, b) => (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0))
67
+ .map((e) => e.name.replace(/\.json$/i, ""));
68
+ }
69
+
70
+ /**
71
+ * Parse + validate the JSON the user typed after /save-preset <name>. EVERY field is required —
72
+ * a preset that holds only some of the setup is a trap: loading it half-configures a machine and
73
+ * the half that is missing is the half nobody notices until a turn fails.
74
+ *
75
+ * { "apiUrl": "https://api.openai.com/v1",
76
+ * "apiKey": "sk-…",
77
+ * "agentModel": "gpt-5",
78
+ * "codingModel": "gpt-5",
79
+ * "embedModel": "nomic-embed-text",
80
+ * "embedUrl": "https://ollama.com" } ← embedUrl optional; defaults to apiUrl
81
+ *
82
+ * ONE PROBLEM, NAMED — the first real problem refuses the whole preset, before anything is
83
+ * written. Returns { ok: true, preset } or { ok: false, problem }.
84
+ */
85
+ export function validatePreset(text) {
86
+ let doc;
87
+ try {
88
+ doc = JSON.parse(String(text));
89
+ } catch {
90
+ return { ok: false, problem: "that is not JSON — paste one object: { apiUrl, apiKey, agentModel, codingModel, embedModel }." };
91
+ }
92
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
93
+ return { ok: false, problem: "a preset is one JSON object: { apiUrl, apiKey, agentModel, codingModel, embedModel }." };
94
+ }
95
+ if (!isHttpUrl(doc.apiUrl)) {
96
+ return { ok: false, problem: `apiUrl must be an http(s) URL — got ${JSON.stringify(doc.apiUrl)}.` };
97
+ }
98
+ if (!clean(doc.apiKey)) {
99
+ return { ok: false, problem: "apiKey is required — a preset saves the key so loading needs nothing else." };
100
+ }
101
+ for (const f of ["agentModel", "codingModel", "embedModel"]) {
102
+ if (!clean(doc[f])) return { ok: false, problem: `${f} is required and must be a model name — got ${JSON.stringify(doc[f])}.` };
103
+ }
104
+ if (doc.embedUrl !== undefined && doc.embedUrl !== "" && !isHttpUrl(doc.embedUrl)) {
105
+ return { ok: false, problem: `embedUrl must be an http(s) URL — got ${JSON.stringify(doc.embedUrl)}.` };
106
+ }
107
+ const apiUrl = ensureV1Root(clean(doc.apiUrl));
108
+ return {
109
+ ok: true,
110
+ preset: {
111
+ apiUrl,
112
+ apiKey: clean(doc.apiKey),
113
+ agentModel: clean(doc.agentModel),
114
+ codingModel: clean(doc.codingModel),
115
+ embedModel: clean(doc.embedModel),
116
+ embedUrl: doc.embedUrl ? ensureV1Root(clean(doc.embedUrl)) : apiUrl,
117
+ },
118
+ };
119
+ }
120
+
121
+ /** The object /save-preset writes. The name travels INSIDE the file so a renamed file still says what it was. */
122
+ export function buildPreset({ name, preset, now = new Date(), host = "" } = {}) {
123
+ return {
124
+ gu: "preset",
125
+ version: PRESET_FILE_VERSION,
126
+ note: "A named model preset: server, key and the agent / coding / embedding models. This file CONTAINS a live API key.",
127
+ name: clean(name),
128
+ savedAt: new Date(now).toISOString(),
129
+ ...(host ? { savedOn: host } : {}),
130
+ apiUrl: preset.apiUrl,
131
+ apiKey: preset.apiKey,
132
+ agentModel: preset.agentModel,
133
+ codingModel: preset.codingModel,
134
+ embedModel: preset.embedModel,
135
+ embedUrl: preset.embedUrl,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Read a preset FILE back. Strict like validatePreset, plus the markers so a settings file or a
141
+ * random JSON is refused with a sentence instead of applied as a preset.
142
+ */
143
+ export function validatePresetFile(text) {
144
+ let doc;
145
+ try {
146
+ doc = JSON.parse(String(text));
147
+ } catch {
148
+ return { ok: false, problem: "that file is not JSON — is it a file /save-preset wrote?" };
149
+ }
150
+ if (!doc || typeof doc !== "object" || Array.isArray(doc) || doc.gu !== "preset") {
151
+ return { ok: false, problem: 'that file is not a gu preset file (no "gu": "preset" marker).' };
152
+ }
153
+ if (doc.version !== PRESET_FILE_VERSION) {
154
+ return {
155
+ ok: false,
156
+ problem: `that preset is version ${JSON.stringify(doc.version)}; this gu writes and reads version ${PRESET_FILE_VERSION}. Update gu, or save it again.`,
157
+ };
158
+ }
159
+ const back = validatePreset(JSON.stringify(doc));
160
+ if (!back.ok) return back;
161
+ return { ok: true, name: clean(doc.name), preset: back.preset, savedAt: clean(doc.savedAt), savedOn: clean(doc.savedOn) };
162
+ }
163
+
164
+ /**
165
+ * The POST body for /api/worker/settings that applies a preset. One coding backend row carries
166
+ * the server and BOTH models: the coding model as the default, the agent model as a second
167
+ * model of the same row when they differ — one server is one row, the rule every picker and the
168
+ * API itself already enforce. A row this account already has for that server KEEPS ITS ROW ID
169
+ * (the settings-transfer reason: a key whose row is re-minted is stranded forever). The embedder
170
+ * is the global pair, cleared of any account-wide key pick so the row's keys resolve it.
171
+ */
172
+ export function presetToSettingsPatch(preset, current = {}) {
173
+ const existing = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
174
+ const sameServer = (a, b) =>
175
+ Boolean(clean(a) && clean(b)) && ensureV1Root(clean(a)).toLowerCase() === ensureV1Root(clean(b)).toLowerCase();
176
+ const mine = existing.find((r) => r?.id && sameServer(r.url, preset.apiUrl));
177
+ const models = [...new Set([preset.agentModel, preset.codingModel])].filter((m) => m !== preset.codingModel);
178
+ const row = {
179
+ ...(mine?.id ? { id: mine.id } : {}),
180
+ kind: mine?.kind ?? "openai",
181
+ url: preset.apiUrl,
182
+ defaultModel: preset.codingModel,
183
+ models: models.slice(0, MAX_MODELS_PER_SERVER),
184
+ enabled: true,
185
+ };
186
+ return {
187
+ patch: {
188
+ agentCodingBackends: [row],
189
+ agentModelUrl: preset.apiUrl,
190
+ agentModelName: preset.agentModel,
191
+ ragEmbedUrl: preset.embedUrl,
192
+ ragEmbedModel: preset.embedModel,
193
+ ragEmbedKeyId: "",
194
+ },
195
+ existingRowId: mine?.id ?? "",
196
+ };
197
+ }
198
+
199
+ /** The lines /load-presets prints before it asks — what applying the preset will do. */
200
+ export function presetPlanLines(name, preset, current = {}) {
201
+ const same = (a, b) => clean(a).toLowerCase() === clean(b).toLowerCase();
202
+ const lines = [];
203
+ lines.push(`preset "${name}":`);
204
+ lines.push(` server: ${preset.apiUrl}`);
205
+ lines.push(` coding model: ${preset.codingModel}`);
206
+ lines.push(` agent model: ${preset.agentModel}`);
207
+ const embedSame = same(preset.embedUrl, current.ragEmbedUrl) && same(preset.embedModel, current.ragEmbedModel);
208
+ lines.push(` embedding: ${preset.embedModel} at ${preset.embedUrl}${embedSame ? " (already set)" : ""}`);
209
+ if (!embedSame && preset.embedModel !== clean(current.ragEmbedModel) && clean(current.ragEmbedModel)) {
210
+ lines.push(" a different embedder: every knowledge base rebuilds on its next search.");
211
+ }
212
+ lines.push(` api key: saved as "${clean(name)}", made the server's default`);
213
+ lines.push(" replaces the coding provider list with this one server.");
214
+ return lines;
215
+ }
@@ -476,6 +476,7 @@ export function buildFooterRows({
476
476
  token = "",
477
477
  mode = "",
478
478
  notice = "",
479
+ missing = "",
479
480
  }) {
480
481
  const w = Math.max(20, width || 80);
481
482
  return [
@@ -488,6 +489,10 @@ export function buildFooterRows({
488
489
  // used to be appended to that row and was clipped at the right edge ("…pick one with /mo").
489
490
  // Present only while it shows; paintBar re-cuts the frozen zone when the count changes.
490
491
  ...(notice ? [clipVisible(gutter + notice, w - 1)] : []),
492
+ // Task #14: a skipped model setup stays VISIBLE. When private/client-expert setup was
493
+ // declined the agent model and/or the embed model is unset — say so at the bottom, every
494
+ // frame, until one is chosen. Its own row, under the passing notice, so it persists.
495
+ ...(missing ? [clipVisible(gutter + missing, w - 1)] : []),
491
496
  ];
492
497
  }
493
498