1use crate::detect::Environment;
7use anyhow::{Context, Result};
8use std::path::{Path, PathBuf};
9
10pub struct SkillConfig {
12 pub name: String,
14 pub content: String,
16 pub version: String,
18 pub environment: Environment,
20}
21
22impl SkillConfig {
23 pub fn new(
26 name: impl Into<String>,
27 content: impl Into<String>,
28 version: impl Into<String>,
29 ) -> Self {
30 Self {
31 name: name.into(),
32 content: content.into(),
33 version: version.into(),
34 environment: Environment::detect(),
35 }
36 }
37
38 pub fn with_environment(
40 name: impl Into<String>,
41 content: impl Into<String>,
42 version: impl Into<String>,
43 environment: Environment,
44 ) -> Self {
45 Self {
46 name: name.into(),
47 content: content.into(),
48 version: version.into(),
49 environment,
50 }
51 }
52
53 pub fn skill_path(&self, root: Option<&Path>) -> PathBuf {
56 self.environment.skill_path(&self.name, root)
57 }
58
59 pub fn install(&self, root: Option<&Path>) -> Result<()> {
62 let path = self.skill_path(root);
63
64 if path.exists() {
66 let existing = std::fs::read_to_string(&path)
67 .with_context(|| format!("failed to read {}", path.display()))?;
68 if existing == self.content {
69 eprintln!("Skill already up to date (v{}).", self.version);
70 return Ok(());
71 }
72 }
73
74 if let Some(parent) = path.parent() {
76 std::fs::create_dir_all(parent)
77 .with_context(|| format!("failed to create {}", parent.display()))?;
78 }
79
80 std::fs::write(&path, &self.content)
82 .with_context(|| format!("failed to write {}", path.display()))?;
83 eprintln!("Installed skill v{} → {}", self.version, path.display());
84
85 Ok(())
86 }
87
88 pub fn check(&self, root: Option<&Path>) -> Result<bool> {
93 let path = self.skill_path(root);
94
95 if !path.exists() {
96 eprintln!(
97 "Not installed. Run `{} skill install` to install.",
98 self.name
99 );
100 return Ok(false);
101 }
102
103 let existing = std::fs::read_to_string(&path)
104 .with_context(|| format!("failed to read {}", path.display()))?;
105
106 if existing == self.content {
107 eprintln!("Up to date (v{}).", self.version);
108 Ok(true)
109 } else {
110 eprintln!(
111 "Outdated. Run `{} skill install` to update to v{}.",
112 self.name, self.version
113 );
114 Ok(false)
115 }
116 }
117
118 pub fn install_for(&self, env: Environment, root: Option<&Path>) -> Result<()> {
120 let rel = env.skill_rel_path(&self.name);
121 let path = match root {
122 Some(r) => r.join(rel),
123 None => rel,
124 };
125
126 if path.exists() {
127 let existing = std::fs::read_to_string(&path)
128 .with_context(|| format!("failed to read {}", path.display()))?;
129 if existing == self.content {
130 eprintln!("[{}] already up to date (v{}).", env, self.version);
131 return Ok(());
132 }
133 }
134
135 if let Some(parent) = path.parent() {
136 std::fs::create_dir_all(parent)
137 .with_context(|| format!("failed to create {}", parent.display()))?;
138 }
139
140 std::fs::write(&path, &self.content)
141 .with_context(|| format!("failed to write {}", path.display()))?;
142 eprintln!(
143 "[{}] installed skill v{} → {}",
144 env,
145 self.version,
146 path.display()
147 );
148 Ok(())
149 }
150
151 pub fn install_all(&self, root: Option<&Path>) -> Result<()> {
153 for (env, _) in Environment::all_skill_rel_paths(&self.name) {
154 self.install_for(env, root)?;
155 }
156 Ok(())
157 }
158
159 pub fn uninstall(&self, root: Option<&Path>) -> Result<()> {
161 let path = self.skill_path(root);
162
163 if !path.exists() {
164 eprintln!("Skill not installed.");
165 return Ok(());
166 }
167
168 std::fs::remove_file(&path)
169 .with_context(|| format!("failed to remove {}", path.display()))?;
170
171 if let Some(parent) = path.parent()
173 && parent.read_dir().is_ok_and(|mut d| d.next().is_none())
174 {
175 let _ = std::fs::remove_dir(parent);
176 }
177
178 eprintln!("Uninstalled skill from {}", path.display());
179 Ok(())
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn test_config() -> SkillConfig {
188 SkillConfig::with_environment(
189 "test-tool",
190 "# Test Skill\n\nSome content.\n",
191 "1.0.0",
192 crate::detect::Environment::ClaudeCode,
193 )
194 }
195
196 #[test]
197 fn skill_path_with_root() {
198 let config = test_config();
199 let path = config.skill_path(Some(Path::new("/project")));
200 assert_eq!(
201 path,
202 PathBuf::from("/project/.claude/skills/test-tool/SKILL.md")
203 );
204 }
205
206 #[test]
207 fn skill_path_without_root() {
208 let config = test_config();
209 let path = config.skill_path(None);
210 assert_eq!(path, PathBuf::from(".claude/skills/test-tool/SKILL.md"));
211 }
212
213 #[test]
214 fn install_creates_file() {
215 let dir = tempfile::tempdir().unwrap();
216 let config = test_config();
217
218 config.install(Some(dir.path())).unwrap();
219
220 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
221 assert!(path.exists());
222 let content = std::fs::read_to_string(&path).unwrap();
223 assert_eq!(content, config.content);
224 }
225
226 #[test]
227 fn install_idempotent() {
228 let dir = tempfile::tempdir().unwrap();
229 let config = test_config();
230
231 config.install(Some(dir.path())).unwrap();
232 config.install(Some(dir.path())).unwrap();
233
234 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
235 let content = std::fs::read_to_string(&path).unwrap();
236 assert_eq!(content, config.content);
237 }
238
239 #[test]
240 fn install_overwrites_outdated() {
241 let dir = tempfile::tempdir().unwrap();
242 let config = test_config();
243
244 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
245 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
246 std::fs::write(&path, "old content").unwrap();
247
248 config.install(Some(dir.path())).unwrap();
249
250 let content = std::fs::read_to_string(&path).unwrap();
251 assert_eq!(content, config.content);
252 }
253
254 #[test]
255 fn check_not_installed() {
256 let dir = tempfile::tempdir().unwrap();
257 let config = test_config();
258
259 let result = config.check(Some(dir.path())).unwrap();
260 assert!(!result);
261 }
262
263 #[test]
264 fn check_up_to_date() {
265 let dir = tempfile::tempdir().unwrap();
266 let config = test_config();
267
268 config.install(Some(dir.path())).unwrap();
269 let result = config.check(Some(dir.path())).unwrap();
270 assert!(result);
271 }
272
273 #[test]
274 fn check_outdated() {
275 let dir = tempfile::tempdir().unwrap();
276 let config = test_config();
277
278 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
279 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
280 std::fs::write(&path, "old content").unwrap();
281
282 let result = config.check(Some(dir.path())).unwrap();
283 assert!(!result);
284 }
285
286 #[test]
287 fn uninstall_removes_file() {
288 let dir = tempfile::tempdir().unwrap();
289 let config = test_config();
290
291 config.install(Some(dir.path())).unwrap();
292 config.uninstall(Some(dir.path())).unwrap();
293
294 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
295 assert!(!path.exists());
296 }
297
298 #[test]
299 fn uninstall_not_installed() {
300 let dir = tempfile::tempdir().unwrap();
301 let config = test_config();
302
303 config.uninstall(Some(dir.path())).unwrap();
305 }
306}