1use std::collections::HashMap;
63use std::fs;
64use std::path::{Path, PathBuf};
65
66use serde::Deserialize;
67
68use super::manifest::{SkillSource, SkillsSource};
69
70#[derive(Debug, Clone)]
80pub struct BundledSkill {
81 pub name: &'static str,
84 pub body: &'static str,
88}
89
90#[derive(Debug, Clone, Default, Deserialize)]
99pub struct SkillFrontmatter {
100 #[serde(default)]
105 pub name: String,
106 #[serde(default)]
109 pub description: String,
110
111 #[serde(default)]
114 pub applies_to: Option<HashMap<String, String>>,
115
116 #[serde(default)]
120 pub references_tools: Vec<String>,
121
122 #[serde(default)]
126 pub references_arguments: Vec<String>,
127
128 #[serde(default)]
133 pub references_properties: Vec<String>,
134
135 #[serde(default = "default_auto_inject_hint")]
140 pub auto_inject_hint: bool,
141
142 #[serde(default)]
146 pub applies_when: Vec<serde_yaml::Value>,
147}
148
149fn default_auto_inject_hint() -> bool {
150 true
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum SkillProvenance {
158 Project,
161 DomainPack(PathBuf),
164 Bundled,
167}
168
169#[derive(Debug, Clone)]
172pub struct Skill {
173 pub frontmatter: SkillFrontmatter,
174 pub body: String,
175 pub provenance: SkillProvenance,
176}
177
178impl Skill {
179 pub fn name(&self) -> &str {
182 &self.frontmatter.name
183 }
184
185 pub fn description(&self) -> &str {
187 &self.frontmatter.description
188 }
189}
190
191#[derive(Debug)]
197pub enum SkillError {
198 Io {
200 path: PathBuf,
201 source: std::io::Error,
202 },
203 MissingFrontmatter { path: PathBuf },
205 InvalidFrontmatter { path: PathBuf, message: String },
207 MissingRequiredField { path: PathBuf, field: &'static str },
209 SkillTooLarge {
211 path: PathBuf,
212 bytes: usize,
213 limit: usize,
214 },
215 PathNotFound { raw: String, resolved: PathBuf },
218 BundledSkillInvalid { name: &'static str, message: String },
223}
224
225impl std::fmt::Display for SkillError {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 match self {
228 SkillError::Io { path, source } => {
229 write!(f, "skill I/O error at {}: {source}", path.display())
230 }
231 SkillError::MissingFrontmatter { path } => write!(
232 f,
233 "skill at {} is missing the `---` YAML frontmatter delimiter at the start of the file",
234 path.display()
235 ),
236 SkillError::InvalidFrontmatter { path, message } => {
237 write!(
238 f,
239 "skill frontmatter at {} is not valid YAML: {message}",
240 path.display()
241 )
242 }
243 SkillError::MissingRequiredField { path, field } => write!(
244 f,
245 "skill at {} is missing required frontmatter field `{field}`",
246 path.display()
247 ),
248 SkillError::SkillTooLarge {
249 path,
250 bytes,
251 limit,
252 } => write!(
253 f,
254 "skill at {} is {bytes} bytes; exceeds the {limit} byte hard limit",
255 path.display()
256 ),
257 SkillError::PathNotFound { raw, resolved } => write!(
258 f,
259 "skill path {raw:?} (resolved to {}) does not exist or is not a directory",
260 resolved.display()
261 ),
262 SkillError::BundledSkillInvalid { name, message } => write!(
263 f,
264 "bundled skill `{name}` is malformed: {message}"
265 ),
266 }
267 }
268}
269
270impl std::error::Error for SkillError {
271 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
272 match self {
273 SkillError::Io { source, .. } => Some(source),
274 _ => None,
275 }
276 }
277}
278
279pub const SOFT_SIZE_LIMIT_BYTES: usize = 4 * 1024;
284pub const HARD_SIZE_LIMIT_BYTES: usize = 16 * 1024;
288pub const SESSION_TOTAL_LIMIT_BYTES: usize = 64 * 1024;
293
294fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
305 let trimmed = content.strip_prefix("---\n").or_else(|| {
306 content.strip_prefix("---\r\n")
308 })?;
309 let mut search_start = 0;
311 while let Some(idx) = trimmed[search_start..].find("---") {
312 let abs = search_start + idx;
313 let at_line_start = abs == 0 || trimmed.as_bytes().get(abs - 1) == Some(&b'\n');
315 let after = &trimmed[abs + 3..];
317 let line_end_ok = after.is_empty() || after.starts_with('\n') || after.starts_with("\r\n");
318 if at_line_start && line_end_ok {
319 let frontmatter = &trimmed[..abs];
320 let body_start = if after.starts_with("\r\n") {
321 abs + 3 + 2
322 } else if after.starts_with('\n') {
323 abs + 3 + 1
324 } else {
325 abs + 3
326 };
327 let body = &trimmed[body_start..];
328 return Some((frontmatter, body));
329 }
330 search_start = abs + 3;
331 }
332 None
333}
334
335pub fn parse_skill(content: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
338 let (frontmatter_str, body) =
339 split_frontmatter(content).ok_or_else(|| SkillError::MissingFrontmatter {
340 path: path.to_path_buf(),
341 })?;
342
343 let frontmatter: SkillFrontmatter =
344 serde_yaml::from_str(frontmatter_str).map_err(|e| SkillError::InvalidFrontmatter {
345 path: path.to_path_buf(),
346 message: e.to_string(),
347 })?;
348
349 if frontmatter.name.is_empty() {
350 return Err(SkillError::MissingRequiredField {
351 path: path.to_path_buf(),
352 field: "name",
353 });
354 }
355 if frontmatter.description.is_empty() {
356 return Err(SkillError::MissingRequiredField {
357 path: path.to_path_buf(),
358 field: "description",
359 });
360 }
361
362 Ok((frontmatter, body.to_string()))
363}
364
365pub fn load_skill_from_file(path: &Path, provenance: SkillProvenance) -> Result<Skill, SkillError> {
369 let content = fs::read_to_string(path).map_err(|e| SkillError::Io {
370 path: path.to_path_buf(),
371 source: e,
372 })?;
373
374 if content.len() > HARD_SIZE_LIMIT_BYTES {
375 return Err(SkillError::SkillTooLarge {
376 path: path.to_path_buf(),
377 bytes: content.len(),
378 limit: HARD_SIZE_LIMIT_BYTES,
379 });
380 }
381 if content.len() > SOFT_SIZE_LIMIT_BYTES {
382 tracing::warn!(
383 path = %path.display(),
384 bytes = content.len(),
385 soft_limit = SOFT_SIZE_LIMIT_BYTES,
386 "skill exceeds the soft size limit; consider splitting"
387 );
388 }
389
390 let (frontmatter, body) = parse_skill(&content, path)?;
391 Ok(Skill {
392 frontmatter,
393 body,
394 provenance,
395 })
396}
397
398pub fn load_skills_from_dir(
404 dir: &Path,
405 provenance: SkillProvenance,
406) -> Result<Vec<Skill>, SkillError> {
407 if !dir.is_dir() {
408 return Ok(Vec::new());
409 }
410
411 let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
412 path: dir.to_path_buf(),
413 source: e,
414 })?;
415
416 let mut skills = Vec::new();
417 for entry in entries {
418 let entry = match entry {
419 Ok(e) => e,
420 Err(e) => {
421 tracing::warn!(
422 dir = %dir.display(),
423 error = %e,
424 "failed to read directory entry; skipping"
425 );
426 continue;
427 }
428 };
429 let path = entry.path();
430 if path.extension().map(|e| e == "md").unwrap_or(false) {
433 match load_skill_from_file(&path, provenance.clone()) {
434 Ok(skill) => skills.push(skill),
435 Err(e) => {
436 tracing::warn!(
437 path = %path.display(),
438 error = %e,
439 "failed to load skill; skipping"
440 );
441 }
442 }
443 }
444 }
445 Ok(skills)
446}
447
448pub fn resolve_skill_path(raw: &str, manifest_dir: &Path) -> PathBuf {
461 let p = Path::new(raw);
462 if p.is_absolute() {
463 return p.to_path_buf();
464 }
465 if let Some(rest) = raw.strip_prefix("~/") {
466 if let Some(home) = std::env::var_os("HOME") {
467 return PathBuf::from(home).join(rest);
468 }
469 }
471 manifest_dir.join(raw)
472}
473
474pub fn project_skills_dir(yaml_path: &Path) -> PathBuf {
480 let stem = yaml_path
481 .file_stem()
482 .map(|s| s.to_string_lossy().into_owned())
483 .unwrap_or_else(|| "manifest".to_string());
484 let parent = yaml_path.parent().unwrap_or_else(|| Path::new("."));
485 parent.join(format!("{stem}.skills"))
486}
487
488pub fn library_bundled_skills() -> Vec<BundledSkill> {
498 crate::server::bundled_skills_index::library_bundled_skills()
499}
500
501pub fn render_skill_template(name: &str, description: &str) -> String {
516 format!(
517 "---\n\
518 name: {name}\n\
519 description: {description}\n\
520 # Optional mcp-methods extension fields (uncomment as needed):\n\
521 # applies_to:\n\
522 # mcp_methods: \">=0.3.35\"\n\
523 # references_tools:\n\
524 # - {name}\n\
525 # references_arguments:\n\
526 # - {name}.<arg_name>\n\
527 # auto_inject_hint: true\n\
528 ---\n\
529 \n\
530 # `{name}` methodology\n\
531 \n\
532 ## Overview\n\
533 \n\
534 <TODO: 2–3 sentences. What this skill enables, when to reach for it,\n\
535 what comes before and after it in the typical workflow.>\n\
536 \n\
537 ## Quick Reference\n\
538 \n\
539 | Task | Approach |\n\
540 |---|---|\n\
541 | <TODO: common task A> | <TODO: one-line pattern> |\n\
542 | <TODO: common task B> | <TODO: one-line pattern> |\n\
543 \n\
544 ## <TODO: Major topic>\n\
545 \n\
546 <TODO: concrete prose, code blocks, examples.>\n\
547 \n\
548 ## Common Pitfalls\n\
549 \n\
550 ❌ <TODO: specific anti-pattern, framed as a behaviour to avoid>\n\
551 \n\
552 ✅ <TODO: positive guidance, often a heuristic>\n\
553 \n\
554 ## When `{name}` is the wrong tool\n\
555 \n\
556 - **<TODO: scenario>** — use <other tool> because <reason>.\n"
557 )
558}
559
560pub fn write_skill_template(
575 dest: &Path,
576 name: &str,
577 description: &str,
578) -> Result<PathBuf, SkillError> {
579 let path = resolve_template_dest(dest, name);
580
581 if path.exists() {
582 return Err(SkillError::Io {
583 path: path.clone(),
584 source: std::io::Error::new(
585 std::io::ErrorKind::AlreadyExists,
586 "destination already exists; delete it before re-running",
587 ),
588 });
589 }
590
591 if let Some(parent) = path.parent() {
592 if !parent.as_os_str().is_empty() && !parent.exists() {
593 fs::create_dir_all(parent).map_err(|e| SkillError::Io {
594 path: parent.to_path_buf(),
595 source: e,
596 })?;
597 }
598 }
599
600 let body = render_skill_template(name, description);
601 fs::write(&path, body).map_err(|e| SkillError::Io {
602 path: path.clone(),
603 source: e,
604 })?;
605 Ok(path)
606}
607
608fn resolve_template_dest(dest: &Path, name: &str) -> PathBuf {
609 if dest.is_dir() {
610 return dest.join(format!("{name}.md"));
611 }
612 if dest
613 .extension()
614 .map(|e| e.eq_ignore_ascii_case("md"))
615 .unwrap_or(false)
616 {
617 return dest.to_path_buf();
618 }
619 dest.join(format!("{name}.md"))
620}
621
622#[derive(Debug, Default)]
632pub struct Registry {
633 bundled: Vec<BundledSkill>,
634 root_dirs: Vec<(PathBuf, String)>, root_includes_bundled: bool,
639 project_dir: Option<PathBuf>,
642}
643
644impl Registry {
645 pub fn new() -> Self {
649 Self::default()
650 }
651
652 pub fn add_bundled(mut self, skill: BundledSkill) -> Self {
670 self.bundled.push(skill);
671 self
672 }
673
674 pub fn add_bundled_many(mut self, skills: impl IntoIterator<Item = BundledSkill>) -> Self {
676 self.bundled.extend(skills);
677 self
678 }
679
680 pub fn merge_framework_defaults(self) -> Self {
685 let defaults = library_bundled_skills();
686 self.add_bundled_many(defaults)
687 }
688
689 pub fn layer_dirs(
701 mut self,
702 source: &SkillsSource,
703 yaml_path: &Path,
704 ) -> Result<Self, SkillError> {
705 let manifest_dir = yaml_path.parent().unwrap_or_else(|| Path::new("."));
706
707 match source {
708 SkillsSource::Disabled => {
709 self.root_includes_bundled = false;
714 }
715 SkillsSource::Sources(sources) => {
716 for src in sources {
717 match src {
718 SkillSource::Bundled => {
719 self.root_includes_bundled = true;
720 }
721 SkillSource::Path(raw) => {
722 let resolved = resolve_skill_path(raw, manifest_dir);
723 if !resolved.is_dir() {
724 return Err(SkillError::PathNotFound {
725 raw: raw.clone(),
726 resolved,
727 });
728 }
729 self.root_dirs.push((resolved, raw.clone()));
730 }
731 }
732 }
733 }
734 }
735
736 Ok(self)
737 }
738
739 pub fn auto_detect_project_layer(mut self, yaml_path: &Path) -> Self {
744 let candidate = project_skills_dir(yaml_path);
745 if candidate.is_dir() {
746 self.project_dir = Some(candidate);
747 }
748 self
749 }
750
751 pub fn finalise(self) -> Result<ResolvedRegistry, SkillError> {
766 let Self {
767 bundled,
768 root_dirs,
769 root_includes_bundled,
770 project_dir,
771 } = self;
772
773 let mut bundled_skills: Vec<Skill> = Vec::with_capacity(bundled.len());
776 if root_includes_bundled {
777 for b in &bundled {
778 let path = PathBuf::from(format!("<bundled:{}>", b.name));
779 let (frontmatter, body) =
780 parse_skill(b.body, &path).map_err(|e| SkillError::BundledSkillInvalid {
781 name: b.name,
782 message: e.to_string(),
783 })?;
784 if frontmatter.name != b.name {
785 return Err(SkillError::BundledSkillInvalid {
786 name: b.name,
787 message: format!(
788 "frontmatter name {:?} does not match the bundled key {:?}",
789 frontmatter.name, b.name
790 ),
791 });
792 }
793 bundled_skills.push(Skill {
794 frontmatter,
795 body,
796 provenance: SkillProvenance::Bundled,
797 });
798 }
799 }
800
801 let mut root_skills_per_dir: Vec<Vec<Skill>> = Vec::with_capacity(root_dirs.len());
803 for (resolved, _raw) in &root_dirs {
804 let provenance = SkillProvenance::DomainPack(resolved.clone());
805 let skills = load_skills_from_dir(resolved, provenance)?;
806 root_skills_per_dir.push(skills);
807 }
808
809 let project_skills: Vec<Skill> = match &project_dir {
811 Some(dir) => load_skills_from_dir(dir, SkillProvenance::Project)?,
812 None => Vec::new(),
813 };
814
815 let mut resolved: HashMap<String, Skill> = HashMap::new();
825 let mut collisions: HashMap<String, Vec<SkillProvenance>> = HashMap::new();
826
827 for skill in &bundled_skills {
831 let name = skill.name().to_string();
832 collisions
833 .entry(name.clone())
834 .or_default()
835 .push(skill.provenance.clone());
836 resolved.insert(name, skill.clone());
837 }
838 for skills in root_skills_per_dir.iter().rev() {
839 for skill in skills {
840 let name = skill.name().to_string();
841 collisions
842 .entry(name.clone())
843 .or_default()
844 .push(skill.provenance.clone());
845 resolved.insert(name, skill.clone());
846 }
847 }
848 for skill in &project_skills {
849 let name = skill.name().to_string();
850 collisions
851 .entry(name.clone())
852 .or_default()
853 .push(skill.provenance.clone());
854 resolved.insert(name, skill.clone());
855 }
856
857 for (name, candidates) in &collisions {
860 if candidates.len() > 1 {
861 let winner = resolved
862 .get(name)
863 .map(|s| format_provenance(&s.provenance))
864 .unwrap_or_else(|| "<none>".to_string());
865 let all_candidates: Vec<String> =
866 candidates.iter().map(format_provenance).collect();
867 tracing::info!(
868 skill = %name,
869 candidates = ?all_candidates,
870 winner = %winner,
871 "skill resolved across multiple layers"
872 );
873 }
874 }
875
876 let total_bytes: usize = resolved.values().map(|s| s.body.len()).sum();
878 if total_bytes > SESSION_TOTAL_LIMIT_BYTES {
879 tracing::warn!(
880 total_bytes,
881 limit = SESSION_TOTAL_LIMIT_BYTES,
882 skill_count = resolved.len(),
883 "total resolved skill body size exceeds session limit; \
884 consider trimming or splitting skills"
885 );
886 }
887
888 Ok(ResolvedRegistry { skills: resolved })
889 }
890}
891
892fn format_provenance(p: &SkillProvenance) -> String {
893 match p {
894 SkillProvenance::Project => "project".to_string(),
895 SkillProvenance::DomainPack(path) => format!("pack:{}", path.display()),
896 SkillProvenance::Bundled => "bundled".to_string(),
897 }
898}
899
900#[derive(Debug, Default)]
906pub struct ResolvedRegistry {
907 skills: HashMap<String, Skill>,
908}
909
910impl ResolvedRegistry {
911 pub fn skill_names(&self) -> Vec<String> {
914 let mut names: Vec<String> = self.skills.keys().cloned().collect();
915 names.sort();
916 names
917 }
918
919 pub fn get(&self, name: &str) -> Option<&Skill> {
922 self.skills.get(name)
923 }
924
925 pub fn iter(&self) -> impl Iterator<Item = (&String, &Skill)> {
928 self.skills.iter()
929 }
930
931 pub fn len(&self) -> usize {
933 self.skills.len()
934 }
935
936 pub fn is_empty(&self) -> bool {
938 self.skills.is_empty()
939 }
940}
941
942#[cfg(test)]
945mod tests {
946 use super::*;
947 use std::io::Write;
948
949 fn write_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
950 let path = dir.join(format!("{name}.md"));
951 let mut f = fs::File::create(&path).unwrap();
952 f.write_all(content.as_bytes()).unwrap();
953 path
954 }
955
956 fn minimal_skill(name: &str) -> String {
957 format!(
958 "---\nname: {name}\ndescription: A test skill named {name}.\n---\n\n# {name}\n\nBody.\n"
959 )
960 }
961
962 #[test]
965 fn parse_frontmatter_basic() {
966 let content = "---\nname: foo\ndescription: A foo skill.\n---\n\nBody here.\n";
967 let path = PathBuf::from("test.md");
968 let (fm, body) = parse_skill(content, &path).unwrap();
969 assert_eq!(fm.name, "foo");
970 assert_eq!(fm.description, "A foo skill.");
971 assert_eq!(body, "\nBody here.\n");
972 assert!(fm.auto_inject_hint, "auto_inject_hint defaults to true");
973 }
974
975 #[test]
976 fn parse_frontmatter_missing_delimiters_rejected() {
977 let content = "name: foo\ndescription: bar\n";
978 let path = PathBuf::from("test.md");
979 let err = parse_skill(content, &path).unwrap_err();
980 assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
981 }
982
983 #[test]
984 fn parse_frontmatter_invalid_yaml_rejected() {
985 let content = "---\nname: foo\n bad: yaml: nesting\n---\nbody\n";
986 let path = PathBuf::from("test.md");
987 let err = parse_skill(content, &path).unwrap_err();
988 assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
989 }
990
991 #[test]
992 fn parse_frontmatter_missing_name_rejected() {
993 let content = "---\ndescription: bar\n---\nbody\n";
994 let path = PathBuf::from("test.md");
995 let err = parse_skill(content, &path).unwrap_err();
996 assert!(matches!(
997 err,
998 SkillError::MissingRequiredField { field: "name", .. }
999 ));
1000 }
1001
1002 #[test]
1003 fn parse_frontmatter_missing_description_rejected() {
1004 let content = "---\nname: foo\n---\nbody\n";
1005 let path = PathBuf::from("test.md");
1006 let err = parse_skill(content, &path).unwrap_err();
1007 assert!(matches!(
1008 err,
1009 SkillError::MissingRequiredField {
1010 field: "description",
1011 ..
1012 }
1013 ));
1014 }
1015
1016 #[test]
1017 fn parse_frontmatter_all_optional_fields() {
1018 let content = "---\n\
1019name: foo\n\
1020description: Full surface.\n\
1021references_tools: [grep, list_source]\n\
1022references_arguments: [grep.pattern]\n\
1023references_properties: [Function.module]\n\
1024auto_inject_hint: false\n\
1025applies_to:\n mcp_methods: \">=0.3.35\"\n\
1026---\n\
1027Body.\n";
1028 let path = PathBuf::from("test.md");
1029 let (fm, _) = parse_skill(content, &path).unwrap();
1030 assert_eq!(fm.references_tools, vec!["grep", "list_source"]);
1031 assert_eq!(fm.references_arguments, vec!["grep.pattern"]);
1032 assert_eq!(fm.references_properties, vec!["Function.module"]);
1033 assert!(!fm.auto_inject_hint);
1034 assert_eq!(
1035 fm.applies_to.unwrap().get("mcp_methods"),
1036 Some(&">=0.3.35".to_string())
1037 );
1038 }
1039
1040 #[test]
1043 fn load_skill_from_file_basic() {
1044 let dir = tempfile::tempdir().unwrap();
1045 let path = write_skill(dir.path(), "foo", &minimal_skill("foo"));
1046 let skill = load_skill_from_file(&path, SkillProvenance::Project).unwrap();
1047 assert_eq!(skill.name(), "foo");
1048 assert_eq!(skill.provenance, SkillProvenance::Project);
1049 }
1050
1051 #[test]
1052 fn load_skill_too_large_rejected() {
1053 let dir = tempfile::tempdir().unwrap();
1054 let big_body = "x".repeat(HARD_SIZE_LIMIT_BYTES + 100);
1056 let content = format!("---\nname: big\ndescription: too big.\n---\n{big_body}");
1057 let path = write_skill(dir.path(), "big", &content);
1058 let err = load_skill_from_file(&path, SkillProvenance::Project).unwrap_err();
1059 assert!(matches!(err, SkillError::SkillTooLarge { .. }));
1060 }
1061
1062 #[test]
1063 fn load_skills_from_dir_walks_markdown_only() {
1064 let dir = tempfile::tempdir().unwrap();
1065 write_skill(dir.path(), "a", &minimal_skill("a"));
1066 write_skill(dir.path(), "b", &minimal_skill("b"));
1067 fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
1069 let sub = dir.path().join("sub");
1071 fs::create_dir(&sub).unwrap();
1072 write_skill(&sub, "c", &minimal_skill("c"));
1073
1074 let skills = load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1075 assert_eq!(skills.len(), 2);
1076 let mut names: Vec<&str> = skills.iter().map(|s| s.name()).collect();
1077 names.sort();
1078 assert_eq!(names, vec!["a", "b"]);
1079 }
1080
1081 #[test]
1082 fn load_skills_from_dir_missing_returns_empty() {
1083 let dir = tempfile::tempdir().unwrap();
1084 let nonexistent = dir.path().join("does-not-exist");
1085 let skills = load_skills_from_dir(&nonexistent, SkillProvenance::Project).unwrap();
1086 assert!(skills.is_empty());
1087 }
1088
1089 #[test]
1092 fn resolve_skill_path_relative() {
1093 let manifest_dir = Path::new("/a/b");
1094 assert_eq!(
1095 resolve_skill_path("./skills", manifest_dir),
1096 PathBuf::from("/a/b/./skills")
1097 );
1098 assert_eq!(
1099 resolve_skill_path("skills", manifest_dir),
1100 PathBuf::from("/a/b/skills")
1101 );
1102 }
1103
1104 #[test]
1105 fn resolve_skill_path_absolute() {
1106 let manifest_dir = Path::new("/a/b");
1107 assert_eq!(
1108 resolve_skill_path("/abs/skills", manifest_dir),
1109 PathBuf::from("/abs/skills")
1110 );
1111 }
1112
1113 #[test]
1114 fn resolve_skill_path_home_relative() {
1115 let manifest_dir = Path::new("/a/b");
1116 unsafe {
1120 std::env::set_var("HOME", "/home/test");
1121 }
1122 assert_eq!(
1123 resolve_skill_path("~/skills", manifest_dir),
1124 PathBuf::from("/home/test/skills")
1125 );
1126 }
1127
1128 #[test]
1129 fn project_skills_dir_naming() {
1130 assert_eq!(
1131 project_skills_dir(Path::new("/a/b/legal_mcp.yaml")),
1132 PathBuf::from("/a/b/legal_mcp.skills")
1133 );
1134 assert_eq!(
1135 project_skills_dir(Path::new("workspace_mcp.yaml")),
1136 PathBuf::from("workspace_mcp.skills")
1137 );
1138 }
1139
1140 #[test]
1143 fn registry_disabled_resolves_empty() {
1144 let dir = tempfile::tempdir().unwrap();
1145 let yaml = dir.path().join("test_mcp.yaml");
1146 fs::write(&yaml, "name: x\n").unwrap();
1147
1148 let registry = Registry::new()
1149 .layer_dirs(&SkillsSource::Disabled, &yaml)
1150 .unwrap()
1151 .auto_detect_project_layer(&yaml)
1152 .finalise()
1153 .unwrap();
1154 assert!(registry.is_empty());
1155 }
1156
1157 #[test]
1158 fn registry_add_bundled_only_visible_when_opted_in() {
1159 let dir = tempfile::tempdir().unwrap();
1160 let yaml = dir.path().join("test_mcp.yaml");
1161 fs::write(&yaml, "name: x\n").unwrap();
1162
1163 let bundled = BundledSkill {
1164 name: "foo",
1165 body: Box::leak(minimal_skill("foo").into_boxed_str()),
1169 };
1170
1171 let registry = Registry::new()
1173 .add_bundled(bundled.clone())
1174 .layer_dirs(&SkillsSource::Disabled, &yaml)
1175 .unwrap()
1176 .finalise()
1177 .unwrap();
1178 assert!(registry.is_empty(), "disabled must short-circuit bundled");
1179
1180 let registry = Registry::new()
1182 .add_bundled(bundled)
1183 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1184 .unwrap()
1185 .finalise()
1186 .unwrap();
1187 assert_eq!(registry.len(), 1);
1188 assert!(registry.get("foo").is_some());
1189 assert_eq!(
1190 registry.get("foo").unwrap().provenance,
1191 SkillProvenance::Bundled
1192 );
1193 }
1194
1195 #[test]
1196 fn registry_three_layer_resolution_project_wins_over_bundled() {
1197 let dir = tempfile::tempdir().unwrap();
1198 let yaml = dir.path().join("test_mcp.yaml");
1199 fs::write(&yaml, "name: x\n").unwrap();
1200
1201 let bundled = BundledSkill {
1203 name: "foo",
1204 body: "---\nname: foo\ndescription: from bundled.\n---\nbundled body\n",
1205 };
1206
1207 let project_dir = dir.path().join("test_mcp.skills");
1209 fs::create_dir(&project_dir).unwrap();
1210 fs::write(
1211 project_dir.join("foo.md"),
1212 "---\nname: foo\ndescription: from project.\n---\nproject body\n",
1213 )
1214 .unwrap();
1215
1216 let registry = Registry::new()
1217 .add_bundled(bundled)
1218 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1219 .unwrap()
1220 .auto_detect_project_layer(&yaml)
1221 .finalise()
1222 .unwrap();
1223
1224 assert_eq!(registry.len(), 1);
1225 let skill = registry.get("foo").unwrap();
1226 assert_eq!(skill.description(), "from project.");
1227 assert_eq!(skill.provenance, SkillProvenance::Project);
1228 }
1229
1230 #[test]
1231 fn registry_root_layer_first_declaration_wins() {
1232 let dir = tempfile::tempdir().unwrap();
1233 let yaml = dir.path().join("test_mcp.yaml");
1234 fs::write(&yaml, "name: x\n").unwrap();
1235
1236 let primary = dir.path().join("primary");
1238 fs::create_dir(&primary).unwrap();
1239 fs::write(
1240 primary.join("foo.md"),
1241 "---\nname: foo\ndescription: from primary.\n---\nprimary body\n",
1242 )
1243 .unwrap();
1244
1245 let secondary = dir.path().join("secondary");
1247 fs::create_dir(&secondary).unwrap();
1248 fs::write(
1249 secondary.join("foo.md"),
1250 "---\nname: foo\ndescription: from secondary.\n---\nsecondary body\n",
1251 )
1252 .unwrap();
1253
1254 let registry = Registry::new()
1255 .layer_dirs(
1256 &SkillsSource::Sources(vec![
1257 SkillSource::Path("./primary".into()),
1258 SkillSource::Path("./secondary".into()),
1259 ]),
1260 &yaml,
1261 )
1262 .unwrap()
1263 .finalise()
1264 .unwrap();
1265
1266 assert_eq!(registry.len(), 1);
1267 assert_eq!(registry.get("foo").unwrap().description(), "from primary.");
1268 }
1269
1270 #[test]
1271 fn registry_root_layer_nonexistent_path_rejected() {
1272 let dir = tempfile::tempdir().unwrap();
1273 let yaml = dir.path().join("test_mcp.yaml");
1274 fs::write(&yaml, "name: x\n").unwrap();
1275
1276 let err = Registry::new()
1277 .layer_dirs(
1278 &SkillsSource::Sources(vec![SkillSource::Path("./does-not-exist".into())]),
1279 &yaml,
1280 )
1281 .unwrap_err();
1282 assert!(matches!(err, SkillError::PathNotFound { .. }));
1283 }
1284
1285 #[test]
1286 fn registry_empty_list_opts_in_without_root_sources() {
1287 let dir = tempfile::tempdir().unwrap();
1288 let yaml = dir.path().join("test_mcp.yaml");
1289 fs::write(&yaml, "name: x\n").unwrap();
1290
1291 let project_dir = dir.path().join("test_mcp.skills");
1293 fs::create_dir(&project_dir).unwrap();
1294 fs::write(project_dir.join("only.md"), minimal_skill("only")).unwrap();
1295
1296 let registry = Registry::new()
1297 .layer_dirs(&SkillsSource::Sources(vec![]), &yaml)
1298 .unwrap()
1299 .auto_detect_project_layer(&yaml)
1300 .finalise()
1301 .unwrap();
1302
1303 assert_eq!(registry.len(), 1);
1304 assert_eq!(
1305 registry.get("only").unwrap().provenance,
1306 SkillProvenance::Project
1307 );
1308 }
1309
1310 #[test]
1311 fn registry_bundled_name_mismatch_rejected_at_finalise() {
1312 let dir = tempfile::tempdir().unwrap();
1313 let yaml = dir.path().join("test_mcp.yaml");
1314 fs::write(&yaml, "name: x\n").unwrap();
1315
1316 let bundled = BundledSkill {
1318 name: "foo",
1319 body: Box::leak(
1320 "---\nname: bar\ndescription: mismatch.\n---\nbody\n"
1321 .to_string()
1322 .into_boxed_str(),
1323 ),
1324 };
1325
1326 let err = Registry::new()
1327 .add_bundled(bundled)
1328 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1329 .unwrap()
1330 .finalise()
1331 .unwrap_err();
1332 assert!(matches!(err, SkillError::BundledSkillInvalid { .. }));
1333 }
1334
1335 #[test]
1336 fn registry_library_bundled_skills_returns_vec() {
1337 let skills = library_bundled_skills();
1342 assert!(
1343 !skills.is_empty(),
1344 "library_bundled_skills should return framework defaults from Phase 1d onward"
1345 );
1346 }
1347
1348 #[test]
1349 fn registry_skill_names_sorted() {
1350 let dir = tempfile::tempdir().unwrap();
1351 let yaml = dir.path().join("test_mcp.yaml");
1352 fs::write(&yaml, "name: x\n").unwrap();
1353
1354 let pack = dir.path().join("pack");
1355 fs::create_dir(&pack).unwrap();
1356 fs::write(pack.join("zeta.md"), minimal_skill("zeta")).unwrap();
1357 fs::write(pack.join("alpha.md"), minimal_skill("alpha")).unwrap();
1358 fs::write(pack.join("mu.md"), minimal_skill("mu")).unwrap();
1359
1360 let registry = Registry::new()
1361 .layer_dirs(
1362 &SkillsSource::Sources(vec![SkillSource::Path("./pack".into())]),
1363 &yaml,
1364 )
1365 .unwrap()
1366 .finalise()
1367 .unwrap();
1368
1369 assert_eq!(registry.skill_names(), vec!["alpha", "mu", "zeta"]);
1370 }
1371
1372 #[test]
1375 fn render_skill_template_is_parse_valid() {
1376 let body = render_skill_template("custom_method", "A test description for the skill.");
1380 let (fm, _body) =
1381 parse_skill(&body, &PathBuf::from("test.md")).expect("rendered template must parse");
1382 assert_eq!(fm.name, "custom_method");
1383 assert_eq!(fm.description, "A test description for the skill.");
1384 }
1385
1386 #[test]
1387 fn render_skill_template_substitutes_name_into_body_headings() {
1388 let body = render_skill_template("my_skill", "desc");
1389 assert!(body.contains("# `my_skill` methodology"));
1390 assert!(body.contains("## When `my_skill` is the wrong tool"));
1391 }
1392
1393 #[test]
1394 fn write_skill_template_writes_into_directory() {
1395 let dir = tempfile::tempdir().unwrap();
1396 let dest = write_skill_template(dir.path(), "alpha", "First skill.").unwrap();
1397 assert_eq!(dest, dir.path().join("alpha.md"));
1398 let content = fs::read_to_string(&dest).unwrap();
1399 assert!(content.contains("name: alpha"));
1400 }
1401
1402 #[test]
1403 fn write_skill_template_writes_to_explicit_md_path() {
1404 let dir = tempfile::tempdir().unwrap();
1405 let explicit = dir.path().join("renamed.md");
1406 let dest = write_skill_template(&explicit, "alpha", "First skill.").unwrap();
1407 assert_eq!(dest, explicit);
1408 assert!(explicit.is_file());
1409 }
1410
1411 #[test]
1412 fn write_skill_template_creates_missing_parents() {
1413 let dir = tempfile::tempdir().unwrap();
1414 let nested = dir.path().join("a/b/c");
1415 let dest = write_skill_template(&nested, "alpha", "First skill.").unwrap();
1416 assert_eq!(dest, nested.join("alpha.md"));
1417 assert!(dest.is_file());
1418 }
1419
1420 #[test]
1421 fn write_skill_template_refuses_to_overwrite() {
1422 let dir = tempfile::tempdir().unwrap();
1423 let path = dir.path().join("alpha.md");
1424 fs::write(&path, "existing").unwrap();
1425 let err = write_skill_template(dir.path(), "alpha", "Replace me?").unwrap_err();
1426 assert!(matches!(err, SkillError::Io { .. }));
1427 assert_eq!(fs::read_to_string(&path).unwrap(), "existing");
1429 }
1430
1431 #[test]
1432 fn write_skill_template_round_trips_through_registry() {
1433 let dir = tempfile::tempdir().unwrap();
1436 let yaml = dir.path().join("test_mcp.yaml");
1437 fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1438 let skills_dir = dir.path().join("test_mcp.skills");
1439 write_skill_template(&skills_dir, "custom_method", "Project-layer skill body.").unwrap();
1440
1441 let registry = Registry::new()
1442 .auto_detect_project_layer(&yaml)
1443 .finalise()
1444 .unwrap();
1445 let skill = registry
1446 .get("custom_method")
1447 .expect("template should resolve");
1448 assert_eq!(skill.description(), "Project-layer skill body.");
1449 }
1450}