1use std::collections::HashMap;
26use std::fs;
27use std::path::{Path, PathBuf};
28
29#[derive(Debug, Clone)]
31pub struct Skill {
32 pub name: String,
34 pub description: String,
36 pub file_path: PathBuf,
38 pub base_dir: PathBuf,
40 pub source: String,
42}
43
44#[derive(Debug, Clone, Default)]
46pub struct SkillSet {
47 skills: Vec<Skill>,
48}
49
50#[derive(Debug, thiserror::Error)]
90pub enum SkillError {
91 #[error("IO error reading {path}: {source}")]
92 Io {
93 path: PathBuf,
94 source: std::io::Error,
95 },
96 #[error("SKILL.md in {path} missing required frontmatter field: {field}")]
97 MissingField { path: PathBuf, field: &'static str }, #[error("SKILL.md in {path} has invalid frontmatter: {detail}")]
99 InvalidFrontmatter { path: PathBuf, detail: String },
100}
101
102impl SkillSet {
103 pub fn load(
106 dirs: &[impl AsRef<Path>], ) -> Result<Self, SkillError> {
108 let mut by_name: HashMap<String, Skill> = HashMap::new();
124
125 for (i, dir) in dirs.iter().enumerate() {
126 let dir = dir.as_ref(); if !dir.exists() {
128 continue; }
130 let source = format!("dir:{}", i);
131 let skills = load_skills_from_dir(dir, &source)?;
148 for skill in skills {
149 by_name.insert(skill.name.clone(), skill); }
151 }
152
153 let mut skills: Vec<Skill> = by_name.into_values().collect();
164 skills.sort_by(|a, b| a.name.cmp(&b.name));
175 Ok(Self { skills }) }
177
178 pub fn load_dir(
180 dir: impl AsRef<Path>, source: &str, ) -> Result<Self, SkillError> {
183 let skills = load_skills_from_dir(dir.as_ref(), source)?;
184 Ok(Self { skills })
185 }
186
187 pub fn empty() -> Self {
189 Self { skills: Vec::new() }
190 }
191
192 pub fn merge(
194 &mut self,
195 other: SkillSet, ) {
197 let mut by_name: HashMap<String, Skill> =
198 self.skills.drain(..).map(|s| (s.name.clone(), s)).collect();
199 for skill in other.skills {
200 by_name.insert(skill.name.clone(), skill);
201 }
202 self.skills = by_name.into_values().collect();
203 self.skills.sort_by(|a, b| a.name.cmp(&b.name));
204 }
205
206 pub fn skills(&self) -> &[Skill] {
208 &self.skills
209 }
210
211 pub fn len(&self) -> usize {
213 self.skills.len()
214 }
215
216 pub fn is_empty(&self) -> bool {
218 self.skills.is_empty()
219 }
220
221 pub fn format_for_prompt(&self) -> String {
236 if self.skills.is_empty() {
237 return String::new();
238 }
239
240 let mut out = String::from("<available_skills>\n");
241 for skill in &self.skills {
242 out.push_str(" <skill>\n");
243 out.push_str(&format!(" <name>{}</name>\n", xml_escape(&skill.name)));
244 out.push_str(&format!(
245 " <description>{}</description>\n",
246 xml_escape(&skill.description)
247 ));
248 out.push_str(&format!(
249 " <location>{}</location>\n",
250 xml_escape(&skill.file_path.to_string_lossy())
251 ));
252 out.push_str(" </skill>\n");
253 }
254 out.push_str("</available_skills>");
255 out
256 }
257}
258
259fn load_skills_from_dir(
262 dir: &Path, source: &str, ) -> Result<Vec<Skill>, SkillError> {
265 let mut skills = Vec::new();
266
267 let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
284 path: dir.to_path_buf(),
285 source: e,
286 })?;
287
288 for entry in entries {
289 let entry = entry.map_err(|e| SkillError::Io {
290 path: dir.to_path_buf(),
291 source: e,
292 })?;
293 let path = entry.path();
294 if !path.is_dir() {
295 continue;
296 }
297
298 let skill_md = path.join("SKILL.md");
299 if !skill_md.exists() {
300 continue;
301 }
302
303 let content = fs::read_to_string(&skill_md).map_err(|e| SkillError::Io {
304 path: skill_md.clone(),
305 source: e,
306 })?;
307
308 let (name, description) = parse_frontmatter(&content, &skill_md)?;
309
310 let dir_name = path
326 .file_name()
327 .unwrap_or_default()
328 .to_string_lossy() .to_string();
330
331 let name = if name == dir_name { name } else { dir_name };
333
334 let base_dir = fs::canonicalize(&path).unwrap_or(path);
335 let file_path = base_dir.join("SKILL.md");
336
337 skills.push(Skill {
338 name,
339 description,
340 file_path,
341 base_dir,
342 source: source.to_string(),
343 });
344 }
345
346 skills.sort_by(|a, b| a.name.cmp(&b.name));
347 Ok(skills)
348}
349
350fn parse_frontmatter(
353 content: &str, path: &Path, ) -> Result<(String, String), SkillError> {
356 let trimmed = content.trim_start();
357 if !trimmed.starts_with("---") {
358 return Err(SkillError::InvalidFrontmatter {
359 path: path.to_path_buf(),
360 detail: "missing opening ---".into(),
361 });
362 }
363
364 let after_open = &trimmed[3..];
365 let end = after_open
366 .find("\n---")
367 .ok_or(SkillError::InvalidFrontmatter {
368 path: path.to_path_buf(),
369 detail: "missing closing ---".into(),
370 })?;
371
372 let yaml_block = &after_open[..end];
373
374 let mut name = None;
375 let mut description = None;
376
377 for line in yaml_block.lines() {
378 let line = line.trim();
379 if let Some(rest) = line.strip_prefix("name:") {
380 name = Some(unquote(rest.trim()));
381 } else if let Some(rest) = line.strip_prefix("description:") {
382 description = Some(unquote(rest.trim()));
383 }
384 }
385
386 let name = name.ok_or(SkillError::MissingField {
387 path: path.to_path_buf(),
388 field: "name",
389 })?;
390 let description = description.ok_or(SkillError::MissingField {
391 path: path.to_path_buf(),
392 field: "description",
393 })?;
394
395 if name.is_empty() {
396 return Err(SkillError::MissingField {
397 path: path.to_path_buf(),
398 field: "name",
399 });
400 }
401 if description.is_empty() {
402 return Err(SkillError::MissingField {
403 path: path.to_path_buf(),
404 field: "description",
405 });
406 }
407
408 Ok((name, description))
409}
410
411fn unquote(s: &str) -> String {
413 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
414 s[1..s.len() - 1].to_string()
415 } else {
416 s.to_string()
417 }
418}
419
420fn xml_escape(s: &str) -> String {
422 s.replace('&', "&")
423 .replace('<', "<")
424 .replace('>', ">")
425 .replace('"', """)
426 .replace('\'', "'")
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use std::fs;
433 use tempfile::TempDir;
434
435 fn create_skill(dir: &Path, name: &str, description: &str) {
436 let skill_dir = dir.join(name);
437 fs::create_dir_all(&skill_dir).unwrap();
438 fs::write(
439 skill_dir.join("SKILL.md"),
440 format!(
441 "---\nname: {}\ndescription: {}\n---\n\n# {}\n\nInstructions here.\n",
442 name, description, name
443 ),
444 )
445 .unwrap();
446 }
447
448 #[test]
449 fn load_skills_from_directory() {
450 let tmp = TempDir::new().unwrap();
451 create_skill(tmp.path(), "weather", "Get current weather and forecasts.");
452 create_skill(tmp.path(), "git", "Git operations: commit, branch, merge.");
453
454 let skills = SkillSet::load(&[tmp.path()]).unwrap();
455 assert_eq!(skills.len(), 2);
456 assert_eq!(skills.skills()[0].name, "git");
457 assert_eq!(skills.skills()[1].name, "weather");
458 }
459
460 #[test]
461 fn format_for_prompt_xml() {
462 let tmp = TempDir::new().unwrap();
463 create_skill(tmp.path(), "weather", "Get weather.");
464
465 let skills = SkillSet::load(&[tmp.path()]).unwrap();
466 let prompt = skills.format_for_prompt();
467
468 assert!(prompt.contains("<available_skills>"));
469 assert!(prompt.contains("<name>weather</name>"));
470 assert!(prompt.contains("<description>Get weather.</description>"));
471 assert!(prompt.contains("SKILL.md</location>"));
472 assert!(prompt.contains("</available_skills>"));
473 }
474
475 #[test]
476 fn empty_when_no_skills() {
477 let tmp = TempDir::new().unwrap();
478 let skills = SkillSet::load(&[tmp.path()]).unwrap();
479 assert!(skills.is_empty());
480 assert_eq!(skills.format_for_prompt(), "");
481 }
482
483 #[test]
484 fn later_dirs_override_earlier() {
485 let dir1 = TempDir::new().unwrap();
486 let dir2 = TempDir::new().unwrap();
487 create_skill(dir1.path(), "weather", "Old description.");
488 create_skill(dir2.path(), "weather", "New description.");
489
490 let skills = SkillSet::load(&[dir1.path(), dir2.path()]).unwrap();
491 assert_eq!(skills.len(), 1);
492 assert_eq!(skills.skills()[0].description, "New description.");
493 }
494
495 #[test]
496 fn skips_nonexistent_dirs() {
497 let skills = SkillSet::load(&[Path::new("/nonexistent/path")]).unwrap();
498 assert!(skills.is_empty());
499 }
500
501 #[test]
502 fn skips_dirs_without_skill_md() {
503 let tmp = TempDir::new().unwrap();
504 fs::create_dir_all(tmp.path().join("not-a-skill")).unwrap();
505 fs::write(tmp.path().join("not-a-skill/README.md"), "hello").unwrap();
506
507 let skills = SkillSet::load(&[tmp.path()]).unwrap();
508 assert!(skills.is_empty());
509 }
510
511 #[test]
512 fn error_on_missing_frontmatter() {
513 let tmp = TempDir::new().unwrap();
514 let skill_dir = tmp.path().join("bad-skill");
515 fs::create_dir_all(&skill_dir).unwrap();
516 fs::write(skill_dir.join("SKILL.md"), "# No frontmatter\n").unwrap();
517
518 let result = SkillSet::load(&[tmp.path()]);
519 assert!(result.is_err());
520 }
521
522 #[test]
523 fn error_on_missing_name() {
524 let tmp = TempDir::new().unwrap();
525 let skill_dir = tmp.path().join("no-name");
526 fs::create_dir_all(&skill_dir).unwrap();
527 fs::write(
528 skill_dir.join("SKILL.md"),
529 "---\ndescription: Has desc but no name.\n---\n",
530 )
531 .unwrap();
532
533 let result = SkillSet::load(&[tmp.path()]);
534 assert!(result.is_err());
535 }
536
537 #[test]
538 fn error_on_missing_description() {
539 let tmp = TempDir::new().unwrap();
540 let skill_dir = tmp.path().join("no-desc");
541 fs::create_dir_all(&skill_dir).unwrap();
542 fs::write(skill_dir.join("SKILL.md"), "---\nname: no-desc\n---\n").unwrap();
543
544 let result = SkillSet::load(&[tmp.path()]);
545 assert!(result.is_err());
546 }
547
548 #[test]
549 fn quoted_frontmatter_values() {
550 let tmp = TempDir::new().unwrap();
551 let skill_dir = tmp.path().join("quoted");
552 fs::create_dir_all(&skill_dir).unwrap();
553 fs::write(
554 skill_dir.join("SKILL.md"),
555 "---\nname: \"quoted\"\ndescription: 'A quoted description.'\n---\n",
556 )
557 .unwrap();
558
559 let skills = SkillSet::load(&[tmp.path()]).unwrap();
560 assert_eq!(skills.skills()[0].name, "quoted");
561 assert_eq!(skills.skills()[0].description, "A quoted description.");
562 }
563
564 #[test]
565 fn xml_escaping() {
566 let tmp = TempDir::new().unwrap();
567 let skill_dir = tmp.path().join("escape-test");
568 fs::create_dir_all(&skill_dir).unwrap();
569 fs::write(
570 skill_dir.join("SKILL.md"),
571 "---\nname: escape-test\ndescription: Uses <tags> & \"quotes\"\n---\n",
572 )
573 .unwrap();
574
575 let skills = SkillSet::load(&[tmp.path()]).unwrap();
576 let prompt = skills.format_for_prompt();
577 assert!(prompt.contains("<tags>"));
578 assert!(prompt.contains("&"));
579 assert!(prompt.contains(""quotes""));
580 }
581
582 #[test]
583 fn merge_skill_sets() {
584 let dir1 = TempDir::new().unwrap();
585 let dir2 = TempDir::new().unwrap();
586 create_skill(dir1.path(), "weather", "Weather v1.");
587 create_skill(dir1.path(), "git", "Git operations.");
588 create_skill(dir2.path(), "weather", "Weather v2.");
589 create_skill(dir2.path(), "docker", "Docker management.");
590
591 let mut set1 = SkillSet::load(&[dir1.path()]).unwrap();
592 let set2 = SkillSet::load(&[dir2.path()]).unwrap();
593 set1.merge(set2);
594
595 assert_eq!(set1.len(), 3);
596 let names: Vec<&str> = set1.skills().iter().map(|s| s.name.as_str()).collect();
597 assert_eq!(names, vec!["docker", "git", "weather"]);
598 assert_eq!(
600 set1.skills()
601 .iter()
602 .find(|s| s.name == "weather")
603 .unwrap()
604 .description,
605 "Weather v2."
606 );
607 }
608
609 #[test]
610 fn load_real_agentskills_format() {
611 let tmp = TempDir::new().unwrap();
613 let skill_dir = tmp.path().join("nano-banana-pro");
614 fs::create_dir_all(&skill_dir).unwrap();
615 fs::write(
616 skill_dir.join("SKILL.md"),
617 r#"---
618name: nano-banana-pro
619description: Generate or edit images via Gemini 3 Pro Image.
620metadata:
621 {
622 "openclaw":
623 {
624 "emoji": "🍌",
625 "requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"] },
626 },
627 }
628---
629
630# Nano Banana Pro
631
632Use the bundled script to generate images.
633"#,
634 )
635 .unwrap();
636
637 let skills = SkillSet::load(&[tmp.path()]).unwrap();
638 assert_eq!(skills.len(), 1);
639 assert_eq!(skills.skills()[0].name, "nano-banana-pro");
640 assert_eq!(
641 skills.skills()[0].description,
642 "Generate or edit images via Gemini 3 Pro Image."
643 );
644 }
645}