//! Skill loader - parses SKILL.md and TOML manifests use std::path::{Path, PathBuf}; use zclaw_types::{Result, SkillId, ZclawError}; use super::{SkillManifest, SkillMode}; /// Load a skill from a directory pub fn load_skill_from_dir(dir: &Path) -> Result { // Try SKILL.md first let skill_md = dir.join("SKILL.md"); if skill_md.exists() { return load_skill_md(&skill_md); } // Try skill.toml let skill_toml = dir.join("skill.toml"); if skill_toml.exists() { return load_skill_toml(&skill_toml); } Err(ZclawError::NotFound(format!( "No SKILL.md or skill.toml found in {}", dir.display() ))) } /// Parse SKILL.md file pub fn load_skill_md(path: &Path) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| ZclawError::StorageError(format!("Failed to read SKILL.md: {}", e)))?; parse_skill_md(&content) } /// Parse SKILL.md content pub fn parse_skill_md(content: &str) -> Result { let mut name = String::new(); let mut description = String::new(); let mut version = "1.0.0".to_string(); let mut mode = SkillMode::PromptOnly; let mut capabilities = Vec::new(); let mut tags = Vec::new(); let mut triggers = Vec::new(); let mut category: Option = None; let mut in_triggers_list = false; // Parse frontmatter if present if content.starts_with("---") { if let Some(end) = content[3..].find("---") { let frontmatter = &content[3..end + 3]; for line in frontmatter.lines() { let line = line.trim(); if line.is_empty() || line == "---" { continue; } // Handle triggers list items if in_triggers_list && line.starts_with("- ") { triggers.push(line[2..].trim().trim_matches('"').to_string()); continue; } else { in_triggers_list = false; } // Parse category field if let Some(cat) = line.strip_prefix("category:") { category = Some(cat.trim().trim_matches('"').to_string()); continue; } if let Some((key, value)) = line.split_once(':') { let key = key.trim(); let value = value.trim().trim_matches('"'); match key { "name" => name = value.to_string(), "description" => description = value.to_string(), "version" => version = value.to_string(), "mode" => mode = parse_mode(value), "capabilities" => { capabilities = value.split(',') .map(|s| s.trim().to_string()) .collect(); } "tags" => { tags = value.split(',') .map(|s| s.trim().to_string()) .collect(); } "triggers" => { // Check if it's a list on next lines or inline if value.is_empty() { in_triggers_list = true; } else { triggers = value.split(',') .map(|s| s.trim().trim_matches('"').to_string()) .collect(); } } _ => {} } } } } } // If no frontmatter, try to extract from content if name.is_empty() { // Try to extract from first heading for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with("# ") { name = trimmed[2..].to_string(); break; } } } // Use filename as fallback name if name.is_empty() { name = "unnamed-skill".to_string(); } // Extract description from first paragraph if description.is_empty() { let mut in_paragraph = false; let mut desc_lines = Vec::new(); for line in content.lines() { let trimmed = line.trim(); if trimmed.is_empty() { if in_paragraph && !desc_lines.is_empty() { break; } continue; } if trimmed.starts_with('#') { continue; } if trimmed.starts_with("---") { continue; } in_paragraph = true; desc_lines.push(trimmed); } if !desc_lines.is_empty() { description = desc_lines.join(" "); if description.len() > 200 { description = description[..200].to_string(); } } } let id = name.to_lowercase() .replace(' ', "-") .replace(|c: char| !c.is_alphanumeric() && c != '-', ""); Ok(SkillManifest { id: SkillId::new(&id), name, description, version, author: None, mode, capabilities, input_schema: None, output_schema: None, tags, category, triggers, enabled: true, }) } /// Parse skill.toml file pub fn load_skill_toml(path: &Path) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| ZclawError::StorageError(format!("Failed to read skill.toml: {}", e)))?; parse_skill_toml(&content) } /// Parse skill.toml content pub fn parse_skill_toml(content: &str) -> Result { // Simple TOML parser for basic structure let mut id = String::new(); let mut name = String::new(); let mut description = String::new(); let mut version = "1.0.0".to_string(); let mut mode = "prompt_only".to_string(); let mut capabilities = Vec::new(); let mut tags = Vec::new(); let mut category: Option = None; let mut triggers = Vec::new(); for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') || line.starts_with('[') { continue; } if let Some((key, value)) = line.split_once('=') { let key = key.trim(); let value = value.trim().trim_matches('"'); match key { "id" => id = value.to_string(), "name" => name = value.to_string(), "description" => description = value.to_string(), "version" => version = value.to_string(), "mode" => mode = value.to_string(), "capabilities" => { // Simple array parsing let value = value.trim_start_matches('[').trim_end_matches(']'); capabilities = value.split(',') .map(|s| s.trim().trim_matches('"').to_string()) .filter(|s| !s.is_empty()) .collect(); } "tags" => { let value = value.trim_start_matches('[').trim_end_matches(']'); tags = value.split(',') .map(|s| s.trim().trim_matches('"').to_string()) .filter(|s| !s.is_empty()) .collect(); } "triggers" => { let value = value.trim_start_matches('[').trim_end_matches(']'); triggers = value.split(',') .map(|s| s.trim().trim_matches('"').to_string()) .filter(|s| !s.is_empty()) .collect(); } "category" => { category = Some(value.to_string()); } _ => {} } } } if name.is_empty() { return Err(ZclawError::InvalidInput("Skill name is required".into())); } let skill_id = if id.is_empty() { SkillId::new(&name.to_lowercase().replace(' ', "-")) } else { SkillId::new(&id) }; Ok(SkillManifest { id: skill_id, name, description, version, author: None, mode: parse_mode(&mode), capabilities, input_schema: None, output_schema: None, tags, category, triggers, enabled: true, }) } fn parse_mode(s: &str) -> SkillMode { match s.to_lowercase().replace('_', "-").as_str() { "prompt-only" | "promptonly" | "prompt_only" => SkillMode::PromptOnly, "python" => SkillMode::Python, "shell" => SkillMode::Shell, "wasm" => SkillMode::Wasm, "native" => SkillMode::Native, _ => SkillMode::PromptOnly, } } /// Discover skills in a directory pub fn discover_skills(dir: &Path) -> Result> { let mut skills = Vec::new(); if !dir.exists() { return Ok(skills); } for entry in std::fs::read_dir(dir) .map_err(|e| ZclawError::StorageError(format!("Failed to read directory: {}", e)))? { let entry = entry.map_err(|e| ZclawError::StorageError(e.to_string()))?; let path = entry.path(); if path.is_dir() { // Check for SKILL.md or skill.toml if path.join("SKILL.md").exists() || path.join("skill.toml").exists() { skills.push(path); } } } Ok(skills) }