Skip to main content

xcom_rs/skills/
install.rs

1use crate::skills::models::{Skill, SkillInstallResult};
2use anyhow::{Context, Result};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone)]
7pub struct InstallOptions {
8    pub global: bool,
9    pub agent: Option<String>,
10}
11
12/// Install a skill to the canonical location and optional agent-specific paths
13pub fn install_skill(skill: &Skill, options: &InstallOptions) -> Result<SkillInstallResult> {
14    let canonical_path = resolve_canonical_path(&skill.name, options.global)?;
15
16    // Create canonical directory
17    if let Some(parent) = canonical_path.parent() {
18        fs::create_dir_all(parent).context("Failed to create canonical directory")?;
19    }
20
21    // Copy SKILL.md to canonical location
22    fs::copy(&skill.source_path, &canonical_path)
23        .context("Failed to copy SKILL.md to canonical location")?;
24
25    tracing::info!(
26        skill = %skill.name,
27        path = %canonical_path.display(),
28        "Installed skill to canonical location"
29    );
30
31    // Create agent-specific paths if requested
32    let mut target_paths = vec![canonical_path.clone()];
33    if let Some(agent_name) = &options.agent {
34        let agent_specific_paths = resolve_agent_paths(&skill.name, agent_name, options.global)?;
35        for agent_path in agent_specific_paths {
36            if let Err(e) = create_agent_link(&canonical_path, &agent_path) {
37                tracing::warn!(
38                    error = %e,
39                    agent_path = %agent_path.display(),
40                    "Failed to create agent-specific link, using copy fallback"
41                );
42                // Fallback to copy
43                if let Some(parent) = agent_path.parent() {
44                    fs::create_dir_all(parent)?;
45                }
46                fs::copy(&canonical_path, &agent_path)
47                    .context("Failed to copy to agent-specific location")?;
48            }
49            target_paths.push(agent_path);
50        }
51    }
52
53    Ok(SkillInstallResult::success(
54        skill.name.clone(),
55        canonical_path,
56        target_paths,
57        false, // We always use copy for now
58    ))
59}
60
61/// Resolve canonical installation path
62fn resolve_canonical_path(skill_name: &str, global: bool) -> Result<PathBuf> {
63    let base = if global {
64        dirs::home_dir()
65            .context("Failed to get home directory")?
66            .join(".agents")
67    } else {
68        PathBuf::from(".agents")
69    };
70
71    Ok(base.join("skills").join(skill_name).join("SKILL.md"))
72}
73
74/// Resolve agent-specific paths
75fn resolve_agent_paths(skill_name: &str, agent: &str, global: bool) -> Result<Vec<PathBuf>> {
76    let mut paths = Vec::new();
77
78    match agent {
79        "claude" => {
80            let base = if global {
81                dirs::home_dir()
82                    .context("Failed to get home directory")?
83                    .join(".claude")
84            } else {
85                PathBuf::from(".claude")
86            };
87            paths.push(base.join("skills").join(skill_name).join("SKILL.md"));
88        }
89        "opencode" => {
90            if global {
91                let home = dirs::home_dir().context("Failed to get home directory")?;
92                paths.push(
93                    home.join(".config")
94                        .join("opencode")
95                        .join("skills")
96                        .join(skill_name)
97                        .join("SKILL.md"),
98                );
99            }
100            // For project scope, opencode uses canonical only
101        }
102        _ => {
103            tracing::warn!(agent = %agent, "Unknown agent, skipping agent-specific paths");
104        }
105    }
106
107    Ok(paths)
108}
109
110/// Create link or copy to agent-specific location
111fn create_agent_link(source: &Path, target: &Path) -> Result<()> {
112    if let Some(parent) = target.parent() {
113        fs::create_dir_all(parent).context("Failed to create agent directory")?;
114    }
115
116    // Try symlink first (not available on all platforms)
117    #[cfg(unix)]
118    {
119        use std::os::unix::fs::symlink;
120        symlink(source, target).context("Failed to create symlink")
121    }
122
123    #[cfg(windows)]
124    {
125        use std::os::windows::fs::symlink_file;
126        symlink_file(source, target).context("Failed to create symlink")
127    }
128
129    #[cfg(not(any(unix, windows)))]
130    {
131        anyhow::bail!("Symlink not supported on this platform");
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use std::fs;
139    use tempfile::TempDir;
140
141    #[test]
142    fn test_install_skill_project_scope() {
143        let temp_dir = TempDir::new().unwrap();
144        let skill_md = temp_dir.path().join("test.md");
145        fs::write(&skill_md, "Test skill content").unwrap();
146
147        let skill = Skill {
148            name: "test-skill".to_string(),
149            source_path: skill_md,
150            description: None,
151        };
152
153        let options = InstallOptions {
154            global: false,
155            agent: None,
156        };
157
158        // Change to temp dir for test
159        let original_dir = std::env::current_dir().unwrap();
160        std::env::set_current_dir(temp_dir.path()).unwrap();
161
162        let result = install_skill(&skill, &options).unwrap();
163
164        std::env::set_current_dir(original_dir).unwrap();
165
166        assert!(result.success);
167        assert_eq!(result.name, "test-skill");
168        assert_eq!(result.target_paths.len(), 1);
169        assert!(result.target_paths[0]
170            .to_string_lossy()
171            .contains("test-skill"));
172    }
173
174    #[test]
175    fn test_resolve_canonical_path_project() {
176        let path = resolve_canonical_path("test-skill", false).unwrap();
177        assert!(path.to_string_lossy().contains(".agents"));
178        assert!(path.to_string_lossy().contains("test-skill"));
179        assert!(path.to_string_lossy().ends_with("SKILL.md"));
180    }
181
182    #[test]
183    fn test_resolve_agent_paths_claude() {
184        let paths = resolve_agent_paths("test-skill", "claude", false).unwrap();
185        assert_eq!(paths.len(), 1);
186        assert!(paths[0].to_string_lossy().contains(".claude"));
187        assert!(paths[0].to_string_lossy().contains("test-skill"));
188    }
189
190    #[test]
191    fn test_resolve_agent_paths_opencode_global() {
192        let paths = resolve_agent_paths("test-skill", "opencode", true).unwrap();
193        assert_eq!(paths.len(), 1);
194        assert!(paths[0].to_string_lossy().contains("opencode"));
195    }
196
197    #[test]
198    fn test_resolve_agent_paths_opencode_project() {
199        let paths = resolve_agent_paths("test-skill", "opencode", false).unwrap();
200        assert_eq!(paths.len(), 0); // Project scope uses canonical only
201    }
202}