rtk-ai/rtk

Bug: commands inside $(…), backticks, xargs, and for-loops are not rewritten (exit 1 "no RTK equivalent")

Open

#1 252 ouverte le 12 avr. 2026

Voir sur GitHub
 (1 commentaire) (0 réactions) (0 assignés)Rust (2 914 forks)batch import
P1-criticalarea:clibugeffort-largehelp wantedpriority:high

Métriques du dépôt

Stars
 (48 085 stars)
Métriques de merge PR
 (Merge moyen 11j 1h) (45 PRs mergées en 30 j)

Description

Summary

In rtk 0.35.0, rtk rewrite returns exit 1 (no RTK equivalent) for every command wrapped in a command substitution ($(...), backticks), piped into xargs, or inside a for/while/do loop body — even when the inner command has a perfectly good registry entry like cat, grep, git, ls, find, curl. As a result, the Claude Code hook passes these commands through unchanged and all RTK savings are lost for the wrapped invocation.

This is the generalized form of #1109 (which described a corrupted $(git ...) rewrite). The current behavior in 0.35.0 is safer than #1109 — nothing gets corrupted — but the rewrite is now absent entirely, and it affects every registry entry, not just git.

Environment

  • rtk 0.35.0 (cargo-installed, ~/.cargo/bin/rtk)
  • macOS 15.3 (Darwin 25.3.0), zsh
  • Claude Code with the standard rtk-rewrite.sh PreToolUse hook (header: # rtk-hook-version: 3)

Reproduction

Every one of these returns exit 1 ("no RTK equivalent") and produces no rewrite:

$ rtk rewrite 'X=$(cat /tmp/foo.txt)'; echo "exit: $?"
exit: 1

$ rtk rewrite 'BRANCH=$(git rev-parse --abbrev-ref HEAD)'; echo "exit: $?"
exit: 1

$ rtk rewrite 'echo "files: $(ls -la /tmp)"'; echo "exit: $?"
exit: 1

$ rtk rewrite 'COUNT=$(grep -c foo /tmp/a.txt)'; echo "exit: $?"
exit: 1

$ rtk rewrite 'X=`cat /tmp/foo.txt`'; echo "exit: $?"
exit: 1

$ rtk rewrite 'find . -name "*.md" | xargs cat'; echo "exit: $?"
exit: 1

$ rtk rewrite 'for f in *.txt; do cat "$f"; done'; echo "exit: $?"
exit: 1

Control — these do rewrite correctly, which shows the rewriter already handles ;, |, and &&:

$ rtk rewrite 'cat /tmp/foo.txt'
rtk read /tmp/foo.txt           # exit 0

$ rtk rewrite 'cat /tmp/a.txt; cat /tmp/b.txt'
rtk read /tmp/a.txt; rtk read /tmp/b.txt   # exit 0

$ rtk rewrite 'cat /tmp/foo.txt | grep bar'
rtk read /tmp/foo.txt | grep bar           # exit 0

$ rtk rewrite 'cd /tmp && cat test.txt'
cd /tmp && rtk read test.txt               # exit 0

So ;, |, and && are split-and-walked correctly, but $(...), `...`, xargs <cmd>, and loop bodies are not.

Expected

Rewrite the inner command regardless of the enclosing shell construct:

Input Expected output
X=$(cat /tmp/foo.txt) X=$(rtk read /tmp/foo.txt)
echo "files: $(ls -la /tmp)" echo "files: $(rtk ls -la /tmp)"
find . -name "*.md" | xargs cat find . -name "*.md" | xargs rtk read
for f in *.txt; do cat "\$f"; done for f in *.txt; do rtk read "\$f"; done
X=`cat foo` X=`rtk read foo`
BRANCH=$(git rev-parse --abbrev-ref HEAD) BRANCH=$(rtk git rev-parse --abbrev-ref HEAD)

Impact

This is the single largest source of missed savings for heavy Claude Code users. rtk discover on a 30-day window of my sessions reports:

Scanned: 9277 sessions (last 30 days), 48051 Bash commands
Already using RTK: 91 commands (0%)

MISSED SAVINGS — Commands RTK already handles
Command     Count    RTK Equivalent    Est. Savings
cat         2774     rtk read          ~832.6K tokens
git diff    2547     rtk git           ~625.8K tokens
find .      2267     rtk find          ~584.0K tokens
curl -s     3352     rtk curl          ~495.1K tokens
grep -n     2796     rtk grep          ~462.0K tokens
ls -la      4191     rtk ls            ~271.8K tokens
...
Total: 21677 commands → ~3.9M tokens saveable

My lifetime savings from rtk so far are 15.1M tokens (78.3%) across 3009 rewrites. A chunk of the 3.9M missed above is from commands wrapped in \$(...), heredocs, xargs, and loops — handling them would plausibly push lifetime savings past 25M without any behavior change on the user side.

Suggested fix

As #1109 suggested: replace the regex/prefix substitution with a shell-aware walker. Minimum viable version — pre-split on command-introducing constructs and run the registry lookup on each sub-command:

  • \$(...) and `...` (recursive — nested substitutions exist in the wild)
  • xargs <cmd> — the first non-flag argument to xargs is the command to rewrite
  • Loop bodies: do <cmd> / then <cmd> inside for, while, if
  • while read / find -exec <cmd> {} \;

tree-sitter-bash would cover all of these in one pass; the existing ;/|/&& support suggests a simple splitter is already in place and could be extended.

Tests to add in src/discover/registry.rs (or wherever the rewrite tests live):

assert_rewrite("X=$(cat /tmp/foo.txt)",                "X=$(rtk read /tmp/foo.txt)");
assert_rewrite("echo \"$(ls -la /tmp)\"",              "echo \"$(rtk ls -la /tmp)\"");
assert_rewrite("find . -name '*.md' | xargs cat",      "find . -name '*.md' | xargs rtk read");
assert_rewrite("for f in *.txt; do cat \"\$f\"; done", "for f in *.txt; do rtk read \"\$f\"; done");
assert_rewrite("X=\`cat foo\`",                        "X=\`rtk read foo\`");

Related

  • #1109 — original \$(git ...) corruption bug; this issue is its generalization: current behavior is "no rewrite" for any command inside \$(...), not just git
  • #1243 — heredoc rewrites (adjacent, same root cause: non-trivial shell constructs not traversed)
  • #538 — native Claude tools bypass the hook (different layer, same symptom for users)

Guide contributeur