@tiens.nguyen/gu-cli 1.0.780 → 1.0.782
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-run.mjs +1 -1
- package/gonext_agent_chat.py +36 -4
- package/gu-repl.mjs +168 -96
- 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 +181 -19
- package/presets-transfer.mjs +208 -1
package/client-expert-run.mjs
CHANGED
|
@@ -1111,7 +1111,7 @@ export async function runClientExpertSetup({
|
|
|
1111
1111
|
console.log("");
|
|
1112
1112
|
console.log(" " + cyan("✓ Model setup skipped") + dim(loadPreset
|
|
1113
1113
|
? " — the preset picker opens as soon as gu starts"
|
|
1114
|
-
: " — set them any time with /model (or /
|
|
1114
|
+
: " — set them any time with /model (or /open-presets)"));
|
|
1115
1115
|
} else if (Object.keys(written).length === 0) {
|
|
1116
1116
|
console.log(" " + cyan("✓ Models and RAG") + dim(" — already configured, left alone"));
|
|
1117
1117
|
} else {
|
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,
|
|
@@ -882,7 +882,7 @@ const COMMANDS = [
|
|
|
882
882
|
// before writing. A preset file HOLDS its API key (0600, home folder — the settings files'
|
|
883
883
|
// location), so it is marked "gu": "preset" and the two commands never offer each other's files.
|
|
884
884
|
{ name: "/save-preset", usage: "/save-preset <name> [{json}]", desc: "save a named preset — server, API key and the agent/coding/embedding models — in your editor ($EDITOR, nano…), or as one line of JSON; all validated" },
|
|
885
|
-
{ name: "/load-presets", usage: "/
|
|
885
|
+
{ name: "/open-presets", aliases: ["/load-presets"], usage: "/open-presets [name]", desc: "pick a saved preset: Enter applies it (asks first) · v view · c copy as a command · j copy JSON · e edit" },
|
|
886
886
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
887
887
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
888
888
|
// ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
|
|
@@ -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. /open-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}`));
|
|
@@ -7044,33 +7118,41 @@ async function runUndoCommand(line) {
|
|
|
7044
7118
|
*/
|
|
7045
7119
|
async function presetFromPaste(pr, home) {
|
|
7046
7120
|
const ed = await import("./preset-editor.mjs");
|
|
7047
|
-
|
|
7048
|
-
console.log(GUTTER + dim("Enter on an empty line opens your editor instead · Esc cancels."));
|
|
7121
|
+
const editor = ed.pickEditor();
|
|
7049
7122
|
let parsed = null;
|
|
7050
|
-
let lastText = "";
|
|
7051
7123
|
try {
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7124
|
+
// AN EDITOR FIRST (#322): a preset is many lines — on an Ubuntu server over SSH, nano is the
|
|
7125
|
+
// natural place to paste it. The one-line prompt is only the fallback with no editor/terminal.
|
|
7126
|
+
if (editor && process.stdin.isTTY) {
|
|
7127
|
+
// One short line (#322): the save keys are in the file's own header, where they are needed.
|
|
7128
|
+
console.log(GUTTER + dim(`Opening ${basename(editor.cmd)} — paste your preset there, then save and quit.`));
|
|
7129
|
+
parsed = await fixPresetInEditor(ed, ed.pasteTemplate(editor.saveHint), { editor });
|
|
7130
|
+
if (!parsed) return "";
|
|
7131
|
+
} else {
|
|
7132
|
+
if (!editor) console.log(GUTTER + yellow(ed.NO_EDITOR_HINT) + dim(" — or paste it here, on one line."));
|
|
7133
|
+
console.log(GUTTER + "Paste a preset you already have — its JSON, a preset file, or a /save-preset command.");
|
|
7134
|
+
console.log(GUTTER + dim("Esc cancels."));
|
|
7135
|
+
while (!parsed) {
|
|
7136
|
+
const text = String((await ask("preset: ")) ?? "");
|
|
7137
|
+
if (!text.trim()) return "";
|
|
7138
|
+
const p = ed.parsePastedPreset(text);
|
|
7139
|
+
if (p.ok) {
|
|
7140
|
+
parsed = p;
|
|
7141
|
+
break;
|
|
7142
|
+
}
|
|
7143
|
+
console.log(GUTTER + yellow(`Not a preset yet — ${p.problem}`));
|
|
7144
|
+
console.log(GUTTER + dim("Paste it again, or Esc to cancel."));
|
|
7064
7145
|
}
|
|
7065
|
-
lastText = text;
|
|
7066
|
-
console.log(GUTTER + yellow(`Not a preset yet — ${p.problem}`));
|
|
7067
|
-
console.log(GUTTER + dim("Paste it again, or press Enter to fix it in your editor."));
|
|
7068
7146
|
}
|
|
7069
7147
|
const name = await askPresetName(pr, parsed.name || "preset");
|
|
7070
7148
|
if (!name) return "";
|
|
7071
7149
|
const file = pr.presetFilePath(name, home);
|
|
7072
7150
|
if (existsSync(file) && !(await askYesNo(`A preset called "${name}" exists — replace it?`, false))) return "";
|
|
7073
|
-
|
|
7151
|
+
// A whole-setup preset (#320) is kept as it is — every server, key and default — named and dated.
|
|
7152
|
+
const out = parsed.full
|
|
7153
|
+
? { ...parsed.doc, gu: "preset", version: pr.FULL_PRESET_VERSION, name, savedAt: new Date().toISOString(), savedOn: hostname() }
|
|
7154
|
+
: pr.buildPreset({ name, preset: parsed.preset, host: hostname() });
|
|
7155
|
+
writeFileSync(file, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
|
|
7074
7156
|
console.log(GUTTER + `Preset "${name}" saved to ${file}`);
|
|
7075
7157
|
return file;
|
|
7076
7158
|
} catch (e) {
|
|
@@ -7080,10 +7162,9 @@ async function presetFromPaste(pr, home) {
|
|
|
7080
7162
|
}
|
|
7081
7163
|
|
|
7082
7164
|
/** The editor loop of #312, seeded with `text`: → a parsed preset, or null when given up. */
|
|
7083
|
-
async function fixPresetInEditor(ed, text) {
|
|
7084
|
-
const editor = ed.pickEditor();
|
|
7165
|
+
async function fixPresetInEditor(ed, text, { editor = ed.pickEditor() } = {}) {
|
|
7085
7166
|
if (!editor || !process.stdin.isTTY) {
|
|
7086
|
-
console.log(GUTTER + (editor ? "an editor needs a terminal." :
|
|
7167
|
+
console.log(GUTTER + (editor ? "an editor needs a terminal." : ed.NO_EDITOR_HINT + "."));
|
|
7087
7168
|
return null;
|
|
7088
7169
|
}
|
|
7089
7170
|
let t = text;
|
|
@@ -7095,9 +7176,14 @@ async function fixPresetInEditor(ed, text) {
|
|
|
7095
7176
|
}
|
|
7096
7177
|
const p = ed.parsePastedPreset(r.text);
|
|
7097
7178
|
if (p.ok) return p;
|
|
7098
|
-
|
|
7179
|
+
// Nothing pasted (only the // header left, or quit without saving) → a cancel, not an error.
|
|
7180
|
+
if (p.empty || ed.parseEdited(r.text).empty) {
|
|
7181
|
+
console.log(GUTTER + dim("Nothing pasted — cancelled."));
|
|
7182
|
+
return null;
|
|
7183
|
+
}
|
|
7184
|
+
console.log(GUTTER + yellow(`Not a preset yet — ${p.problem}`));
|
|
7099
7185
|
if (!(await askYesNo("Edit again?", true))) return null;
|
|
7100
|
-
t = r.text;
|
|
7186
|
+
t = r.text; // their text, with the mistake in it — not the empty template again
|
|
7101
7187
|
}
|
|
7102
7188
|
}
|
|
7103
7189
|
|
|
@@ -7116,7 +7202,8 @@ async function runLoadPresetsCommand(line) {
|
|
|
7116
7202
|
const home = homedir();
|
|
7117
7203
|
console.log("");
|
|
7118
7204
|
let file = "";
|
|
7119
|
-
|
|
7205
|
+
// /open-presets (#322), or its old name /load-presets — the printed undo lines of older backups use it.
|
|
7206
|
+
const typed = line.trim().replace(/^\/(open|load)-presets\b/, "").trim();
|
|
7120
7207
|
if (typed) {
|
|
7121
7208
|
file = pr.presetFilePath(typed, home);
|
|
7122
7209
|
if (!file) return; // a cancel word
|
|
@@ -7145,7 +7232,7 @@ async function runLoadPresetsCommand(line) {
|
|
|
7145
7232
|
return;
|
|
7146
7233
|
}
|
|
7147
7234
|
} else {
|
|
7148
|
-
console.log(dim(` Presets in ${home} (${ed.PICKER_HINT}):`));
|
|
7235
|
+
console.log(dim(` Presets in ${home === homedir() ? "~" : home} (${ed.PICKER_HINT}):`));
|
|
7149
7236
|
const PASTE_ROW = "+ Paste a preset I already have…";
|
|
7150
7237
|
const pick = await pickFromList([...found, PASTE_ROW], 0, { keys: ed.PICKER_KEYS });
|
|
7151
7238
|
if (!pick || pick.index < 0) {
|
|
@@ -7176,20 +7263,22 @@ async function runLoadPresetsCommand(line) {
|
|
|
7176
7263
|
return;
|
|
7177
7264
|
}
|
|
7178
7265
|
const pname = shown.name || found[pick.index];
|
|
7179
|
-
|
|
7266
|
+
// A whole-setup preset (#320) is shown and copied AS SAVED — every server, every key.
|
|
7267
|
+
const body = shown.full ? JSON.parse(readFileSync(file, "utf8")) : shown.preset;
|
|
7268
|
+
const command = ed.presetCopyCommand(pname, body);
|
|
7180
7269
|
if (pick.action === "view") {
|
|
7181
|
-
const block = ed.presetBlock(pname,
|
|
7270
|
+
const block = ed.presetBlock(pname, body, { width: process.stdout.columns || 80, gutter: GUTTER });
|
|
7182
7271
|
const [top, ...restRows] = block.rows;
|
|
7183
7272
|
const cut = block.button.from;
|
|
7184
7273
|
console.log(dim(top.slice(0, cut)) + accent(top.slice(cut, block.button.to)) + dim(top.slice(block.button.to)));
|
|
7185
7274
|
for (const r of restRows) console.log(r);
|
|
7186
7275
|
registerCopyBlock(block.rows.length, command, 0, { button: block.button, note: "✓ copied — includes the API key" });
|
|
7187
7276
|
console.log(GUTTER + dim("click ⧉ copy for a /save-preset command to paste anywhere — it includes the real key."));
|
|
7188
|
-
console.log(GUTTER + dim(`/
|
|
7277
|
+
console.log(GUTTER + dim(`/open-presets ${pname} applies it.`));
|
|
7189
7278
|
console.log("");
|
|
7190
7279
|
return;
|
|
7191
7280
|
}
|
|
7192
|
-
const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(
|
|
7281
|
+
const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(body) : command);
|
|
7193
7282
|
console.log(
|
|
7194
7283
|
GUTTER +
|
|
7195
7284
|
(res.ok
|
|
@@ -7202,6 +7291,17 @@ async function runLoadPresetsCommand(line) {
|
|
|
7202
7291
|
}
|
|
7203
7292
|
}
|
|
7204
7293
|
const checked = pr.validatePresetFile(readFileSync(file, "utf8"));
|
|
7294
|
+
if (checked.ok && checked.full) {
|
|
7295
|
+
const pname = checked.name || basename(file).replace(/\.json$/i, "");
|
|
7296
|
+
await applyTransferDoc(checked.value, {
|
|
7297
|
+
fromLine: `preset "${pname}" from ${file}${checked.savedOn ? ` (saved on ${checked.savedOn})` : ""} — the whole setup:`,
|
|
7298
|
+
summary: pr.fullPresetSummary(checked.value, checked.codingDefault),
|
|
7299
|
+
codingDefault: checked.codingDefault,
|
|
7300
|
+
warnings: checked.warnings,
|
|
7301
|
+
backupName: pname,
|
|
7302
|
+
});
|
|
7303
|
+
return;
|
|
7304
|
+
}
|
|
7205
7305
|
if (!checked.ok) {
|
|
7206
7306
|
console.log(GUTTER + `Not loaded — ${checked.problem}`);
|
|
7207
7307
|
console.log("");
|
|
@@ -7347,58 +7447,29 @@ async function runLoadSettingsCommand(line) {
|
|
|
7347
7447
|
console.log(GUTTER + dim(` file: ${file}\n`));
|
|
7348
7448
|
return;
|
|
7349
7449
|
}
|
|
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" : ""}.`));
|
|
7450
|
+
await applyTransferDoc(parsed.value, {
|
|
7451
|
+
fromLine: `from ${file}${parsed.value.savedOn ? ` (saved on ${parsed.value.savedOn})` : ""}`,
|
|
7452
|
+
carriesSecrets: parsed.carriesSecrets,
|
|
7453
|
+
});
|
|
7454
|
+
}
|
|
7379
7455
|
|
|
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"));
|
|
7456
|
+
/**
|
|
7457
|
+
* APPLY A SETTINGS DOCUMENT — /load-settings, and since #320 a whole-setup preset. One path, so a
|
|
7458
|
+
* preset gets exactly the guarantees the settings file earned: a server this account already has
|
|
7459
|
+
* keeps its row id (keys stay bound), each key lands on its own server, and the key that was its
|
|
7460
|
+
* server's default is the default here. A preset's keys carry their VALUES, so nothing is asked;
|
|
7461
|
+
* a settings file's keys do not, so each missing one is asked for. `codingDefault` (a preset) is
|
|
7462
|
+
* restored last, once its server's row exists.
|
|
7463
|
+
*/
|
|
7464
|
+
async function applyTransferDoc(value, opts = {}) {
|
|
7465
|
+
const pa = await import("./preset-apply.mjs");
|
|
7466
|
+
return pa.applyTransfer(value, {
|
|
7467
|
+
...opts,
|
|
7468
|
+
api: replPresetApi(),
|
|
7469
|
+
io: replPresetIo,
|
|
7470
|
+
backupDir: join(homedir(), ".gonext", "preset-backups"),
|
|
7471
|
+
host: hostname(),
|
|
7472
|
+
});
|
|
7402
7473
|
}
|
|
7403
7474
|
|
|
7404
7475
|
/** The .json files directly in a folder, with enough of each to recognise a settings file. */
|
|
@@ -12390,9 +12461,9 @@ async function main() {
|
|
|
12390
12461
|
if (openPresetsAtStart) {
|
|
12391
12462
|
openPresetsAtStart = false;
|
|
12392
12463
|
try {
|
|
12393
|
-
await runLoadPresetsCommand("/
|
|
12464
|
+
await runLoadPresetsCommand("/open-presets");
|
|
12394
12465
|
} catch (e) {
|
|
12395
|
-
console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /
|
|
12466
|
+
console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /open-presets)`));
|
|
12396
12467
|
}
|
|
12397
12468
|
}
|
|
12398
12469
|
|
|
@@ -12607,7 +12678,8 @@ async function main() {
|
|
|
12607
12678
|
await runSavePresetCommand(line);
|
|
12608
12679
|
continue;
|
|
12609
12680
|
}
|
|
12610
|
-
|
|
12681
|
+
// #322: /open-presets, and its old name /load-presets (older backups print it in their undo line)
|
|
12682
|
+
if (line === "/open-presets" || line.startsWith("/open-presets ") || line === "/load-presets" || line.startsWith("/load-presets ")) {
|
|
12611
12683
|
await runLoadPresetsCommand(line);
|
|
12612
12684
|
continue;
|
|
12613
12685
|
}
|
|
@@ -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.782",
|
|
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/open-presets.test.mjs 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: /open-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,25 +23,63 @@ 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
|
|
|
33
|
+
/** GUI editors — no use in an SSH session or on a server with no display: they hang or fail. */
|
|
34
|
+
const GUI_EDITORS = /^(code|code-insiders|cursor|codium|subl|sublime_text|gedit|kate|kwrite|mousepad|xed|pluma|atom|mate|zed|gvim|mvim|macvim|notepad\+\+|notepad|textedit|bbedit|open|xdg-open)(\.exe)?$/i;
|
|
35
|
+
|
|
30
36
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
37
|
+
* Are we in a terminal with no graphical display — an SSH session, or a Linux server? (#322: the user
|
|
38
|
+
* often runs gu on an Ubuntu server over SSH.)
|
|
39
|
+
*/
|
|
40
|
+
export function isHeadless({ env = process.env, platform = process.platform } = {}) {
|
|
41
|
+
if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT) return true;
|
|
42
|
+
return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Which editor to run → { cmd, args, saveHint }. $VISUAL then $EDITOR (either may carry arguments, e.g.
|
|
47
|
+
* "code --wait"); then nano, vim, vi; Notepad on Windows.
|
|
48
|
+
*
|
|
49
|
+
* HEADLESS (SSH, a server — #322): a GUI editor named in $VISUAL/$EDITOR is skipped, because it would
|
|
50
|
+
* hang or fail with no display; a terminal one is used instead. nano gets -w (no hard-wrapping): a
|
|
51
|
+
* pasted preset's long lines carry API keys, and a wrapped line would split a key and break it.
|
|
52
|
+
* `saveHint` is what the header tells the person to press — editors differ, and a server's nano is
|
|
53
|
+
* often someone's first.
|
|
34
54
|
*/
|
|
35
55
|
export function pickEditor({ env = process.env, platform = process.platform, has = onPath } = {}) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
const headless = isHeadless({ env, platform });
|
|
57
|
+
const named = [env.VISUAL, env.EDITOR].map(splitCommand).filter((w) => w.length);
|
|
58
|
+
for (const words of named) {
|
|
59
|
+
const base = words[0].split(/[\\/]/).pop();
|
|
60
|
+
if (headless && GUI_EDITORS.test(base)) continue;
|
|
61
|
+
return withHints({ cmd: words[0], args: words.slice(1) });
|
|
39
62
|
}
|
|
40
|
-
if (platform === "win32") return { cmd: "notepad", args: [] };
|
|
41
|
-
for (const cmd of ["nano", "vi"]) if (has(cmd)) return { cmd, args: [] };
|
|
63
|
+
if (platform === "win32" && !headless) return withHints({ cmd: "notepad", args: [] });
|
|
64
|
+
for (const cmd of ["nano", "vim", "vi"]) if (has(cmd)) return withHints({ cmd, args: [] });
|
|
42
65
|
return null;
|
|
43
66
|
}
|
|
44
67
|
|
|
68
|
+
function withHints(ed) {
|
|
69
|
+
const base = ed.cmd.split(/[\\/]/).pop().replace(/\.exe$/i, "").toLowerCase();
|
|
70
|
+
if (base === "nano" && !ed.args.some((a) => /^(-w|--nowrap|-b|--breaklonglines)$/.test(a))) ed = { ...ed, args: ["-w", ...ed.args] };
|
|
71
|
+
const saveHint =
|
|
72
|
+
base === "nano" ? "nano: Ctrl+O, Enter, Ctrl+X to save · Ctrl+X, N to cancel"
|
|
73
|
+
: /^(vim?|nvim|gvim)$/.test(base) ? "vi: Esc, then :wq Enter to save · :q! Enter to cancel"
|
|
74
|
+
: base === "notepad" ? "Notepad: Ctrl+S, then close the window"
|
|
75
|
+
: base === "emacs" ? "emacs: Ctrl+X Ctrl+S, then Ctrl+X Ctrl+C"
|
|
76
|
+
: "save, then quit the editor";
|
|
77
|
+
return { ...ed, saveHint };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What to say when a server has no editor at all. */
|
|
81
|
+
export const NO_EDITOR_HINT = "no editor found — install one (Ubuntu: sudo apt install nano) or set $EDITOR";
|
|
82
|
+
|
|
45
83
|
/** "code --wait" → ["code", "--wait"]; quotes group ("'/Applications/My Editor' -w"). */
|
|
46
84
|
export function splitCommand(s) {
|
|
47
85
|
const out = [];
|
|
@@ -80,6 +118,19 @@ const FIELDS = ["apiUrl", "apiKey", "agentModel", "codingModel", "embedModel", "
|
|
|
80
118
|
|
|
81
119
|
/** The text the editor opens with: a few `//` lines saying what to do, then the JSON. */
|
|
82
120
|
export function editorText(name, preset = {}) {
|
|
121
|
+
if (isFullPreset(preset)) {
|
|
122
|
+
const { note: _n, savedAt: _a, savedOn: _o, ...body } = preset;
|
|
123
|
+
return (
|
|
124
|
+
[
|
|
125
|
+
`// gu preset "${name}" — THE WHOLE SETUP: every coding server and its models, each server's API key,`,
|
|
126
|
+
"// the default coder, the agent and the embedder. Edit anything, then save and quit to store it.",
|
|
127
|
+
"// Lines starting with // are ignored. Quit without saving (or unchanged) to cancel. This CONTAINS your API keys.",
|
|
128
|
+
].join("\n") +
|
|
129
|
+
"\n" +
|
|
130
|
+
JSON.stringify({ ...body, gu: "preset", version: FULL_PRESET_VERSION, name }, null, 2) +
|
|
131
|
+
"\n"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
83
134
|
const body = {};
|
|
84
135
|
for (const f of FIELDS) body[f] = clean(preset[f]);
|
|
85
136
|
return (
|
|
@@ -94,6 +145,77 @@ export function editorText(name, preset = {}) {
|
|
|
94
145
|
);
|
|
95
146
|
}
|
|
96
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Where JSON text first goes wrong (an index), or -1 when it is valid — a small strict parser used only
|
|
150
|
+
* to NAME the spot of a mistake when Node's own message does not.
|
|
151
|
+
*/
|
|
152
|
+
export function jsonErrorIndex(src) {
|
|
153
|
+
let i = 0;
|
|
154
|
+
const ws = () => { while (i < src.length && " \t\r\n".includes(src[i])) i++; };
|
|
155
|
+
const fail = () => { throw i; };
|
|
156
|
+
const lit = (w) => { if (src.startsWith(w, i)) i += w.length; else fail(); };
|
|
157
|
+
const str = () => {
|
|
158
|
+
i++;
|
|
159
|
+
while (i < src.length && src[i] !== '"') {
|
|
160
|
+
if (src[i] === "\\") {
|
|
161
|
+
// only JSON's escapes: \" \\ \/ \b \f \n \r \t and \uXXXX
|
|
162
|
+
const e = src[i + 1];
|
|
163
|
+
if (e === "u") {
|
|
164
|
+
if (!/^[0-9a-fA-F]{4}$/.test(src.slice(i + 2, i + 6))) { i += 1; fail(); }
|
|
165
|
+
i += 6;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!e || !'"\\/bfnrt'.includes(e)) { i += 1; fail(); }
|
|
169
|
+
i += 2;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (src.charCodeAt(i) < 0x20) fail();
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
if (src[i] !== '"') fail();
|
|
176
|
+
i++;
|
|
177
|
+
};
|
|
178
|
+
const num = () => {
|
|
179
|
+
const m = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(src.slice(i));
|
|
180
|
+
if (!m) fail();
|
|
181
|
+
i += m[0].length;
|
|
182
|
+
};
|
|
183
|
+
const val = () => {
|
|
184
|
+
ws();
|
|
185
|
+
const c = src[i];
|
|
186
|
+
if (c === "{") {
|
|
187
|
+
i++; ws();
|
|
188
|
+
if (src[i] === "}") { i++; return; }
|
|
189
|
+
for (;;) {
|
|
190
|
+
ws(); if (src[i] !== '"') fail(); str(); ws();
|
|
191
|
+
if (src[i] !== ":") fail(); i++; val(); ws();
|
|
192
|
+
if (src[i] === ",") { i++; continue; }
|
|
193
|
+
if (src[i] === "}") { i++; return; }
|
|
194
|
+
fail();
|
|
195
|
+
}
|
|
196
|
+
} else if (c === "[") {
|
|
197
|
+
i++; ws();
|
|
198
|
+
if (src[i] === "]") { i++; return; }
|
|
199
|
+
for (;;) {
|
|
200
|
+
val(); ws();
|
|
201
|
+
if (src[i] === ",") { i++; continue; }
|
|
202
|
+
if (src[i] === "]") { i++; return; }
|
|
203
|
+
fail();
|
|
204
|
+
}
|
|
205
|
+
} else if (c === '"') str();
|
|
206
|
+
else if (c === "t") lit("true");
|
|
207
|
+
else if (c === "f") lit("false");
|
|
208
|
+
else if (c === "n") lit("null");
|
|
209
|
+
else num();
|
|
210
|
+
};
|
|
211
|
+
try {
|
|
212
|
+
val(); ws();
|
|
213
|
+
return i < src.length ? i : -1;
|
|
214
|
+
} catch (at) {
|
|
215
|
+
return typeof at === "number" ? at : 0;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
97
219
|
/**
|
|
98
220
|
* What came back from the editor → { ok, preset } | { ok: false, problem }. The // lines are
|
|
99
221
|
* blanked (not removed) so a JSON error's line number is the line the person sees in the editor.
|
|
@@ -104,10 +226,25 @@ export function parseEdited(text) {
|
|
|
104
226
|
try {
|
|
105
227
|
JSON.parse(src);
|
|
106
228
|
} catch (e) {
|
|
107
|
-
|
|
229
|
+
// Node names the line only for SOME errors ("Expected ':' …"); a stray comma comes back as just a
|
|
230
|
+
// snippet. So when it does not say, find the spot ourselves — the person fixes a LINE in an editor.
|
|
231
|
+
let at = /line (\d+) column (\d+)/.exec(String(e?.message ?? ""));
|
|
232
|
+
if (!at) {
|
|
233
|
+
const i = jsonErrorIndex(src);
|
|
234
|
+
if (i >= 0) {
|
|
235
|
+
const before = src.slice(0, i).split("\n");
|
|
236
|
+
at = [null, String(before.length), String(before[before.length - 1].length + 1)];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
108
239
|
const where = at ? ` (line ${at[1]}, column ${at[2]})` : "";
|
|
109
240
|
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
241
|
}
|
|
242
|
+
const obj = JSON.parse(src);
|
|
243
|
+
if (isFullPreset(obj)) {
|
|
244
|
+
const doc = { ...obj, gu: "preset", version: FULL_PRESET_VERSION };
|
|
245
|
+
const v = validatePresetFile(JSON.stringify(doc));
|
|
246
|
+
return v.ok ? { ok: true, full: true, doc, checked: v } : v;
|
|
247
|
+
}
|
|
111
248
|
return validatePreset(src);
|
|
112
249
|
}
|
|
113
250
|
|
|
@@ -147,14 +284,20 @@ const presetBody = (preset) => {
|
|
|
147
284
|
return o;
|
|
148
285
|
};
|
|
149
286
|
|
|
150
|
-
/**
|
|
287
|
+
/** A whole-setup preset without its bookkeeping (note, dates) — what is copied and edited. */
|
|
288
|
+
const fullBody = (doc) => {
|
|
289
|
+
const { note: _n, savedAt: _a, savedOn: _o, ...rest } = doc;
|
|
290
|
+
return rest;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/** What "copy" puts on the clipboard: the command that recreates the preset — WITH the key(s). */
|
|
151
294
|
export function presetCopyCommand(name, preset) {
|
|
152
|
-
return `/save-preset ${clean(name)} ${JSON.stringify(presetBody(preset))}`;
|
|
295
|
+
return `/save-preset ${clean(name)} ${JSON.stringify(isFullPreset(preset) ? fullBody(preset) : presetBody(preset))}`;
|
|
153
296
|
}
|
|
154
297
|
|
|
155
|
-
/** …or just the JSON, pretty, WITH the key. */
|
|
298
|
+
/** …or just the JSON, pretty, WITH the key(s). */
|
|
156
299
|
export function presetCopyJson(preset) {
|
|
157
|
-
return JSON.stringify(presetBody(preset), null, 2);
|
|
300
|
+
return JSON.stringify(isFullPreset(preset) ? fullBody(preset) : presetBody(preset), null, 2);
|
|
158
301
|
}
|
|
159
302
|
|
|
160
303
|
/**
|
|
@@ -163,7 +306,7 @@ export function presetCopyJson(preset) {
|
|
|
163
306
|
* → { rows, button: { from, to } } — the button's column span on row 0, 0-based, gutter included.
|
|
164
307
|
*/
|
|
165
308
|
export function presetBlock(name, preset, { width = 80, gutter = " " } = {}) {
|
|
166
|
-
const shown = { ...presetBody(preset), apiKey: maskKey(preset.apiKey) };
|
|
309
|
+
const shown = isFullPreset(preset) ? fullBody(maskedFullPreset(preset)) : { ...presetBody(preset), apiKey: maskKey(preset.apiKey) };
|
|
167
310
|
const inner = JSON.stringify(shown, null, 2).split("\n");
|
|
168
311
|
const room = Math.max(24, width - 1 - gutter.length);
|
|
169
312
|
const innerW = Math.min(room - 4, Math.max(...inner.map((l) => [...l].length), 30));
|
|
@@ -184,7 +327,24 @@ export function presetBlock(name, preset, { width = 80, gutter = " " } = {}) {
|
|
|
184
327
|
|
|
185
328
|
/** The keys the /load-presets picker takes, in the order its hint line names them. */
|
|
186
329
|
export const PICKER_KEYS = { v: "view", c: "copy", j: "json", e: "edit" };
|
|
187
|
-
|
|
330
|
+
// Short enough for one 80-column row with " Presets in ~ (…):" around it (#322 — it wrapped).
|
|
331
|
+
export const PICKER_HINT = "Enter use · v view · c copy · j JSON · e edit · Esc";
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The file the editor opens for "paste a preset" (#322: "load preset doesn't give me any editor so
|
|
335
|
+
* that I can copy-paste the JSON" — and mostly on an Ubuntu server over SSH). Empty below the header;
|
|
336
|
+
* the header says how to save in THIS editor.
|
|
337
|
+
*/
|
|
338
|
+
export function pasteTemplate(saveHint = "save, then quit the editor") {
|
|
339
|
+
return [
|
|
340
|
+
// Each line fits an 80-column SSH terminal — nano cuts longer ones off, save keys and all.
|
|
341
|
+
"// Paste your gu preset below (JSON, a preset file or a /save-preset line).",
|
|
342
|
+
`// ${saveHint}`,
|
|
343
|
+
"// Lines starting with // are ignored. Quit without saving to cancel.",
|
|
344
|
+
"",
|
|
345
|
+
"",
|
|
346
|
+
].join("\n");
|
|
347
|
+
}
|
|
188
348
|
|
|
189
349
|
/**
|
|
190
350
|
* A PRESET SOMEONE ALREADY HAS, pasted into /load-presets (#317: "I need a place to input a preset
|
|
@@ -202,16 +362,18 @@ export function parsePastedPreset(text) {
|
|
|
202
362
|
let src = String(text ?? "").trim();
|
|
203
363
|
if (!src) return { ok: false, empty: true, problem: "nothing was pasted." };
|
|
204
364
|
let name = "";
|
|
205
|
-
|
|
365
|
+
// The editor's // header (#322) sits above a pasted command — look past it.
|
|
366
|
+
const cmd = /^\/save-preset\s+(\S+)\s+([\s\S]+)$/.exec(src.replace(/^[ \t]*\/\/.*$/gm, "").trim());
|
|
206
367
|
if (cmd) {
|
|
207
368
|
name = cmd[1];
|
|
208
369
|
src = cmd[2].trim();
|
|
209
|
-
} else if (/^\/save-preset\b/.test(src)) {
|
|
370
|
+
} else if (/^\/save-preset\b/.test(src.replace(/^[ \t]*\/\/.*$/gm, "").trim())) {
|
|
210
371
|
return { ok: false, problem: "that /save-preset command has no JSON after its name." };
|
|
211
372
|
}
|
|
212
373
|
const parsed = parseEdited(src);
|
|
213
374
|
if (!parsed.ok) return parsed;
|
|
214
|
-
// A whole preset file carries its name inside; the command's
|
|
375
|
+
// A whole-setup preset (#320) or a preset file carries its name inside; the command's name wins.
|
|
376
|
+
if (parsed.full) return { ok: true, full: true, doc: parsed.doc, name: name || clean(parsed.doc.name) };
|
|
215
377
|
try {
|
|
216
378
|
const doc = JSON.parse(src.replace(/^[ \t]*\/\/.*$/gm, ""));
|
|
217
379
|
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
|
+
}
|