From 4d799b0e0af5aaf654a8b89edf26c25bcdeac6b3 Mon Sep 17 00:00:00 2001 From: patrick szymkowiak <52030887+pszymkowiak@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:41:44 +0100 Subject: [PATCH 1/8] fix: remove all decorative emojis from CLI output (#687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove decorative emojis from CLI output (#511) Replace decorative emojis with plain text to reduce token waste. Keep functional symbols (⚠️ ✓ ❌ ✅ ℹ️) that convey meaning in fewer tokens. Signed-off-by: Patrick Szymkowiak Signed-off-by: Patrick szymkowiak * fix: remove remaining decorative emojis from find_cmd and formatter Missed in initial emoji cleanup pass: 📁 in find_cmd.rs and parser/formatter.rs Signed-off-by: Patrick szymkowiak * fix: remove all decorative emojis from CLI output (#511) Replace emojis with plain text tokens across all production files for better LLM compatibility. Test fixtures and external tool detection patterns (e.g. Black's "All done!") are preserved. Signed-off-by: Patrick Szymkowiak Signed-off-by: Patrick szymkowiak * fix: remove last decorative emoji from next_cmd.rs Remove ⚡ from Next.js Build header, missed in previous passes. Signed-off-by: Patrick szymkowiak * fix: remove remaining emojis from gh_cmd.rs and init.rs Replace production emojis: - gh_cmd.rs: 🟣→[merged], ⚪→[unknown]/[pending], ⭐→removed, 🔱→removed - init.rs: ⚪→[--] for "not found" status indicators Signed-off-by: Patrick szymkowiak * fix: remove all checkmark emojis from CLI output Replace ✓ (U+2713) with plain text across 19 files: - "ok ✓" → "ok" (git add/commit/push/pull) - "✓ cargo test: ..." → "cargo test: ..." (all tool summaries) - Preserved ✓ in input detection patterns and test fixtures LLMs cannot interpret emoji semantics; plain text is clearer. Signed-off-by: Patrick szymkowiak --------- Signed-off-by: Patrick Szymkowiak Signed-off-by: Patrick szymkowiak Signed-off-by: Patrick Szymkowiak --- src/cargo_cmd.rs | 57 ++++++++++++------------ src/cc_economics.rs | 10 ++--- src/ccusage.rs | 6 +-- src/container.rs | 60 +++++++++++++------------ src/deps.rs | 10 ++--- src/diff_cmd.rs | 8 ++-- src/display_helpers.rs | 14 +++--- src/env_cmd.rs | 12 ++--- src/find_cmd.rs | 2 +- src/format_cmd.rs | 11 ++--- src/gain.rs | 6 +-- src/gh_cmd.rs | 97 +++++++++++++++++++---------------------- src/git.rs | 64 +++++++++++++-------------- src/go_cmd.rs | 18 ++++---- src/golangci_cmd.rs | 4 +- src/grep_cmd.rs | 6 +-- src/init.rs | 79 +++++++++++++++++---------------- src/lint_cmd.rs | 12 ++--- src/log_cmd.rs | 12 ++--- src/ls.rs | 4 +- src/main.rs | 8 ++-- src/next_cmd.rs | 10 ++--- src/npm_cmd.rs | 4 +- src/parser/README.md | 2 +- src/parser/formatter.rs | 20 ++++----- src/pip_cmd.rs | 5 +-- src/pnpm_cmd.rs | 4 +- src/prettier_cmd.rs | 10 ++--- src/prisma_cmd.rs | 16 +++---- src/pytest_cmd.rs | 8 ++-- src/ruff_cmd.rs | 16 +++---- src/runner.rs | 10 ++--- src/summary.rs | 36 +++++++-------- src/trust.rs | 6 +-- src/tsc_cmd.rs | 2 +- src/vitest_cmd.rs | 2 +- src/wget_cmd.rs | 32 +++++--------- 37 files changed, 334 insertions(+), 349 deletions(-) diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs index 3e963698..63eea4b7 100644 --- a/src/cargo_cmd.rs +++ b/src/cargo_cmd.rs @@ -264,7 +264,7 @@ fn filter_cargo_install(output: &str) -> String { // Already installed / up to date if already_installed { let info = ignored_line.split('`').nth(1).unwrap_or(&ignored_line); - return format!("✓ cargo install: {} already installed", info); + return format!("cargo install: {} already installed", info); } // Errors @@ -313,10 +313,7 @@ fn filter_cargo_install(output: &str) -> String { // Success let crate_info = format_crate_info(&installed_crate, &installed_version, "package"); - let mut result = format!( - "✓ cargo install ({}, {} deps compiled)", - crate_info, compiled - ); + let mut result = format!("cargo install ({}, {} deps compiled)", crate_info, compiled); for line in &replaced_lines { result.push_str(&format!("\n {}", line)); @@ -502,7 +499,7 @@ fn filter_cargo_nextest(output: &str) -> String { } else { format!("{}, {}s", binary_text, duration) }; - return format!("✓ cargo nextest: {} ({})", parts.join(", "), meta); + return format!("cargo nextest: {} ({})", parts.join(", "), meta); } // With failures - show failure details then summary @@ -625,7 +622,7 @@ fn filter_cargo_build(output: &str) -> String { } if error_count == 0 && warnings == 0 { - return format!("✓ cargo build ({} crates compiled)", compiled); + return format!("cargo build ({} crates compiled)", compiled); } let mut result = String::new(); @@ -739,11 +736,11 @@ impl AggregatedTestResult { if self.has_duration { format!( - "✓ cargo test: {} ({}, {:.2}s)", + "cargo test: {} ({}, {:.2}s)", counts, suite_text, self.duration_secs ) } else { - format!("✓ cargo test: {} ({})", counts, suite_text) + format!("cargo test: {} ({})", counts, suite_text) } } } @@ -831,7 +828,7 @@ fn filter_cargo_test(output: &str) -> String { // Fallback: use original behavior if regex failed for line in &summary_lines { - result.push_str(&format!("✓ {}\n", line)); + result.push_str(&format!("{}\n", line)); } return result.trim().to_string(); } @@ -931,7 +928,7 @@ fn filter_cargo_clippy(output: &str) -> String { } if error_count == 0 && warning_count == 0 { - return "✓ cargo clippy: No issues found".to_string(); + return "cargo clippy: No issues found".to_string(); } let mut result = String::new(); @@ -1103,7 +1100,7 @@ mod tests { Finished dev [unoptimized + debuginfo] target(s) in 15.23s "#; let result = filter_cargo_build(output); - assert!(result.contains("✓ cargo build")); + assert!(result.contains("cargo build")); assert!(result.contains("3 crates compiled")); } @@ -1139,7 +1136,7 @@ test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; fin "#; let result = filter_cargo_test(output); assert!( - result.contains("✓ cargo test: 15 passed (1 suite, 0.01s)"), + result.contains("cargo test: 15 passed (1 suite, 0.01s)"), "Expected compact format, got: {}", result ); @@ -1196,7 +1193,7 @@ test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; fin "#; let result = filter_cargo_test(output); assert!( - result.contains("✓ cargo test: 137 passed (4 suites, 1.45s)"), + result.contains("cargo test: 137 passed (4 suites, 1.45s)"), "Expected aggregated format, got: {}", result ); @@ -1260,7 +1257,7 @@ test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; fini "#; let result = filter_cargo_test(output); assert!( - result.contains("✓ cargo test: 0 passed (3 suites, 0.00s)"), + result.contains("cargo test: 0 passed (3 suites, 0.00s)"), "Expected compact format for zero tests, got: {}", result ); @@ -1280,7 +1277,7 @@ test result: ok. 18 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; fin "#; let result = filter_cargo_test(output); assert!( - result.contains("✓ cargo test: 63 passed, 5 ignored, 2 filtered out (2 suites, 0.70s)"), + result.contains("cargo test: 63 passed, 5 ignored, 2 filtered out (2 suites, 0.70s)"), "Expected compact format with ignored and filtered, got: {}", result ); @@ -1295,7 +1292,7 @@ test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; fin "#; let result = filter_cargo_test(output); assert!( - result.contains("✓ cargo test: 15 passed (1 suite, 0.01s)"), + result.contains("cargo test: 15 passed (1 suite, 0.01s)"), "Expected singular 'suite', got: {}", result ); @@ -1309,9 +1306,9 @@ running 15 tests test result: MALFORMED LINE WITHOUT PROPER FORMAT "#; let result = filter_cargo_test(output); - // Should fallback to original behavior (show line with checkmark) + // Should fallback to original behavior (show line without checkmark) assert!( - result.contains("✓ test result: MALFORMED"), + result.contains("test result: MALFORMED"), "Expected fallback format, got: {}", result ); @@ -1323,7 +1320,7 @@ test result: MALFORMED LINE WITHOUT PROPER FORMAT Finished dev [unoptimized + debuginfo] target(s) in 1.53s "#; let result = filter_cargo_clippy(output); - assert!(result.contains("✓ cargo clippy: No issues found")); + assert!(result.contains("cargo clippy: No issues found")); } #[test] @@ -1366,7 +1363,7 @@ warning: `rtk` (bin) generated 2 warnings Replaced package `rtk v0.9.4` with `rtk v0.11.0` (/Users/user/.cargo/bin/rtk) "#; let result = filter_cargo_install(output); - assert!(result.contains("✓ cargo install"), "got: {}", result); + assert!(result.contains("cargo install"), "got: {}", result); assert!(result.contains("rtk v0.11.0"), "got: {}", result); assert!(result.contains("5 deps compiled"), "got: {}", result); assert!(result.contains("Replaced"), "got: {}", result); @@ -1383,7 +1380,7 @@ warning: `rtk` (bin) generated 2 warnings Replaced package `rtk v0.9.4` with `rtk v0.11.0` (/Users/user/.cargo/bin/rtk) "#; let result = filter_cargo_install(output); - assert!(result.contains("✓ cargo install"), "got: {}", result); + assert!(result.contains("cargo install"), "got: {}", result); assert!(result.contains("Replacing"), "got: {}", result); assert!(result.contains("Replaced"), "got: {}", result); } @@ -1428,7 +1425,7 @@ error: aborting due to 1 previous error #[test] fn test_filter_cargo_install_empty_output() { let result = filter_cargo_install(""); - assert!(result.contains("✓ cargo install"), "got: {}", result); + assert!(result.contains("cargo install"), "got: {}", result); assert!(result.contains("0 deps compiled"), "got: {}", result); } @@ -1442,7 +1439,7 @@ error: aborting due to 1 previous error warning: be sure to add `/Users/user/.cargo/bin` to your PATH "#; let result = filter_cargo_install(output); - assert!(result.contains("✓ cargo install"), "got: {}", result); + assert!(result.contains("cargo install"), "got: {}", result); assert!( result.contains("be sure to add"), "PATH warning should be kept: {}", @@ -1492,7 +1489,7 @@ error: aborting due to 2 previous errors Installing rtk v0.11.0 "#; let result = filter_cargo_install(output); - assert!(result.contains("✓ cargo install"), "got: {}", result); + assert!(result.contains("cargo install"), "got: {}", result); assert!(!result.contains("Locking"), "got: {}", result); assert!(!result.contains("Blocking"), "got: {}", result); assert!(!result.contains("Downloading"), "got: {}", result); @@ -1506,7 +1503,7 @@ error: aborting due to 2 previous errors "#; let result = filter_cargo_install(output); // Path-based install: crate info not extracted from path - assert!(result.contains("✓ cargo install"), "got: {}", result); + assert!(result.contains("cargo install"), "got: {}", result); assert!(result.contains("1 deps compiled"), "got: {}", result); } @@ -1532,7 +1529,7 @@ error: aborting due to 2 previous errors "#; let result = filter_cargo_nextest(output); assert_eq!( - result, "✓ cargo nextest: 301 passed (1 binary, 0.192s)", + result, "cargo nextest: 301 passed (1 binary, 0.192s)", "got: {}", result ); @@ -1617,7 +1614,7 @@ error: test run failed "#; let result = filter_cargo_nextest(output); assert_eq!( - result, "✓ cargo nextest: 50 passed, 3 skipped (2 binaries, 0.500s)", + result, "cargo nextest: 50 passed, 3 skipped (2 binaries, 0.500s)", "got: {}", result ); @@ -1668,7 +1665,7 @@ error: test run failed "#; let result = filter_cargo_nextest(output); assert_eq!( - result, "✓ cargo nextest: 100 passed (5 binaries, 1.234s)", + result, "cargo nextest: 100 passed (5 binaries, 1.234s)", "got: {}", result ); @@ -1703,7 +1700,7 @@ error: test run failed result ); assert!( - result.contains("✓ cargo nextest: 10 passed"), + result.contains("cargo nextest: 10 passed"), "got: {}", result ); diff --git a/src/cc_economics.rs b/src/cc_economics.rs index cf135ac3..6f50f677 100644 --- a/src/cc_economics.rs +++ b/src/cc_economics.rs @@ -250,7 +250,7 @@ fn merge_weekly(cc: Option>, rtk: Vec) -> Vec m, None => { - eprintln!("⚠️ Invalid week_start format: {}", entry.week_start); + eprintln!("[warn] Invalid week_start format: {}", entry.week_start); continue; } }; @@ -442,7 +442,7 @@ fn display_summary(tracker: &Tracker, verbose: u8) -> Result<()> { let totals = compute_totals(&periods); - println!("💰 Claude Code Economics"); + println!("[cost] Claude Code Economics"); println!("════════════════════════════════════════════════════"); println!(); @@ -550,7 +550,7 @@ fn display_daily(tracker: &Tracker, verbose: u8) -> Result<()> { .context("Failed to load daily token savings from database")?; let periods = merge_daily(cc_daily, rtk_daily); - println!("📅 Daily Economics"); + println!("Daily Economics"); println!("════════════════════════════════════════════════════"); print_period_table(&periods, verbose); Ok(()) @@ -564,7 +564,7 @@ fn display_weekly(tracker: &Tracker, verbose: u8) -> Result<()> { .context("Failed to load weekly token savings from database")?; let periods = merge_weekly(cc_weekly, rtk_weekly); - println!("📅 Weekly Economics"); + println!("Weekly Economics"); println!("════════════════════════════════════════════════════"); print_period_table(&periods, verbose); Ok(()) @@ -578,7 +578,7 @@ fn display_monthly(tracker: &Tracker, verbose: u8) -> Result<()> { .context("Failed to load monthly token savings from database")?; let periods = merge_monthly(cc_monthly, rtk_monthly); - println!("📅 Monthly Economics"); + println!("Monthly Economics"); println!("════════════════════════════════════════════════════"); print_period_table(&periods, verbose); Ok(()) diff --git a/src/ccusage.rs b/src/ccusage.rs index d9ca8668..b49e483d 100644 --- a/src/ccusage.rs +++ b/src/ccusage.rs @@ -126,7 +126,7 @@ pub fn fetch(granularity: Granularity) -> Result>> { let mut cmd = match build_command() { Some(cmd) => cmd, None => { - eprintln!("⚠️ ccusage not found. Install: npm i -g ccusage (or use npx ccusage)"); + eprintln!("[warn] ccusage not found. Install: npm i -g ccusage (or use npx ccusage)"); return Ok(None); } }; @@ -146,7 +146,7 @@ pub fn fetch(granularity: Granularity) -> Result>> { let output = match output { Err(e) => { - eprintln!("⚠️ ccusage execution failed: {}", e); + eprintln!("[warn] ccusage execution failed: {}", e); return Ok(None); } Ok(o) => o, @@ -155,7 +155,7 @@ pub fn fetch(granularity: Granularity) -> Result>> { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!( - "⚠️ ccusage exited with {}: {}", + "[warn] ccusage exited with {}: {}", output.status, stderr.trim() ); diff --git a/src/container.rs b/src/container.rs index ee8d4268..e609de0c 100644 --- a/src/container.rs +++ b/src/container.rs @@ -53,14 +53,14 @@ fn docker_ps(_verbose: u8) -> Result<()> { let mut rtk = String::new(); if stdout.trim().is_empty() { - rtk.push_str("🐳 0 containers"); + rtk.push_str("[docker] 0 containers"); println!("{}", rtk); timer.track("docker ps", "rtk docker ps", &raw, &rtk); return Ok(()); } let count = stdout.lines().count(); - rtk.push_str(&format!("🐳 {} containers:\n", count)); + rtk.push_str(&format!("[docker] {} containers:\n", count)); for line in stdout.lines().take(15) { let parts: Vec<&str> = line.split('\t').collect(); @@ -119,7 +119,7 @@ fn docker_images(_verbose: u8) -> Result<()> { let mut rtk = String::new(); if lines.is_empty() { - rtk.push_str("🐳 0 images"); + rtk.push_str("[docker] 0 images"); println!("{}", rtk); timer.track("docker images", "rtk docker images", &raw, &rtk); return Ok(()); @@ -146,7 +146,11 @@ fn docker_images(_verbose: u8) -> Result<()> { } else { format!("{:.0}MB", total_size_mb) }; - rtk.push_str(&format!("🐳 {} images ({})\n", lines.len(), total_display)); + rtk.push_str(&format!( + "[docker] {} images ({})\n", + lines.len(), + total_display + )); for line in lines.iter().take(15) { let parts: Vec<&str> = line.split('\t').collect(); @@ -202,7 +206,7 @@ fn docker_logs(args: &[String], _verbose: u8) -> Result<()> { } let analyzed = crate::log_cmd::run_stdin_str(&raw); - let rtk = format!("🐳 Logs for {}:\n{}", container, analyzed); + let rtk = format!("[docker] Logs for {}:\n{}", container, analyzed); println!("{}", rtk); timer.track( &format!("docker logs {}", container), @@ -238,7 +242,7 @@ fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> { let json: serde_json::Value = match serde_json::from_str(&raw) { Ok(v) => v, Err(_) => { - rtk.push_str("☸️ No pods found"); + rtk.push_str("No pods found"); println!("{}", rtk); timer.track("kubectl get pods", "rtk kubectl pods", &raw, &rtk); return Ok(()); @@ -246,7 +250,7 @@ fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> { }; let Some(pods) = json["items"].as_array().filter(|a| !a.is_empty()) else { - rtk.push_str("☸️ No pods found"); + rtk.push_str("No pods found"); println!("{}", rtk); timer.track("kubectl get pods", "rtk kubectl pods", &raw, &rtk); return Ok(()); @@ -292,21 +296,21 @@ fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> { let mut parts = Vec::new(); if running > 0 { - parts.push(format!("{} ✓", running)); + parts.push(format!("{}", running)); } if pending > 0 { parts.push(format!("{} pending", pending)); } if failed > 0 { - parts.push(format!("{} ✗", failed)); + parts.push(format!("{} [x]", failed)); } if restarts_total > 0 { parts.push(format!("{} restarts", restarts_total)); } - rtk.push_str(&format!("☸️ {} pods: {}\n", pods.len(), parts.join(", "))); + rtk.push_str(&format!("{} pods: {}\n", pods.len(), parts.join(", "))); if !issues.is_empty() { - rtk.push_str("⚠️ Issues:\n"); + rtk.push_str("[warn] Issues:\n"); for issue in issues.iter().take(10) { rtk.push_str(&format!(" {}\n", issue)); } @@ -345,7 +349,7 @@ fn kubectl_services(args: &[String], _verbose: u8) -> Result<()> { let json: serde_json::Value = match serde_json::from_str(&raw) { Ok(v) => v, Err(_) => { - rtk.push_str("☸️ No services found"); + rtk.push_str("No services found"); println!("{}", rtk); timer.track("kubectl get svc", "rtk kubectl svc", &raw, &rtk); return Ok(()); @@ -353,12 +357,12 @@ fn kubectl_services(args: &[String], _verbose: u8) -> Result<()> { }; let Some(services) = json["items"].as_array().filter(|a| !a.is_empty()) else { - rtk.push_str("☸️ No services found"); + rtk.push_str("No services found"); println!("{}", rtk); timer.track("kubectl get svc", "rtk kubectl svc", &raw, &rtk); return Ok(()); }; - rtk.push_str(&format!("☸️ {} services:\n", services.len())); + rtk.push_str(&format!("{} services:\n", services.len())); for svc in services.iter().take(15) { let ns = svc["metadata"]["namespace"].as_str().unwrap_or("-"); @@ -433,7 +437,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result<()> { } let analyzed = crate::log_cmd::run_stdin_str(&raw); - let rtk = format!("☸️ Logs for {}:\n{}", pod, analyzed); + let rtk = format!("Logs for {}:\n{}", pod, analyzed); println!("{}", rtk); timer.track( &format!("kubectl logs {}", pod), @@ -451,10 +455,10 @@ pub fn format_compose_ps(raw: &str) -> String { let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() { - return "🐳 0 compose services".to_string(); + return "[compose] 0 services".to_string(); } - let mut result = format!("🐳 {} compose services:\n", lines.len()); + let mut result = format!("[compose] {} services:\n", lines.len()); for line in lines.iter().take(20) { let parts: Vec<&str> = line.split('\t').collect(); @@ -493,19 +497,19 @@ pub fn format_compose_ps(raw: &str) -> String { /// Format `docker compose logs` output into compact form pub fn format_compose_logs(raw: &str) -> String { if raw.trim().is_empty() { - return "🐳 No logs".to_string(); + return "[compose] No logs".to_string(); } // docker compose logs prefixes each line with "service-N | " // Use the existing log deduplication engine let analyzed = crate::log_cmd::run_stdin_str(raw); - format!("🐳 Compose logs:\n{}", analyzed) + format!("[compose] Logs:\n{}", analyzed) } /// Format `docker compose build` output into compact summary pub fn format_compose_build(raw: &str) -> String { if raw.trim().is_empty() { - return "🐳 Build: no output".to_string(); + return "[compose] Build: no output".to_string(); } let mut result = String::new(); @@ -513,7 +517,7 @@ pub fn format_compose_build(raw: &str) -> String { // Extract the summary line: "[+] Building 12.3s (8/8) FINISHED" for line in raw.lines() { if line.contains("Building") && line.contains("FINISHED") { - result.push_str(&format!("🐳 {}\n", line.trim())); + result.push_str(&format!("[compose] {}\n", line.trim())); break; } } @@ -521,9 +525,9 @@ pub fn format_compose_build(raw: &str) -> String { if result.is_empty() { // No FINISHED line found — might still be building or errored if let Some(line) = raw.lines().find(|l| l.contains("Building")) { - result.push_str(&format!("🐳 {}\n", line.trim())); + result.push_str(&format!("[compose] {}\n", line.trim())); } else { - result.push_str("🐳 Build:\n"); + result.push_str("[compose] Build:\n"); } } @@ -822,8 +826,11 @@ mod tests { let raw = "redis-1\tredis:7\tUp 5 hours\t"; let out = format_compose_ps(raw); assert!(out.contains("redis"), "should show service name"); + // Should not show port info when no ports (but [compose] prefix is OK) + let lines: Vec<&str> = out.lines().collect(); + let redis_line = lines.iter().find(|l| l.contains("redis")).unwrap(); assert!( - !out.contains("["), + !redis_line.contains("] ["), "should not show port brackets when empty" ); } @@ -852,10 +859,7 @@ web-1 | 192.168.1.1 - GET /favicon.ico 404 api-1 | Server listening on port 3000 api-1 | Connected to database"; let out = format_compose_logs(raw); - assert!( - out.contains("Compose logs"), - "should have compose logs header" - ); + assert!(out.contains("Logs"), "should have compose logs header"); } #[test] diff --git a/src/deps.rs b/src/deps.rs index 29ea21e0..27902984 100644 --- a/src/deps.rs +++ b/src/deps.rs @@ -26,7 +26,7 @@ pub fn run(path: &Path, verbose: u8) -> Result<()> { if cargo_path.exists() { found = true; raw.push_str(&fs::read_to_string(&cargo_path).unwrap_or_default()); - rtk.push_str("📦 Rust (Cargo.toml):\n"); + rtk.push_str("Rust (Cargo.toml):\n"); rtk.push_str(&summarize_cargo_str(&cargo_path)?); } @@ -34,7 +34,7 @@ pub fn run(path: &Path, verbose: u8) -> Result<()> { if package_path.exists() { found = true; raw.push_str(&fs::read_to_string(&package_path).unwrap_or_default()); - rtk.push_str("📦 Node.js (package.json):\n"); + rtk.push_str("Node.js (package.json):\n"); rtk.push_str(&summarize_package_json_str(&package_path)?); } @@ -42,7 +42,7 @@ pub fn run(path: &Path, verbose: u8) -> Result<()> { if requirements_path.exists() { found = true; raw.push_str(&fs::read_to_string(&requirements_path).unwrap_or_default()); - rtk.push_str("📦 Python (requirements.txt):\n"); + rtk.push_str("Python (requirements.txt):\n"); rtk.push_str(&summarize_requirements_str(&requirements_path)?); } @@ -50,7 +50,7 @@ pub fn run(path: &Path, verbose: u8) -> Result<()> { if pyproject_path.exists() { found = true; raw.push_str(&fs::read_to_string(&pyproject_path).unwrap_or_default()); - rtk.push_str("📦 Python (pyproject.toml):\n"); + rtk.push_str("Python (pyproject.toml):\n"); rtk.push_str(&summarize_pyproject_str(&pyproject_path)?); } @@ -58,7 +58,7 @@ pub fn run(path: &Path, verbose: u8) -> Result<()> { if gomod_path.exists() { found = true; raw.push_str(&fs::read_to_string(&gomod_path).unwrap_or_default()); - rtk.push_str("📦 Go (go.mod):\n"); + rtk.push_str("Go (go.mod):\n"); rtk.push_str(&summarize_gomod_str(&gomod_path)?); } diff --git a/src/diff_cmd.rs b/src/diff_cmd.rs index 13608254..d9299eb5 100644 --- a/src/diff_cmd.rs +++ b/src/diff_cmd.rs @@ -22,7 +22,7 @@ pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> { let mut rtk = String::new(); if diff.added == 0 && diff.removed == 0 { - rtk.push_str("✅ Files are identical"); + rtk.push_str("[ok] Files are identical"); println!("{}", rtk); timer.track( &format!("diff {} {}", file1.display(), file2.display()), @@ -33,7 +33,7 @@ pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> { return Ok(()); } - rtk.push_str(&format!("📊 {} → {}\n", file1.display(), file2.display())); + rtk.push_str(&format!("{} → {}\n", file1.display(), file2.display())); rtk.push_str(&format!( " +{} added, -{} removed, ~{} modified\n\n", diff.added, diff.removed, diff.modified @@ -168,7 +168,7 @@ fn condense_unified_diff(diff: &str) -> String { // File header if line.starts_with("+++ ") { if !current_file.is_empty() && (added > 0 || removed > 0) { - result.push(format!("📄 {} (+{} -{})", current_file, added, removed)); + result.push(format!("[file] {} (+{} -{})", current_file, added, removed)); for c in changes.iter().take(10) { result.push(format!(" {}", c)); } @@ -199,7 +199,7 @@ fn condense_unified_diff(diff: &str) -> String { // Last file if !current_file.is_empty() && (added > 0 || removed > 0) { - result.push(format!("📄 {} (+{} -{})", current_file, added, removed)); + result.push(format!("[file] {} (+{} -{})", current_file, added, removed)); for c in changes.iter().take(10) { result.push(format!(" {}", c)); } diff --git a/src/display_helpers.rs b/src/display_helpers.rs index a102c397..60354c7c 100644 --- a/src/display_helpers.rs +++ b/src/display_helpers.rs @@ -21,7 +21,7 @@ pub fn format_duration(ms: u64) -> String { /// Trait for period-based statistics that can be displayed in tables pub trait PeriodStats { - /// Icon for this period type (e.g., "📅", "📊", "📆") + /// Icon for this period type (e.g., "D", "W", "M") fn icon() -> &'static str; /// Label for this period type (e.g., "Daily", "Weekly", "Monthly") @@ -143,7 +143,7 @@ pub fn print_period_table(data: &[T]) { impl PeriodStats for DayStats { fn icon() -> &'static str { - "📅" + "D" } fn label() -> &'static str { @@ -193,7 +193,7 @@ impl PeriodStats for DayStats { impl PeriodStats for WeekStats { fn icon() -> &'static str { - "📊" + "W" } fn label() -> &'static str { @@ -253,7 +253,7 @@ impl PeriodStats for WeekStats { impl PeriodStats for MonthStats { fn icon() -> &'static str { - "📆" + "M" } fn label() -> &'static str { @@ -322,7 +322,7 @@ mod tests { assert_eq!(day.commands(), 10); assert_eq!(day.saved_tokens(), 200); assert_eq!(day.avg_time_ms(), 150); - assert_eq!(DayStats::icon(), "📅"); + assert_eq!(DayStats::icon(), "D"); assert_eq!(DayStats::label(), "Daily"); } @@ -342,7 +342,7 @@ mod tests { assert_eq!(week.period(), "01-20 → 01-26"); assert_eq!(week.avg_time_ms(), 100); - assert_eq!(WeekStats::icon(), "📊"); + assert_eq!(WeekStats::icon(), "W"); assert_eq!(WeekStats::label(), "Weekly"); } @@ -361,7 +361,7 @@ mod tests { assert_eq!(month.period(), "2026-01"); assert_eq!(month.avg_time_ms(), 100); - assert_eq!(MonthStats::icon(), "📆"); + assert_eq!(MonthStats::icon(), "M"); assert_eq!(MonthStats::label(), "Monthly"); } diff --git a/src/env_cmd.rs b/src/env_cmd.rs index 4a2437c4..d4b9b6a3 100644 --- a/src/env_cmd.rs +++ b/src/env_cmd.rs @@ -62,7 +62,7 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { // Print categorized if !path_vars.is_empty() { - println!("📂 PATH Variables:"); + println!("PATH Variables:"); for (k, v) in &path_vars { if k == "PATH" { // Split PATH for readability @@ -81,28 +81,28 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { } if !lang_vars.is_empty() { - println!("\n🔧 Language/Runtime:"); + println!("\nLanguage/Runtime:"); for (k, v) in &lang_vars { println!(" {}={}", k, v); } } if !cloud_vars.is_empty() { - println!("\n☁️ Cloud/Services:"); + println!("\nCloud/Services:"); for (k, v) in &cloud_vars { println!(" {}={}", k, v); } } if !tool_vars.is_empty() { - println!("\n🛠️ Tools:"); + println!("\nTools:"); for (k, v) in &tool_vars { println!(" {}={}", k, v); } } if !other_vars.is_empty() { - println!("\n📋 Other:"); + println!("\nOther:"); for (k, v) in other_vars.iter().take(20) { println!(" {}={}", k, v); } @@ -118,7 +118,7 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { + tool_vars.len() + other_vars.len().min(20); if filter.is_none() { - println!("\n📊 Total: {} vars (showing {} relevant)", total, shown); + println!("\nTotal: {} vars (showing {} relevant)", total, shown); } let raw: String = vars.iter().map(|(k, v)| format!("{}={}\n", k, v)).collect(); diff --git a/src/find_cmd.rs b/src/find_cmd.rs index 25da54e2..df1e41b2 100644 --- a/src/find_cmd.rs +++ b/src/find_cmd.rs @@ -305,7 +305,7 @@ pub fn run( let dirs_count = dirs.len(); let total_files = files.len(); - println!("📁 {}F {}D:", total_files, dirs_count); + println!("{}F {}D:", total_files, dirs_count); println!(); // Display with proper --max limiting (count individual files) diff --git a/src/format_cmd.rs b/src/format_cmd.rs index fe6ce13f..23c01a2b 100644 --- a/src/format_cmd.rs +++ b/src/format_cmd.rs @@ -226,7 +226,7 @@ fn filter_black_output(output: &str) -> String { if !needs_formatting && (all_done || files_unchanged > 0) { // All files formatted correctly - result.push_str("✓ Format (black): All files formatted"); + result.push_str("Format (black): All files formatted"); if files_unchanged > 0 { result.push_str(&format!(" ({} files checked)", files_unchanged)); } @@ -258,13 +258,10 @@ fn filter_black_output(output: &str) -> String { } if files_unchanged > 0 { - result.push_str(&format!( - "\n✓ {} files already formatted\n", - files_unchanged - )); + result.push_str(&format!("\n{} files already formatted\n", files_unchanged)); } - result.push_str("\n💡 Run `black .` to format these files\n"); + result.push_str("\n[hint] Run `black .` to format these files\n"); } else { // Fallback: show raw output result.push_str(output.trim()); @@ -349,7 +346,7 @@ mod tests { fn test_filter_black_all_formatted() { let output = "All done! ✨ 🍰 ✨\n5 files left unchanged."; let result = filter_black_output(output); - assert!(result.contains("✓ Format (black)")); + assert!(result.contains("Format (black)")); assert!(result.contains("All files formatted")); assert!(result.contains("5 files checked")); } diff --git a/src/gain.rs b/src/gain.rs index dfba7e08..bafdc001 100644 --- a/src/gain.rs +++ b/src/gain.rs @@ -109,7 +109,7 @@ pub fn run( hook_check::HookStatus::Missing => { eprintln!( "{}", - "⚠️ No hook installed — run `rtk init -g` for automatic token savings" + "[warn] No hook installed — run `rtk init -g` for automatic token savings" .yellow() ); eprintln!(); @@ -117,7 +117,7 @@ pub fn run( hook_check::HookStatus::Outdated => { eprintln!( "{}", - "⚠️ Hook outdated — run `rtk init -g` to update".yellow() + "[warn] Hook outdated — run `rtk init -g` to update".yellow() ); eprintln!(); } @@ -659,7 +659,7 @@ fn check_rtk_disabled_bypass() -> Option { let pct = (bypassed as f64 / total_bash as f64) * 100.0; if pct > 10.0 { Some(format!( - "⚠️ {} commands ({:.0}%) used RTK_DISABLED=1 unnecessarily — run `rtk discover` for details", + "[warn] {} commands ({:.0}%) used RTK_DISABLED=1 unnecessarily — run `rtk discover` for details", bypassed, pct )) } else { diff --git a/src/gh_cmd.rs b/src/gh_cmd.rs index cf17fabd..9073c7e0 100644 --- a/src/gh_cmd.rs +++ b/src/gh_cmd.rs @@ -235,8 +235,8 @@ fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { filtered.push_str("PRs\n"); println!("PRs"); } else { - filtered.push_str("📋 Pull Requests\n"); - println!("📋 Pull Requests"); + filtered.push_str("Pull Requests\n"); + println!("Pull Requests"); } for pr in prs.iter().take(20) { @@ -254,10 +254,10 @@ fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { } } else { match state { - "OPEN" => "🟢", - "MERGED" => "🟣", - "CLOSED" => "🔴", - _ => "⚪", + "OPEN" => "[open]", + "MERGED" => "[merged]", + "CLOSED" => "[closed]", + _ => "[unknown]", } }; @@ -352,10 +352,10 @@ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { } } else { match state { - "OPEN" => "🟢", - "MERGED" => "🟣", - "CLOSED" => "🔴", - _ => "⚪", + "OPEN" => "[open]", + "MERGED" => "[merged]", + "CLOSED" => "[closed]", + _ => "[unknown]", } }; @@ -368,8 +368,8 @@ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { print!("{}", line); let mergeable_str = match mergeable { - "MERGEABLE" => "✓", - "CONFLICTING" => "✗", + "MERGEABLE" => "[ok]", + "CONFLICTING" => "[x]", _ => "?", }; let line = format!(" {} | {}\n", state, mergeable_str); @@ -417,11 +417,11 @@ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { if ultra_compact { if failed > 0 { - let line = format!(" ✗{}/{} {} fail\n", passed, total, failed); + let line = format!(" [x]{}/{} {} fail\n", passed, total, failed); filtered.push_str(&line); print!("{}", line); } else { - let line = format!(" ✓{}/{}\n", passed, total); + let line = format!(" {}/{}\n", passed, total); filtered.push_str(&line); print!("{}", line); } @@ -430,7 +430,7 @@ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { filtered.push_str(&line); print!("{}", line); if failed > 0 { - let line = format!(" ⚠️ {} checks failed\n", failed); + let line = format!(" [warn] {} checks failed\n", failed); filtered.push_str(&line); print!("{}", line); } @@ -504,9 +504,9 @@ fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> let mut failed_checks = Vec::new(); for line in stdout.lines() { - if line.contains('✓') || line.contains("pass") { + if line.contains("[ok]") || line.contains("pass") { passed += 1; - } else if line.contains('✗') || line.contains("fail") { + } else if line.contains("[x]") || line.contains("fail") { failed += 1; failed_checks.push(line.trim().to_string()); } else if line.contains('*') || line.contains("pending") { @@ -516,20 +516,20 @@ fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> let mut filtered = String::new(); - let line = "🔍 CI Checks Summary:\n"; + let line = "CI Checks Summary:\n"; filtered.push_str(line); print!("{}", line); - let line = format!(" ✅ Passed: {}\n", passed); + let line = format!(" [ok] Passed: {}\n", passed); filtered.push_str(&line); print!("{}", line); - let line = format!(" ❌ Failed: {}\n", failed); + let line = format!(" [FAIL] Failed: {}\n", failed); filtered.push_str(&line); print!("{}", line); if pending > 0 { - let line = format!(" ⏳ Pending: {}\n", pending); + let line = format!(" [pending] Pending: {}\n", pending); filtered.push_str(&line); print!("{}", line); } @@ -581,7 +581,7 @@ fn pr_status(_verbose: u8, _ultra_compact: bool) -> Result<()> { let mut filtered = String::new(); if let Some(created_by) = json["createdBy"].as_array() { - let line = format!("📝 Your PRs ({}):\n", created_by.len()); + let line = format!("Your PRs ({}):\n", created_by.len()); filtered.push_str(&line); print!("{}", line); for pr in created_by.iter().take(5) { @@ -636,13 +636,8 @@ fn list_issues(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> let mut filtered = String::new(); if let Some(issues) = json.as_array() { - if ultra_compact { - filtered.push_str("Issues\n"); - println!("Issues"); - } else { - filtered.push_str("🐛 Issues\n"); - println!("🐛 Issues"); - } + filtered.push_str("Issues\n"); + println!("Issues"); for issue in issues.iter().take(20) { let number = issue["number"].as_i64().unwrap_or(0); let title = issue["title"].as_str().unwrap_or("???"); @@ -656,9 +651,9 @@ fn list_issues(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> } } else { if state == "OPEN" { - "🟢" + "[open]" } else { - "🔴" + "[closed]" } }; let line = format!(" {} #{} {}\n", icon, number, truncate(title, 60)); @@ -721,7 +716,11 @@ fn view_issue(args: &[String], _verbose: u8) -> Result<()> { let author = json["author"]["login"].as_str().unwrap_or("???"); let url = json["url"].as_str().unwrap_or(""); - let icon = if state == "OPEN" { "🟢" } else { "🔴" }; + let icon = if state == "OPEN" { + "[open]" + } else { + "[closed]" + }; let mut filtered = String::new(); @@ -814,8 +813,8 @@ fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { filtered.push_str("Runs\n"); println!("Runs"); } else { - filtered.push_str("🏃 Workflow Runs\n"); - println!("🏃 Workflow Runs"); + filtered.push_str("Workflow Runs\n"); + println!("Workflow Runs"); } for run in runs { let id = run["databaseId"].as_i64().unwrap_or(0); @@ -825,8 +824,8 @@ fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { let icon = if ultra_compact { match conclusion { - "success" => "✓", - "failure" => "✗", + "success" => "[ok]", + "failure" => "[x]", "cancelled" => "X", _ => { if status == "in_progress" { @@ -838,14 +837,14 @@ fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { } } else { match conclusion { - "success" => "✅", - "failure" => "❌", - "cancelled" => "🚫", + "success" => "[ok]", + "failure" => "[FAIL]", + "cancelled" => "[X]", _ => { if status == "in_progress" { - "⏳" + "[time]" } else { - "⚪" + "[pending]" } } } @@ -910,7 +909,7 @@ fn view_run(args: &[String], _verbose: u8) -> Result<()> { let mut filtered = String::new(); - let line = format!("🏃 Workflow Run #{}\n", run_id); + let line = format!("Workflow Run #{}\n", run_id); filtered.push_str(&line); print!("{}", line); @@ -924,8 +923,8 @@ fn view_run(args: &[String], _verbose: u8) -> Result<()> { // Skip successful jobs in compact mode continue; } - if line.contains('✗') || line.contains("fail") { - let formatted = format!(" ❌ {}\n", line.trim()); + if line.contains("[x]") || line.contains("fail") { + let formatted = format!(" [FAIL] {}\n", line.trim()); filtered.push_str(&formatted); print!("{}", formatted); } @@ -992,15 +991,11 @@ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { let forks = json["forkCount"].as_i64().unwrap_or(0); let private = json["isPrivate"].as_bool().unwrap_or(false); - let visibility = if private { - "🔒 Private" - } else { - "🌐 Public" - }; + let visibility = if private { "[private]" } else { "[public]" }; let mut filtered = String::new(); - let line = format!("📦 {}/{}\n", owner, name); + let line = format!("{}/{}\n", owner, name); filtered.push_str(&line); print!("{}", line); @@ -1014,7 +1009,7 @@ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { print!("{}", line); } - let line = format!(" ⭐ {} stars | 🔱 {} forks\n", stars, forks); + let line = format!(" {} stars | {} forks\n", stars, forks); filtered.push_str(&line); print!("{}", line); diff --git a/src/git.rs b/src/git.rs index 0f4d137a..3d49fdd6 100644 --- a/src/git.rs +++ b/src/git.rs @@ -573,7 +573,7 @@ fn format_status_output(porcelain: &str) -> String { if let Some(branch_line) = lines.first() { if branch_line.starts_with("##") { let branch = branch_line.trim_start_matches("## "); - output.push_str(&format!("branch: {}\n", branch)); + output.push_str(&format!("* {}\n", branch)); } } @@ -623,7 +623,7 @@ fn format_status_output(porcelain: &str) -> String { let max_untracked = limits.status_max_untracked; if staged > 0 { - output.push_str(&format!("staged: {} files\n", staged)); + output.push_str(&format!("+ Staged: {} files\n", staged)); for f in staged_files.iter().take(max_files) { output.push_str(&format!(" {}\n", f)); } @@ -636,7 +636,7 @@ fn format_status_output(porcelain: &str) -> String { } if modified > 0 { - output.push_str(&format!("modified: {} files\n", modified)); + output.push_str(&format!("~ Modified: {} files\n", modified)); for f in modified_files.iter().take(max_files) { output.push_str(&format!(" {}\n", f)); } @@ -649,7 +649,7 @@ fn format_status_output(porcelain: &str) -> String { } if untracked > 0 { - output.push_str(&format!("untracked: {} files\n", untracked)); + output.push_str(&format!("? Untracked: {} files\n", untracked)); for f in untracked_files.iter().take(max_untracked) { output.push_str(&format!(" {}\n", f)); } @@ -704,7 +704,7 @@ fn filter_status_with_args(output: &str) -> String { } if result.is_empty() { - "ok ✓".to_string() + "ok".to_string() } else { result.join("\n") } @@ -830,9 +830,9 @@ fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> { // Parse "1 file changed, 5 insertions(+)" format let short = stat.lines().last().unwrap_or("").trim(); if short.is_empty() { - "ok ✓".to_string() + "ok".to_string() } else { - format!("ok ✓ {}", short) + format!("ok {}", short) } }; @@ -893,15 +893,15 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result<() if let Some(hash_start) = line.find(' ') { let hash = line[1..hash_start].split(' ').next_back().unwrap_or(""); if !hash.is_empty() && hash.len() >= 7 { - format!("ok ✓ {}", &hash[..7.min(hash.len())]) + format!("ok {}", &hash[..7.min(hash.len())]) } else { - "ok ✓".to_string() + "ok".to_string() } } else { - "ok ✓".to_string() + "ok".to_string() } } else { - "ok ✓".to_string() + "ok".to_string() }; println!("{}", compact); @@ -959,7 +959,7 @@ fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> if line.contains("->") { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 3 { - result = format!("ok ✓ {}", parts[parts.len() - 1]); + result = format!("ok {}", parts[parts.len() - 1]); break; } } @@ -967,7 +967,7 @@ fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> if !result.is_empty() { result } else { - "ok ✓".to_string() + "ok".to_string() } }; @@ -1051,9 +1051,9 @@ fn run_pull(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> } if files > 0 { - format!("ok ✓ {} files +{} -{}", files, insertions, deletions) + format!("ok {} files +{} -{}", files, insertions, deletions) } else { - "ok ✓".to_string() + "ok".to_string() } }; @@ -1171,7 +1171,7 @@ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<() let combined = format!("{}{}", stdout, stderr); let msg = if output.status.success() { - "ok ✓" + "ok" } else { &combined }; @@ -1184,7 +1184,7 @@ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<() ); if output.status.success() { - println!("ok ✓"); + println!("ok"); } else { eprintln!("FAILED: git branch {}", args.join(" ")); if !stderr.trim().is_empty() { @@ -1548,7 +1548,7 @@ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result< let combined = format!("{}{}", stdout, stderr); let msg = if output.status.success() { - "ok ✓" + "ok" } else { &combined }; @@ -1561,7 +1561,7 @@ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result< ); if output.status.success() { - println!("ok ✓"); + println!("ok"); } else { eprintln!("FAILED: git worktree {}", args.join(" ")); if !stderr.trim().is_empty() { @@ -1808,8 +1808,8 @@ mod tests { fn test_format_status_output_modified_files() { let porcelain = "## main...origin/main\n M src/main.rs\n M src/lib.rs\n"; let result = format_status_output(porcelain); - assert!(result.contains("branch: main...origin/main")); - assert!(result.contains("modified: 2 files")); + assert!(result.contains("* main...origin/main")); + assert!(result.contains("~ Modified: 2 files")); assert!(result.contains("src/main.rs")); assert!(result.contains("src/lib.rs")); assert!(!result.contains("Staged")); @@ -1820,8 +1820,8 @@ mod tests { fn test_format_status_output_untracked_files() { let porcelain = "## feature/new\n?? temp.txt\n?? debug.log\n?? test.sh\n"; let result = format_status_output(porcelain); - assert!(result.contains("branch: feature/new")); - assert!(result.contains("untracked: 3 files")); + assert!(result.contains("* feature/new")); + assert!(result.contains("? Untracked: 3 files")); assert!(result.contains("temp.txt")); assert!(result.contains("debug.log")); assert!(result.contains("test.sh")); @@ -1837,13 +1837,13 @@ A added.rs ?? untracked.txt "#; let result = format_status_output(porcelain); - assert!(result.contains("branch: main")); - assert!(result.contains("staged: 2 files")); + assert!(result.contains("* main")); + assert!(result.contains("+ Staged: 2 files")); assert!(result.contains("staged.rs")); assert!(result.contains("added.rs")); - assert!(result.contains("modified: 1 files")); + assert!(result.contains("~ Modified: 1 files")); assert!(result.contains("modified.rs")); - assert!(result.contains("untracked: 1 files")); + assert!(result.contains("? Untracked: 1 files")); assert!(result.contains("untracked.txt")); } @@ -1855,7 +1855,7 @@ A added.rs porcelain.push_str(&format!("M file{}.rs\n", i)); } let result = format_status_output(&porcelain); - assert!(result.contains("staged: 20 files")); + assert!(result.contains("+ Staged: 20 files")); assert!(result.contains("file1.rs")); assert!(result.contains("file15.rs")); assert!(result.contains("... +5 more")); @@ -1871,7 +1871,7 @@ A added.rs porcelain.push_str(&format!(" M file{}.rs\n", i)); } let result = format_status_output(&porcelain); - assert!(result.contains("modified: 20 files")); + assert!(result.contains("~ Modified: 20 files")); assert!(result.contains("file1.rs")); assert!(result.contains("file15.rs")); assert!(result.contains("... +5 more")); @@ -1886,7 +1886,7 @@ A added.rs porcelain.push_str(&format!("?? file{}.rs\n", i)); } let result = format_status_output(&porcelain); - assert!(result.contains("untracked: 15 files")); + assert!(result.contains("? Untracked: 15 files")); assert!(result.contains("file1.rs")); assert!(result.contains("file10.rs")); assert!(result.contains("... +5 more")); @@ -2100,7 +2100,7 @@ no changes added to commit (use "git add" and/or "git commit -a") let porcelain = "## main\n M สวัสดี.txt\n?? ทดสอบ.rs\n"; let result = format_status_output(porcelain); // Should not panic - assert!(result.contains("branch: main")); + assert!(result.contains("* main")); assert!(result.contains("สวัสดี.txt")); assert!(result.contains("ทดสอบ.rs")); } @@ -2109,7 +2109,7 @@ no changes added to commit (use "git add" and/or "git commit -a") fn test_format_status_output_emoji_filename() { let porcelain = "## main\nA 🎉-party.txt\n M 日本語ファイル.rs\n"; let result = format_status_output(porcelain); - assert!(result.contains("branch: main")); + assert!(result.contains("* main")); } /// Regression test: --oneline and other user format flags must preserve all commits. diff --git a/src/go_cmd.rs b/src/go_cmd.rs index 06ee8b54..d250c427 100644 --- a/src/go_cmd.rs +++ b/src/go_cmd.rs @@ -348,7 +348,7 @@ fn filter_go_test_json(output: &str) -> String { if !has_failures { return format!( - "✓ Go test: {} passed in {} packages", + "Go test: {} passed in {} packages", total_pass, total_packages ); } @@ -372,7 +372,7 @@ fn filter_go_test_json(output: &str) -> String { } result.push_str(&format!( - "\n📦 {} [build failed]\n", + "\n{} [build failed]\n", compact_package_name(package) )); @@ -392,14 +392,14 @@ fn filter_go_test_json(output: &str) -> String { } result.push_str(&format!( - "\n📦 {} ({} passed, {} failed)\n", + "\n{} ({} passed, {} failed)\n", compact_package_name(package), pkg_result.pass, pkg_result.fail )); for (test, outputs) in &pkg_result.failed_tests { - result.push_str(&format!(" ❌ {}\n", test)); + result.push_str(&format!(" [FAIL] {}\n", test)); // Show failure output (limit to key lines) let relevant_lines: Vec<&String> = outputs @@ -452,7 +452,7 @@ fn filter_go_build(output: &str) -> String { } if errors.is_empty() { - return "✓ Go build: Success".to_string(); + return "Go build: Success".to_string(); } let mut result = String::new(); @@ -484,7 +484,7 @@ fn filter_go_vet(output: &str) -> String { } if issues.is_empty() { - return "✓ Go vet: No issues found".to_string(); + return "Go vet: No issues found".to_string(); } let mut result = String::new(); @@ -524,7 +524,7 @@ mod tests { {"Time":"2024-01-01T10:00:02Z","Action":"pass","Package":"example.com/foo","Elapsed":0.5}"#; let result = filter_go_test_json(output); - assert!(result.contains("✓ Go test")); + assert!(result.contains("Go test")); assert!(result.contains("1 passed")); assert!(result.contains("1 packages")); } @@ -547,7 +547,7 @@ mod tests { fn test_filter_go_build_success() { let output = ""; let result = filter_go_build(output); - assert!(result.contains("✓ Go build")); + assert!(result.contains("Go build")); assert!(result.contains("Success")); } @@ -567,7 +567,7 @@ main.go:15:2: cannot use x (type int) as type string"#; fn test_filter_go_vet_no_issues() { let output = ""; let result = filter_go_vet(output); - assert!(result.contains("✓ Go vet")); + assert!(result.contains("Go vet")); assert!(result.contains("No issues found")); } diff --git a/src/golangci_cmd.rs b/src/golangci_cmd.rs index ab0f74f3..f6a3166c 100644 --- a/src/golangci_cmd.rs +++ b/src/golangci_cmd.rs @@ -118,7 +118,7 @@ fn filter_golangci_json(output: &str) -> String { let issues = golangci_output.issues; if issues.is_empty() { - return "✓ golangci-lint: No issues found".to_string(); + return "golangci-lint: No issues found".to_string(); } let total_issues = issues.len(); @@ -215,7 +215,7 @@ mod tests { fn test_filter_golangci_no_issues() { let output = r#"{"Issues":[]}"#; let result = filter_golangci_json(output); - assert!(result.contains("✓ golangci-lint")); + assert!(result.contains("golangci-lint")); assert!(result.contains("No issues found")); } diff --git a/src/grep_cmd.rs b/src/grep_cmd.rs index 50ee4ad6..c1819dde 100644 --- a/src/grep_cmd.rs +++ b/src/grep_cmd.rs @@ -62,7 +62,7 @@ pub fn run( eprintln!("{}", stderr.trim()); } } - let msg = format!("🔍 0 for '{}'", pattern); + let msg = format!("0 matches for '{}'", pattern); println!("{}", msg); timer.track( &format!("grep -rn '{}' {}", pattern, path), @@ -105,7 +105,7 @@ pub fn run( } let mut rtk_output = String::new(); - rtk_output.push_str(&format!("🔍 {} in {}F:\n\n", total, by_file.len())); + rtk_output.push_str(&format!("{} matches in {}F:\n\n", total, by_file.len())); let mut shown = 0; let mut files: Vec<_> = by_file.iter().collect(); @@ -117,7 +117,7 @@ pub fn run( } let file_display = compact_path(file); - rtk_output.push_str(&format!("📄 {} ({}):\n", file_display, matches.len())); + rtk_output.push_str(&format!("[file] {} ({}):\n", file_display, matches.len())); let per_file = config::limits().grep_max_per_file; for (line_num, content) in matches.iter().take(per_file) { diff --git a/src/init.rs b/src/init.rs index e50b79f5..565d1719 100644 --- a/src/init.rs +++ b/src/init.rs @@ -722,7 +722,7 @@ fn run_default_mode( _verbose: u8, _install_opencode: bool, ) -> Result<()> { - eprintln!("⚠️ Hook-based mode requires Unix (macOS/Linux)."); + eprintln!("[warn] Hook-based mode requires Unix (macOS/Linux)."); eprintln!(" Windows: use --claude-md mode for full injection."); eprintln!(" Falling back to --claude-md mode."); run_claude_md_mode(_global, _verbose, _install_opencode) @@ -779,7 +779,7 @@ fn run_default_mode( println!(" CLAUDE.md: @RTK.md reference added"); if migrated { - println!("\n ✅ Migrated: removed 137-line RTK block from CLAUDE.md"); + println!("\n [ok] Migrated: removed 137-line RTK block from CLAUDE.md"); println!(" replaced with @RTK.md (10 lines)"); } @@ -880,7 +880,7 @@ fn run_hook_only_mode( install_opencode: bool, ) -> Result<()> { if !global { - eprintln!("⚠️ Warning: --hook-only only makes sense with --global"); + eprintln!("[warn] Warning: --hook-only only makes sense with --global"); eprintln!(" For local projects, use default mode or --claude-md"); return Ok(()); } @@ -963,22 +963,22 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu match action { RtkBlockUpsert::Added => { fs::write(&path, new_content)?; - println!("✅ Added rtk instructions to existing {}", path.display()); + println!("[ok] Added rtk instructions to existing {}", path.display()); } RtkBlockUpsert::Updated => { fs::write(&path, new_content)?; - println!("✅ Updated rtk instructions in {}", path.display()); + println!("[ok] Updated rtk instructions in {}", path.display()); } RtkBlockUpsert::Unchanged => { println!( - "✅ {} already contains up-to-date rtk instructions", + "[ok] {} already contains up-to-date rtk instructions", path.display() ); return Ok(()); } RtkBlockUpsert::Malformed => { eprintln!( - "⚠️ Warning: Found '\nold\n\n", + ) + .unwrap(); + + let added = patch_agents_md(&agents_md, 0).unwrap(); + + assert!(added); + let content = fs::read_to_string(&agents_md).unwrap(); + assert!(!content.contains("old")); + assert_eq!(content.matches("@RTK.md").count(), 1); + } + + #[test] + fn test_uninstall_codex_at_is_idempotent() { + let temp = TempDir::new().unwrap(); + let codex_dir = temp.path(); + let agents_md = codex_dir.join("AGENTS.md"); + let rtk_md = codex_dir.join("RTK.md"); + + fs::write(&agents_md, "# Team rules\n\n@RTK.md\n").unwrap(); + fs::write(&rtk_md, "codex config").unwrap(); + + let removed_first = uninstall_codex_at(codex_dir, 0).unwrap(); + let removed_second = uninstall_codex_at(codex_dir, 0).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!rtk_md.exists()); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert!(!content.contains("@RTK.md")); + assert!(content.contains("# Team rules")); + } + #[test] fn test_local_init_unchanged() { // Local init should use claude-md mode diff --git a/src/main.rs b/src/main.rs index 81350dea..1429b1da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -323,9 +323,9 @@ enum Commands { extra_args: Vec, }, - /// Initialize rtk instructions in CLAUDE.md + /// Initialize rtk instructions for assistant CLI usage Init { - /// Add to global ~/.claude/CLAUDE.md instead of local + /// Add to global assistant config directory instead of local project file #[arg(short, long)] global: bool, @@ -357,9 +357,13 @@ enum Commands { #[arg(long = "no-patch", group = "patch")] no_patch: bool, - /// Remove all RTK artifacts (hook, RTK.md, CLAUDE.md reference, settings.json entry) + /// Remove RTK artifacts for the selected assistant mode #[arg(long)] uninstall: bool, + + /// Target Codex CLI (uses AGENTS.md + RTK.md, no Claude hook patching) + #[arg(long)] + codex: bool, }, /// Download with compact output (strips progress bars) @@ -1632,11 +1636,12 @@ fn main() -> Result<()> { auto_patch, no_patch, uninstall, + codex, } => { if show { - init::show_config()?; + init::show_config(codex)?; } else if uninstall { - init::uninstall(global, gemini, cli.verbose)?; + init::uninstall(global, gemini, codex, cli.verbose)?; } else if gemini { let patch_mode = if auto_patch { init::PatchMode::Auto @@ -1663,6 +1668,7 @@ fn main() -> Result<()> { install_opencode, claude_md, hook_only, + codex, patch_mode, cli.verbose, )?; From 0800bbecef3c1744336aaab36f6888066a258c2e Mon Sep 17 00:00:00 2001 From: Jeziel Lopes Date: Wed, 18 Mar 2026 12:21:07 -0300 Subject: [PATCH 5/8] feat(copilot): add Copilot hook support (VS Code + CLI) (#605) Add `rtk hook copilot` command that handles both VS Code Copilot Chat (updatedInput rewrite) and GitHub Copilot CLI (deny-with-suggestion). - Auto-detects format: snake_case (VS Code) vs camelCase (Copilot CLI) - Delegates to `rtk rewrite` (single source of truth) - 14 hook tests (format detection, rewrite gating, output shape) - .github/hooks/rtk-rewrite.json for repo-scoped hook config - .github/copilot-instructions.md for RTK awareness - Test script: hooks/test-copilot-rtk-rewrite.sh Rebased on develop (includes Gemini #573, Codex #377, OpenClaw #358). Original work by @jeziellopes, cleaned up and rebased by maintainer. Signed-off-by: Patrick szymkowiak Co-authored-by: Patrick szymkowiak --- .github/copilot-instructions.md | 127 +++++++++++++ .github/hooks/rtk-rewrite.json | 12 ++ hooks/copilot-rtk-awareness.md | 60 ++++++ hooks/test-copilot-rtk-rewrite.sh | 293 ++++++++++++++++++++++++++++++ src/hook_cmd.rs | 208 ++++++++++++++++++++- src/main.rs | 7 +- 6 files changed, 705 insertions(+), 2 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/hooks/rtk-rewrite.json create mode 100644 hooks/copilot-rtk-awareness.md create mode 100755 hooks/test-copilot-rtk-rewrite.sh diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..7651df48 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,127 @@ +# Copilot Instructions for rtk + +**rtk (Rust Token Killer)** is a CLI proxy that filters and compresses command outputs before they reach an LLM context, saving 60–90% of tokens. It wraps common tools (`git`, `cargo`, `grep`, `pnpm`, `go`, etc.) and outputs condensed summaries instead of raw output. + +## Using rtk in this session + +**Always prefix commands with `rtk` when running shell commands** — this is the entire point of the project and reduces token consumption for every operation you perform. + +```bash +# Instead of: Use: +git status rtk git status +git log -10 rtk git log -10 +cargo test rtk cargo test +cargo clippy --all-targets rtk cargo clippy --all-targets +grep -r "pattern" src/ rtk grep -r "pattern" src/ +``` + +**rtk meta-commands** (always use these directly, no prefix needed): +```bash +rtk gain # Show token savings analytics for this session +rtk gain --history # Full command history with per-command savings +rtk discover # Scan session history for missed rtk opportunities +rtk proxy # Run a command raw (no filtering) but still track it +``` + +**Verify rtk is installed before starting:** +```bash +rtk --version # Should print: rtk X.Y.Z +rtk gain # Should show a dashboard (not "command not found") +``` + +> ⚠️ **Name collision**: `rtk gain` failing means you have `reachingforthejack/rtk` (Rust Type Kit) installed instead of this project. Run `which rtk` and check the binary source. + +## Build, Test & Lint + +```bash +# Development build +cargo build + +# Run all tests +cargo test + +# Run a single test by name +cargo test test_filter_git_log + +# Run all tests in a module +cargo test git::tests:: + +# Run tests with stdout +cargo test -- --nocapture + +# Pre-commit gate (must all pass before any PR) +cargo fmt --all --check && cargo clippy --all-targets && cargo test + +# Smoke tests (requires installed binary) +bash scripts/test-all.sh +``` + +PRs target the **`develop`** branch, not `main`. All commits require a DCO sign-off (`git commit -s`). + +## Architecture + +``` +main.rs ← Clap Commands enum → specialized module (git.rs, *_cmd.rs, etc.) + ↓ + execute subprocess + ↓ + filter/compress output + ↓ + tracking::TimedExecution → SQLite (~/.local/share/rtk/tracking.db) +``` + +Key modules: +- **`main.rs`** — Clap `Commands` enum routes every subcommand to its module. Each arm calls `tracking::TimedExecution::start()` before running, then `.track(...)` after. +- **`filter.rs`** — Language-aware filtering with `FilterLevel` (`none` / `minimal` / `aggressive`) and `Language` enum. Used by `read` and `smart` commands. +- **`tracking.rs`** — SQLite persistence for token savings, scoped per project path. Powers `rtk gain`. +- **`tee.rs`** — On filter failure, saves raw output to `~/.local/share/rtk/tee/` and prints a one-line hint so the LLM can re-read without re-running the command. +- **`utils.rs`** — Shared helpers: `truncate`, `strip_ansi`, `execute_command`, package-manager auto-detection (pnpm/yarn/npm/npx). + +New commands follow this structure: one file `src/_cmd.rs` with a `pub fn run(...)` entry point, registered in the `Commands` enum in `main.rs`. + +## Key Conventions + +### Error handling +- Use `anyhow::Result` throughout (this is a binary, not a library). +- Always attach context: `operation.context("description")?` — never bare `?` without context. +- No `unwrap()` in production code; `expect("reason")` is acceptable only in tests. +- Every filter must fall back to raw command execution on error — never break the user's workflow. + +### Regex +- Compile once with `lazy_static!`, never inside a function body: + ```rust + lazy_static! { + static ref RE: Regex = Regex::new(r"pattern").unwrap(); + } + ``` + +### Testing +- Unit tests live **inside the module file** in `#[cfg(test)] mod tests { ... }` — not in `tests/`. +- Fixtures are real captured command output in `tests/fixtures/_raw.txt`, loaded with `include_str!("../tests/fixtures/...")`. +- Each test module defines its own local `fn count_tokens(text: &str) -> usize` (word-split approximation) — there is no shared utility for this. +- Token savings assertions use `assert!(savings >= 60.0, ...)`. +- Snapshot tests use `assert_snapshot!()` from the `insta` crate; review with `cargo insta review`. + +### Adding a new command +1. Create `src/_cmd.rs` with `pub fn run(...)`. +2. Add `mod _cmd;` at the top of `main.rs`. +3. Add a variant to the `Commands` enum with `#[arg(trailing_var_arg = true, allow_hyphen_values = true)]` for pass-through flags. +4. Route the variant in the `match` block, wrapping execution with `tracking::TimedExecution`. +5. Write a fixture from real output, then unit tests in the module file. +6. Update `README.md` (command list + savings %) and `CHANGELOG.md`. + +### Exit codes +Preserve the underlying command's exit code. Use `std::process::exit(code)` when the child process exits non-zero. + +### Performance constraints +- Startup must stay under 10ms — no async runtime (no `tokio`/`async-std`). +- No blocking I/O at startup; config is loaded on-demand. +- Binary size target: <5 MB stripped. + +### Branch naming +``` +fix(scope): short-description +feat(scope): short-description +chore(scope): short-description +``` +`scope` is the affected component (e.g. `git`, `filter`, `tracking`). diff --git a/.github/hooks/rtk-rewrite.json b/.github/hooks/rtk-rewrite.json new file mode 100644 index 00000000..c488d434 --- /dev/null +++ b/.github/hooks/rtk-rewrite.json @@ -0,0 +1,12 @@ +{ + "hooks": { + "PreToolUse": [ + { + "type": "command", + "command": "rtk hook", + "cwd": ".", + "timeout": 5 + } + ] + } +} diff --git a/hooks/copilot-rtk-awareness.md b/hooks/copilot-rtk-awareness.md new file mode 100644 index 00000000..185f460c --- /dev/null +++ b/hooks/copilot-rtk-awareness.md @@ -0,0 +1,60 @@ +# RTK — Copilot Integration (VS Code Copilot Chat + Copilot CLI) + +**Usage**: Token-optimized CLI proxy (60-90% savings on dev operations) + +## What's automatic + +The `.github/copilot-instructions.md` file is loaded at session start by both Copilot CLI and VS Code Copilot Chat. +It instructs Copilot to prefix commands with `rtk` automatically. + +The `.github/hooks/rtk-rewrite.json` hook adds a `PreToolUse` safety net via `rtk hook` — +a cross-platform Rust binary that intercepts raw bash tool calls and rewrites them. +No shell scripts, no `jq` dependency, works on Windows natively. + +## Meta commands (always use directly) + +```bash +rtk gain # Token savings dashboard for this session +rtk gain --history # Per-command history with savings % +rtk discover # Scan session history for missed rtk opportunities +rtk proxy # Run raw (no filtering) but still track it +``` + +## Installation verification + +```bash +rtk --version # Should print: rtk X.Y.Z +rtk gain # Should show a dashboard (not "command not found") +which rtk # Verify correct binary path +``` + +> ⚠️ **Name collision**: If `rtk gain` fails, you may have `reachingforthejack/rtk` +> (Rust Type Kit) installed instead. Check `which rtk` and reinstall from rtk-ai/rtk. + +## How the hook works + +`rtk hook` reads `PreToolUse` JSON from stdin, detects the agent format, and responds appropriately: + +**VS Code Copilot Chat** (supports `updatedInput` — transparent rewrite, no denial): +1. Agent runs `git status` → `rtk hook` intercepts via `PreToolUse` +2. `rtk hook` detects VS Code format (`tool_name`/`tool_input` keys) +3. Returns `hookSpecificOutput.updatedInput.command = "rtk git status"` +4. Agent runs the rewritten command silently — no denial, no retry + +**GitHub Copilot CLI** (deny-with-suggestion — CLI ignores `updatedInput` today, see [issue #2013](https://github.com/github/copilot-cli/issues/2013)): +1. Agent runs `git status` → `rtk hook` intercepts via `PreToolUse` +2. `rtk hook` detects Copilot CLI format (`toolName`/`toolArgs` keys) +3. Returns `permissionDecision: deny` with reason: `"Token savings: use 'rtk git status' instead"` +4. Copilot reads the reason and re-runs `rtk git status` + +When Copilot CLI adds `updatedInput` support, only `rtk hook` needs updating — no config changes. + +## Integration comparison + +| Tool | Mechanism | Hook output | File | +|-----------------------|-----------------------------------------|--------------------------|------------------------------------| +| Claude Code | `PreToolUse` hook with `updatedInput` | Transparent rewrite | `hooks/rtk-rewrite.sh` | +| VS Code Copilot Chat | `PreToolUse` hook with `updatedInput` | Transparent rewrite | `.github/hooks/rtk-rewrite.json` | +| GitHub Copilot CLI | `PreToolUse` deny-with-suggestion | Denial + retry | `.github/hooks/rtk-rewrite.json` | +| OpenCode | Plugin `tool.execute.before` | Transparent rewrite | `hooks/opencode-rtk.ts` | +| (any) | Custom instructions | Prompt-level guidance | `.github/copilot-instructions.md` | diff --git a/hooks/test-copilot-rtk-rewrite.sh b/hooks/test-copilot-rtk-rewrite.sh new file mode 100755 index 00000000..f1cca949 --- /dev/null +++ b/hooks/test-copilot-rtk-rewrite.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Test suite for rtk hook (cross-platform preToolUse handler). +# Feeds mock preToolUse JSON through `rtk hook` and verifies allow/deny decisions. +# +# Usage: bash hooks/test-copilot-rtk-rewrite.sh +# +# Copilot CLI input format: +# {"toolName":"bash","toolArgs":"{\"command\":\"...\"}"} +# Output on intercept: {"permissionDecision":"deny","permissionDecisionReason":"..."} +# +# VS Code Copilot Chat input format: +# {"tool_name":"Bash","tool_input":{"command":"..."}} +# Output on intercept: {"hookSpecificOutput":{"permissionDecision":"allow","updatedInput":{...}}} +# +# Output on pass-through: empty (exit 0) + +RTK="${RTK:-rtk}" +PASS=0 +FAIL=0 +TOTAL=0 + +# Colors +GREEN='\033[32m' +RED='\033[31m' +DIM='\033[2m' +RESET='\033[0m' + +# Build a Copilot CLI preToolUse input JSON +copilot_bash_input() { + local cmd="$1" + local tool_args + tool_args=$(jq -cn --arg cmd "$cmd" '{"command":$cmd}') + jq -cn --arg ta "$tool_args" '{"toolName":"bash","toolArgs":$ta}' +} + +# Build a VS Code Copilot Chat preToolUse input JSON +vscode_bash_input() { + local cmd="$1" + jq -cn --arg cmd "$cmd" '{"tool_name":"Bash","tool_input":{"command":$cmd}}' +} + +# Build a non-bash tool input +tool_input() { + local tool_name="$1" + jq -cn --arg t "$tool_name" '{"toolName":$t,"toolArgs":"{}"}' +} + +# Assert Copilot CLI: hook denies and reason contains the expected rtk command +test_deny() { + local description="$1" + local input_cmd="$2" + local expected_rtk="$3" + TOTAL=$((TOTAL + 1)) + + local output + output=$(copilot_bash_input "$input_cmd" | "$RTK" hook 2>/dev/null) || true + + local decision reason + decision=$(echo "$output" | jq -r '.permissionDecision // empty' 2>/dev/null) + reason=$(echo "$output" | jq -r '.permissionDecisionReason // empty' 2>/dev/null) + + if [ "$decision" = "deny" ] && echo "$reason" | grep -qF "$expected_rtk"; then + printf " ${GREEN}DENY${RESET} %s ${DIM}→ %s${RESET}\n" "$description" "$expected_rtk" + PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${RESET} %s\n" "$description" + printf " expected decision: deny, reason containing: %s\n" "$expected_rtk" + printf " actual decision: %s\n" "$decision" + printf " actual reason: %s\n" "$reason" + FAIL=$((FAIL + 1)) + fi +} + +# Assert VS Code Copilot Chat: hook returns updatedInput (allow) with rewritten command +test_vscode_rewrite() { + local description="$1" + local input_cmd="$2" + local expected_rtk="$3" + TOTAL=$((TOTAL + 1)) + + local output + output=$(vscode_bash_input "$input_cmd" | "$RTK" hook 2>/dev/null) || true + + local decision updated_cmd + decision=$(echo "$output" | jq -r '.hookSpecificOutput.permissionDecision // empty' 2>/dev/null) + updated_cmd=$(echo "$output" | jq -r '.hookSpecificOutput.updatedInput.command // empty' 2>/dev/null) + + if [ "$decision" = "allow" ] && echo "$updated_cmd" | grep -qF "$expected_rtk"; then + printf " ${GREEN}REWRITE${RESET} %s ${DIM}→ %s${RESET}\n" "$description" "$updated_cmd" + PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${RESET} %s\n" "$description" + printf " expected decision: allow, updatedInput containing: %s\n" "$expected_rtk" + printf " actual decision: %s\n" "$decision" + printf " actual updatedInput: %s\n" "$updated_cmd" + FAIL=$((FAIL + 1)) + fi +} + +# Assert the hook emits no output (pass-through) +test_allow() { + local description="$1" + local input="$2" + TOTAL=$((TOTAL + 1)) + + local output + output=$(echo "$input" | "$RTK" hook 2>/dev/null) || true + + if [ -z "$output" ]; then + printf " ${GREEN}PASS${RESET} %s ${DIM}→ (allow)${RESET}\n" "$description" + PASS=$((PASS + 1)) + else + local decision + decision=$(echo "$output" | jq -r '.permissionDecision // .hookSpecificOutput.permissionDecision // empty' 2>/dev/null) + printf " ${RED}FAIL${RESET} %s\n" "$description" + printf " expected: (no output)\n" + printf " actual: permissionDecision=%s\n" "$decision" + FAIL=$((FAIL + 1)) + fi +} + +echo "============================================" +echo " RTK Hook Test Suite (rtk hook)" +echo "============================================" +echo "" + +# ---- SECTION 1: Copilot CLI — commands that should be denied ---- +echo "--- Copilot CLI: intercepted (deny with rtk suggestion) ---" + +test_deny "git status" \ + "git status" \ + "rtk git status" + +test_deny "git log --oneline -10" \ + "git log --oneline -10" \ + "rtk git log" + +test_deny "git diff HEAD" \ + "git diff HEAD" \ + "rtk git diff" + +test_deny "cargo test" \ + "cargo test" \ + "rtk cargo test" + +test_deny "cargo clippy --all-targets" \ + "cargo clippy --all-targets" \ + "rtk cargo clippy" + +test_deny "cargo build" \ + "cargo build" \ + "rtk cargo build" + +test_deny "grep -rn pattern src/" \ + "grep -rn pattern src/" \ + "rtk grep" + +test_deny "gh pr list" \ + "gh pr list" \ + "rtk gh" + +echo "" + +# ---- SECTION 2: VS Code Copilot Chat — commands that should be rewritten via updatedInput ---- +echo "--- VS Code Copilot Chat: intercepted (updatedInput rewrite) ---" + +test_vscode_rewrite "git status" \ + "git status" \ + "rtk git status" + +test_vscode_rewrite "cargo test" \ + "cargo test" \ + "rtk cargo test" + +test_vscode_rewrite "gh pr list" \ + "gh pr list" \ + "rtk gh" + +echo "" + +# ---- SECTION 3: Pass-through cases ---- +echo "--- Pass-through (allow silently) ---" + +test_allow "Copilot CLI: already rtk: rtk git status" \ + "$(copilot_bash_input "rtk git status")" + +test_allow "Copilot CLI: already rtk: rtk cargo test" \ + "$(copilot_bash_input "rtk cargo test")" + +test_allow "Copilot CLI: heredoc" \ + "$(copilot_bash_input "cat <<'EOF' +hello +EOF")" + +test_allow "Copilot CLI: unknown command: htop" \ + "$(copilot_bash_input "htop")" + +test_allow "Copilot CLI: unknown command: echo" \ + "$(copilot_bash_input "echo hello world")" + +test_allow "Copilot CLI: non-bash tool: view" \ + "$(tool_input "view")" + +test_allow "Copilot CLI: non-bash tool: edit" \ + "$(tool_input "edit")" + +test_allow "VS Code: already rtk" \ + "$(vscode_bash_input "rtk git status")" + +test_allow "VS Code: non-bash tool: editFiles" \ + "$(jq -cn '{"tool_name":"editFiles"}')" + +echo "" + +# ---- SECTION 4: Output format assertions ---- +echo "--- Output format ---" + +# Copilot CLI output format +TOTAL=$((TOTAL + 1)) +raw_output=$(copilot_bash_input "git status" | "$RTK" hook 2>/dev/null) + +if echo "$raw_output" | jq . >/dev/null 2>&1; then + printf " ${GREEN}PASS${RESET} Copilot CLI: output is valid JSON\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Copilot CLI: output is not valid JSON: %s\n" "$raw_output" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +decision=$(echo "$raw_output" | jq -r '.permissionDecision') +if [ "$decision" = "deny" ]; then + printf " ${GREEN}PASS${RESET} Copilot CLI: permissionDecision == \"deny\"\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Copilot CLI: expected \"deny\", got \"%s\"\n" "$decision" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +reason=$(echo "$raw_output" | jq -r '.permissionDecisionReason') +if echo "$reason" | grep -qE '`rtk [^`]+`'; then + printf " ${GREEN}PASS${RESET} Copilot CLI: reason contains backtick-quoted rtk command ${DIM}→ %s${RESET}\n" "$reason" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Copilot CLI: reason missing backtick-quoted command: %s\n" "$reason" + FAIL=$((FAIL + 1)) +fi + +# VS Code output format +TOTAL=$((TOTAL + 1)) +vscode_output=$(vscode_bash_input "git status" | "$RTK" hook 2>/dev/null) + +if echo "$vscode_output" | jq . >/dev/null 2>&1; then + printf " ${GREEN}PASS${RESET} VS Code: output is valid JSON\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} VS Code: output is not valid JSON: %s\n" "$vscode_output" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +vscode_decision=$(echo "$vscode_output" | jq -r '.hookSpecificOutput.permissionDecision') +if [ "$vscode_decision" = "allow" ]; then + printf " ${GREEN}PASS${RESET} VS Code: hookSpecificOutput.permissionDecision == \"allow\"\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} VS Code: expected \"allow\", got \"%s\"\n" "$vscode_decision" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +vscode_updated=$(echo "$vscode_output" | jq -r '.hookSpecificOutput.updatedInput.command') +if echo "$vscode_updated" | grep -q "^rtk "; then + printf " ${GREEN}PASS${RESET} VS Code: updatedInput.command starts with rtk ${DIM}→ %s${RESET}\n" "$vscode_updated" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} VS Code: updatedInput.command should start with rtk: %s\n" "$vscode_updated" + FAIL=$((FAIL + 1)) +fi + +echo "" + +# ---- SUMMARY ---- +echo "============================================" +if [ $FAIL -eq 0 ]; then + printf " ${GREEN}ALL $TOTAL TESTS PASSED${RESET}\n" +else + printf " ${RED}$FAIL FAILED${RESET} / $TOTAL total ($PASS passed)\n" +fi +echo "============================================" + +exit $FAIL diff --git a/src/hook_cmd.rs b/src/hook_cmd.rs index b85551f3..29a7365d 100644 --- a/src/hook_cmd.rs +++ b/src/hook_cmd.rs @@ -1,9 +1,144 @@ use anyhow::{Context, Result}; -use serde_json::Value; +use serde_json::{json, Value}; use std::io::{self, Read}; use crate::discover::registry::rewrite_command; +// ── Copilot hook (VS Code + Copilot CLI) ────────────────────── + +/// Format detected from the preToolUse JSON input. +enum HookFormat { + /// VS Code Copilot Chat / Claude Code: `tool_name` + `tool_input.command`, supports `updatedInput`. + VsCode { command: String }, + /// GitHub Copilot CLI: camelCase `toolName` + `toolArgs` (JSON string), deny-with-suggestion only. + CopilotCli { command: String }, + /// Non-bash tool, already uses rtk, or unknown format — pass through silently. + PassThrough, +} + +/// Run the Copilot preToolUse hook. +/// Auto-detects VS Code Copilot Chat vs Copilot CLI format. +pub fn run_copilot() -> Result<()> { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("Failed to read stdin")?; + + let input = input.trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(e) => { + eprintln!("[rtk hook] Failed to parse JSON input: {e}"); + return Ok(()); + } + }; + + match detect_format(&v) { + HookFormat::VsCode { command } => handle_vscode(&command), + HookFormat::CopilotCli { command } => handle_copilot_cli(&command), + HookFormat::PassThrough => Ok(()), + } +} + +fn detect_format(v: &Value) -> HookFormat { + // VS Code Copilot Chat / Claude Code: snake_case keys + if let Some(tool_name) = v.get("tool_name").and_then(|t| t.as_str()) { + if matches!(tool_name, "runTerminalCommand" | "Bash" | "bash") { + if let Some(cmd) = v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + return HookFormat::VsCode { + command: cmd.to_string(), + }; + } + } + return HookFormat::PassThrough; + } + + // Copilot CLI: camelCase keys, toolArgs is a JSON-encoded string + if let Some(tool_name) = v.get("toolName").and_then(|t| t.as_str()) { + if tool_name == "bash" { + if let Some(tool_args_str) = v.get("toolArgs").and_then(|t| t.as_str()) { + if let Ok(tool_args) = serde_json::from_str::(tool_args_str) { + if let Some(cmd) = tool_args + .get("command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + return HookFormat::CopilotCli { + command: cmd.to_string(), + }; + } + } + } + } + return HookFormat::PassThrough; + } + + HookFormat::PassThrough +} + +fn get_rewritten(cmd: &str) -> Option { + if cmd.contains("<<") { + return None; + } + + let excluded = crate::config::Config::load() + .map(|c| c.hooks.exclude_commands) + .unwrap_or_default(); + + let rewritten = rewrite_command(cmd, &excluded)?; + + if rewritten == cmd { + return None; + } + + Some(rewritten) +} + +fn handle_vscode(cmd: &str) -> Result<()> { + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => return Ok(()), + }; + + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": { "command": rewritten } + } + }); + println!("{output}"); + Ok(()) +} + +fn handle_copilot_cli(cmd: &str) -> Result<()> { + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => return Ok(()), + }; + + let output = json!({ + "permissionDecision": "deny", + "permissionDecisionReason": format!( + "Token savings: use `{}` instead (rtk saves 60-90% tokens)", + rewritten + ) + }); + println!("{output}"); + Ok(()) +} + +// ── Gemini hook ─────────────────────────────────────────────── + /// Run the Gemini CLI BeforeTool hook. /// Reads JSON from stdin, rewrites shell commands to rtk equivalents, /// outputs JSON to stdout in Gemini CLI format. @@ -61,6 +196,77 @@ fn print_rewrite(cmd: &str) { mod tests { use super::*; + // --- Copilot format detection --- + + fn vscode_input(tool: &str, cmd: &str) -> Value { + json!({ + "tool_name": tool, + "tool_input": { "command": cmd } + }) + } + + fn copilot_cli_input(cmd: &str) -> Value { + let args = serde_json::to_string(&json!({ "command": cmd })).unwrap(); + json!({ "toolName": "bash", "toolArgs": args }) + } + + #[test] + fn test_detect_vscode_bash() { + assert!(matches!( + detect_format(&vscode_input("Bash", "git status")), + HookFormat::VsCode { .. } + )); + } + + #[test] + fn test_detect_vscode_run_terminal_command() { + assert!(matches!( + detect_format(&vscode_input("runTerminalCommand", "cargo test")), + HookFormat::VsCode { .. } + )); + } + + #[test] + fn test_detect_copilot_cli_bash() { + assert!(matches!( + detect_format(&copilot_cli_input("git status")), + HookFormat::CopilotCli { .. } + )); + } + + #[test] + fn test_detect_non_bash_is_passthrough() { + let v = json!({ "tool_name": "editFiles" }); + assert!(matches!(detect_format(&v), HookFormat::PassThrough)); + } + + #[test] + fn test_detect_unknown_is_passthrough() { + assert!(matches!(detect_format(&json!({})), HookFormat::PassThrough)); + } + + #[test] + fn test_get_rewritten_supported() { + assert!(get_rewritten("git status").is_some()); + } + + #[test] + fn test_get_rewritten_unsupported() { + assert!(get_rewritten("htop").is_none()); + } + + #[test] + fn test_get_rewritten_already_rtk() { + assert!(get_rewritten("rtk git status").is_none()); + } + + #[test] + fn test_get_rewritten_heredoc() { + assert!(get_rewritten("cat <<'EOF'\nhello\nEOF").is_none()); + } + + // --- Gemini format --- + #[test] fn test_print_allow_format() { // Verify the allow JSON format matches Gemini CLI expectations diff --git a/src/main.rs b/src/main.rs index 1429b1da..1279a454 100644 --- a/src/main.rs +++ b/src/main.rs @@ -673,7 +673,7 @@ enum Commands { args: Vec, }, - /// Hook processors for LLM CLI tools (Gemini CLI, etc.) + /// Hook processors for LLM CLI tools (Gemini CLI, Copilot, etc.) Hook { #[command(subcommand)] command: HookCommands, @@ -684,6 +684,8 @@ enum Commands { enum HookCommands { /// Process Gemini CLI BeforeTool hook (reads JSON from stdin) Gemini, + /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin) + Copilot, } #[derive(Subcommand)] @@ -2014,6 +2016,9 @@ fn main() -> Result<()> { HookCommands::Gemini => { hook_cmd::run_gemini()?; } + HookCommands::Copilot => { + hook_cmd::run_copilot()?; + } }, Commands::Rewrite { args } => { From c3917e4de2a21f9507abf73b09921a6be36a9aed Mon Sep 17 00:00:00 2001 From: Moisei Rabinovich Date: Wed, 18 Mar 2026 17:39:03 +0200 Subject: [PATCH 6/8] feat: add Cursor Agent support via --agent flag (#595) Add `rtk init -g --agent cursor` to install RTK hooks for Cursor Agent. Cursor's preToolUse hook supports command rewriting via updated_input, functionally identical to Claude Code's PreToolUse. Works with both the Cursor editor and cursor-cli (they share ~/.cursor/hooks.json). Changes: - New `--agent ` flag (claude|cursor) on `rtk init`, extensible for future agents. Default is claude (backward compatible). - Cursor hook script (hooks/cursor-rtk-rewrite.sh) outputs Cursor's JSON format: {permission, updated_input} vs Claude's hookSpecificOutput. - `rtk init --show` reports Cursor hook and hooks.json status. - `rtk init -g --uninstall` removes Cursor artifacts. - `rtk discover` notes that Cursor sessions are tracked via `rtk gain` (Cursor transcripts lack structured tool_use/tool_result blocks). - Unit tests for Cursor hooks.json patching, detection, and removal. Made-with: Cursor Signed-off-by: Patrick szymkowiak Co-authored-by: Moisei <1199723+moisei@users.noreply.github.com> --- hooks/cursor-rtk-rewrite.sh | 54 ++++ src/discover/provider.rs | 6 +- src/discover/report.rs | 8 + src/init.rs | 509 +++++++++++++++++++++++++++++++++--- src/main.rs | 22 +- 5 files changed, 564 insertions(+), 35 deletions(-) create mode 100755 hooks/cursor-rtk-rewrite.sh diff --git a/hooks/cursor-rtk-rewrite.sh b/hooks/cursor-rtk-rewrite.sh new file mode 100755 index 00000000..4b80b260 --- /dev/null +++ b/hooks/cursor-rtk-rewrite.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# rtk-hook-version: 1 +# RTK Cursor Agent hook — rewrites shell commands to use rtk for token savings. +# Works with both Cursor editor and cursor-cli (they share ~/.cursor/hooks.json). +# Cursor preToolUse hook format: receives JSON on stdin, returns JSON on stdout. +# Requires: rtk >= 0.23.0, jq +# +# This is a thin delegating hook: all rewrite logic lives in `rtk rewrite`, +# which is the single source of truth (src/discover/registry.rs). +# 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 + +if ! command -v rtk &>/dev/null; then + echo "[rtk] WARNING: rtk is not installed or not in PATH. Hook cannot rewrite commands. Install: https://github.com/rtk-ai/rtk#installation" >&2 + exit 0 +fi + +# Version guard: rtk rewrite was added in 0.23.0. +RTK_VERSION=$(rtk --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +if [ -n "$RTK_VERSION" ]; then + MAJOR=$(echo "$RTK_VERSION" | cut -d. -f1) + MINOR=$(echo "$RTK_VERSION" | cut -d. -f2) + if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 23 ]; then + echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.23.0). Upgrade: cargo install rtk" >&2 + exit 0 + fi +fi + +INPUT=$(cat) +CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +if [ -z "$CMD" ]; then + echo '{}' + exit 0 +fi + +# Delegate all rewrite logic to the Rust binary. +# rtk rewrite exits 1 when there's no rewrite — hook passes through silently. +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || { echo '{}'; exit 0; } + +# No change — nothing to do. +if [ "$CMD" = "$REWRITTEN" ]; then + echo '{}' + exit 0 +fi + +jq -n --arg cmd "$REWRITTEN" '{ + "permission": "allow", + "updated_input": { "command": $cmd } +}' diff --git a/src/discover/provider.rs b/src/discover/provider.rs index ae0852d2..b4105a9d 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -22,7 +22,11 @@ pub struct ExtractedCommand { pub sequence_index: usize, } -/// Trait for session providers (Claude Code, future: Cursor, Windsurf). +/// Trait for session providers (Claude Code, OpenCode, etc.). +/// +/// Note: Cursor Agent transcripts use a text-only format without structured +/// tool_use/tool_result blocks, so command extraction is not possible. +/// Use `rtk gain` to track savings for Cursor sessions instead. pub trait SessionProvider { fn discover_sessions( &self, diff --git a/src/discover/report.rs b/src/discover/report.rs index 5d05f150..5b1fe801 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -165,6 +165,14 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri out.push_str("\n~estimated from tool_result output sizes\n"); + // Cursor note: check if Cursor hooks are installed + if let Some(home) = dirs::home_dir() { + let cursor_hook = home.join(".cursor").join("hooks").join("rtk-rewrite.sh"); + if cursor_hook.exists() { + out.push_str("\nNote: Cursor sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } + } + if verbose && report.parse_errors > 0 { out.push_str(&format!("Parse errors skipped: {}\n", report.parse_errors)); } diff --git a/src/init.rs b/src/init.rs index eaf104f8..d0759aae 100644 --- a/src/init.rs +++ b/src/init.rs @@ -9,6 +9,9 @@ use crate::integrity; // Embedded hook script (guards before set -euo pipefail) const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); +// Embedded Cursor hook script (preToolUse format) +const CURSOR_REWRITE_HOOK: &str = include_str!("../hooks/cursor-rtk-rewrite.sh"); + // Embedded OpenCode plugin (auto-rewrite) const OPENCODE_PLUGIN: &str = include_str!("../hooks/opencode-rtk.ts"); @@ -205,46 +208,67 @@ pub fn run( global: bool, install_claude: bool, install_opencode: bool, + install_cursor: bool, claude_md: bool, hook_only: bool, codex: bool, patch_mode: PatchMode, verbose: u8, ) -> Result<()> { - match ( - codex, - install_claude, - install_opencode, - global, - claude_md, - hook_only, - patch_mode, - ) { - (true, _, true, _, _, _, _) => anyhow::bail!("--codex cannot be combined with --opencode"), - (true, _, _, _, true, _, _) => anyhow::bail!("--codex cannot be combined with --claude-md"), - (true, _, _, _, _, true, _) => anyhow::bail!("--codex cannot be combined with --hook-only"), - (true, _, _, _, _, _, PatchMode::Auto) => { - anyhow::bail!("--codex cannot be combined with --auto-patch") + // Validation: Codex mode conflicts + if codex { + if install_opencode { + anyhow::bail!("--codex cannot be combined with --opencode"); } - (true, _, _, _, _, _, PatchMode::Skip) => { - anyhow::bail!("--codex cannot be combined with --no-patch") + if claude_md { + anyhow::bail!("--codex cannot be combined with --claude-md"); } - (true, _, _, _, _, _, PatchMode::Ask) => run_codex_mode(global, verbose), - (false, _, true, false, _, _, _) => { - anyhow::bail!("OpenCode plugin is global-only. Use: rtk init -g --opencode") + if hook_only { + anyhow::bail!("--codex cannot be combined with --hook-only"); } - (false, false, true, _, _, _, _) => run_opencode_only_mode(verbose), - (false, true, opencode, _, true, _, _) => run_claude_md_mode(global, verbose, opencode), - (false, true, opencode, _, false, true, _) => { - run_hook_only_mode(global, patch_mode, verbose, opencode) + if matches!(patch_mode, PatchMode::Auto) { + anyhow::bail!("--codex cannot be combined with --auto-patch"); + } + if matches!(patch_mode, PatchMode::Skip) { + anyhow::bail!("--codex cannot be combined with --no-patch"); + } + return run_codex_mode(global, verbose); + } + + // Validation: Global-only features + if install_opencode && !global { + anyhow::bail!("OpenCode plugin is global-only. Use: rtk init -g --opencode"); + } + + if install_cursor && !global { + anyhow::bail!("Cursor hooks are global-only. Use: rtk init -g --agent cursor"); + } + + // Mode selection (Claude Code / OpenCode) + match (install_claude, install_opencode, claude_md, hook_only) { + (false, true, _, _) => run_opencode_only_mode(verbose)?, + (true, opencode, true, _) => run_claude_md_mode(global, verbose, opencode)?, + (true, opencode, false, true) => { + run_hook_only_mode(global, patch_mode, verbose, opencode)? } - (false, true, opencode, _, false, false, _) => { - run_default_mode(global, patch_mode, verbose, opencode) + (true, opencode, false, false) => { + run_default_mode(global, patch_mode, verbose, opencode)? } - (false, false, false, _, _, _, _) => { - anyhow::bail!("at least one of install_claude or install_opencode must be true") + (false, false, _, _) => { + if !install_cursor { + anyhow::bail!( + "at least one of install_claude or install_opencode must be true" + ) + } } } + + // Cursor hooks (additive, installed alongside Claude Code) + if install_cursor { + install_cursor_hooks(verbose)?; + } + + Ok(()) } /// Prepare hook directory and return paths (hook_dir, hook_path) @@ -480,11 +504,30 @@ fn remove_hook_from_settings(verbose: u8) -> Result { Ok(removed) } -/// Full uninstall for Claude, Gemini, or Codex artifacts. -pub fn uninstall(global: bool, gemini: bool, codex: bool, verbose: u8) -> Result<()> { +/// Full uninstall for Claude, Gemini, Codex, or Cursor artifacts. +pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: u8) -> Result<()> { if codex { return uninstall_codex(global, verbose); } + + if cursor { + if !global { + anyhow::bail!("Cursor uninstall only works with --global flag"); + } + let cursor_removed = remove_cursor_hooks(verbose) + .context("Failed to remove Cursor hooks")?; + if !cursor_removed.is_empty() { + println!("RTK uninstalled (Cursor):"); + for item in &cursor_removed { + println!(" - {}", item); + } + println!("\nRestart Cursor to apply changes."); + } else { + println!("RTK Cursor support was not installed (nothing to remove)"); + } + return Ok(()); + } + if !global { anyhow::bail!("Uninstall only works with --global flag. For local projects, manually remove RTK from CLAUDE.md"); } @@ -563,6 +606,10 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, verbose: u8) -> Result removed.push(format!("OpenCode plugin: {}", path.display())); } + // 6. Remove Cursor hooks + let cursor_removed = remove_cursor_hooks(verbose)?; + removed.extend(cursor_removed); + // Report results if removed.is_empty() { println!("RTK was not installed (nothing to remove)"); @@ -571,7 +618,7 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, verbose: u8) -> Result for item in removed { println!(" - {}", item); } - println!("\nRestart Claude Code and OpenCode (if used) to apply changes."); + println!("\nRestart Claude Code, OpenCode, and Cursor (if used) to apply changes."); } Ok(()) @@ -1441,6 +1488,215 @@ fn remove_opencode_plugin(verbose: u8) -> Result> { Ok(removed) } + +// ─── Cursor Agent support ───────────────────────────────────────────── + +/// Resolve ~/.cursor directory +fn resolve_cursor_dir() -> Result { + dirs::home_dir() + .map(|h| h.join(".cursor")) + .context("Cannot determine home directory. Is $HOME set?") +} + +/// Install Cursor hooks: hook script + hooks.json +fn install_cursor_hooks(verbose: u8) -> Result<()> { + let cursor_dir = resolve_cursor_dir()?; + let hooks_dir = cursor_dir.join("hooks"); + fs::create_dir_all(&hooks_dir) + .with_context(|| format!("Failed to create Cursor hooks directory: {}", hooks_dir.display()))?; + + // 1. Write hook script + let hook_path = hooks_dir.join("rtk-rewrite.sh"); + let hook_changed = write_if_changed(&hook_path, CURSOR_REWRITE_HOOK, "Cursor hook", verbose)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Failed to set Cursor hook permissions: {}", hook_path.display()))?; + } + + // 2. Create or patch hooks.json + let hooks_json_path = cursor_dir.join("hooks.json"); + let patched = patch_cursor_hooks_json(&hooks_json_path, verbose)?; + + // Report + let hook_status = if hook_changed { "installed/updated" } else { "already up to date" }; + println!("\nCursor hook {} (global).\n", hook_status); + println!(" Hook: {}", hook_path.display()); + println!(" hooks.json: {}", hooks_json_path.display()); + + if patched { + println!(" hooks.json: RTK preToolUse entry added"); + } else { + println!(" hooks.json: RTK preToolUse entry already present"); + } + + println!(" Cursor reloads hooks.json automatically. Test with: git status\n"); + + Ok(()) +} + +/// Patch ~/.cursor/hooks.json to add RTK preToolUse hook. +/// Returns true if the file was modified. +fn patch_cursor_hooks_json(path: &Path, verbose: u8) -> Result { + let mut root = if path.exists() { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + if content.trim().is_empty() { + serde_json::json!({ "version": 1 }) + } else { + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {} as JSON", path.display()))? + } + } else { + serde_json::json!({ "version": 1 }) + }; + + // Check idempotency + if cursor_hook_already_present(&root) { + if verbose > 0 { + eprintln!("Cursor hooks.json: RTK hook already present"); + } + return Ok(false); + } + + // Insert the RTK preToolUse entry + insert_cursor_hook_entry(&mut root); + + // Backup if exists + if path.exists() { + let backup_path = path.with_extension("json.bak"); + fs::copy(path, &backup_path) + .with_context(|| format!("Failed to backup to {}", backup_path.display()))?; + if verbose > 0 { + eprintln!("Backup: {}", backup_path.display()); + } + } + + // Atomic write + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; + atomic_write(path, &serialized)?; + + Ok(true) +} + +/// Check if RTK preToolUse hook is already present in Cursor hooks.json +fn cursor_hook_already_present(root: &serde_json::Value) -> bool { + let hooks = match root.get("hooks").and_then(|h| h.get("preToolUse")).and_then(|p| p.as_array()) + { + Some(arr) => arr, + None => return false, + }; + + hooks.iter().any(|entry| { + entry + .get("command") + .and_then(|c| c.as_str()) + .map_or(false, |cmd| cmd.contains("rtk-rewrite.sh")) + }) +} + +/// Insert RTK preToolUse entry into Cursor hooks.json +fn insert_cursor_hook_entry(root: &mut serde_json::Value) { + let root_obj = match root.as_object_mut() { + Some(obj) => obj, + None => { + *root = serde_json::json!({ "version": 1 }); + root.as_object_mut() + .expect("Just created object, must succeed") + } + }; + + // Ensure version key + root_obj + .entry("version") + .or_insert(serde_json::json!(1)); + + let hooks = root_obj + .entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .expect("hooks must be an object"); + + let pre_tool_use = hooks + .entry("preToolUse") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .expect("preToolUse must be an array"); + + pre_tool_use.push(serde_json::json!({ + "command": "./hooks/rtk-rewrite.sh", + "matcher": "Shell" + })); +} + +/// Remove Cursor RTK artifacts: hook script + hooks.json entry +fn remove_cursor_hooks(verbose: u8) -> Result> { + let cursor_dir = resolve_cursor_dir()?; + let mut removed = Vec::new(); + + // 1. Remove hook script + let hook_path = cursor_dir.join("hooks").join("rtk-rewrite.sh"); + if hook_path.exists() { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove Cursor hook: {}", hook_path.display()))?; + removed.push(format!("Cursor hook: {}", hook_path.display())); + } + + // 2. Remove RTK entry from hooks.json + let hooks_json_path = cursor_dir.join("hooks.json"); + if hooks_json_path.exists() { + let content = fs::read_to_string(&hooks_json_path) + .with_context(|| format!("Failed to read {}", hooks_json_path.display()))?; + + if !content.trim().is_empty() { + if let Ok(mut root) = serde_json::from_str::(&content) { + if remove_cursor_hook_from_json(&mut root) { + let backup_path = hooks_json_path.with_extension("json.bak"); + fs::copy(&hooks_json_path, &backup_path).ok(); + + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize hooks.json")?; + atomic_write(&hooks_json_path, &serialized)?; + + removed.push("Cursor hooks.json: removed RTK entry".to_string()); + + if verbose > 0 { + eprintln!("Removed RTK hook from Cursor hooks.json"); + } + } + } + } + } + + Ok(removed) +} + +/// Remove RTK preToolUse entry from Cursor hooks.json +/// Returns true if entry was found and removed +fn remove_cursor_hook_from_json(root: &mut serde_json::Value) -> bool { + let pre_tool_use = match root + .get_mut("hooks") + .and_then(|h| h.get_mut("preToolUse")) + .and_then(|p| p.as_array_mut()) + { + Some(arr) => arr, + None => return false, + }; + + let original_len = pre_tool_use.len(); + pre_tool_use.retain(|entry| { + !entry + .get("command") + .and_then(|c| c.as_str()) + .map_or(false, |cmd| cmd.contains("rtk-rewrite.sh")) + }); + + pre_tool_use.len() < original_len +} + /// Show current rtk configuration pub fn show_config(codex: bool) -> Result<()> { if codex { @@ -1599,6 +1855,66 @@ fn show_claude_config() -> Result<()> { println!("[--] OpenCode: config dir not found"); } + // Check Cursor hooks + if let Ok(cursor_dir) = resolve_cursor_dir() { + let cursor_hook = cursor_dir.join("hooks").join("rtk-rewrite.sh"); + let cursor_hooks_json = cursor_dir.join("hooks.json"); + + if cursor_hook.exists() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let meta = fs::metadata(&cursor_hook)?; + let is_executable = meta.permissions().mode() & 0o111 != 0; + let content = fs::read_to_string(&cursor_hook)?; + let is_thin = content.contains("rtk rewrite"); + + if !is_executable { + println!( + "⚠️ Cursor hook: {} (NOT executable - run: chmod +x)", + cursor_hook.display() + ); + } else if is_thin { + println!("✅ Cursor hook: {} (thin delegator)", cursor_hook.display()); + } else { + println!( + "⚠️ Cursor hook: {} (outdated — missing rtk rewrite delegation)", + cursor_hook.display() + ); + } + } + + #[cfg(not(unix))] + { + println!("✅ Cursor hook: {} (exists)", cursor_hook.display()); + } + } else { + println!("⚪ Cursor hook: not found"); + } + + if cursor_hooks_json.exists() { + let content = fs::read_to_string(&cursor_hooks_json)?; + if !content.trim().is_empty() { + if let Ok(root) = serde_json::from_str::(&content) { + if cursor_hook_already_present(&root) { + println!("✅ Cursor hooks.json: RTK preToolUse configured"); + } else { + println!("⚠️ Cursor hooks.json: exists but RTK not configured"); + println!(" Run: rtk init -g --agent cursor"); + } + } else { + println!("⚠️ Cursor hooks.json: exists but invalid JSON"); + } + } else { + println!("⚪ Cursor hooks.json: empty"); + } + } else { + println!("⚪ Cursor hooks.json: not found"); + } + } else { + println!("⚪ Cursor: home dir not found"); + } + println!("\nUsage:"); println!(" rtk init # Full injection into local CLAUDE.md"); println!(" rtk init -g # Hook + RTK.md + @RTK.md + settings.json (recommended)"); @@ -1610,6 +1926,7 @@ fn show_claude_config() -> Result<()> { println!(" rtk init --codex # Configure local AGENTS.md + RTK.md"); println!(" rtk init -g --codex # Configure ~/.codex/AGENTS.md + ~/.codex/RTK.md"); println!(" rtk init -g --opencode # OpenCode plugin only"); + println!(" rtk init -g --agent cursor # Install Cursor Agent hooks"); Ok(()) } @@ -2110,7 +2427,7 @@ More notes #[test] fn test_codex_mode_rejects_auto_patch() { - let err = run(false, false, false, false, false, true, PatchMode::Auto, 0).unwrap_err(); + let err = run(false, false, false, false, false, false, true, PatchMode::Auto, 0).unwrap_err(); assert_eq!( err.to_string(), "--codex cannot be combined with --auto-patch" @@ -2119,7 +2436,7 @@ More notes #[test] fn test_codex_mode_rejects_no_patch() { - let err = run(false, false, false, false, false, true, PatchMode::Skip, 0).unwrap_err(); + let err = run(false, false, false, false, false, false, true, PatchMode::Skip, 0).unwrap_err(); assert_eq!( err.to_string(), "--codex cannot be combined with --no-patch" @@ -2429,4 +2746,132 @@ More notes let removed = remove_hook_from_json(&mut json_content); assert!(!removed); } + + // ─── Cursor hooks.json tests ─── + + #[test] + fn test_cursor_hook_already_present_true() { + let json_content = serde_json::json!({ + "version": 1, + "hooks": { + "preToolUse": [{ + "command": "./hooks/rtk-rewrite.sh", + "matcher": "Shell" + }] + } + }); + assert!(cursor_hook_already_present(&json_content)); + } + + #[test] + fn test_cursor_hook_already_present_false_empty() { + let json_content = serde_json::json!({ "version": 1 }); + assert!(!cursor_hook_already_present(&json_content)); + } + + #[test] + fn test_cursor_hook_already_present_false_other_hooks() { + let json_content = serde_json::json!({ + "version": 1, + "hooks": { + "preToolUse": [{ + "command": "./hooks/some-other-hook.sh", + "matcher": "Shell" + }] + } + }); + assert!(!cursor_hook_already_present(&json_content)); + } + + #[test] + fn test_insert_cursor_hook_entry_empty() { + let mut json_content = serde_json::json!({ "version": 1 }); + insert_cursor_hook_entry(&mut json_content); + + let hooks = json_content["hooks"]["preToolUse"].as_array().unwrap(); + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0]["command"], "./hooks/rtk-rewrite.sh"); + assert_eq!(hooks[0]["matcher"], "Shell"); + assert_eq!(json_content["version"], 1); + } + + #[test] + fn test_insert_cursor_hook_preserves_existing() { + let mut json_content = serde_json::json!({ + "version": 1, + "hooks": { + "preToolUse": [{ + "command": "./hooks/other.sh", + "matcher": "Shell" + }], + "afterFileEdit": [{ + "command": "./hooks/format.sh" + }] + } + }); + + insert_cursor_hook_entry(&mut json_content); + + let pre_tool_use = json_content["hooks"]["preToolUse"].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 2); + assert_eq!(pre_tool_use[0]["command"], "./hooks/other.sh"); + assert_eq!(pre_tool_use[1]["command"], "./hooks/rtk-rewrite.sh"); + + // afterFileEdit should be preserved + assert!(json_content["hooks"]["afterFileEdit"].is_array()); + } + + #[test] + fn test_remove_cursor_hook_from_json() { + let mut json_content = serde_json::json!({ + "version": 1, + "hooks": { + "preToolUse": [ + { "command": "./hooks/other.sh", "matcher": "Shell" }, + { "command": "./hooks/rtk-rewrite.sh", "matcher": "Shell" } + ] + } + }); + + let removed = remove_cursor_hook_from_json(&mut json_content); + assert!(removed); + + let hooks = json_content["hooks"]["preToolUse"].as_array().unwrap(); + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0]["command"], "./hooks/other.sh"); + } + + #[test] + fn test_remove_cursor_hook_not_present() { + let mut json_content = serde_json::json!({ + "version": 1, + "hooks": { + "preToolUse": [ + { "command": "./hooks/other.sh", "matcher": "Shell" } + ] + } + }); + + let removed = remove_cursor_hook_from_json(&mut json_content); + assert!(!removed); + } + + #[test] + fn test_cursor_hook_script_has_guards() { + assert!(CURSOR_REWRITE_HOOK.contains("command -v rtk")); + assert!(CURSOR_REWRITE_HOOK.contains("command -v jq")); + let jq_pos = CURSOR_REWRITE_HOOK.find("command -v jq").unwrap(); + let rtk_delegate_pos = CURSOR_REWRITE_HOOK.find("rtk rewrite \"$CMD\"").unwrap(); + assert!( + jq_pos < rtk_delegate_pos, + "Guards must appear before rtk rewrite delegation" + ); + } + + #[test] + fn test_cursor_hook_outputs_cursor_format() { + assert!(CURSOR_REWRITE_HOOK.contains("\"permission\": \"allow\"")); + assert!(CURSOR_REWRITE_HOOK.contains("\"updated_input\"")); + assert!(!CURSOR_REWRITE_HOOK.contains("hookSpecificOutput")); + } } diff --git a/src/main.rs b/src/main.rs index 1279a454..6dcc036c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,10 +67,19 @@ mod wget_cmd; use anyhow::{Context, Result}; use clap::error::ErrorKind; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use std::ffi::OsString; use std::path::{Path, PathBuf}; +/// Target agent for hook installation. +#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)] +pub enum AgentTarget { + /// Claude Code (default) + Claude, + /// Cursor Agent (editor and CLI) + Cursor, +} + #[derive(Parser)] #[command( name = "rtk", @@ -337,6 +346,10 @@ enum Commands { #[arg(long)] gemini: bool, + /// Target agent to install hooks for (default: claude) + #[arg(long, value_enum)] + agent: Option, + /// Show current configuration #[arg(long)] show: bool, @@ -1632,6 +1645,7 @@ fn main() -> Result<()> { global, opencode, gemini, + agent, show, claude_md, hook_only, @@ -1643,7 +1657,8 @@ fn main() -> Result<()> { if show { init::show_config(codex)?; } else if uninstall { - init::uninstall(global, gemini, codex, cli.verbose)?; + let cursor = agent.map_or(false, |a| a == AgentTarget::Cursor); + init::uninstall(global, gemini, codex, cursor, cli.verbose)?; } else if gemini { let patch_mode = if auto_patch { init::PatchMode::Auto @@ -1656,6 +1671,8 @@ fn main() -> Result<()> { } else { let install_opencode = opencode; let install_claude = !opencode; + let install_cursor = + agent.map_or(false, |a| a == AgentTarget::Cursor); let patch_mode = if auto_patch { init::PatchMode::Auto @@ -1668,6 +1685,7 @@ fn main() -> Result<()> { global, install_claude, install_opencode, + install_cursor, claude_md, hook_only, codex, From 86d50698c18279c0c5e448ebeaaa5d6f62836cba Mon Sep 17 00:00:00 2001 From: patrick szymkowiak <52030887+pszymkowiak@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:34:56 +0100 Subject: [PATCH 7/8] feat: add Windsurf support via rtk init --agent windsurf (#695) (#697) Install RTK rules in .windsurfrules (project-scoped) so Cascade prefixes shell commands with rtk for token savings. Windsurf hooks don't support command rewriting (only blocking), so RTK uses the rules-based approach (like Codex with AGENTS.md). Tested: Windsurf Cascade correctly uses rtk git status after install. Signed-off-by: Patrick szymkowiak --- hooks/windsurf-rtk-rules.md | 32 +++++++++ src/init.rs | 127 +++++++++++++++++++++++++++++------- src/main.rs | 7 +- 3 files changed, 142 insertions(+), 24 deletions(-) create mode 100644 hooks/windsurf-rtk-rules.md diff --git a/hooks/windsurf-rtk-rules.md b/hooks/windsurf-rtk-rules.md new file mode 100644 index 00000000..8491f5a7 --- /dev/null +++ b/hooks/windsurf-rtk-rules.md @@ -0,0 +1,32 @@ +# RTK - Rust Token Killer (Windsurf) + +**Usage**: Token-optimized CLI proxy for shell commands. + +## Rule + +Always prefix shell commands with `rtk` to minimize token consumption. + +Examples: + +```bash +rtk git status +rtk cargo test +rtk ls src/ +rtk grep "pattern" src/ +rtk find "*.rs" . +rtk docker ps +rtk gh pr list +``` + +## Meta Commands + +```bash +rtk gain # Show token savings +rtk gain --history # Command history with savings +rtk discover # Find missed RTK opportunities +rtk proxy # Run raw (no filtering, for debugging) +``` + +## Why + +RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. diff --git a/src/init.rs b/src/init.rs index d0759aae..407be9bb 100644 --- a/src/init.rs +++ b/src/init.rs @@ -209,6 +209,7 @@ pub fn run( install_claude: bool, install_opencode: bool, install_cursor: bool, + install_windsurf: bool, claude_md: bool, hook_only: bool, codex: bool, @@ -244,21 +245,24 @@ pub fn run( anyhow::bail!("Cursor hooks are global-only. Use: rtk init -g --agent cursor"); } + if install_windsurf && !global { + anyhow::bail!("Windsurf support is global-only. Use: rtk init -g --agent windsurf"); + } + + // Windsurf-only mode + if install_windsurf { + return run_windsurf_mode(verbose); + } + // Mode selection (Claude Code / OpenCode) match (install_claude, install_opencode, claude_md, hook_only) { (false, true, _, _) => run_opencode_only_mode(verbose)?, (true, opencode, true, _) => run_claude_md_mode(global, verbose, opencode)?, - (true, opencode, false, true) => { - run_hook_only_mode(global, patch_mode, verbose, opencode)? - } - (true, opencode, false, false) => { - run_default_mode(global, patch_mode, verbose, opencode)? - } + (true, opencode, false, true) => run_hook_only_mode(global, patch_mode, verbose, opencode)?, + (true, opencode, false, false) => run_default_mode(global, patch_mode, verbose, opencode)?, (false, false, _, _) => { if !install_cursor { - anyhow::bail!( - "at least one of install_claude or install_opencode must be true" - ) + anyhow::bail!("at least one of install_claude or install_opencode must be true") } } } @@ -514,8 +518,8 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: if !global { anyhow::bail!("Cursor uninstall only works with --global flag"); } - let cursor_removed = remove_cursor_hooks(verbose) - .context("Failed to remove Cursor hooks")?; + let cursor_removed = + remove_cursor_hooks(verbose).context("Failed to remove Cursor hooks")?; if !cursor_removed.is_empty() { println!("RTK uninstalled (Cursor):"); for item in &cursor_removed { @@ -1153,6 +1157,48 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu } /// Codex mode: slim RTK.md + @RTK.md reference in AGENTS.md +// ─── Windsurf support ───────────────────────────────────────── + +/// Embedded Windsurf RTK rules +const WINDSURF_RULES: &str = include_str!("../hooks/windsurf-rtk-rules.md"); + +/// Resolve Windsurf user config directory (~/.codeium/windsurf) +fn resolve_windsurf_dir() -> Result { + dirs::home_dir() + .map(|h| h.join(".codeium").join("windsurf")) + .context("Cannot determine home directory") +} + +fn run_windsurf_mode(verbose: u8) -> Result<()> { + // Windsurf reads .windsurfrules from the project root (workspace-scoped). + // Global rules (~/.codeium/windsurf/memories/global_rules.md) are unreliable. + let rules_path = PathBuf::from(".windsurfrules"); + + let existing = fs::read_to_string(&rules_path).unwrap_or_default(); + if existing.contains("RTK") || existing.contains("rtk") { + println!("\nRTK already configured for Windsurf in this project.\n"); + println!(" Rules: .windsurfrules (already present)"); + } else { + let new_content = if existing.trim().is_empty() { + WINDSURF_RULES.to_string() + } else { + format!("{}\n\n{}", existing.trim(), WINDSURF_RULES) + }; + fs::write(&rules_path, &new_content).context("Failed to write .windsurfrules")?; + + if verbose > 0 { + eprintln!("Wrote .windsurfrules"); + } + + println!("\nRTK configured for Windsurf Cascade.\n"); + println!(" Rules: .windsurfrules (installed)"); + } + println!(" Cascade will now use rtk commands for token savings."); + println!(" Restart Windsurf. Test with: git status\n"); + + Ok(()) +} + fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { let (agents_md_path, rtk_md_path) = if global { let codex_dir = resolve_codex_dir()?; @@ -1502,8 +1548,12 @@ fn resolve_cursor_dir() -> Result { fn install_cursor_hooks(verbose: u8) -> Result<()> { let cursor_dir = resolve_cursor_dir()?; let hooks_dir = cursor_dir.join("hooks"); - fs::create_dir_all(&hooks_dir) - .with_context(|| format!("Failed to create Cursor hooks directory: {}", hooks_dir.display()))?; + fs::create_dir_all(&hooks_dir).with_context(|| { + format!( + "Failed to create Cursor hooks directory: {}", + hooks_dir.display() + ) + })?; // 1. Write hook script let hook_path = hooks_dir.join("rtk-rewrite.sh"); @@ -1512,8 +1562,12 @@ fn install_cursor_hooks(verbose: u8) -> Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to set Cursor hook permissions: {}", hook_path.display()))?; + fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).with_context(|| { + format!( + "Failed to set Cursor hook permissions: {}", + hook_path.display() + ) + })?; } // 2. Create or patch hooks.json @@ -1521,7 +1575,11 @@ fn install_cursor_hooks(verbose: u8) -> Result<()> { let patched = patch_cursor_hooks_json(&hooks_json_path, verbose)?; // Report - let hook_status = if hook_changed { "installed/updated" } else { "already up to date" }; + let hook_status = if hook_changed { + "installed/updated" + } else { + "already up to date" + }; println!("\nCursor hook {} (global).\n", hook_status); println!(" Hook: {}", hook_path.display()); println!(" hooks.json: {}", hooks_json_path.display()); @@ -1584,7 +1642,10 @@ fn patch_cursor_hooks_json(path: &Path, verbose: u8) -> Result { /// Check if RTK preToolUse hook is already present in Cursor hooks.json fn cursor_hook_already_present(root: &serde_json::Value) -> bool { - let hooks = match root.get("hooks").and_then(|h| h.get("preToolUse")).and_then(|p| p.as_array()) + let hooks = match root + .get("hooks") + .and_then(|h| h.get("preToolUse")) + .and_then(|p| p.as_array()) { Some(arr) => arr, None => return false, @@ -1610,9 +1671,7 @@ fn insert_cursor_hook_entry(root: &mut serde_json::Value) { }; // Ensure version key - root_obj - .entry("version") - .or_insert(serde_json::json!(1)); + root_obj.entry("version").or_insert(serde_json::json!(1)); let hooks = root_obj .entry("hooks") @@ -2427,7 +2486,19 @@ More notes #[test] fn test_codex_mode_rejects_auto_patch() { - let err = run(false, false, false, false, false, false, true, PatchMode::Auto, 0).unwrap_err(); + let err = run( + false, + false, + false, + false, + false, + false, + false, + true, + PatchMode::Auto, + 0, + ) + .unwrap_err(); assert_eq!( err.to_string(), "--codex cannot be combined with --auto-patch" @@ -2436,7 +2507,19 @@ More notes #[test] fn test_codex_mode_rejects_no_patch() { - let err = run(false, false, false, false, false, false, true, PatchMode::Skip, 0).unwrap_err(); + let err = run( + false, + false, + false, + false, + false, + false, + false, + true, + PatchMode::Skip, + 0, + ) + .unwrap_err(); assert_eq!( err.to_string(), "--codex cannot be combined with --no-patch" diff --git a/src/main.rs b/src/main.rs index 6dcc036c..d78da66c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,6 +78,8 @@ pub enum AgentTarget { Claude, /// Cursor Agent (editor and CLI) Cursor, + /// Windsurf IDE (Cascade) + Windsurf, } #[derive(Parser)] @@ -1671,8 +1673,8 @@ fn main() -> Result<()> { } else { let install_opencode = opencode; let install_claude = !opencode; - let install_cursor = - agent.map_or(false, |a| a == AgentTarget::Cursor); + let install_cursor = agent.map_or(false, |a| a == AgentTarget::Cursor); + let install_windsurf = agent.map_or(false, |a| a == AgentTarget::Windsurf); let patch_mode = if auto_patch { init::PatchMode::Auto @@ -1686,6 +1688,7 @@ fn main() -> Result<()> { install_claude, install_opencode, install_cursor, + install_windsurf, claude_md, hook_only, codex, From d921cc4fa0324843c82523085cca2014b075a12e Mon Sep 17 00:00:00 2001 From: patrick szymkowiak <52030887+pszymkowiak@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:58:43 +0100 Subject: [PATCH 8/8] feat: add Cline/Roo Code support via rtk init --agent cline (#701) (#702) Install RTK rules in .clinerules (project-scoped) so Cline prefixes shell commands with rtk for token savings. Same rules-based approach as Windsurf and Codex. Signed-off-by: Patrick szymkowiak --- hooks/cline-rtk-rules.md | 32 +++++++++++++++++++++++ src/init.rs | 55 ++++++++++++++++++++++++++++++++-------- src/main.rs | 10 +++++--- 3 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 hooks/cline-rtk-rules.md diff --git a/hooks/cline-rtk-rules.md b/hooks/cline-rtk-rules.md new file mode 100644 index 00000000..7d6c8aff --- /dev/null +++ b/hooks/cline-rtk-rules.md @@ -0,0 +1,32 @@ +# RTK - Rust Token Killer (Cline) + +**Usage**: Token-optimized CLI proxy for shell commands. + +## Rule + +Always prefix shell commands with `rtk` to minimize token consumption. + +Examples: + +```bash +rtk git status +rtk cargo test +rtk ls src/ +rtk grep "pattern" src/ +rtk find "*.rs" . +rtk docker ps +rtk gh pr list +``` + +## Meta Commands + +```bash +rtk gain # Show token savings +rtk gain --history # Command history with savings +rtk discover # Find missed RTK opportunities +rtk proxy # Run raw (no filtering, for debugging) +``` + +## Why + +RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. diff --git a/src/init.rs b/src/init.rs index 407be9bb..98be225e 100644 --- a/src/init.rs +++ b/src/init.rs @@ -204,12 +204,14 @@ Overall average: **60-90% token reduction** on common development operations. "##; /// Main entry point for `rtk init` +#[allow(clippy::too_many_arguments)] pub fn run( global: bool, install_claude: bool, install_opencode: bool, install_cursor: bool, install_windsurf: bool, + install_cline: bool, claude_md: bool, hook_only: bool, codex: bool, @@ -254,6 +256,11 @@ pub fn run( return run_windsurf_mode(verbose); } + // Cline-only mode + if install_cline { + return run_cline_mode(verbose); + } + // Mode selection (Claude Code / OpenCode) match (install_claude, install_opencode, claude_md, hook_only) { (false, true, _, _) => run_opencode_only_mode(verbose)?, @@ -1156,17 +1163,43 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu Ok(()) } -/// Codex mode: slim RTK.md + @RTK.md reference in AGENTS.md // ─── Windsurf support ───────────────────────────────────────── /// Embedded Windsurf RTK rules const WINDSURF_RULES: &str = include_str!("../hooks/windsurf-rtk-rules.md"); -/// Resolve Windsurf user config directory (~/.codeium/windsurf) -fn resolve_windsurf_dir() -> Result { - dirs::home_dir() - .map(|h| h.join(".codeium").join("windsurf")) - .context("Cannot determine home directory") +/// Embedded Cline RTK rules +const CLINE_RULES: &str = include_str!("../hooks/cline-rtk-rules.md"); + +// ─── Cline / Roo Code support ───────────────────────────────── + +fn run_cline_mode(verbose: u8) -> Result<()> { + // Cline reads .clinerules from the project root (workspace-scoped) + let rules_path = PathBuf::from(".clinerules"); + + let existing = fs::read_to_string(&rules_path).unwrap_or_default(); + if existing.contains("RTK") || existing.contains("rtk") { + println!("\nRTK already configured for Cline in this project.\n"); + println!(" Rules: .clinerules (already present)"); + } else { + let new_content = if existing.trim().is_empty() { + CLINE_RULES.to_string() + } else { + format!("{}\n\n{}", existing.trim(), CLINE_RULES) + }; + fs::write(&rules_path, &new_content).context("Failed to write .clinerules")?; + + if verbose > 0 { + eprintln!("Wrote .clinerules"); + } + + println!("\nRTK configured for Cline.\n"); + println!(" Rules: .clinerules (installed)"); + } + println!(" Cline will now use rtk commands for token savings."); + println!(" Test with: git status\n"); + + Ok(()) } fn run_windsurf_mode(verbose: u8) -> Result<()> { @@ -1655,7 +1688,7 @@ fn cursor_hook_already_present(root: &serde_json::Value) -> bool { entry .get("command") .and_then(|c| c.as_str()) - .map_or(false, |cmd| cmd.contains("rtk-rewrite.sh")) + .is_some_and(|cmd| cmd.contains("rtk-rewrite.sh")) }) } @@ -1750,7 +1783,7 @@ fn remove_cursor_hook_from_json(root: &mut serde_json::Value) -> bool { !entry .get("command") .and_then(|c| c.as_str()) - .map_or(false, |cmd| cmd.contains("rtk-rewrite.sh")) + .is_some_and(|cmd| cmd.contains("rtk-rewrite.sh")) }); pre_tool_use.len() < original_len @@ -2139,7 +2172,7 @@ fn patch_gemini_settings( if arr.iter().any(|h| { h.pointer("/hooks/0/command") .and_then(|v| v.as_str()) - .map_or(false, |c| c.contains("rtk")) + .is_some_and(|c| c.contains("rtk")) }) { if verbose > 0 { eprintln!("Gemini settings.json already has RTK hook"); @@ -2248,7 +2281,7 @@ fn uninstall_gemini(verbose: u8) -> Result> { arr.retain(|h| { !h.pointer("/hooks/0/command") .and_then(|v| v.as_str()) - .map_or(false, |c| c.contains("rtk")) + .is_some_and(|c| c.contains("rtk")) }); if arr.len() < before { let new_content = serde_json::to_string_pretty(&settings)?; @@ -2494,6 +2527,7 @@ More notes false, false, false, + false, true, PatchMode::Auto, 0, @@ -2515,6 +2549,7 @@ More notes false, false, false, + false, true, PatchMode::Skip, 0, diff --git a/src/main.rs b/src/main.rs index d78da66c..2bbc4bb2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,6 +80,8 @@ pub enum AgentTarget { Cursor, /// Windsurf IDE (Cascade) Windsurf, + /// Cline / Roo Code (VS Code) + Cline, } #[derive(Parser)] @@ -1659,7 +1661,7 @@ fn main() -> Result<()> { if show { init::show_config(codex)?; } else if uninstall { - let cursor = agent.map_or(false, |a| a == AgentTarget::Cursor); + let cursor = agent == Some(AgentTarget::Cursor); init::uninstall(global, gemini, codex, cursor, cli.verbose)?; } else if gemini { let patch_mode = if auto_patch { @@ -1673,8 +1675,9 @@ fn main() -> Result<()> { } else { let install_opencode = opencode; let install_claude = !opencode; - let install_cursor = agent.map_or(false, |a| a == AgentTarget::Cursor); - let install_windsurf = agent.map_or(false, |a| a == AgentTarget::Windsurf); + let install_cursor = agent == Some(AgentTarget::Cursor); + let install_windsurf = agent == Some(AgentTarget::Windsurf); + let install_cline = agent == Some(AgentTarget::Cline); let patch_mode = if auto_patch { init::PatchMode::Auto @@ -1689,6 +1692,7 @@ fn main() -> Result<()> { install_opencode, install_cursor, install_windsurf, + install_cline, claude_md, hook_only, codex,