feat: rtk rewrite — single source of truth for LLM hook rewrites#241
feat: rtk rewrite — single source of truth for LLM hook rewrites#241FlorianBruniaux wants to merge 3 commits intomasterfrom
rtk rewrite — single source of truth for LLM hook rewrites#241Conversation
…rites Implements `rtk rewrite <cmd>` as the canonical rewrite engine for all LLM hook integrations (Claude Code, Gemini CLI, future tools). - Add `rewrite_prefixes` field to `RtkRule` in discover/registry.rs - Add public `rewrite_command()` with compound command support (&&, ||, ;, |) - Add `rewrite_segment()`, `rewrite_compound()`, `strip_word_prefix()` helpers - Handle already-rtk commands (exit 0, identical output) - Handle unsupported/ignored commands (exit 1, no output) - Add 20 unit tests covering all branches and edge cases - Create `src/rewrite_cmd.rs` thin CLI wrapper - Register `Commands::Rewrite` in main.rs - Simplify `.claude/hooks/rtk-rewrite.sh` from 357 → 60 lines Hooks no longer need duplicate mapping logic — a single `REWRITTEN=$(rtk rewrite "$CMD") || exit 0` handles everything. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces rtk rewrite, a new CLI command that serves as a centralized rewriting engine for LLM hooks (Claude Code, Gemini CLI). The goal is to eliminate duplicate command-mapping logic across multiple bash hooks by providing a single source of truth in Rust. When a new RTK filter is added, all hooks automatically support it without bash changes.
Changes:
- Adds
rtk rewrite <cmd>command that rewrites shell commands to their RTK equivalents (exits 0 with rewritten output, or exits 1 if unsupported) - Simplifies
.claude/hooks/rtk-rewrite.shfrom 357 lines to 60 lines by delegating rewrite logic tortk rewrite - Extends
src/discover/registry.rswithrewrite_prefixesfield and rewrite functions that handle simple and compound commands (&&,||,;,|)
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/rewrite_cmd.rs | New thin CLI wrapper that calls registry::rewrite_command and exits appropriately |
| src/main.rs | Registers Commands::Rewrite variant with documentation |
| src/discover/registry.rs | Adds rewrite_prefixes to RtkRule, implements rewrite_command/rewrite_compound/rewrite_segment/strip_word_prefix functions, adds 19 unit tests |
| .claude/hooks/rtk-rewrite.sh | Simplified to single rtk rewrite call, removes 300+ lines of duplicate bash patterns |
Comments suppressed due to low confidence (3)
src/discover/registry.rs:137
- The rewrite_prefixes for "rtk read" include "head" and "tail", but this loses the special argument transformation that existed in the old bash hook.
The old hook converted:
head -10 file.txt→rtk read file.txt --max-lines 10head --lines=10 file.txt→rtk read file.txt --max-lines 10
The new implementation would convert:
head -10 file.txt→rtk read -10 file.txt(INVALID - rtk read doesn't understand -N syntax)
This means head/tail commands will be "rewritten" to invalid rtk read commands that will fail at execution time.
Consider either:
- Remove "head" and "tail" from rewrite_prefixes (let them pass through unchanged)
- Add logic to rewrite_segment to handle argument transformation for head/tail
- Update rtk read to accept head/tail-style arguments like -N
Option 1 is simplest and most consistent with the PR's philosophy of simple prefix replacement without complex transformations.
RtkRule {
rtk_cmd: "rtk read",
rewrite_prefixes: &["cat", "head", "tail"],
category: "Files",
savings_pct: 60.0,
subcmd_savings: &[],
subcmd_status: &[],
src/discover/registry.rs:260
- This PR removes support for several RTK commands that exist in the codebase but are missing from the RULES array. The following RTK commands have implementations (confirmed by src/ directory listing) but are not included in PATTERNS or RULES:
rtk pytest(src/pytest_cmd.rs exists)rtk ruff(src/ruff_cmd.rs exists)rtk pip(src/pip_cmd.rs exists)rtk go(src/go_cmd.rs exists)rtk golangci-lint(src/golangci_cmd.rs exists)
These commands were supported in the old bash hook (visible in the removed lines of .claude/hooks/rtk-rewrite.sh) but are now missing. This means users who were relying on these rewrites will experience a regression - their commands will no longer be automatically rewritten by LLM hooks.
To fix this, add entries to both PATTERNS and RULES arrays for each missing command, following the same pattern as existing entries. For example, for pytest:
- Add pattern:
r"^(python3?\s+-m\s+)?pytest(\s|$)" - Add rule with rewrite_prefixes:
&["python -m pytest", "python3 -m pytest", "pytest"]
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"^pnpm\s+(list|ls|outdated|install)",
r"^npm\s+(run|exec)",
r"^npx\s+",
r"^(cat|head|tail)\s+",
r"^(rg|grep)\s+",
r"^ls(\s|$)",
r"^find\s+",
r"^(npx\s+|pnpm\s+)?tsc(\s|$)",
r"^(npx\s+|pnpm\s+)?(eslint|biome|lint)(\s|$)",
r"^(npx\s+|pnpm\s+)?prettier",
r"^(npx\s+|pnpm\s+)?next\s+build",
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"^curl\s+",
r"^wget\s+",
];
const RULES: &[RtkRule] = &[
RtkRule {
rtk_cmd: "rtk git",
rewrite_prefixes: &["git"],
category: "Git",
savings_pct: 70.0,
subcmd_savings: &[
("diff", 80.0),
("show", 80.0),
("add", 59.0),
("commit", 59.0),
],
subcmd_status: &[],
},
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)],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk cargo",
rewrite_prefixes: &["cargo"],
category: "Cargo",
savings_pct: 80.0,
subcmd_savings: &[("test", 90.0), ("check", 80.0)],
subcmd_status: &[("fmt", super::report::RtkStatus::Passthrough)],
},
RtkRule {
rtk_cmd: "rtk pnpm",
rewrite_prefixes: &["pnpm"],
category: "PackageManager",
savings_pct: 80.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk npm",
rewrite_prefixes: &["npm"],
category: "PackageManager",
savings_pct: 70.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk npx",
rewrite_prefixes: &["npx"],
category: "PackageManager",
savings_pct: 70.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk read",
rewrite_prefixes: &["cat", "head", "tail"],
category: "Files",
savings_pct: 60.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk grep",
rewrite_prefixes: &["rg", "grep"],
category: "Files",
savings_pct: 75.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk ls",
rewrite_prefixes: &["ls"],
category: "Files",
savings_pct: 65.0,
subcmd_savings: &[],
subcmd_status: &[],
},
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: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk lint",
rewrite_prefixes: &[
"npx eslint",
"pnpm lint",
"npx biome",
"eslint",
"biome",
"lint",
],
category: "Build",
savings_pct: 84.0,
subcmd_savings: &[],
subcmd_status: &[],
},
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: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk vitest",
rewrite_prefixes: &["pnpm vitest", "npx vitest", "vitest", "jest"],
category: "Tests",
savings_pct: 99.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk playwright",
rewrite_prefixes: &["npx playwright", "pnpm playwright", "playwright"],
category: "Tests",
savings_pct: 94.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk prisma",
rewrite_prefixes: &["npx prisma", "pnpm prisma", "prisma"],
category: "Build",
savings_pct: 88.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk docker",
rewrite_prefixes: &["docker"],
category: "Infra",
savings_pct: 85.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk kubectl",
rewrite_prefixes: &["kubectl"],
category: "Infra",
savings_pct: 85.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk curl",
rewrite_prefixes: &["curl"],
category: "Network",
savings_pct: 70.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk wget",
rewrite_prefixes: &["wget"],
category: "Network",
savings_pct: 65.0,
subcmd_savings: &[],
subcmd_status: &[],
},
];
src/discover/registry.rs:210
- The rewrite_prefixes array is incomplete - it doesn't cover all command forms that the PATTERN matches.
The PATTERN r"^(pnpm\s+|npx\s+)?(vitest|jest|test)(\s|$)" matches:
- "pnpm test"
- "npx test"
- "test"
- "npx jest"
- "pnpm jest"
But rewrite_prefixes only includes: "pnpm vitest", "npx vitest", "vitest", "jest"
This means commands like "pnpm test" will match the pattern (classify as supported) but fail to rewrite because "pnpm test" isn't in the prefix list.
Additionally, the old bash hook converted "pnpm test" to "rtk vitest run" (with the "run" subcommand), but the current implementation would convert it to "rtk vitest" without a subcommand. Since VitestCommands requires a subcommand (line 745-752 of main.rs shows only a "Run" variant), "rtk vitest" without arguments would fail.
Add missing prefixes and consider whether "run" should be appended: rewrite_prefixes: &["pnpm vitest", "npx vitest", "pnpm test", "npx test", "vitest", "jest", "test"]
RtkRule {
rtk_cmd: "rtk vitest",
rewrite_prefixes: &["pnpm vitest", "npx vitest", "vitest", "jest"],
category: "Tests",
savings_pct: 99.0,
subcmd_savings: &[],
subcmd_status: &[],
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| rewrite_command("rtk git add . && cargo test"), | ||
| Some("rtk git add . && rtk cargo test".into()) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Test coverage is missing for the || (OR) operator in compound commands. While the code handles || (lines 576-589), there's no test verifying this works correctly.
Add a test like:
#[test]
fn test_rewrite_compound_or() {
assert_eq!(
rewrite_command("git fetch || git pull"),
Some("rtk git fetch || rtk git pull".into())
);
}| } | |
| } | |
| #[test] | |
| fn test_rewrite_compound_or() { | |
| assert_eq!( | |
| rewrite_command("git fetch || git pull"), | |
| Some("rtk git fetch || rtk git pull".into()) | |
| ); | |
| } |
| rewrite_command("rtk git add . && cargo test"), | ||
| Some("rtk git add . && rtk cargo test".into()) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Test coverage is missing for the ; (semicolon) separator in compound commands. While the code handles ; (lines 619-635), there's no test verifying this works correctly.
Add a test like:
#[test]
fn test_rewrite_compound_semicolon() {
assert_eq!(
rewrite_command("git add .; cargo test"),
Some("rtk git add .; rtk cargo test".into())
);
}| } | |
| } | |
| #[test] | |
| fn test_rewrite_compound_semicolon() { | |
| assert_eq!( | |
| rewrite_command("git add .; cargo test"), | |
| Some("rtk git add .; rtk cargo test".into()) | |
| ); | |
| } |
| rtk_cmd: "rtk lint", | ||
| rewrite_prefixes: &[ | ||
| "npx eslint", | ||
| "pnpm lint", | ||
| "npx biome", | ||
| "eslint", | ||
| "biome", | ||
| "lint", | ||
| ], |
There was a problem hiding this comment.
The rewrite_prefixes array is incomplete - it doesn't cover all command forms that the PATTERN regex matches.
The PATTERN r"^(npx\s+|pnpm\s+)?(eslint|biome|lint)(\s|$)" matches commands like:
- "pnpm eslint"
- "npx lint"
- "pnpm biome"
But rewrite_prefixes only includes:
- "npx eslint", "pnpm lint", "npx biome", "eslint", "biome", "lint"
Missing: "pnpm eslint", "npx lint", "pnpm biome check", "eslint check", "biome check" etc.
When a command like "pnpm eslint src/" is encountered:
- classify_command matches it (PATTERN matches)
- rewrite_segment tries all prefixes but none match "pnpm eslint"
- Returns None - rewrite silently fails despite command being "supported"
Add missing prefix combinations to ensure all matched patterns can be rewritten:
rewrite_prefixes: &["npx eslint", "pnpm eslint", "npx biome", "pnpm biome", "pnpm lint", "npx lint", "eslint", "biome", "lint"]
| *'<<'*) _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" |
There was a problem hiding this comment.
The calls to _rtk_audit_log with "$CMD" and "$REWRITTEN" persist the full command line and its rewritten form to a local log file, which can include secrets such as API tokens, passwords in URLs, or authorization headers. An attacker (or another local user, backups, or support channels receiving this log) could recover those secrets from ${HOME}/.local/share/rtk/hook-audit.log when RTK_HOOK_AUDIT=1. To reduce this risk, avoid logging raw command strings, or at least redact/strip sensitive arguments and ensure the log file has appropriately restricted permissions and a clear warning in documentation about potential secret exposure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- README.md, CLAUDE.md, ARCHITECTURE.md: 0.20.1 → 0.22.2 - ARCHITECTURE.md: module count 48 → 51 (added rewrite_cmd + 2 from master) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Problem
Every LLM hook (Claude Code, Gemini CLI, future Copilot...) was duplicating the same command-mapping logic in bash. Adding a new RTK filter meant updating every hook independently.
Thomas Delalonde identified this exactly in a community message:
He was right. We had 3 independent copies of the same mapping:
.claude/hooks/rtk-rewrite.sh— 357 lines of bash grep/sedsrc/discover/registry.rs— 21 Rust regex patterns (forrtk discover)Solution:
rtk rewrite <cmd>A new CLI command that acts as the canonical rewrite engine for all hook integrations.
Contract:
exit 0+ stdout = rewritten command (or already RTK, identical output)exit 1= no match, hook should pass through unchangedImpact on hooks
Every LLM hook now reduces to a single line:
Claude Code hook: 357 lines → 60 lines
Thomas's Gemini CLI hook: ~120 lines → ~15 lines
When a new RTK filter is added (e.g.,
rtk ruff), all hooks update automatically — zero bash changes required.Implementation
src/discover/registry.rsrewrite_prefixes: &'static [&'static str]toRtkRulestruct (ordered longest-first for correct matching)rewrite_command(cmd: &str) -> Option<String>— top-level entry point&&/||/;/|(outside quotes), reconstructs with original separatorsclassify_command()for ignore/prefix handling (no duplicate IGNORED_PREFIXES logic)src/rewrite_cmd.rs(new)Thin CLI wrapper —
Some→ print + exit 0,None→std::process::exit(1)src/main.rsCommands::Rewrite { cmd }variant registered.claude/hooks/rtk-rewrite.shSimplified from 357 → 60 lines. Single source of truth delegated to
rtk rewrite.Testing
20 new unit tests covering all branches:
&&,||,;cd,echo)terraform)Gemini CLI hook template
Thomas's hook can now be reduced to:
A
rtk init --geminitemplate is out of scope for this PR (tracked separately).Inspired by Thomas Delalonde's community feedback — merci Thomas.