@tiens.nguyen/gu-cli 1.0.897 → 1.0.899
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 -156
- 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,9 +1051,8 @@ 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
|
-
{ name: "/server", desc: "pick a deployment server (host/user) for this session" },
|
|
1058
1056
|
{ name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
|
|
1059
1057
|
{ name: "/s3", usage: "/s3 [test]", desc: "set up YOUR S3 bucket (RAG index, and the PDFs/downloads gu makes) and check the AWS keys work — without it, files are saved on this computer" },
|
|
1060
1058
|
{ name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
|
|
@@ -2720,6 +2718,11 @@ let cliNoticeShown = false;
|
|
|
2720
2718
|
function announceCliUpdate() {
|
|
2721
2719
|
if (cliNoticeShown || !availableUpdates.cli) return;
|
|
2722
2720
|
if (following) return; // a turn owns the transcript — try again at the next prompt
|
|
2721
|
+
// …and so does an open menu or question (GH #397): printed into a picker's in-place block, the
|
|
2722
|
+
// notice pushed it down and the picker could no longer erase itself — the rows of a menu left
|
|
2723
|
+
// closed stayed on screen. Retried at the next prompt, like a turn.
|
|
2724
|
+
// A slash command still running hides the input row (inputVisible): its output is not done.
|
|
2725
|
+
if (listPickerActive || askWaiting || !inputVisible) return;
|
|
2723
2726
|
let lines = [];
|
|
2724
2727
|
try {
|
|
2725
2728
|
lines = repeatUpdateNotice({ last: readUpdateAttempt(), cli: availableUpdates.cli, running: REPL_VERSION,
|
|
@@ -5244,7 +5247,7 @@ const ask = async (q, { prefill = "", hint = [], atomic = false, noCancel = fals
|
|
|
5244
5247
|
// Masked prompt for a secret (an API key). Mutes readline's echo/refresh (secretInput)
|
|
5245
5248
|
// so the typed value never hits the terminal or scrollback (task #127). Returns "" on a
|
|
5246
5249
|
// bare Enter (skip).
|
|
5247
|
-
const askSecret = async (q) => {
|
|
5250
|
+
const askSecret = async (q, { hint = [] } = {}) => {
|
|
5248
5251
|
// Same rule as ask(): input has ended, so stop. Returning "" here would hand an EMPTY
|
|
5249
5252
|
// secret to a caller that is about to store or send it.
|
|
5250
5253
|
if (_stdinClosed) return await ask("");
|
|
@@ -5255,6 +5258,9 @@ const askSecret = async (q) => {
|
|
|
5255
5258
|
// FOR. With the bar pinned the row itself becomes the prompt, so nothing is left to infer.
|
|
5256
5259
|
secretPrompt = String(q ?? "").replace(/\s+$/, "") + " ";
|
|
5257
5260
|
secretInput = true;
|
|
5261
|
+
// A hint UNDER the input, gone once answered (GH #397) — printed above, "type or paste the
|
|
5262
|
+
// key…" stayed in the history after the key was in, as if it were still being asked.
|
|
5263
|
+
askHint = barPinned() ? hint : [];
|
|
5258
5264
|
// Same reason as ask() above: inputLineFor returns "" when inputVisible is false, so the one
|
|
5259
5265
|
// bullet per character — the only proof a keystroke registered while the echo is muted —
|
|
5260
5266
|
// would not be drawn either. askSecret is reached from /model, which is exactly a command
|
|
@@ -5264,7 +5270,10 @@ const askSecret = async (q) => {
|
|
|
5264
5270
|
// Unpinned (piped output, or a window too short to reserve rows) there IS no frozen row, so
|
|
5265
5271
|
// the label has to be printed the old way or the prompt is silent.
|
|
5266
5272
|
if (barPinned()) paintBar();
|
|
5267
|
-
else
|
|
5273
|
+
else {
|
|
5274
|
+
for (const l of hint) console.log(GUTTER + dim(l));
|
|
5275
|
+
process.stdout.write(q);
|
|
5276
|
+
}
|
|
5268
5277
|
let raw = "";
|
|
5269
5278
|
try {
|
|
5270
5279
|
askWaiting = true;
|
|
@@ -5277,6 +5286,7 @@ const askSecret = async (q) => {
|
|
|
5277
5286
|
askWaiting = false;
|
|
5278
5287
|
secretInput = false;
|
|
5279
5288
|
secretPrompt = "";
|
|
5289
|
+
askHint = [];
|
|
5280
5290
|
inputVisible = _secretWasVisible;
|
|
5281
5291
|
// READLINE KEEPS EVERY SUBMITTED LINE (task #191). Muting the echo stops the key reaching
|
|
5282
5292
|
// the screen; it does nothing about rl.history, which is where the line goes on Enter — so
|
|
@@ -5343,10 +5353,10 @@ async function ensureWorkspace() {
|
|
|
5343
5353
|
// Registered before cloud sync existed → ask once and persist. Otherwise honor the
|
|
5344
5354
|
// stored choice. (undefined = old record that never opted in → ask.)
|
|
5345
5355
|
if (hit.allowSync === undefined) {
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5356
|
+
// No question (owner, 11-07): the mode chosen at setup decides where history goes —
|
|
5357
|
+
// the cloud on a hosted account, the local database on Client Expert. A folder that
|
|
5358
|
+
// answered No before keeps that answer; only a folder never asked is filled in.
|
|
5359
|
+
hit.allowSync = true;
|
|
5350
5360
|
await writeFile(WS_FILE, JSON.stringify({ workspaces: list }, null, 2) + "\n");
|
|
5351
5361
|
}
|
|
5352
5362
|
// The workspace facts now live in the startup banner (task #131) — just remember them.
|
|
@@ -5380,16 +5390,10 @@ async function ensureWorkspace() {
|
|
|
5380
5390
|
} catch (e) {
|
|
5381
5391
|
console.log(GUTTER + yellow(`Could not save the browser permission (${e?.message || e}) — gu will ask when it first needs it.`));
|
|
5382
5392
|
}
|
|
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));
|
|
5393
|
+
// NO SYNC QUESTION (owner, 11-07): Cloud or Client Expert is chosen once, at setup, and it
|
|
5394
|
+
// decides where chat history lives — your account's cloud database, or the local one. To
|
|
5395
|
+
// change it, `gu clear` and set up again. RAG is never synced (it stays where it was built).
|
|
5396
|
+
const allowSync = true;
|
|
5393
5397
|
const next = list.filter((w) => w.path !== cwd);
|
|
5394
5398
|
next.push({
|
|
5395
5399
|
name: basename(cwd),
|
|
@@ -6297,10 +6301,12 @@ async function pickExtraModels(rest0, preChecked = []) {
|
|
|
6297
6301
|
const already = new Set(preChecked);
|
|
6298
6302
|
const { codingModelCandidates } = await import("./coding-provider-setup.mjs");
|
|
6299
6303
|
const keep = new Set(codingModelCandidates(rest0).models);
|
|
6300
|
-
const rest = rest0
|
|
6304
|
+
const rest = rest0
|
|
6305
|
+
.filter((m) => keep.has(m) || already.has(m))
|
|
6306
|
+
.sort((a, b) => a.localeCompare(b, "en", { numeric: true })); // as the pick above (GH #397)
|
|
6301
6307
|
if (rest.length === 0) return [];
|
|
6302
|
-
console.log(dim(`
|
|
6303
|
-
console.log(dim(`
|
|
6308
|
+
console.log(dim(` Any other models to switch to in /model? optional · up to ${MAX_ALT_CODERS}`));
|
|
6309
|
+
console.log(dim(` space ticks · Enter saves (nothing ticked skips this) · Esc cancels`));
|
|
6304
6310
|
const ticks = await pickManyFromList(
|
|
6305
6311
|
rest,
|
|
6306
6312
|
rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
|
|
@@ -6328,7 +6334,12 @@ async function pickExtraModels(rest0, preChecked = []) {
|
|
|
6328
6334
|
* Returns the model id, or null when the user cancelled.
|
|
6329
6335
|
*/
|
|
6330
6336
|
async function chooseCodingModelFrom(ps, all, question, current, { back = false } = {}) {
|
|
6331
|
-
const { models:
|
|
6337
|
+
const { models: coders0, filtered } = ps.codingModelCandidates(all);
|
|
6338
|
+
// SORTED, like /agent-model's list (GH #397): the provider's own order is arbitrary, and a
|
|
6339
|
+
// family (qwen3.8-*, deepseek-v4-*) scattered over the list is hard to scan.
|
|
6340
|
+
const byName = (a, b) => a.localeCompare(b, "en", { numeric: true });
|
|
6341
|
+
const coders = [...coders0].sort(byName);
|
|
6342
|
+
all = [...all].sort(byName);
|
|
6332
6343
|
let list = coders;
|
|
6333
6344
|
let showingAll = filtered === 0;
|
|
6334
6345
|
for (;;) {
|
|
@@ -6372,8 +6383,9 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
|
|
|
6372
6383
|
* stays available for the same reason it does there — a listed model may be unusable and an
|
|
6373
6384
|
* unlisted one may be exactly right.
|
|
6374
6385
|
*/
|
|
6375
|
-
async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
|
|
6376
|
-
|
|
6386
|
+
async function chooseFromModelList(all, question, current = 0, { back = false, notes = null } = {}) {
|
|
6387
|
+
// `notes` (GH #397): a dim note per row ("an audio/speech model"), as /agent-model's own list has.
|
|
6388
|
+
const rows = [...all.map((m, i) => (notes?.[i] ? `${m}${dim(` · ${notes[i]}`)}` : m)), "type a model name…"];
|
|
6377
6389
|
const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
|
|
6378
6390
|
const idx = await pickFromList(rows, at >= 0 ? at : 0, {
|
|
6379
6391
|
back,
|
|
@@ -6390,6 +6402,21 @@ async function chooseFromModelList(all, question, current = 0, { back = false }
|
|
|
6390
6402
|
return all[idx];
|
|
6391
6403
|
}
|
|
6392
6404
|
|
|
6405
|
+
/**
|
|
6406
|
+
* The agent's pick from a fresh provider (GH #397): the SAME order and labels as /agent-model's
|
|
6407
|
+
* "pick another model on <host>…" — chat models alphabetically, then audio/image/video models
|
|
6408
|
+
* sunk to the bottom and named. The add flow showed them mixed in and unlabelled.
|
|
6409
|
+
*/
|
|
6410
|
+
async function pickAgentModelFrom(models, question, current, opts) {
|
|
6411
|
+
const am = await import("./agent-model-command.mjs");
|
|
6412
|
+
const sorted = am.agentModelsFromProvider(models);
|
|
6413
|
+
const ids = sorted.map((m) => m.model);
|
|
6414
|
+
return chooseFromModelList(ids, question, Math.max(0, ids.indexOf(current)), {
|
|
6415
|
+
...opts,
|
|
6416
|
+
notes: sorted.map((m) => m.note),
|
|
6417
|
+
});
|
|
6418
|
+
}
|
|
6419
|
+
|
|
6393
6420
|
/**
|
|
6394
6421
|
* /model's provider manager. `back` (#242): opened from /model's list, so its first menu offers
|
|
6395
6422
|
* "← back" and returns BACK. Inside it, each sub-menu's "← back" returns HERE — re-read, because a
|
|
@@ -6750,6 +6777,7 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
6750
6777
|
* newKey ask for a key even if the server has one (the stored one was refused)
|
|
6751
6778
|
*/
|
|
6752
6779
|
async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false, back = false } = {}) {
|
|
6780
|
+
let claudeSdkLater = false; // offered after the save, see step 1
|
|
6753
6781
|
const as = await import("./add-server.mjs");
|
|
6754
6782
|
const ps = await import("./coding-provider-setup.mjs");
|
|
6755
6783
|
const sk = await import("./server-keys.mjs");
|
|
@@ -6802,7 +6830,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6802
6830
|
const OLLAMA_HERE = 0;
|
|
6803
6831
|
const rowsShown = [
|
|
6804
6832
|
`Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
|
|
6805
|
-
|
|
6833
|
+
// The HOST, not the full URL (GH #397): "…/compatible-mode/v1" ran past 100 columns and was
|
|
6834
|
+
// cut mid-word; the path is plumbing, and the URL is echoed in full once one is picked.
|
|
6835
|
+
...presets.map((p) => `${p.name}${dim(` · ${hostOf(p.url)}`)}`),
|
|
6806
6836
|
"another URL…",
|
|
6807
6837
|
];
|
|
6808
6838
|
// A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
|
|
@@ -6857,10 +6887,10 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6857
6887
|
console.log("");
|
|
6858
6888
|
return;
|
|
6859
6889
|
}
|
|
6860
|
-
// Claude as the CODER: prompt caching needs Anthropic's SDK (#262)
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
6890
|
+
// Claude as the CODER: prompt caching needs Anthropic's SDK (#262). Offered AFTER the key and the
|
|
6891
|
+
// model are saved (GH #397): asked first, it interrupted the flow before the user had even been
|
|
6892
|
+
// asked for a key, and dumped pip's raw output over the screen.
|
|
6893
|
+
claudeSdkLater = Boolean(target.claude && role === "coding");
|
|
6864
6894
|
|
|
6865
6895
|
// --- 2. the server's key --------------------------------------------------------------
|
|
6866
6896
|
// AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
|
|
@@ -6968,7 +6998,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6968
6998
|
const canBack = { back: providerPicker || back };
|
|
6969
6999
|
model = R.filterCoders
|
|
6970
7000
|
? await chooseCodingModelFrom(ps, models, R.question, 0, canBack)
|
|
6971
|
-
:
|
|
7001
|
+
: role === "agent"
|
|
7002
|
+
? await pickAgentModelFrom(models, R.question, current, canBack)
|
|
7003
|
+
: await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)), canBack);
|
|
6972
7004
|
if (model === BACK) {
|
|
6973
7005
|
if (providerPicker) continue; // → Which provider?
|
|
6974
7006
|
return BACK;
|
|
@@ -7083,7 +7115,16 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
7083
7115
|
}
|
|
7084
7116
|
return;
|
|
7085
7117
|
}
|
|
7086
|
-
await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName });
|
|
7118
|
+
await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName, extras });
|
|
7119
|
+
if (claudeSdkLater) {
|
|
7120
|
+
// pip's own lines ("Requirement already satisfied…", "Collecting…") are noise here: keep gu's.
|
|
7121
|
+
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;
|
|
7122
|
+
await md.ensureClaudeSdk({
|
|
7123
|
+
log: (l) => { const t = String(l ?? "").trim(); if (t && !pipNoise.test(t)) console.log(GUTTER + dim(t)); },
|
|
7124
|
+
confirm: (q) => askYesNo(` ${q}`, true),
|
|
7125
|
+
});
|
|
7126
|
+
console.log("");
|
|
7127
|
+
}
|
|
7087
7128
|
}
|
|
7088
7129
|
|
|
7089
7130
|
/**
|
|
@@ -7096,7 +7137,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
7096
7137
|
* · DID SAVING EVICT ANOTHER PROVIDER? An API older than row ids keys a backend by its KIND,
|
|
7097
7138
|
* so a second openai row replaced the first with a 200. Said BEFORE the success line.
|
|
7098
7139
|
*/
|
|
7099
|
-
async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName }) {
|
|
7140
|
+
async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName, extras = [] }) {
|
|
7100
7141
|
const cwd = resolve(process.cwd());
|
|
7101
7142
|
const after = await withPendingStatus("checking it is offered", () =>
|
|
7102
7143
|
fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
|
|
@@ -7145,15 +7186,23 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
|
|
|
7145
7186
|
// that was just entered, which reads as the save having failed.
|
|
7146
7187
|
codingKeyMissingKnown = false;
|
|
7147
7188
|
console.log(accent(` ✓ coding model → ${model}`) + dim(` · ${host}`));
|
|
7148
|
-
if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)}
|
|
7189
|
+
if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)}`));
|
|
7190
|
+
// The extras were ticked a screen ago and never mentioned again (GH #397) — say where they went.
|
|
7191
|
+
if (extras.length) console.log(dim(` also in /model: ${extras.join(", ")}`));
|
|
7192
|
+
console.log("");
|
|
7149
7193
|
// A HOSTED SERVER NEEDS ITS WINDOW SET: with nothing set gu assumes 24,000 prompt tokens and
|
|
7150
7194
|
// compacts there — on a large hosted model that throws history away every turn.
|
|
7151
7195
|
if (ps.shouldMentionContextWindow(locality)) {
|
|
7152
|
-
console.log(GUTTER + dim("tip:
|
|
7153
|
-
console.log(GUTTER + dim("
|
|
7196
|
+
console.log(GUTTER + dim("tip: set this model's context size with /context-window —"));
|
|
7197
|
+
console.log(GUTTER + dim(" hosted providers don't report it, so gu assumes 24k tokens.\n"));
|
|
7154
7198
|
}
|
|
7155
7199
|
const t = await fetchOutputTokenTotals(cwd);
|
|
7156
7200
|
{ const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
|
|
7201
|
+
// The bar still said "coding model not set · /model" under the success line of the FIRST model
|
|
7202
|
+
// added on a fresh install, until the next turn repainted it (GH #397).
|
|
7203
|
+
noModelAtAll = after?.payload ? nothingToAnswerWith(after.payload) : false;
|
|
7204
|
+
if (after?.payload) agentMissing = after.payload.agentMissing === true;
|
|
7205
|
+
if (barPinned()) paintBar();
|
|
7157
7206
|
}
|
|
7158
7207
|
|
|
7159
7208
|
|
|
@@ -7258,7 +7307,7 @@ async function chooseModel() {
|
|
|
7258
7307
|
// through to add when there is genuinely nothing there.
|
|
7259
7308
|
// As the picker's header (GH #397), so "← back" re-opening this menu does not stack it.
|
|
7260
7309
|
const go = await pickFromList(
|
|
7261
|
-
[dim("leave it"), "add
|
|
7310
|
+
[dim("leave it"), "add a provider — Claude API, OpenAI, Kimi, Ollama…"],
|
|
7262
7311
|
1,
|
|
7263
7312
|
{ header: () => [dim(" No coding model configured.")], headerRows: 1 }
|
|
7264
7313
|
);
|
|
@@ -7271,16 +7320,10 @@ async function chooseModel() {
|
|
|
7271
7320
|
// provider manager is where another one is added (an Ollama box is "another URL…").
|
|
7272
7321
|
// #412 removed "change the Ollama server this machine uses…" from all three menus: it re-asked
|
|
7273
7322
|
// 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
|
-
}
|
|
7323
|
+
// GH #397: ONE model used to get its own screen ("Only one coding model is available: X ·
|
|
7324
|
+
// keep it / add or change a provider"). Set up from /agent-model first, that was the agent's
|
|
7325
|
+
// model — and the other 12 coders on the same provider were three menus away. The full menu
|
|
7326
|
+
// below already has "pick another model on <host>…", keys and add, so one model uses it too.
|
|
7284
7327
|
const active = sessionCodingModel || def;
|
|
7285
7328
|
// Arrow-key picker (↑/↓ + Enter) — pre-highlight the active model, tag the default, and
|
|
7286
7329
|
// name the backend when more than one is in play (the same model name can appear twice).
|
|
@@ -7289,6 +7332,9 @@ async function chooseModel() {
|
|
|
7289
7332
|
// OpenAI-compatible providers are both "OpenAI-compatible", so the kind names neither.
|
|
7290
7333
|
const { labels, tags, showTag } = codingChoiceLabels(choices, def);
|
|
7291
7334
|
const activeIdx = choices.findIndex((c) => c.id === active);
|
|
7335
|
+
// GH #397: "(default)" marked the account default while this folder used another model, and
|
|
7336
|
+
// nothing said which one runs here. The one in use is marked.
|
|
7337
|
+
if (activeIdx >= 0 && choices[activeIdx].id !== def) labels[activeIdx] += accent(" (in use here)");
|
|
7292
7338
|
// THE CURRENT CODER'S KEYS, ON THIS LIST (#248) — the manager ctrl+k opens, for a provider that
|
|
7293
7339
|
// takes a key (OpenAI-compatible, ollama.com). Before the add row, so no model row moves.
|
|
7294
7340
|
const activeChoice = choices[activeIdx >= 0 ? activeIdx : choices.findIndex((c) => c.id === def)];
|
|
@@ -9357,8 +9403,9 @@ async function askServerKey({ host = "", optional = false, escBack = false, name
|
|
|
9357
9403
|
// A RETRY KEEPS THE NAME (GH #397): only the secret was wrong, so only the secret is asked.
|
|
9358
9404
|
if (knownName === null) console.log("\n" + GUTTER + dim(escBack ? "Esc goes back to the providers." : "Esc to cancel."));
|
|
9359
9405
|
const name = knownName === null ? (await askKeyName()).trim() : String(knownName);
|
|
9360
|
-
|
|
9361
|
-
|
|
9406
|
+
const secret = (
|
|
9407
|
+
await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `, { hint: [SECRET_KEY_HINT] })
|
|
9408
|
+
).trim();
|
|
9362
9409
|
if (!secret) {
|
|
9363
9410
|
console.log(GUTTER + dim(optional ? "(no key — the server will be called anonymously)" : "(cancelled — nothing was saved)\n"));
|
|
9364
9411
|
return { name, secret: "" };
|
|
@@ -9446,19 +9493,38 @@ async function chooseAgentModel() {
|
|
|
9446
9493
|
const source = am.agentListSource(settings);
|
|
9447
9494
|
const needsKey = am.agentSourceNeedsKey(source);
|
|
9448
9495
|
for (;;) {
|
|
9449
|
-
|
|
9496
|
+
// The first row is the default and the way back to ONE model: the coding model talks too (GH #397).
|
|
9497
|
+
const labels = [
|
|
9498
|
+
`same as the coding model${dim(" — follows /model")}` + (current ? "" : accent(" (current)")),
|
|
9499
|
+
...saved.map((c) => c.label + (c.current ? accent(" (current)") : "")),
|
|
9500
|
+
];
|
|
9450
9501
|
const MODELS = curHost ? labels.length : -1;
|
|
9451
9502
|
if (curHost) labels.push(actionRow(`pick another model on ${curHost}…`));
|
|
9503
|
+
// NO SEPARATE AGENT MODEL YET: offer the coder's own server, so a talking model from the provider
|
|
9504
|
+
// already set up is one pick away — its saved key is reused (GH #397).
|
|
9505
|
+
const coderUrl = (await import("./coding-provider-setup.mjs")).ensureV1Root(String(sessionCodingUrl || defaultCodingUrl || "")).toLowerCase();
|
|
9506
|
+
const coderRow = curHost ? null : (saved.find((c) => c.url.toLowerCase() === coderUrl) ?? null);
|
|
9507
|
+
const CODER_MODELS = coderRow ? labels.length : -1;
|
|
9508
|
+
if (coderRow) labels.push(actionRow(`pick another model on ${coderRow.host}…`));
|
|
9452
9509
|
const KEYS = curHost && needsKey ? labels.length : -1;
|
|
9453
9510
|
if (KEYS >= 0) labels.push(actionRow(`API keys for ${curHost} — switch, add or remove…`));
|
|
9454
9511
|
const ADD = labels.length;
|
|
9455
9512
|
labels.push(actionRow("add a provider — Claude API, OpenAI, Kimi, Ollama…"));
|
|
9456
9513
|
// The hint drops to its own row when the header would wrap (80 columns over SSH).
|
|
9457
|
-
|
|
9514
|
+
// GH #397: "(now unset)" hid that the coding model answers in the meantime; and printed
|
|
9515
|
+
// before the list, this title stacked each time "← back" re-opened the menu.
|
|
9516
|
+
const now = current
|
|
9517
|
+
? `now ${current}${curHost ? ` on ${curHost}` : ""}`
|
|
9518
|
+
: "same as the coding model";
|
|
9519
|
+
const head = ` Agent model — the one that TALKS (${now})`;
|
|
9458
9520
|
const hint = `(${PICK_ARROWS}):`;
|
|
9459
9521
|
const cols = Math.max(20, process.stdout.columns || 80);
|
|
9460
|
-
|
|
9461
|
-
const idx = await pickFromList(labels,
|
|
9522
|
+
const headLines = head.length + 2 + hint.length < cols ? [`${head} ${hint}`] : [head, ` ${hint}`];
|
|
9523
|
+
const idx = await pickFromList(labels, saved.findIndex((c) => c.current) + 1, {
|
|
9524
|
+
footer: ESC_FOOTER,
|
|
9525
|
+
header: () => headLines.map((l) => dim(l)),
|
|
9526
|
+
headerRows: headLines.length,
|
|
9527
|
+
});
|
|
9462
9528
|
if (idx < 0) {
|
|
9463
9529
|
console.log(dim(" (cancelled)\n"));
|
|
9464
9530
|
return;
|
|
@@ -9468,6 +9534,11 @@ async function chooseAgentModel() {
|
|
|
9468
9534
|
if ((await agentModelOnServer({ back: true, start: "models" })) === BACK) continue;
|
|
9469
9535
|
return;
|
|
9470
9536
|
}
|
|
9537
|
+
if (idx === CODER_MODELS) {
|
|
9538
|
+
const dialect = coderRow.kind === "ollama" ? "ollama" : "openai";
|
|
9539
|
+
if ((await addServer({ role: "agent", dialect, url: coderRow.url, back: true })) === BACK) continue;
|
|
9540
|
+
return;
|
|
9541
|
+
}
|
|
9471
9542
|
if (idx === KEYS) {
|
|
9472
9543
|
const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
|
|
9473
9544
|
if (!rowId) return enterAgentProviderKey(source.row, curHost);
|
|
@@ -9478,7 +9549,12 @@ async function chooseAgentModel() {
|
|
|
9478
9549
|
if ((await addServer({ role: "agent", dialect: "openai", back: true })) === BACK) continue;
|
|
9479
9550
|
return;
|
|
9480
9551
|
}
|
|
9481
|
-
|
|
9552
|
+
if (idx === 0) {
|
|
9553
|
+
if (!current) console.log(dim(" already the same as the coding model — nothing changed.\n"));
|
|
9554
|
+
else await useCoderForAgent();
|
|
9555
|
+
return;
|
|
9556
|
+
}
|
|
9557
|
+
await useSavedAgentChoice(saved[idx - 1]);
|
|
9482
9558
|
return;
|
|
9483
9559
|
}
|
|
9484
9560
|
}
|
|
@@ -9488,6 +9564,31 @@ async function chooseAgentModel() {
|
|
|
9488
9564
|
* API finds the key by the URL (resolveKeyForRole), so nothing is re-entered — unless the server
|
|
9489
9565
|
* needs a key and has none, which is asked here rather than failing on the next question.
|
|
9490
9566
|
*/
|
|
9567
|
+
/**
|
|
9568
|
+
* "Same as the coding model" (GH #397): clear the separate agent model, so the coding model talks
|
|
9569
|
+
* too — and keeps following each folder's /model pick. The way BACK to one model: before this the
|
|
9570
|
+
* only route was removing the whole provider (and its key).
|
|
9571
|
+
*/
|
|
9572
|
+
async function useCoderForAgent() {
|
|
9573
|
+
const md = await import("./model-doctor.mjs");
|
|
9574
|
+
const saved = await withPendingStatus("saving", () =>
|
|
9575
|
+
md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: "", agentModelName: "", agentModel: "" })
|
|
9576
|
+
);
|
|
9577
|
+
if (!saved) {
|
|
9578
|
+
console.log(red(" couldn't save that — nothing changed.\n"));
|
|
9579
|
+
return;
|
|
9580
|
+
}
|
|
9581
|
+
if (sessionAgentKeyId) await saveFolderKeyPick("agent", "");
|
|
9582
|
+
console.log(accent(" ✓ agent model → same as the coding model") + dim(" · /model switches both"));
|
|
9583
|
+
console.log(GUTTER + dim("takes effect on your next question.\n"));
|
|
9584
|
+
const after = (await fetchAgentPayload([{ role: "user", content: "." }]).catch(() => null))?.payload;
|
|
9585
|
+
if (after) {
|
|
9586
|
+
agentMissing = after.agentMissing === true;
|
|
9587
|
+
noModelAtAll = nothingToAnswerWith(after);
|
|
9588
|
+
}
|
|
9589
|
+
if (barPinned()) paintBar();
|
|
9590
|
+
}
|
|
9591
|
+
|
|
9491
9592
|
async function useSavedAgentChoice(c) {
|
|
9492
9593
|
if (c.current) {
|
|
9493
9594
|
console.log(dim(` already using ${c.model} on ${c.host} — nothing changed.\n`));
|
|
@@ -9725,39 +9826,6 @@ async function agentModelOnServer({ back = false, start = "menu" } = {}) {
|
|
|
9725
9826
|
console.log(dim(" takes effect on your next question.\n"));
|
|
9726
9827
|
}
|
|
9727
9828
|
|
|
9728
|
-
// Probe whether SSH KEY auth already works for a server (so we can tell the user to run
|
|
9729
|
-
// ssh-copy-id if not). BatchMode=yes = never prompt for a password → exit!=0 if no key.
|
|
9730
|
-
async function checkServerKeyAuth(s) {
|
|
9731
|
-
try {
|
|
9732
|
-
const ok = await new Promise((resolve) => {
|
|
9733
|
-
const p = spawn(
|
|
9734
|
-
"ssh",
|
|
9735
|
-
[
|
|
9736
|
-
"-o", "BatchMode=yes",
|
|
9737
|
-
"-o", "ConnectTimeout=6",
|
|
9738
|
-
"-o", "StrictHostKeyChecking=accept-new",
|
|
9739
|
-
`${s.user}@${s.host}`,
|
|
9740
|
-
"true",
|
|
9741
|
-
],
|
|
9742
|
-
{ stdio: "ignore" }
|
|
9743
|
-
);
|
|
9744
|
-
p.on("exit", (code) => resolve(code === 0));
|
|
9745
|
-
p.on("error", () => resolve(false));
|
|
9746
|
-
});
|
|
9747
|
-
if (ok) console.log(dim(" ✓ key auth works — deploys will connect without a password."));
|
|
9748
|
-
else
|
|
9749
|
-
console.log(
|
|
9750
|
-
yellow(" key auth not set up yet.") +
|
|
9751
|
-
dim(` Run once in your terminal: `) +
|
|
9752
|
-
`ssh-copy-id ${s.user}@${s.host}`
|
|
9753
|
-
);
|
|
9754
|
-
} catch {
|
|
9755
|
-
/* best-effort check — never block the picker */
|
|
9756
|
-
}
|
|
9757
|
-
}
|
|
9758
|
-
|
|
9759
|
-
// /server — pick a deployment target (from the web app's Servers registry) for this
|
|
9760
|
-
// session. Host/user only, never a secret; the agent deploys here with KEY auth (#69).
|
|
9761
9829
|
/**
|
|
9762
9830
|
* `/proxy` — configure the outbound proxy for MODEL calls (task #160).
|
|
9763
9831
|
*
|
|
@@ -10146,53 +10214,6 @@ async function runProxyCommand(line) {
|
|
|
10146
10214
|
}
|
|
10147
10215
|
}
|
|
10148
10216
|
|
|
10149
|
-
async function chooseServer() {
|
|
10150
|
-
let servers;
|
|
10151
|
-
try {
|
|
10152
|
-
const res = await withPendingStatus("loading servers", () =>
|
|
10153
|
-
fetch(`${apiBase}/api/worker/deploy-servers`, {
|
|
10154
|
-
headers: { "X-Worker-Key": workerKey },
|
|
10155
|
-
signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
|
|
10156
|
-
})
|
|
10157
|
-
);
|
|
10158
|
-
const data = await res.json().catch(() => ({}));
|
|
10159
|
-
if (!res.ok) {
|
|
10160
|
-
console.log(red(` couldn't load servers: ${data?.error || `HTTP ${res.status}`}\n`));
|
|
10161
|
-
return;
|
|
10162
|
-
}
|
|
10163
|
-
servers = Array.isArray(data?.servers) ? data.servers : [];
|
|
10164
|
-
} catch (err) {
|
|
10165
|
-
console.log(red(` ${describeApiFailure(err, "couldn't load servers")}\n`));
|
|
10166
|
-
return;
|
|
10167
|
-
}
|
|
10168
|
-
if (servers.length === 0) {
|
|
10169
|
-
console.log(dim(" No deployment servers yet — add them in the web app → Servers.\n"));
|
|
10170
|
-
return;
|
|
10171
|
-
}
|
|
10172
|
-
// Arrow-key picker: ↑/↓ to move, Enter to choose (no number typing). The list ends with
|
|
10173
|
-
// a "none" row to clear the selection. Pre-highlight the current pick if there is one.
|
|
10174
|
-
const labels = servers.map((s) => `${s.name} (${s.user}@${s.host})`);
|
|
10175
|
-
labels.push("none (clear selection)");
|
|
10176
|
-
const activeIdx = servers.findIndex((s) => s.host === selectedServer?.host);
|
|
10177
|
-
console.log(dim(` Choose a deployment server (${PICK_HINT}):`));
|
|
10178
|
-
const idx = await pickFromList(labels, activeIdx >= 0 ? activeIdx : 0);
|
|
10179
|
-
if (idx < 0) {
|
|
10180
|
-
console.log(dim(" (cancelled)\n"));
|
|
10181
|
-
return;
|
|
10182
|
-
}
|
|
10183
|
-
if (idx === servers.length) {
|
|
10184
|
-
// the trailing "none" row
|
|
10185
|
-
selectedServer = null;
|
|
10186
|
-
console.log(dim(" ✓ deployment server cleared.\n"));
|
|
10187
|
-
return;
|
|
10188
|
-
}
|
|
10189
|
-
const s = servers[idx];
|
|
10190
|
-
selectedServer = { name: s.name, host: s.host, user: s.user };
|
|
10191
|
-
console.log(accent(` ✓ deploy target → ${s.name} (${s.user}@${s.host})`));
|
|
10192
|
-
await checkServerKeyAuth(selectedServer);
|
|
10193
|
-
console.log("");
|
|
10194
|
-
}
|
|
10195
|
-
|
|
10196
10217
|
/**
|
|
10197
10218
|
* Ask for the coding model's API key if the resolved coder needs one and none is stored.
|
|
10198
10219
|
*
|
|
@@ -10921,8 +10942,7 @@ async function replaceCodingKey({ back = false } = {}) {
|
|
|
10921
10942
|
(ks.stored ? dim("(replacing ") + accent(maskKey(ks.stored)) + dim(")") : dim("(none stored)")) +
|
|
10922
10943
|
dim(" · Enter alone cancels, \"clear\" removes it\n")
|
|
10923
10944
|
);
|
|
10924
|
-
|
|
10925
|
-
const typed = (await askSecret(` ${ks.model} API key: `)).trim();
|
|
10945
|
+
const typed = (await askSecret(` ${ks.model} API key: `, { hint: [SECRET_KEY_HINT] })).trim();
|
|
10926
10946
|
if (!typed) {
|
|
10927
10947
|
console.log(GUTTER + dim("(cancelled — the stored key is unchanged)\n"));
|
|
10928
10948
|
paintBar();
|
|
@@ -10976,7 +10996,9 @@ async function manageServerKeys({ role = "coding", backendId, host, model = "",
|
|
|
10976
10996
|
});
|
|
10977
10997
|
const ADD = rows.length;
|
|
10978
10998
|
rows.push(dim("add a new key…"));
|
|
10979
|
-
|
|
10999
|
+
// The agent model is a MACHINE setting (GH #397): "proj's agent model" named a folder it is not tied to.
|
|
11000
|
+
const whose = role === "agent" ? `the ${roleLabel}` : `${folderLabel()}'s ${roleLabel}`;
|
|
11001
|
+
console.log("\n" + GUTTER + dim(`Keys for ${host} — ${whose}${model ? ` (${model})` : ""}\n`));
|
|
10980
11002
|
console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
|
|
10981
11003
|
const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true, back });
|
|
10982
11004
|
const idx = picked?.index ?? -1;
|
|
@@ -11904,6 +11926,28 @@ function pickFromList(items, initialSel = 0, opts = {}) {
|
|
|
11904
11926
|
listTimeoutIndex = -1;
|
|
11905
11927
|
listHeader = typeof opts.header === "function" ? opts.header : null;
|
|
11906
11928
|
listHeaderRows = listHeader ? Math.max(0, opts.headerRows | 0) : 0;
|
|
11929
|
+
// A STATIC HEADER WIDER THAN THE TERMINAL IS WRAPPED AT WORDS (GH #397), not clipped: on 80
|
|
11930
|
+
// columns "Which model on token-plan.maas.qwencloudapi.com? (↑/↓ then Enter · Esc to c" lost
|
|
11931
|
+
// its way out. Done once, here, so the block keeps the constant height its redraw walks
|
|
11932
|
+
// depend on. A live panel (refreshMs) is already sized by its caller and is left alone.
|
|
11933
|
+
if (listHeader && !(opts.refreshMs > 0)) {
|
|
11934
|
+
const w = Math.max(20, process.stdout.columns || 80) - 1;
|
|
11935
|
+
let given = [];
|
|
11936
|
+
try { given = (listHeader(listHeaderRows) || []).slice(0, listHeaderRows); } catch { /* blank */ }
|
|
11937
|
+
if (given.some((l) => visibleLen(l) > w)) {
|
|
11938
|
+
const wrapped = given.flatMap((l) => {
|
|
11939
|
+
if (visibleLen(l) <= w) return [l];
|
|
11940
|
+
const plain = stripAnsiEscapes(l);
|
|
11941
|
+
const lead = /^\s*/.exec(plain)[0];
|
|
11942
|
+
// The key hint " (↑/↓ then Enter · Esc …)" moves to its own line whole, before any word wrap.
|
|
11943
|
+
const at = plain.indexOf(" (", lead.length);
|
|
11944
|
+
const parts = at > 0 ? [plain.slice(lead.length, at), plain.slice(at + 2)] : [plain.slice(lead.length)];
|
|
11945
|
+
return parts.flatMap((p) => wrapIndented(p, { width: w, indent: lead, hang: "" })).map((r) => dim(r));
|
|
11946
|
+
});
|
|
11947
|
+
listHeader = () => wrapped;
|
|
11948
|
+
listHeaderRows = wrapped.length;
|
|
11949
|
+
}
|
|
11950
|
+
}
|
|
11907
11951
|
if (listHeader && opts.refreshMs > 0) {
|
|
11908
11952
|
// A live panel (#376): redrawn in place until the picker closes.
|
|
11909
11953
|
const live = setInterval(() => {
|
|
@@ -12382,6 +12426,7 @@ async function localScaffoldState() {
|
|
|
12382
12426
|
/** Print the startup scaffold line — deferred while a turn owns the transcript. */
|
|
12383
12427
|
function announceScaffold() {
|
|
12384
12428
|
if (!scaffoldNotice || following) return;
|
|
12429
|
+
if (listPickerActive || askWaiting || !inputVisible) return; // as announceCliUpdate (GH #397)
|
|
12385
12430
|
const { kind, text } = scaffoldNotice;
|
|
12386
12431
|
scaffoldNotice = null;
|
|
12387
12432
|
if (kind === "link") {
|
|
@@ -14434,7 +14479,11 @@ async function main() {
|
|
|
14434
14479
|
await import("./agent-model-label.mjs");
|
|
14435
14480
|
// Skip the `ps` sweep entirely when settings already name the model — the overwhelmingly
|
|
14436
14481
|
// common case, and startup is not the place to spend a subprocess proving it.
|
|
14437
|
-
|
|
14482
|
+
// NO AGENT MODEL SET: the payload's agentModelId is only the account-default coder, which may
|
|
14483
|
+
// not be this folder's pick — naming it here said "kimi" over a folder running qwen (GH #397).
|
|
14484
|
+
const agentLabel = p.agentMissing === true
|
|
14485
|
+
? { name: "", known: false, reason: "not-set" }
|
|
14486
|
+
: namesAModel(p.agentModelId)
|
|
14438
14487
|
? { name: String(p.agentModelId).trim(), known: true, reason: "settings" }
|
|
14439
14488
|
: agentModelLabel({
|
|
14440
14489
|
configuredId: p.agentModelId,
|
|
@@ -14695,7 +14744,7 @@ async function main() {
|
|
|
14695
14744
|
dim(" Skipped — this machine won't SERVE models; the agent still runs here and\n") +
|
|
14696
14745
|
dim(" calls your model machine over the network. Point the Agent model URL at\n") +
|
|
14697
14746
|
dim(" that machine (not 127.0.0.1) — `gu status` shows what it resolves to.\n") +
|
|
14698
|
-
dim(" `gu doctor` sets models up here ·
|
|
14747
|
+
dim(" `gu doctor` sets models up here · `gu clear` starts setup over.\n")
|
|
14699
14748
|
);
|
|
14700
14749
|
void st;
|
|
14701
14750
|
}
|
|
@@ -15349,11 +15398,6 @@ async function main() {
|
|
|
15349
15398
|
await syncEmbedWarning({ repair: false });
|
|
15350
15399
|
continue;
|
|
15351
15400
|
}
|
|
15352
|
-
if (line === "/server") {
|
|
15353
|
-
await chooseServer();
|
|
15354
|
-
await saveSession(cwd, history); // remember the pick for this folder
|
|
15355
|
-
continue;
|
|
15356
|
-
}
|
|
15357
15401
|
if (line === "/full-log" || line.startsWith("/full-log ")) {
|
|
15358
15402
|
await runFullLogCommand(line);
|
|
15359
15403
|
continue;
|
|
@@ -15437,18 +15481,6 @@ async function main() {
|
|
|
15437
15481
|
);
|
|
15438
15482
|
continue;
|
|
15439
15483
|
}
|
|
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
15484
|
if (line === "/rag" || line === "/rag-cloud" || line === "/rag-local") {
|
|
15453
15485
|
// ONE QUESTION, THREE ANSWERS (task #180). This was two commands that toggled one flag,
|
|
15454
15486
|
// 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.899",
|
|
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
|
*
|