Skip to content
Merged
18 changes: 15 additions & 3 deletions docs/guide/getting-started/supported-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Agent runs "cargo test"
|-------|-----------------|---------------------------|
| Claude Code | Shell hook (`PreToolUse`) | Yes |
| VS Code Copilot Chat | Shell hook (`PreToolUse`) | Yes |
| GitHub Copilot CLI | Shell hook (deny-with-suggestion) | No (agent retries) |
| GitHub Copilot CLI | Shell hook (`preToolUse` `modifiedArgs`) | Yes |
| Cursor | Shell hook (`preToolUse`) | Yes |
| Gemini CLI | Rust binary (`BeforeTool`) | Yes |
| OpenCode | TypeScript plugin (`tool.execute.before`) | Yes |
Expand Down Expand Up @@ -66,12 +66,24 @@ rtk init --global --cursor

Restart Cursor. The hook uses `preToolUse` with Cursor's `updated_input` format.

### VS Code Copilot Chat
### GitHub Copilot (VS Code Chat + CLI)

```bash
rtk init --global --copilot
rtk init --copilot # project-scoped (.github/hooks/)
rtk init --global --copilot # user-scoped (~/.copilot/hooks/, respects $COPILOT_HOME)
```

Project-scoped writes `.github/hooks/rtk-rewrite.json` (both hosts get transparent rewrite — VS Code Chat via `updatedInput`, Copilot CLI via `modifiedArgs`) plus the RTK block in `.github/copilot-instructions.md`. User-scoped writes the same hook config to `~/.copilot/hooks/rtk-rewrite.json` and the RTK block to `~/.copilot/copilot-instructions.md` (both respect `$COPILOT_HOME` if set).

Uninstall:

```bash
rtk init --uninstall --copilot
rtk init --uninstall --global --copilot
```

Removes only RTK's hook file (and, for project, the RTK block in `copilot-instructions.md`). Other files in `.github/hooks/` or `~/.copilot/hooks/` and your own instruction content are untouched.

### Gemini CLI

```bash
Expand Down
10 changes: 7 additions & 3 deletions src/core/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,11 +368,14 @@ fn detect_hook_type() -> String {
}
}

// Check project-level hooks
// Check project-level hooks (Claude script + project-scoped Copilot config)
if let Ok(cwd) = std::env::current_dir() {
if cwd.join(".claude/hooks/rtk-rewrite.sh").exists() {
return "claude".to_string();
}
if cwd.join(".github/hooks/rtk-rewrite.json").exists() {
return "copilot".to_string();
}
}

"none".to_string()
Expand Down Expand Up @@ -571,7 +574,7 @@ mod tests {
assert!(stats.low_savings_commands.len() <= 5);
assert!((0.0..=100.0).contains(&stats.avg_savings_per_command));
assert!(
["claude", "gemini", "codex", "cursor", "none", "unknown"]
["claude", "gemini", "codex", "cursor", "copilot", "none", "unknown"]
.iter()
.any(|&h| stats.hook_type.starts_with(h)),
"Unexpected hook type: {}",
Expand All @@ -583,7 +586,8 @@ mod tests {
fn test_detect_hook_type_returns_known() {
let ht = detect_hook_type();
assert!(
["claude", "gemini", "codex", "cursor", "none", "unknown"].contains(&ht.as_str()),
["claude", "gemini", "codex", "cursor", "copilot", "none", "unknown"]
.contains(&ht.as_str()),
"Unexpected hook type: {}",
ht
);
Expand Down
64 changes: 60 additions & 4 deletions src/discover/report.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! Data types for reporting which commands RTK can and cannot optimize.

use crate::hooks::constants::{
CURSOR_DIR, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME,
HOOKS_SUBDIR, REWRITE_HOOK_FILE,
COPILOT_HOOK_FILE, CURSOR_DIR, GITHUB_DIR, HERMES_DIR, HERMES_PLUGINS_SUBDIR,
HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_SUBDIR, REWRITE_HOOK_FILE,
};
use serde::Serialize;
use std::path::Path;
Expand Down Expand Up @@ -52,13 +52,19 @@ pub struct UnsupportedEntry {
pub struct AgentIntegrationStatus {
pub cursor_hook_installed: bool,
pub hermes_plugin_installed: bool,
pub copilot_hook_installed: bool,
}

impl AgentIntegrationStatus {
pub fn detect() -> Self {
dirs::home_dir()
let mut status = dirs::home_dir()
.map(|home| Self::detect_from_home(&home))
.unwrap_or_default()
.unwrap_or_default();
// Copilot is project-scoped (.github/hooks/), unlike the home-based agents.
status.copilot_hook_installed = std::env::current_dir()
.map(|cwd| Self::copilot_hook_installed_in(&cwd))
.unwrap_or(false);
status
}

fn detect_from_home(home: &Path) -> Self {
Expand All @@ -74,8 +80,16 @@ impl AgentIntegrationStatus {
.join(HERMES_PLUGIN_NAME)
.join(HERMES_PLUGIN_MANIFEST_FILE)
.is_file(),
copilot_hook_installed: false,
}
}

fn copilot_hook_installed_in(dir: &Path) -> bool {
dir.join(GITHUB_DIR)
.join(HOOKS_SUBDIR)
.join(COPILOT_HOOK_FILE)
.exists()
}
}

/// Full discover report.
Expand Down Expand Up @@ -221,6 +235,10 @@ fn append_agent_notes(out: &mut String, status: AgentIntegrationStatus) {
if status.hermes_plugin_installed {
out.push_str("\nNote: Hermes plugin is installed; Hermes sessions are tracked via `rtk gain` (discover scans Claude Code only)\n");
}

if status.copilot_hook_installed {
out.push_str("\nNote: GitHub Copilot sessions are tracked via `rtk gain` (discover scans Claude Code only)\n");
}
}

/// Format report as JSON.
Expand Down Expand Up @@ -362,12 +380,50 @@ mod tests {
report.agent_status = AgentIntegrationStatus {
cursor_hook_installed: true,
hermes_plugin_installed: true,
copilot_hook_installed: true,
};

let output = format_json(&report);
let json: serde_json::Value = serde_json::from_str(&output).unwrap();

assert_eq!(json["agent_status"]["cursor_hook_installed"], true);
assert_eq!(json["agent_status"]["hermes_plugin_installed"], true);
assert_eq!(json["agent_status"]["copilot_hook_installed"], true);
}

#[test]
fn test_agent_status_detects_copilot_hook_in_project() {
let temp = tempfile::tempdir().unwrap();
let hook = temp
.path()
.join(GITHUB_DIR)
.join(HOOKS_SUBDIR)
.join(COPILOT_HOOK_FILE);
std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
std::fs::write(&hook, "{}").unwrap();

assert!(AgentIntegrationStatus::copilot_hook_installed_in(
temp.path()
));
assert!(!AgentIntegrationStatus::copilot_hook_installed_in(
tempfile::tempdir().unwrap().path()
));
}

#[test]
fn test_format_text_reports_copilot_detected() {
let mut report = make_report(0, 0);
report.agent_status = AgentIntegrationStatus {
copilot_hook_installed: true,
..AgentIntegrationStatus::default()
};

let output = format_text(&report, 10, false);

assert!(
output.contains("GitHub Copilot sessions are tracked via `rtk gain`"),
"Expected Copilot note in output but got:\n{}",
output
);
}
}
6 changes: 6 additions & 0 deletions src/hooks/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ pub const CURSOR_DIR: &str = ".cursor";
pub const CODEX_DIR: &str = ".codex";
pub const GEMINI_DIR: &str = ".gemini";

pub const GITHUB_DIR: &str = ".github";
pub const COPILOT_HOOK_FILE: &str = "rtk-rewrite.json";
pub const COPILOT_INSTRUCTIONS_FILE: &str = "copilot-instructions.md";
pub const COPILOT_USER_DIR: &str = ".copilot";
pub const COPILOT_HOME_ENV: &str = "COPILOT_HOME";

pub const PI_DIR: &str = ".pi/agent";
pub const PI_LOCAL_DIR: &str = ".pi";
pub const PI_EXTENSIONS_SUBDIR: &str = "extensions";
Expand Down
Loading
Loading