1use serde::{Deserialize, Serialize};
2use serde_json::json;
3use std::{
4 collections::HashMap,
5 fs,
6 path::{Component, Path, PathBuf},
7};
8
9const SKILL_DOC: &str = "SKILL.md";
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SkillIndexEntry {
13 pub id: String,
14 pub name: String,
15 pub description: String,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SkillReadResult {
20 pub skill_id: String,
21 pub path: Option<String>,
22 pub content: String,
23 pub resources: Vec<SkillResourceIndexEntry>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SkillResourceIndexEntry {
28 pub path: String,
29 pub description: Option<String>,
30}
31
32#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
33pub enum SkillError {
34 #[error("skill root does not exist: {0}")]
35 RootNotFound(String),
36 #[error("skill not found: {0}")]
37 NotFound(String),
38 #[error("skill resource not found: {skill_id}:{path}")]
39 ResourceNotFound { skill_id: String, path: String },
40 #[error("invalid skill path: {0}")]
41 InvalidPath(String),
42 #[error("invalid skill metadata in {skill_id}: {message}")]
43 InvalidMetadata { skill_id: String, message: String },
44 #[error("skill io error: {0}")]
45 Io(String),
46}
47
48#[derive(Debug, Clone)]
49struct SkillEntry {
50 id: String,
51 name: String,
52 description: String,
53 dir: PathBuf,
54}
55
56#[derive(Debug, Default)]
64pub struct SkillManager {
65 skills: HashMap<String, SkillEntry>,
66}
67
68impl SkillManager {
69 pub fn discover_dir(path: impl AsRef<Path>) -> Result<Self, SkillError> {
70 Self::discover_dirs([path])
71 }
72
73 pub fn discover_dirs<I, P>(paths: I) -> Result<Self, SkillError>
74 where
75 I: IntoIterator<Item = P>,
76 P: AsRef<Path>,
77 {
78 let mut manager = Self::default();
79 for path in paths {
80 manager.discover_one(path.as_ref())?;
81 }
82 Ok(manager)
83 }
84
85 pub fn list(&self) -> Vec<SkillIndexEntry> {
86 let mut entries = self
87 .skills
88 .values()
89 .map(|skill| SkillIndexEntry {
90 id: skill.id.clone(),
91 name: skill.name.clone(),
92 description: skill.description.clone(),
93 })
94 .collect::<Vec<_>>();
95 entries.sort_by(|a, b| a.id.cmp(&b.id));
96 entries
97 }
98
99 pub fn read(&self, skill_id: &str, path: Option<&str>) -> Result<SkillReadResult, SkillError> {
100 let skill = self
101 .skills
102 .get(skill_id)
103 .ok_or_else(|| SkillError::NotFound(skill_id.to_string()))?;
104 let resources = resource_index(&skill.dir)?;
105
106 match path {
107 Some(path) => {
108 validate_relative_path(path)?;
109 let resource_path = skill.dir.join(path);
110 if !resource_path.is_file() || path == SKILL_DOC {
111 return Err(SkillError::ResourceNotFound {
112 skill_id: skill_id.to_string(),
113 path: path.to_string(),
114 });
115 }
116 Ok(SkillReadResult {
117 skill_id: skill.id.clone(),
118 path: Some(path.to_string()),
119 content: fs::read_to_string(resource_path)
120 .map_err(|e| SkillError::Io(e.to_string()))?,
121 resources,
122 })
123 }
124 None => Ok(SkillReadResult {
125 skill_id: skill.id.clone(),
126 path: None,
127 content: fs::read_to_string(skill.dir.join(SKILL_DOC))
128 .map_err(|e| SkillError::Io(e.to_string()))?,
129 resources,
130 }),
131 }
132 }
133
134 fn discover_one(&mut self, root: &Path) -> Result<(), SkillError> {
135 if !root.exists() {
136 return Err(SkillError::RootNotFound(root.display().to_string()));
137 }
138 if root.join(SKILL_DOC).is_file() {
139 self.insert_skill_dir(root)?;
140 }
141 if root.is_dir() {
142 for entry in fs::read_dir(root).map_err(|e| SkillError::Io(e.to_string()))? {
143 let entry = entry.map_err(|e| SkillError::Io(e.to_string()))?;
144 let path = entry.path();
145 if path.is_dir() && path.join(SKILL_DOC).is_file() {
146 self.insert_skill_dir(&path)?;
147 }
148 }
149 }
150 Ok(())
151 }
152
153 fn insert_skill_dir(&mut self, dir: &Path) -> Result<(), SkillError> {
154 let id = dir
155 .file_name()
156 .and_then(|name| name.to_str())
157 .ok_or_else(|| SkillError::InvalidPath(dir.display().to_string()))?
158 .to_string();
159 let doc =
160 fs::read_to_string(dir.join(SKILL_DOC)).map_err(|e| SkillError::Io(e.to_string()))?;
161 let (name, description) = parse_skill_doc(&id, &doc)?;
162 self.skills.insert(
163 id.clone(),
164 SkillEntry {
165 id,
166 name,
167 description,
168 dir: dir.to_path_buf(),
169 },
170 );
171 Ok(())
172 }
173}
174
175#[derive(Debug, Deserialize)]
176struct SkillFrontmatter {
177 name: String,
178 description: String,
179}
180
181impl SkillReadResult {
182 pub fn into_value(self) -> serde_json::Value {
183 json!({
184 "skill_id": self.skill_id,
185 "path": self.path,
186 "content": self.content,
187 "resources": self.resources,
188 })
189 }
190}
191
192fn parse_skill_doc(id: &str, doc: &str) -> Result<(String, String), SkillError> {
193 let frontmatter = extract_frontmatter(id, doc)?;
194 let metadata = serde_yaml::from_str::<SkillFrontmatter>(&frontmatter).map_err(|e| {
195 SkillError::InvalidMetadata {
196 skill_id: id.to_string(),
197 message: e.to_string(),
198 }
199 })?;
200 let name = metadata.name.trim();
201 let description = metadata.description.trim();
202 if name.is_empty() {
203 return Err(SkillError::InvalidMetadata {
204 skill_id: id.to_string(),
205 message: "missing or empty `name`".to_string(),
206 });
207 }
208 if description.is_empty() {
209 return Err(SkillError::InvalidMetadata {
210 skill_id: id.to_string(),
211 message: "missing or empty `description`".to_string(),
212 });
213 }
214 Ok((name.to_string(), description.to_string()))
215}
216
217fn extract_frontmatter(id: &str, doc: &str) -> Result<String, SkillError> {
218 let doc = doc.strip_prefix('\u{feff}').unwrap_or(doc);
219 let mut lines = doc.lines();
220 match lines.next() {
221 Some(line) if line.trim() == "---" => {}
222 _ => {
223 return Err(SkillError::InvalidMetadata {
224 skill_id: id.to_string(),
225 message: "`SKILL.md` must start with YAML frontmatter".to_string(),
226 });
227 }
228 }
229
230 let mut frontmatter = Vec::new();
231 for line in lines {
232 if line.trim() == "---" {
233 return Ok(frontmatter.join("\n"));
234 }
235 frontmatter.push(line);
236 }
237 Err(SkillError::InvalidMetadata {
238 skill_id: id.to_string(),
239 message: "missing closing frontmatter delimiter".to_string(),
240 })
241}
242
243fn resource_index(skill_dir: &Path) -> Result<Vec<SkillResourceIndexEntry>, SkillError> {
244 let mut out = Vec::new();
245 collect_resources(skill_dir, skill_dir, &mut out)?;
246 out.sort_by(|a, b| a.path.cmp(&b.path));
247 Ok(out)
248}
249
250fn collect_resources(
251 base: &Path,
252 dir: &Path,
253 out: &mut Vec<SkillResourceIndexEntry>,
254) -> Result<(), SkillError> {
255 for entry in fs::read_dir(dir).map_err(|e| SkillError::Io(e.to_string()))? {
256 let entry = entry.map_err(|e| SkillError::Io(e.to_string()))?;
257 let path = entry.path();
258 if path.is_dir() {
259 collect_resources(base, &path, out)?;
260 continue;
261 }
262 let rel = path
263 .strip_prefix(base)
264 .map_err(|e| SkillError::InvalidPath(e.to_string()))?;
265 let rel = rel.to_string_lossy().replace('\\', "/");
266 if rel == SKILL_DOC {
267 continue;
268 }
269 out.push(SkillResourceIndexEntry {
270 path: rel,
271 description: None,
272 });
273 }
274 Ok(())
275}
276
277fn validate_relative_path(path: &str) -> Result<(), SkillError> {
278 let path = Path::new(path);
279 if path.is_absolute() {
280 return Err(SkillError::InvalidPath(path.display().to_string()));
281 }
282 for component in path.components() {
283 if matches!(
284 component,
285 Component::ParentDir | Component::RootDir | Component::Prefix(_)
286 ) {
287 return Err(SkillError::InvalidPath(path.display().to_string()));
288 }
289 }
290 Ok(())
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn parses_required_yaml_frontmatter() {
299 let doc = r#"---
300name: "docs"
301description: |
302 Work with document files.
303---
304
305# Documents
306
307main skill instructions
308"#;
309
310 let (name, description) = parse_skill_doc("docs", doc).unwrap();
311
312 assert_eq!(name, "docs");
313 assert_eq!(description, "Work with document files.");
314 }
315
316 #[test]
317 fn rejects_skill_doc_without_frontmatter() {
318 let err = parse_skill_doc("docs", "# Documents\n\nWork with document files").unwrap_err();
319
320 assert!(matches!(
321 err,
322 SkillError::InvalidMetadata { skill_id, .. } if skill_id == "docs"
323 ));
324 }
325}