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