xcom_rs/skills/
install.rs1use 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
12pub fn install_skill(skill: &Skill, options: &InstallOptions) -> Result<SkillInstallResult> {
14 let canonical_path = resolve_canonical_path(&skill.name, options.global)?;
15
16 if let Some(parent) = canonical_path.parent() {
18 fs::create_dir_all(parent).context("Failed to create canonical directory")?;
19 }
20
21 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 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 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, ))
59}
60
61fn 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
74fn 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 }
102 _ => {
103 tracing::warn!(agent = %agent, "Unknown agent, skipping agent-specific paths");
104 }
105 }
106
107 Ok(paths)
108}
109
110fn 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 #[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 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); }
202}