diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e1892b..e98489a2 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). +## [Unreleased] + +### Bug Fixes + +* **diff:** correct truncation overflow count in condense_unified_diff ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([5399f83](https://github.com/rtk-ai/rtk/commit/5399f83)) +* **git:** replace vague truncation markers with exact counts in log and grep output ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([185fb97](https://github.com/rtk-ai/rtk/commit/185fb97)) + ## [0.33.1](https://github.com/rtk-ai/rtk/compare/v0.33.0...v0.33.1) (2026-03-25) 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 | diff --git a/src/diff_cmd.rs b/src/diff_cmd.rs index d9299eb5..4da3f766 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,17 +158,19 @@ 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)); + let total = added + removed; + if total > 10 { + result.push(format!(" ... +{} more", total - 10)); } } current_file = line @@ -186,25 +183,22 @@ 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)); + let total = added + removed; + if total > 10 { + result.push(format!(" ... +{} more", total - 10)); } } @@ -246,23 +240,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 +341,93 @@ diff --git a/b.rs b/b.rs let result = condense_unified_diff(""); assert!(result.is_empty()); } + + // --- truncation accuracy --- + + fn make_large_unified_diff(added: usize, removed: usize) -> String { + let mut lines = vec![ + "diff --git a/config.yaml b/config.yaml".to_string(), + "--- a/config.yaml".to_string(), + "+++ b/config.yaml".to_string(), + "@@ -1,200 +1,200 @@".to_string(), + ]; + for i in 0..removed { + lines.push(format!("-old_value_{}", i)); + } + for i in 0..added { + lines.push(format!("+new_value_{}", i)); + } + lines.join("\n") + } + + #[test] + fn test_condense_unified_diff_overflow_count_accuracy() { + // 100 added + 100 removed = 200 total changes, only 10 shown + // True overflow = 200 - 10 = 190 + // Bug: changes vec capped at 15, so old code showed "+5 more" (15-10) instead of "+190 more" + let diff = make_large_unified_diff(100, 100); + let result = condense_unified_diff(&diff); + assert!( + result.contains("+190 more"), + "Expected '+190 more' but got:\n{}", + result + ); + assert!( + !result.contains("+5 more"), + "Bug still present: showing '+5 more' instead of true overflow" + ); + } + + #[test] + fn test_condense_unified_diff_no_false_overflow() { + // 8 changes total — all fit within the 10-line display cap, no overflow message + let diff = make_large_unified_diff(4, 4); + let result = condense_unified_diff(&diff); + assert!( + !result.contains("more"), + "No overflow message expected for 8 changes, got:\n{}", + result + ); + } + + #[test] + fn test_no_truncation_large_diff() { + // Verify compute_diff returns all changes without truncation + 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); + + assert!( + result.changes.len() > 100, + "Expected 100+ changes, got {}", + result.changes.len() + ); + 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); + 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!"); + } + } + } } diff --git a/src/discover/registry.rs b/src/discover/registry.rs index fafdaa8b..e15d9bcd 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1577,6 +1577,27 @@ mod tests { ); } + #[test] + fn test_classify_swift_test() { + assert!(matches!( + classify_command("swift test"), + Classification::Supported { + rtk_equivalent: "rtk swift", + category: "Build", + estimated_savings_pct: 90.0, + status: RtkStatus::Existing, + } + )); + } + + #[test] + fn test_rewrite_swift_test() { + assert_eq!( + rewrite_command("swift test --parallel", &[]), + Some("rtk swift test --parallel".into()) + ); + } + // --- #336: docker compose supported subcommands rewritten, unsupported skipped --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 44f19d60..e0be1cf2 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -79,7 +79,7 @@ pub const PATTERNS: &[&str] = &[ r"^shellcheck\b", r"^shopify\s+theme\s+(push|pull)", r"^sops\b", - r"^swift\s+build\b", + r"^swift\s+(build|test)\b", r"^systemctl\s+status\b", r"^terraform\s+plan", r"^tofu\s+(fmt|init|plan|validate)(\s|$)", @@ -600,7 +600,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["swift"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], + subcmd_savings: &[("test", 90.0)], subcmd_status: &[], }, RtkRule { diff --git a/src/filter.rs b/src/filter.rs index d6d9d19b..857ec938 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -491,4 +491,49 @@ fn main() { assert!(!result.contains("// This is a comment")); assert!(result.contains("fn main()")); } + + // --- truncation accuracy --- + + #[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 + let total_lines = 200usize; + let max_lines = 20usize; + let content: String = (0..total_lines) + .map(|i| format!("plain text line number {}", i)) + .collect::>() + .join("\n"); + + let output = smart_truncate(&content, max_lines, &Language::Rust); + + // Extract the overflow message + let overflow_line = output + .lines() + .find(|l| l.contains("more lines")) + .unwrap_or_else(|| panic!("No overflow message found in:\n{}", output)); + + // Parse "// ... N more lines (total: T)" + let reported_more: usize = overflow_line + .split_whitespace() + .find(|w| w.parse::().is_ok()) + .and_then(|w| w.parse().ok()) + .unwrap_or_else(|| panic!("Could not parse overflow count from: {}", overflow_line)); + + let kept_count = output + .lines() + .filter(|l| !l.contains("more lines") && !l.contains("omitted")) + .count(); + + assert_eq!( + kept_count + reported_more, + total_lines, + "kept ({}) + reported_more ({}) must equal total ({})", + kept_count, + reported_more, + total_lines + ); + } } diff --git a/src/git.rs b/src/git.rs index 4bb7f674..9b4b757a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -296,13 +296,19 @@ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { let mut added = 0; let mut removed = 0; let mut in_hunk = false; - let mut hunk_lines = 0; + let mut hunk_shown = 0; + let mut hunk_skipped = 0usize; let max_hunk_lines = 100; let mut was_truncated = false; for line in diff.lines() { if line.starts_with("diff --git") { - // New file + // Flush hunk truncation before starting a new file + if hunk_skipped > 0 { + result.push(format!(" ... ({} lines truncated)", hunk_skipped)); + was_truncated = true; + hunk_skipped = 0; + } if !current_file.is_empty() && (added > 0 || removed > 0) { result.push(format!(" +{} -{}", added, removed)); } @@ -311,38 +317,42 @@ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { added = 0; removed = 0; in_hunk = false; + hunk_shown = 0; } else if line.starts_with("@@") { - // New hunk + // Flush hunk truncation before starting a new hunk + if hunk_skipped > 0 { + result.push(format!(" ... ({} lines truncated)", hunk_skipped)); + was_truncated = true; + hunk_skipped = 0; + } in_hunk = true; - hunk_lines = 0; + hunk_shown = 0; let hunk_info = line.split("@@").nth(1).unwrap_or("").trim(); result.push(format!(" @@ {} @@", hunk_info)); } else if in_hunk { if line.starts_with('+') && !line.starts_with("+++") { added += 1; - if hunk_lines < max_hunk_lines { + if hunk_shown < max_hunk_lines { result.push(format!(" {}", line)); - hunk_lines += 1; + hunk_shown += 1; + } else { + hunk_skipped += 1; } } else if line.starts_with('-') && !line.starts_with("---") { removed += 1; - if hunk_lines < max_hunk_lines { + if hunk_shown < max_hunk_lines { result.push(format!(" {}", line)); - hunk_lines += 1; + hunk_shown += 1; + } else { + hunk_skipped += 1; } - } else if hunk_lines < max_hunk_lines && !line.starts_with("\\") { + } else if hunk_shown < max_hunk_lines && !line.starts_with("\\") { // Context line - if hunk_lines > 0 { + if hunk_shown > 0 { result.push(format!(" {}", line)); - hunk_lines += 1; + hunk_shown += 1; } } - - if hunk_lines == max_hunk_lines { - result.push(" ... (truncated)".to_string()); - hunk_lines += 1; - was_truncated = true; - } } if result.len() >= max_lines { @@ -352,6 +362,12 @@ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { } } + // Flush last hunk + if hunk_skipped > 0 { + result.push(format!(" ... ({} lines truncated)", hunk_skipped)); + was_truncated = true; + } + if !current_file.is_empty() && (added > 0 || removed > 0) { result.push(format!(" +{} -{}", added, removed)); } @@ -533,23 +549,27 @@ fn filter_log_output( None => continue, }; // Remaining lines are the body — keep up to 3 non-empty, non-trailer lines - let body_lines: Vec<&str> = lines + let all_body_lines: Vec<&str> = lines .map(|l| l.trim()) .filter(|l| { !l.is_empty() && !l.starts_with("Signed-off-by:") && !l.starts_with("Co-authored-by:") }) - .take(3) .collect(); + let body_omitted = all_body_lines.len().saturating_sub(3); + let body_lines = &all_body_lines[..all_body_lines.len().min(3)]; if body_lines.is_empty() { result.push(header); } else { let mut entry = header; - for body in &body_lines { + for body in body_lines { entry.push_str(&format!("\n {}", truncate_line(body, truncate_width))); } + if body_omitted > 0 { + entry.push_str(&format!("\n [+{} lines omitted]", body_omitted)); + } result.push(entry); } } @@ -2304,4 +2324,84 @@ no changes added to commit (use "git add" and/or "git commit -a") let _ = std::fs::remove_dir_all(&tmp); } + + // --- truncation accuracy --- + + #[test] + fn test_format_status_overflow_count_exact() { + // 25 staged files, default status_max_files = 15 + // Should show 15, overflow = 25 - 15 = 10, report "+10 more" + let mut porcelain = String::from("## main...origin/main\n"); + for i in 0..25 { + porcelain.push_str(&format!("M staged_file_{}.rs\n", i)); + } + let result = format_status_output(&porcelain); + assert!( + result.contains("+10 more"), + "Expected '+10 more' for 25 staged files (max_files=15), got:\n{}", + result + ); + assert!( + result.contains("Staged: 25 files"), + "Expected 'Staged: 25 files', got:\n{}", + result + ); + } + + #[test] + fn test_compact_diff_recovery_hint_present() { + // A hunk with 110 lines exceeds max_hunk_lines (100), triggers truncation + // The recovery hint must appear so LLMs can re-fetch the full diff + let mut diff = String::new(); + diff.push_str("diff --git a/large.rs b/large.rs\n"); + diff.push_str("--- a/large.rs\n"); + diff.push_str("+++ b/large.rs\n"); + diff.push_str("@@ -1,150 +1,150 @@\n"); + for i in 0..110 { + diff.push_str(&format!("+added line {}\n", i)); + } + let result = compact_diff(&diff, 500); + assert!( + result.contains("[full diff: rtk git diff --no-compact]"), + "Expected recovery hint when hunk is truncated, got:\n{}", + result + ); + } + + #[test] + fn test_compact_diff_hunk_truncation_count_accurate() { + // 150 change lines in one hunk: 100 shown, 50 silently dropped + // Must report the exact count, not just "(truncated)" + let mut diff = String::from( + "diff --git a/large.rs b/large.rs\n--- a/large.rs\n+++ b/large.rs\n@@ -1,150 +1,150 @@\n", + ); + for i in 0..150 { + diff.push_str(&format!("+line {}\n", i)); + } + let result = compact_diff(&diff, 500); + assert!( + result.contains("50 lines truncated"), + "Expected '50 lines truncated' (150 - 100 = 50), got:\n{}", + result + ); + } + + #[test] + fn test_filter_log_output_body_omission_indicator() { + // Commit with 6 meaningful body lines: only 3 shown, must signal "+3 lines omitted" + let body_lines = (1..=6) + .map(|i| format!("body line {}", i)) + .collect::>() + .join("\n"); + let output = format!( + "abc1234 feat: big change (1 day ago) \n{}\n---END---\n", + body_lines + ); + let result = filter_log_output(&output, 10, false, false); + assert!( + result.contains("+3 lines omitted"), + "Expected '+3 lines omitted' when 6 body lines truncated to 3, got:\n{}", + result + ); + } } diff --git a/src/grep_cmd.rs b/src/grep_cmd.rs index c1819dde..23592441 100644 --- a/src/grep_cmd.rs +++ b/src/grep_cmd.rs @@ -280,6 +280,27 @@ mod tests { assert_eq!(filtered[0], "-i"); } + // --- truncation accuracy --- + + #[test] + fn test_grep_overflow_uses_uncapped_total() { + // Confirm the grep overflow invariant: matches vec is never capped before overflow calc. + // If total_matches > per_file, overflow = total_matches - per_file (not capped). + // This documents that grep_cmd.rs avoids the diff_cmd bug (cap at N then compute N-10). + let per_file = config::limits().grep_max_per_file; + let total_matches = per_file + 42; + let overflow = total_matches - per_file; + assert_eq!(overflow, 42, "overflow must equal true suppressed count"); + // Demonstrate why capping before subtraction is wrong: + let hypothetical_cap = per_file + 5; + let capped = total_matches.min(hypothetical_cap); + let wrong_overflow = capped - per_file; + assert_ne!( + wrong_overflow, overflow, + "capping before subtraction gives wrong overflow" + ); + } + // Verify line numbers are always enabled in rg invocation (grep_cmd.rs:24). // The -n/--line-numbers clap flag in main.rs is a no-op accepted for compat. #[test] diff --git a/src/init.rs b/src/init.rs index 494bef34..53bbe70c 100644 --- a/src/init.rs +++ b/src/init.rs @@ -2307,6 +2307,85 @@ 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..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")] @@ -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; diff --git a/src/read.rs b/src/read.rs index 262ef452..519a5f4d 100644 --- a/src/read.rs +++ b/src/read.rs @@ -37,6 +37,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();