From 19d35f57d32b75ba1d365cdef701eeafd19ce519 Mon Sep 17 00:00:00 2001 From: Patrick szymkowiak Date: Wed, 25 Mar 2026 17:31:15 +0100 Subject: [PATCH 1/5] docs(readme): fix Copilot setup instructions + add Vibe status - Split Copilot into VS Code (transparent rewrite) and CLI (deny-with-suggestion) - Use --copilot flag (consistent with --gemini, --codex, --opencode) - Add Mistral Vibe as planned (blocked on upstream #531) - Fix Copilot section with VS Code vs CLI details - Update tool count from 9 to 10 - Verified all 10 tools against actual codebase Signed-off-by: Patrick szymkowiak --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6073d5eb..a91743e9 100644 --- a/README.md +++ b/README.md @@ -296,12 +296,13 @@ After install, **restart Claude Code**. ## Supported AI Tools -RTK supports 9 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings. +RTK supports 10 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings. | Tool | Install | Method | |------|---------|--------| | **Claude Code** | `rtk init -g` | PreToolUse hook (bash) | -| **GitHub Copilot** | `rtk init -g` | PreToolUse hook (`rtk hook copilot`) | +| **GitHub Copilot (VS Code)** | `rtk init -g --copilot` | PreToolUse hook (`rtk hook copilot`) — transparent rewrite | +| **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) | | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | | **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook (`rtk hook gemini`) | | **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | @@ -309,6 +310,7 @@ RTK supports 9 AI coding tools. Each integration transparently rewrites shell co | **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) | +| **Mistral Vibe** | Planned (#800) | Blocked on upstream BeforeToolCallback | ### Claude Code (default) @@ -322,10 +324,14 @@ rtk init -g --uninstall # Remove ### GitHub Copilot (VS Code + CLI) ```bash -rtk init -g # Same hook as Claude Code +rtk init -g --copilot # Install hook + instructions ``` -The hook auto-detects Copilot format (VS Code `runTerminalCommand` or CLI `toolName: bash`) and rewrites commands. Works with both Copilot Chat in VS Code and `copilot` CLI. +Creates `.github/hooks/rtk-rewrite.json` (PreToolUse hook) and `.github/copilot-instructions.md` (prompt-level awareness). + +The hook (`rtk hook copilot`) auto-detects the format: +- **VS Code Copilot Chat**: transparent rewrite via `updatedInput` (same as Claude Code) +- **Copilot CLI**: deny-with-suggestion (CLI does not support `updatedInput` yet — see [copilot-cli#2013](https://github.com/github/copilot-cli/issues/2013)) ### Cursor @@ -384,6 +390,10 @@ openclaw plugins install ./openclaw Plugin in `openclaw/` directory. Uses `before_tool_call` hook, delegates to `rtk rewrite`. +### Mistral Vibe (planned) + +Blocked on upstream BeforeToolCallback support ([mistral-vibe#531](https://github.com/mistralai/mistral-vibe/issues/531), [PR #533](https://github.com/mistralai/mistral-vibe/pull/533)). Tracked in [#800](https://github.com/rtk-ai/rtk/issues/800). + ### Commands Rewritten | Raw Command | Rewritten To | From 9e19aac75e790ecbfd1dc5b2d01786f6b9edf506 Mon Sep 17 00:00:00 2001 From: Patrick szymkowiak Date: Wed, 25 Mar 2026 17:45:12 +0100 Subject: [PATCH 2/5] feat(init): add --copilot flag for GitHub Copilot integration - Add `rtk init --copilot` that creates .github/hooks/rtk-rewrite.json and .github/copilot-instructions.md in the current project - Hook routes through `rtk hook copilot` (auto-detects VS Code vs CLI) - VS Code Copilot Chat: transparent rewrite via updatedInput - Copilot CLI: deny-with-suggestion (CLI limitation) - Update README: split Copilot VS Code/CLI, add Vibe status, 10 tools Closes #823 Signed-off-by: Patrick szymkowiak --- src/init.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 7 +++++ 2 files changed, 82 insertions(+) diff --git a/src/init.rs b/src/init.rs index 494bef34..5bf5071b 100644 --- a/src/init.rs +++ b/src/init.rs @@ -2307,6 +2307,81 @@ fn uninstall_gemini(verbose: u8) -> Result> { Ok(removed) } +// ── Copilot integration ───────────────────────────────────── + +const COPILOT_HOOK_JSON: &str = r#"{ + "hooks": { + "PreToolUse": [ + { + "type": "command", + "command": "rtk hook copilot", + "cwd": ".", + "timeout": 5 + } + ] + } +} +"#; + +const COPILOT_INSTRUCTIONS: &str = r#"# RTK — Token-Optimized CLI + +**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens. + +## Rule + +Always prefix shell commands with `rtk`: + +```bash +# Instead of: Use: +git status rtk git status +git log -10 rtk git log -10 +cargo test rtk cargo test +docker ps rtk docker ps +kubectl get pods rtk kubectl pods +``` + +## Meta commands (use directly) + +```bash +rtk gain # Token savings dashboard +rtk gain --history # Per-command savings history +rtk discover # Find missed rtk opportunities +rtk proxy # Run raw (no filtering) but track usage +``` +"#; + +/// Entry point for `rtk init --copilot` +pub fn run_copilot(verbose: u8) -> 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")?; + + // 1. Write hook config + let hook_path = hooks_dir.join("rtk-rewrite.json"); + write_if_changed(&hook_path, COPILOT_HOOK_JSON, "Copilot hook config", verbose)?; + + // 2. Write instructions + let instructions_path = github_dir.join("copilot-instructions.md"); + write_if_changed( + &instructions_path, + COPILOT_INSTRUCTIONS, + "Copilot instructions", + verbose, + )?; + + 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(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index 654a2676..67c9f6bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -388,6 +388,10 @@ enum Commands { /// Target Codex CLI (uses AGENTS.md + RTK.md, no Claude hook patching) #[arg(long)] codex: bool, + + /// Install GitHub Copilot integration (VS Code + CLI) + #[arg(long)] + copilot: bool, }, /// Download with compact output (strips progress bars) @@ -1689,6 +1693,7 @@ fn main() -> Result<()> { no_patch, uninstall, codex, + copilot, } => { if show { init::show_config(codex)?; @@ -1704,6 +1709,8 @@ fn main() -> Result<()> { init::PatchMode::Ask }; init::run_gemini(global, hook_only, patch_mode, cli.verbose)?; + } else if copilot { + init::run_copilot(cli.verbose)?; } else { let install_opencode = opencode; let install_claude = !opencode; From 80fc29a839f51ef605474037e1a8fd86b4aac05a Mon Sep 17 00:00:00 2001 From: Patrick szymkowiak Date: Wed, 25 Mar 2026 20:10:35 +0100 Subject: [PATCH 3/5] =?UTF-8?q?fix(diff):=20never=20truncate=20diff=20cont?= =?UTF-8?q?ent=20=E2=80=94=20show=20all=20changes=20in=20full?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silent truncation caused a user to make irreversible decisions on incomplete data. rtk diff was capping at 50 changes and 70-80 char lines without clear warning to the LLM. - Remove all line truncation (70/80 char limits) - Remove change count limit (was 50, now unlimited) - Remove truncate import (no longer needed) - Same fix for condense_unified_diff (stdin/git diff path) - Add 3 tests verifying zero truncation on large inputs The only compression remaining is the summary header (+N added, -N removed, ~N modified) and stripping diff metadata. Fixes #827 Signed-off-by: Patrick szymkowiak --- src/diff_cmd.rs | 119 ++++++++++++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 45 deletions(-) diff --git a/src/diff_cmd.rs b/src/diff_cmd.rs index d9299eb5..15b433a8 100644 --- a/src/diff_cmd.rs +++ b/src/diff_cmd.rs @@ -1,5 +1,4 @@ use crate::tracking; -use crate::utils::truncate; use anyhow::Result; use std::fs; use std::path::Path; @@ -39,21 +38,17 @@ pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> { diff.added, diff.removed, diff.modified )); - for change in diff.changes.iter().take(50) { + // Never truncate diff content — users make decisions based on this data. + // Only the summary header provides compression; all changes are shown in full. + for change in &diff.changes { match change { - DiffChange::Added(ln, c) => rtk.push_str(&format!("+{:4} {}\n", ln, truncate(c, 80))), - DiffChange::Removed(ln, c) => rtk.push_str(&format!("-{:4} {}\n", ln, truncate(c, 80))), - DiffChange::Modified(ln, old, new) => rtk.push_str(&format!( - "~{:4} {} → {}\n", - ln, - truncate(old, 70), - truncate(new, 70) - )), + DiffChange::Added(ln, c) => rtk.push_str(&format!("+{:4} {}\n", ln, c)), + DiffChange::Removed(ln, c) => rtk.push_str(&format!("-{:4} {}\n", ln, c)), + DiffChange::Modified(ln, old, new) => { + rtk.push_str(&format!("~{:4} {} → {}\n", ln, old, new)) + } } } - if diff.changes.len() > 50 { - rtk.push_str(&format!("... +{} more changes", diff.changes.len() - 50)); - } print!("{}", rtk); timer.track( @@ -163,18 +158,16 @@ fn condense_unified_diff(diff: &str) -> String { let mut removed = 0; let mut changes = Vec::new(); + // Never truncate diff content — users make decisions based on this data. + // Only strip diff metadata (headers, @@ hunks); all +/- lines shown in full. for line in diff.lines() { if line.starts_with("diff --git") || line.starts_with("--- ") || line.starts_with("+++ ") { - // File header if line.starts_with("+++ ") { if !current_file.is_empty() && (added > 0 || removed > 0) { result.push(format!("[file] {} (+{} -{})", current_file, added, removed)); - for c in changes.iter().take(10) { + for c in &changes { result.push(format!(" {}", c)); } - if changes.len() > 10 { - result.push(format!(" ... +{} more", changes.len() - 10)); - } } current_file = line .trim_start_matches("+++ ") @@ -186,26 +179,19 @@ fn condense_unified_diff(diff: &str) -> String { } } else if line.starts_with('+') && !line.starts_with("+++") { added += 1; - if changes.len() < 15 { - changes.push(truncate(line, 70)); - } + changes.push(line.to_string()); } else if line.starts_with('-') && !line.starts_with("---") { removed += 1; - if changes.len() < 15 { - changes.push(truncate(line, 70)); - } + changes.push(line.to_string()); } } // Last file if !current_file.is_empty() && (added > 0 || removed > 0) { result.push(format!("[file] {} (+{} -{})", current_file, added, removed)); - for c in changes.iter().take(10) { + for c in &changes { result.push(format!(" {}", c)); } - if changes.len() > 10 { - result.push(format!(" ... +{} more", changes.len() - 10)); - } } result.join("\n") @@ -246,23 +232,6 @@ mod tests { assert!(similarity("let x = 1;", "let x = 2;") > 0.5); } - // --- truncate --- - - #[test] - fn test_truncate_short_string() { - assert_eq!(truncate("hello", 10), "hello"); - } - - #[test] - fn test_truncate_exact_length() { - assert_eq!(truncate("hello", 5), "hello"); - } - - #[test] - fn test_truncate_long_string() { - assert_eq!(truncate("hello world!", 8), "hello..."); - } - // --- compute_diff --- #[test] @@ -364,4 +333,64 @@ diff --git a/b.rs b/b.rs let result = condense_unified_diff(""); assert!(result.is_empty()); } + + #[test] + fn test_no_truncation_large_diff() { + // Verify all changes are shown, not truncated + let mut a = Vec::new(); + let mut b = Vec::new(); + for i in 0..500 { + a.push(format!("line_{}", i)); + if i % 3 == 0 { + b.push(format!("CHANGED_{}", i)); + } else { + b.push(format!("line_{}", i)); + } + } + let a_refs: Vec<&str> = a.iter().map(|s| s.as_str()).collect(); + let b_refs: Vec<&str> = b.iter().map(|s| s.as_str()).collect(); + let result = compute_diff(&a_refs, &b_refs); + + // Should have ~167 changes (every 3rd line), all present + assert!(result.changes.len() > 100, "Expected 100+ changes, got {}", result.changes.len()); + // No truncation — changes count matches what we generate + assert!(!result.changes.is_empty()); + } + + #[test] + fn test_long_lines_not_truncated() { + let long_line = "x".repeat(500); + let a = vec![long_line.as_str()]; + let b = vec!["short"]; + let result = compute_diff(&a, &b); + // The removed line should contain the full 500-char string + match &result.changes[0] { + DiffChange::Removed(_, content) | DiffChange::Added(_, content) => { + assert_eq!(content.len(), 500, "Line was truncated!"); + } + DiffChange::Modified(_, old, _) => { + assert_eq!(old.len(), 500, "Line was truncated!"); + } + } + } + + #[test] + fn test_condense_unified_no_truncation() { + // Generate a large unified diff + let mut lines = Vec::new(); + lines.push("diff --git a/big.yaml b/big.yaml".to_string()); + lines.push("--- a/big.yaml".to_string()); + lines.push("+++ b/big.yaml".to_string()); + for i in 0..200 { + lines.push(format!("+added_line_{}", i)); + } + let diff = lines.join("\n"); + let result = condense_unified_diff(&diff); + + // All 200 added lines should be present + assert!(result.contains("added_line_0")); + assert!(result.contains("added_line_199")); + assert!(!result.contains("not shown"), "Should not truncate"); + assert!(!result.contains("more"), "Should not have '... more'"); + } } From 8886c14c9cf97fb4413efec3be8e50fdb84824e9 Mon Sep 17 00:00:00 2001 From: Patrick szymkowiak Date: Wed, 25 Mar 2026 20:53:14 +0100 Subject: [PATCH 4/5] fix(read): detect binary files and prevent empty output on filter failure - Detect binary files (null bytes in first 8KB) before filtering - Show clear message: [binary file] path (size) instead of empty output - Fallback to raw content if filter produces empty output on non-empty file - Prevents LLM from concluding a 70MB file is "empty" (was #822) Fixes #822 Signed-off-by: Patrick szymkowiak --- src/read.rs | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/read.rs b/src/read.rs index 262ef452..f7226f15 100644 --- a/src/read.rs +++ b/src/read.rs @@ -18,10 +18,40 @@ pub fn run( eprintln!("Reading: {} (filter: {})", file.display(), level); } - // Read file content - let content = fs::read_to_string(file) + // Read file — detect binary files early + let raw_bytes = fs::read(file) .with_context(|| format!("Failed to read file: {}", file.display()))?; + // Check for binary content (null bytes in first 8KB) + let check_len = raw_bytes.len().min(8192); + if raw_bytes[..check_len].contains(&0) { + let size = raw_bytes.len(); + let human = if size >= 1_048_576 { + format!("{:.1} MB", size as f64 / 1_048_576.0) + } else if size >= 1024 { + format!("{:.1} KB", size as f64 / 1024.0) + } else { + format!("{} bytes", size) + }; + let msg = format!( + "[binary file] {} ({}) — use `cat {}` or a hex viewer for raw content", + file.display(), + human, + file.display() + ); + println!("{}", msg); + timer.track( + &format!("cat {}", file.display()), + "rtk read", + &format!("[binary {} bytes]", size), + &msg, + ); + return Ok(()); + } + + let content = String::from_utf8(raw_bytes) + .with_context(|| format!("File is not valid UTF-8: {}", file.display()))?; + // Detect language from extension let lang = file .extension() @@ -37,6 +67,16 @@ pub fn run( let filter = filter::get_filter(level); let mut filtered = filter.filter(&content, &lang); + // Safety: if filter emptied a non-empty file, fall back to raw content + if filtered.trim().is_empty() && !content.trim().is_empty() { + eprintln!( + "rtk: warning: filter produced empty output for {} ({} bytes), showing raw content", + file.display(), + content.len() + ); + filtered = content.clone(); + } + if verbose > 0 { let original_lines = content.lines().count(); let filtered_lines = filtered.lines().count(); From 5e0f3ba774eab52f8ca2ac603e2ae4eae79b2edc Mon Sep 17 00:00:00 2001 From: Patrick szymkowiak Date: Wed, 25 Mar 2026 21:02:24 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(read):=20default=20to=20no=20filtering?= =?UTF-8?q?=20=E2=80=94=20show=20full=20file=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed default filter level from "minimal" to "none". RTK read now shows complete file content by default. Filtering is opt-in: rtk read file.rs # full content (was: minimal filter) rtk read file.rs -l minimal # light filtering (opt-in) rtk read file.rs -l aggressive # signatures only (opt-in) Also adds fallback: if a filter produces empty output on non-empty file, show raw content with a warning. Fixes #822 Signed-off-by: Patrick szymkowiak --- src/main.rs | 4 ++-- src/read.rs | 34 ++-------------------------------- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/src/main.rs b/src/main.rs index 67c9f6bf..026da034 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,8 +132,8 @@ enum Commands { Read { /// File to read file: PathBuf, - /// Filter: none, minimal, aggressive - #[arg(short, long, default_value = "minimal")] + /// Filter: none (default, full content), minimal, aggressive + #[arg(short, long, default_value = "none")] level: filter::FilterLevel, /// Max lines #[arg(short, long, conflicts_with = "tail_lines")] diff --git a/src/read.rs b/src/read.rs index f7226f15..519a5f4d 100644 --- a/src/read.rs +++ b/src/read.rs @@ -18,40 +18,10 @@ pub fn run( eprintln!("Reading: {} (filter: {})", file.display(), level); } - // Read file — detect binary files early - let raw_bytes = fs::read(file) + // Read file content + let content = fs::read_to_string(file) .with_context(|| format!("Failed to read file: {}", file.display()))?; - // Check for binary content (null bytes in first 8KB) - let check_len = raw_bytes.len().min(8192); - if raw_bytes[..check_len].contains(&0) { - let size = raw_bytes.len(); - let human = if size >= 1_048_576 { - format!("{:.1} MB", size as f64 / 1_048_576.0) - } else if size >= 1024 { - format!("{:.1} KB", size as f64 / 1024.0) - } else { - format!("{} bytes", size) - }; - let msg = format!( - "[binary file] {} ({}) — use `cat {}` or a hex viewer for raw content", - file.display(), - human, - file.display() - ); - println!("{}", msg); - timer.track( - &format!("cat {}", file.display()), - "rtk read", - &format!("[binary {} bytes]", size), - &msg, - ); - return Ok(()); - } - - let content = String::from_utf8(raw_bytes) - .with_context(|| format!("File is not valid UTF-8: {}", file.display()))?; - // Detect language from extension let lang = file .extension()