@tiens.nguyen/gu-cli 1.0.780 → 1.0.781
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/gonext_agent_chat.py +36 -4
- package/gu-repl.mjs +128 -66
- package/image-capable-coders.json +9 -0
- package/image-paste.mjs +24 -3
- package/package.json +4 -1
- package/preset-apply.mjs +156 -0
- package/preset-editor.mjs +117 -8
- package/presets-transfer.mjs +208 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -28,6 +28,7 @@ import json
|
|
|
28
28
|
import os
|
|
29
29
|
import random
|
|
30
30
|
import re
|
|
31
|
+
from urllib.parse import urlparse
|
|
31
32
|
import sys
|
|
32
33
|
import threading
|
|
33
34
|
import time
|
|
@@ -7506,6 +7507,31 @@ def _prepare_image(path):
|
|
|
7506
7507
|
return im
|
|
7507
7508
|
|
|
7508
7509
|
|
|
7510
|
+
# WHICH CODERS MAY BE SENT IMAGES (#284, #316) — the SAME list the CLI gates Ctrl+V with
|
|
7511
|
+
# (image-capable-coders.json, beside this file), so the two can never disagree. A server/model is
|
|
7512
|
+
# added there only after a real request with an image came back right.
|
|
7513
|
+
def _image_coders():
|
|
7514
|
+
# GU_IMAGE_CODERS_FILE: tests only — a stand-in coder on 127.0.0.1 is put on the list there.
|
|
7515
|
+
path = os.environ.get("GU_IMAGE_CODERS_FILE") or os.path.join(
|
|
7516
|
+
os.path.dirname(os.path.abspath(__file__)), "image-capable-coders.json")
|
|
7517
|
+
try:
|
|
7518
|
+
with open(path, encoding="utf-8") as f:
|
|
7519
|
+
return json.load(f).get("coders") or []
|
|
7520
|
+
except Exception: # noqa: BLE001
|
|
7521
|
+
# No list → the one coder that has always taken images, rather than none or all.
|
|
7522
|
+
return [{"host": "api.anthropic.com"}]
|
|
7523
|
+
|
|
7524
|
+
|
|
7525
|
+
def _coder_takes_images(url, model, coders=None):
|
|
7526
|
+
"""May the question's pasted images be sent to this coding server + model?"""
|
|
7527
|
+
host = (urlparse(url or "").hostname or "").lower().rstrip(".")
|
|
7528
|
+
name = str(model or "").split("::")[-1].strip()
|
|
7529
|
+
for c in (coders if coders is not None else _image_coders()):
|
|
7530
|
+
if c.get("host") == host and (not c.get("model") or re.search(c["model"], name, re.I)):
|
|
7531
|
+
return True
|
|
7532
|
+
return False
|
|
7533
|
+
|
|
7534
|
+
|
|
7509
7535
|
def _load_turn_images(refs):
|
|
7510
7536
|
"""[(label, path)] → (PIL images ready for Claude, [(label, reason)] for the ones that failed)."""
|
|
7511
7537
|
out, failed = [], []
|
|
@@ -13252,14 +13278,20 @@ def run_agent_chat(cfg):
|
|
|
13252
13278
|
_RAG_STATE["s"] = "no-index"
|
|
13253
13279
|
except Exception: # noqa: BLE001
|
|
13254
13280
|
pass
|
|
13255
|
-
# The question's pasted images (task #284): a
|
|
13256
|
-
#
|
|
13281
|
+
# The question's pasted images (task #284, #316): a coder on the shared image list (Claude,
|
|
13282
|
+
# Kimi K3 on Moonshot / Ollama cloud) sees them; any other coder is told they were not sent
|
|
13283
|
+
# rather than left to guess from a bare "[Image #1]".
|
|
13257
13284
|
_turn_images = []
|
|
13258
13285
|
if _turn_image_refs:
|
|
13259
|
-
|
|
13286
|
+
# The Anthropic check stays first: it is THE test of "this is Claude's native API" the
|
|
13287
|
+
# rest of the file uses (and the one test launchers point at a local stand-in).
|
|
13288
|
+
if _is_anthropic_url(coding_base_url) or _coder_takes_images(coding_base_url, coding_model_id):
|
|
13260
13289
|
_turn_images, _img_failed = _load_turn_images(_turn_image_refs)
|
|
13261
13290
|
task_with_hint = _label_failed_images(task_with_hint, _img_failed)
|
|
13262
|
-
|
|
13291
|
+
# Content-free (#248): how many, and their sizes — never the pixels or the path.
|
|
13292
|
+
_log(f"attached {len(_turn_images)} image(s) to the question for "
|
|
13293
|
+
f"{(urlparse(coding_base_url or '').hostname or '?')} / {coding_model_id}"
|
|
13294
|
+
+ ("" if not _turn_images else " — " + ", ".join(f"{im.width}x{im.height}" for im in _turn_images))
|
|
13263
13295
|
+ (f", {len(_img_failed)} could not be opened" if _img_failed else ""))
|
|
13264
13296
|
for _lbl, _why in _img_failed:
|
|
13265
13297
|
_emit({"type": "step", "text": f"⚠ {_lbl} not sent — {_why}"})
|
package/gu-repl.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
|
|
27
27
|
import { readStepTokens } from "./step-tokens.mjs";
|
|
28
28
|
import { snapshotInput, restoreInput, replaceStoppedQuestion, stopNoteLines } from "./turn-stop.mjs";
|
|
29
|
-
import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
|
|
29
|
+
import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync, mkdirSync } from "node:fs";
|
|
30
30
|
import { spawn } from "node:child_process";
|
|
31
31
|
// FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
|
|
32
32
|
// `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
|
|
@@ -133,7 +133,7 @@ import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
|
133
133
|
import { readHooks } from "./hooks-config.mjs";
|
|
134
134
|
import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
|
|
135
135
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
136
|
-
import { IMAGE_NEEDS_CLAUDE, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
|
|
136
|
+
import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
|
|
137
137
|
import {
|
|
138
138
|
isDoubleClick,
|
|
139
139
|
clickRun,
|
|
@@ -1613,7 +1613,8 @@ function showImageNotice(text) {
|
|
|
1613
1613
|
if (barPinned()) paintBar();
|
|
1614
1614
|
}
|
|
1615
1615
|
/** Images go to the coding model; only a Claude coder takes them for now. */
|
|
1616
|
-
|
|
1616
|
+
// #316: Claude, and Kimi K3 on a server verified to take images — one list, shared with the agent.
|
|
1617
|
+
const coderTakesImages = () => coderTakesImagesFor(sessionCodingUrl || defaultCodingUrl, sessionCodingModel || defaultCodingModel);
|
|
1617
1618
|
function insertAtCaret(text) {
|
|
1618
1619
|
dropSelectedInputText();
|
|
1619
1620
|
const before = rl.line.slice(0, rl.cursor);
|
|
@@ -6833,12 +6834,56 @@ function withTerminalHandedOver(run) {
|
|
|
6833
6834
|
* the API key by design (unlike /save-settings) — stored 0600 in the home folder, marked
|
|
6834
6835
|
* "gu": "preset" so the two commands never offer each other's files.
|
|
6835
6836
|
*/
|
|
6837
|
+
/**
|
|
6838
|
+
* THIS ACCOUNT'S WHOLE SETUP as a version-2 preset (#320): every coding server and its models, the
|
|
6839
|
+
* default coder, the agent, the embedder — and each server's API key, read through the local API the
|
|
6840
|
+
* way the worker is handed one (GET /api/worker/coding-key?backendId). The keys stay in memory and in
|
|
6841
|
+
* the 0600 file; nothing prints them. → the preset, or null when the settings cannot be read.
|
|
6842
|
+
*/
|
|
6843
|
+
async function buildFullPresetNow(name) {
|
|
6844
|
+
return (await import("./preset-apply.mjs")).buildFullPresetFrom(replPresetApi(), { name, host: hostname() });
|
|
6845
|
+
}
|
|
6846
|
+
|
|
6847
|
+
/** The local API, as preset-apply.mjs talks to it (#320) — the same calls the rest of the REPL makes. */
|
|
6848
|
+
function replPresetApi() {
|
|
6849
|
+
return {
|
|
6850
|
+
settings: () => fetchWorkerSettings(),
|
|
6851
|
+
keys: () => fetchCodingKeys(),
|
|
6852
|
+
keyFor: (backendId) => fetchKeyForBackend(backendId),
|
|
6853
|
+
saveSettings: async (patch) => (await import("./model-doctor.mjs")).saveWorkerSettings(apiBase, workerKey, patch),
|
|
6854
|
+
addKey: (name, key, rowId) => addCodingKey(name, key, rowId),
|
|
6855
|
+
setDefaultKey: (rowId, keyId) => setServerDefaultKey(rowId, keyId),
|
|
6856
|
+
};
|
|
6857
|
+
}
|
|
6858
|
+
const replPresetIo = {
|
|
6859
|
+
ask: (q) => ask(" " + q),
|
|
6860
|
+
say: (line, tone) =>
|
|
6861
|
+
console.log(GUTTER + (tone === "err" ? red : tone === "warn" ? yellow : tone === "ok" ? accent : dim)(" " + line)),
|
|
6862
|
+
};
|
|
6863
|
+
|
|
6864
|
+
/** The key a server would be handed ({ apiKey, keyId }), or null. Never logged. */
|
|
6865
|
+
async function fetchKeyForBackend(backendId) {
|
|
6866
|
+
try {
|
|
6867
|
+
const r = await fetch(`${apiBase}/api/worker/coding-key?backendId=${encodeURIComponent(backendId)}`, {
|
|
6868
|
+
headers: { "X-Worker-Key": workerKey },
|
|
6869
|
+
signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
|
|
6870
|
+
});
|
|
6871
|
+
if (!r.ok) return null;
|
|
6872
|
+
const j = await r.json().catch(() => null);
|
|
6873
|
+
return typeof j?.apiKey === "string" && j.apiKey ? j : null;
|
|
6874
|
+
} catch {
|
|
6875
|
+
return null;
|
|
6876
|
+
}
|
|
6877
|
+
}
|
|
6878
|
+
|
|
6836
6879
|
async function runSavePresetCommand(line) {
|
|
6837
6880
|
const pr = await import("./presets-transfer.mjs");
|
|
6838
6881
|
const home = homedir();
|
|
6839
6882
|
console.log("");
|
|
6840
|
-
const usage = "usage: /save-preset <name> (
|
|
6841
|
-
|
|
6883
|
+
const usage = "usage: /save-preset <name> (saves your whole setup) · /save-preset <name> --edit (in your editor) · /save-preset <name> {json}";
|
|
6884
|
+
const raw = line.trim().slice("/save-preset".length).trim();
|
|
6885
|
+
const wantsEdit = /(^|\s)--edit(?=\s|$)/.test(raw);
|
|
6886
|
+
let rest = raw.replace(/(^|\s)--edit(?=\s|$)/, " ").trim();
|
|
6842
6887
|
if (!rest) {
|
|
6843
6888
|
const typed = String((await ask("preset name: ")) ?? "").trim();
|
|
6844
6889
|
if (!typed) {
|
|
@@ -6862,19 +6907,47 @@ async function runSavePresetCommand(line) {
|
|
|
6862
6907
|
console.log(GUTTER + dim("server + api key + agent, coding and embedding models — the key is IN this file, keep it private."));
|
|
6863
6908
|
console.log("");
|
|
6864
6909
|
};
|
|
6910
|
+
// A WHOLE-SETUP preset (#320): written as given (dated, named), then summarised — keys masked.
|
|
6911
|
+
const storeFull = (doc) => {
|
|
6912
|
+
const out = { ...doc, gu: "preset", version: pr.FULL_PRESET_VERSION, name, savedAt: new Date().toISOString(), savedOn: hostname(),
|
|
6913
|
+
note: "The whole setup: every coding server and its models, the default coder, the agent and the embedder — and each server's API key. This file CONTAINS live API keys." };
|
|
6914
|
+
writeFileSync(file, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
|
|
6915
|
+
const v = pr.validatePresetFile(JSON.stringify(out));
|
|
6916
|
+
console.log(GUTTER + `Preset "${name}" saved to ${file} — your whole setup:`);
|
|
6917
|
+
if (v.ok && v.full) for (const l of pr.fullPresetSummary(v.value, v.codingDefault)) console.log(GUTTER + dim(l));
|
|
6918
|
+
console.log(GUTTER + dim("It CONTAINS your API keys — keep it private. /load-presets → v to view it, c to copy it for another machine."));
|
|
6919
|
+
console.log("");
|
|
6920
|
+
};
|
|
6921
|
+
const ed = await import("./preset-editor.mjs");
|
|
6865
6922
|
if (json) {
|
|
6866
|
-
const checked =
|
|
6923
|
+
const checked = ed.parseEdited(json);
|
|
6867
6924
|
if (!checked.ok) {
|
|
6868
6925
|
console.log(GUTTER + `Preset not saved — ${checked.problem}`);
|
|
6869
6926
|
console.log("");
|
|
6870
6927
|
return;
|
|
6871
6928
|
}
|
|
6872
|
-
|
|
6929
|
+
if (checked.full) storeFull(checked.doc);
|
|
6930
|
+
else store(checked.preset);
|
|
6873
6931
|
return;
|
|
6874
6932
|
}
|
|
6875
6933
|
|
|
6876
|
-
// THE
|
|
6877
|
-
|
|
6934
|
+
// THE WHOLE SETUP, NOW (#320): every server, its models and key, the defaults — saved without an
|
|
6935
|
+
// editor, because "as it is now" is what a person saving their setup means. --edit opens it first.
|
|
6936
|
+
if (!wantsEdit) {
|
|
6937
|
+
if (existsSync(file) && !(await askYesNo(`A preset called "${name}" exists — replace it with the setup you have now?`, false))) {
|
|
6938
|
+
console.log(GUTTER + dim("Not saved.") + "\n");
|
|
6939
|
+
return;
|
|
6940
|
+
}
|
|
6941
|
+
const doc = await withPendingStatus("reading your servers, models and keys", () => buildFullPresetNow(name));
|
|
6942
|
+
if (!doc) {
|
|
6943
|
+
console.log(GUTTER + red(`couldn't read your settings from ${apiBase} — nothing was saved.`) + "\n");
|
|
6944
|
+
return;
|
|
6945
|
+
}
|
|
6946
|
+
storeFull(doc);
|
|
6947
|
+
return;
|
|
6948
|
+
}
|
|
6949
|
+
|
|
6950
|
+
// THE EDITOR. Filled in from the preset being edited, else from the whole setup this account has now.
|
|
6878
6951
|
const editor = ed.pickEditor();
|
|
6879
6952
|
if (!editor || !process.stdin.isTTY) {
|
|
6880
6953
|
console.log(GUTTER + (editor ? "an editor needs a terminal — " : "no editor found (set $EDITOR, e.g. export EDITOR=nano) — ") + usage);
|
|
@@ -6890,7 +6963,7 @@ async function runSavePresetCommand(line) {
|
|
|
6890
6963
|
/* unreadable — start from the current setup instead */
|
|
6891
6964
|
}
|
|
6892
6965
|
}
|
|
6893
|
-
if (!start) start = ed.presetFromSettings((await fetchWorkerSettings()) ?? {});
|
|
6966
|
+
if (!start) start = (await buildFullPresetNow(name)) ?? ed.presetFromSettings((await fetchWorkerSettings()) ?? {});
|
|
6894
6967
|
const initial = ed.editorText(name, start);
|
|
6895
6968
|
console.log(GUTTER + dim(`${start.gu ? "editing" : "new"} preset "${name}" in ${basename(editor.cmd)} — save and quit to store it, quit without changes to cancel.`));
|
|
6896
6969
|
let text = initial;
|
|
@@ -6907,7 +6980,8 @@ async function runSavePresetCommand(line) {
|
|
|
6907
6980
|
}
|
|
6908
6981
|
const parsed = ed.parseEdited(r.text);
|
|
6909
6982
|
if (parsed.ok) {
|
|
6910
|
-
|
|
6983
|
+
if (parsed.full) storeFull(parsed.doc);
|
|
6984
|
+
else store(parsed.preset);
|
|
6911
6985
|
return;
|
|
6912
6986
|
}
|
|
6913
6987
|
console.log(GUTTER + yellow(`Not saved yet — ${parsed.problem}`));
|
|
@@ -7070,7 +7144,11 @@ async function presetFromPaste(pr, home) {
|
|
|
7070
7144
|
if (!name) return "";
|
|
7071
7145
|
const file = pr.presetFilePath(name, home);
|
|
7072
7146
|
if (existsSync(file) && !(await askYesNo(`A preset called "${name}" exists — replace it?`, false))) return "";
|
|
7073
|
-
|
|
7147
|
+
// A whole-setup preset (#320) is kept as it is — every server, key and default — named and dated.
|
|
7148
|
+
const out = parsed.full
|
|
7149
|
+
? { ...parsed.doc, gu: "preset", version: pr.FULL_PRESET_VERSION, name, savedAt: new Date().toISOString(), savedOn: hostname() }
|
|
7150
|
+
: pr.buildPreset({ name, preset: parsed.preset, host: hostname() });
|
|
7151
|
+
writeFileSync(file, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
|
|
7074
7152
|
console.log(GUTTER + `Preset "${name}" saved to ${file}`);
|
|
7075
7153
|
return file;
|
|
7076
7154
|
} catch (e) {
|
|
@@ -7176,9 +7254,11 @@ async function runLoadPresetsCommand(line) {
|
|
|
7176
7254
|
return;
|
|
7177
7255
|
}
|
|
7178
7256
|
const pname = shown.name || found[pick.index];
|
|
7179
|
-
|
|
7257
|
+
// A whole-setup preset (#320) is shown and copied AS SAVED — every server, every key.
|
|
7258
|
+
const body = shown.full ? JSON.parse(readFileSync(file, "utf8")) : shown.preset;
|
|
7259
|
+
const command = ed.presetCopyCommand(pname, body);
|
|
7180
7260
|
if (pick.action === "view") {
|
|
7181
|
-
const block = ed.presetBlock(pname,
|
|
7261
|
+
const block = ed.presetBlock(pname, body, { width: process.stdout.columns || 80, gutter: GUTTER });
|
|
7182
7262
|
const [top, ...restRows] = block.rows;
|
|
7183
7263
|
const cut = block.button.from;
|
|
7184
7264
|
console.log(dim(top.slice(0, cut)) + accent(top.slice(cut, block.button.to)) + dim(top.slice(block.button.to)));
|
|
@@ -7189,7 +7269,7 @@ async function runLoadPresetsCommand(line) {
|
|
|
7189
7269
|
console.log("");
|
|
7190
7270
|
return;
|
|
7191
7271
|
}
|
|
7192
|
-
const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(
|
|
7272
|
+
const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(body) : command);
|
|
7193
7273
|
console.log(
|
|
7194
7274
|
GUTTER +
|
|
7195
7275
|
(res.ok
|
|
@@ -7202,6 +7282,17 @@ async function runLoadPresetsCommand(line) {
|
|
|
7202
7282
|
}
|
|
7203
7283
|
}
|
|
7204
7284
|
const checked = pr.validatePresetFile(readFileSync(file, "utf8"));
|
|
7285
|
+
if (checked.ok && checked.full) {
|
|
7286
|
+
const pname = checked.name || basename(file).replace(/\.json$/i, "");
|
|
7287
|
+
await applyTransferDoc(checked.value, {
|
|
7288
|
+
fromLine: `preset "${pname}" from ${file}${checked.savedOn ? ` (saved on ${checked.savedOn})` : ""} — the whole setup:`,
|
|
7289
|
+
summary: pr.fullPresetSummary(checked.value, checked.codingDefault),
|
|
7290
|
+
codingDefault: checked.codingDefault,
|
|
7291
|
+
warnings: checked.warnings,
|
|
7292
|
+
backupName: pname,
|
|
7293
|
+
});
|
|
7294
|
+
return;
|
|
7295
|
+
}
|
|
7205
7296
|
if (!checked.ok) {
|
|
7206
7297
|
console.log(GUTTER + `Not loaded — ${checked.problem}`);
|
|
7207
7298
|
console.log("");
|
|
@@ -7347,58 +7438,29 @@ async function runLoadSettingsCommand(line) {
|
|
|
7347
7438
|
console.log(GUTTER + dim(` file: ${file}\n`));
|
|
7348
7439
|
return;
|
|
7349
7440
|
}
|
|
7350
|
-
|
|
7351
|
-
|
|
7352
|
-
|
|
7353
|
-
|
|
7354
|
-
|
|
7355
|
-
const keys = (await fetchCodingKeys()) ?? [];
|
|
7356
|
-
const plan = st.planLoad(parsed.value, { ...current, keys });
|
|
7357
|
-
console.log(GUTTER + dim(` from ${file}${parsed.value.savedOn ? ` (saved on ${parsed.value.savedOn})` : ""}`));
|
|
7358
|
-
for (const l of plan.lines) console.log(GUTTER + dim(" " + l));
|
|
7359
|
-
if (parsed.carriesSecrets) {
|
|
7360
|
-
console.log(GUTTER + yellow(" this file contains API keys in plain text — delete it when you are done."));
|
|
7361
|
-
}
|
|
7362
|
-
console.log("");
|
|
7363
|
-
const go = (await ask(" apply this? [y/N] ")).trim().toLowerCase();
|
|
7364
|
-
if (go !== "y" && go !== "yes") {
|
|
7365
|
-
console.log(GUTTER + dim(" nothing changed.\n"));
|
|
7366
|
-
return;
|
|
7367
|
-
}
|
|
7368
|
-
const md = await import("./model-doctor.mjs");
|
|
7369
|
-
// `current` so a provider this account already has keeps its row id — see toSettingsPatch.
|
|
7370
|
-
const patch = st.toSettingsPatch(parsed.value, current);
|
|
7371
|
-
const saved = Object.keys(patch).length
|
|
7372
|
-
? await md.saveWorkerSettings(apiBase, workerKey, patch)
|
|
7373
|
-
: true;
|
|
7374
|
-
if (!saved) {
|
|
7375
|
-
console.log(GUTTER + red(" the API refused the settings — nothing was changed.\n"));
|
|
7376
|
-
return;
|
|
7377
|
-
}
|
|
7378
|
-
console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}${patch.ragEmbedUrl || patch.ragEmbedModel ? ", the embedding model" : ""}.`));
|
|
7441
|
+
await applyTransferDoc(parsed.value, {
|
|
7442
|
+
fromLine: `from ${file}${parsed.value.savedOn ? ` (saved on ${parsed.value.savedOn})` : ""}`,
|
|
7443
|
+
carriesSecrets: parsed.carriesSecrets,
|
|
7444
|
+
});
|
|
7445
|
+
}
|
|
7379
7446
|
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
console.log(GUTTER + (id ? accent(` added ${k.name}.`) : red(` could not add ${k.name}.`)));
|
|
7398
|
-
// The key that was its server's DEFAULT on the saving machine is the default here too.
|
|
7399
|
-
if (id && rowId && k.default) await setServerDefaultKey(rowId, id);
|
|
7400
|
-
}
|
|
7401
|
-
console.log(GUTTER + dim(" pick a coding model with /model.\n"));
|
|
7447
|
+
/**
|
|
7448
|
+
* APPLY A SETTINGS DOCUMENT — /load-settings, and since #320 a whole-setup preset. One path, so a
|
|
7449
|
+
* preset gets exactly the guarantees the settings file earned: a server this account already has
|
|
7450
|
+
* keeps its row id (keys stay bound), each key lands on its own server, and the key that was its
|
|
7451
|
+
* server's default is the default here. A preset's keys carry their VALUES, so nothing is asked;
|
|
7452
|
+
* a settings file's keys do not, so each missing one is asked for. `codingDefault` (a preset) is
|
|
7453
|
+
* restored last, once its server's row exists.
|
|
7454
|
+
*/
|
|
7455
|
+
async function applyTransferDoc(value, opts = {}) {
|
|
7456
|
+
const pa = await import("./preset-apply.mjs");
|
|
7457
|
+
return pa.applyTransfer(value, {
|
|
7458
|
+
...opts,
|
|
7459
|
+
api: replPresetApi(),
|
|
7460
|
+
io: replPresetIo,
|
|
7461
|
+
backupDir: join(homedir(), ".gonext", "preset-backups"),
|
|
7462
|
+
host: hostname(),
|
|
7463
|
+
});
|
|
7402
7464
|
}
|
|
7403
7465
|
|
|
7404
7466
|
/** The .json files directly in a folder, with enough of each to recognise a settings file. */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "Which coding models may be SENT pasted images (#284, #316). ONE list, read by the CLI (image-paste.mjs) and the agent (gonext_agent_chat.py) so the two can never disagree. Add a server/model ONLY after a real request with an image came back right (see #316's probe). host = the server's hostname exactly; model = a case-insensitive regular expression on the model name, or omitted for every model on that host.",
|
|
3
|
+
"coders": [
|
|
4
|
+
{ "host": "api.anthropic.com", "why": "Claude — #284" },
|
|
5
|
+
{ "host": "api.moonshot.ai", "model": "^kimi-k3(\\b|:|$)", "why": "Kimi K3 on Moonshot — verified 2026-09-30: answered 'Red circle' for a test PNG" },
|
|
6
|
+
{ "host": "api.moonshot.cn", "model": "^kimi-k3(\\b|:|$)", "why": "Moonshot's China endpoint, same API as .ai" },
|
|
7
|
+
{ "host": "ollama.com", "model": "^kimi-k3(\\b|:|$)", "why": "Kimi K3 on Ollama cloud — verified 2026-09-30: answered 'Red circle.' for a test PNG" }
|
|
8
|
+
]
|
|
9
|
+
}
|
package/image-paste.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
/**
|
|
2
3
|
* Pasting an image into the gu input (task #284).
|
|
3
4
|
*
|
|
@@ -64,7 +65,26 @@ const NOT_AN_IMAGE = "that file is not an image (png, jpg, gif or webp)";
|
|
|
64
65
|
|
|
65
66
|
export const imagesDir = (home = homedir()) => join(home, ".gonext", "images");
|
|
66
67
|
|
|
67
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* WHICH CODERS MAY BE SENT IMAGES (#284, #316): the list in image-capable-coders.json, shared with
|
|
70
|
+
* the agent (gonext_agent_chat.py reads the same file), so the CLI's gate and the agent's can never
|
|
71
|
+
* disagree. A server/model is added there only after a real request with an image came back right.
|
|
72
|
+
*/
|
|
73
|
+
export const IMAGE_CODERS = JSON.parse(readFileSync(new URL("./image-capable-coders.json", import.meta.url), "utf8")).coders;
|
|
74
|
+
|
|
75
|
+
/** May a pasted image be sent to this coder? `model` may be a qualified id ("openai::kimi-k3"). */
|
|
76
|
+
export function coderTakesImagesFor(url, model = "", coders = IMAGE_CODERS) {
|
|
77
|
+
let host = "";
|
|
78
|
+
try {
|
|
79
|
+
host = new URL(String(url ?? "")).hostname.toLowerCase().replace(/\.$/, "");
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const name = String(model ?? "").split("::").pop().trim();
|
|
84
|
+
return coders.some((c) => c.host === host && (!c.model || new RegExp(c.model, "i").test(name)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Is this coding server Claude's own API? (Kept for callers that ask exactly that.) */
|
|
68
88
|
export function isClaudeCoderUrl(url) {
|
|
69
89
|
try {
|
|
70
90
|
return new URL(String(url ?? "")).hostname.toLowerCase().replace(/\.$/, "") === "api.anthropic.com";
|
|
@@ -73,10 +93,11 @@ export function isClaudeCoderUrl(url) {
|
|
|
73
93
|
}
|
|
74
94
|
}
|
|
75
95
|
|
|
96
|
+
// The name is historical (#284 was Claude-only); since #316 Kimi K3 takes images too.
|
|
76
97
|
export const IMAGE_NEEDS_CLAUDE =
|
|
77
|
-
"image paste needs a
|
|
98
|
+
"image paste needs a coding model that takes images (Claude, or Kimi K3) — pick one with /model";
|
|
78
99
|
/** The same, for a window too narrow for the full sentence: the fix (/model) must survive. */
|
|
79
|
-
export const IMAGE_NEEDS_CLAUDE_SHORT = "images need
|
|
100
|
+
export const IMAGE_NEEDS_CLAUDE_SHORT = "images need Claude or Kimi K3 — /model";
|
|
80
101
|
|
|
81
102
|
/**
|
|
82
103
|
* The notice as it fits `cols` columns (#54): the full sentence, else its short form, else the
|
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.781",
|
|
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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"test:steps": "python3 scripts/py-tap.py tests/test_read_caps.py tests/test_trim_payback.py tests/test_explore.py tests/test_and_chains.py tests/test_edit_file_primary.py tests/test_bare_code_rescue.py tests/test_batching_rule.py tests/test_no_pdf_routing.py",
|
|
41
41
|
"check:image": "node scripts/image-paste-check.mjs",
|
|
42
42
|
"check:image-clipboard": "node scripts/image-paste-check.mjs --clipboard",
|
|
43
|
+
"test:presets": "node --test tests/preset-safety.test.mjs tests/preset-more.test.mjs tests/preset-apply.test.mjs tests/preset-full.test.mjs tests/presets-transfer.test.mjs tests/preset-editor.test.mjs tests/load-preset-paste.test.mjs",
|
|
43
44
|
"test:skills": "python3 scripts/py-tap.py tests/test_skills_mcp_turn.py tests/test_hooks_turn.py tests/test_mcp_client.py && node --test tests/skills.test.mjs tests/mcp-config.test.mjs tests/hooks-config.test.mjs tests/slash-menu.test.mjs tests/conversation-archive.test.mjs",
|
|
44
45
|
"capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
|
|
45
46
|
"test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
|
|
@@ -65,7 +66,9 @@
|
|
|
65
66
|
"credential-recovery.mjs",
|
|
66
67
|
"settings-transfer.mjs",
|
|
67
68
|
"presets-transfer.mjs",
|
|
69
|
+
"image-capable-coders.json",
|
|
68
70
|
"preset-editor.mjs",
|
|
71
|
+
"preset-apply.mjs",
|
|
69
72
|
"device-login.mjs",
|
|
70
73
|
"daemon-control.mjs",
|
|
71
74
|
"log-policy.mjs",
|
package/preset-apply.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SAVING AND APPLYING A WHOLE SETUP — the steps themselves, with the local API, the question and the
|
|
3
|
+
* disk passed in (#320). It used to live inside gu-repl.mjs, where only "is it wired" could be tested;
|
|
4
|
+
* here the REAL code runs in tests against a fake API that records every call and can be told to
|
|
5
|
+
* fail — which is how "loading a preset cannot break the workspace" is checked rather than hoped.
|
|
6
|
+
*
|
|
7
|
+
* Used for /save-preset, /load-presets (a version-2 preset) and /load-settings — one path for all.
|
|
8
|
+
*
|
|
9
|
+
* api = {
|
|
10
|
+
* settings(): Promise<object|null> GET /api/worker/settings
|
|
11
|
+
* keys(): Promise<array|null> GET /api/worker/coding-keys (no values)
|
|
12
|
+
* keyFor(backendId): Promise<{apiKey,keyId}|null> the key a server would be handed
|
|
13
|
+
* saveSettings(patch): Promise<boolean> POST /api/worker/settings
|
|
14
|
+
* addKey(name, key, rowId): Promise<string> → the new key's id, or "" when refused
|
|
15
|
+
* setDefaultKey(rowId, id): Promise<boolean>
|
|
16
|
+
* }
|
|
17
|
+
* io = { ask(question) → Promise<string>, say(line, tone) } tone: "dim" | "warn" | "ok" | "err"
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { buildFullPreset, keysToApply } from "./presets-transfer.mjs";
|
|
22
|
+
import { planLoad, toSettingsPatch } from "./settings-transfer.mjs";
|
|
23
|
+
import { existingBackendRows, providerRowForUrl } from "./coding-provider-setup.mjs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* THIS ACCOUNT'S WHOLE SETUP as a version-2 preset: every server, its models and the key it would
|
|
27
|
+
* be handed, the default coder, the agent, the embedder. → the preset, or null when unreadable.
|
|
28
|
+
*/
|
|
29
|
+
export async function buildFullPresetFrom(api, { name, host = "", now = new Date() } = {}) {
|
|
30
|
+
const settings = await api.settings();
|
|
31
|
+
if (!settings) return null;
|
|
32
|
+
const keys = (await api.keys()) ?? [];
|
|
33
|
+
const rows = Array.isArray(settings.agentCodingBackends) ? settings.agentCodingBackends : [];
|
|
34
|
+
const keyValues = [];
|
|
35
|
+
for (const r of rows) {
|
|
36
|
+
if (!r?.id) continue;
|
|
37
|
+
const k = await api.keyFor(r.id);
|
|
38
|
+
if (k?.apiKey) keyValues.push({ backendId: r.id, keyId: k.keyId, value: k.apiKey });
|
|
39
|
+
}
|
|
40
|
+
return buildFullPreset({ name, settings, keys, keyValues, host, now });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Save the whole current setup before a preset replaces it. → { ok, file } | { ok: false, why }.
|
|
45
|
+
* Never throws: a backup that fails is a REFUSAL to apply, not a crash.
|
|
46
|
+
*/
|
|
47
|
+
export async function backupSetup(api, presetName, { dir, host = "", now = new Date(), fs = { mkdirSync, writeFileSync } } = {}) {
|
|
48
|
+
try {
|
|
49
|
+
const doc = await buildFullPresetFrom(api, { name: `before-${presetName}`, host, now });
|
|
50
|
+
if (!doc) return { ok: false, why: "the settings could not be read" };
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
52
|
+
const stamp = new Date(now).toISOString().replace(/[:.]/g, "-");
|
|
53
|
+
const file = join(dir, `before-${String(presetName).replace(/[^\w.-]+/g, "_")}-${stamp}.json`);
|
|
54
|
+
fs.writeFileSync(file, JSON.stringify({ ...doc, savedAt: new Date(now).toISOString(), savedOn: host }, null, 2) + "\n", { mode: 0o600 });
|
|
55
|
+
return { ok: true, file };
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return { ok: false, why: e?.message ?? String(e) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* APPLY A SETTINGS DOCUMENT — a whole-setup preset, or a /save-settings file. In order, each step only
|
|
63
|
+
* if the one before it succeeded:
|
|
64
|
+
* 1. read the account; show the plan (servers, models, masked keys, warnings);
|
|
65
|
+
* 2. ask — anything but y/yes changes NOTHING;
|
|
66
|
+
* 3. back up the current setup (when `backupName`) — no backup, no apply;
|
|
67
|
+
* 4. one settings write: the servers (rows this account has keep their ids), agent, embedder;
|
|
68
|
+
* 5. the keys: carried ones added to their server, ones already there kept (and made default when the
|
|
69
|
+
* preset says so), a settings file's missing ones asked for;
|
|
70
|
+
* 6. the default coder, once its server's row exists.
|
|
71
|
+
* → { applied: boolean, step?: where it stopped, backup?: file, added: [names], failed: [names] }
|
|
72
|
+
*/
|
|
73
|
+
export async function applyTransfer(value, { api, io, fromLine = "", carriesSecrets = false, summary = null, codingDefault = null, warnings = [], backupName = "", backupDir = "", host = "", now = new Date(), fs } = {}) {
|
|
74
|
+
const say = (line, tone = "dim") => io.say(line, tone);
|
|
75
|
+
const out = { applied: false, added: [], failed: [] };
|
|
76
|
+
const current = await api.settings();
|
|
77
|
+
if (!current) {
|
|
78
|
+
say("couldn't reach the API — nothing was changed.", "err");
|
|
79
|
+
return { ...out, step: "read" };
|
|
80
|
+
}
|
|
81
|
+
const keys = (await api.keys()) ?? [];
|
|
82
|
+
const plan = planLoad(value, { ...current, keys });
|
|
83
|
+
if (fromLine) say(fromLine);
|
|
84
|
+
const lines = summary
|
|
85
|
+
? [...summary, ...plan.lines.filter((l) => /^(agent model|embedding model| a different embedder)/.test(l))]
|
|
86
|
+
: plan.lines;
|
|
87
|
+
for (const l of lines) say(l);
|
|
88
|
+
if (summary && plan.replaces) say(`replaces the ${plan.replaces} coding provider${plan.replaces === 1 ? "" : "s"} on this account.`);
|
|
89
|
+
for (const w of warnings) say("⚠ " + w, "warn");
|
|
90
|
+
if (carriesSecrets) say("this file contains API keys in plain text — delete it when you are done.", "warn");
|
|
91
|
+
const go = String((await io.ask("apply this? [y/N] ")) ?? "").trim().toLowerCase();
|
|
92
|
+
if (go !== "y" && go !== "yes") {
|
|
93
|
+
say("nothing changed.");
|
|
94
|
+
return { ...out, step: "declined" };
|
|
95
|
+
}
|
|
96
|
+
if (backupName) {
|
|
97
|
+
const b = await backupSetup(api, backupName, { dir: backupDir, host, now, fs });
|
|
98
|
+
if (!b.ok) {
|
|
99
|
+
say(`couldn't save a backup of your current setup (${b.why}) — nothing was changed.`, "err");
|
|
100
|
+
return { ...out, step: "backup" };
|
|
101
|
+
}
|
|
102
|
+
out.backup = b.file;
|
|
103
|
+
say(`backup of your current setup: ${b.file}`);
|
|
104
|
+
say(`to undo: /load-presets ${b.file}`);
|
|
105
|
+
}
|
|
106
|
+
const patch = toSettingsPatch(value, current);
|
|
107
|
+
const saved = Object.keys(patch).length ? await api.saveSettings(patch) : true;
|
|
108
|
+
if (!saved) {
|
|
109
|
+
say("the API refused the settings — nothing was changed.", "err");
|
|
110
|
+
return { ...out, step: "settings" };
|
|
111
|
+
}
|
|
112
|
+
out.applied = true;
|
|
113
|
+
say(`applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}${patch.ragEmbedUrl || patch.ragEmbedModel ? ", the embedding model" : ""}.`, "ok");
|
|
114
|
+
|
|
115
|
+
const fresh = (await api.settings()) ?? {};
|
|
116
|
+
const rows = existingBackendRows(fresh);
|
|
117
|
+
const { add, kept } = keysToApply(value, { currentKeys: keys, rows, missing: plan.missingKeys });
|
|
118
|
+
for (const k of kept) {
|
|
119
|
+
say(`kept your existing key ${k.name} for ${k.backendUrl} (the preset's copy was not used).`);
|
|
120
|
+
if (k.default && k.rowId && k.existingId && !(await api.setDefaultKey(k.rowId, k.existingId))) {
|
|
121
|
+
say(`could not make ${k.name} the default for ${k.backendUrl} — pick it in /model with ctrl+k.`, "err");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const k of add) {
|
|
125
|
+
const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
|
|
126
|
+
const typed = k.key || String((await io.ask(`key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)) ?? "").trim();
|
|
127
|
+
if (!typed) {
|
|
128
|
+
say(`skipped ${k.name} — add it later with ctrl+k.`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const id = await api.addKey(k.name, typed, k.rowId);
|
|
132
|
+
if (!id) {
|
|
133
|
+
out.failed.push(k.name);
|
|
134
|
+
say(`could not add ${k.name}${where} — add it in /model with ctrl+k.`, "err");
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
out.added.push(k.name);
|
|
138
|
+
say(`added ${k.name}.`, "ok");
|
|
139
|
+
if (k.rowId && k.default && !(await api.setDefaultKey(k.rowId, id))) {
|
|
140
|
+
say(`added ${k.name}, but could not make it the default for ${k.backendUrl} — pick it in /model with ctrl+k.`, "err");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (codingDefault?.url && codingDefault?.model) {
|
|
144
|
+
const row = providerRowForUrl(rows, codingDefault.url)?.id ?? "";
|
|
145
|
+
const ok = await api.saveSettings({
|
|
146
|
+
agentCodingModelUrl: codingDefault.url,
|
|
147
|
+
agentCodingModelName: codingDefault.model,
|
|
148
|
+
...(codingDefault.kind ? { agentCodingModelKind: codingDefault.kind } : {}),
|
|
149
|
+
...(row ? { agentCodingDefaultBackendId: row } : {}),
|
|
150
|
+
});
|
|
151
|
+
say(ok ? `coding model: ${codingDefault.model} at ${codingDefault.url}.` : "could not set the coding model — pick it with /model.", ok ? "ok" : "err");
|
|
152
|
+
return { ...out, step: "done", coder: ok };
|
|
153
|
+
}
|
|
154
|
+
say("pick a coding model with /model.");
|
|
155
|
+
return { ...out, step: "done" };
|
|
156
|
+
}
|
package/preset-editor.mjs
CHANGED
|
@@ -23,7 +23,10 @@ import { spawnSync } from "node:child_process";
|
|
|
23
23
|
import { mkdtempSync, writeFileSync, readFileSync, rmSync, chmodSync } from "node:fs";
|
|
24
24
|
import { tmpdir } from "node:os";
|
|
25
25
|
import { join } from "node:path";
|
|
26
|
-
import { validatePreset } from "./presets-transfer.mjs";
|
|
26
|
+
import { validatePreset, validatePresetFile, maskedFullPreset, FULL_PRESET_VERSION } from "./presets-transfer.mjs";
|
|
27
|
+
|
|
28
|
+
/** A version-2 preset — the WHOLE setup (#320): every server, keys, defaults. */
|
|
29
|
+
export const isFullPreset = (doc) => Boolean(doc && typeof doc === "object" && (doc.version === FULL_PRESET_VERSION || Array.isArray(doc.codingBackends)));
|
|
27
30
|
|
|
28
31
|
const clean = (v) => (typeof v === "string" ? v.trim() : "");
|
|
29
32
|
|
|
@@ -80,6 +83,19 @@ const FIELDS = ["apiUrl", "apiKey", "agentModel", "codingModel", "embedModel", "
|
|
|
80
83
|
|
|
81
84
|
/** The text the editor opens with: a few `//` lines saying what to do, then the JSON. */
|
|
82
85
|
export function editorText(name, preset = {}) {
|
|
86
|
+
if (isFullPreset(preset)) {
|
|
87
|
+
const { note: _n, savedAt: _a, savedOn: _o, ...body } = preset;
|
|
88
|
+
return (
|
|
89
|
+
[
|
|
90
|
+
`// gu preset "${name}" — THE WHOLE SETUP: every coding server and its models, each server's API key,`,
|
|
91
|
+
"// the default coder, the agent and the embedder. Edit anything, then save and quit to store it.",
|
|
92
|
+
"// Lines starting with // are ignored. Quit without saving (or unchanged) to cancel. This CONTAINS your API keys.",
|
|
93
|
+
].join("\n") +
|
|
94
|
+
"\n" +
|
|
95
|
+
JSON.stringify({ ...body, gu: "preset", version: FULL_PRESET_VERSION, name }, null, 2) +
|
|
96
|
+
"\n"
|
|
97
|
+
);
|
|
98
|
+
}
|
|
83
99
|
const body = {};
|
|
84
100
|
for (const f of FIELDS) body[f] = clean(preset[f]);
|
|
85
101
|
return (
|
|
@@ -94,6 +110,77 @@ export function editorText(name, preset = {}) {
|
|
|
94
110
|
);
|
|
95
111
|
}
|
|
96
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Where JSON text first goes wrong (an index), or -1 when it is valid — a small strict parser used only
|
|
115
|
+
* to NAME the spot of a mistake when Node's own message does not.
|
|
116
|
+
*/
|
|
117
|
+
export function jsonErrorIndex(src) {
|
|
118
|
+
let i = 0;
|
|
119
|
+
const ws = () => { while (i < src.length && " \t\r\n".includes(src[i])) i++; };
|
|
120
|
+
const fail = () => { throw i; };
|
|
121
|
+
const lit = (w) => { if (src.startsWith(w, i)) i += w.length; else fail(); };
|
|
122
|
+
const str = () => {
|
|
123
|
+
i++;
|
|
124
|
+
while (i < src.length && src[i] !== '"') {
|
|
125
|
+
if (src[i] === "\\") {
|
|
126
|
+
// only JSON's escapes: \" \\ \/ \b \f \n \r \t and \uXXXX
|
|
127
|
+
const e = src[i + 1];
|
|
128
|
+
if (e === "u") {
|
|
129
|
+
if (!/^[0-9a-fA-F]{4}$/.test(src.slice(i + 2, i + 6))) { i += 1; fail(); }
|
|
130
|
+
i += 6;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!e || !'"\\/bfnrt'.includes(e)) { i += 1; fail(); }
|
|
134
|
+
i += 2;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (src.charCodeAt(i) < 0x20) fail();
|
|
138
|
+
i++;
|
|
139
|
+
}
|
|
140
|
+
if (src[i] !== '"') fail();
|
|
141
|
+
i++;
|
|
142
|
+
};
|
|
143
|
+
const num = () => {
|
|
144
|
+
const m = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(src.slice(i));
|
|
145
|
+
if (!m) fail();
|
|
146
|
+
i += m[0].length;
|
|
147
|
+
};
|
|
148
|
+
const val = () => {
|
|
149
|
+
ws();
|
|
150
|
+
const c = src[i];
|
|
151
|
+
if (c === "{") {
|
|
152
|
+
i++; ws();
|
|
153
|
+
if (src[i] === "}") { i++; return; }
|
|
154
|
+
for (;;) {
|
|
155
|
+
ws(); if (src[i] !== '"') fail(); str(); ws();
|
|
156
|
+
if (src[i] !== ":") fail(); i++; val(); ws();
|
|
157
|
+
if (src[i] === ",") { i++; continue; }
|
|
158
|
+
if (src[i] === "}") { i++; return; }
|
|
159
|
+
fail();
|
|
160
|
+
}
|
|
161
|
+
} else if (c === "[") {
|
|
162
|
+
i++; ws();
|
|
163
|
+
if (src[i] === "]") { i++; return; }
|
|
164
|
+
for (;;) {
|
|
165
|
+
val(); ws();
|
|
166
|
+
if (src[i] === ",") { i++; continue; }
|
|
167
|
+
if (src[i] === "]") { i++; return; }
|
|
168
|
+
fail();
|
|
169
|
+
}
|
|
170
|
+
} else if (c === '"') str();
|
|
171
|
+
else if (c === "t") lit("true");
|
|
172
|
+
else if (c === "f") lit("false");
|
|
173
|
+
else if (c === "n") lit("null");
|
|
174
|
+
else num();
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
val(); ws();
|
|
178
|
+
return i < src.length ? i : -1;
|
|
179
|
+
} catch (at) {
|
|
180
|
+
return typeof at === "number" ? at : 0;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
97
184
|
/**
|
|
98
185
|
* What came back from the editor → { ok, preset } | { ok: false, problem }. The // lines are
|
|
99
186
|
* blanked (not removed) so a JSON error's line number is the line the person sees in the editor.
|
|
@@ -104,10 +191,25 @@ export function parseEdited(text) {
|
|
|
104
191
|
try {
|
|
105
192
|
JSON.parse(src);
|
|
106
193
|
} catch (e) {
|
|
107
|
-
|
|
194
|
+
// Node names the line only for SOME errors ("Expected ':' …"); a stray comma comes back as just a
|
|
195
|
+
// snippet. So when it does not say, find the spot ourselves — the person fixes a LINE in an editor.
|
|
196
|
+
let at = /line (\d+) column (\d+)/.exec(String(e?.message ?? ""));
|
|
197
|
+
if (!at) {
|
|
198
|
+
const i = jsonErrorIndex(src);
|
|
199
|
+
if (i >= 0) {
|
|
200
|
+
const before = src.slice(0, i).split("\n");
|
|
201
|
+
at = [null, String(before.length), String(before[before.length - 1].length + 1)];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
108
204
|
const where = at ? ` (line ${at[1]}, column ${at[2]})` : "";
|
|
109
205
|
return { ok: false, problem: `that is not valid JSON${where} — ${String(e?.message ?? e).replace(/\s*in JSON at position \d+.*$|\s*\(line \d+ column \d+\)/g, "")}.` };
|
|
110
206
|
}
|
|
207
|
+
const obj = JSON.parse(src);
|
|
208
|
+
if (isFullPreset(obj)) {
|
|
209
|
+
const doc = { ...obj, gu: "preset", version: FULL_PRESET_VERSION };
|
|
210
|
+
const v = validatePresetFile(JSON.stringify(doc));
|
|
211
|
+
return v.ok ? { ok: true, full: true, doc, checked: v } : v;
|
|
212
|
+
}
|
|
111
213
|
return validatePreset(src);
|
|
112
214
|
}
|
|
113
215
|
|
|
@@ -147,14 +249,20 @@ const presetBody = (preset) => {
|
|
|
147
249
|
return o;
|
|
148
250
|
};
|
|
149
251
|
|
|
150
|
-
/**
|
|
252
|
+
/** A whole-setup preset without its bookkeeping (note, dates) — what is copied and edited. */
|
|
253
|
+
const fullBody = (doc) => {
|
|
254
|
+
const { note: _n, savedAt: _a, savedOn: _o, ...rest } = doc;
|
|
255
|
+
return rest;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** What "copy" puts on the clipboard: the command that recreates the preset — WITH the key(s). */
|
|
151
259
|
export function presetCopyCommand(name, preset) {
|
|
152
|
-
return `/save-preset ${clean(name)} ${JSON.stringify(presetBody(preset))}`;
|
|
260
|
+
return `/save-preset ${clean(name)} ${JSON.stringify(isFullPreset(preset) ? fullBody(preset) : presetBody(preset))}`;
|
|
153
261
|
}
|
|
154
262
|
|
|
155
|
-
/** …or just the JSON, pretty, WITH the key. */
|
|
263
|
+
/** …or just the JSON, pretty, WITH the key(s). */
|
|
156
264
|
export function presetCopyJson(preset) {
|
|
157
|
-
return JSON.stringify(presetBody(preset), null, 2);
|
|
265
|
+
return JSON.stringify(isFullPreset(preset) ? fullBody(preset) : presetBody(preset), null, 2);
|
|
158
266
|
}
|
|
159
267
|
|
|
160
268
|
/**
|
|
@@ -163,7 +271,7 @@ export function presetCopyJson(preset) {
|
|
|
163
271
|
* → { rows, button: { from, to } } — the button's column span on row 0, 0-based, gutter included.
|
|
164
272
|
*/
|
|
165
273
|
export function presetBlock(name, preset, { width = 80, gutter = " " } = {}) {
|
|
166
|
-
const shown = { ...presetBody(preset), apiKey: maskKey(preset.apiKey) };
|
|
274
|
+
const shown = isFullPreset(preset) ? fullBody(maskedFullPreset(preset)) : { ...presetBody(preset), apiKey: maskKey(preset.apiKey) };
|
|
167
275
|
const inner = JSON.stringify(shown, null, 2).split("\n");
|
|
168
276
|
const room = Math.max(24, width - 1 - gutter.length);
|
|
169
277
|
const innerW = Math.min(room - 4, Math.max(...inner.map((l) => [...l].length), 30));
|
|
@@ -211,7 +319,8 @@ export function parsePastedPreset(text) {
|
|
|
211
319
|
}
|
|
212
320
|
const parsed = parseEdited(src);
|
|
213
321
|
if (!parsed.ok) return parsed;
|
|
214
|
-
// A whole preset file carries its name inside; the command's
|
|
322
|
+
// A whole-setup preset (#320) or a preset file carries its name inside; the command's name wins.
|
|
323
|
+
if (parsed.full) return { ok: true, full: true, doc: parsed.doc, name: name || clean(parsed.doc.name) };
|
|
215
324
|
try {
|
|
216
325
|
const doc = JSON.parse(src.replace(/^[ \t]*\/\/.*$/gm, ""));
|
|
217
326
|
if (!name && doc && typeof doc.name === "string" && doc.gu === "preset") name = doc.name.trim();
|
package/presets-transfer.mjs
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { basename, extname, isAbsolute, join, resolve } from "node:path";
|
|
23
|
-
import { ensureV1Root, isCancelWord, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
|
|
23
|
+
import { ensureV1Root, isCancelWord, MAX_MODELS_PER_SERVER, providerRowForUrl } from "./coding-provider-setup.mjs";
|
|
24
|
+
import { buildTransfer, validateTransfer, FILE_VERSION as SETTINGS_FILE_VERSION } from "./settings-transfer.mjs";
|
|
24
25
|
|
|
25
26
|
/** The file name used when none is given — in the default folder (the home directory). */
|
|
26
27
|
export const DEFAULT_PRESET_NAME = "gu-preset.json";
|
|
@@ -150,6 +151,7 @@ export function validatePresetFile(text) {
|
|
|
150
151
|
if (!doc || typeof doc !== "object" || Array.isArray(doc) || doc.gu !== "preset") {
|
|
151
152
|
return { ok: false, problem: 'that file is not a gu preset file (no "gu": "preset" marker).' };
|
|
152
153
|
}
|
|
154
|
+
if (doc.version === FULL_PRESET_VERSION) return validateFullPreset(doc);
|
|
153
155
|
if (doc.version !== PRESET_FILE_VERSION) {
|
|
154
156
|
return {
|
|
155
157
|
ok: false,
|
|
@@ -213,3 +215,208 @@ export function presetPlanLines(name, preset, current = {}) {
|
|
|
213
215
|
lines.push(" replaces the coding provider list with this one server.");
|
|
214
216
|
return lines;
|
|
215
217
|
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------------------------
|
|
220
|
+
// THE WHOLE SETUP (#320: "save/load presets lack all the API URLs I have set up … I need to see and
|
|
221
|
+
// copy all, including the API key, to another machine, so I can use it without creating the
|
|
222
|
+
// providers again or choosing the models again").
|
|
223
|
+
//
|
|
224
|
+
// A version-2 preset is the /save-settings document (every coding server with its models, the agent
|
|
225
|
+
// model, the embedder — settings-transfer.mjs) PLUS what that file refuses by design: each server's
|
|
226
|
+
// API key, in `keys[].key`, and the default coding model. It is applied with the very same code as
|
|
227
|
+
// /load-settings, so a server this account already has keeps its row id and each key lands on its
|
|
228
|
+
// own server as that server's default. Version 1 (one server) still loads, unchanged.
|
|
229
|
+
// ---------------------------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
export const FULL_PRESET_VERSION = 2;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Build a version-2 preset. `settings` = GET /api/worker/settings; `keys` = the masked key list
|
|
235
|
+
* (id, name, backendId, default); `keyValues` = [{ backendId, keyId?, value }] — each server's key as
|
|
236
|
+
* the worker would be handed it. A server with no readable key simply carries none.
|
|
237
|
+
*/
|
|
238
|
+
export function buildFullPreset({ name, settings = {}, keys = [], keyValues = [], now = new Date(), host = "" } = {}) {
|
|
239
|
+
const t = buildTransfer({ settings, keys, now, host });
|
|
240
|
+
const rows = Array.isArray(settings.agentCodingBackends) ? settings.agentCodingBackends : [];
|
|
241
|
+
const urlOf = (id) => clean(rows.find((r) => r?.id === id)?.url);
|
|
242
|
+
const list = Array.isArray(keys) ? keys : [];
|
|
243
|
+
const out = [];
|
|
244
|
+
for (const v of Array.isArray(keyValues) ? keyValues : []) {
|
|
245
|
+
const url = urlOf(v.backendId);
|
|
246
|
+
const value = clean(v.value);
|
|
247
|
+
if (!url || !value) continue;
|
|
248
|
+
// Its name: the key the server resolves to (by id), else that server's default, else the host.
|
|
249
|
+
const k = list.find((x) => x?.id && x.id === v.keyId) || list.find((x) => x?.backendId === v.backendId && x.default) ||
|
|
250
|
+
list.find((x) => x?.backendId === v.backendId);
|
|
251
|
+
const nm = clean(k?.name) || new URL(url).hostname;
|
|
252
|
+
if (out.some((o) => o.backendUrl === url)) continue; // one key per server
|
|
253
|
+
out.push({ name: nm, backendUrl: url, default: true, key: value });
|
|
254
|
+
}
|
|
255
|
+
const coder = {
|
|
256
|
+
url: clean(settings.agentCodingModelUrl),
|
|
257
|
+
model: clean(settings.agentCodingModelName),
|
|
258
|
+
kind: clean(settings.agentCodingModelKind),
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
gu: "preset",
|
|
262
|
+
version: FULL_PRESET_VERSION,
|
|
263
|
+
note: "The whole setup: every coding server and its models, the default coder, the agent and the embedder — and each server's API key. This file CONTAINS live API keys.",
|
|
264
|
+
name: clean(name),
|
|
265
|
+
savedAt: t.savedAt,
|
|
266
|
+
...(t.savedOn ? { savedOn: t.savedOn } : {}),
|
|
267
|
+
codingDefault: coder,
|
|
268
|
+
codingBackends: t.codingBackends,
|
|
269
|
+
agentModel: t.agentModel,
|
|
270
|
+
embedModel: t.embedModel,
|
|
271
|
+
keys: out,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** How many servers a preset may carry — far above anyone's real list, low enough to refuse junk. */
|
|
276
|
+
export const MAX_PRESET_SERVERS = 20;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Is this text a usable API key? A preset someone typed or pasted can carry the MASKED form copied
|
|
280
|
+
* from the view ("sk-…abcd"), a placeholder, or a line break from a wrapped paste — each would
|
|
281
|
+
* replace a working key with one that fails every turn, which is exactly how a workspace breaks.
|
|
282
|
+
* → "" when fine, else the reason.
|
|
283
|
+
*/
|
|
284
|
+
export function keyProblem(value) {
|
|
285
|
+
const v = String(value ?? "");
|
|
286
|
+
if (!v.trim()) return "it is empty";
|
|
287
|
+
if (/[…]|\.\.\./.test(v)) return "it is the MASKED form (with …) — copy the preset with c, not from the view";
|
|
288
|
+
if (/^<.*>$|your[-_ ]?(api[-_ ]?)?key|^x+$|^\*+$|placeholder|changeme/i.test(v.trim())) return "it is a placeholder, not a key";
|
|
289
|
+
if (/[\s\u0000-\u001f\u007f]/.test(v.trim()) || v !== v.trim()) return "it contains spaces or line breaks";
|
|
290
|
+
if (v.trim().length < 8) return "it is too short to be a key";
|
|
291
|
+
if (v.trim().length > 400) return "it is too long to be a key";
|
|
292
|
+
return "";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const sameServer = (a, b) => Boolean(clean(a) && clean(b)) && ensureV1Root(clean(a)).toLowerCase() === ensureV1Root(clean(b)).toLowerCase();
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Check a version-2 preset with /load-settings' own rules — and then the ones a WHOLE-SETUP preset
|
|
299
|
+
* needs so that loading it cannot leave the workspace in a mess (#320, the user: "when I load a preset
|
|
300
|
+
* from JSON I typed, it must not break my workspace — wrong API key or anything"). Everything is
|
|
301
|
+
* checked BEFORE anything is applied; the first real problem refuses the whole preset, named.
|
|
302
|
+
* Things that are allowed but worth knowing come back as `warnings` for the plan.
|
|
303
|
+
*/
|
|
304
|
+
export function validateFullPreset(doc) {
|
|
305
|
+
const back = validateTransfer(JSON.stringify({ ...doc, gu: "settings", version: SETTINGS_FILE_VERSION }));
|
|
306
|
+
if (!back.ok) return back;
|
|
307
|
+
const v = back.value;
|
|
308
|
+
const warnings = [];
|
|
309
|
+
if (!v.backends.length) return { ok: false, problem: "the preset has no coding servers — nothing to load." };
|
|
310
|
+
if (v.backends.length > MAX_PRESET_SERVERS) {
|
|
311
|
+
return { ok: false, problem: `the preset has ${v.backends.length} servers — at most ${MAX_PRESET_SERVERS}.` };
|
|
312
|
+
}
|
|
313
|
+
// SERVERS: each one real, each one once — "https://ollama.com" and "…/v1" are the SAME server,
|
|
314
|
+
// and two rows for it would split its keys between twins.
|
|
315
|
+
for (const [i, b] of v.backends.entries()) {
|
|
316
|
+
const at = `codingBackends[${i}]`;
|
|
317
|
+
if (!b.url) return { ok: false, problem: `${at} has no url.` };
|
|
318
|
+
if (!b.defaultModel) return { ok: false, problem: `${at} (${b.url}) has no defaultModel.` };
|
|
319
|
+
const twin = v.backends.findIndex((o, j) => j < i && sameServer(o.url, b.url));
|
|
320
|
+
if (twin >= 0) return { ok: false, problem: `${at} (${b.url}) is the same server as codingBackends[${twin}] (${v.backends[twin].url}).` };
|
|
321
|
+
}
|
|
322
|
+
// KEYS: each bound to a server IN THE PRESET, each a usable key, one default per server.
|
|
323
|
+
const defaults = new Map();
|
|
324
|
+
for (const [i, k] of v.keys.entries()) {
|
|
325
|
+
const at = `keys[${i}] "${k.name}"`;
|
|
326
|
+
if (!k.backendUrl) return { ok: false, problem: `${at} names no server (backendUrl) — a key belongs to a server.` };
|
|
327
|
+
const b = v.backends.find((x) => sameServer(x.url, k.backendUrl));
|
|
328
|
+
if (!b) return { ok: false, problem: `${at} is for ${k.backendUrl}, which is not one of the preset's servers.` };
|
|
329
|
+
if (k.key !== undefined) {
|
|
330
|
+
const why = keyProblem(k.key);
|
|
331
|
+
if (why) return { ok: false, problem: `${at} cannot be used: ${why}.` };
|
|
332
|
+
}
|
|
333
|
+
if (k.default) {
|
|
334
|
+
const root = ensureV1Root(b.url).toLowerCase();
|
|
335
|
+
if (defaults.has(root)) return { ok: false, problem: `${b.url} has two default keys ("${defaults.get(root)}" and "${k.name}") — mark one.` };
|
|
336
|
+
defaults.set(root, k.name);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const noKey = v.backends.filter((b) => !v.keys.some((k) => k.key && sameServer(k.backendUrl, b.url)));
|
|
340
|
+
for (const b of noKey) warnings.push(`${b.url} has no key in the preset — it keeps whatever key this machine has for it (or none).`);
|
|
341
|
+
// THE DEFAULT CODER must be a server and model the preset itself sets up.
|
|
342
|
+
const cd = doc.codingDefault && typeof doc.codingDefault === "object" ? doc.codingDefault : {};
|
|
343
|
+
if (cd.url !== undefined && cd.url !== "" && !isHttpUrl(cd.url)) {
|
|
344
|
+
return { ok: false, problem: `codingDefault.url must be an http(s) URL — got ${JSON.stringify(cd.url)}.` };
|
|
345
|
+
}
|
|
346
|
+
if (clean(cd.url) || clean(cd.model)) {
|
|
347
|
+
const b = v.backends.find((x) => sameServer(x.url, cd.url));
|
|
348
|
+
if (!b) return { ok: false, problem: `codingDefault is on ${clean(cd.url) || "no server"}, which is not one of the preset's servers.` };
|
|
349
|
+
const models = [b.defaultModel, ...b.models];
|
|
350
|
+
if (!models.includes(clean(cd.model))) {
|
|
351
|
+
return { ok: false, problem: `codingDefault model "${clean(cd.model)}" is not a model of ${b.url} (${models.join(", ")}).` };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Agent / embedder on a server the preset does not set up: allowed (a local Ollama needs no row),
|
|
355
|
+
// but said — its key then has to come from this machine.
|
|
356
|
+
for (const [role, m] of [["agent model", v.agentModel], ["embedding model", v.embedModel]]) {
|
|
357
|
+
if (m.url && !v.backends.some((b) => sameServer(b.url, m.url))) warnings.push(`the ${role} is on ${m.url}, which the preset does not set up — its key must already be on this machine.`);
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
ok: true,
|
|
361
|
+
full: true,
|
|
362
|
+
name: clean(doc.name),
|
|
363
|
+
value: v,
|
|
364
|
+
warnings,
|
|
365
|
+
carriesSecrets: back.carriesSecrets,
|
|
366
|
+
codingDefault: { url: clean(cd.url), model: clean(cd.model), kind: clean(cd.kind) },
|
|
367
|
+
savedAt: clean(doc.savedAt),
|
|
368
|
+
savedOn: clean(doc.savedOn),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* WHICH KEYS TO ADD when a preset is applied (#320) — pure, so it is tested rather than trusted.
|
|
374
|
+
* A key the preset CARRIES is added, bound to its server's row; one this machine already has by the
|
|
375
|
+
* same name on the same server is KEPT (not duplicated, not overwritten); keys without a value come
|
|
376
|
+
* from `missing` (a settings file's, asked for). → { add: [{ name, key?, rowId, default }], kept: [...] }
|
|
377
|
+
*/
|
|
378
|
+
export function keysToApply(value, { currentKeys = [], rows = [], missing = [] } = {}) {
|
|
379
|
+
const rowOf = (url) => providerRowForUrl(rows, url)?.id ?? "";
|
|
380
|
+
const existing = (k) => {
|
|
381
|
+
const row = rowOf(k.backendUrl);
|
|
382
|
+
return row ? currentKeys.find((c) => clean(c.name).toLowerCase() === clean(k.name).toLowerCase() && c.backendId === row) : null;
|
|
383
|
+
};
|
|
384
|
+
const have = (k) => Boolean(existing(k));
|
|
385
|
+
const carried = (value?.keys ?? []).filter((k) => k.key);
|
|
386
|
+
// KEPT keys still carry the preset's DEFAULT choice: restoring a backup after a preset made another
|
|
387
|
+
// key its server's default must make this one the default again, or the undo is incomplete.
|
|
388
|
+
const kept = carried.filter(have).map((k) => ({ ...k, rowId: rowOf(k.backendUrl), existingId: existing(k).id, default: k.default === true }));
|
|
389
|
+
const add = [...carried.filter((k) => !have(k)), ...missing].map((k) => ({
|
|
390
|
+
name: k.name,
|
|
391
|
+
...(k.key ? { key: k.key } : {}),
|
|
392
|
+
backendUrl: k.backendUrl ?? "",
|
|
393
|
+
rowId: k.backendUrl ? rowOf(k.backendUrl) : "",
|
|
394
|
+
default: k.default === true,
|
|
395
|
+
...(k.tail ? { tail: k.tail } : {}),
|
|
396
|
+
}));
|
|
397
|
+
return { add, kept };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A key as it may be SHOWN: its first few and last four characters. */
|
|
401
|
+
const mask = (k) => {
|
|
402
|
+
const v = clean(k);
|
|
403
|
+
if (!v) return "";
|
|
404
|
+
return v.length <= 8 ? "…" + v.slice(-2) : `${v.startsWith("sk-") ? "sk-" : v.slice(0, 3)}…${v.slice(-4)}`;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/** The whole preset with every key MASKED — what is printed; the file / the copy keep the real keys. */
|
|
408
|
+
export function maskedFullPreset(doc) {
|
|
409
|
+
return { ...doc, keys: (Array.isArray(doc?.keys) ? doc.keys : []).map((k) => ({ ...k, ...(k.key ? { key: mask(k.key) } : {}) })) };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The lines /load-presets prints before applying a version-2 preset (keys masked). */
|
|
413
|
+
export function fullPresetSummary(v, codingDefault) {
|
|
414
|
+
const lines = [];
|
|
415
|
+
for (const b of v.backends) {
|
|
416
|
+
const models = [...new Set([b.defaultModel, ...(b.models ?? [])].filter(Boolean))];
|
|
417
|
+
const key = v.keys.find((k) => k.backendUrl && ensureV1Root(k.backendUrl).toLowerCase() === ensureV1Root(b.url).toLowerCase());
|
|
418
|
+
lines.push(` ${b.url} ${models.join(", ") || "(no models)"}${key?.key ? ` · key ${key.name} ${mask(key.key)}` : " · no key"}`);
|
|
419
|
+
}
|
|
420
|
+
if (codingDefault?.url || codingDefault?.model) lines.push(`coding model: ${codingDefault.model || "?"} at ${codingDefault.url || "?"}`);
|
|
421
|
+
return lines;
|
|
422
|
+
}
|