@tiens.nguyen/gu-cli 1.0.835 → 1.0.836

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/install-path.mjs CHANGED
@@ -53,7 +53,7 @@ const isEntryPoint = (() => {
53
53
 
54
54
  if (isEntryPoint) {
55
55
  try {
56
- main();
56
+ await main();
57
57
  } catch (e) {
58
58
  // A postinstall that throws uninstalls the package. Report, swallow, succeed.
59
59
  console.log(`gu-cli: PATH setup skipped (${e.message}) — run \`npx @tiens.nguyen/gu-cli fix-path\` if \`gu\` is not found.`);
@@ -105,7 +105,7 @@ export function fixPathPrefix({ here = "", env = process.env, exists = existsSyn
105
105
  }
106
106
 
107
107
  function main() {
108
- if (platform() === "win32") return; // registry PATH + PowerShell alias: not here
108
+ if (platform() === "win32") return windowsNotes(); // registry PATH: report, never edit (#375)
109
109
  if (process.env.CI) return; // no user shell to help in CI
110
110
  const prefix = process.env.npm_config_prefix;
111
111
  if (!prefix) return; // not an npm install context
@@ -143,6 +143,23 @@ function main() {
143
143
  }
144
144
  }
145
145
 
146
+ /**
147
+ * Windows (#375, GitHub #156): the PATH lives in the registry and is the user's to change, so the
148
+ * install only SAYS whether npm's folder is on it and how to add it — the same words as the
149
+ * installer and `gu-cli fix-path`, which also offers to add it. npm shows this only with
150
+ * --foreground-scripts; the installer (install.ps1) is what most people see.
151
+ */
152
+ async function windowsNotes() {
153
+ if (process.env.CI || process.env.npm_config_global !== "true") return;
154
+ const prefix = process.env.npm_config_prefix;
155
+ if (!prefix) return;
156
+ const wp = await import("./win-path.mjs");
157
+ const status = wp.pathStatus({ dir: prefix, sessionPath: process.env.Path || process.env.PATH || "", ...wp.readSavedPaths() });
158
+ if (status.saved) return;
159
+ const { notes } = wp.installSummary({ version: "", location: "", dir: prefix, status });
160
+ console.log(`gu-cli: !! Setup notes:\n${notes.map((n) => ` \u2022 ${n}`).join("\n")}\n Or run: gu-cli fix-path`);
161
+ }
162
+
146
163
  /** The installer's home — the invoking user's, not root's, under sudo. */
147
164
  function realUserHome() {
148
165
  const sudoUser = process.env.SUDO_USER;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.835",
3
+ "version": "1.0.836",
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",
@@ -126,6 +126,7 @@
126
126
  "skill-source.mjs",
127
127
  "shell-details.mjs",
128
128
  "proc-identity.mjs",
129
+ "win-path.mjs",
129
130
  "browser-command.mjs",
130
131
  "browser-bridge.mjs",
131
132
  "browser-extension/manifest.json",
@@ -146,6 +147,7 @@
146
147
  "gu_skills.py",
147
148
  "gu_test_digest.py",
148
149
  "gu_browser.py",
150
+ "gu_powershell.py",
149
151
  "gu_hooks.py",
150
152
  "prompt_scaffold_check.py",
151
153
  "gonext_transcribe.py",
package/shell-details.mjs CHANGED
@@ -117,23 +117,17 @@ export function statusText(st) {
117
117
  return "ended (exit code unknown)";
118
118
  }
119
119
 
120
- /**
121
- * The bottom notice row's shell control, or null. Running shells first — that is the thing worth
122
- * a row. Otherwise the newest one that finished while this terminal was open and that the user
123
- * has not opened yet, so a build that ends between questions is not missed.
124
- */
125
- export function shellsNotice(entries, { viewed = new Set(), since = 0 } = {}) {
120
+ /** The bottom notice row's shell control while a shell is running, else null. */
121
+ export function shellsNotice(entries) {
122
+ // ONLY WHILE SOMETHING RUNS (#376, GitHub #157). A finished shell used to keep "shell exited 0
123
+ // (click)" on the row until it was opened — a done build still asking for attention. When one
124
+ // finishes, the agent hears about it (a recap turn, or the next step of the turn it is in), and
125
+ // /shells still lists every shell with its output.
126
126
  const running = entries.filter((e) => e.st.state === "running");
127
- if (running.length) {
128
- const one = running.length === 1;
129
- // No runtime here: it would change the row every second, and the bar repaints on change.
130
- return { tone: "accent", text: `${running.length} shell${one ? "" : "s"} running (click)` };
131
- }
132
- const done = entries.filter((e) => !viewed.has(String(e.rec.id)) && e.st.ended >= since);
133
- if (!done.length) return null;
134
- const last = done[done.length - 1];
135
- const ok = last.st.state === "exited";
136
- return { tone: ok ? "accent" : "warn", text: `shell ${statusText(last.st)} (click)` };
127
+ if (!running.length) return null;
128
+ const one = running.length === 1;
129
+ // No runtime here: it would change the row every second, and the bar repaints on change.
130
+ return { tone: "accent", text: `${running.length} shell${one ? "" : "s"} running (click)` };
137
131
  }
138
132
 
139
133
  const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
@@ -257,6 +251,29 @@ export function renderShellDetails({ rec, st, output = [], outputFrom = "", widt
257
251
  return rows;
258
252
  }
259
253
 
254
+ /**
255
+ * Shell details as a panel of EXACTLY `rows` rows (#376, GitHub #157) — drawn at the bottom, above
256
+ * the bar, and redrawn in place every second, so its height must not change while it is open
257
+ * (the in-place redraw walks up as many rows as it drew). The output keeps its NEWEST lines when
258
+ * space is short: a build's last lines are the news. Short content is padded with blank rows.
259
+ */
260
+ export function shellPanelRows({ rec, st, output = [], outputFrom = "", width = 80, gutter = " ", style = {}, rows = 20, footer = "" }) {
261
+ const head = renderShellDetails({ rec, st, output: [], outputFrom, width, gutter, style });
262
+ // renderShellDetails ends with "No output available" when given none; drop it — the real
263
+ // output (or that line) is placed below, in the room that is left.
264
+ head.pop();
265
+ const p = { dim: plain, ...style };
266
+ const room = Math.max(1, rows - head.length - (footer ? 1 : 0));
267
+ const inner = Math.max(20, width - gutter.length);
268
+ const out = output.length
269
+ ? output.slice(-room).map((l) => gutter + (l.length > inner ? l.slice(0, inner - 1) + "…" : l))
270
+ : [gutter + p.dim("No output available")];
271
+ const body = [...head, ...out].slice(0, Math.max(0, rows - (footer ? 1 : 0)));
272
+ while (body.length < rows - (footer ? 1 : 0)) body.push("");
273
+ if (footer) body.push(gutter + p.dim(footer));
274
+ return body.slice(0, rows);
275
+ }
276
+
260
277
  /** A picker row for one shell: status · runtime · command, clipped to `width`. */
261
278
  export function shellPickerLabel(rec, st, width = 80) {
262
279
  const head = `${statusText(st)} · ${fmtRuntime(st.runtime)} · `;
@@ -312,10 +329,14 @@ export function shellsToRecap(entries, { cwd = process.cwd(), since = 0 } = {})
312
329
  const up = dirname(raw);
313
330
  return up === raw ? raw : join(real(up), basename(raw));
314
331
  };
315
- const base = real(cwd).replace(/\/+$/, "");
332
+ // THE SAME PROJECT (#376): the git repository this terminal is in, else this folder. gu started
333
+ // in app/ while the agent ran the build at the repository root (its workspace): the shell's
334
+ // folder is ABOVE this one, and the recap never came, though the build was this terminal's.
335
+ // Not "any folder above" — the home folder is above every project.
336
+ const base = projectRoot(real(cwd)).replace(/\/+$/, "");
316
337
  const under = (wd) => {
317
- const w = real(wd);
318
- return w === base || w.startsWith(base + "/");
338
+ const w = real(wd).replace(/\/+$/, "");
339
+ return Boolean(w) && (w === base || w.startsWith(base + "/"));
319
340
  };
320
341
  return entries
321
342
  .filter(({ rec, st }) => st.state !== "running" && st.state !== "killed" && !rec.seen && !rec.announced
@@ -323,6 +344,17 @@ export function shellsToRecap(entries, { cwd = process.cwd(), since = 0 } = {})
323
344
  .map((e) => e.rec);
324
345
  }
325
346
 
347
+ /** The nearest folder at or above `dir` holding .git — or `dir` itself when there is none. */
348
+ export function projectRoot(dir, exists = existsSync) {
349
+ let d = String(dir ?? "");
350
+ for (;;) {
351
+ if (exists(join(d, ".git"))) return d;
352
+ const up = dirname(d);
353
+ if (up === d) return String(dir ?? "");
354
+ d = up;
355
+ }
356
+ }
357
+
326
358
  /** Mark these shells announced in shells.json, so neither the agent nor another terminal repeats it. */
327
359
  export function markAnnounced(ids, { path = shellsPath() } = {}) {
328
360
  const shells = readShells(path);
@@ -340,6 +372,9 @@ export function recapQuestion(entries) {
340
372
  const parts = entries.map(({ rec, st }) =>
341
373
  `shell ${rec.id} ${statusText(st)} after ${fmtRuntime(st.runtime)} (${String(rec.command ?? "").replace(/\s+/g, " ").trim().slice(0, 120)})`);
342
374
  const ids = entries.map(({ rec }) => `shell_output('${rec.id}')`).join(", ");
375
+ // #376: come back to the work, not only report on it — a failed build is the next thing to fix,
376
+ // a finished one may be the step before the next; a recap only when nothing is left to do.
343
377
  return `[gu: background ${entries.length === 1 ? "shell" : "shells"} finished — ${parts.join("; ")}] `
344
- + `Read the result with ${ids}, tell me briefly what happened, and continue with the next step if the task needs one.`;
378
+ + `Read the result with ${ids}. If it failed, find out why and fix it. If it was a step of the task you `
379
+ + `were doing, carry on with the next step now. If nothing is left to do, tell me in one or two lines what happened.`;
345
380
  }
package/win-path.mjs ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * IS gu ON THE WINDOWS PATH, AND IF NOT, WHAT DOES THE USER DO? (task #375, GitHub #156)
3
+ *
4
+ * On Windows, `npm i -g` puts gu.cmd in npm's global folder (%APPDATA%\npm by default; nvm-windows
5
+ * and a custom prefix put it elsewhere). When that folder is not on PATH, the next `gu` answers
6
+ * "'gu' is not recognized" and the user calls the install broken. Claude Code's installer says it
7
+ * plainly instead — "C:\Users\x\.local\bin is not in your PATH. Add it by opening: System
8
+ * Properties → Environment Variables → Edit User PATH → New → Add the path above. Then restart
9
+ * your terminal." — and gu now does the same, in the same words, from:
10
+ *
11
+ * - the installer (https://gu.getmarsic.cc/install.ps1, web-v2/public — its own PowerShell copy
12
+ * of this wording; tests/win-path.test.mjs holds the two together),
13
+ * - `gu-cli fix-path`, which also offers to add it (a yes, never silently),
14
+ * - npm's postinstall (shown only with --foreground-scripts: npm hides install-script output).
15
+ *
16
+ * WHY THE SAVED PATH AND NOT ONLY THIS ONE: a new terminal reads PATH from the registry (User +
17
+ * Machine), not from the process that installed. A folder in this session only — added by hand,
18
+ * or by an installer that ran here — is gone in the next window.
19
+ *
20
+ * Pure except readSavedPaths / addToUserPath, which run PowerShell.
21
+ */
22
+ import { spawnSync } from "node:child_process";
23
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ /** `%APPDATA%\npm\` / `"C:/x"` / `c:\X` → one comparable form. %VARS% from `env` (case-insensitive). */
28
+ export function normalizeDir(dir, env = process.env) {
29
+ const lower = {};
30
+ for (const [k, v] of Object.entries(env || {})) lower[k.toLowerCase()] = v;
31
+ let d = String(dir ?? "").trim().replace(/^"(.*)"$/, "$1");
32
+ d = d.replace(/%([^%]+)%/g, (m, name) => lower[name.toLowerCase()] ?? m);
33
+ d = d.replace(/\//g, "\\").replace(/\\+$/, "");
34
+ if (/^[a-z]:$/i.test(d)) d += "\\";
35
+ return d.toLowerCase();
36
+ }
37
+
38
+ /** Does the ;-separated PATH value hold `dir`? */
39
+ export function pathHas(pathValue, dir, env = process.env) {
40
+ const want = normalizeDir(dir, env);
41
+ if (!want) return false;
42
+ return String(pathValue ?? "")
43
+ .split(";")
44
+ .some((p) => p.trim() && normalizeDir(p, env) === want);
45
+ }
46
+
47
+ /** { session, saved } — saved = the User or the Machine PATH in the registry. */
48
+ export function pathStatus({ dir, sessionPath = "", userPath = "", machinePath = "", env = process.env }) {
49
+ return {
50
+ session: pathHas(sessionPath, dir, env),
51
+ saved: pathHas(userPath, dir, env) || pathHas(machinePath, dir, env),
52
+ };
53
+ }
54
+
55
+ /** The user PATH value with `dir` appended once — no doubled or trailing `;`. */
56
+ export function userPathWith(userPath, dir, env = process.env) {
57
+ const parts = String(userPath ?? "")
58
+ .split(";")
59
+ .map((p) => p.trim())
60
+ .filter(Boolean);
61
+ if (parts.some((p) => normalizeDir(p, env) === normalizeDir(dir, env))) return parts.join(";");
62
+ return [...parts, dir].join(";");
63
+ }
64
+
65
+ /** npm's global folder from where this package lives: <prefix>\node_modules\@tiens.nguyen\gu-cli. */
66
+ export function windowsPrefixOf(packageDir) {
67
+ const parts = String(packageDir ?? "").replace(/\//g, "\\").replace(/\\+$/, "").split("\\");
68
+ const n = parts.length;
69
+ if (n < 4 || parts[n - 3].toLowerCase() !== "node_modules" || !parts[n - 2].startsWith("@")) return "";
70
+ const prefix = parts.slice(0, n - 3);
71
+ if (prefix.some((p) => p.toLowerCase() === "node_modules")) return ""; // a project's, not global
72
+ return prefix.join("\\");
73
+ }
74
+
75
+ /** Where npm put gu's Windows launcher in that folder (kept here: see powershell-alias.mjs on
76
+ * why gu-cli.mjs never names it). */
77
+ export const guLocation = (dir) => join(dir, "gu.cmd");
78
+
79
+ const ARROW = "\u2192";
80
+
81
+ /** The lines Claude Code's installer prints, in gu's words. The installer prints the same. */
82
+ export const NOTE_PATH = (dir) =>
83
+ `${dir} is not in your PATH. Add it by opening: System Properties ${ARROW} Environment Variables ` +
84
+ `${ARROW} Edit User PATH ${ARROW} New ${ARROW} Add the path above. Then restart your terminal.`;
85
+ export const NOTE_PATH_SESSION = (dir) =>
86
+ `${dir} is in your saved PATH, but not in this terminal yet. Open a new terminal to use gu.`;
87
+ export const NOTE_ALIAS =
88
+ "In PowerShell, `gu` is a built-in alias (Get-Unique) that wins over this program. Run " +
89
+ "`gu-cli fix-powershell` once to remove it; until then start gu with `gu.cmd`. cmd.exe is not affected.";
90
+
91
+ /** The one-liner to add `dir` to the user PATH in PowerShell, keeping %VARS% entries as they are. */
92
+ export function addToPathOneLiner(dir) {
93
+ const q = String(dir).replace(/'/g, "''");
94
+ return (
95
+ `$k = 'HKCU:\\Environment'; $p = (Get-Item $k).GetValue('Path', '', 'DoNotExpandEnvironmentNames'); ` +
96
+ `Set-ItemProperty $k Path (($p.TrimEnd(';') + ';${q}').TrimStart(';')) -Type ExpandString`
97
+ );
98
+ }
99
+
100
+ /**
101
+ * What to print after an install: { ok, lines, notes }. `notes` are the "Setup notes" bullets —
102
+ * empty when there is nothing for the user to do.
103
+ */
104
+ export function installSummary({ version, location, dir, status, powershell = false }) {
105
+ const notes = [];
106
+ if (!status.saved) notes.push(NOTE_PATH(dir), `Or run this in PowerShell: ${addToPathOneLiner(dir)}`);
107
+ else if (!status.session) notes.push(NOTE_PATH_SESSION(dir));
108
+ if (powershell) notes.push(NOTE_ALIAS);
109
+ const lines = [
110
+ "gu successfully installed!",
111
+ "",
112
+ ` Version: ${version || "?"}`,
113
+ "",
114
+ ` Location: ${location}`,
115
+ "",
116
+ // In PowerShell `gu` is Get-Unique until the alias is removed; gu.cmd is the same program.
117
+ ` Next: Run ${powershell ? "gu.cmd" : "gu"} to get started`,
118
+ ];
119
+ return { ok: status.saved, lines, notes };
120
+ }
121
+
122
+ /** Print an installSummary the way the installer does (yellow notes, `•` bullets). */
123
+ export function formatSummary({ lines, notes }, { color = false } = {}) {
124
+ const y = (s) => (color ? `\x1b[33m${s}\x1b[0m` : s);
125
+ const g = (s) => (color ? `\x1b[32m${s}\x1b[0m` : s);
126
+ const out = [g(lines[0]), ...lines.slice(1)];
127
+ if (notes.length) {
128
+ out.push("", y("!! Setup notes:"));
129
+ for (const n of notes) out.push(` \u2022 ${n}`);
130
+ }
131
+ return out.join("\n");
132
+ }
133
+
134
+ // ------------------------------------------------------------------ Windows only ------------
135
+
136
+ const powershellExe = () =>
137
+ join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
138
+
139
+ /** Run a PowerShell script from a FILE (no quoting across a command line), stdout as text. */
140
+ function runPs(script) {
141
+ const dir = mkdtempSync(join(tmpdir(), "gu-path-"));
142
+ const file = join(dir, "x.ps1");
143
+ try {
144
+ writeFileSync(file, "\ufeff" + script, "utf8"); // a BOM: Windows PowerShell 5.1 reads UTF-8 then
145
+ const r = spawnSync(powershellExe(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", file], {
146
+ encoding: "utf8",
147
+ timeout: 30_000,
148
+ windowsHide: true,
149
+ });
150
+ return { ok: !r.error && r.status === 0, out: String(r.stdout || ""), err: String(r.stderr || r.error?.message || "") };
151
+ } finally {
152
+ rmSync(dir, { recursive: true, force: true });
153
+ }
154
+ }
155
+
156
+ /** { userPath, machinePath } from the registry, as stored (%VARS% unexpanded). */
157
+ export function readSavedPaths() {
158
+ const r = runPs(
159
+ "$u = (Get-Item 'HKCU:\\Environment').GetValue('Path', '', 'DoNotExpandEnvironmentNames')\n" +
160
+ "$m = (Get-Item 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment').GetValue('Path', '', 'DoNotExpandEnvironmentNames')\n" +
161
+ "[Console]::Out.Write(\"$u`n$m\")\n",
162
+ );
163
+ const [userPath = "", machinePath = ""] = r.out.split(/\r?\n/);
164
+ return { userPath, machinePath, ok: r.ok };
165
+ }
166
+
167
+ /**
168
+ * Add `dir` to the USER PATH — only ever after the user said yes. Written as REG_EXPAND_SZ so
169
+ * entries like %USERPROFILE%\x keep working (setx and .NET's SetEnvironmentVariable on 5.1 flatten
170
+ * them, and setx cuts PATH at 1024 characters); then the change is announced
171
+ * (WM_SETTINGCHANGE, via a throwaway variable's set + clear) so new terminals see it.
172
+ */
173
+ export function addToUserPath(dir) {
174
+ const q = String(dir).replace(/'/g, "''");
175
+ const r = runPs(
176
+ "$k = 'HKCU:\\Environment'\n" +
177
+ "$p = (Get-Item $k).GetValue('Path', '', 'DoNotExpandEnvironmentNames')\n" +
178
+ `$d = '${q}'\n` +
179
+ "$have = @($p -split ';' | Where-Object { $_.Trim().TrimEnd('\\') -ieq $d.TrimEnd('\\') })\n" +
180
+ "if (-not $have) { Set-ItemProperty $k Path (($p.TrimEnd(';') + ';' + $d).TrimStart(';')) -Type ExpandString }\n" +
181
+ "[Environment]::SetEnvironmentVariable('GU_PATH_REFRESH', '1', 'User')\n" +
182
+ "[Environment]::SetEnvironmentVariable('GU_PATH_REFRESH', $null, 'User')\n",
183
+ );
184
+ return { ok: r.ok, error: r.ok ? "" : r.err.trim() };
185
+ }