Skip to main content

a_agent/context/
skills.rs

1use std::collections::BTreeMap;
2use std::fs::{self, File};
3use std::io::{Read, Take};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7
8const METADATA_LIMIT: u64 = 8192;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct SkillMetadata {
12    pub name: String,
13    pub description: String,
14    pub path: PathBuf,
15}
16
17pub fn discover_skills(global_root: &Path, project_root: &Path) -> Result<Vec<SkillMetadata>> {
18    let mut skills = BTreeMap::new();
19    index_root(global_root, &mut skills)?;
20    index_root(project_root, &mut skills)?;
21    Ok(skills.into_values().collect())
22}
23
24fn index_root(root: &Path, skills: &mut BTreeMap<String, SkillMetadata>) -> Result<()> {
25    if !root.is_dir() {
26        return Ok(());
27    }
28    let mut entries = fs::read_dir(root)
29        .with_context(|| format!("read skills directory {}", root.display()))?
30        .collect::<std::io::Result<Vec<_>>>()?;
31    entries.sort_by_key(|entry| entry.file_name());
32    for entry in entries {
33        if !entry.file_type()?.is_dir() {
34            continue;
35        }
36        let path = entry.path().join("SKILL.md");
37        if !path.is_file() {
38            continue;
39        }
40        let mut source = String::new();
41        let mut reader: Take<File> = File::open(&path)?.take(METADATA_LIMIT);
42        reader.read_to_string(&mut source)?;
43        let metadata = parse_skill_metadata(&source, &path)?;
44        skills.insert(metadata.name.clone(), metadata);
45    }
46    Ok(())
47}
48
49pub fn parse_skill_metadata(source: &str, path: &Path) -> Result<SkillMetadata> {
50    let fallback_name = path
51        .parent()
52        .and_then(Path::file_name)
53        .and_then(|name| name.to_str())
54        .context("skill path has no UTF-8 directory name")?;
55    let mut name = None;
56    let mut description = None;
57
58    if source.starts_with("---\n") || source.starts_with("---\r\n") {
59        for line in source
60            .lines()
61            .skip(1)
62            .take_while(|line| line.trim() != "---")
63        {
64            if let Some((key, value)) = line.split_once(':') {
65                let value = value.trim().trim_matches(['\'', '"']);
66                match key.trim() {
67                    "name" => name = Some(value.to_owned()),
68                    "description" => description = Some(value.to_owned()),
69                    _ => {}
70                }
71            }
72        }
73    }
74    if description.is_none() {
75        description = source.lines().find_map(|line| {
76            let line = line.trim();
77            (!line.is_empty() && line != "---" && !line.starts_with('#')).then(|| line.to_owned())
78        });
79    }
80    Ok(SkillMetadata {
81        name: name.unwrap_or_else(|| fallback_name.to_owned()),
82        description: description.unwrap_or_else(|| "No description provided.".into()),
83        path: path.to_path_buf(),
84    })
85}