Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,19 +296,21 @@ 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 |
| **Windsurf** | `rtk init --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) |
| **Mistral Vibe** | Planned (#800) | Blocked on upstream BeforeToolCallback |

### Claude Code (default)

Expand All @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
119 changes: 74 additions & 45 deletions src/diff_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use crate::tracking;
use crate::utils::truncate;
use anyhow::Result;
use std::fs;
use std::path::Path;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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("+++ ")
Expand All @@ -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")
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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'");
}
}
75 changes: 75 additions & 0 deletions src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2307,6 +2307,81 @@ fn uninstall_gemini(verbose: u8) -> Result<Vec<String>> {
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 <cmd> # 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::*;
Expand Down
11 changes: 9 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1689,6 +1693,7 @@ fn main() -> Result<()> {
no_patch,
uninstall,
codex,
copilot,
} => {
if show {
init::show_config(codex)?;
Expand All @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading