@tiens.nguyen/gu-cli 1.0.869 → 1.0.871
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/agent-model-command.mjs +0 -67
- package/client-expert-api.mjs +0 -36
- package/doctor-check.mjs +9 -1
- package/gonext_agent_chat.py +22 -4
- package/gu-cli.mjs +28 -0
- package/gu-repl.mjs +178 -450
- package/hints.mjs +67 -0
- package/model-doctor.mjs +2 -7
- package/package.json +4 -1
- package/platform-tools.mjs +30 -2
- package/quick-models.mjs +64 -0
- package/service-check.mjs +181 -0
package/hints.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TIPS UNDER THE THINKING WORD (#234) — one line that teaches a command or a key while the model
|
|
3
|
+
* works, the way Claude Code's "⎿ Tip: …" does.
|
|
4
|
+
*
|
|
5
|
+
* Pure: the REPL picks one per turn and draws it under the playful word. Every "/command" named
|
|
6
|
+
* here must be a real one (hints.test.mjs checks them against the REPL's COMMANDS list), because a
|
|
7
|
+
* tip that names a command that does not exist is worse than no tip.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const HINTS = [
|
|
11
|
+
"/model switches the coding model for this session",
|
|
12
|
+
"/quick-models picks two models; then ← / → at the prompt flips between them",
|
|
13
|
+
"/agent-model switches the model that talks to you (the agent model)",
|
|
14
|
+
"Type / then Tab to complete a command; /help lists them all",
|
|
15
|
+
"shift+tab switches between accept edits and manually edit",
|
|
16
|
+
"ctrl+t opens the model's full thought while it works",
|
|
17
|
+
"ctrl+c stops the turn; type continue to pick it up again",
|
|
18
|
+
"ctrl+k replaces the coding model's API key without leaving the terminal",
|
|
19
|
+
"/new starts a new conversation in this folder — the old one is kept",
|
|
20
|
+
"/doctor checks that every configured model really answers",
|
|
21
|
+
"/tokens shows how big the last prompt was and how much of it was RAG",
|
|
22
|
+
"/prices shows what the last turn and this session cost",
|
|
23
|
+
"/context-window tells gu how big your coding model's context is",
|
|
24
|
+
"/mcp lists the MCP servers and adds one step by step",
|
|
25
|
+
"/hooks lists the commands gu runs on start, ask and turn end",
|
|
26
|
+
"/shells shows long commands the agent left running in the background",
|
|
27
|
+
"/budget shows your API provider balances (Pro)",
|
|
28
|
+
"/browser checks the gu Chrome extension the agent uses to click web pages",
|
|
29
|
+
"/support sends the gu team a request with your logs attached (secrets masked)",
|
|
30
|
+
"/clear-logs shows what gu's logs and indexes take on disk, then asks what to clear",
|
|
31
|
+
"/switch-skill chooses whose skills and MCP servers this folder uses: Claude's or gu's",
|
|
32
|
+
"/use-presets applies a saved setup after showing what changes",
|
|
33
|
+
"/save-settings writes your providers and models to a file you can load on another machine",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One tip index, never `last` when there is a choice (so two turns in a row do not repeat).
|
|
38
|
+
* `rand` is injectable (0 <= rand() < 1) for tests.
|
|
39
|
+
*/
|
|
40
|
+
export function pickHint(rand = Math.random, last = -1, list = HINTS) {
|
|
41
|
+
if (list.length === 0) return -1;
|
|
42
|
+
if (list.length === 1) return 0;
|
|
43
|
+
let i = Math.floor(rand() * list.length) % list.length;
|
|
44
|
+
if (i === last) i = (i + 1) % list.length;
|
|
45
|
+
return i;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The glyph and label in front of a tip — plain text, painted dim by the caller. */
|
|
49
|
+
export const HINT_PREFIX = "⎿ Tip: ";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The tip as ONE row no wider than `width` - 1 columns including `indent` spaces: cut with "…" and
|
|
53
|
+
* never wrapped (a wrapped row in the live block is the #109/#138 failure). "" when there is no room
|
|
54
|
+
* for anything readable.
|
|
55
|
+
*/
|
|
56
|
+
export function hintRow(text, { width = 80, indent = 3 } = {}) {
|
|
57
|
+
const room = Math.max(0, (width || 80) - 1 - indent);
|
|
58
|
+
const full = HINT_PREFIX + String(text ?? "").trim();
|
|
59
|
+
if (!String(text ?? "").trim() || room < 20) return "";
|
|
60
|
+
return full.length <= room ? full : full.slice(0, room - 1).replace(/\s+$/, "") + "…";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** How long the model must have been working before the tip appears, in seconds. */
|
|
64
|
+
export const HINT_AFTER_SECONDS = 4;
|
|
65
|
+
|
|
66
|
+
/** Is there room for the tip row? Not on a very short terminal, where every row counts. */
|
|
67
|
+
export const hintFits = (rows) => (rows || 24) >= 16;
|
package/model-doctor.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { open, mkdir, readdir, writeFile } from "node:fs/promises";
|
|
|
12
12
|
import { arch, homedir, platform, totalmem } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { ensureSupportedPython, isBuildToolsMissing } from "./platform-tools.mjs";
|
|
15
|
+
import { ensureSupportedPython, isBuildToolsMissing, buildFailureMessage } from "./platform-tools.mjs";
|
|
16
16
|
|
|
17
17
|
// Minimum RAM to run the default 14B-4bit chat model comfortably on Apple Silicon.
|
|
18
18
|
const MIN_CHAT_RAM_GB = 16;
|
|
@@ -207,12 +207,7 @@ async function pipInstall(pkgs, log) {
|
|
|
207
207
|
log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
|
|
208
208
|
let r = await runPip(python3Bin(), pkgs);
|
|
209
209
|
if (r.code === 0) return;
|
|
210
|
-
if (isBuildToolsMissing(r.err))
|
|
211
|
-
throw new Error(
|
|
212
|
-
"pip had to compile a package and this machine has no C++ compiler. Use Python 3.13 or 3.12 (64-bit), which " +
|
|
213
|
-
"has prebuilt packages: " + pythonInstallHint() + ". Then run gu doctor again."
|
|
214
|
-
);
|
|
215
|
-
}
|
|
210
|
+
if (isBuildToolsMissing(r.err)) throw new Error(buildFailureMessage(r.err));
|
|
216
211
|
const noPip = isPipMissing(r.err);
|
|
217
212
|
if (!noPip && !isExternallyManaged(r.err)) {
|
|
218
213
|
throw new Error(`pip install ${pkgs.join(" ")} exited ${r.code}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.871",
|
|
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",
|
|
@@ -114,6 +114,7 @@
|
|
|
114
114
|
"ollama-setup.mjs",
|
|
115
115
|
"s3-setup.mjs",
|
|
116
116
|
"doctor-check.mjs",
|
|
117
|
+
"service-check.mjs",
|
|
117
118
|
"embed-model-command.mjs",
|
|
118
119
|
"proxy-command.mjs",
|
|
119
120
|
"proxy-selftest.mjs",
|
|
@@ -122,6 +123,8 @@
|
|
|
122
123
|
"model-doctor.mjs",
|
|
123
124
|
"coding-key-prompt.mjs",
|
|
124
125
|
"model-fallback.mjs",
|
|
126
|
+
"quick-models.mjs",
|
|
127
|
+
"hints.mjs",
|
|
125
128
|
"terminal-title.mjs",
|
|
126
129
|
"gu-relaunch.mjs",
|
|
127
130
|
"server-keys.mjs",
|
package/platform-tools.mjs
CHANGED
|
@@ -314,9 +314,37 @@ export async function ensureSupportedPython({
|
|
|
314
314
|
};
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* pip's "I had to compile and could not" failure. Newer pip words it "error: failed-wheel-build-for-install /
|
|
319
|
+
* Failed to build installable wheels for some pyproject.toml based projects" (reported on a Windows 11
|
|
320
|
+
* customer installing smolagents + openai, #232), older pip "Failed building wheel for X".
|
|
321
|
+
*/
|
|
318
322
|
export function isBuildToolsMissing(output) {
|
|
319
|
-
return /Microsoft Visual C\+\+ 14\.0 or greater is required|Failed building wheel for|error: command '(?:gcc|cl\.exe)' failed/i.test(String(output ?? ""));
|
|
323
|
+
return /Microsoft Visual C\+\+ 14\.0 or greater is required|Failed building wheel for|failed-wheel-build-for-install|Failed to build installable wheels|error: command '(?:gcc|cl\.exe)' failed|could not find (?:`?cargo`?|rustc)|metadata-generation-failed/i.test(String(output ?? ""));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The packages pip could not build: ["pydantic-core", "jiter"], from either pip wording. */
|
|
327
|
+
export function failedBuildPackages(output) {
|
|
328
|
+
const text = String(output ?? "");
|
|
329
|
+
const names = new Set();
|
|
330
|
+
for (const m of text.matchAll(/Failed building wheel for ([A-Za-z0-9_.-]+)/g)) names.add(m[1]);
|
|
331
|
+
// New pip: "╰─> pydantic-core" (or "-> pydantic-core") under the failed-wheel-build summary.
|
|
332
|
+
const at = text.search(/Failed to build installable wheels/i);
|
|
333
|
+
if (at >= 0) for (const m of text.slice(at).matchAll(/^\s*(?:╰─>|\+->|->)\s*([A-Za-z0-9_.-]+)\s*$/gm)) names.add(m[1]);
|
|
334
|
+
return [...names];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** What to tell the user when pip could not build wheels: which package, and the fix that works. */
|
|
338
|
+
export function buildFailureMessage(output, { platform = process.platform } = {}) {
|
|
339
|
+
const names = failedBuildPackages(output);
|
|
340
|
+
const what = names.length ? `pip could not build ${names.join(", ")}` : "pip had to compile a package and could not";
|
|
341
|
+
const rust = /rustc|cargo|maturin|pydantic[-_]core|jiter|rpds/i.test(String(output ?? "")) || names.some((n) => /pydantic[-_]core|jiter|rpds/i.test(n));
|
|
342
|
+
const how = platform === "win32" ? "winget install Python.Python.3.13" : "your package manager, e.g. python3.13";
|
|
343
|
+
return (
|
|
344
|
+
`${what}. That happens when this Python has no prebuilt package for it (too new, 32-bit, or ARM) and the machine ` +
|
|
345
|
+
`has no compiler${rust ? " (these packages need the Rust toolchain)" : ""}. Install 64-bit Python 3.13 (${how}), ` +
|
|
346
|
+
`open a new terminal, then run gu doctor again.`
|
|
347
|
+
);
|
|
320
348
|
}
|
|
321
349
|
|
|
322
350
|
/** How to SAY the resolved interpreter in a log line or error message. */
|
package/quick-models.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUICK MODELS (#233) — two coding models to flip between with ← / → at the prompt.
|
|
3
|
+
*
|
|
4
|
+
* Pure helpers only (no I/O, no terminal): the picker, the key and the bar live in gu-repl.mjs and
|
|
5
|
+
* call these, so the rules below are testable. A quick model is the same (backend, model) PAIR
|
|
6
|
+
* /model offers — { id, model, kind, url } — stored whole, so a switch needs no network round trip
|
|
7
|
+
* to find out what "claude-opus" meant. At most TWO: the point is a flip, not a menu.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const QUICK_MAX = 2;
|
|
11
|
+
|
|
12
|
+
/** At most QUICK_MAX distinct choices, each with an id; anything else in the saved list is dropped. */
|
|
13
|
+
export function normalizeQuick(list) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const c of Array.isArray(list) ? list : []) {
|
|
16
|
+
const id = typeof c?.id === "string" ? c.id.trim() : "";
|
|
17
|
+
if (!id || out.some((o) => o.id === id)) continue;
|
|
18
|
+
out.push({
|
|
19
|
+
id,
|
|
20
|
+
model: typeof c.model === "string" && c.model ? c.model : id,
|
|
21
|
+
kind: typeof c.kind === "string" ? c.kind : "",
|
|
22
|
+
url: typeof c.url === "string" ? c.url : "",
|
|
23
|
+
});
|
|
24
|
+
if (out.length === QUICK_MAX) break;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The ← / → flip needs exactly two. One quick model is a pin, not a flip. */
|
|
30
|
+
export const quickReady = (quick) => normalizeQuick(quick).length === QUICK_MAX;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Where ← / → goes. Active is one of the two → the OTHER one. Active is neither (the user picked
|
|
34
|
+
* something else with /model) → the first one, so a key press always lands somewhere defined.
|
|
35
|
+
* null when fewer than two are chosen.
|
|
36
|
+
*/
|
|
37
|
+
export function quickTarget(quick, activeId) {
|
|
38
|
+
const q = normalizeQuick(quick);
|
|
39
|
+
if (q.length < QUICK_MAX) return null;
|
|
40
|
+
const at = q.findIndex((c) => c.id === activeId);
|
|
41
|
+
return at < 0 ? q[0] : q[1 - at];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Does a model id match a quick choice? The session stores "" for the account default. */
|
|
45
|
+
export const isQuickActive = (quick, activeId) => normalizeQuick(quick).some((c) => c.id === activeId);
|
|
46
|
+
|
|
47
|
+
/** The dim hint beside the mode on the bar: only when the keys do something. */
|
|
48
|
+
export const quickBarHint = (quick) => (quickReady(quick) ? "← → switch model" : "");
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The yellow warning row: quick models are chosen but the model in use is not one of them, so ← / →
|
|
52
|
+
* would not toggle what the user thinks it toggles. Plain text, cut to `width` with an ellipsis.
|
|
53
|
+
*/
|
|
54
|
+
export function quickWarning(quick, activeId, { width = 80, gutter = " " } = {}) {
|
|
55
|
+
const q = normalizeQuick(quick);
|
|
56
|
+
if (q.length === 0 || isQuickActive(q, activeId)) return "";
|
|
57
|
+
const full = q.length === QUICK_MAX
|
|
58
|
+
? `⚠ not on a quick model — ← → goes to ${q[0].model} · /quick-models`
|
|
59
|
+
: "⚠ not on your quick model · pick 2 with /quick-models to use ← →";
|
|
60
|
+
const short = "⚠ not on a quick model · /quick-models";
|
|
61
|
+
const room = Math.max(8, (width || 80) - 1 - gutter.length);
|
|
62
|
+
const pick = full.length <= room ? full : short;
|
|
63
|
+
return pick.length <= room ? pick : pick.slice(0, Math.max(1, room - 1)) + "…";
|
|
64
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can this machine reach the gu service — and if not, why? (task #413, GH #240)
|
|
3
|
+
*
|
|
4
|
+
* node --test tests/service-check.test.mjs
|
|
5
|
+
*
|
|
6
|
+
* Reported on a Windows VM: the agent model answered "hi", but every coding turn failed with
|
|
7
|
+
* "the gu service couldn't be reached". The coding turn is the only one that needs the gu service:
|
|
8
|
+
* the local API fetches the agent's instructions from it first, with Node's plain fetch. The
|
|
9
|
+
* Python agent and the terminal's model calls reach the internet by other routes (the proxy,
|
|
10
|
+
* the system certificates), so "the internet works" and "the local API can't get out" are both
|
|
11
|
+
* true at once. The usual causes on a work machine need different fixes:
|
|
12
|
+
*
|
|
13
|
+
* · a network that inspects HTTPS — Node does not trust its certificate, Python does
|
|
14
|
+
* · a network that needs a proxy — the local API does not use gu's /proxy
|
|
15
|
+
* · an address that is blocked or cannot be looked up
|
|
16
|
+
*
|
|
17
|
+
* So the check makes the request the local API makes, then the variations that tell the causes
|
|
18
|
+
* apart, and says which one it is. The decisions are pure (serviceVerdict) so they are tested
|
|
19
|
+
* without a network; checkGuService only gathers the facts.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
|
|
23
|
+
/** Why a fetch failed, as a kind, a code and words. Node says "fetch failed" and keeps the reason in `cause`. */
|
|
24
|
+
export function describeNetError(err) {
|
|
25
|
+
if (err?.name === "TimeoutError" || err?.name === "AbortError") {
|
|
26
|
+
return { kind: "timeout", code: "TIMEOUT", words: "no answer in time" };
|
|
27
|
+
}
|
|
28
|
+
const code = String(err?.cause?.code ?? err?.code ?? "");
|
|
29
|
+
if (/CERT|SELF_SIGNED|UNABLE_TO_(GET|VERIFY)|ERR_TLS/i.test(code)) {
|
|
30
|
+
return { kind: "cert", code, words: "this computer doesn't trust the certificate it was shown" };
|
|
31
|
+
}
|
|
32
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN") return { kind: "dns", code, words: "the address could not be looked up" };
|
|
33
|
+
if (["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "ENETUNREACH", "EHOSTUNREACH"].includes(code)) {
|
|
34
|
+
return { kind: "blocked", code, words: code === "ECONNREFUSED" ? "the connection was refused" : code === "ECONNRESET" ? "the connection was cut" : "the connection didn't go through" };
|
|
35
|
+
}
|
|
36
|
+
return { kind: "other", code, words: code || String(err?.cause?.message ?? err?.message ?? err ?? "network error") };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One GET; any HTTP answer means the server was reached (401 included — no token was sent). */
|
|
40
|
+
async function reach(url, { fetchImpl = fetch, dispatcher, timeoutMs = 10_000 } = {}) {
|
|
41
|
+
try {
|
|
42
|
+
const r = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs), ...(dispatcher ? { dispatcher } : {}) });
|
|
43
|
+
return { ok: true, status: r.status };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return { ok: false, ...describeNetError(err) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The same request from a NODE STARTED WITH --use-system-ca: does trusting this computer's own
|
|
51
|
+
* certificates (Windows' store, macOS' keychain) fix it? A Node without the flag says "bad option".
|
|
52
|
+
*/
|
|
53
|
+
export function reachWithSystemCa(url, { execPath = process.execPath, timeoutMs = 15_000, spawnImpl = spawn } = {}) {
|
|
54
|
+
const script =
|
|
55
|
+
`fetch(${JSON.stringify(url)},{signal:AbortSignal.timeout(${timeoutMs - 3000})})` +
|
|
56
|
+
`.then(r=>process.stdout.write("ok "+r.status))` +
|
|
57
|
+
`.catch(e=>process.stdout.write("err "+((e.cause&&e.cause.code)||e.name||e.message)))`;
|
|
58
|
+
return new Promise((resolveP) => {
|
|
59
|
+
let out = "";
|
|
60
|
+
let err = "";
|
|
61
|
+
let child;
|
|
62
|
+
try {
|
|
63
|
+
child = spawnImpl(execPath, ["--use-system-ca", "-e", script], { stdio: ["ignore", "pipe", "pipe"] });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
resolveP({ ok: false, unsupported: false, words: e?.message ?? String(e) });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
69
|
+
child.stdout?.on("data", (d) => { out += d; });
|
|
70
|
+
child.stderr?.on("data", (d) => { err += d; });
|
|
71
|
+
child.on("error", (e) => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
resolveP({ ok: false, unsupported: false, words: e?.message ?? String(e) });
|
|
74
|
+
});
|
|
75
|
+
child.on("close", () => {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
if (/bad option|unknown option/i.test(err)) resolveP({ ok: false, unsupported: true, words: "" });
|
|
78
|
+
else if (out.startsWith("ok ")) resolveP({ ok: true, status: Number(out.slice(3)) });
|
|
79
|
+
else resolveP({ ok: false, unsupported: false, words: out.replace(/^err /, "") || err.trim().split("\n").pop() || "no answer" });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* THE VERDICT, from the facts. Pure.
|
|
86
|
+
*
|
|
87
|
+
* direct { ok, status } | { ok:false, kind, words } Node's plain fetch (what the local API does)
|
|
88
|
+
* viaProxy same, or null when no proxy is set
|
|
89
|
+
* systemCa { ok } | { ok:false, unsupported, words }, or null when not tried
|
|
90
|
+
* localApi { ok, version } | { ok:false, error }, or null when this machine has no local API
|
|
91
|
+
*
|
|
92
|
+
* Returns { ok, lines }: lines are for the terminal, a ✓ / ✗ line first and the reason after.
|
|
93
|
+
*/
|
|
94
|
+
export function serviceVerdict({ host, direct, viaProxy = null, systemCa = null, localApi = null, nodeVersion = process.version }) {
|
|
95
|
+
const lines = [];
|
|
96
|
+
if (localApi?.ok) {
|
|
97
|
+
lines.push(`✓ gu service — private mode can fetch the agent's instructions (v${localApi.version})`);
|
|
98
|
+
return { ok: true, lines };
|
|
99
|
+
}
|
|
100
|
+
if (direct.ok) {
|
|
101
|
+
if (!localApi) {
|
|
102
|
+
lines.push(`✓ gu service — ${host} answers`);
|
|
103
|
+
return { ok: true, lines };
|
|
104
|
+
}
|
|
105
|
+
lines.push(`✗ gu service — ${host} answers this computer, but the local API couldn't get the agent's instructions:`);
|
|
106
|
+
lines.push(` ${localApi.error}`);
|
|
107
|
+
// A token problem says its own fix; anything else is most often a local API started before
|
|
108
|
+
// the network came up, or an older one.
|
|
109
|
+
if (!/gu token/.test(localApi.error)) lines.push(" restart the local API (`gu api restart`) and try again.");
|
|
110
|
+
return { ok: false, lines };
|
|
111
|
+
}
|
|
112
|
+
lines.push(`✗ gu service — ${host} can't be reached: ${direct.words}`);
|
|
113
|
+
lines.push(" a coding turn needs it (the agent's instructions come from it); a plain reply like \"hi\" does not.");
|
|
114
|
+
if (direct.kind === "cert") {
|
|
115
|
+
if (systemCa?.ok) {
|
|
116
|
+
lines.push(" It works when Node trusts this computer's own certificates: this network inspects HTTPS.");
|
|
117
|
+
lines.push(" gu doesn't start its local API that way yet (#413) — ask IT, or use another network meanwhile.");
|
|
118
|
+
} else if (systemCa?.unsupported) {
|
|
119
|
+
lines.push(` This network probably inspects HTTPS. Node ${nodeVersion} can't use the system certificates;`);
|
|
120
|
+
lines.push(" Node 22.15 or newer can. Ask IT, or use another network.");
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(" Even with this computer's own certificates it fails — ask IT which certificate the network uses.");
|
|
123
|
+
}
|
|
124
|
+
} else if (viaProxy?.ok) {
|
|
125
|
+
lines.push(" It works through your /proxy: this network needs it, and the local API doesn't use it yet (#413).");
|
|
126
|
+
} else if (viaProxy && !viaProxy.ok) {
|
|
127
|
+
lines.push(` Through your /proxy it fails too: ${viaProxy.words}. Check the proxy settings with /proxy.`);
|
|
128
|
+
} else if (direct.kind === "dns") {
|
|
129
|
+
lines.push(" Check this computer's DNS, or whether the network blocks the address.");
|
|
130
|
+
} else {
|
|
131
|
+
lines.push(` Allow ${host} on this network (firewall), or set /proxy if the network needs one.`);
|
|
132
|
+
}
|
|
133
|
+
if (localApi && !localApi.ok) lines.push(` the local API says: ${localApi.error}`);
|
|
134
|
+
return { ok: false, lines };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Gather the facts and judge them. `hostedBase` is where the local API forwards to; `apiBase` +
|
|
139
|
+
* `workerKey` reach this machine's local API, asked only when `localApi` is true (Client Expert).
|
|
140
|
+
*/
|
|
141
|
+
export async function checkGuService({
|
|
142
|
+
hostedBase,
|
|
143
|
+
apiBase = "",
|
|
144
|
+
workerKey = "",
|
|
145
|
+
localApi = false,
|
|
146
|
+
proxy = null,
|
|
147
|
+
dispatcherFor = null,
|
|
148
|
+
fetchImpl = fetch,
|
|
149
|
+
systemCaImpl = reachWithSystemCa,
|
|
150
|
+
timeoutMs = 10_000,
|
|
151
|
+
} = {}) {
|
|
152
|
+
const base = String(hostedBase ?? "").replace(/\/+$/, "");
|
|
153
|
+
const host = (() => { try { return new URL(base).host; } catch { return base; } })();
|
|
154
|
+
const url = `${base}/api/health`;
|
|
155
|
+
const direct = await reach(url, { fetchImpl, timeoutMs });
|
|
156
|
+
|
|
157
|
+
let viaProxy = null;
|
|
158
|
+
if (!direct.ok && proxy?.enabled && typeof dispatcherFor === "function") {
|
|
159
|
+
const dispatcher = await dispatcherFor(proxy).catch(() => null);
|
|
160
|
+
if (dispatcher) viaProxy = await reach(url, { fetchImpl, dispatcher, timeoutMs });
|
|
161
|
+
}
|
|
162
|
+
const systemCa = !direct.ok && direct.kind === "cert" ? await systemCaImpl(url) : null;
|
|
163
|
+
|
|
164
|
+
let local = null;
|
|
165
|
+
if (localApi && apiBase) {
|
|
166
|
+
try {
|
|
167
|
+
const r = await fetchImpl(`${apiBase.replace(/\/+$/, "")}/api/worker/prompt-scaffold/refresh`, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "X-Worker-Key": workerKey },
|
|
170
|
+
signal: AbortSignal.timeout(timeoutMs + 5000),
|
|
171
|
+
});
|
|
172
|
+
const body = await r.json().catch(() => null);
|
|
173
|
+
local = body?.ok
|
|
174
|
+
? { ok: true, version: body.version }
|
|
175
|
+
: { ok: false, error: String(body?.error ?? `HTTP ${r.status}`) };
|
|
176
|
+
} catch (err) {
|
|
177
|
+
local = { ok: false, error: `the local API didn't answer (${describeNetError(err).words})` };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return serviceVerdict({ host, direct, viaProxy, systemCa, localApi: local });
|
|
181
|
+
}
|