@tiens.nguyen/gu-cli 1.0.761 → 1.0.763
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 +38 -0
- package/gu-repl.mjs +197 -7
- package/package.json +1 -1
package/client-expert-setup.mjs
CHANGED
|
@@ -614,3 +614,41 @@ 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
|
+
}
|
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,41 @@ 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 } =
|
|
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 plan0 = resolveFirstRunMode({
|
|
680
|
+
licenseToken: parseLicenseFlag(argv),
|
|
671
681
|
expertAvailable: gate0.available,
|
|
672
|
-
expertWhy: gate0.message,
|
|
673
682
|
});
|
|
683
|
+
let mode0;
|
|
684
|
+
if (plan0.ask) {
|
|
685
|
+
mode0 = await askMode(ask0, {
|
|
686
|
+
expertAvailable: gate0.available,
|
|
687
|
+
expertWhy: gate0.message,
|
|
688
|
+
});
|
|
689
|
+
} else {
|
|
690
|
+
mode0 = plan0.mode;
|
|
691
|
+
if (mode0 === "client-expert") {
|
|
692
|
+
console.log(
|
|
693
|
+
dim("\n Private by default: the whole stack (API, database, models) is set up on") +
|
|
694
|
+
dim("\n this machine; nothing it does is shared.")
|
|
695
|
+
);
|
|
696
|
+
console.log(
|
|
697
|
+
dim(" To pick a hosted mode instead, start with a pro license: gu --license <token>\n")
|
|
698
|
+
);
|
|
699
|
+
} else {
|
|
700
|
+
// Private is the default, but this machine cannot run it — say why ONCE and land
|
|
701
|
+
// in cloud mode, which works everywhere, rather than installing a broken stack.
|
|
702
|
+
console.log(dim(`\n Private mode is not available here — ${gate0.message}`));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
674
705
|
// RECORD IT HERE. This branch used to ask and not save, so the later mode question in
|
|
675
706
|
// main() still saw no chosenMode and asked the SAME question a second time in one run —
|
|
676
707
|
// and by then worker.env existed, so its answer list said Client Expert was unavailable.
|
|
@@ -813,6 +844,12 @@ const COMMANDS = [
|
|
|
813
844
|
{ 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
845
|
{ 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
846
|
{ 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" },
|
|
847
|
+
// PRESETS (#6) are the user's OWN JSON, not a snapshot: /save-preset takes a name AND the
|
|
848
|
+
// object {apiUrl, apiKey, agentModel, codingModel, embedModel} and validates every field
|
|
849
|
+
// before writing. A preset file HOLDS its API key (0600, home folder — the settings files'
|
|
850
|
+
// location), so it is marked "gu": "preset" and the two commands never offer each other's files.
|
|
851
|
+
{ 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" },
|
|
852
|
+
{ 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
853
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
817
854
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
818
855
|
// ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
|
|
@@ -6429,6 +6466,142 @@ function settingsArg(line, word) {
|
|
|
6429
6466
|
return line.slice(word.length).trim();
|
|
6430
6467
|
}
|
|
6431
6468
|
|
|
6469
|
+
/**
|
|
6470
|
+
* /save-preset <name> <json> — the user TYPES the preset (#6): a name and a JSON object with
|
|
6471
|
+
* apiUrl, apiKey, agentModel, codingModel and embedModel, all validated before anything is
|
|
6472
|
+
* written. Unlike /save-settings (which snapshots the account and refuses secrets), a preset
|
|
6473
|
+
* SAVES the API key by design — it is the "this exact setup, one command" file, stored 0600 in
|
|
6474
|
+
* the same home-folder location as settings files, marked "gu": "preset" so the two commands
|
|
6475
|
+
* never offer each other's files.
|
|
6476
|
+
*/
|
|
6477
|
+
async function runSavePresetCommand(line) {
|
|
6478
|
+
const pr = await import("./presets-transfer.mjs");
|
|
6479
|
+
const home = homedir();
|
|
6480
|
+
console.log("");
|
|
6481
|
+
const usage =
|
|
6482
|
+
'usage: /save-preset <name> {"apiUrl":"https://…","apiKey":"…","agentModel":"…","codingModel":"…","embedModel":"…"} (embedUrl optional)';
|
|
6483
|
+
const rest = line.trim().slice("/save-preset".length).trim();
|
|
6484
|
+
if (!rest) {
|
|
6485
|
+
console.log(GUTTER + usage);
|
|
6486
|
+
console.log("");
|
|
6487
|
+
return;
|
|
6488
|
+
}
|
|
6489
|
+
const space = rest.search(/\s/);
|
|
6490
|
+
const name = space === -1 ? rest : rest.slice(0, space);
|
|
6491
|
+
const json = space === -1 ? "" : rest.slice(space + 1).trim();
|
|
6492
|
+
if (!pr.isPresetName(name)) {
|
|
6493
|
+
console.log(GUTTER + `"${name}" is not a preset name — one word, no slashes. ` + usage);
|
|
6494
|
+
console.log("");
|
|
6495
|
+
return;
|
|
6496
|
+
}
|
|
6497
|
+
if (!json) {
|
|
6498
|
+
console.log(GUTTER + "the JSON is missing — " + usage);
|
|
6499
|
+
console.log("");
|
|
6500
|
+
return;
|
|
6501
|
+
}
|
|
6502
|
+
const checked = pr.validatePreset(json);
|
|
6503
|
+
if (!checked.ok) {
|
|
6504
|
+
console.log(GUTTER + `Preset not saved — ${checked.problem}`);
|
|
6505
|
+
console.log("");
|
|
6506
|
+
return;
|
|
6507
|
+
}
|
|
6508
|
+
const file = pr.presetFilePath(name, home);
|
|
6509
|
+
writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset: checked.preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
|
|
6510
|
+
console.log(GUTTER + `Preset "${name}" saved to ${file}`);
|
|
6511
|
+
console.log(GUTTER + dim("server + api key + agent, coding and embedding models — the key is IN this file, keep it private."));
|
|
6512
|
+
console.log("");
|
|
6513
|
+
}
|
|
6514
|
+
|
|
6515
|
+
/**
|
|
6516
|
+
* /load-presets — list the preset files /save-preset wrote (newest first, the arrow-key picker)
|
|
6517
|
+
* and apply the chosen one: the server + both models land as one coding-backend row, the preset's
|
|
6518
|
+
* key is saved under the preset's name and made that row's default, and the embedder is set. The
|
|
6519
|
+
* user reconfirms before anything changes, like /load-settings.
|
|
6520
|
+
*/
|
|
6521
|
+
async function runLoadPresetsCommand(line) {
|
|
6522
|
+
const pr = await import("./presets-transfer.mjs");
|
|
6523
|
+
const home = homedir();
|
|
6524
|
+
console.log("");
|
|
6525
|
+
let file = "";
|
|
6526
|
+
const typed = line.trim().slice("/load-presets".length).trim();
|
|
6527
|
+
if (typed) {
|
|
6528
|
+
file = pr.presetFilePath(typed, home);
|
|
6529
|
+
if (!file) return; // a cancel word
|
|
6530
|
+
if (!existsSync(file)) {
|
|
6531
|
+
console.log(GUTTER + `no preset file at ${file}`);
|
|
6532
|
+
console.log("");
|
|
6533
|
+
return;
|
|
6534
|
+
}
|
|
6535
|
+
} else {
|
|
6536
|
+
const found = pr.presetFilesIn(listJsonFiles(home));
|
|
6537
|
+
if (found.length === 0) {
|
|
6538
|
+
console.log(GUTTER + `no presets in ${home} — save one with /save-preset <name> {…}`);
|
|
6539
|
+
console.log("");
|
|
6540
|
+
return;
|
|
6541
|
+
}
|
|
6542
|
+
let pick;
|
|
6543
|
+
if (found.length === 1) {
|
|
6544
|
+
pick = 0;
|
|
6545
|
+
console.log(GUTTER + `one preset here: ${found[0]}`);
|
|
6546
|
+
} else {
|
|
6547
|
+
console.log(dim(` Presets in ${home} (${PICK_HINT}):`));
|
|
6548
|
+
pick = await pickFromList(found.map((n) => ({ label: n })));
|
|
6549
|
+
}
|
|
6550
|
+
if (pick < 0) {
|
|
6551
|
+
console.log(GUTTER + "load cancelled.");
|
|
6552
|
+
console.log("");
|
|
6553
|
+
return;
|
|
6554
|
+
}
|
|
6555
|
+
file = pr.presetFilePath(found[pick], home);
|
|
6556
|
+
}
|
|
6557
|
+
const checked = pr.validatePresetFile(readFileSync(file, "utf8"));
|
|
6558
|
+
if (!checked.ok) {
|
|
6559
|
+
console.log(GUTTER + `Not loaded — ${checked.problem}`);
|
|
6560
|
+
console.log("");
|
|
6561
|
+
return;
|
|
6562
|
+
}
|
|
6563
|
+
const name = checked.name || basename(file).replace(/\.json$/i, "");
|
|
6564
|
+
const preset = checked.preset;
|
|
6565
|
+
const settings = await fetchWorkerSettings();
|
|
6566
|
+
for (const l of pr.presetPlanLines(name, preset, settings ?? {})) console.log(GUTTER + l);
|
|
6567
|
+
if ((await ask("Apply this preset? [y/N] ")) !== "y") {
|
|
6568
|
+
console.log(GUTTER + "load cancelled — nothing changed.");
|
|
6569
|
+
console.log("");
|
|
6570
|
+
return;
|
|
6571
|
+
}
|
|
6572
|
+
const { patch, existingRowId } = pr.presetToSettingsPatch(preset, settings ?? {});
|
|
6573
|
+
const md = await import("./model-doctor.mjs");
|
|
6574
|
+
const saved = await md.saveWorkerSettings(apiBase, workerKey, patch);
|
|
6575
|
+
if (!saved) {
|
|
6576
|
+
console.log(GUTTER + "the worker could not apply it — is the local API running? (`gu api status`)");
|
|
6577
|
+
console.log("");
|
|
6578
|
+
return;
|
|
6579
|
+
}
|
|
6580
|
+
let defaultNote = "";
|
|
6581
|
+
const keyId = await addCodingKey(name, preset.apiKey, existingRowId);
|
|
6582
|
+
if (keyId) {
|
|
6583
|
+
// A new row's id arrives on the key's backendId once the settings write re-read; re-fetch
|
|
6584
|
+
// the settings to find the row for this server when it was not one the account already had.
|
|
6585
|
+
let rowId = existingRowId;
|
|
6586
|
+
if (!rowId) {
|
|
6587
|
+
const after = await fetchWorkerSettings();
|
|
6588
|
+
const rows = Array.isArray(after?.agentCodingBackends) ? after.agentCodingBackends : [];
|
|
6589
|
+
rowId = rows.find((r) => r?.id && typeof r.url === "string" && r.url.replace(/\/+$/, "").toLowerCase() === preset.apiUrl.replace(/\/+$/, "").toLowerCase())?.id ?? "";
|
|
6590
|
+
}
|
|
6591
|
+
if (rowId) {
|
|
6592
|
+
const set = await setServerDefaultKey(rowId, keyId);
|
|
6593
|
+
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"}`;
|
|
6594
|
+
} else {
|
|
6595
|
+
defaultNote = " — the key saved (the row's id was not readable to make it the default; set it in /agent-coding if needed).";
|
|
6596
|
+
}
|
|
6597
|
+
} else {
|
|
6598
|
+
defaultNote = " — but the API key could not be saved (the add-key call failed; add it in /agent-coding).";
|
|
6599
|
+
}
|
|
6600
|
+
console.log(GUTTER + `preset "${name}" applied from ${file}${defaultNote}`);
|
|
6601
|
+
console.log(GUTTER + "the new setup answers from the next prompt on.");
|
|
6602
|
+
console.log("");
|
|
6603
|
+
}
|
|
6604
|
+
|
|
6432
6605
|
async function runSaveSettingsCommand(line) {
|
|
6433
6606
|
const st = await import("./settings-transfer.mjs");
|
|
6434
6607
|
const home = homedir();
|
|
@@ -10576,7 +10749,7 @@ async function main() {
|
|
|
10576
10749
|
// machine that had been working for months.
|
|
10577
10750
|
try {
|
|
10578
10751
|
const { loadState, saveState, askMode } = await import("./client-expert-run.mjs");
|
|
10579
|
-
const { clientExpertAvailable, expertPlanPending, shouldAskMode } =
|
|
10752
|
+
const { clientExpertAvailable, expertPlanPending, shouldAskMode, parseLicenseFlag, resolveFirstRunMode } =
|
|
10580
10753
|
await import("./client-expert-setup.mjs");
|
|
10581
10754
|
const st = await loadState();
|
|
10582
10755
|
const { probeMachine } = await import("./client-expert-run.mjs");
|
|
@@ -10590,10 +10763,19 @@ async function main() {
|
|
|
10590
10763
|
...machine,
|
|
10591
10764
|
});
|
|
10592
10765
|
if (shouldAskMode({ chosenMode: st.chosenMode, isTty: IS_TTY, expertAvailable: gate.available })) {
|
|
10593
|
-
|
|
10766
|
+
// Same rule as the first run: the mode menu is a pro-license feature — without
|
|
10767
|
+
// `--license <token>` this machine defaults to private (Client Expert) when it can
|
|
10768
|
+
// run it, instead of being asked.
|
|
10769
|
+
const plan = resolveFirstRunMode({
|
|
10770
|
+
licenseToken: parseLicenseFlag(argv),
|
|
10594
10771
|
expertAvailable: gate.available,
|
|
10595
|
-
expertWhy: gate.message,
|
|
10596
10772
|
});
|
|
10773
|
+
const chosen = plan.ask
|
|
10774
|
+
? await askMode(ask, {
|
|
10775
|
+
expertAvailable: gate.available,
|
|
10776
|
+
expertWhy: gate.message,
|
|
10777
|
+
})
|
|
10778
|
+
: plan.mode;
|
|
10597
10779
|
await saveState({ ...st, chosenMode: chosen ?? "client" });
|
|
10598
10780
|
if (chosen === "client-expert") {
|
|
10599
10781
|
const { runClientExpertSetup } = await import("./client-expert-run.mjs");
|
|
@@ -11535,6 +11717,14 @@ async function main() {
|
|
|
11535
11717
|
await runS3Command(line);
|
|
11536
11718
|
continue;
|
|
11537
11719
|
}
|
|
11720
|
+
if (line === "/save-preset" || line.startsWith("/save-preset ")) {
|
|
11721
|
+
await runSavePresetCommand(line);
|
|
11722
|
+
continue;
|
|
11723
|
+
}
|
|
11724
|
+
if (line === "/load-presets" || line.startsWith("/load-presets ")) {
|
|
11725
|
+
await runLoadPresetsCommand(line);
|
|
11726
|
+
continue;
|
|
11727
|
+
}
|
|
11538
11728
|
if (line === "/save-settings" || line.startsWith("/save-settings ")) {
|
|
11539
11729
|
await runSaveSettingsCommand(line);
|
|
11540
11730
|
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.763",
|
|
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",
|