1use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use thiserror::Error;
18
19use crate::core::manifest::ManifestRegistry;
20use crate::core::scope::ScopeConfig;
21
22#[derive(Error, Debug)]
23pub enum SkillError {
24 #[error("Failed to read skill file {0}: {1}")]
25 Io(String, std::io::Error),
26 #[error("Failed to parse skill.toml {0}: {1}")]
27 Parse(String, toml::de::Error),
28 #[error("Skill not found: {0}")]
29 NotFound(String),
30 #[error("Skills directory not found: {0}")]
31 NoDirectory(String),
32 #[error("Invalid skill: {0}")]
33 Invalid(String),
34}
35
36#[derive(Debug, Clone, Deserialize, Default)]
38pub struct AnthropicFrontmatter {
39 pub name: Option<String>,
40 pub description: Option<String>,
41 pub license: Option<String>,
42 pub compatibility: Option<String>,
43 #[serde(default)]
44 pub metadata: HashMap<String, String>,
45 #[serde(rename = "allowed-tools")]
47 pub allowed_tools: Option<String>,
48}
49
50pub fn parse_frontmatter(content: &str) -> (Option<AnthropicFrontmatter>, &str) {
55 let trimmed = content.trim_start();
56 if !trimmed.starts_with("---") {
57 return (None, content);
58 }
59
60 let after_open = &trimmed[3..];
62 let after_open = match after_open.find('\n') {
64 Some(pos) => &after_open[pos + 1..],
65 None => return (None, content),
66 };
67
68 match after_open.find("\n---") {
69 Some(end_pos) => {
70 let yaml_str = &after_open[..end_pos];
71 let body_start = &after_open[end_pos + 4..]; let body = match body_start.find('\n') {
74 Some(pos) => &body_start[pos + 1..],
75 None => "",
76 };
77
78 match serde_yaml::from_str::<AnthropicFrontmatter>(yaml_str) {
79 Ok(fm) => (Some(fm), body),
80 Err(_) => (None, content), }
82 }
83 None => (None, content),
84 }
85}
86
87pub fn strip_frontmatter(content: &str) -> &str {
89 let (_, body) = parse_frontmatter(content);
90 body
91}
92
93pub fn compute_content_hash(content: &str) -> String {
95 let mut hasher = Sha256::new();
96 hasher.update(content.as_bytes());
97 let result = hasher.finalize();
98 hex::encode(result)
99}
100
101pub fn is_anthropic_valid_name(name: &str) -> bool {
106 if name.is_empty() || name.len() > 64 {
107 return false;
108 }
109 let bytes = name.as_bytes();
110 if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
112 return false;
113 }
114 if !bytes[bytes.len() - 1].is_ascii_lowercase() && !bytes[bytes.len() - 1].is_ascii_digit() {
115 return false;
116 }
117 let mut prev_hyphen = false;
119 for &b in bytes {
120 if b == b'-' {
121 if prev_hyphen {
122 return false;
123 }
124 prev_hyphen = true;
125 } else if b.is_ascii_lowercase() || b.is_ascii_digit() {
126 prev_hyphen = false;
127 } else {
128 return false;
129 }
130 }
131 true
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub enum SkillFormat {
137 #[serde(rename = "anthropic")]
139 Anthropic,
140 #[serde(rename = "legacy-toml")]
142 LegacyToml,
143 #[serde(rename = "inferred")]
145 Inferred,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SkillMeta {
151 pub name: String,
152 #[serde(default = "default_version")]
153 pub version: String,
154 #[serde(default)]
155 pub description: String,
156 #[serde(default)]
157 pub author: Option<String>,
158
159 #[serde(default)]
162 pub tools: Vec<String>,
163 #[serde(default)]
165 pub providers: Vec<String>,
166 #[serde(default)]
168 pub categories: Vec<String>,
169
170 #[serde(default)]
172 pub keywords: Vec<String>,
173 #[serde(default)]
174 pub hint: Option<String>,
175
176 #[serde(default)]
179 pub depends_on: Vec<String>,
180 #[serde(default)]
182 pub suggests: Vec<String>,
183
184 #[serde(default)]
187 pub license: Option<String>,
188 #[serde(default)]
190 pub compatibility: Option<String>,
191 #[serde(default)]
193 pub extra_metadata: HashMap<String, String>,
194 #[serde(default)]
196 pub allowed_tools: Option<String>,
197 #[serde(default)]
199 pub has_frontmatter: bool,
200 #[serde(default = "default_format")]
202 pub format: SkillFormat,
203
204 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub source_url: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub content_hash: Option<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub pinned_sha: Option<String>,
214
215 #[serde(skip)]
218 pub dir: PathBuf,
219}
220
221impl Default for SkillMeta {
222 fn default() -> Self {
223 Self {
224 name: String::new(),
225 version: default_version(),
226 description: String::new(),
227 author: None,
228 tools: Vec::new(),
229 providers: Vec::new(),
230 categories: Vec::new(),
231 keywords: Vec::new(),
232 hint: None,
233 depends_on: Vec::new(),
234 suggests: Vec::new(),
235 license: None,
236 compatibility: None,
237 extra_metadata: HashMap::new(),
238 allowed_tools: None,
239 has_frontmatter: false,
240 format: SkillFormat::Inferred,
241 source_url: None,
242 content_hash: None,
243 pinned_sha: None,
244 dir: PathBuf::new(),
245 }
246 }
247}
248
249fn default_format() -> SkillFormat {
250 SkillFormat::Inferred
251}
252
253fn default_version() -> String {
254 "0.1.0".to_string()
255}
256
257#[derive(Debug, Deserialize)]
259struct SkillToml {
260 skill: SkillMeta,
261}
262
263pub struct SkillRegistry {
265 skills: Vec<SkillMeta>,
266 name_index: HashMap<String, usize>,
268 tool_index: HashMap<String, Vec<usize>>,
270 provider_index: HashMap<String, Vec<usize>>,
272 category_index: HashMap<String, Vec<usize>>,
274}
275
276impl SkillRegistry {
277 pub fn load(skills_dir: &Path) -> Result<Self, SkillError> {
282 let mut skills = Vec::new();
283 let mut name_index = HashMap::new();
284 let mut tool_index: HashMap<String, Vec<usize>> = HashMap::new();
285 let mut provider_index: HashMap<String, Vec<usize>> = HashMap::new();
286 let mut category_index: HashMap<String, Vec<usize>> = HashMap::new();
287
288 if !skills_dir.is_dir() {
289 return Ok(SkillRegistry {
291 skills,
292 name_index,
293 tool_index,
294 provider_index,
295 category_index,
296 });
297 }
298
299 let entries = std::fs::read_dir(skills_dir)
300 .map_err(|e| SkillError::Io(skills_dir.display().to_string(), e))?;
301
302 for entry in entries {
303 let entry = entry.map_err(|e| SkillError::Io(skills_dir.display().to_string(), e))?;
304 let path = entry.path();
305 if !path.is_dir() {
306 continue;
307 }
308
309 let skill = load_skill_from_dir(&path)?;
310
311 let idx = skills.len();
312 name_index.insert(skill.name.clone(), idx);
313
314 for tool in &skill.tools {
315 tool_index.entry(tool.clone()).or_default().push(idx);
316 }
317 for provider in &skill.providers {
318 provider_index
319 .entry(provider.clone())
320 .or_default()
321 .push(idx);
322 }
323 for category in &skill.categories {
324 category_index
325 .entry(category.clone())
326 .or_default()
327 .push(idx);
328 }
329
330 skills.push(skill);
331 }
332
333 Ok(SkillRegistry {
334 skills,
335 name_index,
336 tool_index,
337 provider_index,
338 category_index,
339 })
340 }
341
342 pub fn get_skill(&self, name: &str) -> Option<&SkillMeta> {
344 self.name_index.get(name).map(|&idx| &self.skills[idx])
345 }
346
347 pub fn list_skills(&self) -> &[SkillMeta] {
349 &self.skills
350 }
351
352 pub fn skills_for_tool(&self, tool_name: &str) -> Vec<&SkillMeta> {
354 self.tool_index
355 .get(tool_name)
356 .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
357 .unwrap_or_default()
358 }
359
360 pub fn skills_for_provider(&self, provider_name: &str) -> Vec<&SkillMeta> {
362 self.provider_index
363 .get(provider_name)
364 .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
365 .unwrap_or_default()
366 }
367
368 pub fn skills_for_category(&self, category: &str) -> Vec<&SkillMeta> {
370 self.category_index
371 .get(category)
372 .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
373 .unwrap_or_default()
374 }
375
376 pub fn search(&self, query: &str) -> Vec<&SkillMeta> {
378 let q = query.to_lowercase();
379 let terms: Vec<&str> = q.split_whitespace().collect();
380
381 let mut scored: Vec<(usize, &SkillMeta)> = self
382 .skills
383 .iter()
384 .filter_map(|skill| {
385 let mut score = 0usize;
386 let name_lower = skill.name.to_lowercase();
387 let desc_lower = skill.description.to_lowercase();
388
389 for term in &terms {
390 if name_lower.contains(term) {
392 score += 10;
393 }
394 if desc_lower.contains(term) {
396 score += 5;
397 }
398 if skill
400 .keywords
401 .iter()
402 .any(|k| k.to_lowercase().contains(term))
403 {
404 score += 8;
405 }
406 if skill.tools.iter().any(|t| t.to_lowercase().contains(term)) {
408 score += 6;
409 }
410 if let Some(hint) = &skill.hint {
412 if hint.to_lowercase().contains(term) {
413 score += 4;
414 }
415 }
416 if skill
418 .providers
419 .iter()
420 .any(|p| p.to_lowercase().contains(term))
421 {
422 score += 6;
423 }
424 if skill
426 .categories
427 .iter()
428 .any(|c| c.to_lowercase().contains(term))
429 {
430 score += 4;
431 }
432 }
433
434 if score > 0 {
435 Some((score, skill))
436 } else {
437 None
438 }
439 })
440 .collect();
441
442 scored.sort_by(|a, b| b.0.cmp(&a.0));
444 scored.into_iter().map(|(_, skill)| skill).collect()
445 }
446
447 pub fn read_content(&self, name: &str) -> Result<String, SkillError> {
449 let skill = self
450 .get_skill(name)
451 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
452 let skill_md = skill.dir.join("SKILL.md");
453 if !skill_md.exists() {
454 return Ok(String::new());
455 }
456 let raw = std::fs::read_to_string(&skill_md)
457 .map_err(|e| SkillError::Io(skill_md.display().to_string(), e))?;
458 Ok(strip_frontmatter(&raw).to_string())
459 }
460
461 pub fn list_references(&self, name: &str) -> Result<Vec<String>, SkillError> {
463 let skill = self
464 .get_skill(name)
465 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
466 let refs_dir = skill.dir.join("references");
467 if !refs_dir.is_dir() {
468 return Ok(Vec::new());
469 }
470 let mut refs = Vec::new();
471 let entries = std::fs::read_dir(&refs_dir)
472 .map_err(|e| SkillError::Io(refs_dir.display().to_string(), e))?;
473 for entry in entries {
474 let entry = entry.map_err(|e| SkillError::Io(refs_dir.display().to_string(), e))?;
475 if let Some(name) = entry.file_name().to_str() {
476 refs.push(name.to_string());
477 }
478 }
479 refs.sort();
480 Ok(refs)
481 }
482
483 pub fn read_reference(&self, skill_name: &str, ref_name: &str) -> Result<String, SkillError> {
485 if ref_name.contains("..")
487 || ref_name.contains('/')
488 || ref_name.contains('\\')
489 || ref_name.contains('\0')
490 {
491 return Err(SkillError::NotFound(format!(
492 "Invalid reference name '{ref_name}' — path traversal not allowed"
493 )));
494 }
495
496 let skill = self
497 .get_skill(skill_name)
498 .ok_or_else(|| SkillError::NotFound(skill_name.to_string()))?;
499 let refs_dir = skill.dir.join("references");
500 let ref_path = refs_dir.join(ref_name);
501
502 if let (Ok(canonical_ref), Ok(canonical_dir)) =
504 (ref_path.canonicalize(), refs_dir.canonicalize())
505 {
506 if !canonical_ref.starts_with(&canonical_dir) {
507 return Err(SkillError::NotFound(format!(
508 "Reference '{ref_name}' resolves outside references directory"
509 )));
510 }
511 }
512
513 if !ref_path.exists() {
514 return Err(SkillError::NotFound(format!(
515 "Reference '{ref_name}' in skill '{skill_name}'"
516 )));
517 }
518 std::fs::read_to_string(&ref_path)
519 .map_err(|e| SkillError::Io(ref_path.display().to_string(), e))
520 }
521
522 pub fn skill_count(&self) -> usize {
524 self.skills.len()
525 }
526
527 pub fn validate_tool_bindings(
530 &self,
531 name: &str,
532 manifest_registry: &ManifestRegistry,
533 ) -> Result<(Vec<String>, Vec<String>), SkillError> {
534 let skill = self
535 .get_skill(name)
536 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
537
538 let mut valid = Vec::new();
539 let mut unknown = Vec::new();
540
541 for tool_name in &skill.tools {
542 if manifest_registry.get_tool(tool_name).is_some() {
543 valid.push(tool_name.clone());
544 } else {
545 unknown.push(tool_name.clone());
546 }
547 }
548
549 Ok((valid, unknown))
550 }
551}
552
553pub fn resolve_skills<'a>(
564 skill_registry: &'a SkillRegistry,
565 manifest_registry: &ManifestRegistry,
566 scopes: &ScopeConfig,
567) -> Vec<&'a SkillMeta> {
568 let mut resolved_indices: Vec<usize> = Vec::new();
569 let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
570
571 for scope in &scopes.scopes {
572 if let Some(skill_name) = scope.strip_prefix("skill:") {
574 if let Some(&idx) = skill_registry.name_index.get(skill_name) {
575 if seen.insert(idx) {
576 resolved_indices.push(idx);
577 }
578 }
579 }
580
581 if let Some(tool_name) = scope.strip_prefix("tool:") {
583 if let Some(indices) = skill_registry.tool_index.get(tool_name) {
584 for &idx in indices {
585 if seen.insert(idx) {
586 resolved_indices.push(idx);
587 }
588 }
589 }
590
591 if let Some((provider, _)) = manifest_registry.get_tool(tool_name) {
593 if let Some(indices) = skill_registry.provider_index.get(&provider.name) {
594 for &idx in indices {
595 if seen.insert(idx) {
596 resolved_indices.push(idx);
597 }
598 }
599 }
600
601 if let Some(category) = &provider.category {
603 if let Some(indices) = skill_registry.category_index.get(category) {
604 for &idx in indices {
605 if seen.insert(idx) {
606 resolved_indices.push(idx);
607 }
608 }
609 }
610 }
611 }
612 }
613 }
614
615 let mut i = 0;
617 while i < resolved_indices.len() {
618 let skill = &skill_registry.skills[resolved_indices[i]];
619 for dep_name in &skill.depends_on {
620 if let Some(&dep_idx) = skill_registry.name_index.get(dep_name) {
621 if seen.insert(dep_idx) {
622 resolved_indices.push(dep_idx);
623 }
624 }
625 }
626 i += 1;
627 }
628
629 resolved_indices
630 .into_iter()
631 .map(|idx| &skill_registry.skills[idx])
632 .collect()
633}
634
635const MAX_SKILL_INJECT_SIZE: usize = 32 * 1024;
638
639pub fn build_skill_context(skills: &[&SkillMeta]) -> String {
643 if skills.is_empty() {
644 return String::new();
645 }
646
647 let mut total_size = 0;
648 let mut sections = Vec::new();
649 for skill in skills {
650 let mut section = format!(
651 "--- BEGIN SKILL: {} ---\n- **{}**: {}",
652 skill.name, skill.name, skill.description
653 );
654 if let Some(hint) = &skill.hint {
655 section.push_str(&format!("\n Hint: {hint}"));
656 }
657 if !skill.tools.is_empty() {
658 section.push_str(&format!("\n Covers tools: {}", skill.tools.join(", ")));
659 }
660 if !skill.suggests.is_empty() {
661 section.push_str(&format!(
662 "\n Related skills: {}",
663 skill.suggests.join(", ")
664 ));
665 }
666 section.push_str(&format!("\n--- END SKILL: {} ---", skill.name));
667
668 total_size += section.len();
669 if total_size > MAX_SKILL_INJECT_SIZE {
670 sections.push("(remaining skills truncated due to size limit)".to_string());
671 break;
672 }
673 sections.push(section);
674 }
675 sections.join("\n\n")
676}
677
678fn load_skill_from_dir(dir: &Path) -> Result<SkillMeta, SkillError> {
687 let skill_toml_path = dir.join("skill.toml");
688 let skill_md_path = dir.join("SKILL.md");
689
690 let dir_name = dir
691 .file_name()
692 .and_then(|n| n.to_str())
693 .unwrap_or("unknown")
694 .to_string();
695
696 let (frontmatter, _body) = if skill_md_path.exists() {
698 let content = std::fs::read_to_string(&skill_md_path)
699 .map_err(|e| SkillError::Io(skill_md_path.display().to_string(), e))?;
700 let (fm, body) = parse_frontmatter(&content);
701 let body_owned = body.to_string();
703 (fm, Some((content, body_owned)))
704 } else {
705 (None, None)
706 };
707
708 if let Some(fm) = frontmatter {
709 let mut meta = SkillMeta {
711 name: fm.name.unwrap_or_else(|| dir_name.clone()),
712 description: fm.description.unwrap_or_default(),
713 license: fm.license,
714 compatibility: fm.compatibility,
715 extra_metadata: fm.metadata,
716 allowed_tools: fm.allowed_tools,
717 has_frontmatter: true,
718 format: SkillFormat::Anthropic,
719 dir: dir.to_path_buf(),
720 ..Default::default()
721 };
722
723 if let Some(author) = meta.extra_metadata.get("author").cloned() {
725 meta.author = Some(author);
726 }
727 if let Some(version) = meta.extra_metadata.get("version").cloned() {
728 meta.version = version;
729 }
730
731 if skill_toml_path.exists() {
733 let contents = std::fs::read_to_string(&skill_toml_path)
734 .map_err(|e| SkillError::Io(skill_toml_path.display().to_string(), e))?;
735 if let Ok(parsed) = toml::from_str::<SkillToml>(&contents) {
736 let ext = parsed.skill;
737 meta.tools = ext.tools;
739 meta.providers = ext.providers;
740 meta.categories = ext.categories;
741 meta.keywords = ext.keywords;
742 meta.hint = ext.hint;
743 meta.depends_on = ext.depends_on;
744 meta.suggests = ext.suggests;
745 }
746 }
747
748 load_integrity_info(&mut meta);
749 Ok(meta)
750 } else if skill_toml_path.exists() {
751 let contents = std::fs::read_to_string(&skill_toml_path)
753 .map_err(|e| SkillError::Io(skill_toml_path.display().to_string(), e))?;
754 let parsed: SkillToml = toml::from_str(&contents)
755 .map_err(|e| SkillError::Parse(skill_toml_path.display().to_string(), e))?;
756 let mut meta = parsed.skill;
757 meta.dir = dir.to_path_buf();
758 meta.format = SkillFormat::LegacyToml;
759 if meta.name.is_empty() {
760 meta.name = dir_name;
761 }
762 load_integrity_info(&mut meta);
763 Ok(meta)
764 } else if let Some((_full_content, body)) = _body {
765 let description = body
767 .lines()
768 .find(|l| !l.is_empty() && !l.starts_with('#'))
769 .map(|l| l.trim().to_string())
770 .unwrap_or_default();
771
772 Ok(SkillMeta {
773 name: dir_name,
774 description,
775 format: SkillFormat::Inferred,
776 dir: dir.to_path_buf(),
777 ..Default::default()
778 })
779 } else {
780 Err(SkillError::Invalid(format!(
781 "Directory '{}' has neither skill.toml nor SKILL.md",
782 dir.display()
783 )))
784 }
785}
786
787fn load_integrity_info(meta: &mut SkillMeta) {
789 let toml_path = meta.dir.join("skill.toml");
790 if !toml_path.exists() {
791 return;
792 }
793 let contents = match std::fs::read_to_string(&toml_path) {
794 Ok(c) => c,
795 Err(_) => return,
796 };
797 let parsed: toml::Value = match toml::from_str(&contents) {
798 Ok(v) => v,
799 Err(_) => return,
800 };
801 if let Some(integrity) = parsed.get("ati").and_then(|a| a.get("integrity")) {
802 meta.content_hash = integrity
803 .get("content_hash")
804 .and_then(|v| v.as_str())
805 .map(|s| s.to_string());
806 meta.source_url = integrity
807 .get("source_url")
808 .and_then(|v| v.as_str())
809 .map(|s| s.to_string());
810 meta.pinned_sha = integrity
811 .get("pinned_sha")
812 .and_then(|v| v.as_str())
813 .map(|s| s.to_string());
814 }
815}
816
817pub fn scaffold_skill_toml(name: &str, tools: &[String], provider: Option<&str>) -> String {
819 let mut toml = format!(
820 r#"[skill]
821name = "{name}"
822version = "0.1.0"
823description = ""
824"#
825 );
826
827 if !tools.is_empty() {
828 let tools_str: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
829 toml.push_str(&format!("tools = [{}]\n", tools_str.join(", ")));
830 } else {
831 toml.push_str("tools = []\n");
832 }
833
834 if let Some(p) = provider {
835 toml.push_str(&format!("providers = [\"{p}\"]\n"));
836 } else {
837 toml.push_str("providers = []\n");
838 }
839
840 toml.push_str(
841 r#"categories = []
842keywords = []
843hint = ""
844depends_on = []
845suggests = []
846"#,
847 );
848
849 toml
850}
851
852pub fn scaffold_skill_md(name: &str) -> String {
854 let title = name
855 .split('-')
856 .map(|w| {
857 let mut c = w.chars();
858 match c.next() {
859 None => String::new(),
860 Some(f) => f.to_uppercase().to_string() + c.as_str(),
861 }
862 })
863 .collect::<Vec<_>>()
864 .join(" ");
865
866 format!(
867 r#"# {title} Skill
868
869TODO: Describe what this skill does and when to use it.
870
871## Tools Available
872
873- TODO: List the tools this skill covers
874
875## Decision Tree
876
8771. TODO: Step-by-step methodology
878
879## Examples
880
881TODO: Add example workflows
882"#
883 )
884}
885
886pub fn scaffold_skill_md_with_frontmatter(name: &str, description: &str) -> String {
888 let title = name
889 .split('-')
890 .map(|w| {
891 let mut c = w.chars();
892 match c.next() {
893 None => String::new(),
894 Some(f) => f.to_uppercase().to_string() + c.as_str(),
895 }
896 })
897 .collect::<Vec<_>>()
898 .join(" ");
899
900 format!(
901 r#"---
902name: {name}
903description: {description}
904metadata:
905 version: "0.1.0"
906---
907
908# {title} Skill
909
910TODO: Describe what this skill does and when to use it.
911
912## Tools Available
913
914- TODO: List the tools this skill covers
915
916## Decision Tree
917
9181. TODO: Step-by-step methodology
919
920## Examples
921
922TODO: Add example workflows
923"#
924 )
925}
926
927pub fn scaffold_ati_extension_toml(name: &str, tools: &[String], provider: Option<&str>) -> String {
930 let mut toml = format!(
931 r#"# ATI extension fields for skill '{name}'
932# Core metadata (name, description, license) lives in SKILL.md frontmatter.
933
934[skill]
935name = "{name}"
936"#
937 );
938
939 if !tools.is_empty() {
940 let tools_str: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
941 toml.push_str(&format!("tools = [{}]\n", tools_str.join(", ")));
942 } else {
943 toml.push_str("tools = []\n");
944 }
945
946 if let Some(p) = provider {
947 toml.push_str(&format!("providers = [\"{p}\"]\n"));
948 } else {
949 toml.push_str("providers = []\n");
950 }
951
952 toml.push_str(
953 r#"categories = []
954keywords = []
955depends_on = []
956suggests = []
957"#,
958 );
959
960 toml
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use std::fs;
967
968 fn create_test_skill(
969 dir: &Path,
970 name: &str,
971 tools: &[&str],
972 providers: &[&str],
973 categories: &[&str],
974 ) {
975 let skill_dir = dir.join(name);
976 fs::create_dir_all(&skill_dir).unwrap();
977
978 let tools_toml: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
979 let providers_toml: Vec<String> = providers.iter().map(|p| format!("\"{p}\"")).collect();
980 let categories_toml: Vec<String> = categories.iter().map(|c| format!("\"{c}\"")).collect();
981
982 let toml_content = format!(
983 r#"[skill]
984name = "{name}"
985version = "1.0.0"
986description = "Test skill for {name}"
987tools = [{tools}]
988providers = [{providers}]
989categories = [{categories}]
990keywords = ["test", "{name}"]
991hint = "Use for testing {name}"
992depends_on = []
993suggests = []
994"#,
995 tools = tools_toml.join(", "),
996 providers = providers_toml.join(", "),
997 categories = categories_toml.join(", "),
998 );
999
1000 fs::write(skill_dir.join("skill.toml"), toml_content).unwrap();
1001 fs::write(
1002 skill_dir.join("SKILL.md"),
1003 format!("# {name}\n\nTest skill content."),
1004 )
1005 .unwrap();
1006 }
1007
1008 #[test]
1009 fn test_load_skill_with_toml() {
1010 let tmp = tempfile::tempdir().unwrap();
1011 create_test_skill(
1012 tmp.path(),
1013 "sanctions",
1014 &["ca_business_sanctions_search"],
1015 &["complyadvantage"],
1016 &["compliance"],
1017 );
1018
1019 let registry = SkillRegistry::load(tmp.path()).unwrap();
1020 assert_eq!(registry.skill_count(), 1);
1021
1022 let skill = registry.get_skill("sanctions").unwrap();
1023 assert_eq!(skill.version, "1.0.0");
1024 assert_eq!(skill.tools, vec!["ca_business_sanctions_search"]);
1025 assert_eq!(skill.providers, vec!["complyadvantage"]);
1026 assert_eq!(skill.categories, vec!["compliance"]);
1027 }
1028
1029 #[test]
1030 fn test_load_skill_md_fallback() {
1031 let tmp = tempfile::tempdir().unwrap();
1032 let skill_dir = tmp.path().join("legacy-skill");
1033 fs::create_dir_all(&skill_dir).unwrap();
1034 fs::write(
1035 skill_dir.join("SKILL.md"),
1036 "# Legacy Skill\n\nA skill with only SKILL.md, no skill.toml.\n",
1037 )
1038 .unwrap();
1039
1040 let registry = SkillRegistry::load(tmp.path()).unwrap();
1041 assert_eq!(registry.skill_count(), 1);
1042
1043 let skill = registry.get_skill("legacy-skill").unwrap();
1044 assert_eq!(
1045 skill.description,
1046 "A skill with only SKILL.md, no skill.toml."
1047 );
1048 assert!(skill.tools.is_empty()); }
1050
1051 #[test]
1052 fn test_tool_index() {
1053 let tmp = tempfile::tempdir().unwrap();
1054 create_test_skill(tmp.path(), "skill-a", &["tool_x", "tool_y"], &[], &[]);
1055 create_test_skill(tmp.path(), "skill-b", &["tool_y", "tool_z"], &[], &[]);
1056
1057 let registry = SkillRegistry::load(tmp.path()).unwrap();
1058
1059 let skills = registry.skills_for_tool("tool_x");
1061 assert_eq!(skills.len(), 1);
1062 assert_eq!(skills[0].name, "skill-a");
1063
1064 let skills = registry.skills_for_tool("tool_y");
1066 assert_eq!(skills.len(), 2);
1067
1068 let skills = registry.skills_for_tool("tool_z");
1070 assert_eq!(skills.len(), 1);
1071 assert_eq!(skills[0].name, "skill-b");
1072
1073 assert!(registry.skills_for_tool("nope").is_empty());
1075 }
1076
1077 #[test]
1078 fn test_provider_and_category_index() {
1079 let tmp = tempfile::tempdir().unwrap();
1080 create_test_skill(
1081 tmp.path(),
1082 "compliance-skill",
1083 &[],
1084 &["complyadvantage"],
1085 &["compliance", "aml"],
1086 );
1087
1088 let registry = SkillRegistry::load(tmp.path()).unwrap();
1089
1090 assert_eq!(registry.skills_for_provider("complyadvantage").len(), 1);
1091 assert_eq!(registry.skills_for_category("compliance").len(), 1);
1092 assert_eq!(registry.skills_for_category("aml").len(), 1);
1093 assert!(registry.skills_for_provider("serpapi").is_empty());
1094 }
1095
1096 #[test]
1097 fn test_search() {
1098 let tmp = tempfile::tempdir().unwrap();
1099 create_test_skill(
1100 tmp.path(),
1101 "sanctions-screening",
1102 &["ca_business_sanctions_search"],
1103 &["complyadvantage"],
1104 &["compliance"],
1105 );
1106 create_test_skill(
1107 tmp.path(),
1108 "web-search",
1109 &["web_search"],
1110 &["serpapi"],
1111 &["search"],
1112 );
1113
1114 let registry = SkillRegistry::load(tmp.path()).unwrap();
1115
1116 let results = registry.search("sanctions");
1118 assert!(!results.is_empty());
1119 assert_eq!(results[0].name, "sanctions-screening");
1120
1121 let results = registry.search("web");
1123 assert!(!results.is_empty());
1124 assert_eq!(results[0].name, "web-search");
1125
1126 let results = registry.search("nonexistent");
1128 assert!(results.is_empty());
1129 }
1130
1131 #[test]
1132 fn test_read_content_and_references() {
1133 let tmp = tempfile::tempdir().unwrap();
1134 let skill_dir = tmp.path().join("test-skill");
1135 let refs_dir = skill_dir.join("references");
1136 fs::create_dir_all(&refs_dir).unwrap();
1137
1138 fs::write(
1139 skill_dir.join("skill.toml"),
1140 r#"[skill]
1141name = "test-skill"
1142description = "Test"
1143"#,
1144 )
1145 .unwrap();
1146 fs::write(skill_dir.join("SKILL.md"), "# Test\n\nContent here.").unwrap();
1147 fs::write(refs_dir.join("guide.md"), "Reference guide content").unwrap();
1148
1149 let registry = SkillRegistry::load(tmp.path()).unwrap();
1150
1151 let content = registry.read_content("test-skill").unwrap();
1152 assert!(content.contains("Content here."));
1153
1154 let refs = registry.list_references("test-skill").unwrap();
1155 assert_eq!(refs, vec!["guide.md"]);
1156
1157 let ref_content = registry.read_reference("test-skill", "guide.md").unwrap();
1158 assert!(ref_content.contains("Reference guide content"));
1159 }
1160
1161 #[test]
1162 fn test_resolve_skills_explicit() {
1163 let tmp = tempfile::tempdir().unwrap();
1164 create_test_skill(tmp.path(), "skill-a", &[], &[], &[]);
1165 create_test_skill(tmp.path(), "skill-b", &[], &[], &[]);
1166
1167 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1168 let manifest_reg = ManifestRegistry::empty();
1169
1170 let scopes = ScopeConfig {
1171 scopes: vec!["skill:skill-a".to_string()],
1172 sub: String::new(),
1173 expires_at: 0,
1174 rate_config: None,
1175 };
1176
1177 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1178 assert_eq!(resolved.len(), 1);
1179 assert_eq!(resolved[0].name, "skill-a");
1180 }
1181
1182 #[test]
1183 fn test_resolve_skills_by_tool_binding() {
1184 let tmp = tempfile::tempdir().unwrap();
1185 create_test_skill(
1186 tmp.path(),
1187 "sanctions-skill",
1188 &["ca_sanctions_search"],
1189 &[],
1190 &[],
1191 );
1192 create_test_skill(
1193 tmp.path(),
1194 "unrelated-skill",
1195 &["some_other_tool"],
1196 &[],
1197 &[],
1198 );
1199
1200 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1201
1202 let manifest_reg = ManifestRegistry::empty();
1203
1204 let scopes = ScopeConfig {
1205 scopes: vec!["tool:ca_sanctions_search".to_string()],
1206 sub: String::new(),
1207 expires_at: 0,
1208 rate_config: None,
1209 };
1210
1211 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1212 assert_eq!(resolved.len(), 1);
1213 assert_eq!(resolved[0].name, "sanctions-skill");
1214 }
1215
1216 #[test]
1217 fn test_resolve_skills_with_dependencies() {
1218 let tmp = tempfile::tempdir().unwrap();
1219
1220 let dir_a = tmp.path().join("skill-a");
1222 fs::create_dir_all(&dir_a).unwrap();
1223 fs::write(
1224 dir_a.join("skill.toml"),
1225 r#"[skill]
1226name = "skill-a"
1227description = "Skill A"
1228tools = ["tool_a"]
1229depends_on = ["skill-b"]
1230"#,
1231 )
1232 .unwrap();
1233 fs::write(dir_a.join("SKILL.md"), "# Skill A").unwrap();
1234
1235 let dir_b = tmp.path().join("skill-b");
1237 fs::create_dir_all(&dir_b).unwrap();
1238 fs::write(
1239 dir_b.join("skill.toml"),
1240 r#"[skill]
1241name = "skill-b"
1242description = "Skill B"
1243tools = ["tool_b"]
1244"#,
1245 )
1246 .unwrap();
1247 fs::write(dir_b.join("SKILL.md"), "# Skill B").unwrap();
1248
1249 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1250
1251 let manifest_tmp = tempfile::tempdir().unwrap();
1252 fs::create_dir_all(manifest_tmp.path()).unwrap();
1253 let manifest_reg = ManifestRegistry::load(manifest_tmp.path())
1254 .unwrap_or_else(|_| panic!("cannot load empty manifest dir"));
1255
1256 let scopes = ScopeConfig {
1257 scopes: vec!["tool:tool_a".to_string()],
1258 sub: String::new(),
1259 expires_at: 0,
1260 rate_config: None,
1261 };
1262
1263 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1264 assert_eq!(resolved.len(), 2);
1266 let names: Vec<&str> = resolved.iter().map(|s| s.name.as_str()).collect();
1267 assert!(names.contains(&"skill-a"));
1268 assert!(names.contains(&"skill-b"));
1269 }
1270
1271 #[test]
1272 fn test_scaffold() {
1273 let toml = scaffold_skill_toml(
1274 "my-skill",
1275 &["tool_a".into(), "tool_b".into()],
1276 Some("provider_x"),
1277 );
1278 assert!(toml.contains("name = \"my-skill\""));
1279 assert!(toml.contains("\"tool_a\""));
1280 assert!(toml.contains("\"provider_x\""));
1281
1282 let md = scaffold_skill_md("my-cool-skill");
1283 assert!(md.contains("# My Cool Skill Skill"));
1284 }
1285
1286 #[test]
1287 fn test_build_skill_context() {
1288 let skill = SkillMeta {
1289 name: "test-skill".to_string(),
1290 version: "1.0.0".to_string(),
1291 description: "A test skill".to_string(),
1292 tools: vec!["tool_a".to_string(), "tool_b".to_string()],
1293 hint: Some("Use for testing".to_string()),
1294 suggests: vec!["other-skill".to_string()],
1295 ..Default::default()
1296 };
1297
1298 let ctx = build_skill_context(&[&skill]);
1299 assert!(ctx.contains("**test-skill**"));
1300 assert!(ctx.contains("A test skill"));
1301 assert!(ctx.contains("Use for testing"));
1302 assert!(ctx.contains("tool_a, tool_b"));
1303 assert!(ctx.contains("other-skill"));
1304 }
1305
1306 #[test]
1307 fn test_empty_directory() {
1308 let tmp = tempfile::tempdir().unwrap();
1309 let registry = SkillRegistry::load(tmp.path()).unwrap();
1310 assert_eq!(registry.skill_count(), 0);
1311 }
1312
1313 #[test]
1314 fn test_nonexistent_directory() {
1315 let registry = SkillRegistry::load(Path::new("/nonexistent/path")).unwrap();
1316 assert_eq!(registry.skill_count(), 0);
1317 }
1318}