diff --git a/.github/workflows/security-check.yml b/.github/workflows/security-check.yml index 63f44cbc..75859305 100644 --- a/.github/workflows/security-check.yml +++ b/.github/workflows/security-check.yml @@ -2,7 +2,7 @@ name: Security Check on: pull_request: - branches: [ master, develop ] + branches: [ master ] permissions: contents: read diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 35ad0937..98004448 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.27.2" + ".": "0.28.0" } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5565d7ef..9a9bfd0f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1488,4 +1488,4 @@ When implementing a new command, consider: **Last Updated**: 2026-02-22 **Architecture Version**: 2.2 -**rtk Version**: 0.27.2 +**rtk Version**: 0.28.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 05322d57..682c9d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.28.0](https://github.com/rtk-ai/rtk/compare/v0.27.2...v0.28.0) (2026-03-10) + + +### Features + +* **gt:** add Graphite CLI support ([#290](https://github.com/rtk-ai/rtk/issues/290)) ([7fbc4ef](https://github.com/rtk-ai/rtk/commit/7fbc4ef4b553d5e61feeb6e73d8f6a96b6df3dd9)) +* TOML Part 1 — filter DSL engine + 14 built-in filters ([#349](https://github.com/rtk-ai/rtk/issues/349)) ([adda253](https://github.com/rtk-ai/rtk/commit/adda2537be1fe69625ac280f15e8c8067d08c711)) +* TOML Part 2 — user-global config, shadow warning, rtk init templates, 4 new built-in filters ([#351](https://github.com/rtk-ai/rtk/issues/351)) ([926e6a0](https://github.com/rtk-ai/rtk/commit/926e6a0dd4512c4cbb0f5ac133e60cb6134a3174)) +* TOML Part 3 — 15 additional built-in filters (ping, rsync, dotnet, swift, shellcheck, hadolint, poetry, composer, brew, df, ps, systemctl, yamllint, markdownlint, uv) ([#386](https://github.com/rtk-ai/rtk/issues/386)) ([b71a8d2](https://github.com/rtk-ai/rtk/commit/b71a8d24e2dbd3ff9bb423c849638bfa23830c0b)) + ## [0.27.2](https://github.com/rtk-ai/rtk/compare/v0.27.1...v0.27.2) (2026-03-06) diff --git a/CLAUDE.md b/CLAUDE.md index c287d0ba..5c31d055 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ This is a fork with critical fixes for git argument parsing and modern JavaScrip **Verify correct installation:** ```bash -rtk --version # Should show "rtk 0.27.2" (or newer) +rtk --version # Should show "rtk 0.28.0" (or newer) rtk gain # Should show token savings stats (NOT "command not found") ``` diff --git a/Cargo.lock b/Cargo.lock index 94ad86c2..ed9cd191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -838,7 +838,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.27.2" +version = "0.28.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index e5d63cf2..3b86d995 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.27.2" +version = "0.28.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" diff --git a/README.md b/README.md index 1bb15b77..a749b505 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Download from [releases](https://github.com/rtk-ai/rtk/releases): ### Verify Installation ```bash -rtk --version # Should show "rtk 0.27.2" +rtk --version # Should show "rtk 0.28.0" rtk gain # Should show token savings stats ``` diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index 36f4194b..845c8d16 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -8,6 +8,7 @@ # To add or change rewrite rules, edit the Rust registry — not this file. if ! command -v jq &>/dev/null; then + echo "[rtk] WARNING: jq is not installed. Hook cannot rewrite commands. Install jq: https://jqlang.github.io/jq/download/" >&2 exit 0 fi diff --git a/src/git.rs b/src/git.rs index ea01404a..413359a3 100644 --- a/src/git.rs +++ b/src/git.rs @@ -617,11 +617,14 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<() let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - let formatted = if !stderr.is_empty() && stderr.contains("not a git repository") { - "Not a git repository".to_string() - } else { - format_status_output(&stdout) - }; + if !stderr.is_empty() && stderr.contains("not a git repository") { + let message = "Not a git repository".to_string(); + eprintln!("{}", message); + timer.track("git status", "rtk git status", &raw_output, &message); + std::process::exit(output.status.code().unwrap_or(128)); + } + + let formatted = format_status_output(&stdout); println!("{}", formatted); @@ -1810,4 +1813,41 @@ no changes added to commit (use "git add" and/or "git commit -a") .collect(); assert_eq!(cmd_args, vec!["commit", "--amend", "-m", "new msg"]); } + + #[test] + fn test_git_status_not_a_repo_exits_nonzero() { + // Run rtk git status in a directory that is not a git repo + let tmp = std::env::temp_dir().join("rtk_test_not_a_repo"); + let _ = std::fs::create_dir_all(&tmp); + + // Build the path to the test binary + let bin_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("debug") + .join("rtk"); + let output = std::process::Command::new(&bin_path) + .args(["git", "status"]) + .current_dir(&tmp) + .output() + .expect("Failed to run rtk"); + + // Should exit with non-zero (128 from git) + assert!( + !output.status.success(), + "Expected non-zero exit code for git status outside a repo, got {:?}", + output.status.code() + ); + + // Message should be on stderr, not stdout + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stderr.contains("Not a git repository"), + "Expected 'Not a git repository' on stderr, got stderr={:?}, stdout={:?}", + stderr, + stdout + ); + + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/src/main.rs b/src/main.rs index 067f343e..2ca89466 100644 --- a/src/main.rs +++ b/src/main.rs @@ -737,7 +737,7 @@ enum PnpmCommands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, - /// Build (delegates to next build filter) + /// Build (generic passthrough, no framework-specific filter) Build { /// Additional build arguments #[arg(trailing_var_arg = true, allow_hyphen_values = true)] @@ -1335,7 +1335,10 @@ fn main() -> Result<()> { )?; } PnpmCommands::Build { args } => { - next_cmd::run(&args, cli.verbose)?; + let mut build_args: Vec = vec!["build".into()]; + build_args.extend(args); + let os_args: Vec = build_args.into_iter().map(OsString::from).collect(); + pnpm_cmd::run_passthrough(&os_args, cli.verbose)?; } PnpmCommands::Typecheck { args } => { tsc_cmd::run(&args, cli.verbose)?; diff --git a/src/npm_cmd.rs b/src/npm_cmd.rs index 639cbfde..82679bcd 100644 --- a/src/npm_cmd.rs +++ b/src/npm_cmd.rs @@ -8,7 +8,14 @@ pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result<()> { let mut cmd = Command::new("npm"); cmd.arg("run"); - for arg in args { + // Strip leading "run" to avoid doubling (rtk npm run build → npm run build, not npm run run build) + let effective_args = if args.first().map(|s| s.as_str()) == Some("run") { + &args[1..] + } else { + args + }; + + for arg in effective_args { cmd.arg(arg); } @@ -17,7 +24,7 @@ pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result<()> { } if verbose > 0 { - eprintln!("Running: npm run {}", args.join(" ")); + eprintln!("Running: npm run {}", effective_args.join(" ")); } let output = cmd.output().context("Failed to run npm run")?; @@ -29,8 +36,8 @@ pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result<()> { println!("{}", filtered); timer.track( - &format!("npm run {}", args.join(" ")), - &format!("rtk npm run {}", args.join(" ")), + &format!("npm run {}", effective_args.join(" ")), + &format!("rtk npm run {}", effective_args.join(" ")), &raw, &filtered, ); @@ -100,6 +107,39 @@ npm notice assert!(result.contains("Build completed")); } + #[test] + fn test_strip_leading_run_from_args() { + // When user runs `rtk npm run build`, args = ["run", "build"] + // The "run" should be stripped since cmd.arg("run") already adds it + let args: Vec = vec!["run".into(), "build".into()]; + let effective_args = if args.first().map(|s| s.as_str()) == Some("run") { + &args[1..] + } else { + &args[..] + }; + assert_eq!(effective_args, &["build"]); + + // When user runs `rtk npm build`, args = ["build"] + // No stripping needed + let args2: Vec = vec!["build".into()]; + let effective_args2 = if args2.first().map(|s| s.as_str()) == Some("run") { + &args2[1..] + } else { + &args2[..] + }; + assert_eq!(effective_args2, &["build"]); + + // When user runs `rtk npm run`, args = ["run"] + // Strip "run" → empty args (npm run with no script) + let args3: Vec = vec!["run".into()]; + let effective_args3 = if args3.first().map(|s| s.as_str()) == Some("run") { + &args3[1..] + } else { + &args3[..] + }; + assert!(effective_args3.is_empty()); + } + #[test] fn test_filter_npm_output_empty() { let output = "\n\n\n"; diff --git a/src/telemetry.rs b/src/telemetry.rs index 2e1898b4..86e3ceff 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -3,18 +3,15 @@ use crate::tracking; use sha2::{Digest, Sha256}; use std::path::PathBuf; -const TELEMETRY_URL: &str = match option_env!("RTK_TELEMETRY_URL") { - Some(url) => url, - None => "https://telemetry.rtk-ai.app/ping", -}; +const TELEMETRY_URL: Option<&str> = option_env!("RTK_TELEMETRY_URL"); const TELEMETRY_TOKEN: Option<&str> = option_env!("RTK_TELEMETRY_TOKEN"); const PING_INTERVAL_SECS: u64 = 23 * 3600; // 23 hours /// Send a telemetry ping if enabled and not already sent today. /// Fire-and-forget: errors are silently ignored. pub fn maybe_ping() { - // Empty URL → telemetry disabled - if TELEMETRY_URL.is_empty() { + // No URL compiled in → telemetry disabled + if TELEMETRY_URL.is_none() { return; } @@ -50,28 +47,23 @@ pub fn maybe_ping() { } fn send_ping() -> Result<(), Box> { - let url = TELEMETRY_URL; + let url = TELEMETRY_URL.ok_or("no telemetry URL")?; let device_hash = generate_device_hash(); let version = env!("CARGO_PKG_VERSION").to_string(); let os = std::env::consts::OS.to_string(); let arch = std::env::consts::ARCH.to_string(); - let install_method = detect_install_method(); // Get stats from tracking DB - let (commands_24h, top_commands, savings_pct, tokens_saved_24h, tokens_saved_total) = - get_stats(); + let (commands_24h, top_commands, savings_pct) = get_stats(); let payload = serde_json::json!({ "device_hash": device_hash, "version": version, "os": os, "arch": arch, - "install_method": install_method, "commands_24h": commands_24h, "top_commands": top_commands, "savings_pct": savings_pct, - "tokens_saved_24h": tokens_saved_24h, - "tokens_saved_total": tokens_saved_total, }); let mut req = ureq::post(url).set("Content-Type", "application/json"); @@ -102,62 +94,22 @@ fn generate_device_hash() -> String { format!("{:x}", hasher.finalize()) } -fn get_stats() -> (i64, Vec, Option, i64, i64) { +fn get_stats() -> (i64, Vec, Option) { let tracker = match tracking::Tracker::new() { Ok(t) => t, - Err(_) => return (0, vec![], None, 0, 0), + Err(_) => return (0, vec![], None), }; - let since_24h = chrono::Utc::now() - chrono::Duration::hours(24); - // Get 24h command count and top commands from tracking DB - let commands_24h = tracker.count_commands_since(since_24h).unwrap_or(0); + let commands_24h = tracker + .count_commands_since(chrono::Utc::now() - chrono::Duration::hours(24)) + .unwrap_or(0); let top_commands = tracker.top_commands(5).unwrap_or_default(); let savings_pct = tracker.overall_savings_pct().ok(); - let tokens_saved_24h = tracker.tokens_saved_24h(since_24h).unwrap_or(0); - - let tokens_saved_total = tracker.total_tokens_saved().unwrap_or(0); - - ( - commands_24h, - top_commands, - savings_pct, - tokens_saved_24h, - tokens_saved_total, - ) -} - -/// Detect how RTK was installed by inspecting the binary path. -fn detect_install_method() -> &'static str { - let exe = match std::env::current_exe() { - Ok(p) => p, - Err(_) => return "unknown", - }; - - // Resolve symlinks to find the real binary location - let real_path = std::fs::canonicalize(&exe) - .unwrap_or(exe) - .to_string_lossy() - .to_string(); - - install_method_from_path(&real_path) -} - -fn install_method_from_path(path: &str) -> &'static str { - if path.contains("/Cellar/rtk/") || path.contains("/homebrew/") { - "homebrew" - } else if path.contains("/.cargo/bin/") || path.contains("\\.cargo\\bin\\") { - "cargo" - } else if path.contains("/.local/bin/") || path.contains("\\.local\\bin\\") { - "script" - } else if path.contains("/nix/store/") { - "nix" - } else { - "other" - } + (commands_24h, top_commands, savings_pct) } fn telemetry_marker_path() -> PathBuf { @@ -189,42 +141,4 @@ mod tests { let path = telemetry_marker_path(); assert!(path.to_string_lossy().contains("rtk")); } - - #[test] - fn test_detect_install_method_returns_known_value() { - let method = detect_install_method(); - assert!( - ["homebrew", "cargo", "script", "nix", "other"].contains(&method), - "unexpected install method: {}", - method - ); - } - - #[test] - fn test_install_method_unix_paths() { - assert_eq!(install_method_from_path("/opt/homebrew/Cellar/rtk/0.27.2/bin/rtk"), "homebrew"); - assert_eq!(install_method_from_path("/home/user/.cargo/bin/rtk"), "cargo"); - assert_eq!(install_method_from_path("/home/user/.local/bin/rtk"), "script"); - assert_eq!(install_method_from_path("/nix/store/abc123-rtk/bin/rtk"), "nix"); - assert_eq!(install_method_from_path("/usr/local/bin/rtk"), "other"); - } - - #[test] - fn test_install_method_windows_paths() { - assert_eq!(install_method_from_path(r"C:\Users\dev\.cargo\bin\rtk.exe"), "cargo"); - assert_eq!(install_method_from_path(r"C:\Users\dev\.local\bin\rtk.exe"), "script"); - assert_eq!(install_method_from_path(r"C:\Program Files\rtk\rtk.exe"), "other"); - } - - #[test] - fn test_get_stats_returns_tuple() { - let (cmds, top, pct, saved_24h, saved_total) = get_stats(); - assert!(cmds >= 0); - assert!(top.len() <= 5); - assert!(saved_24h >= 0); - assert!(saved_total >= 0); - if let Some(p) = pct { - assert!(p >= 0.0 && p <= 100.0); - } - } } diff --git a/src/tracking.rs b/src/tracking.rs index 66363a6d..bccf0654 100644 --- a/src/tracking.rs +++ b/src/tracking.rs @@ -925,27 +925,6 @@ impl Tracker { Ok(0.0) } } - - /// Get total tokens saved across all tracked commands (for telemetry). - pub fn total_tokens_saved(&self) -> Result { - let saved: i64 = self.conn.query_row( - "SELECT COALESCE(SUM(saved_tokens), 0) FROM commands", - [], - |row| row.get(0), - )?; - Ok(saved) - } - - /// Get tokens saved in the last 24 hours (for telemetry). - pub fn tokens_saved_24h(&self, since: chrono::DateTime) -> Result { - let ts = since.format("%Y-%m-%dT%H:%M:%S").to_string(); - let saved: i64 = self.conn.query_row( - "SELECT COALESCE(SUM(saved_tokens), 0) FROM commands WHERE timestamp >= ?1", - params![ts], - |row| row.get(0), - )?; - Ok(saved) - } } fn get_db_path() -> Result {