@tiens.nguyen/gu-cli 1.0.779 → 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 +170 -133
- 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/terminal-layout.mjs +38 -0
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.
|
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
maskKey,
|
|
75
75
|
otherAgentRows,
|
|
76
76
|
updateRow,
|
|
77
|
+
noticeRow,
|
|
77
78
|
updateNotice,
|
|
78
79
|
answerBlock,
|
|
79
80
|
updateRestartNote,
|
|
@@ -132,7 +133,7 @@ import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
|
132
133
|
import { readHooks } from "./hooks-config.mjs";
|
|
133
134
|
import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
|
|
134
135
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
135
|
-
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";
|
|
136
137
|
import {
|
|
137
138
|
isDoubleClick,
|
|
138
139
|
clickRun,
|
|
@@ -1612,7 +1613,8 @@ function showImageNotice(text) {
|
|
|
1612
1613
|
if (barPinned()) paintBar();
|
|
1613
1614
|
}
|
|
1614
1615
|
/** Images go to the coding model; only a Claude coder takes them for now. */
|
|
1615
|
-
|
|
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);
|
|
1616
1618
|
function insertAtCaret(text) {
|
|
1617
1619
|
dropSelectedInputText();
|
|
1618
1620
|
const before = rl.line.slice(0, rl.cursor);
|
|
@@ -2604,6 +2606,28 @@ function syncBarPinning() {
|
|
|
2604
2606
|
if (!canPinBar()) unpinBar();
|
|
2605
2607
|
else pinBar();
|
|
2606
2608
|
}
|
|
2609
|
+
/**
|
|
2610
|
+
* THE NOTICE ROW ABOVE THE SEPARATOR (#318) — or null when there is nothing to say.
|
|
2611
|
+
* Priority, highest first: where you are (jump to bottom), what you can act on with a click (an
|
|
2612
|
+
* update), a standing fact about the licence (the gu token notice — yellow only when urgent), and
|
|
2613
|
+
* other agents. Dropped whole, lowest first, when they do not all fit.
|
|
2614
|
+
*/
|
|
2615
|
+
function zoneNotice(width) {
|
|
2616
|
+
const items = [];
|
|
2617
|
+
if (scrolledBack()) {
|
|
2618
|
+
const n = scrollOffset;
|
|
2619
|
+
items.push({ key: "jump", clickable: true, paint: accent, text: `↓ jump to bottom (click) · ${n} line${n === 1 ? "" : "s"} below` });
|
|
2620
|
+
}
|
|
2621
|
+
if (availableUpdates.any) {
|
|
2622
|
+
const up = updateRow({ ...availableUpdates, width: 400 });
|
|
2623
|
+
if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
|
|
2624
|
+
}
|
|
2625
|
+
if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
|
|
2626
|
+
const agents = agentsIndicator(otherAgents);
|
|
2627
|
+
if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
|
|
2628
|
+
return noticeRow(items, { width, gutter: GUTTER });
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2607
2631
|
/**
|
|
2608
2632
|
* The whole frozen zone, top to bottom: a separator, a blank row, the INPUT LINE, then the
|
|
2609
2633
|
* bar's rule, token and mode rows. The blank is what lifts the separator — and the control it
|
|
@@ -2624,17 +2648,12 @@ function frozenZone() {
|
|
|
2624
2648
|
// #307: a question held until its turn prints anything sits on top of the status block — where
|
|
2625
2649
|
// it will land once committed, so nothing moves when it does.
|
|
2626
2650
|
const status = footerActive ? [] : [...pendingEchoRows(), ...liveStatusRows];
|
|
2627
|
-
//
|
|
2628
|
-
//
|
|
2629
|
-
//
|
|
2630
|
-
|
|
2631
|
-
// The token notice counts only when it FITS — the same test its write below uses. With
|
|
2632
|
-
// Boolean(tokenNotice) alone, a narrow terminal lifted a blank row for a line it never drew.
|
|
2633
|
-
const tokenFits = Boolean(tokenNotice) && visibleLen(tokenNotice.text) + 4 <= width - 1;
|
|
2634
|
-
const headControl =
|
|
2635
|
-
scrolledBack() || availableUpdates.any || tokenFits || Boolean(agentsIndicator(otherAgents));
|
|
2651
|
+
// THE SEPARATOR CARRIES NOTHING (#318). What used to ride on it — jump-to-bottom, the update
|
|
2652
|
+
// control, the gu token notice, the agents count — is on its own NOTICE ROW above it; the head
|
|
2653
|
+
// is just the rule, with no blank to lift a control off the typed line.
|
|
2654
|
+
const notice = zoneNotice(width);
|
|
2636
2655
|
const zone = inputZoneRows({
|
|
2637
|
-
headControl,
|
|
2656
|
+
headControl: false,
|
|
2638
2657
|
width,
|
|
2639
2658
|
// #258: typing during a turn is not sent — the row says so (an ask() label still comes first).
|
|
2640
2659
|
prompt: secretInput && secretPrompt
|
|
@@ -2679,61 +2698,14 @@ function frozenZone() {
|
|
|
2679
2698
|
selection: secretInput ? null : inputSelection,
|
|
2680
2699
|
highlight: highlightRow,
|
|
2681
2700
|
});
|
|
2682
|
-
const rows = status.concat(zone.rows, askHintRows(width), footerRows());
|
|
2683
|
-
//
|
|
2684
|
-
//
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
// right now; an update is neither, and will still be there when you come back.
|
|
2691
|
-
// · otherwise, and only when something is genuinely newer → the update control (task #181).
|
|
2692
|
-
//
|
|
2693
|
-
// Both are stored with their COLUMN span, because the row is hit-tested by column now — one
|
|
2694
|
-
// row, two possible targets, and "anywhere on this row" would make the wrong one fire.
|
|
2695
|
-
updateRowTarget = null;
|
|
2696
|
-
agentsRowTarget = null;
|
|
2697
|
-
if (scrolledBack()) {
|
|
2698
|
-
const hint = jumpToBottomRow({ below: scrollOffset, width });
|
|
2699
|
-
rows[sep] = clipVisible(accent(hint.text), width - 1);
|
|
2700
|
-
} else if (availableUpdates.any) {
|
|
2701
|
-
const up = updateRow({ ...availableUpdates, width });
|
|
2702
|
-
if (up) {
|
|
2703
|
-
rows[sep] = clipVisible(dim(up.text.slice(0, up.from)) + accent(up.text.slice(up.from)), width - 1);
|
|
2704
|
-
updateRowTarget = { from: up.from, to: up.to };
|
|
2705
|
-
}
|
|
2706
|
-
} else if (tokenFits) {
|
|
2707
|
-
// The gu token's one line (#232), right-aligned like the controls around it. BELOW the update
|
|
2708
|
-
// control, because an update is actionable with a click and this is a standing fact; ABOVE the
|
|
2709
|
-
// agents count, because it needs the user and that does not. Not clickable. Too narrow to say
|
|
2710
|
-
// it whole → the condition above lets the agents count (or nothing) have the row instead.
|
|
2711
|
-
const len = visibleLen(tokenNotice.text);
|
|
2712
|
-
const from = width - 1 - len;
|
|
2713
|
-
// Yellow only when the user must act (plan ended, over the computer limit); an old token is
|
|
2714
|
-
// routine and reads in the rule's own dim grey (#292 — the yellow line "seems so serious").
|
|
2715
|
-
const paint = tokenNotice.urgent ? yellow : dim;
|
|
2716
|
-
rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + paint(tokenNotice.text), width - 1);
|
|
2717
|
-
} else if (agentsIndicator(otherAgents)) {
|
|
2718
|
-
// …and when there is no update to offer, the same row carries the agents count (task #195).
|
|
2719
|
-
// THREE THINGS CANNOT SHARE ONE ROW, so they are ranked by how time-critical they are:
|
|
2720
|
-
// the jump hint is about where you are RIGHT NOW, an update is a standing fact, and other
|
|
2721
|
-
// agents change on their own. Update wins over agents because it is rarer and actionable;
|
|
2722
|
-
// agents are always one click away through the same row once it is free.
|
|
2723
|
-
const label = `${agentsIndicator(otherAgents)} running (click)`;
|
|
2724
|
-
const len = visibleLen(label);
|
|
2725
|
-
if (len + 4 <= width - 1) {
|
|
2726
|
-
const from = width - 1 - len;
|
|
2727
|
-
rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + accent(label), width - 1);
|
|
2728
|
-
agentsRowTarget = { from, to: from + len };
|
|
2729
|
-
}
|
|
2730
|
-
}
|
|
2731
|
-
// Spread, not a hand-written pick: this used to list the fields it forwarded, and adding
|
|
2732
|
-
// rowCount/caretRow to inputZoneRows left them silently undefined here — the caret would have
|
|
2733
|
-
// parked on the first row of a wrapped input forever.
|
|
2734
|
-
// statusRows so paintBar can offset the input row and the separator's absolute position;
|
|
2735
|
-
// without it every click and caret park would be `status.length` rows out.
|
|
2736
|
-
return { ...zone, rows, statusRows: sep };
|
|
2701
|
+
const rows = status.concat(notice ? [notice.row] : [], zone.rows, askHintRows(width), footerRows());
|
|
2702
|
+
// The separator is `status.length (+1 for the notice row)` rows down — everything that writes to
|
|
2703
|
+
// or hit-tests a row by index counts from here.
|
|
2704
|
+
const sep = status.length + (notice ? 1 : 0);
|
|
2705
|
+
updateRowTarget = notice?.spans.update ?? null;
|
|
2706
|
+
agentsRowTarget = notice?.spans.agents ?? null;
|
|
2707
|
+
jumpSpan = notice?.spans.jump ?? null;
|
|
2708
|
+
return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
|
|
2737
2709
|
}
|
|
2738
2710
|
/**
|
|
2739
2711
|
* Put the output position back at column 0 of its own row — without printing anything.
|
|
@@ -2773,8 +2745,10 @@ function paintBar() {
|
|
|
2773
2745
|
// The rows exactly as painted, so a double-click reads the same characters the user is
|
|
2774
2746
|
// looking at rather than re-deriving them from rl.line at a width this may not be.
|
|
2775
2747
|
inputRowTexts = zone.rows.slice((zone.statusRows ?? 0) + head);
|
|
2776
|
-
|
|
2777
|
-
|
|
2748
|
+
// THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
|
|
2749
|
+
const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
|
|
2750
|
+
jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
|
|
2751
|
+
updateRowAbs = updateRowTarget || agentsRowTarget ? noticeAbs : 0;
|
|
2778
2752
|
// preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
|
|
2779
2753
|
// is parked on the input line right after, so there is nothing to preserve here.
|
|
2780
2754
|
ui(
|
|
@@ -2928,6 +2902,8 @@ let liveStatusRows = [];
|
|
|
2928
2902
|
// Where the "jump to bottom" line currently is (1-based viewport row + column span), so a click
|
|
2929
2903
|
// can hit it. Null while the view is live.
|
|
2930
2904
|
let jumpRowTarget = null;
|
|
2905
|
+
/** The jump-to-bottom hint's column span on the notice row (#318), set by frozenZone. */
|
|
2906
|
+
let jumpSpan = null;
|
|
2931
2907
|
// The update control's column span on the separator row, and that row's absolute position.
|
|
2932
2908
|
// Column-scoped (task #181): the row is shared with the jump-to-bottom hint, so a click has to
|
|
2933
2909
|
// land ON the label rather than anywhere along it.
|
|
@@ -6858,12 +6834,56 @@ function withTerminalHandedOver(run) {
|
|
|
6858
6834
|
* the API key by design (unlike /save-settings) — stored 0600 in the home folder, marked
|
|
6859
6835
|
* "gu": "preset" so the two commands never offer each other's files.
|
|
6860
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
|
+
|
|
6861
6879
|
async function runSavePresetCommand(line) {
|
|
6862
6880
|
const pr = await import("./presets-transfer.mjs");
|
|
6863
6881
|
const home = homedir();
|
|
6864
6882
|
console.log("");
|
|
6865
|
-
const usage = "usage: /save-preset <name> (
|
|
6866
|
-
|
|
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();
|
|
6867
6887
|
if (!rest) {
|
|
6868
6888
|
const typed = String((await ask("preset name: ")) ?? "").trim();
|
|
6869
6889
|
if (!typed) {
|
|
@@ -6887,19 +6907,47 @@ async function runSavePresetCommand(line) {
|
|
|
6887
6907
|
console.log(GUTTER + dim("server + api key + agent, coding and embedding models — the key is IN this file, keep it private."));
|
|
6888
6908
|
console.log("");
|
|
6889
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");
|
|
6890
6922
|
if (json) {
|
|
6891
|
-
const checked =
|
|
6923
|
+
const checked = ed.parseEdited(json);
|
|
6892
6924
|
if (!checked.ok) {
|
|
6893
6925
|
console.log(GUTTER + `Preset not saved — ${checked.problem}`);
|
|
6894
6926
|
console.log("");
|
|
6895
6927
|
return;
|
|
6896
6928
|
}
|
|
6897
|
-
|
|
6929
|
+
if (checked.full) storeFull(checked.doc);
|
|
6930
|
+
else store(checked.preset);
|
|
6898
6931
|
return;
|
|
6899
6932
|
}
|
|
6900
6933
|
|
|
6901
|
-
// THE
|
|
6902
|
-
|
|
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.
|
|
6903
6951
|
const editor = ed.pickEditor();
|
|
6904
6952
|
if (!editor || !process.stdin.isTTY) {
|
|
6905
6953
|
console.log(GUTTER + (editor ? "an editor needs a terminal — " : "no editor found (set $EDITOR, e.g. export EDITOR=nano) — ") + usage);
|
|
@@ -6915,7 +6963,7 @@ async function runSavePresetCommand(line) {
|
|
|
6915
6963
|
/* unreadable — start from the current setup instead */
|
|
6916
6964
|
}
|
|
6917
6965
|
}
|
|
6918
|
-
if (!start) start = ed.presetFromSettings((await fetchWorkerSettings()) ?? {});
|
|
6966
|
+
if (!start) start = (await buildFullPresetNow(name)) ?? ed.presetFromSettings((await fetchWorkerSettings()) ?? {});
|
|
6919
6967
|
const initial = ed.editorText(name, start);
|
|
6920
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.`));
|
|
6921
6969
|
let text = initial;
|
|
@@ -6932,7 +6980,8 @@ async function runSavePresetCommand(line) {
|
|
|
6932
6980
|
}
|
|
6933
6981
|
const parsed = ed.parseEdited(r.text);
|
|
6934
6982
|
if (parsed.ok) {
|
|
6935
|
-
|
|
6983
|
+
if (parsed.full) storeFull(parsed.doc);
|
|
6984
|
+
else store(parsed.preset);
|
|
6936
6985
|
return;
|
|
6937
6986
|
}
|
|
6938
6987
|
console.log(GUTTER + yellow(`Not saved yet — ${parsed.problem}`));
|
|
@@ -7095,7 +7144,11 @@ async function presetFromPaste(pr, home) {
|
|
|
7095
7144
|
if (!name) return "";
|
|
7096
7145
|
const file = pr.presetFilePath(name, home);
|
|
7097
7146
|
if (existsSync(file) && !(await askYesNo(`A preset called "${name}" exists — replace it?`, false))) return "";
|
|
7098
|
-
|
|
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 });
|
|
7099
7152
|
console.log(GUTTER + `Preset "${name}" saved to ${file}`);
|
|
7100
7153
|
return file;
|
|
7101
7154
|
} catch (e) {
|
|
@@ -7201,9 +7254,11 @@ async function runLoadPresetsCommand(line) {
|
|
|
7201
7254
|
return;
|
|
7202
7255
|
}
|
|
7203
7256
|
const pname = shown.name || found[pick.index];
|
|
7204
|
-
|
|
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);
|
|
7205
7260
|
if (pick.action === "view") {
|
|
7206
|
-
const block = ed.presetBlock(pname,
|
|
7261
|
+
const block = ed.presetBlock(pname, body, { width: process.stdout.columns || 80, gutter: GUTTER });
|
|
7207
7262
|
const [top, ...restRows] = block.rows;
|
|
7208
7263
|
const cut = block.button.from;
|
|
7209
7264
|
console.log(dim(top.slice(0, cut)) + accent(top.slice(cut, block.button.to)) + dim(top.slice(block.button.to)));
|
|
@@ -7214,7 +7269,7 @@ async function runLoadPresetsCommand(line) {
|
|
|
7214
7269
|
console.log("");
|
|
7215
7270
|
return;
|
|
7216
7271
|
}
|
|
7217
|
-
const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(
|
|
7272
|
+
const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(body) : command);
|
|
7218
7273
|
console.log(
|
|
7219
7274
|
GUTTER +
|
|
7220
7275
|
(res.ok
|
|
@@ -7227,6 +7282,17 @@ async function runLoadPresetsCommand(line) {
|
|
|
7227
7282
|
}
|
|
7228
7283
|
}
|
|
7229
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
|
+
}
|
|
7230
7296
|
if (!checked.ok) {
|
|
7231
7297
|
console.log(GUTTER + `Not loaded — ${checked.problem}`);
|
|
7232
7298
|
console.log("");
|
|
@@ -7372,58 +7438,29 @@ async function runLoadSettingsCommand(line) {
|
|
|
7372
7438
|
console.log(GUTTER + dim(` file: ${file}\n`));
|
|
7373
7439
|
return;
|
|
7374
7440
|
}
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
const keys = (await fetchCodingKeys()) ?? [];
|
|
7381
|
-
const plan = st.planLoad(parsed.value, { ...current, keys });
|
|
7382
|
-
console.log(GUTTER + dim(` from ${file}${parsed.value.savedOn ? ` (saved on ${parsed.value.savedOn})` : ""}`));
|
|
7383
|
-
for (const l of plan.lines) console.log(GUTTER + dim(" " + l));
|
|
7384
|
-
if (parsed.carriesSecrets) {
|
|
7385
|
-
console.log(GUTTER + yellow(" this file contains API keys in plain text — delete it when you are done."));
|
|
7386
|
-
}
|
|
7387
|
-
console.log("");
|
|
7388
|
-
const go = (await ask(" apply this? [y/N] ")).trim().toLowerCase();
|
|
7389
|
-
if (go !== "y" && go !== "yes") {
|
|
7390
|
-
console.log(GUTTER + dim(" nothing changed.\n"));
|
|
7391
|
-
return;
|
|
7392
|
-
}
|
|
7393
|
-
const md = await import("./model-doctor.mjs");
|
|
7394
|
-
// `current` so a provider this account already has keeps its row id — see toSettingsPatch.
|
|
7395
|
-
const patch = st.toSettingsPatch(parsed.value, current);
|
|
7396
|
-
const saved = Object.keys(patch).length
|
|
7397
|
-
? await md.saveWorkerSettings(apiBase, workerKey, patch)
|
|
7398
|
-
: true;
|
|
7399
|
-
if (!saved) {
|
|
7400
|
-
console.log(GUTTER + red(" the API refused the settings — nothing was changed.\n"));
|
|
7401
|
-
return;
|
|
7402
|
-
}
|
|
7403
|
-
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
|
+
}
|
|
7404
7446
|
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7414
|
-
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
console.log(GUTTER + (id ? accent(` added ${k.name}.`) : red(` could not add ${k.name}.`)));
|
|
7423
|
-
// The key that was its server's DEFAULT on the saving machine is the default here too.
|
|
7424
|
-
if (id && rowId && k.default) await setServerDefaultKey(rowId, id);
|
|
7425
|
-
}
|
|
7426
|
-
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
|
+
});
|
|
7427
7464
|
}
|
|
7428
7465
|
|
|
7429
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
|
+
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -2814,3 +2814,41 @@ export function hintRows(lines, { width, gutter = " ", paint = (s) => s } = {}
|
|
|
2814
2814
|
}
|
|
2815
2815
|
return out.map((r) => gutter + paint(r));
|
|
2816
2816
|
}
|
|
2817
|
+
|
|
2818
|
+
/**
|
|
2819
|
+
* THE NOTICE ROW — its own row, directly ABOVE the separator (#318).
|
|
2820
|
+
*
|
|
2821
|
+
* The rule above ">>" is only a separator (the user, angrily: "the line is for separating the
|
|
2822
|
+
* user's input, not for that"). Four things used to ride on it, one at a time — the jump-to-bottom
|
|
2823
|
+
* hint, the update control, the gu token notice, the agents count — because the row looked free.
|
|
2824
|
+
* Now they share this row instead, right-aligned, joined with " · ", in PRIORITY order (the first
|
|
2825
|
+
* item is the most important). What does not fit is dropped WHOLE, lowest priority first — never
|
|
2826
|
+
* cut mid-word. No items → null, and the zone simply has no such row.
|
|
2827
|
+
*
|
|
2828
|
+
* items: [{ key, text, paint?, clickable? }] → { row, spans: { [key]: { from, to } } } | null
|
|
2829
|
+
* `spans` are 0-based columns of the CLICKABLE items, gutter included — what a click is tested on.
|
|
2830
|
+
*/
|
|
2831
|
+
export function noticeRow(items, { width = 80, gutter = " ", sep = " · " } = {}) {
|
|
2832
|
+
const w = Math.max(20, width | 0);
|
|
2833
|
+
const room = w - 1 - visibleLen(gutter);
|
|
2834
|
+
const list = (Array.isArray(items) ? items : []).filter((i) => i && i.text);
|
|
2835
|
+
let kept = list;
|
|
2836
|
+
const joinedLen = (xs) => xs.reduce((n, x, i) => n + visibleLen(x.text) + (i ? sep.length : 0), 0);
|
|
2837
|
+
while (kept.length && joinedLen(kept) > room) kept = kept.slice(0, -1);
|
|
2838
|
+
if (!kept.length) return null;
|
|
2839
|
+
const start = w - 1 - joinedLen(kept);
|
|
2840
|
+
let row = " ".repeat(start);
|
|
2841
|
+
let col = start;
|
|
2842
|
+
const spans = {};
|
|
2843
|
+
kept.forEach((x, i) => {
|
|
2844
|
+
if (i) {
|
|
2845
|
+
row += sep;
|
|
2846
|
+
col += sep.length;
|
|
2847
|
+
}
|
|
2848
|
+
const len = visibleLen(x.text);
|
|
2849
|
+
if (x.clickable) spans[x.key] = { from: col, to: col + len };
|
|
2850
|
+
row += (x.paint ?? ((s) => s))(x.text);
|
|
2851
|
+
col += len;
|
|
2852
|
+
});
|
|
2853
|
+
return { row, spans };
|
|
2854
|
+
}
|