@tiens.nguyen/gu-cli 1.0.762 → 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.
- package/client-expert-setup.mjs +194 -0
- package/gu-repl.mjs +266 -7
- package/package.json +2 -1
- package/presets-transfer.mjs +215 -0
- package/terminal-layout.mjs +5 -0
package/client-expert-setup.mjs
CHANGED
|
@@ -614,3 +614,197 @@ export function shouldAskMode({ chosenMode, isTty, expertAvailable }) {
|
|
|
614
614
|
if (!isTty) return false; // a pipe or CI has nobody to answer
|
|
615
615
|
return Boolean(expertAvailable);
|
|
616
616
|
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* `--license <pro token>` — the flag that unlocks the first-run MODE MENU.
|
|
620
|
+
*
|
|
621
|
+
* The menu ("how should this machine run?" — hosted client / MLX host / private) used to be
|
|
622
|
+
* shown to every first install. It is now a pro feature: a bare `gu` goes PRIVATE by default
|
|
623
|
+
* (Client Expert — the whole stack on this machine, nothing shared), and the only way to be
|
|
624
|
+
* offered the hosted modes is to start with a license token. The token itself is NOT validated
|
|
625
|
+
* here and NOT stored — it only gates which question gets asked, this run. Returns the trimmed
|
|
626
|
+
* token, or "" when absent.
|
|
627
|
+
*/
|
|
628
|
+
export function parseLicenseFlag(argv) {
|
|
629
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
630
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
631
|
+
const a = String(args[i] ?? "");
|
|
632
|
+
if (a === "--license") return String(args[i + 1] ?? "").trim();
|
|
633
|
+
if (a.startsWith("--license=")) return a.slice("--license=".length).trim();
|
|
634
|
+
}
|
|
635
|
+
return "";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* What a first run DOES about the mode question.
|
|
640
|
+
*
|
|
641
|
+
* · WITH a license token → ask: the three-mode menu, exactly as before.
|
|
642
|
+
* · WITHOUT one → a silent default: "client-expert" (private) when this machine can
|
|
643
|
+
* run it, otherwise "client" (cloud works everywhere).
|
|
644
|
+
*
|
|
645
|
+
* The default lands on the mode that KEEPS DATA ON THE MACHINE on purpose: choosing hosted
|
|
646
|
+
* storage for someone is a privacy decision they have to make themselves — which is exactly
|
|
647
|
+
* what the license-gated menu is for. Pure, so the rule is a test row rather than something to
|
|
648
|
+
* re-read in the REPL.
|
|
649
|
+
*/
|
|
650
|
+
export function resolveFirstRunMode({ licenseToken, expertAvailable } = {}) {
|
|
651
|
+
if (String(licenseToken ?? "").trim()) return { ask: true, mode: null };
|
|
652
|
+
if (expertAvailable) return { ask: false, mode: "client-expert" };
|
|
653
|
+
return { ask: false, mode: "client" };
|
|
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
|
@@ -569,13 +569,16 @@ if (cliVerdict.kind === "run") {
|
|
|
569
569
|
} else if (argv.includes("--help") || argv.includes("-h")) {
|
|
570
570
|
console.log(
|
|
571
571
|
"gu — terminal agent REPL for the current folder\n\n" +
|
|
572
|
-
"Usage: gu [-v|--verbose]\n" +
|
|
572
|
+
"Usage: gu [-v|--verbose] [--license <pro token>]\n" +
|
|
573
573
|
" gu status # worker + a table of the running model servers\n" +
|
|
574
574
|
" gu start|stop|restart|logs # manage the background worker\n" +
|
|
575
575
|
" gu logs <port|pid> [-f] # tail a running model server's log\n" +
|
|
576
576
|
" gu doctor|login|set # models / account (same as `gu-cli …`)\n" +
|
|
577
577
|
" gu rag # is RAG working? tests the embedder end-to-end\n\n" +
|
|
578
578
|
" -v, --verbose show RAG index-file activity in the bottom bar (each shard written)\n" +
|
|
579
|
+
" --license <token> pro license: a first install ASKS how this machine runs (hosted\n" +
|
|
580
|
+
" client / MLX host / private). Without it, a first install is\n" +
|
|
581
|
+
" private by default — the whole stack on this machine.\n" +
|
|
579
582
|
" -h, --help this help\n\n" +
|
|
580
583
|
"Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
|
|
581
584
|
"Inside the REPL: /exit · /model · /new · /help — Ctrl-C stops following a turn.\n" +
|
|
@@ -664,13 +667,59 @@ if (!workerKey || !apiBase) {
|
|
|
664
667
|
const {
|
|
665
668
|
probeMachine, askMode, runClientExpertSetup, loadState, saveState,
|
|
666
669
|
} = await import("./client-expert-run.mjs");
|
|
667
|
-
const { clientExpertAvailable } =
|
|
670
|
+
const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode, promptForLicenseToken } =
|
|
671
|
+
await import("./client-expert-setup.mjs");
|
|
668
672
|
const machine0 = await probeMachine();
|
|
669
673
|
const gate0 = clientExpertAvailable({ workerEnvExists: false, ...machine0 });
|
|
670
|
-
|
|
674
|
+
// THE MENU IS A PRO FEATURE. A bare first install no longer asks "how should this
|
|
675
|
+
// machine run?" — it goes PRIVATE (Client Expert: the whole stack on this machine,
|
|
676
|
+
// nothing shared) and walks the MongoDB/API/models setup straight away. The
|
|
677
|
+
// three-way menu (hosted client / MLX host / private) is shown only when the run
|
|
678
|
+
// carries a pro license token: `gu --license <token>`.
|
|
679
|
+
const licenseToken0 = parseLicenseFlag(argv);
|
|
680
|
+
let plan0 = resolveFirstRunMode({
|
|
681
|
+
licenseToken: licenseToken0,
|
|
671
682
|
expertAvailable: gate0.available,
|
|
672
|
-
expertWhy: gate0.message,
|
|
673
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
|
+
}
|
|
701
|
+
let mode0;
|
|
702
|
+
if (plan0.ask) {
|
|
703
|
+
mode0 = await askMode(ask0, {
|
|
704
|
+
expertAvailable: gate0.available,
|
|
705
|
+
expertWhy: gate0.message,
|
|
706
|
+
});
|
|
707
|
+
} else {
|
|
708
|
+
mode0 = plan0.mode;
|
|
709
|
+
if (mode0 === "client-expert") {
|
|
710
|
+
console.log(
|
|
711
|
+
dim("\n Private by default: the whole stack (API, database, models) is set up on") +
|
|
712
|
+
dim("\n this machine; nothing it does is shared.")
|
|
713
|
+
);
|
|
714
|
+
console.log(
|
|
715
|
+
dim(" To pick a hosted mode instead, start with a pro license: gu --license <token>\n")
|
|
716
|
+
);
|
|
717
|
+
} else {
|
|
718
|
+
// Private is the default, but this machine cannot run it — say why ONCE and land
|
|
719
|
+
// in cloud mode, which works everywhere, rather than installing a broken stack.
|
|
720
|
+
console.log(dim(`\n Private mode is not available here — ${gate0.message}`));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
674
723
|
// RECORD IT HERE. This branch used to ask and not save, so the later mode question in
|
|
675
724
|
// main() still saw no chosenMode and asked the SAME question a second time in one run —
|
|
676
725
|
// and by then worker.env existed, so its answer list said Client Expert was unavailable.
|
|
@@ -813,6 +862,12 @@ const COMMANDS = [
|
|
|
813
862
|
{ name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
|
|
814
863
|
{ 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" },
|
|
815
864
|
{ 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" },
|
|
865
|
+
// PRESETS (#6) are the user's OWN JSON, not a snapshot: /save-preset takes a name AND the
|
|
866
|
+
// object {apiUrl, apiKey, agentModel, codingModel, embedModel} and validates every field
|
|
867
|
+
// before writing. A preset file HOLDS its API key (0600, home folder — the settings files'
|
|
868
|
+
// location), so it is marked "gu": "preset" and the two commands never offer each other's files.
|
|
869
|
+
{ name: "/save-preset", usage: '/save-preset <name> {"apiUrl":"…","apiKey":"…","agentModel":"…","codingModel":"…","embedModel":"…"}', desc: "save a named preset: server, API key and the agent/coding/embedding models, all validated — same home folder as settings files" },
|
|
870
|
+
{ name: "/load-presets", usage: "/load-presets [name]", desc: "pick a saved preset (arrow keys) and put it back — server, both models, embedder and its key; asks before it replaces anything" },
|
|
816
871
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
817
872
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
818
873
|
// ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
|
|
@@ -1444,6 +1499,24 @@ const imageStore = createImageStore();
|
|
|
1444
1499
|
// Shown on the bottom row after a refused or failed image paste; clears itself.
|
|
1445
1500
|
let imageNotice = "";
|
|
1446
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
|
+
|
|
1447
1520
|
function showImageNotice(text) {
|
|
1448
1521
|
imageNotice = text;
|
|
1449
1522
|
if (imageNoticeTimer) clearTimeout(imageNoticeTimer);
|
|
@@ -2303,6 +2376,8 @@ const footerRows = () =>
|
|
|
2303
2376
|
notice: imageNotice
|
|
2304
2377
|
? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
|
|
2305
2378
|
: "",
|
|
2379
|
+
// Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
|
|
2380
|
+
missing: missingNoticeText() ? red(missingNoticeText()) : "",
|
|
2306
2381
|
});
|
|
2307
2382
|
// True only while the MAIN loop's ">> " prompt is waiting for input. The bar belongs to that
|
|
2308
2383
|
// prompt, so it must never appear under an ask()/askSecret() question or during a turn.
|
|
@@ -5264,6 +5339,13 @@ async function reconfigureOllamaServer() {
|
|
|
5264
5339
|
role: "coder",
|
|
5265
5340
|
});
|
|
5266
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
|
+
|
|
5267
5349
|
if (!agent && !coder) {
|
|
5268
5350
|
console.log(dim(" nothing set — models stay as they are.\n"));
|
|
5269
5351
|
return;
|
|
@@ -6429,6 +6511,142 @@ function settingsArg(line, word) {
|
|
|
6429
6511
|
return line.slice(word.length).trim();
|
|
6430
6512
|
}
|
|
6431
6513
|
|
|
6514
|
+
/**
|
|
6515
|
+
* /save-preset <name> <json> — the user TYPES the preset (#6): a name and a JSON object with
|
|
6516
|
+
* apiUrl, apiKey, agentModel, codingModel and embedModel, all validated before anything is
|
|
6517
|
+
* written. Unlike /save-settings (which snapshots the account and refuses secrets), a preset
|
|
6518
|
+
* SAVES the API key by design — it is the "this exact setup, one command" file, stored 0600 in
|
|
6519
|
+
* the same home-folder location as settings files, marked "gu": "preset" so the two commands
|
|
6520
|
+
* never offer each other's files.
|
|
6521
|
+
*/
|
|
6522
|
+
async function runSavePresetCommand(line) {
|
|
6523
|
+
const pr = await import("./presets-transfer.mjs");
|
|
6524
|
+
const home = homedir();
|
|
6525
|
+
console.log("");
|
|
6526
|
+
const usage =
|
|
6527
|
+
'usage: /save-preset <name> {"apiUrl":"https://…","apiKey":"…","agentModel":"…","codingModel":"…","embedModel":"…"} (embedUrl optional)';
|
|
6528
|
+
const rest = line.trim().slice("/save-preset".length).trim();
|
|
6529
|
+
if (!rest) {
|
|
6530
|
+
console.log(GUTTER + usage);
|
|
6531
|
+
console.log("");
|
|
6532
|
+
return;
|
|
6533
|
+
}
|
|
6534
|
+
const space = rest.search(/\s/);
|
|
6535
|
+
const name = space === -1 ? rest : rest.slice(0, space);
|
|
6536
|
+
const json = space === -1 ? "" : rest.slice(space + 1).trim();
|
|
6537
|
+
if (!pr.isPresetName(name)) {
|
|
6538
|
+
console.log(GUTTER + `"${name}" is not a preset name — one word, no slashes. ` + usage);
|
|
6539
|
+
console.log("");
|
|
6540
|
+
return;
|
|
6541
|
+
}
|
|
6542
|
+
if (!json) {
|
|
6543
|
+
console.log(GUTTER + "the JSON is missing — " + usage);
|
|
6544
|
+
console.log("");
|
|
6545
|
+
return;
|
|
6546
|
+
}
|
|
6547
|
+
const checked = pr.validatePreset(json);
|
|
6548
|
+
if (!checked.ok) {
|
|
6549
|
+
console.log(GUTTER + `Preset not saved — ${checked.problem}`);
|
|
6550
|
+
console.log("");
|
|
6551
|
+
return;
|
|
6552
|
+
}
|
|
6553
|
+
const file = pr.presetFilePath(name, home);
|
|
6554
|
+
writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset: checked.preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
|
|
6555
|
+
console.log(GUTTER + `Preset "${name}" saved to ${file}`);
|
|
6556
|
+
console.log(GUTTER + dim("server + api key + agent, coding and embedding models — the key is IN this file, keep it private."));
|
|
6557
|
+
console.log("");
|
|
6558
|
+
}
|
|
6559
|
+
|
|
6560
|
+
/**
|
|
6561
|
+
* /load-presets — list the preset files /save-preset wrote (newest first, the arrow-key picker)
|
|
6562
|
+
* and apply the chosen one: the server + both models land as one coding-backend row, the preset's
|
|
6563
|
+
* key is saved under the preset's name and made that row's default, and the embedder is set. The
|
|
6564
|
+
* user reconfirms before anything changes, like /load-settings.
|
|
6565
|
+
*/
|
|
6566
|
+
async function runLoadPresetsCommand(line) {
|
|
6567
|
+
const pr = await import("./presets-transfer.mjs");
|
|
6568
|
+
const home = homedir();
|
|
6569
|
+
console.log("");
|
|
6570
|
+
let file = "";
|
|
6571
|
+
const typed = line.trim().slice("/load-presets".length).trim();
|
|
6572
|
+
if (typed) {
|
|
6573
|
+
file = pr.presetFilePath(typed, home);
|
|
6574
|
+
if (!file) return; // a cancel word
|
|
6575
|
+
if (!existsSync(file)) {
|
|
6576
|
+
console.log(GUTTER + `no preset file at ${file}`);
|
|
6577
|
+
console.log("");
|
|
6578
|
+
return;
|
|
6579
|
+
}
|
|
6580
|
+
} else {
|
|
6581
|
+
const found = pr.presetFilesIn(listJsonFiles(home));
|
|
6582
|
+
if (found.length === 0) {
|
|
6583
|
+
console.log(GUTTER + `no presets in ${home} — save one with /save-preset <name> {…}`);
|
|
6584
|
+
console.log("");
|
|
6585
|
+
return;
|
|
6586
|
+
}
|
|
6587
|
+
let pick;
|
|
6588
|
+
if (found.length === 1) {
|
|
6589
|
+
pick = 0;
|
|
6590
|
+
console.log(GUTTER + `one preset here: ${found[0]}`);
|
|
6591
|
+
} else {
|
|
6592
|
+
console.log(dim(` Presets in ${home} (${PICK_HINT}):`));
|
|
6593
|
+
pick = await pickFromList(found.map((n) => ({ label: n })));
|
|
6594
|
+
}
|
|
6595
|
+
if (pick < 0) {
|
|
6596
|
+
console.log(GUTTER + "load cancelled.");
|
|
6597
|
+
console.log("");
|
|
6598
|
+
return;
|
|
6599
|
+
}
|
|
6600
|
+
file = pr.presetFilePath(found[pick], home);
|
|
6601
|
+
}
|
|
6602
|
+
const checked = pr.validatePresetFile(readFileSync(file, "utf8"));
|
|
6603
|
+
if (!checked.ok) {
|
|
6604
|
+
console.log(GUTTER + `Not loaded — ${checked.problem}`);
|
|
6605
|
+
console.log("");
|
|
6606
|
+
return;
|
|
6607
|
+
}
|
|
6608
|
+
const name = checked.name || basename(file).replace(/\.json$/i, "");
|
|
6609
|
+
const preset = checked.preset;
|
|
6610
|
+
const settings = await fetchWorkerSettings();
|
|
6611
|
+
for (const l of pr.presetPlanLines(name, preset, settings ?? {})) console.log(GUTTER + l);
|
|
6612
|
+
if ((await ask("Apply this preset? [y/N] ")) !== "y") {
|
|
6613
|
+
console.log(GUTTER + "load cancelled — nothing changed.");
|
|
6614
|
+
console.log("");
|
|
6615
|
+
return;
|
|
6616
|
+
}
|
|
6617
|
+
const { patch, existingRowId } = pr.presetToSettingsPatch(preset, settings ?? {});
|
|
6618
|
+
const md = await import("./model-doctor.mjs");
|
|
6619
|
+
const saved = await md.saveWorkerSettings(apiBase, workerKey, patch);
|
|
6620
|
+
if (!saved) {
|
|
6621
|
+
console.log(GUTTER + "the worker could not apply it — is the local API running? (`gu api status`)");
|
|
6622
|
+
console.log("");
|
|
6623
|
+
return;
|
|
6624
|
+
}
|
|
6625
|
+
let defaultNote = "";
|
|
6626
|
+
const keyId = await addCodingKey(name, preset.apiKey, existingRowId);
|
|
6627
|
+
if (keyId) {
|
|
6628
|
+
// A new row's id arrives on the key's backendId once the settings write re-read; re-fetch
|
|
6629
|
+
// the settings to find the row for this server when it was not one the account already had.
|
|
6630
|
+
let rowId = existingRowId;
|
|
6631
|
+
if (!rowId) {
|
|
6632
|
+
const after = await fetchWorkerSettings();
|
|
6633
|
+
const rows = Array.isArray(after?.agentCodingBackends) ? after.agentCodingBackends : [];
|
|
6634
|
+
rowId = rows.find((r) => r?.id && typeof r.url === "string" && r.url.replace(/\/+$/, "").toLowerCase() === preset.apiUrl.replace(/\/+$/, "").toLowerCase())?.id ?? "";
|
|
6635
|
+
}
|
|
6636
|
+
if (rowId) {
|
|
6637
|
+
const set = await setServerDefaultKey(rowId, keyId);
|
|
6638
|
+
defaultNote = set.ok ? " — the preset's key is saved and made the server's default." : ` — the key saved but making it the default failed: ${set.error ?? "unknown"}`;
|
|
6639
|
+
} else {
|
|
6640
|
+
defaultNote = " — the key saved (the row's id was not readable to make it the default; set it in /agent-coding if needed).";
|
|
6641
|
+
}
|
|
6642
|
+
} else {
|
|
6643
|
+
defaultNote = " — but the API key could not be saved (the add-key call failed; add it in /agent-coding).";
|
|
6644
|
+
}
|
|
6645
|
+
console.log(GUTTER + `preset "${name}" applied from ${file}${defaultNote}`);
|
|
6646
|
+
console.log(GUTTER + "the new setup answers from the next prompt on.");
|
|
6647
|
+
console.log("");
|
|
6648
|
+
}
|
|
6649
|
+
|
|
6432
6650
|
async function runSaveSettingsCommand(line) {
|
|
6433
6651
|
const st = await import("./settings-transfer.mjs");
|
|
6434
6652
|
const home = homedir();
|
|
@@ -10576,7 +10794,7 @@ async function main() {
|
|
|
10576
10794
|
// machine that had been working for months.
|
|
10577
10795
|
try {
|
|
10578
10796
|
const { loadState, saveState, askMode } = await import("./client-expert-run.mjs");
|
|
10579
|
-
const { clientExpertAvailable, expertPlanPending, shouldAskMode } =
|
|
10797
|
+
const { clientExpertAvailable, expertPlanPending, shouldAskMode, parseLicenseFlag, resolveFirstRunMode, promptForLicenseToken } =
|
|
10580
10798
|
await import("./client-expert-setup.mjs");
|
|
10581
10799
|
const st = await loadState();
|
|
10582
10800
|
const { probeMachine } = await import("./client-expert-run.mjs");
|
|
@@ -10590,10 +10808,34 @@ async function main() {
|
|
|
10590
10808
|
...machine,
|
|
10591
10809
|
});
|
|
10592
10810
|
if (shouldAskMode({ chosenMode: st.chosenMode, isTty: IS_TTY, expertAvailable: gate.available })) {
|
|
10593
|
-
|
|
10811
|
+
// Same rule as the first run: the mode menu is a pro-license feature — without
|
|
10812
|
+
// `--license <token>` this machine defaults to private (Client Expert) when it can
|
|
10813
|
+
// run it, instead of being asked.
|
|
10814
|
+
const licenseToken1 = parseLicenseFlag(argv);
|
|
10815
|
+
let plan = resolveFirstRunMode({
|
|
10816
|
+
licenseToken: licenseToken1,
|
|
10594
10817
|
expertAvailable: gate.available,
|
|
10595
|
-
expertWhy: gate.message,
|
|
10596
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
|
+
}
|
|
10833
|
+
const chosen = plan.ask
|
|
10834
|
+
? await askMode(ask, {
|
|
10835
|
+
expertAvailable: gate.available,
|
|
10836
|
+
expertWhy: gate.message,
|
|
10837
|
+
})
|
|
10838
|
+
: plan.mode;
|
|
10597
10839
|
await saveState({ ...st, chosenMode: chosen ?? "client" });
|
|
10598
10840
|
if (chosen === "client-expert") {
|
|
10599
10841
|
const { runClientExpertSetup } = await import("./client-expert-run.mjs");
|
|
@@ -10787,6 +11029,13 @@ async function main() {
|
|
|
10787
11029
|
// #125) when no /model override is active.
|
|
10788
11030
|
if (p.codingModelId) defaultCodingModel = p.codingModelId;
|
|
10789
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;
|
|
10790
11039
|
// A RESTORED PICK MUST STILL EXIST. The stored id names a model on the box this folder was
|
|
10791
11040
|
// last pointed at — and the box can change (`/model` → change the Ollama server), a model
|
|
10792
11041
|
// can be removed, or the folder can be opened on another machine entirely. Sending a name
|
|
@@ -11494,10 +11743,12 @@ async function main() {
|
|
|
11494
11743
|
}
|
|
11495
11744
|
if (line === "/agent-model") {
|
|
11496
11745
|
await chooseAgentModel();
|
|
11746
|
+
agentMissing = false; // Task #14: an agent model was just chosen → clear its warning
|
|
11497
11747
|
continue;
|
|
11498
11748
|
}
|
|
11499
11749
|
if (line === "/embed-model") {
|
|
11500
11750
|
await chooseEmbedModel();
|
|
11751
|
+
embedMissing = false; // Task #14: an embed model was just chosen → clear its warning
|
|
11501
11752
|
continue;
|
|
11502
11753
|
}
|
|
11503
11754
|
if (line === "/server") {
|
|
@@ -11535,6 +11786,14 @@ async function main() {
|
|
|
11535
11786
|
await runS3Command(line);
|
|
11536
11787
|
continue;
|
|
11537
11788
|
}
|
|
11789
|
+
if (line === "/save-preset" || line.startsWith("/save-preset ")) {
|
|
11790
|
+
await runSavePresetCommand(line);
|
|
11791
|
+
continue;
|
|
11792
|
+
}
|
|
11793
|
+
if (line === "/load-presets" || line.startsWith("/load-presets ")) {
|
|
11794
|
+
await runLoadPresetsCommand(line);
|
|
11795
|
+
continue;
|
|
11796
|
+
}
|
|
11538
11797
|
if (line === "/save-settings" || line.startsWith("/save-settings ")) {
|
|
11539
11798
|
await runSaveSettingsCommand(line);
|
|
11540
11799
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
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
|
+
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -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
|
|