From fe890b332dee53dbf38696c3480db7fc5585ca75 Mon Sep 17 00:00:00 2001 From: Niels Kootstra <545768+nkootstra@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:26:30 +0200 Subject: [PATCH 1/3] fix(stream): passthrough watch commands --- src/cmds/dotnet/dotnet_cmd.rs | 25 ++++-------- src/cmds/git/gh_cmd.rs | 8 +++- src/cmds/go/go_cmd.rs | 28 ++++---------- src/cmds/js/playwright_cmd.rs | 11 ++++++ src/cmds/system/grep_cmd.rs | 6 +-- src/core/runner.rs | 72 +++++++++++++++++++++++++++++++++++ src/core/stream.rs | 12 +++--- src/discover/lexer.rs | 1 + src/discover/registry.rs | 6 +-- src/hooks/constants.rs | 3 ++ src/main.rs | 10 ++++- 11 files changed, 128 insertions(+), 54 deletions(-) diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index a609357111..011f68fe89 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -1,6 +1,7 @@ //! Filters dotnet CLI output — build, test, and format results. use crate::binlog; +use crate::core::runner; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::{resolved_command, truncate}; @@ -76,7 +77,6 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result { anyhow::bail!("dotnet: no subcommand specified"); } - let timer = tracking::TimedExecution::start(); let subcommand = args[0].to_string_lossy().to_string(); let mut cmd = resolved_command("dotnet"); @@ -90,22 +90,13 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result { eprintln!("Running: dotnet {} ...", subcommand); } - let result = exec_capture(&mut cmd) - .with_context(|| format!("Failed to run dotnet {}", subcommand))?; - - let raw = format!("{}\n{}", result.stdout, result.stderr); - - print!("{}", result.stdout); - eprint!("{}", result.stderr); - - timer.track( - &format!("dotnet {}", subcommand), - &format!("rtk dotnet {}", subcommand), - &raw, - &raw, - ); - - Ok(result.exit_code) + runner::run( + cmd, + "dotnet", + &tracking::args_display(args), + runner::RunMode::Passthrough, + runner::RunOptions::default(), + ) } fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Result { diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index 3535fd4ec7..eabb1c58b6 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -433,10 +433,16 @@ fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result for arg in &extra_args { cmd.arg(arg); } + let mut args_display = format!("pr checks {}", pr_number); + if !extra_args.is_empty() { + args_display.push(' '); + args_display.push_str(&extra_args.join(" ")); + } + runner::run_filtered( cmd, "gh", - &format!("pr checks {}", pr_number), + &args_display, format_pr_checks, RunOptions::stdout_only() .early_exit_on_failure() diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index 4ef99daf18..bd17b1439f 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -121,8 +121,6 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result { } } - let timer = tracking::TimedExecution::start(); - let subcommand = args[0].to_string_lossy(); let mut cmd = resolved_command("go"); cmd.arg(&*subcommand); @@ -135,25 +133,13 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result { eprintln!("Running: go {} ...", subcommand); } - let output = cmd - .output() - .with_context(|| format!("Failed to run go {}", subcommand))?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let raw = format!("{}\n{}", stdout, stderr); - - print!("{}", stdout); - eprint!("{}", stderr); - - timer.track( - &format!("go {}", subcommand), - &format!("rtk go {}", subcommand), - &raw, - &raw, // No filtering for unsupported commands - ); - - Ok(exit_code_from_output(&output, "go")) + runner::run( + cmd, + "go", + &tracking::args_display(args), + runner::RunMode::Passthrough, + runner::RunOptions::default(), + ) } /// Detect golangci-lint major version when invoked via `go tool`. diff --git a/src/cmds/js/playwright_cmd.rs b/src/cmds/js/playwright_cmd.rs index bfa41ac278..765c7b9b14 100644 --- a/src/cmds/js/playwright_cmd.rs +++ b/src/cmds/js/playwright_cmd.rs @@ -1,5 +1,6 @@ //! Filters Playwright E2E test output to show only failures. +use crate::core::runner; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::{detect_package_manager, resolved_command, strip_ansi}; @@ -286,6 +287,16 @@ pub fn run(args: &[String], verbose: u8) -> Result { eprintln!("Running: playwright {}", args.join(" ")); } + if args.iter().any(|arg| matches!(arg.as_str(), "codegen" | "show-report" | "--ui")) { + return runner::run( + cmd, + "playwright", + &args.join(" "), + runner::RunMode::Passthrough, + runner::RunOptions::default(), + ); + } + let result = exec_capture(&mut cmd) .context("Failed to run playwright (try: npm install -g playwright)")?; diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 83b1886db2..a21c334227 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -60,10 +60,8 @@ pub fn run( if result.stdout.trim().is_empty() { // Show stderr for errors (bad regex, missing file, etc.) - if exit_code == 2 { - if !result.stderr.trim().is_empty() { - eprintln!("{}", result.stderr.trim()); - } + if exit_code == 2 && !result.stderr.trim().is_empty() { + eprintln!("{}", result.stderr.trim()); } let msg = format!("0 matches for '{}'", pattern); println!("{}", msg); diff --git a/src/core/runner.rs b/src/core/runner.rs index f127a6081d..fa34a472f6 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -59,6 +59,33 @@ pub enum RunMode<'a> { Passthrough, } +fn is_streaming_invocation(tool_name: &str, args_display: &str) -> bool { + let tool = tool_name.split_whitespace().next().unwrap_or(tool_name); + let args: Vec<&str> = args_display.split_whitespace().collect(); + + if args + .iter() + .any(|arg| matches!(*arg, "--watch" | "--watchAll" | "-w")) + { + return true; + } + + let follows = args.iter().any(|arg| matches!(*arg, "--follow" | "-f")); + if follows && matches!(tool, "docker" | "kubectl" | "tail" | "journalctl") { + return true; + } + + (tool == "go" && args.contains(&"run")) + || (tool == "playwright" && args.contains(&"codegen")) + || matches!(tool, "tail" | "nodemon" | "watchman") + || args.iter().any(|arg| { + matches!( + *arg, + "watch" | "dev" | "serve" | "start" | "codegen" | "show-report" + ) + }) +} + pub fn run( mut cmd: Command, tool_name: &str, @@ -71,6 +98,15 @@ pub fn run( match mode { RunMode::Filtered(filter_fn) => { + if is_streaming_invocation(tool_name, args_display) { + let result = + stream::run_streaming(&mut cmd, StdinMode::Inherit, FilterMode::Passthrough) + .with_context(|| format!("Failed to run {}", tool_name))?; + + timer.track_passthrough(&cmd_label, &format!("rtk {} (streaming)", cmd_label)); + return Ok(result.exit_code); + } + let result = stream::run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly) .with_context(|| format!("Failed to run {}", tool_name))?; @@ -199,3 +235,39 @@ pub fn run_streamed( opts, ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_streaming_invocation_detects_watch_flags() { + assert!(is_streaming_invocation("prettier", "--watch .")); + assert!(is_streaming_invocation("gh", "pr checks 123 --watch")); + assert!(is_streaming_invocation("jest", "run -w")); + } + + #[test] + fn test_is_streaming_invocation_detects_follow_flags_for_log_tools() { + assert!(is_streaming_invocation("docker", "logs -f web")); + assert!(is_streaming_invocation("kubectl", "logs --follow web")); + assert!(!is_streaming_invocation("git", "log -f")); + } + + #[test] + fn test_is_streaming_invocation_detects_dev_and_watch_subcommands() { + assert!(is_streaming_invocation("npm", "run dev")); + assert!(is_streaming_invocation("npm", "start")); + assert!(is_streaming_invocation("playwright", "codegen")); + assert!(is_streaming_invocation("dotnet", "watch run")); + assert!(is_streaming_invocation("go", "run .")); + } + + #[test] + fn test_is_streaming_invocation_keeps_finite_commands_filterable() { + assert!(!is_streaming_invocation("npm", "run build")); + assert!(!is_streaming_invocation("gh", "pr checks 123")); + assert!(!is_streaming_invocation("go test", "./...")); + assert!(!is_streaming_invocation("dotnet", "build")); + } +} diff --git a/src/core/stream.rs b/src/core/stream.rs index 7970dfbb3c..30c3492838 100644 --- a/src/core/stream.rs +++ b/src/core/stream.rs @@ -92,6 +92,7 @@ pub struct RegexBlockFilter { } impl RegexBlockFilter { + #[allow(dead_code)] // public helper used by tests and available for future filters pub fn new(tool_name: &str, start_pattern: &str) -> Self { Self { start_re: Regex::new(start_pattern).unwrap_or_else(|e| { @@ -176,6 +177,7 @@ impl Option> StreamFilter for LineFilter { pub enum FilterMode<'a> { Streaming(Box), + #[allow(dead_code)] // retained for buffered filters that need full-output context Buffered(Box String + 'a>), CaptureOnly, Passthrough, @@ -341,7 +343,7 @@ pub fn run_streaming( }; if is_stderr { if !capped_err { - if raw_stderr.len() + line.len() + 1 <= RAW_CAP { + if raw_stderr.len() + line.len() < RAW_CAP { raw_stderr.push_str(&line); raw_stderr.push('\n'); } else { @@ -350,7 +352,7 @@ pub fn run_streaming( } } } else if !capped_out { - if raw_stdout.len() + line.len() + 1 <= RAW_CAP { + if raw_stdout.len() + line.len() < RAW_CAP { raw_stdout.push_str(&line); raw_stdout.push('\n'); } else { @@ -384,7 +386,7 @@ pub fn run_streaming( let mut raw_err = String::new(); let mut capped = false; for line in BufReader::new(stderr).lines().map_while(Result::ok) { - if raw_err.len() + line.len() + 1 <= RAW_CAP { + if raw_err.len() + line.len() < RAW_CAP { raw_err.push_str(&line); raw_err.push('\n'); } else if !capped { @@ -403,7 +405,7 @@ pub fn run_streaming( FilterMode::Streaming(_) => unreachable!("handled by is_streaming branch"), FilterMode::Buffered(filter_fn) => { for line in BufReader::new(stdout).lines().map_while(Result::ok) { - if raw_stdout.len() + line.len() + 1 <= RAW_CAP { + if raw_stdout.len() + line.len() < RAW_CAP { raw_stdout.push_str(&line); raw_stdout.push('\n'); } else if !capped_out { @@ -428,7 +430,7 @@ pub fn run_streaming( } FilterMode::CaptureOnly => { for line in BufReader::new(stdout).lines().map_while(Result::ok) { - if raw_stdout.len() + line.len() + 1 <= RAW_CAP { + if raw_stdout.len() + line.len() < RAW_CAP { raw_stdout.push_str(&line); raw_stdout.push('\n'); } else if !capped_out { diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 8a126530a6..830b794ec1 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -306,6 +306,7 @@ pub fn split_on_operators(cmd: &str, stop_at_pipe: bool) -> Vec<&str> { results } +#[allow(dead_code)] // helper is currently exercised by tests and useful for hook routing pub fn strip_quotes(s: &str) -> String { let chars: Vec = s.chars().collect(); if chars.len() >= 2 diff --git a/src/discover/registry.rs b/src/discover/registry.rs index e40ff08b1b..50c944b15a 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -660,10 +660,8 @@ fn rewrite_segment_inner(seg: &str, excluded: &[ExcludePattern], depth: usize) - if rest.is_empty() { return None; } - return match rewrite_segment_inner(rest, excluded, depth + 1) { - Some(rewritten) => Some(format!("{} {}", prefix, rewritten)), - None => None, - }; + return rewrite_segment_inner(rest, excluded, depth + 1) + .map(|rewritten| format!("{} {}", prefix, rewritten)); } } diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index fcacdb3e52..a1f346e687 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -13,7 +13,10 @@ pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude"; /// Native Rust hook command for Cursor (replaces rtk-rewrite.sh). pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor"; +#[allow(dead_code)] // used by hook-check tests and future opencode integration checks pub const OPENCODE_PLUGIN_PATH: &str = ".config/opencode/plugins/rtk.ts"; +#[allow(dead_code)] // used by hook-check tests pub const CURSOR_DIR: &str = ".cursor"; pub const CODEX_DIR: &str = ".codex"; +#[allow(dead_code)] // used by hook-check tests pub const GEMINI_DIR: &str = ".gemini"; diff --git a/src/main.rs b/src/main.rs index 9cb26fa49b..55d058bece 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2228,8 +2228,14 @@ fn run_cli() -> Result { libc::raise(sig); } unsafe { - libc::signal(libc::SIGINT, handle_signal as libc::sighandler_t); - libc::signal(libc::SIGTERM, handle_signal as libc::sighandler_t); + libc::signal( + libc::SIGINT, + handle_signal as *const () as libc::sighandler_t, + ); + libc::signal( + libc::SIGTERM, + handle_signal as *const () as libc::sighandler_t, + ); } } From e65f48cd8cdf7e547a718e7364b017d237be596f Mon Sep 17 00:00:00 2001 From: Niels Kootstra <545768+nkootstra@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:28:03 +0200 Subject: [PATCH 2/3] chore(cargo): sync lockfile package version --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 7ad9dc981a..d3fb7edb2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.36.0" +version = "0.34.3" dependencies = [ "anyhow", "automod", From 5ddfe1edcf679548ccf51cbec417cd2b1970b6f8 Mon Sep 17 00:00:00 2001 From: Niels Kootstra <545768+nkootstra@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:17:33 +0200 Subject: [PATCH 3/3] fix(stream): scope short watch flag detection --- src/core/runner.rs | 19 ++++++++++++++++++- src/core/tracking.rs | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/core/runner.rs b/src/core/runner.rs index fa34a472f6..30cdedc4fb 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -59,17 +59,25 @@ pub enum RunMode<'a> { Passthrough, } +/// Detect long-running invocations before `RunMode::Filtered` captures output. +/// +/// `args_display` must be structured command arguments, not user-controlled prose. +/// `RunMode::Streamed` and `RunMode::Passthrough` do not call this helper. fn is_streaming_invocation(tool_name: &str, args_display: &str) -> bool { let tool = tool_name.split_whitespace().next().unwrap_or(tool_name); let args: Vec<&str> = args_display.split_whitespace().collect(); if args .iter() - .any(|arg| matches!(*arg, "--watch" | "--watchAll" | "-w")) + .any(|arg| matches!(*arg, "--watch" | "--watchAll")) { return true; } + if args.contains(&"-w") && matches!(tool, "jest" | "vitest" | "webpack" | "nodemon") { + return true; + } + let follows = args.iter().any(|arg| matches!(*arg, "--follow" | "-f")); if follows && matches!(tool, "docker" | "kubectl" | "tail" | "journalctl") { return true; @@ -247,6 +255,13 @@ mod tests { assert!(is_streaming_invocation("jest", "run -w")); } + #[test] + fn test_is_streaming_invocation_scopes_short_watch_flag() { + assert!(is_streaming_invocation("vitest", "run -w")); + assert!(!is_streaming_invocation("wc", "-w src/main.rs")); + assert!(!is_streaming_invocation("cargo", "test -w")); + } + #[test] fn test_is_streaming_invocation_detects_follow_flags_for_log_tools() { assert!(is_streaming_invocation("docker", "logs -f web")); @@ -257,6 +272,7 @@ mod tests { #[test] fn test_is_streaming_invocation_detects_dev_and_watch_subcommands() { assert!(is_streaming_invocation("npm", "run dev")); + assert!(is_streaming_invocation("pnpm", "run dev")); assert!(is_streaming_invocation("npm", "start")); assert!(is_streaming_invocation("playwright", "codegen")); assert!(is_streaming_invocation("dotnet", "watch run")); @@ -267,6 +283,7 @@ mod tests { fn test_is_streaming_invocation_keeps_finite_commands_filterable() { assert!(!is_streaming_invocation("npm", "run build")); assert!(!is_streaming_invocation("gh", "pr checks 123")); + assert!(!is_streaming_invocation("go", "test ./...")); assert!(!is_streaming_invocation("go test", "./...")); assert!(!is_streaming_invocation("dotnet", "build")); } diff --git a/src/core/tracking.rs b/src/core/tracking.rs index 7bb3e575f9..359e4f6885 100644 --- a/src/core/tracking.rs +++ b/src/core/tracking.rs @@ -335,6 +335,7 @@ impl Tracker { Ok(tracker) } + #[cfg(test)] fn init_schema(&self) -> Result<()> { self.conn.execute( "CREATE TABLE IF NOT EXISTS commands (