rtk-ai/rtk

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

Open

#1,252 opened on Apr 12, 2026

View on GitHub
 (1 comment) (0 reactions) (0 assignees)Rust (2,914 forks)batch import
P1-criticalarea:clibugeffort-largehelp wantedpriority:high

Repository metrics

Stars
 (48,085 stars)
PR merge metrics
 (Avg merge 11d 1h) (45 merged PRs in 30d)

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)

Contributor guide