@tiens.nguyen/gu-cli 1.0.695 → 1.0.697

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.
@@ -67,3 +67,36 @@ export function codingChoiceTags(choices) {
67
67
  });
68
68
  return { tags, show: new Set(tags).size > 1 };
69
69
  }
70
+
71
+ /**
72
+ * WHERE AN OLLAMA CODER RUNS — "ollama.com", "127.0.0.1", "ollama1.gomarsic.cc" — or "" for any
73
+ * other kind.
74
+ *
75
+ * Reported: "/model ✓ coding model → kimi-k3 (ollama)". The tag was the API's provider label, the
76
+ * first word of the host, so https://ollama.com read "ollama" — the same word as the KIND — and a
77
+ * paid cloud model looked exactly like one on this machine. Whether it is local or cloud decides
78
+ * whether it costs money and needs a key, so it is shown as a prefix wherever the coder is named.
79
+ *
80
+ * The Ollama default port (11434) is left off; any other port is kept, because two local servers on
81
+ * different ports are different servers.
82
+ */
83
+ export function ollamaWhere(kind, url) {
84
+ if (kind !== "ollama") return "";
85
+ let u;
86
+ try {
87
+ u = new URL(String(url ?? ""));
88
+ } catch {
89
+ return "";
90
+ }
91
+ const host = u.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
92
+ if (host === "ollama.com" || host.endsWith(".ollama.com")) return "ollama.com";
93
+ const local = host === "localhost" || host === "127.0.0.1" || host === "::1";
94
+ const port = u.port && u.port !== "11434" ? `:${u.port}` : "";
95
+ return (local ? "127.0.0.1" : host) + port;
96
+ }
97
+
98
+ /** The coder's name as shown: "ollama.com/kimi-k3" for an Ollama coder, the bare name otherwise. */
99
+ export function coderDisplayName(name, where) {
100
+ const n = String(name ?? "").trim();
101
+ return where && n ? `${where}/${n}` : n;
102
+ }
@@ -52,6 +52,34 @@ export function isKeylessCodingHost(url) {
52
52
  return host === "gomarsic.cc" || host.endsWith(".gomarsic.cc");
53
53
  }
54
54
 
55
+ /**
56
+ * TRUE only for the Ollama CLOUD (https://ollama.com, or a subdomain of it). Mirrors
57
+ * isOllamaCloudUrl in api/src/store/settingsTypes.ts, web/src/lib/coderChoices.mjs and
58
+ * _isOllamaCloudUrl in gu-cli.mjs — keep them in step.
59
+ */
60
+ export function isOllamaCloudUrl(url) {
61
+ const v = String(url ?? "").trim();
62
+ if (!/^https:\/\//i.test(v)) return false;
63
+ let host;
64
+ try {
65
+ host = new URL(v).hostname.toLowerCase().replace(/\.$/, "");
66
+ } catch {
67
+ return false;
68
+ }
69
+ return host === "ollama.com" || host.endsWith(".ollama.com");
70
+ }
71
+
72
+ /**
73
+ * DOES THIS CODER NEED AN API KEY? — an OpenAI-compatible provider, or Ollama's paid CLOUD.
74
+ *
75
+ * Reported: /model on kimi-k3 at https://ollama.com said "needs no API key — nothing to set", so
76
+ * there was no way to give it one. Every "needs a key" check had been `kind === "openai"`, on the
77
+ * belief that Ollama never authenticates — true of a local or LAN Ollama, false of ollama.com.
78
+ */
79
+ export function codingNeedsKey(kind, url) {
80
+ return kind === "openai" || (kind === "ollama" && isOllamaCloudUrl(url));
81
+ }
82
+
55
83
  /**
56
84
  * @param payload the /agent-payload `payload` object (reads codingKeyMissing/codingKeyModel).
57
85
  * @param deps.askSecret async (prompt) => string — masked input; "" means the user skipped.
@@ -113,8 +141,10 @@ export async function promptCodingKeyIfMissing(payload, { askSecret, saveCodingK
113
141
  *
114
142
  * There is a coder either way; what differs is whether it can be USED yet. Name it, and say so.
115
143
  */
116
- export function codingBannerLabel(payload, { dim = identity, override = "" } = {}) {
144
+ export function codingBannerLabel(payload, { dim = identity, override = "", where = "" } = {}) {
117
145
  const p = payload ?? {};
146
+ // "ollama.com/kimi-k3" rather than "kimi-k3": local or cloud, visible in the header too.
147
+ const named = (n) => (where && n ? `${where}/${n}` : n);
118
148
  // A `/model` PICK WINS OVER THE ACCOUNT DEFAULT, because it is what the next turn will
119
149
  // actually use. The payload carries the account's coder; the override is this folder's,
120
150
  // restored from its session file at startup. Reading only the payload put two different
@@ -124,15 +154,15 @@ export function codingBannerLabel(payload, { dim = identity, override = "" } = {
124
154
  // Only possible since the pick started surviving an exit; before that the override was always
125
155
  // empty at startup and the two could not disagree.
126
156
  const pick = String(override ?? "").trim();
127
- if (pick) return pick;
128
- if (p.codingModelId) return String(p.codingModelId);
157
+ if (pick) return named(pick);
158
+ if (p.codingModelId) return named(String(p.codingModelId));
129
159
  const name = String(p.codingKeyMissing ? p.codingKeyModel || "" : "").trim();
130
160
  if (!name) return dim("—");
131
161
  // A keyless host is never asked for anything — the prompt stores the placeholder itself and
132
162
  // the coder is live moments later, so "(needs a key)" would be a lie with a shelf life of
133
163
  // about one line.
134
- if (isKeylessCodingHost(p.codingKeyUrl)) return name;
135
- return name + dim(" (needs a key)");
164
+ if (isKeylessCodingHost(p.codingKeyUrl)) return named(name);
165
+ return named(name) + dim(" (needs a key)");
136
166
  }
137
167
 
138
168
  /**
@@ -187,9 +217,10 @@ export function codingKeyFallbackLine(kj, jobId = "") {
187
217
  * when a choice HAS been made, and when only one key exists — with one there is nothing to
188
218
  * choose, and a warning on a correct setup is how warnings stop being read.
189
219
  */
190
- export function unchosenKeyWarning({ codingKind = "", selectedKeyId = "", keys = null } = {}) {
220
+ export function unchosenKeyWarning({ codingKind = "", codingUrl = "", selectedKeyId = "", keys = null } = {}) {
191
221
  const quiet = { warn: false, using: null, why: "" };
192
- if (codingKind !== "openai") return quiet; // Ollama and local MLX need no credential
222
+ // Local Ollama and MLX need no credential; the Ollama cloud does.
223
+ if (!codingNeedsKey(codingKind, codingUrl)) return quiet;
193
224
  if (!Array.isArray(keys) || keys.length < 2) return quiet;
194
225
  if (String(selectedKeyId ?? "").trim()) return quiet;
195
226
  return { warn: true, using: keys[0], why: "none-chosen" };
@@ -200,7 +231,8 @@ export function unchosenKeyWarning({ codingKind = "", selectedKeyId = "", keys =
200
231
  *
201
232
  * REPORTED: the bar's "key not chosen" mark only appeared after running /model. The startup gate
202
233
  * read `payload.codingKind`, and a payload fetched with NO override describes the ACCOUNT DEFAULT
203
- * — on the reporting machine that was kimi-k3 at ollama.com, kind "ollama", which needs no key —
234
+ * — on the reporting machine kimi-k3 at ollama.com, kind "ollama" (which, it turned out later,
235
+ * DOES need a key: see codingNeedsKey) —
204
236
  * while the folder was pointed at gpt-5, kind "openai". So the check answered a question about a
205
237
  * coder the folder was not using, said "nothing to warn about", and stayed silent. /model looked
206
238
  * right because it re-probes WITH the override.
@@ -212,6 +244,17 @@ export function unchosenKeyWarning({ codingKind = "", selectedKeyId = "", keys =
212
244
  * Matched by id OR by bare model name, mirroring that validation: a session saved before ids were
213
245
  * qualified ("<kind>::<model>", task #123) stores the bare form and is still a valid pick.
214
246
  */
247
+ /** The folder's coder — its kind AND its URL, which together decide whether it needs a key. */
248
+ export function effectiveCoder({ sessionCodingModel = "", choices = null, defaultKind = "", defaultUrl = "" } = {}) {
249
+ const want = String(sessionCodingModel ?? "").trim();
250
+ const list = Array.isArray(choices) ? choices : [];
251
+ if (want) {
252
+ const hit = list.find((c) => c?.id === want || c?.model === want);
253
+ if (hit) return { kind: String(hit.kind ?? ""), url: String(hit.url ?? "") };
254
+ }
255
+ return { kind: String(defaultKind ?? ""), url: String(defaultUrl ?? "") };
256
+ }
257
+
215
258
  export function effectiveCoderKind({
216
259
  sessionCodingModel = "",
217
260
  choices = null,
package/gu-cli.mjs CHANGED
@@ -20,7 +20,7 @@ import { execFile as execFileCallback, spawn } from "node:child_process";
20
20
  import { createHash } from "node:crypto";
21
21
  import { createTaskPool, createFolderQueue } from "./job-pools.mjs";
22
22
  import { ragJobConfig } from "./rag-job-config.mjs";
23
- import { codingKeyFallbackLine } from "./coding-key-prompt.mjs";
23
+ import { codingKeyFallbackLine, isOllamaCloudUrl } from "./coding-key-prompt.mjs";
24
24
  // Pure layout helpers — safe to import here (no side effects on load, unlike gu-repl.mjs).
25
25
  // Shared so the mid-run picker and the REPL's post-stop advice cannot word the same budget
26
26
  // differently.
@@ -3265,22 +3265,8 @@ function _isLocalCodingUrl(baseURL) {
3265
3265
  return !host.includes(".") && !host.includes(":");
3266
3266
  }
3267
3267
 
3268
- /** TRUE only for the Ollama CLOUD endpoint (https://ollama.com[/...]) — the one Ollama
3269
- * backend that actually requires an API key. Any other Ollama URL (localhost, a LAN
3270
- * box, a self-hosted remote) has no auth and gets no key. Mirrors isOllamaCloudUrl in
3271
- * web/src/lib/coderChoices.mjs — keep the two in step. */
3272
- function _isOllamaCloudUrl(baseURL) {
3273
- const v = String(baseURL || "").trim();
3274
- if (!/^https:\/\//i.test(v)) return false;
3275
- let host = "";
3276
- try {
3277
- host = new URL(v).hostname.toLowerCase().replace(/\.$/, "");
3278
- } catch {
3279
- return false;
3280
- }
3281
- return host === "ollama.com" || host.endsWith(".ollama.com");
3282
- }
3283
-
3268
+ /** The Ollama cloud check lives in coding-key-prompt.mjs, shared with the terminal. */
3269
+ const _isOllamaCloudUrl = isOllamaCloudUrl;
3284
3270
 
3285
3271
  /** Resolve to "ollama" | "openai" | "local". `kind` is the user's Settings choice
3286
3272
  * ("ollama" | "openai" | "" = Auto). Only Auto costs a probe. */
package/gu-repl.mjs CHANGED
@@ -113,7 +113,7 @@ import {
113
113
  } from "./terminal-layout.mjs";
114
114
  import { completedFlush } from "./completed-flush.mjs";
115
115
  import { codingBannerLabel, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
116
- import { codingChoiceTags } from "./coding-choice-tags.mjs";
116
+ import { codingChoiceTags, coderDisplayName, ollamaWhere } from "./coding-choice-tags.mjs";
117
117
  import {
118
118
  isDoubleClick,
119
119
  clickRun,
@@ -1973,11 +1973,14 @@ let keyChoicePending = false;
1973
1973
  const identityLineText = () =>
1974
1974
  identityRow(
1975
1975
  {
1976
- coder: activeCoderName({
1977
- sessionLabel: sessionCodingLabel,
1978
- sessionModel: sessionCodingModel,
1979
- accountDefault: defaultCodingModel,
1980
- }),
1976
+ coder: coderDisplayName(
1977
+ activeCoderName({
1978
+ sessionLabel: sessionCodingLabel,
1979
+ sessionModel: sessionCodingModel,
1980
+ accountDefault: defaultCodingModel,
1981
+ }),
1982
+ activeCoderWhere,
1983
+ ),
1981
1984
  folder: folderLabel(),
1982
1985
  keyUnchosen: keyChoicePending,
1983
1986
  },
@@ -3480,7 +3483,15 @@ if (process.stdin.isTTY) {
3480
3483
  // a running turn owns the screen, and a masked prompt drawn over either corrupts both.
3481
3484
  if (key && key.ctrl && key.name === "k" && !slashOverlayActive && !following
3482
3485
  && !listPickerActive && !approvalActive) {
3483
- void replaceCodingKey();
3486
+ // NOT a bare `void`: this runs outside the command loop that turns Esc into "(cancelled)",
3487
+ // so an Esc at the key's name or secret escaped as an unhandled rejection and ENDED THE
3488
+ // PROCESS (reported: "PromptCancelled: cancelled … Node.js v22"). Cancel is caught here; any
3489
+ // other failure is reported and the session goes on — a keypress must not kill the REPL.
3490
+ replaceCodingKey().catch((e) => {
3491
+ if (wasPromptCancelled(e)) console.log(dim(" (cancelled)\n"));
3492
+ else console.log(red(` ctrl+k failed: ${e?.message ?? e}\n`));
3493
+ paintBar();
3494
+ });
3484
3495
  return;
3485
3496
  }
3486
3497
  if (key && key.ctrl && key.name === "t" && !slashOverlayActive) {
@@ -5780,8 +5791,11 @@ async function chooseModel() {
5780
5791
  // OpenAI-compatible providers are both "OpenAI-compatible", so the kind names neither.
5781
5792
  const { tags, show: showTag } = codingChoiceTags(choices);
5782
5793
  const labels = choices.map((c, i) => {
5783
- const tag = showTag ? ` ${dim(`· ${tags[i]}`)}` : "";
5784
- return `${c.model}${tag}${c.id === def ? " (default)" : ""}`;
5794
+ // An Ollama row says WHERE it runs in front of the name ("ollama.com/kimi-k3"), so the kind tag
5795
+ // beside it would only repeat "Ollama". Other rows keep the tag rule.
5796
+ const where = ollamaWhere(c.kind, c.url);
5797
+ const tag = showTag && !where ? ` ${dim(`· ${tags[i]}`)}` : "";
5798
+ return `${coderDisplayName(c.model, where)}${tag}${c.id === def ? " (default)" : ""}`;
5785
5799
  });
5786
5800
  const activeIdx = choices.findIndex((c) => c.id === active);
5787
5801
  labels.push("change the Ollama server this machine uses…");
@@ -5827,9 +5841,12 @@ async function chooseModel() {
5827
5841
  // WRITTEN NOW, not at the end of the next turn: `/model` then `/exit` is how the choice is
5828
5842
  // made and then tested, and that path never reaches saveSession.
5829
5843
  await saveSessionCoding(resolve(process.cwd()));
5830
- const where = showTag ? ` (${tags[idx]})` : "";
5844
+ activeCoderWhere = ollamaWhere(chosen.kind, chosen.url);
5845
+ const where = showTag && !activeCoderWhere ? ` (${tags[idx]})` : "";
5831
5846
  console.log(
5832
- accent(` ✓ coding model → ${chosen.model}${where}${chosen.id === def ? " (default)" : ""}\n`)
5847
+ accent(
5848
+ ` ✓ coding model → ${coderDisplayName(chosen.model, activeCoderWhere)}${where}${chosen.id === def ? " (default)" : ""}\n`
5849
+ )
5833
5850
  );
5834
5851
  // DOES THIS ONE NEED AN API KEY? (task #181) Reported live on Windows 11 / Client Expert:
5835
5852
  // picking kimi-k3 here and asking a question just span — the coder needs a key, none was
@@ -6779,7 +6796,9 @@ async function coderKeyState(override = "") {
6779
6796
  const active = override
6780
6797
  ? choices.find((c) => c.id === override)
6781
6798
  : choices.find((c) => c.isDefault);
6782
- const needsKey = active?.kind === "openai";
6799
+ // Kind AND url: an Ollama coder at https://ollama.com needs a key; a local one does not.
6800
+ const { codingNeedsKey } = await import("./coding-key-prompt.mjs");
6801
+ const needsKey = codingNeedsKey(active?.kind, active?.url);
6783
6802
  return {
6784
6803
  needsKey,
6785
6804
  model: p.codingKeyModel || active?.model || "the coding model",
@@ -7667,6 +7686,8 @@ let defaultCodingModel = "";
7667
7686
  // another backend must not keep showing the previous backend's total). Empty = let the API
7668
7687
  // fall back to the account-default URL from settings (older behaviour).
7669
7688
  let sessionCodingUrl = "";
7689
+ /** "ollama.com" / "127.0.0.1" / a host — where the ACTIVE coder runs, when it is an Ollama one. */
7690
+ let activeCoderWhere = "";
7670
7691
  // The account-default backend's URL (from the startup probe's codingChoices), used when no
7671
7692
  // /model override is active. Set once at startup alongside defaultCodingModel.
7672
7693
  let defaultCodingUrl = "";
@@ -9327,6 +9348,17 @@ async function main() {
9327
9348
  sessionCodingUrl = "";
9328
9349
  }
9329
9350
  }
9351
+ {
9352
+ // WHERE THE CODER RUNS, for the banner and the bar: the folder's pick, else the default.
9353
+ const choices = Array.isArray(probe?.codingChoices) ? probe.codingChoices : [];
9354
+ const active =
9355
+ (sessionCodingModel && choices.find((c) => c.id === sessionCodingModel || c.model === sessionCodingModel)) ||
9356
+ choices.find((c) => c.isDefault) ||
9357
+ null;
9358
+ // No choice list (an older API): no prefix, rather than guess from the ACCOUNT default's raw
9359
+ // payload fields, which key-choice-regression.test.mjs forbids for exactly that reason.
9360
+ activeCoderWhere = active ? ollamaWhere(active.kind, active.url) : "";
9361
+ }
9330
9362
  // Task #133: no RAG storage question any more — the terminal ALWAYS keeps the index on
9331
9363
  // this machine. `/rag-cloud` opts into additionally mirroring it to the user's S3 bucket.
9332
9364
  // Startup banner (task #131): the gu logo on the left, compact status facts on the
@@ -9375,6 +9407,7 @@ async function main() {
9375
9407
  sessionLabel: sessionCodingLabel,
9376
9408
  sessionModel: sessionCodingModel,
9377
9409
  }),
9410
+ where: activeCoderWhere,
9378
9411
  }),
9379
9412
  lbl("RAG") +
9380
9413
  (p.ragEnabled
@@ -9561,7 +9594,7 @@ async function main() {
9561
9594
  // folder has not chosen, and more than one is stored.
9562
9595
  try {
9563
9596
  if (!keyRes?.saved) {
9564
- const { unchosenKeyWarning, effectiveCoderKind } = await import(
9597
+ const { unchosenKeyWarning, effectiveCoder, codingNeedsKey } = await import(
9565
9598
  "./coding-key-prompt.mjs"
9566
9599
  );
9567
9600
  // THE RULE LIVES IN THE MODULE so it can be tested by calling it. Open-coded here it
@@ -9574,15 +9607,17 @@ async function main() {
9574
9607
  // machine an Ollama coder, which needs no key — while the folder was on gpt-5. Reading
9575
9608
  // it directly made this check answer about a coder nobody was using, and the mark only
9576
9609
  // appeared after /model, which re-probes WITH the override.
9577
- const kind = effectiveCoderKind({
9610
+ const { kind, url } = effectiveCoder({
9578
9611
  sessionCodingModel,
9579
9612
  choices: probe?.codingChoices,
9580
9613
  defaultKind: p?.codingKind,
9614
+ defaultUrl: probe?.codingChoices?.find?.((c) => c?.isDefault)?.url ?? "",
9581
9615
  });
9582
9616
  const allKeys =
9583
- kind === "openai" && !sessionCodingKeyId ? await fetchCodingKeys() : null;
9617
+ codingNeedsKey(kind, url) && !sessionCodingKeyId ? await fetchCodingKeys() : null;
9584
9618
  const w = unchosenKeyWarning({
9585
9619
  codingKind: kind,
9620
+ codingUrl: url,
9586
9621
  selectedKeyId: sessionCodingKeyId,
9587
9622
  keys: allKeys,
9588
9623
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.695",
3
+ "version": "1.0.697",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",