Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ rtk sbt run # Strip SBT preamble noise
### Package Managers
```bash
rtk pnpm list # Compact dependency tree
rtk bun run build # Bun scripts (strip banner and command echo)
rtk bunx tsc --noEmit # bunx tools with compact output
rtk uv run pytest # Preserve uv env, keep program output
rtk pip list # Python packages (auto-detect uv)
rtk pip outdated # Outdated packages
Expand Down
126 changes: 126 additions & 0 deletions src/cmds/js/bun_cmd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! Filters Bun command output while preserving the Bun/Bunx executable.

use crate::core::runner;
use crate::core::utils::{resolved_command, strip_ansi};
use anyhow::Result;

/// Run `bun <args>` through the Bun output filter.
pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result<i32> {
run_filtered("bun", args, verbose, skip_env)
}

/// Run `bunx <args>` through the Bun output filter.
pub fn exec(args: &[String], verbose: u8, skip_env: bool) -> Result<i32> {
run_filtered("bunx", args, verbose, skip_env)
}

fn run_filtered(name: &str, args: &[String], verbose: u8, skip_env: bool) -> Result<i32> {
let mut cmd = resolved_command(name);
for arg in args {
cmd.arg(arg);
}

if skip_env {
cmd.env("SKIP_ENV_VALIDATION", "1");
}

let args_display = args.join(" ");
if verbose > 0 {
eprintln!("Running: {} {}", name, args_display);
}

runner::run_filtered(
cmd,
name,
&args_display,
filter_bun_output,
runner::RunOptions::default(),
)
}

/// Remove Bun's version banner and the script command it immediately echoes.
fn filter_bun_output(output: &str) -> String {
let mut result = Vec::new();
let mut skip_script_echo = false;

for line in output.lines() {
let clean = strip_ansi(line);
let trimmed = clean.trim_start();

if is_bun_banner(trimmed) {
skip_script_echo = trimmed.starts_with("bun run v");
continue;
}

if skip_script_echo {
if trimmed.is_empty() {
continue;
}
skip_script_echo = false;
if trimmed.starts_with("$ ") {
continue;
}
}

if trimmed.is_empty() {
continue;
}

result.push(line.to_string());
}

if result.is_empty() {
"ok".to_string()
} else {
result.join("\n")
}
}

fn is_bun_banner(line: &str) -> bool {
(line.starts_with("bun run v") || line.starts_with("bun test v"))
&& line.contains(|c: char| c.is_ascii_digit())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn filters_run_banner_and_immediate_script_echo() {
let output = "bun run v1.2.3 (abc123)\n\n$ tsc --noEmit\nsrc/index.ts(1,1): error TS1234: broken\n";

assert_eq!(
filter_bun_output(output),
"src/index.ts(1,1): error TS1234: broken"
);
}

#[test]
fn filters_test_banner_but_keeps_test_output() {
let output = "bun test v1.2.3 (abc123)\n\npass tests/example.test.ts\n 1 pass\n";

assert_eq!(
filter_bun_output(output),
"pass tests/example.test.ts\n 1 pass"
);
}

#[test]
fn keeps_dollar_prefixed_program_output() {
let output = "bun run v1.2.3\n$ node script.js\nstarted\n$ 10.00 total\n";

assert_eq!(filter_bun_output(output), "started\n$ 10.00 total");
}

#[test]
fn recognizes_ansi_colored_banners() {
let output = "\u{1b}[1mbun run v1.2.3\u{1b}[0m\n\u{1b}[2m$ echo ready\u{1b}[0m\nready\n";

assert_eq!(filter_bun_output(output), "ready");
}

#[test]
fn returns_ok_for_boilerplate_only_output() {
assert_eq!(filter_bun_output("bun run v1.2.3\n$ true\n"), "ok");
}
}
4 changes: 2 additions & 2 deletions src/core/tracking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1203,8 +1203,8 @@ fn categorize_command(rtk_cmd: &str) -> String {
match tool {
"git" | "gh" | "gt" => "git",
"cargo" => "cargo",
"npm" | "npx" | "pnpm" | "vitest" | "tsc" | "lint" | "prettier" | "next" | "playwright"
| "prisma" => "js",
"npm" | "npx" | "pnpm" | "bun" | "bunx" | "vitest" | "tsc" | "lint" | "prettier"
| "next" | "playwright" | "prisma" => "js",
"pytest" | "ruff" | "mypy" | "pip" => "python",
"go" | "golangci-lint" => "go",
"docker" | "kubectl" => "cloud",
Expand Down
41 changes: 41 additions & 0 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3613,6 +3613,47 @@ mod tests {
);
}

#[test]
fn test_rewrite_bun_commands() {
let cases = [
(
"bun test src/example.test.ts",
"rtk bun test src/example.test.ts",
),
(
"bun --env-file=.env.test test tests/unit",
"rtk bun --env-file=.env.test test tests/unit",
),
("bun run tsc --noEmit", "rtk bun run tsc --noEmit"),
("bun drizzle-kit generate", "rtk bun drizzle-kit generate"),
("bun", "rtk bun"),
];

for (command, expected) in cases {
assert_eq!(
rewrite_command_no_prefixes(command, &[]),
Some(expected.to_string()),
"Failed for command: {command}"
);
}
}

#[test]
fn test_rewrite_bun_preserves_environment_prefix() {
assert_eq!(
rewrite_command("SMS_PROVIDER=console bun test src/module.test.ts", &[], &[]),
Some("SMS_PROVIDER=console rtk bun test src/module.test.ts".to_string()),
);
}

#[test]
fn test_rewrite_bunx() {
assert_eq!(
rewrite_command_no_prefixes("bunx tsc --noEmit", &[]),
Some("rtk bunx tsc --noEmit".to_string()),
);
}

// --- Gradle ---

#[test]
Expand Down
16 changes: 16 additions & 0 deletions src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ pub const RULES: &[RtkRule] = &[
savings_pct: 70.0,
..RtkRule::DEFAULT
},
RtkRule {
pattern: r"^bun(?:\s|$)",
rtk_cmd: "rtk bun",
rewrite_prefixes: &["bun"],
category: "PackageManager",
savings_pct: 70.0,
..RtkRule::DEFAULT
},
RtkRule {
pattern: r"^bunx\s+",
rtk_cmd: "rtk bunx",
rewrite_prefixes: &["bunx"],
category: "PackageManager",
savings_pct: 70.0,
..RtkRule::DEFAULT
},
RtkRule {
pattern: r"^(cat|head|tail)\s+",
rtk_cmd: "rtk read",
Expand Down
62 changes: 60 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ use cmds::dotnet::{binlog, dotnet_cmd, dotnet_format_report, dotnet_trx};
use cmds::git::{diff_cmd, gh_cmd, git, glab_cmd, gt_cmd};
use cmds::go::{go_cmd, golangci_cmd};
use cmds::js::{
lint_cmd, next_cmd, npm_cmd, playwright_cmd, pnpm_cmd, prettier_cmd, prisma_cmd, tsc_cmd,
vitest_cmd,
bun_cmd, lint_cmd, next_cmd, npm_cmd, playwright_cmd, pnpm_cmd, prettier_cmd, prisma_cmd,
tsc_cmd, vitest_cmd,
};
use cmds::jvm::{gradlew_cmd, mvn_cmd};
use cmds::php::{ecs_cmd, paratest_cmd, pest_cmd, php_cmd, phpstan_cmd, phpunit_cmd, pint_cmd};
Expand Down Expand Up @@ -567,6 +567,20 @@ enum Commands {
args: Vec<String>,
},

/// Bun commands with compact output
Bun {
/// Bun arguments (subcommand, script, and options)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},

/// bunx commands with compact output
Bunx {
/// bunx arguments (command and options)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},

/// Curl with auto-JSON detection and schema output
Curl {
/// Curl arguments (URL + options)
Expand Down Expand Up @@ -2352,6 +2366,15 @@ fn run_cli() -> Result<i32> {
}
}

Commands::Bun { args } => bun_cmd::run(&args, cli.verbose, cli.skip_env)?,

Commands::Bunx { args } => {
if args.is_empty() {
anyhow::bail!("bunx requires a command argument");
}
bun_cmd::exec(&args, cli.verbose, cli.skip_env)?
}

Commands::Ruff { args } => ruff_cmd::run(&args, cli.verbose)?,

Commands::Pytest { args } => pytest_cmd::run(&args, cli.verbose)?,
Expand Down Expand Up @@ -2748,6 +2771,8 @@ fn is_operational_command(cmd: &Commands) -> bool {
| Commands::Cargo { .. }
| Commands::Npm { .. }
| Commands::Npx { .. }
| Commands::Bun { .. }
| Commands::Bunx { .. }
| Commands::Curl { .. }
| Commands::Ruff { .. }
| Commands::Pytest { .. }
Expand Down Expand Up @@ -3131,6 +3156,8 @@ mod tests {
"cargo",
"npm",
"npx",
"bun",
"bunx",
"curl",
"ruff",
"pytest",
Expand Down Expand Up @@ -3578,6 +3605,37 @@ mod tests {
}
}

#[test]
fn test_bun_commands_preserve_arguments() {
let cases = [
vec!["rtk", "bun", "test", "src/example.test.ts"],
vec!["rtk", "bun", "--env-file=.env.test", "test", "tests/unit"],
vec!["rtk", "bun", "run", "tsc", "--noEmit"],
vec!["rtk", "bun", "drizzle-kit", "generate"],
];

for case in cases {
let expected = case[2..]
.iter()
.map(|arg| arg.to_string())
.collect::<Vec<_>>();
let cli = Cli::try_parse_from(case).unwrap();
match cli.command {
Commands::Bun { args } => assert_eq!(args, expected),
_ => panic!("Expected Commands::Bun"),
}
}
}

#[test]
fn test_bunx_preserves_arguments() {
let cli = Cli::try_parse_from(["rtk", "bunx", "tsc", "--noEmit"]).unwrap();
match cli.command {
Commands::Bunx { args } => assert_eq!(args, vec!["tsc", "--noEmit"]),
_ => panic!("Expected Commands::Bunx"),
}
}

#[test]
fn test_init_pi_flag_rejected() {
// --pi has been removed; --agent pi is the canonical form
Expand Down
Loading