Files
zclaw_openfang/crates/zclaw-skills/src/loader.rs
iven 4e8f2c7692
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix: resolve 6 remaining defects (P2-18, P2-21, P3-04, P3-05, P3-06, P3-02)
P3-03: HTML export now renders key_points in format_scene_content
P3-07: SKILL.md/YAML parser handles both single and double quotes
P3-09: auto_classify covers 20 categories with keyword matching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 12:27:02 +08:00

378 lines
14 KiB
Rust

//! 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<SkillManifest> {
// 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<SkillManifest> {
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<SkillManifest> {
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 tools: Vec<String> = Vec::new();
let mut category: Option<String> = None;
let mut in_triggers_list = false;
let mut in_tools_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(|c| c == '"' || c == '\'').to_string());
continue;
} else {
in_triggers_list = false;
}
// Handle tools list items
if in_tools_list && line.starts_with("- ") {
tools.push(line[2..].trim().trim_matches(|c| c == '"' || c == '\'').to_string());
continue;
} else {
in_tools_list = false;
}
// Parse category field
if let Some(cat) = line.strip_prefix("category:") {
category = Some(cat.trim().trim_matches(|c| c == '"' || c == '\'').to_string());
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
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(|c| c == '"' || c == '\'').to_string())
.collect();
}
}
"tools" => {
if value.is_empty() {
in_tools_list = true;
} else {
tools = value.split(',')
.map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').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 != '-', "");
// P3-09: Auto-classify if category not explicitly set
let category = category.or_else(|| auto_classify(&id, &description, &tags));
Ok(SkillManifest {
id: SkillId::new(&id),
name,
description,
version,
author: None,
mode,
capabilities,
input_schema: None,
output_schema: None,
tags,
category,
triggers,
tools,
enabled: true,
})
}
/// Parse skill.toml file
pub fn load_skill_toml(path: &Path) -> Result<SkillManifest> {
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<SkillManifest> {
// 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<String> = None;
let mut triggers = Vec::new();
let mut tools: Vec<String> = 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(|c| c == '"' || c == '\'');
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(|c| c == '"' || c == '\'').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(|c| c == '"' || c == '\'').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(|c| c == '"' || c == '\'').to_string())
.filter(|s| !s.is_empty())
.collect();
}
"category" => {
category = Some(value.to_string());
}
"tools" => {
let value = value.trim_start_matches('[').trim_end_matches(']');
tools = value.split(',')
.map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
.filter(|s| !s.is_empty())
.collect();
}
_ => {}
}
}
}
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,
tools,
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<Vec<PathBuf>> {
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)
}
/// P3-09: Auto-classify skill into a category based on ID, description, and tags.
/// Covers 20 categories via keyword matching. Returns None if no confident match.
fn auto_classify(id: &str, description: &str, tags: &[String]) -> Option<String> {
let id_lower = id.to_lowercase();
let desc_lower = description.to_lowercase();
let tags_joined = tags.join(" ").to_lowercase();
let corpus = format!("{} {} {}", id_lower, desc_lower, tags_joined);
const RULES: &[(&str, &[&str])] = &[
("coding", &["code", "program", "develop", "debug", "refactor", "typescript", "python", "rust", "javascript"]),
("writing", &["write", "essay", "article", "blog", "copy", "content", "draft", "editor"]),
("research", &["research", "search", "analyze", "investigate", "paper", "study", "survey"]),
("math", &["math", "calcul", "equation", "algebra", "geometry", "statistic", "formula"]),
("translation", &["translat", "language", "i18n", "localize", "chinese", "english", "japanese"]),
("data", &["data", "database", "sql", "csv", "excel", "spreadsheet", "table", "chart"]),
("education", &["teach", "learn", "tutor", "quiz", "course", "lesson", "classroom", "study"]),
("design", &["design", "ui", "ux", "layout", "figma", "color", "font", "style"]),
("marketing", &["market", "seo", "advertis", "brand", "campaign", "social media", "promotion"]),
("finance", &["financ", "account", "budget", "invest", "stock", "tax", "invoice", "payment"]),
("legal", &["legal", "contract", "compliance", "regulation", "law", "policy", "terms"]),
("health", &["health", "medical", "fitness", "nutrition", "mental", "wellness", "doctor"]),
("travel", &["travel", "flight", "hotel", "itinerary", "tourism", "vacation", "trip"]),
("productivity", &["productiv", "task", "todo", "schedule", "calendar", "remind", "organize", "note"]),
("communication", &["email", "chat", "message", "meet", "present", "communicate", "slack"]),
("security", &["security", "audit", "vulnerab", "encrypt", "auth", "password", "firewall"]),
("devops", &["deploy", "docker", "kubernetes", "ci/cd", "pipeline", "infra", "server", "monitor"]),
("image", &["image", "photo", "graphic", "visual", "draw", "illustrat", "canvas", "whiteboard"]),
("audio", &["audio", "speech", "voice", "music", "sound", "transcrib", "podcast"]),
("automation", &["automat", "workflow", "trigger", "schedule", "batch", "script", "pipeline"]),
];
let mut best_match: Option<(&str, usize)> = None;
for (category, keywords) in RULES {
let score = keywords.iter().filter(|kw| corpus.contains(*kw)).count();
if score > 0 {
match best_match {
Some((_, best_score)) if score <= best_score => {}
_ => best_match = Some((*category, score)),
}
}
}
best_match.map(|(cat, _)| cat.to_string())
}