diff --git a/.github/hooks/rtk-rewrite.json b/.github/hooks/rtk-rewrite.json index c488d434..eb2a5a7f 100644 --- a/.github/hooks/rtk-rewrite.json +++ b/.github/hooks/rtk-rewrite.json @@ -3,7 +3,7 @@ "PreToolUse": [ { "type": "command", - "command": "rtk hook", + "command": "rtk hook copilot", "cwd": ".", "timeout": 5 } diff --git a/CHANGELOG.md b/CHANGELOG.md index e98489a2..c14b43d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Features + +* `rtk init --agent copilot` — one-command setup for GitHub Copilot (VS Code + CLI): installs `.github/copilot-instructions.md` and `.github/hooks/rtk-rewrite.json` PreToolUse hook; idempotent; `--uninstall --agent copilot` removes both + ### 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)) +* **hook:** `rtk hook copilot` now correctly registered in `RTK_META_COMMANDS` — bare `rtk hook` no longer falls through to passthrough mode (exit 127); `.github/hooks/rtk-rewrite.json` corrected to invoke `rtk hook copilot` ## [0.33.1](https://github.com/rtk-ai/rtk/compare/v0.33.0...v0.33.1) (2026-03-25) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 64115a1d..4bd64810 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; use super::integrity; +use crate::core::utils; // Embedded hook script (guards before set -euo pipefail) const REWRITE_HOOK: &str = include_str!("../../hooks/claude/rtk-rewrite.sh"); @@ -214,6 +215,7 @@ pub fn run( install_cursor: bool, install_windsurf: bool, install_cline: bool, + install_copilot: bool, claude_md: bool, hook_only: bool, codex: bool, @@ -263,6 +265,15 @@ pub fn run( return run_cline_mode(verbose); } + // Copilot-only mode (always project-scoped — ignore -g if passed) + if install_copilot { + if global { + println!("Note: GitHub Copilot integration is always project-scoped (.github/)."); + println!(" Ignoring -g flag.\n"); + } + return run_copilot_mode(verbose); + } + // Mode selection (Claude Code / OpenCode) match (install_claude, install_opencode, claude_md, hook_only) { (false, true, _, _) => run_opencode_only_mode(verbose)?, @@ -270,7 +281,7 @@ pub fn run( (true, opencode, false, true) => run_hook_only_mode(global, patch_mode, verbose, opencode)?, (true, opencode, false, false) => run_default_mode(global, patch_mode, verbose, opencode)?, (false, false, _, _) => { - if !install_cursor { + if !install_cursor && !install_copilot { anyhow::bail!("at least one of install_claude or install_opencode must be true") } } @@ -523,11 +534,69 @@ 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, + copilot: bool, + verbose: u8, +) -> Result<()> { if codex { return uninstall_codex(global, verbose); } + if copilot { + let instructions_path = PathBuf::from(".github/copilot-instructions.md"); + let hook_path = PathBuf::from(".github/hooks/rtk-rewrite.json"); + let mut removed = Vec::new(); + + if instructions_path.exists() { + let content = fs::read_to_string(&instructions_path).unwrap_or_default(); + // Strip only the RTK section; preserve any pre-existing user content + let stripped = content + .replace(&format!("\n\n{}", COPILOT_INSTRUCTIONS), "") + .replace(COPILOT_INSTRUCTIONS, ""); + let stripped = stripped.trim(); + if stripped.is_empty() { + fs::remove_file(&instructions_path) + .with_context(|| format!("Failed to remove {}", instructions_path.display()))?; + if verbose > 0 { + eprintln!("Removed {}", instructions_path.display()); + } + } else { + fs::write(&instructions_path, format!("{}\n", stripped)) + .with_context(|| format!("Failed to update {}", instructions_path.display()))?; + if verbose > 0 { + eprintln!( + "Updated {} (RTK section removed)", + instructions_path.display() + ); + } + } + removed.push(instructions_path.display().to_string()); + } + + if hook_path.exists() { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove {}", hook_path.display()))?; + if verbose > 0 { + eprintln!("Removed {}", hook_path.display()); + } + removed.push(hook_path.display().to_string()); + } + + if removed.is_empty() { + println!("RTK Copilot support was not installed in this project (nothing to remove)"); + } else { + println!("\nRTK uninstalled (GitHub Copilot):"); + for item in &removed { + println!(" - {}", item); + } + } + return Ok(()); + } + if cursor { if !global { anyhow::bail!("Cursor uninstall only works with --global flag"); @@ -1239,6 +1308,82 @@ fn run_windsurf_mode(verbose: u8) -> Result<()> { Ok(()) } +/// Embedded GitHub Copilot RTK instructions +const COPILOT_INSTRUCTIONS: &str = include_str!("../../hooks/copilot/rtk-awareness.md"); + +/// Embedded VS Code / Copilot CLI hook JSON (single source of truth) +const COPILOT_HOOK_JSON: &str = include_str!("../../.github/hooks/rtk-rewrite.json"); + +// ─── GitHub Copilot support ────────────────────────────────────── + +fn run_copilot_mode(verbose: u8) -> Result<()> { + // GitHub Copilot reads .github/copilot-instructions.md (project-scoped) + // and .github/hooks/rtk-rewrite.json (PreToolUse hook for VS Code + Copilot CLI) + let github_dir = PathBuf::from(".github"); + let hooks_dir = github_dir.join("hooks"); + let instructions_path = github_dir.join("copilot-instructions.md"); + let hook_path = hooks_dir.join("rtk-rewrite.json"); + + // Verify rtk is in PATH (required for the hook to work) + if !utils::tool_exists("rtk") { + eprintln!("Warning: rtk binary not found in PATH."); + eprintln!(" The hook will not work until rtk is installed globally."); + eprintln!(" Install: cargo install rtk-ai (or see README for binary install)"); + } + + // Check if already fully configured + let hook_exists = hook_path.exists(); + let instructions_exist = instructions_path.exists() + && fs::read_to_string(&instructions_path) + .unwrap_or_default() + .contains("rtk"); + if hook_exists && instructions_exist { + println!("RTK already configured for GitHub Copilot in this project."); + println!(" Hook: {}", hook_path.display()); + println!(" Instructions: {}", instructions_path.display()); + return Ok(()); + } + + fs::create_dir_all(&hooks_dir).context("Failed to create .github/hooks directory")?; + + // 1. Install hook JSON (this is what actually intercepts commands) + if hook_exists { + if verbose > 0 { + println!(" Hook: {} (already present)", hook_path.display()); + } + } else { + fs::write(&hook_path, COPILOT_HOOK_JSON) + .context("Failed to write .github/hooks/rtk-rewrite.json")?; + if verbose > 0 { + eprintln!("Wrote {}", hook_path.display()); + } + } + + // 2. Install instructions (soft layer: hints for the LLM) + let existing = fs::read_to_string(&instructions_path).unwrap_or_default(); + if !instructions_exist { + let new_content = if existing.trim().is_empty() { + COPILOT_INSTRUCTIONS.to_string() + } else { + format!("{}\n\n{}", existing.trim(), COPILOT_INSTRUCTIONS) + }; + fs::write(&instructions_path, &new_content) + .context("Failed to write .github/copilot-instructions.md")?; + if verbose > 0 { + eprintln!("Wrote {}", instructions_path.display()); + } + } + + println!("\nRTK configured for GitHub Copilot (project-scoped).\n"); + println!(" Hook config: {}", hook_path.display()); + println!(" Instructions: {}", instructions_path.display()); + println!("\n VS Code Copilot Chat: transparent rewrite via updatedInput"); + println!(" Copilot CLI: deny-with-suggestion (re-runs with rtk prefix)"); + println!("\n Restart your IDE or Copilot CLI session to activate.\n"); + + Ok(()) +} + fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { let (agents_md_path, rtk_md_path) = if global { let codex_dir = resolve_codex_dir()?; @@ -2017,6 +2162,42 @@ fn show_claude_config() -> Result<()> { println!("[--] Cursor: home dir not found"); } + // Check Copilot artifacts (project-scoped in .github/) + let copilot_hook = PathBuf::from(".github/hooks/rtk-rewrite.json"); + let copilot_instructions = PathBuf::from(".github/copilot-instructions.md"); + if copilot_hook.exists() { + let content = fs::read_to_string(&copilot_hook).unwrap_or_default(); + if content.contains("rtk hook copilot") { + println!( + "[ok] Copilot hook: {} (rtk hook copilot configured)", + copilot_hook.display() + ); + } else { + println!( + "[warn] Copilot hook: {} (exists but rtk not configured)", + copilot_hook.display() + ); + } + } else { + println!("[--] Copilot hook: not found (run: rtk init --agent copilot)"); + } + if copilot_instructions.exists() { + let content = fs::read_to_string(&copilot_instructions).unwrap_or_default(); + if content.contains("rtk") { + println!( + "[ok] Copilot instructions: {} (rtk configured)", + copilot_instructions.display() + ); + } else { + println!( + "[--] Copilot instructions: {} (exists but rtk not mentioned)", + copilot_instructions.display() + ); + } + } else { + println!("[--] Copilot instructions: not found (run: rtk init --agent copilot)"); + } + println!("\nUsage:"); println!(" rtk init # Full injection into local CLAUDE.md"); println!(" rtk init -g # Hook + RTK.md + @RTK.md + settings.json (recommended)"); @@ -2029,6 +2210,7 @@ fn show_claude_config() -> Result<()> { println!(" rtk init -g --codex # Configure ~/.codex/AGENTS.md + ~/.codex/RTK.md"); println!(" rtk init -g --opencode # OpenCode plugin only"); println!(" rtk init -g --agent cursor # Install Cursor Agent hooks"); + println!(" rtk init --agent copilot # Install GitHub Copilot hook + instructions"); Ok(()) } @@ -2309,49 +2491,6 @@ 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 @@ -2391,8 +2530,12 @@ pub fn run_copilot(verbose: u8) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; use tempfile::TempDir; + /// Serialize tests that change the process CWD (global state). + static CWD_LOCK: Mutex<()> = Mutex::new(()); + #[test] fn test_init_mentions_all_top_level_commands() { for cmd in [ @@ -2615,6 +2758,7 @@ More notes false, false, false, + false, // install_copilot false, false, true, @@ -2637,6 +2781,7 @@ More notes false, false, false, + false, // install_copilot false, false, true, @@ -3081,4 +3226,164 @@ More notes assert!(CURSOR_REWRITE_HOOK.contains("\"updated_input\"")); assert!(!CURSOR_REWRITE_HOOK.contains("hookSpecificOutput")); } + + // ─── GitHub Copilot init tests ────────────────────────────────── + + #[test] + fn test_run_copilot_mode_installs_both_files() { + let _guard = CWD_LOCK.lock().unwrap(); + let temp = TempDir::new().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp.path()).unwrap(); + + run_copilot_mode(0).unwrap(); + + let instructions = temp.path().join(".github/copilot-instructions.md"); + let hook = temp.path().join(".github/hooks/rtk-rewrite.json"); + + assert!( + instructions.exists(), + "copilot-instructions.md should be created" + ); + assert!( + hook.exists(), + ".github/hooks/rtk-rewrite.json should be created" + ); + + let hook_content = fs::read_to_string(&hook).unwrap(); + assert!( + hook_content.contains("rtk hook copilot"), + "hook JSON must invoke 'rtk hook copilot', got: {}", + hook_content + ); + + let instructions_content = fs::read_to_string(&instructions).unwrap(); + assert!( + instructions_content.contains("RTK") || instructions_content.contains("rtk"), + "instructions should mention RTK" + ); + + std::env::set_current_dir(original_dir).unwrap(); + } + + #[test] + fn test_run_copilot_mode_idempotent() { + let _guard = CWD_LOCK.lock().unwrap(); + let temp = TempDir::new().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp.path()).unwrap(); + + run_copilot_mode(0).unwrap(); + run_copilot_mode(0).unwrap(); // second run should not panic or duplicate + + let instructions = temp.path().join(".github/copilot-instructions.md"); + let content = fs::read_to_string(&instructions).unwrap(); + // RTK block should appear exactly once (not duplicated) + let count = content.matches("# RTK").count(); + assert!( + count <= 1, + "RTK block should not be duplicated, found {} times", + count + ); + + std::env::set_current_dir(original_dir).unwrap(); + } + + #[test] + fn test_uninstall_copilot_removes_both_files() { + let _guard = CWD_LOCK.lock().unwrap(); + let temp = TempDir::new().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp.path()).unwrap(); + + run_copilot_mode(0).unwrap(); + + let instructions = temp.path().join(".github/copilot-instructions.md"); + let hook = temp.path().join(".github/hooks/rtk-rewrite.json"); + assert!(instructions.exists()); + assert!(hook.exists()); + + uninstall(false, false, false, false, true, 0).unwrap(); + + assert!( + !instructions.exists(), + "instructions should be removed on uninstall" + ); + assert!(!hook.exists(), "hook JSON should be removed on uninstall"); + + std::env::set_current_dir(original_dir).unwrap(); + } + + #[test] + fn test_uninstall_copilot_preserves_user_content() { + let _guard = CWD_LOCK.lock().unwrap(); + let temp = TempDir::new().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp.path()).unwrap(); + + // Create a file with pre-existing user content + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + let instructions = github_dir.join("copilot-instructions.md"); + fs::write( + &instructions, + "# My custom instructions\n\nDo not break me.\n", + ) + .unwrap(); + + // Install appends RTK section to existing content + run_copilot_mode(0).unwrap(); + let after_install = fs::read_to_string(&instructions).unwrap(); + assert!( + after_install.contains("My custom instructions"), + "install must preserve user content" + ); + assert!( + after_install.contains("RTK"), + "install must append RTK section" + ); + + // Uninstall should strip RTK section but keep user content + uninstall(false, false, false, false, true, 0).unwrap(); + assert!( + instructions.exists(), + "instructions file should remain when user content exists" + ); + let after_uninstall = fs::read_to_string(&instructions).unwrap(); + assert!( + after_uninstall.contains("My custom instructions"), + "user content must be preserved after uninstall" + ); + assert!( + !after_uninstall.contains("rtk hook copilot"), + "RTK section must be removed on uninstall" + ); + + std::env::set_current_dir(original_dir).unwrap(); + } + + #[test] + fn test_uninstall_copilot_noop_when_not_installed() { + let _guard = CWD_LOCK.lock().unwrap(); + let temp = TempDir::new().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp.path()).unwrap(); + + // Should not error even if nothing is installed + uninstall(false, false, false, false, true, 0).unwrap(); + + std::env::set_current_dir(original_dir).unwrap(); + } + + #[test] + fn test_copilot_hook_json_contains_correct_command() { + assert!( + COPILOT_HOOK_JSON.contains("rtk hook copilot"), + "embedded hook JSON must invoke 'rtk hook copilot'" + ); + assert!( + COPILOT_HOOK_JSON.contains("PreToolUse"), + "hook JSON must use PreToolUse event" + ); + } } diff --git a/src/main.rs b/src/main.rs index 50a39ce5..b9fe3010 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,8 @@ pub enum AgentTarget { Windsurf, /// Cline / Roo Code (VS Code) Cline, + /// GitHub Copilot (VS Code Copilot Chat + Copilot CLI) + Copilot, } #[derive(Parser)] @@ -1040,6 +1042,7 @@ const RTK_META_COMMANDS: &[&str] = &[ "init", "config", "proxy", + "hook", "hook-audit", "cc-economics", "verify", @@ -1657,7 +1660,8 @@ fn main() -> Result<()> { hooks::init::show_config(codex)?; } else if uninstall { let cursor = agent == Some(AgentTarget::Cursor); - hooks::init::uninstall(global, gemini, codex, cursor, cli.verbose)?; + let copilot = agent == Some(AgentTarget::Copilot); + hooks::init::uninstall(global, gemini, codex, cursor, copilot, cli.verbose)?; } else if gemini { let patch_mode = if auto_patch { hooks::init::PatchMode::Auto @@ -1675,6 +1679,7 @@ fn main() -> Result<()> { let install_cursor = agent == Some(AgentTarget::Cursor); let install_windsurf = agent == Some(AgentTarget::Windsurf); let install_cline = agent == Some(AgentTarget::Cline); + let install_copilot = agent == Some(AgentTarget::Copilot); let patch_mode = if auto_patch { hooks::init::PatchMode::Auto @@ -1690,6 +1695,7 @@ fn main() -> Result<()> { install_cursor, install_windsurf, install_cline, + install_copilot, claude_md, hook_only, codex,