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 files_cache: HashMap<(String, String), Vec<u8>>,
277}
278
279impl SkillRegistry {
280 pub fn load(skills_dir: &Path) -> Result<Self, SkillError> {
285 let mut skills = Vec::new();
286 let mut name_index = HashMap::new();
287 let mut tool_index: HashMap<String, Vec<usize>> = HashMap::new();
288 let mut provider_index: HashMap<String, Vec<usize>> = HashMap::new();
289 let mut category_index: HashMap<String, Vec<usize>> = HashMap::new();
290
291 if !skills_dir.is_dir() {
292 return Ok(SkillRegistry {
294 skills,
295 name_index,
296 tool_index,
297 provider_index,
298 category_index,
299 files_cache: HashMap::new(),
300 });
301 }
302
303 let entries = std::fs::read_dir(skills_dir)
304 .map_err(|e| SkillError::Io(skills_dir.display().to_string(), e))?;
305
306 for entry in entries {
307 let entry = entry.map_err(|e| SkillError::Io(skills_dir.display().to_string(), e))?;
308 let path = entry.path();
309 if !path.is_dir() {
310 continue;
311 }
312
313 let skill = load_skill_from_dir(&path)?;
314
315 let idx = skills.len();
316 name_index.insert(skill.name.clone(), idx);
317
318 for tool in &skill.tools {
319 tool_index.entry(tool.clone()).or_default().push(idx);
320 }
321 for provider in &skill.providers {
322 provider_index
323 .entry(provider.clone())
324 .or_default()
325 .push(idx);
326 }
327 for category in &skill.categories {
328 category_index
329 .entry(category.clone())
330 .or_default()
331 .push(idx);
332 }
333
334 skills.push(skill);
335 }
336
337 Ok(SkillRegistry {
338 skills,
339 name_index,
340 tool_index,
341 provider_index,
342 category_index,
343 files_cache: HashMap::new(),
344 })
345 }
346
347 pub fn merge(&mut self, source: crate::core::gcs::GcsSkillSource) {
350 let mut added: std::collections::HashSet<String> = std::collections::HashSet::new();
351
352 for skill in source.skills {
353 if self.name_index.contains_key(&skill.name) {
354 continue;
356 }
357 added.insert(skill.name.clone());
358 let idx = self.skills.len();
359 self.name_index.insert(skill.name.clone(), idx);
360 for tool in &skill.tools {
361 self.tool_index.entry(tool.clone()).or_default().push(idx);
362 }
363 for provider in &skill.providers {
364 self.provider_index
365 .entry(provider.clone())
366 .or_default()
367 .push(idx);
368 }
369 for category in &skill.categories {
370 self.category_index
371 .entry(category.clone())
372 .or_default()
373 .push(idx);
374 }
375 self.skills.push(skill);
376 }
377
378 for ((skill_name, rel_path), data) in source.files {
381 if added.contains(&skill_name) {
382 self.files_cache.insert((skill_name, rel_path), data);
383 }
384 }
385 }
386
387 pub fn get_skill(&self, name: &str) -> Option<&SkillMeta> {
389 self.name_index.get(name).map(|&idx| &self.skills[idx])
390 }
391
392 pub fn list_skills(&self) -> &[SkillMeta] {
394 &self.skills
395 }
396
397 pub fn skills_for_tool(&self, tool_name: &str) -> Vec<&SkillMeta> {
399 self.tool_index
400 .get(tool_name)
401 .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
402 .unwrap_or_default()
403 }
404
405 pub fn skills_for_provider(&self, provider_name: &str) -> Vec<&SkillMeta> {
407 self.provider_index
408 .get(provider_name)
409 .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
410 .unwrap_or_default()
411 }
412
413 pub fn skills_for_category(&self, category: &str) -> Vec<&SkillMeta> {
415 self.category_index
416 .get(category)
417 .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
418 .unwrap_or_default()
419 }
420
421 pub fn search(&self, query: &str) -> Vec<&SkillMeta> {
423 let q = query.to_lowercase();
424 let terms: Vec<&str> = q.split_whitespace().collect();
425
426 let mut scored: Vec<(usize, &SkillMeta)> = self
427 .skills
428 .iter()
429 .filter_map(|skill| {
430 let mut score = 0usize;
431 let name_lower = skill.name.to_lowercase();
432 let desc_lower = skill.description.to_lowercase();
433
434 for term in &terms {
435 if name_lower.contains(term) {
437 score += 10;
438 }
439 if desc_lower.contains(term) {
441 score += 5;
442 }
443 if skill
445 .keywords
446 .iter()
447 .any(|k| k.to_lowercase().contains(term))
448 {
449 score += 8;
450 }
451 if skill.tools.iter().any(|t| t.to_lowercase().contains(term)) {
453 score += 6;
454 }
455 if let Some(hint) = &skill.hint {
457 if hint.to_lowercase().contains(term) {
458 score += 4;
459 }
460 }
461 if skill
463 .providers
464 .iter()
465 .any(|p| p.to_lowercase().contains(term))
466 {
467 score += 6;
468 }
469 if skill
471 .categories
472 .iter()
473 .any(|c| c.to_lowercase().contains(term))
474 {
475 score += 4;
476 }
477 }
478
479 if score > 0 {
480 Some((score, skill))
481 } else {
482 None
483 }
484 })
485 .collect();
486
487 scored.sort_by(|a, b| b.0.cmp(&a.0));
489 scored.into_iter().map(|(_, skill)| skill).collect()
490 }
491
492 pub fn read_content(&self, name: &str) -> Result<String, SkillError> {
495 if let Some(bytes) = self
497 .files_cache
498 .get(&(name.to_string(), "SKILL.md".to_string()))
499 {
500 let raw = std::str::from_utf8(bytes).unwrap_or("");
501 return Ok(strip_frontmatter(raw).to_string());
502 }
503
504 let skill = self
506 .get_skill(name)
507 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
508 let skill_md = skill.dir.join("SKILL.md");
509 if !skill_md.exists() {
510 return Ok(String::new());
511 }
512 let raw = std::fs::read_to_string(&skill_md)
513 .map_err(|e| SkillError::Io(skill_md.display().to_string(), e))?;
514 Ok(strip_frontmatter(&raw).to_string())
515 }
516
517 pub fn list_references(&self, name: &str) -> Result<Vec<String>, SkillError> {
520 let prefix = "references/";
522 let cached_refs: Vec<String> = self
523 .files_cache
524 .keys()
525 .filter(|(skill, path)| skill == name && path.starts_with(prefix))
526 .map(|(_, path)| path.strip_prefix(prefix).unwrap_or(path).to_string())
527 .collect();
528 if !cached_refs.is_empty() {
529 let mut refs = cached_refs;
530 refs.sort();
531 return Ok(refs);
532 }
533
534 let skill = self
536 .get_skill(name)
537 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
538 let refs_dir = skill.dir.join("references");
539 if !refs_dir.is_dir() {
540 return Ok(Vec::new());
541 }
542 let mut refs = Vec::new();
543 let entries = std::fs::read_dir(&refs_dir)
544 .map_err(|e| SkillError::Io(refs_dir.display().to_string(), e))?;
545 for entry in entries {
546 let entry = entry.map_err(|e| SkillError::Io(refs_dir.display().to_string(), e))?;
547 if let Some(name) = entry.file_name().to_str() {
548 refs.push(name.to_string());
549 }
550 }
551 refs.sort();
552 Ok(refs)
553 }
554
555 pub fn read_reference(&self, skill_name: &str, ref_name: &str) -> Result<String, SkillError> {
558 if ref_name.contains("..")
560 || ref_name.contains('/')
561 || ref_name.contains('\\')
562 || ref_name.contains('\0')
563 {
564 return Err(SkillError::NotFound(format!(
565 "Invalid reference name '{ref_name}' — path traversal not allowed"
566 )));
567 }
568
569 let cache_key = (skill_name.to_string(), format!("references/{ref_name}"));
571 if let Some(bytes) = self.files_cache.get(&cache_key) {
572 return std::str::from_utf8(bytes)
573 .map(|s| s.to_string())
574 .map_err(|e| SkillError::Invalid(format!("invalid UTF-8 in reference: {e}")));
575 }
576
577 let skill = self
578 .get_skill(skill_name)
579 .ok_or_else(|| SkillError::NotFound(skill_name.to_string()))?;
580 let refs_dir = skill.dir.join("references");
581 let ref_path = refs_dir.join(ref_name);
582
583 if let (Ok(canonical_ref), Ok(canonical_dir)) =
585 (ref_path.canonicalize(), refs_dir.canonicalize())
586 {
587 if !canonical_ref.starts_with(&canonical_dir) {
588 return Err(SkillError::NotFound(format!(
589 "Reference '{ref_name}' resolves outside references directory"
590 )));
591 }
592 }
593
594 if !ref_path.exists() {
595 return Err(SkillError::NotFound(format!(
596 "Reference '{ref_name}' in skill '{skill_name}'"
597 )));
598 }
599 std::fs::read_to_string(&ref_path)
600 .map_err(|e| SkillError::Io(ref_path.display().to_string(), e))
601 }
602
603 pub fn bundle_files(&self, name: &str) -> Result<HashMap<String, Vec<u8>>, SkillError> {
606 let _skill = self
607 .get_skill(name)
608 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
609
610 let mut files: HashMap<String, Vec<u8>> = HashMap::new();
611
612 for ((skill_name, rel_path), data) in &self.files_cache {
614 if skill_name == name {
615 files.insert(rel_path.clone(), data.clone());
616 }
617 }
618
619 if files.is_empty() {
621 let skill = self.get_skill(name).unwrap();
622 if skill.dir.is_dir() {
623 collect_dir_files(&skill.dir, &skill.dir, &mut files)?;
624 }
625 }
626
627 Ok(files)
628 }
629
630 pub fn skill_count(&self) -> usize {
632 self.skills.len()
633 }
634
635 pub fn validate_tool_bindings(
638 &self,
639 name: &str,
640 manifest_registry: &ManifestRegistry,
641 ) -> Result<(Vec<String>, Vec<String>), SkillError> {
642 let skill = self
643 .get_skill(name)
644 .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
645
646 let mut valid = Vec::new();
647 let mut unknown = Vec::new();
648
649 for tool_name in &skill.tools {
650 if manifest_registry.get_tool(tool_name).is_some() {
651 valid.push(tool_name.clone());
652 } else {
653 unknown.push(tool_name.clone());
654 }
655 }
656
657 Ok((valid, unknown))
658 }
659}
660
661pub fn resolve_skills<'a>(
672 skill_registry: &'a SkillRegistry,
673 manifest_registry: &ManifestRegistry,
674 scopes: &ScopeConfig,
675) -> Vec<&'a SkillMeta> {
676 let mut resolved_indices: Vec<usize> = Vec::new();
677 let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
678
679 for scope in &scopes.scopes {
680 if let Some(skill_name) = scope.strip_prefix("skill:") {
682 if let Some(&idx) = skill_registry.name_index.get(skill_name) {
683 if seen.insert(idx) {
684 resolved_indices.push(idx);
685 }
686 }
687 }
688
689 if let Some(tool_name) = scope.strip_prefix("tool:") {
691 if let Some(indices) = skill_registry.tool_index.get(tool_name) {
692 for &idx in indices {
693 if seen.insert(idx) {
694 resolved_indices.push(idx);
695 }
696 }
697 }
698
699 if let Some((provider, _)) = manifest_registry.get_tool(tool_name) {
701 if let Some(indices) = skill_registry.provider_index.get(&provider.name) {
702 for &idx in indices {
703 if seen.insert(idx) {
704 resolved_indices.push(idx);
705 }
706 }
707 }
708
709 if let Some(category) = &provider.category {
711 if let Some(indices) = skill_registry.category_index.get(category) {
712 for &idx in indices {
713 if seen.insert(idx) {
714 resolved_indices.push(idx);
715 }
716 }
717 }
718 }
719 }
720 }
721 }
722
723 if !scopes.is_wildcard() {
727 for (provider, tool) in
728 crate::core::scope::filter_tools_by_scope(manifest_registry.list_public_tools(), scopes)
729 {
730 if let Some(indices) = skill_registry.tool_index.get(&tool.name) {
731 for &idx in indices {
732 if seen.insert(idx) {
733 resolved_indices.push(idx);
734 }
735 }
736 }
737
738 if let Some(indices) = skill_registry.provider_index.get(&provider.name) {
739 for &idx in indices {
740 if seen.insert(idx) {
741 resolved_indices.push(idx);
742 }
743 }
744 }
745
746 if let Some(category) = &provider.category {
747 if let Some(indices) = skill_registry.category_index.get(category) {
748 for &idx in indices {
749 if seen.insert(idx) {
750 resolved_indices.push(idx);
751 }
752 }
753 }
754 }
755 }
756 }
757
758 let mut i = 0;
760 while i < resolved_indices.len() {
761 let skill = &skill_registry.skills[resolved_indices[i]];
762 for dep_name in &skill.depends_on {
763 if let Some(&dep_idx) = skill_registry.name_index.get(dep_name) {
764 if seen.insert(dep_idx) {
765 resolved_indices.push(dep_idx);
766 }
767 }
768 }
769 i += 1;
770 }
771
772 resolved_indices
773 .into_iter()
774 .map(|idx| &skill_registry.skills[idx])
775 .collect()
776}
777
778pub fn visible_skills<'a>(
784 skill_registry: &'a SkillRegistry,
785 manifest_registry: &ManifestRegistry,
786 scopes: &ScopeConfig,
787) -> Vec<&'a SkillMeta> {
788 if scopes.is_wildcard() {
789 return skill_registry.list_skills().iter().collect();
790 }
791
792 let mut visible = resolve_skills(skill_registry, manifest_registry, scopes);
793 visible.sort_by(|a, b| a.name.cmp(&b.name));
794 visible
795}
796
797const MAX_SKILL_INJECT_SIZE: usize = 32 * 1024;
800
801pub fn build_skill_context(skills: &[&SkillMeta]) -> String {
805 if skills.is_empty() {
806 return String::new();
807 }
808
809 let mut total_size = 0;
810 let mut sections = Vec::new();
811 for skill in skills {
812 let mut section = format!(
813 "--- BEGIN SKILL: {} ---\n- **{}**: {}",
814 skill.name, skill.name, skill.description
815 );
816 if let Some(hint) = &skill.hint {
817 section.push_str(&format!("\n Hint: {hint}"));
818 }
819 if !skill.tools.is_empty() {
820 section.push_str(&format!("\n Covers tools: {}", skill.tools.join(", ")));
821 }
822 if !skill.suggests.is_empty() {
823 section.push_str(&format!(
824 "\n Related skills: {}",
825 skill.suggests.join(", ")
826 ));
827 }
828 section.push_str(&format!("\n--- END SKILL: {} ---", skill.name));
829
830 total_size += section.len();
831 if total_size > MAX_SKILL_INJECT_SIZE {
832 sections.push("(remaining skills truncated due to size limit)".to_string());
833 break;
834 }
835 sections.push(section);
836 }
837 sections.join("\n\n")
838}
839
840fn collect_dir_files(
848 base: &Path,
849 current: &Path,
850 files: &mut HashMap<String, Vec<u8>>,
851) -> Result<(), SkillError> {
852 let entries =
853 std::fs::read_dir(current).map_err(|e| SkillError::Io(current.display().to_string(), e))?;
854 for entry in entries {
855 let entry = entry.map_err(|e| SkillError::Io(current.display().to_string(), e))?;
856 let path = entry.path();
857 if path.is_dir() {
858 collect_dir_files(base, &path, files)?;
859 } else if let Ok(rel) = path.strip_prefix(base) {
860 if let Some(rel_str) = rel.to_str() {
861 if let Ok(data) = std::fs::read(&path) {
862 files.insert(rel_str.to_string(), data);
863 }
864 }
865 }
866 }
867 Ok(())
868}
869
870fn load_skill_from_dir(dir: &Path) -> Result<SkillMeta, SkillError> {
873 let skill_toml_path = dir.join("skill.toml");
874 let skill_md_path = dir.join("SKILL.md");
875
876 let dir_name = dir
877 .file_name()
878 .and_then(|n| n.to_str())
879 .unwrap_or("unknown")
880 .to_string();
881
882 let (frontmatter, _body) = if skill_md_path.exists() {
884 let content = std::fs::read_to_string(&skill_md_path)
885 .map_err(|e| SkillError::Io(skill_md_path.display().to_string(), e))?;
886 let (fm, body) = parse_frontmatter(&content);
887 let body_owned = body.to_string();
889 (fm, Some((content, body_owned)))
890 } else {
891 (None, None)
892 };
893
894 if let Some(fm) = frontmatter {
895 let mut meta = SkillMeta {
897 name: fm.name.unwrap_or_else(|| dir_name.clone()),
898 description: fm.description.unwrap_or_default(),
899 license: fm.license,
900 compatibility: fm.compatibility,
901 extra_metadata: fm.metadata,
902 allowed_tools: fm.allowed_tools,
903 has_frontmatter: true,
904 format: SkillFormat::Anthropic,
905 dir: dir.to_path_buf(),
906 ..Default::default()
907 };
908
909 if let Some(author) = meta.extra_metadata.get("author").cloned() {
911 meta.author = Some(author);
912 }
913 if let Some(version) = meta.extra_metadata.get("version").cloned() {
914 meta.version = version;
915 }
916
917 if skill_toml_path.exists() {
919 let contents = std::fs::read_to_string(&skill_toml_path)
920 .map_err(|e| SkillError::Io(skill_toml_path.display().to_string(), e))?;
921 if let Ok(parsed) = toml::from_str::<SkillToml>(&contents) {
922 let ext = parsed.skill;
923 meta.tools = ext.tools;
925 meta.providers = ext.providers;
926 meta.categories = ext.categories;
927 meta.keywords = ext.keywords;
928 meta.hint = ext.hint;
929 meta.depends_on = ext.depends_on;
930 meta.suggests = ext.suggests;
931 }
932 }
933
934 load_integrity_info(&mut meta);
935 Ok(meta)
936 } else if skill_toml_path.exists() {
937 let contents = std::fs::read_to_string(&skill_toml_path)
939 .map_err(|e| SkillError::Io(skill_toml_path.display().to_string(), e))?;
940 let parsed: SkillToml = toml::from_str(&contents)
941 .map_err(|e| SkillError::Parse(skill_toml_path.display().to_string(), e))?;
942 let mut meta = parsed.skill;
943 meta.dir = dir.to_path_buf();
944 meta.format = SkillFormat::LegacyToml;
945 if meta.name.is_empty() {
946 meta.name = dir_name;
947 }
948 load_integrity_info(&mut meta);
949 Ok(meta)
950 } else if let Some((_full_content, body)) = _body {
951 let description = body
953 .lines()
954 .find(|l| !l.is_empty() && !l.starts_with('#'))
955 .map(|l| l.trim().to_string())
956 .unwrap_or_default();
957
958 Ok(SkillMeta {
959 name: dir_name,
960 description,
961 format: SkillFormat::Inferred,
962 dir: dir.to_path_buf(),
963 ..Default::default()
964 })
965 } else {
966 Err(SkillError::Invalid(format!(
967 "Directory '{}' has neither skill.toml nor SKILL.md",
968 dir.display()
969 )))
970 }
971}
972
973pub fn parse_skill_metadata(
978 name: &str,
979 skill_md_content: &str,
980 skill_toml_content: Option<&str>,
981) -> Result<SkillMeta, SkillError> {
982 let (frontmatter, body) = if !skill_md_content.is_empty() {
983 let (fm, body) = parse_frontmatter(skill_md_content);
984 (fm, Some(body.to_string()))
985 } else {
986 (None, None)
987 };
988
989 if let Some(fm) = frontmatter {
990 let mut meta = SkillMeta {
992 name: fm.name.unwrap_or_else(|| name.to_string()),
993 description: fm.description.unwrap_or_default(),
994 license: fm.license,
995 compatibility: fm.compatibility,
996 extra_metadata: fm.metadata,
997 allowed_tools: fm.allowed_tools,
998 has_frontmatter: true,
999 format: SkillFormat::Anthropic,
1000 ..Default::default()
1001 };
1002
1003 if let Some(author) = meta.extra_metadata.get("author").cloned() {
1004 meta.author = Some(author);
1005 }
1006 if let Some(version) = meta.extra_metadata.get("version").cloned() {
1007 meta.version = version;
1008 }
1009
1010 if let Some(toml_str) = skill_toml_content {
1012 if let Ok(parsed) = toml::from_str::<SkillToml>(toml_str) {
1013 let ext = parsed.skill;
1014 meta.tools = ext.tools;
1015 meta.providers = ext.providers;
1016 meta.categories = ext.categories;
1017 meta.keywords = ext.keywords;
1018 meta.hint = ext.hint;
1019 meta.depends_on = ext.depends_on;
1020 meta.suggests = ext.suggests;
1021 }
1022 }
1023
1024 Ok(meta)
1025 } else if let Some(toml_str) = skill_toml_content {
1026 let parsed: SkillToml = toml::from_str(toml_str)
1028 .map_err(|e| SkillError::Parse(format!("{name}/skill.toml"), e))?;
1029 let mut meta = parsed.skill;
1030 meta.format = SkillFormat::LegacyToml;
1031 if meta.name.is_empty() {
1032 meta.name = name.to_string();
1033 }
1034 Ok(meta)
1035 } else if let Some(body) = body {
1036 let description = body
1038 .lines()
1039 .find(|l| !l.is_empty() && !l.starts_with('#'))
1040 .map(|l| l.trim().to_string())
1041 .unwrap_or_default();
1042
1043 Ok(SkillMeta {
1044 name: name.to_string(),
1045 description,
1046 format: SkillFormat::Inferred,
1047 ..Default::default()
1048 })
1049 } else {
1050 Err(SkillError::Invalid(format!(
1051 "Skill '{name}' has neither skill.toml nor SKILL.md content"
1052 )))
1053 }
1054}
1055
1056fn load_integrity_info(meta: &mut SkillMeta) {
1058 let toml_path = meta.dir.join("skill.toml");
1059 if !toml_path.exists() {
1060 return;
1061 }
1062 let contents = match std::fs::read_to_string(&toml_path) {
1063 Ok(c) => c,
1064 Err(_) => return,
1065 };
1066 let parsed: toml::Value = match toml::from_str(&contents) {
1067 Ok(v) => v,
1068 Err(_) => return,
1069 };
1070 if let Some(integrity) = parsed.get("ati").and_then(|a| a.get("integrity")) {
1071 meta.content_hash = integrity
1072 .get("content_hash")
1073 .and_then(|v| v.as_str())
1074 .map(|s| s.to_string());
1075 meta.source_url = integrity
1076 .get("source_url")
1077 .and_then(|v| v.as_str())
1078 .map(|s| s.to_string());
1079 meta.pinned_sha = integrity
1080 .get("pinned_sha")
1081 .and_then(|v| v.as_str())
1082 .map(|s| s.to_string());
1083 }
1084}
1085
1086pub fn scaffold_skill_toml(name: &str, tools: &[String], provider: Option<&str>) -> String {
1088 let mut toml = format!(
1089 r#"[skill]
1090name = "{name}"
1091version = "0.1.0"
1092description = ""
1093"#
1094 );
1095
1096 if !tools.is_empty() {
1097 let tools_str: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
1098 toml.push_str(&format!("tools = [{}]\n", tools_str.join(", ")));
1099 } else {
1100 toml.push_str("tools = []\n");
1101 }
1102
1103 if let Some(p) = provider {
1104 toml.push_str(&format!("providers = [\"{p}\"]\n"));
1105 } else {
1106 toml.push_str("providers = []\n");
1107 }
1108
1109 toml.push_str(
1110 r#"categories = []
1111keywords = []
1112hint = ""
1113depends_on = []
1114suggests = []
1115"#,
1116 );
1117
1118 toml
1119}
1120
1121pub fn scaffold_skill_md(name: &str) -> String {
1123 let title = name
1124 .split('-')
1125 .map(|w| {
1126 let mut c = w.chars();
1127 match c.next() {
1128 None => String::new(),
1129 Some(f) => f.to_uppercase().to_string() + c.as_str(),
1130 }
1131 })
1132 .collect::<Vec<_>>()
1133 .join(" ");
1134
1135 format!(
1136 r#"# {title} Skill
1137
1138TODO: Describe what this skill does and when to use it.
1139
1140## Tools Available
1141
1142- TODO: List the tools this skill covers
1143
1144## Decision Tree
1145
11461. TODO: Step-by-step methodology
1147
1148## Examples
1149
1150TODO: Add example workflows
1151"#
1152 )
1153}
1154
1155pub fn scaffold_skill_md_with_frontmatter(name: &str, description: &str) -> String {
1157 let title = name
1158 .split('-')
1159 .map(|w| {
1160 let mut c = w.chars();
1161 match c.next() {
1162 None => String::new(),
1163 Some(f) => f.to_uppercase().to_string() + c.as_str(),
1164 }
1165 })
1166 .collect::<Vec<_>>()
1167 .join(" ");
1168
1169 format!(
1170 r#"---
1171name: {name}
1172description: {description}
1173metadata:
1174 version: "0.1.0"
1175---
1176
1177# {title} Skill
1178
1179TODO: Describe what this skill does and when to use it.
1180
1181## Tools Available
1182
1183- TODO: List the tools this skill covers
1184
1185## Decision Tree
1186
11871. TODO: Step-by-step methodology
1188
1189## Examples
1190
1191TODO: Add example workflows
1192"#
1193 )
1194}
1195
1196pub fn scaffold_ati_extension_toml(name: &str, tools: &[String], provider: Option<&str>) -> String {
1199 let mut toml = format!(
1200 r#"# ATI extension fields for skill '{name}'
1201# Core metadata (name, description, license) lives in SKILL.md frontmatter.
1202
1203[skill]
1204name = "{name}"
1205"#
1206 );
1207
1208 if !tools.is_empty() {
1209 let tools_str: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
1210 toml.push_str(&format!("tools = [{}]\n", tools_str.join(", ")));
1211 } else {
1212 toml.push_str("tools = []\n");
1213 }
1214
1215 if let Some(p) = provider {
1216 toml.push_str(&format!("providers = [\"{p}\"]\n"));
1217 } else {
1218 toml.push_str("providers = []\n");
1219 }
1220
1221 toml.push_str(
1222 r#"categories = []
1223keywords = []
1224depends_on = []
1225suggests = []
1226"#,
1227 );
1228
1229 toml
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234 use super::*;
1235 use std::fs;
1236
1237 fn create_test_skill(
1238 dir: &Path,
1239 name: &str,
1240 tools: &[&str],
1241 providers: &[&str],
1242 categories: &[&str],
1243 ) {
1244 let skill_dir = dir.join(name);
1245 fs::create_dir_all(&skill_dir).unwrap();
1246
1247 let tools_toml: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
1248 let providers_toml: Vec<String> = providers.iter().map(|p| format!("\"{p}\"")).collect();
1249 let categories_toml: Vec<String> = categories.iter().map(|c| format!("\"{c}\"")).collect();
1250
1251 let toml_content = format!(
1252 r#"[skill]
1253name = "{name}"
1254version = "1.0.0"
1255description = "Test skill for {name}"
1256tools = [{tools}]
1257providers = [{providers}]
1258categories = [{categories}]
1259keywords = ["test", "{name}"]
1260hint = "Use for testing {name}"
1261depends_on = []
1262suggests = []
1263"#,
1264 tools = tools_toml.join(", "),
1265 providers = providers_toml.join(", "),
1266 categories = categories_toml.join(", "),
1267 );
1268
1269 fs::write(skill_dir.join("skill.toml"), toml_content).unwrap();
1270 fs::write(
1271 skill_dir.join("SKILL.md"),
1272 format!("# {name}\n\nTest skill content."),
1273 )
1274 .unwrap();
1275 }
1276
1277 #[test]
1278 fn test_load_skill_with_toml() {
1279 let tmp = tempfile::tempdir().unwrap();
1280 create_test_skill(
1281 tmp.path(),
1282 "sanctions",
1283 &["ca_business_sanctions_search"],
1284 &["complyadvantage"],
1285 &["compliance"],
1286 );
1287
1288 let registry = SkillRegistry::load(tmp.path()).unwrap();
1289 assert_eq!(registry.skill_count(), 1);
1290
1291 let skill = registry.get_skill("sanctions").unwrap();
1292 assert_eq!(skill.version, "1.0.0");
1293 assert_eq!(skill.tools, vec!["ca_business_sanctions_search"]);
1294 assert_eq!(skill.providers, vec!["complyadvantage"]);
1295 assert_eq!(skill.categories, vec!["compliance"]);
1296 }
1297
1298 #[test]
1299 fn test_load_skill_md_fallback() {
1300 let tmp = tempfile::tempdir().unwrap();
1301 let skill_dir = tmp.path().join("legacy-skill");
1302 fs::create_dir_all(&skill_dir).unwrap();
1303 fs::write(
1304 skill_dir.join("SKILL.md"),
1305 "# Legacy Skill\n\nA skill with only SKILL.md, no skill.toml.\n",
1306 )
1307 .unwrap();
1308
1309 let registry = SkillRegistry::load(tmp.path()).unwrap();
1310 assert_eq!(registry.skill_count(), 1);
1311
1312 let skill = registry.get_skill("legacy-skill").unwrap();
1313 assert_eq!(
1314 skill.description,
1315 "A skill with only SKILL.md, no skill.toml."
1316 );
1317 assert!(skill.tools.is_empty()); }
1319
1320 #[test]
1321 fn test_tool_index() {
1322 let tmp = tempfile::tempdir().unwrap();
1323 create_test_skill(tmp.path(), "skill-a", &["tool_x", "tool_y"], &[], &[]);
1324 create_test_skill(tmp.path(), "skill-b", &["tool_y", "tool_z"], &[], &[]);
1325
1326 let registry = SkillRegistry::load(tmp.path()).unwrap();
1327
1328 let skills = registry.skills_for_tool("tool_x");
1330 assert_eq!(skills.len(), 1);
1331 assert_eq!(skills[0].name, "skill-a");
1332
1333 let skills = registry.skills_for_tool("tool_y");
1335 assert_eq!(skills.len(), 2);
1336
1337 let skills = registry.skills_for_tool("tool_z");
1339 assert_eq!(skills.len(), 1);
1340 assert_eq!(skills[0].name, "skill-b");
1341
1342 assert!(registry.skills_for_tool("nope").is_empty());
1344 }
1345
1346 #[test]
1347 fn test_provider_and_category_index() {
1348 let tmp = tempfile::tempdir().unwrap();
1349 create_test_skill(
1350 tmp.path(),
1351 "compliance-skill",
1352 &[],
1353 &["complyadvantage"],
1354 &["compliance", "aml"],
1355 );
1356
1357 let registry = SkillRegistry::load(tmp.path()).unwrap();
1358
1359 assert_eq!(registry.skills_for_provider("complyadvantage").len(), 1);
1360 assert_eq!(registry.skills_for_category("compliance").len(), 1);
1361 assert_eq!(registry.skills_for_category("aml").len(), 1);
1362 assert!(registry.skills_for_provider("serpapi").is_empty());
1363 }
1364
1365 #[test]
1366 fn test_search() {
1367 let tmp = tempfile::tempdir().unwrap();
1368 create_test_skill(
1369 tmp.path(),
1370 "sanctions-screening",
1371 &["ca_business_sanctions_search"],
1372 &["complyadvantage"],
1373 &["compliance"],
1374 );
1375 create_test_skill(
1376 tmp.path(),
1377 "web-search",
1378 &["web_search"],
1379 &["serpapi"],
1380 &["search"],
1381 );
1382
1383 let registry = SkillRegistry::load(tmp.path()).unwrap();
1384
1385 let results = registry.search("sanctions");
1387 assert!(!results.is_empty());
1388 assert_eq!(results[0].name, "sanctions-screening");
1389
1390 let results = registry.search("web");
1392 assert!(!results.is_empty());
1393 assert_eq!(results[0].name, "web-search");
1394
1395 let results = registry.search("nonexistent");
1397 assert!(results.is_empty());
1398 }
1399
1400 #[test]
1401 fn test_read_content_and_references() {
1402 let tmp = tempfile::tempdir().unwrap();
1403 let skill_dir = tmp.path().join("test-skill");
1404 let refs_dir = skill_dir.join("references");
1405 fs::create_dir_all(&refs_dir).unwrap();
1406
1407 fs::write(
1408 skill_dir.join("skill.toml"),
1409 r#"[skill]
1410name = "test-skill"
1411description = "Test"
1412"#,
1413 )
1414 .unwrap();
1415 fs::write(skill_dir.join("SKILL.md"), "# Test\n\nContent here.").unwrap();
1416 fs::write(refs_dir.join("guide.md"), "Reference guide content").unwrap();
1417
1418 let registry = SkillRegistry::load(tmp.path()).unwrap();
1419
1420 let content = registry.read_content("test-skill").unwrap();
1421 assert!(content.contains("Content here."));
1422
1423 let refs = registry.list_references("test-skill").unwrap();
1424 assert_eq!(refs, vec!["guide.md"]);
1425
1426 let ref_content = registry.read_reference("test-skill", "guide.md").unwrap();
1427 assert!(ref_content.contains("Reference guide content"));
1428 }
1429
1430 #[test]
1431 fn test_resolve_skills_explicit() {
1432 let tmp = tempfile::tempdir().unwrap();
1433 create_test_skill(tmp.path(), "skill-a", &[], &[], &[]);
1434 create_test_skill(tmp.path(), "skill-b", &[], &[], &[]);
1435
1436 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1437 let manifest_reg = ManifestRegistry::empty();
1438
1439 let scopes = ScopeConfig {
1440 scopes: vec!["skill:skill-a".to_string()],
1441 sub: String::new(),
1442 expires_at: 0,
1443 rate_config: None,
1444 };
1445
1446 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1447 assert_eq!(resolved.len(), 1);
1448 assert_eq!(resolved[0].name, "skill-a");
1449 }
1450
1451 #[test]
1452 fn test_resolve_skills_by_tool_binding() {
1453 let tmp = tempfile::tempdir().unwrap();
1454 create_test_skill(
1455 tmp.path(),
1456 "sanctions-skill",
1457 &["ca_sanctions_search"],
1458 &[],
1459 &[],
1460 );
1461 create_test_skill(
1462 tmp.path(),
1463 "unrelated-skill",
1464 &["some_other_tool"],
1465 &[],
1466 &[],
1467 );
1468
1469 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1470
1471 let manifest_reg = ManifestRegistry::empty();
1472
1473 let scopes = ScopeConfig {
1474 scopes: vec!["tool:ca_sanctions_search".to_string()],
1475 sub: String::new(),
1476 expires_at: 0,
1477 rate_config: None,
1478 };
1479
1480 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1481 assert_eq!(resolved.len(), 1);
1482 assert_eq!(resolved[0].name, "sanctions-skill");
1483 }
1484
1485 #[test]
1486 fn test_resolve_skills_legacy_underscore_scope_matches_colon_tool_binding() {
1487 let tmp = tempfile::tempdir().unwrap();
1488 create_test_skill(tmp.path(), "colon-skill", &["test_api:get_data"], &[], &[]);
1489
1490 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1491
1492 let manifest_tmp = tempfile::tempdir().unwrap();
1493 fs::write(
1494 manifest_tmp.path().join("test.toml"),
1495 r#"
1496[provider]
1497name = "test_provider"
1498description = "Test provider"
1499base_url = "http://unused"
1500auth_type = "none"
1501
1502[[tools]]
1503name = "test_api:get_data"
1504description = "test"
1505endpoint = "/"
1506method = "GET"
1507scope = "tool:test_api:get_data"
1508"#,
1509 )
1510 .unwrap();
1511 let manifest_reg = ManifestRegistry::load(manifest_tmp.path()).unwrap();
1512
1513 let scopes = ScopeConfig {
1514 scopes: vec!["tool:test_api_get_data".to_string()],
1515 sub: String::new(),
1516 expires_at: 0,
1517 rate_config: None,
1518 };
1519
1520 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1521 assert_eq!(resolved.len(), 1);
1522 assert_eq!(resolved[0].name, "colon-skill");
1523 }
1524
1525 #[test]
1526 fn test_resolve_skills_with_dependencies() {
1527 let tmp = tempfile::tempdir().unwrap();
1528
1529 let dir_a = tmp.path().join("skill-a");
1531 fs::create_dir_all(&dir_a).unwrap();
1532 fs::write(
1533 dir_a.join("skill.toml"),
1534 r#"[skill]
1535name = "skill-a"
1536description = "Skill A"
1537tools = ["tool_a"]
1538depends_on = ["skill-b"]
1539"#,
1540 )
1541 .unwrap();
1542 fs::write(dir_a.join("SKILL.md"), "# Skill A").unwrap();
1543
1544 let dir_b = tmp.path().join("skill-b");
1546 fs::create_dir_all(&dir_b).unwrap();
1547 fs::write(
1548 dir_b.join("skill.toml"),
1549 r#"[skill]
1550name = "skill-b"
1551description = "Skill B"
1552tools = ["tool_b"]
1553"#,
1554 )
1555 .unwrap();
1556 fs::write(dir_b.join("SKILL.md"), "# Skill B").unwrap();
1557
1558 let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1559
1560 let manifest_tmp = tempfile::tempdir().unwrap();
1561 fs::create_dir_all(manifest_tmp.path()).unwrap();
1562 let manifest_reg = ManifestRegistry::load(manifest_tmp.path())
1563 .unwrap_or_else(|_| panic!("cannot load empty manifest dir"));
1564
1565 let scopes = ScopeConfig {
1566 scopes: vec!["tool:tool_a".to_string()],
1567 sub: String::new(),
1568 expires_at: 0,
1569 rate_config: None,
1570 };
1571
1572 let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1573 assert_eq!(resolved.len(), 2);
1575 let names: Vec<&str> = resolved.iter().map(|s| s.name.as_str()).collect();
1576 assert!(names.contains(&"skill-a"));
1577 assert!(names.contains(&"skill-b"));
1578 }
1579
1580 #[test]
1581 fn test_scaffold() {
1582 let toml = scaffold_skill_toml(
1583 "my-skill",
1584 &["tool_a".into(), "tool_b".into()],
1585 Some("provider_x"),
1586 );
1587 assert!(toml.contains("name = \"my-skill\""));
1588 assert!(toml.contains("\"tool_a\""));
1589 assert!(toml.contains("\"provider_x\""));
1590
1591 let md = scaffold_skill_md("my-cool-skill");
1592 assert!(md.contains("# My Cool Skill Skill"));
1593 }
1594
1595 #[test]
1596 fn test_build_skill_context() {
1597 let skill = SkillMeta {
1598 name: "test-skill".to_string(),
1599 version: "1.0.0".to_string(),
1600 description: "A test skill".to_string(),
1601 tools: vec!["tool_a".to_string(), "tool_b".to_string()],
1602 hint: Some("Use for testing".to_string()),
1603 suggests: vec!["other-skill".to_string()],
1604 ..Default::default()
1605 };
1606
1607 let ctx = build_skill_context(&[&skill]);
1608 assert!(ctx.contains("**test-skill**"));
1609 assert!(ctx.contains("A test skill"));
1610 assert!(ctx.contains("Use for testing"));
1611 assert!(ctx.contains("tool_a, tool_b"));
1612 assert!(ctx.contains("other-skill"));
1613 }
1614
1615 #[test]
1616 fn test_empty_directory() {
1617 let tmp = tempfile::tempdir().unwrap();
1618 let registry = SkillRegistry::load(tmp.path()).unwrap();
1619 assert_eq!(registry.skill_count(), 0);
1620 }
1621
1622 #[test]
1623 fn test_nonexistent_directory() {
1624 let registry = SkillRegistry::load(Path::new("/nonexistent/path")).unwrap();
1625 assert_eq!(registry.skill_count(), 0);
1626 }
1627}