@tiens.nguyen/gu-cli 1.0.893 → 1.0.895
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/agent-deps-preflight.mjs +5 -2
- package/browser-command.mjs +21 -11
- package/client-expert-run.mjs +32 -6
- package/embed-model-command.mjs +5 -5
- package/gonext_agent_chat.py +269 -42
- package/gu-cli.mjs +14 -4
- package/gu-repl.mjs +80 -27
- package/model-doctor.mjs +35 -22
- package/model-fallback.mjs +4 -4
- package/mongo-windows.mjs +63 -0
- package/package.json +2 -1
- package/preset-apply.mjs +1 -1
- package/python-windows.mjs +91 -0
- package/quick-models.mjs +3 -3
- package/terminal-layout.mjs +114 -2
- package/theme.mjs +9 -3
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PYTHON ON A NEW WINDOWS 11 (GH #363).
|
|
3
|
+
*
|
|
4
|
+
* A brand-new Windows 11 has no Python, and gu's agent runs on it. gu used to say "Install Python 3 — winget …"
|
|
5
|
+
* and then carry on through a long database setup that ended in a broken agent. Like the installer does for
|
|
6
|
+
* Node, gu now offers to do it: the official python.org installer, for THIS USER only (no administrator, no
|
|
7
|
+
* system-wide change), after checking that the file is signed by the Python Software Foundation.
|
|
8
|
+
*
|
|
9
|
+
* Pure parts (URL, folders, the PowerShell script) are separate; the net and the machine are injected so tests
|
|
10
|
+
* never download or install anything.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
// Bump on purpose. 3.12 has prebuilt wheels for everything gu installs (see PYTHON_WHEELS_MAX).
|
|
18
|
+
export const PYTHON_WIN_VERSION = "3.12.8";
|
|
19
|
+
|
|
20
|
+
export function pythonWinInstallerUrl(version = PYTHON_WIN_VERSION) {
|
|
21
|
+
return `https://www.python.org/ftp/python/${version}/python-${version}-amd64.exe`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Where a per-user install lands: %LOCALAPPDATA%\Programs\Python\Python312, its Scripts, and the `py` launcher. */
|
|
25
|
+
export function pythonWinFolders(version = PYTHON_WIN_VERSION, localAppData = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")) {
|
|
26
|
+
const [maj, min] = version.split(".");
|
|
27
|
+
const root = join(localAppData, "Programs", "Python", `Python${maj}${min}`);
|
|
28
|
+
return [root, join(root, "Scripts"), join(localAppData, "Programs", "Python", "Launcher")];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Put the new folders on THIS process's PATH: the installer only edits the saved PATH, which only new terminals read. */
|
|
32
|
+
export function withPythonOnPath(pathValue, folders) {
|
|
33
|
+
const have = String(pathValue ?? "").split(";").map((p) => p.trim().toLowerCase());
|
|
34
|
+
const add = folders.filter((f) => !have.includes(f.toLowerCase()));
|
|
35
|
+
return add.length ? add.join(";") + ";" + String(pathValue ?? "") : String(pathValue ?? "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The PowerShell that checks the signature, then installs quietly for the current user. Exit 90 = not signed by the PSF. */
|
|
39
|
+
export function pythonInstallScript(file) {
|
|
40
|
+
const f = file.replace(/'/g, "''");
|
|
41
|
+
return [
|
|
42
|
+
`$f = '${f}'`,
|
|
43
|
+
`$s = Get-AuthenticodeSignature -FilePath $f`,
|
|
44
|
+
`if ($s.Status -ne 'Valid' -or "$($s.SignerCertificate.Subject)" -notmatch 'Python Software Foundation') { exit 90 }`,
|
|
45
|
+
`$p = Start-Process -FilePath $f -ArgumentList '/quiet','InstallAllUsers=0','PrependPath=1','Include_launcher=1','Include_test=0' -Wait -PassThru`,
|
|
46
|
+
`exit $p.ExitCode`,
|
|
47
|
+
].join("; ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The environment for the Python installer: the REAL profile. A test (or anyone) may point USERPROFILE/HOME at a scratch
|
|
52
|
+
* folder; the installer then fails with 0x80070003 ("path not found"). LOCALAPPDATA still names the real profile
|
|
53
|
+
* (<profile>\\AppData\\Local), so the profile is derived from it. Pure.
|
|
54
|
+
*/
|
|
55
|
+
export function pythonInstallerEnv(env = process.env) {
|
|
56
|
+
const out = { ...env };
|
|
57
|
+
const m = /^(.*)[\\/]AppData[\\/]Local[\\/]?$/i.exec(String(env.LOCALAPPDATA ?? ""));
|
|
58
|
+
if (m && m[1]) {
|
|
59
|
+
out.USERPROFILE = m[1];
|
|
60
|
+
out.HOME = m[1];
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Download, verify, install. → { ok, folders?, error? }. `fetchFn` and `run` (file → { code }) are injectable.
|
|
67
|
+
* On success the process PATH is updated so the very next `py -3` in this run finds it.
|
|
68
|
+
*/
|
|
69
|
+
export async function installPythonWindows({ dir = join(homedir(), ".gonext"), version = PYTHON_WIN_VERSION, fetchFn = fetch, run = null, env = process.env } = {}) {
|
|
70
|
+
const file = join(dir, `python-${version}-amd64.exe`);
|
|
71
|
+
try {
|
|
72
|
+
await mkdir(dir, { recursive: true });
|
|
73
|
+
const res = await fetchFn(pythonWinInstallerUrl(version), { redirect: "follow" });
|
|
74
|
+
if (!res.ok) return { ok: false, error: `download failed (HTTP ${res.status})` };
|
|
75
|
+
await writeFile(file, Buffer.from(await res.arrayBuffer()));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return { ok: false, error: `download failed: ${e?.message ?? e}` };
|
|
78
|
+
}
|
|
79
|
+
const runner = run ?? ((f) => new Promise((resolve) => {
|
|
80
|
+
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", pythonInstallScript(f)], { windowsHide: true, timeout: 900000, env: pythonInstallerEnv(env) }, (err) => {
|
|
81
|
+
resolve({ code: err ? (typeof err.code === "number" ? err.code : null) : 0 });
|
|
82
|
+
});
|
|
83
|
+
}));
|
|
84
|
+
const r = await runner(file);
|
|
85
|
+
await rm(file, { force: true }).catch(() => {});
|
|
86
|
+
if (r.code === 90) return { ok: false, error: "the downloaded file is not signed by the Python Software Foundation, so it was NOT installed" };
|
|
87
|
+
if (r.code !== 0) return { ok: false, error: `the Python installer stopped with code ${r.code}` };
|
|
88
|
+
const folders = pythonWinFolders(version, env.LOCALAPPDATA || undefined);
|
|
89
|
+
env.Path !== undefined ? (env.Path = withPythonOnPath(env.Path, folders)) : (env.PATH = withPythonOnPath(env.PATH, folders));
|
|
90
|
+
return { ok: true, folders };
|
|
91
|
+
}
|
package/quick-models.mjs
CHANGED
|
@@ -55,9 +55,9 @@ export function quickWarning(quick, activeId, { width = 80, gutter = " " } = {
|
|
|
55
55
|
const q = normalizeQuick(quick);
|
|
56
56
|
if (q.length === 0 || isQuickActive(q, activeId)) return "";
|
|
57
57
|
const full = q.length === QUICK_MAX
|
|
58
|
-
?
|
|
59
|
-
: "
|
|
60
|
-
const short = "
|
|
58
|
+
? `not on a quick model — ← → goes to ${q[0].model} · /quick-models`
|
|
59
|
+
: "not on your quick model · pick 2 with /quick-models to use ← →";
|
|
60
|
+
const short = "not on a quick model · /quick-models";
|
|
61
61
|
const room = Math.max(8, (width || 80) - 1 - gutter.length);
|
|
62
62
|
const pick = full.length <= room ? full : short;
|
|
63
63
|
return pick.length <= room ? pick : pick.slice(0, Math.max(1, room - 1)) + "…";
|
package/terminal-layout.mjs
CHANGED
|
@@ -451,9 +451,16 @@ export function promptBreakdownRows(b) {
|
|
|
451
451
|
export function createMouseKeyWindow({ maxRun = 24 } = {}) {
|
|
452
452
|
let run = 0;
|
|
453
453
|
let current = false;
|
|
454
|
+
let prev = ""; // the previous key's text, for the split shape below
|
|
454
455
|
return {
|
|
455
456
|
track(str, key) {
|
|
456
|
-
|
|
457
|
+
const text = typeof str === "string" ? str : "";
|
|
458
|
+
// The joined shape ("[<" as one key) — or the SPLIT one (GH #365): an Esc node timed out
|
|
459
|
+
// on, then "[" and "<" as ordinary characters. The "[" has already gone by as a key by
|
|
460
|
+
// then; the run starts at the "<".
|
|
461
|
+
const split = text === "<" && prev === "[";
|
|
462
|
+
prev = key?.code === "[<" ? "" : text;
|
|
463
|
+
if (key?.code === "[<" || (split && !run)) {
|
|
457
464
|
run = 1;
|
|
458
465
|
return (current = true);
|
|
459
466
|
}
|
|
@@ -466,6 +473,100 @@ export function createMouseKeyWindow({ maxRun = 24 } = {}) {
|
|
|
466
473
|
};
|
|
467
474
|
}
|
|
468
475
|
|
|
476
|
+
/**
|
|
477
|
+
* KEEP EVERY SHAPE OF A MOUSE REPORT OUT OF THE LINE (GH #365).
|
|
478
|
+
*
|
|
479
|
+
* The input filter used to recognise a report only by node's key code "[<" — the shape node
|
|
480
|
+
* produces when `ESC [ <` arrives together. When the event loop stalls for longer than
|
|
481
|
+
* readline's escape timeout (500ms) with an ESC at the end of a read, node gives the ESC up as a
|
|
482
|
+
* lone Esc key and the rest comes through as ORDINARY characters: "[", "<", "35;114;22M". Every
|
|
483
|
+
* one of those was typed into the line. Reported on Windows 11 after minimising and restoring
|
|
484
|
+
* the window (a full repaint, and Windows console writes block), then more with every hover,
|
|
485
|
+
* because the prompt reports mouse motion.
|
|
486
|
+
*
|
|
487
|
+
* Shapes, all consumed whole:
|
|
488
|
+
* key "[<", then "35;114;22M" ← the usual one
|
|
489
|
+
* Esc, "[", "<", "35;114;22M" ← the ESC timed out
|
|
490
|
+
* "[", "<", "35;114;22M" ← the Esc went elsewhere (plainLeads only)
|
|
491
|
+
* "^", "[", "[", "<", … ← ESC spelled in caret notation (plainLeads only)
|
|
492
|
+
*
|
|
493
|
+
* Like createProbeReplyReader it holds anything that still fits the grammar and REPLAYS it
|
|
494
|
+
* verbatim the moment something does not, so a keystroke is delayed, never lost. A plain "[" or
|
|
495
|
+
* "^" is only held while the terminal is reporting the mouse (`plainLeads`), so typing them is
|
|
496
|
+
* not slowed down otherwise.
|
|
497
|
+
*/
|
|
498
|
+
const MOUSE_LEADS = ["\x1b[<", "^[[<", "[<"];
|
|
499
|
+
const MOUSE_BODY_DONE = /^(\d+;\d+;\d+[Mm])$/;
|
|
500
|
+
const MOUSE_BODY_PART = /^\d*(?:;\d*){0,2}$/;
|
|
501
|
+
function mouseTextVerdict(text, plainLeads) {
|
|
502
|
+
for (const lead of MOUSE_LEADS) {
|
|
503
|
+
if (!plainLeads && lead !== "\x1b[<") continue;
|
|
504
|
+
if (lead.startsWith(text)) return { kind: "partial" };
|
|
505
|
+
if (!text.startsWith(lead)) continue;
|
|
506
|
+
const body = text.slice(lead.length);
|
|
507
|
+
if (MOUSE_BODY_DONE.test(body)) return { kind: "done", report: body };
|
|
508
|
+
if (body.length <= 20 && MOUSE_BODY_PART.test(body)) return { kind: "partial" };
|
|
509
|
+
}
|
|
510
|
+
return { kind: "bad" };
|
|
511
|
+
}
|
|
512
|
+
export function createMouseReportReader() {
|
|
513
|
+
let held = null; // [str, key] pairs
|
|
514
|
+
const textOf = (str, key) =>
|
|
515
|
+
key?.code === "[<" ? "\x1b[<" : key?.name === "escape" ? "\x1b" : typeof str === "string" ? str : "";
|
|
516
|
+
const startsOne = (str, key, plainLeads) =>
|
|
517
|
+
key?.code === "[<" || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
|
|
518
|
+
return {
|
|
519
|
+
holding: () => held !== null,
|
|
520
|
+
/**
|
|
521
|
+
* Feed one keypress. `plainLeads`: the terminal is reporting the mouse right now.
|
|
522
|
+
* { action: "pass" } — not ours; carry on
|
|
523
|
+
* { action: "hold" } — may be a report; arm the flush timer
|
|
524
|
+
* { action: "consume", report } — a whole report, e.g. "35;114;22M"
|
|
525
|
+
* { action: "replay", keys, hold? } — not a report: replay `keys`; when `hold` is
|
|
526
|
+
* set, the last key began a new candidate and
|
|
527
|
+
* is still held
|
|
528
|
+
*/
|
|
529
|
+
feed(str, key, { plainLeads = false } = {}) {
|
|
530
|
+
if (!held) {
|
|
531
|
+
if (!startsOne(str, key, plainLeads)) return { action: "pass" };
|
|
532
|
+
held = [[str, key]];
|
|
533
|
+
const v = mouseTextVerdict(textOf(str, key), plainLeads);
|
|
534
|
+
if (v.kind === "partial") return { action: "hold" };
|
|
535
|
+
held = null;
|
|
536
|
+
return { action: "pass" };
|
|
537
|
+
}
|
|
538
|
+
held.push([str, key]);
|
|
539
|
+
const text = held.map(([s, k]) => textOf(s, k)).join("");
|
|
540
|
+
const v = mouseTextVerdict(text, plainLeads);
|
|
541
|
+
if (v.kind === "done") {
|
|
542
|
+
held = null;
|
|
543
|
+
return { action: "consume", report: v.report };
|
|
544
|
+
}
|
|
545
|
+
if (v.kind === "partial") return { action: "hold" };
|
|
546
|
+
// Not a report. If the key that broke it could START one ("[" after "[", an Esc), keep it.
|
|
547
|
+
const last = held.pop();
|
|
548
|
+
const keys = held;
|
|
549
|
+
held = null;
|
|
550
|
+
if (startsOne(last[0], last[1], plainLeads) && mouseTextVerdict(textOf(last[0], last[1]), plainLeads).kind === "partial") {
|
|
551
|
+
held = [last];
|
|
552
|
+
return { action: "replay", keys, hold: true };
|
|
553
|
+
}
|
|
554
|
+
return { action: "replay", keys: [...keys, last] };
|
|
555
|
+
},
|
|
556
|
+
/** Timer fired: hand back whatever is still held. */
|
|
557
|
+
flush() {
|
|
558
|
+
const keys = held ?? [];
|
|
559
|
+
held = null;
|
|
560
|
+
return keys;
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Last line of defence at submit: a report that got past every reader is not a question. */
|
|
566
|
+
export function stripLeakedMouseReports(line) {
|
|
567
|
+
return String(line).replace(/(?:\x1b|\^\[)?\[<\d+;\d+;\d+[Mm]/g, "");
|
|
568
|
+
}
|
|
569
|
+
|
|
469
570
|
/**
|
|
470
571
|
* A failed turn's message, as something a person can act on.
|
|
471
572
|
*
|
|
@@ -1152,6 +1253,16 @@ export function syncPrevRowsForResize(rl) {
|
|
|
1152
1253
|
// signature — a tool's own line never contains it.
|
|
1153
1254
|
export const isToolStepSummary = (line) => / \| → /.test(line);
|
|
1154
1255
|
|
|
1256
|
+
/**
|
|
1257
|
+
* A WARNING STEP (GH #378). The agent marks one with a leading "⚠" ("⚠ RAG unavailable …").
|
|
1258
|
+
* The terminal shows it as yellow text with NO icon: Windows 11 terminals draw "⚠" as a wide
|
|
1259
|
+
* colour emoji, which broke the row and looked amateur next to the plain marks. The "⚠" stays
|
|
1260
|
+
* in the protocol (it is how a warning is recognised, and the web app shows it); only the
|
|
1261
|
+
* terminal drops it, here.
|
|
1262
|
+
*/
|
|
1263
|
+
export const WARN_MARK_RE = /^\s*⚠\uFE0F?\s*/;
|
|
1264
|
+
export const stripWarnMark = (text) => String(text ?? "").replace(WARN_MARK_RE, "");
|
|
1265
|
+
|
|
1155
1266
|
// Glyph vocabulary for an action line: a FAILURE pops (✗, full-bright), a completed
|
|
1156
1267
|
// action recedes (✓, dim), anything else is neutral (●, dim). Returns the KIND only —
|
|
1157
1268
|
// the REPL owns the colours, so this stays pure and testable.
|
|
@@ -1201,6 +1312,7 @@ export function isSoftFailCommand(cmd) {
|
|
|
1201
1312
|
}
|
|
1202
1313
|
|
|
1203
1314
|
export function bulletKind(text) {
|
|
1315
|
+
if (WARN_MARK_RE.test(String(text ?? ""))) return "warn";
|
|
1204
1316
|
const mFail = /^Command failed → (.+)$/.exec(String(text ?? "").trim());
|
|
1205
1317
|
if (mFail && isSoftFailCommand(mFail[1])) return "soft";
|
|
1206
1318
|
if (/\bfail(ed|s|ure)?\b|\berror(ed)?\b|→\s*(?:HTTP\s*)?[45]\d\d\b/i.test(text)) return "fail";
|
|
@@ -2725,7 +2837,7 @@ export function identityRow(
|
|
|
2725
2837
|
const where = String(folder ?? "").trim();
|
|
2726
2838
|
// The mark only means anything beside a named coder: with none shown there is nothing for it
|
|
2727
2839
|
// to be about, and it would read as a fault in the folder.
|
|
2728
|
-
const mark = name && keyUnchosen ? red("
|
|
2840
|
+
const mark = name && keyUnchosen ? red(" key not chosen") : "";
|
|
2729
2841
|
// API key tail: show the last 4 characters of the API key when available (e.g., "| abcd")
|
|
2730
2842
|
const keyIndicator = apiKeyTail ? dim(" | ") + dim(apiKeyTail) : "";
|
|
2731
2843
|
return (name ? orange(name) + mark + keyIndicator + dim(" | ") : "") + cyan(where);
|
package/theme.mjs
CHANGED
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
* Windows (win32) with no TERM/COLORTERM/WT_SESSION -> ask stdout.getColorDepth()
|
|
8
8
|
* (conhost on Windows 11 supports 24-bit but advertises nothing).
|
|
9
9
|
*
|
|
10
|
-
* Variants: GU_THEME=dark (default) | light | mono.
|
|
11
|
-
*
|
|
10
|
+
* Variants: GU_THEME=dark (default) | light | mono.
|
|
11
|
+
*
|
|
12
|
+
* NO ICON BEFORE A CARD (GH #378). The header was "⏺ Update(path)"; Windows 11 terminals draw "⏺"
|
|
13
|
+
* as a wide colour emoji, which broke the row and looked amateur. The coloured verb — green
|
|
14
|
+
* Create, yellow Update, red Delete — already says what happened, so the glyph is a space that
|
|
15
|
+
* keeps the text column, on every platform. ("⏺" is still the line protocol from
|
|
16
|
+
* gonext_agent_chat.py: it is how a header is RECOGNISED, never what is drawn.)
|
|
12
17
|
*
|
|
13
18
|
* Rules every caller relies on:
|
|
14
19
|
* - background tiers fill a row by PADDING SPACES (measured with visibleLen), never ESC[K,
|
|
@@ -102,7 +107,8 @@ const EMPTY_SIGN = { row: "", sign: "", signOff: "", wordOn: "", wordOff: "" };
|
|
|
102
107
|
|
|
103
108
|
/** Every escape sequence the edit card uses, for one tier + variant. "" = nothing to emit. */
|
|
104
109
|
export function makeTheme(tier = "16", variant = "dark", { ascii = false, syntax = false } = {}) {
|
|
105
|
-
|
|
110
|
+
void ascii; // GU_ASCII used to swap "⏺" for "*"; nothing is drawn now (GH #378)
|
|
111
|
+
const glyph = " ";
|
|
106
112
|
if (tier === "none") {
|
|
107
113
|
return {
|
|
108
114
|
tier, variant, glyph, fill: false, reset: "", lineNo: "", lineNoOff: "", grey: "",
|