Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions src/commands/install_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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);
Expand All @@ -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<String> = HashSet::new();

for installer in installers {
let name = installer.name();
Expand All @@ -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)
Expand Down Expand Up @@ -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.");
}
Expand Down
37 changes: 25 additions & 12 deletions src/mdm/skills_installer.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<SkillsInstallResult, GitAiError> {
pub fn install_skills(
dry_run: bool,
_verbose: bool,
installed_tools: &HashSet<String>,
) -> Result<SkillsInstallResult, GitAiError> {
let skills_base = skills_dir_path().ok_or_else(|| {
GitAiError::Generic("Could not determine skills directory path".to_string())
})?;
Expand Down Expand Up @@ -166,18 +171,22 @@ pub fn install_skills(dry_run: bool, _verbose: bool) -> Result<SkillsInstallResu
}

// ~/.claude/skills/{skill-name} -> ~/.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);
}
}
}
}
Expand Down Expand Up @@ -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<String> = ["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());
Expand All @@ -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");
Expand Down
33 changes: 33 additions & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<shell> --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)
Expand Down
Loading