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.
Engine: ripgrep when available
Section titled “Engine: ripgrep when available”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 --fixinstalls it on demand. - Opt-in automatic: commit
{ "tools": { "ripgrep": { "autoInstall": true } } }in.harnery/config.jsoncand the firstgrepon 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).
Options
Section titled “Options”| 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.
Boolean composition (--and / --without)
Section titled “Boolean composition (--and / --without)”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.
# Markdown files that mention BOTH terms, showing the freshdesk linesharn grep -l -i 'freshdesk' --and 'filter' --lang md --all-reposMembership 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.
Context (-C / -A / -B)
Section titled “Context (-C / -A / -B)”-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.
Default excludes
Section titled “Default excludes”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.
Examples
Section titled “Examples”# Find a symbol in the current directoryharn grep "createServer"
# Case-insensitive, whole-word, literal search in the repo rootharn grep -i -w -F "TODO" --repo .
# Only TypeScript files, across the parent repo and every submoduleharn grep "useState" --lang ts --all-repos
# List just the files that match, with an extra include globharn grep "deprecated" -l --include "*.md"
# Two lines of context, capped at 50 total matches, as JSONharn grep "throw new Error" -C 2 --limit 50 --json
# Union two language presets (either form)harn grep "useState" --lang ts,tsx --all-reposharn grep "useState" --lang ts --lang tsx --all-repos
# File-level intersection: files with BOTH terms, minus a thirdharn 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 repoharn grep '*.env.example' --files --all-reposharn grep 'Dockerfile*' --files -iFilename mode (--files)
Section titled “Filename mode (--files)”--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.
Output
Section titled “Output”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.