1use std::collections::HashSet;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone)]
13pub struct Skill {
14 pub name: String,
16 pub description: String,
18 pub file_path: PathBuf,
20 pub base_dir: PathBuf,
22 pub disable_model_invocation: bool,
24}
25
26#[derive(Debug)]
28pub struct LoadSkillsOptions<'a> {
29 pub cwd: &'a Path,
31 pub agent_dir: &'a Path,
33 pub extra_skill_paths: &'a [PathBuf],
35 pub include_defaults: bool,
37}
38
39fn parse_frontmatter(content: &str) -> (Option<String>, Option<String>, bool) {
44 let content = content.trim_start();
45 if !content.starts_with("---") {
46 return (None, None, false);
47 }
48
49 let end = match content[3..].find("---") {
50 Some(pos) => pos + 3,
51 None => return (None, None, false),
52 };
53 let front = &content[3..3 + end];
54
55 let mut name: Option<String> = None;
57 let mut description: Option<String> = None;
58 let mut disable = false;
59
60 for line in front.lines() {
61 let line = line.trim();
62 if let Some(stripped) = line.strip_prefix("name:") {
63 let val = stripped.trim().trim_matches('"').to_string();
64 if !val.is_empty() {
65 name = Some(val);
66 }
67 } else if let Some(stripped) = line.strip_prefix("description:") {
68 let val = stripped.trim().trim_matches('"').to_string();
69 if !val.is_empty() {
70 description = Some(val);
71 }
72 } else if let Some(stripped) = line.strip_prefix("disable-model-invocation:") {
73 let val = stripped.trim();
74 disable = val == "true" || val == "yes" || val == "1";
75 }
76 }
77
78 (name, description, disable)
79}
80
81fn load_skill_from_file(file_path: &Path) -> Option<Skill> {
83 let content = fs::read_to_string(file_path).ok()?;
84 let (name, description, disable) = parse_frontmatter(&content);
85
86 let name = name.unwrap_or_else(|| {
87 file_path
89 .parent()
90 .and_then(|p| p.file_name())
91 .and_then(|s| s.to_str())
92 .unwrap_or("unnamed")
93 .to_string()
94 });
95
96 let description = description.unwrap_or_default();
97 let canonical_path = fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf());
98 let base_dir = canonical_path
99 .parent()
100 .map(|p| p.to_path_buf())
101 .unwrap_or_else(|| PathBuf::from("/"));
102
103 Some(Skill {
104 name,
105 description,
106 file_path: canonical_path,
107 base_dir,
108 disable_model_invocation: disable,
109 })
110}
111
112fn discover_skill_dirs(cwd: &Path, agent_dir: &Path, include_defaults: bool) -> Vec<PathBuf> {
120 let mut dirs = Vec::new();
121
122 if include_defaults {
123 dirs.push(agent_dir.join("skills"));
125 if let Some(home) = directories::BaseDirs::new().map(|d| d.home_dir().to_path_buf()) {
126 dirs.push(home.join(".agents").join("skills"));
127 }
128
129 let mut current = Some(cwd.to_path_buf());
131 while let Some(dir) = current {
132 dirs.push(dir.join(".rab").join("skills"));
133 dirs.push(dir.join(".agents").join("skills"));
134 let parent = match dir.parent() {
135 Some(p) if p != dir => p.to_path_buf(),
136 _ => break,
137 };
138 current = Some(parent);
139 }
140 }
141
142 dirs
143}
144
145fn find_skill_files(dir: &Path) -> Vec<PathBuf> {
147 let mut files = Vec::new();
148 let entries = match fs::read_dir(dir) {
149 Ok(e) => e,
150 Err(_) => return files,
151 };
152
153 for entry in entries.flatten() {
154 let path = entry.path();
155 if path.is_dir() {
156 let skill_file = path.join("SKILL.md");
157 if skill_file.exists() {
158 files.push(skill_file);
159 }
160 } else if path.is_file() && path.file_name().is_some_and(|n| n == "SKILL.md") {
161 files.push(path);
162 }
163 }
164
165 files
166}
167
168pub fn load_skills(options: LoadSkillsOptions) -> Vec<Skill> {
170 let mut seen_paths = HashSet::new();
171 let mut skills = Vec::new();
172
173 if options.include_defaults {
175 let dirs = discover_skill_dirs(options.cwd, options.agent_dir, true);
176 for dir in dirs {
177 for file_path in find_skill_files(&dir) {
178 let canon = fs::canonicalize(&file_path).unwrap_or_else(|_| file_path.clone());
179 if seen_paths.insert(canon)
180 && let Some(skill) = load_skill_from_file(&file_path)
181 {
182 skills.push(skill);
183 }
184 }
185 }
186 }
187
188 for path in options.extra_skill_paths {
190 let resolved = if path.is_absolute() {
191 path.clone()
192 } else {
193 options.cwd.join(path)
194 };
195
196 if resolved.is_dir() {
197 for file_path in find_skill_files(&resolved) {
198 let canon = fs::canonicalize(&file_path).unwrap_or_else(|_| file_path.clone());
199 if seen_paths.insert(canon)
200 && let Some(skill) = load_skill_from_file(&file_path)
201 {
202 skills.push(skill);
203 }
204 }
205 } else if resolved.is_file() {
206 let canon = fs::canonicalize(&resolved).unwrap_or(resolved);
207 if seen_paths.insert(canon.clone())
208 && let Some(skill) = load_skill_from_file(&canon)
209 {
210 skills.push(skill);
211 }
212 }
213 }
214
215 skills
216}
217
218pub fn format_skills_for_prompt(skills: &[Skill]) -> String {
222 let visible: Vec<&Skill> = skills
223 .iter()
224 .filter(|s| !s.disable_model_invocation)
225 .collect();
226
227 if visible.is_empty() {
228 return String::new();
229 }
230
231 let mut lines = vec![
232 String::new(),
233 String::new(),
234 "The following skills provide specialized instructions for specific tasks.".to_string(),
235 "Use the read tool to load a skill's file when the task matches its description.".to_string(),
236 "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.".to_string(),
237 String::new(),
238 "<available_skills>".to_string(),
239 ];
240
241 for skill in &visible {
242 lines.push(" <skill>".to_string());
243 lines.push(format!(" <name>{}</name>", escape_xml(&skill.name)));
244 lines.push(format!(
245 " <description>{}</description>",
246 escape_xml(&skill.description)
247 ));
248 lines.push(format!(
249 " <location>{}</location>",
250 escape_xml(&skill.file_path.to_string_lossy())
251 ));
252 lines.push(" </skill>".to_string());
253 }
254
255 lines.push("</available_skills>".to_string());
256 lines.join("\n")
257}
258
259fn escape_xml(s: &str) -> String {
260 s.replace('&', "&")
261 .replace('<', "<")
262 .replace('>', ">")
263 .replace('"', """)
264 .replace('\'', "'")
265}
266
267pub fn strip_frontmatter(content: &str) -> String {
272 let content = content.trim_start();
273 if !content.starts_with("---") {
274 return content.to_string();
275 }
276
277 let remaining = &content[3..];
278 let end = match remaining.find("---") {
279 Some(pos) => pos,
280 None => return content.to_string(),
281 };
282
283 let body_start = 3 + end + 3;
285 content[body_start..].trim().to_string()
286}
287
288pub fn read_skill_body(file_path: &Path) -> Option<String> {
290 let content = std::fs::read_to_string(file_path).ok()?;
291 Some(strip_frontmatter(&content))
292}
293
294pub fn format_skill_invocation(skill: &Skill, additional_instructions: Option<&str>) -> String {
306 let body = read_skill_body(&skill.file_path).unwrap_or_default();
307 let base_dir_str = skill.base_dir.to_string_lossy();
308 let skill_block = format!(
309 "<skill name=\"{}\" location=\"{}\">\nReferences are relative to {}.\n\n{}\n</skill>",
310 escape_xml(&skill.name),
311 escape_xml(&skill.file_path.to_string_lossy()),
312 base_dir_str,
313 body
314 );
315 match additional_instructions {
316 Some(instr) if !instr.is_empty() => format!("{}\n\n{}", skill_block, instr),
317 _ => skill_block,
318 }
319}
320
321pub fn expand_skill_command(text: &str, skills: &[Skill]) -> String {
324 if !text.starts_with("/skill:") {
325 return text.to_string();
326 }
327
328 let rest = &text[7..]; let (skill_name, args) = match rest.find(' ') {
330 Some(pos) => (&rest[..pos], rest[pos + 1..].trim()),
331 None => (rest, ""),
332 };
333
334 let skill = skills.iter().find(|s| s.name == skill_name);
335 match skill {
336 Some(s) => format_skill_invocation(s, if args.is_empty() { None } else { Some(args) }),
337 None => text.to_string(), }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use std::fs;
345 use tempfile::TempDir;
346
347 fn create_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
348 let skill_dir = dir.join(name);
349 fs::create_dir_all(&skill_dir).unwrap();
350 let path = skill_dir.join("SKILL.md");
351 fs::write(&path, content).unwrap();
352 path
353 }
354
355 #[test]
356 fn test_load_skill_with_frontmatter() {
357 let tmp = TempDir::new().unwrap();
358 create_skill(
359 tmp.path(),
360 "my-skill",
361 r#"---
362name: my-skill
363description: My custom skill
364---
365
366# My Skill
367
368Do something specific.
369"#,
370 );
371
372 let skills = load_skills(LoadSkillsOptions {
373 cwd: tmp.path(),
374 agent_dir: tmp.path(),
375 extra_skill_paths: &[],
376 include_defaults: false,
377 });
378 assert!(skills.is_empty(), "no default dirs in tmp");
379
380 let skills = load_skills(LoadSkillsOptions {
381 cwd: tmp.path(),
382 agent_dir: tmp.path(),
383 extra_skill_paths: &[tmp.path().join("my-skill")],
384 include_defaults: false,
385 });
386 assert_eq!(skills.len(), 1);
387 assert_eq!(skills[0].name, "my-skill");
388 assert_eq!(skills[0].description, "My custom skill");
389 assert!(!skills[0].disable_model_invocation);
390 }
391
392 #[test]
393 fn test_skill_without_frontmatter_uses_filename() {
394 let tmp = TempDir::new().unwrap();
395 create_skill(tmp.path(), "simple-skill", "# Just some instructions");
396
397 let skills = load_skills(LoadSkillsOptions {
398 cwd: tmp.path(),
399 agent_dir: tmp.path(),
400 extra_skill_paths: &[tmp.path().join("simple-skill")],
401 include_defaults: false,
402 });
403 assert_eq!(skills.len(), 1);
404 assert_eq!(skills[0].name, "simple-skill");
405 assert_eq!(skills[0].description, "");
406 }
407
408 #[test]
409 fn test_disable_model_invocation() {
410 let tmp = TempDir::new().unwrap();
411 create_skill(
412 tmp.path(),
413 "hidden-skill",
414 r#"---
415name: hidden-skill
416description: Should not auto-invoke
417disable-model-invocation: true
418---
419
420# Hidden
421"#,
422 );
423
424 let skills = load_skills(LoadSkillsOptions {
425 cwd: tmp.path(),
426 agent_dir: tmp.path(),
427 extra_skill_paths: &[tmp.path().join("hidden-skill")],
428 include_defaults: false,
429 });
430 assert_eq!(skills.len(), 1);
431 assert!(skills[0].disable_model_invocation);
432 }
433
434 #[test]
435 fn test_format_skills_for_prompt() {
436 let skills = vec![
437 Skill {
438 name: "code-review".to_string(),
439 description: "Reviews code for bugs".to_string(),
440 file_path: PathBuf::from("/home/user/.rab/agent/skills/code-review/SKILL.md"),
441 base_dir: PathBuf::from("/home/user/.rab/agent/skills/code-review"),
442 disable_model_invocation: false,
443 },
444 Skill {
445 name: "hidden".to_string(),
446 description: "Hidden skill".to_string(),
447 file_path: PathBuf::from("/home/user/.rab/agent/skills/hidden/SKILL.md"),
448 base_dir: PathBuf::from("/home/user/.rab/agent/skills/hidden"),
449 disable_model_invocation: true,
450 },
451 ];
452
453 let result = format_skills_for_prompt(&skills);
454 assert!(result.contains("<available_skills>"));
455 assert!(result.contains("<name>code-review</name>"));
456 assert!(result.contains("<description>Reviews code for bugs</description>"));
457 assert!(
458 result
459 .contains("<location>/home/user/.rab/agent/skills/code-review/SKILL.md</location>")
460 );
461 assert!(!result.contains("hidden"), "disabled skills are excluded");
462 }
463
464 #[test]
465 fn test_format_skills_empty() {
466 assert!(format_skills_for_prompt(&[]).is_empty());
467 }
468
469 #[test]
470 fn test_format_skills_all_disabled() {
471 let skills = vec![Skill {
472 name: "hidden".to_string(),
473 description: "Hidden".to_string(),
474 file_path: PathBuf::from("/tmp/SKILL.md"),
475 base_dir: PathBuf::from("/tmp"),
476 disable_model_invocation: true,
477 }];
478 assert!(format_skills_for_prompt(&skills).is_empty());
479 }
480
481 #[test]
482 fn test_xml_escaping() {
483 let skills = vec![Skill {
484 name: "escape<test>".to_string(),
485 description: "description with & special chars".to_string(),
486 file_path: PathBuf::from("/tmp/skill's file\"name\".md"),
487 base_dir: PathBuf::from("/tmp"),
488 disable_model_invocation: false,
489 }];
490
491 let result = format_skills_for_prompt(&skills);
492 assert!(result.contains("<test>"));
493 assert!(result.contains("&"));
494 assert!(result.contains("'"));
495 assert!(result.contains("""));
496 }
497
498 #[test]
499 fn test_parse_frontmatter_minimal() {
500 let (name, desc, disable) = parse_frontmatter(
501 r#"---
502name: my-skill
503---"#,
504 );
505 assert_eq!(name.as_deref(), Some("my-skill"));
506 assert_eq!(desc, None);
507 assert!(!disable);
508 }
509
510 #[test]
511 fn test_parse_frontmatter_no_delimiters() {
512 let (name, desc, disable) = parse_frontmatter("# Just markdown");
513 assert_eq!(name, None);
514 assert_eq!(desc, None);
515 assert!(!disable);
516 }
517
518 #[test]
519 fn test_parse_frontmatter_all_fields() {
520 let (name, desc, disable) = parse_frontmatter(
521 r#"---
522name: my-skill
523description: Does things
524disable-model-invocation: true
525---"#,
526 );
527 assert_eq!(name.as_deref(), Some("my-skill"));
528 assert_eq!(desc.as_deref(), Some("Does things"));
529 assert!(disable);
530 }
531
532 #[test]
533 fn test_duplicate_path_deduplication() {
534 let tmp = TempDir::new().unwrap();
535 create_skill(tmp.path(), "dup-skill", "# Skill content");
536
537 let path = tmp.path().join("dup-skill").join("SKILL.md");
539 let skills = load_skills(LoadSkillsOptions {
540 cwd: tmp.path(),
541 agent_dir: tmp.path(),
542 extra_skill_paths: &[path.clone(), path],
543 include_defaults: false,
544 });
545 assert_eq!(skills.len(), 1);
546 }
547
548 #[test]
551 fn test_strip_frontmatter_basic() {
552 let result = strip_frontmatter(
553 r"---
554name: my-skill
555description: A test
556---
557
558This is the body.
559",
560 );
561 assert_eq!(result, "This is the body.");
562 }
563
564 #[test]
565 fn test_strip_frontmatter_no_frontmatter() {
566 let result = strip_frontmatter("Just body text");
567 assert_eq!(result, "Just body text");
568 }
569
570 #[test]
571 fn test_strip_frontmatter_partial_delimiters() {
572 let result = strip_frontmatter("---\nname: broken\n");
574 assert_eq!(result, "---\nname: broken\n");
575 }
576
577 #[test]
578 fn test_strip_frontmatter_empty_body() {
579 let result = strip_frontmatter(
580 r"---
581name: empty
582---
583
584",
585 );
586 assert_eq!(result, "");
587 }
588
589 #[test]
590 fn test_strip_frontmatter_leading_whitespace() {
591 let result = strip_frontmatter(" \n---\nname: test\n---\n\nBody content");
593 assert_eq!(result, "Body content");
594 }
595
596 #[test]
597 fn test_strip_frontmatter_crlf_newlines() {
598 let result = strip_frontmatter("---\r\nname: test\r\n---\r\n\r\nBody text");
599 assert_eq!(result, "Body text");
600 }
601
602 #[test]
605 fn test_read_skill_body_from_file() {
606 use std::fs;
607 let tmp = TempDir::new().unwrap();
608 let path = tmp.path().join("SKILL.md");
609 fs::write(
610 &path,
611 r"---
612name: test-skill
613---
614
615# Actual content
616",
617 )
618 .unwrap();
619
620 let body = read_skill_body(&path);
621 assert_eq!(body, Some("# Actual content".to_string()));
622 }
623
624 #[test]
625 fn test_read_skill_body_no_frontmatter() {
626 use std::fs;
627 let tmp = TempDir::new().unwrap();
628 let path = tmp.path().join("SKILL.md");
629 fs::write(&path, "Just content\n").unwrap();
630
631 let body = read_skill_body(&path);
632 assert_eq!(body, Some("Just content\n".to_string()));
633 }
634
635 #[test]
636 fn test_read_skill_body_missing_file() {
637 let body = read_skill_body(Path::new("/nonexistent/SKILL.md"));
638 assert_eq!(body, None);
639 }
640
641 #[test]
644 fn test_format_skill_invocation_basic() {
645 use std::fs;
646 let tmp = TempDir::new().unwrap();
647 let skill_dir = tmp.path().join("test-skill");
648 fs::create_dir_all(&skill_dir).unwrap();
649 let skill_path = skill_dir.join("SKILL.md");
650 fs::write(
651 &skill_path,
652 r"---
653name: test-skill
654description: A test skill
655---
656
657Do the thing.
658",
659 )
660 .unwrap();
661
662 let skill = Skill {
663 name: "test-skill".to_string(),
664 description: "A test skill".to_string(),
665 file_path: fs::canonicalize(&skill_path).unwrap_or(skill_path.clone()),
666 base_dir: skill_dir.clone(),
667 disable_model_invocation: false,
668 };
669
670 let result = format_skill_invocation(&skill, None);
671 assert!(result.starts_with("<skill name=\"test-skill\""));
672 assert!(result.contains("References are relative to"));
673 assert!(result.contains("Do the thing."));
674 assert!(result.ends_with("</skill>"));
675 }
676
677 #[test]
678 fn test_format_skill_invocation_with_args() {
679 use std::fs;
680 let tmp = TempDir::new().unwrap();
681 let skill_dir = tmp.path().join("review-skill");
682 fs::create_dir_all(&skill_dir).unwrap();
683 let skill_path = skill_dir.join("SKILL.md");
684 fs::write(
685 &skill_path,
686 r"---
687name: review
688---
689
690Check the code.
691",
692 )
693 .unwrap();
694
695 let skill = Skill {
696 name: "review".to_string(),
697 description: "".to_string(),
698 file_path: fs::canonicalize(&skill_path).unwrap_or(skill_path.clone()),
699 base_dir: skill_dir,
700 disable_model_invocation: false,
701 };
702
703 let result = format_skill_invocation(&skill, Some("Focus on security."));
704 assert!(result.contains("Check the code."));
705 assert!(result.contains("Focus on security."));
706 }
707
708 #[test]
709 fn test_format_skill_invocation_missing_file() {
710 let skill = Skill {
711 name: "missing".to_string(),
712 description: "Missing skill".to_string(),
713 file_path: PathBuf::from("/nonexistent/SKILL.md"),
714 base_dir: PathBuf::from("/nonexistent"),
715 disable_model_invocation: false,
716 };
717
718 let result = format_skill_invocation(&skill, None);
719 assert!(result.starts_with("<skill"));
721 assert!(result.ends_with("</skill>"));
722 }
723
724 #[test]
727 fn test_expand_skill_command_basic() {
728 use std::fs;
729 let tmp = TempDir::new().unwrap();
730 let skill_dir = tmp.path().join("code-review");
731 fs::create_dir_all(&skill_dir).unwrap();
732 fs::write(
733 skill_dir.join("SKILL.md"),
734 r"---
735name: code-review
736---
737
738Review the code for bugs.
739",
740 )
741 .unwrap();
742
743 let skill_path = fs::canonicalize(skill_dir.join("SKILL.md")).unwrap_or_default();
744 let skills = vec![Skill {
745 name: "code-review".to_string(),
746 description: "".to_string(),
747 file_path: skill_path,
748 base_dir: skill_dir,
749 disable_model_invocation: false,
750 }];
751
752 let result = expand_skill_command("/skill:code-review", &skills);
753 assert!(result.contains("Review the code for bugs."));
754 assert!(result.starts_with("<skill"));
755 }
756
757 #[test]
758 fn test_expand_skill_command_with_args() {
759 use std::fs;
760 let tmp = TempDir::new().unwrap();
761 let skill_dir = tmp.path().join("test");
762 fs::create_dir_all(&skill_dir).unwrap();
763 fs::write(
764 skill_dir.join("SKILL.md"),
765 r"---
766name: test
767---
768
769Run tests.
770",
771 )
772 .unwrap();
773
774 let skill_path = fs::canonicalize(skill_dir.join("SKILL.md")).unwrap_or_default();
775 let skills = vec![Skill {
776 name: "test".to_string(),
777 description: "".to_string(),
778 file_path: skill_path,
779 base_dir: skill_dir,
780 disable_model_invocation: false,
781 }];
782
783 let result = expand_skill_command("/skill:test focus on unit tests", &skills);
784 assert!(result.contains("Run tests."));
785 assert!(result.contains("focus on unit tests"));
786 }
787
788 #[test]
789 fn test_expand_skill_command_unknown_skill() {
790 let skills: Vec<Skill> = vec![];
791 let result = expand_skill_command("/skill:nonexistent", &skills);
792 assert_eq!(result, "/skill:nonexistent");
794 }
795
796 #[test]
797 fn test_expand_skill_command_not_a_skill_command() {
798 let skills: Vec<Skill> = vec![];
799 let result = expand_skill_command("regular message", &skills);
800 assert_eq!(result, "regular message");
801 }
802
803 #[test]
804 fn test_expand_skill_command_no_frontmatter_file() {
805 use std::fs;
806 let tmp = TempDir::new().unwrap();
807 let skill_dir = tmp.path().join("simple");
809 fs::create_dir_all(&skill_dir).unwrap();
810 fs::write(skill_dir.join("SKILL.md"), "# Simple\nJust instructions.\n").unwrap();
811
812 let skill_path = fs::canonicalize(skill_dir.join("SKILL.md")).unwrap_or_default();
813 let skills = vec![Skill {
814 name: "simple".to_string(),
815 description: "".to_string(),
816 file_path: skill_path,
817 base_dir: skill_dir,
818 disable_model_invocation: false,
819 }];
820
821 let result = expand_skill_command("/skill:simple", &skills);
822 assert!(result.contains("Just instructions."));
824 }
825}