pi-cli-guard
Pi extension that rewrites the agent's bash to the modern toolchain: grep/egrep/fgrep → rg (ripgrep), find → fd (conservative whitelist) — applied at the tool_call event, so it composes with other bash extensions
Package details
Install pi-cli-guard from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-cli-guard- Package
pi-cli-guard- Version
0.2.1- Published
- Oct 21, 2026
- Downloads
- 383/mo · 383/wk
- Author
- tychenjiajun
- License
- MIT
- Types
- extension
- Size
- 773.5 KB
- Dependencies
- 1 dependency · 1 peer
Pi manifest JSON
{
"image": "https://raw.githubusercontent.com/tychenjiajun/pi-cli-guard/main/assets/preview.png",
"video": "https://raw.githubusercontent.com/tychenjiajun/pi-cli-guard/main/assets/demo.mp4",
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-cli-guard
English | 简体中文
In one sentence: pi-cli-guard rewrites the model's legacy bash to the modern toolchain the harness already ships —
grep→rgand a conservativefind→fd— at thetool_callseam, so it composes with other bash extensions instead of overriding the tool.
pi-cli-guard is an open-source (MIT), TypeScript extension for the pi AI coding agent that canonicalizes the agent's bash commands to the preferred modern toolchain:
It does not override the bash tool. It mutates event.input.command in
the tool_call event, so it composes with other bash extensions (notably
pi-tool-guard) instead of
colliding with them.
Why this exists: pi's system prompt already recommends
rg, but real sessions show the model reaching for legacy tools anyway. Measured across three repositories: 6,888grepbash calls vs 1,648rg, and 899findvs 1fd— plus the user repeatedly interrupting to say "grep so slow, use rg". A prompt line is not enough; the harness has to fix it.
Install: pi install npm:pi-cli-guard · see the
pi.dev gallery page and the
packages documentation.
Install
pi install npm:pi-cli-guard
# or load this checkout for one session:
pi -e .
rg and fd must be on PATH. A rule is skipped when its binary is missing,
so the original command is never rewritten to something that would fail.
(fd is fdfind on Debian/Ubuntu; both are detected.)
Configuration
Read from the environment — no settings plumbing:
| Variable | Default | Meaning |
|---|---|---|
PI_CLI_GUARD |
on | off/0/false disables the extension entirely |
PI_CLI_GUARD_RULES |
all | comma list of short names (rg, egrep, fgrep, fd) or full ids (grep-to-rg, find-to-fd) |
PI_CLI_GUARD_NOTICE |
on | off stops appending the rewrite note to the result |
What it rewrites

grep → rg
egrep and fgrep share this translator: they are just grep with an implicit
-E/-F, and the injected flag is dropped when it matches rg's default dialect.
Option letters collide between the two tools, so a naive rename is unsafe. The collisions are mapped:
| grep | rg | why |
|---|---|---|
-r |
--with-filename |
grep -r always prints the filename; rg does not for a single file |
-R |
-L --with-filename |
rg's -R does not exist; its -L follows symlinks |
-h |
--no-filename |
rg's -h is help |
-H |
--with-filename |
rg's -H is hidden |
-s |
--no-messages |
rg's -s is case-sensitive |
-L |
--files-without-match |
rg's -L follows symlinks |
-E |
(dropped) | grep's ERE ≈ rg's default regex |
-I |
(dropped) | rg ignores binary files by default |
--include=G |
-g G |
|
--exclude=G |
-g '!G' |
|
--exclude-dir=D |
-g '!D/**' |
Everything else common (-i -v -w -x -c -l -n -o -q -a -F -P -A/-B/-C -e -f -m,
--color, …) passes through.
The dialect gate. grep's default dialect is BRE; rg uses Rust regex, where
a+ means one-or-more a instead of a literal a+. A BRE pattern is rewritten
only when it is provably equivalent in both dialects: plain literals, safe
escapes such as \., and BRE alternation, rewritten a\|b → a|b (by far the
most common real-world pattern — grep "FAIL\|Test Files"). Anything else
(unescaped + ? ( ) { }, \+, \s, \| inside a bracket expression, a
pattern carrying a shell expansion, …) refuses and leaves the command
untouched. -E, -F and -P are dialect-compatible and always allowed.
find → fd
Only these forms are translated; anything else is refused:
| find | fd |
|---|---|
find [path] -name GLOB |
fd -g -s GLOB [path] (explicit -s beats fd's smart case) |
-iname GLOB |
-g -i GLOB |
-type f/d/l |
-t f/d/l |
-maxdepth N / -mindepth N |
-d N / --min-depth N |
-name A -o -name B |
-g '{A,B}' |
-path GLOB (leading *) |
-p -s REGEX (glob → full-path regex) |
-path GLOB -name GLOB |
-p -s REGEX --and '(^|/)BASENAME$' |
\( … \) with no -o inside |
(parentheses dropped) |
leading -L |
-L (follow symlinks) |
-path's glob becomes a Rust regex where * still crosses /; only globs that
start with * are accepted, because that is what makes find's whole-path match
and fd's unanchored --full-path match agree.
Refused (left as find): -exec, -delete, -prune, -newer, -perm,
-size, -regex, !/-not, boolean -o that is not between name globs,
implicit-AND name globs, name globs containing /, an anchored -path glob,
-path mixed with -iname or an -o chain, a group that contains -o (real
precedence), and an -o name chain combined with -type (find's AND binds
tighter than -o, so -type f -name a -o -name b cannot be expressed by one fd
call).
Fidelity guarantees and known deviations
The engine parses the command with unbash
and splices only the command-name…args span, so redirects, pipelines,
&&/|| chains, environment prefixes and comments outside that span are
preserved byte-for-byte. Commands containing command/process substitutions, or
with a redirect interleaved inside the argument span, are skipped entirely. The
engine is idempotent.
Accepted, documented deviations (surfaced to the model in the notice):
- rg respects
.gitignoreand skips hidden files;grep -rdoes not. This is the intended speed win;-u/--no-ignorerestores exhaustive search. - fd skips hidden and
.gitignored paths, and prints directories with a trailing/and without the search root itself. grep PATTERN DIRwithout-rerrors in grep but searches recursively in rg — this turns a common model mistake into working behaviour.- BRE patterns that would change meaning are refused, not guessed.
A command a rule recognises but declines to translate is left byte-for-byte
untouched and only reported (refusals), so the model can tell "the guard had
no faithful equivalent" from "the guard never looked".
The rewrite is reported back to the model (a trailing note on the bash result)
and recorded in details.piCliGuard:
import { readPiCliGuardDetails } from "pi-cli-guard/contract";
const guard = readPiCliGuardDetails(result.details);
if (guard) {
guard.originalCommand; // "grep -rn foo ."
guard.command; // "rg --with-filename -n foo ."
guard.changes; // [{ rule: "grep-to-rg", before, after }]
guard.refusals; // [{ rule: "find-to-fd", before, reason }] — declined rewrites
}
Composability with pi-tool-guard
pi's tool registry is first registration per name wins. pi-tool-guard already
overrides bash, so a second registerTool("bash") would silently disable it.
This extension therefore never registers a tool; it rewrites the input in the
tool_call event. pi-tool-guard sees the rewritten command and can still
strip/re-apply a trailing rg/fd view extractor.
FAQ
Does pi-cli-guard override or replace the bash tool?
No. It mutates event.input.command in the tool_call event; the tool itself
is never re-registered, so the first-registered bash (e.g. pi-tool-guard)
keeps winning.
How is this different from just telling the model to use rg in a prompt?
Prompts are advisory — across real sessions the model still wrote 6,888
grep calls vs 1,648 rg. pi-cli-guard enforces the mapping in the harness
and refuses any rewrite that would change behaviour (BRE, find edge cases).
Does pi-cli-guard work with pi-tool-guard and other bash extensions?
Yes — composition is the design goal. It never registers a tool, so other bash extensions see the already-rewritten command and can act on it afterwards.
What happens if rg or fd is not installed?
The rule is skipped and the original command runs byte-for-byte untouched — the guard never rewrites a command into one that would fail.
Related pi extensions
- pi-tool-guard — runs an
expensive bash command once, keeps its full log, and repairs broken tool-call
arguments.
pi-cli-guardwas written to compose with itsbashoverride. - pi-codemode-guard — compiles LLM-written codemode scripts before they reach the sandbox.
- pi-codemode-snippets —
turns a
// @snippet:.jsfile into a named, composable pi tool. - pi-skill-guard — guards skill loading and invocation.
- pi-voice — speech input for pi.
Development
pnpm install
pnpm test # 169 unit + real-binary equivalence tests
pnpm typecheck
pi -e . # load this checkout as an extension for one session
The equivalence tests run the original and the rewritten command through the
real rg/fd and compare stdout; they skip when the binaries are absent.
Design notes live in AGENTS.md and CONTEXT.md.
Pi's own documentation:
Extensions ·
Pi packages ·
Package gallery.
License
MIT
