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