1use std::fs;
15use std::path::{Path, PathBuf};
16
17use serde::Deserialize;
18
19#[derive(Debug, thiserror::Error)]
21pub enum SkillError {
22 #[error("skill directory missing SKILL.md: {0}")]
24 MissingSkillFile(String),
25 #[error("invalid SKILL.md frontmatter: {0}")]
27 Frontmatter(String),
28 #[error("SKILL.md missing required field: {0}")]
30 MissingField(String),
31 #[error("I/O error: {0}")]
33 Io(#[from] std::io::Error),
34}
35
36#[derive(Debug, Clone, Deserialize)]
42pub struct SkillFrontmatter {
43 pub name: Option<String>,
45 pub description: Option<String>,
47 #[serde(default)]
49 #[allow(dead_code)]
50 pub allowed_tools: Option<Vec<String>>,
51 #[serde(flatten)]
53 #[allow(dead_code)]
54 pub extra: serde_json::Map<String, serde_json::Value>,
55}
56
57#[derive(Debug, Clone)]
59pub struct Skill {
60 frontmatter: SkillFrontmatter,
61 body: String,
63 dir: PathBuf,
65}
66
67impl Skill {
68 pub fn load(dir: impl Into<PathBuf>) -> Result<Self, SkillError> {
70 let dir = dir.into();
71 let skill_path = dir.join("SKILL.md");
72 if !skill_path.is_file() {
73 return Err(SkillError::MissingSkillFile(
74 skill_path.display().to_string(),
75 ));
76 }
77 let raw = fs::read_to_string(&skill_path)?;
78 let (frontmatter, body) = parse_frontmatter(&raw, &skill_path)?;
79 Ok(Self {
80 frontmatter,
81 body,
82 dir,
83 })
84 }
85
86 pub fn scan(root: impl AsRef<Path>) -> Result<Vec<Self>, SkillError> {
88 let mut skills = Vec::new();
89 for entry in fs::read_dir(root)? {
90 let entry = entry?;
91 let path = entry.path();
92 if path.is_dir() && path.join("SKILL.md").is_file() {
93 skills.push(Self::load(&path)?);
94 }
95 }
96 Ok(skills)
97 }
98
99 pub fn name(&self) -> &str {
104 self.frontmatter.name.as_deref().unwrap_or_default()
105 }
106
107 pub fn description(&self) -> &str {
111 self.frontmatter.description.as_deref().unwrap_or_default()
112 }
113
114 pub fn disclosure_view(&self) -> String {
118 format!("{}: {}", self.name(), self.description())
119 }
120
121 pub fn full_text(&self) -> String {
123 format!(
124 "# Skill: {}\n\n## Description\n{}\n\n## Instructions\n\n{}",
125 self.name(),
126 self.description(),
127 self.body
128 )
129 }
130
131 pub fn directory(&self) -> &Path {
133 &self.dir
134 }
135}
136
137fn parse_frontmatter(raw: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
139 let stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw); if !stripped.trim_start().starts_with("---") {
141 return Err(SkillError::MissingField(
142 "frontmatter block (---...) missing".into(),
143 ));
144 }
145 let after_open = stripped.find('\n').ok_or_else(|| {
147 SkillError::Frontmatter(format!("{}: no newline after opening ---", path.display()))
148 })?;
149 let rest = &stripped[after_open..];
150 let close = rest.find("\n---").ok_or_else(|| {
151 SkillError::Frontmatter(format!(
152 "{}: no closing --- for frontmatter",
153 path.display()
154 ))
155 })?;
156 let yaml = &rest[..close];
157 let body = &rest[close + 4..];
158
159 let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml)
160 .map_err(|e| SkillError::Frontmatter(format!("{}: {}", path.display(), e)))?;
161 match &frontmatter.name {
162 Some(n) if !n.trim().is_empty() => {}
163 _ => return Err(SkillError::MissingField("name".into())),
164 }
165 match &frontmatter.description {
166 Some(d) if !d.trim().is_empty() => {}
167 _ => return Err(SkillError::MissingField("description".into())),
168 }
169 Ok((frontmatter, body.trim_matches('\n').to_string()))
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use std::io::Write;
176
177 fn write_skill(dir: &Path, name: &str, description: &str, body: &str) -> PathBuf {
178 std::fs::create_dir_all(dir).unwrap();
179 let frontmatter = format!("---\nname: {name}\ndescription: {description}\n---\n\n");
180 let p = dir.join("SKILL.md");
181 let mut f = std::fs::File::create(&p).unwrap();
182 f.write_all(format!("{frontmatter}{body}").as_bytes())
183 .unwrap();
184 p
185 }
186
187 #[test]
188 fn parses_frontmatter_and_body() {
189 let dir = tempfile::tempdir().unwrap();
190 write_skill(
191 dir.path(),
192 "web_search",
193 "Searches the web",
194 "Do a search then summarize.",
195 );
196 let skill = Skill::load(dir.path()).unwrap();
197 assert_eq!(skill.name(), "web_search");
198 assert_eq!(skill.description(), "Searches the web");
199 assert!(skill.full_text().contains("Do a search then summarize."));
200 }
201
202 #[test]
203 fn disclosure_view_excludes_body() {
204 let dir = tempfile::tempdir().unwrap();
205 write_skill(dir.path(), "secret", "Just a name", "<!-- SECRET BODY -->");
206 let skill = Skill::load(dir.path()).unwrap();
207 assert_eq!(skill.disclosure_view(), "secret: Just a name");
208 assert!(!skill.disclosure_view().contains("SECRET BODY"));
209 assert!(skill.full_text().contains("SECRET BODY"));
210 }
211
212 #[test]
213 fn missing_skill_file_errors() {
214 let dir = tempfile::tempdir().unwrap();
215 let err = Skill::load(dir.path()).unwrap_err();
216 assert!(matches!(err, SkillError::MissingSkillFile(_)));
217 }
218
219 #[test]
220 fn missing_required_field_errors() {
221 let dir = tempfile::tempdir().unwrap();
222 let p = dir.path().join("SKILL.md");
223 std::fs::write(&p, "---\ndescription: no name here\n---\n\nbody").unwrap();
224 let err = Skill::load(dir.path()).unwrap_err();
225 assert!(matches!(err, SkillError::MissingField(_)));
226 }
227
228 #[test]
229 fn scan_finds_only_skill_dirs() {
230 let root = tempfile::tempdir().unwrap();
231 write_skill(&root.path().join("a"), "a", "skill A", "body a");
232 write_skill(&root.path().join("b"), "b", "skill B", "body b");
233 std::fs::create_dir(root.path().join("plain")).unwrap();
234 let skills = Skill::scan(root.path()).unwrap();
235 assert_eq!(skills.len(), 2);
236 }
237}