@tiens.nguyen/gu-cli 1.0.791 → 1.0.793
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/clear-logs.mjs +352 -0
- package/clear-state.mjs +4 -0
- package/cli-token.mjs +25 -2
- package/client-expert-api.mjs +249 -6
- package/client-expert-run.mjs +39 -4
- package/client-expert-setup.mjs +21 -1
- package/first-run-steps.mjs +26 -0
- package/gu-cli.mjs +38 -0
- package/gu-repl.mjs +219 -7
- package/install-path.mjs +23 -1
- package/model-doctor.mjs +33 -4
- package/node-version.mjs +50 -2
- package/package.json +2 -1
- package/platform-tools.mjs +49 -3
- package/preset-apply.mjs +18 -8
- package/presets-transfer.mjs +44 -3
- package/settings-transfer.mjs +14 -1
- package/terminal-layout.mjs +31 -2
package/gu-repl.mjs
CHANGED
|
@@ -110,6 +110,8 @@ import {
|
|
|
110
110
|
workspaceScopeWarning,
|
|
111
111
|
isLocalApiBase,
|
|
112
112
|
modeRow,
|
|
113
|
+
nothingToAnswerWith,
|
|
114
|
+
NO_MODEL_LINES,
|
|
113
115
|
resizeRepaintTarget,
|
|
114
116
|
workspaceSyncQuestion,
|
|
115
117
|
workspaceSyncLabel,
|
|
@@ -128,6 +130,7 @@ import {
|
|
|
128
130
|
} from "./terminal-layout.mjs";
|
|
129
131
|
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
130
132
|
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
133
|
+
import { clearLogsNoticeText } from "./clear-logs.mjs";
|
|
131
134
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
132
135
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
133
136
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
@@ -664,6 +667,26 @@ if (!workerKey || !apiBase) {
|
|
|
664
667
|
input: process.stdin,
|
|
665
668
|
output: process.stdout,
|
|
666
669
|
});
|
|
670
|
+
// Ctrl+C AND Ctrl+D MUST END THE RUN CLEANLY (task #333). readline swallows Ctrl+C in raw
|
|
671
|
+
// mode: with no listener it only pauses, the pending question never settles, and Node
|
|
672
|
+
// exits with "Warning: Detected unsettled top-level await" and a stack line — at the
|
|
673
|
+
// models menu of a first install. It also meant the "stopped during <step>" record
|
|
674
|
+
// (markInterruptedOnExit listens on the PROCESS) never fired for a Ctrl+C typed here.
|
|
675
|
+
let rl0Done = false;
|
|
676
|
+
const { stopFirstRun } = await import("./first-run-steps.mjs");
|
|
677
|
+
const endFirstRun = (reason) =>
|
|
678
|
+
stopFirstRun({
|
|
679
|
+
reason,
|
|
680
|
+
hasProcessListener: process.listenerCount("SIGINT") > 0,
|
|
681
|
+
emitSigint: () => process.emit("SIGINT"),
|
|
682
|
+
log: (t) => console.log(t),
|
|
683
|
+
exit: (code) => process.exit(code),
|
|
684
|
+
style: { yellow, dim },
|
|
685
|
+
});
|
|
686
|
+
rl0.on("SIGINT", () => endFirstRun("interrupt"));
|
|
687
|
+
rl0.on("close", () => {
|
|
688
|
+
if (!rl0Done) endFirstRun("eof");
|
|
689
|
+
});
|
|
667
690
|
const ask0 = (q) =>
|
|
668
691
|
new Promise((res) => rl0.question(accent(q), (a) => res(String(a || ""))));
|
|
669
692
|
let typed = "";
|
|
@@ -681,7 +704,7 @@ if (!workerKey || !apiBase) {
|
|
|
681
704
|
const {
|
|
682
705
|
probeMachine, askMode, runClientExpertSetup, loadState, saveState,
|
|
683
706
|
} = await import("./client-expert-run.mjs");
|
|
684
|
-
const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode, promptForLicenseToken } =
|
|
707
|
+
const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode, licenseUnverifiedLine, promptForLicenseToken } =
|
|
685
708
|
await import("./client-expert-setup.mjs");
|
|
686
709
|
const machine0 = await probeMachine();
|
|
687
710
|
const gate0 = clientExpertAvailable({ workerEnvExists: false, ...machine0 });
|
|
@@ -700,7 +723,9 @@ if (!workerKey || !apiBase) {
|
|
|
700
723
|
// (task #13). Format is checked first (no network), then offline signature, then the
|
|
701
724
|
// live API. Empty Enter declines → the private default. Unreachable API still opens
|
|
702
725
|
// the menu (soft licence: an outage never blocks a paid user).
|
|
726
|
+
const { setupPrompts: tokenPrompts } = await import("./setup-prompts.mjs");
|
|
703
727
|
const pick = await promptForLicenseToken(ask0, licenseToken0, {
|
|
728
|
+
askSecret: tokenPrompts(rl0, { style: accent }).askSecret,
|
|
704
729
|
validateOpts: {
|
|
705
730
|
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
706
731
|
},
|
|
@@ -709,7 +734,7 @@ if (!workerKey || !apiBase) {
|
|
|
709
734
|
if (pick.declined || !pick.token) {
|
|
710
735
|
plan0 = resolveFirstRunMode({ licenseToken: "", expertAvailable: gate0.available });
|
|
711
736
|
} else if (pick.result && pick.result.reason === "unverified") {
|
|
712
|
-
console.log(dim("\n
|
|
737
|
+
console.log(dim("\n " + licenseUnverifiedLine(pick.result.detail)));
|
|
713
738
|
}
|
|
714
739
|
}
|
|
715
740
|
let mode0;
|
|
@@ -781,6 +806,7 @@ if (!workerKey || !apiBase) {
|
|
|
781
806
|
typed = (await ask0(" worker key (or Enter to sign in): ")).trim();
|
|
782
807
|
}
|
|
783
808
|
} finally {
|
|
809
|
+
rl0Done = true;
|
|
784
810
|
rl0.close();
|
|
785
811
|
}
|
|
786
812
|
if (stopAfterSetup) {
|
|
@@ -891,6 +917,7 @@ const COMMANDS = [
|
|
|
891
917
|
{ name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
|
|
892
918
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
893
919
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
920
|
+
{ name: "/clear-logs", desc: "show what gu's logs, RAG indexes and old API versions take on disk, then ask what to clear — the logs first" },
|
|
894
921
|
{ name: "/prices", usage: "/prices [update]", desc: "the price table and the cache table, and what the last turn and this session cost — \"update\" applies gu's newest published tables" },
|
|
895
922
|
// ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
|
|
896
923
|
// for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
|
|
@@ -1462,6 +1489,11 @@ function applyClick({ row, col }) {
|
|
|
1462
1489
|
void runPricesUpdate();
|
|
1463
1490
|
return;
|
|
1464
1491
|
}
|
|
1492
|
+
if (logsRowTarget && row === updateRowAbs &&
|
|
1493
|
+
col - 1 >= logsRowTarget.from && col - 1 < logsRowTarget.to) {
|
|
1494
|
+
void runClearLogs();
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1465
1497
|
if (agentsRowTarget && row === updateRowAbs &&
|
|
1466
1498
|
col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
|
|
1467
1499
|
void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
|
|
@@ -1592,6 +1624,9 @@ let imageNoticeTimer = null;
|
|
|
1592
1624
|
// its own model is unset, and choosing that model (/agent-model, /embed-model, /setup, or the
|
|
1593
1625
|
// API seeding one) clears only that one.
|
|
1594
1626
|
let agentMissing = false; // no agent model chosen → agent falls back to the coding model
|
|
1627
|
+
// NO MODEL AT ALL (#99): neither an agent model nor a coder — there is no server to send a
|
|
1628
|
+
// question to. Seeded from the startup payload; re-checked before a question is refused.
|
|
1629
|
+
let noModelAtAll = false;
|
|
1595
1630
|
let embedMissing = false; // no embed model chosen → RAG is off until /embed-model picks one
|
|
1596
1631
|
// One footer row; the two halves are independent so a model that IS set is never named.
|
|
1597
1632
|
// ONE SHORT ROW EACH (#317): joined, they ran past 80 columns and were cut mid-word.
|
|
@@ -2270,7 +2305,7 @@ async function refreshCliToken() {
|
|
|
2270
2305
|
let state = await ct.readState(dir);
|
|
2271
2306
|
const now = new Date();
|
|
2272
2307
|
const noticeNow = () =>
|
|
2273
|
-
ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
|
|
2308
|
+
ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site, why: state.lastAttemptWhy ?? "" });
|
|
2274
2309
|
tokenNotice = noticeNow();
|
|
2275
2310
|
|
|
2276
2311
|
const early = Boolean(tokenNotice) && !startupRenewDone;
|
|
@@ -2289,7 +2324,8 @@ async function refreshCliToken() {
|
|
|
2289
2324
|
// A reply is only kept if it checks out here — a proxy's HTML page or another server's token
|
|
2290
2325
|
// must not replace a good one.
|
|
2291
2326
|
const fresh = r.ok && ct.verifyCliToken(r.token, pk).ok ? r.token : state.token;
|
|
2292
|
-
|
|
2327
|
+
// WHY it failed is kept with the attempt, for the bar (#102).
|
|
2328
|
+
state = { token: fresh, lastAttemptAt: now.toISOString(), lastAttemptWhy: r.ok ? "" : String(r.why ?? "") };
|
|
2293
2329
|
await ct.writeState(dir, state).catch(() => {});
|
|
2294
2330
|
tokenNotice = noticeNow();
|
|
2295
2331
|
}
|
|
@@ -2680,6 +2716,9 @@ function zoneNotice(width) {
|
|
|
2680
2716
|
if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
|
|
2681
2717
|
const agents = agentsIndicator(otherAgents);
|
|
2682
2718
|
if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
|
|
2719
|
+
// LAST, so it is the first to give way on a narrow window: housekeeping, never urgent (#329).
|
|
2720
|
+
const logs = clearLogsNoticeText(logsBytes);
|
|
2721
|
+
if (logs) items.push({ key: "logs", clickable: true, paint: dim, text: logs });
|
|
2683
2722
|
return noticeRow(items, { width, gutter: GUTTER });
|
|
2684
2723
|
}
|
|
2685
2724
|
|
|
@@ -2760,6 +2799,7 @@ function frozenZone() {
|
|
|
2760
2799
|
updateRowTarget = notice?.spans.update ?? null;
|
|
2761
2800
|
agentsRowTarget = notice?.spans.agents ?? null;
|
|
2762
2801
|
pricesRowTarget = notice?.spans.prices ?? null;
|
|
2802
|
+
logsRowTarget = notice?.spans.logs ?? null;
|
|
2763
2803
|
jumpSpan = notice?.spans.jump ?? null;
|
|
2764
2804
|
return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
|
|
2765
2805
|
}
|
|
@@ -2829,7 +2869,7 @@ function paintBar() {
|
|
|
2829
2869
|
// THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
|
|
2830
2870
|
const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
|
|
2831
2871
|
jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
|
|
2832
|
-
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
|
|
2872
|
+
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || logsRowTarget ? noticeAbs : 0;
|
|
2833
2873
|
// preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
|
|
2834
2874
|
// is parked on the input line right after, so there is nothing to preserve here.
|
|
2835
2875
|
ui(
|
|
@@ -2991,6 +3031,8 @@ let updateRowAbs = 0;
|
|
|
2991
3031
|
let agentsRowTarget = null;
|
|
2992
3032
|
// …and "new prices updated <date>" (#323).
|
|
2993
3033
|
let pricesRowTarget = null;
|
|
3034
|
+
// …and "clear logs · 175 MB" (#329), shown only once the logs are worth a row.
|
|
3035
|
+
let logsRowTarget = null;
|
|
2994
3036
|
// A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
|
|
2995
3037
|
// bracketed-paste and mouse-reporting teardowns.
|
|
2996
3038
|
process.on("exit", () => {
|
|
@@ -7071,6 +7113,16 @@ function writePresetFile(pr, file, name, parsed) {
|
|
|
7071
7113
|
const v = pr.validatePresetFile(JSON.stringify(out));
|
|
7072
7114
|
console.log(GUTTER + `Preset "${name}" saved to ${file}:`);
|
|
7073
7115
|
if (v.ok && v.full) for (const l of pr.fullPresetSummary(v.value, v.codingDefault)) console.log(GUTTER + dim(l));
|
|
7116
|
+
// SAY WHAT IS ACTUALLY IN IT (reported 2026-10-02). This line used to read "It CONTAINS API
|
|
7117
|
+
// keys" under a summary that said "no key" four times: the pasted text held key NAMES only, and
|
|
7118
|
+
// the person carried the file to another machine believing the keys were inside.
|
|
7119
|
+
const n = pr.presetKeyCounts(out);
|
|
7120
|
+
if (!n.withValue) {
|
|
7121
|
+
console.log(GUTTER + yellow("It contains NO API keys") + dim(n.nameOnly ? ` — only the names of ${n.nameOnly}. Using it will ask you to paste each one.` : "."));
|
|
7122
|
+
console.log(GUTTER + dim("To carry keys: on the computer that HAS them run /open-presets and copy (keys included)."));
|
|
7123
|
+
return;
|
|
7124
|
+
}
|
|
7125
|
+
if (n.nameOnly) console.log(GUTTER + yellow(`${n.nameOnly} of its keys ${n.nameOnly === 1 ? "is" : "are"} a name only`) + dim(" — their values are not in the file."));
|
|
7074
7126
|
} else {
|
|
7075
7127
|
writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset: parsed.preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
|
|
7076
7128
|
console.log(GUTTER + `Preset "${name}" saved to ${file}`);
|
|
@@ -7879,6 +7931,128 @@ async function runPricesUpdate() {
|
|
|
7879
7931
|
}
|
|
7880
7932
|
}
|
|
7881
7933
|
|
|
7934
|
+
// ---------------------------------------------------------------------------------------------
|
|
7935
|
+
// "CLEAR LOGS" (#329): a quiet item at the right of the notice row once gu's logs are worth a row,
|
|
7936
|
+
// and /clear-logs for where there is no mouse. A click deletes nothing: it MEASURES — the logs,
|
|
7937
|
+
// and beside them the two things that are actually big, the RAG indexes and the downloaded API
|
|
7938
|
+
// versions — shows that with a total, then asks about each. What each "clear" may and may not
|
|
7939
|
+
// touch is decided in clear-logs.mjs; this is the terminal half only.
|
|
7940
|
+
// ---------------------------------------------------------------------------------------------
|
|
7941
|
+
let logsBytes = 0; // what the notice shows; only the logs are measured for it (a handful of stats)
|
|
7942
|
+
let clearLogsBusy = false;
|
|
7943
|
+
|
|
7944
|
+
async function refreshLogsSize() {
|
|
7945
|
+
try {
|
|
7946
|
+
const { surveyLogs } = await import("./clear-logs.mjs");
|
|
7947
|
+
const was = clearLogsNoticeText(logsBytes);
|
|
7948
|
+
logsBytes = (await surveyLogs()).bytes;
|
|
7949
|
+
if (clearLogsNoticeText(logsBytes) !== was && barPinned()) paintBar();
|
|
7950
|
+
} catch {
|
|
7951
|
+
/* a housekeeping notice is never worth an error */
|
|
7952
|
+
}
|
|
7953
|
+
}
|
|
7954
|
+
|
|
7955
|
+
async function runClearLogs() {
|
|
7956
|
+
// ONE AT A TIME, and never over a turn or another picker — reached from a fire-and-forget click
|
|
7957
|
+
// (see showOtherAgents for what a second picker over the first one does).
|
|
7958
|
+
if (clearLogsBusy || listPickerActive || approvalActive || following) return;
|
|
7959
|
+
clearLogsBusy = true;
|
|
7960
|
+
try {
|
|
7961
|
+
const cl = await import("./clear-logs.mjs");
|
|
7962
|
+
const { LOCAL_API_PORT } = await import("./client-expert-api.mjs");
|
|
7963
|
+
const width = Math.max(20, process.stdout.columns || 80);
|
|
7964
|
+
console.log("\n" + GUTTER + dim("Measuring ~/.gonext…"));
|
|
7965
|
+
// Which API version is ANSWERING decides what "old" means — ask it, do not infer it.
|
|
7966
|
+
const runningVersion = await fetch(`http://127.0.0.1:${LOCAL_API_PORT}/api/health`, { signal: AbortSignal.timeout(2000) })
|
|
7967
|
+
.then((r) => r.json())
|
|
7968
|
+
.then((j) => String(j?.version ?? ""))
|
|
7969
|
+
.catch(() => "");
|
|
7970
|
+
const survey = await cl.surveyDisk({ runningVersion });
|
|
7971
|
+
console.log("");
|
|
7972
|
+
for (const row of cl.summaryRows(survey, { width: width - GUTTER.length - 1 })) {
|
|
7973
|
+
console.log(GUTTER + (row.startsWith("Total") ? bold(row) : row));
|
|
7974
|
+
}
|
|
7975
|
+
const freed = [];
|
|
7976
|
+
// A question never wraps: at 80 columns over SSH the picker hint goes on a row of its own.
|
|
7977
|
+
const question = (q) => {
|
|
7978
|
+
const hint = `(${PICK_HINT}):`;
|
|
7979
|
+
console.log("");
|
|
7980
|
+
if (2 + q.length + 2 + hint.length < width) console.log(dim(` ${q} ${hint}`));
|
|
7981
|
+
else console.log(dim(` ${q}\n ${hint}`));
|
|
7982
|
+
};
|
|
7983
|
+
const cancelled = () => {
|
|
7984
|
+
console.log(dim(` (stopped — nothing ${freed.length ? "further " : ""}removed)\n`));
|
|
7985
|
+
paintBar();
|
|
7986
|
+
};
|
|
7987
|
+
|
|
7988
|
+
// 1. THE LOGS — what was asked for, so Yes is the first row.
|
|
7989
|
+
if (survey.logs.bytes > 0) {
|
|
7990
|
+
question(`Clear the logs — ${cl.humanSize(survey.logs.bytes)}?`);
|
|
7991
|
+
const go = await pickFromList(["Yes — clear the logs" + dim(" · the worker and the API keep running"), "No"], 0);
|
|
7992
|
+
if (go < 0) return cancelled();
|
|
7993
|
+
if (go === 0) {
|
|
7994
|
+
const r = await cl.clearLogs(survey.logs);
|
|
7995
|
+
freed.push({ what: "logs", ...r });
|
|
7996
|
+
console.log(GUTTER + accent(`✓ logs cleared — ${cl.humanSize(r.freed)} freed`) +
|
|
7997
|
+
(r.failed.length ? yellow(` (still in use, left alone: ${r.failed.join(", ")})`) : ""));
|
|
7998
|
+
} else {
|
|
7999
|
+
console.log(GUTTER + dim("logs kept"));
|
|
8000
|
+
}
|
|
8001
|
+
} else {
|
|
8002
|
+
console.log("\n" + GUTTER + dim("No logs to clear."));
|
|
8003
|
+
}
|
|
8004
|
+
|
|
8005
|
+
// 2. RAG — costs a re-index, so No is the first row.
|
|
8006
|
+
if (survey.rag.indexes.length) {
|
|
8007
|
+
const gone = survey.rag.indexes.filter((i) => i.missing);
|
|
8008
|
+
const goneBytes = gone.reduce((n, i) => n + i.bytes, 0);
|
|
8009
|
+
const choices = [{ id: "no", label: "No — keep them" }];
|
|
8010
|
+
if (gone.length && gone.length < survey.rag.indexes.length) {
|
|
8011
|
+
choices.push({ id: "gone", label: `Only the ${gone.length} whose folder no longer exists — ${cl.humanSize(goneBytes)}` });
|
|
8012
|
+
}
|
|
8013
|
+
choices.push({ id: "all", label: `Yes — all ${survey.rag.indexes.length}` + dim(" · a folder is indexed again when it is next used") });
|
|
8014
|
+
question(`Also clear the RAG indexes — ${cl.humanSize(survey.rag.bytes)}?`);
|
|
8015
|
+
const pick = await pickFromList(choices.map((c) => c.label), 0);
|
|
8016
|
+
if (pick < 0) return cancelled();
|
|
8017
|
+
if (choices[pick].id !== "no") {
|
|
8018
|
+
const r = await cl.clearRagIndexes(choices[pick].id === "gone" ? gone : survey.rag.indexes);
|
|
8019
|
+
freed.push({ what: "rag", ...r });
|
|
8020
|
+
console.log(GUTTER + accent(`✓ ${r.removed} RAG index${r.removed === 1 ? "" : "es"} cleared — ${cl.humanSize(r.freed)} freed`) +
|
|
8021
|
+
(r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
|
|
8022
|
+
} else {
|
|
8023
|
+
console.log(GUTTER + dim("RAG indexes kept"));
|
|
8024
|
+
}
|
|
8025
|
+
}
|
|
8026
|
+
|
|
8027
|
+
// 3. THE API — only ever the OLDER versions; the one in use is not on offer.
|
|
8028
|
+
if (survey.api.old.length) {
|
|
8029
|
+
const n = survey.api.old.length;
|
|
8030
|
+
question(`Also remove the ${n} older API version${n === 1 ? "" : "s"} — ${cl.humanSize(survey.api.oldBytes)}?`);
|
|
8031
|
+
const go = await pickFromList(["No — keep them", `Yes — remove the ${n} older one${n === 1 ? "" : "s"}` +
|
|
8032
|
+
dim(survey.api.inUse ? ` · ${survey.api.inUse}, the one in use, stays` : "")], 0);
|
|
8033
|
+
if (go < 0) return cancelled();
|
|
8034
|
+
if (go === 1) {
|
|
8035
|
+
console.log(GUTTER + dim("Removing…"));
|
|
8036
|
+
const r = await cl.clearOldApiVersions(survey.api.old);
|
|
8037
|
+
freed.push({ what: "api", ...r });
|
|
8038
|
+
console.log(GUTTER + accent(`✓ ${r.removed} older API version${r.removed === 1 ? "" : "s"} removed — ${cl.humanSize(r.freed)} freed`) +
|
|
8039
|
+
(r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
|
|
8040
|
+
} else {
|
|
8041
|
+
console.log(GUTTER + dim("older API versions kept"));
|
|
8042
|
+
}
|
|
8043
|
+
}
|
|
8044
|
+
|
|
8045
|
+
const total = freed.reduce((n, f) => n + f.freed, 0);
|
|
8046
|
+
console.log("\n" + GUTTER + (freed.length ? bold(`${cl.humanSize(total)} freed in all.`) : dim("Nothing removed.")) + "\n");
|
|
8047
|
+
} catch (e) {
|
|
8048
|
+
console.log(GUTTER + red(`couldn't finish — ${e?.message ?? e}`) + "\n");
|
|
8049
|
+
} finally {
|
|
8050
|
+
clearLogsBusy = false;
|
|
8051
|
+
await refreshLogsSize();
|
|
8052
|
+
if (barPinned()) paintBar();
|
|
8053
|
+
}
|
|
8054
|
+
}
|
|
8055
|
+
|
|
7882
8056
|
/**
|
|
7883
8057
|
* `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
|
|
7884
8058
|
*
|
|
@@ -10308,8 +10482,13 @@ async function tryStartLocalApi() {
|
|
|
10308
10482
|
);
|
|
10309
10483
|
const version = versions.pop();
|
|
10310
10484
|
if (!version) return false; // nothing installed — setup never got that far
|
|
10485
|
+
const portBefore = ce.LOCAL_API_PORT;
|
|
10311
10486
|
await ce.startLocalApi({ apiDir: join(base, version) });
|
|
10312
|
-
|
|
10487
|
+
const up = (await ce.waitForApi({ timeoutMs: 30_000 })).ok;
|
|
10488
|
+
// The port can move here (#337: the saved one now belongs to another program). worker.env
|
|
10489
|
+
// was re-pointed; THIS terminal still holds the old address until it reads the file again.
|
|
10490
|
+
if (ce.LOCAL_API_PORT !== portBefore) reloadWorkerEnv();
|
|
10491
|
+
return up;
|
|
10313
10492
|
} catch {
|
|
10314
10493
|
return false;
|
|
10315
10494
|
}
|
|
@@ -10641,6 +10820,22 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
10641
10820
|
// from the gu service with it. Asked for HERE, before anything is sent, with the steps — rather
|
|
10642
10821
|
// than letting the turn fail and leaving the user to decode why.
|
|
10643
10822
|
if (!skipAsk && !(await ensureTokenForTurn())) return;
|
|
10823
|
+
// NOTHING TO ANSWER WITH (#99). On an account with no models at all the question used to be
|
|
10824
|
+
// sent, sat at "queued — waiting for the worker…", and ended in "✗ Job not found." — the job
|
|
10825
|
+
// had no model server in it, so nothing could read or claim it. Say what is missing instead.
|
|
10826
|
+
// Asked again first: /model or /agent-model may have set one since gu started.
|
|
10827
|
+
if (!skipAsk && noModelAtAll) {
|
|
10828
|
+
try {
|
|
10829
|
+
noModelAtAll = nothingToAnswerWith((await fetchAgentPayload([{ role: "user", content: "." }]))?.payload);
|
|
10830
|
+
} catch {
|
|
10831
|
+
/* cannot ask → keep what startup saw */
|
|
10832
|
+
}
|
|
10833
|
+
if (noModelAtAll) {
|
|
10834
|
+
for (const l of NO_MODEL_LINES) console.log(GUTTER + (l === NO_MODEL_LINES[0] ? red("✗ ") + l : dim(" " + l)));
|
|
10835
|
+
console.log("");
|
|
10836
|
+
return;
|
|
10837
|
+
}
|
|
10838
|
+
}
|
|
10644
10839
|
inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
|
|
10645
10840
|
// Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
|
|
10646
10841
|
// the next thing to draw itself is the turn's ticker — which stays silent until the daemon
|
|
@@ -12129,6 +12324,7 @@ async function main() {
|
|
|
12129
12324
|
// private (task #13); empty Enter declines to the private default.
|
|
12130
12325
|
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
12131
12326
|
const pick1 = await promptForLicenseToken(ask, licenseToken1, {
|
|
12327
|
+
askSecret,
|
|
12132
12328
|
validateOpts: {
|
|
12133
12329
|
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
12134
12330
|
},
|
|
@@ -12358,6 +12554,7 @@ async function main() {
|
|
|
12358
12554
|
// the local setup answers — OR them in, never clear a local "missing" on a maybe, because
|
|
12359
12555
|
// private mode's local API can itself be unconfigured.
|
|
12360
12556
|
if (p.agentMissing === true) agentMissing = true;
|
|
12557
|
+
noModelAtAll = nothingToAnswerWith(p);
|
|
12361
12558
|
if (p.ragEnabled !== true) embedMissing = true;
|
|
12362
12559
|
// A RESTORED PICK MUST STILL EXIST. The stored id names a model on the box this folder was
|
|
12363
12560
|
// last pointed at — and the box can change (`/model` → change the Ollama server), a model
|
|
@@ -12451,7 +12648,14 @@ async function main() {
|
|
|
12451
12648
|
// Task #136/#154: mode changes WHERE things run — too important to leave implicit.
|
|
12452
12649
|
// The precedence lives in modeRow() with its tests; see the note there.
|
|
12453
12650
|
...(() => {
|
|
12454
|
-
const m = modeRow({
|
|
12651
|
+
const m = modeRow({
|
|
12652
|
+
clientExpert: isClientExpert(),
|
|
12653
|
+
clientMode: clientModeBanner,
|
|
12654
|
+
modelUrls: [p.agentBaseURL],
|
|
12655
|
+
// The coder IN USE in this folder, not the account default (the raw payload field
|
|
12656
|
+
// is the default only — key-choice-regression.test.mjs).
|
|
12657
|
+
coderWhere: activeCoderWhere,
|
|
12658
|
+
});
|
|
12455
12659
|
if (!m) return [];
|
|
12456
12660
|
const name = m.label === "client expert" ? accent(m.label + " ") : m.label + " ";
|
|
12457
12661
|
return [lbl("mode") + name + dim(m.detail)];
|
|
@@ -12548,6 +12752,8 @@ async function main() {
|
|
|
12548
12752
|
confirm: (q, defYes) => askYesNo(q, defYes),
|
|
12549
12753
|
// A client installs ONLY the framework — never mlx-lm/hf.
|
|
12550
12754
|
modelHost: clientModeBanner ? false : undefined,
|
|
12755
|
+
// The yes two lines up IS the answer — do not ask the same thing again (#333).
|
|
12756
|
+
agreed: ["agentLibs"],
|
|
12551
12757
|
});
|
|
12552
12758
|
// The worker reads worker.env ONCE at startup, so a daemon that is already
|
|
12553
12759
|
// running still has the OLD python — the packages would look "not installed"
|
|
@@ -12844,6 +13050,8 @@ async function main() {
|
|
|
12844
13050
|
// #323: gu's newest published price + cache tables — same cadence, same silent failure.
|
|
12845
13051
|
void refreshPriceUpdate();
|
|
12846
13052
|
setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
|
|
13053
|
+
void refreshLogsSize();
|
|
13054
|
+
setInterval(() => void refreshLogsSize(), UPDATE_CHECK_MS).unref();
|
|
12847
13055
|
// The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
|
|
12848
13056
|
startupTokenRefresh = refreshCliToken();
|
|
12849
13057
|
setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
|
|
@@ -13304,6 +13512,10 @@ async function main() {
|
|
|
13304
13512
|
await runPricesCommand(line);
|
|
13305
13513
|
continue;
|
|
13306
13514
|
}
|
|
13515
|
+
if (line === "/clear-logs") {
|
|
13516
|
+
await runClearLogs();
|
|
13517
|
+
continue;
|
|
13518
|
+
}
|
|
13307
13519
|
if (line === "/tokens") {
|
|
13308
13520
|
// Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
|
|
13309
13521
|
// this answers "and what displaced it", which is the part that tells you whether RAG is
|
package/install-path.mjs
CHANGED
|
@@ -56,11 +56,33 @@ if (isEntryPoint) {
|
|
|
56
56
|
main();
|
|
57
57
|
} catch (e) {
|
|
58
58
|
// A postinstall that throws uninstalls the package. Report, swallow, succeed.
|
|
59
|
-
console.log(`gu-cli: PATH setup skipped (${e.message}) —
|
|
59
|
+
console.log(`gu-cli: PATH setup skipped (${e.message}) — run \`gu-cli fix-path\` if \`gu\` is not found.`);
|
|
60
60
|
}
|
|
61
61
|
process.exit(0);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The npm global prefix this package is installed under, from where its own files are — or "".
|
|
66
|
+
*
|
|
67
|
+
* For `gu-cli fix-path` (task #333): the same PATH repair as this postinstall, WITHOUT npm
|
|
68
|
+
* running it. npm 11 already prints "install scripts not yet covered by allowScripts" for this
|
|
69
|
+
* package on every install; it still runs the script today (checked on 11.19.1), but the day it
|
|
70
|
+
* stops, a custom-prefix user is back to "gu: command not found" with nothing to run. A global
|
|
71
|
+
* install lives at <prefix>/lib/node_modules/@tiens.nguyen/gu-cli; anything else (a checkout,
|
|
72
|
+
* `npm link`, a project's node_modules) is not one, and gets no PATH edit.
|
|
73
|
+
*/
|
|
74
|
+
export function globalPrefixOf(packageDir) {
|
|
75
|
+
const parts = resolve(String(packageDir ?? "")).split("/");
|
|
76
|
+
const n = parts.length;
|
|
77
|
+
if (n < 5 || parts[n - 3] !== "node_modules" || parts[n - 4] !== "lib") return "";
|
|
78
|
+
if (!parts[n - 2].startsWith("@")) return "";
|
|
79
|
+
// A project-local install is <project>/node_modules/… — never <x>/lib/node_modules, but a
|
|
80
|
+
// nested one could be; a prefix inside another node_modules tree is not a global prefix.
|
|
81
|
+
const prefix = parts.slice(0, n - 4);
|
|
82
|
+
if (prefix.includes("node_modules")) return "";
|
|
83
|
+
return prefix.join("/") || "/";
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
function main() {
|
|
65
87
|
if (platform() === "win32") return; // registry PATH + PowerShell alias: not here
|
|
66
88
|
if (process.env.CI) return; // no user shell to help in CI
|
package/model-doctor.mjs
CHANGED
|
@@ -130,6 +130,17 @@ export function isExternallyManaged(output) {
|
|
|
130
130
|
return /externally[\s-]?managed/i.test(String(output ?? ""));
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Is there no pip on this python at all? (task #333)
|
|
135
|
+
*
|
|
136
|
+
* A stock Ubuntu 24.04 ships python3 WITHOUT pip (python3-pip is a separate package), so the
|
|
137
|
+
* first install said "/usr/bin/python3: No module named pip" and gave up — although
|
|
138
|
+
* python3-venv was installed and a venv brings its own pip. Same remedy as PEP 668: a venv.
|
|
139
|
+
*/
|
|
140
|
+
export function isPipMissing(output) {
|
|
141
|
+
return /No module named pip\b/i.test(String(output ?? ""));
|
|
142
|
+
}
|
|
143
|
+
|
|
133
144
|
/** Run pip once. Resolves { code, err } — never rejects, so the caller owns the policy. */
|
|
134
145
|
function runPip(python, pkgs, extraArgs = []) {
|
|
135
146
|
return new Promise((resolve) => {
|
|
@@ -190,11 +201,16 @@ async function pipInstall(pkgs, log) {
|
|
|
190
201
|
log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
|
|
191
202
|
let r = await runPip(python3Bin(), pkgs);
|
|
192
203
|
if (r.code === 0) return;
|
|
193
|
-
|
|
204
|
+
const noPip = isPipMissing(r.err);
|
|
205
|
+
if (!noPip && !isExternallyManaged(r.err)) {
|
|
194
206
|
throw new Error(`pip install ${pkgs.join(" ")} exited ${r.code}`);
|
|
195
207
|
}
|
|
196
208
|
|
|
197
|
-
log(
|
|
209
|
+
log(
|
|
210
|
+
noPip
|
|
211
|
+
? " this python has no pip — using a private virtualenv, which brings its own."
|
|
212
|
+
: " this python is managed by your OS (PEP 668) — it will not accept packages directly."
|
|
213
|
+
);
|
|
198
214
|
const venvPy = await ensureVenv(log);
|
|
199
215
|
if (venvPy) {
|
|
200
216
|
r = await runPip(venvPy, pkgs);
|
|
@@ -208,6 +224,13 @@ async function pipInstall(pkgs, log) {
|
|
|
208
224
|
}
|
|
209
225
|
}
|
|
210
226
|
|
|
227
|
+
// Without pip AND without venv nothing here can install anything; --break-system-packages
|
|
228
|
+
// would only fail again with the same "No module named pip". Say what to install instead.
|
|
229
|
+
if (noPip) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
"this python has no pip and no venv module — on Debian/Ubuntu: sudo apt install -y python3-venv, then gu doctor"
|
|
232
|
+
);
|
|
233
|
+
}
|
|
211
234
|
log(" falling back to --break-system-packages (no virtualenv available)");
|
|
212
235
|
r = await runPip(python3Bin(), pkgs, ["--break-system-packages"]);
|
|
213
236
|
if (r.code === 0) return;
|
|
@@ -1246,7 +1269,13 @@ export async function ensureClaudeSdk({ log, confirm }) {
|
|
|
1246
1269
|
return (await pyHasModule("anthropic")) ? "installed" : "failed";
|
|
1247
1270
|
}
|
|
1248
1271
|
|
|
1249
|
-
|
|
1272
|
+
/**
|
|
1273
|
+
* `agreed` names packages the caller has ALREADY asked about (task #333): `gu` asks "Install the
|
|
1274
|
+
* agent framework now?" and then called this, which asked "Install the agent framework
|
|
1275
|
+
* (smolagents + openai) — required to run the agent?" — the same question twice in a row on
|
|
1276
|
+
* every first install. A yes that was just given is not asked for again.
|
|
1277
|
+
*/
|
|
1278
|
+
export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels(), agreed = [] }) {
|
|
1250
1279
|
const out = {};
|
|
1251
1280
|
log("");
|
|
1252
1281
|
log(" Checking the agent's dependencies…");
|
|
@@ -1264,7 +1293,7 @@ export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxMode
|
|
|
1264
1293
|
out[key] = "present";
|
|
1265
1294
|
return;
|
|
1266
1295
|
}
|
|
1267
|
-
if (!(await confirm(question, true))) {
|
|
1296
|
+
if (!agreed.includes(key) && !(await confirm(question, true))) {
|
|
1268
1297
|
out[key] = "skipped";
|
|
1269
1298
|
log(` Skipped ${pkgs.join(" ")} — install later: ${python3Bin()} -m pip install -U ${pkgs.join(" ")}`);
|
|
1270
1299
|
return;
|
package/node-version.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export const MIN_NODE_MAJOR = 18;
|
|
|
17
17
|
/** The message for a too-old runtime, or "" when this one is fine. Pure, so it is testable. */
|
|
18
18
|
export function nodeVersionProblem(version = process.version) {
|
|
19
19
|
const major = Number.parseInt(String(version).replace(/^v/, "").split(".")[0], 10);
|
|
20
|
-
if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) return "";
|
|
20
|
+
if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) {return "";}
|
|
21
21
|
return (
|
|
22
22
|
`gu needs Node ${MIN_NODE_MAJOR} or newer — this is ${version}.\n` +
|
|
23
23
|
` Node ${major} has no global fetch, which the setup and update paths rely on.\n` +
|
|
@@ -30,10 +30,58 @@ export function nodeVersionProblem(version = process.version) {
|
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* LOCAL MODE NEEDS MORE THAN GU DOES (task #333). The Local API ships the mongodb 7 driver, whose
|
|
35
|
+
* floor is Node 20.19 — and setup loads that driver IN THIS PROCESS to seed the database, then
|
|
36
|
+
* starts the API with this same node. On Node 18 the first sign was "Could not prepare the
|
|
37
|
+
* database: crypto is not defined", halfway through a first install, after MongoDB and the API
|
|
38
|
+
* were already in place. Found on a stock Ubuntu 24.04, whose own `nodejs` package is 18.19.
|
|
39
|
+
*
|
|
40
|
+
* Kept apart from MIN_NODE_MAJOR on purpose: the CLI itself (Cloud mode) still runs on 18.
|
|
41
|
+
*/
|
|
42
|
+
export const LOCAL_API_MIN_NODE = "20.19.0";
|
|
43
|
+
|
|
44
|
+
/** True when `version` is at least `min` (both "x.y.z", a leading v allowed). */
|
|
45
|
+
export function nodeAtLeast(version, min) {
|
|
46
|
+
const parts = (v) => String(v).replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
47
|
+
const a = parts(version);
|
|
48
|
+
const b = parts(min);
|
|
49
|
+
for (let i = 0; i < 3; i++) {
|
|
50
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0)) {return (a[i] ?? 0) > (b[i] ?? 0);}
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Why Local mode cannot run on this node, with what to do — or "" when it can. Pure. */
|
|
56
|
+
export function localModeNodeProblem(version = process.version, platform = process.platform) {
|
|
57
|
+
if (nodeAtLeast(version, LOCAL_API_MIN_NODE)) {return "";}
|
|
58
|
+
const how =
|
|
59
|
+
platform === "win32"
|
|
60
|
+
? [" winget install OpenJS.NodeJS.LTS (or: nvm install lts with nvm-windows)"]
|
|
61
|
+
: platform === "darwin"
|
|
62
|
+
? [" brew install node (or: nvm install --lts)"]
|
|
63
|
+
: [
|
|
64
|
+
" nvm install --lts && nvm alias default lts/*",
|
|
65
|
+
" or NodeSource:",
|
|
66
|
+
" curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -",
|
|
67
|
+
" sudo apt install -y nodejs",
|
|
68
|
+
];
|
|
69
|
+
return (
|
|
70
|
+
`Local mode needs Node ${LOCAL_API_MIN_NODE} or newer — this is ${version}.\n` +
|
|
71
|
+
" Its API uses the MongoDB 7 driver, which does not run on older Node.\n" +
|
|
72
|
+
(platform === "linux" ? " (Ubuntu 24.04's own nodejs package is 18.)\n" : "") +
|
|
73
|
+
"\n" +
|
|
74
|
+
how.join("\n") +
|
|
75
|
+
"\n\n" +
|
|
76
|
+
" Then reinstall gu with that Node: npm i -g @tiens.nguyen/gu-cli\n" +
|
|
77
|
+
" Nothing was installed or changed."
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
33
81
|
/** Print the problem and exit, or return quietly. */
|
|
34
82
|
export function requireSupportedNode() {
|
|
35
83
|
const problem = nodeVersionProblem();
|
|
36
|
-
if (!problem) return;
|
|
84
|
+
if (!problem) {return;}
|
|
37
85
|
// console.error, not the REPL's own writer: this runs before any of that exists.
|
|
38
86
|
console.error("\n" + problem + "\n");
|
|
39
87
|
process.exit(1);
|
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.793",
|
|
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",
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"poll-errors.mjs",
|
|
78
78
|
"node-version.mjs",
|
|
79
79
|
"clear-state.mjs",
|
|
80
|
+
"clear-logs.mjs",
|
|
80
81
|
"platform-tools.mjs",
|
|
81
82
|
"job-pools.mjs",
|
|
82
83
|
"agent-stop.mjs",
|