harnery 0.7.1 → 0.8.0

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.
@@ -1,12 +1,26 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { resolveSearchEngine } from "../lib/tools/ripgrep.js";
2
3
  /**
3
- * `grep`: monorepo-aware code search. Thin wrapper over grep -rn with
4
- * smart default excludes (skip dist/.next/node_modules/.git/...), repo
4
+ * `grep`: monorepo-aware code search. Prefers ripgrep (`rg`) when it is on
5
+ * PATH and falls back to GNU `grep -rn` transparently — both engines are
6
+ * driven with equivalent flags and their output is parsed into the same
7
+ * envelope, so results are identical (pinned by tests/unit/grep-engine.test.ts).
8
+ * Smart default excludes (skip dist/.next/node_modules/.git/...), repo
5
9
  * scoping (`--repo <name>` or `--all-repos`), and language presets.
6
10
  *
7
- * Default behavior matches grep's "regex" semantics (`-E` extended). Use
8
- * `-F` / `--literal` to pin to literal-string mode. Output is line-oriented
9
- * `file:line:content` in TTY mode, `{rows, total, truncated}` in --json mode.
11
+ * Engine selection: `HARNERY_GREP_ENGINE=rg|grep` forces one; otherwise `rg`
12
+ * is resolved (managed install, PATH, or opt-in auto-provision; see resolveEngine) and used when available. Repos are searched in
13
+ * parallel, and in `--all-repos` mode the parent scan prunes submodule
14
+ * directories so each match is attributed to exactly one repo (previously the
15
+ * parent scan descended into submodules, double-scanning and double-reporting
16
+ * every submodule match).
17
+ *
18
+ * Default behavior matches extended-regex semantics (`grep -E` / ripgrep's
19
+ * default). Use `-F` / `--literal` to pin to literal-string mode. Output is
20
+ * line-oriented `file:line:content` in TTY mode, `{rows, total, truncated}`
21
+ * in --json mode. Matches are sorted (file, then line) for stable output
22
+ * across runs and engines; with `-C <n>` context, engine order is kept so
23
+ * context groups stay adjacent.
10
24
  */
11
25
  const DEFAULT_EXCLUDE_DIRS = [
12
26
  ".git",
@@ -47,7 +61,8 @@ const LANG_GLOBS = {
47
61
  export function registerGrepCommand(program, emit, context) {
48
62
  program
49
63
  .command("grep <pattern> [paths...]")
50
- .description("Monorepo-aware code search. Skips dist/.next/node_modules/.git/... by default. " +
64
+ .description("Monorepo-aware code search (ripgrep when available, GNU grep fallback). " +
65
+ "Skips dist/.next/node_modules/.git/... by default. " +
51
66
  "Use --repo, --all-repos, --lang for scoping. Regex by default; -F for literal.")
52
67
  .option("--repo <name>", "Scope to one submodule (`.` = parent repo root)")
53
68
  .option("--all-repos", "Search parent + every submodule")
@@ -56,6 +71,8 @@ export function registerGrepCommand(program, emit, context) {
56
71
  .option("-w, --whole-word", "Match whole words only")
57
72
  .option("-F, --literal", "Treat <pattern> as a literal string (no regex)")
58
73
  .option("-l, --files-only", "Only print file names containing a match")
74
+ .option("--files", "Filename search: treat <pattern> as a filename glob and list matching files " +
75
+ "(rg --files when available, POSIX find fallback)")
59
76
  .option("-c, --count", "Print match count per file (suppresses content)")
60
77
  .option("-C, --context <n>", "Print N lines of context around each match", "0")
61
78
  .option("--max-count <n>", "Stop after N matches per file")
@@ -83,33 +100,72 @@ export function registerGrepCommand(program, emit, context) {
83
100
  function collect(value, prev) {
84
101
  return [...prev, value];
85
102
  }
86
- async function runGrep(pattern, paths, opts, context) {
103
+ /** Exported for tests (not part of the package exports map). */
104
+ export async function runGrep(pattern, paths, opts, context) {
87
105
  if (!pattern)
88
106
  throw new Error("pattern required");
107
+ if (opts.files) {
108
+ // Filename mode lists files by name glob; content-search flags make no
109
+ // sense here — reject loudly rather than silently ignoring them.
110
+ const incompatible = [
111
+ [opts.lang, "--lang"],
112
+ [opts.count, "-c/--count"],
113
+ [opts.wholeWord, "-w/--whole-word"],
114
+ [opts.literal, "-F/--literal"],
115
+ [opts.maxCount, "--max-count"],
116
+ [opts.include?.length, "--include"],
117
+ [Number.parseInt(opts.context ?? "0", 10) > 0 ? true : undefined, "-C/--context"],
118
+ ];
119
+ for (const [set, flag] of incompatible) {
120
+ if (set)
121
+ throw new Error(`${flag} does not apply to --files (filename glob) mode`);
122
+ }
123
+ }
89
124
  const started = Date.now();
125
+ const { engine, rgBin } = await resolveSearchEngine("grep");
90
126
  const repos = resolveRepos(opts, context);
91
127
  const limit = opts.limit ? Number.parseInt(opts.limit, 10) : Number.POSITIVE_INFINITY;
128
+ const contextN = Number.parseInt(opts.context ?? "0", 10);
129
+ const sortable = !(Number.isFinite(contextN) && contextN > 0);
130
+ // Host-injected default excludes (generated mirrors, vendored trees, ...)
131
+ // ride the same --no-default-excludes gate as the built-in list.
132
+ const hostExcludeDirs = opts.noDefaultExcludes ? [] : (context?.grepExcludeDirs ?? []);
133
+ // All repos are searched concurrently; each is capped at the global limit
134
+ // (a single repo can never contribute more), then the global budget is
135
+ // applied in repo order below so `--limit` semantics stay deterministic.
136
+ const perRepo = await Promise.all(repos.map((repo) => {
137
+ // In --all-repos mode the parent scan prunes submodule dirs — each
138
+ // submodule gets its own scoped scan, so descending from the parent
139
+ // would double-scan and double-report every submodule match. This
140
+ // pruning is correctness (one repo owns each match), so it applies
141
+ // even when --no-default-excludes is set.
142
+ const dedupeDirs = opts.allRepos && repo.name === "parent" ? (context?.submodules ?? []) : [];
143
+ const extraDirs = [...hostExcludeDirs, ...dedupeDirs];
144
+ return runGrepInRepo(pattern, paths, opts, repo.cwd, repo.name, limit, engine, rgBin, extraDirs);
145
+ }));
92
146
  const allRepoResults = [];
93
147
  let totalMatches = 0;
94
148
  const filesSeen = new Set();
95
149
  let truncated = false;
96
- for (const repo of repos) {
97
- if (truncated)
98
- break;
99
- const repoLimit = Number.isFinite(limit)
100
- ? Math.max(0, limit - totalMatches)
101
- : Number.POSITIVE_INFINITY;
102
- if (repoLimit === 0) {
103
- truncated = true;
104
- allRepoResults.push({ name: repo.name, cwd: repo.cwd, matches: [], truncated: true });
105
- break;
150
+ let budget = limit;
151
+ for (let i = 0; i < repos.length; i++) {
152
+ const repo = repos[i];
153
+ if (!repo)
154
+ continue;
155
+ const collected = perRepo[i] ?? [];
156
+ if (sortable) {
157
+ collected.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
106
158
  }
107
- const matches = await runGrepInRepo(pattern, paths, opts, repo.cwd, repo.name, repoLimit);
108
- let repoTruncated = false;
109
- if (Number.isFinite(repoLimit) && matches.length >= repoLimit) {
110
- repoTruncated = true;
159
+ const engineCapped = Number.isFinite(limit) && collected.length >= limit;
160
+ const take = Number.isFinite(budget)
161
+ ? Math.min(collected.length, Math.max(0, budget))
162
+ : collected.length;
163
+ const matches = collected.slice(0, take);
164
+ const repoTruncated = engineCapped || collected.length > take;
165
+ if (repoTruncated)
111
166
  truncated = true;
112
- }
167
+ if (Number.isFinite(budget))
168
+ budget -= take;
113
169
  totalMatches += matches.length;
114
170
  for (const m of matches)
115
171
  filesSeen.add(`${repo.name}/${m.file}`);
@@ -117,7 +173,8 @@ async function runGrep(pattern, paths, opts, context) {
117
173
  }
118
174
  return {
119
175
  pattern,
120
- mode: opts.literal ? "literal" : "regex",
176
+ mode: opts.files ? "files" : opts.literal ? "literal" : "regex",
177
+ engine,
121
178
  repos: allRepoResults,
122
179
  total_matches: totalMatches,
123
180
  total_files: filesSeen.size,
@@ -155,8 +212,32 @@ function resolveRepos(opts, context) {
155
212
  }
156
213
  return [{ name: "cwd", cwd: process.cwd() }];
157
214
  }
158
- async function runGrepInRepo(pattern, paths, opts, cwd, repoName, limit) {
159
- const args = ["-rn", "--color=never", "-I"]; // -I: skip binary files
215
+ async function runGrepInRepo(pattern, paths, opts, cwd, repoName, limit, engine, rgBin, extraExcludeDirs) {
216
+ // Filename mode: `rg --files` when available; POSIX `find` otherwise (GNU
217
+ // grep has no list-by-name mode). Content mode: rg / grep as selected.
218
+ const [bin, args] = opts.files
219
+ ? engine === "rg"
220
+ ? [rgBin, buildRgFilesArgs(pattern, paths, opts, extraExcludeDirs)]
221
+ : ["find", buildFindArgs(pattern, paths, opts, extraExcludeDirs)]
222
+ : engine === "rg"
223
+ ? [rgBin, buildRgArgs(pattern, paths, opts, extraExcludeDirs)]
224
+ : ["grep", buildGrepArgs(pattern, paths, opts, extraExcludeDirs)];
225
+ let matches = await execSearch(bin, args, cwd, limit);
226
+ // -c mode: GNU grep prints a `path:0` row for every searched file; ripgrep
227
+ // omits zero-count rows. Filter zeros on both engines so output matches.
228
+ if (opts.count)
229
+ matches = matches.filter((m) => m.line > 0);
230
+ return matches.map((m) => ({ ...m, repo: repoName }));
231
+ }
232
+ function resolveLangGlobs(opts) {
233
+ const langGlobs = opts.lang ? LANG_GLOBS[opts.lang] : undefined;
234
+ if (opts.lang && !langGlobs) {
235
+ throw new Error(`unknown --lang "${opts.lang}". Valid: ${Object.keys(LANG_GLOBS).join(", ")}`);
236
+ }
237
+ return langGlobs;
238
+ }
239
+ function buildGrepArgs(pattern, paths, opts, extraExcludeDirs) {
240
+ const args = ["-rn", "-H", "--color=never", "-I"]; // -I: skip binary files
160
241
  if (opts.ignoreCase)
161
242
  args.push("-i");
162
243
  if (opts.wholeWord)
@@ -183,12 +264,11 @@ async function runGrepInRepo(pattern, paths, opts, cwd, repoName, limit) {
183
264
  for (const f of DEFAULT_EXCLUDE_FILES)
184
265
  args.push(`--exclude=${f}`);
185
266
  }
267
+ for (const d of extraExcludeDirs)
268
+ args.push(`--exclude-dir=${d}`);
186
269
  for (const e of opts.exclude ?? [])
187
270
  args.push(`--exclude=${e}`);
188
- const langGlobs = opts.lang ? LANG_GLOBS[opts.lang] : undefined;
189
- if (opts.lang && !langGlobs) {
190
- throw new Error(`unknown --lang "${opts.lang}". Valid: ${Object.keys(LANG_GLOBS).join(", ")}`);
191
- }
271
+ const langGlobs = resolveLangGlobs(opts);
192
272
  if (langGlobs)
193
273
  for (const g of langGlobs)
194
274
  args.push(`--include=${g}`);
@@ -199,12 +279,125 @@ async function runGrepInRepo(pattern, paths, opts, cwd, repoName, limit) {
199
279
  args.push(...paths);
200
280
  else
201
281
  args.push(".");
202
- const matches = await execGrep(args, cwd, limit);
203
- return matches.map((m) => ({ ...m, repo: repoName }));
282
+ return args;
204
283
  }
205
- function execGrep(args, cwd, limit) {
284
+ function buildRgArgs(pattern, paths, opts, extraExcludeDirs) {
285
+ // --hidden --no-ignore: match GNU grep's semantics (search dotdirs, ignore
286
+ // .gitignore) so the only filters are the explicit exclude lists.
287
+ // --no-config: a user's ripgrep config file must not skew results.
288
+ // --with-filename: rg drops the file prefix for a single explicit file arg,
289
+ // which would break the shared parser.
290
+ const args = [
291
+ "-n",
292
+ "--no-heading",
293
+ "--with-filename",
294
+ "--color=never",
295
+ "--no-config",
296
+ "--hidden",
297
+ "--no-ignore",
298
+ ];
299
+ if (opts.ignoreCase)
300
+ args.push("-i");
301
+ if (opts.wholeWord)
302
+ args.push("-w");
303
+ if (opts.literal)
304
+ args.push("-F");
305
+ if (opts.filesOnly)
306
+ args.push("-l");
307
+ if (opts.count)
308
+ args.push("-c");
309
+ const contextN = Number.parseInt(opts.context ?? "0", 10);
310
+ if (Number.isFinite(contextN) && contextN > 0)
311
+ args.push(`-C${contextN}`);
312
+ if (opts.maxCount) {
313
+ const n = Number.parseInt(opts.maxCount, 10);
314
+ if (Number.isFinite(n) && n > 0)
315
+ args.push(`-m${n}`);
316
+ }
317
+ // Gitignore-style globs: a bare name matches (and prunes) at any depth,
318
+ // mirroring grep's --exclude-dir / --exclude basename semantics. ORDER
319
+ // MATTERS: rg globs are last-match-wins, so positives (includes/lang) go
320
+ // first and negatives (excludes) last — otherwise `--include '*.md'` would
321
+ // re-include a .md file inside an excluded node_modules/. grep's
322
+ // --exclude-dir always beats --include, so this keeps the engines aligned.
323
+ const langGlobs = resolveLangGlobs(opts);
324
+ if (langGlobs)
325
+ for (const g of langGlobs)
326
+ args.push(`--glob=${g}`);
327
+ for (const g of opts.include ?? [])
328
+ args.push(`--glob=${g}`);
329
+ if (!opts.noDefaultExcludes) {
330
+ for (const d of DEFAULT_EXCLUDE_DIRS)
331
+ args.push(`--glob=!${d}`);
332
+ for (const f of DEFAULT_EXCLUDE_FILES)
333
+ args.push(`--glob=!${f}`);
334
+ }
335
+ for (const d of extraExcludeDirs)
336
+ args.push(`--glob=!${d}`);
337
+ for (const e of opts.exclude ?? [])
338
+ args.push(`--glob=!${e}`);
339
+ args.push("--", pattern);
340
+ if (paths.length > 0)
341
+ args.push(...paths);
342
+ else
343
+ args.push(".");
344
+ return args;
345
+ }
346
+ /**
347
+ * Filename mode via ripgrep: `--files` lists files; a single positive glob
348
+ * acts as a whitelist, negative globs prune. `--iglob` gives -i semantics.
349
+ */
350
+ function buildRgFilesArgs(pattern, paths, opts, extraExcludeDirs) {
351
+ const args = ["--files", "--hidden", "--no-ignore", "--no-config", "--color=never"];
352
+ // Positive pattern FIRST, negatives last (rg globs are last-match-wins;
353
+ // excludes must beat the pattern — see the ordering note in buildRgArgs).
354
+ args.push(`${opts.ignoreCase ? "--iglob" : "--glob"}=${pattern}`);
355
+ if (!opts.noDefaultExcludes) {
356
+ for (const d of DEFAULT_EXCLUDE_DIRS)
357
+ args.push(`--glob=!${d}`);
358
+ for (const f of DEFAULT_EXCLUDE_FILES)
359
+ args.push(`--glob=!${f}`);
360
+ }
361
+ for (const d of extraExcludeDirs)
362
+ args.push(`--glob=!${d}`);
363
+ for (const e of opts.exclude ?? [])
364
+ args.push(`--glob=!${e}`);
365
+ if (paths.length > 0)
366
+ args.push(...paths);
367
+ else
368
+ args.push(".");
369
+ return args;
370
+ }
371
+ /**
372
+ * Filename mode via POSIX find (the no-ripgrep fallback):
373
+ * find <paths> ( -name d1 -o -name d2 ... ) -prune -o -type f -name <glob> [! -name <ex>]... -print
374
+ * Sticks to POSIX operators (`(`, `-o`, `!`) so BSD/macOS find behaves the same.
375
+ */
376
+ function buildFindArgs(pattern, paths, opts, extraExcludeDirs) {
377
+ const args = paths.length > 0 ? [...paths] : ["."];
378
+ const pruneDirs = [...(opts.noDefaultExcludes ? [] : DEFAULT_EXCLUDE_DIRS), ...extraExcludeDirs];
379
+ if (pruneDirs.length > 0) {
380
+ args.push("(");
381
+ pruneDirs.forEach((d, i) => {
382
+ if (i > 0)
383
+ args.push("-o");
384
+ args.push("-name", d);
385
+ });
386
+ args.push(")", "-prune", "-o");
387
+ }
388
+ args.push("-type", "f", opts.ignoreCase ? "-iname" : "-name", pattern);
389
+ const fileExcludes = [
390
+ ...(opts.noDefaultExcludes ? [] : DEFAULT_EXCLUDE_FILES),
391
+ ...(opts.exclude ?? []),
392
+ ];
393
+ for (const e of fileExcludes)
394
+ args.push("!", "-name", e);
395
+ args.push("-print");
396
+ return args;
397
+ }
398
+ function execSearch(bin, args, cwd, limit) {
206
399
  return new Promise((resolveP, reject) => {
207
- const proc = spawn("grep", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
400
+ const proc = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
208
401
  const matches = [];
209
402
  let buffer = "";
210
403
  let stderr = "";
@@ -242,31 +435,34 @@ function execGrep(args, cwd, limit) {
242
435
  if (parsed)
243
436
  matches.push(parsed);
244
437
  }
245
- // grep exits 1 on "no matches": normal, not an error.
246
- if (code !== null && code !== 0 && code !== 1 && !truncated) {
247
- reject(new Error(`grep exited ${code}: ${stderr.trim() || "(no stderr)"}`));
438
+ // Exit 1 is "no matches" on both engines: normal, not an error. Exit 2
439
+ // with matches collected means a partial failure (an unreadable file
440
+ // mid-walk); surface the results rather than throwing them away.
441
+ if (code !== null && code !== 0 && code !== 1 && !truncated && matches.length === 0) {
442
+ reject(new Error(`${bin} exited ${code}: ${stderr.trim() || "(no stderr)"}`));
248
443
  return;
249
444
  }
250
445
  resolveP(matches);
251
446
  });
252
447
  });
253
448
  }
254
- /** Parse `path:line:content` from grep -n output. Returns null for ambiguous (no line number) lines. */
449
+ /** Parse `path:line:content` from grep/rg -n output. Returns null for ambiguous (no line number) lines. */
255
450
  function parseGrepLine(line) {
256
451
  // First colon ends path; second colon ends line number (if numeric).
257
452
  const firstColon = line.indexOf(":");
258
453
  if (firstColon < 0) {
259
454
  // -l mode: just a path. Encode as line=0, text="".
260
- return { file: line, line: 0, text: "" };
455
+ return { file: normalizeFile(line), line: 0, text: "" };
261
456
  }
262
457
  const after = line.slice(firstColon + 1);
263
458
  const secondColon = after.indexOf(":");
264
459
  if (secondColon < 0) {
265
460
  // -c mode: `path:count`.
266
461
  const count = Number.parseInt(after, 10);
267
- if (Number.isFinite(count))
268
- return { file: line.slice(0, firstColon), line: count, text: "" };
269
- return { file: line.slice(0, firstColon), line: 0, text: after };
462
+ if (Number.isFinite(count)) {
463
+ return { file: normalizeFile(line.slice(0, firstColon)), line: count, text: "" };
464
+ }
465
+ return { file: normalizeFile(line.slice(0, firstColon)), line: 0, text: after };
270
466
  }
271
467
  const lineNum = Number.parseInt(after.slice(0, secondColon), 10);
272
468
  if (!Number.isFinite(lineNum)) {
@@ -274,11 +470,15 @@ function parseGrepLine(line) {
274
470
  return null;
275
471
  }
276
472
  return {
277
- file: line.slice(0, firstColon),
473
+ file: normalizeFile(line.slice(0, firstColon)),
278
474
  line: lineNum,
279
475
  text: after.slice(secondColon + 1),
280
476
  };
281
477
  }
478
+ /** Both engines may emit a leading `./` for the default path; strip it so output is engine-identical. */
479
+ function normalizeFile(file) {
480
+ return file.startsWith("./") ? file.slice(2) : file;
481
+ }
282
482
  function renderResult(r, opts) {
283
483
  const lines = [];
284
484
  const showRepoHeader = r.repos.length > 1;
@@ -290,7 +490,7 @@ function renderResult(r, opts) {
290
490
  lines.push(`── ${repo.name} (${repo.matches.length} match${repo.matches.length === 1 ? "" : "es"}) ──`);
291
491
  }
292
492
  for (const m of repo.matches) {
293
- if (opts.filesOnly) {
493
+ if (opts.filesOnly || opts.files) {
294
494
  lines.push(m.file);
295
495
  }
296
496
  else if (opts.count) {
@@ -305,13 +505,14 @@ function renderResult(r, opts) {
305
505
  }
306
506
  }
307
507
  if (r.total_matches === 0) {
308
- lines.push(`(no matches for /${r.pattern}/${r.mode === "literal" ? " (literal)" : ""})`);
508
+ const modeTag = r.mode === "literal" ? " (literal)" : r.mode === "files" ? " (files)" : "";
509
+ lines.push(`(no matches for /${r.pattern}/${modeTag})`);
309
510
  }
310
511
  else if (r.repos.length > 1 || r.truncated) {
311
512
  lines.push("");
312
513
  const summary = `${r.total_matches} match${r.total_matches === 1 ? "" : "es"} across ${r.total_files} file${r.total_files === 1 ? "" : "s"}`;
313
514
  const tail = r.truncated ? " (truncated; use --limit)" : "";
314
- lines.push(`${summary}${tail} (${r.elapsed_ms}ms)`);
515
+ lines.push(`${summary}${tail} (${r.elapsed_ms}ms, ${r.engine})`);
315
516
  }
316
517
  return lines.join("\n");
317
518
  }
@@ -43,4 +43,13 @@ export declare function pinnedBinName(projectRoot: string): string | null;
43
43
  * `coordRoot` is resolved via `findCoordRoot()` when not passed.
44
44
  */
45
45
  export declare function resolveHooksSetupHint(coordRoot?: string | null): string | null;
46
+ /**
47
+ * Whether the host project consented to automatic ripgrep provisioning:
48
+ * `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall": true } } }`.
49
+ * A repo commits that once and every clone self-heals on first `grep`; without
50
+ * it, a missing rg only produces a rate-limited hint (`doctor --fix` installs
51
+ * explicitly). `HARNERY_TOOLS_AUTOINSTALL=1|0` overrides per process.
52
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
53
+ */
54
+ export declare function ripgrepAutoInstall(coordRoot?: string | null): boolean;
46
55
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/core/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAOH,oFAAoF;AACpF,eAAO,MAAM,gBAAgB,SAAS,CAAC;AAgBvC,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAqCvD;AAyBD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAShE;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGhE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAK9E"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/core/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAOH,oFAAoF;AACpF,eAAO,MAAM,gBAAgB,SAAS,CAAC;AAuBvC,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAqCvD;AAyBD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAShE;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGhE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAK9E;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAOrE"}
@@ -132,3 +132,22 @@ export function resolveHooksSetupHint(coordRoot) {
132
132
  const hint = readConfig(root).hooksSetupHint;
133
133
  return typeof hint === "string" && hint.trim() ? hint.trim() : null;
134
134
  }
135
+ /**
136
+ * Whether the host project consented to automatic ripgrep provisioning:
137
+ * `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall": true } } }`.
138
+ * A repo commits that once and every clone self-heals on first `grep`; without
139
+ * it, a missing rg only produces a rate-limited hint (`doctor --fix` installs
140
+ * explicitly). `HARNERY_TOOLS_AUTOINSTALL=1|0` overrides per process.
141
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
142
+ */
143
+ export function ripgrepAutoInstall(coordRoot) {
144
+ const env = coordEnv("TOOLS_AUTOINSTALL");
145
+ if (env === "1")
146
+ return true;
147
+ if (env === "0")
148
+ return false;
149
+ const root = coordRoot ?? findCoordRoot();
150
+ if (!root)
151
+ return false;
152
+ return readConfig(root).tools?.ripgrep?.autoInstall === true;
153
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Managed ripgrep provisioning for `grep --files` / content search.
3
+ *
4
+ * harnery can install a pinned, checksum-verified ripgrep into its own tools
5
+ * directory (`$XDG_DATA_HOME/harnery/tools`, default `~/.local/share/harnery/
6
+ * tools`) so `harn grep` gets the fast engine even on machines where nobody
7
+ * ever installed rg. The download is version-pinned and every artifact's
8
+ * sha256 is baked in below (cross-checked against the official release
9
+ * checksums), so the install is reproducible, never "whatever is latest."
10
+ *
11
+ * Consent model: the installer only runs automatically when the host project
12
+ * opts in via `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall":
13
+ * true } } }` — a repo commits that once and every clone self-heals. Without
14
+ * the opt-in, a missing rg produces a rate-limited stderr hint naming
15
+ * `harn doctor --fix` (explicit install). Environments that forbid downloads
16
+ * lose nothing: every failure path falls back to GNU grep.
17
+ */
18
+ export declare const RG_VERSION = "14.1.1";
19
+ /** True when a pinned artifact exists for this OS/arch (Windows: not yet). */
20
+ export declare function rgInstallSupported(): boolean;
21
+ /** Machine-level harnery tools dir (NOT the per-project .harnery/ state dir). */
22
+ export declare function toolsDir(): string;
23
+ /** Path the managed rg binary lives at once installed. */
24
+ export declare function managedRgPath(): string;
25
+ /**
26
+ * Locate a usable ripgrep. Precedence:
27
+ * 1. HARNERY_RG_PATH (explicit override)
28
+ * 2. the managed tools-dir install (works even when PATH doesn't carry it)
29
+ * 3. `rg` on PATH
30
+ * Returns the spawnable path/name, or null when none respond to --version.
31
+ */
32
+ export declare function findRg(): string | null;
33
+ /**
34
+ * Download + verify + install the pinned ripgrep into the tools dir.
35
+ * Atomic: extracts to a temp dir and renames the binary into place.
36
+ * Throws on unsupported platform, network failure, or checksum mismatch —
37
+ * callers are expected to fall back to grep and say why.
38
+ */
39
+ export declare function installRg(log?: (line: string) => void): Promise<string>;
40
+ export type SearchEngine = "rg" | "grep";
41
+ /**
42
+ * Shared engine resolver for the ripgrep-backed search commands (`grep`,
43
+ * `callers`). Returns the engine plus the spawnable rg path.
44
+ *
45
+ * `HARNERY_GREP_ENGINE=rg|grep` forces one; otherwise findRg() probes
46
+ * HARNERY_RG_PATH → the managed tools-dir install → PATH. On a miss, the
47
+ * host's `.harnery/config.jsonc` `tools.ripgrep.autoInstall` consent triggers
48
+ * a pinned, checksum-verified install into the harnery tools dir (any failure
49
+ * falls back to grep with a stderr note); without consent, a rate-limited
50
+ * stderr hint (keyed per `hintKey`) names the explicit install command.
51
+ */
52
+ export declare function resolveSearchEngine(hintKey?: string): Promise<{
53
+ engine: SearchEngine;
54
+ rgBin: string;
55
+ }>;
56
+ /**
57
+ * Emit `line` to stderr at most once per 24h (stamp file in the tools dir).
58
+ * Keeps the "rg missing" nudge from nagging on every single grep invocation.
59
+ */
60
+ export declare function hintOncePerDay(line: string): void;
61
+ //# sourceMappingURL=ripgrep.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ripgrep.d.ts","sourceRoot":"","sources":["../../../src/lib/tools/ripgrep.ts"],"names":[],"mappings":"AAiBA;;;;;;;;;;;;;;;;GAgBG;AAEH,eAAO,MAAM,UAAU,WAAW,CAAC;AA0BnC,8EAA8E;AAC9E,wBAAgB,kBAAkB,IAAI,OAAO,CAE5C;AAED,iFAAiF;AACjF,wBAAgB,QAAQ,IAAI,MAAM,CAIjC;AAED,0DAA0D;AAC1D,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAWD;;;;;;GAMG;AACH,wBAAgB,MAAM,IAAI,MAAM,GAAG,IAAI,CAStC;AAED;;;;;GAKG;AACH,wBAAsB,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAoD7E;AAkBD,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,MAAM,CAAC;AAEzC;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,SAAS,GACf,OAAO,CAAC;IAAE,MAAM,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CA6BlD;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAYjD"}