1use std::collections::HashMap;
63use std::fs;
64use std::path::{Path, PathBuf};
65use std::sync::Arc;
66
67use serde::Deserialize;
68
69use super::manifest::{load as load_manifest, SkillSource, SkillsSource};
70
71#[derive(Debug, Clone)]
81pub struct BundledSkill {
82 pub name: &'static str,
85 pub body: &'static str,
89}
90
91#[derive(Debug, Clone, Default, Deserialize)]
100pub struct SkillFrontmatter {
101 #[serde(default)]
106 pub name: String,
107 #[serde(default)]
110 pub description: String,
111
112 #[serde(default)]
115 pub applies_to: Option<HashMap<String, String>>,
116
117 #[serde(default)]
121 pub references_tools: Vec<String>,
122
123 #[serde(default)]
127 pub references_arguments: Vec<String>,
128
129 #[serde(default)]
134 pub references_properties: Vec<String>,
135
136 #[serde(default = "default_auto_inject_hint")]
141 pub auto_inject_hint: bool,
142
143 #[serde(default)]
155 pub applies_when: Option<AppliesWhen>,
156}
157
158fn default_auto_inject_hint() -> bool {
159 true
160}
161
162#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
171pub struct AppliesWhen {
172 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub graph_has_node_type: Option<Vec<String>>,
177
178 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub graph_has_property: Option<GraphPropertyCheck>,
183
184 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub tool_registered: Option<String>,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub extension_enabled: Option<String>,
195}
196
197#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
199pub struct GraphPropertyCheck {
200 pub node_type: String,
201 pub prop_name: String,
202}
203
204#[derive(Debug)]
208pub enum PredicateClause<'a> {
209 GraphHasNodeType(&'a [String]),
211 GraphHasProperty {
213 node_type: &'a str,
214 prop_name: &'a str,
215 },
216 ToolRegistered(&'a str),
218 ExtensionEnabled(&'a str),
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
226pub enum PredicateOutcome {
227 Satisfied,
229 Unsatisfied,
231 Unknown,
235}
236
237#[derive(Debug, Clone, Default)]
241pub struct SkillActivation {
242 pub active: bool,
245 pub clauses: Vec<(String, PredicateOutcome)>,
248}
249
250pub trait SkillPredicateEvaluator: Send + Sync {
283 fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool>;
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum SkillProvenance {
291 Project,
294 DomainPack(PathBuf),
297 Bundled,
300}
301
302#[derive(Debug, Clone)]
305pub struct Skill {
306 pub frontmatter: SkillFrontmatter,
307 pub body: String,
308 pub provenance: SkillProvenance,
309}
310
311impl Skill {
312 pub fn name(&self) -> &str {
315 &self.frontmatter.name
316 }
317
318 pub fn description(&self) -> &str {
320 &self.frontmatter.description
321 }
322}
323
324#[derive(Debug)]
330pub enum SkillError {
331 Io {
333 path: PathBuf,
334 source: std::io::Error,
335 },
336 MissingFrontmatter { path: PathBuf },
338 InvalidFrontmatter { path: PathBuf, message: String },
340 MissingRequiredField { path: PathBuf, field: &'static str },
342 SkillTooLarge {
344 path: PathBuf,
345 bytes: usize,
346 limit: usize,
347 },
348 PathNotFound { raw: String, resolved: PathBuf },
351 BundledSkillInvalid { name: &'static str, message: String },
356 Manifest { path: PathBuf, message: String },
359}
360
361impl std::fmt::Display for SkillError {
362 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363 match self {
364 SkillError::Io { path, source } => {
365 write!(f, "skill I/O error at {}: {source}", path.display())
366 }
367 SkillError::MissingFrontmatter { path } => write!(
368 f,
369 "skill at {} is missing the `---` YAML frontmatter delimiter at the start of the file",
370 path.display()
371 ),
372 SkillError::InvalidFrontmatter { path, message } => {
373 write!(
374 f,
375 "skill frontmatter at {} is not valid YAML: {message}",
376 path.display()
377 )
378 }
379 SkillError::MissingRequiredField { path, field } => write!(
380 f,
381 "skill at {} is missing required frontmatter field `{field}`",
382 path.display()
383 ),
384 SkillError::SkillTooLarge {
385 path,
386 bytes,
387 limit,
388 } => write!(
389 f,
390 "skill at {} is {bytes} bytes; exceeds the {limit} byte hard limit",
391 path.display()
392 ),
393 SkillError::PathNotFound { raw, resolved } => write!(
394 f,
395 "skill path {raw:?} (resolved to {}) does not exist or is not a directory",
396 resolved.display()
397 ),
398 SkillError::BundledSkillInvalid { name, message } => write!(
399 f,
400 "bundled skill `{name}` is malformed: {message}"
401 ),
402 SkillError::Manifest { path, message } => write!(
403 f,
404 "manifest load failed at {}: {message}",
405 path.display()
406 ),
407 }
408 }
409}
410
411impl std::error::Error for SkillError {
412 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
413 match self {
414 SkillError::Io { source, .. } => Some(source),
415 _ => None,
416 }
417 }
418}
419
420pub const SOFT_SIZE_LIMIT_BYTES: usize = 4 * 1024;
425pub const HARD_SIZE_LIMIT_BYTES: usize = 16 * 1024;
429pub const SESSION_TOTAL_LIMIT_BYTES: usize = 64 * 1024;
434
435fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
446 let trimmed = content.strip_prefix("---\n").or_else(|| {
447 content.strip_prefix("---\r\n")
449 })?;
450 let mut search_start = 0;
452 while let Some(idx) = trimmed[search_start..].find("---") {
453 let abs = search_start + idx;
454 let at_line_start = abs == 0 || trimmed.as_bytes().get(abs - 1) == Some(&b'\n');
456 let after = &trimmed[abs + 3..];
458 let line_end_ok = after.is_empty() || after.starts_with('\n') || after.starts_with("\r\n");
459 if at_line_start && line_end_ok {
460 let frontmatter = &trimmed[..abs];
461 let body_start = if after.starts_with("\r\n") {
462 abs + 3 + 2
463 } else if after.starts_with('\n') {
464 abs + 3 + 1
465 } else {
466 abs + 3
467 };
468 let body = &trimmed[body_start..];
469 return Some((frontmatter, body));
470 }
471 search_start = abs + 3;
472 }
473 None
474}
475
476pub fn parse_skill(content: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
479 let (frontmatter_str, body) =
480 split_frontmatter(content).ok_or_else(|| SkillError::MissingFrontmatter {
481 path: path.to_path_buf(),
482 })?;
483
484 let frontmatter: SkillFrontmatter =
485 serde_yaml::from_str(frontmatter_str).map_err(|e| SkillError::InvalidFrontmatter {
486 path: path.to_path_buf(),
487 message: e.to_string(),
488 })?;
489
490 if frontmatter.name.is_empty() {
491 return Err(SkillError::MissingRequiredField {
492 path: path.to_path_buf(),
493 field: "name",
494 });
495 }
496 if frontmatter.description.is_empty() {
497 return Err(SkillError::MissingRequiredField {
498 path: path.to_path_buf(),
499 field: "description",
500 });
501 }
502
503 Ok((frontmatter, body.to_string()))
504}
505
506pub fn load_skill_from_file(path: &Path, provenance: SkillProvenance) -> Result<Skill, SkillError> {
510 let content = fs::read_to_string(path).map_err(|e| SkillError::Io {
511 path: path.to_path_buf(),
512 source: e,
513 })?;
514
515 if content.len() > HARD_SIZE_LIMIT_BYTES {
516 return Err(SkillError::SkillTooLarge {
517 path: path.to_path_buf(),
518 bytes: content.len(),
519 limit: HARD_SIZE_LIMIT_BYTES,
520 });
521 }
522 if content.len() > SOFT_SIZE_LIMIT_BYTES {
523 tracing::warn!(
524 path = %path.display(),
525 bytes = content.len(),
526 soft_limit = SOFT_SIZE_LIMIT_BYTES,
527 "skill exceeds the soft size limit; consider splitting"
528 );
529 }
530
531 let (frontmatter, body) = parse_skill(&content, path)?;
532 Ok(Skill {
533 frontmatter,
534 body,
535 provenance,
536 })
537}
538
539#[derive(Debug, Clone)]
555pub struct ParseWarning {
556 pub path: PathBuf,
558 pub error: String,
560}
561
562pub fn load_skills_from_dir(
571 dir: &Path,
572 provenance: SkillProvenance,
573) -> Result<(Vec<Skill>, Vec<ParseWarning>), SkillError> {
574 if !dir.is_dir() {
575 return Ok((Vec::new(), Vec::new()));
576 }
577
578 let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
579 path: dir.to_path_buf(),
580 source: e,
581 })?;
582
583 let mut skills = Vec::new();
584 let mut warnings = Vec::new();
585 for entry in entries {
586 let entry = match entry {
587 Ok(e) => e,
588 Err(e) => {
589 tracing::warn!(
590 dir = %dir.display(),
591 error = %e,
592 "failed to read directory entry; skipping"
593 );
594 warnings.push(ParseWarning {
595 path: dir.to_path_buf(),
596 error: format!("failed to read directory entry: {e}"),
597 });
598 continue;
599 }
600 };
601 let path = entry.path();
602 if path.extension().map(|e| e == "md").unwrap_or(false) {
605 match load_skill_from_file(&path, provenance.clone()) {
606 Ok(skill) => skills.push(skill),
607 Err(e) => {
608 tracing::warn!(
609 path = %path.display(),
610 error = %e,
611 "failed to load skill; skipping"
612 );
613 warnings.push(ParseWarning {
614 path: path.clone(),
615 error: e.to_string(),
616 });
617 }
618 }
619 }
620 }
621 Ok((skills, warnings))
622}
623
624pub fn resolve_skill_path(raw: &str, manifest_dir: &Path) -> PathBuf {
637 let p = Path::new(raw);
638 if p.is_absolute() {
639 return p.to_path_buf();
640 }
641 if let Some(rest) = raw.strip_prefix("~/") {
642 if let Some(home) = std::env::var_os("HOME") {
643 return PathBuf::from(home).join(rest);
644 }
645 }
647 manifest_dir.join(raw)
648}
649
650pub fn project_skills_dir(yaml_path: &Path) -> PathBuf {
656 let stem = yaml_path
657 .file_stem()
658 .map(|s| s.to_string_lossy().into_owned())
659 .unwrap_or_else(|| "manifest".to_string());
660 let parent = yaml_path.parent().unwrap_or_else(|| Path::new("."));
661 parent.join(format!("{stem}.skills"))
662}
663
664pub fn library_bundled_skills() -> Vec<BundledSkill> {
674 crate::server::bundled_skills_index::library_bundled_skills()
675}
676
677pub fn render_skill_template(name: &str, description: &str) -> String {
692 format!(
693 "---\n\
694 name: {name}\n\
695 description: {description}\n\
696 # Optional mcp-methods extension fields (uncomment as needed):\n\
697 # applies_to:\n\
698 # mcp_methods: \">=0.3.35\"\n\
699 # references_tools:\n\
700 # - {name}\n\
701 # references_arguments:\n\
702 # - {name}.<arg_name>\n\
703 # auto_inject_hint: true\n\
704 ---\n\
705 \n\
706 # `{name}` methodology\n\
707 \n\
708 ## Overview\n\
709 \n\
710 <TODO: 2–3 sentences. What this skill enables, when to reach for it,\n\
711 what comes before and after it in the typical workflow.>\n\
712 \n\
713 ## Quick Reference\n\
714 \n\
715 | Task | Approach |\n\
716 |---|---|\n\
717 | <TODO: common task A> | <TODO: one-line pattern> |\n\
718 | <TODO: common task B> | <TODO: one-line pattern> |\n\
719 \n\
720 ## <TODO: Major topic>\n\
721 \n\
722 <TODO: concrete prose, code blocks, examples.>\n\
723 \n\
724 ## Common Pitfalls\n\
725 \n\
726 ❌ <TODO: specific anti-pattern, framed as a behaviour to avoid>\n\
727 \n\
728 ✅ <TODO: positive guidance, often a heuristic>\n\
729 \n\
730 ## When `{name}` is the wrong tool\n\
731 \n\
732 - **<TODO: scenario>** — use <other tool> because <reason>.\n"
733 )
734}
735
736pub fn write_skill_template(
751 dest: &Path,
752 name: &str,
753 description: &str,
754) -> Result<PathBuf, SkillError> {
755 let path = resolve_template_dest(dest, name);
756
757 if path.exists() {
758 return Err(SkillError::Io {
759 path: path.clone(),
760 source: std::io::Error::new(
761 std::io::ErrorKind::AlreadyExists,
762 "destination already exists; delete it before re-running",
763 ),
764 });
765 }
766
767 if let Some(parent) = path.parent() {
768 if !parent.as_os_str().is_empty() && !parent.exists() {
769 fs::create_dir_all(parent).map_err(|e| SkillError::Io {
770 path: parent.to_path_buf(),
771 source: e,
772 })?;
773 }
774 }
775
776 let body = render_skill_template(name, description);
777 fs::write(&path, body).map_err(|e| SkillError::Io {
778 path: path.clone(),
779 source: e,
780 })?;
781 Ok(path)
782}
783
784fn resolve_template_dest(dest: &Path, name: &str) -> PathBuf {
785 if dest.is_dir() {
786 return dest.join(format!("{name}.md"));
787 }
788 if dest
789 .extension()
790 .map(|e| e.eq_ignore_ascii_case("md"))
791 .unwrap_or(false)
792 {
793 return dest.to_path_buf();
794 }
795 dest.join(format!("{name}.md"))
796}
797
798#[derive(Default)]
808pub struct Registry {
809 bundled: Vec<BundledSkill>,
810 root_dirs: Vec<(PathBuf, String)>, root_includes_bundled: bool,
815 project_dir: Option<PathBuf>,
818 evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
824}
825
826impl std::fmt::Debug for Registry {
827 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
828 f.debug_struct("Registry")
829 .field("bundled", &self.bundled)
830 .field("root_dirs", &self.root_dirs)
831 .field("root_includes_bundled", &self.root_includes_bundled)
832 .field("project_dir", &self.project_dir)
833 .field(
834 "evaluator",
835 &self
836 .evaluator
837 .as_ref()
838 .map(|_| "<dyn SkillPredicateEvaluator>"),
839 )
840 .finish()
841 }
842}
843
844impl Registry {
845 pub fn new() -> Self {
850 Self::default()
851 }
852
853 pub fn from_manifest(
875 manifest_path: &Path,
876 include_bundled: bool,
877 ) -> Result<ResolvedRegistry, SkillError> {
878 let manifest = load_manifest(manifest_path).map_err(|e| SkillError::Manifest {
879 path: manifest_path.to_path_buf(),
880 message: e.message,
881 })?;
882 let mut builder = Registry::new();
883 if include_bundled {
884 builder = builder.merge_framework_defaults();
885 }
886 builder = builder.auto_detect_project_layer(manifest_path);
887 builder = builder.layer_dirs(&manifest.skills, manifest_path)?;
888 builder.finalise()
889 }
890
891 pub fn with_predicate_evaluator(
904 mut self,
905 evaluator: impl SkillPredicateEvaluator + 'static,
906 ) -> Self {
907 self.evaluator = Some(Arc::new(evaluator));
908 self
909 }
910
911 pub fn add_bundled(mut self, skill: BundledSkill) -> Self {
929 self.bundled.push(skill);
930 self
931 }
932
933 pub fn add_bundled_many(mut self, skills: impl IntoIterator<Item = BundledSkill>) -> Self {
935 self.bundled.extend(skills);
936 self
937 }
938
939 pub fn merge_framework_defaults(self) -> Self {
944 let defaults = library_bundled_skills();
945 self.add_bundled_many(defaults)
946 }
947
948 pub fn layer_dirs(
960 mut self,
961 source: &SkillsSource,
962 yaml_path: &Path,
963 ) -> Result<Self, SkillError> {
964 let manifest_dir = yaml_path.parent().unwrap_or_else(|| Path::new("."));
965
966 match source {
967 SkillsSource::Disabled => {
968 self.root_includes_bundled = false;
973 }
974 SkillsSource::Sources(sources) => {
975 for src in sources {
976 match src {
977 SkillSource::Bundled => {
978 self.root_includes_bundled = true;
979 }
980 SkillSource::Path(raw) => {
981 let resolved = resolve_skill_path(raw, manifest_dir);
982 if !resolved.is_dir() {
983 return Err(SkillError::PathNotFound {
984 raw: raw.clone(),
985 resolved,
986 });
987 }
988 self.root_dirs.push((resolved, raw.clone()));
989 }
990 }
991 }
992 }
993 }
994
995 Ok(self)
996 }
997
998 pub fn auto_detect_project_layer(mut self, yaml_path: &Path) -> Self {
1003 let candidate = project_skills_dir(yaml_path);
1004 if candidate.is_dir() {
1005 self.project_dir = Some(candidate);
1006 }
1007 self
1008 }
1009
1010 pub fn finalise(self) -> Result<ResolvedRegistry, SkillError> {
1025 let Self {
1026 bundled,
1027 root_dirs,
1028 root_includes_bundled,
1029 project_dir,
1030 evaluator,
1031 } = self;
1032
1033 let mut bundled_skills: Vec<Skill> = Vec::with_capacity(bundled.len());
1036 if root_includes_bundled {
1037 for b in &bundled {
1038 let path = PathBuf::from(format!("<bundled:{}>", b.name));
1039 let (frontmatter, body) =
1040 parse_skill(b.body, &path).map_err(|e| SkillError::BundledSkillInvalid {
1041 name: b.name,
1042 message: e.to_string(),
1043 })?;
1044 if frontmatter.name != b.name {
1045 return Err(SkillError::BundledSkillInvalid {
1046 name: b.name,
1047 message: format!(
1048 "frontmatter name {:?} does not match the bundled key {:?}",
1049 frontmatter.name, b.name
1050 ),
1051 });
1052 }
1053 bundled_skills.push(Skill {
1054 frontmatter,
1055 body,
1056 provenance: SkillProvenance::Bundled,
1057 });
1058 }
1059 }
1060
1061 let mut parse_warnings: Vec<ParseWarning> = Vec::new();
1065 let mut root_skills_per_dir: Vec<Vec<Skill>> = Vec::with_capacity(root_dirs.len());
1066 for (resolved, _raw) in &root_dirs {
1067 let provenance = SkillProvenance::DomainPack(resolved.clone());
1068 let (skills, warnings) = load_skills_from_dir(resolved, provenance)?;
1069 parse_warnings.extend(warnings);
1070 root_skills_per_dir.push(skills);
1071 }
1072
1073 let project_skills: Vec<Skill> = match &project_dir {
1075 Some(dir) => {
1076 let (skills, warnings) = load_skills_from_dir(dir, SkillProvenance::Project)?;
1077 parse_warnings.extend(warnings);
1078 skills
1079 }
1080 None => Vec::new(),
1081 };
1082
1083 let mut resolved: HashMap<String, Skill> = HashMap::new();
1093 let mut collisions: HashMap<String, Vec<SkillProvenance>> = HashMap::new();
1094
1095 for skill in &bundled_skills {
1099 let name = skill.name().to_string();
1100 collisions
1101 .entry(name.clone())
1102 .or_default()
1103 .push(skill.provenance.clone());
1104 resolved.insert(name, skill.clone());
1105 }
1106 for skills in root_skills_per_dir.iter().rev() {
1107 for skill in skills {
1108 let name = skill.name().to_string();
1109 collisions
1110 .entry(name.clone())
1111 .or_default()
1112 .push(skill.provenance.clone());
1113 resolved.insert(name, skill.clone());
1114 }
1115 }
1116 for skill in &project_skills {
1117 let name = skill.name().to_string();
1118 collisions
1119 .entry(name.clone())
1120 .or_default()
1121 .push(skill.provenance.clone());
1122 resolved.insert(name, skill.clone());
1123 }
1124
1125 for (name, candidates) in &collisions {
1128 if candidates.len() > 1 {
1129 let winner = resolved
1130 .get(name)
1131 .map(|s| format_provenance(&s.provenance))
1132 .unwrap_or_else(|| "<none>".to_string());
1133 let all_candidates: Vec<String> =
1134 candidates.iter().map(format_provenance).collect();
1135 tracing::info!(
1136 skill = %name,
1137 candidates = ?all_candidates,
1138 winner = %winner,
1139 "skill resolved across multiple layers"
1140 );
1141 }
1142 }
1143
1144 let total_bytes: usize = resolved.values().map(|s| s.body.len()).sum();
1146 if total_bytes > SESSION_TOTAL_LIMIT_BYTES {
1147 tracing::warn!(
1148 total_bytes,
1149 limit = SESSION_TOTAL_LIMIT_BYTES,
1150 skill_count = resolved.len(),
1151 "total resolved skill body size exceeds session limit; \
1152 consider trimming or splitting skills"
1153 );
1154 }
1155
1156 Ok(ResolvedRegistry {
1157 skills: resolved,
1158 evaluator,
1159 parse_warnings,
1160 })
1161 }
1162}
1163
1164fn format_provenance(p: &SkillProvenance) -> String {
1165 match p {
1166 SkillProvenance::Project => "project".to_string(),
1167 SkillProvenance::DomainPack(path) => format!("pack:{}", path.display()),
1168 SkillProvenance::Bundled => "bundled".to_string(),
1169 }
1170}
1171
1172#[derive(Default)]
1178pub struct ResolvedRegistry {
1179 skills: HashMap<String, Skill>,
1180 pub(crate) evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
1186 parse_warnings: Vec<ParseWarning>,
1192}
1193
1194impl std::fmt::Debug for ResolvedRegistry {
1195 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1196 f.debug_struct("ResolvedRegistry")
1197 .field("skills", &self.skills)
1198 .field(
1199 "evaluator",
1200 &self
1201 .evaluator
1202 .as_ref()
1203 .map(|_| "<dyn SkillPredicateEvaluator>"),
1204 )
1205 .finish()
1206 }
1207}
1208
1209impl ResolvedRegistry {
1210 pub fn skill_names(&self) -> Vec<String> {
1213 let mut names: Vec<String> = self.skills.keys().cloned().collect();
1214 names.sort();
1215 names
1216 }
1217
1218 pub fn get(&self, name: &str) -> Option<&Skill> {
1221 self.skills.get(name)
1222 }
1223
1224 pub fn iter(&self) -> impl Iterator<Item = (&String, &Skill)> {
1227 self.skills.iter()
1228 }
1229
1230 pub fn len(&self) -> usize {
1232 self.skills.len()
1233 }
1234
1235 pub fn is_empty(&self) -> bool {
1237 self.skills.is_empty()
1238 }
1239
1240 pub fn parse_warnings(&self) -> &[ParseWarning] {
1247 &self.parse_warnings
1248 }
1249
1250 pub fn activation_for(
1263 &self,
1264 skill: &Skill,
1265 registered_tools: &std::collections::HashSet<String>,
1266 extensions: &serde_json::Map<String, serde_json::Value>,
1267 ) -> SkillActivation {
1268 let Some(applies_when) = skill.frontmatter.applies_when.as_ref() else {
1269 return SkillActivation {
1270 active: true,
1271 clauses: Vec::new(),
1272 };
1273 };
1274 let mut clauses = Vec::new();
1275 let mut all_satisfied = true;
1276
1277 if let Some(types) = applies_when.graph_has_node_type.as_ref() {
1278 let clause = PredicateClause::GraphHasNodeType(types);
1279 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1280 if outcome != PredicateOutcome::Satisfied {
1281 all_satisfied = false;
1282 }
1283 clauses.push((format!("graph_has_node_type: {types:?}"), outcome));
1284 }
1285 if let Some(prop) = applies_when.graph_has_property.as_ref() {
1286 let clause = PredicateClause::GraphHasProperty {
1287 node_type: &prop.node_type,
1288 prop_name: &prop.prop_name,
1289 };
1290 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1291 if outcome != PredicateOutcome::Satisfied {
1292 all_satisfied = false;
1293 }
1294 clauses.push((
1295 format!("graph_has_property: {}.{}", prop.node_type, prop.prop_name),
1296 outcome,
1297 ));
1298 }
1299 if let Some(tool) = applies_when.tool_registered.as_ref() {
1300 let clause = PredicateClause::ToolRegistered(tool);
1301 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1302 if outcome != PredicateOutcome::Satisfied {
1303 all_satisfied = false;
1304 }
1305 clauses.push((format!("tool_registered: {tool}"), outcome));
1306 }
1307 if let Some(key) = applies_when.extension_enabled.as_ref() {
1308 let clause = PredicateClause::ExtensionEnabled(key);
1309 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1310 if outcome != PredicateOutcome::Satisfied {
1311 all_satisfied = false;
1312 }
1313 clauses.push((format!("extension_enabled: {key}"), outcome));
1314 }
1315
1316 SkillActivation {
1317 active: all_satisfied,
1318 clauses,
1319 }
1320 }
1321
1322 fn dispatch_clause(
1323 &self,
1324 clause: &PredicateClause<'_>,
1325 registered_tools: &std::collections::HashSet<String>,
1326 extensions: &serde_json::Map<String, serde_json::Value>,
1327 ) -> PredicateOutcome {
1328 match clause {
1333 PredicateClause::ToolRegistered(name) => {
1334 return if registered_tools.contains(*name) {
1335 PredicateOutcome::Satisfied
1336 } else {
1337 PredicateOutcome::Unsatisfied
1338 };
1339 }
1340 PredicateClause::ExtensionEnabled(key) => {
1341 let truthy = extensions
1342 .get(*key)
1343 .map(|v| !v.is_null() && v != &serde_json::Value::Bool(false))
1344 .unwrap_or(false);
1345 return if truthy {
1346 PredicateOutcome::Satisfied
1347 } else {
1348 PredicateOutcome::Unsatisfied
1349 };
1350 }
1351 _ => {}
1352 }
1353
1354 match self.evaluator.as_ref().and_then(|e| e.evaluate(clause)) {
1357 Some(true) => PredicateOutcome::Satisfied,
1358 Some(false) => PredicateOutcome::Unsatisfied,
1359 None => PredicateOutcome::Unknown,
1360 }
1361 }
1362}
1363
1364#[cfg(test)]
1367mod tests {
1368 use super::*;
1369 use std::io::Write;
1370
1371 fn write_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
1372 let path = dir.join(format!("{name}.md"));
1373 let mut f = fs::File::create(&path).unwrap();
1374 f.write_all(content.as_bytes()).unwrap();
1375 path
1376 }
1377
1378 fn minimal_skill(name: &str) -> String {
1379 format!(
1380 "---\nname: {name}\ndescription: A test skill named {name}.\n---\n\n# {name}\n\nBody.\n"
1381 )
1382 }
1383
1384 #[test]
1387 fn parse_frontmatter_basic() {
1388 let content = "---\nname: foo\ndescription: A foo skill.\n---\n\nBody here.\n";
1389 let path = PathBuf::from("test.md");
1390 let (fm, body) = parse_skill(content, &path).unwrap();
1391 assert_eq!(fm.name, "foo");
1392 assert_eq!(fm.description, "A foo skill.");
1393 assert_eq!(body, "\nBody here.\n");
1394 assert!(fm.auto_inject_hint, "auto_inject_hint defaults to true");
1395 }
1396
1397 #[test]
1398 fn parse_frontmatter_missing_delimiters_rejected() {
1399 let content = "name: foo\ndescription: bar\n";
1400 let path = PathBuf::from("test.md");
1401 let err = parse_skill(content, &path).unwrap_err();
1402 assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
1403 }
1404
1405 #[test]
1406 fn parse_frontmatter_invalid_yaml_rejected() {
1407 let content = "---\nname: foo\n bad: yaml: nesting\n---\nbody\n";
1408 let path = PathBuf::from("test.md");
1409 let err = parse_skill(content, &path).unwrap_err();
1410 assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
1411 }
1412
1413 #[test]
1414 fn parse_frontmatter_missing_name_rejected() {
1415 let content = "---\ndescription: bar\n---\nbody\n";
1416 let path = PathBuf::from("test.md");
1417 let err = parse_skill(content, &path).unwrap_err();
1418 assert!(matches!(
1419 err,
1420 SkillError::MissingRequiredField { field: "name", .. }
1421 ));
1422 }
1423
1424 #[test]
1425 fn parse_frontmatter_missing_description_rejected() {
1426 let content = "---\nname: foo\n---\nbody\n";
1427 let path = PathBuf::from("test.md");
1428 let err = parse_skill(content, &path).unwrap_err();
1429 assert!(matches!(
1430 err,
1431 SkillError::MissingRequiredField {
1432 field: "description",
1433 ..
1434 }
1435 ));
1436 }
1437
1438 #[test]
1439 fn parse_frontmatter_all_optional_fields() {
1440 let content = "---\n\
1441name: foo\n\
1442description: Full surface.\n\
1443references_tools: [grep, list_source]\n\
1444references_arguments: [grep.pattern]\n\
1445references_properties: [Function.module]\n\
1446auto_inject_hint: false\n\
1447applies_to:\n mcp_methods: \">=0.3.35\"\n\
1448---\n\
1449Body.\n";
1450 let path = PathBuf::from("test.md");
1451 let (fm, _) = parse_skill(content, &path).unwrap();
1452 assert_eq!(fm.references_tools, vec!["grep", "list_source"]);
1453 assert_eq!(fm.references_arguments, vec!["grep.pattern"]);
1454 assert_eq!(fm.references_properties, vec!["Function.module"]);
1455 assert!(!fm.auto_inject_hint);
1456 assert_eq!(
1457 fm.applies_to.unwrap().get("mcp_methods"),
1458 Some(&">=0.3.35".to_string())
1459 );
1460 }
1461
1462 #[test]
1465 fn load_skill_from_file_basic() {
1466 let dir = tempfile::tempdir().unwrap();
1467 let path = write_skill(dir.path(), "foo", &minimal_skill("foo"));
1468 let skill = load_skill_from_file(&path, SkillProvenance::Project).unwrap();
1469 assert_eq!(skill.name(), "foo");
1470 assert_eq!(skill.provenance, SkillProvenance::Project);
1471 }
1472
1473 #[test]
1474 fn load_skill_too_large_rejected() {
1475 let dir = tempfile::tempdir().unwrap();
1476 let big_body = "x".repeat(HARD_SIZE_LIMIT_BYTES + 100);
1478 let content = format!("---\nname: big\ndescription: too big.\n---\n{big_body}");
1479 let path = write_skill(dir.path(), "big", &content);
1480 let err = load_skill_from_file(&path, SkillProvenance::Project).unwrap_err();
1481 assert!(matches!(err, SkillError::SkillTooLarge { .. }));
1482 }
1483
1484 #[test]
1485 fn load_skills_from_dir_walks_markdown_only() {
1486 let dir = tempfile::tempdir().unwrap();
1487 write_skill(dir.path(), "a", &minimal_skill("a"));
1488 write_skill(dir.path(), "b", &minimal_skill("b"));
1489 fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
1491 let sub = dir.path().join("sub");
1493 fs::create_dir(&sub).unwrap();
1494 write_skill(&sub, "c", &minimal_skill("c"));
1495
1496 let (skills, warnings) =
1497 load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1498 assert_eq!(skills.len(), 2);
1499 assert!(warnings.is_empty());
1500 let mut names: Vec<&str> = skills.iter().map(|s| s.name()).collect();
1501 names.sort();
1502 assert_eq!(names, vec!["a", "b"]);
1503 }
1504
1505 #[test]
1506 fn load_skills_from_dir_missing_returns_empty() {
1507 let dir = tempfile::tempdir().unwrap();
1508 let nonexistent = dir.path().join("does-not-exist");
1509 let (skills, warnings) =
1510 load_skills_from_dir(&nonexistent, SkillProvenance::Project).unwrap();
1511 assert!(skills.is_empty());
1512 assert!(warnings.is_empty());
1513 }
1514
1515 #[test]
1516 fn load_skills_from_dir_surfaces_yaml_parse_failure_as_warning() {
1517 let dir = tempfile::tempdir().unwrap();
1525 write_skill(dir.path(), "good", &minimal_skill("good"));
1527 write_skill(
1529 dir.path(),
1530 "broken",
1531 "---\nname: broken\ndescription: First clause: second clause\n---\n# body\n",
1532 );
1533
1534 let (skills, warnings) =
1535 load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1536 assert_eq!(skills.len(), 1, "the good skill should still load");
1537 assert_eq!(skills[0].name(), "good");
1538 assert_eq!(
1539 warnings.len(),
1540 1,
1541 "the broken file should surface as a warning"
1542 );
1543 assert!(warnings[0].path.ends_with("broken.md"));
1544 assert!(!warnings[0].error.is_empty());
1545 }
1546
1547 #[test]
1548 fn resolved_registry_parse_warnings_propagated_from_project_layer() {
1549 let dir = tempfile::tempdir().unwrap();
1552 let yaml = dir.path().join("test_mcp.yaml");
1553 fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1554 let skills_dir = dir.path().join("test_mcp.skills");
1555 fs::create_dir(&skills_dir).unwrap();
1556 write_skill(&skills_dir, "good", &minimal_skill("good"));
1558 write_skill(
1560 &skills_dir,
1561 "broken",
1562 "---\nname: broken\ndescription: bad\nstill in frontmatter\n",
1563 );
1564
1565 let registry = Registry::new()
1566 .auto_detect_project_layer(&yaml)
1567 .finalise()
1568 .unwrap();
1569
1570 assert_eq!(registry.len(), 1, "good skill resolved");
1571 assert!(registry.get("good").is_some());
1572 let warnings = registry.parse_warnings();
1573 assert_eq!(warnings.len(), 1);
1574 assert!(warnings[0].path.ends_with("broken.md"));
1575 }
1576
1577 #[test]
1580 fn resolve_skill_path_relative() {
1581 let manifest_dir = Path::new("/a/b");
1582 assert_eq!(
1583 resolve_skill_path("./skills", manifest_dir),
1584 PathBuf::from("/a/b/./skills")
1585 );
1586 assert_eq!(
1587 resolve_skill_path("skills", manifest_dir),
1588 PathBuf::from("/a/b/skills")
1589 );
1590 }
1591
1592 #[test]
1593 fn resolve_skill_path_absolute() {
1594 let manifest_dir = Path::new("/a/b");
1595 assert_eq!(
1596 resolve_skill_path("/abs/skills", manifest_dir),
1597 PathBuf::from("/abs/skills")
1598 );
1599 }
1600
1601 #[test]
1602 fn resolve_skill_path_home_relative() {
1603 let manifest_dir = Path::new("/a/b");
1604 unsafe {
1608 std::env::set_var("HOME", "/home/test");
1609 }
1610 assert_eq!(
1611 resolve_skill_path("~/skills", manifest_dir),
1612 PathBuf::from("/home/test/skills")
1613 );
1614 }
1615
1616 #[test]
1617 fn project_skills_dir_naming() {
1618 assert_eq!(
1619 project_skills_dir(Path::new("/a/b/legal_mcp.yaml")),
1620 PathBuf::from("/a/b/legal_mcp.skills")
1621 );
1622 assert_eq!(
1623 project_skills_dir(Path::new("workspace_mcp.yaml")),
1624 PathBuf::from("workspace_mcp.skills")
1625 );
1626 }
1627
1628 #[test]
1631 fn registry_disabled_resolves_empty() {
1632 let dir = tempfile::tempdir().unwrap();
1633 let yaml = dir.path().join("test_mcp.yaml");
1634 fs::write(&yaml, "name: x\n").unwrap();
1635
1636 let registry = Registry::new()
1637 .layer_dirs(&SkillsSource::Disabled, &yaml)
1638 .unwrap()
1639 .auto_detect_project_layer(&yaml)
1640 .finalise()
1641 .unwrap();
1642 assert!(registry.is_empty());
1643 }
1644
1645 #[test]
1646 fn registry_add_bundled_only_visible_when_opted_in() {
1647 let dir = tempfile::tempdir().unwrap();
1648 let yaml = dir.path().join("test_mcp.yaml");
1649 fs::write(&yaml, "name: x\n").unwrap();
1650
1651 let bundled = BundledSkill {
1652 name: "foo",
1653 body: Box::leak(minimal_skill("foo").into_boxed_str()),
1657 };
1658
1659 let registry = Registry::new()
1661 .add_bundled(bundled.clone())
1662 .layer_dirs(&SkillsSource::Disabled, &yaml)
1663 .unwrap()
1664 .finalise()
1665 .unwrap();
1666 assert!(registry.is_empty(), "disabled must short-circuit bundled");
1667
1668 let registry = Registry::new()
1670 .add_bundled(bundled)
1671 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1672 .unwrap()
1673 .finalise()
1674 .unwrap();
1675 assert_eq!(registry.len(), 1);
1676 assert!(registry.get("foo").is_some());
1677 assert_eq!(
1678 registry.get("foo").unwrap().provenance,
1679 SkillProvenance::Bundled
1680 );
1681 }
1682
1683 #[test]
1684 fn registry_three_layer_resolution_project_wins_over_bundled() {
1685 let dir = tempfile::tempdir().unwrap();
1686 let yaml = dir.path().join("test_mcp.yaml");
1687 fs::write(&yaml, "name: x\n").unwrap();
1688
1689 let bundled = BundledSkill {
1691 name: "foo",
1692 body: "---\nname: foo\ndescription: from bundled.\n---\nbundled body\n",
1693 };
1694
1695 let project_dir = dir.path().join("test_mcp.skills");
1697 fs::create_dir(&project_dir).unwrap();
1698 fs::write(
1699 project_dir.join("foo.md"),
1700 "---\nname: foo\ndescription: from project.\n---\nproject body\n",
1701 )
1702 .unwrap();
1703
1704 let registry = Registry::new()
1705 .add_bundled(bundled)
1706 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1707 .unwrap()
1708 .auto_detect_project_layer(&yaml)
1709 .finalise()
1710 .unwrap();
1711
1712 assert_eq!(registry.len(), 1);
1713 let skill = registry.get("foo").unwrap();
1714 assert_eq!(skill.description(), "from project.");
1715 assert_eq!(skill.provenance, SkillProvenance::Project);
1716 }
1717
1718 #[test]
1719 fn registry_root_layer_first_declaration_wins() {
1720 let dir = tempfile::tempdir().unwrap();
1721 let yaml = dir.path().join("test_mcp.yaml");
1722 fs::write(&yaml, "name: x\n").unwrap();
1723
1724 let primary = dir.path().join("primary");
1726 fs::create_dir(&primary).unwrap();
1727 fs::write(
1728 primary.join("foo.md"),
1729 "---\nname: foo\ndescription: from primary.\n---\nprimary body\n",
1730 )
1731 .unwrap();
1732
1733 let secondary = dir.path().join("secondary");
1735 fs::create_dir(&secondary).unwrap();
1736 fs::write(
1737 secondary.join("foo.md"),
1738 "---\nname: foo\ndescription: from secondary.\n---\nsecondary body\n",
1739 )
1740 .unwrap();
1741
1742 let registry = Registry::new()
1743 .layer_dirs(
1744 &SkillsSource::Sources(vec![
1745 SkillSource::Path("./primary".into()),
1746 SkillSource::Path("./secondary".into()),
1747 ]),
1748 &yaml,
1749 )
1750 .unwrap()
1751 .finalise()
1752 .unwrap();
1753
1754 assert_eq!(registry.len(), 1);
1755 assert_eq!(registry.get("foo").unwrap().description(), "from primary.");
1756 }
1757
1758 #[test]
1759 fn registry_root_layer_nonexistent_path_rejected() {
1760 let dir = tempfile::tempdir().unwrap();
1761 let yaml = dir.path().join("test_mcp.yaml");
1762 fs::write(&yaml, "name: x\n").unwrap();
1763
1764 let err = Registry::new()
1765 .layer_dirs(
1766 &SkillsSource::Sources(vec![SkillSource::Path("./does-not-exist".into())]),
1767 &yaml,
1768 )
1769 .unwrap_err();
1770 assert!(matches!(err, SkillError::PathNotFound { .. }));
1771 }
1772
1773 #[test]
1774 fn from_manifest_resolves_full_stack() {
1775 let dir = tempfile::tempdir().unwrap();
1776 let yaml = dir.path().join("test_mcp.yaml");
1777 fs::write(&yaml, "name: x\nskills:\n - true\n - ./domain-pack\n").unwrap();
1778
1779 let project_dir = dir.path().join("test_mcp.skills");
1780 fs::create_dir(&project_dir).unwrap();
1781 fs::write(project_dir.join("a.md"), minimal_skill("a")).unwrap();
1782
1783 let pack_dir = dir.path().join("domain-pack");
1784 fs::create_dir(&pack_dir).unwrap();
1785 fs::write(pack_dir.join("b.md"), minimal_skill("b")).unwrap();
1786
1787 let registry = Registry::from_manifest(&yaml, false).unwrap();
1788 let names = registry.skill_names();
1789 assert!(names.contains(&"a".to_string()));
1790 assert!(names.contains(&"b".to_string()));
1791 assert_eq!(
1792 registry.get("a").unwrap().provenance,
1793 SkillProvenance::Project
1794 );
1795 }
1796
1797 #[test]
1798 fn from_manifest_surfaces_manifest_load_error() {
1799 let dir = tempfile::tempdir().unwrap();
1800 let yaml = dir.path().join("broken_mcp.yaml");
1801 fs::write(&yaml, "this: is: not: valid yaml\n").unwrap();
1802
1803 let err = Registry::from_manifest(&yaml, false).unwrap_err();
1804 assert!(matches!(err, SkillError::Manifest { .. }));
1805 }
1806
1807 #[test]
1808 fn registry_empty_list_opts_in_without_root_sources() {
1809 let dir = tempfile::tempdir().unwrap();
1810 let yaml = dir.path().join("test_mcp.yaml");
1811 fs::write(&yaml, "name: x\n").unwrap();
1812
1813 let project_dir = dir.path().join("test_mcp.skills");
1815 fs::create_dir(&project_dir).unwrap();
1816 fs::write(project_dir.join("only.md"), minimal_skill("only")).unwrap();
1817
1818 let registry = Registry::new()
1819 .layer_dirs(&SkillsSource::Sources(vec![]), &yaml)
1820 .unwrap()
1821 .auto_detect_project_layer(&yaml)
1822 .finalise()
1823 .unwrap();
1824
1825 assert_eq!(registry.len(), 1);
1826 assert_eq!(
1827 registry.get("only").unwrap().provenance,
1828 SkillProvenance::Project
1829 );
1830 }
1831
1832 #[test]
1833 fn registry_bundled_name_mismatch_rejected_at_finalise() {
1834 let dir = tempfile::tempdir().unwrap();
1835 let yaml = dir.path().join("test_mcp.yaml");
1836 fs::write(&yaml, "name: x\n").unwrap();
1837
1838 let bundled = BundledSkill {
1840 name: "foo",
1841 body: Box::leak(
1842 "---\nname: bar\ndescription: mismatch.\n---\nbody\n"
1843 .to_string()
1844 .into_boxed_str(),
1845 ),
1846 };
1847
1848 let err = Registry::new()
1849 .add_bundled(bundled)
1850 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1851 .unwrap()
1852 .finalise()
1853 .unwrap_err();
1854 assert!(matches!(err, SkillError::BundledSkillInvalid { .. }));
1855 }
1856
1857 #[test]
1858 fn registry_library_bundled_skills_returns_vec() {
1859 let skills = library_bundled_skills();
1864 assert!(
1865 !skills.is_empty(),
1866 "library_bundled_skills should return framework defaults from Phase 1d onward"
1867 );
1868 }
1869
1870 #[test]
1871 fn registry_skill_names_sorted() {
1872 let dir = tempfile::tempdir().unwrap();
1873 let yaml = dir.path().join("test_mcp.yaml");
1874 fs::write(&yaml, "name: x\n").unwrap();
1875
1876 let pack = dir.path().join("pack");
1877 fs::create_dir(&pack).unwrap();
1878 fs::write(pack.join("zeta.md"), minimal_skill("zeta")).unwrap();
1879 fs::write(pack.join("alpha.md"), minimal_skill("alpha")).unwrap();
1880 fs::write(pack.join("mu.md"), minimal_skill("mu")).unwrap();
1881
1882 let registry = Registry::new()
1883 .layer_dirs(
1884 &SkillsSource::Sources(vec![SkillSource::Path("./pack".into())]),
1885 &yaml,
1886 )
1887 .unwrap()
1888 .finalise()
1889 .unwrap();
1890
1891 assert_eq!(registry.skill_names(), vec!["alpha", "mu", "zeta"]);
1892 }
1893
1894 #[test]
1897 fn render_skill_template_is_parse_valid() {
1898 let body = render_skill_template("custom_method", "A test description for the skill.");
1902 let (fm, _body) =
1903 parse_skill(&body, &PathBuf::from("test.md")).expect("rendered template must parse");
1904 assert_eq!(fm.name, "custom_method");
1905 assert_eq!(fm.description, "A test description for the skill.");
1906 }
1907
1908 #[test]
1909 fn render_skill_template_substitutes_name_into_body_headings() {
1910 let body = render_skill_template("my_skill", "desc");
1911 assert!(body.contains("# `my_skill` methodology"));
1912 assert!(body.contains("## When `my_skill` is the wrong tool"));
1913 }
1914
1915 #[test]
1916 fn write_skill_template_writes_into_directory() {
1917 let dir = tempfile::tempdir().unwrap();
1918 let dest = write_skill_template(dir.path(), "alpha", "First skill.").unwrap();
1919 assert_eq!(dest, dir.path().join("alpha.md"));
1920 let content = fs::read_to_string(&dest).unwrap();
1921 assert!(content.contains("name: alpha"));
1922 }
1923
1924 #[test]
1925 fn write_skill_template_writes_to_explicit_md_path() {
1926 let dir = tempfile::tempdir().unwrap();
1927 let explicit = dir.path().join("renamed.md");
1928 let dest = write_skill_template(&explicit, "alpha", "First skill.").unwrap();
1929 assert_eq!(dest, explicit);
1930 assert!(explicit.is_file());
1931 }
1932
1933 #[test]
1934 fn write_skill_template_creates_missing_parents() {
1935 let dir = tempfile::tempdir().unwrap();
1936 let nested = dir.path().join("a/b/c");
1937 let dest = write_skill_template(&nested, "alpha", "First skill.").unwrap();
1938 assert_eq!(dest, nested.join("alpha.md"));
1939 assert!(dest.is_file());
1940 }
1941
1942 #[test]
1943 fn write_skill_template_refuses_to_overwrite() {
1944 let dir = tempfile::tempdir().unwrap();
1945 let path = dir.path().join("alpha.md");
1946 fs::write(&path, "existing").unwrap();
1947 let err = write_skill_template(dir.path(), "alpha", "Replace me?").unwrap_err();
1948 assert!(matches!(err, SkillError::Io { .. }));
1949 assert_eq!(fs::read_to_string(&path).unwrap(), "existing");
1951 }
1952
1953 #[test]
1954 fn write_skill_template_round_trips_through_registry() {
1955 let dir = tempfile::tempdir().unwrap();
1958 let yaml = dir.path().join("test_mcp.yaml");
1959 fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1960 let skills_dir = dir.path().join("test_mcp.skills");
1961 write_skill_template(&skills_dir, "custom_method", "Project-layer skill body.").unwrap();
1962
1963 let registry = Registry::new()
1964 .auto_detect_project_layer(&yaml)
1965 .finalise()
1966 .unwrap();
1967 let skill = registry
1968 .get("custom_method")
1969 .expect("template should resolve");
1970 assert_eq!(skill.description(), "Project-layer skill body.");
1971 }
1972
1973 fn skill_with_applies_when(applies_when_yaml: &str) -> Skill {
1976 let body = format!(
1977 "---\nname: gated\ndescription: A gated skill.\n\
1978 applies_when:\n{applies_when_yaml}\n---\n\nBody.\n"
1979 );
1980 let (frontmatter, body) = parse_skill(&body, &PathBuf::from("gated.md")).unwrap();
1981 Skill {
1982 frontmatter,
1983 body,
1984 provenance: SkillProvenance::Bundled,
1985 }
1986 }
1987
1988 #[test]
1989 fn applies_when_parses_map_shape() {
1990 let skill = skill_with_applies_when(
1991 " graph_has_node_type: [Function, Class]\n\
1992 \x20 tool_registered: cypher_query\n\
1993 \x20 extension_enabled: csv_http_server\n\
1994 \x20 graph_has_property:\n\
1995 \x20 node_type: Function\n\
1996 \x20 prop_name: module",
1997 );
1998 let applies = skill.frontmatter.applies_when.unwrap();
1999 assert_eq!(
2000 applies.graph_has_node_type.as_deref(),
2001 Some(["Function".to_string(), "Class".to_string()].as_slice())
2002 );
2003 assert_eq!(applies.tool_registered.as_deref(), Some("cypher_query"));
2004 assert_eq!(
2005 applies.extension_enabled.as_deref(),
2006 Some("csv_http_server")
2007 );
2008 assert_eq!(
2009 applies.graph_has_property,
2010 Some(GraphPropertyCheck {
2011 node_type: "Function".to_string(),
2012 prop_name: "module".to_string(),
2013 })
2014 );
2015 }
2016
2017 #[test]
2018 fn applies_when_absent_means_always_active() {
2019 let body = "---\nname: ungated\ndescription: An ungated skill.\n---\n\nBody.\n";
2020 let (frontmatter, body) = parse_skill(body, &PathBuf::from("ungated.md")).unwrap();
2021 let skill = Skill {
2022 frontmatter,
2023 body,
2024 provenance: SkillProvenance::Bundled,
2025 };
2026 let registry = ResolvedRegistry::default();
2027 let activation = registry.activation_for(
2028 &skill,
2029 &std::collections::HashSet::new(),
2030 &serde_json::Map::new(),
2031 );
2032 assert!(activation.active);
2033 assert!(activation.clauses.is_empty());
2034 }
2035
2036 #[test]
2037 fn tool_registered_predicate_dispatches_in_framework() {
2038 let skill = skill_with_applies_when(" tool_registered: cypher_query");
2039 let registry = ResolvedRegistry::default();
2040 let mut tools = std::collections::HashSet::new();
2041
2042 let inactive = registry.activation_for(&skill, &tools, &serde_json::Map::new());
2044 assert!(!inactive.active);
2045 assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
2046
2047 tools.insert("cypher_query".to_string());
2049 let active = registry.activation_for(&skill, &tools, &serde_json::Map::new());
2050 assert!(active.active);
2051 assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
2052 }
2053
2054 #[test]
2055 fn extension_enabled_predicate_dispatches_in_framework() {
2056 let skill = skill_with_applies_when(" extension_enabled: csv_http_server");
2057 let registry = ResolvedRegistry::default();
2058 let tools = std::collections::HashSet::new();
2059 let mut extensions = serde_json::Map::new();
2060
2061 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2063
2064 extensions.insert("csv_http_server".to_string(), serde_json::json!(false));
2066 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2067
2068 extensions.insert("csv_http_server".to_string(), serde_json::Value::Null);
2070 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2071
2072 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
2074 assert!(registry.activation_for(&skill, &tools, &extensions).active);
2075
2076 extensions.insert(
2078 "csv_http_server".to_string(),
2079 serde_json::json!({"enabled": true}),
2080 );
2081 assert!(registry.activation_for(&skill, &tools, &extensions).active);
2082 }
2083
2084 struct StubEvaluator {
2085 has_function: bool,
2086 }
2087 impl SkillPredicateEvaluator for StubEvaluator {
2088 fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool> {
2089 match clause {
2090 PredicateClause::GraphHasNodeType(types) => {
2091 Some(types.iter().any(|t| t == "Function") && self.has_function)
2092 }
2093 _ => None,
2094 }
2095 }
2096 }
2097
2098 #[test]
2099 fn graph_predicate_dispatches_via_evaluator() {
2100 let skill = skill_with_applies_when(" graph_has_node_type: [Function, Class]");
2101
2102 let registry = Registry::new()
2104 .with_predicate_evaluator(StubEvaluator { has_function: true })
2105 .finalise()
2106 .unwrap();
2107 let active = registry.activation_for(
2108 &skill,
2109 &std::collections::HashSet::new(),
2110 &serde_json::Map::new(),
2111 );
2112 assert!(active.active);
2113 assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
2114
2115 let registry = Registry::new()
2117 .with_predicate_evaluator(StubEvaluator {
2118 has_function: false,
2119 })
2120 .finalise()
2121 .unwrap();
2122 let inactive = registry.activation_for(
2123 &skill,
2124 &std::collections::HashSet::new(),
2125 &serde_json::Map::new(),
2126 );
2127 assert!(!inactive.active);
2128 assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
2129 }
2130
2131 #[test]
2132 fn graph_predicate_unknown_without_evaluator_means_inactive() {
2133 let skill = skill_with_applies_when(" graph_has_node_type: [Function]");
2134 let registry = ResolvedRegistry::default();
2135 let activation = registry.activation_for(
2136 &skill,
2137 &std::collections::HashSet::new(),
2138 &serde_json::Map::new(),
2139 );
2140 assert!(!activation.active);
2141 assert_eq!(activation.clauses[0].1, PredicateOutcome::Unknown);
2142 }
2143
2144 #[test]
2145 fn multiple_predicates_all_must_be_satisfied() {
2146 let skill = skill_with_applies_when(
2147 " graph_has_node_type: [Function]\n\
2148 \x20 tool_registered: cypher_query",
2149 );
2150 let registry = Registry::new()
2151 .with_predicate_evaluator(StubEvaluator { has_function: true })
2152 .finalise()
2153 .unwrap();
2154 let mut tools = std::collections::HashSet::new();
2155 let extensions = serde_json::Map::new();
2156
2157 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2159
2160 tools.insert("cypher_query".to_string());
2162 assert!(registry.activation_for(&skill, &tools, &extensions).active);
2163 }
2164}