@tiens.nguyen/gu-cli 1.0.781 → 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/gu-repl.mjs +41 -31
- package/package.json +2 -2
- package/preset-apply.mjs +1 -1
- package/preset-editor.mjs +64 -11
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/gu-repl.mjs
CHANGED
|
@@ -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
|
|
@@ -6915,7 +6915,7 @@ async function runSavePresetCommand(line) {
|
|
|
6915
6915
|
const v = pr.validatePresetFile(JSON.stringify(out));
|
|
6916
6916
|
console.log(GUTTER + `Preset "${name}" saved to ${file} — your whole setup:`);
|
|
6917
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. /
|
|
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
6919
|
console.log("");
|
|
6920
6920
|
};
|
|
6921
6921
|
const ed = await import("./preset-editor.mjs");
|
|
@@ -7118,27 +7118,31 @@ async function runUndoCommand(line) {
|
|
|
7118
7118
|
*/
|
|
7119
7119
|
async function presetFromPaste(pr, home) {
|
|
7120
7120
|
const ed = await import("./preset-editor.mjs");
|
|
7121
|
-
|
|
7122
|
-
console.log(GUTTER + dim("Enter on an empty line opens your editor instead · Esc cancels."));
|
|
7121
|
+
const editor = ed.pickEditor();
|
|
7123
7122
|
let parsed = null;
|
|
7124
|
-
let lastText = "";
|
|
7125
7123
|
try {
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
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."));
|
|
7138
7145
|
}
|
|
7139
|
-
lastText = text;
|
|
7140
|
-
console.log(GUTTER + yellow(`Not a preset yet — ${p.problem}`));
|
|
7141
|
-
console.log(GUTTER + dim("Paste it again, or press Enter to fix it in your editor."));
|
|
7142
7146
|
}
|
|
7143
7147
|
const name = await askPresetName(pr, parsed.name || "preset");
|
|
7144
7148
|
if (!name) return "";
|
|
@@ -7158,10 +7162,9 @@ async function presetFromPaste(pr, home) {
|
|
|
7158
7162
|
}
|
|
7159
7163
|
|
|
7160
7164
|
/** The editor loop of #312, seeded with `text`: → a parsed preset, or null when given up. */
|
|
7161
|
-
async function fixPresetInEditor(ed, text) {
|
|
7162
|
-
const editor = ed.pickEditor();
|
|
7165
|
+
async function fixPresetInEditor(ed, text, { editor = ed.pickEditor() } = {}) {
|
|
7163
7166
|
if (!editor || !process.stdin.isTTY) {
|
|
7164
|
-
console.log(GUTTER + (editor ? "an editor needs a terminal." :
|
|
7167
|
+
console.log(GUTTER + (editor ? "an editor needs a terminal." : ed.NO_EDITOR_HINT + "."));
|
|
7165
7168
|
return null;
|
|
7166
7169
|
}
|
|
7167
7170
|
let t = text;
|
|
@@ -7173,9 +7176,14 @@ async function fixPresetInEditor(ed, text) {
|
|
|
7173
7176
|
}
|
|
7174
7177
|
const p = ed.parsePastedPreset(r.text);
|
|
7175
7178
|
if (p.ok) return p;
|
|
7176
|
-
|
|
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}`));
|
|
7177
7185
|
if (!(await askYesNo("Edit again?", true))) return null;
|
|
7178
|
-
t = r.text;
|
|
7186
|
+
t = r.text; // their text, with the mistake in it — not the empty template again
|
|
7179
7187
|
}
|
|
7180
7188
|
}
|
|
7181
7189
|
|
|
@@ -7194,7 +7202,8 @@ async function runLoadPresetsCommand(line) {
|
|
|
7194
7202
|
const home = homedir();
|
|
7195
7203
|
console.log("");
|
|
7196
7204
|
let file = "";
|
|
7197
|
-
|
|
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();
|
|
7198
7207
|
if (typed) {
|
|
7199
7208
|
file = pr.presetFilePath(typed, home);
|
|
7200
7209
|
if (!file) return; // a cancel word
|
|
@@ -7223,7 +7232,7 @@ async function runLoadPresetsCommand(line) {
|
|
|
7223
7232
|
return;
|
|
7224
7233
|
}
|
|
7225
7234
|
} else {
|
|
7226
|
-
console.log(dim(` Presets in ${home} (${ed.PICKER_HINT}):`));
|
|
7235
|
+
console.log(dim(` Presets in ${home === homedir() ? "~" : home} (${ed.PICKER_HINT}):`));
|
|
7227
7236
|
const PASTE_ROW = "+ Paste a preset I already have…";
|
|
7228
7237
|
const pick = await pickFromList([...found, PASTE_ROW], 0, { keys: ed.PICKER_KEYS });
|
|
7229
7238
|
if (!pick || pick.index < 0) {
|
|
@@ -7265,7 +7274,7 @@ async function runLoadPresetsCommand(line) {
|
|
|
7265
7274
|
for (const r of restRows) console.log(r);
|
|
7266
7275
|
registerCopyBlock(block.rows.length, command, 0, { button: block.button, note: "✓ copied — includes the API key" });
|
|
7267
7276
|
console.log(GUTTER + dim("click ⧉ copy for a /save-preset command to paste anywhere — it includes the real key."));
|
|
7268
|
-
console.log(GUTTER + dim(`/
|
|
7277
|
+
console.log(GUTTER + dim(`/open-presets ${pname} applies it.`));
|
|
7269
7278
|
console.log("");
|
|
7270
7279
|
return;
|
|
7271
7280
|
}
|
|
@@ -12452,9 +12461,9 @@ async function main() {
|
|
|
12452
12461
|
if (openPresetsAtStart) {
|
|
12453
12462
|
openPresetsAtStart = false;
|
|
12454
12463
|
try {
|
|
12455
|
-
await runLoadPresetsCommand("/
|
|
12464
|
+
await runLoadPresetsCommand("/open-presets");
|
|
12456
12465
|
} catch (e) {
|
|
12457
|
-
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)`));
|
|
12458
12467
|
}
|
|
12459
12468
|
}
|
|
12460
12469
|
|
|
@@ -12669,7 +12678,8 @@ async function main() {
|
|
|
12669
12678
|
await runSavePresetCommand(line);
|
|
12670
12679
|
continue;
|
|
12671
12680
|
}
|
|
12672
|
-
|
|
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 ")) {
|
|
12673
12683
|
await runLoadPresetsCommand(line);
|
|
12674
12684
|
continue;
|
|
12675
12685
|
}
|
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,7 +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
|
+
"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",
|
|
44
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",
|
|
45
45
|
"capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
|
|
46
46
|
"test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
|
package/preset-apply.mjs
CHANGED
|
@@ -101,7 +101,7 @@ export async function applyTransfer(value, { api, io, fromLine = "", carriesSecr
|
|
|
101
101
|
}
|
|
102
102
|
out.backup = b.file;
|
|
103
103
|
say(`backup of your current setup: ${b.file}`);
|
|
104
|
-
say(`to undo: /
|
|
104
|
+
say(`to undo: /open-presets ${b.file}`);
|
|
105
105
|
}
|
|
106
106
|
const patch = toSettingsPatch(value, current);
|
|
107
107
|
const saved = Object.keys(patch).length ? await api.saveSettings(patch) : true;
|
package/preset-editor.mjs
CHANGED
|
@@ -30,21 +30,56 @@ export const isFullPreset = (doc) => Boolean(doc && typeof doc === "object" && (
|
|
|
30
30
|
|
|
31
31
|
const clean = (v) => (typeof v === "string" ? v.trim() : "");
|
|
32
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
|
+
|
|
33
36
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
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.
|
|
37
54
|
*/
|
|
38
55
|
export function pickEditor({ env = process.env, platform = process.platform, has = onPath } = {}) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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) });
|
|
42
62
|
}
|
|
43
|
-
if (platform === "win32") return { cmd: "notepad", args: [] };
|
|
44
|
-
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: [] });
|
|
45
65
|
return null;
|
|
46
66
|
}
|
|
47
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
|
+
|
|
48
83
|
/** "code --wait" → ["code", "--wait"]; quotes group ("'/Applications/My Editor' -w"). */
|
|
49
84
|
export function splitCommand(s) {
|
|
50
85
|
const out = [];
|
|
@@ -292,7 +327,24 @@ export function presetBlock(name, preset, { width = 80, gutter = " " } = {}) {
|
|
|
292
327
|
|
|
293
328
|
/** The keys the /load-presets picker takes, in the order its hint line names them. */
|
|
294
329
|
export const PICKER_KEYS = { v: "view", c: "copy", j: "json", e: "edit" };
|
|
295
|
-
|
|
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
|
+
}
|
|
296
348
|
|
|
297
349
|
/**
|
|
298
350
|
* A PRESET SOMEONE ALREADY HAS, pasted into /load-presets (#317: "I need a place to input a preset
|
|
@@ -310,11 +362,12 @@ export function parsePastedPreset(text) {
|
|
|
310
362
|
let src = String(text ?? "").trim();
|
|
311
363
|
if (!src) return { ok: false, empty: true, problem: "nothing was pasted." };
|
|
312
364
|
let name = "";
|
|
313
|
-
|
|
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());
|
|
314
367
|
if (cmd) {
|
|
315
368
|
name = cmd[1];
|
|
316
369
|
src = cmd[2].trim();
|
|
317
|
-
} else if (/^\/save-preset\b/.test(src)) {
|
|
370
|
+
} else if (/^\/save-preset\b/.test(src.replace(/^[ \t]*\/\/.*$/gm, "").trim())) {
|
|
318
371
|
return { ok: false, problem: "that /save-preset command has no JSON after its name." };
|
|
319
372
|
}
|
|
320
373
|
const parsed = parseEdited(src);
|