Skip to main content

tiny_agent/skills/
mod.rs

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/// 从一个或多个目录发现 skills 的管理器。
57///
58/// 发现规则:
59/// - `skill_dir/SKILL.md` 存在时,`skill_dir` 自身是一个 skill;
60/// - `skill_root/*/SKILL.md` 存在时,每个子目录是一个 skill;
61/// - skill id 使用目录名;
62/// - skill name 与 description 来自 `SKILL.md` 的 YAML frontmatter。
63#[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}