Skip to content
Open
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 @@ -202,6 +202,8 @@ rtk rubocop # Ruby linting (JSON, -60%+)
### Package Managers
```bash
rtk pnpm list # Compact dependency tree
rtk bun run typecheck # bun scripts (strip banner + $ echo)
rtk bunx tsc # bunx with routing (tsc/eslint/prisma…)
rtk pip list # Python packages (auto-detect uv)
rtk pip outdated # Outdated packages
rtk bundle install # Ruby gems (strip Using lines)
Expand Down
116 changes: 116 additions & 0 deletions src/cmds/js/bun_cmd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Filters bun output, mirroring the npm/npx filters which don't cover bun.
//!
//! `bun run <script>` / `bun test` / `bun x <tool>` and `bunx <tool>` all share
//! the same boilerplate: a `bun run v1.x.x` banner and a `$ <command>` echo line
//! that add no signal once Claude already knows the command it ran.

use crate::core::runner;
use crate::core::utils::resolved_command;
use anyhow::Result;

/// Run `bun <args>` (e.g. `bun run build`, `bun test`, `bun x tsc`) filtered.
///
/// The rewrite layer only routes `bun run|x|test` here, so `args` already starts
/// with the bun subcommand — no "run" injection is needed (and would be wrong,
/// since `bun build` is bun's bundler, not a script run).
pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result<i32> {
run_filtered("bun", args, verbose, skip_env)
}

/// Run a `bunx <tool>` invocation through the same filtered pipeline.
///
/// Used for unrouted tools in the `Commands::Bunx` fallback so that
/// `rtk bunx cowsay hello` dispatches to `bunx`, not `bun`.
pub fn exec(args: &[String], verbose: u8, skip_env: bool) -> Result<i32> {
run_filtered("bunx", args, verbose, skip_env)
}

/// Shared command-execution path for `run` (bun) and `exec` (bunx).
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(),
)
}

/// Filter bun output - strip the `bun run v…` banner, the `$ <cmd>` echo, blanks.
fn filter_bun_output(output: &str) -> String {
let mut result = Vec::new();

for line in output.lines() {
let trimmed = line.trim_start();
// Skip bun's version banner: "bun run v1.1.0", "bun test v1.1.0"
if (trimmed.starts_with("bun run v") || trimmed.starts_with("bun test v"))
&& trimmed.contains(|c: char| c.is_ascii_digit())
{
continue;
}
// Skip the echoed script command: "$ tsc --noEmit"
if trimmed.starts_with("$ ") {
continue;
}
// Skip empty lines
if line.trim().is_empty() {
continue;
}

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

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

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

#[test]
fn test_filter_bun_output() {
let output = r#"bun run v1.1.0
$ tsc --noEmit

src/index.ts(4,7): error TS2322: Type 'number' is not assignable.

"#;
let result = filter_bun_output(output);
assert!(!result.contains("bun run v"));
assert!(!result.contains("$ tsc"));
assert!(result.contains("error TS2322"));
}

#[test]
fn test_filter_bun_test_banner() {
let output = "bun test v1.1.0\n\n 5 pass\n 0 fail\n";
let result = filter_bun_output(output);
assert!(!result.contains("bun test v"));
assert!(result.contains("5 pass"));
}

#[test]
fn test_filter_bun_output_empty() {
let output = "bun run v1.1.0\n$ true\n\n\n";
let result = filter_bun_output(output);
assert_eq!(result, "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
28 changes: 28 additions & 0 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3005,6 +3005,34 @@ mod tests {
);
}

#[test]
fn test_rewrite_bun() {
assert_eq!(
rewrite_command_no_prefixes("bun run typecheck", &[]),
Some("rtk bun run typecheck".to_string()),
);
assert_eq!(
rewrite_command_no_prefixes("bun test", &[]),
Some("rtk bun test".to_string()),
);
assert_eq!(
rewrite_command_no_prefixes("bun x cowsay hi", &[]),
Some("rtk bun x cowsay hi".to_string()),
);
// `bun build` is bun's bundler, not a script run — must NOT be rewritten.
assert_eq!(rewrite_command_no_prefixes("bun build ./src", &[]), None);
// `bun install` must NOT be rewritten.
assert_eq!(rewrite_command_no_prefixes("bun install", &[]), None);
}

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

// --- Gradle ---

#[test]
Expand Down
18 changes: 18 additions & 0 deletions src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,24 @@ pub const RULES: &[RtkRule] = &[
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
pattern: r"^bun\s+(run|x|test)(\s|$)",
rtk_cmd: "rtk bun",
rewrite_prefixes: &["bun"],
category: "PackageManager",
savings_pct: 70.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
pattern: r"^bunx\s+",
rtk_cmd: "rtk bunx",
rewrite_prefixes: &["bunx"],
category: "PackageManager",
savings_pct: 70.0,
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
pattern: r"^(cat|head|tail)\s+",
rtk_cmd: "rtk read",
Expand Down
48 changes: 46 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;
use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd};
Expand Down Expand Up @@ -551,6 +551,20 @@ enum Commands {
args: Vec<String>,
},

/// bun run / bun test / bun x with filtered output (strip boilerplate)
Bun {
/// bun arguments (subcommand + options)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},

/// bunx with intelligent routing (tsc, eslint, prisma -> specialized filters)
Bunx {
/// bunx arguments (command + 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 @@ -2155,6 +2169,36 @@ 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");
}

// Intelligent routing: delegate to specialized filters (mirrors npx).
match args[0].as_str() {
"tsc" | "typescript" => tsc_cmd::run(&args[1..], cli.verbose)?,
"eslint" => lint_cmd::run(&args[1..], cli.verbose)?,
"prisma" => match args.get(1).map(|s| s.as_str()) {
Some("generate") => prisma_cmd::run(
prisma_cmd::PrismaCommand::Generate,
&args[2..],
cli.verbose,
)?,
Some("db") if args.get(2).map(|s| s.as_str()) == Some("push") => {
prisma_cmd::run(prisma_cmd::PrismaCommand::DbPush, &args[3..], cli.verbose)?
}
// Other prisma subcommands run through the bunx filter.
_ => bun_cmd::exec(&args, cli.verbose, cli.skip_env)?,
},
"next" => next_cmd::run(&args[1..], cli.verbose)?,
"prettier" => prettier_cmd::run(&args[1..], cli.verbose)?,
"playwright" => playwright_cmd::run(&args[1..], cli.verbose)?,
_ => 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