feat(phase4): complete zclaw-skills, zclaw-hands, zclaw-channels, zclaw-protocols 模块实现
This commit is contained in:
256
crates/zclaw-skills/src/loader.rs
Normal file
256
crates/zclaw-skills/src/loader.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
//! 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();
|
||||
|
||||
// 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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
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();
|
||||
|
||||
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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user