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
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>
378 lines
14 KiB
Rust
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())
|
|
}
|