@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.
@@ -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 /load-presets)"));
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: "/load-presets [name]", desc: "pick a saved preset: Enter applies it (asks first) · v view · c copy as a command · j copy JSON · e edit" },
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. /load-presets → v to view it, c to copy it for another machine."));
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
- console.log(GUTTER + "Paste a preset you already have — its JSON, a preset file, or a /save-preset command.");
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
- while (!parsed) {
7127
- const text = String((await ask("preset: ")) ?? "");
7128
- if (!text.trim()) {
7129
- const seed = lastText || ed.editorText("preset", ed.presetFromSettings((await fetchWorkerSettings()) ?? {}));
7130
- parsed = await fixPresetInEditor(ed, seed);
7131
- if (!parsed) return "";
7132
- break;
7133
- }
7134
- const p = ed.parsePastedPreset(text);
7135
- if (p.ok) {
7136
- parsed = p;
7137
- break;
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." : "no editor found (set $EDITOR, e.g. export EDITOR=nano)."));
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
- console.log(GUTTER + yellow(`Not saved yet — ${p.problem}`));
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
- const typed = line.trim().slice("/load-presets".length).trim();
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(`/load-presets ${pname} applies it.`));
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("/load-presets");
12464
+ await runLoadPresetsCommand("/open-presets");
12456
12465
  } catch (e) {
12457
- console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /load-presets)`));
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
- if (line === "/load-presets" || line.startsWith("/load-presets ")) {
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.781",
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: /load-presets ${b.file}`);
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
- * Which editor to run → { cmd, args }. $VISUAL then $EDITOR (either may carry arguments, e.g.
35
- * "code --wait"), then the first of nano / vi that exists; Notepad on Windows, which blocks until
36
- * its window closes. `has(cmd)` says whether a program is on PATH (injected for tests).
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
- for (const v of [env.VISUAL, env.EDITOR]) {
40
- const words = splitCommand(v);
41
- if (words.length) return { cmd: words[0], args: words.slice(1) };
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
- export const PICKER_HINT = "↑↓ · Enter apply · v view · c copy command · j copy JSON · e edit · Esc back";
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
- const cmd = /^\/save-preset\s+(\S+)\s+([\s\S]+)$/.exec(src);
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);