agent_code_lib/skills/
mod.rs1use serde::Deserialize;
15use std::path::{Path, PathBuf};
16use tracing::{debug, warn};
17
18#[derive(Debug, Clone)]
20pub struct Skill {
21 pub name: String,
23 pub metadata: SkillMetadata,
25 pub body: String,
27 pub source: PathBuf,
29}
30
31#[derive(Debug, Clone, Default, Deserialize)]
33#[serde(default)]
34pub struct SkillMetadata {
35 pub description: Option<String>,
37 #[serde(rename = "whenToUse")]
39 pub when_to_use: Option<String>,
40 #[serde(rename = "userInvocable")]
42 pub user_invocable: bool,
43 #[serde(rename = "disableNonInteractive")]
45 pub disable_non_interactive: bool,
46 pub paths: Option<Vec<String>>,
48}
49
50impl Skill {
51 pub fn expand(&self, args: Option<&str>) -> String {
53 let mut body = self.body.clone();
54 if let Some(args) = args {
55 body = body.replace("{{arg}}", args);
56 body = body.replace("{{ arg }}", args);
57 }
58 body
59 }
60}
61
62pub struct SkillRegistry {
64 skills: Vec<Skill>,
65}
66
67impl SkillRegistry {
68 pub fn new() -> Self {
69 Self { skills: Vec::new() }
70 }
71
72 pub fn load_all(project_root: Option<&Path>) -> Self {
74 let mut registry = Self::new();
75
76 if let Some(root) = project_root {
78 let project_skills = root.join(".agent").join("skills");
79 if project_skills.is_dir() {
80 registry.load_from_dir(&project_skills);
81 }
82 }
83
84 if let Some(dir) = user_skills_dir()
86 && dir.is_dir()
87 {
88 registry.load_from_dir(&dir);
89 }
90
91 debug!("Loaded {} skills", registry.skills.len());
92 registry
93 }
94
95 fn load_from_dir(&mut self, dir: &Path) {
97 let entries = match std::fs::read_dir(dir) {
98 Ok(entries) => entries,
99 Err(e) => {
100 warn!("Failed to read skills directory {}: {e}", dir.display());
101 return;
102 }
103 };
104
105 for entry in entries.flatten() {
106 let path = entry.path();
107
108 let skill_path = if path.is_file() && path.extension().is_some_and(|e| e == "md") {
110 path.clone()
111 } else if path.is_dir() {
112 let skill_md = path.join("SKILL.md");
113 if skill_md.exists() {
114 skill_md
115 } else {
116 continue;
117 }
118 } else {
119 continue;
120 };
121
122 match load_skill_file(&skill_path) {
123 Ok(skill) => {
124 debug!(
125 "Loaded skill '{}' from {}",
126 skill.name,
127 skill_path.display()
128 );
129 self.skills.push(skill);
130 }
131 Err(e) => {
132 warn!("Failed to load skill {}: {e}", skill_path.display());
133 }
134 }
135 }
136 }
137
138 pub fn find(&self, name: &str) -> Option<&Skill> {
140 self.skills.iter().find(|s| s.name == name)
141 }
142
143 pub fn user_invocable(&self) -> Vec<&Skill> {
145 self.skills
146 .iter()
147 .filter(|s| s.metadata.user_invocable)
148 .collect()
149 }
150
151 pub fn all(&self) -> &[Skill] {
153 &self.skills
154 }
155}
156
157fn load_skill_file(path: &Path) -> Result<Skill, String> {
159 let content = std::fs::read_to_string(path).map_err(|e| format!("Read error: {e}"))?;
160
161 let name = path
163 .parent()
164 .and_then(|p| {
165 if path.file_name().is_some_and(|f| f == "SKILL.md") {
167 p.file_name().and_then(|n| n.to_str())
168 } else {
169 None
170 }
171 })
172 .or_else(|| path.file_stem().and_then(|s| s.to_str()))
173 .unwrap_or("unknown")
174 .to_string();
175
176 let (metadata, body) = parse_frontmatter(&content)?;
178
179 Ok(Skill {
180 name,
181 metadata,
182 body,
183 source: path.to_path_buf(),
184 })
185}
186
187fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String), String> {
197 let trimmed = content.trim_start();
198
199 if !trimmed.starts_with("---") {
200 return Ok((SkillMetadata::default(), content.to_string()));
202 }
203
204 let after_first = &trimmed[3..];
206 let closing = after_first
207 .find("\n---")
208 .ok_or("Frontmatter not closed (missing closing ---)")?;
209
210 let yaml = &after_first[..closing].trim();
211 let body = &after_first[closing + 4..].trim_start();
212
213 let metadata: SkillMetadata = serde_yaml_parse(yaml)?;
214
215 Ok((metadata, body.to_string()))
216}
217
218fn serde_yaml_parse(yaml: &str) -> Result<SkillMetadata, String> {
221 let mut map = serde_json::Map::new();
223
224 for line in yaml.lines() {
225 let line = line.trim();
226 if line.is_empty() || line.starts_with('#') {
227 continue;
228 }
229 if let Some((key, value)) = line.split_once(':') {
230 let key = key.trim();
231 let value = value.trim().trim_matches('"').trim_matches('\'');
232
233 let json_value = match value {
235 "true" => serde_json::Value::Bool(true),
236 "false" => serde_json::Value::Bool(false),
237 _ => serde_json::Value::String(value.to_string()),
238 };
239 map.insert(key.to_string(), json_value);
240 }
241 }
242
243 let json = serde_json::Value::Object(map);
244 serde_json::from_value(json).map_err(|e| format!("Invalid frontmatter: {e}"))
245}
246
247fn user_skills_dir() -> Option<PathBuf> {
249 dirs::config_dir().map(|d| d.join("agent-code").join("skills"))
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn test_parse_frontmatter() {
258 let content = "---\ndescription: Test skill\nuserInvocable: true\n---\n\nDo the thing.";
259 let (meta, body) = parse_frontmatter(content).unwrap();
260 assert_eq!(meta.description, Some("Test skill".to_string()));
261 assert!(meta.user_invocable);
262 assert_eq!(body, "Do the thing.");
263 }
264
265 #[test]
266 fn test_parse_no_frontmatter() {
267 let content = "Just a prompt with no frontmatter.";
268 let (meta, body) = parse_frontmatter(content).unwrap();
269 assert!(meta.description.is_none());
270 assert_eq!(body, content);
271 }
272
273 #[test]
274 fn test_skill_expand() {
275 let skill = Skill {
276 name: "test".into(),
277 metadata: SkillMetadata::default(),
278 body: "Review {{arg}} carefully.".into(),
279 source: PathBuf::from("test.md"),
280 };
281 assert_eq!(skill.expand(Some("main.rs")), "Review main.rs carefully.");
282 }
283}