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(skill_path.display().to_string()));
74 }
75 let raw = fs::read_to_string(&skill_path)?;
76 let (frontmatter, body) = parse_frontmatter(&raw, &skill_path)?;
77 Ok(Self {
78 frontmatter,
79 body,
80 dir,
81 })
82 }
83
84 pub fn scan(root: impl AsRef<Path>) -> Result<Vec<Self>, SkillError> {
86 let mut skills = Vec::new();
87 for entry in fs::read_dir(root)? {
88 let entry = entry?;
89 let path = entry.path();
90 if path.is_dir() && path.join("SKILL.md").is_file() {
91 skills.push(Self::load(&path)?);
92 }
93 }
94 Ok(skills)
95 }
96
97 pub fn name(&self) -> &str {
102 self.frontmatter.name.as_deref().unwrap_or_default()
103 }
104
105 pub fn description(&self) -> &str {
109 self.frontmatter.description.as_deref().unwrap_or_default()
110 }
111
112 pub fn disclosure_view(&self) -> String {
116 format!("{}: {}", self.name(), self.description())
117 }
118
119 pub fn full_text(&self) -> String {
121 format!(
122 "# Skill: {}\n\n## Description\n{}\n\n## Instructions\n\n{}",
123 self.name(),
124 self.description(),
125 self.body
126 )
127 }
128
129 pub fn directory(&self) -> &Path {
131 &self.dir
132 }
133}
134
135fn parse_frontmatter(raw: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
137 let stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw); if !stripped.trim_start().starts_with("---") {
139 return Err(SkillError::MissingField(
140 "frontmatter block (---...) missing".into(),
141 ));
142 }
143 let after_open = stripped.find('\n').ok_or_else(|| {
145 SkillError::Frontmatter(format!("{}: no newline after opening ---", path.display()))
146 })?;
147 let rest = &stripped[after_open..];
148 let close = rest.find("\n---").ok_or_else(|| {
149 SkillError::Frontmatter(format!("{}: no closing --- for frontmatter", path.display()))
150 })?;
151 let yaml = &rest[..close];
152 let body = &rest[close + 4..];
153
154 let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml)
155 .map_err(|e| SkillError::Frontmatter(format!("{}: {}", path.display(), e)))?;
156 match &frontmatter.name {
157 Some(n) if !n.trim().is_empty() => {}
158 _ => return Err(SkillError::MissingField("name".into())),
159 }
160 match &frontmatter.description {
161 Some(d) if !d.trim().is_empty() => {}
162 _ => return Err(SkillError::MissingField("description".into())),
163 }
164 Ok((frontmatter, body.trim_matches('\n').to_string()))
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use std::io::Write;
171
172 fn write_skill(dir: &Path, name: &str, description: &str, body: &str) -> PathBuf {
173 std::fs::create_dir_all(dir).unwrap();
174 let frontmatter = format!("---\nname: {name}\ndescription: {description}\n---\n\n");
175 let p = dir.join("SKILL.md");
176 let mut f = std::fs::File::create(&p).unwrap();
177 f.write_all(format!("{frontmatter}{body}").as_bytes()).unwrap();
178 p
179 }
180
181 #[test]
182 fn parses_frontmatter_and_body() {
183 let dir = tempfile::tempdir().unwrap();
184 write_skill(dir.path(), "web_search", "Searches the web", "Do a search then summarize.");
185 let skill = Skill::load(dir.path()).unwrap();
186 assert_eq!(skill.name(), "web_search");
187 assert_eq!(skill.description(), "Searches the web");
188 assert_eq!(skill.full_text().contains("Do a search then summarize."), true);
189 }
190
191 #[test]
192 fn disclosure_view_excludes_body() {
193 let dir = tempfile::tempdir().unwrap();
194 write_skill(dir.path(), "secret", "Just a name", "<!-- SECRET BODY -->");
195 let skill = Skill::load(dir.path()).unwrap();
196 assert_eq!(skill.disclosure_view(), "secret: Just a name");
197 assert_eq!(skill.disclosure_view().contains("SECRET BODY"), false);
198 assert_eq!(skill.full_text().contains("SECRET BODY"), true);
199 }
200
201 #[test]
202 fn missing_skill_file_errors() {
203 let dir = tempfile::tempdir().unwrap();
204 let err = Skill::load(dir.path()).unwrap_err();
205 assert!(matches!(err, SkillError::MissingSkillFile(_)));
206 }
207
208 #[test]
209 fn missing_required_field_errors() {
210 let dir = tempfile::tempdir().unwrap();
211 let p = dir.path().join("SKILL.md");
212 std::fs::write(&p, "---\ndescription: no name here\n---\n\nbody").unwrap();
213 let err = Skill::load(dir.path()).unwrap_err();
214 assert!(matches!(err, SkillError::MissingField(_)));
215 }
216
217 #[test]
218 fn scan_finds_only_skill_dirs() {
219 let root = tempfile::tempdir().unwrap();
220 write_skill(&root.path().join("a"), "a", "skill A", "body a");
221 write_skill(&root.path().join("b"), "b", "skill B", "body b");
222 std::fs::create_dir(root.path().join("plain")).unwrap();
223 let skills = Skill::scan(root.path()).unwrap();
224 assert_eq!(skills.len(), 2);
225 }
226}