diff --git a/README.md b/README.md index 148b059024..1f3343bcae 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,9 @@ No. Git AI does not use Git hooks and it does not wrap Git, so you won't see any #### Do I have to set up agent hooks? Nope — Git AI manages the agent hooks and checks/updates them daily. If you want to trigger this yourself (ie just installed a new agent) run `git ai install-hooks`. +#### Can I install the agent hooks without the IDE extensions? +Yes. Run `git ai install-hooks --no-editor-extensions` to skip the IDE extension/plugin installs (VS Code, Cursor, Windsurf, JetBrains) while still installing the agent hooks. To make this permanent — including for the daily hook refresh and updates — set `git ai config set disable_editor_extensions true`. + #### Who uses this? Hundreds of engineering teams (including many in the Fortune 100) use Git AI to understand their AI usage and make agents more effective on their codebase. diff --git a/src/commands/config.rs b/src/commands/config.rs index 6f1380510b..60453a5acd 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -105,6 +105,9 @@ fn print_config_help() { println!(" telemetry_enterprise_dsn Enterprise telemetry DSN"); println!(" disable_version_checks Disable version checks (bool)"); println!(" disable_auto_updates Disable auto updates (bool)"); + println!( + " disable_editor_extensions Skip IDE extension/plugin installs during install-hooks (bool)" + ); println!(" update_channel Update channel (latest/next)"); println!(" feature_flags Feature flags (object)"); println!(" api_base_url API base URL (default: https://usegitai.com)"); @@ -303,6 +306,10 @@ fn show_all_config() -> Result<(), String> { "disable_auto_updates".to_string(), Value::Bool(runtime_config.auto_updates_disabled()), ); + effective_config.insert( + "disable_editor_extensions".to_string(), + Value::Bool(runtime_config.editor_extensions_disabled()), + ); // Optional strings if let Some(ref dsn) = file_config.telemetry_enterprise_dsn { @@ -459,6 +466,7 @@ fn get_config_value(key: &str) -> Result<(), String> { } "disable_version_checks" => Value::Bool(runtime_config.version_checks_disabled()), "disable_auto_updates" => Value::Bool(runtime_config.auto_updates_disabled()), + "disable_editor_extensions" => Value::Bool(runtime_config.editor_extensions_disabled()), "update_channel" => Value::String(runtime_config.update_channel().as_str().to_string()), "feature_flags" => { // Show effective flags with defaults applied @@ -682,6 +690,12 @@ fn set_config_value(key: &str, value: &str, add_mode: bool) -> Result<(), String crate::config::save_file_config(&file_config)?; println!("[disable_auto_updates]: {}", bool_value); } + "disable_editor_extensions" => { + let bool_value = parse_bool(value)?; + file_config.disable_editor_extensions = Some(bool_value); + crate::config::save_file_config(&file_config)?; + println!("[disable_editor_extensions]: {}", bool_value); + } "update_channel" => { // Validate update channel if value != "latest" && value != "next" { @@ -1051,6 +1065,13 @@ fn unset_config_value(key: &str) -> Result<(), String> { println!("- [disable_auto_updates]: {}", v); } } + "disable_editor_extensions" => { + let old_value = file_config.disable_editor_extensions.take(); + crate::config::save_file_config(&file_config)?; + if let Some(v) = old_value { + println!("- [disable_editor_extensions]: {}", v); + } + } "update_channel" => { let old_value = file_config.update_channel.take(); crate::config::save_file_config(&file_config)?; diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 2c14129a59..744a54dbf3 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -367,6 +367,10 @@ fn print_help() { eprintln!(" --skills Also install agent skill files"); eprintln!(" --visual-studio-extension"); eprintln!(" Also install the Visual Studio extension on Windows"); + eprintln!(" --no-editor-extensions"); + eprintln!( + " Skip IDE extension/plugin installs (agent hooks still installed)" + ); eprintln!(" uninstall-hooks Remove git-ai hooks from all detected tools"); eprintln!(" ci Continuous integration utilities"); eprintln!(" github GitHub CI helpers"); diff --git a/src/commands/install_hooks.rs b/src/commands/install_hooks.rs index 665f545286..34df4af67d 100644 --- a/src/commands/install_hooks.rs +++ b/src/commands/install_hooks.rs @@ -22,6 +22,7 @@ struct InstallOptions { verbose: bool, install_skills: bool, include_visual_studio_extension: bool, + skip_editor_extensions: bool, } /// Installation status for a tool @@ -303,7 +304,14 @@ fn ensure_daemon(dry_run: bool) { /// Main entry point for install-hooks command pub fn run(args: &[String]) -> Result, GitAiError> { - let options = parse_install_options(args); + let mut options = parse_install_options(args); + + // The daemon and the install script re-run install-hooks without flags + // (e.g. the daily hooks refresh and auto-updates), so the opt-out must + // also be honored when persisted in the config file. + if !options.skip_editor_extensions { + options.skip_editor_extensions = config::Config::fresh().editor_extensions_disabled(); + } // Daemon trace2 config must be in place before any install work starts. // Non-fatal: the global git config may be read-only (e.g. Nix store symlink). @@ -321,7 +329,10 @@ pub fn run(args: &[String]) -> Result, GitAiError> { // Get absolute path to the current binary let binary_path = get_current_binary_path()?; persist_install_config(&binary_path, options.dry_run)?; - let params = HookInstallerParams { binary_path }; + let params = HookInstallerParams { + binary_path, + skip_editor_extensions: options.skip_editor_extensions, + }; // Run async operations and convert result. let statuses = crate::tokio_runtime::block_on(async_run_install(¶ms, &options))?; @@ -344,6 +355,7 @@ fn parse_install_options(args: &[String]) -> InstallOptions { "--verbose" | "-v" => options.verbose = true, "--skills" => options.install_skills = true, "--visual-studio-extension" => options.include_visual_studio_extension = true, + "--no-editor-extensions" => options.skip_editor_extensions = true, _ => {} } } @@ -450,7 +462,10 @@ pub fn run_uninstall(args: &[String]) -> Result, GitAiEr // Get absolute path to the current binary let binary_path = get_current_binary_path()?; - let params = HookInstallerParams { binary_path }; + let params = HookInstallerParams { + binary_path, + skip_editor_extensions: false, + }; // Run async operations and convert result. let statuses = crate::tokio_runtime::block_on(async_run_uninstall(¶ms, dry_run, verbose))?; @@ -1036,6 +1051,19 @@ mod tests { )); } + #[test] + fn parse_install_options_defaults_editor_extensions_to_enabled() { + let options = parse_install_options(&[]); + assert!(!options.skip_editor_extensions); + } + + #[test] + fn parse_install_options_enables_no_editor_extensions_flag() { + let args = vec!["--no-editor-extensions".to_string()]; + let options = parse_install_options(&args); + assert!(options.skip_editor_extensions); + } + #[test] #[serial] fn persist_install_config_updates_api_base_and_backfills_git_path() { diff --git a/src/config.rs b/src/config.rs index 70ffc68c78..d9795c06e9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -166,6 +166,7 @@ pub struct Config { telemetry_enterprise_dsn: Option, disable_version_checks: bool, disable_auto_updates: bool, + disable_editor_extensions: bool, update_channel: UpdateChannel, feature_flags: FeatureFlags, api_base_url: String, @@ -235,6 +236,8 @@ pub struct FileConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub disable_auto_updates: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_editor_extensions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub update_channel: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub feature_flags: Option, @@ -513,6 +516,12 @@ impl Config { self.disable_auto_updates } + /// Returns true if IDE extension/plugin installation should be skipped + /// during `install-hooks` (agent hooks are still installed). + pub fn editor_extensions_disabled(&self) -> bool { + self.disable_editor_extensions + } + pub fn update_channel(&self) -> UpdateChannel { self.update_channel } @@ -1001,6 +1010,11 @@ fn build_config() -> Config { .as_ref() .and_then(|c| c.disable_auto_updates) .unwrap_or(auto_update_flags_default_disabled); + // Editor extensions install by default; opting out never blocks agent hooks. + let disable_editor_extensions = file_cfg + .as_ref() + .and_then(|c| c.disable_editor_extensions) + .unwrap_or(false); let update_channel = file_cfg .as_ref() .and_then(|c| c.update_channel.as_deref()) @@ -1163,6 +1177,7 @@ fn build_config() -> Config { telemetry_enterprise_dsn, disable_version_checks, disable_auto_updates, + disable_editor_extensions, update_channel, feature_flags, api_base_url, @@ -1193,6 +1208,7 @@ fn build_config() -> Config { telemetry_enterprise_dsn, disable_version_checks, disable_auto_updates, + disable_editor_extensions, update_channel, feature_flags, api_base_url, @@ -1680,6 +1696,7 @@ mod tests { telemetry_enterprise_dsn: None, disable_version_checks: false, disable_auto_updates: false, + disable_editor_extensions: false, update_channel: UpdateChannel::Latest, feature_flags: FeatureFlags::default(), api_base_url: DEFAULT_API_BASE_URL.to_string(), @@ -1922,6 +1939,7 @@ mod tests { telemetry_enterprise_dsn: None, disable_version_checks: false, disable_auto_updates: false, + disable_editor_extensions: false, update_channel: UpdateChannel::Latest, feature_flags: FeatureFlags::default(), api_base_url: DEFAULT_API_BASE_URL.to_string(), @@ -2067,6 +2085,7 @@ mod tests { telemetry_enterprise_dsn: None, disable_version_checks: false, disable_auto_updates: false, + disable_editor_extensions: false, update_channel: UpdateChannel::Latest, feature_flags: FeatureFlags::default(), api_base_url: DEFAULT_API_BASE_URL.to_string(), diff --git a/src/mdm/agents/claude_code.rs b/src/mdm/agents/claude_code.rs index 8332b11c94..237b2812b4 100644 --- a/src/mdm/agents/claude_code.rs +++ b/src/mdm/agents/claude_code.rs @@ -394,6 +394,7 @@ mod tests { fn params() -> HookInstallerParams { HookInstallerParams { binary_path: binary_path(), + skip_editor_extensions: false, } } diff --git a/src/mdm/agents/codex.rs b/src/mdm/agents/codex.rs index b5e2921ec3..84d61da0ad 100644 --- a/src/mdm/agents/codex.rs +++ b/src/mdm/agents/codex.rs @@ -1408,6 +1408,7 @@ codex_hooks = true let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let diff = installer @@ -1458,6 +1459,7 @@ codex_hooks = true let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let diff = installer @@ -1517,6 +1519,7 @@ notify = ["/usr/local/bin/git-ai", "checkpoint", "codex", "--hook-input"] let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer @@ -1563,6 +1566,7 @@ notify = ["/Users/svarlamov/.git-ai/bin/git-ai", "checkpoint", "codex", "--via-c let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer @@ -1597,6 +1601,7 @@ notify = ["notify-send", "Codex finished"] let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer @@ -1653,6 +1658,7 @@ notify = ["notify-send", "Codex finished"] let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let diff = installer @@ -1680,6 +1686,7 @@ notify = ["notify-send", "Codex finished"] let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let first = installer @@ -1753,6 +1760,7 @@ codex_hooks = true let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer @@ -1818,6 +1826,7 @@ codex_hooks = true let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer @@ -1888,6 +1897,7 @@ codex_hooks = true let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer @@ -1981,6 +1991,7 @@ command = "/usr/local/bin/git-ai checkpoint codex --hook-input stdin" let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer @@ -2067,6 +2078,7 @@ command = "/usr/local/bin/git-ai checkpoint codex --hook-input stdin" let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let diff = installer @@ -2126,6 +2138,7 @@ codex_hooks = true let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let diff = installer @@ -2157,6 +2170,7 @@ codex_hooks = true let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let result = installer.install_hooks(¶ms, false).unwrap(); @@ -2215,6 +2229,7 @@ codex_hooks = true let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let check = installer @@ -2310,6 +2325,7 @@ codex_hooks = true let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer @@ -2358,6 +2374,7 @@ codex_hooks = true let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer @@ -2411,6 +2428,7 @@ trusted_hash = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef123 let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer @@ -2452,6 +2470,7 @@ trusted_hash = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef123 let installer = CodexInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer diff --git a/src/mdm/agents/cursor.rs b/src/mdm/agents/cursor.rs index cc1dfd0e15..85eaecf95e 100644 --- a/src/mdm/agents/cursor.rs +++ b/src/mdm/agents/cursor.rs @@ -321,11 +321,21 @@ impl HookInstaller for CursorInstaller { fn install_extras( &self, - _params: &HookInstallerParams, + params: &HookInstallerParams, dry_run: bool, ) -> Result, GitAiError> { let mut results = Vec::new(); + if params.skip_editor_extensions { + results.push(InstallResult { + changed: false, + diff: None, + message: "Cursor: Extension install skipped (editor extensions disabled)" + .to_string(), + }); + return Ok(results); + } + // Install VS Code extension if let Some(cli) = resolve_editor_cli("cursor") { match is_vsc_editor_extension_installed(&cli, "git-ai.git-ai-vscode") { @@ -414,6 +424,7 @@ mod tests { // a real install is never attempted, so this never spawns an editor. let params = HookInstallerParams { binary_path: create_test_binary_path(), + skip_editor_extensions: false, }; let results = CursorInstaller.install_extras(¶ms, true).unwrap(); assert!( @@ -428,6 +439,18 @@ mod tests { ); } + #[test] + fn test_install_extras_skips_extension_when_disabled() { + let params = HookInstallerParams { + binary_path: create_test_binary_path(), + skip_editor_extensions: true, + }; + let results = CursorInstaller.install_extras(¶ms, true).unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].changed); + assert!(results[0].message.contains("skipped")); + } + #[test] fn test_install_hooks_creates_file_from_scratch() { let (_temp_dir, hooks_path) = setup_test_env(); diff --git a/src/mdm/agents/droid.rs b/src/mdm/agents/droid.rs index 2f974af7b3..59204937f4 100644 --- a/src/mdm/agents/droid.rs +++ b/src/mdm/agents/droid.rs @@ -487,6 +487,7 @@ mod tests { fn params() -> HookInstallerParams { HookInstallerParams { binary_path: binary_path(), + skip_editor_extensions: false, } } diff --git a/src/mdm/agents/firebender.rs b/src/mdm/agents/firebender.rs index 3016d328c0..b7a9e5b7ef 100644 --- a/src/mdm/agents/firebender.rs +++ b/src/mdm/agents/firebender.rs @@ -330,6 +330,7 @@ mod tests { .install_hooks( &HookInstallerParams { binary_path: create_test_binary_path(), + skip_editor_extensions: false, }, false, ) @@ -382,6 +383,7 @@ mod tests { .install_hooks( &HookInstallerParams { binary_path: create_test_binary_path(), + skip_editor_extensions: false, }, false, ) @@ -444,6 +446,7 @@ mod tests { .uninstall_hooks( &HookInstallerParams { binary_path: create_test_binary_path(), + skip_editor_extensions: false, }, false, ) @@ -496,6 +499,7 @@ mod tests { let result = installer .check_hooks(&HookInstallerParams { binary_path: create_test_binary_path(), + skip_editor_extensions: false, }) .unwrap(); @@ -538,6 +542,7 @@ mod tests { .install_hooks( &HookInstallerParams { binary_path: create_test_binary_path(), + skip_editor_extensions: false, }, false, ) diff --git a/src/mdm/agents/gemini.rs b/src/mdm/agents/gemini.rs index f28cbccce4..b3aa5d9803 100644 --- a/src/mdm/agents/gemini.rs +++ b/src/mdm/agents/gemini.rs @@ -427,6 +427,7 @@ mod tests { fn params() -> HookInstallerParams { HookInstallerParams { binary_path: binary_path(), + skip_editor_extensions: false, } } diff --git a/src/mdm/agents/github_copilot.rs b/src/mdm/agents/github_copilot.rs index 06b4d97308..cbaa9371da 100644 --- a/src/mdm/agents/github_copilot.rs +++ b/src/mdm/agents/github_copilot.rs @@ -459,6 +459,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let diff = installer.install_hooks(¶ms, false).unwrap(); @@ -497,6 +498,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let first = installer.install_hooks(¶ms, false).unwrap(); @@ -519,6 +521,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer.install_hooks(¶ms, false).unwrap(); @@ -536,6 +539,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let hooks_dir = home.join(".copilot").join("hooks"); @@ -561,6 +565,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let diff = installer.install_hooks(¶ms, false).unwrap(); @@ -597,6 +602,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let diff = installer.install_hooks(¶ms, false).unwrap(); @@ -636,6 +642,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let result = installer.check_hooks(¶ms).unwrap(); @@ -670,6 +677,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let result = installer.check_hooks(¶ms).unwrap(); @@ -714,6 +722,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let diff = installer.uninstall_hooks(¶ms, false).unwrap(); assert!(diff.is_some()); @@ -751,6 +760,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; installer.uninstall_hooks(¶ms, false).unwrap(); @@ -783,6 +793,7 @@ mod tests { let installer = GitHubCopilotInstaller; let params = HookInstallerParams { binary_path: test_binary_path(), + skip_editor_extensions: false, }; let result = installer.check_hooks(¶ms).unwrap(); diff --git a/src/mdm/agents/jetbrains.rs b/src/mdm/agents/jetbrains.rs index 6683d9bc95..250ebf5444 100644 --- a/src/mdm/agents/jetbrains.rs +++ b/src/mdm/agents/jetbrains.rs @@ -201,9 +201,18 @@ impl HookInstaller for JetBrainsInstaller { fn install_extras( &self, - _params: &HookInstallerParams, + params: &HookInstallerParams, dry_run: bool, ) -> Result, GitAiError> { + if params.skip_editor_extensions { + return Ok(vec![InstallResult { + changed: false, + diff: None, + message: "JetBrains: Plugin install skipped (editor extensions disabled)" + .to_string(), + }]); + } + let installations = Self::get_installations(); if installations.is_empty() { @@ -261,3 +270,20 @@ impl HookInstaller for JetBrainsInstaller { Ok(results) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_install_extras_skips_plugin_when_disabled() { + let params = HookInstallerParams { + binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: true, + }; + let results = JetBrainsInstaller.install_extras(¶ms, true).unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].changed); + assert!(results[0].message.contains("skipped")); + } +} diff --git a/src/mdm/agents/opencode.rs b/src/mdm/agents/opencode.rs index 760db44a46..f8f350c959 100644 --- a/src/mdm/agents/opencode.rs +++ b/src/mdm/agents/opencode.rs @@ -414,6 +414,7 @@ mod tests { let installer = OpenCodeInstaller; let params = HookInstallerParams { binary_path: create_test_binary_path(), + skip_editor_extensions: false, }; let result = installer.check_hooks(¶ms).unwrap(); assert!( @@ -432,6 +433,7 @@ mod tests { let installer = OpenCodeInstaller; let params = HookInstallerParams { binary_path: create_test_binary_path(), + skip_editor_extensions: false, }; let result = installer.check_hooks(¶ms).unwrap(); assert!( @@ -449,6 +451,7 @@ mod tests { let installer = OpenCodeInstaller; let params = HookInstallerParams { binary_path: create_test_binary_path(), + skip_editor_extensions: false, }; let result = installer.install_hooks(¶ms, false).unwrap(); assert!(result.is_some(), "install_hooks should produce a diff"); diff --git a/src/mdm/agents/visual_studio.rs b/src/mdm/agents/visual_studio.rs index 36340ac748..108a9abdf8 100644 --- a/src/mdm/agents/visual_studio.rs +++ b/src/mdm/agents/visual_studio.rs @@ -82,9 +82,18 @@ impl HookInstaller for VisualStudioInstaller { fn install_extras( &self, - _params: &HookInstallerParams, + params: &HookInstallerParams, dry_run: bool, ) -> Result, GitAiError> { + if params.skip_editor_extensions { + return Ok(vec![InstallResult { + changed: false, + diff: None, + message: "Visual Studio: Extension install skipped (editor extensions disabled)" + .to_string(), + }]); + } + let installations = find_visual_studio_installations(); if installations.is_empty() { @@ -386,6 +395,7 @@ mod tests { let installer = VisualStudioInstaller; let params = HookInstallerParams { binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: false, }; let result = installer.install_hooks(¶ms, false).unwrap(); assert_eq!(result, None); @@ -396,11 +406,25 @@ mod tests { let installer = VisualStudioInstaller; let params = HookInstallerParams { binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: false, }; let result = installer.uninstall_hooks(¶ms, false).unwrap(); assert_eq!(result, None); } + #[test] + fn test_install_extras_skips_extension_when_disabled() { + let installer = VisualStudioInstaller; + let params = HookInstallerParams { + binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: true, + }; + let results = installer.install_extras(¶ms, true).unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].changed); + assert!(results[0].message.contains("skipped")); + } + #[test] fn test_manifest_declares_vsix_identity() { let manifest = r#" diff --git a/src/mdm/agents/vscode.rs b/src/mdm/agents/vscode.rs index 0925419b8f..1ada0c8498 100644 --- a/src/mdm/agents/vscode.rs +++ b/src/mdm/agents/vscode.rs @@ -109,24 +109,30 @@ impl HookInstaller for VSCodeInstaller { fn install_extras( &self, - _params: &HookInstallerParams, + params: &HookInstallerParams, dry_run: bool, ) -> Result, GitAiError> { let mut results = Vec::new(); - // Skip extension installation in GitHub Codespaces - // Extensions must be configured via devcontainer.json in Codespaces - if is_github_codespaces() { + if params.skip_editor_extensions { + // Editor extension installs are disabled; still configure the + // chat hook settings below since those drive agent attribution. + results.push(InstallResult { + changed: false, + diff: None, + message: "VS Code: Extension install skipped (editor extensions disabled)" + .to_string(), + }); + } else if is_github_codespaces() { + // Skip extension installation in GitHub Codespaces + // Extensions must be configured via devcontainer.json in Codespaces results.push(InstallResult { changed: false, diff: None, message: "VS Code: Unable to install extension in GitHub Codespaces. Add to your devcontainer.json: \"customizations\": { \"vscode\": { \"extensions\": [\"git-ai.git-ai-vscode\"] } }".to_string(), }); return Ok(results); - } - - // Install VS Code extension - if let Some(cli) = resolve_editor_cli("code") { + } else if let Some(cli) = resolve_editor_cli("code") { match is_vsc_editor_extension_installed(&cli, "git-ai.git-ai-vscode") { Ok(true) => { results.push(InstallResult { @@ -271,6 +277,7 @@ mod tests { let installer = VSCodeInstaller; let params = HookInstallerParams { binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: false, }; let results = installer.uninstall_extras(¶ms, false).unwrap(); @@ -287,6 +294,7 @@ mod tests { // no real install is attempted. let params = HookInstallerParams { binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: false, }; let results = VSCodeInstaller.install_extras(¶ms, true).unwrap(); assert!( @@ -301,11 +309,38 @@ mod tests { ); } + #[test] + fn test_install_extras_skips_extension_when_disabled() { + // dry_run=true means chat hook settings are never written, and + // skip_editor_extensions=true means no extension install/check runs. + let params = HookInstallerParams { + binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: true, + }; + let results = VSCodeInstaller.install_extras(¶ms, true).unwrap(); + assert!( + results + .iter() + .any(|r| r.message.contains("Extension install skipped")) + ); + assert!( + results + .iter() + .all(|r| !r.message.contains("Pending extension install")), + "extension install should be skipped: {:?}", + results + .iter() + .map(|r| r.message.clone()) + .collect::>() + ); + } + #[test] fn test_vscode_install_hooks_returns_none() { let installer = VSCodeInstaller; let params = HookInstallerParams { binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: false, }; // install_hooks should return None because VSCode uses extension, not config hooks @@ -318,6 +353,7 @@ mod tests { let installer = VSCodeInstaller; let params = HookInstallerParams { binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: false, }; let result = installer.uninstall_hooks(¶ms, false).unwrap(); diff --git a/src/mdm/agents/windsurf.rs b/src/mdm/agents/windsurf.rs index 521ff64164..1af3d9053f 100644 --- a/src/mdm/agents/windsurf.rs +++ b/src/mdm/agents/windsurf.rs @@ -312,11 +312,21 @@ impl HookInstaller for WindsurfInstaller { fn install_extras( &self, - _params: &HookInstallerParams, + params: &HookInstallerParams, dry_run: bool, ) -> Result, GitAiError> { let mut results = Vec::new(); + if params.skip_editor_extensions { + results.push(InstallResult { + changed: false, + diff: None, + message: "Windsurf: Extension install skipped (editor extensions disabled)" + .to_string(), + }); + return Ok(results); + } + // Skip extension installation in GitHub Codespaces // Extensions must be configured via devcontainer.json in Codespaces if is_github_codespaces() { @@ -416,6 +426,7 @@ mod tests { // a real install is never attempted, so this never spawns an editor. let params = HookInstallerParams { binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: false, }; let results = WindsurfInstaller.install_extras(¶ms, true).unwrap(); assert!( @@ -429,4 +440,16 @@ mod tests { .collect::>() ); } + + #[test] + fn test_install_extras_skips_extension_when_disabled() { + let params = HookInstallerParams { + binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + skip_editor_extensions: true, + }; + let results = WindsurfInstaller.install_extras(¶ms, true).unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].changed); + assert!(results[0].message.contains("skipped")); + } } diff --git a/src/mdm/hook_installer.rs b/src/mdm/hook_installer.rs index 8f9600f868..9b4ab91c1e 100644 --- a/src/mdm/hook_installer.rs +++ b/src/mdm/hook_installer.rs @@ -6,6 +6,11 @@ use std::path::PathBuf; pub struct HookInstallerParams { /// Path to the git-ai binary pub binary_path: PathBuf, + /// Skip installing IDE extensions/plugins (VS Code, Cursor, Windsurf, + /// JetBrains, Visual Studio). Agent hooks are still installed. Set via + /// `install-hooks --no-editor-extensions` or the + /// `disable_editor_extensions` config key. + pub skip_editor_extensions: bool, } /// Result of checking hook status diff --git a/tests/integration/config_cli_coverage.rs b/tests/integration/config_cli_coverage.rs index ce179430fd..d344e0f687 100644 --- a/tests/integration/config_cli_coverage.rs +++ b/tests/integration/config_cli_coverage.rs @@ -243,6 +243,7 @@ fn fully_populated_file_config() -> FileConfig { telemetry_enterprise_dsn: Some("https://example.com".to_string()), disable_version_checks: Some(true), disable_auto_updates: Some(true), + disable_editor_extensions: Some(true), update_channel: Some("latest".to_string()), feature_flags: Some(serde_json::json!({"transcript_sweep": true})), api_base_url: Some("https://usegitai.com".to_string()),