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..ecf5cac3 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 @@ -93,7 +97,8 @@ rtk gain # MUST show token savings, not "command not found" ```bash rtk init -g -# → Installs hook to ~/.claude/hooks/rtk-rewrite.sh +# → Installs hook to ~/.claude/hooks/rtk-rewrite.sh (macOS/Linux) +# or ~/.claude/hooks/rtk-rewrite.cmd (Windows) # → Creates ~/.claude/RTK.md (10 lines, meta commands only) # → Adds @RTK.md reference to ~/.claude/CLAUDE.md # → Prompts: "Patch settings.json? [y/N]" @@ -109,6 +114,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..ed198de4 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ rtk curl # Auto-detect JSON + schema rtk wget # Download, strip progress bars rtk summary # Heuristic summary rtk proxy # Raw passthrough + tracking +rtk pwsh -Command pwd # PowerShell builtins/aliases via RTK ``` ### Token Savings Analytics @@ -227,6 +228,7 @@ rtk gain --all --format json # JSON export for dashboards rtk discover # Find missed savings opportunities rtk discover --all --since 7 # All projects, last 7 days + # Includes Claude Code + Copilot shell sessions rtk session # Show RTK adoption across recent sessions ``` @@ -285,8 +287,49 @@ rtk init -g --hook-only # Hook only, no RTK.md rtk init --show # Verify installation ``` +On Windows, RTK installs `~/.claude/hooks/rtk-rewrite.cmd`; on macOS/Linux it installs `~/.claude/hooks/rtk-rewrite.sh`. + 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/hooks/rtk-rewrite.cmd b/hooks/rtk-rewrite.cmd new file mode 100644 index 00000000..0cfdc107 --- /dev/null +++ b/hooks/rtk-rewrite.cmd @@ -0,0 +1,12 @@ +@echo off +REM rtk-hook-version: 2 +REM RTK Claude Code hook — rewrites commands to use rtk for token savings. +REM Windows variant: delegate to the native Rust Claude/Copilot hook processor. + +where rtk >nul 2>nul +if errorlevel 1 ( + echo [rtk] WARNING: rtk is not installed or not in PATH. Hook cannot rewrite commands. Install: https://github.com/rtk-ai/rtk#installation 1>&2 + exit /b 0 +) + +rtk hook copilot diff --git a/src/discover/mod.rs b/src/discover/mod.rs index ba868a03..84850957 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -5,8 +5,9 @@ pub mod rules; use anyhow::Result; use std::collections::HashMap; +use std::path::PathBuf; -use provider::{ClaudeProvider, SessionProvider}; +use provider::{ClaudeProvider, CopilotProvider, SessionProvider}; use registry::{ category_avg_tokens, classify_command, has_rtk_disabled_prefix, split_command_chain, strip_disabled_prefix, Classification, @@ -30,6 +31,21 @@ struct UnsupportedBucket { example: String, } +#[derive(Clone, Copy)] +enum SessionSource { + Claude, + Copilot, +} + +impl SessionSource { + fn label(self) -> &'static str { + match self { + SessionSource::Claude => "claude", + SessionSource::Copilot => "copilot", + } + } +} + pub fn run( project: Option<&str>, all: bool, @@ -38,27 +54,41 @@ pub fn run( format: &str, verbose: u8, ) -> Result<()> { - let provider = ClaudeProvider; + let claude_provider = ClaudeProvider; + let copilot_provider = CopilotProvider; - // Determine project filter - let project_filter = if all { - None + let (claude_filter, copilot_filter) = if all { + (None, None) } else if let Some(p) = project { - Some(p.to_string()) + (Some(p.to_string()), Some(p.to_string())) } else { - // Default: current working directory let cwd = std::env::current_dir()?; let cwd_str = cwd.to_string_lossy().to_string(); - let encoded = ClaudeProvider::encode_project_path(&cwd_str); - Some(encoded) + ( + Some(ClaudeProvider::encode_project_path(&cwd_str)), + Some(cwd_str), + ) }; - let sessions = provider.discover_sessions(project_filter.as_deref(), Some(since_days))?; + let mut sessions: Vec<(SessionSource, PathBuf)> = Vec::new(); + + match claude_provider.discover_sessions(claude_filter.as_deref(), Some(since_days))? { + found => sessions.extend(found.into_iter().map(|p| (SessionSource::Claude, p))), + } + + match copilot_provider.discover_sessions(copilot_filter.as_deref(), Some(since_days)) { + Ok(found) => sessions.extend(found.into_iter().map(|p| (SessionSource::Copilot, p))), + Err(err) => { + if verbose > 0 { + eprintln!("Skipping Copilot sessions: {err}"); + } + } + } if verbose > 0 { eprintln!("Scanning {} session files...", sessions.len()); - for s in &sessions { - eprintln!(" {}", s.display()); + for (source, path) in &sessions { + eprintln!(" [{}] {}", source.label(), path.display()); } } @@ -70,12 +100,21 @@ pub fn run( let mut supported_map: HashMap<&'static str, SupportedBucket> = HashMap::new(); let mut unsupported_map: HashMap = HashMap::new(); - for session_path in &sessions { - let extracted = match provider.extract_commands(session_path) { + for (source, session_path) in &sessions { + let extracted = match source { + SessionSource::Claude => claude_provider.extract_commands(session_path), + SessionSource::Copilot => copilot_provider.extract_commands(session_path), + }; + let extracted = match extracted { Ok(cmds) => cmds, Err(e) => { if verbose > 0 { - eprintln!("Warning: skipping {}: {}", session_path.display(), e); + eprintln!( + "Warning: skipping [{}] {}: {}", + source.label(), + session_path.display(), + e + ); } parse_errors += 1; continue; @@ -126,7 +165,7 @@ pub fn run( // Estimate tokens for this command let output_tokens = if let Some(len) = ext_cmd.output_len { - // Real: from tool_result content length + // Real: from tool_result / tool execution content length len / 4 } else { // Fallback: category average @@ -156,11 +195,9 @@ pub fn run( bucket.count += 1; } Classification::Ignored => { - // Check if it starts with "rtk " if part.trim().starts_with("rtk ") { already_rtk += 1; } - // Otherwise just skip } } } diff --git a/src/discover/provider.rs b/src/discover/provider.rs index b4105a9d..cc8148a9 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -239,10 +239,226 @@ impl SessionProvider for ClaudeProvider { } } +pub struct CopilotProvider; + +impl CopilotProvider { + fn session_state_dir() -> Result { + let home = dirs::home_dir().context("could not determine home directory")?; + let dir = home.join(".copilot").join("session-state"); + if !dir.exists() { + anyhow::bail!( + "Copilot session-state directory not found: {}\nMake sure GitHub Copilot has been used at least once.", + dir.display() + ); + } + Ok(dir) + } + + fn session_id_from_path(path: &Path) -> String { + if path.file_name().and_then(|n| n.to_str()) == Some("events.jsonl") { + path.parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string() + } else { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string() + } + } + + fn extract_session_cwd(path: &Path) -> Result> { + let file = + fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let reader = BufReader::new(file); + + for line in reader.lines().take(25) { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + + if !line.contains("\"type\":\"session.start\"") { + continue; + } + + let entry: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + if let Some(cwd) = entry.pointer("/data/context/cwd").and_then(|c| c.as_str()) { + return Ok(Some(cwd.to_string())); + } + } + + Ok(None) + } + + fn session_matches_filter(path: &Path, project_filter: &str) -> bool { + let Ok(Some(cwd)) = Self::extract_session_cwd(path) else { + return false; + }; + + cwd.eq_ignore_ascii_case(project_filter) + || cwd.starts_with(project_filter) + || cwd.contains(project_filter) + } + + fn is_shell_tool(tool_name: &str) -> bool { + matches!(tool_name, "powershell" | "bash" | "shell") + } +} + +impl SessionProvider for CopilotProvider { + fn discover_sessions( + &self, + project_filter: Option<&str>, + since_days: Option, + ) -> Result> { + let session_state_dir = Self::session_state_dir()?; + let cutoff = since_days.map(|days| { + SystemTime::now() + .checked_sub(Duration::from_secs(days * 86400)) + .unwrap_or(SystemTime::UNIX_EPOCH) + }); + + let mut sessions = Vec::new(); + + for walk_entry in WalkDir::new(&session_state_dir) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + let file_path = walk_entry.path(); + if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + + if let Some(cutoff_time) = cutoff { + if let Ok(meta) = fs::metadata(file_path) { + if let Ok(mtime) = meta.modified() { + if mtime < cutoff_time { + continue; + } + } + } + } + + if let Some(filter) = project_filter { + if !Self::session_matches_filter(file_path, filter) { + continue; + } + } + + sessions.push(file_path.to_path_buf()); + } + + Ok(sessions) + } + + fn extract_commands(&self, path: &Path) -> Result> { + let file = + fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let reader = BufReader::new(file); + + let session_id = Self::session_id_from_path(path); + let mut pending_tool_uses: Vec<(String, String, usize)> = Vec::new(); + let mut tool_results: HashMap = HashMap::new(); + let mut commands = Vec::new(); + let mut sequence_counter = 0; + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + + if !line.contains("\"type\":\"tool.execution_start\"") + && !line.contains("\"type\":\"tool.execution_complete\"") + { + continue; + } + + let entry: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match entry_type { + "tool.execution_start" => { + let tool_name = entry + .pointer("/data/toolName") + .and_then(|n| n.as_str()) + .unwrap_or(""); + if !Self::is_shell_tool(tool_name) { + continue; + } + + if let (Some(id), Some(cmd)) = ( + entry.pointer("/data/toolCallId").and_then(|i| i.as_str()), + entry + .pointer("/data/arguments/command") + .and_then(|c| c.as_str()), + ) { + pending_tool_uses.push((id.to_string(), cmd.to_string(), sequence_counter)); + sequence_counter += 1; + } + } + "tool.execution_complete" => { + if let Some(id) = entry.pointer("/data/toolCallId").and_then(|i| i.as_str()) { + let content = entry + .pointer("/data/result/content") + .and_then(|c| c.as_str()) + .or_else(|| { + entry + .pointer("/data/result/detailedContent") + .and_then(|c| c.as_str()) + }) + .unwrap_or(""); + let is_error = !entry + .pointer("/data/success") + .and_then(|s| s.as_bool()) + .unwrap_or(true); + let content_preview: String = content.chars().take(1000).collect(); + + tool_results + .insert(id.to_string(), (content.len(), content_preview, is_error)); + } + } + _ => {} + } + } + + for (tool_id, command, sequence_index) in pending_tool_uses { + let (output_len, output_content, is_error) = tool_results + .get(&tool_id) + .map(|(len, content, err)| (Some(*len), Some(content.clone()), *err)) + .unwrap_or((None, None, false)); + + commands.push(ExtractedCommand { + command, + output_len, + session_id: session_id.clone(), + output_content, + is_error, + sequence_index, + }); + } + + Ok(commands) + } +} + #[cfg(test)] mod tests { use super::*; use std::io::Write; + use tempfile::TempDir; fn make_jsonl(lines: &[&str]) -> tempfile::NamedTempFile { let mut f = tempfile::NamedTempFile::new().unwrap(); @@ -390,4 +606,70 @@ mod tests { assert_eq!(cmds[1].command, "second"); assert_eq!(cmds[2].command, "third"); } + + #[test] + fn test_extract_copilot_powershell() { + let jsonl = make_jsonl(&[ + r#"{"type":"session.start","data":{"context":{"cwd":"C:\\Users\\times\\Desktop\\rtk"}}}"#, + r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"powershell","arguments":{"command":"git status","description":"Check git","initial_wait":30}}}"#, + r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"On branch main\nnothing to commit"}}}"#, + ]); + + let provider = CopilotProvider; + let cmds = provider.extract_commands(jsonl.path()).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "git status"); + assert_eq!( + cmds[0].output_len, + Some("On branch main\nnothing to commit".len()) + ); + assert!(!cmds[0].is_error); + } + + #[test] + fn test_extract_copilot_non_shell_ignored() { + let jsonl = make_jsonl(&[ + r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"view","arguments":{"path":"C:\\Users\\times\\README.md"}}}"#, + r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"README"}}}"#, + ]); + + let provider = CopilotProvider; + let cmds = provider.extract_commands(jsonl.path()).unwrap(); + assert!(cmds.is_empty()); + } + + #[test] + fn test_extract_copilot_session_cwd() { + let jsonl = make_jsonl(&[ + r#"{"type":"session.start","data":{"context":{"cwd":"C:\\Users\\times\\Desktop\\rtk"}}}"#, + ]); + + let cwd = CopilotProvider::extract_session_cwd(jsonl.path()).unwrap(); + assert_eq!(cwd.as_deref(), Some("C:\\Users\\times\\Desktop\\rtk")); + } + + #[test] + fn test_copilot_events_jsonl_uses_parent_as_session_id() { + let temp = TempDir::new().unwrap(); + let session_dir = temp.path().join("abc-session"); + fs::create_dir_all(&session_dir).unwrap(); + let events = session_dir.join("events.jsonl"); + fs::write( + &events, + concat!( + r#"{"type":"session.start","data":{"context":{"cwd":"C:\\Users\\times"}}}"#, + "\n", + r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"powershell","arguments":{"command":"rtk gain"}}}"#, + "\n", + r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"ok"}}}"#, + "\n" + ), + ) + .unwrap(); + + let provider = CopilotProvider; + let cmds = provider.extract_commands(&events).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].session_id, "abc-session"); + } } diff --git a/src/discover/registry.rs b/src/discover/registry.rs index d04a112a..b9da1448 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -54,6 +54,185 @@ lazy_static! { Regex::new(r"^(?:(?:-C\s+\S+|-c\s+\S+|--git-dir(?:=\S+|\s+\S+)|--work-tree(?:=\S+|\s+\S+)|--no-pager|--no-optional-locks|--bare|--literal-pathspecs)\s+)+").unwrap(); } +const PWSH_LS_ALIASES: &[&str] = &["dir", "Get-ChildItem"]; +const PWSH_READ_ALIASES: &[&str] = &["Get-Content", "type"]; +const PWSH_GREP_ALIASES: &[&str] = &["Select-String", "sls"]; +const PWSH_PWSH_ALIASES: &[&str] = &["pwd", "Get-Location"]; + +#[derive(Clone, Copy)] +enum PwshBuiltinRoute { + Pwsh, + Ls, + Read, +} + +fn is_windows_shell_context() -> bool { + cfg!(windows) +} + +fn strip_case_insensitive_prefix<'a>(cmd: &'a str, prefix: &str) -> Option<&'a str> { + if cmd.len() < prefix.len() || !cmd[..prefix.len()].eq_ignore_ascii_case(prefix) { + return None; + } + + if cmd.len() == prefix.len() { + return Some(""); + } + + if cmd.as_bytes()[prefix.len()].is_ascii_whitespace() { + Some(cmd[prefix.len() + 1..].trim_start()) + } else { + None + } +} + +fn has_shell_redirection(rest: &str) -> bool { + let trimmed = rest.trim(); + trimmed.contains('>') || trimmed.contains('<') || trimmed.contains('|') +} + +fn safe_ls_args(rest: &str) -> bool { + let trimmed = rest.trim(); + if trimmed.is_empty() { + return true; + } + if has_shell_redirection(trimmed) { + return false; + } + + trimmed.split_whitespace().all(|token| { + if token == "--all" { + return true; + } + if token.starts_with('-') { + if token.starts_with("--") { + return false; + } + token + .trim_start_matches('-') + .chars() + .all(|c| matches!(c, 'a' | 'l' | 'h')) + } else { + true + } + }) +} + +fn safe_read_args(rest: &str) -> bool { + let trimmed = rest.trim(); + if trimmed.is_empty() || has_shell_redirection(trimmed) { + return false; + } + + let tokens: Vec<&str> = trimmed.split_whitespace().collect(); + tokens.len() == 1 && !tokens[0].starts_with('-') +} + +fn route_powershell_builtin(cmd: &str) -> Option { + if !is_windows_shell_context() { + return None; + } + + for alias in PWSH_PWSH_ALIASES { + if strip_case_insensitive_prefix(cmd, alias).is_some() { + return Some(PwshBuiltinRoute::Pwsh); + } + } + + for alias in PWSH_LS_ALIASES { + if let Some(rest) = strip_case_insensitive_prefix(cmd, alias) { + return Some(if safe_ls_args(rest) { + PwshBuiltinRoute::Ls + } else { + PwshBuiltinRoute::Pwsh + }); + } + } + + for alias in PWSH_READ_ALIASES { + if let Some(rest) = strip_case_insensitive_prefix(cmd, alias) { + return Some(if safe_read_args(rest) { + PwshBuiltinRoute::Read + } else { + PwshBuiltinRoute::Pwsh + }); + } + } + + for alias in PWSH_GREP_ALIASES { + if strip_case_insensitive_prefix(cmd, alias).is_some() { + return Some(PwshBuiltinRoute::Pwsh); + } + } + + None +} + +fn classify_powershell_builtin(cmd: &str) -> Option { + match route_powershell_builtin(cmd)? { + PwshBuiltinRoute::Pwsh => Some(Classification::Supported { + rtk_equivalent: "rtk pwsh", + category: "PowerShell", + estimated_savings_pct: 20.0, + status: super::report::RtkStatus::Passthrough, + }), + PwshBuiltinRoute::Ls => Some(Classification::Supported { + rtk_equivalent: "rtk ls", + category: "Files", + estimated_savings_pct: 65.0, + status: super::report::RtkStatus::Existing, + }), + PwshBuiltinRoute::Read => Some(Classification::Supported { + rtk_equivalent: "rtk read", + category: "Files", + estimated_savings_pct: 60.0, + status: super::report::RtkStatus::Existing, + }), + } +} + +fn rewrite_powershell_builtin(seg: &str) -> Option { + if !is_windows_shell_context() { + return None; + } + + let trimmed = seg.trim(); + let stripped_cow = ENV_PREFIX.replace(trimmed, ""); + let env_prefix_len = trimmed.len() - stripped_cow.len(); + let env_prefix = &trimmed[..env_prefix_len]; + let cmd_clean = stripped_cow.trim(); + + match route_powershell_builtin(cmd_clean)? { + PwshBuiltinRoute::Pwsh => Some(format!(r#"{env_prefix}rtk pwsh -Command "{cmd_clean}""#)), + PwshBuiltinRoute::Ls => Some( + format!( + "{env_prefix}rtk ls {}", + cmd_clean + .split_once(char::is_whitespace) + .map(|(_, r)| r.trim_start()) + .unwrap_or("") + ) + .trim_end() + .to_string(), + ), + PwshBuiltinRoute::Read => Some( + format!( + "{env_prefix}rtk read {}", + cmd_clean + .split_once(char::is_whitespace) + .map(|(_, r)| r.trim_start()) + .unwrap_or("") + ) + .trim_end() + .to_string(), + ), + } +} + +fn excluded_command(base: &str, excluded: &[String]) -> bool { + excluded.iter().any(|e| e.eq_ignore_ascii_case(base)) +} + /// Classify a single (already-split) command. pub fn classify_command(cmd: &str) -> Classification { let trimmed = cmd.trim(); @@ -61,6 +240,10 @@ pub fn classify_command(cmd: &str) -> Classification { return Classification::Ignored; } + if let Some(classification) = classify_powershell_builtin(trimmed) { + return classification; + } + // Check ignored for exact in IGNORED_EXACT { if trimmed == *exact { @@ -584,12 +767,16 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option { return rewrite_tail_lines(trimmed); } + if let Some(rewritten) = rewrite_powershell_builtin_with_exclusions(trimmed, excluded) { + return Some(rewritten); + } + // Use classify_command for correct ignore/prefix handling let rtk_equivalent = match classify_command(trimmed) { Classification::Supported { rtk_equivalent, .. } => { // Check if the base command is excluded from rewriting (#243) let base = trimmed.split_whitespace().next().unwrap_or(""); - if excluded.iter().any(|e| e == base) { + if excluded_command(base, excluded) { return None; } rtk_equivalent @@ -638,6 +825,22 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option { None } +fn rewrite_powershell_builtin_with_exclusions(seg: &str, excluded: &[String]) -> Option { + if !is_windows_shell_context() { + return None; + } + + let trimmed = seg.trim(); + let stripped_cow = ENV_PREFIX.replace(trimmed, ""); + let cmd_clean = stripped_cow.trim(); + let base = cmd_clean.split_whitespace().next().unwrap_or(""); + if excluded_command(base, excluded) { + return None; + } + + rewrite_powershell_builtin(seg) +} + /// Strip a command prefix with word-boundary check. /// Returns the remainder of the command after the prefix, or `None` if no match. fn strip_word_prefix<'a>(cmd: &'a str, prefix: &str) -> Option<&'a str> { @@ -760,6 +963,84 @@ mod tests { ); } + #[test] + fn test_classify_pwd_as_pwsh_on_windows() { + if cfg!(windows) { + assert_eq!( + classify_command("pwd"), + Classification::Supported { + rtk_equivalent: "rtk pwsh", + category: "PowerShell", + estimated_savings_pct: 20.0, + status: RtkStatus::Passthrough, + } + ); + } + } + + #[test] + fn test_classify_get_child_item_as_ls_on_windows() { + if cfg!(windows) { + assert_eq!( + classify_command("Get-ChildItem -Force"), + Classification::Supported { + rtk_equivalent: "rtk pwsh", + category: "PowerShell", + estimated_savings_pct: 20.0, + status: RtkStatus::Passthrough, + } + ); + assert_eq!( + classify_command("Get-ChildItem ."), + Classification::Supported { + rtk_equivalent: "rtk ls", + category: "Files", + estimated_savings_pct: 65.0, + status: RtkStatus::Existing, + } + ); + assert_eq!( + classify_command("dir src"), + Classification::Supported { + rtk_equivalent: "rtk ls", + category: "Files", + estimated_savings_pct: 65.0, + status: RtkStatus::Existing, + } + ); + } + } + + #[test] + fn test_classify_get_content_as_read_on_windows() { + if cfg!(windows) { + assert_eq!( + classify_command("Get-Content README.md"), + Classification::Supported { + rtk_equivalent: "rtk read", + category: "Files", + estimated_savings_pct: 60.0, + status: RtkStatus::Existing, + } + ); + } + } + + #[test] + fn test_classify_type_as_pwsh_on_windows() { + if cfg!(windows) { + assert_eq!( + classify_command("type README.md"), + Classification::Supported { + rtk_equivalent: "rtk read", + category: "Files", + estimated_savings_pct: 60.0, + status: RtkStatus::Existing, + } + ); + } + } + #[test] fn test_classify_htop_unsupported() { match classify_command("htop -d 10") { @@ -1142,6 +1423,51 @@ mod tests { ); } + #[test] + fn test_rewrite_pwd_to_pwsh_on_windows() { + if cfg!(windows) { + assert_eq!( + rewrite_command("pwd", &[]), + Some(r#"rtk pwsh -Command "pwd""#.into()) + ); + } + } + + #[test] + fn test_rewrite_get_child_item_to_ls_on_windows() { + if cfg!(windows) { + assert_eq!( + rewrite_command("Get-ChildItem -Force", &[]), + Some(r#"rtk pwsh -Command "Get-ChildItem -Force""#.into()) + ); + assert_eq!( + rewrite_command("Get-ChildItem .", &[]), + Some("rtk ls .".into()) + ); + assert_eq!(rewrite_command("dir src", &[]), Some("rtk ls src".into())); + } + } + + #[test] + fn test_rewrite_get_content_to_read_on_windows() { + if cfg!(windows) { + assert_eq!( + rewrite_command("Get-Content README.md", &[]), + Some("rtk read README.md".into()) + ); + } + } + + #[test] + fn test_rewrite_type_to_pwsh_on_windows() { + if cfg!(windows) { + assert_eq!( + rewrite_command("type README.md", &[]), + Some("rtk read README.md".into()) + ); + } + } + #[test] fn test_rewrite_npx_playwright() { assert_eq!( diff --git a/src/discover/report.rs b/src/discover/report.rs index 5b1fe801..f7cc62cf 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -76,7 +76,7 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri out.push_str(&"=".repeat(52)); out.push('\n'); out.push_str(&format!( - "Scanned: {} sessions (last {} days), {} Bash commands\n", + "Scanned: {} sessions (last {} days), {} shell commands\n", report.sessions_scanned, report.since_days, report.total_commands )); out.push_str(&format!( @@ -169,7 +169,9 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri if let Some(home) = dirs::home_dir() { let cursor_hook = home.join(".cursor").join("hooks").join("rtk-rewrite.sh"); if cursor_hook.exists() { - out.push_str("\nNote: Cursor sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + out.push_str( + "\nNote: Cursor sessions are tracked via `rtk gain` (discover scans Claude Code and Copilot shell sessions)\n", + ); } } diff --git a/src/hook_check.rs b/src/hook_check.rs index 2716ec15..12c7015a 100644 --- a/src/hook_check.rs +++ b/src/hook_check.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -const CURRENT_HOOK_VERSION: u8 = 2; +pub const CURRENT_HOOK_VERSION: u8 = 2; const WARN_INTERVAL_SECS: u64 = 24 * 3600; /// Hook status for diagnostics and `rtk gain`. @@ -77,7 +77,18 @@ fn check_and_warn() -> Option<()> { pub fn parse_hook_version(content: &str) -> u8 { // Version tag must be in the first 5 lines (shebang + header convention) for line in content.lines().take(5) { - if let Some(rest) = line.strip_prefix("# rtk-hook-version:") { + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix("# rtk-hook-version:") { + if let Ok(v) = rest.trim().parse::() { + return v; + } + } + if let Some(rest) = trimmed.strip_prefix("REM rtk-hook-version:") { + if let Ok(v) = rest.trim().parse::() { + return v; + } + } + if let Some(rest) = trimmed.strip_prefix(":: rtk-hook-version:") { if let Ok(v) = rest.trim().parse::() { return v; } @@ -87,8 +98,7 @@ pub fn parse_hook_version(content: &str) -> u8 { } fn hook_installed_path() -> Option { - let home = dirs::home_dir()?; - let path = home.join(".claude").join("hooks").join("rtk-rewrite.sh"); + let path = crate::integrity::resolve_hook_path().ok()?; if path.exists() { Some(path) } else { @@ -123,6 +133,12 @@ mod tests { assert_eq!(parse_hook_version(content), 5); } + #[test] + fn test_parse_hook_version_windows_rem_comment() { + let content = "@echo off\nREM rtk-hook-version: 2\n"; + assert_eq!(parse_hook_version(content), 2); + } + #[test] fn test_parse_hook_version_no_tag() { assert_eq!(parse_hook_version("no version here"), 0); @@ -146,12 +162,12 @@ mod tests { Some(h) => h, None => return, }; - if !home - .join(".claude") - .join("hooks") - .join("rtk-rewrite.sh") - .exists() - { + let hook_exists = crate::integrity::resolve_hook_path() + .ok() + .map(|p| p.exists()) + .unwrap_or(false); + + if !hook_exists { // No hook — status should be Missing (if .claude exists) or Ok (if not) let s = status(); if home.join(".claude").exists() { diff --git a/src/init.rs b/src/init.rs index 241a7ef5..5d1afcf0 100644 --- a/src/init.rs +++ b/src/init.rs @@ -8,6 +8,7 @@ use crate::integrity; // Embedded hook script (guards before set -euo pipefail) const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); +const REWRITE_HOOK_CMD: &str = include_str!("../hooks/rtk-rewrite.cmd"); // Embedded Cursor hook script (preToolUse format) const CURSOR_REWRITE_HOOK: &str = include_str!("../hooks/cursor-rtk-rewrite.sh"); @@ -18,6 +19,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. @@ -180,7 +184,8 @@ rtk wget # Compact download output (65%) ```bash rtk gain # View token savings statistics rtk gain --history # View command history with savings -rtk discover # Analyze Claude Code sessions for missed RTK usage +rtk discover # Analyze Claude Code and Copilot shell sessions for missed RTK usage +rtk pwsh -Command pwd # Route PowerShell builtins/aliases through RTK rtk proxy # Run command without filtering (for debugging) rtk init # Add RTK instructions to CLAUDE.md rtk init --global # Add RTK to ~/.claude/CLAUDE.md @@ -288,24 +293,40 @@ fn prepare_hook_paths() -> Result<(PathBuf, PathBuf)> { let hook_dir = claude_dir.join("hooks"); fs::create_dir_all(&hook_dir) .with_context(|| format!("Failed to create hook directory: {}", hook_dir.display()))?; - let hook_path = hook_dir.join("rtk-rewrite.sh"); + let hook_path = hook_dir.join(claude_hook_filename()); Ok((hook_dir, hook_path)) } +fn claude_hook_filename() -> &'static str { + if cfg!(windows) { + "rtk-rewrite.cmd" + } else { + "rtk-rewrite.sh" + } +} + +fn claude_hook_contents() -> &'static str { + if cfg!(windows) { + REWRITE_HOOK_CMD + } else { + REWRITE_HOOK + } +} + /// Write hook file if missing or outdated, return true if changed -#[cfg(unix)] fn ensure_hook_installed(hook_path: &Path, verbose: u8) -> Result { + let desired = claude_hook_contents(); let changed = if hook_path.exists() { let existing = fs::read_to_string(hook_path) .with_context(|| format!("Failed to read existing hook: {}", hook_path.display()))?; - if existing == REWRITE_HOOK { + if existing == desired { if verbose > 0 { eprintln!("Hook already up to date: {}", hook_path.display()); } false } else { - fs::write(hook_path, REWRITE_HOOK) + fs::write(hook_path, desired) .with_context(|| format!("Failed to write hook to {}", hook_path.display()))?; if verbose > 0 { eprintln!("Updated hook: {}", hook_path.display()); @@ -313,7 +334,7 @@ fn ensure_hook_installed(hook_path: &Path, verbose: u8) -> Result { true } } else { - fs::write(hook_path, REWRITE_HOOK) + fs::write(hook_path, desired) .with_context(|| format!("Failed to write hook to {}", hook_path.display()))?; if verbose > 0 { eprintln!("Created hook: {}", hook_path.display()); @@ -321,10 +342,12 @@ fn ensure_hook_installed(hook_path: &Path, verbose: u8) -> Result { true }; - // Set executable permissions - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(hook_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to set hook permissions: {}", hook_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(hook_path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Failed to set hook permissions: {}", hook_path.display()))?; + } // Store SHA-256 hash for runtime integrity verification. // Always store (idempotent) to ensure baseline exists even for @@ -459,7 +482,7 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { if let Some(hooks_array) = entry.get("hooks").and_then(|h| h.as_array()) { for hook in hooks_array { if let Some(command) = hook.get("command").and_then(|c| c.as_str()) { - if command.contains("rtk-rewrite.sh") { + if command.contains("rtk-rewrite.sh") || command.contains("rtk-rewrite.cmd") { return false; // Remove this entry } } @@ -833,7 +856,7 @@ fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) { } /// Check if RTK hook is already present in settings.json -/// Matches on rtk-rewrite.sh substring to handle different path formats +/// Matches on the RTK hook filename to handle different path formats. fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { let pre_tool_use_array = match root .get("hooks") @@ -850,27 +873,14 @@ fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { .flatten() .filter_map(|hook| hook.get("command")?.as_str()) .any(|cmd| { - // Exact match OR both contain rtk-rewrite.sh + // Exact match OR both contain the installed RTK hook filename. cmd == hook_command || (cmd.contains("rtk-rewrite.sh") && hook_command.contains("rtk-rewrite.sh")) + || (cmd.contains("rtk-rewrite.cmd") && hook_command.contains("rtk-rewrite.cmd")) }) } /// Default mode: hook + slim RTK.md + @RTK.md reference -#[cfg(not(unix))] -fn run_default_mode( - _global: bool, - _patch_mode: PatchMode, - _verbose: u8, - _install_opencode: bool, -) -> Result<()> { - eprintln!("[warn] Hook-based mode requires Unix (macOS/Linux)."); - eprintln!(" Windows: use --claude-md mode for full injection."); - eprintln!(" Falling back to --claude-md mode."); - run_claude_md_mode(_global, _verbose, _install_opencode) -} - -#[cfg(unix)] fn run_default_mode( global: bool, patch_mode: PatchMode, @@ -1004,17 +1014,6 @@ fn generate_global_filters_template(verbose: u8) -> Result<()> { } /// Hook-only mode: just the hook, no RTK.md -#[cfg(not(unix))] -fn run_hook_only_mode( - _global: bool, - _patch_mode: PatchMode, - _verbose: u8, - _install_opencode: bool, -) -> Result<()> { - anyhow::bail!("Hook install requires Unix (macOS/Linux). Use WSL or --claude-md mode.") -} - -#[cfg(unix)] fn run_hook_only_mode( global: bool, patch_mode: PatchMode, @@ -1790,17 +1789,71 @@ 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"); + let hook_path = claude_dir.join("hooks").join(claude_hook_filename()); let rtk_md_path = claude_dir.join("RTK.md"); let global_claude_md = claude_dir.join("CLAUDE.md"); let local_claude_md = PathBuf::from("CLAUDE.md"); @@ -1851,7 +1904,17 @@ fn show_claude_config() -> Result<()> { #[cfg(not(unix))] { - println!("[ok] Hook: {} (exists)", hook_path.display()); + let hook_content = fs::read_to_string(&hook_path).unwrap_or_default(); + let hook_version = crate::hook_check::parse_hook_version(&hook_content); + if hook_content.contains("rtk hook copilot") && hook_version >= 2 { + println!( + "[ok] Hook: {} (delegates to rtk hook copilot, version {})", + hook_path.display(), + hook_version + ); + } else { + println!("[warn] Hook: {} (outdated)", hook_path.display()); + } } } else { println!("[--] Hook: not found"); @@ -2081,6 +2144,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 +2491,50 @@ 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_windows_claude_hook_asset_uses_copilot_processor() { + assert!( + REWRITE_HOOK_CMD.contains("rtk hook copilot"), + "Windows Claude hook should delegate to `rtk hook copilot`" + ); + assert!( + REWRITE_HOOK_CMD.contains("rtk-hook-version: 2"), + "Windows Claude hook should carry the current hook version" + ); + } + #[test] fn test_init_mentions_all_top_level_commands() { for cmd in [ diff --git a/src/integrity.rs b/src/integrity.rs index 41bcf4e8..007d8c19 100644 --- a/src/integrity.rs +++ b/src/integrity.rs @@ -1,6 +1,6 @@ //! Hook integrity verification via SHA-256. //! -//! RTK installs a PreToolUse hook (`rtk-rewrite.sh`) that auto-approves +//! RTK installs a PreToolUse hook wrapper (`rtk-rewrite.sh` / `rtk-rewrite.cmd`) that auto-approves //! rewritten commands with `permissionDecision: "allow"`. Because this //! hook bypasses Claude Code's permission prompts, any unauthorized //! modification represents a command injection vector. @@ -178,10 +178,18 @@ fn read_stored_hash(path: &Path) -> Result { Ok(hash.to_string()) } -/// Resolve the default hook path (~/.claude/hooks/rtk-rewrite.sh) +fn hook_filename() -> &'static str { + if cfg!(windows) { + "rtk-rewrite.cmd" + } else { + "rtk-rewrite.sh" + } +} + +/// Resolve the default hook path (~/.claude/hooks/rtk-rewrite.*) pub fn resolve_hook_path() -> Result { dirs::home_dir() - .map(|h| h.join(".claude").join("hooks").join("rtk-rewrite.sh")) + .map(|h| h.join(".claude").join("hooks").join(hook_filename())) .context("Cannot determine home directory. Is $HOME set?") } diff --git a/src/main.rs b/src/main.rs index 2bbc4bb2..6d3dee20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,7 @@ mod pnpm_cmd; mod prettier_cmd; mod prisma_cmd; mod psql_cmd; +mod pwsh_cmd; mod pytest_cmd; mod read; mod rewrite_cmd; @@ -76,6 +77,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) @@ -217,6 +220,13 @@ enum Commands { args: Vec, }, + /// PowerShell passthrough for builtins and aliases + Pwsh { + /// pwsh arguments, usually `-Command ` + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// pnpm commands with ultra-compact output Pnpm { #[command(subcommand)] @@ -546,7 +556,7 @@ enum Commands { args: Vec, }, - /// Discover missed RTK savings from Claude Code history + /// Discover missed RTK savings from Claude Code and Copilot shell history Discover { /// Filter by project path (substring match) #[arg(short, long)] @@ -1463,6 +1473,10 @@ fn main() -> Result<()> { psql_cmd::run(&args, cli.verbose)?; } + Commands::Pwsh { args } => { + pwsh_cmd::run(&args, cli.verbose)?; + } + Commands::Pnpm { command } => match command { PnpmCommands::List { depth, args } => { pnpm_cmd::run(pnpm_cmd::PnpmCommand::List { depth }, &args, cli.verbose)?; @@ -1659,10 +1673,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 +1690,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; @@ -2217,6 +2237,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Smart { .. } | Commands::Git { .. } | Commands::Gh { .. } + | Commands::Pwsh { .. } | Commands::Pnpm { .. } | Commands::Err { .. } | Commands::Test { .. } @@ -2434,6 +2455,20 @@ mod tests { } } + #[test] + fn test_try_parse_pwsh_command_succeeds() { + let result = Cli::try_parse_from(["rtk", "pwsh", "-Command", "pwd"]); + assert!(result.is_ok(), "pwsh passthrough should parse successfully"); + if let Ok(cli) = result { + match cli.command { + Commands::Pwsh { args } => { + assert_eq!(args, vec!["-Command", "pwd"]); + } + _ => panic!("Expected Pwsh command"), + } + } + } + #[test] fn test_gain_failures_flag_parses() { let result = Cli::try_parse_from(["rtk", "gain", "--failures"]); @@ -2498,6 +2533,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!( diff --git a/src/pwsh_cmd.rs b/src/pwsh_cmd.rs new file mode 100644 index 00000000..7fcc954e --- /dev/null +++ b/src/pwsh_cmd.rs @@ -0,0 +1,59 @@ +use crate::tracking; +use crate::utils::resolved_command; +use anyhow::{Context, Result}; + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut pwsh_args = if args.is_empty() { + vec!["-NoLogo".to_string(), "-NoProfile".to_string()] + } else { + args.to_vec() + }; + + if !pwsh_args + .iter() + .any(|arg| arg.eq_ignore_ascii_case("-NoLogo")) + { + pwsh_args.insert(0, "-NoLogo".to_string()); + } + if !pwsh_args + .iter() + .any(|arg| arg.eq_ignore_ascii_case("-NoProfile")) + { + pwsh_args.insert(1.min(pwsh_args.len()), "-NoProfile".to_string()); + } + + if verbose > 0 { + eprintln!("Running: pwsh {}", pwsh_args.join(" ")); + } + + let mut cmd = resolved_command("pwsh"); + cmd.args(&pwsh_args); + + let output = cmd.output().context("Failed to run PowerShell (pwsh)")?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr); + let exit_code = output.status.code().unwrap_or(1); + + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + + if !stdout.is_empty() { + print!("{}", stdout); + } + + if exit_code != 0 { + std::process::exit(exit_code); + } + + timer.track( + &format!("pwsh {}", pwsh_args.join(" ")), + &format!("rtk pwsh {}", pwsh_args.join(" ")), + &stdout, + &stdout, + ); + + Ok(()) +}