diff --git a/.claude/hooks/rtk-rewrite.sh b/.claude/hooks/rtk-rewrite.sh index 5c8bad02..e1f8d1e5 100755 --- a/.claude/hooks/rtk-rewrite.sh +++ b/.claude/hooks/rtk-rewrite.sh @@ -1,7 +1,9 @@ #!/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. +# Transparently rewrites raw commands to their RTK equivalents. +# Uses `rtk rewrite` as single source of truth — no duplicate mapping logic here. +# +# To add support for new commands, update src/discover/registry.rs (PATTERNS + RULES). # --- Audit logging (opt-in via RTK_HOOK_AUDIT=1) --- _rtk_audit_log() { @@ -30,186 +32,32 @@ 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\ *) _rtk_audit_log "skip:already_rtk" "$CMD"; exit 0 ;; -esac - -# Skip commands with heredocs, variable assignments as the whole command, etc. -case "$FIRST_CMD" in +# Skip heredocs (rtk rewrite also skips them, but bail early) +case "$CMD" in *'<<'*) _rtk_audit_log "skip:heredoc" "$CMD"; 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:]]+status([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git status/rtk git status/')" -elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+diff([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git diff/rtk git diff/')" -elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+log([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git log/rtk git log/')" -elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+add([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git add/rtk git add/')" -elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+commit([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git commit/rtk git commit/')" -elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+push([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git push/rtk git push/')" -elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+pull([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git pull/rtk git pull/')" -elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+branch([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git branch/rtk git branch/')" -elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+fetch([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git fetch/rtk git fetch/')" -elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+stash([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git stash/rtk git stash/')" -elif echo "$MATCH_CMD" | grep -qE '^git[[:space:]]+show([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^git show/rtk git show/')" - -# --- 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:]]+test([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo test/rtk cargo test/')" -elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+build([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo build/rtk cargo build/')" -elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+clippy([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo clippy/rtk cargo clippy/')" -elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+check([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo check/rtk cargo check/')" -elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+install([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo install/rtk cargo install/')" -elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+nextest([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo nextest/rtk cargo nextest/')" -elif echo "$MATCH_CMD" | grep -qE '^cargo[[:space:]]+fmt([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^cargo fmt/rtk cargo fmt/')" - -# --- 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:]]+compose([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^docker /rtk docker /')" -elif echo "$MATCH_CMD" | grep -qE '^docker[[:space:]]+(ps|images|logs|run|build|exec)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^docker /rtk docker /')" -elif echo "$MATCH_CMD" | grep -qE '^kubectl[[:space:]]+(get|logs|describe|apply)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^kubectl /rtk kubectl /')" - -# --- 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 +# Rewrite via rtk — single source of truth for all command mappings. +# Exit 1 = no RTK equivalent, pass through unchanged. +# Exit 0 = rewritten command (or already RTK, identical output). +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || { _rtk_audit_log "skip:no_match" "$CMD" exit 0 +} + +# If output is identical, command was already using RTK — nothing to do. +if [ "$CMD" = "$REWRITTEN" ]; then + _rtk_audit_log "skip:already_rtk" "$CMD" + exit 0 fi _rtk_audit_log "rewrite" "$CMD" "$REWRITTEN" -# Build the updated tool_input with all original fields preserved, only command changed +# 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 +# Output the rewrite instruction in Claude Code hook format. jq -n \ --argjson updated "$UPDATED_INPUT" \ '{ diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml index 27879bc0..2872ab39 100644 --- a/.github/workflows/validate-docs.yml +++ b/.github/workflows/validate-docs.yml @@ -60,10 +60,18 @@ jobs: exit 1 fi - for cmd in ruff pytest pip "go " golangci; do - if ! grep -q "$cmd" "$HOOK_FILE"; then - echo "❌ Hook missing rewrite for: $cmd" + # Since PR #241, the hook delegates to `rtk rewrite` (single source of truth). + # Command coverage is now in src/discover/registry.rs, not the hook bash script. + if ! grep -q "rtk rewrite" "$HOOK_FILE"; then + echo "❌ Hook does not delegate to 'rtk rewrite'" + exit 1 + fi + + # Verify all Python/Go commands are covered in the registry + for cmd in ruff pytest pip golangci; do + if ! grep -q "\"$cmd\"" src/discover/registry.rs; then + echo "❌ Registry missing rewrite_prefixes for: $cmd" exit 1 fi done - echo "✅ Hook rewrites present for Python/Go commands" + echo "✅ Hook delegates to rtk rewrite, registry covers all Python/Go commands" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7d63d3c8..1515c4d9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -288,7 +288,7 @@ SHARED utils.rs Helpers N/A ✓ tee.rs Full output recovery N/A ✓ ``` -**Total: 50 modules** (32 command modules + 18 infrastructure modules) +**Total: 51 modules** (33 command modules + 18 infrastructure modules) ### Module Count Breakdown diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 7ef375cd..c7aa39c7 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -4,6 +4,8 @@ use regex::{Regex, RegexSet}; /// A rule mapping a shell command pattern to its RTK equivalent. struct RtkRule { rtk_cmd: &'static str, + /// Original command prefixes to replace with rtk_cmd (longest first for correct matching). + rewrite_prefixes: &'static [&'static str], category: &'static str, savings_pct: f64, subcmd_savings: &'static [(&'static str, f64)], @@ -75,6 +77,7 @@ const PATTERNS: &[&str] = &[ const RULES: &[RtkRule] = &[ RtkRule { rtk_cmd: "rtk git", + rewrite_prefixes: &["git"], category: "Git", savings_pct: 70.0, subcmd_savings: &[ @@ -87,6 +90,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk gh", + rewrite_prefixes: &["gh"], category: "GitHub", savings_pct: 82.0, subcmd_savings: &[("pr", 87.0), ("run", 82.0), ("issue", 80.0)], @@ -94,6 +98,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk cargo", + rewrite_prefixes: &["cargo"], category: "Cargo", savings_pct: 80.0, subcmd_savings: &[("test", 90.0), ("check", 80.0)], @@ -101,6 +106,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk pnpm", + rewrite_prefixes: &["pnpm"], category: "PackageManager", savings_pct: 80.0, subcmd_savings: &[], @@ -108,6 +114,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk npm", + rewrite_prefixes: &["npm"], category: "PackageManager", savings_pct: 70.0, subcmd_savings: &[], @@ -115,6 +122,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk npx", + rewrite_prefixes: &["npx"], category: "PackageManager", savings_pct: 70.0, subcmd_savings: &[], @@ -122,6 +130,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk read", + rewrite_prefixes: &["cat", "head", "tail"], category: "Files", savings_pct: 60.0, subcmd_savings: &[], @@ -129,6 +138,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk grep", + rewrite_prefixes: &["rg", "grep"], category: "Files", savings_pct: 75.0, subcmd_savings: &[], @@ -136,6 +146,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk ls", + rewrite_prefixes: &["ls"], category: "Files", savings_pct: 65.0, subcmd_savings: &[], @@ -143,13 +154,16 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk find", + rewrite_prefixes: &["find"], category: "Files", savings_pct: 70.0, subcmd_savings: &[], subcmd_status: &[], }, RtkRule { + // Longest prefixes first for correct matching rtk_cmd: "rtk tsc", + rewrite_prefixes: &["pnpm tsc", "npx tsc", "tsc"], category: "Build", savings_pct: 83.0, subcmd_savings: &[], @@ -157,6 +171,14 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk lint", + rewrite_prefixes: &[ + "npx eslint", + "pnpm lint", + "npx biome", + "eslint", + "biome", + "lint", + ], category: "Build", savings_pct: 84.0, subcmd_savings: &[], @@ -164,13 +186,16 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk prettier", + rewrite_prefixes: &["npx prettier", "pnpm prettier", "prettier"], category: "Build", savings_pct: 70.0, subcmd_savings: &[], subcmd_status: &[], }, RtkRule { + // "next build" is stripped to "rtk next" — the build subcommand is internal rtk_cmd: "rtk next", + rewrite_prefixes: &["npx next build", "pnpm next build", "next build"], category: "Build", savings_pct: 87.0, subcmd_savings: &[], @@ -178,6 +203,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk vitest", + rewrite_prefixes: &["pnpm vitest", "npx vitest", "vitest", "jest"], category: "Tests", savings_pct: 99.0, subcmd_savings: &[], @@ -185,6 +211,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk playwright", + rewrite_prefixes: &["npx playwright", "pnpm playwright", "playwright"], category: "Tests", savings_pct: 94.0, subcmd_savings: &[], @@ -192,6 +219,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk prisma", + rewrite_prefixes: &["npx prisma", "pnpm prisma", "prisma"], category: "Build", savings_pct: 88.0, subcmd_savings: &[], @@ -199,6 +227,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk docker", + rewrite_prefixes: &["docker"], category: "Infra", savings_pct: 85.0, subcmd_savings: &[], @@ -206,6 +235,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk kubectl", + rewrite_prefixes: &["kubectl"], category: "Infra", savings_pct: 85.0, subcmd_savings: &[], @@ -213,6 +243,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk curl", + rewrite_prefixes: &["curl"], category: "Network", savings_pct: 70.0, subcmd_savings: &[], @@ -220,6 +251,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk wget", + rewrite_prefixes: &["wget"], category: "Network", savings_pct: 65.0, subcmd_savings: &[], @@ -486,6 +518,202 @@ pub fn split_command_chain(cmd: &str) -> Vec<&str> { results } +/// Rewrite a raw command to its RTK equivalent. +/// +/// Returns `Some(rewritten)` if the command has an RTK equivalent or is already RTK. +/// Returns `None` if the command is unsupported or ignored (hook should pass through). +/// +/// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently. +/// For pipes (`|`), only rewrites the first command (the filter stays raw). +pub fn rewrite_command(cmd: &str) -> Option { + let trimmed = cmd.trim(); + if trimmed.is_empty() { + return None; + } + + // Heredoc or arithmetic expansion — unsafe to split/rewrite + if trimmed.contains("<<") || trimmed.contains("$((") { + return None; + } + + // Simple (non-compound) already-RTK command — return as-is. + // For compound commands that start with "rtk" (e.g. "rtk git add . && cargo test"), + // fall through to rewrite_compound so the remaining segments get rewritten. + let has_compound = trimmed.contains("&&") + || trimmed.contains("||") + || trimmed.contains(';') + || trimmed.contains('|'); + if !has_compound && (trimmed.starts_with("rtk ") || trimmed == "rtk") { + return Some(trimmed.to_string()); + } + + rewrite_compound(trimmed) +} + +/// Rewrite a compound command (with `&&`, `||`, `;`, `|`) by rewriting each segment. +fn rewrite_compound(cmd: &str) -> Option { + let bytes = cmd.as_bytes(); + let len = bytes.len(); + let mut result = String::with_capacity(len + 32); + let mut any_changed = false; + let mut seg_start = 0; + let mut i = 0; + let mut in_single = false; + let mut in_double = false; + + while i < len { + let b = bytes[i]; + match b { + b'\'' if !in_double => { + in_single = !in_single; + i += 1; + } + b'"' if !in_single => { + in_double = !in_double; + i += 1; + } + b'|' if !in_single && !in_double => { + if i + 1 < len && bytes[i + 1] == b'|' { + // `||` operator — rewrite left, continue + let seg = cmd[seg_start..i].trim(); + let rewritten = rewrite_segment(seg).unwrap_or_else(|| seg.to_string()); + if rewritten != seg { + any_changed = true; + } + result.push_str(&rewritten); + result.push_str(" || "); + i += 2; + while i < len && bytes[i] == b' ' { + i += 1; + } + seg_start = i; + } else { + // `|` pipe — rewrite first segment only, pass through the rest unchanged + let seg = cmd[seg_start..i].trim(); + let rewritten = rewrite_segment(seg).unwrap_or_else(|| seg.to_string()); + if rewritten != seg { + any_changed = true; + } + result.push_str(&rewritten); + // Preserve the space before the pipe that was lost by trim() + result.push(' '); + result.push_str(cmd[i..].trim_start()); + return if any_changed { Some(result) } else { None }; + } + } + b'&' if !in_single && !in_double && i + 1 < len && bytes[i + 1] == b'&' => { + // `&&` operator — rewrite left, continue + let seg = cmd[seg_start..i].trim(); + let rewritten = rewrite_segment(seg).unwrap_or_else(|| seg.to_string()); + if rewritten != seg { + any_changed = true; + } + result.push_str(&rewritten); + result.push_str(" && "); + i += 2; + while i < len && bytes[i] == b' ' { + i += 1; + } + seg_start = i; + } + b';' if !in_single && !in_double => { + // `;` separator + let seg = cmd[seg_start..i].trim(); + let rewritten = rewrite_segment(seg).unwrap_or_else(|| seg.to_string()); + if rewritten != seg { + any_changed = true; + } + result.push_str(&rewritten); + result.push(';'); + i += 1; + while i < len && bytes[i] == b' ' { + i += 1; + } + if i < len { + result.push(' '); + } + seg_start = i; + } + _ => { + i += 1; + } + } + } + + // Last (or only) segment + let seg = cmd[seg_start..len].trim(); + let rewritten = rewrite_segment(seg).unwrap_or_else(|| seg.to_string()); + if rewritten != seg { + any_changed = true; + } + result.push_str(&rewritten); + + if any_changed { + Some(result) + } else { + None + } +} + +/// Rewrite a single (non-compound) command segment. +/// Returns `Some(rewritten)` if matched (including already-RTK pass-through). +/// Returns `None` if no match (caller uses original segment). +fn rewrite_segment(seg: &str) -> Option { + let trimmed = seg.trim(); + if trimmed.is_empty() { + return None; + } + + // Already RTK — pass through unchanged + if trimmed.starts_with("rtk ") || trimmed == "rtk" { + return Some(trimmed.to_string()); + } + + // Use classify_command for correct ignore/prefix handling + let rtk_equivalent = match classify_command(trimmed) { + Classification::Supported { rtk_equivalent, .. } => rtk_equivalent, + _ => return None, + }; + + // Find the matching rule (rtk_cmd values are unique across all rules) + let rule = RULES.iter().find(|r| r.rtk_cmd == rtk_equivalent)?; + + // Extract env prefix (sudo, env VAR=val, etc.) + let stripped_cow = ENV_PREFIX.replace(trimmed, ""); + let env_prefix_len = trimmed.len() - stripped_cow.len(); + let env_prefix = &trimmed[..env_prefix_len]; + let cmd_clean = stripped_cow.trim(); + + // Try each rewrite prefix (longest first) with word-boundary check + for &prefix in rule.rewrite_prefixes { + if let Some(rest) = strip_word_prefix(cmd_clean, prefix) { + let rewritten = if rest.is_empty() { + format!("{}{}", env_prefix, rule.rtk_cmd) + } else { + format!("{}{} {}", env_prefix, rule.rtk_cmd, rest) + }; + return Some(rewritten); + } + } + + None +} + +/// Strip a command prefix with word-boundary check. +/// Returns the remainder of the command after the prefix, or `None` if no match. +fn strip_word_prefix<'a>(cmd: &'a str, prefix: &str) -> Option<&'a str> { + if cmd == prefix { + Some("") + } else if cmd.len() > prefix.len() + && cmd.starts_with(prefix) + && cmd.as_bytes()[prefix.len()] == b' ' + { + Some(cmd[prefix.len() + 1..].trim_start()) + } else { + None + } +} + #[cfg(test)] mod tests { use super::super::report::RtkStatus; @@ -732,4 +960,143 @@ mod tests { let cmd = "cat <<'EOF'\nhello && world\nEOF"; assert_eq!(split_command_chain(cmd), vec![cmd]); } + + // --- rewrite_command tests --- + + #[test] + fn test_rewrite_git_status() { + assert_eq!(rewrite_command("git status"), Some("rtk git status".into())); + } + + #[test] + fn test_rewrite_git_log() { + assert_eq!( + rewrite_command("git log -10"), + Some("rtk git log -10".into()) + ); + } + + #[test] + fn test_rewrite_cargo_test() { + assert_eq!(rewrite_command("cargo test"), Some("rtk cargo test".into())); + } + + #[test] + fn test_rewrite_compound_and() { + assert_eq!( + rewrite_command("git add . && cargo test"), + Some("rtk git add . && rtk cargo test".into()) + ); + } + + #[test] + fn test_rewrite_compound_three_segments() { + assert_eq!( + rewrite_command("cargo fmt --all && cargo clippy --all-targets && cargo test"), + Some("rtk cargo fmt --all && rtk cargo clippy --all-targets && rtk cargo test".into()) + ); + } + + #[test] + fn test_rewrite_already_rtk() { + assert_eq!( + rewrite_command("rtk git status"), + Some("rtk git status".into()) + ); + } + + #[test] + fn test_rewrite_unsupported_returns_none() { + assert_eq!(rewrite_command("terraform plan"), None); + } + + #[test] + fn test_rewrite_ignored_cd() { + assert_eq!(rewrite_command("cd /tmp"), None); + } + + #[test] + fn test_rewrite_with_env_prefix() { + assert_eq!( + rewrite_command("GIT_SSH_COMMAND=ssh git push"), + Some("GIT_SSH_COMMAND=ssh rtk git push".into()) + ); + } + + #[test] + fn test_rewrite_npx_tsc() { + assert_eq!( + rewrite_command("npx tsc --noEmit"), + Some("rtk tsc --noEmit".into()) + ); + } + + #[test] + fn test_rewrite_pnpm_tsc() { + assert_eq!( + rewrite_command("pnpm tsc --noEmit"), + Some("rtk tsc --noEmit".into()) + ); + } + + #[test] + fn test_rewrite_cat_file() { + assert_eq!( + rewrite_command("cat src/main.rs"), + Some("rtk read src/main.rs".into()) + ); + } + + #[test] + fn test_rewrite_rg_pattern() { + assert_eq!( + rewrite_command("rg \"fn main\""), + Some("rtk grep \"fn main\"".into()) + ); + } + + #[test] + fn test_rewrite_npx_playwright() { + assert_eq!( + rewrite_command("npx playwright test"), + Some("rtk playwright test".into()) + ); + } + + #[test] + fn test_rewrite_next_build() { + assert_eq!( + rewrite_command("next build --turbo"), + Some("rtk next --turbo".into()) + ); + } + + #[test] + fn test_rewrite_pipe_first_only() { + // After a pipe, the filter command stays raw + assert_eq!( + rewrite_command("git log -10 | grep feat"), + Some("rtk git log -10 | grep feat".into()) + ); + } + + #[test] + fn test_rewrite_heredoc_returns_none() { + assert_eq!(rewrite_command("cat <<'EOF'\nfoo\nEOF"), None); + } + + #[test] + fn test_rewrite_empty_returns_none() { + assert_eq!(rewrite_command(""), None); + assert_eq!(rewrite_command(" "), None); + } + + #[test] + fn test_rewrite_mixed_compound_partial() { + // First segment already RTK, second gets rewritten + assert_eq!( + rewrite_command("rtk git add . && cargo test"), + Some("rtk git add . && rtk cargo test".into()) + ); + } } diff --git a/src/main.rs b/src/main.rs index fcb39303..4f56fb27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ mod prettier_cmd; mod prisma_cmd; mod pytest_cmd; mod read; +mod rewrite_cmd; mod ruff_cmd; mod runner; mod summary; @@ -537,6 +538,18 @@ enum Commands { #[arg(short, long, default_value = "7")] since: u64, }, + + /// Rewrite a raw command to its RTK equivalent (single source of truth for hooks) + /// + /// Exits 0 and prints the rewritten command if supported. + /// Exits 1 with no output if the command has no RTK equivalent. + /// + /// Used by Claude Code, Gemini CLI, and other LLM hooks: + /// REWRITTEN=$(rtk rewrite "$CMD") || exit 0 + Rewrite { + /// Raw command to rewrite (e.g. "git status", "cargo test && git push") + cmd: String, + }, } #[derive(Subcommand)] @@ -1431,6 +1444,10 @@ fn main() -> Result<()> { hook_audit_cmd::run(since, cli.verbose)?; } + Commands::Rewrite { cmd } => { + rewrite_cmd::run(&cmd)?; + } + Commands::Proxy { args } => { use std::process::Command; diff --git a/src/rewrite_cmd.rs b/src/rewrite_cmd.rs new file mode 100644 index 00000000..89676a3a --- /dev/null +++ b/src/rewrite_cmd.rs @@ -0,0 +1,47 @@ +use crate::discover::registry; + +/// Run the `rtk rewrite` command. +/// +/// Prints the RTK-rewritten command to stdout and exits 0. +/// Exits 1 (without output) if the command has no RTK equivalent. +/// +/// Used by shell hooks to rewrite commands transparently: +/// ```bash +/// REWRITTEN=$(rtk rewrite "$CMD") || exit 0 +/// [ "$CMD" = "$REWRITTEN" ] && exit 0 # already RTK, skip +/// ``` +pub fn run(cmd: &str) -> anyhow::Result<()> { + match registry::rewrite_command(cmd) { + Some(rewritten) => { + print!("{}", rewritten); + Ok(()) + } + None => { + std::process::exit(1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_run_supported_command_succeeds() { + // We can't easily test exit code here, but we can test the registry directly + assert!(registry::rewrite_command("git status").is_some()); + } + + #[test] + fn test_run_unsupported_returns_none() { + assert!(registry::rewrite_command("terraform plan").is_none()); + } + + #[test] + fn test_run_already_rtk_returns_some() { + assert_eq!( + registry::rewrite_command("rtk git status"), + Some("rtk git status".into()) + ); + } +}