@tiens.nguyen/gu-cli 1.0.897 → 1.0.898
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 +28 -12
- package/agent-model-label.mjs +1 -1
- package/client-expert-run.mjs +92 -7
- package/doctor-check.mjs +7 -4
- package/embed-model-command.mjs +2 -2
- package/gu-cli.mjs +8 -2
- package/gu-repl.mjs +188 -70
- package/mongo-mac.mjs +166 -0
- package/node-version.mjs +25 -3
- package/package.json +3 -2
- package/server-keys.mjs +0 -1
- package/terminal-layout.mjs +4 -26
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<h1 align="center">gu — the coding agent for your own models</h1>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<strong>
|
|
5
|
-
|
|
6
|
-
<strong>Free
|
|
4
|
+
<strong>Describe the change. gu edits your repo, runs the tests and shows you the diff, with Ollama or any OpenAI-compatible API.</strong><br>
|
|
5
|
+
Your prompts and code go to the model you choose, not to gu: its API and database run on your computer.<br>
|
|
6
|
+
<strong>Free for 30 days: no account, no sign-in, no card.</strong>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
<a href="https://gucli.com">Website</a> ·
|
|
19
19
|
<a href="https://gucli.com/start">Docs</a> ·
|
|
20
20
|
<a href="https://gucli.com/pricing">Pricing</a> ·
|
|
21
|
-
<a href="https://gucli.com/support">Support</a>
|
|
21
|
+
<a href="https://gucli.com/support">Support</a><br>
|
|
22
|
+
<sub>The website is also in Tiếng Việt, 中文 and 日本語.</sub>
|
|
22
23
|
</p>
|
|
23
24
|
|
|
24
25
|
<p align="center">
|
|
@@ -40,8 +41,13 @@ gu
|
|
|
40
41
|
computer. Press Enter to use it, pick a model provider, then type what you want done.
|
|
41
42
|
|
|
42
43
|
A free install is **private**: gu's API and its database (MongoDB) run on this computer, next to
|
|
43
|
-
your code. The first run shows what it will
|
|
44
|
-
Needs **Node.js 20.19+**.
|
|
44
|
+
your code, and your prompts and code go to the model you pick, not to gu. The first run shows what it will
|
|
45
|
+
install and asks before installing anything. Needs **Node.js 20.19+**.
|
|
46
|
+
|
|
47
|
+
**Local or hosted: you choose once, at the first run.** Free installs run in *Local mode* (above).
|
|
48
|
+
With a Pro token, gu uses gu's hosted API and models instead. To switch later, run `gu clear` and
|
|
49
|
+
set gu up again. In Local mode `gu clear` also deletes this computer's conversations (it asks
|
|
50
|
+
first); `gu clear --keep-history` keeps them.
|
|
45
51
|
|
|
46
52
|
> **`gu: command not found`?** Run `npx @tiens.nguyen/gu-cli fix-path`. See [Troubleshooting](#troubleshooting).
|
|
47
53
|
|
|
@@ -65,11 +71,15 @@ Needs **Node.js 20.19+**.
|
|
|
65
71
|
## Is gu for you?
|
|
66
72
|
|
|
67
73
|
**A good fit if** you run (or want to run) your own models, can't send code to someone else's
|
|
68
|
-
cloud, work on remote Linux boxes over SSH, or want a coding agent that
|
|
69
|
-
|
|
74
|
+
cloud, work on remote Linux boxes over SSH, or want a terminal coding agent that is free with
|
|
75
|
+
your own models and $4 a month with ours.
|
|
70
76
|
|
|
71
77
|
**Want a frontier model?** gu runs those too, through any OpenAI-compatible API, and keeps
|
|
72
|
-
everything else: the edits in your repo, the tests, the browser check and `/undo`.
|
|
78
|
+
everything else: the edits in your repo, the tests, the browser check and `/undo`. (Your code then
|
|
79
|
+
goes to that provider, as with any tool that uses it.)
|
|
80
|
+
|
|
81
|
+
**Not a fit if** you want one click and no setup: gu asks you to pick a model, and it works best
|
|
82
|
+
with a strong one.
|
|
73
83
|
|
|
74
84
|
## Quick start
|
|
75
85
|
|
|
@@ -89,8 +99,8 @@ gu uses up to three models, each with its own command:
|
|
|
89
99
|
| `/agent-model` | **agent**: talks to you and plans | this machine |
|
|
90
100
|
| `/embed-model` | **embedding**: RAG (`nomic-embed-text`, `qwen3-embedding`), local or remote | this machine |
|
|
91
101
|
|
|
92
|
-
Run `/doctor` any time to check that each one answers.
|
|
93
|
-
|
|
102
|
+
Run `/doctor` any time to check that each one answers. Pick a strong model for coding: small
|
|
103
|
+
local models often fail at multi-step changes.
|
|
94
104
|
|
|
95
105
|
## Using gu
|
|
96
106
|
|
|
@@ -167,7 +177,7 @@ gu doctor set up and check models on this machine
|
|
|
167
177
|
gu rag [plan [path] | --test] test the embedder, estimate index time
|
|
168
178
|
gu workspace revert [runId] undo an agent run's file edits from its backups
|
|
169
179
|
gu support send a support request with masked logs
|
|
170
|
-
gu clear [--
|
|
180
|
+
gu clear [--keep-history] back to a fresh machine (asks first)
|
|
171
181
|
gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
|
|
172
182
|
```
|
|
173
183
|
|
|
@@ -188,6 +198,7 @@ gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
|
|
|
188
198
|
| --- | --- | --- |
|
|
189
199
|
| **Free trial** | 30 days, from the first run | nothing: the token is created for you |
|
|
190
200
|
| **Free plan** | 3 more months on that computer | a free account at [gucli.com](https://gucli.com) |
|
|
201
|
+
| **After that** | gu asks for a token | Pro, or a voucher for more free days |
|
|
191
202
|
| **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run), any number of computers, provider budgets |
|
|
192
203
|
|
|
193
204
|
On Pro, the hosted models and the hosted API run on gu's servers, so the prompts and code you
|
|
@@ -206,6 +217,11 @@ so the step that adds npm's bin folder to your PATH may not have run. Run
|
|
|
206
217
|
`npx @tiens.nguyen/gu-cli fix-path`. It also finds what an old install left behind (broken links,
|
|
207
218
|
a second gu from another npm, a stale PATH line) and asks before fixing each one. `--check` only reports.
|
|
208
219
|
|
|
220
|
+
**`EACCES` / permission denied during install (macOS, Linux).** npm's global folder belongs to
|
|
221
|
+
root. No sudo needed: run `npm config set prefix ~/.npm-global`, then install again. gu adds that
|
|
222
|
+
folder to your PATH; open a new terminal. With nvm, skip that (nvm refuses an npm prefix):
|
|
223
|
+
`nvm use` a Node 20.19 or newer and install again.
|
|
224
|
+
|
|
209
225
|
**Windows: `gu` runs `Get-Unique`.** PowerShell has a built-in `gu` alias. Run `gu-cli fix-powershell`.
|
|
210
226
|
|
|
211
227
|
**Node version.** gu needs **Node.js 20.19+** (a Pro install using only hosted models runs on 18+).
|
package/agent-model-label.mjs
CHANGED
|
@@ -153,7 +153,7 @@ export function sameModelName(a, b) {
|
|
|
153
153
|
/** Plain-text fallback for each reason we cannot name the model. Never a sentinel. */
|
|
154
154
|
export const UNKNOWN_MODEL_TEXT = Object.freeze({
|
|
155
155
|
remote: "(named by the remote server)",
|
|
156
|
-
"not-set": "
|
|
156
|
+
"not-set": "same as the coding model",
|
|
157
157
|
"no-server": "(no model server running)",
|
|
158
158
|
ambiguous: "(the loaded model)",
|
|
159
159
|
});
|
package/client-expert-run.mjs
CHANGED
|
@@ -20,8 +20,9 @@ import { homedir } from "node:os";
|
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { promisify } from "node:util";
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
|
-
import { localModeNodeProblem } from "./node-version.mjs";
|
|
23
|
+
import { localModeNodeProblem, nvmNodeVersions } from "./node-version.mjs";
|
|
24
24
|
import { downloadMongodExe, startPortableMongod, portableMongoInstalled, mongoWinPaths, mongodCommandLine, MONGO_WIN_VERSION, mongodRuns, isDllNotFoundExit, installVcRuntime, VC_REDIST_URL } from "./mongo-windows.mjs";
|
|
25
|
+
import { macosMajor, macNeedsOwnMongo, mongoMacPaths, macMongoInstalled, downloadMongodMac, mongodRunsMac, MONGO_MAC_OLD_VERSION, MONGO_MAC_BREW_MIN_MAJOR } from "./mongo-mac.mjs";
|
|
25
26
|
import {
|
|
26
27
|
apiDepsInstalled,
|
|
27
28
|
installApiDeps,
|
|
@@ -248,11 +249,17 @@ export async function probeMongo() {
|
|
|
248
249
|
// saying ("you already have mongosh") and because `verify` runs through it.
|
|
249
250
|
const serverPath = (await tryRun("sh", ["-c", "command -v mongod || true"])) || "";
|
|
250
251
|
const shellPath = (await tryRun("sh", ["-c", "command -v mongosh || true"])) || "";
|
|
252
|
+
// gu's own mongod on macOS 13 and older (GH #403) is not on PATH; `portable` says to start that one.
|
|
253
|
+
const portable = process.platform === "darwin" && (await macMongoInstalled());
|
|
254
|
+
// On those Macs Homebrew's mongod cannot run, so it does not count as installed: "it is not
|
|
255
|
+
// running" would send the reader to a server that can never start.
|
|
256
|
+
const brewUsable = !(process.platform === "darwin" && macNeedsOwnMongo(macosMajor()));
|
|
251
257
|
return {
|
|
252
258
|
running: false,
|
|
253
259
|
how: null,
|
|
254
|
-
installed: Boolean(serverPath.trim()),
|
|
260
|
+
installed: (brewUsable && Boolean(serverPath.trim())) || portable,
|
|
255
261
|
shellInstalled: Boolean(shellPath.trim()),
|
|
262
|
+
portable,
|
|
256
263
|
};
|
|
257
264
|
}
|
|
258
265
|
|
|
@@ -298,7 +305,12 @@ export async function probeDockerForMongo(name = MONGO_DOCKER_NAME) {
|
|
|
298
305
|
* macOS: make a native MongoDB answer on 27017 (task #304). → { ok, explained }.
|
|
299
306
|
* `explained` = it printed its own guidance, so the caller adds none.
|
|
300
307
|
*/
|
|
301
|
-
export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.log(l), brewHasTrust = null } = {}) {
|
|
308
|
+
export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.log(l), brewHasTrust = null, osMajor = macosMajor, ownMongo = ensureOwnMongoMac } = {}) {
|
|
309
|
+
// macOS 13 and older: Homebrew's MongoDB installs and then cannot run there (GH #403), so gu
|
|
310
|
+
// runs its own. A Mac that already has gu's own mongod keeps using it after a macOS upgrade.
|
|
311
|
+
if (!mongo.silentPort && (mongo.portable || macNeedsOwnMongo(osMajor()))) {
|
|
312
|
+
return ownMongo(ask, mongo, { log });
|
|
313
|
+
}
|
|
302
314
|
const mn = await import("./mongo-native.mjs");
|
|
303
315
|
brewHasTrust ??= mn.brewHasTrust;
|
|
304
316
|
const brew = mn.findBrew();
|
|
@@ -351,6 +363,71 @@ export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.lo
|
|
|
351
363
|
return { ok: true, explained: true };
|
|
352
364
|
}
|
|
353
365
|
|
|
366
|
+
/**
|
|
367
|
+
* macOS 13 and older (GH #403): gu's own mongod 8.0.5 in ~/.gonext/mongodb, run on 127.0.0.1 —
|
|
368
|
+
* no Homebrew, no service, no sudo. Shown, asked, then done, like the other platforms.
|
|
369
|
+
* Not started at login: gu's start-up check (preflightLocalMongo) starts it again when needed.
|
|
370
|
+
*/
|
|
371
|
+
export async function ensureOwnMongoMac(ask, mongo, {
|
|
372
|
+
log = (l) => console.log(l),
|
|
373
|
+
osMajor = macosMajor,
|
|
374
|
+
paths = mongoMacPaths(),
|
|
375
|
+
download = downloadMongodMac,
|
|
376
|
+
runs = (p) => mongodRunsMac(p.exe),
|
|
377
|
+
start = startPortableMongod,
|
|
378
|
+
waitFor = null,
|
|
379
|
+
} = {}) {
|
|
380
|
+
const installing = !mongo.portable;
|
|
381
|
+
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
382
|
+
if (installing) {
|
|
383
|
+
const major = osMajor();
|
|
384
|
+
log(dim(`\n Homebrew's MongoDB needs macOS ${MONGO_MAC_BREW_MIN_MAJOR} or newer${major ? ` (this Mac has macOS ${major})` : ""}, so gu runs`));
|
|
385
|
+
log(dim(` its own MongoDB ${MONGO_MAC_OLD_VERSION} instead — no Homebrew, no service, no password:\n`));
|
|
386
|
+
log(` download MongoDB ${MONGO_MAC_OLD_VERSION} (about 85 MB) into ${paths.dir}`);
|
|
387
|
+
} else {
|
|
388
|
+
log(dim("\n gu's MongoDB is installed but not running. This starts it:\n"));
|
|
389
|
+
}
|
|
390
|
+
log(" " + mongodCommandLine({ port, paths }));
|
|
391
|
+
log("");
|
|
392
|
+
const yes = await readYesNo(ask, ` ${installing ? "Install" : "Start"} MongoDB now? [Y/n]: `, true, log);
|
|
393
|
+
if (!yes) {
|
|
394
|
+
log(dim(`\n Not ${installing ? "installed" : "started"} — run the commands above yourself.`));
|
|
395
|
+
return { ok: false, explained: true };
|
|
396
|
+
}
|
|
397
|
+
log("\n " + bold(installing ? "Installing MongoDB" : "Starting MongoDB") + dim(installing ? " about 85 MB" : ""));
|
|
398
|
+
if (installing) {
|
|
399
|
+
try {
|
|
400
|
+
await download({ paths, log });
|
|
401
|
+
} catch (e) {
|
|
402
|
+
log("\n " + red("✗ ") + `could not download MongoDB: ${e?.message ?? e}`);
|
|
403
|
+
return { ok: false, explained: true };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const probe = await runs(paths);
|
|
407
|
+
if (!probe.ok) {
|
|
408
|
+
log("\n " + red("✗ ") + `MongoDB cannot run on this Mac: ${probe.why}`);
|
|
409
|
+
return { ok: false, explained: true };
|
|
410
|
+
}
|
|
411
|
+
let started;
|
|
412
|
+
try {
|
|
413
|
+
started = await start({ port, paths });
|
|
414
|
+
} catch (e) {
|
|
415
|
+
started = { ok: false, error: e };
|
|
416
|
+
}
|
|
417
|
+
if (!started?.ok) {
|
|
418
|
+
log("\n " + red("✗ ") + `MongoDB could not be started${started?.error ? `: ${started.error.message ?? started.error}` : ""}`);
|
|
419
|
+
return { ok: false, explained: true };
|
|
420
|
+
}
|
|
421
|
+
const wait = waitFor ?? (await import("./mongo-native.mjs")).waitForMongo;
|
|
422
|
+
if (!(await wait(host, port, { timeoutMs: 60000 }))) {
|
|
423
|
+
log("\n " + red("✗ ") + `MongoDB was ${installing ? "installed" : "started"} but is not answering at ${host}:${port} yet.`);
|
|
424
|
+
log(dim(` Its log: ${paths.log} — then run gu again.`));
|
|
425
|
+
return { ok: false, explained: true };
|
|
426
|
+
}
|
|
427
|
+
log(" " + cyan("✓ MongoDB is running") + dim(` at ${host}:${port} (started by gu — data in ${paths.data})`));
|
|
428
|
+
return { ok: true, explained: true };
|
|
429
|
+
}
|
|
430
|
+
|
|
354
431
|
/** /etc/os-release → { id, codename, versionId }, or null where there is none (not Linux). */
|
|
355
432
|
export function readOsRelease({ read = (f) => readFileSync(f, "utf8") } = {}) {
|
|
356
433
|
let text = "";
|
|
@@ -622,7 +699,8 @@ export async function preflightLocalMongo({
|
|
|
622
699
|
log("\n " + red("gu: MongoDB is not answering") + dim(` at ${host}:${port}`) +
|
|
623
700
|
red(mongo.installed || mongo.silentPort || !local ? "." : " — it is not installed on this machine."));
|
|
624
701
|
log(dim(" Local mode keeps your conversations, models and keys in that database, so"));
|
|
625
|
-
|
|
702
|
+
// Nothing here checks the API, so say nothing about it (it was "fine" with no API process, GH #406).
|
|
703
|
+
log(dim(" nothing can start without it. gu starts the API once the database answers."));
|
|
626
704
|
if (!local) {
|
|
627
705
|
log(dim(`\n That database is on another machine (${host}). Start it there, then run gu again.\n`));
|
|
628
706
|
return { ok: false, fixed: false, installed: false };
|
|
@@ -1132,7 +1210,7 @@ export async function runClientExpertSetup({
|
|
|
1132
1210
|
// BEFORE ANYTHING IS INSTALLED (task #333): the API needs a newer Node than gu does. Not part
|
|
1133
1211
|
// of the gate on purpose — a gate "no" makes the first run settle on Cloud mode for good,
|
|
1134
1212
|
// while the fix here is a Node upgrade, after which `gu` must resume Local setup.
|
|
1135
|
-
const nodeProblem = localModeNodeProblem(process.version, machine.platform);
|
|
1213
|
+
const nodeProblem = localModeNodeProblem(process.version, machine.platform, { nvmVersions: nvmNodeVersions() });
|
|
1136
1214
|
if (nodeProblem) {
|
|
1137
1215
|
console.log("\n " + red("✗ ") + nodeProblem + "\n");
|
|
1138
1216
|
return { complete: false, stoppedAt: "node" };
|
|
@@ -1189,6 +1267,7 @@ export async function runClientExpertSetup({
|
|
|
1189
1267
|
// "Docker is not available here": a machine without Docker is the ordinary case on a server
|
|
1190
1268
|
// and on Windows, not an error to open with.
|
|
1191
1269
|
let explained = false;
|
|
1270
|
+
const hadServer = Boolean(mongo.installed);
|
|
1192
1271
|
if (machine.platform !== "darwin" && !mongo.silentPort && (await probeDockerForMongo()).usable) {
|
|
1193
1272
|
if (await startMongoInDocker(ask)) {
|
|
1194
1273
|
mongo.running = true;
|
|
@@ -1233,6 +1312,8 @@ export async function runClientExpertSetup({
|
|
|
1233
1312
|
mongo.running = true;
|
|
1234
1313
|
mongo.how = after.how;
|
|
1235
1314
|
}
|
|
1315
|
+
// Setup INSTALLED it a moment ago, so it has no credentials of its own to ask about.
|
|
1316
|
+
mongo.installedNow = !hadServer;
|
|
1236
1317
|
}
|
|
1237
1318
|
|
|
1238
1319
|
console.log("\n " + cyan("✓ MongoDB is running") + dim(` (${mongo.how})`));
|
|
@@ -1251,6 +1332,8 @@ export async function runClientExpertSetup({
|
|
|
1251
1332
|
if (mongoUri !== LOCAL_MONGO_URI) {
|
|
1252
1333
|
console.log(dim(" using the MongoDB credentials from the previous run"));
|
|
1253
1334
|
}
|
|
1335
|
+
} else if (credsDecision === "ask" && mongo.installedNow) {
|
|
1336
|
+
state = { ...state, mongoCredsConfirmed: true };
|
|
1254
1337
|
} else if (credsDecision === "ask") {
|
|
1255
1338
|
const credsChoice = await askMongoCredentials(ask, askSecret);
|
|
1256
1339
|
if (credsChoice.exit) {
|
|
@@ -1575,8 +1658,10 @@ export async function runClientExpertSetup({
|
|
|
1575
1658
|
}
|
|
1576
1659
|
if (skipModels) {
|
|
1577
1660
|
console.log("");
|
|
1578
|
-
|
|
1579
|
-
|
|
1661
|
+
// "skipped" is only true when nothing was chosen: a provider that was picked is NOT skipped,
|
|
1662
|
+
// its key and model are just asked in gu itself (GH #397).
|
|
1663
|
+
console.log(" " + cyan(addProvider ? `✓ ${addProvider.name || "Provider"} chosen` : "✓ Model setup skipped") + dim(addProvider
|
|
1664
|
+
? " — gu asks for its key and model as soon as it starts"
|
|
1580
1665
|
: loadPreset
|
|
1581
1666
|
? " — the preset picker opens as soon as gu starts"
|
|
1582
1667
|
: " — set them any time with /model (or /use-presets)"));
|
package/doctor-check.mjs
CHANGED
|
@@ -83,14 +83,17 @@ export function rolesFromPayload(payload, settings, codingKey = "") {
|
|
|
83
83
|
const realKey = (k) => (str(k) && str(k) !== "local" ? str(k) : "");
|
|
84
84
|
const [, , embed] = rolesFromSettings(settings);
|
|
85
85
|
const agentSet = payload.agentMissing !== true;
|
|
86
|
+
// NO SEPARATE AGENT MODEL: the coding model answers the chat calls too (GH #397), so check it
|
|
87
|
+
// on THAT path with its key — a "not set" here hid a 401 on "hi" while coding turns worked.
|
|
88
|
+
const chat = agentSet
|
|
89
|
+
? { url: str(payload.agentBaseURL), model: str(payload.agentModelId), apiKey: realKey(payload.agentApiKey) }
|
|
90
|
+
: { url: str(payload.codingBaseURL), model: str(payload.codingModelId), apiKey: realKey(codingKey) };
|
|
86
91
|
return [
|
|
87
92
|
{
|
|
88
93
|
role: "chat",
|
|
89
94
|
label: "chat model",
|
|
90
|
-
why: "answers and routes every turn",
|
|
91
|
-
|
|
92
|
-
model: agentSet ? str(payload.agentModelId) : "",
|
|
93
|
-
apiKey: agentSet ? realKey(payload.agentApiKey) : "",
|
|
95
|
+
why: agentSet ? "answers and routes every turn" : "answers and routes every turn (the coding model, none set apart)",
|
|
96
|
+
...chat,
|
|
94
97
|
},
|
|
95
98
|
{
|
|
96
99
|
role: "code",
|
package/embed-model-command.mjs
CHANGED
|
@@ -228,14 +228,14 @@ export function embedNotice({ state, model } = {}) {
|
|
|
228
228
|
* WHICH SETUP ROWS THE BOTTOM BAR SHOWS (first-run noise, #188). A fresh install used to stack
|
|
229
229
|
* three warnings at once. Now they come in order of need, and pure (no OS-specific logic):
|
|
230
230
|
* - nothing to answer with → ONLY "coding model not set" (the one thing to do first);
|
|
231
|
-
* - a coding model exists but no agent model →
|
|
231
|
+
* - a coding model exists but no agent model → NOTHING: one model that also talks is the normal
|
|
232
|
+
* setup, not a fault (GH #397). `agentMissing` is still accepted and ignored.
|
|
232
233
|
* - no embed model → NOTHING until embedding was actually needed (`embedNeeded`: the agent tried
|
|
233
234
|
* a RAG tool, or the user opened /embed-model), then the embed row.
|
|
234
235
|
*/
|
|
235
236
|
export function setupNoticeRows({ noModel = false, agentMissing = false, embedMissing = false, embedNeeded = false, embedWhy } = {}) {
|
|
236
237
|
if (noModel) return ["coding model not set · /model"];
|
|
237
238
|
return [
|
|
238
|
-
...(agentMissing ? ["agent model not set — using the coding model · /agent-model"] : []),
|
|
239
239
|
...(embedMissing && embedNeeded ? [embedNotice(embedWhy) || "no embed model — RAG is off · /embed-model"] : []),
|
|
240
240
|
];
|
|
241
241
|
}
|
package/gu-cli.mjs
CHANGED
|
@@ -400,7 +400,7 @@ if (["start", "stop", "status", "logs", "restart"].includes(args[0])) {
|
|
|
400
400
|
if (await ctl.isClientMode()) {
|
|
401
401
|
console.log(
|
|
402
402
|
"[gu-worker] CLIENT mode — this machine runs the agent but serves no models;\n" +
|
|
403
|
-
" it calls your model machine over the network.
|
|
403
|
+
" it calls your model machine over the network. `gu clear` starts setup over."
|
|
404
404
|
);
|
|
405
405
|
}
|
|
406
406
|
if (s.running) {
|
|
@@ -4214,7 +4214,13 @@ async function runAgentChatJob(job) {
|
|
|
4214
4214
|
const input = JSON.stringify({
|
|
4215
4215
|
messages: payload?.messages ?? [],
|
|
4216
4216
|
agentBaseURL,
|
|
4217
|
-
|
|
4217
|
+
// NO AGENT MODEL SET (GH #397): the API points the chat calls at the coding model and sends
|
|
4218
|
+
// the placeholder key "local". Chat calls ("hi", the plain-reply fallback, routing) must
|
|
4219
|
+
// carry the CODER's real key, or a hosted provider answers 401 while coding turns work.
|
|
4220
|
+
agentApiKey:
|
|
4221
|
+
payload?.agentMissing === true && codingApiKey
|
|
4222
|
+
? codingApiKey
|
|
4223
|
+
: (payload?.agentApiKey ?? ""),
|
|
4218
4224
|
agentModelId,
|
|
4219
4225
|
// Task #134: on a CLIENT the RAG embedder lives on the model machine, so "run doctor
|
|
4220
4226
|
// here" is advice that cannot work. The agent uses this to say the right thing.
|
package/gu-repl.mjs
CHANGED
|
@@ -126,7 +126,6 @@ import {
|
|
|
126
126
|
nothingToAnswerWith,
|
|
127
127
|
NO_MODEL_LINES,
|
|
128
128
|
resizeRepaintTarget,
|
|
129
|
-
workspaceSyncQuestion,
|
|
130
129
|
workspaceAccessQuestion,
|
|
131
130
|
workspaceSyncLabel,
|
|
132
131
|
activeCoderName,
|
|
@@ -1052,7 +1051,7 @@ const COMMANDS = [
|
|
|
1052
1051
|
// The two synonyms were dispatched but declared nowhere (found by slash-command-wiring.test.mjs):
|
|
1053
1052
|
// they worked, Tab-completed to nothing, and appeared in no help. Aliases is what the registry
|
|
1054
1053
|
// already has for exactly this — /quit, /rag-local.
|
|
1055
|
-
|
|
1054
|
+
// /host-mode is gone (owner, 11-07): modes are chosen at setup; `gu clear` sets up again.
|
|
1056
1055
|
// /undo is HIDDEN_COMMANDS now — still works when typed.
|
|
1057
1056
|
{ name: "/server", desc: "pick a deployment server (host/user) for this session" },
|
|
1058
1057
|
{ name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
|
|
@@ -2720,6 +2719,11 @@ let cliNoticeShown = false;
|
|
|
2720
2719
|
function announceCliUpdate() {
|
|
2721
2720
|
if (cliNoticeShown || !availableUpdates.cli) return;
|
|
2722
2721
|
if (following) return; // a turn owns the transcript — try again at the next prompt
|
|
2722
|
+
// …and so does an open menu or question (GH #397): printed into a picker's in-place block, the
|
|
2723
|
+
// notice pushed it down and the picker could no longer erase itself — the rows of a menu left
|
|
2724
|
+
// closed stayed on screen. Retried at the next prompt, like a turn.
|
|
2725
|
+
// A slash command still running hides the input row (inputVisible): its output is not done.
|
|
2726
|
+
if (listPickerActive || askWaiting || !inputVisible) return;
|
|
2723
2727
|
let lines = [];
|
|
2724
2728
|
try {
|
|
2725
2729
|
lines = repeatUpdateNotice({ last: readUpdateAttempt(), cli: availableUpdates.cli, running: REPL_VERSION,
|
|
@@ -5244,7 +5248,7 @@ const ask = async (q, { prefill = "", hint = [], atomic = false, noCancel = fals
|
|
|
5244
5248
|
// Masked prompt for a secret (an API key). Mutes readline's echo/refresh (secretInput)
|
|
5245
5249
|
// so the typed value never hits the terminal or scrollback (task #127). Returns "" on a
|
|
5246
5250
|
// bare Enter (skip).
|
|
5247
|
-
const askSecret = async (q) => {
|
|
5251
|
+
const askSecret = async (q, { hint = [] } = {}) => {
|
|
5248
5252
|
// Same rule as ask(): input has ended, so stop. Returning "" here would hand an EMPTY
|
|
5249
5253
|
// secret to a caller that is about to store or send it.
|
|
5250
5254
|
if (_stdinClosed) return await ask("");
|
|
@@ -5255,6 +5259,9 @@ const askSecret = async (q) => {
|
|
|
5255
5259
|
// FOR. With the bar pinned the row itself becomes the prompt, so nothing is left to infer.
|
|
5256
5260
|
secretPrompt = String(q ?? "").replace(/\s+$/, "") + " ";
|
|
5257
5261
|
secretInput = true;
|
|
5262
|
+
// A hint UNDER the input, gone once answered (GH #397) — printed above, "type or paste the
|
|
5263
|
+
// key…" stayed in the history after the key was in, as if it were still being asked.
|
|
5264
|
+
askHint = barPinned() ? hint : [];
|
|
5258
5265
|
// Same reason as ask() above: inputLineFor returns "" when inputVisible is false, so the one
|
|
5259
5266
|
// bullet per character — the only proof a keystroke registered while the echo is muted —
|
|
5260
5267
|
// would not be drawn either. askSecret is reached from /model, which is exactly a command
|
|
@@ -5264,7 +5271,10 @@ const askSecret = async (q) => {
|
|
|
5264
5271
|
// Unpinned (piped output, or a window too short to reserve rows) there IS no frozen row, so
|
|
5265
5272
|
// the label has to be printed the old way or the prompt is silent.
|
|
5266
5273
|
if (barPinned()) paintBar();
|
|
5267
|
-
else
|
|
5274
|
+
else {
|
|
5275
|
+
for (const l of hint) console.log(GUTTER + dim(l));
|
|
5276
|
+
process.stdout.write(q);
|
|
5277
|
+
}
|
|
5268
5278
|
let raw = "";
|
|
5269
5279
|
try {
|
|
5270
5280
|
askWaiting = true;
|
|
@@ -5277,6 +5287,7 @@ const askSecret = async (q) => {
|
|
|
5277
5287
|
askWaiting = false;
|
|
5278
5288
|
secretInput = false;
|
|
5279
5289
|
secretPrompt = "";
|
|
5290
|
+
askHint = [];
|
|
5280
5291
|
inputVisible = _secretWasVisible;
|
|
5281
5292
|
// READLINE KEEPS EVERY SUBMITTED LINE (task #191). Muting the echo stops the key reaching
|
|
5282
5293
|
// the screen; it does nothing about rl.history, which is where the line goes on Enter — so
|
|
@@ -5343,10 +5354,10 @@ async function ensureWorkspace() {
|
|
|
5343
5354
|
// Registered before cloud sync existed → ask once and persist. Otherwise honor the
|
|
5344
5355
|
// stored choice. (undefined = old record that never opted in → ask.)
|
|
5345
5356
|
if (hit.allowSync === undefined) {
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5357
|
+
// No question (owner, 11-07): the mode chosen at setup decides where history goes —
|
|
5358
|
+
// the cloud on a hosted account, the local database on Client Expert. A folder that
|
|
5359
|
+
// answered No before keeps that answer; only a folder never asked is filled in.
|
|
5360
|
+
hit.allowSync = true;
|
|
5350
5361
|
await writeFile(WS_FILE, JSON.stringify({ workspaces: list }, null, 2) + "\n");
|
|
5351
5362
|
}
|
|
5352
5363
|
// The workspace facts now live in the startup banner (task #131) — just remember them.
|
|
@@ -5380,16 +5391,10 @@ async function ensureWorkspace() {
|
|
|
5380
5391
|
} catch (e) {
|
|
5381
5392
|
console.log(GUTTER + yellow(`Could not save the browser permission (${e?.message || e}) — gu will ask when it first needs it.`));
|
|
5382
5393
|
}
|
|
5383
|
-
//
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
"Allow syncing this workspace's chat history to the cloud?\n" +
|
|
5388
|
-
"(your conversation is saved to your account so you can see it on the web app)",
|
|
5389
|
-
true /* default Yes — RAG is NOT synced (it stays on your S3); only chat history. */
|
|
5390
|
-
)
|
|
5391
|
-
: syncQ.value;
|
|
5392
|
-
if (!syncQ.ask && syncQ.note) console.log(wrapGutter(syncQ.note, dim));
|
|
5394
|
+
// NO SYNC QUESTION (owner, 11-07): Cloud or Client Expert is chosen once, at setup, and it
|
|
5395
|
+
// decides where chat history lives — your account's cloud database, or the local one. To
|
|
5396
|
+
// change it, `gu clear` and set up again. RAG is never synced (it stays where it was built).
|
|
5397
|
+
const allowSync = true;
|
|
5393
5398
|
const next = list.filter((w) => w.path !== cwd);
|
|
5394
5399
|
next.push({
|
|
5395
5400
|
name: basename(cwd),
|
|
@@ -6297,10 +6302,12 @@ async function pickExtraModels(rest0, preChecked = []) {
|
|
|
6297
6302
|
const already = new Set(preChecked);
|
|
6298
6303
|
const { codingModelCandidates } = await import("./coding-provider-setup.mjs");
|
|
6299
6304
|
const keep = new Set(codingModelCandidates(rest0).models);
|
|
6300
|
-
const rest = rest0
|
|
6305
|
+
const rest = rest0
|
|
6306
|
+
.filter((m) => keep.has(m) || already.has(m))
|
|
6307
|
+
.sort((a, b) => a.localeCompare(b, "en", { numeric: true })); // as the pick above (GH #397)
|
|
6301
6308
|
if (rest.length === 0) return [];
|
|
6302
|
-
console.log(dim(`
|
|
6303
|
-
console.log(dim(`
|
|
6309
|
+
console.log(dim(` Any other models to switch to in /model? optional · up to ${MAX_ALT_CODERS}`));
|
|
6310
|
+
console.log(dim(` space ticks · Enter saves (nothing ticked skips this) · Esc cancels`));
|
|
6304
6311
|
const ticks = await pickManyFromList(
|
|
6305
6312
|
rest,
|
|
6306
6313
|
rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
|
|
@@ -6328,7 +6335,12 @@ async function pickExtraModels(rest0, preChecked = []) {
|
|
|
6328
6335
|
* Returns the model id, or null when the user cancelled.
|
|
6329
6336
|
*/
|
|
6330
6337
|
async function chooseCodingModelFrom(ps, all, question, current, { back = false } = {}) {
|
|
6331
|
-
const { models:
|
|
6338
|
+
const { models: coders0, filtered } = ps.codingModelCandidates(all);
|
|
6339
|
+
// SORTED, like /agent-model's list (GH #397): the provider's own order is arbitrary, and a
|
|
6340
|
+
// family (qwen3.8-*, deepseek-v4-*) scattered over the list is hard to scan.
|
|
6341
|
+
const byName = (a, b) => a.localeCompare(b, "en", { numeric: true });
|
|
6342
|
+
const coders = [...coders0].sort(byName);
|
|
6343
|
+
all = [...all].sort(byName);
|
|
6332
6344
|
let list = coders;
|
|
6333
6345
|
let showingAll = filtered === 0;
|
|
6334
6346
|
for (;;) {
|
|
@@ -6372,8 +6384,9 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
|
|
|
6372
6384
|
* stays available for the same reason it does there — a listed model may be unusable and an
|
|
6373
6385
|
* unlisted one may be exactly right.
|
|
6374
6386
|
*/
|
|
6375
|
-
async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
|
|
6376
|
-
|
|
6387
|
+
async function chooseFromModelList(all, question, current = 0, { back = false, notes = null } = {}) {
|
|
6388
|
+
// `notes` (GH #397): a dim note per row ("an audio/speech model"), as /agent-model's own list has.
|
|
6389
|
+
const rows = [...all.map((m, i) => (notes?.[i] ? `${m}${dim(` · ${notes[i]}`)}` : m)), "type a model name…"];
|
|
6377
6390
|
const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
|
|
6378
6391
|
const idx = await pickFromList(rows, at >= 0 ? at : 0, {
|
|
6379
6392
|
back,
|
|
@@ -6390,6 +6403,21 @@ async function chooseFromModelList(all, question, current = 0, { back = false }
|
|
|
6390
6403
|
return all[idx];
|
|
6391
6404
|
}
|
|
6392
6405
|
|
|
6406
|
+
/**
|
|
6407
|
+
* The agent's pick from a fresh provider (GH #397): the SAME order and labels as /agent-model's
|
|
6408
|
+
* "pick another model on <host>…" — chat models alphabetically, then audio/image/video models
|
|
6409
|
+
* sunk to the bottom and named. The add flow showed them mixed in and unlabelled.
|
|
6410
|
+
*/
|
|
6411
|
+
async function pickAgentModelFrom(models, question, current, opts) {
|
|
6412
|
+
const am = await import("./agent-model-command.mjs");
|
|
6413
|
+
const sorted = am.agentModelsFromProvider(models);
|
|
6414
|
+
const ids = sorted.map((m) => m.model);
|
|
6415
|
+
return chooseFromModelList(ids, question, Math.max(0, ids.indexOf(current)), {
|
|
6416
|
+
...opts,
|
|
6417
|
+
notes: sorted.map((m) => m.note),
|
|
6418
|
+
});
|
|
6419
|
+
}
|
|
6420
|
+
|
|
6393
6421
|
/**
|
|
6394
6422
|
* /model's provider manager. `back` (#242): opened from /model's list, so its first menu offers
|
|
6395
6423
|
* "← back" and returns BACK. Inside it, each sub-menu's "← back" returns HERE — re-read, because a
|
|
@@ -6750,6 +6778,7 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
6750
6778
|
* newKey ask for a key even if the server has one (the stored one was refused)
|
|
6751
6779
|
*/
|
|
6752
6780
|
async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false, back = false } = {}) {
|
|
6781
|
+
let claudeSdkLater = false; // offered after the save, see step 1
|
|
6753
6782
|
const as = await import("./add-server.mjs");
|
|
6754
6783
|
const ps = await import("./coding-provider-setup.mjs");
|
|
6755
6784
|
const sk = await import("./server-keys.mjs");
|
|
@@ -6802,7 +6831,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6802
6831
|
const OLLAMA_HERE = 0;
|
|
6803
6832
|
const rowsShown = [
|
|
6804
6833
|
`Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
|
|
6805
|
-
|
|
6834
|
+
// The HOST, not the full URL (GH #397): "…/compatible-mode/v1" ran past 100 columns and was
|
|
6835
|
+
// cut mid-word; the path is plumbing, and the URL is echoed in full once one is picked.
|
|
6836
|
+
...presets.map((p) => `${p.name}${dim(` · ${hostOf(p.url)}`)}`),
|
|
6806
6837
|
"another URL…",
|
|
6807
6838
|
];
|
|
6808
6839
|
// A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
|
|
@@ -6857,10 +6888,10 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6857
6888
|
console.log("");
|
|
6858
6889
|
return;
|
|
6859
6890
|
}
|
|
6860
|
-
// Claude as the CODER: prompt caching needs Anthropic's SDK (#262)
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
6891
|
+
// Claude as the CODER: prompt caching needs Anthropic's SDK (#262). Offered AFTER the key and the
|
|
6892
|
+
// model are saved (GH #397): asked first, it interrupted the flow before the user had even been
|
|
6893
|
+
// asked for a key, and dumped pip's raw output over the screen.
|
|
6894
|
+
claudeSdkLater = Boolean(target.claude && role === "coding");
|
|
6864
6895
|
|
|
6865
6896
|
// --- 2. the server's key --------------------------------------------------------------
|
|
6866
6897
|
// AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
|
|
@@ -6968,7 +6999,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6968
6999
|
const canBack = { back: providerPicker || back };
|
|
6969
7000
|
model = R.filterCoders
|
|
6970
7001
|
? await chooseCodingModelFrom(ps, models, R.question, 0, canBack)
|
|
6971
|
-
:
|
|
7002
|
+
: role === "agent"
|
|
7003
|
+
? await pickAgentModelFrom(models, R.question, current, canBack)
|
|
7004
|
+
: await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)), canBack);
|
|
6972
7005
|
if (model === BACK) {
|
|
6973
7006
|
if (providerPicker) continue; // → Which provider?
|
|
6974
7007
|
return BACK;
|
|
@@ -7083,7 +7116,16 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
7083
7116
|
}
|
|
7084
7117
|
return;
|
|
7085
7118
|
}
|
|
7086
|
-
await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName });
|
|
7119
|
+
await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName, extras });
|
|
7120
|
+
if (claudeSdkLater) {
|
|
7121
|
+
// pip's own lines ("Requirement already satisfied…", "Collecting…") are noise here: keep gu's.
|
|
7122
|
+
const pipNoise = /^(Requirement already satisfied|Collecting|Downloading|Using cached|Installing collected|Successfully installed|Obtaining|Preparing|Building|Created wheel|Stored in directory|WARNING|\[notice\]|\s*[━╸─]+)/i;
|
|
7123
|
+
await md.ensureClaudeSdk({
|
|
7124
|
+
log: (l) => { const t = String(l ?? "").trim(); if (t && !pipNoise.test(t)) console.log(GUTTER + dim(t)); },
|
|
7125
|
+
confirm: (q) => askYesNo(` ${q}`, true),
|
|
7126
|
+
});
|
|
7127
|
+
console.log("");
|
|
7128
|
+
}
|
|
7087
7129
|
}
|
|
7088
7130
|
|
|
7089
7131
|
/**
|
|
@@ -7096,7 +7138,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
7096
7138
|
* · DID SAVING EVICT ANOTHER PROVIDER? An API older than row ids keys a backend by its KIND,
|
|
7097
7139
|
* so a second openai row replaced the first with a 200. Said BEFORE the success line.
|
|
7098
7140
|
*/
|
|
7099
|
-
async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName }) {
|
|
7141
|
+
async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName, extras = [] }) {
|
|
7100
7142
|
const cwd = resolve(process.cwd());
|
|
7101
7143
|
const after = await withPendingStatus("checking it is offered", () =>
|
|
7102
7144
|
fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
|
|
@@ -7145,15 +7187,23 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
|
|
|
7145
7187
|
// that was just entered, which reads as the save having failed.
|
|
7146
7188
|
codingKeyMissingKnown = false;
|
|
7147
7189
|
console.log(accent(` ✓ coding model → ${model}`) + dim(` · ${host}`));
|
|
7148
|
-
if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)}
|
|
7190
|
+
if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)}`));
|
|
7191
|
+
// The extras were ticked a screen ago and never mentioned again (GH #397) — say where they went.
|
|
7192
|
+
if (extras.length) console.log(dim(` also in /model: ${extras.join(", ")}`));
|
|
7193
|
+
console.log("");
|
|
7149
7194
|
// A HOSTED SERVER NEEDS ITS WINDOW SET: with nothing set gu assumes 24,000 prompt tokens and
|
|
7150
7195
|
// compacts there — on a large hosted model that throws history away every turn.
|
|
7151
7196
|
if (ps.shouldMentionContextWindow(locality)) {
|
|
7152
|
-
console.log(GUTTER + dim("tip:
|
|
7153
|
-
console.log(GUTTER + dim("
|
|
7197
|
+
console.log(GUTTER + dim("tip: set this model's context size with /context-window —"));
|
|
7198
|
+
console.log(GUTTER + dim(" hosted providers don't report it, so gu assumes 24k tokens.\n"));
|
|
7154
7199
|
}
|
|
7155
7200
|
const t = await fetchOutputTokenTotals(cwd);
|
|
7156
7201
|
{ const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
|
|
7202
|
+
// The bar still said "coding model not set · /model" under the success line of the FIRST model
|
|
7203
|
+
// added on a fresh install, until the next turn repainted it (GH #397).
|
|
7204
|
+
noModelAtAll = after?.payload ? nothingToAnswerWith(after.payload) : false;
|
|
7205
|
+
if (after?.payload) agentMissing = after.payload.agentMissing === true;
|
|
7206
|
+
if (barPinned()) paintBar();
|
|
7157
7207
|
}
|
|
7158
7208
|
|
|
7159
7209
|
|
|
@@ -7258,7 +7308,7 @@ async function chooseModel() {
|
|
|
7258
7308
|
// through to add when there is genuinely nothing there.
|
|
7259
7309
|
// As the picker's header (GH #397), so "← back" re-opening this menu does not stack it.
|
|
7260
7310
|
const go = await pickFromList(
|
|
7261
|
-
[dim("leave it"), "add
|
|
7311
|
+
[dim("leave it"), "add a provider — Claude API, OpenAI, Kimi, Ollama…"],
|
|
7262
7312
|
1,
|
|
7263
7313
|
{ header: () => [dim(" No coding model configured.")], headerRows: 1 }
|
|
7264
7314
|
);
|
|
@@ -7271,16 +7321,10 @@ async function chooseModel() {
|
|
|
7271
7321
|
// provider manager is where another one is added (an Ollama box is "another URL…").
|
|
7272
7322
|
// #412 removed "change the Ollama server this machine uses…" from all three menus: it re-asked
|
|
7273
7323
|
// the agent, embedder and coder servers in one go, which confused people picking a model.
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
0
|
|
7279
|
-
);
|
|
7280
|
-
if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
|
|
7281
|
-
if (go !== 1) console.log("");
|
|
7282
|
-
return;
|
|
7283
|
-
}
|
|
7324
|
+
// GH #397: ONE model used to get its own screen ("Only one coding model is available: X ·
|
|
7325
|
+
// keep it / add or change a provider"). Set up from /agent-model first, that was the agent's
|
|
7326
|
+
// model — and the other 12 coders on the same provider were three menus away. The full menu
|
|
7327
|
+
// below already has "pick another model on <host>…", keys and add, so one model uses it too.
|
|
7284
7328
|
const active = sessionCodingModel || def;
|
|
7285
7329
|
// Arrow-key picker (↑/↓ + Enter) — pre-highlight the active model, tag the default, and
|
|
7286
7330
|
// name the backend when more than one is in play (the same model name can appear twice).
|
|
@@ -7289,6 +7333,9 @@ async function chooseModel() {
|
|
|
7289
7333
|
// OpenAI-compatible providers are both "OpenAI-compatible", so the kind names neither.
|
|
7290
7334
|
const { labels, tags, showTag } = codingChoiceLabels(choices, def);
|
|
7291
7335
|
const activeIdx = choices.findIndex((c) => c.id === active);
|
|
7336
|
+
// GH #397: "(default)" marked the account default while this folder used another model, and
|
|
7337
|
+
// nothing said which one runs here. The one in use is marked.
|
|
7338
|
+
if (activeIdx >= 0 && choices[activeIdx].id !== def) labels[activeIdx] += accent(" (in use here)");
|
|
7292
7339
|
// THE CURRENT CODER'S KEYS, ON THIS LIST (#248) — the manager ctrl+k opens, for a provider that
|
|
7293
7340
|
// takes a key (OpenAI-compatible, ollama.com). Before the add row, so no model row moves.
|
|
7294
7341
|
const activeChoice = choices[activeIdx >= 0 ? activeIdx : choices.findIndex((c) => c.id === def)];
|
|
@@ -9357,8 +9404,9 @@ async function askServerKey({ host = "", optional = false, escBack = false, name
|
|
|
9357
9404
|
// A RETRY KEEPS THE NAME (GH #397): only the secret was wrong, so only the secret is asked.
|
|
9358
9405
|
if (knownName === null) console.log("\n" + GUTTER + dim(escBack ? "Esc goes back to the providers." : "Esc to cancel."));
|
|
9359
9406
|
const name = knownName === null ? (await askKeyName()).trim() : String(knownName);
|
|
9360
|
-
|
|
9361
|
-
|
|
9407
|
+
const secret = (
|
|
9408
|
+
await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `, { hint: [SECRET_KEY_HINT] })
|
|
9409
|
+
).trim();
|
|
9362
9410
|
if (!secret) {
|
|
9363
9411
|
console.log(GUTTER + dim(optional ? "(no key — the server will be called anonymously)" : "(cancelled — nothing was saved)\n"));
|
|
9364
9412
|
return { name, secret: "" };
|
|
@@ -9446,19 +9494,38 @@ async function chooseAgentModel() {
|
|
|
9446
9494
|
const source = am.agentListSource(settings);
|
|
9447
9495
|
const needsKey = am.agentSourceNeedsKey(source);
|
|
9448
9496
|
for (;;) {
|
|
9449
|
-
|
|
9497
|
+
// The first row is the default and the way back to ONE model: the coding model talks too (GH #397).
|
|
9498
|
+
const labels = [
|
|
9499
|
+
`same as the coding model${dim(" — follows /model")}` + (current ? "" : accent(" (current)")),
|
|
9500
|
+
...saved.map((c) => c.label + (c.current ? accent(" (current)") : "")),
|
|
9501
|
+
];
|
|
9450
9502
|
const MODELS = curHost ? labels.length : -1;
|
|
9451
9503
|
if (curHost) labels.push(actionRow(`pick another model on ${curHost}…`));
|
|
9504
|
+
// NO SEPARATE AGENT MODEL YET: offer the coder's own server, so a talking model from the provider
|
|
9505
|
+
// already set up is one pick away — its saved key is reused (GH #397).
|
|
9506
|
+
const coderUrl = (await import("./coding-provider-setup.mjs")).ensureV1Root(String(sessionCodingUrl || defaultCodingUrl || "")).toLowerCase();
|
|
9507
|
+
const coderRow = curHost ? null : (saved.find((c) => c.url.toLowerCase() === coderUrl) ?? null);
|
|
9508
|
+
const CODER_MODELS = coderRow ? labels.length : -1;
|
|
9509
|
+
if (coderRow) labels.push(actionRow(`pick another model on ${coderRow.host}…`));
|
|
9452
9510
|
const KEYS = curHost && needsKey ? labels.length : -1;
|
|
9453
9511
|
if (KEYS >= 0) labels.push(actionRow(`API keys for ${curHost} — switch, add or remove…`));
|
|
9454
9512
|
const ADD = labels.length;
|
|
9455
9513
|
labels.push(actionRow("add a provider — Claude API, OpenAI, Kimi, Ollama…"));
|
|
9456
9514
|
// The hint drops to its own row when the header would wrap (80 columns over SSH).
|
|
9457
|
-
|
|
9515
|
+
// GH #397: "(now unset)" hid that the coding model answers in the meantime; and printed
|
|
9516
|
+
// before the list, this title stacked each time "← back" re-opened the menu.
|
|
9517
|
+
const now = current
|
|
9518
|
+
? `now ${current}${curHost ? ` on ${curHost}` : ""}`
|
|
9519
|
+
: "same as the coding model";
|
|
9520
|
+
const head = ` Agent model — the one that TALKS (${now})`;
|
|
9458
9521
|
const hint = `(${PICK_ARROWS}):`;
|
|
9459
9522
|
const cols = Math.max(20, process.stdout.columns || 80);
|
|
9460
|
-
|
|
9461
|
-
const idx = await pickFromList(labels,
|
|
9523
|
+
const headLines = head.length + 2 + hint.length < cols ? [`${head} ${hint}`] : [head, ` ${hint}`];
|
|
9524
|
+
const idx = await pickFromList(labels, saved.findIndex((c) => c.current) + 1, {
|
|
9525
|
+
footer: ESC_FOOTER,
|
|
9526
|
+
header: () => headLines.map((l) => dim(l)),
|
|
9527
|
+
headerRows: headLines.length,
|
|
9528
|
+
});
|
|
9462
9529
|
if (idx < 0) {
|
|
9463
9530
|
console.log(dim(" (cancelled)\n"));
|
|
9464
9531
|
return;
|
|
@@ -9468,6 +9535,11 @@ async function chooseAgentModel() {
|
|
|
9468
9535
|
if ((await agentModelOnServer({ back: true, start: "models" })) === BACK) continue;
|
|
9469
9536
|
return;
|
|
9470
9537
|
}
|
|
9538
|
+
if (idx === CODER_MODELS) {
|
|
9539
|
+
const dialect = coderRow.kind === "ollama" ? "ollama" : "openai";
|
|
9540
|
+
if ((await addServer({ role: "agent", dialect, url: coderRow.url, back: true })) === BACK) continue;
|
|
9541
|
+
return;
|
|
9542
|
+
}
|
|
9471
9543
|
if (idx === KEYS) {
|
|
9472
9544
|
const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
|
|
9473
9545
|
if (!rowId) return enterAgentProviderKey(source.row, curHost);
|
|
@@ -9478,7 +9550,12 @@ async function chooseAgentModel() {
|
|
|
9478
9550
|
if ((await addServer({ role: "agent", dialect: "openai", back: true })) === BACK) continue;
|
|
9479
9551
|
return;
|
|
9480
9552
|
}
|
|
9481
|
-
|
|
9553
|
+
if (idx === 0) {
|
|
9554
|
+
if (!current) console.log(dim(" already the same as the coding model — nothing changed.\n"));
|
|
9555
|
+
else await useCoderForAgent();
|
|
9556
|
+
return;
|
|
9557
|
+
}
|
|
9558
|
+
await useSavedAgentChoice(saved[idx - 1]);
|
|
9482
9559
|
return;
|
|
9483
9560
|
}
|
|
9484
9561
|
}
|
|
@@ -9488,6 +9565,31 @@ async function chooseAgentModel() {
|
|
|
9488
9565
|
* API finds the key by the URL (resolveKeyForRole), so nothing is re-entered — unless the server
|
|
9489
9566
|
* needs a key and has none, which is asked here rather than failing on the next question.
|
|
9490
9567
|
*/
|
|
9568
|
+
/**
|
|
9569
|
+
* "Same as the coding model" (GH #397): clear the separate agent model, so the coding model talks
|
|
9570
|
+
* too — and keeps following each folder's /model pick. The way BACK to one model: before this the
|
|
9571
|
+
* only route was removing the whole provider (and its key).
|
|
9572
|
+
*/
|
|
9573
|
+
async function useCoderForAgent() {
|
|
9574
|
+
const md = await import("./model-doctor.mjs");
|
|
9575
|
+
const saved = await withPendingStatus("saving", () =>
|
|
9576
|
+
md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: "", agentModelName: "", agentModel: "" })
|
|
9577
|
+
);
|
|
9578
|
+
if (!saved) {
|
|
9579
|
+
console.log(red(" couldn't save that — nothing changed.\n"));
|
|
9580
|
+
return;
|
|
9581
|
+
}
|
|
9582
|
+
if (sessionAgentKeyId) await saveFolderKeyPick("agent", "");
|
|
9583
|
+
console.log(accent(" ✓ agent model → same as the coding model") + dim(" · /model switches both"));
|
|
9584
|
+
console.log(GUTTER + dim("takes effect on your next question.\n"));
|
|
9585
|
+
const after = (await fetchAgentPayload([{ role: "user", content: "." }]).catch(() => null))?.payload;
|
|
9586
|
+
if (after) {
|
|
9587
|
+
agentMissing = after.agentMissing === true;
|
|
9588
|
+
noModelAtAll = nothingToAnswerWith(after);
|
|
9589
|
+
}
|
|
9590
|
+
if (barPinned()) paintBar();
|
|
9591
|
+
}
|
|
9592
|
+
|
|
9491
9593
|
async function useSavedAgentChoice(c) {
|
|
9492
9594
|
if (c.current) {
|
|
9493
9595
|
console.log(dim(` already using ${c.model} on ${c.host} — nothing changed.\n`));
|
|
@@ -10921,8 +11023,7 @@ async function replaceCodingKey({ back = false } = {}) {
|
|
|
10921
11023
|
(ks.stored ? dim("(replacing ") + accent(maskKey(ks.stored)) + dim(")") : dim("(none stored)")) +
|
|
10922
11024
|
dim(" · Enter alone cancels, \"clear\" removes it\n")
|
|
10923
11025
|
);
|
|
10924
|
-
|
|
10925
|
-
const typed = (await askSecret(` ${ks.model} API key: `)).trim();
|
|
11026
|
+
const typed = (await askSecret(` ${ks.model} API key: `, { hint: [SECRET_KEY_HINT] })).trim();
|
|
10926
11027
|
if (!typed) {
|
|
10927
11028
|
console.log(GUTTER + dim("(cancelled — the stored key is unchanged)\n"));
|
|
10928
11029
|
paintBar();
|
|
@@ -10976,7 +11077,9 @@ async function manageServerKeys({ role = "coding", backendId, host, model = "",
|
|
|
10976
11077
|
});
|
|
10977
11078
|
const ADD = rows.length;
|
|
10978
11079
|
rows.push(dim("add a new key…"));
|
|
10979
|
-
|
|
11080
|
+
// The agent model is a MACHINE setting (GH #397): "proj's agent model" named a folder it is not tied to.
|
|
11081
|
+
const whose = role === "agent" ? `the ${roleLabel}` : `${folderLabel()}'s ${roleLabel}`;
|
|
11082
|
+
console.log("\n" + GUTTER + dim(`Keys for ${host} — ${whose}${model ? ` (${model})` : ""}\n`));
|
|
10980
11083
|
console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
|
|
10981
11084
|
const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true, back });
|
|
10982
11085
|
const idx = picked?.index ?? -1;
|
|
@@ -11904,6 +12007,28 @@ function pickFromList(items, initialSel = 0, opts = {}) {
|
|
|
11904
12007
|
listTimeoutIndex = -1;
|
|
11905
12008
|
listHeader = typeof opts.header === "function" ? opts.header : null;
|
|
11906
12009
|
listHeaderRows = listHeader ? Math.max(0, opts.headerRows | 0) : 0;
|
|
12010
|
+
// A STATIC HEADER WIDER THAN THE TERMINAL IS WRAPPED AT WORDS (GH #397), not clipped: on 80
|
|
12011
|
+
// columns "Which model on token-plan.maas.qwencloudapi.com? (↑/↓ then Enter · Esc to c" lost
|
|
12012
|
+
// its way out. Done once, here, so the block keeps the constant height its redraw walks
|
|
12013
|
+
// depend on. A live panel (refreshMs) is already sized by its caller and is left alone.
|
|
12014
|
+
if (listHeader && !(opts.refreshMs > 0)) {
|
|
12015
|
+
const w = Math.max(20, process.stdout.columns || 80) - 1;
|
|
12016
|
+
let given = [];
|
|
12017
|
+
try { given = (listHeader(listHeaderRows) || []).slice(0, listHeaderRows); } catch { /* blank */ }
|
|
12018
|
+
if (given.some((l) => visibleLen(l) > w)) {
|
|
12019
|
+
const wrapped = given.flatMap((l) => {
|
|
12020
|
+
if (visibleLen(l) <= w) return [l];
|
|
12021
|
+
const plain = stripAnsiEscapes(l);
|
|
12022
|
+
const lead = /^\s*/.exec(plain)[0];
|
|
12023
|
+
// The key hint " (↑/↓ then Enter · Esc …)" moves to its own line whole, before any word wrap.
|
|
12024
|
+
const at = plain.indexOf(" (", lead.length);
|
|
12025
|
+
const parts = at > 0 ? [plain.slice(lead.length, at), plain.slice(at + 2)] : [plain.slice(lead.length)];
|
|
12026
|
+
return parts.flatMap((p) => wrapIndented(p, { width: w, indent: lead, hang: "" })).map((r) => dim(r));
|
|
12027
|
+
});
|
|
12028
|
+
listHeader = () => wrapped;
|
|
12029
|
+
listHeaderRows = wrapped.length;
|
|
12030
|
+
}
|
|
12031
|
+
}
|
|
11907
12032
|
if (listHeader && opts.refreshMs > 0) {
|
|
11908
12033
|
// A live panel (#376): redrawn in place until the picker closes.
|
|
11909
12034
|
const live = setInterval(() => {
|
|
@@ -12382,6 +12507,7 @@ async function localScaffoldState() {
|
|
|
12382
12507
|
/** Print the startup scaffold line — deferred while a turn owns the transcript. */
|
|
12383
12508
|
function announceScaffold() {
|
|
12384
12509
|
if (!scaffoldNotice || following) return;
|
|
12510
|
+
if (listPickerActive || askWaiting || !inputVisible) return; // as announceCliUpdate (GH #397)
|
|
12385
12511
|
const { kind, text } = scaffoldNotice;
|
|
12386
12512
|
scaffoldNotice = null;
|
|
12387
12513
|
if (kind === "link") {
|
|
@@ -14434,7 +14560,11 @@ async function main() {
|
|
|
14434
14560
|
await import("./agent-model-label.mjs");
|
|
14435
14561
|
// Skip the `ps` sweep entirely when settings already name the model — the overwhelmingly
|
|
14436
14562
|
// common case, and startup is not the place to spend a subprocess proving it.
|
|
14437
|
-
|
|
14563
|
+
// NO AGENT MODEL SET: the payload's agentModelId is only the account-default coder, which may
|
|
14564
|
+
// not be this folder's pick — naming it here said "kimi" over a folder running qwen (GH #397).
|
|
14565
|
+
const agentLabel = p.agentMissing === true
|
|
14566
|
+
? { name: "", known: false, reason: "not-set" }
|
|
14567
|
+
: namesAModel(p.agentModelId)
|
|
14438
14568
|
? { name: String(p.agentModelId).trim(), known: true, reason: "settings" }
|
|
14439
14569
|
: agentModelLabel({
|
|
14440
14570
|
configuredId: p.agentModelId,
|
|
@@ -14695,7 +14825,7 @@ async function main() {
|
|
|
14695
14825
|
dim(" Skipped — this machine won't SERVE models; the agent still runs here and\n") +
|
|
14696
14826
|
dim(" calls your model machine over the network. Point the Agent model URL at\n") +
|
|
14697
14827
|
dim(" that machine (not 127.0.0.1) — `gu status` shows what it resolves to.\n") +
|
|
14698
|
-
dim(" `gu doctor` sets models up here ·
|
|
14828
|
+
dim(" `gu doctor` sets models up here · `gu clear` starts setup over.\n")
|
|
14699
14829
|
);
|
|
14700
14830
|
void st;
|
|
14701
14831
|
}
|
|
@@ -15437,18 +15567,6 @@ async function main() {
|
|
|
15437
15567
|
);
|
|
15438
15568
|
continue;
|
|
15439
15569
|
}
|
|
15440
|
-
if (line === "/host-mode") {
|
|
15441
|
-
const ctl2 = await import("./daemon-control.mjs");
|
|
15442
|
-
await ctl2.setClientMode(false);
|
|
15443
|
-
await saveSessionSkip(cwd, "skipModelSetupPrompt", false);
|
|
15444
|
-
await saveSessionSkip(cwd, "skipDaemonPrompt", false);
|
|
15445
|
-
console.log(
|
|
15446
|
-
accent("host mode") +
|
|
15447
|
-
dim(" — this machine will run turns again. Start the worker with `gu start`, ") +
|
|
15448
|
-
dim("and `gu doctor` to set up models here.\n")
|
|
15449
|
-
);
|
|
15450
|
-
continue;
|
|
15451
|
-
}
|
|
15452
15570
|
if (line === "/rag" || line === "/rag-cloud" || line === "/rag-local") {
|
|
15453
15571
|
// ONE QUESTION, THREE ANSWERS (task #180). This was two commands that toggled one flag,
|
|
15454
15572
|
// so the command you typed did not determine the state you got — and the reply named a
|
package/mongo-mac.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MONGODB ON macOS 13 AND OLDER (GH #403).
|
|
3
|
+
*
|
|
4
|
+
* Homebrew's mongodb-community is 9.0, and every MongoDB from 8.0.6 on is built for macOS 14+
|
|
5
|
+
* (LC_BUILD_VERSION minos 14.0 — checked with otool on the 8.0.6 … 9.0.4 downloads). On macOS 13
|
|
6
|
+
* it installs fine and then cannot start: "dyld: Symbol not found … built for macOS 14.0".
|
|
7
|
+
* 8.0.5 is the last release built for older Macs (minos 10.14 on Intel, 11.0 on Apple Silicon).
|
|
8
|
+
*
|
|
9
|
+
* So on those Macs gu runs its own mongod 8.0.5, like the Windows one (#210): no Homebrew, no
|
|
10
|
+
* service, no sudo. Everything lives under ~/.gonext/mongodb and goes away with that folder:
|
|
11
|
+
* bin/mongod data/ mongod.log launch.log version.txt
|
|
12
|
+
*
|
|
13
|
+
* The .tgz is gzip, so unlike the Windows zip one file cannot be fetched on its own: the whole
|
|
14
|
+
* archive (~80-90 MB) is downloaded, checked against MongoDB's published SHA-256, and only
|
|
15
|
+
* bin/mongod is kept.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
19
|
+
import { createWriteStream } from "node:fs";
|
|
20
|
+
import { mkdir, rename, rm, stat, writeFile, mkdtemp, chmod } from "node:fs/promises";
|
|
21
|
+
import { homedir, release } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
export const MONGO_MAC_OLD_VERSION = "8.0.5";
|
|
25
|
+
/** Homebrew's MongoDB (and every release after 8.0.5) needs this macOS or newer. */
|
|
26
|
+
export const MONGO_MAC_BREW_MIN_MAJOR = 14;
|
|
27
|
+
|
|
28
|
+
// From https://downloads.mongodb.org/full.json, and re-checked by downloading both (2026-11-07).
|
|
29
|
+
export const MONGO_MAC_DOWNLOADS = {
|
|
30
|
+
x64: {
|
|
31
|
+
url: `https://fastdl.mongodb.org/osx/mongodb-macos-x86_64-${MONGO_MAC_OLD_VERSION}.tgz`,
|
|
32
|
+
sha256: "eb528846663ef106003b3c491dfe3722f918cb75d056670b23a620b339f7f6ec",
|
|
33
|
+
},
|
|
34
|
+
arm64: {
|
|
35
|
+
url: `https://fastdl.mongodb.org/osx/mongodb-macos-arm64-${MONGO_MAC_OLD_VERSION}.tgz`,
|
|
36
|
+
sha256: "71e2510f0455ff24e1961aa0507eb0660b300c84f6da9899052be409090fb990",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** "13.7.1" → 13. Darwin's kernel number is the fallback (Darwin 20-24 = macOS 11-15, 25 = 26). */
|
|
41
|
+
export function macosMajor({ productVersion = null, darwin = release(), platform = process.platform } = {}) {
|
|
42
|
+
if (productVersion == null && platform !== "darwin") return 0; // not a Mac: no answer, not a guess
|
|
43
|
+
let v = productVersion;
|
|
44
|
+
if (v == null) {
|
|
45
|
+
try { v = execFileSync("sw_vers", ["-productVersion"], { encoding: "utf8", timeout: 5000 }); } catch { v = ""; }
|
|
46
|
+
}
|
|
47
|
+
const m = /^(\d+)/.exec(String(v).trim());
|
|
48
|
+
if (m) return Number(m[1]);
|
|
49
|
+
const d = parseInt(String(darwin), 10);
|
|
50
|
+
if (!Number.isFinite(d)) return 0;
|
|
51
|
+
return d >= 25 ? d + 1 : d >= 20 ? d - 9 : 10;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Does this Mac need gu's own MongoDB? 0 (unknown) keeps Homebrew — the path that worked before. */
|
|
55
|
+
export function macNeedsOwnMongo(major) {
|
|
56
|
+
return major > 0 && major < MONGO_MAC_BREW_MIN_MAJOR;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Where gu keeps its own mongod on a Mac — the same layout as Windows. `home` for tests. */
|
|
60
|
+
export function mongoMacPaths(home = homedir()) {
|
|
61
|
+
const dir = join(home, ".gonext", "mongodb");
|
|
62
|
+
return {
|
|
63
|
+
dir,
|
|
64
|
+
exe: join(dir, "bin", "mongod"),
|
|
65
|
+
data: join(dir, "data"),
|
|
66
|
+
log: join(dir, "mongod.log"),
|
|
67
|
+
launchLog: join(dir, "launch.log"),
|
|
68
|
+
version: join(dir, "version.txt"),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Is gu's own mongod there? */
|
|
73
|
+
export async function macMongoInstalled(paths = mongoMacPaths()) {
|
|
74
|
+
try { return (await stat(paths.exe)).isFile(); } catch { return false; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The download for this CPU, or null for one MongoDB does not build. */
|
|
78
|
+
export function macMongoDownload(arch = process.arch) {
|
|
79
|
+
return MONGO_MAC_DOWNLOADS[arch] ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Download the archive, check its SHA-256, keep only bin/mongod. → { bytes }.
|
|
84
|
+
* Throws a plain reason; nothing half-written is left at paths.exe.
|
|
85
|
+
* `fetchFn` and `extract` are injectable so tests never touch the network or tar.
|
|
86
|
+
*/
|
|
87
|
+
export async function downloadMongodMac({
|
|
88
|
+
paths = mongoMacPaths(),
|
|
89
|
+
arch = process.arch,
|
|
90
|
+
fetchFn = fetch,
|
|
91
|
+
extract = extractMongod,
|
|
92
|
+
log = () => {},
|
|
93
|
+
version = MONGO_MAC_OLD_VERSION,
|
|
94
|
+
dl = macMongoDownload(arch),
|
|
95
|
+
} = {}) {
|
|
96
|
+
if (!dl) throw new Error(`MongoDB has no macOS build for ${arch}`);
|
|
97
|
+
await mkdir(paths.dir, { recursive: true });
|
|
98
|
+
const tgz = join(paths.dir, "mongodb.tgz.part");
|
|
99
|
+
const res = await fetchFn(dl.url);
|
|
100
|
+
if (!res.ok || !res.body) throw new Error(`download failed (HTTP ${res.status})`);
|
|
101
|
+
const total = Number(res.headers?.get?.("content-length")) || 0;
|
|
102
|
+
const mb = (n) => (n / 1048576).toFixed(0);
|
|
103
|
+
const hash = createHash("sha256");
|
|
104
|
+
const out = createWriteStream(tgz);
|
|
105
|
+
let got = 0;
|
|
106
|
+
let shown = -1;
|
|
107
|
+
try {
|
|
108
|
+
const reader = res.body.getReader();
|
|
109
|
+
for (;;) {
|
|
110
|
+
const { done, value } = await reader.read();
|
|
111
|
+
if (done) break;
|
|
112
|
+
hash.update(value);
|
|
113
|
+
got += value.length;
|
|
114
|
+
if (!out.write(value)) await new Promise((r) => out.once("drain", r));
|
|
115
|
+
if (total) {
|
|
116
|
+
const pct = Math.floor((got / total) * 10) * 10;
|
|
117
|
+
if (pct !== shown) { shown = pct; log(` … ${pct}% (${mb(got)} of ${mb(total)} MB)`); }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
await new Promise((r) => out.end(r));
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
if (hash.digest("hex") !== dl.sha256) throw new Error("the download is damaged (SHA-256 does not match) — run gu again");
|
|
125
|
+
const tmp = await mkdtemp(join(paths.dir, "unpack-"));
|
|
126
|
+
try {
|
|
127
|
+
const mongod = await extract(tgz, tmp);
|
|
128
|
+
await mkdir(join(paths.dir, "bin"), { recursive: true });
|
|
129
|
+
await rm(paths.exe, { force: true });
|
|
130
|
+
await rename(mongod, paths.exe);
|
|
131
|
+
await chmod(paths.exe, 0o755);
|
|
132
|
+
} finally {
|
|
133
|
+
await rm(tmp, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
await rm(tgz, { force: true });
|
|
137
|
+
}
|
|
138
|
+
await writeFile(paths.version, `${version}\n`);
|
|
139
|
+
return { bytes: got };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** tar -x of only the archive's bin/mongod into `dir` → the extracted file's path. */
|
|
143
|
+
export function extractMongod(tgz, dir) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
execFile("tar", ["-xzf", tgz, "-C", dir, "--strip-components", "2", "*/bin/mongod"], { timeout: 300000 }, (err, _o, stderr) => {
|
|
146
|
+
if (err) reject(new Error(`could not unpack the archive: ${String(stderr || err.message).trim().split("\n").pop()}`));
|
|
147
|
+
else resolve(join(dir, "mongod"));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Can this mongod run here at all? `mongod --version` stops at once with a dyld error when the
|
|
154
|
+
* build is for a newer macOS — the failure this module exists for — so ask it before starting it
|
|
155
|
+
* detached, where that would only show up as "not answering". → { ok, why }.
|
|
156
|
+
*/
|
|
157
|
+
export function mongodRunsMac(exe, { execFn = execFile, timeoutMs = 20000 } = {}) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
execFn(exe, ["--version"], { timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
160
|
+
if (!err) return resolve({ ok: true, why: "" });
|
|
161
|
+
const text = `${stderr || ""}\n${stdout || ""}`;
|
|
162
|
+
const newer = /built for macOS ([\d.]+) which is newer/.exec(text);
|
|
163
|
+
resolve({ ok: false, why: newer ? `it is built for macOS ${newer[1]} or newer` : (text.trim().split("\n")[0] || String(err.message)) });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
package/node-version.mjs
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Checked at the TOP of both entry points, before any import that might itself fail.
|
|
14
14
|
*/
|
|
15
|
+
import { readdirSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
15
18
|
export const MIN_NODE_MAJOR = 18;
|
|
16
19
|
|
|
17
20
|
/** The message for a too-old runtime, or "" when this one is fine. Pure, so it is testable. */
|
|
@@ -52,11 +55,30 @@ export function nodeAtLeast(version, min) {
|
|
|
52
55
|
return true;
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The Node versions nvm has installed when THIS node is one of nvm's, else []. Found on the 2017
|
|
60
|
+
* MacBook Pro (GH #406): nvm's default was 18 while 22 was already installed, and "install a new
|
|
61
|
+
* Node" was the wrong advice — one `nvm alias default` was the whole fix.
|
|
62
|
+
*/
|
|
63
|
+
export function nvmNodeVersions({ env = process.env, execPath = process.execPath, list = null } = {}) {
|
|
64
|
+
const dir = env.NVM_DIR;
|
|
65
|
+
if (!dir || !String(execPath).startsWith(dir)) {return [];}
|
|
66
|
+
let names = list;
|
|
67
|
+
if (!names) {
|
|
68
|
+
try { names = readdirSync(join(dir, "versions", "node")); } catch { return []; }
|
|
69
|
+
}
|
|
70
|
+
return names.filter((n) => /^v\d+\.\d+\.\d+$/.test(n));
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
/** 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) {
|
|
74
|
+
export function localModeNodeProblem(version = process.version, platform = process.platform, { nvmVersions = [] } = {}) {
|
|
57
75
|
if (nodeAtLeast(version, LOCAL_API_MIN_NODE)) {return "";}
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
// nvm already has a new enough Node: switch to it, nothing to install.
|
|
77
|
+
const ready = nvmVersions.filter((v) => nodeAtLeast(v, LOCAL_API_MIN_NODE))
|
|
78
|
+
.sort((a, b) => (nodeAtLeast(a, b) ? -1 : 1))[0];
|
|
79
|
+
const how = ready
|
|
80
|
+
? [` nvm already has ${ready}. Make it the default, then open a new terminal:`, ` nvm alias default ${ready.replace(/^v/, "")}`]
|
|
81
|
+
: platform === "win32"
|
|
60
82
|
? [" winget install OpenJS.NodeJS.LTS (or: nvm install lts with nvm-windows)"]
|
|
61
83
|
: platform === "darwin"
|
|
62
84
|
? [" brew install node (or: nvm install --lts)"]
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama or any OpenAI-compatible provider). Free installs
|
|
3
|
+
"version": "1.0.898",
|
|
4
|
+
"description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama or any OpenAI-compatible provider). Free installs keep the agent, its API and its database on your machine. macOS, Linux and Windows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
7
7
|
"coding-agent",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"turn-stop.mjs",
|
|
75
75
|
"workspace-revert.mjs",
|
|
76
76
|
"mongo-native.mjs",
|
|
77
|
+
"mongo-mac.mjs",
|
|
77
78
|
"mongo-windows.mjs",
|
|
78
79
|
"python-windows.mjs",
|
|
79
80
|
"terminal-hover.mjs",
|
package/server-keys.mjs
CHANGED
|
@@ -49,7 +49,6 @@ export function keyTags(k, { folderKeyId = "", inUseId = "" } = {}) {
|
|
|
49
49
|
if (k.id === folderKeyId) tags.push("this folder");
|
|
50
50
|
else if (k.id === inUseId) tags.push("in use");
|
|
51
51
|
if (k.default) tags.push("default");
|
|
52
|
-
if (k.first) tags.push("first key");
|
|
53
52
|
if (!k.backendId) tags.push("not tied to a server yet");
|
|
54
53
|
return tags;
|
|
55
54
|
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -2790,29 +2790,6 @@ export function resizeRepaintTarget({ barPinned, modal, busy, scrolledBack }) {
|
|
|
2790
2790
|
return scrolledBack ? "scrollback" : "live";
|
|
2791
2791
|
}
|
|
2792
2792
|
|
|
2793
|
-
/**
|
|
2794
|
-
* Should the workspace "sync chat history to the cloud?" question be ASKED? (task #154)
|
|
2795
|
-
*
|
|
2796
|
-
* Not on a Client Expert machine. There is no cloud there: the API and the database are on
|
|
2797
|
-
* this box, "your account" is a local row, and there is no web app to see it on — so every
|
|
2798
|
-
* clause of the question is false. Asking a question whose premise is wrong is worse than not
|
|
2799
|
-
* asking, because the answer cannot mean what the reader thinks it means.
|
|
2800
|
-
*
|
|
2801
|
-
* The sync itself still happens — it writes to the local database, which is where that
|
|
2802
|
-
* machine's conversations already live — so the value is true and it is stated rather than
|
|
2803
|
-
* asked. Reported live: a Client Expert machine on Windows offering to send chat to a cloud
|
|
2804
|
-
* it does not have.
|
|
2805
|
-
*/
|
|
2806
|
-
export function workspaceSyncQuestion({ clientExpert }) {
|
|
2807
|
-
return clientExpert
|
|
2808
|
-
? {
|
|
2809
|
-
ask: false,
|
|
2810
|
-
value: true,
|
|
2811
|
-
note: "chat history is kept in the local database on this machine (Local mode)",
|
|
2812
|
-
}
|
|
2813
|
-
: { ask: true, value: null, note: "" };
|
|
2814
|
-
}
|
|
2815
|
-
|
|
2816
2793
|
/**
|
|
2817
2794
|
* The ONE first-run question for a new folder (GitHub #192). It used to be two or three separate
|
|
2818
2795
|
* Y/n prompts (register, trust to run commands, then the browser on first use). They are one
|
|
@@ -2928,8 +2905,9 @@ export function identityRow(
|
|
|
2928
2905
|
// The mark only means anything beside a named coder: with none shown there is nothing for it
|
|
2929
2906
|
// to be about, and it would read as a fault in the folder.
|
|
2930
2907
|
const mark = name && keyUnchosen ? red(" key not chosen") : "";
|
|
2931
|
-
// API key tail:
|
|
2932
|
-
|
|
2908
|
+
// API key tail, LABELLED (GH #397): a bare "| VW3Y" between the model and the folder read as a
|
|
2909
|
+
// stray code. Same "····" form every key line in gu uses.
|
|
2910
|
+
const keyIndicator = apiKeyTail ? dim(" | ") + dim(`key ····${apiKeyTail}`) : "";
|
|
2933
2911
|
return (name ? orange(name) + mark + keyIndicator + dim(" | ") : "") + cyan(where);
|
|
2934
2912
|
}
|
|
2935
2913
|
|
|
@@ -2938,7 +2916,7 @@ export function identityRow(
|
|
|
2938
2916
|
*
|
|
2939
2917
|
* ON CLIENT EXPERT, NOTHING. That mode's whole promise is that the machine keeps everything —
|
|
2940
2918
|
* API, database, models — and shares none of it, so "cloud sync on" is not merely noise, it
|
|
2941
|
-
* contradicts the mode the user chose (reported 2026-09-04).
|
|
2919
|
+
* contradicts the mode the user chose (reported 2026-09-04). The workspace record already
|
|
2942
2920
|
* knows this and stores `true` meaning "persist it", but the LABEL read that value as "to the
|
|
2943
2921
|
* cloud", which is the one place it does not go.
|
|
2944
2922
|
*
|