1use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, PartialEq, Default)]
11pub enum SkillMode {
12 Always,
14 Trigger,
16 #[default]
18 Manual,
19}
20
21#[derive(Debug, Clone)]
23pub struct Skill {
24 pub name: String,
26 pub description: String,
28 pub path: PathBuf,
30 pub mode: SkillMode,
32 pub triggers: Vec<String>,
34 pub hint: String,
37 pub depends_on: Vec<String>,
39 pub refs: Vec<SkillRef>,
41 pub params: Vec<SkillParam>,
43 pub scripts: Vec<SkillScript>,
45 pub sections: Vec<SkillSection>,
47}
48
49#[derive(Debug, Clone)]
51pub struct SkillSection {
52 pub name: String,
54 pub content: String,
57}
58
59#[derive(Debug, Clone)]
61pub struct SkillRef {
62 pub name: String,
64 pub path: PathBuf,
66}
67
68#[derive(Debug, Clone)]
70pub struct SkillParam {
71 pub name: String,
73 pub description: String,
75 pub default: Option<String>,
77}
78
79#[derive(Debug, Clone)]
81pub struct SkillScript {
82 pub name: String,
84 pub path: PathBuf,
86 pub description: String,
88}
89
90pub fn discover_skills(search_paths: &[PathBuf]) -> Vec<Skill> {
100 let mut skills = Vec::new();
101
102 for base in search_paths {
103 if !base.is_dir() {
104 continue;
105 }
106
107 if let Ok(entries) = fs::read_dir(base) {
108 for entry in entries.flatten() {
109 let dir_path = entry.path();
110 if !dir_path.is_dir() {
111 continue;
112 }
113
114 let skill_file = dir_path.join("SKILL.md");
115 if !skill_file.is_file() {
116 continue;
117 }
118
119 if let Ok(content) = fs::read_to_string(&skill_file) {
120 let (name, description, mode, triggers, hint, depends_on, params) =
121 parse_skill_meta(&content, &dir_path);
122 let refs = discover_refs(&dir_path);
123 let scripts = discover_scripts(&dir_path);
124 let sections = parse_sections(&content);
125 skills.push(Skill {
126 name,
127 description,
128 path: skill_file,
129 mode,
130 triggers,
131 hint,
132 depends_on,
133 refs,
134 params,
135 scripts,
136 sections,
137 });
138 }
139 }
140 }
141 }
142
143 skills
144}
145
146fn parse_sections(content: &str) -> Vec<SkillSection> {
153 let body = extract_body(content);
154 let mut sections = Vec::new();
155 let mut lines = body.lines().peekable();
156
157 while let Some(line) = lines.next() {
158 if let Some(heading) = line.strip_prefix("## ") {
159 let name = heading.trim().to_string();
160 let mut section_lines = Vec::new();
161
162 while let Some(next) = lines.peek() {
164 if next.starts_with("## ") {
165 break;
166 }
167 section_lines.push(lines.next().unwrap());
168 }
169
170 let content = section_lines.join("\n").trim().to_string();
171 sections.push(SkillSection { name, content });
172 }
173 }
174
175 sections
176}
177
178fn discover_refs(skill_dir: &Path) -> Vec<SkillRef> {
180 let refs_dir = skill_dir.join("refs");
181 if !refs_dir.is_dir() {
182 return Vec::new();
183 }
184
185 let mut refs = Vec::new();
186 if let Ok(entries) = fs::read_dir(&refs_dir) {
187 for entry in entries.flatten() {
188 let path = entry.path();
189 if path.is_file() {
190 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
191 if ext == "md" || ext == "txt" {
192 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
193 refs.push(SkillRef {
194 name: stem.to_string(),
195 path,
196 });
197 }
198 }
199 }
200 }
201 }
202 }
203
204 refs
205}
206
207fn discover_scripts(skill_dir: &Path) -> Vec<SkillScript> {
213 let scripts_dir = skill_dir.join("scripts");
214 if !scripts_dir.is_dir() {
215 return Vec::new();
216 }
217
218 let mut scripts = Vec::new();
219 if let Ok(entries) = fs::read_dir(&scripts_dir) {
220 for entry in entries.flatten() {
221 let path = entry.path();
222 if !path.is_file() {
223 continue;
224 }
225
226 let is_exec = is_executable(&path);
228 let has_script_ext = path
229 .extension()
230 .and_then(|e| e.to_str())
231 .map(|e| matches!(e, "sh" | "py" | "rb" | "js"))
232 .unwrap_or(false);
233
234 if !is_exec && !has_script_ext {
235 continue;
236 }
237
238 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
239 let description = extract_script_description(&path);
240 scripts.push(SkillScript {
241 name: stem.to_string(),
242 path,
243 description,
244 });
245 }
246 }
247 }
248
249 scripts
250}
251
252#[cfg(unix)]
254fn is_executable(path: &Path) -> bool {
255 use std::os::unix::fs::PermissionsExt;
256 std::fs::metadata(path)
257 .map(|m| m.permissions().mode() & 0o111 != 0)
258 .unwrap_or(false)
259}
260
261#[cfg(not(unix))]
262fn is_executable(_path: &Path) -> bool {
263 false
264}
265
266fn extract_script_description(path: &Path) -> String {
272 let content = match fs::read_to_string(path) {
273 Ok(c) => c,
274 Err(_) => return String::new(),
275 };
276
277 let mut lines = content.lines();
278 let first = lines.next();
279
280 let target = match first {
282 Some(l) if l.starts_with("#!") => lines.next(),
283 other => other,
284 };
285
286 match target {
287 Some(l) if l.starts_with("# ") || l.starts_with("#") => l
288 .trim_start_matches("# ")
289 .trim_start_matches('#')
290 .trim()
291 .to_string(),
292 Some(l) if l.starts_with("// ") || l.starts_with("//") => l
293 .trim_start_matches("// ")
294 .trim_start_matches("//")
295 .trim()
296 .to_string(),
297 _ => String::new(),
298 }
299}
300
301pub fn parse_skill_meta(
309 content: &str,
310 dir_path: &Path,
311) -> (
312 String,
313 String,
314 SkillMode,
315 Vec<String>,
316 String,
317 Vec<String>,
318 Vec<SkillParam>,
319) {
320 if let Some(frontmatter) = content.strip_prefix("---") {
322 if let Some(end) = frontmatter.find("---") {
323 let yaml = &frontmatter[..end];
324 let body = frontmatter[end + 3..].trim();
325
326 let mut name = None;
328 let mut description = None;
329 let mut mode = SkillMode::Manual;
330 let mut triggers = Vec::new();
331 let mut hint = String::new();
332 let mut params = Vec::new();
333 let mut depends_on = Vec::new();
334
335 let lines: Vec<&str> = yaml.lines().collect();
336 let mut i = 0;
337 while i < lines.len() {
338 let line = lines[i].trim();
339 if let Some(stripped) = line.strip_prefix("name:") {
340 name = Some(stripped.trim().to_string());
341 } else if let Some(stripped) = line.strip_prefix("description:") {
342 description = Some(stripped.trim().to_string());
343 } else if let Some(stripped) = line.strip_prefix("hint:") {
344 hint = stripped.trim().to_string();
345 } else if let Some(stripped) = line.strip_prefix("mode:") {
346 let raw = stripped.trim().to_lowercase();
347 mode = match raw.as_str() {
348 "always" => SkillMode::Always,
349 "trigger" => SkillMode::Trigger,
350 _ => SkillMode::Manual,
351 };
352 } else if let Some(stripped) = line.strip_prefix("triggers:") {
353 triggers = stripped
355 .split(',')
356 .map(|s| s.trim().to_string())
357 .filter(|s| !s.is_empty())
358 .collect();
359 } else if let Some(stripped) = line.strip_prefix("depends_on:") {
360 depends_on = stripped
362 .split(',')
363 .map(|s| s.trim().to_string())
364 .filter(|s| !s.is_empty())
365 .collect();
366 } else if line == "params:" {
367 i += 1;
369 while i < lines.len() {
370 let entry_line = lines[i];
371 if let Some(after_dash) = entry_line.trim().strip_prefix("- name:") {
373 let param_name = after_dash.trim().to_string();
374 let mut param_description = String::new();
375 let mut param_default = None;
376
377 i += 1;
379 while i < lines.len() {
380 let sub_line = lines[i];
381 let trimmed = sub_line.trim();
382 if !sub_line.starts_with(' ') && !sub_line.starts_with('\t') {
384 break;
385 }
386 if let Some(val) = trimmed.strip_prefix("description:") {
387 param_description = val.trim().to_string();
388 } else if let Some(val) = trimmed.strip_prefix("default:") {
389 param_default = Some(val.trim().to_string());
390 } else if trimmed.starts_with("- name:") {
391 i -= 1;
393 break;
394 } else if !trimmed.is_empty() && !trimmed.starts_with('-') {
395 }
397 i += 1;
398 }
399
400 params.push(SkillParam {
401 name: param_name,
402 description: param_description,
403 default: param_default,
404 });
405 } else {
406 i += 1;
408 }
409 }
410 break;
412 }
413 i += 1;
414 }
415
416 let final_name = name.unwrap_or_else(|| {
418 dir_path
419 .file_name()
420 .and_then(|n| n.to_str())
421 .unwrap_or("unnamed")
422 .to_string()
423 });
424
425 let final_description = description.unwrap_or_else(|| {
426 body.lines()
427 .find(|l| !l.trim().is_empty())
428 .map(|l| l.trim().to_string())
429 .unwrap_or_default()
430 });
431
432 if hint.is_empty() && mode == SkillMode::Trigger {
434 hint = format!("{}: {}", final_name, final_description);
435 }
436
437 return (
438 final_name,
439 final_description,
440 mode,
441 triggers,
442 hint,
443 depends_on,
444 params,
445 );
446 }
447 }
448
449 let name = dir_path
451 .file_name()
452 .and_then(|n| n.to_str())
453 .unwrap_or("unnamed")
454 .to_string();
455
456 let description = content
457 .lines()
458 .find(|l| !l.trim().is_empty())
459 .map(|l| l.trim().to_string())
460 .unwrap_or_default();
461
462 (
463 name,
464 description,
465 SkillMode::Manual,
466 Vec::new(),
467 String::new(),
468 Vec::new(),
469 Vec::new(),
470 )
471}
472
473pub fn skills_for_injection(skills: &[Skill], goal: &str) -> Vec<(String, String)> {
480 let mut result: Vec<(String, String)> = Vec::new();
481
482 for skill in skills {
483 match skill.mode {
484 SkillMode::Always => {
485 if let Ok(content) = fs::read_to_string(&skill.path) {
487 let body = extract_body(&content);
488 result.push((skill.name.clone(), body.to_string()));
489 }
490 }
491 SkillMode::Trigger => {
492 let goal_lower = goal.to_lowercase();
494 let matched = skill
495 .triggers
496 .iter()
497 .any(|t| goal_lower.contains(&t.to_lowercase()));
498 if matched {
499 result.push((skill.name.clone(), skill.hint.clone()));
501 }
502 }
503 SkillMode::Manual => {
504 }
506 }
507 }
508
509 result
510}
511
512fn extract_body(content: &str) -> &str {
514 if let Some(frontmatter) = content.strip_prefix("---") {
515 if let Some(end) = frontmatter.find("---") {
516 return frontmatter[end + 3..].trim();
517 }
518 }
519 content.trim()
520}
521
522pub fn skill_index(skills: &[Skill]) -> String {
526 if skills.is_empty() {
527 return String::new();
528 }
529
530 let mut lines = vec![
531 "".to_string(),
532 "Available skills (use `load_skill` to activate):".to_string(),
533 ];
534
535 for skill in skills {
536 let mut suffix_parts = Vec::new();
537
538 let mode_tag = match skill.mode {
540 SkillMode::Always => "[always]",
541 SkillMode::Trigger => "[trigger]",
542 SkillMode::Manual => "",
543 };
544
545 let ref_count = skill.refs.len();
547 if ref_count > 0 {
548 suffix_parts.push(format!("{ref_count} refs"));
549 }
550
551 if !skill.params.is_empty() {
553 let param_strs: Vec<String> = skill
554 .params
555 .iter()
556 .map(|p| {
557 if let Some(ref default) = p.default {
558 format!("{}={}", p.name, default)
559 } else {
560 p.name.clone()
561 }
562 })
563 .collect();
564 suffix_parts.push(format!("params: {}", param_strs.join(", ")));
565 }
566
567 if !skill.sections.is_empty() {
569 let section_names: Vec<&str> = skill.sections.iter().map(|s| s.name.as_str()).collect();
570 suffix_parts.push(format!("sections: {}", section_names.join(", ")));
571 }
572
573 if !skill.depends_on.is_empty() {
575 suffix_parts.push(format!("depends_on: {}", skill.depends_on.join(", ")));
576 }
577
578 let script_names: Vec<&str> = skill.scripts.iter().map(|s| s.name.as_str()).collect();
580
581 let prefix = if mode_tag.is_empty() {
582 format!("- {}: {}", skill.name, skill.description)
583 } else {
584 format!("- {} {}: {}", mode_tag, skill.name, skill.description)
585 };
586
587 if suffix_parts.is_empty() {
588 lines.push(prefix);
589 } else {
590 lines.push(format!("{} ({})", prefix, suffix_parts.join(", ")));
591 }
592
593 if !script_names.is_empty() {
595 let last = lines.last_mut().unwrap();
596 *last = format!("{} [scripts: {}]", last, script_names.join(", "));
597 }
598 }
599
600 lines.push("".to_string());
601 lines.join("\n")
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use std::io::Write;
608
609 #[test]
610 fn discover_skills_parses_frontmatter() {
611 let tmp = tempfile::TempDir::new().unwrap();
612 let base = tmp.path();
613
614 let rust_dir = base.join("rust-traits");
616 fs::create_dir(&rust_dir).unwrap();
617 let mut file = fs::File::create(rust_dir.join("SKILL.md")).unwrap();
618 writeln!(
619 file,
620 "---\
621 \nname: rust-traits\
622 \ndescription: Explain Rust trait design patterns\
623 \n---\
624 \n\nWhen asked about Rust traits, walk the codebase..."
625 )
626 .unwrap();
627
628 let python_dir = base.join("python-api");
630 fs::create_dir(&python_dir).unwrap();
631 let mut file = fs::File::create(python_dir.join("SKILL.md")).unwrap();
632 writeln!(file, "First line of description\n\nMore content...").unwrap();
633
634 let skills = discover_skills(&[base.to_path_buf()]);
635 assert_eq!(skills.len(), 2);
636
637 let rust = skills.iter().find(|s| s.name == "rust-traits").unwrap();
638 assert_eq!(rust.description, "Explain Rust trait design patterns");
639
640 let python = skills.iter().find(|s| s.name == "python-api").unwrap();
641 assert_eq!(python.description, "First line of description");
642 }
643
644 #[test]
645 fn skill_index_empty_for_no_skills() {
646 let result = skill_index(&[]);
647 assert_eq!(result, "");
648 }
649
650 #[test]
651 fn skill_index_renders_correctly() {
652 let skills = vec![
653 Skill {
654 name: "rust-traits".to_string(),
655 description: "Explain Rust trait design".to_string(),
656 path: PathBuf::from("/tmp/skills/rust-traits/SKILL.md"),
657 mode: SkillMode::Manual,
658 triggers: vec![],
659 hint: String::new(),
660 depends_on: vec![],
661 refs: vec![],
662 params: vec![],
663 scripts: vec![],
664 sections: vec![],
665 },
666 Skill {
667 name: "python-api".to_string(),
668 description: "Python API patterns".to_string(),
669 path: PathBuf::from("/tmp/skills/python-api/SKILL.md"),
670 mode: SkillMode::Manual,
671 triggers: vec![],
672 hint: String::new(),
673 depends_on: vec![],
674 refs: vec![],
675 params: vec![],
676 scripts: vec![],
677 sections: vec![],
678 },
679 ];
680
681 let result = skill_index(&skills);
682 assert!(result.contains("Available skills"));
683 assert!(result.contains("- rust-traits: Explain Rust trait design"));
684 assert!(result.contains("- python-api: Python API patterns"));
685 }
686
687 #[test]
688 fn discover_skills_populates_refs() {
689 let tmp = tempfile::TempDir::new().unwrap();
690 let base = tmp.path();
691
692 let skill_dir = base.join("my-skill");
694 fs::create_dir(&skill_dir).unwrap();
695 fs::write(
696 skill_dir.join("SKILL.md"),
697 "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
698 )
699 .unwrap();
700
701 let refs_dir = skill_dir.join("refs");
703 fs::create_dir(&refs_dir).unwrap();
704 fs::write(refs_dir.join("api-spec.md"), "# API Spec\n\nDetails here.").unwrap();
705 fs::write(refs_dir.join("examples.txt"), "Example 1\nExample 2").unwrap();
706 fs::write(refs_dir.join("notes.json"), "{}").unwrap();
708
709 let skills = discover_skills(&[base.to_path_buf()]);
710 assert_eq!(skills.len(), 1);
711
712 let skill = &skills[0];
713 assert_eq!(skill.name, "my-skill");
714 assert_eq!(
715 skill.refs.len(),
716 2,
717 "should find 2 ref files (md + txt), ignoring json"
718 );
719
720 let api_spec = skill.refs.iter().find(|r| r.name == "api-spec").unwrap();
721 assert!(api_spec.path.ends_with("api-spec.md"));
722
723 let examples = skill.refs.iter().find(|r| r.name == "examples").unwrap();
724 assert!(examples.path.ends_with("examples.txt"));
725 }
726
727 #[test]
728 fn discover_skills_handles_no_refs_dir() {
729 let tmp = tempfile::TempDir::new().unwrap();
730 let base = tmp.path();
731
732 let skill_dir = base.join("simple-skill");
734 fs::create_dir(&skill_dir).unwrap();
735 fs::write(
736 skill_dir.join("SKILL.md"),
737 "---\nname: simple-skill\ndescription: No refs\n---\n\nBody",
738 )
739 .unwrap();
740
741 let skills = discover_skills(&[base.to_path_buf()]);
742 assert_eq!(skills.len(), 1);
743 assert!(
744 skills[0].refs.is_empty(),
745 "skill with no refs/ dir should have empty refs"
746 );
747 }
748
749 #[test]
750 fn skill_index_shows_ref_count() {
751 let skills = vec![
752 Skill {
753 name: "with-refs".to_string(),
754 description: "Has references".to_string(),
755 path: PathBuf::from("/tmp/skills/with-refs/SKILL.md"),
756 mode: SkillMode::Manual,
757 triggers: vec![],
758 hint: String::new(),
759 depends_on: vec![],
760 refs: vec![
761 SkillRef {
762 name: "api-spec".to_string(),
763 path: PathBuf::from("/tmp/skills/with-refs/refs/api-spec.md"),
764 },
765 SkillRef {
766 name: "examples".to_string(),
767 path: PathBuf::from("/tmp/skills/with-refs/refs/examples.txt"),
768 },
769 ],
770 params: vec![],
771 scripts: vec![],
772 sections: vec![],
773 },
774 Skill {
775 name: "no-refs".to_string(),
776 description: "No references".to_string(),
777 path: PathBuf::from("/tmp/skills/no-refs/SKILL.md"),
778 mode: SkillMode::Manual,
779 triggers: vec![],
780 hint: String::new(),
781 depends_on: vec![],
782 refs: vec![],
783 params: vec![],
784 scripts: vec![],
785 sections: vec![],
786 },
787 ];
788
789 let result = skill_index(&skills);
790 assert!(result.contains("- with-refs: Has references (2 refs)"));
791 assert!(result.contains("- no-refs: No references"));
792 assert!(
793 !result.contains("(0 refs)"),
794 "should not show count for 0 refs"
795 );
796 }
797
798 #[test]
799 fn discover_skills_parses_params() {
800 let tmp = tempfile::TempDir::new().unwrap();
801 let base = tmp.path();
802
803 let skill_dir = base.join("code-review");
804 fs::create_dir(&skill_dir).unwrap();
805 fs::write(
806 skill_dir.join("SKILL.md"),
807 "---\n\
808 name: code-review\n\
809 description: Review code for quality issues\n\
810 params:\n\
811 \x20\x20- name: language\n\
812 \x20\x20 description: Target language\n\
813 \x20\x20 default: rust\n\
814 \x20\x20- name: strict\n\
815 \x20\x20 description: Enable strict mode\n\
816 ---\n\
817 \n\
818 Review {{language}} code {{#if strict}}strictly{{/if}}.",
819 )
820 .unwrap();
821
822 let skills = discover_skills(&[base.to_path_buf()]);
823 assert_eq!(skills.len(), 1);
824
825 let skill = &skills[0];
826 assert_eq!(skill.name, "code-review");
827 assert_eq!(skill.params.len(), 2);
828
829 let lang = skill.params.iter().find(|p| p.name == "language").unwrap();
830 assert_eq!(lang.description, "Target language");
831 assert_eq!(lang.default.as_deref(), Some("rust"));
832
833 let strict = skill.params.iter().find(|p| p.name == "strict").unwrap();
834 assert_eq!(strict.description, "Enable strict mode");
835 assert_eq!(strict.default, None);
836 }
837
838 #[test]
839 fn discover_skills_handles_no_params() {
840 let tmp = tempfile::TempDir::new().unwrap();
841 let base = tmp.path();
842
843 let skill_dir = base.join("simple");
844 fs::create_dir(&skill_dir).unwrap();
845 fs::write(
846 skill_dir.join("SKILL.md"),
847 "---\nname: simple\ndescription: No params\n---\n\nBody",
848 )
849 .unwrap();
850
851 let skills = discover_skills(&[base.to_path_buf()]);
852 assert_eq!(skills.len(), 1);
853 assert!(
854 skills[0].params.is_empty(),
855 "skill without params should have empty params"
856 );
857 }
858
859 #[test]
860 fn skill_index_shows_params() {
861 let skills = vec![
862 Skill {
863 name: "code-review".to_string(),
864 description: "Review code".to_string(),
865 path: PathBuf::from("/tmp/skills/code-review/SKILL.md"),
866 mode: SkillMode::Manual,
867 triggers: vec![],
868 hint: String::new(),
869 depends_on: vec![],
870 refs: vec![],
871 params: vec![
872 SkillParam {
873 name: "language".to_string(),
874 description: "Target language".to_string(),
875 default: Some("rust".to_string()),
876 },
877 SkillParam {
878 name: "strict".to_string(),
879 description: "Enable strict mode".to_string(),
880 default: None,
881 },
882 ],
883 scripts: vec![],
884 sections: vec![],
885 },
886 Skill {
887 name: "simple".to_string(),
888 description: "No params".to_string(),
889 path: PathBuf::from("/tmp/skills/simple/SKILL.md"),
890 mode: SkillMode::Manual,
891 triggers: vec![],
892 hint: String::new(),
893 depends_on: vec![],
894 refs: vec![],
895 params: vec![],
896 scripts: vec![],
897 sections: vec![],
898 },
899 ];
900
901 let result = skill_index(&skills);
902 assert!(
903 result.contains("params: language=rust, strict"),
904 "should show params with defaults: {result}"
905 );
906 assert!(
907 result.contains("- simple: No params"),
908 "should show skill without params normally"
909 );
910 }
911
912 #[test]
913 fn discover_skills_populates_scripts() {
914 let tmp = tempfile::TempDir::new().unwrap();
915 let base = tmp.path();
916
917 let skill_dir = base.join("my-skill");
919 fs::create_dir(&skill_dir).unwrap();
920 fs::write(
921 skill_dir.join("SKILL.md"),
922 "---\nname: my-skill\ndescription: A skill with scripts\n---\n\nBody",
923 )
924 .unwrap();
925
926 let scripts_dir = skill_dir.join("scripts");
928 fs::create_dir(&scripts_dir).unwrap();
929 fs::write(
930 scripts_dir.join("lint.sh"),
931 "#!/bin/sh\n# Run the linter\necho 'linting...'\n",
932 )
933 .unwrap();
934 fs::write(
935 scripts_dir.join("format.py"),
936 "#!/usr/bin/env python3\n# Format the code\nprint('formatting')\n",
937 )
938 .unwrap();
939 fs::write(scripts_dir.join("notes.txt"), "not a script").unwrap();
941
942 let skills = discover_skills(&[base.to_path_buf()]);
943 assert_eq!(skills.len(), 1);
944
945 let skill = &skills[0];
946 assert_eq!(skill.name, "my-skill");
947 assert_eq!(
948 skill.scripts.len(),
949 2,
950 "should find 2 scripts (sh + py), ignoring txt"
951 );
952
953 let lint = skill.scripts.iter().find(|s| s.name == "lint").unwrap();
954 assert!(lint.path.ends_with("lint.sh"));
955 assert_eq!(lint.description, "Run the linter");
956
957 let format = skill.scripts.iter().find(|s| s.name == "format").unwrap();
958 assert!(format.path.ends_with("format.py"));
959 assert_eq!(format.description, "Format the code");
960 }
961
962 #[test]
963 fn discover_skills_handles_no_scripts_dir() {
964 let tmp = tempfile::TempDir::new().unwrap();
965 let base = tmp.path();
966
967 let skill_dir = base.join("simple-skill");
969 fs::create_dir(&skill_dir).unwrap();
970 fs::write(
971 skill_dir.join("SKILL.md"),
972 "---\nname: simple-skill\ndescription: No scripts\n---\n\nBody",
973 )
974 .unwrap();
975
976 let skills = discover_skills(&[base.to_path_buf()]);
977 assert_eq!(skills.len(), 1);
978 assert!(
979 skills[0].scripts.is_empty(),
980 "skill with no scripts/ dir should have empty scripts"
981 );
982 }
983
984 #[test]
985 fn skill_index_shows_script_names() {
986 let skills = vec![
987 Skill {
988 name: "with-scripts".to_string(),
989 description: "Has scripts".to_string(),
990 path: PathBuf::from("/tmp/skills/with-scripts/SKILL.md"),
991 mode: SkillMode::Manual,
992 triggers: vec![],
993 hint: String::new(),
994 depends_on: vec![],
995 refs: vec![],
996 params: vec![],
997 scripts: vec![
998 SkillScript {
999 name: "lint".to_string(),
1000 path: PathBuf::from("/tmp/skills/with-scripts/scripts/lint.sh"),
1001 description: "Run the linter".to_string(),
1002 },
1003 SkillScript {
1004 name: "format".to_string(),
1005 path: PathBuf::from("/tmp/skills/with-scripts/scripts/format.py"),
1006 description: "Format the code".to_string(),
1007 },
1008 ],
1009 sections: vec![],
1010 },
1011 Skill {
1012 name: "no-scripts".to_string(),
1013 description: "No scripts".to_string(),
1014 path: PathBuf::from("/tmp/skills/no-scripts/SKILL.md"),
1015 mode: SkillMode::Manual,
1016 triggers: vec![],
1017 hint: String::new(),
1018 depends_on: vec![],
1019 refs: vec![],
1020 params: vec![],
1021 scripts: vec![],
1022 sections: vec![],
1023 },
1024 ];
1025
1026 let result = skill_index(&skills);
1027 assert!(result.contains("- with-scripts: Has scripts [scripts: lint, format]"));
1028 assert!(result.contains("- no-scripts: No scripts"));
1029 }
1030
1031 #[test]
1032 fn extract_script_description_shebang_then_comment() {
1033 let tmp = tempfile::TempDir::new().unwrap();
1034 let path = tmp.path().join("test.sh");
1035 fs::write(&path, "#!/bin/sh\n# Run the linter\necho hi\n").unwrap();
1036 assert_eq!(extract_script_description(&path), "Run the linter");
1037 }
1038
1039 #[test]
1040 fn extract_script_description_no_shebang() {
1041 let tmp = tempfile::TempDir::new().unwrap();
1042 let path = tmp.path().join("test.py");
1043 fs::write(&path, "# Format the code\nprint('hi')\n").unwrap();
1044 assert_eq!(extract_script_description(&path), "Format the code");
1045 }
1046
1047 #[test]
1048 fn extract_script_description_js_comment() {
1049 let tmp = tempfile::TempDir::new().unwrap();
1050 let path = tmp.path().join("test.js");
1051 fs::write(&path, "// Run the build\nconsole.log('hi')\n").unwrap();
1052 assert_eq!(extract_script_description(&path), "Run the build");
1053 }
1054
1055 #[test]
1056 fn extract_script_description_empty_when_no_comment() {
1057 let tmp = tempfile::TempDir::new().unwrap();
1058 let path = tmp.path().join("test.sh");
1059 fs::write(&path, "#!/bin/sh\necho hi\n").unwrap();
1060 assert_eq!(extract_script_description(&path), "");
1061 }
1062
1063 #[test]
1066 fn parse_skill_meta_parses_mode_always() {
1067 let tmp = tempfile::TempDir::new().unwrap();
1068 let dir = tmp.path().join("test-skill");
1069 fs::create_dir(&dir).unwrap();
1070 let content = "---\nname: test-skill\ndescription: A test\nmode: always\n---\n\nBody text";
1071 let (name, desc, mode, triggers, hint, _depends_on, params) =
1072 parse_skill_meta(content, &dir);
1073 assert_eq!(name, "test-skill");
1074 assert_eq!(desc, "A test");
1075 assert_eq!(mode, SkillMode::Always);
1076 assert!(triggers.is_empty());
1077 assert!(hint.is_empty());
1078 assert!(params.is_empty());
1079 }
1080
1081 #[test]
1082 fn parse_skill_meta_parses_mode_trigger_with_triggers() {
1083 let tmp = tempfile::TempDir::new().unwrap();
1084 let dir = tmp.path().join("test-skill");
1085 fs::create_dir(&dir).unwrap();
1086 let content =
1087 "---\nname: test-skill\ndescription: A test\nmode: trigger\ntriggers: rust, trait\n---\n\nBody text";
1088 let (name, desc, mode, triggers, hint, _depends_on, params) =
1089 parse_skill_meta(content, &dir);
1090 assert_eq!(name, "test-skill");
1091 assert_eq!(desc, "A test");
1092 assert_eq!(mode, SkillMode::Trigger);
1093 assert_eq!(triggers, vec!["rust", "trait"]);
1094 assert_eq!(hint, "test-skill: A test");
1096 assert!(params.is_empty());
1097 }
1098
1099 #[test]
1100 fn parse_skill_meta_parses_explicit_hint() {
1101 let tmp = tempfile::TempDir::new().unwrap();
1102 let dir = tmp.path().join("test-skill");
1103 fs::create_dir(&dir).unwrap();
1104 let content =
1105 "---\nname: test-skill\ndescription: A test\nmode: trigger\ntriggers: rust\nhint: Rust-related helper\n---\n\nBody text";
1106 let (name, desc, mode, triggers, hint, _depends_on, params) =
1107 parse_skill_meta(content, &dir);
1108 assert_eq!(name, "test-skill");
1109 assert_eq!(desc, "A test");
1110 assert_eq!(mode, SkillMode::Trigger);
1111 assert_eq!(triggers, vec!["rust"]);
1112 assert_eq!(hint, "Rust-related helper");
1113 assert!(params.is_empty());
1114 }
1115
1116 #[test]
1117 fn parse_skill_meta_defaults_to_manual() {
1118 let tmp = tempfile::TempDir::new().unwrap();
1119 let dir = tmp.path().join("test-skill");
1120 fs::create_dir(&dir).unwrap();
1121 let content = "---\nname: test-skill\ndescription: A test\n---\n\nBody text";
1122 let (_, _, mode, triggers, hint, _, _) = parse_skill_meta(content, &dir);
1123 assert_eq!(mode, SkillMode::Manual);
1124 assert!(triggers.is_empty());
1125 assert!(hint.is_empty());
1126 }
1127
1128 #[test]
1129 fn parse_skill_meta_no_frontmatter_defaults_manual() {
1130 let tmp = tempfile::TempDir::new().unwrap();
1131 let dir = tmp.path().join("test-skill");
1132 fs::create_dir(&dir).unwrap();
1133 let content = "Body text";
1134 let (_, _, mode, triggers, hint, _, _) = parse_skill_meta(content, &dir);
1135 assert_eq!(mode, SkillMode::Manual);
1136 assert!(triggers.is_empty());
1137 assert!(hint.is_empty());
1138 }
1139
1140 #[test]
1141 fn skills_for_injection_always_mode() {
1142 let tmp = tempfile::TempDir::new().unwrap();
1143 let dir = tmp.path().join("always-skill");
1144 fs::create_dir(&dir).unwrap();
1145 fs::write(
1146 dir.join("SKILL.md"),
1147 "---\nname: always-skill\ndescription: Always injected\nmode: always\n---\n\nAlways body content",
1148 )
1149 .unwrap();
1150
1151 let skills = discover_skills(&[tmp.path().to_path_buf()]);
1152 let result = skills_for_injection(&skills, "anything");
1153 assert_eq!(result.len(), 1);
1154 assert_eq!(result[0].0, "always-skill");
1155 assert_eq!(result[0].1, "Always body content");
1156 }
1157
1158 #[test]
1159 fn skills_for_injection_trigger_matches() {
1160 let tmp = tempfile::TempDir::new().unwrap();
1161 let dir = tmp.path().join("rust-skill");
1162 fs::create_dir(&dir).unwrap();
1163 fs::write(
1164 dir.join("SKILL.md"),
1165 "---\nname: rust-skill\ndescription: Rust helper\nmode: trigger\ntriggers: rust, trait\n---\n\nRust body content",
1166 )
1167 .unwrap();
1168
1169 let skills = discover_skills(&[tmp.path().to_path_buf()]);
1170 let result = skills_for_injection(&skills, "I need help with Rust traits");
1171 assert_eq!(result.len(), 1);
1172 assert_eq!(result[0].0, "rust-skill");
1173 assert_eq!(result[0].1, "rust-skill: Rust helper");
1175 }
1176
1177 #[test]
1178 fn skills_for_injection_trigger_no_match() {
1179 let tmp = tempfile::TempDir::new().unwrap();
1180 let dir = tmp.path().join("rust-skill");
1181 fs::create_dir(&dir).unwrap();
1182 fs::write(
1183 dir.join("SKILL.md"),
1184 "---\nname: rust-skill\ndescription: Rust helper\nmode: trigger\ntriggers: rust, trait\n---\n\nRust body content",
1185 )
1186 .unwrap();
1187
1188 let skills = discover_skills(&[tmp.path().to_path_buf()]);
1189 let result = skills_for_injection(&skills, "I need help with Python");
1190 assert!(result.is_empty());
1191 }
1192
1193 #[test]
1194 fn skills_for_injection_manual_never_injected() {
1195 let tmp = tempfile::TempDir::new().unwrap();
1196 let dir = tmp.path().join("manual-skill");
1197 fs::create_dir(&dir).unwrap();
1198 fs::write(
1199 dir.join("SKILL.md"),
1200 "---\nname: manual-skill\ndescription: Manual only\n---\n\nManual body",
1201 )
1202 .unwrap();
1203
1204 let skills = discover_skills(&[tmp.path().to_path_buf()]);
1205 let result = skills_for_injection(&skills, "anything");
1206 assert!(result.is_empty());
1207 }
1208
1209 #[test]
1210 fn skill_index_shows_mode_tags() {
1211 let skills = vec![
1212 Skill {
1213 name: "always-skill".to_string(),
1214 description: "Always injected".to_string(),
1215 path: PathBuf::from("/tmp/skills/always-skill/SKILL.md"),
1216 mode: SkillMode::Always,
1217 triggers: vec![],
1218 hint: String::new(),
1219 depends_on: vec![],
1220 refs: vec![],
1221 params: vec![],
1222 scripts: vec![],
1223 sections: vec![],
1224 },
1225 Skill {
1226 name: "trigger-skill".to_string(),
1227 description: "Trigger based".to_string(),
1228 path: PathBuf::from("/tmp/skills/trigger-skill/SKILL.md"),
1229 mode: SkillMode::Trigger,
1230 triggers: vec!["rust".to_string()],
1231 hint: "trigger-skill: Trigger based".to_string(),
1232 depends_on: vec![],
1233 refs: vec![],
1234 params: vec![],
1235 scripts: vec![],
1236 sections: vec![],
1237 },
1238 Skill {
1239 name: "manual-skill".to_string(),
1240 description: "Manual only".to_string(),
1241 path: PathBuf::from("/tmp/skills/manual-skill/SKILL.md"),
1242 mode: SkillMode::Manual,
1243 triggers: vec![],
1244 hint: String::new(),
1245 depends_on: vec![],
1246 refs: vec![],
1247 params: vec![],
1248 scripts: vec![],
1249 sections: vec![],
1250 },
1251 ];
1252
1253 let result = skill_index(&skills);
1254 assert!(result.contains("[always] always-skill"));
1255 assert!(result.contains("[trigger] trigger-skill"));
1256 assert!(result.contains("- manual-skill: Manual only"));
1257 }
1258
1259 #[test]
1262 fn parse_sections_extracts_named_sections() {
1263 let content = "---\nname: test\ndescription: Test\n---\n\n## Overview\n\nGeneral info here.\n\n## Details\n\nMore specific content.\n\n## Examples\n\nExample 1\nExample 2";
1264 let sections = parse_sections(content);
1265 assert_eq!(sections.len(), 3);
1266 assert_eq!(sections[0].name, "Overview");
1267 assert_eq!(sections[0].content, "General info here.");
1268 assert_eq!(sections[1].name, "Details");
1269 assert_eq!(sections[1].content, "More specific content.");
1270 assert_eq!(sections[2].name, "Examples");
1271 assert_eq!(sections[2].content, "Example 1\nExample 2");
1272 }
1273
1274 #[test]
1275 fn parse_sections_returns_empty_for_no_headings() {
1276 let content = "---\nname: test\ndescription: Test\n---\n\nJust a body with no sections.";
1277 let sections = parse_sections(content);
1278 assert!(sections.is_empty());
1279 }
1280
1281 #[test]
1282 fn parse_sections_handles_no_frontmatter() {
1283 let content = "## Intro\n\nHello world\n\n## Conclusion\n\nBye";
1284 let sections = parse_sections(content);
1285 assert_eq!(sections.len(), 2);
1286 assert_eq!(sections[0].name, "Intro");
1287 assert_eq!(sections[0].content, "Hello world");
1288 assert_eq!(sections[1].name, "Conclusion");
1289 assert_eq!(sections[1].content, "Bye");
1290 }
1291
1292 #[test]
1293 fn skill_index_shows_sections() {
1294 let skills = vec![
1295 Skill {
1296 name: "with-sections".to_string(),
1297 description: "Has sections".to_string(),
1298 path: PathBuf::from("/tmp/skills/with-sections/SKILL.md"),
1299 mode: SkillMode::Manual,
1300 triggers: vec![],
1301 hint: String::new(),
1302 depends_on: vec![],
1303 refs: vec![],
1304 params: vec![],
1305 scripts: vec![],
1306 sections: vec![
1307 SkillSection {
1308 name: "Overview".to_string(),
1309 content: "General info".to_string(),
1310 },
1311 SkillSection {
1312 name: "Details".to_string(),
1313 content: "Specific info".to_string(),
1314 },
1315 ],
1316 },
1317 Skill {
1318 name: "no-sections".to_string(),
1319 description: "No sections".to_string(),
1320 path: PathBuf::from("/tmp/skills/no-sections/SKILL.md"),
1321 mode: SkillMode::Manual,
1322 triggers: vec![],
1323 hint: String::new(),
1324 depends_on: vec![],
1325 refs: vec![],
1326 params: vec![],
1327 scripts: vec![],
1328 sections: vec![],
1329 },
1330 ];
1331
1332 let result = skill_index(&skills);
1333 assert!(result.contains("sections: Overview, Details"));
1334 assert!(result.contains("- no-sections: No sections"));
1335 }
1336
1337 #[test]
1338 fn discover_skills_populates_sections() {
1339 let tmp = tempfile::TempDir::new().unwrap();
1340 let base = tmp.path();
1341
1342 let skill_dir = base.join("sectioned-skill");
1343 fs::create_dir(&skill_dir).unwrap();
1344 fs::write(
1345 skill_dir.join("SKILL.md"),
1346 "---\nname: sectioned-skill\ndescription: A skill with sections\n---\n\n## Setup\n\nInstallation steps.\n\n## Usage\n\nHow to use it.\n\n## API\n\nReference docs.",
1347 )
1348 .unwrap();
1349
1350 let skills = discover_skills(&[base.to_path_buf()]);
1351 assert_eq!(skills.len(), 1);
1352 let skill = &skills[0];
1353 assert_eq!(skill.sections.len(), 3);
1354 assert_eq!(skill.sections[0].name, "Setup");
1355 assert_eq!(skill.sections[1].name, "Usage");
1356 assert_eq!(skill.sections[2].name, "API");
1357 }
1358}