@tiens.nguyen/gu-cli 1.0.864 → 1.0.866

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/README.md CHANGED
@@ -35,10 +35,11 @@ meaning the whole stack runs on this machine.
35
35
  ## The `gu` REPL
36
36
 
37
37
  ```
38
- gu [-v|--verbose] [--log] [--license <pro token>]
38
+ gu [-v|--verbose] [--log] [--name <text>] [--license <pro token>]
39
39
  ```
40
40
 
41
41
  - `-v, --verbose` shows RAG index-file activity in the bottom bar.
42
+ - `--name <text>` draws `<text>` instead of the gu logo in the startup banner (this run only). Letters, digits, space, `-` and `_` become block art; anything else, or a name over 12 characters, is shown as plain text.
42
43
  - `--log` logs this run in detail to `~/.gonext/worker.log`. Without it only starts, stops and errors are logged.
43
44
  - Ctrl-C stops following a turn.
44
45
 
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `gu --name <text>` (#224): the startup banner draws <text> instead of the "gu" mark.
3
+ *
4
+ * Same material as the built-in mark: half-block elements only (▀ ▄ █), so it renders on any
5
+ * terminal without a font to install. Two rows of x-height, plus a third row used only by letters
6
+ * with a descender (g j p q y). Glyphs are separated by one blank column.
7
+ *
8
+ * ANYTHING THE FONT CANNOT DRAW (accents, emoji, punctuation) is shown as plain text on a single
9
+ * row instead of being dropped — a name must never silently turn into a different name.
10
+ */
11
+
12
+ const G = {
13
+ a: ["█▀▀█", "█▀▀█"],
14
+ b: ["█▀▀▄", "█▄▄▀"],
15
+ c: ["█▀▀▀", "█▄▄▄"],
16
+ d: ["▄▀▀█", "▀▄▄█"],
17
+ e: ["█▀▀█", "█▄▄▄"],
18
+ f: ["█▀▀▀", "█▀▀ "],
19
+ g: ["█▀▀█", "█▄▄█", "▄▄▄█"],
20
+ h: ["█ █", "█▀▀█"],
21
+ i: ["▄", "█"],
22
+ j: [" █", " █", "▄▄█"],
23
+ k: ["█ ▄▀", "█▀▄ "],
24
+ l: ["█ ", "█▄▄"],
25
+ m: ["█▀▄▀█", "█ █"],
26
+ n: ["█▀▀█", "█ █"],
27
+ o: ["█▀▀█", "█▄▄█"],
28
+ p: ["█▀▀█", "█▀▀▀", "▀ "],
29
+ q: ["█▀▀█", "█▄▄█", " █"],
30
+ r: ["█▀▀█", "█▀▀▄"],
31
+ s: ["▄▀▀▀", "▄▄▄▀"],
32
+ t: ["▀█▀", " █ "],
33
+ u: ["█ █", "█▄▄█"],
34
+ v: ["█ █", "▀▄▄▀"],
35
+ w: ["█ █", "█▄█▄█"],
36
+ x: ["▀▄▀", "▄▀▄"],
37
+ y: ["█ █", "▀▄▄█", "▄▄▄▀"],
38
+ z: ["▀▀▀█", "█▄▄▄"],
39
+ 0: ["█▀▀█", "█▄▀█"],
40
+ 1: ["▄█", " █"],
41
+ 2: ["▄▀▀█", "█▄▄▄"],
42
+ 3: ["▀▀▀█", "▄▄▄█"],
43
+ 4: ["█ █", "▀▀▀█"],
44
+ 5: ["█▀▀▀", "▄▄▄█"],
45
+ 6: ["█▀▀▀", "█▄▄█"],
46
+ 7: ["▀▀▀█", " █ "],
47
+ 8: ["▄▀▀▄", "▀▄▄▀"],
48
+ 9: ["█▀▀█", "▀▀▀█"],
49
+ "-": [" ", "▄▄▄"],
50
+ _: [" ", "▄▄▄▄"],
51
+ " ": [" ", " "],
52
+ };
53
+
54
+ /** Longest name accepted at all; longer is cut (and the cut is reported by the caller). */
55
+ export const NAME_MAX = 24;
56
+ /** Most characters drawn as block art; a longer name is shown as plain text. */
57
+ export const ART_MAX = 12;
58
+
59
+ /** What the user typed → what is shown: trimmed, control chars removed, capped. */
60
+ export function cleanName(raw) {
61
+ return String(raw ?? "")
62
+ .replace(/[\u0000-\u001f\u007f]/g, "")
63
+ .trim()
64
+ .slice(0, NAME_MAX);
65
+ }
66
+
67
+ /** Draw `name` as rows of equal visible width. Returns { rows, width, art }; null for an empty name. */
68
+ export function logoFor(raw, maxWidth = Infinity) {
69
+ const name = cleanName(raw);
70
+ if (!name) return null;
71
+ const lower = name.toLowerCase();
72
+ const drawable = [...lower].every((ch) => G[ch]);
73
+ if (drawable && lower.length <= ART_MAX) {
74
+ const rows = ["", "", ""];
75
+ [...lower].forEach((ch, i) => {
76
+ const g = G[ch];
77
+ const w = Math.max(...g.map((r) => r.length));
78
+ for (let r = 0; r < 3; r++) {
79
+ rows[r] += (i ? " " : "") + (g[r] ?? "").padEnd(w);
80
+ }
81
+ });
82
+ // The third row only exists for descenders; drop it when it is all blanks.
83
+ const out = rows[2].trim() ? rows : rows.slice(0, 2);
84
+ const width = Math.max(...out.map((r) => r.length));
85
+ if (width <= maxWidth) return { rows: out.map((r) => r.padEnd(width)), width, art: true };
86
+ }
87
+ const text = name.slice(0, Math.max(1, Math.min(name.length, maxWidth)));
88
+ return { rows: [text], width: text.length, art: false };
89
+ }
90
+
91
+ /**
92
+ * Read `--name <x>` / `--name=<x>` from argv. { value } | { missing: true } | {} when absent.
93
+ * A following token that is itself a flag is NOT taken as the value.
94
+ */
95
+ export function parseNameArg(argv) {
96
+ const args = Array.isArray(argv) ? argv : [];
97
+ for (let i = 0; i < args.length; i++) {
98
+ const a = String(args[i]);
99
+ if (a.startsWith("--name=")) {
100
+ const value = cleanName(a.slice(7));
101
+ return value ? { value } : { missing: true };
102
+ }
103
+ if (a === "--name") {
104
+ const next = args[i + 1];
105
+ const value = next === undefined || String(next).startsWith("-") ? "" : cleanName(next);
106
+ return value ? { value } : { missing: true };
107
+ }
108
+ }
109
+ return {};
110
+ }
@@ -14,7 +14,7 @@
14
14
  * that does not work — and that state is much harder to get out of than an unfinished setup.
15
15
  */
16
16
  import { execFile, spawn } from "node:child_process";
17
- import { readFile, writeFile, mkdir, rm, rename } from "node:fs/promises";
17
+ import { readFile, writeFile, mkdir, rm, rename, cp } from "node:fs/promises";
18
18
  import { writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { join } from "node:path";
@@ -867,6 +867,43 @@ export async function probeApiInstall(version) {
867
867
  }
868
868
  }
869
869
 
870
+ /** Errors Windows raises while another process (antivirus, indexer) holds a handle in a folder. */
871
+ const LOCKED = new Set(["EPERM", "EBUSY", "EACCES", "ENOTEMPTY"]);
872
+
873
+ /**
874
+ * Put the unpacked folder at its final path. WINDOWS REFUSES TO RENAME OR DELETE A DIRECTORY
875
+ * while any process has a handle inside it, and the files `tar` has just written are exactly what
876
+ * Defender and the indexer open (issue #223: "EPERM: operation not permitted, rename ...incoming").
877
+ * So retry with backoff, then fall back to copy + remove, then say which folder is locked.
878
+ * `fs` is injectable for the tests.
879
+ */
880
+ export async function moveIntoPlace(staging, dir, fs = {}) {
881
+ const { rename: mv = rename, rm: del = rm, cp: copy = cp, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = fs;
882
+ const tries = fs.tries ?? 10;
883
+ let last;
884
+ for (let i = 0; i < tries; i++) {
885
+ try {
886
+ await del(dir, { recursive: true, force: true });
887
+ await mv(staging, dir);
888
+ return;
889
+ } catch (e) {
890
+ last = e;
891
+ if (!LOCKED.has(e?.code)) throw e;
892
+ await sleep(Math.min(250 * 2 ** i, 3000));
893
+ }
894
+ }
895
+ try {
896
+ await del(dir, { recursive: true, force: true });
897
+ await copy(staging, dir, { recursive: true });
898
+ await del(staging, { recursive: true, force: true }).catch(() => {});
899
+ } catch (e) {
900
+ throw new Error(
901
+ `Could not move the unpacked API into ${dir} (${e?.code ?? last?.code}): a program is holding a file in it. ` +
902
+ "Close gu and any terminal or editor open in that folder, pause antivirus scanning for it, and run the install again."
903
+ );
904
+ }
905
+ }
906
+
870
907
  /**
871
908
  * Download and unpack the published API.
872
909
  *
@@ -908,9 +945,8 @@ export async function downloadApiPackage(manifest, { onProgress = () => {} } = {
908
945
  // ends in "gzip: stdin: unexpected end of file". A path with no drive letter works for GNU and bsdtar.
909
946
  await run("tar", ["xzf", "package.tar.gz", "-C", "."], { cwd: staging });
910
947
  await rm(tmpTar, { force: true });
911
- await rm(dir, { recursive: true, force: true });
912
948
  await mkdir(join(homedir(), ".gonext", "api"), { recursive: true });
913
- await rename(staging, dir);
949
+ await moveIntoPlace(staging, dir);
914
950
  onProgress(`installed ${manifest.version} → ${dir}`);
915
951
  return { dir, version: manifest.version };
916
952
  }
package/gu-repl.mjs CHANGED
@@ -193,6 +193,7 @@ import { EditCardStream, isEditCardHeader } from "./edit-card-render.mjs";
193
193
  // Task #240: what a failed agent-payload call means, and when a rejected key may be recovered
194
194
  // from in place instead of ending the session.
195
195
  import { payloadError, recoveryPlan } from "./credential-recovery.mjs";
196
+ import { logoFor, parseNameArg } from "./banner-name.mjs";
196
197
  import { isNoCodeRetry, splitVerboseLine, stepHasCode } from "./verbose-lines.mjs";
197
198
  import {
198
199
  createTranscript,
@@ -382,8 +383,9 @@ const vwidth = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "").length;
382
383
  // square, read as a box; with an all-caps "GU AGENT" beside it the banner shouted its name twice.
383
384
  // The product is written "gu", so the mark is too: a g with a real descender and a u — a wordmark,
384
385
  // one row shorter, and the only coloured thing on the banner.
385
- const LOGO = ["█▀▀█ █ █", "█▄▄█ █▄▄█", "▄▄▄█"];
386
- const LOGO_W = 9;
386
+ // `let`: `gu --name <text>` (#224) swaps in that name's art once argv has been read.
387
+ let LOGO = ["█▀▀█ █ █", "█▄▄█ █▄▄█", "▄▄▄█"];
388
+ let LOGO_W = 9;
387
389
  // Lay the LOGO and the status rows SIDE BY SIDE (logo left, facts right), or STACK them (logo
388
390
  // then facts) when the terminal is too narrow to fit both without wrapping — so the banner is
389
391
  // never the soft-wrapped jumble the plain "· … · …" lines used to be (task #131). Side-by-side
@@ -402,7 +404,7 @@ const renderBanner = (statusRows) => {
402
404
  out.push(GUTTER + left + gap + (statusRows[i] ?? ""));
403
405
  }
404
406
  } else {
405
- for (const r of LOGO) out.push(GUTTER + accent(r));
407
+ for (const r of LOGO) out.push(GUTTER + accent(clipVisible(r, cols - 1 - GUTTER.length)));
406
408
  for (const r of statusRows) out.push(GUTTER + clipVisible(r, cols - 1 - GUTTER.length));
407
409
  }
408
410
  return out.join("\n");
@@ -622,7 +624,7 @@ if (cliVerdict.kind === "run") {
622
624
  } else if (argv.includes("--help") || argv.includes("-h")) {
623
625
  console.log(
624
626
  "gu — terminal agent REPL for the current folder\n\n" +
625
- "Usage: gu [-v|--verbose] [--log] [--color] [--license <pro token>]\n" +
627
+ "Usage: gu [-v|--verbose] [--log] [--color] [--name <text>] [--license <pro token>]\n" +
626
628
  " gu status # worker + a table of the running model servers\n" +
627
629
  " gu start|stop|restart|logs # manage the background worker\n" +
628
630
  " gu logs <port|pid> [-f] # tail a running model server's log\n" +
@@ -633,6 +635,8 @@ if (cliVerdict.kind === "run") {
633
635
  " stops and errors are logged. Ends when this gu closes.\n" +
634
636
  " --color colour the thinking spinner and its playful word (cycling colours).\n" +
635
637
  " Without it they are plain white.\n" +
638
+ " --name <text> show <text> instead of the gu logo in the startup banner\n" +
639
+ " (this run only; letters, digits, space - _ are drawn as art)\n" +
636
640
  " --license <token> pro license: a first install ASKS how this machine runs (hosted\n" +
637
641
  " client / MLX host / private). Without it, a first install is\n" +
638
642
  " private by default — the whole stack on this machine.\n" +
@@ -660,6 +664,21 @@ const REPL_LOG = argv.includes("--log");
660
664
  // --color (#216): the thinking spinner and its playful word cycle through colours only on a run
661
665
  // started with this flag; by default they are plain white.
662
666
  const REPL_COLOR = argv.includes("--color");
667
+ // --name <text> (#224): the startup banner draws <text> in place of the "gu" mark. Per run only.
668
+ {
669
+ const named = parseNameArg(argv);
670
+ if (named.missing) {
671
+ console.error(red("gu: --name needs a name, e.g. gu --name acme"));
672
+ process.exit(2);
673
+ }
674
+ if (named.value) {
675
+ const l = logoFor(named.value, Math.max(8, (process.stdout.columns || 80) - 6));
676
+ if (l) {
677
+ LOGO = l.rows;
678
+ LOGO_W = l.width;
679
+ }
680
+ }
681
+ }
663
682
  if (REPL_LOG) {
664
683
  const ll = await import("./log-level.mjs");
665
684
  await ll.takeLease().catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.864",
3
+ "version": "1.0.866",
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",
@@ -101,6 +101,7 @@
101
101
  "rag-job-config.mjs",
102
102
  "completed-flush.mjs",
103
103
  "verbose-lines.mjs",
104
+ "banner-name.mjs",
104
105
  "agent-model-label.mjs",
105
106
  "coding-choice-tags.mjs",
106
107
  "coding-provider-setup.mjs",