diff --git a/.claude/hooks/rtk-rewrite.sh b/.claude/hooks/rtk-rewrite.sh index ea728576..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,190 +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 /')" -elif echo "$MATCH_CMD" | grep -qE '^mypy([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^mypy/rtk mypy/')" -elif echo "$MATCH_CMD" | grep -qE '^python[[:space:]]+-m[[:space:]]+mypy([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^python -m mypy/rtk mypy/')" - -# --- 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..77d37c34 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: 52 modules** (34 command modules + 18 infrastructure modules) ### Module Count Breakdown @@ -1483,4 +1483,4 @@ When implementing a new command, consider: **Last Updated**: 2026-02-22 **Architecture Version**: 2.2 -**rtk Version**: 0.22.2 +**rtk Version**: 0.23.0 diff --git a/CLAUDE.md b/CLAUDE.md index e06e73db..cc8e096d 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.22.2" (or newer) +rtk --version # Should show "rtk 0.23.0" (or newer) rtk gain # Should show token savings stats (NOT "command not found") ``` diff --git a/README.md b/README.md index b6537eab..e995ee85 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ rtk filters and compresses command outputs before they reach your LLM context, s **How to verify you have the correct rtk:** ```bash -rtk --version # Should show "rtk 0.22.2" +rtk --version # Should show "rtk 0.23.0" rtk gain # Should show token savings stats ``` diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index c11cf72b..78d39627 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -1,14 +1,31 @@ -#!/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. +#!/usr/bin/env bash +# RTK Claude Code hook — rewrites commands to use rtk for token savings. +# Requires: rtk >= 0.23.0, jq +# +# This is a thin delegating hook: all rewrite logic lives in `rtk rewrite`, +# which is the single source of truth (src/discover/registry.rs). +# To add or change rewrite rules, edit the Rust registry — not this file. + +if ! command -v jq &>/dev/null; then + exit 0 +fi -# Guards: skip silently if dependencies missing -if ! command -v rtk &>/dev/null || ! command -v jq &>/dev/null; then +if ! command -v rtk &>/dev/null; then exit 0 fi -set -euo pipefail +# Version guard: rtk rewrite was added in 0.23.0. +# Older binaries: warn once and exit cleanly (no silent failure). +RTK_VERSION=$(rtk --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +if [ -n "$RTK_VERSION" ]; then + MAJOR=$(echo "$RTK_VERSION" | cut -d. -f1) + MINOR=$(echo "$RTK_VERSION" | cut -d. -f2) + # Require >= 0.23.0 + if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 23 ]; then + echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.23.0). Upgrade: cargo install rtk" >&2 + exit 0 + fi +fi INPUT=$(cat) CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') @@ -17,195 +34,18 @@ 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 - COMPOSE_SUBCMD=$(echo "$MATCH_CMD" | sed -E 's/^docker[[:space:]]+compose[[:space:]]*//') - case "$COMPOSE_SUBCMD" in - ps|ps\ *|logs|logs\ *|build|build\ *) - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^docker /rtk docker /')" - ;; - esac - 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 /')" -elif echo "$MATCH_CMD" | grep -qE '^mypy([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^mypy/rtk mypy/')" -elif echo "$MATCH_CMD" | grep -qE '^python[[:space:]]+-m[[:space:]]+mypy([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^python -m mypy/rtk mypy/')" - -# --- 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 +# Delegate all rewrite logic to the Rust binary. +# rtk rewrite exits 1 when there's no rewrite — hook passes through silently. +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || exit 0 -# If no rewrite needed, approve as-is -if [ -z "$REWRITTEN" ]; then +# No change — nothing to do. +if [ "$CMD" = "$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" \ '{ diff --git a/src/discover/registry.rs b/src/discover/registry.rs index c3c52edc..4fbd1715 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)], @@ -50,8 +52,8 @@ pub fn category_avg_tokens(category: &str, subcmd: &str) -> usize { // Patterns ordered to match RTK_RULES indices exactly. const PATTERNS: &[&str] = &[ r"^git\s+(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)", - r"^gh\s+(pr|issue|run|repo|api)", - r"^cargo\s+(build|test|clippy|check|fmt)", + r"^gh\s+(pr|issue|run|repo|api|release)", + r"^cargo\s+(build|test|clippy|check|fmt|install)", r"^pnpm\s+(list|ls|outdated|install)", r"^npm\s+(run|exec)", r"^npx\s+", @@ -66,16 +68,26 @@ const PATTERNS: &[&str] = &[ r"^(pnpm\s+|npx\s+)?(vitest|jest|test)(\s|$)", r"^(npx\s+|pnpm\s+)?playwright", r"^(npx\s+|pnpm\s+)?prisma", - r"^docker\s+(ps|images|logs)", - r"^kubectl\s+(get|logs)", + r"^docker\s+(ps|images|logs|run|exec|build)", + r"^kubectl\s+(get|logs|describe|apply)", + r"^tree(\s|$)", + r"^diff\s+", r"^curl\s+", r"^wget\s+", r"^(python3?\s+-m\s+)?mypy(\s|$)", + // Python tooling + r"^ruff\s+(check|format)", + r"^(python\s+-m\s+)?pytest(\s|$)", + r"^(pip3?|uv\s+pip)\s+(list|outdated|install)", + // Go tooling + r"^go\s+(test|build|vet)", + r"^golangci-lint(\s|$)", ]; const RULES: &[RtkRule] = &[ RtkRule { rtk_cmd: "rtk git", + rewrite_prefixes: &["git"], category: "Git", savings_pct: 70.0, subcmd_savings: &[ @@ -88,6 +100,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)], @@ -95,6 +108,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)], @@ -102,6 +116,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk pnpm", + rewrite_prefixes: &["pnpm"], category: "PackageManager", savings_pct: 80.0, subcmd_savings: &[], @@ -109,6 +124,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk npm", + rewrite_prefixes: &["npm"], category: "PackageManager", savings_pct: 70.0, subcmd_savings: &[], @@ -116,6 +132,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk npx", + rewrite_prefixes: &["npx"], category: "PackageManager", savings_pct: 70.0, subcmd_savings: &[], @@ -123,6 +140,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk read", + rewrite_prefixes: &["cat", "head", "tail"], category: "Files", savings_pct: 60.0, subcmd_savings: &[], @@ -130,6 +148,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk grep", + rewrite_prefixes: &["rg", "grep"], category: "Files", savings_pct: 75.0, subcmd_savings: &[], @@ -137,6 +156,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk ls", + rewrite_prefixes: &["ls"], category: "Files", savings_pct: 65.0, subcmd_savings: &[], @@ -144,13 +164,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: &[], @@ -158,6 +181,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: &[], @@ -165,13 +196,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: &[], @@ -179,6 +213,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: &[], @@ -186,6 +221,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk playwright", + rewrite_prefixes: &["npx playwright", "pnpm playwright", "playwright"], category: "Tests", savings_pct: 94.0, subcmd_savings: &[], @@ -193,6 +229,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk prisma", + rewrite_prefixes: &["npx prisma", "pnpm prisma", "prisma"], category: "Build", savings_pct: 88.0, subcmd_savings: &[], @@ -200,6 +237,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk docker", + rewrite_prefixes: &["docker"], category: "Infra", savings_pct: 85.0, subcmd_savings: &[], @@ -207,13 +245,31 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk kubectl", + rewrite_prefixes: &["kubectl"], category: "Infra", savings_pct: 85.0, subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + rtk_cmd: "rtk tree", + rewrite_prefixes: &["tree"], + category: "Files", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk diff", + rewrite_prefixes: &["diff"], + category: "Files", + savings_pct: 60.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { rtk_cmd: "rtk curl", + rewrite_prefixes: &["curl"], category: "Network", savings_pct: 70.0, subcmd_savings: &[], @@ -221,6 +277,7 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk wget", + rewrite_prefixes: &["wget"], category: "Network", savings_pct: 65.0, subcmd_savings: &[], @@ -228,11 +285,54 @@ const RULES: &[RtkRule] = &[ }, RtkRule { rtk_cmd: "rtk mypy", + rewrite_prefixes: &["python3 -m mypy", "python -m mypy", "mypy"], category: "Build", savings_pct: 80.0, subcmd_savings: &[], subcmd_status: &[], }, + // Python tooling + RtkRule { + rtk_cmd: "rtk ruff", + rewrite_prefixes: &["ruff"], + category: "Python", + savings_pct: 80.0, + subcmd_savings: &[("check", 80.0), ("format", 75.0)], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk pytest", + rewrite_prefixes: &["python -m pytest", "pytest"], + category: "Python", + savings_pct: 90.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk pip", + rewrite_prefixes: &["pip3", "pip", "uv pip"], + category: "Python", + savings_pct: 75.0, + subcmd_savings: &[("list", 75.0), ("outdated", 80.0)], + subcmd_status: &[], + }, + // Go tooling + RtkRule { + rtk_cmd: "rtk go", + rewrite_prefixes: &["go"], + category: "Go", + savings_pct: 85.0, + subcmd_savings: &[("test", 90.0), ("build", 80.0), ("vet", 75.0)], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk golangci-lint", + rewrite_prefixes: &["golangci-lint", "golangci"], + category: "Go", + savings_pct: 85.0, + subcmd_savings: &[], + subcmd_status: &[], + }, ]; /// Commands to ignore (shell builtins, trivial, already rtk). @@ -288,7 +388,9 @@ const IGNORED_PREFIXES: &[&str] = &[ "case ", ]; -const IGNORED_EXACT: &[&str] = &["cd", "echo", "true", "false", "wait", "pwd", "bash", "sh", "fi", "done"]; +const IGNORED_EXACT: &[&str] = &[ + "cd", "echo", "true", "false", "wait", "pwd", "bash", "sh", "fi", "done", +]; lazy_static! { static ref REGEX_SET: RegexSet = RegexSet::new(PATTERNS).expect("invalid regex patterns"); @@ -492,6 +594,252 @@ 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('|') + || 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 => { + // single `&` background execution operator + 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 += 1; + 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 `head -N file` → `rtk read file --max-lines N`. +/// Returns `None` if the command doesn't match this pattern (fall through to generic logic). +fn rewrite_head_numeric(cmd: &str) -> Option { + // Match: head - (with optional env prefix) + lazy_static! { + static ref HEAD_N: Regex = Regex::new(r"^head\s+-(\d+)\s+(.+)$").expect("valid regex"); + static ref HEAD_LINES: Regex = + Regex::new(r"^head\s+--lines=(\d+)\s+(.+)$").expect("valid regex"); + } + if let Some(caps) = HEAD_N.captures(cmd) { + let n = caps.get(1)?.as_str(); + let file = caps.get(2)?.as_str(); + return Some(format!("rtk read {} --max-lines {}", file, n)); + } + if let Some(caps) = HEAD_LINES.captures(cmd) { + let n = caps.get(1)?.as_str(); + let file = caps.get(2)?.as_str(); + return Some(format!("rtk read {} --max-lines {}", file, n)); + } + // head with any other flag (e.g. -c, -q): skip rewriting to avoid clap errors + if cmd.starts_with("head -") { + return None; + } + 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()); + } + + // Special case: `head -N file` / `head --lines=N file` → `rtk read file --max-lines N` + // Must intercept before generic prefix replacement, which would produce `rtk read -20 file`. + // Only intercept when head has a flag (-N, --lines=N, -c, etc.); plain `head file` falls + // through to the generic rewrite below and produces `rtk read file` as expected. + if trimmed.starts_with("head -") { + return rewrite_head_numeric(trimmed); + } + + // 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; @@ -791,4 +1139,348 @@ mod tests { } ); } + + // --- 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_background_single_amp() { + assert_eq!( + rewrite_command("cargo test & git status"), + Some("rtk cargo test & rtk git status".into()) + ); + } + + #[test] + fn test_rewrite_background_unsupported_right() { + assert_eq!( + rewrite_command("cargo test & terraform plan"), + Some("rtk cargo test & terraform plan".into()) + ); + } + + #[test] + fn test_rewrite_background_does_not_affect_double_amp() { + // `&&` must still work after adding `&` support + assert_eq!( + rewrite_command("cargo test && git status"), + Some("rtk cargo test && 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()) + ); + } + + // --- P0.2: head -N rewrite --- + + #[test] + fn test_rewrite_head_numeric_flag() { + // head -20 file → rtk read file --max-lines 20 (not rtk read -20 file) + assert_eq!( + rewrite_command("head -20 src/main.rs"), + Some("rtk read src/main.rs --max-lines 20".into()) + ); + } + + #[test] + fn test_rewrite_head_lines_long_flag() { + assert_eq!( + rewrite_command("head --lines=50 src/lib.rs"), + Some("rtk read src/lib.rs --max-lines 50".into()) + ); + } + + #[test] + fn test_rewrite_head_no_flag_still_rewrites() { + // plain `head file` → `rtk read file` (no numeric flag) + assert_eq!( + rewrite_command("head src/main.rs"), + Some("rtk read src/main.rs".into()) + ); + } + + #[test] + fn test_rewrite_head_other_flag_skipped() { + // head -c 100 file: unsupported flag, skip rewriting + assert_eq!(rewrite_command("head -c 100 src/main.rs"), None); + } + + // --- New registry entries --- + + #[test] + fn test_classify_gh_release() { + assert!(matches!( + classify_command("gh release list"), + Classification::Supported { + rtk_equivalent: "rtk gh", + .. + } + )); + } + + #[test] + fn test_classify_cargo_install() { + assert!(matches!( + classify_command("cargo install rtk"), + Classification::Supported { + rtk_equivalent: "rtk cargo", + .. + } + )); + } + + #[test] + fn test_classify_docker_run() { + assert!(matches!( + classify_command("docker run --rm ubuntu bash"), + Classification::Supported { + rtk_equivalent: "rtk docker", + .. + } + )); + } + + #[test] + fn test_classify_docker_exec() { + assert!(matches!( + classify_command("docker exec -it mycontainer bash"), + Classification::Supported { + rtk_equivalent: "rtk docker", + .. + } + )); + } + + #[test] + fn test_classify_docker_build() { + assert!(matches!( + classify_command("docker build -t myimage ."), + Classification::Supported { + rtk_equivalent: "rtk docker", + .. + } + )); + } + + #[test] + fn test_classify_kubectl_describe() { + assert!(matches!( + classify_command("kubectl describe pod mypod"), + Classification::Supported { + rtk_equivalent: "rtk kubectl", + .. + } + )); + } + + #[test] + fn test_classify_kubectl_apply() { + assert!(matches!( + classify_command("kubectl apply -f deploy.yaml"), + Classification::Supported { + rtk_equivalent: "rtk kubectl", + .. + } + )); + } + + #[test] + fn test_classify_tree() { + assert!(matches!( + classify_command("tree src/"), + Classification::Supported { + rtk_equivalent: "rtk tree", + .. + } + )); + } + + #[test] + fn test_classify_diff() { + assert!(matches!( + classify_command("diff file1.txt file2.txt"), + Classification::Supported { + rtk_equivalent: "rtk diff", + .. + } + )); + } + + #[test] + fn test_rewrite_tree() { + assert_eq!(rewrite_command("tree src/"), Some("rtk tree src/".into())); + } + + #[test] + fn test_rewrite_diff() { + assert_eq!( + rewrite_command("diff file1.txt file2.txt"), + Some("rtk diff file1.txt file2.txt".into()) + ); + } + + #[test] + fn test_rewrite_gh_release() { + assert_eq!( + rewrite_command("gh release list"), + Some("rtk gh release list".into()) + ); + } + + #[test] + fn test_rewrite_cargo_install() { + assert_eq!( + rewrite_command("cargo install rtk"), + Some("rtk cargo install rtk".into()) + ); + } + + #[test] + fn test_rewrite_kubectl_describe() { + assert_eq!( + rewrite_command("kubectl describe pod mypod"), + Some("rtk kubectl describe pod mypod".into()) + ); + } + + #[test] + fn test_rewrite_docker_run() { + assert_eq!( + rewrite_command("docker run --rm ubuntu bash"), + Some("rtk docker run --rm ubuntu bash".into()) + ); + } } diff --git a/src/go_cmd.rs b/src/go_cmd.rs index 278c0ce2..42ff0cd9 100644 --- a/src/go_cmd.rs +++ b/src/go_cmd.rs @@ -267,8 +267,7 @@ fn filter_go_test_json(output: &str) -> String { // Handle build-output/build-fail events (use ImportPath, no Package) match event.action.as_str() { "build-output" => { - if let (Some(import_path), Some(output_text)) = - (&event.import_path, &event.output) + if let (Some(import_path), Some(output_text)) = (&event.import_path, &event.output) { let text = output_text.trim_end().to_string(); if !text.is_empty() { diff --git a/src/init.rs b/src/init.rs index 961e4ac3..8de3d24f 100644 --- a/src/init.rs +++ b/src/init.rs @@ -660,7 +660,7 @@ fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result< // 1. Prepare hook directory and install hook let (_hook_dir, hook_path) = prepare_hook_paths()?; - ensure_hook_installed(&hook_path, verbose)?; + let hook_changed = ensure_hook_installed(&hook_path, verbose)?; // 2. Write RTK.md write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; @@ -669,7 +669,12 @@ fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result< let migrated = patch_claude_md(&claude_md_path, verbose)?; // 4. Print success message - println!("\nRTK hook installed (global).\n"); + let hook_status = if hook_changed { + "installed/updated" + } else { + "already up to date" + }; + println!("\nRTK hook {} (global).\n", hook_status); println!(" Hook: {}", hook_path.display()); println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); println!(" CLAUDE.md: @RTK.md reference added"); @@ -717,9 +722,14 @@ fn run_hook_only_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Resul // Prepare and install hook let (_hook_dir, hook_path) = prepare_hook_paths()?; - ensure_hook_installed(&hook_path, verbose)?; + let hook_changed = ensure_hook_installed(&hook_path, verbose)?; - println!("\nRTK hook installed (hook-only mode).\n"); + let hook_status = if hook_changed { + "installed/updated" + } else { + "already up to date" + }; + println!("\nRTK hook {} (hook-only mode).\n", hook_status); println!(" Hook: {}", hook_path.display()); println!( " Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)." @@ -1136,12 +1146,13 @@ mod tests { fn test_hook_has_guards() { assert!(REWRITE_HOOK.contains("command -v rtk")); assert!(REWRITE_HOOK.contains("command -v jq")); - // Guards must be BEFORE set -euo pipefail - let guard_pos = REWRITE_HOOK.find("command -v rtk").unwrap(); - let set_pos = REWRITE_HOOK.find("set -euo pipefail").unwrap(); + // Guards (rtk/jq availability checks) must appear before the actual delegation call. + // The thin delegating hook no longer uses set -euo pipefail. + let jq_pos = REWRITE_HOOK.find("command -v jq").unwrap(); + let rtk_delegate_pos = REWRITE_HOOK.find("rtk rewrite \"$CMD\"").unwrap(); assert!( - guard_pos < set_pos, - "Guards must come before set -euo pipefail" + jq_pos < rtk_delegate_pos, + "Guards must appear before rtk rewrite delegation" ); } diff --git a/src/main.rs b/src/main.rs index 7125464b..3ae7ead6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ mod prettier_cmd; mod prisma_cmd; mod pytest_cmd; mod read; +mod rewrite_cmd; mod ruff_cmd; mod runner; mod summary; @@ -548,6 +549,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)] @@ -1448,6 +1461,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()) + ); + } +}