From df8b2e17f153101db75dd77a85bbcb4732d62183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=85=D0=BC=D0=B5=D0=B4=D0=BE=D0=B2=20=D0=9D=D1=83?= =?UTF-8?q?=D1=80=D0=B1=D0=B5=D0=BA=20=D0=9C=D0=B5=D0=B4=D0=B5=D1=82=D0=B1?= =?UTF-8?q?=D0=B0=D0=B5=D0=B2=D0=B8=D1=87?= Date: Sat, 21 Mar 2026 23:39:47 +0500 Subject: [PATCH] Add Copilot support and fix Windows startup Add explicit GitHub Copilot integration for both repository-local and global setup, wire the Copilot hook asset to tk hook copilot, document the new workflow, and add focused tests for the new assets and CLI parsing. Also fix the Windows startup stack overflow by reserving a larger main-thread stack at link time so tk.exe --version, --help, and Copilot hook entry points start reliably on Windows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/hooks/rtk-rewrite.json | 2 +- INSTALL.md | 24 +++ README.md | 39 +++++ build.rs | 9 ++ hooks/copilot-global-rtk-awareness.md | 40 +++++ hooks/copilot-rtk-awareness.md | 14 +- src/init.rs | 212 +++++++++++++++++++++++++- src/main.rs | 47 +++++- 8 files changed, 375 insertions(+), 12 deletions(-) create mode 100644 hooks/copilot-global-rtk-awareness.md 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!(