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        // One unreadable skill must never stop the agent from starting, so the
56        // read is reported like a parse failure instead of propagating.
57        match read_metadata_head(&path).and_then(|source| parse_skill_metadata(&source, &path)) {
58            Ok(metadata) => {
59                skills.insert(metadata.name.clone(), metadata);
60            }
61            Err(error) => eprintln!("warning: skipping skill {}: {error}", path.display()),
62        }
63    }
64    Ok(())
65}
66
67/// Reads enough of a `SKILL.md` to hold its frontmatter.
68///
69/// The cap is a byte count, so it can land inside a multi-byte character. The
70/// bytes are decoded lossily rather than strictly: a skill whose body is long and
71/// not ASCII is completely normal, and refusing to decode it used to abort
72/// startup for every skill that followed.
73fn read_metadata_head(path: &Path) -> Result<String> {
74    let mut bytes = Vec::new();
75    let mut reader: Take<File> = File::open(path)
76        .with_context(|| format!("open {}", path.display()))?
77        .take(METADATA_LIMIT);
78    reader
79        .read_to_end(&mut bytes)
80        .with_context(|| format!("read {}", path.display()))?;
81    Ok(String::from_utf8_lossy(&bytes).into_owned())
82}
83
84/// Reads the `name` and `description` of a skill from its YAML frontmatter.
85///
86/// Only top-level keys are read, so a nested `metadata` map cannot shadow them.
87/// Values may be plain, quoted, or block scalars. An unquoted colon is kept as
88/// part of the value instead of failing, because skills written for other
89/// clients frequently contain them.
90pub fn parse_skill_metadata(source: &str, path: &Path) -> Result<SkillMetadata> {
91    let directory = path
92        .parent()
93        .and_then(Path::file_name)
94        .and_then(|name| name.to_str())
95        .context("skill path has no UTF-8 directory name")?;
96    let frontmatter =
97        frontmatter(source).context("no YAML frontmatter delimited by --- was found")?;
98    let fields = top_level_fields(frontmatter);
99
100    let description = fields
101        .get("description")
102        .map(|value| value.trim().to_owned())
103        .unwrap_or_default();
104    if description.is_empty() {
105        anyhow::bail!("description is required and must not be empty");
106    }
107
108    let name = match fields.get("name").map(|name| name.trim()) {
109        Some(name) if !name.is_empty() => {
110            if name != directory {
111                eprintln!(
112                    "warning: skill {} declares name {name:?} but its directory is {directory:?}",
113                    path.display()
114                );
115            }
116            if name.chars().count() > 64 {
117                eprintln!("warning: skill name {name:?} exceeds 64 characters");
118            }
119            name.to_owned()
120        }
121        _ => directory.to_owned(),
122    };
123
124    Ok(SkillMetadata {
125        name,
126        description,
127        path: path.to_path_buf(),
128    })
129}
130
131fn frontmatter(source: &str) -> Option<&str> {
132    let rest = source
133        .strip_prefix("---\n")
134        .or_else(|| source.strip_prefix("---\r\n"))?;
135    let mut offset = 0;
136    for line in rest.lines() {
137        if line.trim_end() == "---" {
138            return Some(&rest[..offset]);
139        }
140        offset += line.len() + 1;
141    }
142    None
143}
144
145fn top_level_fields(frontmatter: &str) -> BTreeMap<String, String> {
146    let mut fields = BTreeMap::new();
147    let mut lines = frontmatter.lines().peekable();
148    while let Some(line) = lines.next() {
149        // An indented line continues the value of an enclosing key; it is never
150        // a key of its own.
151        if line.starts_with(' ') || line.starts_with('\t') || line.trim().is_empty() {
152            continue;
153        }
154        let Some((key, value)) = line.split_once(':') else {
155            continue;
156        };
157        let key = key.trim().to_owned();
158        let value = value.trim();
159        let value = if matches!(value, "|" | "|-" | "|+" | ">" | ">-" | ">+") {
160            let folded = value.starts_with('>');
161            let mut parts = Vec::new();
162            while let Some(next) = lines.peek() {
163                let indented = next.starts_with(' ') || next.starts_with('\t');
164                if !indented && !next.trim().is_empty() {
165                    break;
166                }
167                parts.push(lines.next().unwrap_or_default().trim().to_owned());
168            }
169            while parts.last().is_some_and(|part| part.is_empty()) {
170                parts.pop();
171            }
172            if folded {
173                parts.join(" ")
174            } else {
175                parts.join("\n")
176            }
177        } else {
178            unquote(value)
179        };
180        fields.insert(key, value);
181    }
182    fields
183}
184
185fn unquote(value: &str) -> String {
186    for quote in ['"', '\''] {
187        if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
188            return value[1..value.len() - 1].to_owned();
189        }
190    }
191    value.to_owned()
192}