diff --git a/Cargo.lock b/Cargo.lock index 6fa4eb0..f9baf4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,6 +253,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -598,6 +610,7 @@ dependencies = [ "thiserror", "toml", "walkdir", + "which", ] [[package]] @@ -892,6 +905,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1117,6 +1142,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index af6896b..4f6809f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ rusqlite = { version = "0.31", features = ["bundled"] } toml = "0.8" chrono = "0.4" thiserror = "1.0" +which = "7" tempfile = "3" [dev-dependencies] diff --git a/INSTALL.md b/INSTALL.md index 55b32fd..b67ccf6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -67,6 +67,34 @@ rtk gain # MUST show token savings, not "command not found" ⚠️ **WARNING**: `cargo install rtk` from crates.io might install the wrong package. Always verify with `rtk gain`. +### Windows Installation + +RTK compiles natively for Windows. Download from GitHub Releases or: + +```bash +cargo install --path . +``` + +Configure Claude Code `settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "rtk hook claude" + }] + }] + } +} +``` + +No bash, node, or bun required — `rtk hook claude` is a native Windows binary. +The exec.rs shell selection (`cfg!(windows)`) automatically uses `cmd /C` on Windows +and `sh -c` on Unix for passthrough commands. + ## Project Initialization ### Recommended: Global Hook-First Setup @@ -111,6 +139,37 @@ rtk init # Creates ./CLAUDE.md with full RTK instructions (137 lines) **Token savings**: Instructions loaded only for this project +### Gemini CLI Setup + +**Best for: Gemini CLI users wanting the same token optimization** + +```bash +rtk init --gemini +# → Registers "rtk hook gemini" in ~/.gemini/settings.json +# → Prompts: "Patch settings.json? [y/N]" +# → If yes: patches + creates backup (~/.gemini/settings.json.bak) + +# Automated alternatives: +rtk init --gemini --auto-patch # Patch without prompting +rtk init --gemini --no-patch # Print manual instructions instead + +# Verify installation +rtk init --show # Shows both Claude and Gemini hook status +``` + +**Manual setup** (if `rtk init --gemini` isn't available): +```json +// Add to ~/.gemini/settings.json +{ + "hooks": { + "BeforeTool": [{ + "matcher": "run_shell_command", + "hooks": [{ "type": "command", "command": "rtk hook gemini" }] + }] + } +} +``` + ### Upgrading from Previous Version If you previously used `rtk init -g` with the old system (137-line injection): diff --git a/README.md b/README.md index 4960e85..b452e36 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [Website](https://www.rtk-ai.app) | [GitHub](https://github.com/rtk-ai/rtk) | [Install](INSTALL.md) -rtk filters and compresses command outputs before they reach your LLM context, saving 60-90% of tokens on common operations. +rtk filters and compresses command outputs before they reach your LLM context, saving 60-90% of tokens on common operations. Works with **Claude Code** and **Gemini CLI**. ## ⚠️ Important: Name Collision Warning @@ -624,6 +624,68 @@ chmod +x ~/.claude/hooks/rtk-suggest.sh The suggest hook detects the same commands as the rewrite hook but outputs a `systemMessage` instead of `updatedInput`, informing Claude Code that an rtk alternative exists. +## Gemini CLI Integration + +RTK also supports [Gemini CLI](https://github.com/google-gemini/gemini-cli) via its **BeforeTool** hook protocol. The same safety engine that powers the Claude Code hook is used for Gemini, providing consistent command rewriting and blocking across both agents. + +### Quick Install (Automated) + +```bash +rtk init --gemini +# → Patches ~/.gemini/settings.json with BeforeTool hook +# → Prompts: "Patch settings.json? [y/N]" +# → Creates backup (~/.gemini/settings.json.bak) if file exists + +# Options: +rtk init --gemini --auto-patch # Patch without prompting (CI/CD) +rtk init --gemini --no-patch # Skip patching, print manual JSON snippet + +# Verify installation +rtk init --show +``` + +### Manual Install + +Add the following to `~/.gemini/settings.json`: + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "run_shell_command", + "hooks": [ + { + "type": "command", + "command": "rtk hook gemini" + } + ] + } + ] + } +} +``` + +### How It Works + +When Gemini CLI is about to execute a shell command, it sends a JSON payload to `rtk hook gemini` on stdin. RTK's safety engine evaluates the command and responds with: + +- **Allow + rewrite**: Rewrites the command to its `rtk run -c '...'` equivalent +- **Block**: Returns `"deny"` with a reason explaining which native tool to use instead +- **Passthrough**: Commands already using `rtk` pass through unchanged + +The `matcher` field (`run_shell_command`) identifies Gemini's shell execution tool (analogous to Claude Code's `Bash` matcher). Non-shell tool events pass through without inspection. + +### Uninstalling Gemini Hook + +```bash +rtk init --gemini --uninstall +# → Removes RTK hook entry from ~/.gemini/settings.json +# → Preserves other hooks and settings +``` + +The global `rtk init -g --uninstall` also removes Gemini hooks alongside Claude Code hooks. + ## Uninstalling RTK **Complete Removal (Global Only)**: @@ -631,12 +693,12 @@ The suggest hook detects the same commands as the rewrite hook but outputs a `sy rtk init -g --uninstall # Removes: -# - ~/.claude/hooks/rtk-rewrite.sh +# - RTK hook from ~/.claude/settings.json +# - RTK hook from ~/.gemini/settings.json # - ~/.claude/RTK.md # - @RTK.md reference from ~/.claude/CLAUDE.md -# - RTK hook entry from ~/.claude/settings.json -# Restart Claude Code after uninstall +# Restart Claude Code / Gemini CLI after uninstall ``` **Restore from Backup** (if needed): diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index 59e02ca..f895725 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -1,209 +1,4 @@ -#!/bin/bash -# RTK auto-rewrite hook for Claude Code PreToolUse:Bash -# Transparently rewrites raw commands to their rtk equivalents. -# Outputs JSON with updatedInput to modify the command before execution. - -# Guards: skip silently if dependencies missing -if ! command -v rtk &>/dev/null || ! command -v jq &>/dev/null; then - exit 0 -fi - -set -euo pipefail - -INPUT=$(cat) -CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') - -if [ -z "$CMD" ]; then - exit 0 -fi - -# Extract the first meaningful command (before pipes, &&, etc.) -# We only rewrite if the FIRST command in a chain matches. -FIRST_CMD="$CMD" - -# Skip if already using rtk -case "$FIRST_CMD" in - rtk\ *|*/rtk\ *) exit 0 ;; -esac - -# Skip commands with heredocs, variable assignments as the whole command, etc. -case "$FIRST_CMD" in - *'<<'*) exit 0 ;; -esac - -# Strip leading env var assignments for pattern matching -# e.g., "TEST_SESSION_ID=2 npx playwright test" → match against "npx playwright test" -# but preserve them in the rewritten command for execution. -ENV_PREFIX=$(echo "$FIRST_CMD" | grep -oE '^([A-Za-z_][A-Za-z0-9_]*=[^ ]* +)+' || echo "") -if [ -n "$ENV_PREFIX" ]; then - MATCH_CMD="${FIRST_CMD:${#ENV_PREFIX}}" - CMD_BODY="${CMD:${#ENV_PREFIX}}" -else - MATCH_CMD="$FIRST_CMD" - CMD_BODY="$CMD" -fi - -REWRITTEN="" - -# --- Git commands --- -if echo "$MATCH_CMD" | grep -qE '^git[[:space:]]'; then - GIT_SUBCMD=$(echo "$MATCH_CMD" | sed -E \ - -e 's/^git[[:space:]]+//' \ - -e 's/(-C|-c)[[:space:]]+[^[:space:]]+[[:space:]]*//g' \ - -e 's/--[a-z-]+=[^[:space:]]+[[:space:]]*//g' \ - -e 's/--(no-pager|no-optional-locks|bare|literal-pathspecs)[[:space:]]*//g' \ - -e 's/^[[:space:]]+//') - case "$GIT_SUBCMD" in - status|status\ *|diff|diff\ *|log|log\ *|add|add\ *|commit|commit\ *|push|push\ *|pull|pull\ *|branch|branch\ *|fetch|fetch\ *|stash|stash\ *|show|show\ *) - REWRITTEN="${ENV_PREFIX}rtk $CMD_BODY" - ;; - esac - -# --- GitHub CLI (added: api, release) --- -elif echo "$MATCH_CMD" | grep -qE '^gh[[:space:]]+(pr|issue|run|api|release)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^gh /rtk gh /')" - -# --- Cargo --- -elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]'; then - CARGO_SUBCMD=$(echo "$MATCH_CMD" | sed -E 's/^cargo[[:space:]]+(\+[^[:space:]]+[[:space:]]+)?//') - case "$CARGO_SUBCMD" in - test|test\ *|build|build\ *|clippy|clippy\ *|check|check\ *|install|install\ *|fmt|fmt\ *) - REWRITTEN="${ENV_PREFIX}rtk $CMD_BODY" - ;; - esac - -# --- File operations --- -elif echo "$MATCH_CMD" | grep -qE '^cat[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cat /rtk read /')" -elif echo "$MATCH_CMD" | grep -qE '^(rg|grep)[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(rg|grep) /rtk grep /')" -elif echo "$MATCH_CMD" | grep -qE '^ls([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^ls/rtk ls/')" -elif echo "$MATCH_CMD" | grep -qE '^tree([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^tree/rtk tree/')" -elif echo "$MATCH_CMD" | grep -qE '^find[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^find /rtk find /')" -elif echo "$MATCH_CMD" | grep -qE '^diff[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^diff /rtk diff /')" -elif echo "$MATCH_CMD" | grep -qE '^head[[:space:]]+'; then - # Transform: head -N file → rtk read file --max-lines N - # Also handle: head --lines=N file - if echo "$MATCH_CMD" | grep -qE '^head[[:space:]]+-[0-9]+[[:space:]]+'; then - LINES=$(echo "$MATCH_CMD" | sed -E 's/^head +-([0-9]+) +.+$/\1/') - FILE=$(echo "$MATCH_CMD" | sed -E 's/^head +-[0-9]+ +(.+)$/\1/') - REWRITTEN="${ENV_PREFIX}rtk read $FILE --max-lines $LINES" - elif echo "$MATCH_CMD" | grep -qE '^head[[:space:]]+--lines=[0-9]+[[:space:]]+'; then - LINES=$(echo "$MATCH_CMD" | sed -E 's/^head +--lines=([0-9]+) +.+$/\1/') - FILE=$(echo "$MATCH_CMD" | sed -E 's/^head +--lines=[0-9]+ +(.+)$/\1/') - REWRITTEN="${ENV_PREFIX}rtk read $FILE --max-lines $LINES" - fi - -# --- JS/TS tooling (added: npm run, npm test, vue-tsc) --- -elif echo "$MATCH_CMD" | grep -qE '^(pnpm[[:space:]]+)?(npx[[:space:]]+)?vitest([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(pnpm )?(npx )?vitest( run)?/rtk vitest run/')" -elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+test([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm test/rtk vitest run/')" -elif echo "$MATCH_CMD" | grep -qE '^npm[[:space:]]+test([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^npm test/rtk npm test/')" -elif echo "$MATCH_CMD" | grep -qE '^npm[[:space:]]+run[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^npm run /rtk npm /')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?vue-tsc([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?vue-tsc/rtk tsc/')" -elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+tsc([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm tsc/rtk tsc/')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?tsc([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?tsc/rtk tsc/')" -elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+lint([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm lint/rtk lint/')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?eslint([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?eslint/rtk lint/')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?prettier([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?prettier/rtk prettier/')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?playwright([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?playwright/rtk playwright/')" -elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+playwright([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm playwright/rtk playwright/')" -elif echo "$MATCH_CMD" | grep -qE '^(npx[[:space:]]+)?prisma([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(npx )?prisma/rtk prisma/')" - -# --- Containers (added: docker compose, docker run/build/exec, kubectl describe/apply) --- -elif echo "$MATCH_CMD" | grep -qE '^docker[[:space:]]'; then - if echo "$MATCH_CMD" | grep -qE '^docker[[:space:]]+compose([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^docker /rtk docker /')" - else - DOCKER_SUBCMD=$(echo "$MATCH_CMD" | sed -E \ - -e 's/^docker[[:space:]]+//' \ - -e 's/(-H|--context|--config)[[:space:]]+[^[:space:]]+[[:space:]]*//g' \ - -e 's/--[a-z-]+=[^[:space:]]+[[:space:]]*//g' \ - -e 's/^[[:space:]]+//') - case "$DOCKER_SUBCMD" in - ps|ps\ *|images|images\ *|logs|logs\ *|run|run\ *|build|build\ *|exec|exec\ *) - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^docker /rtk docker /')" - ;; - esac - fi -elif echo "$MATCH_CMD" | grep -qE '^kubectl[[:space:]]'; then - KUBE_SUBCMD=$(echo "$MATCH_CMD" | sed -E \ - -e 's/^kubectl[[:space:]]+//' \ - -e 's/(--context|--kubeconfig|--namespace|-n)[[:space:]]+[^[:space:]]+[[:space:]]*//g' \ - -e 's/--[a-z-]+=[^[:space:]]+[[:space:]]*//g' \ - -e 's/^[[:space:]]+//') - case "$KUBE_SUBCMD" in - get|get\ *|logs|logs\ *|describe|describe\ *|apply|apply\ *) - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^kubectl /rtk kubectl /')" - ;; - esac - -# --- Network --- -elif echo "$MATCH_CMD" | grep -qE '^curl[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^curl /rtk curl /')" -elif echo "$MATCH_CMD" | grep -qE '^wget[[:space:]]+'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^wget /rtk wget /')" - -# --- pnpm package management --- -elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+(list|ls|outdated)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm /rtk pnpm /')" - -# --- Python tooling --- -elif echo "$MATCH_CMD" | grep -qE '^pytest([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pytest/rtk pytest/')" -elif echo "$MATCH_CMD" | grep -qE '^python[[:space:]]+-m[[:space:]]+pytest([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^python -m pytest/rtk pytest/')" -elif echo "$MATCH_CMD" | grep -qE '^ruff[[:space:]]+(check|format)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^ruff /rtk ruff /')" -elif echo "$MATCH_CMD" | grep -qE '^pip[[:space:]]+(list|outdated|install|show)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pip /rtk pip /')" -elif echo "$MATCH_CMD" | grep -qE '^uv[[:space:]]+pip[[:space:]]+(list|outdated|install|show)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^uv pip /rtk pip /')" - -# --- Go tooling --- -elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+test([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go test/rtk go test/')" -elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+build([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go build/rtk go build/')" -elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+vet([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go vet/rtk go vet/')" -elif echo "$MATCH_CMD" | grep -qE '^golangci-lint([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^golangci-lint/rtk golangci-lint/')" -fi - -# If no rewrite needed, approve as-is -if [ -z "$REWRITTEN" ]; then - exit 0 -fi - -# Build the updated tool_input with all original fields preserved, only command changed -ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input') -UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd') - -# Output the rewrite instruction -jq -n \ - --argjson updated "$UPDATED_INPUT" \ - '{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": $updated - } - }' +#!/bin/sh +# Legacy shim — actual hook logic is in the rtk binary. +# Direct usage: rtk hook claude (reads JSON from stdin) +exec rtk hook claude diff --git a/src/cmd/analysis.rs b/src/cmd/analysis.rs new file mode 100644 index 0000000..1d816e2 --- /dev/null +++ b/src/cmd/analysis.rs @@ -0,0 +1,249 @@ +//! Analyzes tokens to decide: Native execution or Passthrough? + +use super::lexer::{strip_quotes, ParsedToken, TokenKind}; + +/// Represents a single command in a chain +#[derive(Debug, Clone, PartialEq)] +pub struct NativeCommand { + pub binary: String, + pub args: Vec, + pub operator: Option, // &&, ||, ;, or None for last command +} + +/// Check if command needs real shell (has shellisms, pipes, redirects) +pub fn needs_shell(tokens: &[ParsedToken]) -> bool { + tokens.iter().any(|t| { + matches!( + t.kind, + TokenKind::Shellism | TokenKind::Pipe | TokenKind::Redirect + ) + }) +} + +/// Parse tokens into native command chain +/// Returns error if syntax is invalid (e.g., operator with no preceding command) +pub fn parse_chain(tokens: Vec) -> Result, String> { + let mut commands = Vec::new(); + let mut current_args = Vec::new(); + + for token in tokens { + match token.kind { + TokenKind::Arg => { + // Strip quotes from the argument + current_args.push(strip_quotes(&token.value)); + } + TokenKind::Operator => { + if current_args.is_empty() { + return Err(format!( + "Syntax error: operator {} with no command", + token.value + )); + } + // First arg is the binary, rest are args + let binary = current_args.remove(0); + commands.push(NativeCommand { + binary, + args: current_args.clone(), + operator: Some(token.value.clone()), + }); + current_args.clear(); + } + TokenKind::Pipe | TokenKind::Redirect | TokenKind::Shellism => { + // Should not reach here if needs_shell() was checked first + // But handle gracefully + return Err(format!( + "Unexpected {:?} in native mode - use passthrough", + token.kind + )); + } + } + } + + // Handle last command (no trailing operator) + if !current_args.is_empty() { + let binary = current_args.remove(0); + commands.push(NativeCommand { + binary, + args: current_args, + operator: None, + }); + } + + Ok(commands) +} + +/// Should the next command run based on operator and last result? +pub fn should_run(operator: Option<&str>, last_success: bool) -> bool { + match operator { + Some("&&") => last_success, + Some("||") => !last_success, + Some(";") | None => true, + _ => true, // Unknown operator, just run + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::lexer::tokenize; + + // === NEEDS_SHELL TESTS === + + #[test] + fn test_needs_shell_simple() { + let tokens = tokenize("git status"); + assert!(!needs_shell(&tokens)); + } + + #[test] + fn test_needs_shell_with_glob() { + let tokens = tokenize("ls *.rs"); + assert!(needs_shell(&tokens)); + } + + #[test] + fn test_needs_shell_with_pipe() { + let tokens = tokenize("cat file | grep x"); + assert!(needs_shell(&tokens)); + } + + #[test] + fn test_needs_shell_with_redirect() { + let tokens = tokenize("cmd > file"); + assert!(needs_shell(&tokens)); + } + + #[test] + fn test_needs_shell_with_chain() { + let tokens = tokenize("cd dir && git status"); + // && is an Operator, not a Shellism - should NOT need shell + assert!(!needs_shell(&tokens)); + } + + // === PARSE_CHAIN TESTS === + + #[test] + fn test_parse_simple_command() { + let tokens = tokenize("git status"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].binary, "git"); + assert_eq!(cmds[0].args, vec!["status"]); + assert_eq!(cmds[0].operator, None); + } + + #[test] + fn test_parse_command_with_multiple_args() { + let tokens = tokenize("git commit -m message"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].binary, "git"); + assert_eq!(cmds[0].args, vec!["commit", "-m", "message"]); + } + + #[test] + fn test_parse_chained_and() { + let tokens = tokenize("cd dir && git status"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].binary, "cd"); + assert_eq!(cmds[0].args, vec!["dir"]); + assert_eq!(cmds[0].operator, Some("&&".to_string())); + assert_eq!(cmds[1].binary, "git"); + assert_eq!(cmds[1].args, vec!["status"]); + assert_eq!(cmds[1].operator, None); + } + + #[test] + fn test_parse_chained_or() { + let tokens = tokenize("cmd1 || cmd2"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].operator, Some("||".to_string())); + } + + #[test] + fn test_parse_chained_semicolon() { + let tokens = tokenize("cmd1 ; cmd2 ; cmd3"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 3); + assert_eq!(cmds[0].operator, Some(";".to_string())); + assert_eq!(cmds[1].operator, Some(";".to_string())); + assert_eq!(cmds[2].operator, None); + } + + #[test] + fn test_parse_triple_chain() { + let tokens = tokenize("a && b && c"); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 3); + } + + #[test] + fn test_parse_operator_at_start() { + let tokens = tokenize("&& cmd"); + let result = parse_chain(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_parse_operator_at_end() { + let tokens = tokenize("cmd &&"); + let cmds = parse_chain(tokens).unwrap(); + // cmd is parsed, && triggers flush but no second command + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].operator, Some("&&".to_string())); + } + + #[test] + fn test_parse_quoted_arg() { + let tokens = tokenize("git commit -m \"Fix && Bug\""); + let cmds = parse_chain(tokens).unwrap(); + assert_eq!(cmds.len(), 1); + // The && inside quotes should be in the arg, not an operator + // args are: commit, -m, "Fix && Bug" + assert_eq!(cmds[0].args.len(), 3); + assert_eq!(cmds[0].args[2], "Fix && Bug"); + } + + #[test] + fn test_parse_empty() { + let tokens = tokenize(""); + let cmds = parse_chain(tokens).unwrap(); + assert!(cmds.is_empty()); + } + + // === SHOULD_RUN TESTS === + + #[test] + fn test_should_run_and_success() { + assert!(should_run(Some("&&"), true)); + } + + #[test] + fn test_should_run_and_failure() { + assert!(!should_run(Some("&&"), false)); + } + + #[test] + fn test_should_run_or_success() { + assert!(!should_run(Some("||"), true)); + } + + #[test] + fn test_should_run_or_failure() { + assert!(should_run(Some("||"), false)); + } + + #[test] + fn test_should_run_semicolon() { + assert!(should_run(Some(";"), true)); + assert!(should_run(Some(";"), false)); + } + + #[test] + fn test_should_run_none() { + assert!(should_run(None, true)); + assert!(should_run(None, false)); + } +} diff --git a/src/cmd/builtins.rs b/src/cmd/builtins.rs new file mode 100644 index 0000000..fa11be6 --- /dev/null +++ b/src/cmd/builtins.rs @@ -0,0 +1,246 @@ +//! Built-in commands that RTK handles natively. +//! These maintain session state across hook calls. + +use super::predicates::{expand_tilde, get_home}; +use anyhow::{Context, Result}; + +/// Change directory (persists in RTK process) +pub fn builtin_cd(args: &[String]) -> Result { + let target = args + .first() + .map(|s| expand_tilde(s)) + .unwrap_or_else(get_home); + + std::env::set_current_dir(&target) + .with_context(|| format!("cd: {}: No such file or directory", target))?; + + Ok(true) +} + +/// Export environment variable +pub fn builtin_export(args: &[String]) -> Result { + for arg in args { + if let Some((key, value)) = arg.split_once('=') { + // Handle quoted values: export FOO="bar baz" + let clean_value = value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))) + .unwrap_or(value); + std::env::set_var(key, clean_value); + } + } + Ok(true) +} + +/// Check if a binary is a builtin +pub fn is_builtin(binary: &str) -> bool { + matches!( + binary, + "cd" | "export" | "pwd" | "echo" | "true" | "false" | ":" + ) +} + +/// Execute a builtin command +pub fn execute(binary: &str, args: &[String]) -> Result { + match binary { + "cd" => builtin_cd(args), + "export" => builtin_export(args), + "pwd" => { + println!("{}", std::env::current_dir()?.display()); + Ok(true) + } + "echo" => { + let (print_args, no_newline) = if args.first().map(|s| s.as_str()) == Some("-n") { + (&args[1..], true) + } else { + (args, false) + }; + print!("{}", print_args.join(" ")); + if !no_newline { + println!(); + } + Ok(true) + } + "true" | ":" => Ok(true), + "false" => Ok(false), + _ => anyhow::bail!("Unknown builtin: {}", binary), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + // === CD TESTS === + // Consolidated into one test: cwd is process-global, so parallel tests race. + + #[test] + fn test_cd_all_cases() { + let original = env::current_dir().unwrap(); + let home = get_home(); + + // 1. cd to existing dir + let result = builtin_cd(&["/tmp".to_string()]).unwrap(); + assert!(result); + let new_dir = env::current_dir().unwrap(); + // On macOS /tmp symlinks to /private/tmp — canonicalize both sides + let canon_tmp = std::fs::canonicalize("/tmp").unwrap(); + let canon_new = std::fs::canonicalize(&new_dir).unwrap(); + assert_eq!(canon_new, canon_tmp, "cd /tmp should land in /tmp"); + + // 2. cd to nonexistent dir + let result = builtin_cd(&["/nonexistent/path/xyz".to_string()]); + assert!(result.is_err()); + // cwd unchanged after failed cd + assert_eq!( + std::fs::canonicalize(env::current_dir().unwrap()).unwrap(), + canon_tmp + ); + + // 3. cd with no args → home + let result = builtin_cd(&[]).unwrap(); + assert!(result); + let cwd = env::current_dir().unwrap(); + let canon_home = std::fs::canonicalize(&home).unwrap(); + let canon_cwd = std::fs::canonicalize(&cwd).unwrap(); + assert_eq!(canon_cwd, canon_home, "cd with no args should go home"); + + // 4. cd ~ → home + let _ = env::set_current_dir("/tmp"); + let result = builtin_cd(&["~".to_string()]).unwrap(); + assert!(result); + let cwd = std::fs::canonicalize(env::current_dir().unwrap()).unwrap(); + assert_eq!(cwd, canon_home, "cd ~ should go home"); + + // 5. cd ~/nonexistent-subpath — may fail, just verify no panic + let _ = builtin_cd(&["~/nonexistent_rtk_test_subpath_xyz".to_string()]); + + // Restore original cwd + let _ = env::set_current_dir(&original); + } + + // === EXPORT TESTS === + + #[test] + fn test_export_simple() { + builtin_export(&["RTK_TEST_SIMPLE=value".to_string()]).unwrap(); + assert_eq!(env::var("RTK_TEST_SIMPLE").unwrap(), "value"); + env::remove_var("RTK_TEST_SIMPLE"); + } + + #[test] + fn test_export_with_equals_in_value() { + builtin_export(&["RTK_TEST_EQUALS=key=value".to_string()]).unwrap(); + assert_eq!(env::var("RTK_TEST_EQUALS").unwrap(), "key=value"); + env::remove_var("RTK_TEST_EQUALS"); + } + + #[test] + fn test_export_quoted_value() { + builtin_export(&["RTK_TEST_QUOTED=\"hello world\"".to_string()]).unwrap(); + assert_eq!(env::var("RTK_TEST_QUOTED").unwrap(), "hello world"); + env::remove_var("RTK_TEST_QUOTED"); + } + + #[test] + fn test_export_multiple() { + builtin_export(&["RTK_TEST_A=1".to_string(), "RTK_TEST_B=2".to_string()]).unwrap(); + assert_eq!(env::var("RTK_TEST_A").unwrap(), "1"); + assert_eq!(env::var("RTK_TEST_B").unwrap(), "2"); + env::remove_var("RTK_TEST_A"); + env::remove_var("RTK_TEST_B"); + } + + #[test] + fn test_export_no_equals() { + // Should be silently ignored (like bash) + let result = builtin_export(&["NO_EQUALS_HERE".to_string()]).unwrap(); + assert!(result); + } + + // === IS_BUILTIN TESTS === + + #[test] + fn test_is_builtin_cd() { + assert!(is_builtin("cd")); + } + + #[test] + fn test_is_builtin_export() { + assert!(is_builtin("export")); + } + + #[test] + fn test_is_builtin_pwd() { + assert!(is_builtin("pwd")); + } + + #[test] + fn test_is_builtin_echo() { + assert!(is_builtin("echo")); + } + + #[test] + fn test_is_builtin_true() { + assert!(is_builtin("true")); + } + + #[test] + fn test_is_builtin_false() { + assert!(is_builtin("false")); + } + + #[test] + fn test_is_builtin_external() { + assert!(!is_builtin("git")); + assert!(!is_builtin("ls")); + assert!(!is_builtin("cargo")); + } + + // === EXECUTE TESTS === + + #[test] + fn test_execute_pwd() { + let result = execute("pwd", &[]).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_echo() { + let result = execute("echo", &["hello".to_string(), "world".to_string()]).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_true() { + let result = execute("true", &[]).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_false() { + let result = execute("false", &[]).unwrap(); + assert!(!result); + } + + #[test] + fn test_execute_unknown_builtin() { + let result = execute("notabuiltin", &[]); + assert!(result.is_err()); + } + + #[test] + fn test_execute_echo_n_flag() { + // echo -n should succeed (prints without newline) + let result = execute("echo", &["-n".to_string(), "hello".to_string()]).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_echo_empty_args() { + let result = execute("echo", &[]).unwrap(); + assert!(result); + } +} diff --git a/src/cmd/claude_hook.rs b/src/cmd/claude_hook.rs new file mode 100644 index 0000000..1918c16 --- /dev/null +++ b/src/cmd/claude_hook.rs @@ -0,0 +1,506 @@ +//! Claude Code PreToolUse hook protocol handler. +//! +//! Reads JSON from stdin, applies safety checks and rewrites, +//! outputs JSON to stdout. +//! +//! Protocol: https://docs.anthropic.com/en/docs/claude-code/hooks +//! +//! ## Exit Code Behavior +//! +//! - Exit 0 = success (allow/rewrite) — tool proceeds +//! - Exit 2 = blocking error (deny) — tool rejected +//! +//! ## Claude Code Stderr Rule (CRITICAL) +//! +//! **Source:** See `/Users/athundt/.claude/clautorun/.worktrees/claude-stable-pre-v0.8.0/notes/hooks_api_reference.md:720-728` +//! +//! ```text +//! CRITICAL: ANY stderr output at exit 0 = hook error = fail-open +//! ``` +//! +//! **Implication:** +//! - Exit 0 + ANY stderr → Claude Code treats hook as FAILED → tool executes anyway (fail-open) +//! - Exit 2 + stderr → Claude Code treats stderr as the block reason → tool blocked, AI sees reason +//! +//! **This module's stderr usage:** +//! - ✅ Exit 0 paths (NoOpinion, Allow): **NEVER write to stderr** +//! - ✅ Exit 2 path (Deny): **stderr ONLY** for bug #4669 workaround (see below) +//! +//! ## Bug #4669 Workaround (Dual-Path Deny) +//! +//! **Issue:** https://github.com/anthropics/claude-code/issues/4669 +//! **Versions:** v1.0.62+ through current (not fixed) +//! **Problem:** `permissionDecision: "deny"` at exit 0 is IGNORED — tool executes anyway +//! +//! **Workaround:** +//! ```text +//! stdout: JSON with permissionDecision "deny" (documented main path, but broken) +//! stderr: plain text reason (fallback path that actually works) +//! exit code: 2 (triggers Claude Code to read stderr as error) +//! ``` +//! +//! This ensures deny works regardless of which path Claude Code processes. +//! +//! ## I/O Enforcement (Module-Specific) +//! +//! **This restriction applies ONLY to claude_hook.rs and gemini_hook.rs.** +//! All other RTK modules (main.rs, git.rs, etc.) use `println!`/`eprintln!` normally. +//! +//! **Why restricted here:** +//! - Hook protocol requires JSON-only stdout +//! - Claude Code's "ANY stderr = hook error" rule (see above) +//! - Accidental prints corrupt the JSON protocol +//! +//! **Enforcement mechanism:** +//! - `#![deny(clippy::print_stdout, clippy::print_stderr)]` at module level (line 52) +//! - `run_inner()` returns `HookResponse` enum — pure logic, no I/O +//! - `run()` is the ONLY function that writes output — single I/O point +//! - Uses `write!`/`writeln!` which are NOT caught by the clippy lint +//! +//! **Pathway:** main.rs → Commands::Hook → claude_hook::run() [DENY ENFORCED HERE] +//! +//! Fail-open: Any parse error or unexpected input → exit 0, no output. + +// Compile-time I/O enforcement for THIS MODULE ONLY. +// Other RTK modules (main.rs, git.rs, etc.) use println!/eprintln! normally. +// +// Why restrict here: +// - Claude Code hook protocol requires JSON-only stdout +// - Claude Code rule: "ANY stderr at exit 0 = hook error = fail-open" +// (Source: clautorun hooks_api_reference.md:720-728) +// - Accidental prints would corrupt the JSON response +// +// Mechanism: +// - Denies println!/eprintln! at compile-time +// - Allows write!/writeln! (used only in run() for controlled output) +// - run_inner() returns HookResponse (no I/O) +// - run() is the single I/O point +#![deny(clippy::print_stdout, clippy::print_stderr)] + +use super::hook::{ + check_for_hook, is_hook_disabled, should_passthrough, update_command_in_tool_input, + HookResponse, HookResult, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{self, Read, Write}; + +// --- Wire format structs (field names must match Claude Code spec exactly) --- + +#[derive(Deserialize)] +pub(crate) struct ClaudePayload { + tool_input: Option, + // Claude Code also sends: tool_name, session_id, session_cwd, + // transcript_path — serde silently ignores unknown fields. + // The settings.json matcher already filters to Bash-only events. +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClaudeResponse { + hook_specific_output: HookOutput, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct HookOutput { + hook_event_name: &'static str, + permission_decision: &'static str, + permission_decision_reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + updated_input: Option, +} + +// --- Guard logic (extracted for testability) --- + +/// Extract the command string from a parsed payload. +/// Returns None if payload has no tool_input or no command field. +pub(crate) fn extract_command(payload: &ClaudePayload) -> Option<&str> { + payload + .tool_input + .as_ref()? + .get("command")? + .as_str() + .filter(|s| !s.is_empty()) +} + +// Guard functions `is_hook_disabled()` and `should_passthrough()` are shared +// with gemini_hook.rs via hook.rs to avoid duplication (DRY). + +/// Build a ClaudeResponse for an allowed/rewritten command. +pub(crate) fn allow_response(reason: String, updated_input: Option) -> ClaudeResponse { + ClaudeResponse { + hook_specific_output: HookOutput { + hook_event_name: "PreToolUse", + permission_decision: "allow", + permission_decision_reason: reason, + updated_input, + }, + } +} + +/// Build a ClaudeResponse for a blocked command. +pub(crate) fn deny_response(reason: String) -> ClaudeResponse { + ClaudeResponse { + hook_specific_output: HookOutput { + hook_event_name: "PreToolUse", + permission_decision: "deny", + permission_decision_reason: reason, + updated_input: None, + }, + } +} + +// --- Entry point --- + +/// Run the Claude Code hook handler. +/// +/// This is the ONLY function that performs I/O (stdout/stderr). +/// `run_inner()` returns a `HookResponse` enum — pure logic, no I/O. +/// Combined with `#![deny(clippy::print_stdout, clippy::print_stderr)]`, +/// this ensures no stray output corrupts the JSON hook protocol. +/// +/// Fail-open design: malformed input → exit 0, no output. +/// Claude Code interprets this as "no opinion" and proceeds normally. +pub fn run() -> anyhow::Result<()> { + // Fail-open: wrap entire handler so ANY error → exit 0 (no opinion). + let response = match run_inner() { + Ok(r) => r, + Err(_) => HookResponse::NoOpinion, // Fail-open: swallow errors + }; + + // ┌────────────────────────────────────────────────────────────────┐ + // │ SINGLE I/O POINT - All stdout/stderr output happens here only │ + // │ │ + // │ Why: Claude Code rule "ANY stderr at exit 0 = hook error" │ + // │ (Source: hooks_api_reference.md:720-728) │ + // │ │ + // │ Enforcement: #![deny(...)] at line 52 prevents println!/eprintln! │ + // │ write!/writeln! are not caught by lint (allowed) │ + // └────────────────────────────────────────────────────────────────┘ + match response { + HookResponse::NoOpinion => { + // Exit 0, NO stdout, NO stderr + // Claude Code sees no output → proceeds with original command + } + HookResponse::Allow(json) => { + // Exit 0, JSON to stdout, NO stderr + // CRITICAL: No stderr at exit 0 (would cause fail-open) + writeln!(io::stdout(), "{json}")?; + } + HookResponse::Deny(json, reason) => { + // Exit 2, JSON to stdout, reason to stderr + // This is the ONLY path that writes to stderr (valid at exit 2 only) + // + // Dual-path deny for bug #4669 workaround: + // - stdout: JSON with permissionDecision "deny" (documented path, but ignored) + // - stderr: plain text reason (actual blocking mechanism via exit 2) + // - exit 2: Triggers Claude Code to read stderr and block tool + writeln!(io::stdout(), "{json}")?; + writeln!(io::stderr(), "{reason}")?; + std::process::exit(2); + } + } + Ok(()) +} + +/// Inner handler: pure decision logic, no I/O. +/// Returns `HookResponse` for `run()` to output. +fn run_inner() -> anyhow::Result { + let mut buffer = String::new(); + io::stdin().read_to_string(&mut buffer)?; + + let payload: ClaudePayload = match serde_json::from_str(&buffer) { + Ok(p) => p, + Err(_) => return Ok(HookResponse::NoOpinion), + }; + + let cmd = match extract_command(&payload) { + Some(c) => c, + None => return Ok(HookResponse::NoOpinion), + }; + + if is_hook_disabled() || should_passthrough(cmd) { + return Ok(HookResponse::NoOpinion); + } + + let result = check_for_hook(cmd, "claude"); + + match result { + HookResult::Rewrite(new_cmd) => { + // Preserve all original tool_input fields, only replace "command" + // Shared helper (DRY with gemini_hook.rs via hook.rs) + let updated = update_command_in_tool_input(payload.tool_input, new_cmd); + + let response = allow_response("RTK safety rewrite applied".into(), Some(updated)); + let json = serde_json::to_string(&response)?; + Ok(HookResponse::Allow(json)) + } + HookResult::Blocked(msg) => { + let response = deny_response(msg.clone()); + let json = serde_json::to_string(&response)?; + Ok(HookResponse::Deny(json, msg)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // CLAUDE CODE WIRE FORMAT CONFORMANCE + // https://docs.anthropic.com/en/docs/claude-code/hooks + // + // These tests verify exact JSON field names per the Claude Code spec. + // A wrong field name means Claude Code silently ignores the response. + // ========================================================================= + + // --- Output: field name conformance --- + + #[test] + fn test_output_uses_hook_specific_output() { + // Claude expects "hookSpecificOutput" (camelCase), NOT "hook_specific_output" + let response = allow_response("test".into(), None); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert!( + parsed.get("hookSpecificOutput").is_some(), + "must have 'hookSpecificOutput' field" + ); + assert!( + parsed.get("hook_specific_output").is_none(), + "must NOT have snake_case field" + ); + } + + #[test] + fn test_output_uses_permission_decision() { + // Claude expects "permissionDecision", NOT "decision" + let response = allow_response("test".into(), None); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + let output = &parsed["hookSpecificOutput"]; + + assert!( + output.get("permissionDecision").is_some(), + "must have 'permissionDecision' field" + ); + assert!( + output.get("decision").is_none(), + "must NOT have Gemini-style 'decision' field" + ); + } + + #[test] + fn test_output_uses_permission_decision_reason() { + let response = deny_response("blocked".into()); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + let output = &parsed["hookSpecificOutput"]; + + assert!( + output.get("permissionDecisionReason").is_some(), + "must have 'permissionDecisionReason'" + ); + } + + #[test] + fn test_output_uses_hook_event_name() { + let response = allow_response("test".into(), None); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + } + + #[test] + fn test_output_uses_updated_input_for_rewrite() { + let input = serde_json::json!({"command": "rtk run -c 'git status'"}); + let response = allow_response("rewrite".into(), Some(input)); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert!( + parsed["hookSpecificOutput"].get("updatedInput").is_some(), + "must have 'updatedInput' for rewrites" + ); + } + + #[test] + fn test_allow_omits_updated_input_when_none() { + let response = allow_response("passthrough".into(), None); + let json = serde_json::to_string(&response).unwrap(); + + assert!( + !json.contains("updatedInput"), + "updatedInput must be omitted when None" + ); + } + + #[test] + fn test_rewrite_preserves_other_tool_input_fields() { + let original = serde_json::json!({ + "command": "git status", + "timeout": 30, + "description": "check repo" + }); + + let mut updated = original.clone(); + if let Some(obj) = updated.as_object_mut() { + obj.insert( + "command".into(), + Value::String("rtk run -c 'git status'".into()), + ); + } + + assert_eq!(updated["timeout"], 30); + assert_eq!(updated["description"], "check repo"); + assert_eq!(updated["command"], "rtk run -c 'git status'"); + } + + #[test] + fn test_output_decision_values() { + let allow = allow_response("test".into(), None); + let deny = deny_response("blocked".into()); + + let allow_json: Value = + serde_json::from_str(&serde_json::to_string(&allow).unwrap()).unwrap(); + let deny_json: Value = + serde_json::from_str(&serde_json::to_string(&deny).unwrap()).unwrap(); + + assert_eq!( + allow_json["hookSpecificOutput"]["permissionDecision"], + "allow" + ); + assert_eq!( + deny_json["hookSpecificOutput"]["permissionDecision"], + "deny" + ); + } + + // --- Input: payload parsing --- + + #[test] + fn test_input_extra_fields_ignored() { + // Claude sends session_id, tool_name, transcript_path, etc. + let json = r#"{"tool_input": {"command": "ls"}, "tool_name": "Bash", "session_id": "abc-123", "session_cwd": "/tmp", "transcript_path": "/path/to/transcript.jsonl"}"#; + let payload: ClaudePayload = serde_json::from_str(json).unwrap(); + assert_eq!(extract_command(&payload), Some("ls")); + } + + #[test] + fn test_input_tool_input_is_object() { + let json = r#"{"tool_input": {"command": "git status", "timeout": 30}}"#; + let payload: ClaudePayload = serde_json::from_str(json).unwrap(); + let input = payload.tool_input.unwrap(); + assert_eq!(input["command"].as_str().unwrap(), "git status"); + assert_eq!(input["timeout"].as_i64().unwrap(), 30); + } + + // --- Guard function tests --- + + #[test] + fn test_extract_command_basic() { + let payload: ClaudePayload = + serde_json::from_str(r#"{"tool_input": {"command": "git status"}}"#).unwrap(); + assert_eq!(extract_command(&payload), Some("git status")); + } + + #[test] + fn test_extract_command_missing_tool_input() { + let payload: ClaudePayload = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(extract_command(&payload), None); + } + + #[test] + fn test_extract_command_missing_command_field() { + let payload: ClaudePayload = + serde_json::from_str(r#"{"tool_input": {"cwd": "/tmp"}}"#).unwrap(); + assert_eq!(extract_command(&payload), None); + } + + #[test] + fn test_extract_command_empty_string() { + let payload: ClaudePayload = + serde_json::from_str(r#"{"tool_input": {"command": ""}}"#).unwrap(); + assert_eq!(extract_command(&payload), None); + } + + #[test] + fn test_shared_should_passthrough_rtk_prefix() { + assert!(should_passthrough("rtk run -c 'ls'")); + assert!(should_passthrough("rtk cargo test")); + assert!(should_passthrough("/usr/local/bin/rtk run -c 'ls'")); + } + + #[test] + fn test_shared_should_passthrough_heredoc() { + assert!(should_passthrough("cat <(input); + } + } + + // --- Fail-open behavior --- + + #[test] + fn test_run_inner_returns_no_opinion_for_empty_payload() { + // "{}" has no tool_input → no command → NoOpinion + let payload: ClaudePayload = serde_json::from_str("{}").unwrap(); + assert_eq!(extract_command(&payload), None); + } + + #[test] + fn test_shared_is_hook_disabled_hook_enabled_zero() { + std::env::set_var("RTK_HOOK_ENABLED", "0"); + assert!(is_hook_disabled()); + std::env::remove_var("RTK_HOOK_ENABLED"); + } + + #[test] + fn test_shared_is_hook_disabled_rtk_active() { + std::env::set_var("RTK_ACTIVE", "1"); + assert!(is_hook_disabled()); + std::env::remove_var("RTK_ACTIVE"); + } + + // --- Integration: Bug #4669 workaround verification --- + + #[test] + fn test_deny_response_includes_reason_for_stderr() { + // Bug #4669 workaround: deny must provide plain text reason + // that can be output to stderr alongside the JSON stdout. + // The msg is cloned for both paths in run_inner(). + let msg = "RTK: cat is blocked (use rtk read instead)"; + let response = deny_response(msg.to_string()); + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + // JSON stdout path + assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "deny"); + assert_eq!( + parsed["hookSpecificOutput"]["permissionDecisionReason"], + msg + ); + // The same msg string is used for stderr in run() via HookResponse::Deny + } + + // Note: Integration tests for check_for_hook() safety decisions are in + // src/cmd/hook.rs (test_safe_commands_rewrite, test_blocked_commands, etc.) + // to avoid duplication. This module focuses on Claude Code wire format. +} diff --git a/src/cmd/exec.rs b/src/cmd/exec.rs new file mode 100644 index 0000000..5eabd63 --- /dev/null +++ b/src/cmd/exec.rs @@ -0,0 +1,426 @@ +//! Command executor: runs simple chains natively, delegates complex shell to /bin/sh. + +use anyhow::{Context, Result}; +use std::process::{Command, Stdio}; + +use super::{analysis, builtins, filters, lexer}; +use crate::tracking; + +/// Check if RTK is already active (recursion guard) +fn is_rtk_active() -> bool { + std::env::var("RTK_ACTIVE").is_ok() +} + +/// RAII guard: sets RTK_ACTIVE on creation, removes on drop (even on panic). +struct RtkActiveGuard; + +impl RtkActiveGuard { + fn new() -> Self { + std::env::set_var("RTK_ACTIVE", "1"); + RtkActiveGuard + } +} + +impl Drop for RtkActiveGuard { + fn drop(&mut self) { + std::env::remove_var("RTK_ACTIVE"); + } +} + +/// Execute a raw command string +pub fn execute(raw: &str, verbose: u8) -> Result { + // Recursion guard + if is_rtk_active() { + if verbose > 0 { + eprintln!("rtk: Recursion detected, passing through"); + } + return run_passthrough(raw, verbose); + } + + // Handle empty input + if raw.trim().is_empty() { + return Ok(true); + } + + let _guard = RtkActiveGuard::new(); + execute_inner(raw, verbose) +} + +fn execute_inner(raw: &str, verbose: u8) -> Result { + // PR 2 adds: crate::config::rules::try_remap() alias expansion + + let tokens = lexer::tokenize(raw); + + // === STEP 1: Decide Native vs Passthrough === + if analysis::needs_shell(&tokens) { + // PR 2 adds: safety::check_raw(raw) before passthrough + return run_passthrough(raw, verbose); + } + + // === STEP 2: Parse into native command chain === + let commands = + analysis::parse_chain(tokens).map_err(|e| anyhow::anyhow!("Parse error: {}", e))?; + + // === STEP 3: Execute native chain === + run_native(&commands, verbose) +} + +/// Run commands in native mode (iterate, check safety, filter output) +fn run_native(commands: &[analysis::NativeCommand], verbose: u8) -> Result { + let mut last_success = true; + let mut prev_operator: Option<&str> = None; + + for cmd in commands { + // === SHORT-CIRCUIT LOGIC === + // Check if we should run based on PREVIOUS operator and result + // The operator stored in cmd is the one AFTER it, so we use prev_operator + if !analysis::should_run(prev_operator, last_success) { + // For && with failure or || with success, skip this command + prev_operator = cmd.operator.as_deref(); + continue; + } + + // === RECURSION PREVENTION === + // Handle "rtk run" or "rtk" binary specially + if cmd.binary == "rtk" && cmd.args.first().map(|s| s.as_str()) == Some("run") { + // Flatten: execute the inner command directly + // rtk run -c "git status" → args = ["run", "-c", "git status"] + let inner = if cmd.args.get(1).map(|s| s.as_str()) == Some("-c") { + cmd.args.get(2).cloned().unwrap_or_default() + } else { + cmd.args.get(1).cloned().unwrap_or_default() + }; + if verbose > 0 { + eprintln!("rtk: Flattening nested rtk run"); + } + return execute(&inner, verbose); + } + // Other rtk commands: spawn as external (they have their own filters) + + // PR 2 adds: safety::check() dispatch block + + // === BUILTINS === + if builtins::is_builtin(&cmd.binary) { + last_success = builtins::execute(&cmd.binary, &cmd.args)?; + prev_operator = cmd.operator.as_deref(); + continue; + } + + // === EXTERNAL COMMAND WITH FILTERING === + last_success = spawn_with_filter(&cmd.binary, &cmd.args, verbose)?; + prev_operator = cmd.operator.as_deref(); + } + + Ok(last_success) +} + +/// Spawn external command and apply appropriate filter +fn spawn_with_filter(binary: &str, args: &[String], _verbose: u8) -> Result { + let timer = tracking::TimedExecution::start(); + + // Try to find the binary in PATH + let binary_path = match which::which(binary) { + Ok(path) => path, + Err(_) => { + // Binary not found + eprintln!("rtk: {}: command not found", binary); + return Ok(false); + } + }; + + // Use wait_with_output() to avoid deadlock when child output exceeds + // pipe buffer (~64KB Linux, ~16KB macOS). This reads stdout/stderr in + // separate threads internally before calling wait(). + let output = Command::new(&binary_path) + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("Failed to execute: {}", binary))?; + + let raw_out = String::from_utf8_lossy(&output.stdout); + let raw_err = String::from_utf8_lossy(&output.stderr); + + // Determine filter type and apply + let filter_type = filters::get_filter_type(binary); + let filtered_out = filters::apply_to_string(filter_type, &raw_out); + let filtered_err = crate::utils::strip_ansi(&raw_err); + + // Print filtered output + print!("{}", filtered_out); + eprint!("{}", filtered_err); + + // Track usage with raw vs filtered for accurate savings + let raw_output = format!("{}{}", raw_out, raw_err); + let filtered_output = format!("{}{}", filtered_out, filtered_err); + timer.track( + &format!("{} {}", binary, args.join(" ")), + &format!("rtk run {} {}", binary, args.join(" ")), + &raw_output, + &filtered_output, + ); + + Ok(output.status.success()) +} + +/// Run command via system shell (passthrough mode) +pub fn run_passthrough(raw: &str, verbose: u8) -> Result { + if verbose > 0 { + eprintln!("rtk: Passthrough mode for complex command"); + } + + let timer = tracking::TimedExecution::start(); + + let shell = if cfg!(windows) { "cmd" } else { "sh" }; + let flag = if cfg!(windows) { "/C" } else { "-c" }; + + let output = Command::new(shell) + .arg(flag) + .arg(raw) + .stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .context("Failed to execute passthrough")?; + + let raw_out = String::from_utf8_lossy(&output.stdout); + let raw_err = String::from_utf8_lossy(&output.stderr); + + // Basic filtering even in passthrough (strip ANSI) + let filtered_out = crate::utils::strip_ansi(&raw_out); + let filtered_err = crate::utils::strip_ansi(&raw_err); + print!("{}", filtered_out); + eprint!("{}", filtered_err); + + let raw_output = format!("{}{}", raw_out, raw_err); + let filtered_output = format!("{}{}", filtered_out, filtered_err); + timer.track( + raw, + &format!("rtk passthrough {}", raw), + &raw_output, + &filtered_output, + ); + + Ok(output.status.success()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::test_helpers::EnvGuard; + + // === RAII GUARD TESTS === + + #[test] + fn test_is_rtk_active_default() { + let _env = EnvGuard::new(); + assert!(!is_rtk_active()); + } + + #[test] + fn test_raii_guard_sets_and_clears() { + let _env = EnvGuard::new(); + { + let _guard = RtkActiveGuard::new(); + assert!(is_rtk_active()); + } + assert!( + !is_rtk_active(), + "RTK_ACTIVE must be cleared when guard drops" + ); + } + + #[test] + fn test_raii_guard_clears_on_panic() { + let _env = EnvGuard::new(); + let result = std::panic::catch_unwind(|| { + let _guard = RtkActiveGuard::new(); + assert!(is_rtk_active()); + panic!("simulated panic"); + }); + assert!(result.is_err()); + assert!( + !is_rtk_active(), + "RTK_ACTIVE must be cleared even after panic" + ); + } + + // === EXECUTE TESTS === + + #[test] + fn test_execute_empty() { + let result = execute("", 0).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_whitespace_only() { + let result = execute(" ", 0).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_simple_command() { + let result = execute("echo hello", 0).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_builtin_cd() { + let original = std::env::current_dir().unwrap(); + let result = execute("cd /tmp", 0).unwrap(); + assert!(result); + // On macOS, /tmp might be a symlink to /private/tmp + // Just verify the command succeeded (the cd happened) + let _ = std::env::set_current_dir(&original); + } + + #[test] + fn test_execute_builtin_pwd() { + let result = execute("pwd", 0).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_builtin_true() { + let result = execute("true", 0).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_builtin_false() { + let result = execute("false", 0).unwrap(); + assert!(!result); + } + + #[test] + fn test_execute_chain_and_success() { + let result = execute("true && echo success", 0).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_chain_and_failure() { + let result = execute("false && echo should_not_run", 0).unwrap(); + // Chain stops at false, so result is false + assert!(!result); + } + + #[test] + fn test_execute_chain_or_success() { + let result = execute("true || echo should_not_run", 0).unwrap(); + // true succeeds, || doesn't run second command + assert!(result); + } + + #[test] + fn test_execute_chain_or_failure() { + let result = execute("false || echo fallback", 0).unwrap(); + // false fails, || runs fallback + assert!(result); + } + + #[test] + fn test_execute_chain_semicolon() { + let result = execute("true ; false", 0).unwrap(); + // Both run, last result is false + assert!(!result); + } + + #[test] + fn test_execute_passthrough_for_glob() { + let result = execute("echo *", 0).unwrap(); + // Should work via passthrough + assert!(result); + } + + #[test] + fn test_execute_passthrough_for_pipe() { + let result = execute("echo hello | cat", 0).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_quoted_operator() { + let result = execute(r#"echo "hello && world""#, 0).unwrap(); + assert!(result); + } + + #[test] + fn test_execute_binary_not_found() { + let result = execute("nonexistent_command_xyz_123", 0).unwrap(); + assert!(!result); + } + + #[test] + fn test_execute_chain_and_three_commands() { + // 3-command chain: true succeeds, false fails, stops before third + let result = execute("true && false && true", 0).unwrap(); + assert!(!result); + } + + #[test] + fn test_execute_chain_semicolon_last_wins() { + // Semicolon runs all; last result (true) determines outcome + let result = execute("false ; true", 0).unwrap(); + assert!(result); + } + + // === INTEGRATION TESTS (moved from edge_cases.rs) === + + #[test] + fn test_chain_mixed_operators() { + // false -> || runs true -> true && runs echo + let result = execute("false || true && echo works", 0).unwrap(); + assert!(result); + } + + #[test] + fn test_passthrough_redirect() { + let result = execute("echo test > /dev/null", 0).unwrap(); + assert!(result); + } + + #[test] + fn test_integration_cd_tilde() { + let original = std::env::current_dir().unwrap(); + let result = execute("cd ~", 0).unwrap(); + assert!(result); + let _ = std::env::set_current_dir(&original); + } + + #[test] + fn test_integration_export() { + let result = execute("export TEST_VAR=value", 0).unwrap(); + assert!(result); + std::env::remove_var("TEST_VAR"); + } + + #[test] + fn test_integration_env_prefix() { + let result = execute("TEST=1 echo hello", 0); + assert!(result.is_ok()); + } + + #[test] + fn test_integration_dash_args() { + let result = execute("echo --help -v --version", 0).unwrap(); + assert!(result); + } + + #[test] + fn test_integration_quoted_empty() { + let result = execute(r#"echo """#, 0).unwrap(); + assert!(result); + } + + // === RECURRENCE PREVENTION TESTS === + + #[test] + fn test_execute_rtk_recursion() { + // This should flatten, not infinitely recurse + let result = execute("rtk run \"echo hello\"", 0); + assert!(result.is_ok()); + } +} diff --git a/src/cmd/filters.rs b/src/cmd/filters.rs new file mode 100644 index 0000000..0bd9dea --- /dev/null +++ b/src/cmd/filters.rs @@ -0,0 +1,212 @@ +//! Filter Registry — basic token reduction for `rtk run` native execution. +//! +//! This module provides **basic filtering (20-40% savings)** for commands +//! executed through rtk run. It is a **fallback** for commands +//! without dedicated RTK implementations. +//! +//! For **specialized filtering (60-90% savings)**, use dedicated modules: +//! - `src/git.rs` — git commands (diff, log, status, etc.) +//! - `src/runner.rs` — test commands (cargo test, pytest, etc.) +//! - `src/grep_cmd.rs` — code search (grep, ripgrep) +//! - `src/pnpm_cmd.rs` — package managers + +use crate::utils; + +/// Filter types for different command categories +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FilterType { + Git, + Cargo, + Test, + Pnpm, + Npm, + Generic, + None, +} + +/// Determine which filter to apply based on binary name +pub fn get_filter_type(binary: &str) -> FilterType { + match binary { + "git" => FilterType::Git, + "cargo" => FilterType::Cargo, + "npm" | "npx" => FilterType::Npm, + "pnpm" => FilterType::Pnpm, + "pytest" | "go" | "vitest" | "jest" | "mocha" => FilterType::Test, + "ls" | "find" | "grep" | "rg" | "fd" => FilterType::Generic, + _ => FilterType::None, + } +} + +/// Apply filter to already-captured string output +pub fn apply_to_string(filter: FilterType, output: &str) -> String { + match filter { + FilterType::Git => utils::strip_ansi(output), + FilterType::Cargo => filter_cargo_output(output), + FilterType::Test => filter_test_output(output), + FilterType::Generic => truncate_lines(output, 100), + FilterType::Npm | FilterType::Pnpm => utils::strip_ansi(output), + FilterType::None => output.to_string(), + } +} + +/// Filter cargo output: remove verbose "Compiling" lines +fn filter_cargo_output(output: &str) -> String { + output + .lines() + .filter(|line| { + let line = line.trim(); + !line.starts_with("Compiling ") || line.contains("error") || line.contains("warning") + }) + .collect::>() + .join("\n") +} + +/// Filter test output: remove passing tests, keep failures +fn filter_test_output(output: &str) -> String { + output + .lines() + .filter(|line| { + let line = line.trim(); + line.contains("FAILED") + || line.contains("error") + || line.contains("Error") + || line.contains("failed") + || line.contains("test result:") + || line.starts_with("----") + }) + .collect::>() + .join("\n") +} + +/// Truncate output to max lines +fn truncate_lines(output: &str, max_lines: usize) -> String { + let lines: Vec<&str> = output.lines().collect(); + if lines.len() <= max_lines { + output.to_string() + } else { + let truncated: Vec<&str> = lines.iter().take(max_lines).copied().collect(); + format!( + "{}\n... ({} more lines)", + truncated.join("\n"), + lines.len() - max_lines + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // === GET_FILTER_TYPE TESTS === + + #[test] + fn test_filter_type_git() { + assert_eq!(get_filter_type("git"), FilterType::Git); + } + + #[test] + fn test_filter_type_cargo() { + assert_eq!(get_filter_type("cargo"), FilterType::Cargo); + } + + #[test] + fn test_filter_type_npm() { + assert_eq!(get_filter_type("npm"), FilterType::Npm); + assert_eq!(get_filter_type("npx"), FilterType::Npm); + } + + #[test] + fn test_filter_type_generic() { + assert_eq!(get_filter_type("ls"), FilterType::Generic); + assert_eq!(get_filter_type("grep"), FilterType::Generic); + } + + #[test] + fn test_filter_type_none() { + assert_eq!(get_filter_type("unknown_command"), FilterType::None); + } + + // === STRIP_ANSI TESTS (now testing utils::strip_ansi) === + + #[test] + fn test_strip_ansi_no_codes() { + assert_eq!(utils::strip_ansi("hello world"), "hello world"); + } + + #[test] + fn test_strip_ansi_color() { + assert_eq!(utils::strip_ansi("\x1b[32mgreen\x1b[0m"), "green"); + } + + #[test] + fn test_strip_ansi_bold() { + assert_eq!(utils::strip_ansi("\x1b[1mbold\x1b[0m"), "bold"); + } + + #[test] + fn test_strip_ansi_multiple() { + assert_eq!( + utils::strip_ansi("\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m"), + "red green" + ); + } + + #[test] + fn test_strip_ansi_complex() { + assert_eq!( + utils::strip_ansi("\x1b[1;31;42mbold red on green\x1b[0m"), + "bold red on green" + ); + } + + // === FILTER_CARGO_OUTPUT TESTS === + + #[test] + fn test_filter_cargo_keeps_errors() { + let input = "Compiling dep1\nerror: something wrong\nCompiling dep2"; + let output = filter_cargo_output(input); + assert!(output.contains("error")); + assert!(!output.contains("Compiling dep1")); + } + + #[test] + fn test_filter_cargo_keeps_warnings() { + let input = "Compiling dep1\nwarning: unused variable\nCompiling dep2"; + let output = filter_cargo_output(input); + assert!(output.contains("warning")); + } + + // === TRUNCATE_LINES TESTS === + + #[test] + fn test_truncate_short() { + let input = "line1\nline2\nline3"; + let output = truncate_lines(input, 10); + assert_eq!(output, input); + } + + #[test] + fn test_truncate_long() { + let input = "line1\nline2\nline3\nline4\nline5"; + let output = truncate_lines(input, 3); + assert!(output.contains("line3")); + assert!(!output.contains("line4")); + assert!(output.contains("2 more lines")); + } + + // === APPLY_TO_STRING TESTS === + + #[test] + fn test_apply_to_string_none() { + let input = "hello world"; + let output = apply_to_string(FilterType::None, input); + assert_eq!(output, input); + } + + #[test] + fn test_apply_to_string_git() { + let input = "\x1b[32mgreen\x1b[0m"; + let output = apply_to_string(FilterType::Git, input); + assert_eq!(output, "green"); + } +} diff --git a/src/cmd/gemini_hook.rs b/src/cmd/gemini_hook.rs new file mode 100644 index 0000000..98eadc6 --- /dev/null +++ b/src/cmd/gemini_hook.rs @@ -0,0 +1,490 @@ +//! Gemini CLI BeforeTool hook protocol handler. +//! +//! Reads JSON from stdin, applies safety checks and rewrites, +//! outputs JSON to stdout. +//! +//! Protocol: https://geminicli.com/docs/hooks/reference/ +//! +//! ## Exit Code Behavior +//! +//! - Exit 0 = normal (JSON `decision` field is respected) +//! - Exit 2 = blocking error (equivalent to `decision: "deny"`) +//! +//! ## Gemini CLI Stderr Rule +//! +//! **Source:** See `/Users/athundt/.claude/clautorun/.worktrees/claude-stable-pre-v0.8.0/notes/hooks_api_reference.md:740-753` +//! +//! Unlike Claude Code, Gemini CLI **allows stderr for debugging**: +//! ```text +//! stderr is SAFE for debug/logging (shown to user/agent) +//! ``` +//! +//! **This module's stderr usage:** +//! - Currently: **NO stderr output** (JSON `reason` field sufficient for all cases) +//! - Future: Could add debug logging to stderr if needed (safe in Gemini) +//! +//! ## I/O Enforcement (Module-Specific) +//! +//! **This restriction applies ONLY to gemini_hook.rs and claude_hook.rs.** +//! All other RTK modules (main.rs, git.rs, etc.) use `println!`/`eprintln!` normally. +//! +//! **Why restricted here:** +//! - Hook protocol requires JSON-only stdout +//! - Accidental prints corrupt the JSON response +//! - Consistency with claude_hook.rs architecture +//! +//! **Enforcement mechanism:** +//! - `#![deny(clippy::print_stdout, clippy::print_stderr)]` at module level (line 42) +//! - `run_inner()` returns `HookResponse` enum — pure logic, no I/O +//! - `run()` is the ONLY function that writes output — single I/O point +//! - Uses `write!`/`writeln!` which are NOT caught by the clippy lint +//! +//! **Pathway:** main.rs → Commands::Hook → gemini_hook::run() [DENY ENFORCED HERE] +//! +//! Fail-open: Any parse error or unexpected input → exit 0, no output. + +// Compile-time I/O enforcement for THIS MODULE ONLY. +// Other RTK modules (main.rs, git.rs, etc.) use println!/eprintln! normally. +// +// Why restrict here: +// - Gemini CLI hook protocol requires JSON-only stdout +// - Accidental prints would corrupt the JSON response +// - Architectural consistency with claude_hook.rs +// +// Note: Unlike Claude Code, Gemini ALLOWS stderr for debug logging +// (see hooks_api_reference.md:740-753), but we don't need it. +// The JSON `reason` field is sufficient for all messaging. +// +// Mechanism: +// - Denies println!/eprintln! at compile-time +// - Allows write!/writeln! (used only in run() for controlled output) +// - run_inner() returns HookResponse (no I/O) +// - run() is the single I/O point +#![deny(clippy::print_stdout, clippy::print_stderr)] + +use super::hook::{ + check_for_hook, is_hook_disabled, should_passthrough, update_command_in_tool_input, + HookResponse, HookResult, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{self, Read, Write}; + +#[derive(Deserialize)] +struct GeminiPayload { + hook_event_name: Option, + tool_name: Option, + tool_input: Option, +} + +#[derive(Serialize)] +struct GeminiResponse { + decision: String, // "allow" or "deny" + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + #[serde(rename = "hookSpecificOutput")] + #[serde(skip_serializing_if = "Option::is_none")] + hook_specific_output: Option, +} + +#[derive(Serialize)] +struct HookSpecificOutput { + tool_input: Value, +} + +/// Tool names that represent shell command execution in Gemini CLI +fn is_shell_tool(name: &str) -> bool { + // Gemini CLI built-in shell tool, plus common MCP patterns + name == "run_shell_command" || name == "shell" || name.ends_with("__run_shell_command") +} + +/// Run the Gemini hook handler. +/// +/// This is the ONLY function that performs I/O (stdout). +/// `run_inner()` returns a `HookResponse` enum — pure logic, no I/O. +/// Combined with `#![deny(clippy::print_stdout, clippy::print_stderr)]`, +/// this ensures no stray output corrupts the JSON hook protocol. +/// +/// Fail-open design: malformed input → exit 0, no output. +pub fn run() -> anyhow::Result<()> { + let response = match run_inner() { + Ok(r) => r, + Err(_) => HookResponse::NoOpinion, // Fail-open: swallow errors + }; + + // ┌────────────────────────────────────────────────────────────────┐ + // │ SINGLE I/O POINT - All stdout output happens here only │ + // │ │ + // │ Why: Gemini CLI hook protocol requires JSON-only stdout │ + // │ (Gemini ALLOWS stderr for debug, but we don't need it) │ + // │ │ + // │ Enforcement: #![deny(...)] at line 42 prevents println!/eprintln! │ + // │ write!/writeln! are not caught by lint (allowed) │ + // └────────────────────────────────────────────────────────────────┘ + match response { + HookResponse::NoOpinion => { + // Exit 0, NO stdout, NO stderr + // Gemini CLI sees no output → proceeds with original command + } + HookResponse::Allow(json) | HookResponse::Deny(json, _) => { + // Exit 0, JSON to stdout, NO stderr + // Note: Gemini ALLOWS stderr for debug (unlike Claude), but JSON + // `reason` field is sufficient. The HookResponse::Deny + // second field (stderr_reason) is empty for Gemini. + writeln!(io::stdout(), "{json}")?; + } + } + Ok(()) +} + +/// Inner handler: pure decision logic, no I/O. +/// Returns `HookResponse` for `run()` to output. +fn run_inner() -> anyhow::Result { + let mut buffer = String::new(); + io::stdin().read_to_string(&mut buffer)?; + + let payload: GeminiPayload = match serde_json::from_str(&buffer) { + Ok(p) => p, + Err(_) => return Ok(HookResponse::NoOpinion), + }; + + // Only handle BeforeTool events — other events get a plain allow + if payload.hook_event_name.as_deref() != Some("BeforeTool") { + return Ok(HookResponse::Allow(r#"{"decision": "allow"}"#.into())); + } + + // Only intercept shell execution tools + match &payload.tool_name { + Some(name) if is_shell_tool(name) => {} + _ => return Ok(HookResponse::Allow(r#"{"decision": "allow"}"#.into())), + }; + + // Extract the command string from tool_input + let cmd = match &payload.tool_input { + Some(input) => input + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + None => return Ok(HookResponse::Allow(r#"{"decision": "allow"}"#.into())), + }; + + if cmd.is_empty() { + return Ok(HookResponse::Allow(r#"{"decision": "allow"}"#.into())); + } + + // Shared guard checks (same as claude_hook.rs, DRY via hook.rs) + if is_hook_disabled() || should_passthrough(&cmd) { + return Ok(HookResponse::NoOpinion); + } + + let decision = check_for_hook(&cmd, "gemini"); + + let response = match decision { + HookResult::Rewrite(new_cmd) => { + // Preserve all original tool_input fields, only replace "command" + // Shared helper (DRY with claude_hook.rs via hook.rs) + let new_input = update_command_in_tool_input(payload.tool_input, new_cmd); + + GeminiResponse { + decision: "allow".into(), + reason: Some("RTK applied safety optimizations.".into()), + hook_specific_output: Some(HookSpecificOutput { + tool_input: new_input, + }), + } + } + HookResult::Blocked(msg) => GeminiResponse { + decision: "deny".into(), + reason: Some(msg), + hook_specific_output: None, + }, + }; + + let json = serde_json::to_string(&response)?; + // Gemini deny uses JSON response only (no stderr/exit-code workaround needed) + if response.decision == "deny" { + Ok(HookResponse::Deny(json, String::new())) + } else { + Ok(HookResponse::Allow(json)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // GEMINI WIRE FORMAT CONFORMANCE + // https://geminicli.com/docs/hooks/reference/ + // + // These tests verify exact JSON field names per the Gemini CLI spec. + // A wrong field name means Gemini silently ignores the response. + // ========================================================================= + + // --- Input: field name conformance --- + + #[test] + fn test_input_uses_hook_event_name_not_type() { + // Gemini sends "hook_event_name", NOT "type" + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"command": "git status"}}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.hook_event_name.as_deref(), Some("BeforeTool")); + + // Verify the old wrong field name does NOT populate our struct + let wrong_json = r#"{"type": "BeforeTool", "tool_name": "run_shell_command"}"#; + let payload: GeminiPayload = serde_json::from_str(wrong_json).unwrap(); + assert_eq!( + payload.hook_event_name, None, + "\"type\" must not be accepted as event name" + ); + } + + #[test] + fn test_input_includes_tool_name() { + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"command": "ls"}}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.tool_name.as_deref(), Some("run_shell_command")); + } + + #[test] + fn test_input_tool_input_is_object() { + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"command": "git status", "timeout": 30}}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + let input = payload.tool_input.unwrap(); + assert_eq!(input["command"].as_str().unwrap(), "git status"); + assert_eq!(input["timeout"].as_i64().unwrap(), 30); + } + + #[test] + fn test_input_extra_fields_ignored() { + // Gemini sends session_id, cwd, timestamp, transcript_path etc. + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"command": "ls"}, "session_id": "abc123", "cwd": "/tmp", "timestamp": "2026-01-01T00:00:00Z", "transcript_path": "/path/to/transcript"}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.hook_event_name.as_deref(), Some("BeforeTool")); + } + + // --- Output: field name conformance --- + + #[test] + fn test_output_uses_decision_not_result() { + // Gemini expects "decision", NOT "result" + let response = GeminiResponse { + decision: "allow".into(), + reason: None, + hook_specific_output: None, + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert!( + parsed.get("decision").is_some(), + "must have 'decision' field" + ); + assert!( + parsed.get("result").is_none(), + "must NOT have 'result' field" + ); + } + + #[test] + fn test_output_uses_reason_not_message() { + // Gemini expects "reason", NOT "message" + let response = GeminiResponse { + decision: "deny".into(), + reason: Some("Blocked for safety".into()), + hook_specific_output: None, + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert!(parsed.get("reason").is_some(), "must have 'reason' field"); + assert!( + parsed.get("message").is_none(), + "must NOT have 'message' field" + ); + } + + #[test] + fn test_output_uses_hook_specific_output_not_modified_input() { + // Gemini expects "hookSpecificOutput", NOT "modified_input" + let response = GeminiResponse { + decision: "allow".into(), + reason: None, + hook_specific_output: Some(HookSpecificOutput { + tool_input: serde_json::json!({"command": "rtk run -c 'ls'"}), + }), + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert!( + parsed.get("hookSpecificOutput").is_some(), + "must have 'hookSpecificOutput' field" + ); + assert!( + parsed.get("modified_input").is_none(), + "must NOT have 'modified_input' field" + ); + } + + #[test] + fn test_output_rewrite_nests_under_tool_input() { + // Gemini merges hookSpecificOutput.tool_input into the original + let response = GeminiResponse { + decision: "allow".into(), + reason: Some("RTK applied safety optimizations.".into()), + hook_specific_output: Some(HookSpecificOutput { + tool_input: serde_json::json!({"command": "rtk run -c 'git status'"}), + }), + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + + assert_eq!( + parsed["hookSpecificOutput"]["tool_input"]["command"], + "rtk run -c 'git status'" + ); + } + + #[test] + fn test_output_allow_omits_optional_fields() { + let response = GeminiResponse { + decision: "allow".into(), + reason: None, + hook_specific_output: None, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(!json.contains("reason"), "reason must be omitted when None"); + assert!( + !json.contains("hookSpecificOutput"), + "hookSpecificOutput must be omitted when None" + ); + } + + #[test] + fn test_output_decision_values() { + // Only "allow" and "deny" are valid + for val in ["allow", "deny"] { + let response = GeminiResponse { + decision: val.into(), + reason: Some("test".into()), + hook_specific_output: None, + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["decision"].as_str().unwrap(), val); + } + } + + // --- Tool filtering --- + + #[test] + fn test_is_shell_tool() { + assert!(is_shell_tool("run_shell_command")); + assert!(is_shell_tool("shell")); + assert!(is_shell_tool("mcp__server__run_shell_command")); + assert!(!is_shell_tool("read_file")); + assert!(!is_shell_tool("write_file")); + assert!(!is_shell_tool("search_code")); + assert!(!is_shell_tool("list_directory")); + } + + #[test] + fn test_non_shell_tools_always_allowed() { + // read_file, write_file, etc. must never be intercepted + for tool in ["read_file", "write_file", "search_code", "list_directory"] { + let json = format!( + r#"{{"hook_event_name": "BeforeTool", "tool_name": "{}", "tool_input": {{"path": "/etc/passwd"}}}}"#, + tool + ); + let payload: GeminiPayload = serde_json::from_str(&json).unwrap(); + assert!( + !is_shell_tool(payload.tool_name.as_deref().unwrap()), + "tool '{}' must not be treated as shell tool", + tool + ); + } + } + + // --- Event filtering --- + + #[test] + fn test_non_before_tool_events_ignored() { + for event in ["AfterTool", "BeforeAgent", "AfterAgent", "SessionStart"] { + let json = format!( + r#"{{"hook_event_name": "{}", "tool_name": "run_shell_command", "tool_input": {{"command": "rm -rf /"}}}}"#, + event + ); + let payload: GeminiPayload = serde_json::from_str(&json).unwrap(); + assert_ne!(payload.hook_event_name.as_deref(), Some("BeforeTool")); + } + } + + // --- Rewrite preserves other tool_input fields --- + + #[test] + fn test_rewrite_preserves_other_tool_input_fields() { + let original_input = serde_json::json!({ + "command": "git status", + "timeout": 30, + "cwd": "/project" + }); + + let mut new_input = original_input.clone(); + if let Some(obj) = new_input.as_object_mut() { + obj.insert( + "command".into(), + Value::String("rtk run -c 'git status'".into()), + ); + } + + assert_eq!(new_input["timeout"], 30); + assert_eq!(new_input["cwd"], "/project"); + assert_eq!(new_input["command"], "rtk run -c 'git status'"); + } + + // --- Edge cases --- + + #[test] + fn test_missing_tool_input() { + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command"}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + assert!(payload.tool_input.is_none()); + } + + #[test] + fn test_missing_command_in_tool_input() { + let json = r#"{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"cwd": "/tmp"}}"#; + let payload: GeminiPayload = serde_json::from_str(json).unwrap(); + let input = payload.tool_input.unwrap(); + assert!(input.get("command").is_none()); + } + + #[test] + fn test_malformed_json_does_not_panic() { + let bad_inputs = ["", "not json", "{}", r#"{"hook_event_name": 42}"#, "null"]; + for input in bad_inputs { + // Should not panic, just return Err or deserialize to defaults + let _ = serde_json::from_str::(input); + } + } + + // --- Guard parity with Claude hook --- + + #[test] + fn test_shared_guards_available() { + // Verify shared guard functions are accessible (DRY with claude_hook.rs) + assert!(!should_passthrough("git status")); + assert!(should_passthrough("rtk git status")); + assert!(should_passthrough("cat < HookResult { + check_for_hook_inner(raw, 0) +} + +fn check_for_hook_inner(raw: &str, depth: usize) -> HookResult { + if depth >= MAX_REWRITE_DEPTH { + return HookResult::Blocked("Rewrite loop detected (max depth exceeded)".to_string()); + } + if raw.trim().is_empty() { + return HookResult::Rewrite(raw.to_string()); + } + // PR 2 adds: crate::config::rules::try_remap() alias expansion + // PR 2 adds: safety::check_raw() and safety::check() dispatch + + let tokens = lexer::tokenize(raw); + + if analysis::needs_shell(&tokens) { + return HookResult::Rewrite(format!("rtk run -c '{}'", escape_quotes(raw))); + } + + match analysis::parse_chain(tokens) { + Ok(commands) => { + // Single command: route to optimized RTK subcommand. + // Chained commands (&&, ||, ;): wrap entire chain in rtk run -c. + if commands.len() == 1 { + HookResult::Rewrite(route_native_command(&commands[0], raw)) + } else { + HookResult::Rewrite(format!("rtk run -c '{}'", escape_quotes(raw))) + } + } + Err(_) => HookResult::Rewrite(format!("rtk run -c '{}'", escape_quotes(raw))), + } +} + +// --- Shared guard logic (used by both claude_hook.rs and gemini_hook.rs) --- + +/// Check if hook processing is disabled by environment. +/// +/// Returns true if: +/// - `RTK_HOOK_ENABLED=0` (master toggle off) +/// - `RTK_ACTIVE` is set (recursion prevention — rtk sets this when running commands) +pub fn is_hook_disabled() -> bool { + std::env::var("RTK_HOOK_ENABLED").as_deref() == Ok("0") || std::env::var("RTK_ACTIVE").is_ok() +} + +/// Check if this command should bypass hook processing entirely. +/// +/// Returns true for commands that should not be rewritten: +/// - Already routed through rtk (`rtk ...` or `/path/to/rtk ...`) +/// - Contains heredoc (`<<`) which needs raw shell processing +pub fn should_passthrough(cmd: &str) -> bool { + cmd.starts_with("rtk ") || cmd.contains("/rtk ") || cmd.contains("<<") +} + +/// Replace the command field in a tool_input object, preserving other fields. +/// +/// Used by both claude_hook.rs and gemini_hook.rs when rewriting commands. +/// If tool_input is None or not an object, creates a new object with just the command. +/// +/// # Arguments +/// * `tool_input` - The original tool_input from the hook payload (may be None) +/// * `new_cmd` - The rewritten command string to replace with +/// +/// # Returns +/// A Value with the command field updated, all other fields preserved. +pub fn update_command_in_tool_input( + tool_input: Option, + new_cmd: String, +) -> serde_json::Value { + use serde_json::Value; + let mut updated = tool_input.unwrap_or_else(|| Value::Object(Default::default())); + if let Some(obj) = updated.as_object_mut() { + obj.insert("command".into(), Value::String(new_cmd)); + } + updated +} + +/// Hook output for protocol handlers (claude_hook.rs, gemini_hook.rs). +/// +/// This enum separates decision logic from I/O: `run_inner()` returns a +/// `HookResponse`, and `run()` is the single place that writes to stdout/stderr. +/// Combined with `#[deny(clippy::print_stdout, clippy::print_stderr)]` on the +/// hook modules, this prevents any stray output from corrupting the JSON protocol. +#[derive(Debug, Clone, PartialEq)] +pub enum HookResponse { + /// No opinion — exit 0, no output. Host proceeds normally. + NoOpinion, + /// Allow/rewrite — exit 0, JSON to stdout. + Allow(String), + /// Deny — exit 2, JSON to stdout + reason to stderr. + /// Fields: (stdout_json, stderr_reason) + Deny(String, String), +} + +/// Escape single quotes for shell +fn escape_quotes(s: &str) -> String { + s.replace("'", "'\\''") +} + +/// Replace the first occurrence of `old_prefix` in `raw` with `new_prefix`. +/// +/// Preserves everything after the prefix (including original quoting). +/// Falls back to `rtk run -c ''` if prefix not found (safe degradation). +/// +/// # Examples +/// - `replace_first_word("grep -r p src/", "grep", "rtk grep")` → `"rtk grep -r p src/"` +/// - `replace_first_word("rg pattern", "rg", "rtk grep")` → `"rtk grep pattern"` +fn replace_first_word(raw: &str, old_prefix: &str, new_prefix: &str) -> String { + raw.strip_prefix(old_prefix) + .map(|rest| format!("{new_prefix}{rest}")) + .unwrap_or_else(|| format!("rtk run -c '{}'", escape_quotes(raw))) +} + +/// Route pnpm subcommands to RTK equivalents. +/// +/// Uses `cmd.args` (parsed, quote-stripped) for routing decisions. +/// Uses `raw` or reconstructed args for output to preserve original quoting. +fn route_pnpm(cmd: &analysis::NativeCommand, raw: &str) -> String { + let sub = cmd.args.first().map(String::as_str).unwrap_or(""); + match sub { + "list" | "ls" | "outdated" | "install" => format!("rtk {raw}"), + + // pnpm vitest [run] [flags] → rtk vitest run [flags] + // Shell script sed bug: 's/^(pnpm )?vitest/rtk vitest run/' on + // "pnpm vitest run --coverage" produces "rtk vitest run run --coverage". + // Binary hook corrects this by stripping the leading "run" from parsed args. + "vitest" => { + let after_vitest: Vec<&str> = cmd.args[1..] + .iter() + .map(String::as_str) + .skip_while(|&a| a == "run") + .collect(); + if after_vitest.is_empty() { + "rtk vitest run".to_string() + } else { + format!("rtk vitest run {}", after_vitest.join(" ")) + } + } + + // pnpm test [flags] → rtk vitest run [flags] + "test" => { + let after_test: Vec<&str> = cmd.args[1..].iter().map(String::as_str).collect(); + if after_test.is_empty() { + "rtk vitest run".to_string() + } else { + format!("rtk vitest run {}", after_test.join(" ")) + } + } + + "tsc" => replace_first_word(raw, "pnpm tsc", "rtk tsc"), + "lint" => replace_first_word(raw, "pnpm lint", "rtk lint"), + "playwright" => replace_first_word(raw, "pnpm playwright", "rtk playwright"), + + _ => format!("rtk run -c '{}'", escape_quotes(raw)), + } +} + +/// Route npx subcommands to RTK equivalents. +fn route_npx(cmd: &analysis::NativeCommand, raw: &str) -> String { + let sub = cmd.args.first().map(String::as_str).unwrap_or(""); + match sub { + "tsc" | "typescript" => replace_first_word(raw, &format!("npx {sub}"), "rtk tsc"), + "eslint" => replace_first_word(raw, "npx eslint", "rtk lint"), + "prettier" => replace_first_word(raw, "npx prettier", "rtk prettier"), + "playwright" => replace_first_word(raw, "npx playwright", "rtk playwright"), + "prisma" => replace_first_word(raw, "npx prisma", "rtk prisma"), + _ => format!("rtk run -c '{}'", escape_quotes(raw)), + } +} + +/// Route a single parsed native command to its optimized RTK subcommand. +/// +/// ## Design +/// - Uses `cmd.binary`/`cmd.args` (lexer→parse_chain output) for routing DECISIONS. +/// - Uses `raw: &str` with `replace_first_word` for string REPLACEMENT (preserves quoting). +/// - `format!("rtk {raw}")` works when the binary name equals the RTK subcommand. +/// - `replace_first_word` handles renames: `rg → rtk grep`, `cat → rtk read`. +/// +/// ## Fallback +/// Unknown binaries or unrecognized subcommands → `rtk run -c ''` (safe passthrough). +/// +/// ## Mirrors +/// `~/.claude/hooks/rtk-rewrite.sh` routing table. Corrects the shell script's +/// `vitest run` double-"run" bug by using parsed args rather than regex substitution. +/// +/// ## Safety interaction +/// PR 2 adds safety::check before this function. The `cat` arm is defensive for +/// when `RTK_BLOCK_TOKEN_WASTE=0`. +fn route_native_command(cmd: &analysis::NativeCommand, raw: &str) -> String { + let sub = cmd.args.first().map(String::as_str).unwrap_or(""); + let sub2 = cmd.args.get(1).map(String::as_str).unwrap_or(""); + + // 1. Static routing table: O(1) lookup via HashMap (built once at startup). + // Covers all simple cases: direct routes and renames (rg→grep, eslint→lint). + if let Some(route) = crate::discover::registry::lookup(&cmd.binary, sub) { + return if route.rtk_cmd == cmd.binary.as_str() { + // Direct route (binary name == rtk subcommand): prepend "rtk " + format!("rtk {raw}") + } else { + // Rename route (rg → grep, eslint → lint): replace binary prefix + replace_first_word(raw, &cmd.binary, &format!("rtk {}", route.rtk_cmd)) + }; + } + + // 2. Complex cases that require Rust logic and cannot be expressed as table entries. + + // cat: blocked by safety rules before reaching here; defensive for RTK_BLOCK_TOKEN_WASTE=0 + if cmd.binary == "cat" { + return replace_first_word(raw, "cat", "rtk read"); + } + + match cmd.binary.as_str() { + // vitest: bare invocation → rtk vitest run (not rtk vitest) + "vitest" if sub.is_empty() => "rtk vitest run".to_string(), + "vitest" => format!("rtk {raw}"), + + // uv pip: two-word prefix replacement + "uv" if sub == "pip" && matches!(sub2, "list" | "outdated" | "install" | "show") => { + replace_first_word(raw, "uv pip", "rtk pip") + } + + // python/python3 -m pytest: two-arg prefix replacement + "python" | "python3" if sub == "-m" && sub2 == "pytest" => { + let prefix = format!("{} -m pytest", cmd.binary); + replace_first_word(raw, &prefix, "rtk pytest") + } + + // pnpm / npx: delegated to helpers (complex sub-routing) + "pnpm" => route_pnpm(cmd, raw), + "npx" => route_npx(cmd, raw), + + // Fallback: unknown binary or unrecognized subcommand + _ => format!("rtk run -c '{}'", escape_quotes(raw)), + } +} +/// Format hook result for Claude (text output) +/// +/// Exit codes: +/// - 0: Success, command rewritten/allowed +/// - 2: Blocking error, command should be denied +pub fn format_for_claude(result: HookResult) -> (String, bool, i32) { + match result { + HookResult::Rewrite(cmd) => (cmd, true, 0), + HookResult::Blocked(msg) => (msg, false, 2), // Exit 2 = blocking error per Claude Code spec + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // === TEST HELPERS === + + fn assert_rewrite(input: &str, contains: &str) { + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => assert!( + cmd.contains(contains), + "'{}' rewrite should contain '{}', got '{}'", + input, + contains, + cmd + ), + other => panic!("Expected Rewrite for '{}', got {:?}", input, other), + } + } + + fn assert_blocked(input: &str, contains: &str) { + match check_for_hook(input, "claude") { + HookResult::Blocked(msg) => assert!( + msg.contains(contains), + "'{}' block msg should contain '{}', got '{}'", + input, + contains, + msg + ), + other => panic!("Expected Blocked for '{}', got {:?}", input, other), + } + } + + // === ESCAPE_QUOTES === + + #[test] + fn test_escape_quotes() { + assert_eq!(escape_quotes("hello"), "hello"); + assert_eq!(escape_quotes("it's"), "it'\\''s"); + assert_eq!(escape_quotes("it's a test's"), "it'\\''s a test'\\''s"); + } + + // === EMPTY / WHITESPACE === + + #[test] + fn test_check_empty_and_whitespace() { + match check_for_hook("", "claude") { + HookResult::Rewrite(cmd) => assert!(cmd.is_empty()), + _ => panic!("Expected Rewrite for empty"), + } + match check_for_hook(" ", "claude") { + HookResult::Rewrite(cmd) => assert!(cmd.trim().is_empty()), + _ => panic!("Expected Rewrite for whitespace"), + } + } + + // === COMMANDS THAT SHOULD REWRITE (table-driven) === + + #[test] + fn test_safe_commands_rewrite() { + let cases = [ + ("git status", "rtk git status"), // now routes to optimized subcommand + ("ls *.rs", "rtk run"), // shellism passthrough (glob) + (r#"git commit -m "Fix && Bug""#, "rtk git commit"), // quoted &&: single cmd, routes + ("FOO=bar echo hello", "rtk run"), // env prefix → shellism + ("echo `date`", "rtk run"), // backticks + ("echo $(date)", "rtk run"), // subshell + ("echo {a,b}.txt", "rtk run"), // brace expansion + ("echo 'hello!@#$%^&*()'", "rtk run"), // special chars + ("echo '日本語 🎉'", "rtk run"), // unicode + ("cd /tmp && git status", "rtk run"), // chain rewrite + ]; + for (input, expected) in cases { + assert_rewrite(input, expected); + } + // Chain rewrite preserves operator structure + match check_for_hook("cd /tmp && git status", "claude") { + HookResult::Rewrite(cmd) => assert!( + cmd.contains("&&"), + "Chain rewrite must preserve '&&', got '{}'", + cmd + ), + other => panic!("Expected Rewrite for chain, got {:?}", other), + } + // Very long command + assert_rewrite(&format!("echo {}", "a".repeat(1000)), "rtk run"); + } + + // === ENV VAR PREFIX PRESERVATION === + // Ported from old hooks/test-rtk-rewrite.sh Section 2. + // Commands prefixed with KEY=VALUE env vars must not be blocked. + + #[test] + fn test_env_var_prefix_preserved() { + let cases = [ + "GIT_PAGER=cat git status", + "GIT_PAGER=cat git log --oneline -10", + "NODE_ENV=test CI=1 npx vitest run", + "LANG=C ls -la", + "NODE_ENV=test npm run test:e2e", + "COMPOSE_PROJECT_NAME=test docker compose up -d", + "TEST_SESSION_ID=2 npx playwright test --config=foo", + ]; + for input in cases { + assert_rewrite(input, "rtk run"); + } + } + + // === GLOBAL OPTIONS (PR #99 parity) === + // Commands with global options before subcommands must not be blocked. + // Ported from upstream hooks/rtk-rewrite.sh global option stripping. + + #[test] + fn test_global_options_not_blocked() { + let cases = [ + // Git global options + "git --no-pager status", + "git -C /path/to/project status", + "git -C /path --no-pager log --oneline", + "git --no-optional-locks diff HEAD", + "git --bare log", + // Cargo toolchain prefix + "cargo +nightly test", + "cargo +stable build --release", + // Docker global options + "docker --context prod ps", + "docker -H tcp://host:2375 images", + // Kubectl global options + "kubectl -n kube-system get pods", + "kubectl --context prod describe pod foo", + ]; + for input in cases { + assert_rewrite(input, "rtk run"); + } + } + + // === SPECIFIC COMMANDS NOT BLOCKED === + // Ported from old hooks/test-rtk-rewrite.sh Sections 1 & 3. + // These commands must pass through (not be blocked by safety rules). + + #[test] + fn test_specific_commands_not_blocked() { + let cases = [ + // Git variants + "git log --oneline -10", + "git diff HEAD", + "git show abc123", + "git add .", + // GitHub CLI + "gh pr list", + "gh api repos/owner/repo", + "gh release list", + // Package managers + "npm run test:e2e", + "npm run build", + "npm test", + // Docker + "docker compose up -d", + "docker compose logs postgrest", + "docker compose down", + "docker run --rm postgres", + "docker exec -it db psql", + // Kubernetes + "kubectl describe pod foo", + "kubectl apply -f deploy.yaml", + // Test runners + "npx playwright test", + "npx prisma migrate", + "cargo test", + // Vitest variants (dedup is internal to rtk run, not hook level) + "vitest", + "vitest run", + "vitest run --reporter=verbose", + "npx vitest run", + "pnpm vitest run --coverage", + // TypeScript + "vue-tsc -b", + "npx vue-tsc --noEmit", + // Utilities + "curl -s https://example.com", + "ls -la", + "grep -rn pattern src/", + "rg pattern src/", + ]; + for input in cases { + // Test name intent: commands must Rewrite (not Blocked), regardless of routing target. + // Specific routing targets are verified in test_routing_native_commands. + assert!( + matches!(check_for_hook(input, "claude"), HookResult::Rewrite(_)), + "'{}' should Rewrite (not Blocked)", + input + ); + } + } + + // === COMMANDS THAT PASS THROUGH (builtins/unknown) === + // Ported from old hooks/test-rtk-rewrite.sh Section 5. + // These are not blocked — they get wrapped in rtk run -c. + + #[test] + fn test_builtins_not_blocked() { + let cases = [ + "echo hello world", + "cd /tmp", + "mkdir -p foo/bar", + "python3 script.py", + "node -e 'console.log(1)'", + "find . -name '*.ts'", + "tree src/", + "wget https://example.com/file", + ]; + for input in cases { + assert_rewrite(input, "rtk run"); + } + } + + // === COMPOUND COMMANDS (chained with &&, ||, ;) === + // Shell script only matched FIRST command in a chain. + // Rust hook parses each command independently (#112). + + #[test] + fn test_compound_commands_rewrite() { + let cases = [ + // Basic chains — each command rewritten independently + ("cd /tmp && git status", "&&"), + ("cd dir && git status && git diff", "&&"), + ("git add . && git commit -m msg", "&&"), + // Semicolon chains + ("echo start ; git status ; echo done", ";"), + // Or-chains + ("git pull || echo failed", "||"), + ]; + for (input, operator) in cases { + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + assert!(cmd.contains("rtk run"), "'{input}' should rewrite"); + assert!( + cmd.contains(operator), + "'{input}' must preserve '{operator}', got '{cmd}'" + ); + } + other => panic!("Expected Rewrite for '{input}', got {other:?}"), + } + } + } + + // PR 2 adds: test_compound_blocked_in_chain (safety-dependent test) + + #[test] + fn test_compound_quoted_operators_not_split() { + // && inside quotes must NOT split the command into a chain. + // parse_chain sees one command: git commit with args ["-m", "Fix && Bug"]. + // That single command routes to rtk git commit (not rtk run -c). + let input = r#"git commit -m "Fix && Bug""#; + match check_for_hook(input, "claude") { + HookResult::Rewrite(cmd) => { + assert!( + cmd.contains("rtk git commit"), + "Quoted && must not split; should route to rtk git commit, got '{cmd}'" + ); + } + other => panic!("Expected Rewrite for quoted &&, got {other:?}"), + } + } + + // PR 2 adds: test_blocked_commands (safety-dependent test) + + // === SHELLISM PASSTHROUGH: cat/sed/head allowed with pipe/redirect === + + #[test] + fn test_token_waste_allowed_in_pipelines() { + let cases = [ + "cat file.txt | grep pattern", + "cat file.txt > output.txt", + "sed 's/old/new/' file.txt > output.txt", + "head -n 10 file.txt | grep pattern", + "for f in *.txt; do cat \"$f\" | grep x; done", + ]; + for input in cases { + assert_rewrite(input, "rtk run"); + } + } + + // === MULTI-AGENT === + + #[test] + fn test_different_agents_same_result() { + // Both agents must Rewrite (not Block) safe commands. + // Specific routing targets verified in test_cross_agent_routing_identical. + for agent in ["claude", "gemini"] { + match check_for_hook("git status", agent) { + HookResult::Rewrite(_) => {} + other => panic!("Expected Rewrite for agent '{}', got {:?}", agent, other), + } + } + } + + // === FORMAT_FOR_CLAUDE === + + #[test] + fn test_format_for_claude() { + let (output, success, code) = + format_for_claude(HookResult::Rewrite("rtk run -c 'git status'".to_string())); + assert_eq!(output, "rtk run -c 'git status'"); + assert!(success); + assert_eq!(code, 0); + + let (output, success, code) = + format_for_claude(HookResult::Blocked("Error message".to_string())); + assert_eq!(output, "Error message"); + assert!(!success); + assert_eq!(code, 2); // Exit 2 = blocking error per Claude Code spec + } + + // === RECURSION DEPTH LIMIT === + + #[test] + fn test_rewrite_depth_limit() { + // At max depth → blocked + match check_for_hook_inner("echo hello", MAX_REWRITE_DEPTH) { + HookResult::Blocked(msg) => assert!(msg.contains("loop"), "msg: {}", msg), + _ => panic!("Expected Blocked at max depth"), + } + // At depth 0 → normal rewrite + match check_for_hook_inner("echo hello", 0) { + HookResult::Rewrite(cmd) => assert!(cmd.contains("rtk run")), + _ => panic!("Expected Rewrite at depth 0"), + } + } + + // ========================================================================= + // CLAUDE CODE WIRE FORMAT CONFORMANCE + // https://docs.anthropic.com/en/docs/claude-code/hooks + // + // Claude Code hook protocol: + // - Rewrite: command on stdout, exit code 0 + // - Block: message on stderr, exit code 2 + // - Other exit codes are non-blocking errors + // + // format_for_claude() is the boundary between HookResult and the wire. + // These tests verify it produces the exact contract Claude Code expects. + // ========================================================================= + + #[test] + fn test_claude_rewrite_exit_code_is_zero() { + let (_, _, code) = format_for_claude(HookResult::Rewrite("rtk run -c 'ls'".into())); + assert_eq!(code, 0, "Rewrite must exit 0 (success)"); + } + + #[test] + fn test_claude_block_exit_code_is_two() { + let (_, _, code) = format_for_claude(HookResult::Blocked("denied".into())); + assert_eq!( + code, 2, + "Block must exit 2 (blocking error per Claude Code spec)" + ); + } + + #[test] + fn test_claude_rewrite_output_is_command_text() { + // Claude Code reads stdout as the rewritten command — must be plain text, not JSON + let (output, success, _) = + format_for_claude(HookResult::Rewrite("rtk run -c 'git status'".into())); + assert_eq!(output, "rtk run -c 'git status'"); + assert!(success); + // Must NOT be JSON + assert!( + !output.starts_with('{'), + "Rewrite output must be plain text, not JSON" + ); + } + + #[test] + fn test_claude_block_output_is_human_message() { + // Claude Code reads stderr for the block reason + let (output, success, _) = + format_for_claude(HookResult::Blocked("Use Read tool instead".into())); + assert_eq!(output, "Use Read tool instead"); + assert!(!success); + // Must NOT be JSON + assert!( + !output.starts_with('{'), + "Block output must be plain text, not JSON" + ); + } + + #[test] + fn test_claude_rewrite_success_flag_true() { + let (_, success, _) = format_for_claude(HookResult::Rewrite("cmd".into())); + assert!(success, "Rewrite must set success=true"); + } + + #[test] + fn test_claude_block_success_flag_false() { + let (_, success, _) = format_for_claude(HookResult::Blocked("msg".into())); + assert!(!success, "Block must set success=false"); + } + + #[test] + fn test_claude_exit_codes_not_one() { + // Exit code 1 means non-blocking error in Claude Code — we must never use it + let (_, _, rewrite_code) = format_for_claude(HookResult::Rewrite("cmd".into())); + let (_, _, block_code) = format_for_claude(HookResult::Blocked("msg".into())); + assert_ne!( + rewrite_code, 1, + "Exit code 1 is non-blocking error, not valid for rewrite" + ); + assert_ne!( + block_code, 1, + "Exit code 1 is non-blocking error, not valid for block" + ); + } + + // === CROSS-PROTOCOL: Same decision for both agents === + + #[test] + fn test_cross_protocol_safe_command_allowed_by_both() { + // Both Claude and Gemini must allow the same safe commands + for cmd in ["git status", "cargo test", "ls -la", "echo hello"] { + let claude = check_for_hook(cmd, "claude"); + let gemini = check_for_hook(cmd, "gemini"); + match (&claude, &gemini) { + (HookResult::Rewrite(_), HookResult::Rewrite(_)) => {} + _ => panic!( + "'{}': Claude={:?}, Gemini={:?} — both should Rewrite", + cmd, claude, gemini + ), + } + } + } + + // PR 2 adds: test_cross_protocol_blocked_command_denied_by_both (safety-dependent test) + + // ===================================================================== + // ROUTING TESTS — verify route_native_command dispatch + // ===================================================================== + + #[test] + fn test_routing_native_commands() { + // Table-driven: commands that route to optimized rtk subcommands. + // Each (input, expected_substr) must appear in the rewritten output. + let cases = [ + // Git: known subcommands + ("git status", "rtk git status"), + ("git log --oneline -10", "rtk git log --oneline -10"), + ("git diff HEAD", "rtk git diff HEAD"), + ("git add .", "rtk git add ."), + ("git commit -m msg", "rtk git commit"), + // GitHub CLI + ("gh pr view 156", "rtk gh pr view 156"), + // Cargo + ("cargo test", "rtk cargo test"), + ( + "cargo clippy --all-targets", + "rtk cargo clippy --all-targets", + ), + // File ops (rg → rtk grep rename) + // NOTE: PR 2 adds safety that blocks cat before reaching router; arm is defensive. + ("grep -r pattern src/", "rtk grep -r pattern src/"), + ("rg pattern src/", "rtk grep pattern src/"), + ("ls -la", "rtk ls -la"), + // JS/TS tooling + ("vitest", "rtk vitest run"), // bare → rtk vitest run + ("vitest run", "rtk vitest run"), // explicit run preserved + ("vitest run --coverage", "rtk vitest run --coverage"), + ("pnpm test", "rtk vitest run"), + ("pnpm vitest", "rtk vitest run"), + ("pnpm lint", "rtk lint"), + ("npx tsc --noEmit", "rtk tsc --noEmit"), + // Python + ("python -m pytest tests/", "rtk pytest tests/"), + ("uv pip list", "rtk pip list"), + // Go + ("go test ./...", "rtk go test ./..."), + ("go build ./...", "rtk go build ./..."), + ("go vet ./...", "rtk go vet ./..."), + // All ROUTES entries not yet covered above + ("eslint src/", "rtk lint src/"), // rename: eslint → lint + ("tsc --noEmit", "rtk tsc --noEmit"), // bare tsc (not npx tsc) + ("prettier src/", "rtk prettier src/"), + ("playwright test", "rtk playwright test"), + ("prisma migrate dev", "rtk prisma migrate dev"), + ( + "curl https://api.example.com", + "rtk curl https://api.example.com", + ), + ("pytest tests/", "rtk pytest tests/"), // bare pytest (not python -m pytest) + ("pytest -x tests/unit", "rtk pytest -x tests/unit"), + ("golangci-lint run ./...", "rtk golangci-lint run ./..."), + ("docker ps", "rtk docker ps"), + ("docker images", "rtk docker images"), + ("docker logs mycontainer", "rtk docker logs mycontainer"), + ("kubectl get pods", "rtk kubectl get pods"), + ("kubectl logs mypod", "rtk kubectl logs mypod"), + ("ruff check src/", "rtk ruff check src/"), + ("ruff format src/", "rtk ruff format src/"), + ("pip list", "rtk pip list"), + ("pip install requests", "rtk pip install requests"), + ("pip outdated", "rtk pip outdated"), + ("pip show requests", "rtk pip show requests"), + ("gh issue list", "rtk gh issue list"), + ("gh run view 123", "rtk gh run view 123"), + ("git stash pop", "rtk git stash pop"), + ("git fetch origin", "rtk git fetch origin"), + ]; + for (input, expected) in cases { + assert_rewrite(input, expected); + } + } + + #[test] + fn test_routing_subcommand_filter_fallback() { + // Commands where binary is in ROUTES but subcommand is NOT in the Only list + // must fall through to `rtk run -c '...'`. + let cases = [ + "docker build .", // docker Only: ps, images, logs + "docker run -it nginx", // docker Only: ps, images, logs + "kubectl apply -f dep.yaml", // kubectl Only: get, logs + "kubectl delete pod mypod", // kubectl Only: get, logs + "go mod tidy", // go Only: test, build, vet + "go generate ./...", // go Only: test, build, vet + "ruff lint src/", // ruff Only: check, format + "pip freeze", // pip Only: list, outdated, install, show + "pip uninstall requests", // pip Only: list, outdated, install, show + "cargo publish", // cargo Only: test, build, clippy, check + "cargo run", // cargo Only: test, build, clippy, check + "git rebase -i HEAD~3", // git Only list (rebase not included) + "git cherry-pick abc123", // git Only list + "gh repo clone foo/bar", // gh Only: pr, issue, run + ]; + for input in cases { + assert_rewrite(input, "rtk run -c"); + } + } + + #[test] + fn test_routing_vitest_no_double_run() { + // Shell script sed bug: 's/^(pnpm )?vitest/rtk vitest run/' on + // "pnpm vitest run --coverage" produces "rtk vitest run run --coverage". + // Binary hook corrects this by using parsed args instead of regex substitution. + let result = match check_for_hook("pnpm vitest run --coverage", "claude") { + HookResult::Rewrite(cmd) => cmd, + other => panic!("Expected Rewrite, got {:?}", other), + }; + assert_rewrite("pnpm vitest run --coverage", "rtk vitest run --coverage"); + assert!( + !result.contains("run run"), + "Must not double 'run' in output: '{}'", + result + ); + } + + #[test] + fn test_routing_fallbacks_to_rtk_run() { + // Unknown subcommand, chains (2+ cmds), and pipes fall back to rtk run -c. + let cases = [ + "git checkout main", // unknown git subcommand + "git add . && git commit -m msg", // chain → 2 commands → rtk run -c + "git log | grep fix", // pipe → needs_shell → rtk run -c + "tail -n 20 file.txt", // no rtk tail subcommand + "tail -f server.log", // no rtk tail subcommand + ]; + for input in cases { + assert_rewrite(input, "rtk run -c"); + } + } + + #[test] + fn test_cross_agent_routing_identical() { + // Both claude and gemini must route the same commands to the same output. + for cmd in ["git status", "cargo test", "ls -la"] { + let claude_result = check_for_hook(cmd, "claude"); + let gemini_result = check_for_hook(cmd, "gemini"); + match (&claude_result, &gemini_result) { + (HookResult::Rewrite(c), HookResult::Rewrite(g)) => { + assert_eq!(c, g, "claude and gemini must route '{}' identically", cmd); + assert!( + !c.contains("rtk run -c"), + "'{}' should not fall back to rtk run -c", + cmd + ); + } + _ => panic!( + "'{}' should Rewrite for both agents: claude={:?} gemini={:?}", + cmd, claude_result, gemini_result + ), + } + } + } +} diff --git a/src/cmd/lexer.rs b/src/cmd/lexer.rs new file mode 100644 index 0000000..5f820bc --- /dev/null +++ b/src/cmd/lexer.rs @@ -0,0 +1,474 @@ +//! State-machine lexer that respects quotes and escapes. +//! Critical: `git commit -m "Fix && Bug"` must NOT split on && + +#[derive(Debug, PartialEq, Clone)] +pub enum TokenKind { + Arg, // Regular argument + Operator, // &&, ||, ; + Pipe, // | + Redirect, // >, >>, <, 2> + Shellism, // *, $, `, (, ), {, } - forces passthrough +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ParsedToken { + pub kind: TokenKind, + pub value: String, // The actual string value +} + +/// Tokenize input with quote awareness. +/// Returns Vec of parsed tokens. +pub fn tokenize(input: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut chars = input.chars().peekable(); + + let mut quote: Option = None; // None, Some('\''), Some('"') + let mut escaped = false; + + while let Some(c) = chars.next() { + // Handle escape sequences (but NOT inside single quotes) + if escaped { + current.push(c); + escaped = false; + continue; + } + if c == '\\' && quote != Some('\'') { + escaped = true; + current.push(c); + continue; + } + + // Handle quotes + if let Some(q) = quote { + if c == q { + quote = None; // Close quote + } + current.push(c); + continue; + } + if c == '\'' || c == '"' { + quote = Some(c); + current.push(c); + continue; + } + + // Outside quotes - handle operators and shellisms + match c { + // Shellisms force passthrough (includes ! for history expansion/negation) + '*' | '?' | '$' | '`' | '(' | ')' | '{' | '}' | '!' => { + flush_arg(&mut tokens, &mut current); + tokens.push(ParsedToken { + kind: TokenKind::Shellism, + value: c.to_string(), + }); + } + // Operators + '&' | '|' | ';' | '>' | '<' => { + flush_arg(&mut tokens, &mut current); + + let mut op = c.to_string(); + // Lookahead for double-char operators + if let Some(&next) = chars.peek() { + if (next == c && c != ';' && c != '<') || (c == '>' && next == '>') { + op.push(chars.next().unwrap()); + } + } + + let kind = match op.as_str() { + "&&" | "||" | ";" => TokenKind::Operator, + "|" => TokenKind::Pipe, + "&" => TokenKind::Shellism, // Background job needs real shell + _ => TokenKind::Redirect, + }; + tokens.push(ParsedToken { kind, value: op }); + } + // Whitespace delimits arguments + c if c.is_whitespace() => { + flush_arg(&mut tokens, &mut current); + } + // Regular character + _ => current.push(c), + } + } + + // Handle unclosed quote (treat remaining as arg, don't panic) + flush_arg(&mut tokens, &mut current); + tokens +} + +fn flush_arg(tokens: &mut Vec, current: &mut String) { + let trimmed = current.trim(); + if !trimmed.is_empty() { + tokens.push(ParsedToken { + kind: TokenKind::Arg, + value: trimmed.to_string(), + }); + } + current.clear(); +} + +/// Strip quotes from a token value +pub fn strip_quotes(s: &str) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() >= 2 + && ((chars[0] == '"' && chars[chars.len() - 1] == '"') + || (chars[0] == '\'' && chars[chars.len() - 1] == '\'')) + { + return chars[1..chars.len() - 1].iter().collect(); + } + s.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + // === BASIC FUNCTIONALITY TESTS === + + #[test] + fn test_simple_command() { + let tokens = tokenize("git status"); + assert_eq!(tokens.len(), 2); + assert_eq!(tokens[0].kind, TokenKind::Arg); + assert_eq!(tokens[0].value, "git"); + assert_eq!(tokens[1].value, "status"); + } + + #[test] + fn test_command_with_args() { + let tokens = tokenize("git commit -m message"); + assert_eq!(tokens.len(), 4); + assert_eq!(tokens[0].value, "git"); + assert_eq!(tokens[1].value, "commit"); + assert_eq!(tokens[2].value, "-m"); + assert_eq!(tokens[3].value, "message"); + } + + // === QUOTE HANDLING TESTS === + + #[test] + fn test_quoted_operator_not_split() { + let tokens = tokenize(r#"git commit -m "Fix && Bug""#); + // && inside quotes should NOT be an Operator token + assert!(!tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Operator) && t.value == "&&")); + assert!(tokens.iter().any(|t| t.value.contains("Fix && Bug"))); + } + + #[test] + fn test_single_quoted_string() { + let tokens = tokenize("echo 'hello world'"); + assert!(tokens.iter().any(|t| t.value == "'hello world'")); + } + + #[test] + fn test_double_quoted_string() { + let tokens = tokenize("echo \"hello world\""); + assert!(tokens.iter().any(|t| t.value == "\"hello world\"")); + } + + #[test] + fn test_empty_quoted_string() { + let tokens = tokenize("echo \"\""); + // Should have echo and "" + assert!(tokens.iter().any(|t| t.value == "\"\"")); + } + + #[test] + fn test_nested_quotes() { + let tokens = tokenize(r#"echo "outer 'inner' outer""#); + assert!(tokens.iter().any(|t| t.value.contains("'inner'"))); + } + + #[test] + fn test_strip_quotes_double() { + assert_eq!(strip_quotes("\"hello\""), "hello"); + } + + #[test] + fn test_strip_quotes_single() { + assert_eq!(strip_quotes("'hello'"), "hello"); + } + + #[test] + fn test_strip_quotes_none() { + assert_eq!(strip_quotes("hello"), "hello"); + } + + #[test] + fn test_strip_quotes_mismatched() { + assert_eq!(strip_quotes("\"hello'"), "\"hello'"); + } + + // === ESCAPE HANDLING TESTS === + + #[test] + fn test_escaped_space() { + let tokens = tokenize("echo hello\\ world"); + // Escaped space should be part of the arg + assert!(tokens.iter().any(|t| t.value.contains("hello"))); + } + + #[test] + fn test_backslash_in_single_quotes() { + // In single quotes, backslash is literal + let tokens = tokenize(r#"echo 'hello\nworld'"#); + assert!(tokens.iter().any(|t| t.value.contains(r#"\n"#))); + } + + #[test] + fn test_escaped_quote_in_double() { + let tokens = tokenize(r#"echo "hello\"world""#); + assert!(tokens.iter().any(|t| t.value.contains("hello"))); + } + + // === EDGE CASE TESTS === + + #[test] + fn test_empty_input() { + let tokens = tokenize(""); + assert!(tokens.is_empty()); + } + + #[test] + fn test_whitespace_only() { + let tokens = tokenize(" "); + assert!(tokens.is_empty()); + } + + #[test] + fn test_unclosed_single_quote() { + // Should not panic, treat remaining as part of arg + let tokens = tokenize("'unclosed"); + assert!(!tokens.is_empty()); + } + + #[test] + fn test_unclosed_double_quote() { + // Should not panic, treat remaining as part of arg + let tokens = tokenize("\"unclosed"); + assert!(!tokens.is_empty()); + } + + #[test] + fn test_unicode_preservation() { + let tokens = tokenize("echo \"héllo wörld\""); + assert!(tokens.iter().any(|t| t.value.contains("héllo"))); + } + + #[test] + fn test_multiple_spaces() { + let tokens = tokenize("git status"); + assert_eq!(tokens.len(), 2); + } + + #[test] + fn test_leading_trailing_spaces() { + let tokens = tokenize(" git status "); + assert_eq!(tokens.len(), 2); + } + + // === OPERATOR TESTS === + + #[test] + fn test_and_operator() { + let tokens = tokenize("cmd1 && cmd2"); + assert!(tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Operator) && t.value == "&&")); + } + + #[test] + fn test_or_operator() { + let tokens = tokenize("cmd1 || cmd2"); + assert!(tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Operator) && t.value == "||")); + } + + #[test] + fn test_semicolon() { + let tokens = tokenize("cmd1 ; cmd2"); + assert!(tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Operator) && t.value == ";")); + } + + #[test] + fn test_multiple_and() { + let tokens = tokenize("a && b && c"); + let ops: Vec<_> = tokens + .iter() + .filter(|t| matches!(t.kind, TokenKind::Operator)) + .collect(); + assert_eq!(ops.len(), 2); + } + + #[test] + fn test_mixed_operators() { + let tokens = tokenize("a && b || c"); + let ops: Vec<_> = tokens + .iter() + .filter(|t| matches!(t.kind, TokenKind::Operator)) + .collect(); + assert_eq!(ops.len(), 2); + } + + #[test] + fn test_operator_at_start() { + let tokens = tokenize("&& cmd"); + // Should still parse, just with operator first + assert!(tokens.iter().any(|t| t.value == "&&")); + } + + #[test] + fn test_operator_at_end() { + let tokens = tokenize("cmd &&"); + assert!(tokens.iter().any(|t| t.value == "&&")); + } + + // === PIPE TESTS === + + #[test] + fn test_pipe_detection() { + let tokens = tokenize("cat file | grep pattern"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Pipe))); + } + + #[test] + fn test_quoted_pipe_not_pipe() { + let tokens = tokenize("\"a|b\""); + // Pipe inside quotes is not a Pipe token + assert!(!tokens.iter().any(|t| matches!(t.kind, TokenKind::Pipe))); + } + + #[test] + fn test_multiple_pipes() { + let tokens = tokenize("a | b | c"); + let pipes: Vec<_> = tokens + .iter() + .filter(|t| matches!(t.kind, TokenKind::Pipe)) + .collect(); + assert_eq!(pipes.len(), 2); + } + + // === SHELLISM TESTS === + + #[test] + fn test_glob_detection() { + let tokens = tokenize("ls *.rs"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + #[test] + fn test_quoted_glob_not_shellism() { + let tokens = tokenize("echo \"*.txt\""); + // Glob inside quotes is not a Shellism token + assert!(!tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + #[test] + fn test_variable_detection() { + let tokens = tokenize("echo $HOME"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + #[test] + fn test_quoted_variable_not_shellism() { + let tokens = tokenize("echo \"$HOME\""); + // $ inside double quotes is NOT detected as a Shellism token + // because the lexer respects quotes + // This is correct - the variable can't be expanded by us anyway + // so the whole command will need to passthrough to shell + // But at the tokenization level, it's not a Shellism + assert!(!tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + #[test] + fn test_backtick_substitution() { + let tokens = tokenize("echo `date`"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + #[test] + fn test_subshell_detection() { + let tokens = tokenize("echo $(date)"); + // Both $ and ( should be shellisms + let shellisms: Vec<_> = tokens + .iter() + .filter(|t| matches!(t.kind, TokenKind::Shellism)) + .collect(); + assert!(!shellisms.is_empty()); + } + + #[test] + fn test_brace_expansion() { + let tokens = tokenize("echo {a,b}.txt"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Shellism))); + } + + #[test] + fn test_escaped_glob() { + let tokens = tokenize("echo \\*.txt"); + // Escaped glob should not be a shellism + assert!(!tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Shellism) && t.value == "*")); + } + + // === REDIRECT TESTS === + + #[test] + fn test_redirect_out() { + let tokens = tokenize("cmd > file"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Redirect))); + } + + #[test] + fn test_redirect_append() { + let tokens = tokenize("cmd >> file"); + assert!(tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Redirect) && t.value == ">>")); + } + + #[test] + fn test_redirect_in() { + let tokens = tokenize("cmd < file"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Redirect))); + } + + #[test] + fn test_redirect_stderr() { + let tokens = tokenize("cmd 2> file"); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Redirect))); + } + + // === EXCLAMATION / NEGATION TESTS === + + #[test] + fn test_exclamation_is_shellism() { + let tokens = tokenize("if ! grep -q pattern file; then echo missing; fi"); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Shellism) && t.value == "!"), + "! (negation) must be Shellism" + ); + } + + // === BACKGROUND JOB TESTS === + + #[test] + fn test_background_job_is_shellism() { + let tokens = tokenize("sleep 10 &"); + assert!( + tokens + .iter() + .any(|t| matches!(t.kind, TokenKind::Shellism) && t.value == "&"), + "Single & (background job) must be Shellism, not Redirect" + ); + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 0000000..e6702a8 --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,36 @@ +//! Command execution subsystem for RTK hook integration. +//! +//! This module provides the core hook engine that powers `rtk hook claude`. +//! It handles chained command rewriting, native command execution, and output filtering. + +// Analysis and lexing (no external deps) +pub(crate) mod analysis; +pub(crate) mod lexer; + +// Predicates and utilities (no external deps) +pub(crate) mod predicates; + +// Builtins (depends on predicates) +pub(crate) mod builtins; + +// Filters (depends on crate::utils) +pub(crate) mod filters; + +// Exec (depends on analysis, builtins, filters, lexer) +pub mod exec; + +// Hook logic (depends on analysis, lexer) +pub mod hook; + +// Claude hook protocol (depends on hook) +pub mod claude_hook; + +// Gemini hook protocol (depends on hook) +pub mod gemini_hook; + +#[cfg(test)] +pub(crate) mod test_helpers; + +// Public exports +pub use exec::execute; +pub use hook::check_for_hook; diff --git a/src/cmd/predicates.rs b/src/cmd/predicates.rs new file mode 100644 index 0000000..9bd5a6a --- /dev/null +++ b/src/cmd/predicates.rs @@ -0,0 +1,94 @@ +//! Context-aware predicates for conditional safety rules. +//! These give RTK "situational awareness" - checking git state, file existence, etc. + +use std::process::Command; + +/// Check if there are unstaged changes in the current git repo +pub(crate) fn has_unstaged_changes() -> bool { + Command::new("git") + .args(["diff", "--quiet"]) + .status() + .map(|s| !s.success()) // git diff --quiet returns 1 if changes exist + .unwrap_or(false) +} + +/// Critical for token reduction: detect if output goes to human or agent +pub(crate) fn is_interactive() -> bool { + use std::io::IsTerminal; + std::io::stderr().is_terminal() +} + +/// Expand ~ to $HOME, with fallback +pub(crate) fn expand_tilde(path: &str) -> String { + if path.starts_with("~") { + // Try HOME first, then USERPROFILE (Windows) + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| "/".to_string()); + path.replacen("~", &home, 1) + } else { + path.to_string() + } +} + +/// Get HOME directory with fallback +pub(crate) fn get_home() -> String { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| "/".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + // === PATH EXPANSION TESTS === + + #[test] + fn test_expand_tilde_simple() { + let home = env::var("HOME").unwrap_or("/".to_string()); + assert_eq!(expand_tilde("~/src"), format!("{}/src", home)); + } + + #[test] + fn test_expand_tilde_no_tilde() { + assert_eq!(expand_tilde("/absolute/path"), "/absolute/path"); + } + + #[test] + fn test_expand_tilde_only_tilde() { + let home = env::var("HOME").unwrap_or("/".to_string()); + assert_eq!(expand_tilde("~"), home); + } + + #[test] + fn test_expand_tilde_relative() { + assert_eq!(expand_tilde("relative/path"), "relative/path"); + } + + // === HOME DIRECTORY TESTS === + + #[test] + fn test_get_home_returns_something() { + let home = get_home(); + assert!(!home.is_empty()); + } + + // === INTERACTIVE TESTS === + + #[test] + fn test_is_interactive() { + // This will be false when running tests + // Just ensure it doesn't panic + let _ = is_interactive(); + } + + // === GIT PREDICATE TESTS === + + #[test] + fn test_has_unstaged_changes() { + // Just ensure it doesn't panic + let _ = has_unstaged_changes(); + } +} diff --git a/src/cmd/test_helpers.rs b/src/cmd/test_helpers.rs new file mode 100644 index 0000000..06f929a --- /dev/null +++ b/src/cmd/test_helpers.rs @@ -0,0 +1,35 @@ +//! Shared test utilities for the cmd module. + +use std::sync::{Mutex, MutexGuard, OnceLock}; + +static ENV_LOCK: OnceLock> = OnceLock::new(); + +/// RAII guard that serializes env-var-mutating tests and auto-cleans on drop. +/// Prevents race conditions between parallel test threads and ensures cleanup +/// even if a test panics. +pub struct EnvGuard { + _lock: MutexGuard<'static, ()>, +} + +impl EnvGuard { + pub fn new() -> Self { + let lock = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()); + Self::cleanup(); + Self { _lock: lock } + } + + fn cleanup() { + std::env::remove_var("RTK_SAFE_COMMANDS"); + std::env::remove_var("RTK_BLOCK_TOKEN_WASTE"); + std::env::remove_var("RTK_ACTIVE"); + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + Self::cleanup(); + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 7ef375c..1c22c62 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1,5 +1,197 @@ use lazy_static::lazy_static; use regex::{Regex, RegexSet}; +use std::collections::HashMap; +use std::sync::OnceLock; + +// --------------------------------------------------------------------------- +// Hook routing table — used by `cmd::hook` for O(1) command rewriting. +// This is the single source of truth for which external binaries route through +// RTK and exactly which subcommands are covered. +// +// # Adding a new command +// 1. Add one `Route` entry to `ROUTES`. +// 2. Add a discover entry (PATTERNS + RULES) below if needed. +// 3. Done — hook routing is automatic. +// --------------------------------------------------------------------------- + +/// Subcommand filter for a route entry. +#[derive(Debug, Clone, Copy)] +pub enum Subcmds { + /// Route ALL subcommands of this binary (e.g., ls, curl, prettier). + Any, + /// Route ONLY these specific subcommands; others fall through to `rtk run -c`. + Only(&'static [&'static str]), +} + +/// One row in the static routing table. +/// +/// - `binaries`: one or more external binary names mapping to the same RTK subcommand. +/// - `subcmds`: subcommand filter — `Any` matches everything, `Only` restricts to a list. +/// - `rtk_cmd`: the RTK subcommand name (e.g., `"grep"`, `"lint"`, `"git"`). +/// +/// For direct routes where `binary == rtk_cmd`, the hook uses `format!("rtk {raw}")`. +/// For renames (`rg` → `grep`, `eslint` → `lint`), it uses `replace_first_word`. +#[derive(Debug, Clone, Copy)] +pub struct Route { + pub binaries: &'static [&'static str], + pub subcmds: Subcmds, + pub rtk_cmd: &'static str, +} + +/// Static routing table. Single source of truth for hook routing. +/// +/// Order does not matter — lookups use a HashMap built once at startup (O(1) per call). +/// +/// Complex cases (vitest bare invocation, `uv pip`, `python -m pytest`, pnpm, npx) +/// require Rust logic and stay as match arms in `cmd::hook::route_native_command`. +pub const ROUTES: &[Route] = &[ + // Version control + Route { + binaries: &["git"], + subcmds: Subcmds::Only(&[ + "status", "diff", "log", "add", "commit", "push", "pull", "branch", "fetch", "stash", + "show", + ]), + rtk_cmd: "git", + }, + // GitHub CLI + Route { + binaries: &["gh"], + subcmds: Subcmds::Only(&["pr", "issue", "run"]), + rtk_cmd: "gh", + }, + // Rust build tools + Route { + binaries: &["cargo"], + subcmds: Subcmds::Only(&["test", "build", "clippy", "check"]), + rtk_cmd: "cargo", + }, + // Search — two binaries, one RTK subcommand (rename) + Route { + binaries: &["rg", "grep"], + subcmds: Subcmds::Any, + rtk_cmd: "grep", + }, + // JavaScript linting — rename + Route { + binaries: &["eslint"], + subcmds: Subcmds::Any, + rtk_cmd: "lint", + }, + // File system + Route { + binaries: &["ls"], + subcmds: Subcmds::Any, + rtk_cmd: "ls", + }, + // TypeScript compiler + Route { + binaries: &["tsc"], + subcmds: Subcmds::Any, + rtk_cmd: "tsc", + }, + // JavaScript formatting + Route { + binaries: &["prettier"], + subcmds: Subcmds::Any, + rtk_cmd: "prettier", + }, + // E2E testing + Route { + binaries: &["playwright"], + subcmds: Subcmds::Any, + rtk_cmd: "playwright", + }, + // Database ORM + Route { + binaries: &["prisma"], + subcmds: Subcmds::Any, + rtk_cmd: "prisma", + }, + // Network + Route { + binaries: &["curl"], + subcmds: Subcmds::Any, + rtk_cmd: "curl", + }, + // Python testing + Route { + binaries: &["pytest"], + subcmds: Subcmds::Any, + rtk_cmd: "pytest", + }, + // Go linting + Route { + binaries: &["golangci-lint"], + subcmds: Subcmds::Any, + rtk_cmd: "golangci-lint", + }, + // Containers — read-only subcommands only + Route { + binaries: &["docker"], + subcmds: Subcmds::Only(&["ps", "images", "logs"]), + rtk_cmd: "docker", + }, + // Kubernetes — read-only subcommands only + Route { + binaries: &["kubectl"], + subcmds: Subcmds::Only(&["get", "logs"]), + rtk_cmd: "kubectl", + }, + // Go build tools + Route { + binaries: &["go"], + subcmds: Subcmds::Only(&["test", "build", "vet"]), + rtk_cmd: "go", + }, + // Python linting/formatting + Route { + binaries: &["ruff"], + subcmds: Subcmds::Only(&["check", "format"]), + rtk_cmd: "ruff", + }, + // Python package management + Route { + binaries: &["pip"], + subcmds: Subcmds::Only(&["list", "outdated", "install", "show"]), + rtk_cmd: "pip", + }, +]; + +/// Look up the routing entry for a binary + subcommand. +/// +/// Returns `Some(route)` if the binary is in the table AND the subcommand matches +/// the entry's filter. Returns `None` if unrecognised or subcommand not in `Only` list. +/// +/// The HashMap is built once per process (OnceLock). Each binary maps to the index of +/// its `Route` in `ROUTES`. Multiple binaries from the same entry (e.g., `rg`/`grep`) +/// both point to the same index. +pub fn lookup(binary: &str, sub: &str) -> Option<&'static Route> { + static MAP: OnceLock> = OnceLock::new(); + let map = MAP.get_or_init(|| { + let mut m = HashMap::new(); + for (i, route) in ROUTES.iter().enumerate() { + for &bin in route.binaries { + m.entry(bin).or_insert(i); + } + } + m + }); + + let idx = *map.get(binary)?; + let route = &ROUTES[idx]; + + let matches = match route.subcmds { + Subcmds::Any => true, + Subcmds::Only(subs) => subs.contains(&sub), + }; + + if matches { + Some(route) + } else { + None + } +} /// A rule mapping a shell command pattern to its RTK equivalent. struct RtkRule { @@ -70,6 +262,12 @@ const PATTERNS: &[&str] = &[ r"^kubectl\s+(get|logs)", r"^curl\s+", r"^wget\s+", + // Python/Go tooling (added with Python & Go support) + r"^pytest(\s|$)", + r"^go\s+(test|build|vet)(\s|$)", + r"^ruff\s+(check|format)(\s|$)", + r"^(pip|pip3)\s+(list|outdated|install|show)(\s|$)", + r"^golangci-lint(\s|$)", ]; const RULES: &[RtkRule] = &[ @@ -225,6 +423,42 @@ const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // Python/Go tooling (added with Python & Go support) + RtkRule { + rtk_cmd: "rtk pytest", + category: "Tests", + savings_pct: 90.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk go", + category: "Build", + savings_pct: 85.0, + subcmd_savings: &[("test", 90.0)], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk ruff", + category: "Build", + savings_pct: 80.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk pip", + category: "PackageManager", + savings_pct: 75.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk golangci-lint", + category: "Build", + savings_pct: 85.0, + subcmd_savings: &[], + subcmd_status: &[], + }, ]; /// Commands to ignore (shell builtins, trivial, already rtk). @@ -662,6 +896,129 @@ mod tests { ); } + // --- Tests for commands added in Python/Go support (must be in both ROUTES and PATTERNS) --- + + #[test] + fn test_classify_pytest_bare() { + match classify_command("pytest tests/") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk pytest") + } + other => panic!("pytest should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_pytest_flags() { + match classify_command("pytest -x tests/unit") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk pytest") + } + other => panic!("pytest -x should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_go_test() { + match classify_command("go test ./...") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk go") + } + other => panic!("go test should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_go_build() { + match classify_command("go build ./...") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk go") + } + other => panic!("go build should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_go_vet() { + match classify_command("go vet ./...") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk go") + } + other => panic!("go vet should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_go_unsupported_subcommand_not_matched() { + // go mod tidy is not in the Only list; should not be classified as rtk go + match classify_command("go mod tidy") { + Classification::Unsupported { .. } | Classification::Ignored => {} + Classification::Supported { rtk_equivalent, .. } => { + panic!("go mod should not match, but got rtk_equivalent={rtk_equivalent}") + } + } + } + + #[test] + fn test_classify_ruff_check() { + match classify_command("ruff check src/") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk ruff") + } + other => panic!("ruff check should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_ruff_format() { + match classify_command("ruff format src/") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk ruff") + } + other => panic!("ruff format should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_pip_list() { + match classify_command("pip list") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk pip") + } + other => panic!("pip list should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_pip_install() { + match classify_command("pip install requests") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk pip") + } + other => panic!("pip install should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_pip3_list() { + match classify_command("pip3 list") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk pip") + } + other => panic!("pip3 list should be Supported, got {other:?}"), + } + } + + #[test] + fn test_classify_golangci_lint() { + match classify_command("golangci-lint run ./...") { + Classification::Supported { rtk_equivalent, .. } => { + assert_eq!(rtk_equivalent, "rtk golangci-lint") + } + other => panic!("golangci-lint should be Supported, got {other:?}"), + } + } + #[test] fn test_patterns_rules_length_match() { assert_eq!( @@ -732,4 +1089,89 @@ mod tests { let cmd = "cat <<'EOF'\nhello && world\nEOF"; assert_eq!(split_command_chain(cmd), vec![cmd]); } + + // --- Route lookup tests --- + + #[test] + fn test_lookup_direct_route() { + let r = lookup("git", "status").unwrap(); + assert_eq!(r.rtk_cmd, "git"); + } + + #[test] + fn test_lookup_git_unknown_subcommand_returns_none() { + assert!(lookup("git", "rebase").is_none()); + assert!(lookup("git", "bisect").is_none()); + } + + #[test] + fn test_lookup_rename_rg_to_grep() { + let r = lookup("rg", "").unwrap(); + assert_eq!(r.rtk_cmd, "grep"); + } + + #[test] + fn test_lookup_rename_grep_to_grep() { + let r = lookup("grep", "-r").unwrap(); + assert_eq!(r.rtk_cmd, "grep"); + } + + #[test] + fn test_lookup_rename_eslint_to_lint() { + let r = lookup("eslint", "src/").unwrap(); + assert_eq!(r.rtk_cmd, "lint"); + } + + #[test] + fn test_lookup_any_subcommand() { + let r = lookup("ls", "-la").unwrap(); + assert_eq!(r.rtk_cmd, "ls"); + let r2 = lookup("ls", "").unwrap(); + assert_eq!(r2.rtk_cmd, "ls"); + } + + #[test] + fn test_lookup_unknown_binary_returns_none() { + assert!(lookup("unknownbinary99", "").is_none()); + // These stay as complex Rust match arms, not in ROUTES + assert!(lookup("vitest", "").is_none()); + assert!(lookup("pnpm", "list").is_none()); + assert!(lookup("npx", "tsc").is_none()); + assert!(lookup("uv", "pip").is_none()); + } + + #[test] + fn test_lookup_docker_subcommand_filter() { + assert!(lookup("docker", "ps").is_some()); + assert!(lookup("docker", "images").is_some()); + assert!(lookup("docker", "build").is_none()); + assert!(lookup("docker", "run").is_none()); + } + + #[test] + fn test_lookup_cargo_subcommand_filter() { + assert!(lookup("cargo", "test").is_some()); + assert!(lookup("cargo", "clippy").is_some()); + assert!(lookup("cargo", "publish").is_none()); + } + + #[test] + fn test_no_duplicate_binaries_in_routes() { + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for route in ROUTES { + for &bin in route.binaries { + assert!( + seen.insert(bin), + "Binary '{bin}' appears in multiple ROUTES entries" + ); + } + } + } + + #[test] + fn test_lookup_is_o1_consistent() { + let r1 = lookup("git", "status"); + let r2 = lookup("git", "status"); + assert_eq!(r1.map(|r| r.rtk_cmd), r2.map(|r| r.rtk_cmd)); + } } diff --git a/src/init.rs b/src/init.rs index 961e4ac..04d1ddb 100644 --- a/src/init.rs +++ b/src/init.rs @@ -471,9 +471,10 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { fn patch_settings_json(hook_path: &Path, mode: PatchMode, verbose: u8) -> Result { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join("settings.json"); - let hook_command = hook_path - .to_str() - .context("Hook path contains invalid UTF-8")?; + // Use binary command instead of .sh file path for PR 1 v2 + // The rtk hook claude command is a compiled Rust binary + let hook_command = "rtk hook claude"; + let _ = hook_path; // Suppress unused parameter warning (still passed for API compatibility) // Read or create settings.json let mut root = if settings_path.exists() { @@ -1093,6 +1094,412 @@ pub fn show_config() -> Result<()> { Ok(()) } +// ============================================================================ +// GEMINI CLI INTEGRATION +// ============================================================================ + +/// Resolve ~/.gemini directory with proper home expansion +fn resolve_gemini_dir() -> Result { + dirs::home_dir() + .map(|h| h.join(".gemini")) + .context("Cannot determine home directory. Is $HOME set?") +} + +/// Shared: patch an instruction file (CLAUDE.md or GEMINI.md) to add @RTK.md reference. +/// Migrates old RTK block if present. Returns true if migration occurred. +fn patch_instruction_file(path: &Path, file_label: &str, verbose: u8) -> Result { + let mut content = if path.exists() { + fs::read_to_string(path)? + } else { + String::new() + }; + + let mut migrated = false; + + // Check for old block and migrate + if content.contains("