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