Repository navigation
Conversation
KuSh
added this pull request to stack #4139
October 2, 2026 16:01
📊 Automated PR Analysis
SummaryRefactors the command-line rewrite path to peel transparent wrappers (env assignments, uv run, timeout/time/nice/nohup, command/exec/noglob, reserved words, user transparent_prefixes) using a single generic walker driven by per-family grammar tables instead of ad-hoc tool-specific logic. Adds one file per wrapper family under discover/families/ with its own accept/reject tests, and updates core/README and hook docs to describe the new peeling behavior and exclude_commands matching semantics. Review Checklist
Analyzed automatically by wshm · This is an automated analysis, not a human review. |
KuSh
force-pushed
the
feat/4315-1-cmdline-walker
branch
from
October 2, 2026 16:20
78057f2 to
b8dd618
Compare
KuSh
force-pushed
the
feat/4315-1-cmdline-walker
branch
from
October 5, 2026 22:36
b8dd618 to
6031a95
Compare
KuSh
force-pushed
the
feat/4315-1-cmdline-walker
branch
from
October 7, 2026 07:16
6031a95 to
45dd57a
Compare
KuSh
force-pushed
the
feat/4315-1-cmdline-walker
branch
from
October 9, 2026 21:17
45dd57a to
e7f9971
Compare
KuSh
force-pushed
the
feat/4315-1-cmdline-walker
branch
from
October 15, 2026 12:03
e7f9971 to
ab24355
Compare
…er family tables
Transparent wrappers are peeled by one walker that reads data: env
assignments, the reserved words bash runs a pipeline after, the routable
`uv run` wrapper, the process wrappers (timeout, time, nice, nohup), the
precommand words and a user's prefixes. Each family is a table, and the
walker knows no tool by name.
- `core/cmdline/family.rs` declares what a wrapper family is: a head, a label
of the caller's own type, and a `Form` (a bare keyword, a word of the
assignment run, or a wrapper with its own flag `Grammar` and operand
count);
- `core/cmdline/walker.rs` peels a caller-supplied `&[Family]` off the words
and tokens a caller already lexed, at a `Position` (whether bash reads an
assignment, or a reserved word such as `time`, at that place), and returns
word indices. It reads reserved words and `time`'s options from
`core/cmdline/bash_grammar.rs` and flags from the `Grammar` of
`core/arg_tokenizer.rs`, where `.numeric()` reads the `-N` flag `nice`
takes, and two new axis values read the options of a reserved word, each
argument one option compared as written: `SingleDash::Whole` (`-pp` is
the short flag `pp`) and `Attach::Never` (`-p=1` is the flag `p=1`).
`Grammar::new` refuses a value spec they could never apply: a
solo-only value under `Whole`, and an attached-only one on a
whole-argument spelling under `Never`;
- `discover/families/` holds the tables, one file per family with its own
accept/reject table test: `env`, `precommand` (`command`, `builtin`,
`exec`, `noglob`, `nocorrect`), `uv_run`, `process`, `match_only`
(`stdbuf` and a bare `xargs`, which only the permission gate peels), and
how a user's `transparent_prefixes` entry is read. The meaning of each label
(`LayerKind`) is defined there. Adding a family is one file and a line in
`ALL`, and in `MATCHING` unless the rules also match it whole.
`registry::Walk` drives the walker over the words of the one lex a segment is
cut from and records each layer as a range of words. The decision and its
fall-through search read those ranges, and everything the rewrite decides is
an edit against the segment's own bytes.
Rules the walker and the rewrite implement:
- short options cluster the way getopt reads them (`timeout -vk 5 300`,
`timeout -vs9 300`, `/usr/bin/time -pv`), and `timeout -f` and `-p` are
recognised;
- where a pipeline starts, `if`, `then`, `elif`, `else`, `while`, `until`,
`do` and bash's `time` are reserved words the walk steps over, and a
pipeline starts again behind each (`if git diff --quiet; then git status;
fi` becomes `if rtk git diff --quiet; then rtk git status; fi`, `time -p
time cmd` peels both). `time`'s options are read as bash 5.3 reads them:
`-p`, then `--`, each optional, each compared whole as written, through
the `Grammar` the reserved-word table declares for them, a word running to
a blank, an operator, a redirect, a pipe, `&` or a bracket. A redirect
ends the options too: `time -p>out -- cmd` runs a command named `--`; a
substitution glued to a word is text of it (`time -p<(true) cmd` runs
`-p/dev/fd/63`). Where the options end inside a word the walk takes (`time
-p>out cmd`), no layer can end, so the command is left as written, as it
is behind a redirect that stands apart (`time -p >out cmd`), and no other
tier reads the reserved word as a program. The walk and the reading of the
line's grammar read them through one function. The word behind them is the
command bash runs, so `time -v cmd`, `time -o x cmd` and `time -p -p cmd`
run a command named `-v`, `-o` or `-p` and are left as written: the
rewrite takes no prefix, filter or rule for the GNU program there (the
permission gate reads both, below), and `rtk` is never put inside its
words. `coproc` runs one command and no pipeline, and is no layer. GNU
`time` and its `-f`/`-o`/`-a`/`-v`/`-p`/`-q` grammar apply anywhere else
and through a path;
- a compound command whose parts are command lists and that a reserved word
closes, when it is a pipeline's stage, with a `|` or `|&` right before its
opening word or right after its closing word and the redirections behind
it, writes its output into that pipe or reads its input from it, so every
command in any
of its lists, a `case` arm's included, is left as written, the later ones
of a list as much as the first (`for x in a; do git status; git log; done
| wc -l`, `for x in a; do git status && git log; done | head`, `case x in
a) git log;; esac | wc -l`, `ls | while read x; do git status; done`), as
a subshell's commands are. A compound command or a subshell right after
`coproc` or `coproc NAME` is the coprocess's body, whose input and output
are pipes, and is left as written the same way (`coproc while true; do git
status; done`, `coproc ( git status )`), a name of several tokens being
one word (`coproc NAME${x} ( git status )`). The rule reaches across the
lines of a block: a compound that one line opens and a later one closes
holds the commands between. A compound no pipe joins is rewritten like
any other list (`if true; then git status; fi; ls | head` becomes `if
true; then rtk git status; fi; rtk ls | head`), and a pipeline inside
one of its lists goes through the pipeline rules like any other (`case $y
in (a|b) git status | head;; esac` becomes `case $y in (a|b) rtk git
status | head;; esac`). `lexer::read_nesting` finds the compounds in the
pass that reads the line's grammar;
- `nice` options follow GNU nice: `-n N`, `-N`, `--adjustment=N`; `nice +5
cmd` is left as written, because `+5` is the program nice runs;
- a wrapper's options end at the first operand it requires: `timeout 300 -s
KILL cmd` and `timeout 300 -- cmd` are left as written, because after the
duration `-s` and `--` are the program timeout runs;
- a shell `NAME=value` is an assignment only at the start of a simple command
(the front of a segment, of any pipeline stage, or of a compound command's
list), and one run of assignments and `env` words is one layer. After
`timeout`, `nice`, `nohup`, a precommand word (`exec`, `command`,
`noglob`, ...) or `uv run` it is the program's name, so none of those are
rewritten. `env`'s own operands are its own wherever it sits (`timeout 5
env A=1 cmd` is rewritten), and what follows a user prefix may be an
assignment;
- an unbraced `$NAME` glued to a word (`timeout 5$UNIT cmd`) is one word;
- a wrapper head is read with its quotes and escapes removed (`'timeout' 5
cmd`, `\timeout 5 cmd`), and one that carries an expansion (`$D/timeout`,
or a backquote inside double quotes) never matches; the words of a
multi-word head (`uv run`) may be apart by any spaces or tabs;
- a path-spelled head (`/usr/bin/env`, `/bin/command`, `/usr/bin/uv run`) is
the program at that path, not the word `env`, `command`, `exec`, `noglob`
or `uv run` stands for, so no layer takes it and the command it heads is
left as written; behind a `uv run` under `exclude_commands` it is refused,
as what it runs is out of sight;
- a word behind a subshell's closing `)` in the same simple command, glued to
it or not and at any depth (`(true)git status`, `(true) git status`,
`( (true)git status )`), is left as written, and the subshell inside is
rewritten (`( git status )word`); a case pattern's `)` is not one (`case x
in a)git status;; esac` is rewritten);
- a command word that starts with `-` (a wrapper's own option, or `--`),
quoted or not, is never a command, and a user prefix that would end inside
the words of the layer it covers (`nice` inside `nice -5`, `nice timeout`
inside `nice timeout 5`) is skipped, so `rtk` never lands inside a
wrapper's grammar;
- the `RTK_DISABLED=` warning is printed exactly when a walk of the rewrite
reaches the prefix and the command is refused: the first walk, behind any
layer it peels (`timeout 5 env RTK_DISABLED=1 git status`), a retry behind
a user prefix (`nohup RTK_DISABLED=1 git status` under the prefix `nohup`
warns, as `docker exec c RTK_DISABLED=1 git status` under `docker exec c`
does), or the walk of what a refused fall-through wraps. A quoted
`sh|bash -c` script the rewrite reads, in which a command is refused this
way, warns where the first walk reaches it and where a fall-through
settles on its rewrite. Rewritten around the refused command it settles
the command; left as written it settles nothing, and a wrapper's rule may
still route the line whole (`uv run bash -c 'RTK_DISABLED=1 git status'`
becomes `rtk uv run …` and warns). A fall-through that wins over a walk
that stopped at the prefix draws none, and neither does a producer left
as written before a
consumer that does not take filtered output (`RTK_DISABLED=1 git status |
wc -l`), which no walk reads, nor a `NAME=value` word where no simple
command starts (`timeout 5 RTK_DISABLED=1 git status`, `uv run
RTK_DISABLED=1 git status`), which is a program's name. The warning is
carried on the rewrite's answer and printed from the one run of the
rewrite, ahead of the configuration's and the filter registry's warnings,
which are held until then (a panic inside the rewrite drops the held
warnings with it, and so does a caller that catches the panic). The
marker is the
assignment `RTK_DISABLED=` by name, not a substring (`XRTK_DISABLED=1`,
`MSG='RTK_DISABLED=1'` are not the bypass), and the append form
`RTK_DISABLED+=1` is one only as a shell assignment, not as an `env`
operand. An assignment run with no command behind it is a statement of its
own, not the prefix, and draws none (`RTK_DISABLED=1; git status` rewrites
`git status`). A line that holds `$((` is refused before any walk, so it
draws no warning;
- the decision answers `Excluded` for a command `exclude_commands` covers,
before anything that depends on where the command sits, a quoted shell
script excepted, whose own commands are checked instead. It reads the
command up to the redirections behind it, as typed and as bash runs it,
and a pattern that matches either excludes it. As bash runs it, the words
have their quotes and escapes removed, and are read as they are, with the
program's absolute path left out, or as the tool a rule routes it to (read
off the words bash runs, so `npx "playwright" test` is `playwright`; an
empty argument is kept, so `pytest ''` is not `^pytest$`). A pattern reads
those words with one space between two of them and a blank inside a
word behind a backslash, every other character as it is, so a blank
separates two words where bash separates them. Under
`exclude_commands = ["pytest"]`, `'pytest' -x`, `\pytest -x`, `timeout 5
'pytest' -x` and `'/opt/my tools/pytest' -x` are left as written, also
where a project filter takes every command, and so is `'pytest' 2>&1`
under `^pytest$`; `'pytest x'` runs a program named `pytest x`, which
neither pattern covers;
- `uv run`'s fall-through to its own rule reads what it wraps with the
rewrite's own walk (the same peels, user-prefix retries and depth budget),
and refuses when that walk reaches `RTK_DISABLED=` or a command the
decision excludes; what it wraps as written is checked the same way, by
the same decision. A quoted shell script is decided as a whole, so a
command excluded inside it does not refuse the fall-through (`uv run bash
-c 'pytest'` is routed as before). Under `exclude_commands = ["pytest"]`,
`uv run timeout 5 pytest`, `uv run noglob pytest`, `uv run
/usr/bin/pytest`, `uv run 'pytest'`, `uv run p"ytest" -x`, `uv run
\pytest`, `uv run '/opt/my tools/pytest' -x`, `uv run timeout 5 'pytest'
2>&1`, `uv run uv run pytest`, `uv run time pytest`, `uv run python -m
pytest`, `uv run nice python -m pytest`, and `uv run nice -n 19 ionice
pytest` under the prefix `nice -n 19 ionice` are refused, as `uv run
pytest` and `python -m pytest` are, and so is `uv run 'pytest' 2>&1` under
`^pytest$`, while `uv run 'pytest x'` is not; `uv run timeout 5 env
RTK_DISABLED=1 pytest` is refused, and layers deeper than the walk follows
count as excluded, since nothing shows what they wrap. So does an option
the walk cannot read past, which is `uv`'s own and leaves the command
behind it unread (`uv run --with x pytest`), though a user prefix that
starts at `uv run` is tried. A `--` right behind `uv run` is read through
to the command, so `uv run -- pytest` is refused and `uv run -- cargo
test` is not. A `NAME=value` word right behind `uv run` is a program's
name, and the fall-through refuses it (`uv run RTK_DISABLED=1 git status`,
`uv run A=1 pytest`). Without `exclude_commands` nothing is hidden, and an
option or `--` behind `uv run` leaves the whole string to its rule (`uv
run --with x pytest` becomes `rtk uv run --with x pytest`);
- a user-configured prefix is tried at the start of every layer whose own
command has no rewrite, not only behind `uv run`: `transparent_prefixes =
["nice -n 19 ionice"]` rewrites `nice -n 19 ionice git status`; the
built-in peel wins whenever it succeeds, and a reserved word is never a
prefix's to claim;
- a `transparent_prefixes` entry is read without the `$IFS` bytes at either
end, and one that does not lex into whole words (an unclosed quote or a
trailing backslash), or whose first word is a bash reserved word
(`coproc`, `time`, `if`, ...), which bash reads as grammar where a
command starts, is refused, with a warning each time the rewrite runs;
- a quoted `sh|bash|zsh|fish -c` wrapper is decided wherever a walk
reaches it, its script rewritten one wrapper deep, and a wrapper at the
walk's depth limit is still the command (`exec … exec bash -c '…'`). The
permission gate checks for a wrapper at the words the rewrite decides at,
the walk's and every fall-through's, whether the segment opens a pipeline
or is a later stage of one (`registry::shell_wrapper_positions`), so it
looks wherever the rewrite would reach, and at a pipeline's start a little
further;
- the permission gate reads past prefixes with the same walk over its own
list of families (`families::MATCHING`): the rewrite's, but for `uv run`
and the user's prefixes, which the host does not strip, plus `stdbuf` and
a bare `xargs`. Each word of an assignment run is a reading of its own, and
an assignment is read wherever it sits (`nohup HUSKY=0 git push` is `git
push` to a deny rule). `time` is read both ways, as bash's reserved word
and as the program a later pipeline stage or `sh` runs (`time -v git
push`). A quoted wrapper's script is read the same way and, where a deny
rule exists, one whose prefixes outrun the peel's cap is deferred to the
host;
- every warning the rewrite prints starts with `[rtk] warning:`, as rtk's
other warnings do: the `RTK_DISABLED=` warning (`[rtk] RTK_DISABLED=1
detected` before), a refused `transparent_prefixes` entry, and a trivial or
invalid `exclude_commands` pattern.
Part of #4315
Refs #3230
Refs #1606
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KuSh
force-pushed
the
feat/4315-1-cmdline-walker
branch
from
October 19, 2026 16:38
ab24355 to
50663bc
Compare
`rtk rewrite` renders a deny as exit 2 with no output: rtk's pipeline rules are applied by `rtk hook claude` before the rewrite flow, not by `rtk rewrite`. The exit-code list in FEATURES.md still described a block message on exit 2, and told integrations to read stdout there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First layer of #4315. The wrappers the rewrite looks through (env assignments,
uv run,timeout/time/nice/nohup,command/exec/noglob, reserved words, usertransparent_prefixes) are read by one generic walker over family tables.core/cmdline/family.rsandwalker.rs: the walker peels the families a caller passes and names no tool. Wrapper flags are read throughGrammar;nice -5through.numeric(), and a reserved word's options through two new axis values,SingleDash::Whole(-ppis the flagpp) andAttach::Never(-p=1is the flagp=1).discover/families/: one file per family, each with its own accept/reject table test. Adding a family is one file and a line inALL, and inMATCHINGunless the rules also match it whole.registry::Walkruns the walker over the segment's one lex. Every decision is still an edit on the segment's bytes.rtk discover,rtk gainandrtk sessionare unchanged here; their counting follows in a later PR.Behaviour
timeout -vk 5 300), GNUnice -n N/-N, and options end at the first operand (timeout 300 -s KILL cmdis left as written).if git diff --quiet; then git status; firewrites both commands. Commands inside a compound command that is piped are left as written.time's options are read as bash 5.3 reads them:-p, then--, each compared as written. A redirect ends them (time -p>out -- cmdruns a command named--), and a process substitution is text of its word (time -p<(true) cmdruns-p/dev/fd/63). Where the options end inside a word, the command is left as written.NAME=valueis an assignment only where a simple command starts; aftertimeout,nice,execoruv runit is the program's name.'timeout' 5 cmd); path-spelled ones (/usr/bin/env) are not.exclude_commandsmatches the command as typed and as bash runs it (quotes removed), behind any wrapper,uv runincluded.RTK_DISABLED=warning is printed exactly when the rewrite reaches the assignment, matched by name. Inside a quotedsh|bash -cscript it warns where develop does.families::MATCHING: the rewrite's families butuv run, plusstdbufand a barexargs.timeis read both as bash's reserved word and as GNUtime. A quotedsh|bash -cscript is read the same way, and one whose prefixes outrun the peel's cap is deferred to the host where a deny rule exists.nice +5 git pushandtimeout 5 -- git pushrun a program named+5or--, so a deny rule ongit pushno longer matches them.[rtk] warning:each time the rewrite runs. Every warning the rewrite prints starts with[rtk] warning:(theRTK_DISABLED=one was[rtk] RTK_DISABLED=1 detected).Part of #4315
Refs #3230
Refs #1606
Test plan
cargo fmt --all --check && cargo clippy --all-targets && cargo test --all: 5320 passing, clippy clean, at this commitwincheck.sh(cfg(windows) type-check) cleantimeoption readings checked against bash 5.3 in DockerRTK_DISABLED=: rewrite and warnings identical to develop over 200 commandsrtk discover,rtk gainandrtk sessionagainst the parent on a sample session corpus: to re-run on this headDraft, part of #4315 (layer 1 of the command-line walker). Based on #4401.
🤖 Generated with Claude Code