From 3480ce5e4ee588033b295a8d136aee1bef153468 Mon Sep 17 00:00:00 2001 From: Adam Powis Date: Thu, 19 Mar 2026 10:53:25 +0000 Subject: [PATCH 01/10] fix(golangci-lint): add v2 compatibility with runtime version detection golangci-lint v2 removed --out-format=json in favour of --output.json.path stdout. Detect the installed major version at runtime via golangci-lint --version and branch on the correct flag and JSON extraction strategy. - Use --output.json.path stdout for v2, --out-format=json for v1 - Extract JSON from first line only on v2 (v2 appends trailing metadata after JSON line) - Deserialise new v2 fields: SourceLines, Severity, Offset (serde default for v1 compat) - Show first source line per linter-file group on v2 for richer context - Always forward stderr to caller (was silently dropped unless --verbose) - Falls back to v1 behaviour on any version detection failure Signed-off-by: Adam Powis --- src/golangci_cmd.rs | 449 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 425 insertions(+), 24 deletions(-) diff --git a/src/golangci_cmd.rs b/src/golangci_cmd.rs index f6a3166c..b0edb677 100644 --- a/src/golangci_cmd.rs +++ b/src/golangci_cmd.rs @@ -4,6 +4,7 @@ use crate::utils::{resolved_command, truncate}; use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::HashMap; +use std::process::Command; #[derive(Debug, Deserialize)] struct Position { @@ -15,6 +16,9 @@ struct Position { #[serde(rename = "Column")] #[allow(dead_code)] column: usize, + #[serde(rename = "Offset", default)] + #[allow(dead_code)] + offset: usize, } #[derive(Debug, Deserialize)] @@ -26,6 +30,11 @@ struct Issue { text: String, #[serde(rename = "Pos")] pos: Position, + #[serde(rename = "SourceLines", default)] + source_lines: Vec, + #[serde(rename = "Severity", default)] + #[allow(dead_code)] + severity: String, } #[derive(Debug, Deserialize)] @@ -34,18 +43,63 @@ struct GolangciOutput { issues: Vec, } +/// Parse major version number from `golangci-lint --version` output. +/// Returns 1 on any failure (safe fallback — v1 behaviour). +fn parse_major_version(version_output: &str) -> u32 { + // Handles: + // "golangci-lint version 1.59.1" + // "golangci-lint has version 2.10.0 built with ..." + for word in version_output.split_whitespace() { + if let Some(major) = word.split('.').next().and_then(|s| s.parse::().ok()) { + if word.contains('.') { + return major; + } + } + } + 1 +} + +/// Run `golangci-lint --version` and return the major version number. +/// Returns 1 on any failure. +fn detect_major_version() -> u32 { + let output = Command::new("golangci-lint").arg("--version").output(); + + match output { + Ok(o) => { + let stdout = String::from_utf8_lossy(&o.stdout); + let stderr = String::from_utf8_lossy(&o.stderr); + let version_text = if stdout.trim().is_empty() { + &*stderr + } else { + &*stdout + }; + parse_major_version(version_text) + } + Err(_) => 1, + } +} + pub fn run(args: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); + let version = detect_major_version(); + let mut cmd = resolved_command("golangci-lint"); - // Force JSON output - let has_format = args - .iter() - .any(|a| a == "--out-format" || a.starts_with("--out-format=")); + // Force JSON output (only if user hasn't specified it) + let has_format = args.iter().any(|a| { + a == "--out-format" + || a.starts_with("--out-format=") + || a == "--output.json.path" + || a.starts_with("--output.json.path=") + }); if !has_format { - cmd.arg("run").arg("--out-format=json"); + if version >= 2 { + cmd.arg("run").arg("--output.json.path").arg("stdout"); + } else { + cmd.arg("run").arg("--out-format=json"); + } } else { cmd.arg("run"); } @@ -55,7 +109,11 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { } if verbose > 0 { - eprintln!("Running: golangci-lint run --out-format=json"); + if version >= 2 { + eprintln!("Running: golangci-lint run --output.json.path stdout"); + } else { + eprintln!("Running: golangci-lint run --out-format=json"); + } } let output = cmd.output().context( @@ -66,12 +124,19 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr); let raw = format!("{}\n{}", stdout, stderr); - let filtered = filter_golangci_json(&stdout); + // v2 outputs JSON on first line + trailing text; v1 outputs just JSON + let json_output = if version >= 2 { + stdout.lines().next().unwrap_or("") + } else { + &*stdout + }; + + let filtered = filter_golangci_json(json_output, version); println!("{}", filtered); - // Include stderr if present (config errors, etc.) - if !stderr.trim().is_empty() && verbose > 0 { + // Always forward stderr (config errors, missing linters, etc.) + if !stderr.trim().is_empty() { eprintln!("{}", stderr.trim()); } @@ -87,9 +152,6 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { match output.status.code() { Some(0) | Some(1) => Ok(()), Some(code) => { - if !stderr.trim().is_empty() { - eprintln!("{}", stderr.trim()); - } std::process::exit(code); } None => { @@ -100,13 +162,12 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { } /// Filter golangci-lint JSON output - group by linter and file -fn filter_golangci_json(output: &str) -> String { +fn filter_golangci_json(output: &str, version: u32) -> String { let result: Result = serde_json::from_str(output); let golangci_output = match result { Ok(o) => o, Err(e) => { - // Fallback if JSON parsing fails return format!( "golangci-lint (JSON parse failed: {})\n{}", e, @@ -137,7 +198,7 @@ fn filter_golangci_json(output: &str) -> String { // Group by file let mut by_file: HashMap<&str, usize> = HashMap::new(); for issue in &issues { - *by_file.entry(&issue.pos.filename).or_insert(0) += 1; + *by_file.entry(issue.pos.filename.as_str()).or_insert(0) += 1; } let mut file_counts: Vec<_> = by_file.iter().collect(); @@ -170,16 +231,33 @@ fn filter_golangci_json(output: &str) -> String { result.push_str(&format!(" {} ({} issues)\n", short_path, count)); // Show top 3 linters in this file - let mut file_linters: HashMap = HashMap::new(); - for issue in issues.iter().filter(|i| &i.pos.filename == *file) { - *file_linters.entry(issue.from_linter.clone()).or_insert(0) += 1; + let mut file_linters: HashMap> = HashMap::new(); + for issue in issues.iter().filter(|i| i.pos.filename.as_str() == **file) { + file_linters + .entry(issue.from_linter.clone()) + .or_default() + .push(issue); } let mut file_linter_counts: Vec<_> = file_linters.iter().collect(); - file_linter_counts.sort_by(|a, b| b.1.cmp(a.1)); - - for (linter, count) in file_linter_counts.iter().take(3) { - result.push_str(&format!(" {} ({})\n", linter, count)); + file_linter_counts.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + + for (linter, linter_issues) in file_linter_counts.iter().take(3) { + result.push_str(&format!(" {} ({})\n", linter, linter_issues.len())); + + // v2 only: show first source line for this linter-file group + if version >= 2 { + if let Some(first_issue) = linter_issues.first() { + if let Some(source_line) = first_issue.source_lines.first() { + let trimmed = source_line.trim(); + let display = match trimmed.char_indices().nth(80) { + Some((i, _)) => &trimmed[..i], + None => trimmed, + }; + result.push_str(&format!(" → {}\n", display)); + } + } + } } } @@ -214,7 +292,7 @@ mod tests { #[test] fn test_filter_golangci_no_issues() { let output = r#"{"Issues":[]}"#; - let result = filter_golangci_json(output); + let result = filter_golangci_json(output, 1); assert!(result.contains("golangci-lint")); assert!(result.contains("No issues found")); } @@ -241,7 +319,7 @@ mod tests { ] }"#; - let result = filter_golangci_json(output); + let result = filter_golangci_json(output, 1); assert!(result.contains("3 issues")); assert!(result.contains("2 files")); assert!(result.contains("errcheck")); @@ -266,4 +344,327 @@ mod tests { ); assert_eq!(compact_path("relative/file.go"), "file.go"); } + + #[test] + fn test_parse_version_v1_format() { + assert_eq!(parse_major_version("golangci-lint version 1.59.1"), 1); + } + + #[test] + fn test_parse_version_v2_format() { + assert_eq!( + parse_major_version("golangci-lint has version 2.10.0 built with go1.26.0 from 95dcb68a on 2026-02-17T13:05:51Z"), + 2 + ); + } + + #[test] + fn test_parse_version_empty_returns_1() { + assert_eq!(parse_major_version(""), 1); + } + + #[test] + fn test_parse_version_malformed_returns_1() { + assert_eq!(parse_major_version("not a version string"), 1); + } + + #[test] + fn test_filter_golangci_v2_fields_parse_cleanly() { + // v2 JSON includes Severity, SourceLines, Offset — must not panic + let output = r#"{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value not checked", + "Severity": "error", + "SourceLines": [" if err := foo(); err != nil {"], + "Pos": {"Filename": "main.go", "Line": 42, "Column": 5, "Offset": 1024} + } + ] +}"#; + let result = filter_golangci_json(output, 2); + assert!(result.contains("errcheck")); + assert!(result.contains("main.go")); + } + + #[test] + fn test_filter_v2_shows_source_lines() { + let output = r#"{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value not checked", + "Severity": "error", + "SourceLines": [" if err := foo(); err != nil {"], + "Pos": {"Filename": "main.go", "Line": 42, "Column": 5, "Offset": 0} + } + ] +}"#; + let result = filter_golangci_json(output, 2); + assert!( + result.contains("→"), + "v2 should show source line with → prefix" + ); + assert!(result.contains("if err := foo()")); + } + + #[test] + fn test_filter_v1_does_not_show_source_lines() { + let output = r#"{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value not checked", + "Severity": "error", + "SourceLines": [" if err := foo(); err != nil {"], + "Pos": {"Filename": "main.go", "Line": 42, "Column": 5, "Offset": 0} + } + ] +}"#; + let result = filter_golangci_json(output, 1); + assert!(!result.contains("→"), "v1 should not show source lines"); + } + + #[test] + fn test_filter_v2_empty_source_lines_graceful() { + let output = r#"{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value not checked", + "Severity": "", + "SourceLines": [], + "Pos": {"Filename": "main.go", "Line": 42, "Column": 5, "Offset": 0} + } + ] +}"#; + let result = filter_golangci_json(output, 2); + assert!(result.contains("errcheck")); + assert!( + !result.contains("→"), + "no source line to show, should degrade gracefully" + ); + } + + #[test] + fn test_filter_v2_source_line_truncated_to_80_chars() { + let long_line = "x".repeat(120); + let output = format!( + r#"{{ + "Issues": [ + {{ + "FromLinter": "lll", + "Text": "line too long", + "Severity": "", + "SourceLines": ["{}"], + "Pos": {{"Filename": "main.go", "Line": 1, "Column": 1, "Offset": 0}} + }} + ] +}}"#, + long_line + ); + let result = filter_golangci_json(&output, 2); + // Content truncated at 80 chars; prefix " → " = 10 bytes (6 spaces + 3-byte arrow + space) + // Total line max = 80 + 10 = 90 bytes + for line in result.lines() { + if line.trim_start().starts_with('→') { + assert!(line.len() <= 90, "source line too long: {}", line.len()); + } + } + } + + #[test] + fn test_filter_v2_source_line_truncated_non_ascii() { + // Japanese characters are 3 bytes each; 30 chars = 90 bytes > 80 bytes naive slice would panic + let long_line = "日".repeat(30); // 30 chars, 90 bytes + let output = format!( + r#"{{ + "Issues": [ + {{ + "FromLinter": "lll", + "Text": "line too long", + "Severity": "", + "SourceLines": ["{}"], + "Pos": {{"Filename": "main.go", "Line": 1, "Column": 1, "Offset": 0}} + }} + ] +}}"#, + long_line + ); + // Should not panic and output should be ≤ 80 chars + let result = filter_golangci_json(&output, 2); + for line in result.lines() { + if line.trim_start().starts_with('→') { + let content = line.trim_start().trim_start_matches('→').trim(); + assert!( + content.chars().count() <= 80, + "content chars: {}", + content.chars().count() + ); + } + } + } + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + #[test] + fn test_golangci_v2_token_savings() { + // Simulate a realistic v2 JSON output with multiple issues + let raw = r#"{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value of `foo` is not checked", + "Severity": "error", + "SourceLines": [ + " if err := foo(); err != nil {", + " return err", + " }" + ], + "Pos": { + "Filename": "pkg/handler/server.go", + "Line": 42, + "Column": 5, + "Offset": 1024 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "errcheck", + "Text": "Error return value of `bar` is not checked", + "Severity": "error", + "SourceLines": [ + " bar()", + " return nil", + "}" + ], + "Pos": { + "Filename": "pkg/handler/server.go", + "Line": 55, + "Column": 2, + "Offset": 2048 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "gosimple", + "Text": "S1003: should replace strings.Index with strings.Contains", + "Severity": "warning", + "SourceLines": [ + " if strings.Index(s, sub) >= 0 {", + " return true", + " }" + ], + "Pos": { + "Filename": "pkg/utils/strings.go", + "Line": 15, + "Column": 2, + "Offset": 512 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "govet", + "Text": "printf: Sprintf format %s has arg of wrong type int", + "Severity": "error", + "SourceLines": [ + " fmt.Sprintf(\"%s\", 42)" + ], + "Pos": { + "Filename": "cmd/main/main.go", + "Line": 10, + "Column": 3, + "Offset": 256 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "unused", + "Text": "func `unusedHelper` is unused", + "Severity": "warning", + "SourceLines": [ + "func unusedHelper() {", + " // implementation", + "}" + ], + "Pos": { + "Filename": "internal/helpers.go", + "Line": 100, + "Column": 1, + "Offset": 4096 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "errcheck", + "Text": "Error return value of `close` is not checked", + "Severity": "error", + "SourceLines": [ + " defer file.Close()" + ], + "Pos": { + "Filename": "pkg/handler/server.go", + "Line": 120, + "Column": 10, + "Offset": 3072 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "gosimple", + "Text": "S1005: should omit nil check", + "Severity": "warning", + "SourceLines": [ + " if m != nil {", + " for k, v := range m {", + " process(k, v)", + " }", + " }" + ], + "Pos": { + "Filename": "pkg/utils/strings.go", + "Line": 45, + "Column": 1, + "Offset": 1536 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + } + ], + "Report": { + "Warnings": [], + "Linters": [ + {"Name": "errcheck", "Enabled": true, "EnabledByDefault": true}, + {"Name": "gosimple", "Enabled": true, "EnabledByDefault": true}, + {"Name": "govet", "Enabled": true, "EnabledByDefault": true}, + {"Name": "unused", "Enabled": true, "EnabledByDefault": true} + ] + } +}"#; + + let filtered = filter_golangci_json(raw, 2); + let savings = 100.0 - (count_tokens(&filtered) as f64 / count_tokens(raw) as f64 * 100.0); + + assert!( + savings >= 60.0, + "Expected ≥60% token savings, got {:.1}%\nFiltered output:\n{}", + savings, + filtered + ); + } } From a10d7359ce198e5d2f2f266588953944827870b6 Mon Sep 17 00:00:00 2001 From: YoubAmj <11021965+youbamj@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:17:55 +0000 Subject: [PATCH 02/10] fix(npx): auto-approve installable fallbacks Signed-off-by: YoubAmj <11021965+youbamj@users.noreply.github.com> --- src/ccusage.rs | 6 +++--- src/main.rs | 4 ++-- src/next_cmd.rs | 4 ++-- src/prisma_cmd.rs | 4 ++-- src/tsc_cmd.rs | 4 ++-- src/utils.rs | 17 +++++++++++++++++ 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/ccusage.rs b/src/ccusage.rs index b49e483d..83255f0b 100644 --- a/src/ccusage.rs +++ b/src/ccusage.rs @@ -4,7 +4,7 @@ //! Claude Code API usage metrics. Handles subprocess execution, JSON parsing, //! and graceful degradation when ccusage is unavailable. -use crate::utils::{resolved_command, tool_exists}; +use crate::utils::{npx_command, resolved_command, tool_exists}; use anyhow::{Context, Result}; use serde::Deserialize; use std::process::Command; @@ -95,7 +95,7 @@ fn build_command() -> Option { } // Fallback: try npx - let npx_check = resolved_command("npx") + let npx_check = npx_command() .arg("ccusage") .arg("--help") .stdout(std::process::Stdio::null()) @@ -103,7 +103,7 @@ fn build_command() -> Option { .status(); if npx_check.map(|s| s.success()).unwrap_or(false) { - let mut cmd = resolved_command("npx"); + let mut cmd = npx_command(); cmd.arg("ccusage"); return Some(cmd); } diff --git a/src/main.rs b/src/main.rs index 0ff5124c..99335868 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1969,7 +1969,7 @@ fn main() -> Result<()> { _ => { // Passthrough other prisma subcommands let timer = tracking::TimedExecution::start(); - let mut cmd = utils::resolved_command("npx"); + let mut cmd = utils::npx_command(); for arg in &args { cmd.arg(arg); } @@ -1986,7 +1986,7 @@ fn main() -> Result<()> { } } else { let timer = tracking::TimedExecution::start(); - let status = utils::resolved_command("npx") + let status = utils::npx_command() .arg("prisma") .status() .context("Failed to run npx prisma")?; diff --git a/src/next_cmd.rs b/src/next_cmd.rs index e958258d..b6e4a466 100644 --- a/src/next_cmd.rs +++ b/src/next_cmd.rs @@ -1,5 +1,5 @@ use crate::tracking; -use crate::utils::{resolved_command, strip_ansi, tool_exists, truncate}; +use crate::utils::{npx_command, resolved_command, strip_ansi, tool_exists, truncate}; use anyhow::{Context, Result}; use regex::Regex; @@ -12,7 +12,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { let mut cmd = if next_exists { resolved_command("next") } else { - let mut c = resolved_command("npx"); + let mut c = npx_command(); c.arg("next"); c }; diff --git a/src/prisma_cmd.rs b/src/prisma_cmd.rs index a82ece07..f45fd92d 100644 --- a/src/prisma_cmd.rs +++ b/src/prisma_cmd.rs @@ -1,5 +1,5 @@ use crate::tracking; -use crate::utils::{resolved_command, tool_exists}; +use crate::utils::{npx_command, resolved_command, tool_exists}; use anyhow::{Context, Result}; use std::process::Command; @@ -30,7 +30,7 @@ fn create_prisma_command() -> Command { if tool_exists("prisma") { resolved_command("prisma") } else { - let mut c = resolved_command("npx"); + let mut c = npx_command(); c.arg("prisma"); c } diff --git a/src/tsc_cmd.rs b/src/tsc_cmd.rs index 0758a149..8a6c8189 100644 --- a/src/tsc_cmd.rs +++ b/src/tsc_cmd.rs @@ -1,5 +1,5 @@ use crate::tracking; -use crate::utils::{resolved_command, tool_exists, truncate}; +use crate::utils::{npx_command, resolved_command, tool_exists, truncate}; use anyhow::{Context, Result}; use regex::Regex; use std::collections::HashMap; @@ -13,7 +13,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { let mut cmd = if tsc_exists { resolved_command("tsc") } else { - let mut c = resolved_command("npx"); + let mut c = npx_command(); c.arg("tsc"); c }; diff --git a/src/utils.rs b/src/utils.rs index c1882fa8..73d6ab94 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -306,6 +306,13 @@ pub fn package_manager_exec(tool: &str) -> Command { } } +/// Build a plain `npx` command that auto-approves first-run package installs. +pub fn npx_command() -> Command { + let mut cmd = resolved_command("npx"); + cmd.arg("-y"); + cmd +} + /// Resolve a binary name to its full path, honoring PATHEXT on Windows. /// /// On Windows, Node.js tools are installed as `.CMD`/`.BAT`/`.PS1` shims. @@ -372,6 +379,16 @@ pub fn tool_exists(name: &str) -> bool { mod tests { use super::*; + #[test] + fn test_npx_command_adds_auto_yes_arg() { + let cmd = npx_command(); + let args: Vec<_> = cmd + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert_eq!(args, vec!["-y"]); + } + #[test] fn test_truncate_short_string() { assert_eq!(truncate("hello", 10), "hello"); From 15d5beb9f70caf1f84e9b506faaf840c70c1cf4e Mon Sep 17 00:00:00 2001 From: YoubAmj <11021965+youbamj@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:19:18 +0000 Subject: [PATCH 03/10] fix: preserve cargo test compile diagnostics Signed-off-by: YoubAmj <11021965+youbamj@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ src/cargo_cmd.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7053624..c3f1c7ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **ruby:** add `ruby_exec()` shared utility for auto-detecting `bundle exec` when Gemfile exists * **ruby:** add discover/rewrite rules for rake, rails, rspec, rubocop, and bundle commands +### Bug Fixes + +* **cargo:** preserve compile diagnostics when `cargo test` fails before any test suites run + ## [0.30.1](https://github.com/rtk-ai/rtk/compare/v0.30.0...v0.30.1) (2026-03-18) diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs index 63eea4b7..eabf8a37 100644 --- a/src/cargo_cmd.rs +++ b/src/cargo_cmd.rs @@ -850,6 +850,18 @@ fn filter_cargo_test(output: &str) -> String { } if result.trim().is_empty() { + let has_compile_errors = output.lines().any(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with("error[") || trimmed.starts_with("error:") + }); + + if has_compile_errors { + let build_filtered = filter_cargo_build(output); + if build_filtered.starts_with("cargo build:") { + return build_filtered.replacen("cargo build:", "cargo test:", 1); + } + } + // Fallback: show last meaningful lines let meaningful: Vec<&str> = output .lines() @@ -1314,6 +1326,29 @@ test result: MALFORMED LINE WITHOUT PROPER FORMAT ); } + #[test] + fn test_filter_cargo_test_compile_error_preserves_error_header() { + let output = r#" Compiling rtk v0.31.0 (/workspace/projects/rtk) +error[E0425]: cannot find value `missing_symbol` in this scope + --> tests/repro_compile_fail.rs:3:13 + | +3 | let _ = missing_symbol; + | ^^^^^^^^^^^^^^ not found in this scope + +For more information about this error, try `rustc --explain E0425`. +error: could not compile `rtk` (test "repro_compile_fail") due to 1 previous error +"#; + let result = filter_cargo_test(output); + assert!(result.contains("cargo test: 1 errors, 0 warnings (1 crates)")); + assert!(result.contains("error[E0425]"), "got: {}", result); + assert!( + result.contains("--> tests/repro_compile_fail.rs:3:13"), + "got: {}", + result + ); + assert!(!result.starts_with('|'), "got: {}", result); + } + #[test] fn test_filter_cargo_clippy_clean() { let output = r#" Checking rtk v0.5.0 From ea6ed0ac74d271b4a01f0eeb699c544144c46b79 Mon Sep 17 00:00:00 2001 From: YoubAmj <11021965+youbamj@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:09:25 +0000 Subject: [PATCH 04/10] docs(changelog): add npx fallback fix entry Signed-off-by: YoubAmj <11021965+youbamj@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7053624..b640bd64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **ruby:** add `ruby_exec()` shared utility for auto-detecting `bundle exec` when Gemfile exists * **ruby:** add discover/rewrite rules for rake, rails, rspec, rubocop, and bundle commands +### Bug Fixes + +* **npx:** auto-approve installable `npx` fallback commands to avoid first-run approval hangs + ## [0.30.1](https://github.com/rtk-ai/rtk/compare/v0.30.0...v0.30.1) (2026-03-18) From 138e91411b4802e445a97429056cca73282d09e1 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Thu, 19 Mar 2026 16:48:44 -0700 Subject: [PATCH 05/10] fix(ruby): use rails test for positional file args in rtk rake rake test ignores positional file arguments and only supports TEST=path for single-file runs. When users pass positional test files (e.g., `rtk rake test file1.rb file2.rb` or `rtk rake test file.rb:15`), select_runner() now switches to `rails test` which handles single files, multiple files, and line-number syntax natively. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Nicholas Lee --- CHANGELOG.md | 4 ++ src/rake_cmd.rs | 117 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7053624..963463b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Bug Fixes + +* **ruby:** use `rails test` instead of `rake test` when positional file args are passed — `rake test` ignores positional files and only supports `TEST=path` + ### Features * **ruby:** add RSpec test runner filter with JSON parsing and text fallback (60%+ reduction) diff --git a/src/rake_cmd.rs b/src/rake_cmd.rs index f6ab62f5..e3fba68f 100644 --- a/src/rake_cmd.rs +++ b/src/rake_cmd.rs @@ -8,11 +8,50 @@ use crate::tracking; use crate::utils::{exit_code_from_output, ruby_exec, strip_ansi}; use anyhow::{Context, Result}; +/// Decide whether to use `rake test` or `rails test` based on args. +/// +/// `rake test` only supports a single file via `TEST=path` and ignores positional +/// file args. When any positional test file paths are detected, we switch to +/// `rails test` which handles single files, multiple files, and line-number +/// syntax (`file.rb:15`) natively. +fn select_runner(args: &[String]) -> (&'static str, Vec) { + let has_test_subcommand = args.first().map_or(false, |a| a == "test"); + if !has_test_subcommand { + return ("rake", args.to_vec()); + } + + let after_test: Vec<&String> = args[1..].iter().collect(); + + let positional_files: Vec<&&String> = after_test + .iter() + .filter(|a| !a.contains('=') && !a.starts_with('-')) + .filter(|a| looks_like_test_path(a)) + .collect(); + + let needs_rails = !positional_files.is_empty(); + + if needs_rails { + ("rails", args.to_vec()) + } else { + ("rake", args.to_vec()) + } +} + +fn looks_like_test_path(arg: &str) -> bool { + let path = arg.split(':').next().unwrap_or(arg); + path.ends_with(".rb") + || path.starts_with("test/") + || path.starts_with("spec/") + || path.contains("_test.rb") + || path.contains("_spec.rb") +} + pub fn run(args: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); - let mut cmd = ruby_exec("rake"); - for arg in args { + let (tool, effective_args) = select_runner(args); + let mut cmd = ruby_exec(tool); + for arg in &effective_args { cmd.arg(arg); } @@ -20,7 +59,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { eprintln!( "Running: {} {}", cmd.get_program().to_string_lossy(), - args.join(" ") + effective_args.join(" ") ); } @@ -438,4 +477,76 @@ NoMethodError: undefined method `blah' assert!(result.contains("ok rake test")); assert!(result.contains("4 runs")); } + + // ── select_runner tests ───────────────────────────── + + fn args(s: &str) -> Vec { + s.split_whitespace().map(String::from).collect() + } + + #[test] + fn test_select_runner_single_file_uses_rake() { + let (tool, _) = select_runner(&args("test TEST=test/models/post_test.rb")); + assert_eq!(tool, "rake"); + } + + #[test] + fn test_select_runner_no_files_uses_rake() { + let (tool, _) = select_runner(&args("test")); + assert_eq!(tool, "rake"); + } + + #[test] + fn test_select_runner_multiple_files_uses_rails() { + let (tool, a) = select_runner(&args( + "test test/models/post_test.rb test/models/user_test.rb", + )); + assert_eq!(tool, "rails"); + assert_eq!( + a, + args("test test/models/post_test.rb test/models/user_test.rb") + ); + } + + #[test] + fn test_select_runner_line_number_uses_rails() { + let (tool, _) = select_runner(&args("test test/models/post_test.rb:15")); + assert_eq!(tool, "rails"); + } + + #[test] + fn test_select_runner_multiple_with_line_numbers() { + let (tool, _) = select_runner(&args( + "test test/models/post_test.rb:15 test/models/user_test.rb:30", + )); + assert_eq!(tool, "rails"); + } + + #[test] + fn test_select_runner_non_test_subcommand_uses_rake() { + let (tool, _) = select_runner(&args("db:migrate")); + assert_eq!(tool, "rake"); + } + + #[test] + fn test_select_runner_single_positional_file_uses_rails() { + let (tool, _) = select_runner(&args("test test/models/post_test.rb")); + assert_eq!(tool, "rails"); + } + + #[test] + fn test_select_runner_flags_not_counted_as_files() { + let (tool, _) = select_runner(&args("test --verbose --seed 12345")); + assert_eq!(tool, "rake"); + } + + #[test] + fn test_looks_like_test_path() { + assert!(looks_like_test_path("test/models/post_test.rb")); + assert!(looks_like_test_path("test/models/post_test.rb:15")); + assert!(looks_like_test_path("spec/models/post_spec.rb")); + assert!(looks_like_test_path("my_file.rb")); + assert!(!looks_like_test_path("--verbose")); + assert!(!looks_like_test_path("12345")); + } } From 53bc81e9e6d3d0876fb1a23dbf6f08bc074b68be Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:09:50 +0100 Subject: [PATCH 06/10] fix(cicd): gete release like tag for pre-release added script to act like release please (release please flag was unclear) added workflow dispatch event + dev like for prelease debug guards for workflow_dispatch (limit to push master for release events) Signed-off-by: aesoft <43991222+aeppling@users.noreply.github.com> --- .github/workflows/cd.yml | 57 +++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 49d52bff..5b01ac30 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,6 +1,7 @@ name: CD on: + workflow_dispatch: push: branches: [develop, master] @@ -18,7 +19,9 @@ jobs: # ═══════════════════════════════════════════════ pre-release: - if: github.ref == 'refs/heads/develop' + if: >- + github.ref == 'refs/heads/develop' + || (github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master') runs-on: ubuntu-latest outputs: tag: ${{ steps.tag.outputs.tag }} @@ -27,16 +30,53 @@ jobs: with: fetch-depth: 0 - - name: Compute pre-release tag + - name: Compute version from commits like release please id: tag run: | - VERSION=$(grep '^version = ' Cargo.toml | head -1 | cut -d'"' -f2) - TAG="v${VERSION}-rc.${{ github.run_number }}" + # ── Find latest stable tag reachable from HEAD ── + LATEST_TAG="" + for t in $(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | grep -v '-'); do + if git merge-base --is-ancestor "$t" HEAD 2>/dev/null; then + LATEST_TAG="$t"; break + fi + done + if [ -z "$LATEST_TAG" ]; then + echo "::error::No stable release tag found in branch history" + exit 1 + fi + LATEST_VERSION="${LATEST_TAG#v}" + echo "Latest ancestor release: $LATEST_TAG" + + # ── Analyse conventional commits since that tag ── + COMMITS=$(git log "${LATEST_TAG}..HEAD" --format="%s") + HAS_BREAKING=$(echo "$COMMITS" | grep -cE '^[a-z]+(\(.+\))?!:' || true) + HAS_FEAT=$(echo "$COMMITS" | grep -cE '^feat(\(.+\))?:' || true) + HAS_FIX=$(echo "$COMMITS" | grep -cE '^fix(\(.+\))?:' || true) + echo "Commits since ${LATEST_TAG} — breaking=$HAS_BREAKING feat=$HAS_FEAT fix=$HAS_FIX" - # Safety: warn if this base version is already released - if git ls-remote --tags origin "refs/tags/v${VERSION}" | grep -q .; then - echo "::warning::v${VERSION} already released. Consider bumping Cargo.toml on develop." + # ── Compute next version (matches release-please observed behaviour) ── + # Pre-1.0 with bump-minor-pre-major: breaking → minor, feat → minor, fix → patch + IFS='.' read -r MAJOR MINOR PATCH <<< "$LATEST_VERSION" + if [ "$MAJOR" -eq 0 ]; then + if [ "$HAS_BREAKING" -gt 0 ] || [ "$HAS_FEAT" -gt 0 ]; then + MINOR=$((MINOR + 1)); PATCH=0 # breaking or feat → minor + else + PATCH=$((PATCH + 1)) # fix only → patch + fi + else + if [ "$HAS_BREAKING" -gt 0 ]; then + MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 # breaking → major + elif [ "$HAS_FEAT" -gt 0 ]; then + MINOR=$((MINOR + 1)); PATCH=0 # feat → minor + else + PATCH=$((PATCH + 1)) # fix → patch + fi fi + VERSION="${MAJOR}.${MINOR}.${PATCH}" + TAG="v${VERSION}-rc.${{ github.run_number }}" + + echo "Next version: $VERSION (from $LATEST_VERSION)" + echo "Pre-release tag: $TAG" # Safety: fail if this exact tag already exists if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then @@ -45,7 +85,6 @@ jobs: fi echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "Pre-release tag: $TAG" build-prerelease: name: Build pre-release @@ -64,7 +103,7 @@ jobs: # ═══════════════════════════════════════════════ release-please: - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/master' && github.event_name == 'push' runs-on: ubuntu-latest outputs: release_created: ${{ steps.release.outputs.release_created }} From 865749438e67f6da7f719d054bf377d857925ad3 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:20:48 +0100 Subject: [PATCH 07/10] fix(cicd): missing doc Signed-off-by: aesoft <43991222+aeppling@users.noreply.github.com> --- .github/workflows/CICD.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CICD.md b/.github/workflows/CICD.md index 071d234a..53776a00 100644 --- a/.github/workflows/CICD.md +++ b/.github/workflows/CICD.md @@ -39,18 +39,19 @@ Trigger: pull_request to develop or master ## Merge to develop — pre-release (cd.yml) -Trigger: push to develop | Concurrency: cancel-in-progress +Trigger: push to develop | workflow_dispatch (not master) | Concurrency: cancel-in-progress ``` ┌──────────────────┐ │ push to develop │ + │ OR dispatch │ └────────┬─────────┘ │ ┌────────▼──────────────────┐ │ pre-release │ - │ read Cargo.toml version │ - │ tag = v{ver}-rc.{run} │ - │ safety: fail if exists │ + │ compute next version │ + │ from conventional commits │ + │ tag = v{next}-rc.{run} │ └────────┬──────────────────┘ │ ┌────────▼──────────────────┐ @@ -74,7 +75,7 @@ Trigger: push to develop | Concurrency: cancel-in-progress ## Merge to master — stable release (cd.yml) -Trigger: push to master | Concurrency: never cancelled +Trigger: push to master (only) | Concurrency: never cancelled ``` ┌──────────────────┐ From 6aa5e90dc466f87c88a2401b4eb2aa0f323379f4 Mon Sep 17 00:00:00 2001 From: Adam Powis Date: Fri, 20 Mar 2026 10:38:10 +0000 Subject: [PATCH 08/10] fix(golangci): use resolved_command for version detection, move test fixture to file Signed-off-by: Adam Powis --- src/golangci_cmd.rs | 149 +--------------------------- tests/fixtures/golangci_v2_json.txt | 144 +++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 147 deletions(-) create mode 100644 tests/fixtures/golangci_v2_json.txt diff --git a/src/golangci_cmd.rs b/src/golangci_cmd.rs index b0edb677..b2fdcd28 100644 --- a/src/golangci_cmd.rs +++ b/src/golangci_cmd.rs @@ -4,7 +4,6 @@ use crate::utils::{resolved_command, truncate}; use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::HashMap; -use std::process::Command; #[derive(Debug, Deserialize)] struct Position { @@ -62,7 +61,7 @@ fn parse_major_version(version_output: &str) -> u32 { /// Run `golangci-lint --version` and return the major version number. /// Returns 1 on any failure. fn detect_major_version() -> u32 { - let output = Command::new("golangci-lint").arg("--version").output(); + let output = resolved_command("golangci-lint").arg("--version").output(); match output { Ok(o) => { @@ -511,151 +510,7 @@ mod tests { #[test] fn test_golangci_v2_token_savings() { - // Simulate a realistic v2 JSON output with multiple issues - let raw = r#"{ - "Issues": [ - { - "FromLinter": "errcheck", - "Text": "Error return value of `foo` is not checked", - "Severity": "error", - "SourceLines": [ - " if err := foo(); err != nil {", - " return err", - " }" - ], - "Pos": { - "Filename": "pkg/handler/server.go", - "Line": 42, - "Column": 5, - "Offset": 1024 - }, - "Replacement": null, - "ExpectNoLint": false, - "ExpectedNoLintLinter": "" - }, - { - "FromLinter": "errcheck", - "Text": "Error return value of `bar` is not checked", - "Severity": "error", - "SourceLines": [ - " bar()", - " return nil", - "}" - ], - "Pos": { - "Filename": "pkg/handler/server.go", - "Line": 55, - "Column": 2, - "Offset": 2048 - }, - "Replacement": null, - "ExpectNoLint": false, - "ExpectedNoLintLinter": "" - }, - { - "FromLinter": "gosimple", - "Text": "S1003: should replace strings.Index with strings.Contains", - "Severity": "warning", - "SourceLines": [ - " if strings.Index(s, sub) >= 0 {", - " return true", - " }" - ], - "Pos": { - "Filename": "pkg/utils/strings.go", - "Line": 15, - "Column": 2, - "Offset": 512 - }, - "Replacement": null, - "ExpectNoLint": false, - "ExpectedNoLintLinter": "" - }, - { - "FromLinter": "govet", - "Text": "printf: Sprintf format %s has arg of wrong type int", - "Severity": "error", - "SourceLines": [ - " fmt.Sprintf(\"%s\", 42)" - ], - "Pos": { - "Filename": "cmd/main/main.go", - "Line": 10, - "Column": 3, - "Offset": 256 - }, - "Replacement": null, - "ExpectNoLint": false, - "ExpectedNoLintLinter": "" - }, - { - "FromLinter": "unused", - "Text": "func `unusedHelper` is unused", - "Severity": "warning", - "SourceLines": [ - "func unusedHelper() {", - " // implementation", - "}" - ], - "Pos": { - "Filename": "internal/helpers.go", - "Line": 100, - "Column": 1, - "Offset": 4096 - }, - "Replacement": null, - "ExpectNoLint": false, - "ExpectedNoLintLinter": "" - }, - { - "FromLinter": "errcheck", - "Text": "Error return value of `close` is not checked", - "Severity": "error", - "SourceLines": [ - " defer file.Close()" - ], - "Pos": { - "Filename": "pkg/handler/server.go", - "Line": 120, - "Column": 10, - "Offset": 3072 - }, - "Replacement": null, - "ExpectNoLint": false, - "ExpectedNoLintLinter": "" - }, - { - "FromLinter": "gosimple", - "Text": "S1005: should omit nil check", - "Severity": "warning", - "SourceLines": [ - " if m != nil {", - " for k, v := range m {", - " process(k, v)", - " }", - " }" - ], - "Pos": { - "Filename": "pkg/utils/strings.go", - "Line": 45, - "Column": 1, - "Offset": 1536 - }, - "Replacement": null, - "ExpectNoLint": false, - "ExpectedNoLintLinter": "" - } - ], - "Report": { - "Warnings": [], - "Linters": [ - {"Name": "errcheck", "Enabled": true, "EnabledByDefault": true}, - {"Name": "gosimple", "Enabled": true, "EnabledByDefault": true}, - {"Name": "govet", "Enabled": true, "EnabledByDefault": true}, - {"Name": "unused", "Enabled": true, "EnabledByDefault": true} - ] - } -}"#; + let raw = include_str!("../tests/fixtures/golangci_v2_json.txt"); let filtered = filter_golangci_json(raw, 2); let savings = 100.0 - (count_tokens(&filtered) as f64 / count_tokens(raw) as f64 * 100.0); diff --git a/tests/fixtures/golangci_v2_json.txt b/tests/fixtures/golangci_v2_json.txt new file mode 100644 index 00000000..959b27f4 --- /dev/null +++ b/tests/fixtures/golangci_v2_json.txt @@ -0,0 +1,144 @@ +{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value of `foo` is not checked", + "Severity": "error", + "SourceLines": [ + " if err := foo(); err != nil {", + " return err", + " }" + ], + "Pos": { + "Filename": "pkg/handler/server.go", + "Line": 42, + "Column": 5, + "Offset": 1024 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "errcheck", + "Text": "Error return value of `bar` is not checked", + "Severity": "error", + "SourceLines": [ + " bar()", + " return nil", + "}" + ], + "Pos": { + "Filename": "pkg/handler/server.go", + "Line": 55, + "Column": 2, + "Offset": 2048 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "gosimple", + "Text": "S1003: should replace strings.Index with strings.Contains", + "Severity": "warning", + "SourceLines": [ + " if strings.Index(s, sub) >= 0 {", + " return true", + " }" + ], + "Pos": { + "Filename": "pkg/utils/strings.go", + "Line": 15, + "Column": 2, + "Offset": 512 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "govet", + "Text": "printf: Sprintf format %s has arg of wrong type int", + "Severity": "error", + "SourceLines": [ + " fmt.Sprintf(\"%s\", 42)" + ], + "Pos": { + "Filename": "cmd/main/main.go", + "Line": 10, + "Column": 3, + "Offset": 256 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "unused", + "Text": "func `unusedHelper` is unused", + "Severity": "warning", + "SourceLines": [ + "func unusedHelper() {", + " // implementation", + "}" + ], + "Pos": { + "Filename": "internal/helpers.go", + "Line": 100, + "Column": 1, + "Offset": 4096 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "errcheck", + "Text": "Error return value of `close` is not checked", + "Severity": "error", + "SourceLines": [ + " defer file.Close()" + ], + "Pos": { + "Filename": "pkg/handler/server.go", + "Line": 120, + "Column": 10, + "Offset": 3072 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "gosimple", + "Text": "S1005: should omit nil check", + "Severity": "warning", + "SourceLines": [ + " if m != nil {", + " for k, v := range m {", + " process(k, v)", + " }", + " }" + ], + "Pos": { + "Filename": "pkg/utils/strings.go", + "Line": 45, + "Column": 1, + "Offset": 1536 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + } + ], + "Report": { + "Warnings": [], + "Linters": [ + {"Name": "errcheck", "Enabled": true, "EnabledByDefault": true}, + {"Name": "gosimple", "Enabled": true, "EnabledByDefault": true}, + {"Name": "govet", "Enabled": true, "EnabledByDefault": true}, + {"Name": "unused", "Enabled": true, "EnabledByDefault": true} + ] + } +} From cd299d21fb38da7631cba170c7d5135983d11a63 Mon Sep 17 00:00:00 2001 From: YoubAmj <11021965+youbamj@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:17:55 +0000 Subject: [PATCH 09/10] fix(npx): auto-approve installable fallbacks Signed-off-by: YoubAmj <11021965+youbamj@users.noreply.github.com> --- src/ccusage.rs | 6 +++--- src/main.rs | 4 ++-- src/next_cmd.rs | 4 ++-- src/prisma_cmd.rs | 4 ++-- src/tsc_cmd.rs | 4 ++-- src/utils.rs | 17 +++++++++++++++++ 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/ccusage.rs b/src/ccusage.rs index b49e483d..83255f0b 100644 --- a/src/ccusage.rs +++ b/src/ccusage.rs @@ -4,7 +4,7 @@ //! Claude Code API usage metrics. Handles subprocess execution, JSON parsing, //! and graceful degradation when ccusage is unavailable. -use crate::utils::{resolved_command, tool_exists}; +use crate::utils::{npx_command, resolved_command, tool_exists}; use anyhow::{Context, Result}; use serde::Deserialize; use std::process::Command; @@ -95,7 +95,7 @@ fn build_command() -> Option { } // Fallback: try npx - let npx_check = resolved_command("npx") + let npx_check = npx_command() .arg("ccusage") .arg("--help") .stdout(std::process::Stdio::null()) @@ -103,7 +103,7 @@ fn build_command() -> Option { .status(); if npx_check.map(|s| s.success()).unwrap_or(false) { - let mut cmd = resolved_command("npx"); + let mut cmd = npx_command(); cmd.arg("ccusage"); return Some(cmd); } diff --git a/src/main.rs b/src/main.rs index 0ff5124c..99335868 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1969,7 +1969,7 @@ fn main() -> Result<()> { _ => { // Passthrough other prisma subcommands let timer = tracking::TimedExecution::start(); - let mut cmd = utils::resolved_command("npx"); + let mut cmd = utils::npx_command(); for arg in &args { cmd.arg(arg); } @@ -1986,7 +1986,7 @@ fn main() -> Result<()> { } } else { let timer = tracking::TimedExecution::start(); - let status = utils::resolved_command("npx") + let status = utils::npx_command() .arg("prisma") .status() .context("Failed to run npx prisma")?; diff --git a/src/next_cmd.rs b/src/next_cmd.rs index e958258d..b6e4a466 100644 --- a/src/next_cmd.rs +++ b/src/next_cmd.rs @@ -1,5 +1,5 @@ use crate::tracking; -use crate::utils::{resolved_command, strip_ansi, tool_exists, truncate}; +use crate::utils::{npx_command, resolved_command, strip_ansi, tool_exists, truncate}; use anyhow::{Context, Result}; use regex::Regex; @@ -12,7 +12,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { let mut cmd = if next_exists { resolved_command("next") } else { - let mut c = resolved_command("npx"); + let mut c = npx_command(); c.arg("next"); c }; diff --git a/src/prisma_cmd.rs b/src/prisma_cmd.rs index a82ece07..f45fd92d 100644 --- a/src/prisma_cmd.rs +++ b/src/prisma_cmd.rs @@ -1,5 +1,5 @@ use crate::tracking; -use crate::utils::{resolved_command, tool_exists}; +use crate::utils::{npx_command, resolved_command, tool_exists}; use anyhow::{Context, Result}; use std::process::Command; @@ -30,7 +30,7 @@ fn create_prisma_command() -> Command { if tool_exists("prisma") { resolved_command("prisma") } else { - let mut c = resolved_command("npx"); + let mut c = npx_command(); c.arg("prisma"); c } diff --git a/src/tsc_cmd.rs b/src/tsc_cmd.rs index 0758a149..8a6c8189 100644 --- a/src/tsc_cmd.rs +++ b/src/tsc_cmd.rs @@ -1,5 +1,5 @@ use crate::tracking; -use crate::utils::{resolved_command, tool_exists, truncate}; +use crate::utils::{npx_command, resolved_command, tool_exists, truncate}; use anyhow::{Context, Result}; use regex::Regex; use std::collections::HashMap; @@ -13,7 +13,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { let mut cmd = if tsc_exists { resolved_command("tsc") } else { - let mut c = resolved_command("npx"); + let mut c = npx_command(); c.arg("tsc"); c }; diff --git a/src/utils.rs b/src/utils.rs index c1882fa8..73d6ab94 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -306,6 +306,13 @@ pub fn package_manager_exec(tool: &str) -> Command { } } +/// Build a plain `npx` command that auto-approves first-run package installs. +pub fn npx_command() -> Command { + let mut cmd = resolved_command("npx"); + cmd.arg("-y"); + cmd +} + /// Resolve a binary name to its full path, honoring PATHEXT on Windows. /// /// On Windows, Node.js tools are installed as `.CMD`/`.BAT`/`.PS1` shims. @@ -372,6 +379,16 @@ pub fn tool_exists(name: &str) -> bool { mod tests { use super::*; + #[test] + fn test_npx_command_adds_auto_yes_arg() { + let cmd = npx_command(); + let args: Vec<_> = cmd + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert_eq!(args, vec!["-y"]); + } + #[test] fn test_truncate_short_string() { assert_eq!(truncate("hello", 10), "hello"); From 37386a35211db39569af181a029051fa0bfc4564 Mon Sep 17 00:00:00 2001 From: YoubAmj <11021965+youbamj@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:09:25 +0000 Subject: [PATCH 10/10] docs(changelog): add npx fallback fix entry Signed-off-by: YoubAmj <11021965+youbamj@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6efe17d5..440f8f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes * **cargo:** preserve compile diagnostics when `cargo test` fails before any test suites run +* **npx:** auto-approve installable `npx` fallback commands to avoid first-run approval hangs ## [0.30.1](https://github.com/rtk-ai/rtk/compare/v0.30.0...v0.30.1) (2026-03-18)