a_agent/context/
skills.rs1use 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 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 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
67fn 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
84pub 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 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}