@tiens.nguyen/gu-cli 1.0.893 → 1.0.895

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.
@@ -14,6 +14,9 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
16
 
17
+ // Warnings are yellow text, no icon (GH #378: "⚠" draws as a broken emoji on Windows 11).
18
+ const yellow = (s) => (process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[33m${s}\x1b[0m` : String(s));
19
+
17
20
  export const DECLINED_FILE = join(homedir(), ".gonext", "agent-deps-declined");
18
21
 
19
22
  /** Is this start one that should check? Interactive terminals only, and not when a test or script opts out. Pure. */
@@ -74,13 +77,13 @@ export async function agentDepsPreflight({
74
77
  const out = await install();
75
78
  const ok = out && (out.agentLibs === "installed" || out.agentLibs === "present");
76
79
  if (!ok) {
77
- log(" ⚠ the agent framework did not install — run `gu-cli deps` to see why. Starting gu anyway.");
80
+ log(yellow(" the agent framework did not install — run `gu-cli deps` to see why. Starting gu anyway."));
78
81
  return "failed";
79
82
  }
80
83
  log(" ✓ the agent framework is installed.\n");
81
84
  return "installed";
82
85
  } catch (e) {
83
- log(` ⚠ ${e instanceof Error ? e.message : e}\n Starting gu anyway — run \`gu-cli deps\` to retry.`);
86
+ log(yellow(` ${e instanceof Error ? e.message : e}`) + `\n Starting gu anyway — run \`gu-cli deps\` to retry.`);
84
87
  return "failed";
85
88
  }
86
89
  }
@@ -140,36 +140,46 @@ export function allowIn(folder, allow = true) {
140
140
  writeFileSync(allowPath(), JSON.stringify({ folders }, null, 2));
141
141
  }
142
142
 
143
+ /**
144
+ * WHICH LINES ARE WARNINGS, without an icon to say so (GH #378 — "⚠" draws broken on Windows 11).
145
+ * The report stays a plain string array; `.warnings` is the set of its lines the caller paints
146
+ * yellow.
147
+ */
148
+ function withWarnings(lines, firstN = 0, extra = []) {
149
+ lines.warnings = new Set([...lines.slice(0, firstN), ...extra]);
150
+ return lines;
151
+ }
152
+
143
153
  export function browserReport({ connected, version, installType, refused = [], running = true, error = "" }, { allowed = false, storeUrl = STORE_URL, storeInstalled = false } = {}) {
144
154
  // The bridge itself would not start (#170): that is gu's fault, and says so — not "nothing to install".
145
155
  if (!running && error) {
146
- return [
147
- `⚠ gu's browser bridge did not start: ${error}`,
148
- "⚠ This is a problem in gu, not in Chrome — no browser needs installing.",
156
+ return withWarnings([
157
+ `gu's browser bridge did not start: ${error}`,
158
+ "This is a problem in gu, not in Chrome — no browser needs installing.",
149
159
  "Update gu: npm install -g @tiens.nguyen/gu-cli@latest — then restart gu.",
150
160
  "The bridge's log: ~/.gonext/browser-bridge.log",
151
- ];
161
+ ], 2);
152
162
  }
153
163
  // Two copies installed (the store one next to a developer one): the bridge kept one (#148).
154
164
  const twoCopies = refused.length
155
- ? [`⚠ A second gu extension (${refused.map((r) => r.id).join(", ")}) tried to connect and was`,
156
- "⚠ turned away — two copies are installed. Keep one: /browser cleanup removes",
157
- "⚠ the developer (unpacked) copy."]
165
+ ? [`A second gu extension (${refused.map((r) => r.id).join(", ")}) tried to connect and was`,
166
+ "turned away — two copies are installed. Keep one: /browser cleanup removes",
167
+ "the developer (unpacked) copy."]
158
168
  : [];
159
169
  const here = allowed
160
170
  ? "In this folder it may, without asking (/browser forget to undo)."
161
171
  : "In this folder it asks first (/browser allow to stop asking).";
162
172
  if (connected) {
163
173
  const kind = installType === "development" ? ", developer — loaded unpacked" : installType === "normal" ? ", Chrome Web Store" : "";
164
- return [
174
+ return withWarnings([
165
175
  `✓ The gu Chrome extension is connected (v${version}${kind}).`,
166
176
  "The agent uses your Chrome, in its own tabs (the \"gu\" tab group) and tabs",
167
177
  "you share from the extension's icon → Let gu use this tab.",
168
178
  here,
169
179
  ...twoCopies,
170
- ];
180
+ ], 0, twoCopies);
171
181
  }
172
- return [
182
+ return withWarnings([
173
183
  "Nothing to install. When the agent needs a browser it asks you first, then",
174
184
  "opens gu's own Chrome window (a separate profile — not signed in to your",
175
185
  "accounts) and works in it with a real mouse and keyboard.",
@@ -184,7 +194,7 @@ export function browserReport({ connected, version, installType, refused = [], r
184
194
  : ["To let it use YOUR Chrome (your logins, a tab you share): /browser install"]),
185
195
  here,
186
196
  ...twoCopies,
187
- ];
197
+ ], 0, twoCopies);
188
198
  }
189
199
 
190
200
  // /browser install — the extension, for the user's own Chrome. The store page when there is one;
@@ -21,7 +21,7 @@ import { join } from "node:path";
21
21
  import { promisify } from "node:util";
22
22
  import { createHash } from "node:crypto";
23
23
  import { localModeNodeProblem } from "./node-version.mjs";
24
- import { downloadMongodExe, startPortableMongod, portableMongoInstalled, mongoWinPaths, mongodCommandLine, MONGO_WIN_VERSION } from "./mongo-windows.mjs";
24
+ import { downloadMongodExe, startPortableMongod, portableMongoInstalled, mongoWinPaths, mongodCommandLine, MONGO_WIN_VERSION, mongodRuns, isDllNotFoundExit, installVcRuntime, VC_REDIST_URL } from "./mongo-windows.mjs";
25
25
  import {
26
26
  apiDepsInstalled,
27
27
  installApiDeps,
@@ -415,7 +415,7 @@ export function runInTerminal(cmd, { platform = process.platform, spawnFn = spaw
415
415
  * administrator prompt. Shown, asked, then done, like the other platforms. If the download fails
416
416
  * the winget commands are printed as the way out.
417
417
  */
418
- export async function ensureNativeMongoWindows(ask, mongo, { log = (l) => console.log(l), plan = null, waitFor = null, download = downloadMongodExe, start = startPortableMongod } = {}) {
418
+ export async function ensureNativeMongoWindows(ask, mongo, { log = (l) => console.log(l), plan = null, waitFor = null, download = downloadMongodExe, start = startPortableMongod, runs = mongodRuns, installRuntime = installVcRuntime } = {}) {
419
419
  const installing = !mongo.installed;
420
420
  const paths = mongoWinPaths();
421
421
  const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
@@ -445,6 +445,32 @@ export async function ensureNativeMongoWindows(ask, mongo, { log = (l) => consol
445
445
  return { ok: false, explained: true };
446
446
  }
447
447
  }
448
+ // A new Windows 11 has no Visual C++ runtime, and mongod.exe then dies at once with 0xC0000135 — started
449
+ // detached, that shows up only as "not answering". Ask mongod itself first, and say the real reason (GH #363).
450
+ const probe = await runs(paths);
451
+ if (!probe.ok && isDllNotFoundExit(probe.code)) {
452
+ log("\n " + red("✗ ") + "MongoDB cannot start: this Windows is missing Microsoft's Visual C++ runtime");
453
+ log(dim(" (mongod.exe stops with 0xC0000135, \"a DLL was not found\"). It is a free Microsoft download, about 25 MB;"));
454
+ log(dim(" installing it asks for administrator permission once. " + VC_REDIST_URL));
455
+ const ok = await readYesNo(ask, " Install the Visual C++ runtime now? [Y/n]: ", true, log);
456
+ if (!ok) {
457
+ log(dim("\n Not installed — install it from the link above, then run gu again."));
458
+ return { ok: false, explained: true };
459
+ }
460
+ log("\n " + bold("Installing the Visual C++ runtime"));
461
+ const vc = await installRuntime({});
462
+ if (!vc.ok) {
463
+ log("\n " + red("✗ ") + `could not install the Visual C++ runtime: ${vc.error ?? "unknown error"}`);
464
+ log(dim(" Install it yourself from " + VC_REDIST_URL + " , then run gu again."));
465
+ return { ok: false, explained: true };
466
+ }
467
+ log(" " + cyan("✓ Visual C++ runtime installed"));
468
+ const again = await runs(paths);
469
+ if (!again.ok) {
470
+ log("\n " + red("✗ ") + "mongod.exe still cannot run" + (again.code != null ? ` (exit code ${again.code})` : "") + ". A restart of Windows may be needed; then run gu again.");
471
+ return { ok: false, explained: true };
472
+ }
473
+ }
448
474
  let started;
449
475
  try {
450
476
  started = await start({ port, paths });
@@ -813,7 +839,7 @@ export async function askMongoCredentials(ask, askSecret = null) {
813
839
  return { uri, custom: true };
814
840
  }
815
841
  if (verdict.unverifiable) {
816
- console.log("\n " + yellow("⚠ could not verify: ") + dim(verdict.why));
842
+ console.log("\n " + yellow("could not verify: ") + dim(verdict.why));
817
843
  if (await readYesNo(ask, " Continue with these credentials anyway? [Y/n]: ", true)) {
818
844
  return { uri, custom: true, unverified: true };
819
845
  }
@@ -1399,7 +1425,7 @@ export async function runClientExpertSetup({
1399
1425
  }
1400
1426
  } catch (e) {
1401
1427
  // Non-fatal, but say so: a stale daemon is the difference between "set up" and "works".
1402
- console.log("\n " + yellow("⚠ could not restart the background worker: ") + e.message);
1428
+ console.log("\n " + yellow("could not restart the background worker: ") + e.message);
1403
1429
  console.log(dim(" Run `gu-cli restart` — until you do, turns will be enqueued but"));
1404
1430
  console.log(dim(" never picked up, because the running worker is still polling the old API.\n"));
1405
1431
  }
@@ -1544,7 +1570,7 @@ export async function runClientExpertSetup({
1544
1570
  for (const r of await saveSetupKeys({ workerKey: creds.workerKey, models: chosenModels })) {
1545
1571
  console.log(r.ok
1546
1572
  ? " " + cyan("✓ API key stored") + dim(` for ${r.host} — the server's default key`)
1547
- : " " + yellow("⚠ could not store the key") + dim(` for ${r.host} — add it in /model with ctrl+k`));
1573
+ : " " + yellow("could not store the key") + dim(` for ${r.host} — add it in /model with ctrl+k`));
1548
1574
  }
1549
1575
  }
1550
1576
  if (skipModels) {
@@ -1588,7 +1614,7 @@ export async function runClientExpertSetup({
1588
1614
  } catch (e) {
1589
1615
  // NOT fatal: everything else works, and the model can be set from the terminal. But say so
1590
1616
  // clearly, because the symptom otherwise is the terminal refusing to start.
1591
- console.log("\n " + yellow("⚠ could not seed the model settings: ") + e.message);
1617
+ console.log("\n " + yellow("could not seed the model settings: ") + e.message);
1592
1618
  console.log(dim(" The terminal may say no agent model is configured. Set one with `/model`,"));
1593
1619
  console.log(dim(" or run `gu doctor` to set up a local one.\n"));
1594
1620
  state = recordStep(state, "models", "failed", { reason: e.message });
@@ -220,8 +220,8 @@ export function embedSaveFields({ url, model }) {
220
220
  /** The bar's row for a state that is not "on" — "" when RAG is on. One short row (#317). */
221
221
  export function embedNotice({ state, model } = {}) {
222
222
  if (state === "on") return "";
223
- if (state === "off" && model) return `⚠ RAG is switched off — ${model} is set · /embed-model turns it on`;
224
- return "⚠ no embed model — RAG is off · /embed-model";
223
+ if (state === "off" && model) return `RAG is switched off — ${model} is set · /embed-model turns it on`;
224
+ return "no embed model — RAG is off · /embed-model";
225
225
  }
226
226
 
227
227
  /**
@@ -233,9 +233,9 @@ export function embedNotice({ state, model } = {}) {
233
233
  * a RAG tool, or the user opened /embed-model), then the embed row.
234
234
  */
235
235
  export function setupNoticeRows({ noModel = false, agentMissing = false, embedMissing = false, embedNeeded = false, embedWhy } = {}) {
236
- if (noModel) return ["⚠ coding model not set · /model"];
236
+ if (noModel) return ["coding model not set · /model"];
237
237
  return [
238
- ...(agentMissing ? ["⚠ agent model not set — using the coding model · /agent-model"] : []),
239
- ...(embedMissing && embedNeeded ? [embedNotice(embedWhy) || "⚠ no embed model — RAG is off · /embed-model"] : []),
238
+ ...(agentMissing ? ["agent model not set — using the coding model · /agent-model"] : []),
239
+ ...(embedMissing && embedNeeded ? [embedNotice(embedWhy) || "no embed model — RAG is off · /embed-model"] : []),
240
240
  ];
241
241
  }
@@ -2800,7 +2800,7 @@ def _lexical_fallback_search(query: str, files, max_files: int = 8, per_file: in
2800
2800
  total = 0
2801
2801
  for abs_path, _rel in files:
2802
2802
  try:
2803
- with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
2803
+ with _open_project_text(abs_path) as fh:
2804
2804
  found, hits, lines = set(), 0, []
2805
2805
  for i, line in enumerate(fh, 1):
2806
2806
  matched = [t for t, rx in rxs if rx.search(line)]
@@ -5798,11 +5798,23 @@ _RAG_TEXT_EXTS = {
5798
5798
  ".html", ".htm", ".css", ".scss", ".sass", ".less", ".xml", ".yaml", ".yml",
5799
5799
  ".toml", ".ini", ".cfg", ".conf", ".env", ".sh", ".bash", ".zsh", ".sql",
5800
5800
  ".graphql", ".proto", ".gradle", ".dockerfile", ".tsv", ".csv",
5801
+ # WINDOWS / .NET (#490). This list is also what grep_repo can SEE, so a file type missing
5802
+ # here is invisible to search and RAG alike — on a Windows project that was the scripts,
5803
+ # the project files and half the UI: a .csproj, a web.config, a .xaml view, a .ps1 build
5804
+ # step could not be found by any tool, and nothing said so.
5805
+ ".ps1", ".psm1", ".psd1", ".bat", ".cmd", ".vb", ".fs", ".fsx", ".razor", ".cshtml",
5806
+ ".vbhtml", ".xaml", ".axaml", ".csproj", ".vbproj", ".fsproj", ".vcxproj", ".sln",
5807
+ ".props", ".targets", ".config", ".resx", ".manifest", ".reg",
5808
+ # Other common source types the list never had.
5809
+ ".dart", ".scala", ".lua", ".pl", ".pm", ".r", ".ex", ".exs", ".erl", ".hs", ".clj",
5810
+ ".m", ".mm", ".tf", ".hcl", ".cmake", ".groovy", ".kts", ".astro", ".prisma",
5801
5811
  }
5802
5812
  _RAG_SKIP_DIRS = {
5803
5813
  "node_modules", ".git", ".svn", ".hg", "__pycache__", ".venv", "venv",
5804
5814
  "dist", "build", ".next", ".nuxt", "out", ".cache", "bin", "obj", ".idea",
5805
5815
  ".gradle", "vendor", "target",
5816
+ # Visual Studio's per-machine cache (IntelliSense DBs, solution state) — never project code.
5817
+ ".vs",
5806
5818
  # ANOTHER AGENT'S SESSION LOG IS NOT PROJECT STATE. Reported live: asked to "continue",
5807
5819
  # the agent listed .claude-memory/, read latest-checkpoint.md, and reasoned from it —
5808
5820
  # "a partial session about d365-sharepoint-upload … 25 edits … resume from memory" — as if
@@ -6480,6 +6492,44 @@ def _rag_unzip(zip_path: str, dest_dir: str = "") -> tuple:
6480
6492
  return dest_dir, names, False
6481
6493
 
6482
6494
 
6495
+ def _sniff_text_encoding(path: str) -> str:
6496
+ """The encoding to read a project file with: a BOM decides, else UTF-8.
6497
+
6498
+ WINDOWS WRITES UTF-16 (#490). Windows PowerShell 5.1's `>` and Out-File write UTF-16LE, and
6499
+ some Visual Studio / Notepad files are UTF-16 too. Read as UTF-8, every character carries a
6500
+ NUL after it, so no pattern ever matched and the index embedded noise. UTF-8 WITH a BOM
6501
+ (Visual Studio's default for .cs) read fine except line 1, which began with U+FEFF and so
6502
+ never matched `^using`. Only a BOM is trusted: guessing UTF-16 from NUL counts would also
6503
+ "detect" it in binary files.
6504
+ """
6505
+ try:
6506
+ with open(path, "rb") as fh:
6507
+ head = fh.read(4)
6508
+ except OSError:
6509
+ return "utf-8"
6510
+ if head.startswith(b"\xef\xbb\xbf"):
6511
+ return "utf-8-sig"
6512
+ if head.startswith(b"\xff\xfe") or head.startswith(b"\xfe\xff"):
6513
+ return "utf-16" # the codec reads the BOM and picks LE/BE
6514
+ return "utf-8"
6515
+
6516
+
6517
+ def _open_project_text(path: str):
6518
+ """Open a project file for READING ONLY, in the encoding its BOM names (see above)."""
6519
+ return open(path, "r", encoding=_sniff_text_encoding(path), errors="replace")
6520
+
6521
+
6522
+ def _rag_wanted_name(fn: str) -> bool:
6523
+ """Is a file with this NAME one the project tools read (text type, not a lockfile/minified)? Pure.
6524
+ One rule for the RAG walk and both grep engines, so ripgrep and Python see the same files (#380)."""
6525
+ import os
6526
+ ext = os.path.splitext(fn)[1].lower()
6527
+ base = fn.lower()
6528
+ if ext not in _RAG_TEXT_EXTS and base not in ("dockerfile", "makefile", ".gitignore"):
6529
+ return False
6530
+ return not (base in _RAG_SKIP_FILES or base.endswith(_RAG_SKIP_SUFFIXES))
6531
+
6532
+
6483
6533
  def _rag_iter_text_files(root: str):
6484
6534
  """Yield (abs_path, rel_path, ext) for indexable text files under root."""
6485
6535
  import os
@@ -6490,10 +6540,7 @@ def _rag_iter_text_files(root: str):
6490
6540
  dirnames[:] = [d for d in dirnames if d not in _RAG_SKIP_DIRS]
6491
6541
  for fn in filenames:
6492
6542
  ext = os.path.splitext(fn)[1].lower()
6493
- base = fn.lower()
6494
- if ext not in _RAG_TEXT_EXTS and base not in ("dockerfile", "makefile", ".gitignore"):
6495
- continue
6496
- if base in _RAG_SKIP_FILES or base.endswith(_RAG_SKIP_SUFFIXES):
6543
+ if not _rag_wanted_name(fn):
6497
6544
  continue
6498
6545
  abs_path = os.path.join(dirpath, fn)
6499
6546
  try:
@@ -6501,7 +6548,181 @@ def _rag_iter_text_files(root: str):
6501
6548
  continue
6502
6549
  except OSError:
6503
6550
  continue
6504
- yield abs_path, os.path.relpath(abs_path, root), ext
6551
+ # "/" ON EVERY OS (#490). This is the RAG index's key for the file, and what search
6552
+ # results show. With os.sep it was `src\a.py` on Windows and `src/a.py` elsewhere,
6553
+ # so an index could not be shared between the two, and results printed differently
6554
+ # per machine. Windows accepts "/" in every path the tools take.
6555
+ yield abs_path, os.path.relpath(abs_path, root).replace(os.sep, "/"), ext
6556
+
6557
+
6558
+ # ---- grep: ripgrep when there is one, Python when not (#380) ---------------------------------
6559
+ #
6560
+ # Claude Code searches with ripgrep on every OS, Windows 11 included (#379). The Python walk below
6561
+ # needs nothing installed but decodes every line of every file in Python — slow on a big repo.
6562
+ # So grep_repo asks ripgrep first and keeps Python as the fallback. Both must return THE SAME hits:
6563
+ # the same files (skip dirs, text types, lockfiles, 2 MB cap — ripgrep's .gitignore handling is
6564
+ # OFF, because the Python walk never read .gitignore), the same line format, the same cap, and the
6565
+ # same "an invalid regex is searched as plain text". A regex ripgrep cannot run (Python-only
6566
+ # syntax such as look-around or back-references) falls back to Python instead of failing.
6567
+
6568
+ def _rg_binary(env=None):
6569
+ """The ripgrep to use, or "" for none. GU_RG=0 turns it off; GU_RG=<path> picks one; then PATH,
6570
+ then gu's own copy in ~/.gonext/bin."""
6571
+ import os
6572
+ import shutil
6573
+ env = os.environ if env is None else env
6574
+ want = (env.get("GU_RG") or "").strip()
6575
+ if want.lower() in ("0", "off", "false", "no"):
6576
+ return ""
6577
+ if want:
6578
+ return want if os.path.isfile(want) and os.access(want, os.X_OK) else ""
6579
+ found = shutil.which("rg", path=env.get("PATH"))
6580
+ if found:
6581
+ return found
6582
+ own = os.path.join(os.path.expanduser("~"), ".gonext", "bin", "rg.exe" if os.name == "nt" else "rg")
6583
+ return own if os.path.isfile(own) and os.access(own, os.X_OK) else ""
6584
+
6585
+
6586
+ def _grep_hit(path: str, line_no: int, line: str) -> str:
6587
+ """One hit, as both engines print it: ABSOLUTE path (read_file_lines resolves relative paths
6588
+ against the WORKSPACE root, not the search root), line number, the line cut at 200 chars."""
6589
+ return f"{path}:{line_no}: {line.rstrip()[:200]}"
6590
+
6591
+
6592
+ def _grep_hit_order(hit: str):
6593
+ """Sort key: path, then line number as a number (so :9 comes before :10)."""
6594
+ m = re.match(r"^(.*?):(\d+): ", hit)
6595
+ return (m.group(1), int(m.group(2))) if m else (hit, 0)
6596
+
6597
+
6598
+ def _grep_compile(pattern: str):
6599
+ """→ (compiled regex, literal?). An invalid regex is searched as plain text."""
6600
+ try:
6601
+ return re.compile(pattern), False
6602
+ except re.error:
6603
+ return re.compile(re.escape(pattern)), True
6604
+
6605
+
6606
+ def _grep_python(root: str, pattern: str, glob: str = "", limit: int = 100) -> list:
6607
+ import fnmatch
6608
+ import os
6609
+ rx, _ = _grep_compile(pattern)
6610
+ hits = []
6611
+ for abs_path, rel, _ext in _rag_iter_text_files(root):
6612
+ if glob and not fnmatch.fnmatch(os.path.basename(rel), glob) and not fnmatch.fnmatch(rel, glob):
6613
+ continue
6614
+ try:
6615
+ with _open_project_text(abs_path) as fh:
6616
+ for i, line in enumerate(fh, 1):
6617
+ if rx.search(line):
6618
+ hits.append(_grep_hit(abs_path, i, line))
6619
+ if len(hits) >= limit:
6620
+ return hits
6621
+ except OSError:
6622
+ continue
6623
+ return hits
6624
+
6625
+
6626
+ def _grep_rg_args(rg: str, root: str, pattern: str, literal: bool) -> list:
6627
+ """The ripgrep command line that selects the files the Python walk would. Pure."""
6628
+ args = [rg, "--no-config", "--no-ignore", "--hidden", "--no-messages", "--null", "--line-number",
6629
+ "--with-filename", "--no-heading", "--color=never", "--max-filesize", str(_RAG_MAX_FILE_BYTES)]
6630
+ # No --sort: it makes ripgrep single-threaded — measured 12 s instead of 2.3 s over 85 GB of
6631
+ # projects. The hits that come back are sorted instead; past the cap, which 100 is "first
6632
+ # found", as it always was with the Python walk (directory order).
6633
+ if literal:
6634
+ args.append("--fixed-strings")
6635
+ for d in sorted(_RAG_SKIP_DIRS):
6636
+ args += ["--glob", f"!{d}/"]
6637
+ for e in sorted(_RAG_TEXT_EXTS):
6638
+ args += ["--iglob", f"*{e}"]
6639
+ for n in ("dockerfile", "makefile", ".gitignore"):
6640
+ args += ["--iglob", n]
6641
+ return args + ["--regexp", pattern, "--", root]
6642
+
6643
+
6644
+ def _grep_rg(rg: str, root: str, pattern: str, glob: str = "", limit: int = 100, timeout: float = 60.0):
6645
+ """→ hits, or None when ripgrep could not do it (missing, crashed, or a regex it does not support)
6646
+ so the caller falls back to Python. The file list is filtered by the SAME rule as the walk."""
6647
+ import fnmatch
6648
+ import os
6649
+ import subprocess
6650
+ import time
6651
+ _rx, literal = _grep_compile(pattern)
6652
+ single_file = os.path.isfile(root)
6653
+ try:
6654
+ proc = subprocess.Popen(_grep_rg_args(rg, root, pattern, literal), stdout=subprocess.PIPE,
6655
+ stderr=subprocess.PIPE, stdin=subprocess.DEVNULL)
6656
+ except OSError:
6657
+ return None
6658
+ hits = []
6659
+ started = time.monotonic()
6660
+ try:
6661
+ for raw in proc.stdout:
6662
+ name, sep, rest = raw.partition(b"\0")
6663
+ if not sep:
6664
+ continue
6665
+ num, _, text = rest.partition(b":")
6666
+ path = os.path.normpath(name.decode("utf-8", "surrogateescape"))
6667
+ if not single_file:
6668
+ base = os.path.basename(path)
6669
+ if not _rag_wanted_name(base):
6670
+ continue
6671
+ if glob:
6672
+ rel = os.path.relpath(path, root).replace(os.sep, "/")
6673
+ if not fnmatch.fnmatch(base, glob) and not fnmatch.fnmatch(rel, glob):
6674
+ continue
6675
+ try:
6676
+ line_no = int(num)
6677
+ except ValueError:
6678
+ continue
6679
+ hits.append(_grep_hit(path, line_no, text.decode("utf-8", "replace")))
6680
+ if len(hits) >= limit or time.monotonic() - started > timeout:
6681
+ break
6682
+ finally:
6683
+ if proc.poll() is None:
6684
+ proc.kill()
6685
+ try:
6686
+ err = proc.stderr.read().decode("utf-8", "replace")
6687
+ except Exception: # noqa: BLE001
6688
+ err = ""
6689
+ proc.wait()
6690
+ proc.stdout.close()
6691
+ proc.stderr.close()
6692
+ # 0 = hits, 1 = none; 2 = an error. Killed on purpose after the cap → keep what we have.
6693
+ if proc.returncode == 2 and not hits:
6694
+ _log(f"grep: ripgrep could not search ({err.strip()[:200]}) — using the Python search")
6695
+ return None
6696
+ return sorted(hits, key=_grep_hit_order)
6697
+
6698
+
6699
+ def _grep_search(root: str, pattern: str, glob: str = "", limit: int = 100, env=None):
6700
+ """→ (hits, engine) with engine "ripgrep" or "python"."""
6701
+ rg = _rg_binary(env)
6702
+ if rg:
6703
+ hits = _grep_rg(rg, root, pattern, glob, limit)
6704
+ if hits is not None:
6705
+ return hits, "ripgrep"
6706
+ return _grep_python(root, pattern, glob, limit), "python"
6707
+
6708
+
6709
+ def _rag_posix_chunk(c: dict, sep: str = "") -> dict:
6710
+ """Move a chunk from an index built with `\\` paths (Windows, before #490) to `/` paths.
6711
+
6712
+ In place and without re-embedding: only the file key and the location line at the top of
6713
+ its text change, so an existing Windows index keeps its vectors instead of re-embedding
6714
+ the whole project once. A no-op off Windows, where `\\` can be part of a real file name.
6715
+ """
6716
+ if (sep or os.sep) != "\\":
6717
+ return c
6718
+ f = c.get("file")
6719
+ if isinstance(f, str) and "\\" in f:
6720
+ new = f.replace("\\", "/")
6721
+ t = c.get("text")
6722
+ if isinstance(t, str) and t.startswith(f + ":"):
6723
+ c["text"] = new + t[len(f):]
6724
+ c["file"] = new
6725
+ return c
6505
6726
 
6506
6727
 
6507
6728
  # A line that STARTS a definition — where a chunk should be cut, and what names it. Kept
@@ -8376,6 +8597,30 @@ def _pipe_hidden_failure_note(line: str, ps: bool = False) -> str:
8376
8597
  + ("." if ps else ", or start the line with `set -o pipefail;`."))
8377
8598
 
8378
8599
 
8600
+ # A UNIX SEARCH COMMAND IN POWERSHELL (#490). Windows has no grep/rg/xargs, and its `find` is
8601
+ # FIND.EXE — a different program that answers `find . -name x` with "Parameter format not
8602
+ # correct". The model learned search as a shell habit, so it reaches for these anyway; the
8603
+ # error alone sends it to guess at Select-String syntax next. grep_repo / list_dir do the same
8604
+ # job inside gu on every OS, so the failure names them.
8605
+ _PS_NO_SEARCH_TOOL_RE = re.compile(
8606
+ r"The term '(grep|egrep|fgrep|rg|ag|ack|xargs|locate|fd)' is not recognized"
8607
+ r"|^(FIND): (?:Parameter format not correct|Invalid switch)",
8608
+ re.M | re.I)
8609
+
8610
+
8611
+ def _ps_search_tool_hint(out: str) -> str:
8612
+ """A pointer to grep_repo when PowerShell could not run a Unix search command, else ""."""
8613
+ m = _PS_NO_SEARCH_TOOL_RE.search(out or "")
8614
+ if not m:
8615
+ return ""
8616
+ prog = (m.group(1) or m.group(2) or "").lower()
8617
+ what = ("`find` here is Windows' FIND.EXE, not Unix find" if prog == "find"
8618
+ else f"`{prog}` does not exist on Windows")
8619
+ return (f"\n\nNOTE — {what}. Search file CONTENTS with grep_repo(pattern, path, glob) "
8620
+ "and list files with list_dir(path); both run inside gu on every OS. Do not retry "
8621
+ "with Select-String or Get-ChildItem -Recurse.")
8622
+
8623
+
8379
8624
  # THE PROJECT'S OWN TEST COMMANDS (#360 review). The model guessed `python3 -m pytest` in a repo
8380
8625
  # whose package.json says `python3 -m unittest discover tests -v && node --test` — pytest was not
8381
8626
  # even installed. The manifests already say how each part of the project is tested; reading them
@@ -9320,11 +9565,15 @@ def _ws_write_file(path: str, content: str, action: str, backup) -> None:
9320
9565
  import tempfile
9321
9566
  _ws_record_change(action, path, backup)
9322
9567
  target = os.path.realpath(path)
9568
+ # KEEP THE FILE'S ENCODING (#490). Reads decode a BOM'd UTF-8 or UTF-16 file, so an edit
9569
+ # must write it back the same way — a UTF-16 .resx rewritten as UTF-8 is a different file to
9570
+ # Visual Studio, and dropping a BOM shows up as a whole-file diff. No BOM = UTF-8, as before.
9571
+ enc = _sniff_text_encoding(target) if os.path.isfile(target) else "utf-8"
9323
9572
  _STOP["writing"] += 1
9324
9573
  try:
9325
9574
  fd, tmp = tempfile.mkstemp(prefix=".gu-write-", suffix=".tmp", dir=os.path.dirname(target) or ".")
9326
9575
  try:
9327
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
9576
+ with os.fdopen(fd, "w", encoding=enc) as fh:
9328
9577
  fh.write(content)
9329
9578
  fh.flush()
9330
9579
  os.fsync(fh.fileno())
@@ -9340,7 +9589,7 @@ def _ws_write_file(path: str, content: str, action: str, backup) -> None:
9340
9589
  except PermissionError:
9341
9590
  if not _IS_WINDOWS:
9342
9591
  raise
9343
- with open(target, "w", encoding="utf-8") as fh:
9592
+ with open(target, "w", encoding=enc) as fh:
9344
9593
  fh.write(content)
9345
9594
  os.unlink(tmp)
9346
9595
  _agent_created_add(target)
@@ -14480,7 +14729,7 @@ def run_agent_chat(cfg):
14480
14729
  _emit_read("Reading file", path, "skipped — already read, unchanged")
14481
14730
  _last_obs["text"] = _rb
14482
14731
  return _rb
14483
- with open(rp, "r", encoding="utf-8", errors="replace") as fh:
14732
+ with _open_project_text(rp) as fh:
14484
14733
  data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
14485
14734
  out = _ws_cap_obs(data or "(empty file)", cap=_OBS_CAPS["read"])
14486
14735
  _emit_read("Reading file", path, f"{data.count(chr(10)) + 1} line(s)"
@@ -14546,7 +14795,7 @@ def run_agent_chat(cfg):
14546
14795
  # drop what was deleted. Nothing extra is needed to catch the agent's OWN edits:
14547
14796
  # writing a file changes its mtime.
14548
14797
  import os as _os_idx
14549
- _prev = _rag_load_all(source_key) if not replaced else []
14798
+ _prev = [_rag_posix_chunk(_c) for _c in (_rag_load_all(source_key) if not replaced else [])]
14550
14799
  _prev_stamp = {}
14551
14800
  for _c in _prev:
14552
14801
  _st = _c.get("stamp")
@@ -14576,7 +14825,7 @@ def run_agent_chat(cfg):
14576
14825
  unchanged_files += 1
14577
14826
  continue
14578
14827
  try:
14579
- with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
14828
+ with _open_project_text(abs_path) as fh:
14580
14829
  text = fh.read()
14581
14830
  except OSError:
14582
14831
  skipped += 1
@@ -15156,33 +15405,9 @@ def run_agent_chat(cfg):
15156
15405
  try:
15157
15406
  _emit_running("Searching code",
15158
15407
  _fmt_call("grep_repo", pattern=pattern, path=path, glob=glob))
15159
- import fnmatch
15160
- import os
15161
15408
  root = _rag_read_allowed((path or "").strip() or _default_ws_root())
15162
- try:
15163
- rx = re.compile(pattern)
15164
- except re.error:
15165
- rx = re.compile(re.escape(pattern))
15166
- hits = []
15167
- for abs_path, rel, _ext in _rag_iter_text_files(root):
15168
- if glob and not fnmatch.fnmatch(os.path.basename(rel), glob) \
15169
- and not fnmatch.fnmatch(rel, glob):
15170
- continue
15171
- try:
15172
- with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
15173
- for i, line in enumerate(fh, 1):
15174
- if rx.search(line):
15175
- # ABSOLUTE path — read_file_lines/read_text_file resolve
15176
- # relative paths against the WORKSPACE root, not this
15177
- # search root, so a path relative to `root` (e.g. when
15178
- # searching a subfolder) would be silently wrong there.
15179
- hits.append(f"{abs_path}:{i}: {line.rstrip()[:200]}")
15180
- if len(hits) >= 100:
15181
- break
15182
- except OSError:
15183
- continue
15184
- if len(hits) >= 100:
15185
- break
15409
+ # ripgrep when there is one (as Claude Code does, #379/#380), else the Python walk.
15410
+ hits, _engine = _grep_search(root, pattern, glob, 100)
15186
15411
  _search_tally_bump("grep", len(hits), pattern)
15187
15412
  _emit({"type": "step", "text": f"Searching code → {pattern[:60]} ({len(hits)} hits)"})
15188
15413
  if not hits:
@@ -15220,7 +15445,7 @@ def run_agent_chat(cfg):
15220
15445
  defs, calls, tests = [], [], []
15221
15446
  for abs_path, rel, _ext in _rag_iter_text_files(root):
15222
15447
  try:
15223
- with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
15448
+ with _open_project_text(abs_path) as fh:
15224
15449
  text = fh.read()
15225
15450
  except OSError:
15226
15451
  continue
@@ -15302,7 +15527,7 @@ def run_agent_chat(cfg):
15302
15527
  start_line=int(start_line or 1),
15303
15528
  end_line=int(end_line or 0)))
15304
15529
  rp = _rag_read_allowed((path or "").strip())
15305
- with open(rp, "r", encoding="utf-8", errors="replace") as fh:
15530
+ with _open_project_text(rp) as fh:
15306
15531
  lines = fh.readlines()
15307
15532
  s = max(1, int(start_line or 1))
15308
15533
  e = int(end_line or 0) or len(lines)
@@ -15350,7 +15575,7 @@ def run_agent_chat(cfg):
15350
15575
  _last_obs["text"] = msg
15351
15576
  return msg
15352
15577
  backup = _ws_snapshot(rp)
15353
- with open(rp, "r", encoding="utf-8", errors="replace") as fh:
15578
+ with _open_project_text(rp) as fh:
15354
15579
  lines = fh.readlines()
15355
15580
  s, e = int(start_line), int(end_line)
15356
15581
  if not (1 <= s <= e <= len(lines)):
@@ -15403,7 +15628,7 @@ def run_agent_chat(cfg):
15403
15628
  msg = f"Error: not a file: {path}"
15404
15629
  _last_obs["text"] = msg
15405
15630
  return msg
15406
- with open(rp, "r", encoding="utf-8", errors="replace") as fh:
15631
+ with _open_project_text(rp) as fh:
15407
15632
  text = fh.read()
15408
15633
  n = text.count(old_string)
15409
15634
  if n == 0:
@@ -15964,6 +16189,8 @@ def run_agent_chat(cfg):
15964
16189
  result += _pipe_hidden_failure_note(_hidden, ps=_ps)
15965
16190
  _emit({"type": "step", "text":
15966
16191
  f"Command failed behind a pipe → {_trim_words(_hidden)}"})
16192
+ if _ps:
16193
+ result += _ps_search_tool_hint(out)
15967
16194
  if rc != 0 and _use_shell and (_PS_QUOTE_ERR_RE if _ps else _SHELL_QUOTE_ERR_RE).search(out or ""):
15968
16195
  result += _PS_HEREDOC_HINT if _ps else _HEREDOC_HINT
15969
16196
  # Say where the shell now is, so a next `cd sub` is not done twice (#362).
@@ -16968,7 +17195,7 @@ def run_agent_chat(cfg):
16968
17195
  # "the real attempt never got to run" (seen live: workspace summarize request →
16969
17196
  # coding model timed out 3x on a slow/unreachable remote Ollama box → silent
16970
17197
  # fallback claimed "no access to your workspace").
16971
- note = f"⚠️ I couldn't finish the full investigation ({_clip(str(e), 120)}) — here's what I can say without it:\n\n{fallback}"
17198
+ note = f"I couldn't finish the full investigation ({_clip(str(e), 120)}) — here's what I can say without it:\n\n{fallback}"
16972
17199
  # Append the workspace's ACTUAL top-level contents (computed locally, no model
16973
17200
  # call — see _workspace_overview) so the user sees their files are really there
16974
17201
  # even during a total coding-model outage, instead of trusting a plain-chat