1use anyhow::{Context, Result};
7use std::path::{Path, PathBuf};
8
9pub struct SkillConfig {
11 pub name: String,
13 pub content: String,
15 pub version: String,
17 pub path_resolver: Box<dyn Fn(&str) -> PathBuf + Send + Sync>,
19}
20
21impl SkillConfig {
22 pub fn new(
24 name: impl Into<String>,
25 content: impl Into<String>,
26 version: impl Into<String>,
27 path_resolver: impl Fn(&str) -> PathBuf + Send + Sync + 'static,
28 ) -> Self {
29 Self {
30 name: name.into(),
31 content: content.into(),
32 version: version.into(),
33 path_resolver: Box::new(path_resolver),
34 }
35 }
36
37 pub fn generic(
39 name: impl Into<String>,
40 content: impl Into<String>,
41 version: impl Into<String>,
42 ) -> Self {
43 Self::new(name, content, version, |name| {
44 PathBuf::from(format!(".agent/skills/{name}/SKILL.md"))
45 })
46 }
47
48 pub fn skill_path(&self, root: Option<&Path>) -> PathBuf {
50 let rel = (self.path_resolver)(&self.name);
51 match root {
52 Some(r) => r.join(rel),
53 None => rel,
54 }
55 }
56
57 pub fn install(&self, root: Option<&Path>) -> Result<()> {
59 let path = self.skill_path(root);
60
61 if path.exists() {
62 let existing = std::fs::read_to_string(&path)
63 .with_context(|| format!("failed to read {}", path.display()))?;
64 if existing == self.content {
65 eprintln!("Skill already up to date (v{}).", self.version);
66 return Ok(());
67 }
68 }
69
70 if let Some(parent) = path.parent() {
71 std::fs::create_dir_all(parent)
72 .with_context(|| format!("failed to create {}", parent.display()))?;
73 }
74
75 std::fs::write(&path, &self.content)
76 .with_context(|| format!("failed to write {}", path.display()))?;
77 eprintln!("Installed skill v{} → {}", self.version, path.display());
78
79 Ok(())
80 }
81
82 pub fn check(&self, root: Option<&Path>) -> Result<bool> {
84 let path = self.skill_path(root);
85
86 if !path.exists() {
87 eprintln!("Not installed. Run `{} skill install` to install.", self.name);
88 return Ok(false);
89 }
90
91 let existing = std::fs::read_to_string(&path)
92 .with_context(|| format!("failed to read {}", path.display()))?;
93
94 if existing == self.content {
95 eprintln!("Up to date (v{}).", self.version);
96 Ok(true)
97 } else {
98 eprintln!(
99 "Outdated. Run `{} skill install` to update to v{}.",
100 self.name, self.version
101 );
102 Ok(false)
103 }
104 }
105
106 pub fn uninstall(&self, root: Option<&Path>) -> Result<()> {
108 let path = self.skill_path(root);
109
110 if !path.exists() {
111 eprintln!("Skill not installed.");
112 return Ok(());
113 }
114
115 std::fs::remove_file(&path)
116 .with_context(|| format!("failed to remove {}", path.display()))?;
117
118 if let Some(parent) = path.parent()
119 && parent.read_dir().is_ok_and(|mut d| d.next().is_none())
120 {
121 let _ = std::fs::remove_dir(parent);
122 }
123
124 eprintln!("Uninstalled skill from {}", path.display());
125 Ok(())
126 }
127}
128
129#[cfg(feature = "detect")]
131pub fn skill_for_environment(
132 name: impl Into<String>,
133 content: impl Into<String>,
134 version: impl Into<String>,
135) -> SkillConfig {
136 let env = agent_kit::detect::Environment::detect();
137 let name_str = name.into();
138 let name_clone = name_str.clone();
139 SkillConfig {
140 name: name_str,
141 content: content.into(),
142 version: version.into(),
143 path_resolver: Box::new(move |_| env.skill_rel_path(&name_clone)),
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn test_config() -> SkillConfig {
152 SkillConfig::new(
153 "test-tool",
154 "# Test Skill\n\nSome content.\n",
155 "1.0.0",
156 |name| PathBuf::from(format!(".claude/skills/{name}/SKILL.md")),
157 )
158 }
159
160 #[test]
161 fn skill_path_with_root() {
162 let config = test_config();
163 let path = config.skill_path(Some(Path::new("/project")));
164 assert_eq!(
165 path,
166 PathBuf::from("/project/.claude/skills/test-tool/SKILL.md")
167 );
168 }
169
170 #[test]
171 fn skill_path_without_root() {
172 let config = test_config();
173 let path = config.skill_path(None);
174 assert_eq!(
175 path,
176 PathBuf::from(".claude/skills/test-tool/SKILL.md")
177 );
178 }
179
180 #[test]
181 fn generic_skill_path() {
182 let config = SkillConfig::generic("my-tool", "content", "1.0.0");
183 let path = config.skill_path(None);
184 assert_eq!(
185 path,
186 PathBuf::from(".agent/skills/my-tool/SKILL.md")
187 );
188 }
189
190 #[test]
191 fn install_creates_file() {
192 let dir = tempfile::tempdir().unwrap();
193 let config = test_config();
194 config.install(Some(dir.path())).unwrap();
195
196 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
197 assert!(path.exists());
198 let content = std::fs::read_to_string(&path).unwrap();
199 assert_eq!(content, config.content);
200 }
201
202 #[test]
203 fn install_idempotent() {
204 let dir = tempfile::tempdir().unwrap();
205 let config = test_config();
206 config.install(Some(dir.path())).unwrap();
207 config.install(Some(dir.path())).unwrap();
208
209 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
210 let content = std::fs::read_to_string(&path).unwrap();
211 assert_eq!(content, config.content);
212 }
213
214 #[test]
215 fn check_not_installed() {
216 let dir = tempfile::tempdir().unwrap();
217 let config = test_config();
218 assert!(!config.check(Some(dir.path())).unwrap());
219 }
220
221 #[test]
222 fn check_up_to_date() {
223 let dir = tempfile::tempdir().unwrap();
224 let config = test_config();
225 config.install(Some(dir.path())).unwrap();
226 assert!(config.check(Some(dir.path())).unwrap());
227 }
228
229 #[test]
230 fn uninstall_removes_file() {
231 let dir = tempfile::tempdir().unwrap();
232 let config = test_config();
233 config.install(Some(dir.path())).unwrap();
234 config.uninstall(Some(dir.path())).unwrap();
235
236 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
237 assert!(!path.exists());
238 }
239
240 #[test]
241 fn uninstall_not_installed() {
242 let dir = tempfile::tempdir().unwrap();
243 let config = test_config();
244 config.uninstall(Some(dir.path())).unwrap();
245 }
246}