@tiens.nguyen/gu-cli 1.0.686

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.
Files changed (43) hide show
  1. package/README.md +52 -0
  2. package/agent-model-command.mjs +259 -0
  3. package/agent-model-label.mjs +159 -0
  4. package/clear-state.mjs +149 -0
  5. package/client-expert-api.mjs +736 -0
  6. package/client-expert-run.mjs +892 -0
  7. package/client-expert-setup.mjs +616 -0
  8. package/coding-choice-tags.mjs +69 -0
  9. package/coding-key-prompt.mjs +229 -0
  10. package/coding-provider-setup.mjs +808 -0
  11. package/completed-flush.mjs +105 -0
  12. package/daemon-control.mjs +462 -0
  13. package/device-login.mjs +212 -0
  14. package/doctor-check.mjs +239 -0
  15. package/embed-model-command.mjs +157 -0
  16. package/first-run-steps.mjs +171 -0
  17. package/gonext_agent_chat.py +12299 -0
  18. package/gonext_mlx_embed.py +155 -0
  19. package/gonext_probe_agent.py +93 -0
  20. package/gonext_transcribe.py +130 -0
  21. package/gu-cli.mjs +4930 -0
  22. package/gu-repl.mjs +10326 -0
  23. package/job-pools.mjs +89 -0
  24. package/model-doctor.mjs +1494 -0
  25. package/node-version.mjs +40 -0
  26. package/ollama-setup.mjs +832 -0
  27. package/package.json +100 -0
  28. package/platform-tools.mjs +520 -0
  29. package/poll-errors.mjs +141 -0
  30. package/proxy-command.mjs +165 -0
  31. package/proxy-config.mjs +255 -0
  32. package/proxy-dispatcher.mjs +132 -0
  33. package/proxy-selftest.mjs +234 -0
  34. package/proxy-store.mjs +69 -0
  35. package/rag-job-config.mjs +59 -0
  36. package/rag-selftest.mjs +215 -0
  37. package/s3-setup.mjs +85 -0
  38. package/terminal-copy.mjs +248 -0
  39. package/terminal-hover.mjs +153 -0
  40. package/terminal-layout.mjs +2507 -0
  41. package/terminal-viewport.mjs +602 -0
  42. package/thinking_words.txt +1003 -0
  43. package/version-check.mjs +72 -0
@@ -0,0 +1,616 @@
1
+ /**
2
+ * First-run setup for CLIENT EXPERT mode — the whole stack on the user's machine (task #154).
3
+ *
4
+ * WHAT THIS FILE IS. The ORDER of setup and the decision "what happens next", separated from
5
+ * the doing of it. Installing MongoDB, downloading a release and starting a server are all
6
+ * slow, destructive and impossible to unit-test; deciding which of them is next is neither.
7
+ * Everything here is pure: it takes what we RECORDED last time and what we can SEE right now,
8
+ * and returns the next step. The installer supplies the probes and performs the actions.
9
+ *
10
+ * THE RULE THAT SHAPES IT: a remembered "done" is never trusted on its own.
11
+ *
12
+ * Setup spans several runs of `gu` — the user quits, reboots, comes back tomorrow. Between
13
+ * those runs mongod gets stopped, a port gets taken, a folder gets deleted. A resume that
14
+ * believed its own notes would sail past a step whose result no longer exists and fail later,
15
+ * somewhere unrelated, with a message about something else. So each step carries a cheap
16
+ * VERIFICATION, and a step counts as done only when the note AND the world agree. That is why
17
+ * `planSetup` takes both `recorded` and `probe`, and why the two are never merged.
18
+ */
19
+
20
+ /**
21
+ * In order. Each step depends on every step before it, which is what makes "the first one not
22
+ * satisfied" the right answer rather than "the first one not recorded".
23
+ */
24
+ export const CLIENT_EXPERT_STEPS = [
25
+ {
26
+ id: "mode",
27
+ title: "Choose how this machine runs",
28
+ // The one question that cannot be probed — it is a decision, not a state.
29
+ satisfied: (p) => p.mode === "client-expert",
30
+ },
31
+ {
32
+ id: "mongo",
33
+ title: "MongoDB installed and running",
34
+ satisfied: (p) => p.mongoRunning === true,
35
+ },
36
+ {
37
+ id: "database",
38
+ title: "Database, collections, indexes and worker key created",
39
+ satisfied: (p) => p.dbReady === true,
40
+ },
41
+ {
42
+ id: "api-files",
43
+ title: "Local API downloaded",
44
+ satisfied: (p) => p.apiInstalled === true,
45
+ },
46
+ {
47
+ id: "api-running",
48
+ title: "Local API answering on its port",
49
+ // Healthy is not enough: an API too old for this terminal is a silent-failure machine
50
+ // (fields it does not store simply come back missing), so the contract is part of "up".
51
+ satisfied: (p) => p.apiHealthy === true && p.contractOk === true,
52
+ },
53
+ {
54
+ id: "worker-env",
55
+ title: "Terminal pointed at the local API",
56
+ satisfied: (p) => p.workerEnvPointsLocal === true,
57
+ },
58
+ {
59
+ id: "models",
60
+ title: "Coding model and RAG settings",
61
+ // From here Client Expert is exactly Client mode — same questions, same answers, only the
62
+ // API and database happen to be local. Nothing below this line should be mode-aware.
63
+ satisfied: (p) => p.modelsConfigured === true,
64
+ },
65
+ ];
66
+
67
+ export const STEP_IDS = CLIENT_EXPERT_STEPS.map((s) => s.id);
68
+
69
+ /** Platforms the walkthrough can actually install MongoDB on (task #154 scope). */
70
+ export const SUPPORTED_PLATFORMS = ["darwin", "linux", "win32"];
71
+
72
+ /**
73
+ * May this machine enter Client Expert at all?
74
+ *
75
+ * Only a machine that has never been paired. `worker.env` holds the hosted API base and the
76
+ * worker key, so its presence means conversations, token history and workspaces already exist
77
+ * on the hosted side under an identity that does not exist locally. Switching such a machine
78
+ * would strand that data or demand a migration, and a half-migrated user cannot tell which
79
+ * store an answer came from. A PARTIAL file counts as existing — that is a paired machine in
80
+ * a bad state, not a fresh one, and overwriting it would destroy the link it still has.
81
+ */
82
+ /**
83
+ * Can MongoDB actually run on this hardware? (Found when the first Pi test was attempted.)
84
+ *
85
+ * "linux" is not specific enough, and both ways it fails do so AFTER the user has committed:
86
+ * · MongoDB ships NO 32-bit ARM packages — on armv7l `apt install mongodb-org` finds nothing;
87
+ * · MongoDB 5.0+ needs ARMv8.2-A with LSE atomics. A Raspberry Pi 4 is ARMv8.0, so mongod
88
+ * installs happily and then dies with "Illegal instruction". 4.4 was the last that ran on
89
+ * it; a Pi 5 (ARMv8.2) is fine.
90
+ * Refusing up front with the reason beats walking someone through an install that cannot work.
91
+ */
92
+ export function mongoSupported({ platform, arch, cpuModel = "" }) {
93
+ if (platform === "darwin") return { supported: true, reason: null, message: "" };
94
+ // MongoDB ships a native Windows server; there is no ARM story to worry about because the
95
+ // Windows build is x64 only, and an ARM Windows box runs it under emulation.
96
+ if (platform === "win32") return { supported: true, reason: null, message: "" };
97
+ if (platform !== "linux") {
98
+ return { supported: false, reason: "platform", message: `MongoDB setup is not scripted for ${platform}.` };
99
+ }
100
+ if (arch === "arm" || /armv[67]l?/i.test(String(arch))) {
101
+ return {
102
+ supported: false,
103
+ reason: "arch32",
104
+ message:
105
+ "This is a 32-bit ARM system, and MongoDB publishes no 32-bit ARM packages — there is " +
106
+ "nothing to install. A 64-bit OS (aarch64) is required.",
107
+ };
108
+ }
109
+ // Pi 4 and earlier are ARMv8.0; MongoDB 5.0+ needs 8.2's LSE atomics and dies on them.
110
+ if (arch === "arm64" || arch === "aarch64") {
111
+ if (/raspberry pi [1-4]\b/i.test(cpuModel)) {
112
+ return {
113
+ supported: false,
114
+ reason: "armv8.0",
115
+ message:
116
+ `${cpuModel.trim() || "This Pi"} is ARMv8.0, and MongoDB 5.0+ requires ARMv8.2 — ` +
117
+ "mongod would install and then exit with 'Illegal instruction'. MongoDB 4.4 was the " +
118
+ "last release that ran on it; a Pi 5 or an x86_64 machine works.",
119
+ };
120
+ }
121
+ }
122
+ return { supported: true, reason: null, message: "" };
123
+ }
124
+
125
+ /**
126
+ * Is there a Client Expert setup this machine started and has not finished?
127
+ *
128
+ * The whole point is to tell "this user is signed in to the hosted API" apart from "this user
129
+ * chose Client Expert, is partway through, and gu signed them in meanwhile so the machine
130
+ * would be usable". Those produce an identical worker.env, so the plan on disk is the ONLY
131
+ * thing that distinguishes them.
132
+ *
133
+ * Reversible by design: answering the mode question with anything else overwrites chosenMode,
134
+ * so a user who gives up lands in an ordinary paired state instead of being asked forever.
135
+ */
136
+ export function expertPlanPending(state) {
137
+ if (!state || typeof state !== "object") return false;
138
+ if (state.chosenMode !== "client-expert") return false;
139
+ // Finished plans are not pending — a completed machine IS Client Expert, not mid-setup.
140
+ return !STEP_IDS.every((id) => state[id] === "done");
141
+ }
142
+
143
+ export function clientExpertAvailable({
144
+ workerEnvExists,
145
+ expertPending = false,
146
+ platform,
147
+ arch,
148
+ cpuModel,
149
+ }) {
150
+ // A pairing gu performed ITSELF, as the stopgap the walkthrough promised, must not be
151
+ // read as "the user already chose the hosted API". Client Expert stops at MongoDB and says
152
+ // "until then this machine works in CLIENT mode" — which means signing in, which writes
153
+ // worker.env. Without this clause the very next question told the user Client Expert was
154
+ // unavailable because the machine was paired, seconds after they picked it and while their
155
+ // half-finished plan sat on disk. Reported live on Windows 11.
156
+ if (workerEnvExists && !expertPending) {
157
+ return {
158
+ available: false,
159
+ reason: "paired",
160
+ message:
161
+ "This machine is already signed in to the hosted API (~/.gonext/worker.env). " +
162
+ "Local mode sets up its own database and identity, so it only runs on a machine " +
163
+ "that has never been paired. To switch, remove ~/.gonext/worker.env first — you will " +
164
+ "lose the link to your hosted history.",
165
+ };
166
+ }
167
+ if (!SUPPORTED_PLATFORMS.includes(platform)) {
168
+ return {
169
+ available: false,
170
+ reason: "platform",
171
+ message:
172
+ `Local mode supports macOS, Ubuntu and Windows for now; this is ${platform}. ` +
173
+ "Cloud mode works here and keeps the API and database hosted.",
174
+ };
175
+ }
176
+ // The OS is not the whole question — the CPU has to be able to run the database.
177
+ if (arch !== undefined) {
178
+ const mongo = mongoSupported({ platform, arch, cpuModel });
179
+ if (!mongo.supported) {
180
+ return {
181
+ available: false,
182
+ reason: mongo.reason,
183
+ message: `${mongo.message} Cloud mode works here and keeps the database hosted.`,
184
+ };
185
+ }
186
+ }
187
+ return { available: true, reason: null, message: "" };
188
+ }
189
+
190
+ /**
191
+ * The next thing to do, and why.
192
+ *
193
+ * `recorded` — what a previous run wrote down (the notes).
194
+ * `probe` — what is true right now (the world).
195
+ *
196
+ * A step is complete only when BOTH agree. When they disagree the step reopens, and the plan
197
+ * says so: "recorded, but no longer true" is a different situation from "never done", and a
198
+ * user who is told which one it is can act on it.
199
+ */
200
+ export function planSetup({ recorded = {}, probe = {} } = {}) {
201
+ const steps = CLIENT_EXPERT_STEPS.map((step) => {
202
+ const noted = recorded[step.id] === "done";
203
+ const real = step.satisfied(probe) === true;
204
+ return {
205
+ id: step.id,
206
+ title: step.title,
207
+ state: real ? "done" : noted ? "reopened" : "pending",
208
+ };
209
+ });
210
+ const next = steps.find((s) => s.state !== "done") ?? null;
211
+ // "You stopped during this" reads very differently from "this failed" or "this is new", and
212
+ // a resume that cannot tell them apart greets everyone with the same blank prompt.
213
+ const nextWas = next ? recorded[next.id] ?? null : null;
214
+ return {
215
+ steps,
216
+ next: next ? next.id : null,
217
+ nextWas,
218
+ resuming: nextWas === "interrupted",
219
+ complete: next === null,
220
+ // Surfaced so the caller can say "MongoDB was set up before but is not running now" rather
221
+ // than starting the install walkthrough again from scratch, which reads as no progress.
222
+ regressed: steps.filter((s) => s.state === "reopened").map((s) => s.id),
223
+ done: steps.filter((s) => s.state === "done").map((s) => s.id),
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Record a step's outcome. Kept tiny and separate from the file it lives in, so the shape of
229
+ * the notes is one thing and their storage another.
230
+ *
231
+ * A FAILURE CARRIES ITS REASON AND ITS COUNT. "failed" alone sends the user round the same
232
+ * loop with the same result and no idea why; setup that cannot say "this failed three times,
233
+ * because the port is in use" is setup that traps people.
234
+ */
235
+ export function recordStep(recorded, id, outcome, detail = {}) {
236
+ if (!STEP_IDS.includes(id)) throw new Error(`unknown setup step: ${id}`);
237
+ const prior = recorded[`${id}:attempts`] ?? 0;
238
+ const next = {
239
+ ...recorded,
240
+ [id]: outcome,
241
+ updatedAt: new Date().toISOString(),
242
+ };
243
+ if (outcome === "failed") {
244
+ next[`${id}:attempts`] = prior + 1;
245
+ if (detail.reason) next[`${id}:reason`] = detail.reason;
246
+ } else if (outcome === "done") {
247
+ // ONLY success clears the history — otherwise a step that failed twice and then worked
248
+ // would keep offering to give up.
249
+ delete next[`${id}:attempts`];
250
+ delete next[`${id}:reason`];
251
+ } else {
252
+ // Everything else — "interrupted" (Ctrl+C), "declined" (the user said no) — keeps its
253
+ // reason and does NOT count as an attempt. Neither is evidence that the step cannot work:
254
+ // one means they went to lunch, the other that they chose not to. Counting either would
255
+ // offer to abandon Client Expert to someone who simply answered a question.
256
+ //
257
+ // This branch used to test for "interrupted" specifically, so "declined" fell into the
258
+ // success path and silently dropped the reason — the next run could not say why it had
259
+ // stopped, which is the one thing that makes a resume feel like a continuation.
260
+ if (detail.reason) next[`${id}:reason`] = detail.reason;
261
+ }
262
+ return next;
263
+ }
264
+
265
+ /** How many times this step has failed since it last succeeded. */
266
+ export function stepAttempts(recorded, id) {
267
+ return recorded?.[`${id}:attempts`] ?? 0;
268
+ }
269
+
270
+ /** Why it failed last time, for showing above the retry. */
271
+ export function stepReason(recorded, id) {
272
+ return recorded?.[`${id}:reason`] ?? null;
273
+ }
274
+
275
+ /**
276
+ * After a few identical failures, stop pretending the next attempt is different.
277
+ *
278
+ * The alternative — retry forever — is how a user ends up running the same brew command five
279
+ * times. Offering the way out is not giving up on them; it is admitting that something here
280
+ * needs a human, and that they should not be stuck in Client Expert to find out.
281
+ */
282
+ export function shouldOfferGiveUp(recorded, id, max = 3) {
283
+ return stepAttempts(recorded, id) >= max;
284
+ }
285
+
286
+ /**
287
+ * Is the local API's wire contract compatible, and if not, WHICH SIDE is behind?
288
+ *
289
+ * The two skews need opposite advice, and telling someone to update the wrong one wastes an
290
+ * afternoon. In this mode the user owns both, so we cannot fix either for them — only say
291
+ * plainly which one to move.
292
+ */
293
+ export function describeContract({ apiContract, terminalNeeds }) {
294
+ if (!Number.isFinite(apiContract)) {
295
+ return {
296
+ ok: false,
297
+ skew: "unknown",
298
+ message:
299
+ "The local API did not report a contract version. It is probably older than this " +
300
+ "feature — update it with `gu client-expert update`.",
301
+ };
302
+ }
303
+ if (apiContract === terminalNeeds) return { ok: true, skew: null, message: "" };
304
+ if (apiContract < terminalNeeds) {
305
+ return {
306
+ ok: false,
307
+ skew: "api-old",
308
+ message:
309
+ `Your local API speaks contract ${apiContract}; this terminal needs ${terminalNeeds}. ` +
310
+ "Run `gu client-expert update` to download the matching API.",
311
+ };
312
+ }
313
+ return {
314
+ ok: false,
315
+ skew: "terminal-old",
316
+ message:
317
+ `Your local API speaks contract ${apiContract}; this terminal only understands ` +
318
+ `${terminalNeeds}. Update the terminal: \`npm i -g @tiens.nguyen/gu-cli\`.`,
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Steps that can leave a HALF-FINISHED artifact behind when interrupted, and what to clear.
324
+ *
325
+ * The danger of cancelling mid-step is not the lost work — it is the debris that LOOKS like
326
+ * finished work. A partial download is still a folder; a folder is what a naive "is it
327
+ * installed?" check sees. Anything listed here must be cleared before the retry, so the probe
328
+ * that follows is answering about a clean slate.
329
+ *
330
+ * Steps NOT listed are safely re-runnable as-is: `brew install` and `apt-get install` are
331
+ * idempotent, and creating collections/indexes that already exist is a no-op.
332
+ */
333
+ const CLEANUP_ON_INTERRUPT = {
334
+ "api-files": [
335
+ "Delete the partially downloaded API folder (a partial download still looks like an install)",
336
+ ],
337
+ "api-running": [
338
+ "Stop any API process left holding the port (it will refuse the next start as 'port in use')",
339
+ ],
340
+ };
341
+
342
+ /**
343
+ * How to pick a step back up after it was interrupted.
344
+ *
345
+ * Returns the action AND whether anything must be swept first, so the caller never has to know
346
+ * which steps are dirty-on-interrupt.
347
+ */
348
+ export function resumeAction(recorded = {}, id) {
349
+ if (!STEP_IDS.includes(id)) throw new Error(`unknown setup step: ${id}`);
350
+ const state = recorded[id];
351
+ const cleanup = state === "interrupted" ? CLEANUP_ON_INTERRUPT[id] ?? [] : [];
352
+ return {
353
+ action: cleanup.length ? "retry-after-cleanup" : "retry",
354
+ interrupted: state === "interrupted",
355
+ cleanup,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * What leaving Client Expert has to undo, given how far setup got.
361
+ *
362
+ * A mode that cannot be exited is worse than one that is never entered, and the exit has to be
363
+ * derived from PROGRESS: telling someone to stop a MongoDB they never installed is noise, and
364
+ * failing to mention a worker.env we wrote leaves the terminal pointing at an API that is no
365
+ * longer running. Nothing here is destructive by itself — it is the list a human confirms.
366
+ */
367
+ export function abandonPlan(recorded = {}) {
368
+ const done = (id) => recorded[id] === "done";
369
+ const actions = [];
370
+ if (done("worker-env")) {
371
+ actions.push({
372
+ id: "worker-env",
373
+ what: "Remove ~/.gonext/worker.env",
374
+ why: "it points this terminal at a local API that will no longer be running",
375
+ });
376
+ }
377
+ if (done("api-running")) {
378
+ actions.push({ id: "api-running", what: "Stop the local API", why: "it holds its port" });
379
+ }
380
+ if (done("api-files")) {
381
+ actions.push({
382
+ id: "api-files",
383
+ what: "Delete the downloaded API folder",
384
+ why: "nothing else uses it",
385
+ });
386
+ }
387
+ if (done("database")) {
388
+ actions.push({
389
+ id: "database",
390
+ what: "Drop the local database (OPTIONAL — this deletes your conversations)",
391
+ why: "it is the only copy; keep it if you may come back",
392
+ destructive: true,
393
+ });
394
+ }
395
+ if (done("mongo")) {
396
+ actions.push({
397
+ id: "mongo",
398
+ what: "Leave MongoDB installed (OPTIONAL to remove)",
399
+ why: "you may be using it for something else",
400
+ });
401
+ }
402
+ return actions;
403
+ }
404
+
405
+ /** The container this owns, and the volume its data lives in. */
406
+ export const MONGO_DOCKER_NAME = "gu-mongo";
407
+ export const MONGO_DOCKER_VOLUME = "gu-mongo-data";
408
+ export const MONGO_DOCKER_IMAGE = "mongo:8";
409
+
410
+ /**
411
+ * Run MongoDB in Docker instead of installing it. → { commands, summary }
412
+ *
413
+ * WHY THIS IS OFFERED FIRST WHERE DOCKER EXISTS. The alternative on macOS is
414
+ * `brew tap mongodb/brew` → `brew install mongodb-community` → `brew services start …`: three
415
+ * commands, a tap most people do not know they need, and a system-wide service. A user who
416
+ * skipped the tap got `No available formula with the name "mongodb-community"`, which is where
417
+ * this task started. One container is less to install, less to undo, and it is the same
418
+ * mongo:8 the project's own end-to-end tests already run against.
419
+ *
420
+ * TWO THINGS THAT ARE NOT OPTIONAL HERE:
421
+ * · A NAMED VOLUME. This database holds the user's conversation history and token counts —
422
+ * the whole point of Local mode. Without `-v`, `docker rm` throws it away, and a container
423
+ * is exactly the thing people remove while tidying up.
424
+ * · `--restart unless-stopped`, so it comes back after a reboot. A database that quietly
425
+ * stops existing on Tuesday is worse than one that was never offered.
426
+ *
427
+ * `exists` distinguishes creating from starting: `docker run` on a name that is already taken
428
+ * fails with a conflict, and the useful action then is `docker start`.
429
+ */
430
+ export function mongoDockerPlan({ exists = false, port = 27017 } = {}) {
431
+ if (exists) {
432
+ return {
433
+ commands: [`docker start ${MONGO_DOCKER_NAME}`],
434
+ summary: `start the existing ${MONGO_DOCKER_NAME} container`,
435
+ };
436
+ }
437
+ return {
438
+ commands: [
439
+ // One line, so a user who would rather run it themselves can copy it whole.
440
+ `docker run -d --name ${MONGO_DOCKER_NAME} --restart unless-stopped ` +
441
+ `-p ${port}:27017 -v ${MONGO_DOCKER_VOLUME}:/data/db ${MONGO_DOCKER_IMAGE}`,
442
+ ],
443
+ summary: `run ${MONGO_DOCKER_IMAGE} on port ${port}, data kept in the ` +
444
+ `${MONGO_DOCKER_VOLUME} volume`,
445
+ };
446
+ }
447
+
448
+ /** The install commands for a platform, or null when we do not support it. */
449
+ export function mongoInstallPlan(platform) {
450
+ if (platform === "darwin") {
451
+ return {
452
+ platform,
453
+ commands: [
454
+ "brew tap mongodb/brew",
455
+ "brew install mongodb-community",
456
+ "brew services start mongodb-community",
457
+ ],
458
+ verify: "mongosh --eval 'db.runCommand({ ping: 1 })'",
459
+ };
460
+ }
461
+ if (platform === "win32") {
462
+ return {
463
+ platform,
464
+ commands: [
465
+ // winget ships with Windows 11. The installer registers a SERVICE named MongoDB and
466
+ // starts it, which is why the probe below looks for a service rather than a process.
467
+ "winget install --id MongoDB.Server --accept-package-agreements --accept-source-agreements",
468
+ "winget install --id MongoDB.Shell --accept-package-agreements --accept-source-agreements",
469
+ ],
470
+ verify: "mongosh --eval \"db.runCommand({ ping: 1 })\"",
471
+ };
472
+ }
473
+ if (platform === "linux") {
474
+ return {
475
+ platform,
476
+ commands: [
477
+ "sudo apt-get install -y gnupg curl",
478
+ "curl -fsSL https://pgp.mongodb.com/server-7.0.asc | sudo gpg --dearmor -o /usr/share/keyrings/mongodb-server-7.0.gpg",
479
+ 'echo "deb [signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list',
480
+ "sudo apt-get update && sudo apt-get install -y mongodb-org",
481
+ "sudo systemctl enable --now mongod",
482
+ ],
483
+ verify: "mongosh --eval 'db.runCommand({ ping: 1 })'",
484
+ };
485
+ }
486
+ return null;
487
+ }
488
+
489
+
490
+ /**
491
+ * How far `doctor` may go to fix each step on its own (task #154).
492
+ *
493
+ * The classification is about CONSEQUENCE, not difficulty. Starting a service we installed, on
494
+ * a port we chose, is reversible and boring — doing it silently saves the user a lookup.
495
+ * Installing system packages, downloading tens of megabytes, or creating an IDENTITY are not
496
+ * things a status command should decide to do.
497
+ */
498
+ export const STEP_FIX = {
499
+ mode: { mode: "manual", action: null, why: "which mode this machine runs is the user's choice" },
500
+ mongo: {
501
+ mode: "auto",
502
+ action: "start MongoDB",
503
+ why: "starting a service that is already installed is reversible and expected",
504
+ // Only when it was installed before. A machine that never had MongoDB needs the
505
+ // walkthrough, sudo and a package manager — not a status command quietly installing it.
506
+ autoOnlyIfPreviouslyDone: true,
507
+ },
508
+ database: {
509
+ mode: "auto",
510
+ action: "recreate missing collections and indexes",
511
+ why: "creating what already exists is a no-op, so this is safe to repeat",
512
+ // THE TRAP. Re-seeding must ENSURE structure, never mint a new worker key: worker.env
513
+ // already holds one, and a fresh key would authenticate against nothing, leaving a
514
+ // terminal that fails every request with "Invalid worker API key" and no clue why.
515
+ neverRegenerateIdentity: true,
516
+ },
517
+ "api-files": {
518
+ mode: "confirm",
519
+ action: "re-download the API release",
520
+ why: "tens of megabytes over someone's network is not a side effect of `status`",
521
+ },
522
+ "api-running": {
523
+ mode: "auto",
524
+ action: "start the local API",
525
+ why: "it is our process, on the port we chose, and it holds no state",
526
+ autoOnlyIfPreviouslyDone: true,
527
+ },
528
+ "worker-env": {
529
+ mode: "confirm",
530
+ action: "rewrite ~/.gonext/worker.env to point at the local API",
531
+ why: "this file is how the terminal finds its API; rewriting it silently is how a machine ends up pointing somewhere the user did not choose",
532
+ },
533
+ models: { mode: "manual", action: null, why: "model choices are the user's" },
534
+ };
535
+
536
+ /**
537
+ * The API and database health lines for `status` / `doctor`.
538
+ *
539
+ * Built from the same plan the installer uses, deliberately: a second implementation of "is
540
+ * the API up?" would drift from the first, and then `status` would say healthy while setup
541
+ * said broken. One source, two renderings.
542
+ *
543
+ * Returns nothing to fix when this machine is not in Client Expert — a Client-mode user has no
544
+ * local API or database, and reporting them as "down" would be inventing a fault.
545
+ */
546
+ export function healthReport({ recorded = {}, probe = {} } = {}) {
547
+ if (probe.mode !== "client-expert") {
548
+ return { applicable: false, ok: true, problems: [], summary: "hosted API and database" };
549
+ }
550
+ const plan = planSetup({ recorded, probe });
551
+ const problems = plan.steps
552
+ .filter((s) => s.state !== "done")
553
+ .map((s) => {
554
+ const fix = STEP_FIX[s.id] ?? { mode: "manual", action: null, why: "" };
555
+ const everWorked = recorded[s.id] === "done" || s.state === "reopened";
556
+ // "Auto" is conditional for anything whose first-time version is heavy: restarting a
557
+ // service is not the same act as installing one.
558
+ const canAuto =
559
+ fix.mode === "auto" && (!fix.autoOnlyIfPreviouslyDone || everWorked);
560
+ return {
561
+ id: s.id,
562
+ title: s.title,
563
+ state: s.state,
564
+ fixMode: canAuto ? "auto" : fix.mode === "auto" ? "manual" : fix.mode,
565
+ action: fix.action,
566
+ why: fix.why,
567
+ };
568
+ });
569
+ // ONLY THE FIRST PROBLEM MAY BE FIXED. Everything after it depends on it, so "fixing" a later
570
+ // one is meaningless work against a broken prerequisite — seeding collections into a MongoDB
571
+ // that is not running, or starting an API with no database to serve from. Caught by a test
572
+ // that expected an uninstalled MongoDB to leave nothing auto-fixable, and found `database`
573
+ // volunteering to run anyway. Fix the root, re-probe, and the next becomes eligible in turn —
574
+ // which is also exactly what a person would do.
575
+ const blocked = problems.length ? problems[0].id : null;
576
+ for (let i = 1; i < problems.length; i += 1) {
577
+ problems[i] = {
578
+ ...problems[i],
579
+ fixMode: "blocked",
580
+ why: `nothing to do until ${blocked} is fixed — this depends on it`,
581
+ };
582
+ }
583
+ return {
584
+ applicable: true,
585
+ ok: problems.length === 0,
586
+ problems,
587
+ // The FIRST problem is the one to act on: the rest are usually its consequences.
588
+ rootCause: problems.length ? problems[0].id : null,
589
+ summary: problems.length === 0
590
+ ? "local API and database healthy"
591
+ : `${problems.length} thing(s) need attention, starting with ${problems[0].id}`,
592
+ };
593
+ }
594
+
595
+ /** The problems `doctor` may fix without asking, in order. */
596
+ export function autoFixable(report) {
597
+ return (report?.problems ?? []).filter((p) => p.fixMode === "auto");
598
+ }
599
+
600
+ /**
601
+ * Should gu ASK which mode this machine runs in?
602
+ *
603
+ * Only when the answer can change something. `chosenMode` is read by exactly one thing — is a
604
+ * Client Expert plan in progress — so on a machine that is already paired, where Client Expert
605
+ * is refused anyway, the question offers Client vs Host: a pair that changes NOTHING, because
606
+ * client mode is derived from settings by isClientMode(), not from this answer.
607
+ *
608
+ * Asking an established machine to re-pick its mode on the first run after an upgrade implies
609
+ * that choosing Host would do something. It would not. Reported live on a machine that had
610
+ * been working for months and was asked to choose again after an update.
611
+ */
612
+ export function shouldAskMode({ chosenMode, isTty, expertAvailable }) {
613
+ if (chosenMode) return false; // already settled, however it was settled
614
+ if (!isTty) return false; // a pipe or CI has nobody to answer
615
+ return Boolean(expertAvailable);
616
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Which tag tells two entries in the /model picker apart (task #223).
3
+ *
4
+ * THE PROBLEM. A choice is a (backend, model) pair, and the picker has always tagged each row
5
+ * with its BACKEND KIND when more than one kind was in play:
6
+ *
7
+ * qwen3:14b · Ollama
8
+ * kimi-k3 · OpenAI-compatible
9
+ *
10
+ * #223 lets the user add their own OpenAI-compatible provider beside the built-in Kimi K3 one.
11
+ * Both rows are kind "openai", so the kind prints THE SAME WORDS beside two different servers:
12
+ *
13
+ * kimi-k3 · OpenAI-compatible
14
+ * qwen3-coder · OpenAI-compatible ← a different company, a different key, a different bill
15
+ *
16
+ * THE RULE, in order:
17
+ * 1. Kinds all distinct → use the kind. "Ollama" and "local MLX" say more about what a row IS
18
+ * than a hostname does, and this is the picker people already know.
19
+ * 2. Otherwise → use the PROVIDER tag the API derives from each URL ("moonshot",
20
+ * "openrouter", "192.168.1.45:11434"), which is unique within a list by construction.
21
+ * 3. No label from the API → fall back to the kind. An older API sends no `label` field, and
22
+ * a blank tag would be worse than the slightly-ambiguous one it replaced.
23
+ *
24
+ * WHY THE TAG IS NOT THE ID, and why this module only computes something to DRAW: the provider
25
+ * tag comes from the URL, so it changes the moment someone fixes a typo in their endpoint. The
26
+ * row id a folder stores is minted and opaque for exactly that reason. Nothing here is saved.
27
+ */
28
+
29
+ /** How each backend kind is spelled for a human. "" is the legacy Auto marker. */
30
+ export const KIND_LABEL = {
31
+ ollama: "Ollama",
32
+ openai: "OpenAI-compatible",
33
+ local: "local MLX",
34
+ "": "auto",
35
+ };
36
+
37
+ /** What a single choice's kind is called, with the raw kind as a last resort. */
38
+ export function kindLabel(choice) {
39
+ const kind = choice?.kind ?? "";
40
+ return KIND_LABEL[kind] ?? kind ?? "";
41
+ }
42
+
43
+ /**
44
+ * The tag for each choice, and whether tags are worth showing at all.
45
+ *
46
+ * Returns { tags: string[], show: boolean } — `tags` parallel to `choices`, and `show` false
47
+ * when every row would carry the same tag, because a tag that is identical on every line is
48
+ * noise in a list whose job is to tell them apart.
49
+ */
50
+ export function codingChoiceTags(choices) {
51
+ const list = Array.isArray(choices) ? choices : [];
52
+ const kinds = new Set(list.map((c) => c?.kind ?? ""));
53
+ // CAN THE KIND STILL SEPARATE THE BACKENDS? Counted against distinct BACKENDS, not distinct
54
+ // rows — one backend commonly offers several models, and counting rows would conclude "the
55
+ // kinds cannot separate these" for an Ollama box with two allowed models beside Kimi K3,
56
+ // changing a display that was never ambiguous. A backend is identified by its provider tag
57
+ // (unique per backend by construction), falling back to its URL for an API that sends none.
58
+ const backends = new Set(
59
+ list.map((c) => (typeof c?.label === "string" && c.label.trim()) || c?.url || c?.kind || "")
60
+ );
61
+ const kindsDiffer = kinds.size === backends.size;
62
+ const tags = list.map((c) => {
63
+ const byKind = kindLabel(c);
64
+ if (kindsDiffer) return byKind;
65
+ const label = typeof c?.label === "string" ? c.label.trim() : "";
66
+ return label || byKind;
67
+ });
68
+ return { tags, show: new Set(tags).size > 1 };
69
+ }