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
17/// Directories scanned for Agent Skills, lowest precedence first.
18///
19/// The Agent Skills specification defines what a skill directory contains but
20/// not where it lives. Only the shared `.agents/skills` convention is used, so a
21/// skill installed by any compliant client is visible here and vice versa. The
22/// user scope is scanned before the project scope, so a project skill takes
23/// precedence over a user skill with the same name.
24pub fn skill_roots(home: &Path, project_root: &Path) -> Vec<PathBuf> {
25    vec![
26        home.join(".agents/skills"),
27        project_root.join(".agents/skills"),
28    ]
29}
30
31pub fn discover_skills(roots: &[PathBuf]) -> Result<Vec<SkillMetadata>> {
32    let mut skills = BTreeMap::new();
33    for root in roots {
34        index_root(root, &mut skills)?;
35    }
36    Ok(skills.into_values().collect())
37}
38
39fn index_root(root: &Path, skills: &mut BTreeMap<String, SkillMetadata>) -> Result<()> {
40    if !root.is_dir() {
41        return Ok(());
42    }
43    let mut entries = fs::read_dir(root)
44        .with_context(|| format!("read skills directory {}", root.display()))?
45        .collect::<std::io::Result<Vec<_>>>()?;
46    entries.sort_by_key(|entry| entry.file_name());
47    for entry in entries {
48        if !entry.file_type()?.is_dir() {
49            continue;
50        }
51        let path = entry.path().join("SKILL.md");
52        if !path.is_file() {
53            continue;
54        }
55        let mut source = String::new();
56        let mut reader: Take<File> = File::open(&path)?.take(METADATA_LIMIT);
57        reader.read_to_string(&mut source)?;
58        match parse_skill_metadata(&source, &path) {
59            Ok(metadata) => {
60                skills.insert(metadata.name.clone(), metadata);
61            }
62            Err(error) => eprintln!("warning: skipping skill {}: {error}", path.display()),
63        }
64    }
65    Ok(())
66}
67
68/// Reads the `name` and `description` of a skill from its YAML frontmatter.
69///
70/// Only top-level keys are read, so a nested `metadata` map cannot shadow them.
71/// Values may be plain, quoted, or block scalars. An unquoted colon is kept as
72/// part of the value instead of failing, because skills written for other
73/// clients frequently contain them.
74pub fn parse_skill_metadata(source: &str, path: &Path) -> Result<SkillMetadata> {
75    let directory = path
76        .parent()
77        .and_then(Path::file_name)
78        .and_then(|name| name.to_str())
79        .context("skill path has no UTF-8 directory name")?;
80    let frontmatter =
81        frontmatter(source).context("no YAML frontmatter delimited by --- was found")?;
82    let fields = top_level_fields(frontmatter);
83
84    let description = fields
85        .get("description")
86        .map(|value| value.trim().to_owned())
87        .unwrap_or_default();
88    if description.is_empty() {
89        anyhow::bail!("description is required and must not be empty");
90    }
91
92    let name = match fields.get("name").map(|name| name.trim()) {
93        Some(name) if !name.is_empty() => {
94            if name != directory {
95                eprintln!(
96                    "warning: skill {} declares name {name:?} but its directory is {directory:?}",
97                    path.display()
98                );
99            }
100            if name.chars().count() > 64 {
101                eprintln!("warning: skill name {name:?} exceeds 64 characters");
102            }
103            name.to_owned()
104        }
105        _ => directory.to_owned(),
106    };
107
108    Ok(SkillMetadata {
109        name,
110        description,
111        path: path.to_path_buf(),
112    })
113}
114
115fn frontmatter(source: &str) -> Option<&str> {
116    let rest = source
117        .strip_prefix("---\n")
118        .or_else(|| source.strip_prefix("---\r\n"))?;
119    let mut offset = 0;
120    for line in rest.lines() {
121        if line.trim_end() == "---" {
122            return Some(&rest[..offset]);
123        }
124        offset += line.len() + 1;
125    }
126    None
127}
128
129fn top_level_fields(frontmatter: &str) -> BTreeMap<String, String> {
130    let mut fields = BTreeMap::new();
131    let mut lines = frontmatter.lines().peekable();
132    while let Some(line) = lines.next() {
133        // An indented line continues the value of an enclosing key; it is never
134        // a key of its own.
135        if line.starts_with(' ') || line.starts_with('\t') || line.trim().is_empty() {
136            continue;
137        }
138        let Some((key, value)) = line.split_once(':') else {
139            continue;
140        };
141        let key = key.trim().to_owned();
142        let value = value.trim();
143        let value = if matches!(value, "|" | "|-" | "|+" | ">" | ">-" | ">+") {
144            let folded = value.starts_with('>');
145            let mut parts = Vec::new();
146            while let Some(next) = lines.peek() {
147                let indented = next.starts_with(' ') || next.starts_with('\t');
148                if !indented && !next.trim().is_empty() {
149                    break;
150                }
151                parts.push(lines.next().unwrap_or_default().trim().to_owned());
152            }
153            while parts.last().is_some_and(|part| part.is_empty()) {
154                parts.pop();
155            }
156            if folded {
157                parts.join(" ")
158            } else {
159                parts.join("\n")
160            }
161        } else {
162            unquote(value)
163        };
164        fields.insert(key, value);
165    }
166    fields
167}
168
169fn unquote(value: &str) -> String {
170    for quote in ['"', '\''] {
171        if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
172            return value[1..value.len() - 1].to_owned();
173        }
174    }
175    value.to_owned()
176}