-
Notifications
You must be signed in to change notification settings - Fork 332
Description
TL;DR: PR #156 replaces the current shell script with a fully tested Rust hook engine featuring an integrated lexer that prevents AI timeouts via line-by-line output streaming, increases tokens saved by successfully rewriting compound commands, and stops RTK from breaking standard tools like find and vitest.
Summary
The current shell hook (hooks/rtk-rewrite.sh, v0.28.2) works well for its original purpose — rewriting single commands via rtk rewrite. However, five gaps have emerged as the hook system needs to handle streaming output, multiple handlers, and broader command routing. The shell hook calls rtk rewrite "$CMD", emits the result as JSON, and exits — which means it can't stream test output incrementally, can't coordinate additional handlers (e.g. for Gemini CLI or safety rules), and routes some commands through RTK filters that aren't designed for them (via classify_command(), originally built for rtk discover history analysis).
PR #156 (feat/rust-hooks-v2) extends the hook system with a Rust-based hook engine: conservative command routing via hook_lookup() whitelist, line-by-line streaming for test runners (BufReader in new src/stream.rs), multi-handler coordination (shell: parallel-merge, binary: manifest-based), and robust stderr/exit-code handling for deny decisions. 1055 tests, developed with TDD.
Master's hook flow: Claude Code calls hooks/rtk-rewrite.sh → shell runs rtk rewrite "$CMD" → Rust calls registry::rewrite_command() in src/rewrite_cmd.rs → routing via classify_command() using RULES from src/discover/rules.rs → returns rewritten command or exits non-zero if unrecognized.
Hook engine scope (PR #156, not counting upstream merge): 6,533 lines across 14 files. 62% tests (~4,050 lines), 38% production code (~2,483 lines). The two alternative approaches (#141 JS/Bun hook by Fernando Basilis, master's shell hook) have no hook-specific tests.
Bug 1 🔴 CRITICAL — No streaming: .output() buffers all command output
What happens on master
RTK's command modules currently use Rust's .output() method, which collects all stdout/stderr in memory before processing — this works fine for quick commands, but becomes a problem for long-running test suites. Since the shell hook rewrites commands like cargo test → rtk cargo test, the buffering behavior applies to every hook-rewritten test command.
Code paths on master (v0.28.2):
src/runner.rs:17-19:.stdout(Stdio::piped()).stderr(Stdio::piped()).output()— blocks until child exitssrc/cargo_cmd.rs:86:String::from_utf8_lossy(&output.stdout)— entire test suite collected before filteringsrc/go_cmd.rs:63: same collect-then-process forgo test -jsonNDJSON outputsrc/pytest_cmd.rs,src/vitest_cmd.rs: same.output()pattern
Concrete impact
When an AI coding assistant runs cargo test on a project with a large test suite:
- Hook rewrites to
rtk cargo test - RTK spawns
cargo testand calls.output()— blocks for the entire test run duration - AI sees zero output until all tests finish (could be minutes)
- Claude Code may timeout the Bash tool waiting for output
- If parent is killed (timeout or user interrupt), child process may orphan
- No incremental feedback → AI assumes command hung → kills and retries → wasted tokens
Affected commands: cargo test, go test, pytest, vitest run — every test runner the hook rewrites.
How to reproduce
# In a project with a slow test suite (>10 seconds):
rtk cargo test # No output appears until ALL tests complete
cargo test # Compare: output streams incrementallyFix in PR #156
New src/stream.rs module with BufReader wrapping child stdout for line-by-line streaming (line 22). Output flows incrementally to the AI as tests run. Broken pipes handled cleanly via map_while(Result::ok) (line 276) — stops iteration on first I/O error instead of spinning infinitely.
PR comparison
| Approach | Streaming |
|---|---|
master (v0.28.2) |
❌ .output() blocks until child exits — zero incremental output |
#156 feat/rust-hooks-v2 |
✅ BufReader line-by-line streaming with clean pipe break handling |
| #141 JS hook (closed) | ❌ No streaming support |
Bug 2 🔴 CRITICAL — Shell hook cannot coordinate multiple handlers (no extensibility)
What happens on master
The shell hook hooks/rtk-rewrite.sh (61 lines) rewrites one command and exits. There is no extension point. Its core logic:
# hooks/rtk-rewrite.sh on master (v0.28.2)
REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || exit 0
if [ "$CMD" = "$REWRITTEN" ]; then exit 0; fi
# emit JSON with permissionDecision: "allow" and the rewritten commandIt calls rtk rewrite, emits the result, and exits. The current design handles one handler (RTK's rewrite) well, but doesn't have a way to discover or run additional handlers. If a Claude Code extension registers a PreToolUse hook handler (e.g., Gemini CLI support via PR #158, future TOML-based safety rules, or any third-party extension), the shell hook won't discover or execute it.
Master's src/init.rs only patches ~/.claude/settings.json to register this shell hook (via insert_hook_entry() at line 630). It has no concept of extension handler discovery, handler registration manifests, or cache file patching. Running rtk init registers the shell hook — nothing more.
Concrete impact
If a user installs a Claude Code extension that registers a PreToolUse hook handler (e.g., Gemini CLI support from PR #158), the shell hook doesn't know about it. The extension appears installed but its handler never runs — with no error or warning to indicate why.
Fix in PR #156
Three complementary pieces:
-
Shell hook (
hooks/rtk-rewrite.sh): parallel-merge coordinator. All registered handlers launch in background (BEGIN/END_RTK_BASH_HANDLERSsection), COLLECT phase waits for all to finish, MERGE phase applies deny-wins logic. RTK rewrite only applied if no handler denied. -
Binary hook (
src/cmd/hook/claude.rs):run_manifest_handlers()called on bothNoOpinion(line 203) andAllow(line 221) code paths. Handlers discovered via manifest file. Deny from any handler wins over RTK's allow/rewrite decision. -
Init handler registration (
src/init.rs):patch_plugin_caches()andpatch_single_cache_file()scan~/.claude/extensions/*/hooks/for extension handlers, write a handler manifest, and include a reconstruction path sortk initis idempotent (running it twice doesn't break registration).
PR comparison
| Approach | Handler coordination | Init registers handlers |
|---|---|---|
master (v0.28.2) |
❌ rtk rewrite "$CMD" → exit 0 — no coordination |
❌ Only patches settings.json |
#156 feat/rust-hooks-v2 |
✅ Parallel-merge (shell) + run_manifest_handlers() on both paths (binary) |
✅ patch_plugin_caches() + manifest + idempotent reconstruction |
| #141 JS hook (closed) | ❌ No handler coordination | ❌ No handler registration |
Bug 3 🟠 HIGH — Claude Code bug #4669: stderr at exit 0 disables hook (fail-open)
What happens on master
Claude Code treats ANY stderr output at exit 0 as a hook error and runs the tool unmodified (fail-open). The shell hook uses 2>/dev/null on the rtk rewrite call, suppressing RTK's own stderr. But if any other subprocess writes to stderr during the hook's execution, Claude Code interprets it as a hook error and runs the command unmodified.
All error paths in rtk-rewrite.sh exit 0 (version guard, missing jq, missing rtk, empty command), and there's no exit-code-based deny mechanism yet. This means that even if deny logic were added to the shell hook, a stray stderr line from any subprocess could cause Claude Code to bypass it (due to upstream bug #4669).
Concrete impact
A deny decision emitted as {"hookSpecificOutput":{"permissionDecision":"deny"}} at exit 0 is silently ignored if any stderr was written during hook execution. The command executes unmodified. No user-visible error.
Fix in PR #156
src/cmd/hook/claude.rs: deny path uses exit 2 + stderr message (not exit 0 + JSON) at line 210 and line 235. Commands are reliably blocked regardless of Claude Code bug #4669. Exit 0 paths (NoOpinion, Allow) never write to stderr — they are stderr-clean by construction.
PR comparison
| Approach | Deny reliability |
|---|---|
master (v0.28.2) |
❌ exit 0 + any stderr = fail-open (Claude Code bug #4669) |
#156 feat/rust-hooks-v2 |
✅ exit 2 + stderr for deny; exit 0 paths are stderr-clean |
| #141 JS hook (closed) | ❌ Not addressed |
Bug 4 🟡 MEDIUM — Wrong command routing: docker run, find, etc. incorrectly go through RTK
What happens on master
The shell hook calls rtk rewrite → registry::rewrite_command() → rewrite_segment() → classify_command() (in src/discover/registry.rs). The problem: classify_command() uses the RULES table from src/discover/rules.rs, which was designed for history analysis (rtk discover). It matches any command RTK recognizes, not just commands where RTK has well-tested filters. This is too broad for hook use — the hook should only rewrite commands where RTK's output is known to be correct.
Examples of wrong routing on master:
find . -name '*.rs'→rtk find . -name '*.rs'— breaks: RTK's find expectsrtk find <PATTERN> [PATH]syntax, standard find flags like-namefail with "unexpected argument" (exit 2)docker run --rm ubuntu bash→rtk docker run --rm ubuntu bash— works viaDockerCommands::Otherpassthrough (src/main.rs:1421), but adds unnecessary RTK overhead for a command RTK doesn't filter
Concrete impact
find is the clearest breakage: rtk find . -name '*.rs' fails with exit 2 because RTK's find command uses rtk find <PATTERN> [PATH] syntax — standard find flags like -name, -type, -maxdepth are not recognized. Other commands like docker run work via passthrough but add unnecessary RTK overhead.
How to reproduce
# Rewrite routes these through RTK even though RTK's filters aren't designed for them:
rtk rewrite "find . -name '*.rs'" # Returns "rtk find . -name '*.rs'"
rtk find . -name '*.rs' # ERROR: "unexpected argument '-n'" (RTK find has different syntax)
rtk rewrite "docker run --rm ubuntu bash" # Returns "rtk docker run ..." (works via passthrough, but unnecessary)Fix in PR #156
hook_lookup() in src/cmd/hook/mod.rs: conservative whitelist matching only subcommands RTK has tested filters for. Examples:
docker: onlyps,images,logs(NOTrun,exec,build)git: onlystatus,log,diff,show,add,commit,push,pull,fetch,stashcargo: onlytest,build,clippy,check,install,fmt(NOTpublish,run)
Unknown commands pass through unchanged instead of being wrapped in rtk run -c.
PR comparison
| Approach | Command routing |
|---|---|
master (v0.28.2) |
❌ classify_command() routes docker run/exec/build, find, tree, wget through RTK |
#156 feat/rust-hooks-v2 |
✅ hook_lookup() whitelist — only routes subcommands with tested filters |
| #141 JS hook (closed) | ❌ No routing whitelist |
Bug 5 🟡 MEDIUM — vitest without run subcommand produces broken invocation
What happens on master
The rewrite prefix for vitest in src/discover/rules.rs is "vitest", so bare vitest → rtk vitest (without the required run subcommand). Upstream tests only cover vitest run, never bare vitest.
Concrete impact
rtk vitest (no run) prints Clap help text and exits with code 2 — the test suite never runs. The VitestCommands enum in main.rs:824 only has a Run variant, so Clap requires the subcommand.
How to reproduce
rtk rewrite "vitest" # Returns "rtk vitest" (missing "run")
rtk vitest # Prints help and exits 2 — Clap requires a subcommand
rtk rewrite "vitest run" # Returns "rtk vitest run" (correct — works)Fix in PR #156
hook_lookup() and route_pnpm() (line 287) / route_npx() (line 333) inject run when absent: bare vitest → rtk vitest run. Test: test_routing_vitest_no_double_run and table test.
PR comparison
| Approach | vitest handling |
|---|---|
master (v0.28.2) |
❌ vitest → rtk vitest (no run injection) |
#156 feat/rust-hooks-v2 |
✅ vitest → rtk vitest run (automatic injection) |
| #141 JS hook (closed) | ❌ No run injection |
Additional capabilities in PR #156
Dual-format deny detection (🟡 medium)
is_json_deny() in claude.rs detects both Claude Code format (hookSpecificOutput.permissionDecision == "deny") and Gemini format (decision == "deny"). Master has no deny detection (no binary hook exists).
Dependent PRs (optional features built on PR #156's hook engine)
| PR | Feature | Dependency | Status |
|---|---|---|---|
| #158 | Gemini CLI support | Requires #156 (imports from hook.rs, exec.rs) |
Ready after #156 merge |
| #157 | Data safety rules | Optional — upstream may use TOML-based safety instead | Independent, can be skipped |
PRs #157 and #158 will not compile against current master — both import from modules added by #156. They have zero file overlap and can merge in any order after #156.
Merge sequence
1. Merge PR #156 (hook engine) → master — fixes all 5 bugs, 1055 tests
2. Merge PR #158 (Gemini support) → master — depends on #156
3. Optional: PR #157 (data safety) → master — independent, can be skipped