Skip to content

harn grep

harn grep [options] <pattern> [paths...]

Monorepo-aware code search with a curated skip list, repo scoping, and language presets — the everyday “find this string in the codebase” tool that doesn’t drown you in matches from node_modules/ or a build directory. Regex by default (extended-regex semantics); pass -F for a literal string.

Reach for this for shell use, scripting, and anything that wants the --json envelope. It searches the current working directory unless you point it at explicit paths or scope it with --repo / --all-repos.

harn grep prefers ripgrep (rg) when it is on PATH and falls back to GNU grep transparently. Both engines are driven with equivalent flags and parsed into the same envelope, so results are identical either way (pinned by an engine-parity test suite); the envelope’s engine field reports which one ran. Force a specific engine with HARNERY_GREP_ENGINE=rg|grep — the escape hatch if an exotic regex behaves differently across engines (e.g. backreferences, which GNU grep accepts and ripgrep rejects).

Installing ripgrep is worth it on any non-trivial tree. Measured on a real 24-repo monorepo (warm cache), the search phase of an --all-repos sweep dropped from 863ms (GNU grep) to 147ms with ripgrep — and the same release’s parallel repo fan-out plus parent-scan pruning took the end-to-end sweep from 6.8s to about 1.1s while eliminating double-reported submodule matches (90 rows shrank to 56 unique ones). Ripgrep’s parallel directory walker pulls further ahead on cold caches and larger trees; on small warm trees the engines are equivalent. scripts/bench-grep.ts reproduces the comparison on a synthetic tree or any directory you point it at.

Ripgrep is run with --hidden --no-ignore --no-config: hidden directories are searched and .gitignore files are not honored, exactly like the grep path — the explicit exclude lists below are the only filters, so agent config in dotdirs (.agents/, .claude/, …) stays findable.

Managed install (harnery can provision rg itself)

Section titled “Managed install (harnery can provision rg itself)”

When no rg is found (probe order: HARNERY_RG_PATH → the managed install → PATH), harnery can install one: a version-pinned, sha256-verified ripgrep download into its own tools directory ($XDG_DATA_HOME/harnery/tools, default ~/.local/share/harnery/tools — never system dirs), which grep probes directly so PATH doesn’t need updating. Two ways to trigger it:

  • Explicit: harn doctor --fix installs it on demand.
  • Opt-in automatic: commit { "tools": { "ripgrep": { "autoInstall": true } } } in .harnery/config.jsonc and the first grep on a machine without rg self-provisions. One committed consent covers every clone of the repo.

Without the opt-in, a missing rg emits a rate-limited (once per day) stderr hint naming doctor --fix and falls back to GNU grep. Every failure path (no network, checksum mismatch, unsupported OS/arch) also falls back to grep — provisioning can never break a search. The checksums are pinned in src/lib/tools/ripgrep.ts, cross-checked against the official release .sha256 assets; HARNERY_TOOLS_AUTOINSTALL=1|0 overrides the config per process. Windows has no pinned artifact yet (hint only).

Flag Description
--repo <name> Scope to one submodule; . (or parent) means the repo root.
--all-repos Search the parent repo plus every configured submodule, labeled per-repo.
--lang <lang> File-type preset: ts, tsx, js, jsx, py, php, sql, md, sh, json, yaml, rb, go, rs. Repeatable and/or comma-separated (--lang ts,tsx) — values union.
-i, --ignore-case Case-insensitive match.
-w, --whole-word Match whole words only.
-F, --literal Treat <pattern> as a literal string (no regex).
-l, --files-only Print only the names of files containing a match.
--files Filename search: treat <pattern> as a filename glob and list matching files (no content search).
-c, --count Print a match count per file (suppresses content).
-C, --context <n> Print N lines of context around each match (default 0).
-A, --after-context <n> Lines after each match; overrides -C’s after side.
-B, --before-context <n> Lines before each match; overrides -C’s before side.
--and <pattern> Only show files that ALSO contain this pattern (file-level, repeatable).
--without <pattern> Drop files that contain this pattern (file-level, repeatable).
-q, --quiet No output; exit 0 if any qualified match exists, 1 if none.
--max-count <n> Stop after N matches per file.
--limit <n> Truncate output to N matches total.
--include <glob> Extra include glob (repeatable).
--exclude <glob> Extra exclude glob (repeatable).
--no-default-excludes Disable the default skip list.
--json Emit a structured JSON envelope instead of file:line:content.

Numeric flags are validated strictly (--limit nope, negative, or fractional values fail before any engine spawns). Meaningless flag combinations are rejected with explicit errors rather than silently ignored: context with -l/-c/--files/-q; --and/--without with --files; -q with --json/-l/-c/--limit.

Patterns compose at the file level: a file qualifies when it contains the primary pattern, contains every --and pattern somewhere, and contains no --without pattern anywhere. The displayed rows are always the primary pattern’s matches. Match-shaping flags (-i, -w, -F) and every scope/file-selection flag apply to all patterns; output flags apply only to the primary search.

Terminal window
# Markdown files that mention BOTH terms, showing the freshdesk lines
harn grep -l -i 'freshdesk' --and 'filter' --lang md --all-repos

Membership scans are complete before the primary search is budgeted, so --limit can never be consumed by a non-qualifying file, and a membership scan that fails part-way rejects the whole command instead of silently returning false negatives.

-C N sets both sides; -A / -B override one side regardless of flag order (-C 2 -A 0 = two before, zero after). Context is materialized from one file read per selected file — not from engine context output — so both engines produce byte-identical rows, overlapping windows merge, and --limit counts only match rows (a selected match always keeps its full context window). Human output separates disjoint windows with --; JSON rows carry kind: "match" | "context" instead of separator rows.

Unless --no-default-excludes is set, these directories are skipped: .git, .cache, node_modules, dist, build, out, .next, vendor, __pycache__, .venv, tmp, tmp-files, coverage, .parcel-cache, .turbo, and the coordination directory. Binary files are always skipped.

A host CLI can extend the skip list via the program context’s grepExcludeDirs — meant for host-generated mirror directories whose matches only ever duplicate the source files they were generated from. Host excludes ride the same --no-default-excludes gate as the built-in list.

In --all-repos mode the parent scan additionally prunes every submodule directory, since each submodule gets its own scoped scan — every match is attributed to exactly one repo. This pruning is about correct attribution, not noise, so it stays on even with --no-default-excludes.

Terminal window
# Find a symbol in the current directory
harn grep "createServer"
# Case-insensitive, whole-word, literal search in the repo root
harn grep -i -w -F "TODO" --repo .
# Only TypeScript files, across the parent repo and every submodule
harn grep "useState" --lang ts --all-repos
# List just the files that match, with an extra include glob
harn grep "deprecated" -l --include "*.md"
# Two lines of context, capped at 50 total matches, as JSON
harn grep "throw new Error" -C 2 --limit 50 --json
# Union two language presets (either form)
harn grep "useState" --lang ts,tsx --all-repos
harn grep "useState" --lang ts --lang tsx --all-repos
# File-level intersection: files with BOTH terms, minus a third
harn grep "freshdesk" --and "filter" --without "deprecated" --lang md
# Scripting: does any file match? (exit 0 = yes, 1 = no; no output)
harn grep -q "TODO" --repo . && echo "still has TODOs"
# Filename search: find files by name glob across every repo
harn grep '*.env.example' --files --all-repos
harn grep 'Dockerfile*' --files -i

--files switches from content search to filename search: <pattern> becomes a filename glob (basename semantics — '*.md' matches at any depth) and the output is the matching file paths. Backed by rg --files when ripgrep is available, POSIX find otherwise — results are identical (same parity suite). The exclude lists, --repo / --all-repos scoping, -i, --exclude, and --limit all apply; content-search flags (--lang, -c, -C, -w, -F, --max-count, --include) are rejected with a clear error. Quote the glob so your shell doesn’t expand it.

Human mode prints file:line:content per match (context rows render grep-style as file-line-content), with a ── <repo> ── header when more than one repo is searched. --json emits an envelope shaped { pattern, mode, engine, and_patterns[], without_patterns[], repos[], total_matches, total_files, truncated, elapsed_ms }, where each entry in repos[] carries { name, cwd, matches[], truncated } and every row is { repo, file, line, text, kind } with kind: "match" | "context".

Filenames are framed with the NUL delimiter (--null on both engines), so paths containing colons, dashes, or spaces can never be mis-split. total_matches / total_files count match rows only — context rows are free and never consume --limit. truncated is true only when a result beyond --limit actually exists: a search with exactly N results under --limit N is not truncated (one row of lookahead distinguishes the two).

Matches are sorted by file then line, so output is stable across runs and identical between engines. In -c count mode, zero-count rows are filtered on both engines. Under --limit, which matches survive truncation depends on engine walk order — counts and flags are stable, the selection may not be.

--repo and --all-repos need harnery to know the repo root and submodule list (supplied by the host CLI’s program context). Without that context, use plain paths or run from inside the directory you want to search. The engine exiting with no matches is treated as normal, not an error; a partial failure (an unreadable file mid-walk) still returns the matches that were found.