@tiens.nguyen/gu-cli 1.0.865 → 1.0.867
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 +2 -1
- package/banner-name.mjs +110 -0
- package/client-expert-run.mjs +39 -3
- package/gu-repl.mjs +24 -5
- package/model-doctor.mjs +13 -1
- package/package.json +2 -1
- package/platform-tools.mjs +86 -0
- package/terminal-layout.mjs +1 -1
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
|
|
package/banner-name.mjs
ADDED
|
@@ -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
|
+
}
|
package/client-expert-run.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
386
|
-
|
|
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(() => {});
|
|
@@ -2916,7 +2935,7 @@ const footerBuilt = () =>
|
|
|
2916
2935
|
identity: identityLineText(),
|
|
2917
2936
|
token: tokenLineText(),
|
|
2918
2937
|
mode: modeLineText(),
|
|
2919
|
-
// Task #34: "rag:
|
|
2938
|
+
// Task #34: "rag: using grep as fallback" as a soft yellow note beside the mode, not an
|
|
2920
2939
|
// alarming red segment on the token row. yellow goes through paint(), so NO_COLOR turns it off.
|
|
2921
2940
|
// Issue #120: an auto-compaction this turn outranks the rag note (same soft yellow slot).
|
|
2922
2941
|
modeNote: compactLockNote() || compactBarNote(lastPromptBreakdown) || ragBarNote(lastPromptBreakdown),
|
package/model-doctor.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { open, mkdir, readdir, writeFile } from "node:fs/promises";
|
|
|
12
12
|
import { arch, homedir, platform, totalmem } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { ensureSupportedPython, isBuildToolsMissing } from "./platform-tools.mjs";
|
|
15
16
|
|
|
16
17
|
// Minimum RAM to run the default 14B-4bit chat model comfortably on Apple Silicon.
|
|
17
18
|
const MIN_CHAT_RAM_GB = 16;
|
|
@@ -144,7 +145,8 @@ export function isPipMissing(output) {
|
|
|
144
145
|
/** Run pip once. Resolves { code, err } — never rejects, so the caller owns the policy. */
|
|
145
146
|
function runPip(python, pkgs, extraArgs = []) {
|
|
146
147
|
return new Promise((resolve) => {
|
|
147
|
-
|
|
148
|
+
// --prefer-binary: take an older wheel over a newer source build (a compiler is not a given, #225).
|
|
149
|
+
const child = spawn(python, ["-m", "pip", "install", "-U", "--prefer-binary", ...extraArgs, ...pkgs], {
|
|
148
150
|
// stderr is PIPED so the PEP 668 marker can be recognised, and echoed as it arrives so
|
|
149
151
|
// the user still watches a real install rather than a frozen line.
|
|
150
152
|
stdio: ["ignore", "inherit", "pipe"],
|
|
@@ -198,9 +200,19 @@ async function ensureVenv(log) {
|
|
|
198
200
|
* venv cannot be built, --break-system-packages, which is announced rather than done quietly.
|
|
199
201
|
*/
|
|
200
202
|
async function pipInstall(pkgs, log) {
|
|
203
|
+
// A Python newer than the wheels (3.15 -> PyYAML builds from source -> "Visual C++ 14.0 is required"): #225.
|
|
204
|
+
const sp = await ensureSupportedPython({ log });
|
|
205
|
+
if (!sp.ok) throw new Error(sp.message);
|
|
206
|
+
if (sp.switched) await pinPythonForDaemon({ log });
|
|
201
207
|
log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
|
|
202
208
|
let r = await runPip(python3Bin(), pkgs);
|
|
203
209
|
if (r.code === 0) return;
|
|
210
|
+
if (isBuildToolsMissing(r.err)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
"pip had to compile a package and this machine has no C++ compiler. Use Python 3.13 or 3.12 (64-bit), which " +
|
|
213
|
+
"has prebuilt packages: " + pythonInstallHint() + ". Then run gu doctor again."
|
|
214
|
+
);
|
|
215
|
+
}
|
|
204
216
|
const noPip = isPipMissing(r.err);
|
|
205
217
|
if (!noPip && !isExternallyManaged(r.err)) {
|
|
206
218
|
throw new Error(`pip install ${pkgs.join(" ")} exited ${r.code}`);
|
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.867",
|
|
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",
|
package/platform-tools.mjs
CHANGED
|
@@ -233,6 +233,92 @@ export async function resolvePython() {
|
|
|
233
233
|
: { cmd: "python3", args: [] };
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/* ------------------------------------------- a Python new enough to lack wheels (#225) ---- */
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The newest Python that gu's packages (PyYAML via smolagents, pillow, ...) ship prebuilt wheels for.
|
|
240
|
+
* Above it pip BUILDS FROM SOURCE, which on Windows asks for Visual C++ 14 and ends in
|
|
241
|
+
* "Microsoft Visual C++ 14.0 or greater is required" (reported with Python 3.15). Raise it as wheels land.
|
|
242
|
+
*/
|
|
243
|
+
export const PYTHON_WHEELS_MAX = [3, 14];
|
|
244
|
+
|
|
245
|
+
/** "3.15.0" / "3.15" -> [3, 15], or null when it is not a version. */
|
|
246
|
+
export function parsePyVersion(out) {
|
|
247
|
+
const m = /^\s*(\d+)\.(\d+)/.exec(String(out ?? ""));
|
|
248
|
+
return m ? [Number(m[1]), Number(m[2])] : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Is this [major, minor] newer than the newest Python with wheels? */
|
|
252
|
+
export function pythonTooNew(v, max = PYTHON_WHEELS_MAX) {
|
|
253
|
+
return Array.isArray(v) && (v[0] > max[0] || (v[0] === max[0] && v[1] > max[1]));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Older interpreters to fall back to, best first (3.13, 3.12 are the most widely wheeled). */
|
|
257
|
+
export function altPythonCandidates(platform = process.platform) {
|
|
258
|
+
const minors = [13, 12, 14, 11, 10];
|
|
259
|
+
return platform === "win32"
|
|
260
|
+
? minors.map((m) => ({ cmd: "py", args: [`-3.${m}`] }))
|
|
261
|
+
: minors.map((m) => ({ cmd: `python3.${m}`, args: [] }));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const EXE_PROBE = ["-c", "import sys; print(sys.executable)"];
|
|
265
|
+
const VER_PROBE = ["-c", "import sys; print('%d.%d' % sys.version_info[:2])"];
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The first older interpreter that runs, as { cmd, args, exe } (exe = its real path), or null.
|
|
269
|
+
* `probe(cmd, args)` resolves stdout or null; injectable for the tests.
|
|
270
|
+
*/
|
|
271
|
+
export async function findSupportedPython({ platform = process.platform, probe = (c, a) => run(c, a, { timeout: 8000 }) } = {}) {
|
|
272
|
+
for (const c of altPythonCandidates(platform)) {
|
|
273
|
+
const exe = String((await probe(c.cmd, [...c.args, ...EXE_PROBE])) ?? "").trim();
|
|
274
|
+
if (exe && !/\n/.test(exe)) return { ...c, exe };
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Make sure the Python gu will install packages into is one that has wheels.
|
|
281
|
+
* - GONEXT_PROBE_PYTHON set: the user chose; leave it.
|
|
282
|
+
* - default Python fine: nothing to do.
|
|
283
|
+
* - default too new: switch to an older one (sets GONEXT_PROBE_PYTHON in `env`, so every spawn in
|
|
284
|
+
* this process follows) or say plainly what to install. Never starts a build that cannot succeed.
|
|
285
|
+
* Resolves { ok, switched?, from?, message? }.
|
|
286
|
+
*/
|
|
287
|
+
export async function ensureSupportedPython({
|
|
288
|
+
platform = process.platform,
|
|
289
|
+
env = process.env,
|
|
290
|
+
probe = (c, a) => run(c, a, { timeout: 8000 }),
|
|
291
|
+
log = () => {},
|
|
292
|
+
} = {}) {
|
|
293
|
+
if ((env.GONEXT_PROBE_PYTHON ?? env.GONEXT_MLX_LM_PYTHON ?? "").trim()) return { ok: true };
|
|
294
|
+
let version = null;
|
|
295
|
+
for (const c of pythonCandidates(platform)) {
|
|
296
|
+
version = parsePyVersion(await probe(c.cmd, [...c.args, ...VER_PROBE]));
|
|
297
|
+
if (version) break;
|
|
298
|
+
}
|
|
299
|
+
if (!version || !pythonTooNew(version)) return { ok: true };
|
|
300
|
+
const from = version.join(".");
|
|
301
|
+
const alt = await findSupportedPython({ platform, probe });
|
|
302
|
+
if (alt) {
|
|
303
|
+
env.GONEXT_PROBE_PYTHON = alt.exe;
|
|
304
|
+
log(` Python ${from} is too new for gu's packages (no prebuilt wheels yet) - using ${alt.exe} instead.`);
|
|
305
|
+
return { ok: true, switched: alt.exe, from };
|
|
306
|
+
}
|
|
307
|
+
const how = platform === "win32" ? "winget install Python.Python.3.13, or python.org" : "your package manager, or python.org";
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
from,
|
|
311
|
+
message:
|
|
312
|
+
`Python ${from} is too new: gu's packages (PyYAML and others) have no prebuilt ${platform === "win32" ? "Windows " : ""}` +
|
|
313
|
+
`wheels for it yet, so pip would try to compile them. Install Python 3.13 (${how}), open a new terminal, then run gu doctor again.`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** pip's "I had to compile and there is no compiler" failure. */
|
|
318
|
+
export function isBuildToolsMissing(output) {
|
|
319
|
+
return /Microsoft Visual C\+\+ 14\.0 or greater is required|Failed building wheel for|error: command '(?:gcc|cl\.exe)' failed/i.test(String(output ?? ""));
|
|
320
|
+
}
|
|
321
|
+
|
|
236
322
|
/** How to SAY the resolved interpreter in a log line or error message. */
|
|
237
323
|
export const pythonLabel = (py) => [py.cmd, ...py.args].join(" ");
|
|
238
324
|
|
package/terminal-layout.mjs
CHANGED
|
@@ -148,7 +148,7 @@ export function ragSegmentLabel(b) {
|
|
|
148
148
|
switch (b.ragState) {
|
|
149
149
|
// Task #34: no "✗" — search still works through grep, so this is a notice, not an error.
|
|
150
150
|
case "unreachable":
|
|
151
|
-
return "rag:
|
|
151
|
+
return "rag: using grep as fallback";
|
|
152
152
|
case "no-index":
|
|
153
153
|
return "rag — not indexed";
|
|
154
154
|
case "unavailable":
|