Gemini CLI support, rm→trash / git→stash data safety rtk.*.md rules, chained command rewriting, Rust-based hooks#131
Open
ahundt wants to merge 27 commits intortk-ai:masterfrom
Open
Conversation
Implements a "Fat Binary, Thin Hook" architecture that enables RTK to safely execute arbitrary commands while applying token optimization and safety rules. Core components: - src/cmd/lexer.rs: Quote-aware state-machine lexer that correctly handles operators (&&, ||, ;) inside quoted strings - src/cmd/analysis.rs: Native vs passthrough decision engine - src/cmd/safety.rs: Safety rules with env var toggles (RTK_SAFE_RM, RTK_SAFE_GIT) and tool suggestions (cat->Read, sed->Edit) - src/cmd/trash_cmd.rs: Built-in cross-platform trash using `trash` crate - src/cmd/builtins.rs: Native cd/export/pwd/echo implementations - src/cmd/exec.rs: Hybrid executor with recursion guard (RTK_ACTIVE) - src/cmd/filters.rs: Registry connecting binaries to token reducers - src/cmd/hook.rs: Claude text protocol handler - src/cmd/gemini_hook.rs: Gemini JSON protocol handler New CLI commands: - `rtk run -c <cmd>`: Execute command through hybrid engine - `rtk hook check --agent <claude|gemini> <cmd>`: Hook protocol check - `rtk hook gemini`: Handle Gemini JSON stdin Features: - Handles chained commands (cd dir && git status) - Short-circuit evaluation (&&, ||, ;) - Passthrough for shellisms (*, $, pipes, redirects) - Safety: rm -> trash, git clean -fd -> block, cat -> suggest Read Tests: 312 new tests in cmd module, all 499 total tests passing Closes rtk-ai#112 (chained commands not rewritten) Refs rtk-ai#115 (safe command remapping)
Hook script (hooks/rtk-rewrite.sh): - Add RTK_HOOK_ENABLED master toggle (default: 1) - Add RTK_HOOK_HYBRID to use rtk hook check (default: 1) - Add RTK_ACTIVE recursion guard - Delegate to rtk hook check for intelligent command analysis - Output proper deny response when commands are blocked Safety module (src/cmd/safety.rs): - Consolidate env vars to coarse-grained toggles: - RTK_SAFE_COMMANDS=1 enables all safety features (rm->trash, git safety) - RTK_BLOCK_TOKEN_WASTE=0 disables token waste prevention - Token waste prevention (cat/sed/head) is enabled by default - Update all tests for new env var names Builtins (src/cmd/builtins.rs): - Fix flaky cd test assertion Environment Variables Summary: - RTK_HOOK_ENABLED=0|1 - Master hook toggle (default: 1) - RTK_HOOK_HYBRID=0|1 - Use hybrid engine (default: 1) - RTK_SAFE_COMMANDS=1 - Enable command safety (rm->trash, git) - RTK_BLOCK_TOKEN_WASTE=0 - Disable cat/sed/head blocking - RTK_ACTIVE=1 - Recursion guard (internal)
The Claude Code hook specification requires exit code 2 for blocking errors, not exit code 1. Using exit 1 may be treated as non-blocking, potentially allowing blocked commands to proceed. Changes: - hook.rs: format_for_claude() now returns exit code 2 for Blocked - rtk-rewrite.sh: Changed exit 1 to exit 2 for deny responses - Added 13 new edge case tests for hook protocol coverage: - head command blocking - env var prefix handling - backtick/subshell/brace expansion shellisms - chained command handling - special characters and unicode - very long commands - exit code verification tests - multi-agent support verification All 572 tests pass.
Verify that cat/sed/head blocking is context-aware: - cat with pipe (cat file | grep x) → allowed via passthrough - cat with redirect (cat file > out) → allowed via passthrough - sed with redirect (sed s/x/y/ f > out) → allowed via passthrough - head with pipe (head -n 10 f | grep) → allowed via passthrough - cat standalone → blocked (should use Read tool) - cat in chain (cd dir && cat file) → blocked (should use cd + Read) - cat in complex script (for loops, etc.) → allowed via passthrough This ensures token waste prevention only blocks commands that could be replaced with proper tools, not legitimate shell usage. All 579 tests pass.
Parallel test execution caused intermittent failures when tests accessed global environment variables. Added static OnceLock<Mutex> to serialize tests that modify RTK_SAFE_COMMANDS and RTK_BLOCK_TOKEN_WASTE. Changes: - safety.rs: Added ENV_LOCK mutex, all 20 tests use env_lock() - edge_cases.rs: Added same pattern for 9 safety edge case tests The mutex ensures only one test modifies env vars at a time, preventing race conditions while keeping parallel execution for other tests that dont touch env vars. All 579 tests pass reliably with parallel execution.
Changed RTK_SAFE_COMMANDS from opt-in to opt-out: - rm -> trash: now enabled by default - git reset --hard -> stash prepend: now enabled by default - git clean -fd/-df -> block: now enabled by default To disable safety features, set RTK_SAFE_COMMANDS=0 Also fixed: - check_raw() function to use opt-out logic - Mutex poison recovery in test env_lock() - Updated all affected tests All 582 tests pass.
- Silent on success (like rm behavior) - Single-line error with bypass hint - Removed verbose progress messages - Reduced from 5+ lines to 0-1 lines output Before: 'Moving X item(s) to trash...\nDone.\n' After: (silent) Before: 6-line error with suggestions After: 'trash: ✗ <error> (RTK_SAFE_COMMANDS=0 to bypass)'
The SuggestTool action appends 'Use the **X** tool.' so agent_msg should not include the tool suggestion to avoid duplication. Before: 'BLOCK: cat wastes tokens. Use Read tool.. Use the **Read** tool.' After: 'BLOCK: cat wastes tokens. Use the **Read** tool.'
- Empty args: 'rm: missing operand' - Missing files: 'rm: cannot remove X: No such file' - Silent on success (like rm) - Concise error on trash failure
- git checkout . -> prepend stash (with predicate: has_unstaged_changes) - git checkout -- -> prepend stash (with predicate) - git stash drop -> rewrite to stash pop (recoverable) - git clean -f -> block (suggest -n dry-run) - git checkout <branch> still works (not matched) - git checkout -b still works (not matched) All rules use RTK_SAFE_COMMANDS env var, enabled by default.
… init
Previous behavior:
- safety.rs: starts_with(pattern) matched "catalog" as "cat", "sedan" as "sed"
- exec.rs: RTK_ACTIVE env var leaked if execute_inner() panicked
- hook.rs: no recursion depth limit on safety rewrite chains
- gemini_hook.rs: used wrong field names ("type", "result", "message",
"modified_input") that Gemini CLI silently ignored
- filters.rs: duplicate strip_ansi() reimplemented vs utils::strip_ansi()
- init.rs: no Gemini CLI hook setup support
- predicates.rs: used deprecated atty crate
What changed:
Bug fixes:
- safety.rs: match single-word patterns against binary exactly, multi-word
against full_cmd.starts_with(); fix check_raw() word boundary matching
- exec.rs: replace set/unset_rtk_active() with RAII RtkActiveGuard (Drop cleans
up even on panic)
- hook.rs: add MAX_REWRITE_DEPTH=3 with check_for_hook_inner() recursion limit
- gemini_hook.rs: rewrite to match actual Gemini CLI API spec -- use
hook_event_name, decision, reason, hookSpecificOutput.tool_input; add
is_shell_tool() filter for run_shell_command/shell/MCP patterns
- filters.rs: delete duplicate strip_ansi(), use crate::utils::strip_ansi;
deduplicate apply() by delegating to apply_to_string()
- predicates.rs: replace atty::is() with std::io::IsTerminal, remove atty dep
- builtins.rs: fix echo -n flag handling (was printing "-n" literally)
DRY refactoring (-735 net lines):
- safety.rs: add rule!() declarative macro for SafetyRule construction
- hook.rs: table-driven tests with assert_rewrite()/assert_blocked() helpers
- edge_cases.rs: prune from 660 to 122 lines, keep only cross-module
integration tests (unit tests live in their home modules)
- test_helpers.rs: new shared EnvGuard with RAII cleanup for env-mutating tests
- filters.rs: document as fallback filter (20-40%) vs dedicated modules (60-90%)
Dead code removal:
- lexer.rs: remove has_shellisms(), merge identical if blocks
- analysis.rs: remove ExecutionPlan enum and analyze() function
- predicates.rs: remove 7 unused functions (has_staged_changes, stash_exists,
is_file, is_dir, path_exists, in_git_repo, binary_exists)
- safety.rs: remove unused SafetyAction::Allow and SafetyAction::Block variants
- hook.rs: remove unused format_for_gemini()
- trash_cmd.rs: remove unused is_available()
- ruff_cmd.rs: remove unused RuffLocation struct and unused fields
New feature:
- init.rs: add rtk init --gemini for Gemini CLI hook setup -- patches
~/.gemini/settings.json with BeforeTool hook, supports --auto-patch,
--no-patch, and --uninstall; parallel to existing Claude Code init
- main.rs: route --gemini flag to init::run_gemini()
Tests added (546 total, 0 failures):
- gemini_hook.rs: 17 protocol conformance tests (field names, tool filtering,
event filtering, edge cases)
- hook.rs: 9 Claude Code wire format tests (exit codes, text output) +
cross-protocol consistency tests
- init.rs: 11 Gemini init tests (idempotency, deep-merge, independence from
Claude hooks)
- exec.rs: RAII guard panic-safety test via catch_unwind
Files affected:
- src/cmd/safety.rs: pattern matching fix, rule!() macro, remove dead variants
- src/cmd/exec.rs: RtkActiveGuard RAII, flatten if-else
- src/cmd/hook.rs: recursion limit, table-driven tests, remove dead code
- src/cmd/gemini_hook.rs: complete rewrite to match Gemini CLI API spec
- src/cmd/filters.rs: dedup strip_ansi, dedup apply(), add module docs
- src/cmd/edge_cases.rs: prune to integration-only tests
- src/cmd/test_helpers.rs: new shared EnvGuard
- src/cmd/lexer.rs: remove dead code, merge branches
- src/cmd/analysis.rs: remove dead code
- src/cmd/predicates.rs: remove dead code, replace atty
- src/cmd/builtins.rs: fix echo -n, clean test assertions
- src/cmd/trash_cmd.rs: remove dead code
- src/cmd/mod.rs: register test_helpers module
- src/init.rs: add Gemini CLI init support (9 new functions, 11 tests)
- src/main.rs: add --gemini flag, route to init::run_gemini()
- src/ruff_cmd.rs: remove unused structs/fields
- Cargo.toml: remove atty dependency
- Various *.rs: minor clippy fixes (container, find_cmd, gh_cmd, git, etc.)
Testable:
- cargo test (546 pass)
- cargo run -- hook check "catalog list" (should NOT trigger cat block)
- cargo run -- init --gemini --auto-patch (creates ~/.gemini/settings.json)
…tension manifest Previous behavior: - README.md: no documentation for Gemini CLI integration - init.rs: show_config() only checked Claude Code artifacts (hook, RTK.md, CLAUDE.md, settings.json) — no Gemini status - No gemini-extension.json for `gemini extensions install` What changed: - README.md: add "Gemini CLI Integration" section with quick install (`rtk init --gemini`), manual install (settings.json snippet), protocol explanation (BeforeTool → rtk hook gemini), and uninstall instructions; update "Uninstalling RTK" to mention ~/.gemini/settings.json removal - src/init.rs: show_config() now calls resolve_gemini_dir() and gemini_hook_already_present() to check ~/.gemini/settings.json for the RTK BeforeTool hook entry; displays status with same checkmark/warning/circle pattern as Claude Code checks; adds three Gemini usage lines to help output - gemini-extension.json: new manifest file for `gemini extensions install` with name, version, description, entryPoint, hooks config, repo URL, license Files affected: - README.md: +62 lines (Gemini CLI Integration section, uninstall update) - src/init.rs: +34 lines (Gemini status in show_config, usage lines) - gemini-extension.json: new file (extension manifest) Testable: - cargo test (546 pass, 0 fail) - cargo run -- init --show (should display Gemini settings.json status)
…eanup
Previous behavior:
- exec.rs: spawn_with_filter() used spawn() + piped reads before wait(),
deadlocking if child output exceeded OS pipe buffer (~64KB)
- exec.rs: rtk run -c "git status" flattening took args[1] ("-c") instead
of args[2] (the actual command)
- exec.rs: timer.track() passed filtered output as both raw and filtered,
making savings always 0%
- exec.rs: run_passthrough() merged stderr into stdout via single print!()
- exec.rs: RAII guard tests called set_var/remove_var without EnvGuard
mutex, racing with other env-mutating tests
- lexer.rs: single & (background job) classified as Redirect, not Shellism;
! (history expansion/negation) not recognized as shellism
- safety.rs: check_raw() sudo detection used windows(2) — "sudo -u root rm"
bypassed because rm is not adjacent to sudo
- safety.rs: Rewrite action called template.replace("{args}", ...) but no
rule uses {args} — dead code path
- safety.rs: block messages said "Use Read tool" / "Use Edit tool" which
are Claude Code tool names, not understood by Gemini CLI
- gemini_hook.rs: dead branch (new_cmd == cmd) never triggered because
check_for_hook wraps all safe commands in rtk run -c
- gemini_hook.rs: _tool_name cloned a string that was never used
- hook.rs: non-exhaustive _ => {} match silently swallowed future variants
- filters.rs: apply() function left unused after spawn → output() refactor
What changed:
- exec.rs: replace spawn() + piped read with Command::output() (uses
internal threads, no deadlock); fix -c flag handling in rtk run
flattening; separate raw vs filtered for accurate tracking; print
stderr to stderr in passthrough; wrap RAII tests with EnvGuard
- lexer.rs: classify single & as Shellism (background jobs need real
shell); add ! to shellism characters
- safety.rs: scan all words after sudo for rm (not just adjacent);
remove dead {args} template substitution; make block messages
agent-generic ("file-reading tool" instead of "Read tool")
- gemini_hook.rs: remove dead new_cmd == cmd branch; remove unused
_tool_name clone
- hook.rs: replace _ => {} with explicit Rewritten/TrashRequested arms
- filters.rs: remove unused apply() function and ChildStdout/Stderr imports
- cargo fmt applied to all src/cmd/ files (92 formatting fixes)
Files affected:
- src/cmd/exec.rs: deadlock fix, -c flattening, tracking, stderr, EnvGuard
- src/cmd/safety.rs: sudo scan, agent-generic messages, remove {args}
- src/cmd/lexer.rs: & as Shellism, ! as shellism
- src/cmd/hook.rs: exhaustive match arms
- src/cmd/gemini_hook.rs: remove dead branch and unused clone
- src/cmd/filters.rs: remove dead apply() function
- src/cmd/analysis.rs, builtins.rs, mod.rs, predicates.rs, trash_cmd.rs:
cargo fmt only
- src/container.rs, git.rs, init.rs, main.rs, pip_cmd.rs, utils.rs:
cargo fmt only
Testable:
- cargo test (549 pass, 0 fail)
- cargo clippy --all-targets (0 warnings in src/cmd/)
…en visibility Previous behavior: The src/cmd/ module used vague terms like "hybrid command engine", "hybrid", and "native" throughout docs, comments, env vars, and scripts. init.rs duplicated ~100 lines of identical JSON read/check/backup/write logic across Claude and Gemini hook setup paths. Seven internal modules were pub instead of pub(crate). edge_cases.rs contained 16 tests, 9 of which duplicated tests already in exec.rs. What changed: - README.md: "hybrid engine" → "safety checks and token-optimized output" - hooks/rtk-rewrite.sh: RTK_HOOK_HYBRID → RTK_HOOK_REWRITE, all "hybrid engine"/"native mode" refs replaced with "rewrite mode"/"safety rewrite" - scripts/test-hybrid-engine.sh → scripts/test-cmd-interceptor.sh (renamed) - gemini-extension.json: deleted (dead metadata, no code references it; Gemini integration works via rtk init --gemini → ~/.gemini/settings.json) - src/cmd/mod.rs: doc rename, 7 pub mod → pub(crate) mod (analysis, builtins, filters, lexer, predicates, safety, trash_cmd), removed mod edge_cases declaration - src/cmd/predicates.rs: 4 pub fn → pub(crate) fn (has_unstaged_changes, is_interactive, expand_tilde, get_home) - src/cmd/exec.rs: doc rename, added 9 tests (7 moved from edge_cases.rs + 2 restoring coverage for 3-command && chain and semicolon-last-wins) - src/cmd/edge_cases.rs: deleted (9 duplicate tests dropped, 7 unique tests moved to exec.rs) - src/cmd/hook.rs: merged test_chain_rewrite and test_very_long_command into test_safe_commands_rewrite table, preserved && operator assertion - src/cmd/filters.rs: doc "hybrid engine" → "rtk run" - src/main.rs: doc "hybrid engine" → "safety checks and token-optimized output" - src/init.rs: extracted 3 shared functions replacing 6 copy-pasted ones: patch_settings_shared() for JSON hook patching (Claude + Gemini), remove_hook_from_settings_file() for hook removal (Claude + Gemini), show_agent_hook_status() for config display (Claude + Gemini) Why: Vague terminology made the codebase harder to understand. Duplicated init.rs patterns meant adding a third agent hook would require copy-pasting ~80 lines. Public internal modules allowed misuse from outside src/cmd/. Files affected: 12 files, -373/+231 lines (net -142). 540 tests pass.
…ude`
Previous behavior: Claude Code hook used a 257-line bash script
(hooks/rtk-rewrite.sh) that required jq, duplicated safety logic
with regex fallbacks, and couldn't maintain state across invocations.
Gemini hook was already a direct binary call (`rtk hook gemini`).
What changed:
- src/cmd/claude_hook.rs: New 453-line module (24 tests) implementing
Claude Code JSON hook protocol with fail-open run()/run_inner() split.
Serde structs with camelCase field names match Claude Code spec exactly
(hookSpecificOutput, permissionDecision, updatedInput).
- src/cmd/mod.rs: Register claude_hook module
- src/main.rs: Add `Claude` variant to HookCommands enum with routing
- src/init.rs: Remove dead code (REWRITE_HOOK, prepare_hook_paths,
ensure_hook_installed ~80 lines). Change patch_settings_json() to
register "rtk hook claude" directly. Update hook_already_present()
and remove_hook_from_json() to match both legacy rtk-rewrite.sh
and new rtk hook claude patterns. Update show_config() to check
settings.json instead of hook file existence.
- src/cmd/hook.rs: Add 3 new test functions porting coverage from
deleted shell tests: env var prefix preservation (7 commands),
specific command pass-through (34 commands including npm/docker/
kubectl/vitest/vue-tsc), and builtin pass-through (8 commands).
- hooks/rtk-rewrite.sh: 257 lines -> 4-line migration shim
(`exec rtk hook claude`)
- hooks/test-rtk-rewrite.sh: Deleted (293 lines). All coverage
now in Rust unit tests.
Why: Achieves architectural parity with Gemini hook. Eliminates jq
dependency. Reduces hook latency from ~50-150ms (bash+jq) to ~1-2ms
(Rust). Unlocks future statefulness (session-scoped rules, persistent
cd tracking via existing tracking.rs SQLite infrastructure). Net
reduction of ~91 lines (611 added, 702 removed).
Files affected:
- src/cmd/claude_hook.rs (new): Protocol handler + 24 tests
- src/cmd/hook.rs: +94 lines (3 new test functions, 49 test cases)
- src/cmd/mod.rs: +1 line (module registration)
- src/main.rs: +5 lines (enum variant + match arm)
- src/init.rs: -209 lines net (dead code removal + updates)
- hooks/rtk-rewrite.sh: -254 lines (257 -> 4 line shim)
- hooks/test-rtk-rewrite.sh: -293 lines (deleted)
Testable: 566 tests pass (24 new in claude_hook, 3 new in hook.rs)
echo '{"tool_input":{"command":"git status"}}' | cargo run -- hook claude
echo '{"tool_input":{"command":"cat /etc/passwd"}}' | cargo run -- hook claude
echo 'bad json' | cargo run -- hook claude # exit 0, no output
…k claude Previous behavior: README.md, INSTALL.md, TROUBLESHOOTING.md, and check-installation.sh still referenced the old hooks/rtk-rewrite.sh shell script path for hook installation, manual setup, and uninstall. What changed: - README.md: Update 7 references to use rtk hook claude - INSTALL.md: Update 2 references for settings.json registration - docs/TROUBLESHOOTING.md: Rewrite manual fallback to use rtk hook claude - scripts/check-installation.sh: Replace file check with settings.json check - src/init.rs: Add test_remove_hook_from_json_new_format test Why: Previous commit (883924b) consolidated hook logic into Rust binary but did not update documentation and scripts that still directed users to copy shell scripts and referenced paths no longer used. Files: README.md, INSTALL.md, docs/TROUBLESHOOTING.md, scripts/check-installation.sh, src/init.rs (+1 test, 567 total)
…ni parity
Previous behavior:
- Both hook modules used println!/eprintln! directly, risking JSON corruption
- Guard functions (is_disabled, should_passthrough) duplicated in claude_hook.rs
- Gemini hook lacked fail-open wrapper (run/run_inner split)
- Gemini hook lacked recursion and disabled checks
- Claude hook had 3 integration tests duplicating hook.rs tests
- Gemini CLI setup not documented
What changed:
- hook.rs: Added shared guards (is_hook_disabled, should_passthrough) and HookResponse enum
- Both hooks: Added #![deny(clippy::print_stdout, clippy::print_stderr)] for compile-time enforcement
- Both hooks: Refactored run_inner() to return HookResponse (no I/O), run() is single I/O point
- claude_hook.rs: Removed local guard functions, import from hook.rs (DRY)
- gemini_hook.rs: Added missing fail-open wrapper and guard checks
- claude_hook.rs: Removed 3 duplicate integration tests (now only in hook.rs)
- INSTALL.md: Added Gemini CLI Setup section with rtk init --gemini
- TROUBLESHOOTING.md: Added "RTK not working in Gemini CLI" section
- check-installation.sh: Added Check 7 for Gemini hook status
Why:
- Compile-time I/O enforcement prevents accidental protocol corruption
- DRY eliminates 22 lines of duplicated guard logic
- Separation of concerns: logic in run_inner(), I/O in run()
- Gemini hook now has same robustness guarantees as Claude hook
- Documentation supports both Claude Code and Gemini CLI users equally
Files:
- src/cmd/hook.rs: +37 lines (shared infrastructure)
- src/cmd/claude_hook.rs: refactored, -3 tests, +doc comments
- src/cmd/gemini_hook.rs: +fail-open wrapper, +guards, +2 tests
- INSTALL.md: +31 lines (Gemini setup)
- docs/TROUBLESHOOTING.md: +52 lines (Gemini troubleshooting)
- scripts/check-installation.sh: +8 lines (Gemini verification)
Testable:
- cargo test — all 567 tests pass (-3 from deduplication)
- cargo clippy — no print_stdout/print_stderr violations
- echo '{"tool_input":{"command":"git status"}}' | rtk hook claude
- echo '{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git status"}}' | rtk hook gemini
…ni uninstall Previous behavior: - Module doc comments did not explain WHY deny attribute is needed or WHERE it applies - No reference to API spec rule "ANY stderr at exit 0 = hook error" - tool_input command replacement duplicated in both hook modules (6-7 lines each) - Gemini hook lacked dedicated uninstall function (only removed via Claude uninstall) - No inline comments explaining bug #4669 dual-path deny at I/O points - No architecture diagram showing where clippy deny is enforced What changed: - claude_hook.rs: Added module doc with stderr rule (lines 13-23), bug #4669 details (lines 25-37), I/O scope (lines 39-51), API spec citations (line 13), enhanced run() comments with box diagram (lines 172-182) - gemini_hook.rs: Added module doc with Gemini stderr rule (lines 13-22), I/O scope (lines 24-39), enhanced run() comments with box diagram (lines 115-125) - hook.rs: Added architecture diagram (lines 6-15), I/O policy scope (lines 17-25), pathway showing deny enforcement (line 29), extracted update_command_in_tool_input() helper (lines 138-158) - init.rs: Added uninstall_gemini() function (lines 429-452) removing hook from ~/.gemini/settings.json - main.rs: Route --gemini --uninstall to init::uninstall_gemini() (lines 1079-1083) - Both hooks: Import and use shared update_command_in_tool_input() (replaced 6-7 line blocks with 1 call) Why: - API spec compliance: Document exact stderr rules per hooks_api_reference.md:720-728 (Claude) and 740-753 (Gemini) - Clarity: Developers need to know WHERE restrictions apply (2 files) vs normal behavior (all others) - Bug #4669 transparency: Explain dual-path deny workaround inline at I/O points - DRY: Eliminate 11 lines of duplicated tool_input preservation logic - Parity: Gemini now has equal uninstall support (rtk init --gemini --uninstall) - Maintainability: Source citations enable verification against official docs Files: - src/cmd/claude_hook.rs: +52 doc lines, -6 code (replaced with shared call) - src/cmd/gemini_hook.rs: +44 doc lines, -5 code (replaced with shared call) - src/cmd/hook.rs: +38 doc lines, +20 code (new shared helper + docs) - src/init.rs: +24 lines (new uninstall_gemini function) - src/main.rs: +4 lines (route Gemini uninstall) Testable: - cargo test — all 567 tests pass (no behavior changes) - cargo clippy — no violations - rtk init --gemini --uninstall — now works (new functionality) - Verify doc accuracy: compare src/cmd/claude_hook.rs:13-23 against hooks_api_reference.md:720-728
… flags Previous behavior: - rtk init with no flags → Claude Code only - rtk init --gemini → Gemini CLI only (mutually exclusive) - No way to set up both platforms in one command - Uninstall was platform-exclusive (either/or) What changed: - Init command description: "Initialize rtk for Claude Code and/or Gemini CLI (default: both)" - Moved --gemini out of mode group, into new "platform" group - Added --skip-claude flag (platform group): Skip Claude Code setup - Added --skip-gemini flag (platform group): Skip Gemini CLI setup - Updated routing logic (main.rs:1089-1117): - setup_claude = \!skip_claude && \!gemini (default: true) - setup_gemini = \!skip_gemini || gemini (default: true) - Error if both platforms skipped - Run both if both enabled (sequential execution) - Summary message when both platforms set up - Updated uninstall logic (main.rs:1088-1101): Same platform selection logic Why: - DX improvement: One command sets up both CLIs (most users have both) - Backward compatibility: --gemini still works (alias for --skip-claude) - Selective setup: Can choose one platform if needed (--skip-claude or --skip-gemini) - Consistent behavior: Install and uninstall use same platform selection logic Usage examples: - rtk init -g → Both Claude and Gemini (new default) - rtk init --skip-gemini → Claude only - rtk init --skip-claude → Gemini only - rtk init --gemini → Gemini only (backward compat) - rtk init --uninstall → Remove both - rtk init --uninstall --skip-claude → Remove Gemini only Testable: - cargo test — all 567 tests pass - rtk init --help — shows new platform flags - Platform selection error: rtk init --skip-claude --skip-gemini (should error)
…laude/--gemini Previous behavior: - No documentation of which init.rs functions are shared vs platform-specific - Routing logic had unreachable error checks (clap group already prevents conflicts) - patch_settings_shared() purpose not explicitly documented - Platform flags were --skip-claude/--skip-gemini (confusing double-negative) What changed: - init.rs: Added 42-line architecture documentation block (lines 453-492) explaining: - 6 shared infrastructure functions (patch_settings_shared, show_agent_hook_status, etc.) - Platform-specific differences (PreToolUse/Bash vs BeforeTool/run_shell_command) - Why differences cannot be unified (protocol variations) - Default behavior (both platforms) - Usage examples (lines 486-492) - init.rs: Enhanced patch_settings_shared() doc comment (line 498): "Used by both Claude Code and Gemini CLI" - main.rs: Simplified to --claude (Claude only) and --gemini (Gemini only) flags - main.rs: Platform selection logic (lines 1089-1119): no flags = both, flag = that one only - main.rs: Removed unreachable error checks (clap group prevents flag conflicts) Why: - DX: Simpler mental model (--claude vs --skip-claude double-negative) - Maintainability: Developers can see which functions are shared (DRY) vs platform-specific - Clarity: Documents why platform-specific functions exist (protocol differences, not duplication) - Code size: Removes unreachable error handling (clap handles it) - Verification: Explicit line number references enable quick navigation to shared infrastructure DRY summary: - Shared: 6 core functions used by both platforms - Platform-specific: JSON structure differences (PreToolUse vs BeforeTool), artifact management (RTK.md vs none) - Appropriate separation: Protocol-specific code isolated, common logic extracted Usage examples: - rtk init → Both Claude and Gemini (default) - rtk init --claude → Claude only - rtk init --gemini → Gemini only - rtk init --uninstall → Remove both - rtk init --uninstall --claude → Remove Claude only Testable: - cargo test — all 567 tests pass - rtk init --claude --gemini → clap error (group conflict, at most one) - rtk init --help — shows platform selection flags
Previous behavior: - run_gemini() printed success message after patch_gemini_settings() already reported result - Users saw 3 messages for Gemini: patch result + run_gemini block + multi-platform summary - Claude only showed 1 consolidated message + summary - Inconsistent verbosity between platforms What changed: - init.rs:run_gemini() (lines 1060-1064): Simplified to call patch_gemini_settings() and return - Removed duplicate output block (18 lines deleted): "RTK Gemini CLI hook setup" header + details + restart message - patch_settings_shared() already prints: hook status, backup info, restart instructions - Matches Claude behavior: patch function reports, run function is silent Why: - Consistency: Both platforms now have 1 message each + 1 summary (when both set up) - DRY: Dont duplicate what patch_settings_shared() already prints - UX: Less noise, clearer output Expected output after fix: ``` RTK hook installed (global). # Claude Hook: rtk hook claude... settings.json: hook already present Patch /Users/athundt/.gemini/settings.json? [y/N] # Gemini y Gemini settings.json: hook added Restart Gemini CLI. Test with: gemini ✓ RTK installed for both platforms # Summary Restart both CLIs. ``` Testable: - cargo test — all 567 tests pass - rtk init -g — should show 1 Claude msg + 1 Gemini msg + 1 summary (not 3 Gemini msgs)
Previous behavior: - Claude: Creates RTK.md + patches CLAUDE.md + patches settings.json (3 artifacts) - Gemini: Only patches settings.json (1 artifact) - Asymmetric workflows despite both CLIs supporting instruction files - Code duplication: patch_claude_md and @RTK.md removal logic not shared What changed: - run_gemini() now mirrors run_default_mode(): 1. Creates ~/.gemini/RTK.md (10 lines, same as Claude) 2. Patches ~/.gemini/GEMINI.md (adds @RTK.md reference) 3. Patches ~/.gemini/settings.json (hook registration) - uninstall_gemini() now mirrors Claude uninstall(): - Removes RTK.md file - Removes @RTK.md reference from GEMINI.md - Removes hook from settings.json - Extracted patch_instruction_file() (lines 833-877): Shared by patch_claude_md() and patch_gemini_md() - Extracted remove_rtk_reference_from_file() (lines 805-830): Shared by both uninstall functions - Updated architecture docs (lines 459-494): Symmetric workflow now documented Why: - Gemini CLI DOES support GEMINI.md instruction files (confirmed via https://geminicli.com/docs/cli/gemini-md/) - Gemini CLI DOES support @ directive for file inclusion (same syntax as Claude) - Both platforms should work identically for consistent UX - DRY: Eliminates 38 lines of duplicated @RTK.md patching/removal logic Research sources: - https://geminicli.com/docs/cli/gemini-md/ — GEMINI.md documentation - https://geminicli.com/docs/extensions/ — Extension context files - https://claude.com/blog/using-claude-md-files — CLAUDE.md @include syntax Testable: - cargo test — all 567 tests pass (no regressions) - rtk init --gemini → should create RTK.md + patch GEMINI.md + patch settings.json - rtk init --uninstall --gemini → should remove all 3 artifacts - ls ~/.gemini/RTK.md && cat ~/.gemini/GEMINI.md (verify files exist with @RTK.md)
Previous behavior: - --claude and --gemini were in clap group "platform" (mutually exclusive) - Could not use both flags together - rtk init -g --claude --gemini → clap error - Platform logic was verbose match expressions (not DRY) What changed: - main.rs: Removed group = "platform" from both flags - main.rs: Simplified platform selection to single-line Boolean expressions: - setup_claude = \!gemini || claude (true unless --gemini alone) - setup_gemini = \!claude || gemini (true unless --claude alone) - Applies to both install and uninstall paths (DRY) Why: - Boolean algebra: claude || (\!claude && \!gemini) reduces to \!gemini || claude - DRY: 4 lines instead of 20+ lines of match tables - Clarity: Logic is obvious from the expression - Flexibility: All flag combinations now work Flag combinations: - rtk init → Both (Claude local + Gemini global) - rtk init -g → Both (Claude global + Gemini global) - rtk init --claude → Claude only (local) - rtk init --gemini → Gemini only (global) - rtk init --claude --gemini → Both (Claude local + Gemini global) - rtk init -g --claude --gemini → Both (Claude global + Gemini global) Note: -g affects Claude mode (local vs global), Gemini is always global Testable: - cargo test — all 567 tests pass - rtk init --claude --gemini → works (no error)
…covery
Replace hardcoded safety rules (SafetyAction/SafetyRule/rule\!() macro) with
a unified data-driven Rule system using MD files with YAML frontmatter.
Config system:
- Split config.rs into config/{mod,discovery,rules}.rs
- Add load_merged() with precedence: CLI > env vars > project-local > global > defaults
- Add ConfigOverlay for partial config merging
- Add CRUD: get/set/unset/list subcommands for scalar config and rules
- Add --config-path, --config-add, --rules-path, --rules-add CLI flags
- Wire dead config fields: tracking.enabled, tracking.history_days, database_path
Rules system:
- 11 built-in rules as MD files compiled via include_str\!()
- Directory walk-up discovery: .claude/, .gemini/, .rtk/ from cwd to home
- Same-name override semantics (user rules override builtins)
- Predicate system with registry + bash fallback
- try_remap() for single-word alias expansion (e.g. t -> cargo test)
Safety refactor:
- Rewrite check()/check_raw() to iterate rules::load_all()
- Extract dispatch() to eliminate duplication
- Preserve SafetyResult enum (used by exec.rs)
Other:
- Export rules during rtk init for discoverability
- Fix flaky test_cd_to_existing_dir by consolidating cwd-mutating tests
- Add serde_yaml dependency for YAML frontmatter parsing
- 643 tests pass (extensive TDD coverage for precedence, robustness, CLI parsing)
Remove unused code properly with modern idiomatic Rust fixes: - Remove BILLION constant from cc_economics.rs (unused) - Remove is_available() function from ccusage.rs (unused) - Remove get_all_rules_dirs() from config/mod.rs (unused) - Remove is_safety() method from config/rules.rs (unused) - Add #[serde(default)] to golangci_cmd fields (line, column, text) - Add #[serde(default)] to playwright_cmd duration field - Mark deprecated track() with #[allow(dead_code)] for backwards compatibility All changes from this branch compile without warnings. Remaining 17 warnings are pre-existing and unrelated to multi-platform-hooks branch.
…, add multi-pattern test README.md: - Add Gemini CLI alongside Claude Code in intro, name collision, installation, quick start, and token savings sections - Rename "Auto-Rewrite Hook" section to "Claude Code Integration" for parallel structure with "Gemini CLI Integration" - Expand safety table from 8 to 11 rows (all rule files) with per-rule opt-out env var column - Add "Why these commands?" rationale, action type definitions, rule priority list (highest to lowest), custom rule example with chmod-777, rule field reference, when: condition documentation - Add chained command before/after example with smart quoting note - Clarify hook check output, manual install PreToolUse placement, Gemini matcher field, blocked commands in rewrite table - Fix duplicate uninstall entry, default init installs both platforms - Mention SKILL.md similarity for rule file format src/config/rules.rs: - Add test_matches_rule_multiple_patterns_in_one_rule test confirming multiple patterns in a single rule file work (chmod -R 777 + chmod 777)
… dispatcher Merge origin/master (7401f10) into feat/multi-platform-hooks. From master (6 commits): - src/format_cmd.rs: add universal format command (prettier, black, ruff format) - src/lint_cmd.rs: add Python lint dispatcher (pylint, mypy, flake8, ruff via rtk lint) - src/git.rs: add "Not a git repository" error handling for git status - src/ruff_cmd.rs: make filter_ruff_check_json and filter_ruff_format pub - src/main.rs: add Format command variant and mod format_cmd - hooks/rtk-rewrite.sh: global option stripping for git/cargo/docker/kubectl (PR rtk-ai#99) - Cargo.toml: version bump to 0.16.0 Conflict resolution: - hooks/rtk-rewrite.sh: kept 3-line migration shim (shell logic replaced by Rust handlers in this branch), integrated PR rtk-ai#99 global option stripping into Rust Branch additions to integrate master capabilities: - src/config/rules.rs: add strip_global_options() for git (-C, --no-pager, --no-optional-locks, --bare, --literal-pathspecs, --key=value), cargo (+toolchain), docker (-H, --context, --config), kubectl (--context, --kubeconfig, -n). Updated matches_rule() to normalize commands before multi-word pattern matching. Table-driven tests: 26 strip cases, 11 rule-matching-with-globals cases. - src/cmd/hook.rs: add test_global_options_not_blocked (12 cases), test_compound_commands_rewrite (5 chain cases with operator preservation), test_compound_blocked_in_chain (3 cases), test_compound_quoted_operators_not_split. - src/ccusage.rs: fix trailing whitespace (cargo fmt) 675 tests pass, 0 failures. cargo fmt clean, no new clippy warnings.
This was referenced Feb 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Executive summary
Adds Gemini CLI support (
rtk init --gemini), 11 data safety rules (rm→trash,git reset --hard→stash), chained command rewriting (cd && git statusrewrites both), and extensiblertk.*.mddata safety rule files. Closes #112 (chained commands missed) and #115 (destructive command safety). 53 files, 656 tests, 2 new deps (trash,which).Features
rtk initinstalls hooks for both platforms.rm,git reset --hard,git clean -f, etc.) and make them recoverable via trash or git stashcd /path && git statusnow rewrites each command independently instead of only seeingcdrtk.*.mddata safety rule filespatterns,action,redirect,when,env_var). 11 built-in rules ship compiled into the binary; users drop override/new files in.rtk/,.claude/, or.gemini/dirs. Config system split intoconfig/{mod,discovery,rules}.rswith[discovery]section, CRUD commands, and per-project overlay.Context: RTK has saved 1.25B tokens across 3,757 tracked commands on my test machine. Issue #112 documents 271 git status, 65 tail, 81 grep, 105 ls calls in chained commands that were passing through unoptimized.
What Changed
1. Gemini CLI Support
RTK's token optimization now works on Gemini CLI in addition to Claude Code. Both platforms share the same command parsing, data safety rules, and rewrite engine. Only the JSON protocol layer differs.
rtk initwith no flags installs both Claude and Gemini hooksrtk init --claudeinstalls Claude hooks only (~/.claude/settings.json)rtk init --geminiinstalls Gemini hooks only (~/.gemini/settings.json)--claude --geminito install both, or use no flags for bothArchitecture:
src/cmd/hook.rs(shared logic) →src/cmd/claude_hook.rs(Claude JSON protocol) /src/cmd/gemini_hook.rs(Gemini JSON protocol)2. Data Safety Rules (#115)
11 rule files (each an
rtk.*.mdfile insrc/rules/) intercept destructive commands and make them recoverable. The table below groups related rules into rows (e.g.,git clean -f,-fd,-dfare 3 separate rule files). Built-in rules use one pattern per file for clarity, though users can combine multiple patterns in a single rule file. Each incident of data loss costs ~20-50K tokens in recovery (investigate damage → restore/rewrite → regenerate → debug differences).rmgit reset --hardgit stash push -m 'RTK: reset backup' && git reset --hard {args}git checkout ./git checkout --git stash push -m 'RTK: checkout backup' && git checkout ... {args}git clean -f/-fd/-dfgit stash -u -m 'RTK: clean backup' && git clean ... {args}git stash dropgit stash popcat,sed,headAction types:
trash(move to OS trash viatrashcrate),rewrite(modify command before execution),warn(print message, allow execution),block/suggest_tool(fail with error and suggest alternative).Rules define their own
when:conditions.git reset --hard,git checkout ., andgit checkout --usewhen: has_unstaged_changes(only fire when there are uncommitted changes to protect). The remaining rules default towhen: always. Seesrc/rules/*.mdfor per-rule conditions and redirect templates.Opt-out env vars:
RTK_SAFE_COMMANDS=0disables rm/git data safety rules.RTK_BLOCK_TOKEN_WASTE=0disables cat/sed/head blocking. Each rule specifies which env var controls it via theenv_var:field in its frontmatter.Key files:
src/cmd/safety.rs,src/rules/*.md3. Chained Command Parsing (#112)
Before this PR,
cd /path && git status && git diffonly sawcdas the binary.git statusandgit diffpassed through unoptimized. Each missed optimization wastes ~300K tokens/month at typical usage. The new lexer splits chains on&&,||,;(respecting quotes), rewrites each command independently, and reassembles the chain.git commit -m "Fix && Bug"doesn't split on&&inside quotesecho test | grep testexecute as-is (no rewrites inside pipes)cd,export,pwd,echo) handled natively to preserve session stateKey files:
src/cmd/lexer.rs,src/cmd/analysis.rs,src/cmd/builtins.rs4. Extensible Data Safety Rule System
Rules are markdown files with YAML frontmatter (similar to SKILL.md files). 11 built-in data safety rules ship compiled into the binary via
include_str!(). Users can override or extend them by placingrtk.*.mdfiles in discoverable directories.Built-in rule —
src/rules/rtk.safety.rm-to-trash.md:Built-in rule —
src/rules/rtk.safety.git-reset-hard.md:Users can create their own rules the same way. For example, intercepting
chmod -R 777only inside git repos (~/.config/rtk/rtk.safety.chmod-777.md):The
when:field supports named predicates (always,has_unstaged_changes) or arbitrary shell commands. Any command that exits 0 means the rule applies.Rule priority (highest to lowest):
--rules-addCLI paths.claude/,.gemini/,.rtk/in each ancestor from cwd to$HOME(closest to cwd wins)~/.claude/,~/.gemini/(global, LLM-visible)~/.config/rtk/(global config)include_str!()defaultsSame-name rules override by priority.
enabled: falsedisables a rule entirely. All search directories are configurable via[discovery]inconfig.toml.Key files:
src/config/rules.rs,src/config/discovery.rsConfig changes: Split
config.rsintoconfig/{mod,discovery,rules}.rs. Added[discovery]section for configurable search dirs, global dirs, and rules dirs. Config CRUD viartk config get/set/unset/list. Per-project.rtk/config.tomloverlay. Env var overrides:RTK_SEARCH_DIRSandRTK_RULES_DIRS(comma-separated paths, replace defaults when set).Implementation Notes
hooks/rtk-rewrite.sh(204 lines of hard-coded sed/grep patterns, only rewrote the first command in chains)rtk hook claude/rtk hook gemini(native Rust handlers)cd /path && git statusrewrites both commands independently (core fix for Hook: chained commands (cd dir && cmd) are never rewritten #112)src/rules/*.mdfiles, not bash editsrtk hook check <cmd>shows exact rewrites (impossible with bash)rtk hook claudetrash+whichdeps and embedded rules; LTO + stripping enabled).trash(cross-platform trash API for rm safety),which(binary path validation for hook installation).Not Included (Future)
sudo rm,docker rm,kubectl deletewith confirmationsalways,has_unstaged_changes, plus arbitrary shell commands)Review Guide
src/cmd/lexer.rs+src/cmd/analysis.rs— Chain parsing correctnesssrc/cmd/safety.rs+src/rules/— Data safety rule matching and rewritingsrc/cmd/claude_hook.rs+src/cmd/gemini_hook.rs— Protocol compliance with each platformsrc/config/rules.rs+src/config/discovery.rs— Rule loading, precedence, override semanticssrc/init.rs— Installation flow (rtk init --claude --gemini)Cargo.toml— New dependencies (trash,which) validated through functional tests aboveTest Plan
cargo test— 656 tests passrtk hook check "rm file"— hook rewrites tortk run -c 'rm file'(hook layer)rtk run -c "rm file"— executes the rewrite: file moved to trash, not deleted (execution layer)rtk hook check "cd /tmp && git status"— rewrites both commands independentlyRTK_SAFE_COMMANDS=0 rtk run -c "rm file"— passes through (opt-out works)RTK_BLOCK_TOKEN_WASTE=0 rtk run -c "cat file"— cat executes (opt-out works)rtk run -c "git reset --hard"— no stash created (when: has_unstaged_changescondition fails)rtk run -c "git clean -fd"— stashes untracked files before cleaning (when: always)rtk run -c "git stash drop"— rewrites togit stash pop(when: always)rtk config list— shows[discovery]section with search_dirs, global_dirsrtk config set discovery.search_dirs .rtk,.custom && rtk config get discovery.search_dirs— config CRUD worksrtk init --claude && rtk init --gemini— installs hooks for both platformsrtk.safety.chmod-777.mdin~/.config/rtk/—rtk run -c "chmod 777 file"triggers warningrtk.safety.rm-to-trash.mdwithenabled: falsein.rtk/— project-level rule overrides global,rmpasses throughPlease let me know what you think!