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 1/2] 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!( From 36e98d5561181c0d762ad8405d732329e3cb9a58 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: Sun, 22 Mar 2026 00:42:57 +0500 Subject: [PATCH 2/2] Add Windows PowerShell and discover support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- INSTALL.md | 3 +- README.md | 4 + hooks/rtk-rewrite.cmd | 12 ++ src/discover/mod.rs | 73 ++++++--- src/discover/provider.rs | 282 +++++++++++++++++++++++++++++++++ src/discover/registry.rs | 328 ++++++++++++++++++++++++++++++++++++++- src/discover/report.rs | 6 +- src/hook_check.rs | 36 +++-- src/init.rs | 98 +++++++----- src/integrity.rs | 14 +- src/main.rs | 29 +++- src/pwsh_cmd.rs | 59 +++++++ 12 files changed, 868 insertions(+), 76 deletions(-) create mode 100644 hooks/rtk-rewrite.cmd create mode 100644 src/pwsh_cmd.rs diff --git a/INSTALL.md b/INSTALL.md index 55f680cb..ecf5cac3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -97,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]" diff --git a/README.md b/README.md index df991a80..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,6 +287,8 @@ 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 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 7f3dbb17..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"); @@ -183,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 @@ -291,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()); @@ -316,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()); @@ -324,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 @@ -462,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 } } @@ -836,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") @@ -853,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, @@ -1007,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, @@ -1857,7 +1853,7 @@ fn show_copilot_config() -> Result<()> { 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"); @@ -1908,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"); @@ -2517,6 +2523,18 @@ mod tests { ); } + #[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 7d240215..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; @@ -219,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)] @@ -548,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)] @@ -1465,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)?; @@ -2225,6 +2237,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Smart { .. } | Commands::Git { .. } | Commands::Gh { .. } + | Commands::Pwsh { .. } | Commands::Pnpm { .. } | Commands::Err { .. } | Commands::Test { .. } @@ -2442,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"]); 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(()) +}