1use anyhow::{Context, Result};
20use serde::{Deserialize, Serialize};
21use std::path::PathBuf;
22use tokio::fs;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SkillMeta {
27 pub name: String,
29 pub description: String,
31}
32
33#[derive(Debug, Clone)]
35pub struct Skill {
36 pub meta: SkillMeta,
38 pub content: String,
40 pub path: PathBuf,
42}
43
44fn parse_frontmatter(content: &str) -> Result<(SkillMeta, String)> {
49 let trimmed = content.trim_start();
50 if !trimmed.starts_with("---") {
51 return Ok((
53 SkillMeta {
54 name: String::new(),
55 description: String::new(),
56 },
57 content.to_string(),
58 ));
59 }
60
61 let after_open = &trimmed[3..];
63 let closing_pos = after_open.find("---").context("unclosed frontmatter")?;
64 let yaml_content = &after_open[..closing_pos];
65 let rest = &after_open[closing_pos + 3..];
66
67 let mut name = String::new();
69 let mut description = String::new();
70
71 for line in yaml_content.lines() {
72 let line = line.trim();
73 if line.is_empty() || line.starts_with('#') {
74 continue;
75 }
76 if let Some(val) = line.strip_prefix("name:") {
77 name = val.trim().trim_matches('"').trim_matches('\'').to_string();
78 } else if let Some(val) = line.strip_prefix("description:") {
79 description = val.trim().trim_matches('"').trim_matches('\'').to_string();
80 }
81 }
82
83 Ok((
84 SkillMeta { name, description },
85 rest.trim_start().to_string(),
86 ))
87}
88
89#[derive(Clone)]
91pub struct SkillStore {
92 skills_dir: PathBuf,
94}
95
96impl std::fmt::Debug for SkillStore {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.debug_struct("SkillStore")
99 .field("skills_dir", &self.skills_dir)
100 .finish()
101 }
102}
103
104impl SkillStore {
105 pub fn new(skills_dir: PathBuf) -> Result<Self> {
118 Ok(Self { skills_dir })
119 }
120
121 pub async fn init_defaults(&self, defaults_dir: &PathBuf) -> Result<()> {
123 if !self.skills_dir.exists() {
124 fs::create_dir_all(&self.skills_dir).await?;
125 }
126
127 {
129 let mut entries = fs::read_dir(&self.skills_dir).await?;
130 let mut count = 0;
131 while entries.next_entry().await?.is_some() {
132 count += 1;
133 }
134 if count > 0 {
135 return Ok(()); }
137 }
138
139 if defaults_dir.exists() {
141 let mut entries = fs::read_dir(defaults_dir).await?;
142 while let Some(entry) = entries.next_entry().await? {
143 let src = entry.path();
144 if src.is_dir() {
145 let skill_name = src
146 .file_name()
147 .and_then(|n| n.to_str())
148 .unwrap_or("unknown");
149 let dest = self.skills_dir.join(skill_name);
150 fs::create_dir_all(&dest).await?;
151
152 let mut skill_files = fs::read_dir(&src).await?;
153 while let Some(sfile) = skill_files.next_entry().await? {
154 if sfile.file_name() == "SKILL.md" {
155 let content = fs::read_to_string(sfile.path()).await?;
156 let dest_file = dest.join("SKILL.md");
157 if !dest_file.exists() {
158 fs::write(&dest_file, content).await?;
159 }
160 }
161 }
162 }
163 }
164 }
165
166 Ok(())
167 }
168
169 pub async fn list_skills(&self) -> Result<Vec<SkillMeta>> {
171 let mut skills = Vec::new();
172
173 if !self.skills_dir.exists() {
174 return Ok(skills);
175 }
176
177 let mut entries = fs::read_dir(&self.skills_dir).await?;
178 while let Some(entry) = entries.next_entry().await? {
179 let path = entry.path();
180 if path.is_dir() {
181 let skill_file = path.join("SKILL.md");
182 if skill_file.exists() {
183 if let Ok(content) = fs::read_to_string(&skill_file).await {
184 if let Ok((meta, _)) = parse_frontmatter(&content) {
185 if !meta.name.is_empty() {
186 skills.push(meta);
187 }
188 }
189 }
190 }
191 }
192 }
193
194 skills.sort_by(|a, b| a.name.cmp(&b.name));
195 Ok(skills)
196 }
197
198 pub async fn load_skill(&self, name: &str) -> Result<Option<Skill>> {
202 let skill_path = self.skills_dir.join(name).join("SKILL.md");
203
204 if !skill_path.exists() {
205 return Ok(None);
206 }
207
208 let content = fs::read_to_string(&skill_path).await?;
209 let (meta, content) = parse_frontmatter(&content)?;
210
211 Ok(Some(Skill {
212 meta,
213 content,
214 path: skill_path,
215 }))
216 }
217
218 pub async fn create_skill(&self, name: &str, description: &str, content: &str) -> Result<()> {
222 fs::create_dir_all(self.skills_dir.join(name)).await?;
223
224 let skill_file = self.skills_dir.join(name).join("SKILL.md");
225 let frontmatter = format!(
226 "---\nname: {}\ndescription: {}\n---\n\n{}",
227 name, description, content
228 );
229
230 fs::write(&skill_file, frontmatter).await?;
231 Ok(())
232 }
233
234 pub async fn delete_skill(&self, name: &str) -> Result<()> {
238 let skill_dir = self.skills_dir.join(name);
239 if skill_dir.exists() {
240 fs::remove_dir_all(&skill_dir).await?;
241 }
242 Ok(())
243 }
244
245 pub fn path(&self) -> &PathBuf {
247 &self.skills_dir
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn test_parse_frontmatter_with_metadata() {
257 let content = r#"---
258name: code-review
259description: Guidelines for reviewing code changes
260---
261
262# Code Review
263
264Follow these steps to review code effectively.
265"#;
266 let (meta, rest) = parse_frontmatter(content).unwrap();
267 assert_eq!(meta.name, "code-review");
268 assert_eq!(meta.description, "Guidelines for reviewing code changes");
269 assert!(rest.contains("Code Review"));
270 }
271
272 #[test]
273 fn test_parse_frontmatter_no_metadata() {
274 let content = "# Just a Title\n\nSome content";
275 let (meta, rest) = parse_frontmatter(content).unwrap();
276 assert!(meta.name.is_empty());
277 assert!(rest.contains("Just a Title"));
278 }
279
280 #[test]
281 fn test_parse_frontmatter_quoted_values() {
282 let content = r#"---
283name: "test-skill"
284description: 'A test skill'
285---
286
287Content here
288"#;
289 let (meta, _) = parse_frontmatter(content).unwrap();
290 assert_eq!(meta.name, "test-skill");
291 assert_eq!(meta.description, "A test skill");
292 }
293}