Files
zclaw_openfang/crates/zclaw-skills/src/loader.rs
iven aa6a9cbd84
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
feat: 新增技能编排引擎和工作流构建器组件
refactor: 统一Hands系统常量到单个源文件
refactor: 更新Hands中文名称和描述

fix: 修复技能市场在连接状态变化时重新加载
fix: 修复身份变更提案的错误处理逻辑

docs: 更新多个功能文档的验证状态和实现位置
docs: 更新Hands系统文档

test: 添加测试文件验证工作区路径
2026-03-25 08:27:25 +08:00

301 lines
9.6 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 category: Option<String> = 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<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();
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<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)
}