Bug: commands inside $(…), backticks, xargs, and for-loops are not rewritten (exit 1 "no RTK equivalent")
#1,252 opened on 2026年4月12日
Repository metrics
- Stars
- (48,085 stars)
- PR merge metrics
- (平均マージ 11d 1h) (30d で 45 merged PRs)
説明
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.shPreToolUse 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 toxargsis the command to rewrite- Loop bodies:
do <cmd>/then <cmd>insidefor,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)