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/INSTALL.md b/INSTALL.md index 98457d09..55f680cb 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -78,6 +78,10 @@ rtk gain # MUST show token savings, not "command not found" │ Hook + RTK.md (~10 tokens in context) │ Commands auto-rewritten transparently │ + ├─ GitHub Copilot everywhere → rtk init -g --agent copilot + │ Global Copilot instructions in ~/.copilot/copilot-instructions.md + │ Helps both Copilot CLI and VS Code Copilot Chat use rtk + │ ├─ YES, minimal → rtk init -g --hook-only │ Hook only, nothing added to CLAUDE.md │ Zero tokens in context @@ -109,6 +113,26 @@ rtk init --show # Check hook is installed and executable **Token savings**: ~99.5% reduction (2000 tokens → 10 tokens in context) +### GitHub Copilot Setup + +**Best for: GitHub Copilot CLI and VS Code Copilot Chat** + +```bash +# Global instructions for all Copilot sessions +rtk init -g --agent copilot + +# Repository-local instructions + PreToolUse hook +rtk init --agent copilot +``` + +Global mode writes `~/.copilot/copilot-instructions.md`, which Copilot CLI and VS Code Copilot Chat both load automatically. + +Repository mode writes: +- `.github/copilot-instructions.md` +- `.github/hooks/rtk-rewrite.json` + +The repository hook runs `rtk hook copilot`, which is cross-platform and works on Windows without `bash` or `jq`. + **What is settings.json?** Claude Code's hook registry. RTK adds a PreToolUse hook that rewrites commands transparently. Without this, Claude won't invoke the hook automatically. diff --git a/README.md b/README.md index d818e2af..df991a80 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,45 @@ rtk init --show # Verify installation After install, **restart Claude Code**. +## GitHub Copilot Support + +RTK supports both GitHub Copilot CLI and VS Code Copilot Chat. + +**Global instructions for Copilot (recommended for VS Code):** +```bash +rtk init -g --agent copilot +``` + +This writes `~/.copilot/copilot-instructions.md`, which Copilot CLI and VS Code Copilot Chat both load automatically. + +**Repository hook + instructions (for a specific repo):** +```bash +rtk init --agent copilot +``` + +This creates: +- `.github/copilot-instructions.md` +- `.github/hooks/rtk-rewrite.json` + +The hook uses the native Rust processor directly: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "type": "command", + "command": "rtk hook copilot", + "cwd": ".", + "timeout": 5 + } + ] + } +} +``` + +This is cross-platform and works on Windows without shell wrappers or `jq`. + ## Gemini CLI Support (Global) RTK supports Gemini CLI via a native Rust hook processor. The hook intercepts `run_shell_command` tool calls and rewrites them to `rtk` equivalents using the same rewrite engine as Claude Code. diff --git a/build.rs b/build.rs index 0a268eea..1d4ae93d 100644 --- a/build.rs +++ b/build.rs @@ -3,6 +3,15 @@ use std::fs; use std::path::Path; fn main() { + #[cfg(windows)] + { + // Clap + the full command graph can exceed the default 1 MiB Windows + // main-thread stack during process startup. Reserve a larger stack for + // the CLI binary so `rtk.exe --version`, `--help`, and hook entry + // points start reliably without requiring ad-hoc RUSTFLAGS. + println!("cargo:rustc-link-arg=/STACK:8388608"); + } + let filters_dir = Path::new("src/filters"); let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR must be set by Cargo"); let dest = Path::new(&out_dir).join("builtin_filters.toml"); diff --git a/hooks/copilot-global-rtk-awareness.md b/hooks/copilot-global-rtk-awareness.md new file mode 100644 index 00000000..b750169d --- /dev/null +++ b/hooks/copilot-global-rtk-awareness.md @@ -0,0 +1,40 @@ +# RTK - Copilot Global Instructions + +**Usage**: Token-optimized CLI proxy (60-90% savings on dev operations) + +This file is intended for `~/.copilot/copilot-instructions.md` so GitHub Copilot CLI and VS Code Copilot Chat can load RTK guidance globally. + +## Golden Rule + +Always prefer `rtk` for shell commands that produce verbose output. + +Examples: + +```bash +rtk git status +rtk git diff +rtk cargo test +rtk npm run build +rtk pytest -q +rtk docker ps +``` + +## Meta Commands + +```bash +rtk gain # Show token savings analytics +rtk gain --history # Show command usage history with savings +rtk discover # Analyze sessions for missed RTK usage +rtk proxy # Run raw command without filtering +``` + +## Verification + +```bash +rtk --version +rtk gain +where rtk # Windows +which rtk # macOS/Linux +``` + +⚠️ **Name collision**: If `rtk gain` fails, you may have the wrong `rtk` installed. diff --git a/hooks/copilot-rtk-awareness.md b/hooks/copilot-rtk-awareness.md index 185f460c..8a469a0b 100644 --- a/hooks/copilot-rtk-awareness.md +++ b/hooks/copilot-rtk-awareness.md @@ -7,7 +7,7 @@ The `.github/copilot-instructions.md` file is loaded at session start by both Copilot CLI and VS Code Copilot Chat. It instructs Copilot to prefix commands with `rtk` automatically. -The `.github/hooks/rtk-rewrite.json` hook adds a `PreToolUse` safety net via `rtk hook` — +The `.github/hooks/rtk-rewrite.json` hook adds a `PreToolUse` safety net via `rtk hook copilot` — a cross-platform Rust binary that intercepts raw bash tool calls and rewrites them. No shell scripts, no `jq` dependency, works on Windows natively. @@ -33,21 +33,21 @@ which rtk # Verify correct binary path ## How the hook works -`rtk hook` reads `PreToolUse` JSON from stdin, detects the agent format, and responds appropriately: +`rtk hook copilot` reads `PreToolUse` JSON from stdin, detects the agent format, and responds appropriately: **VS Code Copilot Chat** (supports `updatedInput` — transparent rewrite, no denial): -1. Agent runs `git status` → `rtk hook` intercepts via `PreToolUse` -2. `rtk hook` detects VS Code format (`tool_name`/`tool_input` keys) +1. Agent runs `git status` → `rtk hook copilot` intercepts via `PreToolUse` +2. `rtk hook copilot` detects VS Code format (`tool_name`/`tool_input` keys) 3. Returns `hookSpecificOutput.updatedInput.command = "rtk git status"` 4. Agent runs the rewritten command silently — no denial, no retry **GitHub Copilot CLI** (deny-with-suggestion — CLI ignores `updatedInput` today, see [issue #2013](https://github.com/github/copilot-cli/issues/2013)): -1. Agent runs `git status` → `rtk hook` intercepts via `PreToolUse` -2. `rtk hook` detects Copilot CLI format (`toolName`/`toolArgs` keys) +1. Agent runs `git status` → `rtk hook copilot` intercepts via `PreToolUse` +2. `rtk hook copilot` detects Copilot CLI format (`toolName`/`toolArgs` keys) 3. Returns `permissionDecision: deny` with reason: `"Token savings: use 'rtk git status' instead"` 4. Copilot reads the reason and re-runs `rtk git status` -When Copilot CLI adds `updatedInput` support, only `rtk hook` needs updating — no config changes. +When Copilot CLI adds `updatedInput` support, only `rtk hook copilot` needs updating — no config changes. ## Integration comparison diff --git a/src/init.rs b/src/init.rs index 241a7ef5..7f3dbb17 100644 --- a/src/init.rs +++ b/src/init.rs @@ -18,6 +18,9 @@ const OPENCODE_PLUGIN: &str = include_str!("../hooks/opencode-rtk.ts"); // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md"); const RTK_SLIM_CODEX: &str = include_str!("../hooks/rtk-awareness-codex.md"); +const COPILOT_INSTRUCTIONS: &str = include_str!("../hooks/copilot-rtk-awareness.md"); +const COPILOT_GLOBAL_INSTRUCTIONS: &str = include_str!("../hooks/copilot-global-rtk-awareness.md"); +const COPILOT_HOOK_JSON: &str = include_str!("../.github/hooks/rtk-rewrite.json"); /// Template written by `rtk init` when no filters.toml exists yet. const FILTERS_TEMPLATE: &str = r#"# Project-local RTK filters — commit this file with your repo. @@ -1790,14 +1793,68 @@ fn remove_cursor_hook_from_json(root: &mut serde_json::Value) -> bool { } /// Show current rtk configuration -pub fn show_config(codex: bool) -> Result<()> { +pub fn show_config(codex: bool, copilot: bool) -> Result<()> { if codex { return show_codex_config(); } + if copilot { + return show_copilot_config(); + } + show_claude_config() } +fn show_copilot_config() -> Result<()> { + let copilot_dir = resolve_copilot_dir()?; + let global_instructions = copilot_dir.join("copilot-instructions.md"); + let local_instructions = PathBuf::from(".github").join("copilot-instructions.md"); + let local_hook = PathBuf::from(".github") + .join("hooks") + .join("rtk-rewrite.json"); + + println!("rtk Configuration (GitHub Copilot):\n"); + + if global_instructions.exists() { + println!( + "[ok] Global copilot-instructions.md: {}", + global_instructions.display() + ); + } else { + println!("[--] Global copilot-instructions.md: not found"); + } + + if local_instructions.exists() { + println!( + "[ok] Local .github/copilot-instructions.md: {}", + local_instructions.display() + ); + } else { + println!("[--] Local .github/copilot-instructions.md: not found"); + } + + if local_hook.exists() { + let content = fs::read_to_string(&local_hook)?; + if content.contains("rtk hook copilot") { + println!("[ok] Local .github/hooks/rtk-rewrite.json: RTK hook configured"); + } else { + println!( + "[warn] Local .github/hooks/rtk-rewrite.json: exists but hook command is stale" + ); + } + } else { + println!("[--] Local .github/hooks/rtk-rewrite.json: not found"); + } + + println!("\nUsage:"); + println!(" rtk init -g --agent copilot # Configure ~/.copilot/copilot-instructions.md"); + println!(" rtk init --agent copilot # Configure .github/copilot-instructions.md + hook"); + println!(" rtk init --agent copilot --uninstall # Remove local Copilot RTK files"); + println!(" rtk init -g --agent copilot --uninstall # Remove global Copilot RTK instructions"); + + Ok(()) +} + fn show_claude_config() -> Result<()> { let claude_dir = resolve_claude_dir()?; let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); @@ -2081,6 +2138,127 @@ fn show_codex_config() -> Result<()> { Ok(()) } +/// Resolve ~/.copilot directory with proper home expansion +fn resolve_copilot_dir() -> Result { + dirs::home_dir() + .map(|h| h.join(".copilot")) + .context("Cannot determine home directory. Is $HOME set?") +} + +/// Entry point for `rtk init --agent copilot` +pub fn run_copilot(global: bool, verbose: u8) -> Result<()> { + if global { + let copilot_dir = resolve_copilot_dir()?; + fs::create_dir_all(&copilot_dir).with_context(|| { + format!( + "Failed to create Copilot config directory: {}", + copilot_dir.display() + ) + })?; + + let instructions_path = copilot_dir.join("copilot-instructions.md"); + write_if_changed( + &instructions_path, + COPILOT_GLOBAL_INSTRUCTIONS, + "copilot-instructions.md", + verbose, + )?; + + println!("\nRTK configured for GitHub Copilot (global).\n"); + println!(" Instructions: {}", instructions_path.display()); + println!(" Covers: GitHub Copilot CLI + VS Code Copilot Chat"); + println!(" Note: For repo-local PreToolUse hook safety nets, run `rtk init --agent copilot` inside a repository."); + println!(" Restart Copilot CLI / VS Code chat session. Test with: git status\n"); + return Ok(()); + } + + let github_dir = PathBuf::from(".github"); + let hooks_dir = github_dir.join("hooks"); + fs::create_dir_all(&hooks_dir) + .with_context(|| format!("Failed to create hooks directory: {}", hooks_dir.display()))?; + + let instructions_path = github_dir.join("copilot-instructions.md"); + let hook_path = hooks_dir.join("rtk-rewrite.json"); + + write_if_changed( + &instructions_path, + COPILOT_INSTRUCTIONS, + "copilot-instructions.md", + verbose, + )?; + write_if_changed(&hook_path, COPILOT_HOOK_JSON, "Copilot hook", verbose)?; + + println!("\nRTK configured for GitHub Copilot (repository).\n"); + println!(" Instructions: {}", instructions_path.display()); + println!(" Hook: {}", hook_path.display()); + println!(" Covers: VS Code Copilot Chat + GitHub Copilot CLI"); + println!(" Hook command: rtk hook copilot"); + println!(" Restart the Copilot session. Test with: git status\n"); + + Ok(()) +} + +pub fn uninstall_copilot(global: bool, verbose: u8) -> Result<()> { + let mut removed = Vec::new(); + + if global { + let copilot_dir = resolve_copilot_dir()?; + let instructions_path = copilot_dir.join("copilot-instructions.md"); + if instructions_path.exists() { + fs::remove_file(&instructions_path).with_context(|| { + format!( + "Failed to remove Copilot instructions: {}", + instructions_path.display() + ) + })?; + removed.push(format!( + "Global copilot-instructions.md: {}", + instructions_path.display() + )); + } + } else { + let instructions_path = PathBuf::from(".github").join("copilot-instructions.md"); + let hook_path = PathBuf::from(".github") + .join("hooks") + .join("rtk-rewrite.json"); + + if instructions_path.exists() { + fs::remove_file(&instructions_path).with_context(|| { + format!( + "Failed to remove Copilot instructions: {}", + instructions_path.display() + ) + })?; + removed.push(format!( + "Local copilot-instructions.md: {}", + instructions_path.display() + )); + } + + if hook_path.exists() { + fs::remove_file(&hook_path).with_context(|| { + format!("Failed to remove Copilot hook: {}", hook_path.display()) + })?; + removed.push(format!("Local Copilot hook: {}", hook_path.display())); + } + } + + if removed.is_empty() { + println!("RTK Copilot support was not installed (nothing to remove)"); + } else { + println!("RTK uninstalled for GitHub Copilot:"); + for item in removed { + println!(" - {}", item); + } + } + + if verbose > 0 { + eprintln!("Copilot uninstall complete"); + } + + Ok(()) +} + fn run_opencode_only_mode(verbose: u8) -> Result<()> { let opencode_plugin_path = prepare_opencode_plugin_path()?; ensure_opencode_plugin_installed(&opencode_plugin_path, verbose)?; @@ -2307,6 +2485,38 @@ mod tests { use super::*; use tempfile::TempDir; + #[test] + fn test_copilot_hook_asset_uses_copilot_subcommand() { + assert!( + COPILOT_HOOK_JSON.contains("\"command\": \"rtk hook copilot\""), + "Copilot hook asset must invoke `rtk hook copilot`" + ); + } + + #[test] + fn test_copilot_repo_instructions_reference_correct_hook_command() { + assert!( + COPILOT_INSTRUCTIONS.contains("rtk hook copilot"), + "Repo Copilot instructions should document the correct hook command" + ); + assert!( + COPILOT_INSTRUCTIONS.contains(".github/copilot-instructions.md"), + "Repo Copilot instructions should reference the repository instructions path" + ); + } + + #[test] + fn test_copilot_global_instructions_reference_global_path() { + assert!( + COPILOT_GLOBAL_INSTRUCTIONS.contains(".copilot/copilot-instructions.md"), + "Global Copilot instructions should reference the global Copilot instructions path" + ); + assert!( + COPILOT_GLOBAL_INSTRUCTIONS.contains("rtk gain"), + "Global Copilot instructions should keep RTK meta commands documented" + ); + } + #[test] fn test_init_mentions_all_top_level_commands() { for cmd in [ diff --git a/src/main.rs b/src/main.rs index 2bbc4bb2..7d240215 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,6 +76,8 @@ use std::path::{Path, PathBuf}; pub enum AgentTarget { /// Claude Code (default) Claude, + /// GitHub Copilot CLI / VS Code Copilot Chat + Copilot, /// Cursor Agent (editor and CLI) Cursor, /// Windsurf IDE (Cascade) @@ -1659,10 +1661,14 @@ fn main() -> Result<()> { codex, } => { if show { - init::show_config(codex)?; + init::show_config(codex, agent == Some(AgentTarget::Copilot))?; } else if uninstall { - let cursor = agent == Some(AgentTarget::Cursor); - init::uninstall(global, gemini, codex, cursor, cli.verbose)?; + if agent == Some(AgentTarget::Copilot) { + init::uninstall_copilot(global, cli.verbose)?; + } else { + let cursor = agent == Some(AgentTarget::Cursor); + init::uninstall(global, gemini, codex, cursor, cli.verbose)?; + } } else if gemini { let patch_mode = if auto_patch { init::PatchMode::Auto @@ -1672,6 +1678,8 @@ fn main() -> Result<()> { init::PatchMode::Ask }; init::run_gemini(global, hook_only, patch_mode, cli.verbose)?; + } else if agent == Some(AgentTarget::Copilot) { + init::run_copilot(global, cli.verbose)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -2498,6 +2506,39 @@ mod tests { } } + #[test] + fn test_init_agent_copilot_parses() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "copilot"]) + .expect("copilot agent init should parse"); + + match cli.command { + Commands::Init { agent, .. } => { + assert_eq!(agent, Some(AgentTarget::Copilot)); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_init_global_agent_copilot_show_parses() { + let cli = Cli::try_parse_from(["rtk", "init", "-g", "--agent", "copilot", "--show"]) + .expect("global copilot show should parse"); + + match cli.command { + Commands::Init { + global, + agent, + show, + .. + } => { + assert!(global); + assert!(show); + assert_eq!(agent, Some(AgentTarget::Copilot)); + } + _ => panic!("Expected Init command"), + } + } + #[test] fn test_shell_split_simple() { assert_eq!(