From 4cd3563168135a869bebadee0413661f3d8f35fc Mon Sep 17 00:00:00 2001 From: Prakhar Khatri Date: Sun, 3 May 2026 10:17:48 +0000 Subject: [PATCH 1/2] feat: add dynamic agent selection to configure Configure now supports interactive agent selection plus explicit --all and --agents paths so users can install only the integrations they want while preserving scripted setup. --- Cargo.lock | 41 +++++ Cargo.toml | 1 + README.md | 10 +- src/cli.rs | 8 + src/configure/mod.rs | 423 ++++++++++++++++++++++++++++++++++++++----- src/main.rs | 12 +- 6 files changed, 438 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 28fb9a9..37e3388 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,7 @@ dependencies = [ "chrono", "clap", "colored", + "dialoguer", "dirs", "ed25519-dalek", "glob", @@ -252,6 +253,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -320,6 +333,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", +] + [[package]] name = "difflib" version = "0.4.0" @@ -388,6 +411,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -897,6 +926,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -1037,6 +1072,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 9c2cfe4..0bf0d01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ base64 = "0.22" libc = "0.2" tempfile = "3" uuid = { version = "1", features = ["v4"] } +dialoguer = { version = "0.12.0", default-features = false } [dev-dependencies] assert_cmd = "2" diff --git a/README.md b/README.md index 40db1e9..1618cc9 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,12 @@ agentdiff consolidate --branch feature/my-branch --push # Write CI workflows to .github/workflows/ (run once per repo) agentdiff install-ci +# Configure all supported agents directly, including Gemini/Antigravity +agentdiff configure --all + +# Configure only selected agents without the interactive picker +agentdiff configure --agents cursor,codex,opencode + # Skip specific agents during configure agentdiff configure --no-copilot --no-antigravity @@ -203,7 +209,7 @@ agentdiff status --remote --no-fetch # fast: show refs + SHAs only, skip trace | **Codex CLI** | `notify` hook (`~/.codex/config.toml`) | Task-level file changes | | **Gemini / Antigravity** | `BeforeTool`/`AfterTool` hooks (`~/.gemini/settings.json`) | `write_file`, `replace` | -Agent hooks for Claude, Cursor, Codex, Windsurf, OpenCode, and Gemini are all installed **globally once** via `agentdiff configure`. However, capture only fires in repos where `agentdiff init` has been run — the `.git/agentdiff/` directory must exist for any data to be written. +Agent hooks are installed **globally once** via `agentdiff configure`. In an interactive terminal, AgentDiff detects available agent configs and lets you choose integrations with Space + Enter. By default it selects the main coding agents and leaves Gemini/Antigravity optional; use `agentdiff configure --all` to install every supported integration directly. Capture only fires in repos where `agentdiff init` has been run — the `.git/agentdiff/` directory must exist for any data to be written. --- @@ -376,7 +382,7 @@ agentdiff list --uncommitted **1. `agentdiff configure` — one-time global setup** -Installs Python capture scripts to `~/.agentdiff/scripts/` and registers hooks with each agent: +Installs Python capture scripts to `~/.agentdiff/scripts/` and registers hooks with selected agents. In an interactive terminal, AgentDiff shows a Space/Enter multi-select picker; in scripts, use `--all` or `--agents cursor,codex` to avoid prompting. - Claude Code → `~/.claude/settings.json` (PostToolUse) - Cursor → `~/.cursor/hooks.json` (afterFileEdit, afterTabFileEdit) diff --git a/src/cli.rs b/src/cli.rs index c52e546..3978da4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -87,6 +87,14 @@ pub struct StatusArgs { #[derive(Args, Debug)] pub struct ConfigureArgs { + /// Configure every supported agent without prompting + #[arg(long)] + pub all: bool, + + /// Configure only these agents (comma-separated: claude-code,cursor,codex,windsurf,opencode,copilot,antigravity) + #[arg(long, value_delimiter = ',', value_name = "AGENTS")] + pub agents: Vec, + /// Skip Claude Code hook setup #[arg(long)] pub no_claude: bool, diff --git a/src/configure/mod.rs b/src/configure/mod.rs index 27b87ab..d9a3f48 100644 --- a/src/configure/mod.rs +++ b/src/configure/mod.rs @@ -6,11 +6,13 @@ mod cursor; mod opencode; mod windsurf; +use crate::cli::ConfigureArgs; use crate::config::Config; use crate::util::{dim, ok, warn}; use anyhow::{Context, Result}; use colored::Colorize; -use std::{fs, process::Command}; +use dialoguer::{theme::ColorfulTheme, MultiSelect}; +use std::{fs, io::IsTerminal, process::Command}; // Script sources embedded at compile time. const CLAUDE_CAPTURE_SCRIPT: &str = include_str!("../../scripts/capture-claude.py"); @@ -26,17 +28,7 @@ const RECORD_CONTEXT_SCRIPT: &str = include_str!("../../scripts/record-context.p const WRITE_NOTE_SCRIPT: &str = include_str!("../../scripts/write-note.py"); /// Configure global agent hooks — run once per machine, no git repo required. -pub fn run_configure( - config: &mut Config, - no_claude: bool, - no_cursor: bool, - no_codex: bool, - no_antigravity: bool, - no_windsurf: bool, - no_opencode: bool, - no_copilot: bool, - no_mcp: bool, -) -> Result<()> { +pub fn run_configure(config: &mut Config, args: &ConfigureArgs) -> Result<()> { println!("{}", "agentdiff configure".bold().cyan()); println!(); println!( @@ -45,6 +37,8 @@ pub fn run_configure( ); println!(); + let selection = resolve_agent_selection(args)?; + // Check Python 3 availability — capture scripts require it. check_python3()?; @@ -55,40 +49,40 @@ pub fn run_configure( step_install_scripts(config)?; // Step 3 — configure Claude Code ~/.claude/settings.json (hooks + MCP server) - if !no_claude { + if selection.claude { claude::step_configure_claude(config)?; } - if !no_mcp { + if selection.claude && !args.no_mcp { claude::step_configure_mcp_claude()?; } // Step 4 — configure Cursor ~/.cursor/hooks.json - if !no_cursor { + if selection.cursor { cursor::step_configure_cursor(config)?; } // Step 5 — configure Codex ~/.codex/config.toml + ~/.codex/hooks.json - if !no_codex { + if selection.codex { codex::step_configure_codex(config)?; } // Step 6 — configure Gemini / Antigravity hooks - if !no_antigravity { + if selection.antigravity { antigravity::step_configure_antigravity(config)?; } // Step 7 — configure Windsurf globally (~/.codeium/windsurf/hooks.json) - if !no_windsurf { + if selection.windsurf { windsurf::step_configure_windsurf(config)?; } // Step 8 — configure OpenCode globally (~/.config/opencode/plugins/) - if !no_opencode { + if selection.opencode { opencode::step_configure_opencode(config)?; } // Step 9 — install VS Code Copilot extension - if !no_copilot { + if selection.copilot { copilot::step_configure_copilot(config)?; } @@ -103,13 +97,13 @@ pub fn run_configure( println!(); print_configure_summary( config, - no_claude, - no_cursor, - no_codex, - no_antigravity, - no_windsurf, - no_opencode, - no_copilot, + !selection.claude, + !selection.cursor, + !selection.codex, + !selection.antigravity, + !selection.windsurf, + !selection.opencode, + !selection.copilot, ); println!(); println!("{}", "agentdiff configure complete.".bold().green()); @@ -120,6 +114,273 @@ pub fn run_configure( Ok(()) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AgentTarget { + Claude, + Cursor, + Codex, + Windsurf, + OpenCode, + Copilot, + Antigravity, +} + +impl AgentTarget { + fn all() -> &'static [AgentTarget] { + &[ + AgentTarget::Claude, + AgentTarget::Cursor, + AgentTarget::Codex, + AgentTarget::Windsurf, + AgentTarget::OpenCode, + AgentTarget::Copilot, + AgentTarget::Antigravity, + ] + } + + fn display(self) -> &'static str { + match self { + AgentTarget::Claude => "Claude Code", + AgentTarget::Cursor => "Cursor", + AgentTarget::Codex => "Codex CLI", + AgentTarget::Windsurf => "Windsurf", + AgentTarget::OpenCode => "OpenCode", + AgentTarget::Copilot => "VS Code Copilot", + AgentTarget::Antigravity => "Gemini/Antigravity", + } + } + + fn default_selected(self) -> bool { + !matches!(self, AgentTarget::Antigravity) + } + + fn aliases(self) -> &'static [&'static str] { + match self { + AgentTarget::Claude => &["claude", "claude-code", "claudecode"], + AgentTarget::Cursor => &["cursor"], + AgentTarget::Codex => &["codex", "codex-cli"], + AgentTarget::Windsurf => &["windsurf"], + AgentTarget::OpenCode => &["opencode", "open-code"], + AgentTarget::Copilot => &["copilot", "github-copilot", "vscode-copilot"], + AgentTarget::Antigravity => &["antigravity", "gemini", "gemini-cli"], + } + } + + fn from_name(name: &str) -> Option { + let normalized = name.trim().to_ascii_lowercase(); + AgentTarget::all() + .iter() + .copied() + .find(|agent| agent.aliases().contains(&normalized.as_str())) + } +} + +#[derive(Clone, Copy, Debug)] +struct AgentSelection { + claude: bool, + cursor: bool, + codex: bool, + windsurf: bool, + opencode: bool, + copilot: bool, + antigravity: bool, +} + +impl AgentSelection { + fn all() -> Self { + Self { + claude: true, + cursor: true, + codex: true, + windsurf: true, + opencode: true, + copilot: true, + antigravity: true, + } + } + + fn recommended() -> Self { + Self { + antigravity: false, + ..Self::all() + } + } + + fn empty() -> Self { + Self { + claude: false, + cursor: false, + codex: false, + windsurf: false, + opencode: false, + copilot: false, + antigravity: false, + } + } + + fn set(&mut self, agent: AgentTarget, enabled: bool) { + match agent { + AgentTarget::Claude => self.claude = enabled, + AgentTarget::Cursor => self.cursor = enabled, + AgentTarget::Codex => self.codex = enabled, + AgentTarget::Windsurf => self.windsurf = enabled, + AgentTarget::OpenCode => self.opencode = enabled, + AgentTarget::Copilot => self.copilot = enabled, + AgentTarget::Antigravity => self.antigravity = enabled, + } + } + + fn apply_skip_flags(&mut self, args: &ConfigureArgs) { + if args.no_claude { + self.claude = false; + } + if args.no_cursor { + self.cursor = false; + } + if args.no_codex { + self.codex = false; + } + if args.no_windsurf { + self.windsurf = false; + } + if args.no_opencode { + self.opencode = false; + } + if args.no_copilot { + self.copilot = false; + } + if args.no_antigravity { + self.antigravity = false; + } + } +} + +fn resolve_agent_selection(args: &ConfigureArgs) -> Result { + if args.all && !args.agents.is_empty() { + anyhow::bail!("use either --all or --agents, not both"); + } + + let mut selection = if args.all { + AgentSelection::all() + } else if !args.agents.is_empty() { + let mut explicit = AgentSelection::empty(); + for raw in &args.agents { + let agent = AgentTarget::from_name(raw) + .ok_or_else(|| anyhow::anyhow!("unknown agent '{raw}' in --agents"))?; + explicit.set(agent, true); + } + explicit + } else if std::io::stdin().is_terminal() { + prompt_agent_selection()? + } else { + println!( + "{} non-interactive configure: using recommended agents (use --all for Gemini/Antigravity too)", + dim() + ); + AgentSelection::recommended() + }; + + selection.apply_skip_flags(args); + Ok(selection) +} + +fn prompt_agent_selection() -> Result { + let detected = detect_agents(); + let items: Vec = AgentTarget::all() + .iter() + .map(|agent| { + let status = if detected.contains(agent) { + "detected" + } else { + "not detected" + }; + let default_note = if agent.default_selected() { + "default" + } else { + "optional" + }; + format!("{} ({status}, {default_note})", agent.display()) + }) + .collect(); + let defaults: Vec = AgentTarget::all() + .iter() + .map(|agent| agent.default_selected() && detected.contains(agent)) + .collect(); + + println!("{}", "Select agents to configure:".bold()); + println!( + "{}", + "Use Space to toggle, Enter to continue. Gemini/Antigravity is optional by default." + .dimmed() + ); + let selected = MultiSelect::with_theme(&ColorfulTheme::default()) + .items(&items) + .defaults(&defaults) + .interact() + .context("reading configure agent selection")?; + + let mut selection = AgentSelection::empty(); + for index in selected { + if let Some(agent) = AgentTarget::all().get(index).copied() { + selection.set(agent, true); + } + } + Ok(selection) +} + +fn detect_agents() -> Vec { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + let mut detected = Vec::new(); + + if home.join(".claude").exists() { + detected.push(AgentTarget::Claude); + } + if home.join(".cursor").exists() || windows_cursor_dir_exists() { + detected.push(AgentTarget::Cursor); + } + if home.join(".codex").exists() { + detected.push(AgentTarget::Codex); + } + if home.join(".codeium").join("windsurf").exists() { + detected.push(AgentTarget::Windsurf); + } + if dirs::config_dir() + .map(|dir| dir.join("opencode").exists()) + .unwrap_or(false) + { + detected.push(AgentTarget::OpenCode); + } + if [ + ".vscode/extensions", + ".vscode-server/extensions", + ".vscode-insiders/extensions", + ] + .iter() + .any(|path| home.join(path).exists()) + { + detected.push(AgentTarget::Copilot); + } + if home.join(".gemini").exists() { + detected.push(AgentTarget::Antigravity); + } + + detected +} + +fn windows_cursor_dir_exists() -> bool { + let users = std::path::Path::new("/mnt/c/Users"); + std::fs::read_dir(users) + .map(|entries| { + entries.filter_map(Result::ok).any(|entry| { + let path = entry.path().join(".cursor"); + path.exists() + }) + }) + .unwrap_or(false) +} + fn print_configure_summary( _config: &Config, no_claude: bool, @@ -173,12 +434,16 @@ fn print_configure_summary( ); continue; } - let presence_path = presence_parts.iter().fold(home.clone(), |p, part| p.join(part)); + let presence_path = presence_parts + .iter() + .fold(home.clone(), |p, part| p.join(part)); if !presence_path.exists() { println!(" {} {} not installed on this machine", dim(), name); continue; } - let config_path = config_parts.iter().fold(home.clone(), |p, part| p.join(part)); + let config_path = config_parts + .iter() + .fold(home.clone(), |p, part| p.join(part)); if !config_path.exists() { println!( " {} {} hook missing — re-run 'agentdiff configure'", @@ -205,7 +470,10 @@ fn print_configure_summary( if !no_antigravity { let gemini_dir = home.join(".gemini"); if !gemini_dir.exists() { - println!(" {} gemini/antigravity not installed on this machine", dim()); + println!( + " {} gemini/antigravity not installed on this machine", + dim() + ); } else { // Gemini CLI: settings.json hooks let cli_ok = std::fs::read_to_string(gemini_dir.join("settings.json")) @@ -240,9 +508,18 @@ fn print_configure_summary( .unwrap_or(false); match (toml_ok, hooks_ok) { (true, true) => println!(" {} codex registered (config.toml + hooks.json)", ok()), - (true, false) => println!(" {} codex config.toml ok, hooks.json missing — re-run 'agentdiff configure'", warn()), - (false, true) => println!(" {} codex hooks.json ok, config.toml missing — re-run 'agentdiff configure'", warn()), - (false, false) => println!(" {} codex hook missing — re-run 'agentdiff configure'", warn()), + (true, false) => println!( + " {} codex config.toml ok, hooks.json missing — re-run 'agentdiff configure'", + warn() + ), + (false, true) => println!( + " {} codex hooks.json ok, config.toml missing — re-run 'agentdiff configure'", + warn() + ), + (false, false) => println!( + " {} codex hook missing — re-run 'agentdiff configure'", + warn() + ), } } } else { @@ -251,8 +528,8 @@ fn print_configure_summary( // OpenCode: platform-aware path (macOS: ~/Library/Application Support, Linux: ~/.config) if !no_opencode { - let opencode_path = dirs::config_dir() - .map(|d| d.join("opencode").join("plugins").join("agentdiff.ts")); + let opencode_path = + dirs::config_dir().map(|d| d.join("opencode").join("plugins").join("agentdiff.ts")); match opencode_path { Some(ref p) if p.exists() => { let registered = std::fs::read_to_string(p) @@ -267,10 +544,7 @@ fn print_configure_summary( ); } } - _ => println!( - " {} opencode not installed on this machine", - dim() - ), + _ => println!(" {} opencode not installed on this machine", dim()), } } else { println!(" {} opencode skipped (--no-opencode)", dim()); @@ -287,7 +561,11 @@ fn print_configure_summary( .iter() .filter_map(|d| { let p = home.join(d); - if p.exists() { Some(p) } else { None } + if p.exists() { + Some(p) + } else { + None + } }) .any(|d| { std::fs::read_dir(&d) @@ -305,10 +583,7 @@ fn print_configure_summary( }); let any_vscode = vscode_dirs.iter().any(|d| home.join(d).exists()); if !any_vscode { - println!( - " {} copilot not installed on this machine", - dim() - ); + println!(" {} copilot not installed on this machine", dim()); } else if found { println!(" {} copilot registered", ok()); } else { @@ -385,3 +660,63 @@ fn step_install_scripts(config: &Config) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn args() -> ConfigureArgs { + ConfigureArgs { + all: false, + agents: Vec::new(), + no_claude: false, + no_cursor: false, + no_codex: false, + no_antigravity: false, + no_windsurf: false, + no_opencode: false, + no_copilot: false, + no_mcp: false, + } + } + + #[test] + fn parses_agent_aliases() { + assert_eq!(AgentTarget::from_name("claude"), Some(AgentTarget::Claude)); + assert_eq!( + AgentTarget::from_name("codex-cli"), + Some(AgentTarget::Codex) + ); + assert_eq!( + AgentTarget::from_name("github-copilot"), + Some(AgentTarget::Copilot) + ); + assert_eq!( + AgentTarget::from_name("gemini"), + Some(AgentTarget::Antigravity) + ); + assert_eq!(AgentTarget::from_name("unknown"), None); + } + + #[test] + fn skip_flags_override_explicit_agents() { + let mut args = args(); + args.agents = vec!["cursor".to_string(), "codex".to_string()]; + args.no_cursor = true; + + let selection = resolve_agent_selection(&args).unwrap(); + + assert!(!selection.cursor); + assert!(selection.codex); + assert!(!selection.claude); + } + + #[test] + fn all_and_agents_are_mutually_exclusive() { + let mut args = args(); + args.all = true; + args.agents = vec!["cursor".to_string()]; + + assert!(resolve_agent_selection(&args).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index bcd4734..87489db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,17 +56,7 @@ fn main() -> anyhow::Result<()> { match cli.command { Command::Configure(args) => { let mut cfg = config; - configure::run_configure( - &mut cfg, - args.no_claude, - args.no_cursor, - args.no_codex, - args.no_antigravity, - args.no_windsurf, - args.no_opencode, - args.no_copilot, - args.no_mcp, - ) + configure::run_configure(&mut cfg, &args) } Command::Init(args) => { let mut cfg = config; From be6fc947653c24f3466c0cc7055c115cd473ccb6 Mon Sep 17 00:00:00 2001 From: Prakhar Khatri Date: Tue, 5 May 2026 13:20:06 +0000 Subject: [PATCH 2/2] fix: tighten configure agent detection Avoid marking Copilot as detected unless a Copilot extension is installed, and document that Claude MCP setup follows the Claude Code selection. --- README.md | 4 +-- src/configure/mod.rs | 72 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1618cc9..ac7167a 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ agentdiff status --remote --no-fetch # fast: show refs + SHAs only, skip trace | **Codex CLI** | `notify` hook (`~/.codex/config.toml`) | Task-level file changes | | **Gemini / Antigravity** | `BeforeTool`/`AfterTool` hooks (`~/.gemini/settings.json`) | `write_file`, `replace` | -Agent hooks are installed **globally once** via `agentdiff configure`. In an interactive terminal, AgentDiff detects available agent configs and lets you choose integrations with Space + Enter. By default it selects the main coding agents and leaves Gemini/Antigravity optional; use `agentdiff configure --all` to install every supported integration directly. Capture only fires in repos where `agentdiff init` has been run — the `.git/agentdiff/` directory must exist for any data to be written. +Agent hooks are installed **globally once** via `agentdiff configure`. In an interactive terminal, AgentDiff detects available agent configs and lets you choose integrations with Space + Enter. By default it selects the main coding agents and leaves Gemini/Antigravity optional; use `agentdiff configure --all` to install every supported integration directly. Claude MCP setup is part of the Claude Code integration, so it only runs when Claude is selected; use `--no-mcp` to skip MCP while still configuring Claude. Capture only fires in repos where `agentdiff init` has been run — the `.git/agentdiff/` directory must exist for any data to be written. --- @@ -382,7 +382,7 @@ agentdiff list --uncommitted **1. `agentdiff configure` — one-time global setup** -Installs Python capture scripts to `~/.agentdiff/scripts/` and registers hooks with selected agents. In an interactive terminal, AgentDiff shows a Space/Enter multi-select picker; in scripts, use `--all` or `--agents cursor,codex` to avoid prompting. +Installs Python capture scripts to `~/.agentdiff/scripts/` and registers hooks with selected agents. In an interactive terminal, AgentDiff shows a Space/Enter multi-select picker; in scripts, use `--all` or `--agents cursor,codex` to avoid prompting. The Claude MCP server is registered only when Claude Code is selected. - Claude Code → `~/.claude/settings.json` (PostToolUse) - Cursor → `~/.cursor/hooks.json` (afterFileEdit, afterTabFileEdit) diff --git a/src/configure/mod.rs b/src/configure/mod.rs index d9a3f48..d903173 100644 --- a/src/configure/mod.rs +++ b/src/configure/mod.rs @@ -12,7 +12,7 @@ use crate::util::{dim, ok, warn}; use anyhow::{Context, Result}; use colored::Colorize; use dialoguer::{theme::ColorfulTheme, MultiSelect}; -use std::{fs, io::IsTerminal, process::Command}; +use std::{fs, io::IsTerminal, path::Path, process::Command}; // Script sources embedded at compile time. const CLAUDE_CAPTURE_SCRIPT: &str = include_str!("../../scripts/capture-claude.py"); @@ -352,14 +352,7 @@ fn detect_agents() -> Vec { { detected.push(AgentTarget::OpenCode); } - if [ - ".vscode/extensions", - ".vscode-server/extensions", - ".vscode-insiders/extensions", - ] - .iter() - .any(|path| home.join(path).exists()) - { + if copilot_extension_exists(&home) || windows_copilot_extension_exists() { detected.push(AgentTarget::Copilot); } if home.join(".gemini").exists() { @@ -369,9 +362,51 @@ fn detect_agents() -> Vec { detected } +fn copilot_extension_exists(home: &Path) -> bool { + [ + ".vscode/extensions", + ".vscode-server/extensions", + ".vscode-insiders/extensions", + ] + .iter() + .any(|path| extension_dir_has_copilot(&home.join(path))) +} + +fn extension_dir_has_copilot(path: &Path) -> bool { + fs::read_dir(path) + .map(|entries| { + entries.filter_map(Result::ok).any(|entry| { + entry + .file_name() + .to_string_lossy() + .to_ascii_lowercase() + .starts_with("github.copilot") + }) + }) + .unwrap_or(false) +} + +fn windows_copilot_extension_exists() -> bool { + let users = Path::new("/mnt/c/Users"); + fs::read_dir(users) + .map(|entries| { + entries.filter_map(Result::ok).any(|entry| { + let home = entry.path(); + [ + ".vscode/extensions", + ".vscode-server/extensions", + ".vscode-insiders/extensions", + ] + .iter() + .any(|path| extension_dir_has_copilot(&home.join(path))) + }) + }) + .unwrap_or(false) +} + fn windows_cursor_dir_exists() -> bool { - let users = std::path::Path::new("/mnt/c/Users"); - std::fs::read_dir(users) + let users = Path::new("/mnt/c/Users"); + fs::read_dir(users) .map(|entries| { entries.filter_map(Result::ok).any(|entry| { let path = entry.path().join(".cursor"); @@ -719,4 +754,19 @@ mod tests { assert!(resolve_agent_selection(&args).is_err()); } + + #[test] + fn copilot_detection_requires_copilot_extension() { + let root = + std::env::temp_dir().join(format!("agentdiff-copilot-detect-{}", std::process::id())); + let extensions = root.join(".vscode").join("extensions"); + fs::create_dir_all(extensions.join("rust-lang.rust-analyzer-1.0.0")).unwrap(); + + assert!(!copilot_extension_exists(&root)); + + fs::create_dir_all(extensions.join("github.copilot-1.2.3")).unwrap(); + assert!(copilot_extension_exists(&root)); + + let _ = fs::remove_dir_all(root); + } }