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
refactor: 统一Hands系统常量到单个源文件 refactor: 更新Hands中文名称和描述 fix: 修复技能市场在连接状态变化时重新加载 fix: 修复身份变更提案的错误处理逻辑 docs: 更新多个功能文档的验证状态和实现位置 docs: 更新Hands系统文档 test: 添加测试文件验证工作区路径
301 lines
9.6 KiB
Rust
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)
|
|
}
|