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.
@@ -0,0 +1,217 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { resolveBinName, ripgrepAutoInstall } from "../../core/config.js";
8
+ import { coordEnv } from "../env.js";
9
+ /**
10
+ * Managed ripgrep provisioning for `grep --files` / content search.
11
+ *
12
+ * harnery can install a pinned, checksum-verified ripgrep into its own tools
13
+ * directory (`$XDG_DATA_HOME/harnery/tools`, default `~/.local/share/harnery/
14
+ * tools`) so `harn grep` gets the fast engine even on machines where nobody
15
+ * ever installed rg. The download is version-pinned and every artifact's
16
+ * sha256 is baked in below (cross-checked against the official release
17
+ * checksums), so the install is reproducible, never "whatever is latest."
18
+ *
19
+ * Consent model: the installer only runs automatically when the host project
20
+ * opts in via `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall":
21
+ * true } } }` — a repo commits that once and every clone self-heals. Without
22
+ * the opt-in, a missing rg produces a rate-limited stderr hint naming
23
+ * `harn doctor --fix` (explicit install). Environments that forbid downloads
24
+ * lose nothing: every failure path falls back to GNU grep.
25
+ */
26
+ export const RG_VERSION = "14.1.1";
27
+ /** sha256 pins verified against the official ripgrep release .sha256 assets. */
28
+ const ARTIFACTS = {
29
+ "linux-x64": {
30
+ name: `ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz`,
31
+ sha256: "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e",
32
+ },
33
+ "linux-arm64": {
34
+ name: `ripgrep-${RG_VERSION}-aarch64-unknown-linux-gnu.tar.gz`,
35
+ sha256: "c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f",
36
+ },
37
+ "darwin-x64": {
38
+ name: `ripgrep-${RG_VERSION}-x86_64-apple-darwin.tar.gz`,
39
+ sha256: "fc87e78f7cb3fea12d69072e7ef3b21509754717b746368fd40d88963630e2b3",
40
+ },
41
+ "darwin-arm64": {
42
+ name: `ripgrep-${RG_VERSION}-aarch64-apple-darwin.tar.gz`,
43
+ sha256: "24ad76777745fbff131c8fbc466742b011f925bfa4fffa2ded6def23b5b937be",
44
+ },
45
+ };
46
+ function platformKey() {
47
+ return `${process.platform}-${process.arch}`;
48
+ }
49
+ /** True when a pinned artifact exists for this OS/arch (Windows: not yet). */
50
+ export function rgInstallSupported() {
51
+ return platformKey() in ARTIFACTS;
52
+ }
53
+ /** Machine-level harnery tools dir (NOT the per-project .harnery/ state dir). */
54
+ export function toolsDir() {
55
+ const xdg = process.env.XDG_DATA_HOME;
56
+ const base = xdg && xdg.trim() !== "" ? xdg : join(homedir(), ".local", "share");
57
+ return join(base, "harnery", "tools");
58
+ }
59
+ /** Path the managed rg binary lives at once installed. */
60
+ export function managedRgPath() {
61
+ return join(toolsDir(), "rg");
62
+ }
63
+ function isExecutable(p) {
64
+ try {
65
+ const r = spawnSync(p, ["--version"], { stdio: "ignore" });
66
+ return r.status === 0;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /**
73
+ * Locate a usable ripgrep. Precedence:
74
+ * 1. HARNERY_RG_PATH (explicit override)
75
+ * 2. the managed tools-dir install (works even when PATH doesn't carry it)
76
+ * 3. `rg` on PATH
77
+ * Returns the spawnable path/name, or null when none respond to --version.
78
+ */
79
+ export function findRg() {
80
+ const override = coordEnv("RG_PATH");
81
+ if (override && override.trim() !== "") {
82
+ return isExecutable(override) ? override : null;
83
+ }
84
+ const managed = managedRgPath();
85
+ if (existsSync(managed) && isExecutable(managed))
86
+ return managed;
87
+ if (isExecutable("rg"))
88
+ return "rg";
89
+ return null;
90
+ }
91
+ /**
92
+ * Download + verify + install the pinned ripgrep into the tools dir.
93
+ * Atomic: extracts to a temp dir and renames the binary into place.
94
+ * Throws on unsupported platform, network failure, or checksum mismatch —
95
+ * callers are expected to fall back to grep and say why.
96
+ */
97
+ export async function installRg(log) {
98
+ const key = platformKey();
99
+ const artifact = ARTIFACTS[key];
100
+ if (!artifact) {
101
+ throw new Error(`no pinned ripgrep artifact for ${key} — install ripgrep manually (https://github.com/BurntSushi/ripgrep#installation)`);
102
+ }
103
+ const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${artifact.name}`;
104
+ log?.(`downloading ${artifact.name} (pinned ${RG_VERSION})...`);
105
+ const res = await fetch(url);
106
+ if (!res.ok)
107
+ throw new Error(`download failed: HTTP ${res.status} for ${url}`);
108
+ const bytes = Buffer.from(await res.arrayBuffer());
109
+ const digest = createHash("sha256").update(bytes).digest("hex");
110
+ if (digest !== artifact.sha256) {
111
+ throw new Error(`sha256 mismatch for ${artifact.name}: expected ${artifact.sha256}, got ${digest} — refusing to install`);
112
+ }
113
+ const work = join(tmpdir(), `harnery-rg-${process.pid}`);
114
+ rmSync(work, { recursive: true, force: true });
115
+ mkdirSync(work, { recursive: true });
116
+ try {
117
+ const tarball = join(work, artifact.name);
118
+ writeFileSync(tarball, bytes);
119
+ await extractTar(tarball, work);
120
+ const extractedRg = join(work, artifact.name.replace(/\.tar\.gz$/, ""), "rg");
121
+ const rgStat = await stat(extractedRg);
122
+ if (!rgStat.isFile())
123
+ throw new Error(`extracted archive missing rg binary at ${extractedRg}`);
124
+ mkdirSync(toolsDir(), { recursive: true });
125
+ const dest = managedRgPath();
126
+ // rename() can't cross filesystems (tmp is often tmpfs); copy + rename
127
+ // within the destination dir keeps the final placement atomic.
128
+ const staged = `${dest}.tmp-${process.pid}`;
129
+ writeFileSync(staged, await readFile(extractedRg));
130
+ chmodSync(staged, 0o755);
131
+ renameSync(staged, dest);
132
+ if (!isExecutable(dest)) {
133
+ rmSync(dest, { force: true });
134
+ throw new Error(`installed rg at ${dest} but it failed to execute`);
135
+ }
136
+ log?.(`installed ripgrep ${RG_VERSION} -> ${dest}`);
137
+ return dest;
138
+ }
139
+ finally {
140
+ rmSync(work, { recursive: true, force: true });
141
+ }
142
+ }
143
+ function extractTar(tarball, cwd) {
144
+ return new Promise((resolveP, reject) => {
145
+ const proc = spawn("tar", ["xzf", tarball], { cwd, stdio: ["ignore", "ignore", "pipe"] });
146
+ let stderr = "";
147
+ proc.stderr.setEncoding("utf8");
148
+ proc.stderr.on("data", (c) => {
149
+ stderr += c;
150
+ });
151
+ proc.on("error", reject);
152
+ proc.on("close", (code) => {
153
+ if (code === 0)
154
+ resolveP();
155
+ else
156
+ reject(new Error(`tar exited ${code}: ${stderr.trim()}`));
157
+ });
158
+ });
159
+ }
160
+ /**
161
+ * Shared engine resolver for the ripgrep-backed search commands (`grep`,
162
+ * `callers`). Returns the engine plus the spawnable rg path.
163
+ *
164
+ * `HARNERY_GREP_ENGINE=rg|grep` forces one; otherwise findRg() probes
165
+ * HARNERY_RG_PATH → the managed tools-dir install → PATH. On a miss, the
166
+ * host's `.harnery/config.jsonc` `tools.ripgrep.autoInstall` consent triggers
167
+ * a pinned, checksum-verified install into the harnery tools dir (any failure
168
+ * falls back to grep with a stderr note); without consent, a rate-limited
169
+ * stderr hint (keyed per `hintKey`) names the explicit install command.
170
+ */
171
+ export async function resolveSearchEngine(hintKey = "grep") {
172
+ const forced = coordEnv("GREP_ENGINE");
173
+ if (forced === "grep")
174
+ return { engine: "grep", rgBin: "rg" };
175
+ const found = findRg();
176
+ if (found)
177
+ return { engine: "rg", rgBin: found };
178
+ if (forced === "rg") {
179
+ // Forced rg with none findable: let the spawn error surface loudly.
180
+ return { engine: "rg", rgBin: "rg" };
181
+ }
182
+ if (rgInstallSupported()) {
183
+ if (ripgrepAutoInstall()) {
184
+ try {
185
+ const installed = await installRg((line) => process.stderr.write(`harnery: ${line}\n`));
186
+ return { engine: "rg", rgBin: installed };
187
+ }
188
+ catch (err) {
189
+ process.stderr.write(`harnery: ripgrep auto-install failed (${err.message}); using grep fallback\n`);
190
+ return { engine: "grep", rgBin: "rg" };
191
+ }
192
+ }
193
+ const bin = resolveBinName();
194
+ hintOncePerDay(`${bin} ${hintKey}: ripgrep not found; using the slower GNU grep fallback. ` +
195
+ `Run \`${bin} doctor --fix\` to install it (pinned + checksum-verified), ` +
196
+ `or set { "tools": { "ripgrep": { "autoInstall": true } } } in .harnery/config.jsonc.`);
197
+ }
198
+ return { engine: "grep", rgBin: "rg" };
199
+ }
200
+ /**
201
+ * Emit `line` to stderr at most once per 24h (stamp file in the tools dir).
202
+ * Keeps the "rg missing" nudge from nagging on every single grep invocation.
203
+ */
204
+ export function hintOncePerDay(line) {
205
+ try {
206
+ const stamp = join(toolsDir(), ".rg-hint-stamp");
207
+ if (existsSync(stamp) && Date.now() - statSync(stamp).mtimeMs < 24 * 60 * 60 * 1000) {
208
+ return;
209
+ }
210
+ mkdirSync(toolsDir(), { recursive: true });
211
+ writeFileSync(stamp, String(Date.now()));
212
+ }
213
+ catch {
214
+ // stamping failed; still emit the hint
215
+ }
216
+ process.stderr.write(`${line}\n`);
217
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
package/src/commander.ts CHANGED
@@ -120,6 +120,15 @@ export interface HarneryProgramContext {
120
120
  * carrying provider-specific checks. harn standalone ships none.
121
121
  */
122
122
  envSections?: Record<string, EnvSection>;
123
+ /**
124
+ * Extra directory names for `grep` to exclude by default — host-generated
125
+ * mirrors, vendored trees, and similar directories that only ever produce
126
+ * duplicate or noisy matches. Names match at any depth (same semantics as
127
+ * grep's `--exclude-dir`). Applied alongside the built-in skip list and
128
+ * disabled together with it by `--no-default-excludes`. harn standalone
129
+ * ships none.
130
+ */
131
+ grepExcludeDirs?: readonly string[];
123
132
  /**
124
133
  * Optional callback that returns extra HTTP headers to attach to outbound
125
134
  * `fetch` calls based on the target URL. Useful for consumers that need
@@ -1,14 +1,17 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import type { Command } from "commander";
3
3
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
4
+ import { resolveSearchEngine, type SearchEngine } from "../lib/tools/ripgrep.ts";
4
5
 
5
6
  /**
6
7
  * `harn callers <symbol>`: find references to a symbol across the monorepo with
7
- * kind classification (call / import / type / decl / ref). Thin wrapper over
8
- * grep -rnw with post-filtering: declarations + single-line comments are
9
- * filtered out by default, and lines starting with " * " (multi-line comment
10
- * continuation) are dropped. Inherent text-based-search limitations: multi-line
11
- * string literals and block-comment interiors aren't excluded.
8
+ * kind classification (call / import / type / decl / ref). Whole-word search
9
+ * (ripgrep when available, GNU grep fallback — same engine + provisioning path
10
+ * as `harn grep`, honoring HARNERY_GREP_ENGINE) with post-filtering:
11
+ * declarations + single-line comments are filtered out by default, and lines
12
+ * starting with " * " (multi-line comment continuation) are dropped. Inherent
13
+ * text-based-search limitations: multi-line string literals and block-comment
14
+ * interiors aren't excluded. Repos are searched in parallel.
12
15
  */
13
16
 
14
17
  interface CallersOpts {
@@ -116,21 +119,31 @@ async function runCallers(
116
119
  }
117
120
 
118
121
  const started = Date.now();
122
+ const { engine, rgBin } = await resolveSearchEngine("callers");
119
123
  const repos = resolveRepos(opts, context);
120
124
  const limit = opts.limit ? Number.parseInt(opts.limit, 10) : 500;
121
125
 
126
+ // Search every repo concurrently, each capped at the global limit, then
127
+ // apply the global budget in repo order so truncation stays deterministic.
128
+ // In --all-repos mode the parent scan prunes submodule dirs (each submodule
129
+ // is scanned on its own), so a match is attributed to exactly one repo.
130
+ const perRepo = await Promise.all(
131
+ repos.map((repo) => {
132
+ const pruneDirs = opts.allRepos && repo.name === "parent" ? (context?.submodules ?? []) : [];
133
+ return searchInRepo(symbol, opts, repo, limit, engine, rgBin, pruneDirs);
134
+ }),
135
+ );
136
+
122
137
  const allCallers: Caller[] = [];
123
138
  let truncated = false;
124
-
125
- for (const repo of repos) {
126
- if (allCallers.length >= limit) {
127
- truncated = true;
128
- break;
129
- }
130
- const remaining = limit - allCallers.length;
131
- const found = await grepInRepo(symbol, opts, repo, remaining);
139
+ let budget = limit;
140
+ for (let i = 0; i < repos.length; i++) {
141
+ const found = perRepo[i] ?? { callers: [], truncated: false };
132
142
  if (found.truncated) truncated = true;
133
- allCallers.push(...found.callers);
143
+ const take = Math.min(found.callers.length, Math.max(0, budget));
144
+ if (found.callers.length > take) truncated = true;
145
+ allCallers.push(...found.callers.slice(0, take));
146
+ budget -= take;
134
147
  }
135
148
 
136
149
  const by_kind: Record<string, number> = {};
@@ -184,24 +197,72 @@ function resolveRepos(
184
197
  return [{ name: "cwd", cwd: process.cwd() }];
185
198
  }
186
199
 
187
- function grepInRepo(
188
- symbol: string,
189
- opts: CallersOpts,
190
- repo: { name: string; cwd: string },
191
- limit: number,
192
- ): Promise<{ callers: Caller[]; truncated: boolean }> {
193
- const args: string[] = ["-rnw", "--color=never", "-I", "-E"];
194
- for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--exclude-dir=${d}`);
195
- for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--exclude=${f}`);
200
+ function resolveLangGlobs(opts: CallersOpts): string[] | undefined {
196
201
  const langGlobs = opts.lang ? LANG_GLOBS[opts.lang] : undefined;
197
202
  if (opts.lang && !langGlobs) {
198
203
  throw new Error(`unknown --lang "${opts.lang}". Valid: ${Object.keys(LANG_GLOBS).join(", ")}`);
199
204
  }
205
+ return langGlobs;
206
+ }
207
+
208
+ function buildCallersGrepArgs(
209
+ symbol: string,
210
+ opts: CallersOpts,
211
+ extraExcludeDirs: readonly string[],
212
+ ): string[] {
213
+ const args: string[] = ["-rnw", "-H", "--color=never", "-I", "-E"];
214
+ for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--exclude-dir=${d}`);
215
+ for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--exclude=${f}`);
216
+ for (const d of extraExcludeDirs) args.push(`--exclude-dir=${d}`);
217
+ const langGlobs = resolveLangGlobs(opts);
200
218
  if (langGlobs) for (const g of langGlobs) args.push(`--include=${g}`);
201
219
  args.push("--", symbol, ".");
220
+ return args;
221
+ }
222
+
223
+ function buildCallersRgArgs(
224
+ symbol: string,
225
+ opts: CallersOpts,
226
+ extraExcludeDirs: readonly string[],
227
+ ): string[] {
228
+ // --hidden --no-ignore --no-config: match grep semantics (dotdirs searched,
229
+ // .gitignore ignored, user rg config can't skew results). -w = whole word.
230
+ // Lang globs (positives) go before dir excludes (negatives) since rg globs
231
+ // are last-match-wins.
232
+ const args: string[] = [
233
+ "-nw",
234
+ "--no-heading",
235
+ "--with-filename",
236
+ "--color=never",
237
+ "--no-config",
238
+ "--hidden",
239
+ "--no-ignore",
240
+ ];
241
+ const langGlobs = resolveLangGlobs(opts);
242
+ if (langGlobs) for (const g of langGlobs) args.push(`--glob=${g}`);
243
+ for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--glob=!${d}`);
244
+ for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--glob=!${f}`);
245
+ for (const d of extraExcludeDirs) args.push(`--glob=!${d}`);
246
+ args.push("--", symbol, ".");
247
+ return args;
248
+ }
249
+
250
+ function searchInRepo(
251
+ symbol: string,
252
+ opts: CallersOpts,
253
+ repo: { name: string; cwd: string },
254
+ limit: number,
255
+ engine: SearchEngine,
256
+ rgBin: string,
257
+ extraExcludeDirs: readonly string[],
258
+ ): Promise<{ callers: Caller[]; truncated: boolean }> {
259
+ const [bin, args] =
260
+ engine === "rg"
261
+ ? ([rgBin, buildCallersRgArgs(symbol, opts, extraExcludeDirs)] as const)
262
+ : (["grep", buildCallersGrepArgs(symbol, opts, extraExcludeDirs)] as const);
202
263
 
203
264
  return new Promise((resolveP, reject) => {
204
- const proc = spawn("grep", args, { cwd: repo.cwd, stdio: ["ignore", "pipe", "pipe"] });
265
+ const proc = spawn(bin, args, { cwd: repo.cwd, stdio: ["ignore", "pipe", "pipe"] });
205
266
  const callers: Caller[] = [];
206
267
  let buffer = "";
207
268
  let stderr = "";
@@ -237,8 +298,11 @@ function grepInRepo(
237
298
  const parsed = parseCallerLine(buffer, symbol, opts, repo.name);
238
299
  if (parsed) callers.push(parsed);
239
300
  }
240
- if (code !== null && code !== 0 && code !== 1 && !truncated) {
241
- reject(new Error(`grep exited ${code}: ${stderr.trim() || "(no stderr)"}`));
301
+ // Exit 1 = no matches (both engines): normal. Exit 2 with nothing
302
+ // collected = a real failure; with matches, a partial walk error we
303
+ // tolerate (surface what was found).
304
+ if (code !== null && code !== 0 && code !== 1 && !truncated && callers.length === 0) {
305
+ reject(new Error(`${bin} exited ${code}: ${stderr.trim() || "(no stderr)"}`));
242
306
  return;
243
307
  }
244
308
  resolveP({ callers, truncated });
@@ -17,8 +17,9 @@ import os from "node:os";
17
17
  import path from "node:path";
18
18
  import type { Command } from "commander";
19
19
  import type { EmitContext } from "../commander.ts";
20
- import { resolveBinName } from "../core/config.ts";
20
+ import { resolveBinName, ripgrepAutoInstall } from "../core/config.ts";
21
21
  import { loadHarnessWiring } from "../core/hooks/harness/wiring.ts";
22
+ import { findRg, installRg, managedRgPath, rgInstallSupported } from "../lib/tools/ripgrep.ts";
22
23
 
23
24
  type Severity = "ok" | "warn" | "fail";
24
25
 
@@ -31,6 +32,7 @@ interface Check {
31
32
 
32
33
  interface CheckOpts {
33
34
  json?: boolean;
35
+ fix?: boolean;
34
36
  }
35
37
 
36
38
  export function registerDoctorCommand(program: Command, emit: EmitContext): void {
@@ -41,7 +43,18 @@ export function registerDoctorCommand(program: Command, emit: EmitContext): void
41
43
  "unless a required dep (Node, git) is missing.",
42
44
  )
43
45
  .option("--json", "Machine-readable JSON output")
44
- .action((opts: CheckOpts) => {
46
+ .option(
47
+ "--fix",
48
+ "Install missing managed tools (currently: ripgrep, pinned + checksum-verified)",
49
+ )
50
+ .action(async (opts: CheckOpts) => {
51
+ if (opts.fix && !findRg() && rgInstallSupported()) {
52
+ try {
53
+ await installRg((line) => emit.text(`${line}\n`));
54
+ } catch (err) {
55
+ emit.text(`ripgrep install failed: ${(err as Error).message}\n`);
56
+ }
57
+ }
45
58
  const checks = runChecks();
46
59
  const requiredFailed = checks.some((c) => c.severity === "fail");
47
60
 
@@ -80,6 +93,7 @@ export function runChecks(): Check[] {
80
93
  checkNode(),
81
94
  checkGit(),
82
95
  checkBun(),
96
+ checkRipgrep(),
83
97
  checkHarneryDir(),
84
98
  checkHarnessHooks(),
85
99
  checkRestic(),
@@ -89,6 +103,40 @@ export function runChecks(): Check[] {
89
103
  ];
90
104
  }
91
105
 
106
+ function checkRipgrep(): Check {
107
+ const found = findRg();
108
+ if (found) {
109
+ const r = whichVersion(found);
110
+ const managed = found === managedRgPath() ? ", managed" : "";
111
+ return {
112
+ name: "ripgrep",
113
+ severity: "ok",
114
+ detail: `${r.out.replace(/^ripgrep\s*/, "")} (${found === "rg" ? "PATH" : found}${managed})`,
115
+ };
116
+ }
117
+ if (!rgInstallSupported()) {
118
+ return {
119
+ name: "ripgrep",
120
+ severity: "warn",
121
+ detail: "missing (grep fallback; no pinned artifact for this OS/arch)",
122
+ hint: "https://github.com/BurntSushi/ripgrep#installation",
123
+ };
124
+ }
125
+ if (ripgrepAutoInstall()) {
126
+ return {
127
+ name: "ripgrep",
128
+ severity: "warn",
129
+ detail: "missing (autoInstall on: will self-provision on first grep)",
130
+ };
131
+ }
132
+ return {
133
+ name: "ripgrep",
134
+ severity: "warn",
135
+ detail: "missing (grep fallback works, just slower)",
136
+ hint: `${resolveBinName()} doctor --fix (pinned + checksum-verified, installs to the harnery tools dir)`,
137
+ };
138
+ }
139
+
92
140
  function whichVersion(bin: string, args: string[] = ["--version"]): { ok: boolean; out: string } {
93
141
  const r = spawnSync(bin, args, { encoding: "utf-8" });
94
142
  if (r.status !== 0) return { ok: false, out: "" };