diff --git a/src/commands/install_hooks.rs b/src/commands/install_hooks.rs index ccdbad1ad..5e1dc01b2 100644 --- a/src/commands/install_hooks.rs +++ b/src/commands/install_hooks.rs @@ -7,7 +7,7 @@ use crate::mdm::hook_installer::HookInstallerParams; use crate::mdm::skills_installer; use crate::mdm::spinner::{Spinner, print_diff}; use crate::mdm::utils::{get_current_binary_path, git_shim_path}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; /// Installation status for a tool #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -169,14 +169,6 @@ async fn async_run_install( // Track detailed results for metrics (tool_id, result) let mut detailed_results: Vec<(String, InstallResult)> = Vec::new(); - // Install skills first (these are global, not per-agent) - // Skills are always nuked and reinstalled fresh (silently) - if let Ok(result) = skills_installer::install_skills(dry_run, verbose) - && result.changed - { - has_changes = true; - } - // Ensure git symlinks for Fork compatibility if let Err(e) = crate::mdm::ensure_git_symlinks() { eprintln!("Warning: Failed to create git symlinks: {}", e); @@ -186,6 +178,7 @@ async fn async_run_install( println!("\n\x1b[1mCoding Agents\x1b[0m"); let installers = get_all_installers(); + let mut installed_tools: HashSet = HashSet::new(); for installer in installers { let name = installer.name(); @@ -200,6 +193,7 @@ async fn async_run_install( continue; } + installed_tools.insert(id.to_string()); any_checked = true; // Install/update hooks (only for tools that use config file hooks) @@ -310,6 +304,13 @@ async fn async_run_install( } } + // Install skills for detected agents only + if let Ok(result) = skills_installer::install_skills(dry_run, verbose, &installed_tools) + && result.changed + { + has_changes = true; + } + if !any_checked { println!("No compatible coding agents detected. Nothing to install."); } diff --git a/src/mdm/skills_installer.rs b/src/mdm/skills_installer.rs index 051009b7f..871ea0bfb 100644 --- a/src/mdm/skills_installer.rs +++ b/src/mdm/skills_installer.rs @@ -1,6 +1,7 @@ use crate::config::skills_dir_path; use crate::error::GitAiError; use crate::mdm::utils::write_atomic; +use std::collections::HashSet; use std::fs; use std::path::PathBuf; @@ -126,7 +127,11 @@ fn remove_skill_link(link_path: &PathBuf) -> Result<(), GitAiError> { /// Then links each skill to: /// - ~/.agents/skills/{skill-name} (symlink on Unix, copy on Windows) /// - ~/.claude/skills/{skill-name} (symlink on Unix, copy on Windows) -pub fn install_skills(dry_run: bool, _verbose: bool) -> Result { +pub fn install_skills( + dry_run: bool, + _verbose: bool, + installed_tools: &HashSet, +) -> Result { let skills_base = skills_dir_path().ok_or_else(|| { GitAiError::Generic("Could not determine skills directory path".to_string()) })?; @@ -166,18 +171,22 @@ pub fn install_skills(dry_run: bool, _verbose: bool) -> Result ~/.git-ai/skills/{skill-name} - if let Some(claude_dir) = claude_skills_dir() { - let claude_link = claude_dir.join(skill.name); - if let Err(e) = link_skill_dir(&skill_dir, &claude_link) { - eprintln!("Warning: Failed to link skill at {:?}: {}", claude_link, e); + if installed_tools.contains("claude-code") { + if let Some(claude_dir) = claude_skills_dir() { + let claude_link = claude_dir.join(skill.name); + if let Err(e) = link_skill_dir(&skill_dir, &claude_link) { + eprintln!("Warning: Failed to link skill at {:?}: {}", claude_link, e); + } } } // ~/.cursor/skills/{skill-name} -> ~/.git-ai/skills/{skill-name} - if let Some(cursor_dir) = cursor_skills_dir() { - let cursor_link = cursor_dir.join(skill.name); - if let Err(e) = link_skill_dir(&skill_dir, &cursor_link) { - eprintln!("Warning: Failed to link skill at {:?}: {}", cursor_link, e); + if installed_tools.contains("cursor") { + if let Some(cursor_dir) = cursor_skills_dir() { + let cursor_link = cursor_dir.join(skill.name); + if let Err(e) = link_skill_dir(&skill_dir, &cursor_link) { + eprintln!("Warning: Failed to link skill at {:?}: {}", cursor_link, e); + } } } } @@ -439,14 +448,18 @@ mod tests { // and don't race with other tests that mutate HOME (e.g. codex tests). with_temp_home(|_home| { let skills_base = skills_dir_path().unwrap(); + let all_tools: HashSet = ["claude-code", "cursor"] + .iter() + .map(|s| s.to_string()) + .collect(); // Dry run should not create anything - let dry_result = install_skills(true, false).unwrap(); + let dry_result = install_skills(true, false, &all_tools).unwrap(); assert!(dry_result.changed); assert_eq!(dry_result.installed_count, EMBEDDED_SKILLS.len()); // Install creates skill files with correct content - let result = install_skills(false, false).unwrap(); + let result = install_skills(false, false, &all_tools).unwrap(); assert!(result.changed); assert_eq!(result.installed_count, EMBEDDED_SKILLS.len()); assert!(skills_base.exists()); @@ -458,7 +471,7 @@ mod tests { } // Install again is idempotent - let result2 = install_skills(false, false).unwrap(); + let result2 = install_skills(false, false, &all_tools).unwrap(); assert!(result2.changed); for skill in EMBEDDED_SKILLS { let skill_md = skills_base.join(skill.name).join("SKILL.md"); diff --git a/src/utils.rs b/src/utils.rs index da660606f..eff5551ef 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -210,6 +210,39 @@ pub fn is_in_background_agent() -> bool { }) } +/// Detect the current shell name and version. +/// +/// Returns a tuple of `(shell_name, version)` where `shell_name` is the +/// basename of the shell (e.g. "zsh", "bash", "fish") and `version` is +/// the output of ` --version` trimmed to a single line. +/// +/// Returns `None` if the shell cannot be determined. +pub fn shell_name_and_version() -> Option<(String, String)> { + // Aidan wrote this... + let shell_path = std::env::var("SHELL").ok()?; + let shell_name = std::path::Path::new(&shell_path) + .file_name()? + .to_str()? + .to_string(); + + let version_output = Command::new(&shell_path) + .arg("--version") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .ok()?; + + let raw = if version_output.status.success() { + String::from_utf8_lossy(&version_output.stdout) + } else { + String::from_utf8_lossy(&version_output.stderr) + }; + + let version = raw.lines().next().unwrap_or("").trim().to_string(); + + Some((shell_name, version)) +} + /// A cross-platform exclusive file lock. /// /// Holds an exclusive advisory lock (Unix) or exclusive-access file handle (Windows)