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 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
68pub 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 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}