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)]
123 pub references_tools: Vec<String>,
124
125 #[serde(default)]
129 pub references_arguments: Vec<String>,
130
131 #[serde(default)]
136 pub references_properties: Vec<String>,
137
138 #[serde(default = "default_auto_inject_hint")]
145 pub auto_inject_hint: bool,
146
147 #[serde(default)]
159 pub applies_when: Option<AppliesWhen>,
160}
161
162fn default_auto_inject_hint() -> bool {
163 true
164}
165
166#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
175pub struct AppliesWhen {
176 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub graph_has_node_type: Option<Vec<String>>,
181
182 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub graph_has_property: Option<GraphPropertyCheck>,
187
188 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub tool_registered: Option<String>,
193
194 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub extension_enabled: Option<String>,
199}
200
201#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
203pub struct GraphPropertyCheck {
204 pub node_type: String,
205 pub prop_name: String,
206}
207
208#[derive(Debug)]
212pub enum PredicateClause<'a> {
213 GraphHasNodeType(&'a [String]),
215 GraphHasProperty {
217 node_type: &'a str,
218 prop_name: &'a str,
219 },
220 ToolRegistered(&'a str),
222 ExtensionEnabled(&'a str),
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum PredicateOutcome {
231 Satisfied,
233 Unsatisfied,
235 Unknown,
239}
240
241#[derive(Debug, Clone, Default)]
245pub struct SkillActivation {
246 pub active: bool,
249 pub clauses: Vec<(String, PredicateOutcome)>,
252}
253
254pub trait SkillPredicateEvaluator: Send + Sync {
287 fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool>;
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum SkillProvenance {
295 Project,
298 DomainPack(PathBuf),
301 Bundled,
304}
305
306#[derive(Debug, Clone)]
309pub struct Skill {
310 pub frontmatter: SkillFrontmatter,
311 pub body: String,
312 pub provenance: SkillProvenance,
313}
314
315impl Skill {
316 pub fn name(&self) -> &str {
319 &self.frontmatter.name
320 }
321
322 pub fn description(&self) -> &str {
324 &self.frontmatter.description
325 }
326}
327
328#[derive(Debug)]
334pub enum SkillError {
335 Io {
337 path: PathBuf,
338 source: std::io::Error,
339 },
340 MissingFrontmatter { path: PathBuf },
342 InvalidFrontmatter { path: PathBuf, message: String },
344 MissingRequiredField { path: PathBuf, field: &'static str },
346 SkillTooLarge {
348 path: PathBuf,
349 bytes: usize,
350 limit: usize,
351 },
352 PathNotFound { raw: String, resolved: PathBuf },
355 BundledSkillInvalid { name: &'static str, message: String },
360 Manifest { path: PathBuf, message: String },
363}
364
365impl std::fmt::Display for SkillError {
366 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367 match self {
368 SkillError::Io { path, source } => {
369 write!(f, "skill I/O error at {}: {source}", path.display())
370 }
371 SkillError::MissingFrontmatter { path } => write!(
372 f,
373 "skill at {} is missing the `---` YAML frontmatter delimiter at the start of the file",
374 path.display()
375 ),
376 SkillError::InvalidFrontmatter { path, message } => {
377 write!(
378 f,
379 "skill frontmatter at {} is not valid YAML: {message}",
380 path.display()
381 )
382 }
383 SkillError::MissingRequiredField { path, field } => write!(
384 f,
385 "skill at {} is missing required frontmatter field `{field}`",
386 path.display()
387 ),
388 SkillError::SkillTooLarge {
389 path,
390 bytes,
391 limit,
392 } => write!(
393 f,
394 "skill at {} is {bytes} bytes; exceeds the {limit} byte hard limit",
395 path.display()
396 ),
397 SkillError::PathNotFound { raw, resolved } => write!(
398 f,
399 "skill path {raw:?} (resolved to {}) does not exist or is not a directory",
400 resolved.display()
401 ),
402 SkillError::BundledSkillInvalid { name, message } => write!(
403 f,
404 "bundled skill `{name}` is malformed: {message}"
405 ),
406 SkillError::Manifest { path, message } => write!(
407 f,
408 "manifest load failed at {}: {message}",
409 path.display()
410 ),
411 }
412 }
413}
414
415impl std::error::Error for SkillError {
416 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
417 match self {
418 SkillError::Io { source, .. } => Some(source),
419 _ => None,
420 }
421 }
422}
423
424pub const SOFT_SIZE_LIMIT_BYTES: usize = 4 * 1024;
429pub const HARD_SIZE_LIMIT_BYTES: usize = 16 * 1024;
433pub const SESSION_TOTAL_LIMIT_BYTES: usize = 64 * 1024;
438
439fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
450 let trimmed = content.strip_prefix("---\n").or_else(|| {
451 content.strip_prefix("---\r\n")
453 })?;
454 let mut search_start = 0;
456 while let Some(idx) = trimmed[search_start..].find("---") {
457 let abs = search_start + idx;
458 let at_line_start = abs == 0 || trimmed.as_bytes().get(abs - 1) == Some(&b'\n');
460 let after = &trimmed[abs + 3..];
462 let line_end_ok = after.is_empty() || after.starts_with('\n') || after.starts_with("\r\n");
463 if at_line_start && line_end_ok {
464 let frontmatter = &trimmed[..abs];
465 let body_start = if after.starts_with("\r\n") {
466 abs + 3 + 2
467 } else if after.starts_with('\n') {
468 abs + 3 + 1
469 } else {
470 abs + 3
471 };
472 let body = &trimmed[body_start..];
473 return Some((frontmatter, body));
474 }
475 search_start = abs + 3;
476 }
477 None
478}
479
480pub fn parse_skill(content: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
483 let (frontmatter_str, body) =
484 split_frontmatter(content).ok_or_else(|| SkillError::MissingFrontmatter {
485 path: path.to_path_buf(),
486 })?;
487
488 let frontmatter: SkillFrontmatter =
489 serde_yaml::from_str(frontmatter_str).map_err(|e| SkillError::InvalidFrontmatter {
490 path: path.to_path_buf(),
491 message: e.to_string(),
492 })?;
493
494 if frontmatter.name.is_empty() {
495 return Err(SkillError::MissingRequiredField {
496 path: path.to_path_buf(),
497 field: "name",
498 });
499 }
500 if frontmatter.description.is_empty() {
501 return Err(SkillError::MissingRequiredField {
502 path: path.to_path_buf(),
503 field: "description",
504 });
505 }
506
507 Ok((frontmatter, body.to_string()))
508}
509
510pub fn load_skill_from_file(path: &Path, provenance: SkillProvenance) -> Result<Skill, SkillError> {
514 let content = fs::read_to_string(path).map_err(|e| SkillError::Io {
515 path: path.to_path_buf(),
516 source: e,
517 })?;
518
519 if content.len() > HARD_SIZE_LIMIT_BYTES {
520 return Err(SkillError::SkillTooLarge {
521 path: path.to_path_buf(),
522 bytes: content.len(),
523 limit: HARD_SIZE_LIMIT_BYTES,
524 });
525 }
526 if content.len() > SOFT_SIZE_LIMIT_BYTES {
527 tracing::warn!(
528 path = %path.display(),
529 bytes = content.len(),
530 soft_limit = SOFT_SIZE_LIMIT_BYTES,
531 "skill exceeds the soft size limit; consider splitting"
532 );
533 }
534
535 let (frontmatter, body) = parse_skill(&content, path)?;
536 Ok(Skill {
537 frontmatter,
538 body,
539 provenance,
540 })
541}
542
543#[derive(Debug, Clone)]
559pub struct ParseWarning {
560 pub path: PathBuf,
562 pub error: String,
564}
565
566pub fn load_skills_from_dir(
575 dir: &Path,
576 provenance: SkillProvenance,
577) -> Result<(Vec<Skill>, Vec<ParseWarning>), SkillError> {
578 if !dir.is_dir() {
579 return Ok((Vec::new(), Vec::new()));
580 }
581
582 let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
583 path: dir.to_path_buf(),
584 source: e,
585 })?;
586
587 let mut skills = Vec::new();
588 let mut warnings = Vec::new();
589 for entry in entries {
590 let entry = match entry {
591 Ok(e) => e,
592 Err(e) => {
593 tracing::warn!(
594 dir = %dir.display(),
595 error = %e,
596 "failed to read directory entry; skipping"
597 );
598 warnings.push(ParseWarning {
599 path: dir.to_path_buf(),
600 error: format!("failed to read directory entry: {e}"),
601 });
602 continue;
603 }
604 };
605 let path = entry.path();
606 if path.extension().map(|e| e == "md").unwrap_or(false) {
609 match load_skill_from_file(&path, provenance.clone()) {
610 Ok(skill) => skills.push(skill),
611 Err(e) => {
612 tracing::warn!(
613 path = %path.display(),
614 error = %e,
615 "failed to load skill; skipping"
616 );
617 warnings.push(ParseWarning {
618 path: path.clone(),
619 error: e.to_string(),
620 });
621 }
622 }
623 }
624 }
625 Ok((skills, warnings))
626}
627
628pub fn resolve_skill_path(raw: &str, manifest_dir: &Path) -> PathBuf {
641 let p = Path::new(raw);
642 if p.is_absolute() {
643 return p.to_path_buf();
644 }
645 if let Some(rest) = raw.strip_prefix("~/") {
646 if let Some(home) = std::env::var_os("HOME") {
647 return PathBuf::from(home).join(rest);
648 }
649 }
651 manifest_dir.join(raw)
652}
653
654pub fn project_skills_dir(yaml_path: &Path) -> PathBuf {
660 let stem = yaml_path
661 .file_stem()
662 .map(|s| s.to_string_lossy().into_owned())
663 .unwrap_or_else(|| "manifest".to_string());
664 let parent = yaml_path.parent().unwrap_or_else(|| Path::new("."));
665 parent.join(format!("{stem}.skills"))
666}
667
668pub fn library_bundled_skills() -> Vec<BundledSkill> {
678 crate::server::bundled_skills_index::library_bundled_skills()
679}
680
681pub fn render_skill_template(name: &str, description: &str) -> String {
696 format!(
697 "---\n\
698 name: {name}\n\
699 description: {description}\n\
700 # Optional mcp-methods extension fields (uncomment as needed):\n\
701 # applies_to:\n\
702 # mcp_methods: \">=0.3.35\"\n\
703 # references_tools:\n\
704 # - {name}\n\
705 # references_arguments:\n\
706 # - {name}.<arg_name>\n\
707 # auto_inject_hint: true\n\
708 ---\n\
709 \n\
710 # `{name}` methodology\n\
711 \n\
712 ## Overview\n\
713 \n\
714 <TODO: 2–3 sentences. What this skill enables, when to reach for it,\n\
715 what comes before and after it in the typical workflow.>\n\
716 \n\
717 ## Quick Reference\n\
718 \n\
719 | Task | Approach |\n\
720 |---|---|\n\
721 | <TODO: common task A> | <TODO: one-line pattern> |\n\
722 | <TODO: common task B> | <TODO: one-line pattern> |\n\
723 \n\
724 ## <TODO: Major topic>\n\
725 \n\
726 <TODO: concrete prose, code blocks, examples.>\n\
727 \n\
728 ## Common Pitfalls\n\
729 \n\
730 ❌ <TODO: specific anti-pattern, framed as a behaviour to avoid>\n\
731 \n\
732 ✅ <TODO: positive guidance, often a heuristic>\n\
733 \n\
734 ## When `{name}` is the wrong tool\n\
735 \n\
736 - **<TODO: scenario>** — use <other tool> because <reason>.\n"
737 )
738}
739
740pub fn write_skill_template(
755 dest: &Path,
756 name: &str,
757 description: &str,
758) -> Result<PathBuf, SkillError> {
759 let path = resolve_template_dest(dest, name);
760
761 if path.exists() {
762 return Err(SkillError::Io {
763 path: path.clone(),
764 source: std::io::Error::new(
765 std::io::ErrorKind::AlreadyExists,
766 "destination already exists; delete it before re-running",
767 ),
768 });
769 }
770
771 if let Some(parent) = path.parent() {
772 if !parent.as_os_str().is_empty() && !parent.exists() {
773 fs::create_dir_all(parent).map_err(|e| SkillError::Io {
774 path: parent.to_path_buf(),
775 source: e,
776 })?;
777 }
778 }
779
780 let body = render_skill_template(name, description);
781 fs::write(&path, body).map_err(|e| SkillError::Io {
782 path: path.clone(),
783 source: e,
784 })?;
785 Ok(path)
786}
787
788fn resolve_template_dest(dest: &Path, name: &str) -> PathBuf {
789 if dest.is_dir() {
790 return dest.join(format!("{name}.md"));
791 }
792 if dest
793 .extension()
794 .map(|e| e.eq_ignore_ascii_case("md"))
795 .unwrap_or(false)
796 {
797 return dest.to_path_buf();
798 }
799 dest.join(format!("{name}.md"))
800}
801
802#[derive(Default)]
812pub struct Registry {
813 bundled: Vec<BundledSkill>,
814 root_dirs: Vec<(PathBuf, String)>, root_includes_bundled: bool,
819 project_dir: Option<PathBuf>,
822 evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
828}
829
830impl std::fmt::Debug for Registry {
831 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
832 f.debug_struct("Registry")
833 .field("bundled", &self.bundled)
834 .field("root_dirs", &self.root_dirs)
835 .field("root_includes_bundled", &self.root_includes_bundled)
836 .field("project_dir", &self.project_dir)
837 .field(
838 "evaluator",
839 &self
840 .evaluator
841 .as_ref()
842 .map(|_| "<dyn SkillPredicateEvaluator>"),
843 )
844 .finish()
845 }
846}
847
848impl Registry {
849 pub fn new() -> Self {
854 Self::default()
855 }
856
857 pub fn from_manifest(
879 manifest_path: &Path,
880 include_bundled: bool,
881 ) -> Result<ResolvedRegistry, SkillError> {
882 let manifest = load_manifest(manifest_path).map_err(|e| SkillError::Manifest {
883 path: manifest_path.to_path_buf(),
884 message: e.message,
885 })?;
886 let mut builder = Registry::new();
887 if include_bundled {
888 builder = builder.merge_framework_defaults();
889 }
890 builder = builder.auto_detect_project_layer(manifest_path);
891 builder = builder.layer_dirs(&manifest.skills, manifest_path)?;
892 builder.finalise()
893 }
894
895 pub fn with_predicate_evaluator(
908 mut self,
909 evaluator: impl SkillPredicateEvaluator + 'static,
910 ) -> Self {
911 self.evaluator = Some(Arc::new(evaluator));
912 self
913 }
914
915 pub fn add_bundled(mut self, skill: BundledSkill) -> Self {
933 self.bundled.push(skill);
934 self
935 }
936
937 pub fn add_bundled_many(mut self, skills: impl IntoIterator<Item = BundledSkill>) -> Self {
939 self.bundled.extend(skills);
940 self
941 }
942
943 pub fn merge_framework_defaults(self) -> Self {
948 let defaults = library_bundled_skills();
949 self.add_bundled_many(defaults)
950 }
951
952 pub fn layer_dirs(
964 mut self,
965 source: &SkillsSource,
966 yaml_path: &Path,
967 ) -> Result<Self, SkillError> {
968 let manifest_dir = yaml_path.parent().unwrap_or_else(|| Path::new("."));
969
970 match source {
971 SkillsSource::Disabled => {
972 self.root_includes_bundled = false;
977 }
978 SkillsSource::Sources(sources) => {
979 for src in sources {
980 match src {
981 SkillSource::Bundled => {
982 self.root_includes_bundled = true;
983 }
984 SkillSource::Path(raw) => {
985 let resolved = resolve_skill_path(raw, manifest_dir);
986 if !resolved.is_dir() {
987 return Err(SkillError::PathNotFound {
988 raw: raw.clone(),
989 resolved,
990 });
991 }
992 self.root_dirs.push((resolved, raw.clone()));
993 }
994 }
995 }
996 }
997 }
998
999 Ok(self)
1000 }
1001
1002 pub fn auto_detect_project_layer(mut self, yaml_path: &Path) -> Self {
1007 let candidate = project_skills_dir(yaml_path);
1008 if candidate.is_dir() {
1009 self.project_dir = Some(candidate);
1010 }
1011 self
1012 }
1013
1014 pub fn finalise(self) -> Result<ResolvedRegistry, SkillError> {
1029 let Self {
1030 bundled,
1031 root_dirs,
1032 root_includes_bundled,
1033 project_dir,
1034 evaluator,
1035 } = self;
1036
1037 let mut bundled_skills: Vec<Skill> = Vec::with_capacity(bundled.len());
1040 if root_includes_bundled {
1041 for b in &bundled {
1042 let path = PathBuf::from(format!("<bundled:{}>", b.name));
1043 let (frontmatter, body) =
1044 parse_skill(b.body, &path).map_err(|e| SkillError::BundledSkillInvalid {
1045 name: b.name,
1046 message: e.to_string(),
1047 })?;
1048 if frontmatter.name != b.name {
1049 return Err(SkillError::BundledSkillInvalid {
1050 name: b.name,
1051 message: format!(
1052 "frontmatter name {:?} does not match the bundled key {:?}",
1053 frontmatter.name, b.name
1054 ),
1055 });
1056 }
1057 bundled_skills.push(Skill {
1058 frontmatter,
1059 body,
1060 provenance: SkillProvenance::Bundled,
1061 });
1062 }
1063 }
1064
1065 let mut parse_warnings: Vec<ParseWarning> = Vec::new();
1069 let mut root_skills_per_dir: Vec<Vec<Skill>> = Vec::with_capacity(root_dirs.len());
1070 for (resolved, _raw) in &root_dirs {
1071 let provenance = SkillProvenance::DomainPack(resolved.clone());
1072 let (skills, warnings) = load_skills_from_dir(resolved, provenance)?;
1073 parse_warnings.extend(warnings);
1074 root_skills_per_dir.push(skills);
1075 }
1076
1077 let project_skills: Vec<Skill> = match &project_dir {
1079 Some(dir) => {
1080 let (skills, warnings) = load_skills_from_dir(dir, SkillProvenance::Project)?;
1081 parse_warnings.extend(warnings);
1082 skills
1083 }
1084 None => Vec::new(),
1085 };
1086
1087 let mut resolved: HashMap<String, Skill> = HashMap::new();
1097 let mut collisions: HashMap<String, Vec<SkillProvenance>> = HashMap::new();
1098
1099 for skill in &bundled_skills {
1103 let name = skill.name().to_string();
1104 collisions
1105 .entry(name.clone())
1106 .or_default()
1107 .push(skill.provenance.clone());
1108 resolved.insert(name, skill.clone());
1109 }
1110 for skills in root_skills_per_dir.iter().rev() {
1111 for skill in skills {
1112 let name = skill.name().to_string();
1113 collisions
1114 .entry(name.clone())
1115 .or_default()
1116 .push(skill.provenance.clone());
1117 resolved.insert(name, skill.clone());
1118 }
1119 }
1120 for skill in &project_skills {
1121 let name = skill.name().to_string();
1122 collisions
1123 .entry(name.clone())
1124 .or_default()
1125 .push(skill.provenance.clone());
1126 resolved.insert(name, skill.clone());
1127 }
1128
1129 for (name, candidates) in &collisions {
1132 if candidates.len() > 1 {
1133 let winner = resolved
1134 .get(name)
1135 .map(|s| format_provenance(&s.provenance))
1136 .unwrap_or_else(|| "<none>".to_string());
1137 let all_candidates: Vec<String> =
1138 candidates.iter().map(format_provenance).collect();
1139 tracing::info!(
1140 skill = %name,
1141 candidates = ?all_candidates,
1142 winner = %winner,
1143 "skill resolved across multiple layers"
1144 );
1145 }
1146 }
1147
1148 let total_bytes: usize = resolved.values().map(|s| s.body.len()).sum();
1150 if total_bytes > SESSION_TOTAL_LIMIT_BYTES {
1151 tracing::warn!(
1152 total_bytes,
1153 limit = SESSION_TOTAL_LIMIT_BYTES,
1154 skill_count = resolved.len(),
1155 "total resolved skill body size exceeds session limit; \
1156 consider trimming or splitting skills"
1157 );
1158 }
1159
1160 Ok(ResolvedRegistry {
1161 skills: resolved,
1162 evaluator,
1163 parse_warnings,
1164 })
1165 }
1166}
1167
1168fn format_provenance(p: &SkillProvenance) -> String {
1169 match p {
1170 SkillProvenance::Project => "project".to_string(),
1171 SkillProvenance::DomainPack(path) => format!("pack:{}", path.display()),
1172 SkillProvenance::Bundled => "bundled".to_string(),
1173 }
1174}
1175
1176#[derive(Default)]
1182pub struct ResolvedRegistry {
1183 skills: HashMap<String, Skill>,
1184 pub(crate) evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
1190 parse_warnings: Vec<ParseWarning>,
1196}
1197
1198impl std::fmt::Debug for ResolvedRegistry {
1199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1200 f.debug_struct("ResolvedRegistry")
1201 .field("skills", &self.skills)
1202 .field(
1203 "evaluator",
1204 &self
1205 .evaluator
1206 .as_ref()
1207 .map(|_| "<dyn SkillPredicateEvaluator>"),
1208 )
1209 .finish()
1210 }
1211}
1212
1213impl ResolvedRegistry {
1214 pub fn skill_names(&self) -> Vec<String> {
1217 let mut names: Vec<String> = self.skills.keys().cloned().collect();
1218 names.sort();
1219 names
1220 }
1221
1222 pub fn get(&self, name: &str) -> Option<&Skill> {
1225 self.skills.get(name)
1226 }
1227
1228 pub fn iter(&self) -> impl Iterator<Item = (&String, &Skill)> {
1231 self.skills.iter()
1232 }
1233
1234 pub fn len(&self) -> usize {
1236 self.skills.len()
1237 }
1238
1239 pub fn is_empty(&self) -> bool {
1241 self.skills.is_empty()
1242 }
1243
1244 pub fn parse_warnings(&self) -> &[ParseWarning] {
1251 &self.parse_warnings
1252 }
1253
1254 pub fn activation_for(
1267 &self,
1268 skill: &Skill,
1269 registered_tools: &std::collections::HashSet<String>,
1270 extensions: &serde_json::Map<String, serde_json::Value>,
1271 ) -> SkillActivation {
1272 let Some(applies_when) = skill.frontmatter.applies_when.as_ref() else {
1273 return SkillActivation {
1274 active: true,
1275 clauses: Vec::new(),
1276 };
1277 };
1278 let mut clauses = Vec::new();
1279 let mut all_satisfied = true;
1280
1281 if let Some(types) = applies_when.graph_has_node_type.as_ref() {
1282 let clause = PredicateClause::GraphHasNodeType(types);
1283 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1284 if outcome != PredicateOutcome::Satisfied {
1285 all_satisfied = false;
1286 }
1287 clauses.push((format!("graph_has_node_type: {types:?}"), outcome));
1288 }
1289 if let Some(prop) = applies_when.graph_has_property.as_ref() {
1290 let clause = PredicateClause::GraphHasProperty {
1291 node_type: &prop.node_type,
1292 prop_name: &prop.prop_name,
1293 };
1294 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1295 if outcome != PredicateOutcome::Satisfied {
1296 all_satisfied = false;
1297 }
1298 clauses.push((
1299 format!("graph_has_property: {}.{}", prop.node_type, prop.prop_name),
1300 outcome,
1301 ));
1302 }
1303 if let Some(tool) = applies_when.tool_registered.as_ref() {
1304 let clause = PredicateClause::ToolRegistered(tool);
1305 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1306 if outcome != PredicateOutcome::Satisfied {
1307 all_satisfied = false;
1308 }
1309 clauses.push((format!("tool_registered: {tool}"), outcome));
1310 }
1311 if let Some(key) = applies_when.extension_enabled.as_ref() {
1312 let clause = PredicateClause::ExtensionEnabled(key);
1313 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1314 if outcome != PredicateOutcome::Satisfied {
1315 all_satisfied = false;
1316 }
1317 clauses.push((format!("extension_enabled: {key}"), outcome));
1318 }
1319
1320 SkillActivation {
1321 active: all_satisfied,
1322 clauses,
1323 }
1324 }
1325
1326 fn dispatch_clause(
1327 &self,
1328 clause: &PredicateClause<'_>,
1329 registered_tools: &std::collections::HashSet<String>,
1330 extensions: &serde_json::Map<String, serde_json::Value>,
1331 ) -> PredicateOutcome {
1332 match clause {
1337 PredicateClause::ToolRegistered(name) => {
1338 return if registered_tools.contains(*name) {
1339 PredicateOutcome::Satisfied
1340 } else {
1341 PredicateOutcome::Unsatisfied
1342 };
1343 }
1344 PredicateClause::ExtensionEnabled(key) => {
1345 let truthy = extensions
1346 .get(*key)
1347 .map(|v| !v.is_null() && v != &serde_json::Value::Bool(false))
1348 .unwrap_or(false);
1349 return if truthy {
1350 PredicateOutcome::Satisfied
1351 } else {
1352 PredicateOutcome::Unsatisfied
1353 };
1354 }
1355 _ => {}
1356 }
1357
1358 match self.evaluator.as_ref().and_then(|e| e.evaluate(clause)) {
1361 Some(true) => PredicateOutcome::Satisfied,
1362 Some(false) => PredicateOutcome::Unsatisfied,
1363 None => PredicateOutcome::Unknown,
1364 }
1365 }
1366}
1367
1368#[cfg(test)]
1371mod tests {
1372 use super::*;
1373 use std::io::Write;
1374
1375 fn write_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
1376 let path = dir.join(format!("{name}.md"));
1377 let mut f = fs::File::create(&path).unwrap();
1378 f.write_all(content.as_bytes()).unwrap();
1379 path
1380 }
1381
1382 fn minimal_skill(name: &str) -> String {
1383 format!(
1384 "---\nname: {name}\ndescription: A test skill named {name}.\n---\n\n# {name}\n\nBody.\n"
1385 )
1386 }
1387
1388 #[test]
1391 fn parse_frontmatter_basic() {
1392 let content = "---\nname: foo\ndescription: A foo skill.\n---\n\nBody here.\n";
1393 let path = PathBuf::from("test.md");
1394 let (fm, body) = parse_skill(content, &path).unwrap();
1395 assert_eq!(fm.name, "foo");
1396 assert_eq!(fm.description, "A foo skill.");
1397 assert_eq!(body, "\nBody here.\n");
1398 assert!(fm.auto_inject_hint, "auto_inject_hint defaults to true");
1399 }
1400
1401 #[test]
1402 fn parse_frontmatter_missing_delimiters_rejected() {
1403 let content = "name: foo\ndescription: bar\n";
1404 let path = PathBuf::from("test.md");
1405 let err = parse_skill(content, &path).unwrap_err();
1406 assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
1407 }
1408
1409 #[test]
1410 fn parse_frontmatter_invalid_yaml_rejected() {
1411 let content = "---\nname: foo\n bad: yaml: nesting\n---\nbody\n";
1412 let path = PathBuf::from("test.md");
1413 let err = parse_skill(content, &path).unwrap_err();
1414 assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
1415 }
1416
1417 #[test]
1418 fn parse_frontmatter_missing_name_rejected() {
1419 let content = "---\ndescription: bar\n---\nbody\n";
1420 let path = PathBuf::from("test.md");
1421 let err = parse_skill(content, &path).unwrap_err();
1422 assert!(matches!(
1423 err,
1424 SkillError::MissingRequiredField { field: "name", .. }
1425 ));
1426 }
1427
1428 #[test]
1429 fn parse_frontmatter_missing_description_rejected() {
1430 let content = "---\nname: foo\n---\nbody\n";
1431 let path = PathBuf::from("test.md");
1432 let err = parse_skill(content, &path).unwrap_err();
1433 assert!(matches!(
1434 err,
1435 SkillError::MissingRequiredField {
1436 field: "description",
1437 ..
1438 }
1439 ));
1440 }
1441
1442 #[test]
1443 fn parse_frontmatter_all_optional_fields() {
1444 let content = "---\n\
1445name: foo\n\
1446description: Full surface.\n\
1447references_tools: [grep, list_source]\n\
1448references_arguments: [grep.pattern]\n\
1449references_properties: [Function.module]\n\
1450auto_inject_hint: false\n\
1451applies_to:\n mcp_methods: \">=0.3.35\"\n\
1452---\n\
1453Body.\n";
1454 let path = PathBuf::from("test.md");
1455 let (fm, _) = parse_skill(content, &path).unwrap();
1456 assert_eq!(fm.references_tools, vec!["grep", "list_source"]);
1457 assert_eq!(fm.references_arguments, vec!["grep.pattern"]);
1458 assert_eq!(fm.references_properties, vec!["Function.module"]);
1459 assert!(!fm.auto_inject_hint);
1460 assert_eq!(
1461 fm.applies_to.unwrap().get("mcp_methods"),
1462 Some(&">=0.3.35".to_string())
1463 );
1464 }
1465
1466 #[test]
1469 fn load_skill_from_file_basic() {
1470 let dir = tempfile::tempdir().unwrap();
1471 let path = write_skill(dir.path(), "foo", &minimal_skill("foo"));
1472 let skill = load_skill_from_file(&path, SkillProvenance::Project).unwrap();
1473 assert_eq!(skill.name(), "foo");
1474 assert_eq!(skill.provenance, SkillProvenance::Project);
1475 }
1476
1477 #[test]
1478 fn load_skill_too_large_rejected() {
1479 let dir = tempfile::tempdir().unwrap();
1480 let big_body = "x".repeat(HARD_SIZE_LIMIT_BYTES + 100);
1482 let content = format!("---\nname: big\ndescription: too big.\n---\n{big_body}");
1483 let path = write_skill(dir.path(), "big", &content);
1484 let err = load_skill_from_file(&path, SkillProvenance::Project).unwrap_err();
1485 assert!(matches!(err, SkillError::SkillTooLarge { .. }));
1486 }
1487
1488 #[test]
1489 fn load_skills_from_dir_walks_markdown_only() {
1490 let dir = tempfile::tempdir().unwrap();
1491 write_skill(dir.path(), "a", &minimal_skill("a"));
1492 write_skill(dir.path(), "b", &minimal_skill("b"));
1493 fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
1495 let sub = dir.path().join("sub");
1497 fs::create_dir(&sub).unwrap();
1498 write_skill(&sub, "c", &minimal_skill("c"));
1499
1500 let (skills, warnings) =
1501 load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1502 assert_eq!(skills.len(), 2);
1503 assert!(warnings.is_empty());
1504 let mut names: Vec<&str> = skills.iter().map(|s| s.name()).collect();
1505 names.sort();
1506 assert_eq!(names, vec!["a", "b"]);
1507 }
1508
1509 #[test]
1510 fn load_skills_from_dir_missing_returns_empty() {
1511 let dir = tempfile::tempdir().unwrap();
1512 let nonexistent = dir.path().join("does-not-exist");
1513 let (skills, warnings) =
1514 load_skills_from_dir(&nonexistent, SkillProvenance::Project).unwrap();
1515 assert!(skills.is_empty());
1516 assert!(warnings.is_empty());
1517 }
1518
1519 #[test]
1520 fn load_skills_from_dir_surfaces_yaml_parse_failure_as_warning() {
1521 let dir = tempfile::tempdir().unwrap();
1529 write_skill(dir.path(), "good", &minimal_skill("good"));
1531 write_skill(
1533 dir.path(),
1534 "broken",
1535 "---\nname: broken\ndescription: First clause: second clause\n---\n# body\n",
1536 );
1537
1538 let (skills, warnings) =
1539 load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1540 assert_eq!(skills.len(), 1, "the good skill should still load");
1541 assert_eq!(skills[0].name(), "good");
1542 assert_eq!(
1543 warnings.len(),
1544 1,
1545 "the broken file should surface as a warning"
1546 );
1547 assert!(warnings[0].path.ends_with("broken.md"));
1548 assert!(!warnings[0].error.is_empty());
1549 }
1550
1551 #[test]
1552 fn resolved_registry_parse_warnings_propagated_from_project_layer() {
1553 let dir = tempfile::tempdir().unwrap();
1556 let yaml = dir.path().join("test_mcp.yaml");
1557 fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1558 let skills_dir = dir.path().join("test_mcp.skills");
1559 fs::create_dir(&skills_dir).unwrap();
1560 write_skill(&skills_dir, "good", &minimal_skill("good"));
1562 write_skill(
1564 &skills_dir,
1565 "broken",
1566 "---\nname: broken\ndescription: bad\nstill in frontmatter\n",
1567 );
1568
1569 let registry = Registry::new()
1570 .auto_detect_project_layer(&yaml)
1571 .finalise()
1572 .unwrap();
1573
1574 assert_eq!(registry.len(), 1, "good skill resolved");
1575 assert!(registry.get("good").is_some());
1576 let warnings = registry.parse_warnings();
1577 assert_eq!(warnings.len(), 1);
1578 assert!(warnings[0].path.ends_with("broken.md"));
1579 }
1580
1581 #[test]
1584 fn resolve_skill_path_relative() {
1585 let manifest_dir = Path::new("/a/b");
1586 assert_eq!(
1587 resolve_skill_path("./skills", manifest_dir),
1588 PathBuf::from("/a/b/./skills")
1589 );
1590 assert_eq!(
1591 resolve_skill_path("skills", manifest_dir),
1592 PathBuf::from("/a/b/skills")
1593 );
1594 }
1595
1596 #[test]
1597 fn resolve_skill_path_absolute() {
1598 let manifest_dir = Path::new("/a/b");
1599 assert_eq!(
1600 resolve_skill_path("/abs/skills", manifest_dir),
1601 PathBuf::from("/abs/skills")
1602 );
1603 }
1604
1605 #[test]
1606 fn resolve_skill_path_home_relative() {
1607 let manifest_dir = Path::new("/a/b");
1608 unsafe {
1612 std::env::set_var("HOME", "/home/test");
1613 }
1614 assert_eq!(
1615 resolve_skill_path("~/skills", manifest_dir),
1616 PathBuf::from("/home/test/skills")
1617 );
1618 }
1619
1620 #[test]
1621 fn project_skills_dir_naming() {
1622 assert_eq!(
1623 project_skills_dir(Path::new("/a/b/legal_mcp.yaml")),
1624 PathBuf::from("/a/b/legal_mcp.skills")
1625 );
1626 assert_eq!(
1627 project_skills_dir(Path::new("workspace_mcp.yaml")),
1628 PathBuf::from("workspace_mcp.skills")
1629 );
1630 }
1631
1632 #[test]
1635 fn registry_disabled_resolves_empty() {
1636 let dir = tempfile::tempdir().unwrap();
1637 let yaml = dir.path().join("test_mcp.yaml");
1638 fs::write(&yaml, "name: x\n").unwrap();
1639
1640 let registry = Registry::new()
1641 .layer_dirs(&SkillsSource::Disabled, &yaml)
1642 .unwrap()
1643 .auto_detect_project_layer(&yaml)
1644 .finalise()
1645 .unwrap();
1646 assert!(registry.is_empty());
1647 }
1648
1649 #[test]
1650 fn registry_add_bundled_only_visible_when_opted_in() {
1651 let dir = tempfile::tempdir().unwrap();
1652 let yaml = dir.path().join("test_mcp.yaml");
1653 fs::write(&yaml, "name: x\n").unwrap();
1654
1655 let bundled = BundledSkill {
1656 name: "foo",
1657 body: Box::leak(minimal_skill("foo").into_boxed_str()),
1661 };
1662
1663 let registry = Registry::new()
1665 .add_bundled(bundled.clone())
1666 .layer_dirs(&SkillsSource::Disabled, &yaml)
1667 .unwrap()
1668 .finalise()
1669 .unwrap();
1670 assert!(registry.is_empty(), "disabled must short-circuit bundled");
1671
1672 let registry = Registry::new()
1674 .add_bundled(bundled)
1675 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1676 .unwrap()
1677 .finalise()
1678 .unwrap();
1679 assert_eq!(registry.len(), 1);
1680 assert!(registry.get("foo").is_some());
1681 assert_eq!(
1682 registry.get("foo").unwrap().provenance,
1683 SkillProvenance::Bundled
1684 );
1685 }
1686
1687 #[test]
1688 fn registry_three_layer_resolution_project_wins_over_bundled() {
1689 let dir = tempfile::tempdir().unwrap();
1690 let yaml = dir.path().join("test_mcp.yaml");
1691 fs::write(&yaml, "name: x\n").unwrap();
1692
1693 let bundled = BundledSkill {
1695 name: "foo",
1696 body: "---\nname: foo\ndescription: from bundled.\n---\nbundled body\n",
1697 };
1698
1699 let project_dir = dir.path().join("test_mcp.skills");
1701 fs::create_dir(&project_dir).unwrap();
1702 fs::write(
1703 project_dir.join("foo.md"),
1704 "---\nname: foo\ndescription: from project.\n---\nproject body\n",
1705 )
1706 .unwrap();
1707
1708 let registry = Registry::new()
1709 .add_bundled(bundled)
1710 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1711 .unwrap()
1712 .auto_detect_project_layer(&yaml)
1713 .finalise()
1714 .unwrap();
1715
1716 assert_eq!(registry.len(), 1);
1717 let skill = registry.get("foo").unwrap();
1718 assert_eq!(skill.description(), "from project.");
1719 assert_eq!(skill.provenance, SkillProvenance::Project);
1720 }
1721
1722 #[test]
1723 fn registry_root_layer_first_declaration_wins() {
1724 let dir = tempfile::tempdir().unwrap();
1725 let yaml = dir.path().join("test_mcp.yaml");
1726 fs::write(&yaml, "name: x\n").unwrap();
1727
1728 let primary = dir.path().join("primary");
1730 fs::create_dir(&primary).unwrap();
1731 fs::write(
1732 primary.join("foo.md"),
1733 "---\nname: foo\ndescription: from primary.\n---\nprimary body\n",
1734 )
1735 .unwrap();
1736
1737 let secondary = dir.path().join("secondary");
1739 fs::create_dir(&secondary).unwrap();
1740 fs::write(
1741 secondary.join("foo.md"),
1742 "---\nname: foo\ndescription: from secondary.\n---\nsecondary body\n",
1743 )
1744 .unwrap();
1745
1746 let registry = Registry::new()
1747 .layer_dirs(
1748 &SkillsSource::Sources(vec![
1749 SkillSource::Path("./primary".into()),
1750 SkillSource::Path("./secondary".into()),
1751 ]),
1752 &yaml,
1753 )
1754 .unwrap()
1755 .finalise()
1756 .unwrap();
1757
1758 assert_eq!(registry.len(), 1);
1759 assert_eq!(registry.get("foo").unwrap().description(), "from primary.");
1760 }
1761
1762 #[test]
1763 fn registry_root_layer_nonexistent_path_rejected() {
1764 let dir = tempfile::tempdir().unwrap();
1765 let yaml = dir.path().join("test_mcp.yaml");
1766 fs::write(&yaml, "name: x\n").unwrap();
1767
1768 let err = Registry::new()
1769 .layer_dirs(
1770 &SkillsSource::Sources(vec![SkillSource::Path("./does-not-exist".into())]),
1771 &yaml,
1772 )
1773 .unwrap_err();
1774 assert!(matches!(err, SkillError::PathNotFound { .. }));
1775 }
1776
1777 #[test]
1778 fn from_manifest_resolves_full_stack() {
1779 let dir = tempfile::tempdir().unwrap();
1780 let yaml = dir.path().join("test_mcp.yaml");
1781 fs::write(&yaml, "name: x\nskills:\n - true\n - ./domain-pack\n").unwrap();
1782
1783 let project_dir = dir.path().join("test_mcp.skills");
1784 fs::create_dir(&project_dir).unwrap();
1785 fs::write(project_dir.join("a.md"), minimal_skill("a")).unwrap();
1786
1787 let pack_dir = dir.path().join("domain-pack");
1788 fs::create_dir(&pack_dir).unwrap();
1789 fs::write(pack_dir.join("b.md"), minimal_skill("b")).unwrap();
1790
1791 let registry = Registry::from_manifest(&yaml, false).unwrap();
1792 let names = registry.skill_names();
1793 assert!(names.contains(&"a".to_string()));
1794 assert!(names.contains(&"b".to_string()));
1795 assert_eq!(
1796 registry.get("a").unwrap().provenance,
1797 SkillProvenance::Project
1798 );
1799 }
1800
1801 #[test]
1802 fn from_manifest_surfaces_manifest_load_error() {
1803 let dir = tempfile::tempdir().unwrap();
1804 let yaml = dir.path().join("broken_mcp.yaml");
1805 fs::write(&yaml, "this: is: not: valid yaml\n").unwrap();
1806
1807 let err = Registry::from_manifest(&yaml, false).unwrap_err();
1808 assert!(matches!(err, SkillError::Manifest { .. }));
1809 }
1810
1811 #[test]
1812 fn registry_empty_list_opts_in_without_root_sources() {
1813 let dir = tempfile::tempdir().unwrap();
1814 let yaml = dir.path().join("test_mcp.yaml");
1815 fs::write(&yaml, "name: x\n").unwrap();
1816
1817 let project_dir = dir.path().join("test_mcp.skills");
1819 fs::create_dir(&project_dir).unwrap();
1820 fs::write(project_dir.join("only.md"), minimal_skill("only")).unwrap();
1821
1822 let registry = Registry::new()
1823 .layer_dirs(&SkillsSource::Sources(vec![]), &yaml)
1824 .unwrap()
1825 .auto_detect_project_layer(&yaml)
1826 .finalise()
1827 .unwrap();
1828
1829 assert_eq!(registry.len(), 1);
1830 assert_eq!(
1831 registry.get("only").unwrap().provenance,
1832 SkillProvenance::Project
1833 );
1834 }
1835
1836 #[test]
1837 fn registry_bundled_name_mismatch_rejected_at_finalise() {
1838 let dir = tempfile::tempdir().unwrap();
1839 let yaml = dir.path().join("test_mcp.yaml");
1840 fs::write(&yaml, "name: x\n").unwrap();
1841
1842 let bundled = BundledSkill {
1844 name: "foo",
1845 body: Box::leak(
1846 "---\nname: bar\ndescription: mismatch.\n---\nbody\n"
1847 .to_string()
1848 .into_boxed_str(),
1849 ),
1850 };
1851
1852 let err = Registry::new()
1853 .add_bundled(bundled)
1854 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1855 .unwrap()
1856 .finalise()
1857 .unwrap_err();
1858 assert!(matches!(err, SkillError::BundledSkillInvalid { .. }));
1859 }
1860
1861 #[test]
1862 fn registry_library_bundled_skills_returns_vec() {
1863 let skills = library_bundled_skills();
1868 assert!(
1869 !skills.is_empty(),
1870 "library_bundled_skills should return framework defaults from Phase 1d onward"
1871 );
1872 }
1873
1874 #[test]
1875 fn registry_skill_names_sorted() {
1876 let dir = tempfile::tempdir().unwrap();
1877 let yaml = dir.path().join("test_mcp.yaml");
1878 fs::write(&yaml, "name: x\n").unwrap();
1879
1880 let pack = dir.path().join("pack");
1881 fs::create_dir(&pack).unwrap();
1882 fs::write(pack.join("zeta.md"), minimal_skill("zeta")).unwrap();
1883 fs::write(pack.join("alpha.md"), minimal_skill("alpha")).unwrap();
1884 fs::write(pack.join("mu.md"), minimal_skill("mu")).unwrap();
1885
1886 let registry = Registry::new()
1887 .layer_dirs(
1888 &SkillsSource::Sources(vec![SkillSource::Path("./pack".into())]),
1889 &yaml,
1890 )
1891 .unwrap()
1892 .finalise()
1893 .unwrap();
1894
1895 assert_eq!(registry.skill_names(), vec!["alpha", "mu", "zeta"]);
1896 }
1897
1898 #[test]
1901 fn render_skill_template_is_parse_valid() {
1902 let body = render_skill_template("custom_method", "A test description for the skill.");
1906 let (fm, _body) =
1907 parse_skill(&body, &PathBuf::from("test.md")).expect("rendered template must parse");
1908 assert_eq!(fm.name, "custom_method");
1909 assert_eq!(fm.description, "A test description for the skill.");
1910 }
1911
1912 #[test]
1913 fn render_skill_template_substitutes_name_into_body_headings() {
1914 let body = render_skill_template("my_skill", "desc");
1915 assert!(body.contains("# `my_skill` methodology"));
1916 assert!(body.contains("## When `my_skill` is the wrong tool"));
1917 }
1918
1919 #[test]
1920 fn write_skill_template_writes_into_directory() {
1921 let dir = tempfile::tempdir().unwrap();
1922 let dest = write_skill_template(dir.path(), "alpha", "First skill.").unwrap();
1923 assert_eq!(dest, dir.path().join("alpha.md"));
1924 let content = fs::read_to_string(&dest).unwrap();
1925 assert!(content.contains("name: alpha"));
1926 }
1927
1928 #[test]
1929 fn write_skill_template_writes_to_explicit_md_path() {
1930 let dir = tempfile::tempdir().unwrap();
1931 let explicit = dir.path().join("renamed.md");
1932 let dest = write_skill_template(&explicit, "alpha", "First skill.").unwrap();
1933 assert_eq!(dest, explicit);
1934 assert!(explicit.is_file());
1935 }
1936
1937 #[test]
1938 fn write_skill_template_creates_missing_parents() {
1939 let dir = tempfile::tempdir().unwrap();
1940 let nested = dir.path().join("a/b/c");
1941 let dest = write_skill_template(&nested, "alpha", "First skill.").unwrap();
1942 assert_eq!(dest, nested.join("alpha.md"));
1943 assert!(dest.is_file());
1944 }
1945
1946 #[test]
1947 fn write_skill_template_refuses_to_overwrite() {
1948 let dir = tempfile::tempdir().unwrap();
1949 let path = dir.path().join("alpha.md");
1950 fs::write(&path, "existing").unwrap();
1951 let err = write_skill_template(dir.path(), "alpha", "Replace me?").unwrap_err();
1952 assert!(matches!(err, SkillError::Io { .. }));
1953 assert_eq!(fs::read_to_string(&path).unwrap(), "existing");
1955 }
1956
1957 #[test]
1958 fn write_skill_template_round_trips_through_registry() {
1959 let dir = tempfile::tempdir().unwrap();
1962 let yaml = dir.path().join("test_mcp.yaml");
1963 fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1964 let skills_dir = dir.path().join("test_mcp.skills");
1965 write_skill_template(&skills_dir, "custom_method", "Project-layer skill body.").unwrap();
1966
1967 let registry = Registry::new()
1968 .auto_detect_project_layer(&yaml)
1969 .finalise()
1970 .unwrap();
1971 let skill = registry
1972 .get("custom_method")
1973 .expect("template should resolve");
1974 assert_eq!(skill.description(), "Project-layer skill body.");
1975 }
1976
1977 fn skill_with_applies_when(applies_when_yaml: &str) -> Skill {
1980 let body = format!(
1981 "---\nname: gated\ndescription: A gated skill.\n\
1982 applies_when:\n{applies_when_yaml}\n---\n\nBody.\n"
1983 );
1984 let (frontmatter, body) = parse_skill(&body, &PathBuf::from("gated.md")).unwrap();
1985 Skill {
1986 frontmatter,
1987 body,
1988 provenance: SkillProvenance::Bundled,
1989 }
1990 }
1991
1992 #[test]
1993 fn applies_when_parses_map_shape() {
1994 let skill = skill_with_applies_when(
1995 " graph_has_node_type: [Function, Class]\n\
1996 \x20 tool_registered: cypher_query\n\
1997 \x20 extension_enabled: csv_http_server\n\
1998 \x20 graph_has_property:\n\
1999 \x20 node_type: Function\n\
2000 \x20 prop_name: module",
2001 );
2002 let applies = skill.frontmatter.applies_when.unwrap();
2003 assert_eq!(
2004 applies.graph_has_node_type.as_deref(),
2005 Some(["Function".to_string(), "Class".to_string()].as_slice())
2006 );
2007 assert_eq!(applies.tool_registered.as_deref(), Some("cypher_query"));
2008 assert_eq!(
2009 applies.extension_enabled.as_deref(),
2010 Some("csv_http_server")
2011 );
2012 assert_eq!(
2013 applies.graph_has_property,
2014 Some(GraphPropertyCheck {
2015 node_type: "Function".to_string(),
2016 prop_name: "module".to_string(),
2017 })
2018 );
2019 }
2020
2021 #[test]
2022 fn applies_when_absent_means_always_active() {
2023 let body = "---\nname: ungated\ndescription: An ungated skill.\n---\n\nBody.\n";
2024 let (frontmatter, body) = parse_skill(body, &PathBuf::from("ungated.md")).unwrap();
2025 let skill = Skill {
2026 frontmatter,
2027 body,
2028 provenance: SkillProvenance::Bundled,
2029 };
2030 let registry = ResolvedRegistry::default();
2031 let activation = registry.activation_for(
2032 &skill,
2033 &std::collections::HashSet::new(),
2034 &serde_json::Map::new(),
2035 );
2036 assert!(activation.active);
2037 assert!(activation.clauses.is_empty());
2038 }
2039
2040 #[test]
2041 fn tool_registered_predicate_dispatches_in_framework() {
2042 let skill = skill_with_applies_when(" tool_registered: cypher_query");
2043 let registry = ResolvedRegistry::default();
2044 let mut tools = std::collections::HashSet::new();
2045
2046 let inactive = registry.activation_for(&skill, &tools, &serde_json::Map::new());
2048 assert!(!inactive.active);
2049 assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
2050
2051 tools.insert("cypher_query".to_string());
2053 let active = registry.activation_for(&skill, &tools, &serde_json::Map::new());
2054 assert!(active.active);
2055 assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
2056 }
2057
2058 #[test]
2059 fn extension_enabled_predicate_dispatches_in_framework() {
2060 let skill = skill_with_applies_when(" extension_enabled: csv_http_server");
2061 let registry = ResolvedRegistry::default();
2062 let tools = std::collections::HashSet::new();
2063 let mut extensions = serde_json::Map::new();
2064
2065 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2067
2068 extensions.insert("csv_http_server".to_string(), serde_json::json!(false));
2070 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2071
2072 extensions.insert("csv_http_server".to_string(), serde_json::Value::Null);
2074 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2075
2076 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
2078 assert!(registry.activation_for(&skill, &tools, &extensions).active);
2079
2080 extensions.insert(
2082 "csv_http_server".to_string(),
2083 serde_json::json!({"enabled": true}),
2084 );
2085 assert!(registry.activation_for(&skill, &tools, &extensions).active);
2086 }
2087
2088 struct StubEvaluator {
2089 has_function: bool,
2090 }
2091 impl SkillPredicateEvaluator for StubEvaluator {
2092 fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool> {
2093 match clause {
2094 PredicateClause::GraphHasNodeType(types) => {
2095 Some(types.iter().any(|t| t == "Function") && self.has_function)
2096 }
2097 _ => None,
2098 }
2099 }
2100 }
2101
2102 #[test]
2103 fn graph_predicate_dispatches_via_evaluator() {
2104 let skill = skill_with_applies_when(" graph_has_node_type: [Function, Class]");
2105
2106 let registry = Registry::new()
2108 .with_predicate_evaluator(StubEvaluator { has_function: true })
2109 .finalise()
2110 .unwrap();
2111 let active = registry.activation_for(
2112 &skill,
2113 &std::collections::HashSet::new(),
2114 &serde_json::Map::new(),
2115 );
2116 assert!(active.active);
2117 assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
2118
2119 let registry = Registry::new()
2121 .with_predicate_evaluator(StubEvaluator {
2122 has_function: false,
2123 })
2124 .finalise()
2125 .unwrap();
2126 let inactive = registry.activation_for(
2127 &skill,
2128 &std::collections::HashSet::new(),
2129 &serde_json::Map::new(),
2130 );
2131 assert!(!inactive.active);
2132 assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
2133 }
2134
2135 #[test]
2136 fn graph_predicate_unknown_without_evaluator_means_inactive() {
2137 let skill = skill_with_applies_when(" graph_has_node_type: [Function]");
2138 let registry = ResolvedRegistry::default();
2139 let activation = registry.activation_for(
2140 &skill,
2141 &std::collections::HashSet::new(),
2142 &serde_json::Map::new(),
2143 );
2144 assert!(!activation.active);
2145 assert_eq!(activation.clauses[0].1, PredicateOutcome::Unknown);
2146 }
2147
2148 #[test]
2149 fn multiple_predicates_all_must_be_satisfied() {
2150 let skill = skill_with_applies_when(
2151 " graph_has_node_type: [Function]\n\
2152 \x20 tool_registered: cypher_query",
2153 );
2154 let registry = Registry::new()
2155 .with_predicate_evaluator(StubEvaluator { has_function: true })
2156 .finalise()
2157 .unwrap();
2158 let mut tools = std::collections::HashSet::new();
2159 let extensions = serde_json::Map::new();
2160
2161 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2163
2164 tools.insert("cypher_query".to_string());
2166 assert!(registry.activation_for(&skill, &tools, &extensions).active);
2167 }
2168}