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 uninstall(&self, root: Option<&Path>) -> Result<()> {
113 let path = self.skill_path(root);
114
115 if !path.exists() {
116 eprintln!("Skill not installed.");
117 return Ok(());
118 }
119
120 std::fs::remove_file(&path)
121 .with_context(|| format!("failed to remove {}", path.display()))?;
122
123 if let Some(parent) = path.parent()
125 && parent.read_dir().is_ok_and(|mut d| d.next().is_none())
126 {
127 let _ = std::fs::remove_dir(parent);
128 }
129
130 eprintln!("Uninstalled skill from {}", path.display());
131 Ok(())
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 fn test_config() -> SkillConfig {
140 SkillConfig::with_environment(
141 "test-tool",
142 "# Test Skill\n\nSome content.\n",
143 "1.0.0",
144 crate::detect::Environment::ClaudeCode,
145 )
146 }
147
148 #[test]
149 fn skill_path_with_root() {
150 let config = test_config();
151 let path = config.skill_path(Some(Path::new("/project")));
152 assert_eq!(path, PathBuf::from("/project/.claude/skills/test-tool/SKILL.md"));
153 }
154
155 #[test]
156 fn skill_path_without_root() {
157 let config = test_config();
158 let path = config.skill_path(None);
159 assert_eq!(path, PathBuf::from(".claude/skills/test-tool/SKILL.md"));
160 }
161
162 #[test]
163 fn install_creates_file() {
164 let dir = tempfile::tempdir().unwrap();
165 let config = test_config();
166
167 config.install(Some(dir.path())).unwrap();
168
169 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
170 assert!(path.exists());
171 let content = std::fs::read_to_string(&path).unwrap();
172 assert_eq!(content, config.content);
173 }
174
175 #[test]
176 fn install_idempotent() {
177 let dir = tempfile::tempdir().unwrap();
178 let config = test_config();
179
180 config.install(Some(dir.path())).unwrap();
181 config.install(Some(dir.path())).unwrap();
182
183 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
184 let content = std::fs::read_to_string(&path).unwrap();
185 assert_eq!(content, config.content);
186 }
187
188 #[test]
189 fn install_overwrites_outdated() {
190 let dir = tempfile::tempdir().unwrap();
191 let config = test_config();
192
193 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
194 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
195 std::fs::write(&path, "old content").unwrap();
196
197 config.install(Some(dir.path())).unwrap();
198
199 let content = std::fs::read_to_string(&path).unwrap();
200 assert_eq!(content, config.content);
201 }
202
203 #[test]
204 fn check_not_installed() {
205 let dir = tempfile::tempdir().unwrap();
206 let config = test_config();
207
208 let result = config.check(Some(dir.path())).unwrap();
209 assert!(!result);
210 }
211
212 #[test]
213 fn check_up_to_date() {
214 let dir = tempfile::tempdir().unwrap();
215 let config = test_config();
216
217 config.install(Some(dir.path())).unwrap();
218 let result = config.check(Some(dir.path())).unwrap();
219 assert!(result);
220 }
221
222 #[test]
223 fn check_outdated() {
224 let dir = tempfile::tempdir().unwrap();
225 let config = test_config();
226
227 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
228 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
229 std::fs::write(&path, "old content").unwrap();
230
231 let result = config.check(Some(dir.path())).unwrap();
232 assert!(!result);
233 }
234
235 #[test]
236 fn uninstall_removes_file() {
237 let dir = tempfile::tempdir().unwrap();
238 let config = test_config();
239
240 config.install(Some(dir.path())).unwrap();
241 config.uninstall(Some(dir.path())).unwrap();
242
243 let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
244 assert!(!path.exists());
245 }
246
247 #[test]
248 fn uninstall_not_installed() {
249 let dir = tempfile::tempdir().unwrap();
250 let config = test_config();
251
252 config.uninstall(Some(dir.path())).unwrap();
254 }
255}