Three related issues prevented RTK from working with the Cursor
preToolUse hook on Windows. Each one alone produced the same
user-visible symptom (`Output: {}` and the original command running
unmodified), so they're fixed together:
1. Cursor preToolUse only enforces allow/deny — `permission: "ask"` is
accepted by the schema but ignored at runtime, so `updated_input`
rewrites could silently be dropped. Always return `allow` for
rewritten commands (deny rules still take precedence and are
evaluated before this point).
2. Cursor's preToolUse panel renders the JSON returned by the hook.
Without a `continue: true` field the panel collapses to
`Output: {}` even when the rewrite ran successfully, which makes
the hook look broken from the user's perspective. Every other
Cursor hook (`afterShellExecution`, `beforeSubmitPrompt`, `stop`,
...) returns `continue: true`; mirror that shape here so the panel
surfaces the actual `permission` and `updated_input` payload.
3. Cursor on Windows prepends one or two UTF-8 BOMs (`EF BB BF`,
sometimes doubled) to the JSON it pipes into the hook process.
serde_json refuses to parse BOM-prefixed input, so `run_cursor`
bailed out into the "no command" branch and returned `{}` for
every invocation. Strip leading BOMs before parsing. This was the
root cause of the long-standing "RTK silently no-ops in Cursor"
reports — the rewrite path was never reached.
Tests cover the flat allow output, `continue: true` on both single
and compound (`cd ... && git status`) rewrites, the BOM-strip on
double-BOM payloads matching what Cursor actually sends, and assert
that the legacy hookSpecificOutput envelope is not emitted.
Verified end to end on Windows by tracing real Cursor stdin payloads:
the hook now returns `{"continue":true,"permission":"allow",
"updated_input":{"command":"rtk git status"}}` for `git status` and
the panel renders it instead of `Output: {}`.
When -bench is present, benchmark output is already compact and useful.
Injecting -json causes the filter to discard benchmark results since they
don't produce pass/fail events the JSON parser expects.
Fixes#1609
Command wrappers like shadowenv (https://github.com/Shopify/shadowenv),
`direnv exec .`, `nix develop --command`, `docker exec <container>`,
`poetry run`, and `bundle exec` sit in front of every command in their
respective project layouts. When rtk is driving a Claude Code / Gemini /
Copilot / opencode hook, these wrappers defeat the rewrite — `rtk
rewrite "shadowenv exec -- git status"` returns exit 1 (no rewrite)
because the registry can't see past the wrapper to the inner `git
status`. Every build tool, test runner, linter, and `git` invocation
in such a layout stays uncompressed.
rtk already solves exactly this shape for shell builtins via
SHELL_PREFIX_BUILTINS (`noglob`, `command`, `builtin`, `exec`,
`nocorrect`): strip the prefix, recurse on the inner command, re-prepend
the prefix to the rewrite. This commit extends that pattern with a new
[hooks].transparent_prefixes config field so users can register
multi-word wrappers that don't change what runs, only how.
[hooks]
transparent_prefixes = ["shadowenv exec --", "direnv exec ."]
With that config, `rtk rewrite "shadowenv exec -- cargo test"` becomes
`shadowenv exec -- rtk cargo test` and exits 3 (ask, matching the
existing ask semantics for inner commands).
Implementation:
- New `rewrite_command_with_prefixes(cmd, excluded, transparent_prefixes)`
public entry. The original `rewrite_command(cmd, excluded)` is kept as
a thin back-compat wrapper that passes an empty prefix slice, so the
~150 existing unit-test call sites don't need updating.
- `transparent_prefixes: &[String]` threaded through `rewrite_compound`,
`rewrite_segment`, `rewrite_segment_inner`.
- In `rewrite_segment_inner`, a second strip loop runs after the
SHELL_PREFIX_BUILTINS loop, with identical strip-recurse-reprepend
semantics. Matching is whole-word via the existing `strip_word_prefix`
helper (multi-word prefixes like `"shadowenv exec --"` are handled
by-value — byte-level `starts_with` plus a space check).
- Recursion is bounded by the existing MAX_PREFIX_DEPTH so pathological
or self-referential configs cannot stack-overflow.
- Production callers (`hooks/rewrite_cmd.rs`, `hooks/hook_cmd.rs`,
`main.rs`) now load both `exclude_commands` and `transparent_prefixes`
from config in the same `Config::load()` call — no new I/O.
Tests (14 new):
- config: 3 TOML roundtrip tests (present, missing, mixed with older
fields).
- registry: 11 behavior tests — strip/reprepend, unknown inner returns
None, unmatched passthrough, composed with shell builtin, multiple
prefixes configured, whole-word matching, empty-rest None, blank-entry
skip, compound `&&` with prefix on both segments, excluded-inner
returns None, recursion bounded.
Design principles (from CONTRIBUTING.md):
- Extensibility: extends an existing, documented pattern.
- Zero Overhead: config load already happens on every hook invocation;
this reads one additional vec. Empty `transparent_prefixes` is the
default and adds one cheap loop-over-zero-elements on the hot path.
- Correctness vs Token Savings: without this, hook rewrites silently
miss every command run through a wrapper — exactly the kind of
correctness gap the principle warns against.
- Separate warnings and errors into distinct sections.
- Ensure status line is emitted last for clarity.
- Maintain consistency with native `dotnet` output behavior.
The `ok` / `fail` verdict line was emitted at the top of the filtered
output, before errors and warnings. Consumers reading the tail of the
stream — `| tail -N`, IDE log followers, agent watch/monitor loops,
bounded-context-window readers — saw error noise followed by EOF with
no verdict anywhere near the end, silently breaking any agent loop that
gates the next step on build success.
Reorder `format_build_output`, `format_test_output`, and
`format_restore_output` to emit the body first, then the separator
(only when the body is non-empty), then the verdict header last,
matching native `dotnet` which ends with `Build succeeded.` or
`Build FAILED.`.
Add three regression tests using strict `output.lines().last()` plus a
`tail -5` inclusion assertion so future regressions that append a
trailing context line get caught.
Closes#1574
Signed-off-by: Artiom Tofan <arto@queue-it.com>
new_gradle_command() now uses resolved_command("gradle") for the two
fallback branches (Windows + Unix when no local wrapper is present),
matching how cargo / golangci-lint / etc. resolve system binaries.
Local wrappers (./gradlew, gradlew.bat) stay as string literals — they
are relative paths, not on PATH, and semgrep's dynamic-command-execution
rule needs literals here.
Net effect: PATHEXT-aware resolution on Windows (.CMD/.BAT shims work),
no behavioural change on Unix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract a new_gradle_command() helper that uses string literals in
every Command::new() branch. The .semgrep.yml dynamic-command-execution
rule rejects Command::new(var) — semgrep needs to statically audit
the executable set.
Same runtime behaviour: prefer ./gradlew (or gradlew.bat on Windows),
fall back to gradle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds rtk gradlew command for build, test, lint, and dependency
operations on Gradle projects. Filters task progress noise, preserves
build scan URLs, test failures, lint violations, and compiler warnings.
Recognises ./gradlew, gradlew, gradlew.bat, and gradle invocations.
Surfaces unit-test report paths and shows a progress indicator for
long-running tasks.
Targets 75% savings (90% on test, 80% on build).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Character devices (c), block devices (b), pipes (p), and sockets (s)
were silently dropped by compact_ls because only regular files (-),
symlinks (l), and directories (d) were handled. This caused
`rtk ls /dev/ttyACM*` to return "(empty)".
Closes#844
Follow-up to the initial #1536 fix after a deeper review pass.
- force_tee_hint() is no longer called on the passthrough path. Previously
every non-TTY pipe invocation (curl | jq, curl | python -m json.tool —
the exact scenarios this PR fixes) was still writing a tee log file as a
side effect, which is wasteful: the tee file's purpose is to give the
LLM a recovery path when output is truncated, and we are no longer
truncating in those cases.
- looks_like_json now also matches bare top-level JSON strings (e.g.
/api/token endpoints returning a long quoted token). Previously a
>500-byte bare JSON string on a TTY would still get truncated mid-stream
and produce invalid output.
- FilterResult.content is now Cow<'_, str> so the passthrough path no
longer copies the whole body. For multi-MB curl responses piped through
rtk this eliminates a full-size allocation.
- Exit code from curl is propagated via Ok(result.exit_code) instead of
the previously hardcoded Ok(0). On the success path the value is 0
anyway, but the inconsistency with the early-exit branch above made the
intent unclear.
Tests: 9 -> 11 (added bare-JSON-string passthrough and a Cow::Borrowed
assertion verifying the passthrough paths don't allocate). cargo test
--all: 1693 passed, 6 ignored.
Verified end-to-end against jsonplaceholder.typicode.com/posts (~27 KB,
100 entries, parseable) and a randomly-generated 100-entry / 52 KB / 1450-line
JSON served from a local http.server (rtk output byte-identical to source
modulo the trailing newline added by println!).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`rtk curl` was unconditionally truncating any response >=500 bytes when a
tee hint was available, inserting the literal `... (N bytes total)` marker
mid-stream. For JSON bodies this produces invalid, unparseable output and
silently breaks every downstream `jq`, `python -m json.tool`, or agent
pipeline that consumes `rtk curl`.
Two changes in `filter_curl_output`:
1. JSON heuristic passthrough — if the trimmed body starts/ends with
matching JSON brackets, return it unchanged. The body is already
buffered in memory so the check is essentially free.
2. `is_terminal()` gate — only truncate non-JSON output when stdout is
a TTY. Pipes and shell redirects (`> file`, `| jq`) now receive the
full body, matching what `curl -o file` already does and closing
the silent-data-loss path described in #1282.
The tee file is still written for inspection, but the hint line is
suppressed in the passthrough cases so it never leaks into pipes.
Tests: existing 5 cases updated to pass `is_tty=true` (preserve TTY-mode
assertions); 4 new cases cover JSON object/array passthrough at
>500 bytes and pipe-mode (non-TTY) full-body delivery.
Verified end-to-end against the public repro from the issue:
`rtk curl -s https://jsonplaceholder.typicode.com/posts | python3 -c
'import sys,json; print(len(json.load(sys.stdin)))'` now returns 100.
Closes#1536
Refs #1282
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- **Path Centralization:** Hardcoded directory and file paths across all hook logic () are replaced with dedicated, exported constants in . This prevents magic strings and simplifies maintenance when system directories change.
- **Code Cleanup:** Move all code only used by tests behind cfg(test) attribute
- **Refactoring:** Apply Clippy fixes and address remaining warnings
Signed-off-by: Nicolas Le Cam <niko.lecam@gmail.com>
KuSh: the in-progress state line ("rebase in progress", "merge in
progress. unresolved conflicts", etc.) is enough — LLMs know which git
commands resolve each state, so the per-state hint list was noise.
Removes STATE_HINTS and extract_state_hint, and reduces extract_state_header
to a short walk that returns the first detected state summary or None.
Existing per-state tests now assert the exact compact summary.
- Drop redundant LANG=C env (LC_ALL=C is sufficient).
- Replace nested if/else chains in detect_status_state and
extract_state_hint with const slices + iter for readability.
- Drop the 2-space indent prefix on hint lines — adds nothing for
machine consumption.
Default `rtk git status` runs `git status --porcelain -b` to build its
compact view, but porcelain v1 omits the state header git prints when a
rebase, merge, cherry-pick, revert, bisect, am, or sparse checkout is in
progress. That header is correctness-critical — hiding "You are currently
editing a commit while rebasing..." leaves the user thinking the repo is
clean when it isn't.
`run_status` already captures plain `git status` output as `raw_output`
for tracking, so this fix adds `extract_state_header` to pull the state
block out of it and prepends the block to the compact output. Returns
`None` when nothing is in progress so clean repos are unchanged.
Covered states: interactive / regular rebase, merge-with-unmerged-paths,
"still merging after conflicts fixed", cherry-pick, revert, bisect, am,
sparse checkout. Preserves the directive hints git prints alongside
(`git rebase --continue`, `git commit --amend`, `git bisect reset`,
etc.), while still filtering generic `(use "git add")` / `(use "git
restore")` noise.
Design principle: Correctness vs Token Savings — never hide information
that changes the user's understanding of repo state.
Consolidate stash default handlers to ensure "no local changes"
is properly detected across all stash operations, not just the
default push case.
Signed-off-by: Nicolas Le Cam <niko.lecam@gmail.com>
Addresses three review items from PR #1032:
- Bundle verbose+dry_run into a Clone+Copy InitContext struct (mirrors
RunOptions in src/core/runner.rs). Collapses 25+ function signatures
that already carried both fields and makes future flags one struct
field instead of N signature changes.
- Emit "[dry-run] Nothing written." exactly once from the top-level
run() and uninstall() exit points instead of from every sub-mode.
Fixes the double footer when --agent cursor combined with default
mode.
- Reject --show with --dry-run via clap conflicts_with rather than
silently ignoring --dry-run.
- Add regression tests for run_default_mode and uninstall dry-run paths
using the existing with_claude_dir_override scaffolding.