diff --git a/.github/workflows/security-check.yml b/.github/workflows/security-check.yml index 75859305a..ea9085c85 100644 --- a/.github/workflows/security-check.yml +++ b/.github/workflows/security-check.yml @@ -118,6 +118,14 @@ jobs: fi echo "" >> $GITHUB_STEP_SUMMARY + - name: Build + run: cargo build + + - name: Smoke Tests + run: | + export PATH=$PATH:$(pwd)/target/debug + ./scripts/test-all.sh + - name: Summary verdict run: | echo "---" >> $GITHUB_STEP_SUMMARY diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9a9bfd0fc..868b74259 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -293,7 +293,7 @@ SHARED utils.rs Helpers N/A ✓ tee.rs Full output recovery N/A ✓ ``` -**Total: 60 modules** (38 command modules + 22 infrastructure modules) +**Total: 61 modules** (38 command modules + 23 infrastructure modules) ### Module Count Breakdown @@ -1488,4 +1488,4 @@ When implementing a new command, consider: **Last Updated**: 2026-02-22 **Architecture Version**: 2.2 -**rtk Version**: 0.28.0 +**rtk Version**: 0.28.2 diff --git a/CLAUDE.md b/CLAUDE.md index 5c31d055d..ab5129619 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ This is a fork with critical fixes for git argument parsing and modern JavaScrip **Verify correct installation:** ```bash -rtk --version # Should show "rtk 0.28.0" (or newer) +rtk --version # Should show "rtk 0.28.2" (or newer) rtk gain # Should show token savings stats (NOT "command not found") ``` diff --git a/INSTALL.md b/INSTALL.md index 98457d09a..3de748b37 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -107,7 +107,26 @@ rtk init -g --no-patch # Print manual instructions instead rtk init --show # Check hook is installed and executable ``` -**Token savings**: ~99.5% reduction (2000 tokens → 10 tokens in context) +### Gemini CLI Setup + +RTK integrates with Gemini CLI via a **BeforeTool hook** that automatically rewrites commands: + +```bash +rtk init -g --gemini +# → Installs ~/.gemini/hooks/rtk-rewrite.sh +# → Creates ~/.gemini/RTK.md (command reference) +# → Creates ~/.gemini/GEMINI.md (usage guide) +# → Patches ~/.gemini/settings.json (registers hook) +``` + +**Verify installation:** +```bash +gemini /hooks # Should show "rtk-rewrite" under BeforeTool +``` + +**Token savings**: 70-90% reduction on git, npm, file operations. + +**How it works**: The hook intercepts `run_shell_command` tool calls and rewrites them to their `rtk` equivalents before execution. Gemini never sees the original command. **What is settings.json?** Claude Code's hook registry. RTK adds a PreToolUse hook that rewrites commands transparently. Without this, Claude won't invoke the hook automatically. diff --git a/README.md b/README.md index a749b505c..9630a8bb6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ rtk filters and compresses command outputs before they reach your LLM context. Single Rust binary, zero dependencies, <10ms overhead. +> **Name collision warning:** There are TWO different "rtk" projects. This is **Rust Token Killer** (`rtk-ai/rtk`). If `rtk gain` doesn't work, you may have installed `reachingforthejack/rtk` (Rust Type Kit) instead. + ## Token Savings (30-min Claude Code Session) | Operation | Frequency | Standard | rtk | Savings | @@ -90,7 +92,7 @@ Download from [releases](https://github.com/rtk-ai/rtk/releases): ### Verify Installation ```bash -rtk --version # Should show "rtk 0.28.0" +rtk --version # Should show "rtk 0.28.2" rtk gain # Should show token savings stats ``` @@ -99,11 +101,17 @@ rtk gain # Should show token savings stats ## Quick Start ```bash -# 1. Install hook for Claude Code (recommended) -rtk init --global -# Follow instructions to register in ~/.claude/settings.json +# 1. Verify installation +rtk gain # Must show token stats, not "command not found" + +# 2. Initialize (RECOMMENDED: hook-first mode) +rtk init --global # For Claude Code +rtk init --global --gemini # For Gemini CLI +# → Installs hook + creates slim RTK.md (10 lines, 99.5% token savings) +# → For Claude: Follow instructions to patch ~/.claude/settings.json +# → For Gemini: Automatically patches ~/.gemini/settings.json -# 2. Restart Claude Code, then test +# 3. Restart Claude Code / Gemini CLI, then test git status # Automatically rewritten to rtk git status ``` diff --git a/hooks/test-rtk-rewrite.sh b/hooks/test-rtk-rewrite.sh index 502023c03..6fc22a23a 100755 --- a/hooks/test-rtk-rewrite.sh +++ b/hooks/test-rtk-rewrite.sh @@ -22,7 +22,7 @@ test_rewrite() { TOTAL=$((TOTAL + 1)) local input_json - input_json=$(jq -n --arg cmd "$input_cmd" '{"tool_name":"Bash","tool_input":{"command":$cmd}}') + input_json=$(jq -n --arg cmd "$input_cmd" '{"tool_name":"run_shell_command","tool_input":{"command":$cmd}}') local output output=$(echo "$input_json" | bash "$HOOK" 2>/dev/null) || true @@ -33,7 +33,7 @@ test_rewrite() { PASS=$((PASS + 1)) else local actual - actual=$(echo "$output" | jq -r '.hookSpecificOutput.updatedInput.command // empty') + actual=$(echo "$output" | jq -r '.hookSpecificOutput.tool_input.command // empty') printf " ${RED}FAIL${RESET} %s\n" "$description" printf " expected: (no rewrite)\n" printf " actual: %s\n" "$actual" @@ -41,7 +41,7 @@ test_rewrite() { fi else local actual - actual=$(echo "$output" | jq -r '.hookSpecificOutput.updatedInput.command // empty' 2>/dev/null) + actual=$(echo "$output" | jq -r '.hookSpecificOutput.tool_input.command // empty' 2>/dev/null) if [ "$actual" = "$expected_cmd" ]; then printf " ${GREEN}PASS${RESET} %s ${DIM}→ %s${RESET}\n" "$description" "$actual" PASS=$((PASS + 1)) @@ -209,17 +209,17 @@ test_rewrite "docker exec -it db psql" \ "docker exec -it db psql" \ "rtk docker exec -it db psql" -test_rewrite "find (NOT rewritten — different arg format)" \ +test_rewrite "find" \ "find . -name '*.ts'" \ - "" + "rtk find . -name '*.ts'" -test_rewrite "tree (NOT rewritten — different arg format)" \ +test_rewrite "tree" \ "tree src/" \ - "" + "rtk tree src/" -test_rewrite "wget (NOT rewritten — different arg format)" \ +test_rewrite "wget" \ "wget https://example.com/file" \ - "" + "rtk wget https://example.com/file" test_rewrite "gh api repos/owner/repo" \ "gh api repos/owner/repo" \ @@ -351,7 +351,7 @@ test_audit_log() { rm -f "$AUDIT_TMPDIR/hook-audit.log" local input_json - input_json=$(jq -n --arg cmd "$input_cmd" '{"tool_name":"Bash","tool_input":{"command":$cmd}}') + input_json=$(jq -n --arg cmd "$input_cmd" '{"tool_name":"run_shell_command","tool_input":{"command":$cmd}}') echo "$input_json" | RTK_HOOK_AUDIT=1 RTK_AUDIT_DIR="$AUDIT_TMPDIR" bash "$HOOK" 2>/dev/null || true if [ ! -f "$AUDIT_TMPDIR/hook-audit.log" ]; then @@ -401,7 +401,7 @@ test_audit_log "audit: rewrite cargo test" \ # Test log format (4 pipe-separated fields) rm -f "$AUDIT_TMPDIR/hook-audit.log" -input_json=$(jq -n --arg cmd "git status" '{"tool_name":"Bash","tool_input":{"command":$cmd}}') +input_json=$(jq -n --arg cmd "git status" '{"tool_name":"run_shell_command","tool_input":{"command":$cmd}}') echo "$input_json" | RTK_HOOK_AUDIT=1 RTK_AUDIT_DIR="$AUDIT_TMPDIR" bash "$HOOK" 2>/dev/null || true TOTAL=$((TOTAL + 1)) log_line=$(cat "$AUDIT_TMPDIR/hook-audit.log" 2>/dev/null || echo "") @@ -417,7 +417,7 @@ fi # Test no log when RTK_HOOK_AUDIT is unset rm -f "$AUDIT_TMPDIR/hook-audit.log" -input_json=$(jq -n --arg cmd "git status" '{"tool_name":"Bash","tool_input":{"command":$cmd}}') +input_json=$(jq -n --arg cmd "git status" '{"tool_name":"run_shell_command","tool_input":{"command":$cmd}}') echo "$input_json" | RTK_AUDIT_DIR="$AUDIT_TMPDIR" bash "$HOOK" 2>/dev/null || true TOTAL=$((TOTAL + 1)) if [ ! -f "$AUDIT_TMPDIR/hook-audit.log" ]; then diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 68a85435f..e9d702be9 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -215,7 +215,7 @@ assert_ok "rtk cargo build" rtk cargo build assert_ok "rtk cargo clippy" rtk cargo clippy # cargo test exits non-zero due to pre-existing failures; check output ignoring exit code output_cargo_test=$(rtk cargo test 2>&1 || true) -if echo "$output_cargo_test" | grep -q "FAILURES\|test result:\|passed"; then +if echo "$output_cargo_test" | grep -qE "FAILURES|test result:|passed"; then PASS=$((PASS + 1)) printf " ${GREEN}PASS${NC} %s\n" "rtk cargo test" else @@ -346,6 +346,30 @@ section "Config & Init" assert_ok "rtk config" rtk config assert_ok "rtk init --show" rtk init --show +section "Init Gemini (global)" + +# Setup temporary home for gemini test +TEST_HOME_GEMINI=$(mktemp -d) +OLD_HOME=$HOME +export HOME=$TEST_HOME_GEMINI + +assert_ok "rtk init -g --gemini" rtk init -g --gemini +if [ -f "$HOME/.gemini/GEMINI.md" ] && [ -f "$HOME/.gemini/settings.json" ]; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} %s\n" "rtk init gemini files exist" +else + FAIL=$((FAIL + 1)) + FAILURES+=("rtk init gemini files exist") + printf " ${RED}FAIL${NC} %s\n" "rtk init gemini files exist" +fi + +# Cleanup +export HOME=$OLD_HOME +rm -rf "$TEST_HOME_GEMINI" + +section "Auto Detect" +bash scripts/test-auto-detect.sh + # ── 22. Wget ───────────────────────────────────────── section "Wget" @@ -426,6 +450,43 @@ else skip_test "rtk golangci-lint" "golangci-lint not installed" fi +# ── 28b. Hook Gemini ────────────────────────────── + +section "Hook Gemini" + +HOOK_OUT=$(echo '{"tool_name":"run_shell_command","tool_input":{"command":"git status"}}' | rtk hook gemini 2>/dev/null) +if echo "$HOOK_OUT" | grep -q '"rtk git status"'; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} %s\n" "rtk hook gemini rewrites git status" +else + FAIL=$((FAIL + 1)) + FAILURES+=("rtk hook gemini rewrites git status") + printf " ${RED}FAIL${NC} %s\n" "rtk hook gemini rewrites git status" + printf " got: %s\n" "$HOOK_OUT" +fi + +HOOK_OUT2=$(echo '{"tool_name":"run_shell_command","tool_input":{"command":"echo hello"}}' | rtk hook gemini 2>/dev/null) +if echo "$HOOK_OUT2" | grep -q '"decision":"allow"' && ! echo "$HOOK_OUT2" | grep -q 'hookSpecificOutput'; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} %s\n" "rtk hook gemini passthrough echo" +else + FAIL=$((FAIL + 1)) + FAILURES+=("rtk hook gemini passthrough echo") + printf " ${RED}FAIL${NC} %s\n" "rtk hook gemini passthrough echo" + printf " got: %s\n" "$HOOK_OUT2" +fi + +HOOK_OUT3=$(echo '{"tool_name":"run_shell_command","tool_input":{"command":"rtk git status"}}' | rtk hook gemini 2>/dev/null) +if echo "$HOOK_OUT3" | grep -q '"decision":"allow"' && ! echo "$HOOK_OUT3" | grep -q 'hookSpecificOutput'; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} %s\n" "rtk hook gemini no double-rewrite" +else + FAIL=$((FAIL + 1)) + FAILURES+=("rtk hook gemini no double-rewrite") + printf " ${RED}FAIL${NC} %s\n" "rtk hook gemini no double-rewrite" + printf " got: %s\n" "$HOOK_OUT3" +fi + # ── 29. Graphite (conditional) ───────────────────── section "Graphite (conditional)" @@ -455,7 +516,11 @@ assert_ok "rtk cc-economics" rtk cc-economics section "Learn" assert_ok "rtk learn --help" rtk learn --help -assert_ok "rtk learn (no sessions)" rtk learn --since 0 2>&1 || true +if [[ -d "$HOME/.claude/projects" ]]; then + assert_ok "rtk learn (no sessions)" rtk learn --since 0 2>&1 || true +else + skip_test "rtk learn (no sessions)" "Claude Code not installed" +fi # ── 32. Rewrite ─────────────────────────────────────── diff --git a/scripts/test-auto-detect.sh b/scripts/test-auto-detect.sh new file mode 100755 index 000000000..c4562316a --- /dev/null +++ b/scripts/test-auto-detect.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -e + +RTK_BIN="./target/debug/rtk" +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' +PASS=0 +FAIL=0 + +HOME_SAV=$HOME +TEST_HOME=$(mktemp -d) +export HOME=$TEST_HOME +trap 'export HOME=$HOME_SAV; rm -rf "$TEST_HOME"' EXIT + +check() { + local label=$1 + local file=$2 + if [ -f "$file" ] || [ -d "$file" ]; then + echo -e "${GREEN}✅ $label${NC}" + PASS=$((PASS + 1)) + else + echo -e "${RED}❌ $label — not found: $file${NC}" + FAIL=$((FAIL + 1)) + fi +} + +# Ensure binary exists +if [ ! -f "$RTK_BIN" ]; then + echo "Building rtk..." + cargo build +fi + +echo "--- Cas 1: Claude only ---" +mkdir -p "$HOME/.claude" +$RTK_BIN init -g --auto-patch > /dev/null +check "Claude: settings.json" "$HOME/.claude/settings.json" +check "Claude: hook" "$HOME/.claude/hooks/rtk-rewrite.sh" +rm -rf "$HOME/.claude" "$HOME/.gemini" + +echo "--- Cas 2: Gemini only ---" +mkdir -p "$HOME/.gemini" +$RTK_BIN init -g --auto-patch > /dev/null +check "Gemini: settings.json" "$HOME/.gemini/settings.json" +check "Gemini: hook" "$HOME/.gemini/hooks/rtk-rewrite.sh" +check "Gemini: GEMINI.md" "$HOME/.gemini/GEMINI.md" +rm -rf "$HOME/.claude" "$HOME/.gemini" + +echo "--- Cas 3: Both ---" +mkdir -p "$HOME/.claude" "$HOME/.gemini" +$RTK_BIN init -g --auto-patch > /dev/null +check "Both: Claude settings.json" "$HOME/.claude/settings.json" +check "Both: Gemini settings.json" "$HOME/.gemini/settings.json" +rm -rf "$HOME/.claude" "$HOME/.gemini" + +echo "--- Cas 4: No CLI ---" +output=$($RTK_BIN init -g 2>&1 || true) +if echo "$output" | grep -q "No CLI detected"; then + echo -e "${GREEN}✅ No CLI: message correct${NC}" + PASS=$((PASS + 1)) +else + echo -e "${RED}❌ No CLI: message manquant${NC}" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ $FAIL -eq 0 ] && echo -e "${GREEN}✨ ALL PASSED ✨${NC}" || exit 1 diff --git a/scripts/test-gemini-init.sh b/scripts/test-gemini-init.sh new file mode 100755 index 000000000..649af8941 --- /dev/null +++ b/scripts/test-gemini-init.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Test Gemini Initialization for RTK + +set -e + +# Setup temporary home to avoid polluting real home +TEST_HOME=$(mktemp -d) +export HOME=$TEST_HOME +echo "Using temporary HOME: $HOME" + +# Define colors +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +# Paths +RTK_BIN="./target/debug/rtk" +GEMINI_DIR="$HOME/.gemini" +GEMINI_MD="$GEMINI_DIR/GEMINI.md" +RTK_MD="$GEMINI_DIR/RTK.md" +HOOK_PATH="$GEMINI_DIR/hooks/rtk-rewrite.sh" + +echo "1. Testing Fresh Gemini Installation..." +$RTK_BIN init -g --gemini > /dev/null + +if [ -f "$GEMINI_MD" ] && [ -f "$RTK_MD" ] && [ -x "$HOOK_PATH" ]; then + echo -e "${GREEN}✅ Fresh installation files created successfully${NC}" +else + echo -e "${RED}❌ Fresh installation failed${NC}" + exit 1 +fi + +if grep -q "rtk git" "$GEMINI_MD"; then + echo -e "${GREEN}✅ GEMINI.md contains correct instructions${NC}" +else + echo -e "${RED}❌ GEMINI.md content is wrong${NC}" + exit 1 +fi + +echo "2. Testing Upsert (Preserve User Content)..." +# Create a file with user content and an old RTK block +cat > "$GEMINI_MD" < +OLD RTK CONTENT + + +End of user file. +EOF + +$RTK_BIN init -g --gemini > /dev/null + +if grep -q "My custom notes here." "$GEMINI_MD" && grep -q "End of user file." "$GEMINI_MD"; then + echo -e "${GREEN}✅ User content preserved during update${NC}" +else + echo -e "${RED}❌ User content LOST during update${NC}" + exit 1 +fi + +if grep -q "rtk git" "$GEMINI_MD" && ! grep -q "OLD RTK CONTENT" "$GEMINI_MD"; then + echo -e "${GREEN}✅ RTK block updated correctly${NC}" +else + echo -e "${RED}❌ RTK block update failed${NC}" + exit 1 +fi + +echo "2b. Testing settings.json Hook Registration..." +if grep -q '"name": "rtk-rewrite"' "$GEMINI_DIR/settings.json"; then + echo -e "${GREEN}✅ Hook registered in settings.json${NC}" +else + echo -e "${RED}❌ Hook NOT registered in settings.json${NC}" + exit 1 +fi + +echo "3. Testing Uninstall..." +$RTK_BIN init -g --uninstall > /dev/null + +if [ ! -f "$GEMINI_MD" ] && [ ! -f "$RTK_MD" ] && [ ! -f "$HOOK_PATH" ]; then + echo -e "${GREEN}✅ Uninstall cleaned up all Gemini files${NC}" +else + echo -e "${RED}❌ Uninstall failed to clean up${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}✨ ALL GEMINI INIT TESTS PASSED ✨${NC}" + +# Cleanup +rm -rf "$TEST_HOME" diff --git a/src/hook_cmd.rs b/src/hook_cmd.rs new file mode 100644 index 000000000..6f315f9fc --- /dev/null +++ b/src/hook_cmd.rs @@ -0,0 +1,920 @@ +use anyhow::{Context, Result}; +use serde_json::Value; +use std::io::{self, Read}; + +/// Run the Gemini CLI BeforeTool hook. +/// Reads JSON from stdin, rewrites shell commands to rtk equivalents, +/// outputs JSON to stdout in Gemini CLI format. +pub fn run_gemini() -> Result<()> { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("Failed to read hook input from stdin")?; + + let json: Value = serde_json::from_str(&input).context("Failed to parse hook input as JSON")?; + + let tool_name = json.get("tool_name").and_then(|v| v.as_str()).unwrap_or(""); + + if tool_name != "run_shell_command" { + print_allow(); + return Ok(()); + } + + let cmd = json + .pointer("/tool_input/command") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if cmd.is_empty() { + print_allow(); + return Ok(()); + } + + // Skip if already using rtk + if cmd.starts_with("rtk ") || cmd.contains("/rtk ") { + print_allow(); + return Ok(()); + } + + // Skip heredocs + if cmd.contains("<<") { + print_allow(); + return Ok(()); + } + + // Strip leading env var assignments for pattern matching + let (env_prefix, match_cmd) = strip_env_prefix(cmd); + + if let Some(rewritten) = try_rewrite(match_cmd) { + let full_rewrite = if env_prefix.is_empty() { + rewritten + } else { + format!("{}{}", env_prefix, rewritten) + }; + print_rewrite(&full_rewrite); + } else { + print_allow(); + } + + Ok(()) +} + +fn print_allow() { + println!(r#"{{"decision":"allow"}}"#); +} + +fn print_rewrite(cmd: &str) { + let output = serde_json::json!({ + "decision": "allow", + "hookSpecificOutput": { + "tool_input": { + "command": cmd + } + } + }); + println!("{}", output); +} + +/// Strip leading env var assignments (e.g., "FOO=bar BAZ=1 git status" -> ("FOO=bar BAZ=1 ", "git status")) +fn strip_env_prefix(cmd: &str) -> (&str, &str) { + let bytes = cmd.as_bytes(); + let mut i = 0; + let len = bytes.len(); + + loop { + // Try to match: [A-Za-z_][A-Za-z0-9_]*=[^ ]* + + let start = i; + + // First char must be letter or underscore + if i >= len || !(bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') { + break; + } + i += 1; + + // Rest of var name: alphanumeric or underscore + while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { + i += 1; + } + + // Must have '=' + if i >= len || bytes[i] != b'=' { + // Not an env var assignment, revert + i = start; + break; + } + i += 1; // skip '=' + + // Value: non-space chars + while i < len && bytes[i] != b' ' { + i += 1; + } + + // Must have at least one space after value + if i >= len || bytes[i] != b' ' { + i = start; + break; + } + + // Skip spaces + while i < len && bytes[i] == b' ' { + i += 1; + } + + // Check if next thing is another env var or a command + // Peek: if next segment looks like VAR=val, continue; else stop + let peek = i; + let mut j = peek; + if j < len && (bytes[j].is_ascii_alphabetic() || bytes[j] == b'_') { + j += 1; + while j < len && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { + j += 1; + } + if j < len && bytes[j] == b'=' { + // Looks like another env var, continue the loop + continue; + } + } + // Next segment is the command, stop here + break; + } + + if i == 0 { + ("", cmd) + } else { + (&cmd[..i], &cmd[i..]) + } +} + +/// Try to rewrite a command to its rtk equivalent. Returns None if no rewrite. +fn try_rewrite(cmd: &str) -> Option { + let first_word = cmd.split_whitespace().next().unwrap_or(""); + let rest = cmd.get(first_word.len()..).unwrap_or("").trim_start(); + + match first_word { + // --- Git --- + "git" => try_rewrite_git(cmd, rest), + + // --- GitHub CLI --- + "gh" => try_rewrite_gh(rest, cmd), + + // --- Cargo --- + "cargo" => try_rewrite_cargo(cmd, rest), + + // --- File operations --- + "cat" => Some(format!("rtk read {}", rest)), + "rg" | "grep" => Some(format!("rtk grep {}", rest)), + "ls" => Some(format!("rtk ls{}", &cmd[2..])), // preserve "ls -la" spacing + "tree" => Some(format!("rtk tree{}", &cmd[4..])), + "find" => Some(format!("rtk find {}", rest)), + "diff" => Some(format!("rtk diff {}", rest)), + "head" => try_rewrite_head(rest), + + // --- JS/TS tooling --- + "vitest" => try_rewrite_vitest(cmd), + "npx" => try_rewrite_npx(rest), + "pnpm" => try_rewrite_pnpm(rest), + "npm" => try_rewrite_npm(rest), + "vue-tsc" => Some(format!( + "rtk tsc{}", + if rest.is_empty() { + String::new() + } else { + format!(" {}", rest) + } + )), + "tsc" => Some(format!( + "rtk tsc{}", + if rest.is_empty() { + String::new() + } else { + format!(" {}", rest) + } + )), + "eslint" => Some(format!( + "rtk lint{}", + if rest.is_empty() { + String::new() + } else { + format!(" {}", rest) + } + )), + "prettier" => Some(format!( + "rtk prettier{}", + if rest.is_empty() { + String::new() + } else { + format!(" {}", rest) + } + )), + "playwright" => Some(format!( + "rtk playwright{}", + if rest.is_empty() { + String::new() + } else { + format!(" {}", rest) + } + )), + "prisma" => Some(format!( + "rtk prisma{}", + if rest.is_empty() { + String::new() + } else { + format!(" {}", rest) + } + )), + + // --- Containers --- + "docker" => try_rewrite_docker(rest, cmd), + "kubectl" => try_rewrite_kubectl(rest, cmd), + + // --- Network --- + "curl" => Some(format!("rtk curl {}", rest)), + "wget" => Some(format!("rtk wget {}", rest)), + + // --- Python tooling --- + "pytest" => Some(format!( + "rtk pytest{}", + if rest.is_empty() { + String::new() + } else { + format!(" {}", rest) + } + )), + "python" => try_rewrite_python(rest), + "ruff" => try_rewrite_ruff(rest), + "pip" => try_rewrite_pip(rest, cmd), + "uv" => try_rewrite_uv(rest), + + // --- Go tooling --- + "go" => try_rewrite_go(rest, cmd), + "golangci-lint" => Some(format!( + "rtk golangci-lint{}", + if rest.is_empty() { + String::new() + } else { + format!(" {}", rest) + } + )), + + _ => None, + } +} + +fn try_rewrite_git(cmd: &str, rest: &str) -> Option { + // Strip git flags like -C, -c, --no-pager to find the actual subcommand + let subcmd = strip_git_flags(rest); + let first = subcmd.split_whitespace().next().unwrap_or(""); + + match first { + "status" | "diff" | "log" | "add" | "commit" | "push" | "pull" | "branch" | "fetch" + | "stash" | "show" => Some(format!("rtk {}", cmd)), + _ => None, + } +} + +fn strip_git_flags(s: &str) -> String { + let mut result = String::new(); + let mut iter = s.split_whitespace().peekable(); + + while let Some(word) = iter.next() { + match word { + "-C" | "-c" => { + // Skip the next arg (value) + iter.next(); + } + w if w.starts_with("--") && w.contains('=') => { + // --key=value flags, skip + } + "--no-pager" | "--no-optional-locks" | "--bare" | "--literal-pathspecs" => { + // Skip known boolean flags + } + _ => { + if !result.is_empty() { + result.push(' '); + } + result.push_str(word); + } + } + } + result +} + +fn try_rewrite_gh(rest: &str, _cmd: &str) -> Option { + let subcmd = rest.split_whitespace().next().unwrap_or(""); + match subcmd { + "pr" | "issue" | "run" | "api" | "release" => Some(format!("rtk gh {}", rest)), + _ => None, + } +} + +fn try_rewrite_cargo(cmd: &str, rest: &str) -> Option { + // Skip toolchain spec like +nightly + let effective = if rest.starts_with('+') { + rest.split_whitespace() + .skip(1) + .collect::>() + .join(" ") + } else { + rest.to_string() + }; + + let subcmd = effective.split_whitespace().next().unwrap_or(""); + match subcmd { + "test" | "build" | "clippy" | "check" | "install" | "fmt" | "nextest" => { + Some(format!("rtk {}", cmd)) + } + _ => None, + } +} + +fn try_rewrite_head(rest: &str) -> Option { + let parts: Vec<&str> = rest.split_whitespace().collect(); + if parts.len() >= 2 { + // head -N file + if let Some(n) = parts[0].strip_prefix('-') { + if n.chars().all(|c| c.is_ascii_digit()) { + let file = parts[1..].join(" "); + return Some(format!("rtk read {} --max-lines {}", file, n)); + } + } + // head --lines=N file + if let Some(n) = parts[0].strip_prefix("--lines=") { + if n.chars().all(|c| c.is_ascii_digit()) { + let file = parts[1..].join(" "); + return Some(format!("rtk read {} --max-lines {}", file, n)); + } + } + } + None +} + +fn try_rewrite_vitest(cmd: &str) -> Option { + // vitest -> rtk vitest run + // vitest run -> rtk vitest run + // vitest run --reporter -> rtk vitest run --reporter + let rest = cmd.strip_prefix("vitest").unwrap_or("").trim_start(); + if rest.is_empty() { + Some("rtk vitest run".to_string()) + } else if rest.starts_with("run") { + Some(format!("rtk vitest {}", rest)) + } else { + Some(format!("rtk vitest run {}", rest)) + } +} + +fn try_rewrite_npx(rest: &str) -> Option { + let tool = rest.split_whitespace().next().unwrap_or(""); + let tool_rest = rest.get(tool.len()..).unwrap_or("").trim_start(); + + match tool { + "vitest" => { + if tool_rest.is_empty() { + Some("rtk vitest run".to_string()) + } else if tool_rest.starts_with("run") { + Some(format!("rtk vitest {}", tool_rest)) + } else { + Some(format!("rtk vitest run {}", tool_rest)) + } + } + "vue-tsc" | "tsc" => Some(format!( + "rtk tsc{}", + if tool_rest.is_empty() { + String::new() + } else { + format!(" {}", tool_rest) + } + )), + "eslint" => Some(format!( + "rtk lint{}", + if tool_rest.is_empty() { + String::new() + } else { + format!(" {}", tool_rest) + } + )), + "prettier" => Some(format!( + "rtk prettier{}", + if tool_rest.is_empty() { + String::new() + } else { + format!(" {}", tool_rest) + } + )), + "playwright" => Some(format!( + "rtk playwright{}", + if tool_rest.is_empty() { + String::new() + } else { + format!(" {}", tool_rest) + } + )), + "prisma" => Some(format!( + "rtk prisma{}", + if tool_rest.is_empty() { + String::new() + } else { + format!(" {}", tool_rest) + } + )), + _ => None, + } +} + +fn try_rewrite_pnpm(rest: &str) -> Option { + let subcmd = rest.split_whitespace().next().unwrap_or(""); + let sub_rest = rest.get(subcmd.len()..).unwrap_or("").trim_start(); + + match subcmd { + "vitest" => { + if sub_rest.is_empty() { + Some("rtk vitest run".to_string()) + } else if sub_rest.starts_with("run") { + Some(format!("rtk vitest {}", sub_rest)) + } else { + Some(format!("rtk vitest run {}", sub_rest)) + } + } + "test" => Some(format!( + "rtk vitest run{}", + if sub_rest.is_empty() { + String::new() + } else { + format!(" {}", sub_rest) + } + )), + "tsc" => Some(format!( + "rtk tsc{}", + if sub_rest.is_empty() { + String::new() + } else { + format!(" {}", sub_rest) + } + )), + "lint" => Some(format!( + "rtk lint{}", + if sub_rest.is_empty() { + String::new() + } else { + format!(" {}", sub_rest) + } + )), + "playwright" => Some(format!( + "rtk playwright{}", + if sub_rest.is_empty() { + String::new() + } else { + format!(" {}", sub_rest) + } + )), + "list" | "ls" | "outdated" => Some(format!("rtk pnpm {}", rest)), + _ => None, + } +} + +fn try_rewrite_npm(rest: &str) -> Option { + let subcmd = rest.split_whitespace().next().unwrap_or(""); + let sub_rest = rest.get(subcmd.len()..).unwrap_or("").trim_start(); + + match subcmd { + "test" => Some(format!( + "rtk npm test{}", + if sub_rest.is_empty() { + String::new() + } else { + format!(" {}", sub_rest) + } + )), + "run" => Some(format!("rtk npm {}", sub_rest)), + _ => None, + } +} + +fn try_rewrite_docker(rest: &str, cmd: &str) -> Option { + let subcmd = rest.split_whitespace().next().unwrap_or(""); + match subcmd { + "compose" => Some(format!("rtk {}", cmd)), + "ps" | "images" | "logs" | "run" | "build" | "exec" => Some(format!("rtk {}", cmd)), + _ => None, + } +} + +fn try_rewrite_kubectl(rest: &str, cmd: &str) -> Option { + // Strip kubectl flags to find actual subcommand + let subcmd = strip_kubectl_flags(rest); + let first = subcmd.split_whitespace().next().unwrap_or(""); + match first { + "get" | "logs" | "describe" | "apply" => Some(format!("rtk {}", cmd)), + _ => None, + } +} + +fn strip_kubectl_flags(s: &str) -> String { + let mut result = String::new(); + let mut iter = s.split_whitespace().peekable(); + + while let Some(word) = iter.next() { + match word { + "--context" | "--kubeconfig" | "--namespace" | "-n" => { + iter.next(); // skip value + } + w if w.starts_with("--") && w.contains('=') => { + // skip --key=value + } + _ => { + if !result.is_empty() { + result.push(' '); + } + result.push_str(word); + } + } + } + result +} + +fn try_rewrite_python(rest: &str) -> Option { + // python -m pytest ... -> rtk pytest ... + let parts: Vec<&str> = rest.splitn(3, ' ').collect(); + if parts.len() >= 2 && parts[0] == "-m" && parts[1] == "pytest" { + let pytest_rest = if parts.len() > 2 { parts[2] } else { "" }; + Some(format!( + "rtk pytest{}", + if pytest_rest.is_empty() { + String::new() + } else { + format!(" {}", pytest_rest) + } + )) + } else { + None + } +} + +fn try_rewrite_ruff(rest: &str) -> Option { + let subcmd = rest.split_whitespace().next().unwrap_or(""); + match subcmd { + "check" | "format" => Some(format!("rtk ruff {}", rest)), + _ => None, + } +} + +fn try_rewrite_pip(rest: &str, _cmd: &str) -> Option { + let subcmd = rest.split_whitespace().next().unwrap_or(""); + match subcmd { + "list" | "outdated" | "install" | "show" => Some(format!("rtk pip {}", rest)), + _ => None, + } +} + +fn try_rewrite_uv(rest: &str) -> Option { + // uv pip list -> rtk pip list + if rest.starts_with("pip ") { + let pip_rest = &rest[4..]; + let subcmd = pip_rest.split_whitespace().next().unwrap_or(""); + match subcmd { + "list" | "outdated" | "install" | "show" => Some(format!("rtk pip {}", pip_rest)), + _ => None, + } + } else { + None + } +} + +fn try_rewrite_go(rest: &str, cmd: &str) -> Option { + let subcmd = rest.split_whitespace().next().unwrap_or(""); + match subcmd { + "test" | "build" | "vet" => Some(format!("rtk {}", cmd)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- strip_env_prefix --- + + #[test] + fn test_strip_env_prefix_none() { + let (prefix, cmd) = strip_env_prefix("git status"); + assert_eq!(prefix, ""); + assert_eq!(cmd, "git status"); + } + + #[test] + fn test_strip_env_prefix_single() { + let (prefix, cmd) = strip_env_prefix("GIT_PAGER=cat git status"); + assert_eq!(prefix, "GIT_PAGER=cat "); + assert_eq!(cmd, "git status"); + } + + #[test] + fn test_strip_env_prefix_multi() { + let (prefix, cmd) = strip_env_prefix("NODE_ENV=test CI=1 npx vitest run"); + assert_eq!(prefix, "NODE_ENV=test CI=1 "); + assert_eq!(cmd, "npx vitest run"); + } + + // --- try_rewrite --- + + #[test] + fn test_git_status() { + assert_eq!(try_rewrite("git status"), Some("rtk git status".into())); + } + + #[test] + fn test_git_log_flags() { + assert_eq!( + try_rewrite("git log --oneline -10"), + Some("rtk git log --oneline -10".into()) + ); + } + + #[test] + fn test_git_no_pager() { + assert_eq!( + try_rewrite("git --no-pager log"), + Some("rtk git --no-pager log".into()) + ); + } + + #[test] + fn test_git_diff() { + assert_eq!( + try_rewrite("git diff HEAD"), + Some("rtk git diff HEAD".into()) + ); + } + + #[test] + fn test_git_unknown_subcmd() { + assert_eq!(try_rewrite("git rebase main"), None); + } + + #[test] + fn test_gh_pr() { + assert_eq!(try_rewrite("gh pr list"), Some("rtk gh pr list".into())); + } + + #[test] + fn test_gh_unknown() { + assert_eq!(try_rewrite("gh auth login"), None); + } + + #[test] + fn test_cargo_test() { + assert_eq!(try_rewrite("cargo test"), Some("rtk cargo test".into())); + } + + #[test] + fn test_cargo_clippy_flags() { + assert_eq!( + try_rewrite("cargo clippy --all-targets"), + Some("rtk cargo clippy --all-targets".into()) + ); + } + + #[test] + fn test_cat() { + assert_eq!( + try_rewrite("cat package.json"), + Some("rtk read package.json".into()) + ); + } + + #[test] + fn test_grep() { + assert_eq!( + try_rewrite("grep -rn pattern src/"), + Some("rtk grep -rn pattern src/".into()) + ); + } + + #[test] + fn test_rg() { + assert_eq!( + try_rewrite("rg pattern src/"), + Some("rtk grep pattern src/".into()) + ); + } + + #[test] + fn test_ls() { + assert_eq!(try_rewrite("ls -la"), Some("rtk ls -la".into())); + } + + #[test] + fn test_head_dash_n() { + assert_eq!( + try_rewrite("head -20 file.txt"), + Some("rtk read file.txt --max-lines 20".into()) + ); + } + + #[test] + fn test_head_lines_eq() { + assert_eq!( + try_rewrite("head --lines=10 file.txt"), + Some("rtk read file.txt --max-lines 10".into()) + ); + } + + #[test] + fn test_vitest_bare() { + assert_eq!(try_rewrite("vitest"), Some("rtk vitest run".into())); + } + + #[test] + fn test_vitest_run_no_double() { + assert_eq!(try_rewrite("vitest run"), Some("rtk vitest run".into())); + } + + #[test] + fn test_npx_playwright() { + assert_eq!( + try_rewrite("npx playwright test"), + Some("rtk playwright test".into()) + ); + } + + #[test] + fn test_npx_vitest_run() { + assert_eq!(try_rewrite("npx vitest run"), Some("rtk vitest run".into())); + } + + #[test] + fn test_pnpm_test() { + assert_eq!(try_rewrite("pnpm test"), Some("rtk vitest run".into())); + } + + #[test] + fn test_pnpm_list() { + assert_eq!(try_rewrite("pnpm list"), Some("rtk pnpm list".into())); + } + + #[test] + fn test_npm_test() { + assert_eq!(try_rewrite("npm test"), Some("rtk npm test".into())); + } + + #[test] + fn test_npm_run() { + assert_eq!( + try_rewrite("npm run test:e2e"), + Some("rtk npm test:e2e".into()) + ); + } + + #[test] + fn test_docker_ps() { + assert_eq!(try_rewrite("docker ps"), Some("rtk docker ps".into())); + } + + #[test] + fn test_docker_compose() { + assert_eq!( + try_rewrite("docker compose up -d"), + Some("rtk docker compose up -d".into()) + ); + } + + #[test] + fn test_kubectl_get() { + assert_eq!( + try_rewrite("kubectl get pods"), + Some("rtk kubectl get pods".into()) + ); + } + + #[test] + fn test_curl() { + assert_eq!( + try_rewrite("curl -s https://example.com"), + Some("rtk curl -s https://example.com".into()) + ); + } + + #[test] + fn test_pytest() { + assert_eq!(try_rewrite("pytest"), Some("rtk pytest".into())); + } + + #[test] + fn test_python_m_pytest() { + assert_eq!( + try_rewrite("python -m pytest -v"), + Some("rtk pytest -v".into()) + ); + } + + #[test] + fn test_ruff_check() { + assert_eq!(try_rewrite("ruff check ."), Some("rtk ruff check .".into())); + } + + #[test] + fn test_pip_list() { + assert_eq!(try_rewrite("pip list"), Some("rtk pip list".into())); + } + + #[test] + fn test_uv_pip_install() { + assert_eq!( + try_rewrite("uv pip install flask"), + Some("rtk pip install flask".into()) + ); + } + + #[test] + fn test_go_test() { + assert_eq!( + try_rewrite("go test ./..."), + Some("rtk go test ./...".into()) + ); + } + + #[test] + fn test_golangci_lint() { + assert_eq!( + try_rewrite("golangci-lint run"), + Some("rtk golangci-lint run".into()) + ); + } + + #[test] + fn test_echo_no_rewrite() { + assert_eq!(try_rewrite("echo hello"), None); + } + + #[test] + fn test_cd_no_rewrite() { + assert_eq!(try_rewrite("cd /tmp"), None); + } + + #[test] + fn test_node_no_rewrite() { + assert_eq!(try_rewrite("node -e 'console.log(1)'"), None); + } + + // --- Full JSON flow --- + + #[test] + fn test_env_prefix_with_rewrite() { + let cmd = "TEST_SESSION_ID=2 npx playwright test --config=foo"; + let (prefix, match_cmd) = strip_env_prefix(cmd); + let rewritten = try_rewrite(match_cmd).unwrap(); + let full = format!("{}{}", prefix, rewritten); + assert_eq!(full, "TEST_SESSION_ID=2 rtk playwright test --config=foo"); + } + + #[test] + fn test_vue_tsc() { + assert_eq!( + try_rewrite("vue-tsc --noEmit"), + Some("rtk tsc --noEmit".into()) + ); + } + + #[test] + fn test_npx_vue_tsc() { + assert_eq!( + try_rewrite("npx vue-tsc --noEmit"), + Some("rtk tsc --noEmit".into()) + ); + } + + #[test] + fn test_pnpm_tsc() { + assert_eq!(try_rewrite("pnpm tsc"), Some("rtk tsc".into())); + } + + #[test] + fn test_pnpm_lint() { + assert_eq!(try_rewrite("pnpm lint"), Some("rtk lint".into())); + } + + #[test] + fn test_eslint() { + assert_eq!(try_rewrite("eslint src/"), Some("rtk lint src/".into())); + } + + #[test] + fn test_tree() { + assert_eq!(try_rewrite("tree src/"), Some("rtk tree src/".into())); + } + + #[test] + fn test_find() { + assert_eq!( + try_rewrite("find . -name '*.ts'"), + Some("rtk find . -name '*.ts'".into()) + ); + } + + #[test] + fn test_wget() { + assert_eq!( + try_rewrite("wget https://example.com/file"), + Some("rtk wget https://example.com/file".into()) + ); + } +} diff --git a/src/init.rs b/src/init.rs index 63e0f0c1a..d209cab03 100644 --- a/src/init.rs +++ b/src/init.rs @@ -9,6 +9,10 @@ use crate::integrity; // Embedded hook script (guards before set -euo pipefail) const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); +const REWRITE_GEMINI_HOOK: &str = "#!/usr/bin/env bash\n\ +# RTK Gemini CLI hook - delegates to rtk hook gemini (Rust, no jq needed)\n\ +exec rtk hook gemini\n"; + // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md"); @@ -196,20 +200,117 @@ Overall average: **60-90% token reduction** on common development operations. "##; +const GEMINI_MD: &str = r##" +# RTK Usage Guide for Gemini CLI + +## What is RTK? +RTK (Rust Token Killer) reduces AI token consumption by 70-90% on command outputs. + +## How it works with Gemini CLI +RTK is integrated via a **BeforeTool hook** that automatically rewrites shell commands. +Configuration: `~/.gemini/settings.json` → `hooks.BeforeTool` + +## Command Mappings (Auto-Rewrite) +When you (or Gemini) run these commands, they're automatically rewritten: + +### Git +- `git status/diff/log/add/commit/push/pull/branch/fetch/stash` → `rtk git ` + +### Files +- `cat ` → `rtk read ` +- `grep/rg ` → `rtk grep ` +- `ls` → `rtk ls` + +### JavaScript/TypeScript +- `npm run