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, alias = "when-to-use")]
48 pub when_to_use: Option<String>,
49 #[serde(default)]
50 pub metadata: HashMap<String, String>,
51 #[serde(rename = "allowed-tools")]
53 pub allowed_tools: Option<String>,
54}
55
56pub fn parse_frontmatter(content: &str) -> (Option<AnthropicFrontmatter>, &str) {
61 let trimmed = content.trim_start();
62 if !trimmed.starts_with("---") {
63 return (None, content);
64 }
65
66 let after_open = &trimmed[3..];
68 let after_open = match after_open.find('\n') {
70 Some(pos) => &after_open[pos + 1..],
71 None => return (None, content),
72 };
73
74 match after_open.find("\n---") {
75 Some(end_pos) => {
76 let yaml_str = &after_open[..end_pos];
77 let body_start = &after_open[end_pos + 4..]; let body = match body_start.find('\n') {
80 Some(pos) => &body_start[pos + 1..],
81 None => "",
82 };
83
84 match serde_yaml::from_str::<AnthropicFrontmatter>(yaml_str) {
85 Ok(fm) => (Some(fm), body),
86 Err(_) => (None, content), }
88 }
89 None => (None, content),
90 }
91}
92
93pub fn strip_frontmatter(content: &str) -> &str {
95 let (_, body) = parse_frontmatter(content);
96 body
97}
98
99pub fn compute_content_hash(content: &str) -> String {
101 let mut hasher = Sha256::new();
102 hasher.update(content.as_bytes());
103 let result = hasher.finalize();
104 hex::encode(result)
105}
106
107pub fn is_anthropic_valid_name(name: &str) -> bool {
112 if name.is_empty() || name.len() > 64 {
113 return false;
114 }
115 let bytes = name.as_bytes();
116 if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
118 return false;
119 }
120 if !bytes[bytes.len() - 1].is_ascii_lowercase() && !bytes[bytes.len() - 1].is_ascii_digit() {
121 return false;
122 }
123 let mut prev_hyphen = false;
125 for &b in bytes {
126 if b == b'-' {
127 if prev_hyphen {
128 return false;
129 }
130 prev_hyphen = true;
131 } else if b.is_ascii_lowercase() || b.is_ascii_digit() {
132 prev_hyphen = false;
133 } else {
134 return false;
135 }
136 }
137 true
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142pub enum SkillFormat {
143 #[serde(rename = "anthropic")]
145 Anthropic,
146 #[serde(rename = "legacy-toml")]
148 LegacyToml,
149 #[serde(rename = "inferred")]
151 Inferred,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct SkillMeta {
157 pub name: String,
158 #[serde(default = "default_version")]
159 pub version: String,
160 #[serde(default)]
161 pub description: String,
162 #[serde(default)]
163 pub author: Option<String>,
164
165 #[serde(default)]
168 pub tools: Vec<String>,
169 #[serde(default)]
171 pub providers: Vec<String>,
172 #[serde(default)]
174 pub categories: Vec<String>,
175
176 #[serde(default)]
178 pub keywords: Vec<String>,
179 #[serde(default)]
180 pub hint: Option<String>,
181
182 #[serde(default)]
185 pub depends_on: Vec<String>,
186 #[serde(default)]
188 pub suggests: Vec<String>,
189
190 #[serde(default)]
193 pub license: Option<String>,
194 #[serde(default)]
196 pub compatibility: Option<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub when_to_use: Option<String>,
202 #[serde(default)]
204 pub extra_metadata: HashMap<String, String>,
205 #[serde(default)]
207 pub allowed_tools: Option<String>,
208 #[serde(default)]
210 pub has_frontmatter: bool,
211 #[serde(default = "default_format")]
213 pub format: SkillFormat,
214
215 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub source_url: Option<String>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub content_hash: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub pinned_sha: Option<String>,
225
226 #[serde(skip)]
229 pub dir: PathBuf,
230}
231
232impl Default for SkillMeta {
233 fn default() -> Self {
234 Self {
235 name: String::new(),
236 version: default_version(),
237 description: String::new(),
238 author: None,
239 tools: Vec::new(),
240 providers: Vec::new(),
241 categories: Vec::new(),
242 keywords: Vec::new(),
243 hint: None,
244 depends_on: Vec::new(),
245 suggests: Vec::new(),
246 license: None,
247 compatibility: None,
248 when_to_use: None,
249 extra_metadata: HashMap::new(),
250 allowed_tools: None,
251 has_frontmatter: false,
252 format: SkillFormat::Inferred,
253 source_url: None,
254 content_hash: None,
255 pinned_sha: None,
256 dir: PathBuf::new(),
257 }
258 }
259}
260
261fn default_format() -> SkillFormat {
262 SkillFormat::Inferred
263}
264
265fn default_version() -> String {
266 "0.1.0".to_string()
267}
268
269#[derive(Debug, Deserialize)]
271struct SkillToml {
272 skill: SkillMeta,
273}
274
275pub struct SkillRegistry {
277 skills: Vec<SkillMeta>,
278 name_index: HashMap<String, usize>,
280 tool_index: HashMap<String, Vec<usize>>,
282 provider_index: HashMap<String, Vec<usize>>,
284 category_index: HashMap<String, Vec<usize>>,
286 files_cache: HashMap<(String, String), Vec<u8>>,
289}
290
291impl SkillRegistry {
292 pub fn load(skills_dir: &Path) -> Result<Self, SkillError> {
297 let mut skills = Vec::new();
298 let mut name_index = HashMap::new();
299 let mut tool_index: HashMap<String, Vec<usize>> = HashMap::new();
300 let mut provider_index: HashMap<String, Vec<usize>> = HashMap::new();
301 let mut category_index: HashMap<String, Vec<usize>> = HashMap::new();
302
303 if !skills_dir.is_dir() {
304 return Ok(SkillRegistry {
306 skills,
307 name_index,
308 tool_index,
309 provider_index,
310 category_index,
311 files_cache: HashMap::new(),
312 });
313 }
314
315 let entries = std::fs::read_dir(skills_dir)
316 .map_err(|e| SkillError::Io(skills_dir.display().to_string(), e))?;
317
318 for entry in entries {
319 let entry = entry.map_err(|e| SkillError::Io(skills_dir.display().to_string(), e))?;
320 let path = entry.path();
321 if !path.is_dir() {
322 continue;
323 }
324
325 let skill = load_skill_from_dir(&path)?;
326
327 let idx = skills.len();
328 name_index.insert(skill.name.clone(), idx);
329
330 for tool in &skill.tools {
331 tool_index.entry(tool.clone()).or_default().push(idx);
332 }
333 for provider in &skill.providers {
334 provider_index
335 .entry(provider.clone())
336 .or_default()
337 .push(idx);
338 }
339 for category in &skill.categories {
340 category_index
341 .entry(category.clone())
342 .or_default()
343 .push(idx);
344 }
345
346 skills.push(skill);
347 }
348
349 Ok(SkillRegistry {
350 skills,
351 name_index,
352 tool_index,
353 provider_index,
354 category_index,
355 files_cache: HashMap::new(),
356 })
357 }
358
359 pub fn merge(&mut self, source: crate::core::gcs::GcsSkillSource) {
362 let mut added: std::collections::HashSet<String> = std::collections::HashSet::new();
363
364 for skill in source.skills {
365 if self.name_index.contains_key(&skill.name) {
366 continue;
368 }
369 added.insert(skill.name.clone());
370 let idx = self.skills.len();
371 self.name_index.insert(skill.name.clone(), idx);
372 for tool in &skill.tools {
373 self.tool_index.entry(tool.clone()).or_default().push(idx);
374 }
375 for provider in &skill.providers {
376 self.provider_index
377 .entry(provider.clone())
378 .or_default()
379 .push(idx);
380 }
381 for category in &skill.categories {
382 self.category_index
383 .entry(category.clone())
384 .or_default()
385 .push(idx);
386 }
387 self.skills.push(skill);
388 }
389
390 for ((skill_name, rel_path), data) in source.files {
393 if added.contains(&skill_name) {
394 self.files_cache.insert((skill_name, rel_path), data);
395 }
396 }
397 }
398
399 pub fn get_skill(&self, name: &str) -> Option<&SkillMeta> {
401 self.name_index.get(name).map(|&idx| &self.skills[idx])
402 }
403
404 pub fn list_skills(&self) -> &[SkillMeta] {
406 &self.skills
407 }
408
409 pub fn skills_for_tool(&self, tool_name: &str) -> Vec<&SkillMeta> {
411 self.tool_index
412 .get(tool_name)
413 .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
414 .unwrap_or_default()
415 }
416
417 pub fn skills_for_provider(&self, provider_name: &str) -> Vec<&SkillMeta> {
419 self.provider_index
420 .get(provider_name)
421 .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
422 .unwrap_or_default()
423 }
424
425 pub fn skills_for_category(&self, category: &str) -> Vec<&SkillMeta> {
427 self.category_index
428 .get(category)
429 .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
430 .unwrap_or_default()
431 }
432
433 pub fn search(&self, query: &str) -> Vec<&SkillMeta> {
435 let q = query.to_lowercase();
436 let terms: Vec<&str> = q.split_whitespace().collect();
437
438 let mut scored: Vec<(usize, &SkillMeta)> = self
439 .skills
440 .iter()
441 .filter_map(|skill| {
442 let mut score = 0usize;
443 let name_lower = skill.name.to_lowercase();
444 let desc_lower = skill.description.to_lowercase();
445
446 for term in &terms {
447 if name_lower.contains(term) {
449 score += 10;
450 }
451 if desc_lower.contains(term) {
453 score += 5;
454 }
455 if skill
457 .keywords
458 .iter()
459 .any(|k| k.to_lowercase().contains(term))
460 {
461 score += 8;
462 }
463 if skill.tools.iter().any(|t| t.to_lowercase().contains(term)) {
465 score += 6;
466 }
467 if let Some(hint) = &skill.hint {
469 if hint.to_lowercase().contains(term) {
470 score += 4;
471 }
472 }
473 if skill
475 .providers
476 .iter()
477 .any(|p| p.to_lowercase().contains(term))
478 {
479 score += 6;
480 }
481 if skill
483 .categories
484 .iter()
485 .any(|c| c.to_lowercase().contains(term))
486 {
487 score += 4;
488 }
489 }
490
491 if score > 0 {
492 Some((score, skill))
493 } else {
494 None
495 }
496 })
497 .collect();
498
499 scored.sort_by(|a, b| b.0.cmp(&a.0));
501 scored.into_iter().map(|(_, skill)| skill).collect()
502 }
503
504 pub fn read_content(&self, name: &str) -> Result<String, SkillError> {
507 if let Some(bytes) = self
509 .files_cache
510 .get(&(name.to_string(), "SKILL.md".to_string()))
511 {
512 let raw = std::str::from_utf8(bytes).unwrap_or("");
513 return Ok(strip_frontmatter(raw).to_string());
514 }
515
516 let skill = self
518 .get_skill(name)
519 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
520 let skill_md = skill.dir.join("SKILL.md");
521 if !skill_md.exists() {
522 return Ok(String::new());
523 }
524 let raw = std::fs::read_to_string(&skill_md)
525 .map_err(|e| SkillError::Io(skill_md.display().to_string(), e))?;
526 Ok(strip_frontmatter(&raw).to_string())
527 }
528
529 pub fn list_references(&self, name: &str) -> Result<Vec<String>, SkillError> {
532 let prefix = "references/";
534 let cached_refs: Vec<String> = self
535 .files_cache
536 .keys()
537 .filter(|(skill, path)| skill == name && path.starts_with(prefix))
538 .map(|(_, path)| path.strip_prefix(prefix).unwrap_or(path).to_string())
539 .collect();
540 if !cached_refs.is_empty() {
541 let mut refs = cached_refs;
542 refs.sort();
543 return Ok(refs);
544 }
545
546 let skill = self
548 .get_skill(name)
549 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
550 let refs_dir = skill.dir.join("references");
551 if !refs_dir.is_dir() {
552 return Ok(Vec::new());
553 }
554 let mut refs = Vec::new();
555 let entries = std::fs::read_dir(&refs_dir)
556 .map_err(|e| SkillError::Io(refs_dir.display().to_string(), e))?;
557 for entry in entries {
558 let entry = entry.map_err(|e| SkillError::Io(refs_dir.display().to_string(), e))?;
559 if let Some(name) = entry.file_name().to_str() {
560 refs.push(name.to_string());
561 }
562 }
563 refs.sort();
564 Ok(refs)
565 }
566
567 pub fn read_reference(&self, skill_name: &str, ref_name: &str) -> Result<String, SkillError> {
570 if ref_name.contains("..")
572 || ref_name.contains('/')
573 || ref_name.contains('\\')
574 || ref_name.contains('\0')
575 {
576 return Err(SkillError::NotFound(format!(
577 "Invalid reference name '{ref_name}' — path traversal not allowed"
578 )));
579 }
580
581 let cache_key = (skill_name.to_string(), format!("references/{ref_name}"));
583 if let Some(bytes) = self.files_cache.get(&cache_key) {
584 return std::str::from_utf8(bytes)
585 .map(|s| s.to_string())
586 .map_err(|e| SkillError::Invalid(format!("invalid UTF-8 in reference: {e}")));
587 }
588
589 let skill = self
590 .get_skill(skill_name)
591 .ok_or_else(|| SkillError::NotFound(skill_name.to_string()))?;
592 let refs_dir = skill.dir.join("references");
593 let ref_path = refs_dir.join(ref_name);
594
595 if let (Ok(canonical_ref), Ok(canonical_dir)) =
597 (ref_path.canonicalize(), refs_dir.canonicalize())
598 {
599 if !canonical_ref.starts_with(&canonical_dir) {
600 return Err(SkillError::NotFound(format!(
601 "Reference '{ref_name}' resolves outside references directory"
602 )));
603 }
604 }
605
606 if !ref_path.exists() {
607 return Err(SkillError::NotFound(format!(
608 "Reference '{ref_name}' in skill '{skill_name}'"
609 )));
610 }
611 std::fs::read_to_string(&ref_path)
612 .map_err(|e| SkillError::Io(ref_path.display().to_string(), e))
613 }
614
615 pub fn bundle_files(&self, name: &str) -> Result<HashMap<String, Vec<u8>>, SkillError> {
618 let _skill = self
619 .get_skill(name)
620 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
621
622 let mut files: HashMap<String, Vec<u8>> = HashMap::new();
623
624 for ((skill_name, rel_path), data) in &self.files_cache {
626 if skill_name == name {
627 files.insert(rel_path.clone(), data.clone());
628 }
629 }
630
631 if files.is_empty() {
633 let skill = self.get_skill(name).unwrap();
634 if skill.dir.is_dir() {
635 collect_dir_files(&skill.dir, &skill.dir, &mut files)?;
636 }
637 }
638
639 Ok(files)
640 }
641
642 pub fn skill_count(&self) -> usize {
644 self.skills.len()
645 }
646
647 pub fn validate_tool_bindings(
650 &self,
651 name: &str,
652 manifest_registry: &ManifestRegistry,
653 ) -> Result<(Vec<String>, Vec<String>), SkillError> {
654 let skill = self
655 .get_skill(name)
656 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
657
658 let mut valid = Vec::new();
659 let mut unknown = Vec::new();
660
661 for tool_name in &skill.tools {
662 if manifest_registry.get_tool(tool_name).is_some() {
663 valid.push(tool_name.clone());
664 } else {
665 unknown.push(tool_name.clone());
666 }
667 }
668
669 Ok((valid, unknown))
670 }
671}
672
673pub fn resolve_skills<'a>(
684 skill_registry: &'a SkillRegistry,
685 manifest_registry: &ManifestRegistry,
686 scopes: &ScopeConfig,
687) -> Vec<&'a SkillMeta> {
688 let mut resolved_indices: Vec<usize> = Vec::new();
689 let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
690
691 for scope in &scopes.scopes {
692 if let Some(skill_name) = scope.strip_prefix("skill:") {
694 if let Some(&idx) = skill_registry.name_index.get(skill_name) {
695 if seen.insert(idx) {
696 resolved_indices.push(idx);
697 }
698 }
699 }
700
701 if let Some(tool_name) = scope.strip_prefix("tool:") {
703 if let Some(indices) = skill_registry.tool_index.get(tool_name) {
704 for &idx in indices {
705 if seen.insert(idx) {
706 resolved_indices.push(idx);
707 }
708 }
709 }
710
711 if let Some((provider, _)) = manifest_registry.get_tool(tool_name) {
713 if let Some(indices) = skill_registry.provider_index.get(&provider.name) {
714 for &idx in indices {
715 if seen.insert(idx) {
716 resolved_indices.push(idx);
717 }
718 }
719 }
720
721 if let Some(category) = &provider.category {
723 if let Some(indices) = skill_registry.category_index.get(category) {
724 for &idx in indices {
725 if seen.insert(idx) {
726 resolved_indices.push(idx);
727 }
728 }
729 }
730 }
731 }
732 }
733 }
734
735 if !scopes.is_wildcard() {
739 for (provider, tool) in
740 crate::core::scope::filter_tools_by_scope(manifest_registry.list_public_tools(), scopes)
741 {
742 if let Some(indices) = skill_registry.tool_index.get(&tool.name) {
743 for &idx in indices {
744 if seen.insert(idx) {
745 resolved_indices.push(idx);
746 }
747 }
748 }
749
750 if let Some(indices) = skill_registry.provider_index.get(&provider.name) {
751 for &idx in indices {
752 if seen.insert(idx) {
753 resolved_indices.push(idx);
754 }
755 }
756 }
757
758 if let Some(category) = &provider.category {
759 if let Some(indices) = skill_registry.category_index.get(category) {
760 for &idx in indices {
761 if seen.insert(idx) {
762 resolved_indices.push(idx);
763 }
764 }
765 }
766 }
767 }
768 }
769
770 let mut i = 0;
772 while i < resolved_indices.len() {
773 let skill = &skill_registry.skills[resolved_indices[i]];
774 for dep_name in &skill.depends_on {
775 if let Some(&dep_idx) = skill_registry.name_index.get(dep_name) {
776 if seen.insert(dep_idx) {
777 resolved_indices.push(dep_idx);
778 }
779 }
780 }
781 i += 1;
782 }
783
784 resolved_indices
785 .into_iter()
786 .map(|idx| &skill_registry.skills[idx])
787 .collect()
788}
789
790pub fn visible_skills<'a>(
796 skill_registry: &'a SkillRegistry,
797 manifest_registry: &ManifestRegistry,
798 scopes: &ScopeConfig,
799) -> Vec<&'a SkillMeta> {
800 if scopes.is_wildcard() {
801 return skill_registry.list_skills().iter().collect();
802 }
803
804 let mut visible = resolve_skills(skill_registry, manifest_registry, scopes);
805 visible.sort_by(|a, b| a.name.cmp(&b.name));
806 visible
807}
808
809const MAX_SKILL_INJECT_SIZE: usize = 32 * 1024;
812
813pub fn build_skill_context(skills: &[&SkillMeta]) -> String {
817 if skills.is_empty() {
818 return String::new();
819 }
820
821 let mut total_size = 0;
822 let mut sections = Vec::new();
823 for skill in skills {
824 let mut section = format!(
825 "--- BEGIN SKILL: {} ---\n- **{}**: {}",
826 skill.name, skill.name, skill.description
827 );
828 if let Some(hint) = &skill.hint {
829 section.push_str(&format!("\n Hint: {hint}"));
830 }
831 if !skill.tools.is_empty() {
832 section.push_str(&format!("\n Covers tools: {}", skill.tools.join(", ")));
833 }
834 if !skill.suggests.is_empty() {
835 section.push_str(&format!(
836 "\n Related skills: {}",
837 skill.suggests.join(", ")
838 ));
839 }
840 section.push_str(&format!("\n--- END SKILL: {} ---", skill.name));
841
842 total_size += section.len();
843 if total_size > MAX_SKILL_INJECT_SIZE {
844 sections.push("(remaining skills truncated due to size limit)".to_string());
845 break;
846 }
847 sections.push(section);
848 }
849 sections.join("\n\n")
850}
851
852fn collect_dir_files(
860 base: &Path,
861 current: &Path,
862 files: &mut HashMap<String, Vec<u8>>,
863) -> Result<(), SkillError> {
864 let entries =
865 std::fs::read_dir(current).map_err(|e| SkillError::Io(current.display().to_string(), e))?;
866 for entry in entries {
867 let entry = entry.map_err(|e| SkillError::Io(current.display().to_string(), e))?;
868 let path = entry.path();
869 if path.is_dir() {
870 collect_dir_files(base, &path, files)?;
871 } else if let Ok(rel) = path.strip_prefix(base) {
872 if let Some(rel_str) = rel.to_str() {
873 if let Ok(data) = std::fs::read(&path) {
874 files.insert(rel_str.to_string(), data);
875 }
876 }
877 }
878 }
879 Ok(())
880}
881
882fn load_skill_from_dir(dir: &Path) -> Result<SkillMeta, SkillError> {
885 let skill_toml_path = dir.join("skill.toml");
886 let skill_md_path = dir.join("SKILL.md");
887
888 let dir_name = dir
889 .file_name()
890 .and_then(|n| n.to_str())
891 .unwrap_or("unknown")
892 .to_string();
893
894 let (frontmatter, _body) = if skill_md_path.exists() {
896 let content = std::fs::read_to_string(&skill_md_path)
897 .map_err(|e| SkillError::Io(skill_md_path.display().to_string(), e))?;
898 let (fm, body) = parse_frontmatter(&content);
899 let body_owned = body.to_string();
901 (fm, Some((content, body_owned)))
902 } else {
903 (None, None)
904 };
905
906 if let Some(fm) = frontmatter {
907 let mut meta = SkillMeta {
909 name: fm.name.unwrap_or_else(|| dir_name.clone()),
910 description: fm.description.unwrap_or_default(),
911 license: fm.license,
912 compatibility: fm.compatibility,
913 when_to_use: fm.when_to_use,
914 extra_metadata: fm.metadata,
915 allowed_tools: fm.allowed_tools,
916 has_frontmatter: true,
917 format: SkillFormat::Anthropic,
918 dir: dir.to_path_buf(),
919 ..Default::default()
920 };
921
922 if let Some(author) = meta.extra_metadata.get("author").cloned() {
924 meta.author = Some(author);
925 }
926 if let Some(version) = meta.extra_metadata.get("version").cloned() {
927 meta.version = version;
928 }
929
930 if skill_toml_path.exists() {
932 let contents = std::fs::read_to_string(&skill_toml_path)
933 .map_err(|e| SkillError::Io(skill_toml_path.display().to_string(), e))?;
934 if let Ok(parsed) = toml::from_str::<SkillToml>(&contents) {
935 let ext = parsed.skill;
936 meta.tools = ext.tools;
938 meta.providers = ext.providers;
939 meta.categories = ext.categories;
940 meta.keywords = ext.keywords;
941 meta.hint = ext.hint;
942 meta.depends_on = ext.depends_on;
943 meta.suggests = ext.suggests;
944 }
945 }
946
947 load_integrity_info(&mut meta);
948 Ok(meta)
949 } else if skill_toml_path.exists() {
950 let contents = std::fs::read_to_string(&skill_toml_path)
952 .map_err(|e| SkillError::Io(skill_toml_path.display().to_string(), e))?;
953 let parsed: SkillToml = toml::from_str(&contents)
954 .map_err(|e| SkillError::Parse(skill_toml_path.display().to_string(), e))?;
955 let mut meta = parsed.skill;
956 meta.dir = dir.to_path_buf();
957 meta.format = SkillFormat::LegacyToml;
958 if meta.name.is_empty() {
959 meta.name = dir_name;
960 }
961 load_integrity_info(&mut meta);
962 Ok(meta)
963 } else if let Some((_full_content, body)) = _body {
964 let description = body
966 .lines()
967 .find(|l| !l.is_empty() && !l.starts_with('#'))
968 .map(|l| l.trim().to_string())
969 .unwrap_or_default();
970
971 Ok(SkillMeta {
972 name: dir_name,
973 description,
974 format: SkillFormat::Inferred,
975 dir: dir.to_path_buf(),
976 ..Default::default()
977 })
978 } else {
979 Err(SkillError::Invalid(format!(
980 "Directory '{}' has neither skill.toml nor SKILL.md",
981 dir.display()
982 )))
983 }
984}
985
986pub fn parse_skill_metadata(
991 name: &str,
992 skill_md_content: &str,
993 skill_toml_content: Option<&str>,
994) -> Result<SkillMeta, SkillError> {
995 let (frontmatter, body) = if !skill_md_content.is_empty() {
996 let (fm, body) = parse_frontmatter(skill_md_content);
997 (fm, Some(body.to_string()))
998 } else {
999 (None, None)
1000 };
1001
1002 if let Some(fm) = frontmatter {
1003 let mut meta = SkillMeta {
1005 name: fm.name.unwrap_or_else(|| name.to_string()),
1006 description: fm.description.unwrap_or_default(),
1007 license: fm.license,
1008 compatibility: fm.compatibility,
1009 when_to_use: fm.when_to_use,
1010 extra_metadata: fm.metadata,
1011 allowed_tools: fm.allowed_tools,
1012 has_frontmatter: true,
1013 format: SkillFormat::Anthropic,
1014 ..Default::default()
1015 };
1016
1017 if let Some(author) = meta.extra_metadata.get("author").cloned() {
1018 meta.author = Some(author);
1019 }
1020 if let Some(version) = meta.extra_metadata.get("version").cloned() {
1021 meta.version = version;
1022 }
1023
1024 if let Some(toml_str) = skill_toml_content {
1026 if let Ok(parsed) = toml::from_str::<SkillToml>(toml_str) {
1027 let ext = parsed.skill;
1028 meta.tools = ext.tools;
1029 meta.providers = ext.providers;
1030 meta.categories = ext.categories;
1031 meta.keywords = ext.keywords;
1032 meta.hint = ext.hint;
1033 meta.depends_on = ext.depends_on;
1034 meta.suggests = ext.suggests;
1035 }
1036 }
1037
1038 Ok(meta)
1039 } else if let Some(toml_str) = skill_toml_content {
1040 let parsed: SkillToml = toml::from_str(toml_str)
1042 .map_err(|e| SkillError::Parse(format!("{name}/skill.toml"), e))?;
1043 let mut meta = parsed.skill;
1044 meta.format = SkillFormat::LegacyToml;
1045 if meta.name.is_empty() {
1046 meta.name = name.to_string();
1047 }
1048 Ok(meta)
1049 } else if let Some(body) = body {
1050 let description = body
1052 .lines()
1053 .find(|l| !l.is_empty() && !l.starts_with('#'))
1054 .map(|l| l.trim().to_string())
1055 .unwrap_or_default();
1056
1057 Ok(SkillMeta {
1058 name: name.to_string(),
1059 description,
1060 format: SkillFormat::Inferred,
1061 ..Default::default()
1062 })
1063 } else {
1064 Err(SkillError::Invalid(format!(
1065 "Skill '{name}' has neither skill.toml nor SKILL.md content"
1066 )))
1067 }
1068}
1069
1070fn load_integrity_info(meta: &mut SkillMeta) {
1072 let toml_path = meta.dir.join("skill.toml");
1073 if !toml_path.exists() {
1074 return;
1075 }
1076 let contents = match std::fs::read_to_string(&toml_path) {
1077 Ok(c) => c,
1078 Err(_) => return,
1079 };
1080 let parsed: toml::Value = match toml::from_str(&contents) {
1081 Ok(v) => v,
1082 Err(_) => return,
1083 };
1084 if let Some(integrity) = parsed.get("ati").and_then(|a| a.get("integrity")) {
1085 meta.content_hash = integrity
1086 .get("content_hash")
1087 .and_then(|v| v.as_str())
1088 .map(|s| s.to_string());
1089 meta.source_url = integrity
1090 .get("source_url")
1091 .and_then(|v| v.as_str())
1092 .map(|s| s.to_string());
1093 meta.pinned_sha = integrity
1094 .get("pinned_sha")
1095 .and_then(|v| v.as_str())
1096 .map(|s| s.to_string());
1097 }
1098}
1099
1100pub fn scaffold_skill_toml(name: &str, tools: &[String], provider: Option<&str>) -> String {
1102 let mut toml = format!(
1103 r#"[skill]
1104name = "{name}"
1105version = "0.1.0"
1106description = ""
1107"#
1108 );
1109
1110 if !tools.is_empty() {
1111 let tools_str: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
1112 toml.push_str(&format!("tools = [{}]\n", tools_str.join(", ")));
1113 } else {
1114 toml.push_str("tools = []\n");
1115 }
1116
1117 if let Some(p) = provider {
1118 toml.push_str(&format!("providers = [\"{p}\"]\n"));
1119 } else {
1120 toml.push_str("providers = []\n");
1121 }
1122
1123 toml.push_str(
1124 r#"categories = []
1125keywords = []
1126hint = ""
1127depends_on = []
1128suggests = []
1129"#,
1130 );
1131
1132 toml
1133}
1134
1135pub fn scaffold_skill_md(name: &str) -> String {
1137 let title = name
1138 .split('-')
1139 .map(|w| {
1140 let mut c = w.chars();
1141 match c.next() {
1142 None => String::new(),
1143 Some(f) => f.to_uppercase().to_string() + c.as_str(),
1144 }
1145 })
1146 .collect::<Vec<_>>()
1147 .join(" ");
1148
1149 format!(
1150 r#"# {title} Skill
1151
1152TODO: Describe what this skill does and when to use it.
1153
1154## Tools Available
1155
1156- TODO: List the tools this skill covers
1157
1158## Decision Tree
1159
11601. TODO: Step-by-step methodology
1161
1162## Examples
1163
1164TODO: Add example workflows
1165"#
1166 )
1167}
1168
1169pub fn scaffold_skill_md_with_frontmatter(name: &str, description: &str) -> String {
1171 let title = name
1172 .split('-')
1173 .map(|w| {
1174 let mut c = w.chars();
1175 match c.next() {
1176 None => String::new(),
1177 Some(f) => f.to_uppercase().to_string() + c.as_str(),
1178 }
1179 })
1180 .collect::<Vec<_>>()
1181 .join(" ");
1182
1183 format!(
1184 r#"---
1185name: {name}
1186description: {description}
1187metadata:
1188 version: "0.1.0"
1189---
1190
1191# {title} Skill
1192
1193TODO: Describe what this skill does and when to use it.
1194
1195## Tools Available
1196
1197- TODO: List the tools this skill covers
1198
1199## Decision Tree
1200
12011. TODO: Step-by-step methodology
1202
1203## Examples
1204
1205TODO: Add example workflows
1206"#
1207 )
1208}
1209
1210pub fn scaffold_ati_extension_toml(name: &str, tools: &[String], provider: Option<&str>) -> String {
1213 let mut toml = format!(
1214 r#"# ATI extension fields for skill '{name}'
1215# Core metadata (name, description, license) lives in SKILL.md frontmatter.
1216
1217[skill]
1218name = "{name}"
1219"#
1220 );
1221
1222 if !tools.is_empty() {
1223 let tools_str: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
1224 toml.push_str(&format!("tools = [{}]\n", tools_str.join(", ")));
1225 } else {
1226 toml.push_str("tools = []\n");
1227 }
1228
1229 if let Some(p) = provider {
1230 toml.push_str(&format!("providers = [\"{p}\"]\n"));
1231 } else {
1232 toml.push_str("providers = []\n");
1233 }
1234
1235 toml.push_str(
1236 r#"categories = []
1237keywords = []
1238depends_on = []
1239suggests = []
1240"#,
1241 );
1242
1243 toml
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248 use super::*;
1249 use std::fs;
1250
1251 fn create_test_skill(
1252 dir: &Path,
1253 name: &str,
1254 tools: &[&str],
1255 providers: &[&str],
1256 categories: &[&str],
1257 ) {
1258 let skill_dir = dir.join(name);
1259 fs::create_dir_all(&skill_dir).unwrap();
1260
1261 let tools_toml: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
1262 let providers_toml: Vec<String> = providers.iter().map(|p| format!("\"{p}\"")).collect();
1263 let categories_toml: Vec<String> = categories.iter().map(|c| format!("\"{c}\"")).collect();
1264
1265 let toml_content = format!(
1266 r#"[skill]
1267name = "{name}"
1268version = "1.0.0"
1269description = "Test skill for {name}"
1270tools = [{tools}]
1271providers = [{providers}]
1272categories = [{categories}]
1273keywords = ["test", "{name}"]
1274hint = "Use for testing {name}"
1275depends_on = []
1276suggests = []
1277"#,
1278 tools = tools_toml.join(", "),
1279 providers = providers_toml.join(", "),
1280 categories = categories_toml.join(", "),
1281 );
1282
1283 fs::write(skill_dir.join("skill.toml"), toml_content).unwrap();
1284 fs::write(
1285 skill_dir.join("SKILL.md"),
1286 format!("# {name}\n\nTest skill content."),
1287 )
1288 .unwrap();
1289 }
1290
1291 #[test]
1292 fn test_load_skill_with_toml() {
1293 let tmp = tempfile::tempdir().unwrap();
1294 create_test_skill(
1295 tmp.path(),
1296 "sanctions",
1297 &["ca_business_sanctions_search"],
1298 &["complyadvantage"],
1299 &["compliance"],
1300 );
1301
1302 let registry = SkillRegistry::load(tmp.path()).unwrap();
1303 assert_eq!(registry.skill_count(), 1);
1304
1305 let skill = registry.get_skill("sanctions").unwrap();
1306 assert_eq!(skill.version, "1.0.0");
1307 assert_eq!(skill.tools, vec!["ca_business_sanctions_search"]);
1308 assert_eq!(skill.providers, vec!["complyadvantage"]);
1309 assert_eq!(skill.categories, vec!["compliance"]);
1310 }
1311
1312 #[test]
1313 fn test_load_skill_md_fallback() {
1314 let tmp = tempfile::tempdir().unwrap();
1315 let skill_dir = tmp.path().join("legacy-skill");
1316 fs::create_dir_all(&skill_dir).unwrap();
1317 fs::write(
1318 skill_dir.join("SKILL.md"),
1319 "# Legacy Skill\n\nA skill with only SKILL.md, no skill.toml.\n",
1320 )
1321 .unwrap();
1322
1323 let registry = SkillRegistry::load(tmp.path()).unwrap();
1324 assert_eq!(registry.skill_count(), 1);
1325
1326 let skill = registry.get_skill("legacy-skill").unwrap();
1327 assert_eq!(
1328 skill.description,
1329 "A skill with only SKILL.md, no skill.toml."
1330 );
1331 assert!(skill.tools.is_empty()); }
1333
1334 #[test]
1335 fn test_tool_index() {
1336 let tmp = tempfile::tempdir().unwrap();
1337 create_test_skill(tmp.path(), "skill-a", &["tool_x", "tool_y"], &[], &[]);
1338 create_test_skill(tmp.path(), "skill-b", &["tool_y", "tool_z"], &[], &[]);
1339
1340 let registry = SkillRegistry::load(tmp.path()).unwrap();
1341
1342 let skills = registry.skills_for_tool("tool_x");
1344 assert_eq!(skills.len(), 1);
1345 assert_eq!(skills[0].name, "skill-a");
1346
1347 let skills = registry.skills_for_tool("tool_y");
1349 assert_eq!(skills.len(), 2);
1350
1351 let skills = registry.skills_for_tool("tool_z");
1353 assert_eq!(skills.len(), 1);
1354 assert_eq!(skills[0].name, "skill-b");
1355
1356 assert!(registry.skills_for_tool("nope").is_empty());
1358 }
1359
1360 #[test]
1361 fn test_provider_and_category_index() {
1362 let tmp = tempfile::tempdir().unwrap();
1363 create_test_skill(
1364 tmp.path(),
1365 "compliance-skill",
1366 &[],
1367 &["complyadvantage"],
1368 &["compliance", "aml"],
1369 );
1370
1371 let registry = SkillRegistry::load(tmp.path()).unwrap();
1372
1373 assert_eq!(registry.skills_for_provider("complyadvantage").len(), 1);
1374 assert_eq!(registry.skills_for_category("compliance").len(), 1);
1375 assert_eq!(registry.skills_for_category("aml").len(), 1);
1376 assert!(registry.skills_for_provider("serpapi").is_empty());
1377 }
1378
1379 #[test]
1380 fn test_search() {
1381 let tmp = tempfile::tempdir().unwrap();
1382 create_test_skill(
1383 tmp.path(),
1384 "sanctions-screening",
1385 &["ca_business_sanctions_search"],
1386 &["complyadvantage"],
1387 &["compliance"],
1388 );
1389 create_test_skill(
1390 tmp.path(),
1391 "web-search",
1392 &["web_search"],
1393 &["serpapi"],
1394 &["search"],
1395 );
1396
1397 let registry = SkillRegistry::load(tmp.path()).unwrap();
1398
1399 let results = registry.search("sanctions");
1401 assert!(!results.is_empty());
1402 assert_eq!(results[0].name, "sanctions-screening");
1403
1404 let results = registry.search("web");
1406 assert!(!results.is_empty());
1407 assert_eq!(results[0].name, "web-search");
1408
1409 let results = registry.search("nonexistent");
1411 assert!(results.is_empty());
1412 }
1413
1414 #[test]
1415 fn test_read_content_and_references() {
1416 let tmp = tempfile::tempdir().unwrap();
1417 let skill_dir = tmp.path().join("test-skill");
1418 let refs_dir = skill_dir.join("references");
1419 fs::create_dir_all(&refs_dir).unwrap();
1420
1421 fs::write(
1422 skill_dir.join("skill.toml"),
1423 r#"[skill]
1424name = "test-skill"
1425description = "Test"
1426"#,
1427 )
1428 .unwrap();
1429 fs::write(skill_dir.join("SKILL.md"), "# Test\n\nContent here.").unwrap();
1430 fs::write(refs_dir.join("guide.md"), "Reference guide content").unwrap();
1431
1432 let registry = SkillRegistry::load(tmp.path()).unwrap();
1433
1434 let content = registry.read_content("test-skill").unwrap();
1435 assert!(content.contains("Content here."));
1436
1437 let refs = registry.list_references("test-skill").unwrap();
1438 assert_eq!(refs, vec!["guide.md"]);
1439
1440 let ref_content = registry.read_reference("test-skill", "guide.md").unwrap();
1441 assert!(ref_content.contains("Reference guide content"));
1442 }
1443
1444 #[test]
1445 fn test_resolve_skills_explicit() {
1446 let tmp = tempfile::tempdir().unwrap();
1447 create_test_skill(tmp.path(), "skill-a", &[], &[], &[]);
1448 create_test_skill(tmp.path(), "skill-b", &[], &[], &[]);
1449
1450 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1451 let manifest_reg = ManifestRegistry::empty();
1452
1453 let scopes = ScopeConfig {
1454 scopes: vec!["skill:skill-a".to_string()],
1455 sub: String::new(),
1456 expires_at: 0,
1457 rate_config: None,
1458 };
1459
1460 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1461 assert_eq!(resolved.len(), 1);
1462 assert_eq!(resolved[0].name, "skill-a");
1463 }
1464
1465 #[test]
1466 fn test_resolve_skills_by_tool_binding() {
1467 let tmp = tempfile::tempdir().unwrap();
1468 create_test_skill(
1469 tmp.path(),
1470 "sanctions-skill",
1471 &["ca_sanctions_search"],
1472 &[],
1473 &[],
1474 );
1475 create_test_skill(
1476 tmp.path(),
1477 "unrelated-skill",
1478 &["some_other_tool"],
1479 &[],
1480 &[],
1481 );
1482
1483 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1484
1485 let manifest_reg = ManifestRegistry::empty();
1486
1487 let scopes = ScopeConfig {
1488 scopes: vec!["tool:ca_sanctions_search".to_string()],
1489 sub: String::new(),
1490 expires_at: 0,
1491 rate_config: None,
1492 };
1493
1494 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1495 assert_eq!(resolved.len(), 1);
1496 assert_eq!(resolved[0].name, "sanctions-skill");
1497 }
1498
1499 #[test]
1500 fn test_resolve_skills_legacy_underscore_scope_matches_colon_tool_binding() {
1501 let tmp = tempfile::tempdir().unwrap();
1502 create_test_skill(tmp.path(), "colon-skill", &["test_api:get_data"], &[], &[]);
1503
1504 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1505
1506 let manifest_tmp = tempfile::tempdir().unwrap();
1507 fs::write(
1508 manifest_tmp.path().join("test.toml"),
1509 r#"
1510[provider]
1511name = "test_provider"
1512description = "Test provider"
1513base_url = "http://unused"
1514auth_type = "none"
1515
1516[[tools]]
1517name = "test_api:get_data"
1518description = "test"
1519endpoint = "/"
1520method = "GET"
1521scope = "tool:test_api:get_data"
1522"#,
1523 )
1524 .unwrap();
1525 let manifest_reg = ManifestRegistry::load(manifest_tmp.path()).unwrap();
1526
1527 let scopes = ScopeConfig {
1528 scopes: vec!["tool:test_api_get_data".to_string()],
1529 sub: String::new(),
1530 expires_at: 0,
1531 rate_config: None,
1532 };
1533
1534 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1535 assert_eq!(resolved.len(), 1);
1536 assert_eq!(resolved[0].name, "colon-skill");
1537 }
1538
1539 #[test]
1540 fn test_resolve_skills_with_dependencies() {
1541 let tmp = tempfile::tempdir().unwrap();
1542
1543 let dir_a = tmp.path().join("skill-a");
1545 fs::create_dir_all(&dir_a).unwrap();
1546 fs::write(
1547 dir_a.join("skill.toml"),
1548 r#"[skill]
1549name = "skill-a"
1550description = "Skill A"
1551tools = ["tool_a"]
1552depends_on = ["skill-b"]
1553"#,
1554 )
1555 .unwrap();
1556 fs::write(dir_a.join("SKILL.md"), "# Skill A").unwrap();
1557
1558 let dir_b = tmp.path().join("skill-b");
1560 fs::create_dir_all(&dir_b).unwrap();
1561 fs::write(
1562 dir_b.join("skill.toml"),
1563 r#"[skill]
1564name = "skill-b"
1565description = "Skill B"
1566tools = ["tool_b"]
1567"#,
1568 )
1569 .unwrap();
1570 fs::write(dir_b.join("SKILL.md"), "# Skill B").unwrap();
1571
1572 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1573
1574 let manifest_tmp = tempfile::tempdir().unwrap();
1575 fs::create_dir_all(manifest_tmp.path()).unwrap();
1576 let manifest_reg = ManifestRegistry::load(manifest_tmp.path())
1577 .unwrap_or_else(|_| panic!("cannot load empty manifest dir"));
1578
1579 let scopes = ScopeConfig {
1580 scopes: vec!["tool:tool_a".to_string()],
1581 sub: String::new(),
1582 expires_at: 0,
1583 rate_config: None,
1584 };
1585
1586 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1587 assert_eq!(resolved.len(), 2);
1589 let names: Vec<&str> = resolved.iter().map(|s| s.name.as_str()).collect();
1590 assert!(names.contains(&"skill-a"));
1591 assert!(names.contains(&"skill-b"));
1592 }
1593
1594 #[test]
1595 fn test_scaffold() {
1596 let toml = scaffold_skill_toml(
1597 "my-skill",
1598 &["tool_a".into(), "tool_b".into()],
1599 Some("provider_x"),
1600 );
1601 assert!(toml.contains("name = \"my-skill\""));
1602 assert!(toml.contains("\"tool_a\""));
1603 assert!(toml.contains("\"provider_x\""));
1604
1605 let md = scaffold_skill_md("my-cool-skill");
1606 assert!(md.contains("# My Cool Skill Skill"));
1607 }
1608
1609 #[test]
1610 fn test_build_skill_context() {
1611 let skill = SkillMeta {
1612 name: "test-skill".to_string(),
1613 version: "1.0.0".to_string(),
1614 description: "A test skill".to_string(),
1615 tools: vec!["tool_a".to_string(), "tool_b".to_string()],
1616 hint: Some("Use for testing".to_string()),
1617 suggests: vec!["other-skill".to_string()],
1618 ..Default::default()
1619 };
1620
1621 let ctx = build_skill_context(&[&skill]);
1622 assert!(ctx.contains("**test-skill**"));
1623 assert!(ctx.contains("A test skill"));
1624 assert!(ctx.contains("Use for testing"));
1625 assert!(ctx.contains("tool_a, tool_b"));
1626 assert!(ctx.contains("other-skill"));
1627 }
1628
1629 #[test]
1630 fn test_empty_directory() {
1631 let tmp = tempfile::tempdir().unwrap();
1632 let registry = SkillRegistry::load(tmp.path()).unwrap();
1633 assert_eq!(registry.skill_count(), 0);
1634 }
1635
1636 #[test]
1637 fn test_nonexistent_directory() {
1638 let registry = SkillRegistry::load(Path::new("/nonexistent/path")).unwrap();
1639 assert_eq!(registry.skill_count(), 0);
1640 }
1641}