use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SkillConfig {
#[serde(default)]
pub skill: SkillMeta,
#[serde(default)]
pub input_schema: Option<serde_json::Value>,
#[serde(default)]
pub output_schema: Option<serde_json::Value>,
#[serde(default)]
pub execution: Option<ExecutionConfig>,
#[serde(default)]
pub triggers: Vec<String>,
#[serde(default)]
pub permissions: Option<PermissionsConfig>,
#[serde(default)]
pub dependencies: Option<DependenciesConfig>,
#[serde(default)]
pub metadata: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SkillMeta {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
fn default_version() -> String {
"0.1.0".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExecutionConfig {
#[serde(default = "default_timeout")]
pub timeout: u64,
#[serde(default)]
pub work_dir: Option<String>,
#[serde(default)]
pub retries: u32,
#[serde(default)]
pub cache: bool,
}
fn default_timeout() -> u64 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PermissionsConfig {
#[serde(default)]
pub network: bool,
#[serde(default)]
pub filesystem: bool,
#[serde(default)]
pub commands: Vec<String>,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default)]
pub denied_domains: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DependenciesConfig {
#[serde(default)]
pub bins: Vec<String>,
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub python_packages: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ConfigLoadOptions {
pub load_basic: bool,
pub load_schema: bool,
pub load_execution: bool,
pub load_triggers: bool,
pub load_permissions: bool,
pub load_dependencies: bool,
pub load_metadata: bool,
}
impl ConfigLoadOptions {
pub fn for_llm() -> Self {
Self {
load_basic: true,
load_schema: true,
..Default::default()
}
}
pub fn for_execution() -> Self {
Self {
load_basic: true,
load_execution: true,
load_permissions: true,
load_dependencies: true,
..Default::default()
}
}
pub fn for_registration() -> Self {
Self {
load_basic: true,
load_triggers: true,
..Default::default()
}
}
pub fn for_search() -> Self {
Self {
load_basic: true,
load_triggers: true,
..Default::default()
}
}
pub fn full() -> Self {
Self {
load_basic: true,
load_schema: true,
load_execution: true,
load_triggers: true,
load_permissions: true,
load_dependencies: true,
load_metadata: true,
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigError {
pub field: String,
pub message: String,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.field, self.message)
}
}
impl std::error::Error for ConfigError {}
impl SkillConfig {
pub fn from_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext.to_lowercase().as_str() {
"yaml" | "yml" => Ok(serde_yaml::from_str(&content)?),
"toml" => Ok(toml::from_str(&content)?),
"json" => Ok(serde_json::from_str(&content)?),
_ => Err(format!("Unsupported config format: {ext}").into()),
}
}
pub fn from_file_with_options(
path: &Path,
options: &ConfigLoadOptions,
) -> Result<Self, Box<dyn std::error::Error>> {
let mut config = Self::from_file(path)?;
config.apply_options(options);
Ok(config)
}
pub fn apply_options(&mut self, options: &ConfigLoadOptions) {
if !options.load_schema {
self.input_schema = None;
self.output_schema = None;
}
if !options.load_execution {
self.execution = None;
}
if !options.load_triggers {
self.triggers.clear();
}
if !options.load_permissions {
self.permissions = None;
}
if !options.load_dependencies {
self.dependencies = None;
}
if !options.load_metadata {
self.metadata.clear();
}
}
pub fn from_dir(dir: &Path) -> Option<Self> {
let formats = ["skill.yaml", "skill.toml", "skill.json"];
for format in &formats {
let path = dir.join(format);
if path.exists()
&& let Ok(config) = Self::from_file(&path)
{
return Some(config);
}
}
None
}
pub fn from_dir_with_options(dir: &Path, options: &ConfigLoadOptions) -> Option<Self> {
let formats = ["skill.yaml", "skill.toml", "skill.json"];
for format in &formats {
let path = dir.join(format);
if path.exists()
&& let Ok(mut config) = Self::from_file(&path)
{
config.apply_options(options);
return Some(config);
}
}
None
}
pub fn merge(&self, other: &Self) -> Self {
Self {
skill: SkillMeta {
name: if self.skill.name.is_empty() {
other.skill.name.clone()
} else {
self.skill.name.clone()
},
description: if self.skill.description.is_empty() {
other.skill.description.clone()
} else {
self.skill.description.clone()
},
version: if self.skill.version.is_empty() || self.skill.version == "0.1.0" {
other.skill.version.clone()
} else {
self.skill.version.clone()
},
author: self
.skill
.author
.clone()
.or_else(|| other.skill.author.clone()),
tags: {
let mut tags = self.skill.tags.clone();
tags.extend(other.skill.tags.iter().cloned());
tags.sort();
tags.dedup();
tags
},
},
input_schema: self
.input_schema
.clone()
.or_else(|| other.input_schema.clone()),
output_schema: self
.output_schema
.clone()
.or_else(|| other.output_schema.clone()),
execution: self.execution.clone().or_else(|| other.execution.clone()),
triggers: {
let mut triggers = self.triggers.clone();
triggers.extend(other.triggers.iter().cloned());
triggers.sort();
triggers.dedup();
triggers
},
permissions: self
.permissions
.clone()
.or_else(|| other.permissions.clone()),
dependencies: self
.dependencies
.clone()
.or_else(|| other.dependencies.clone()),
metadata: {
let mut metadata = self.metadata.clone();
metadata.extend(other.metadata.iter().map(|(k, v)| (k.clone(), v.clone())));
metadata
},
}
}
pub fn display_name(&self) -> &str {
if self.skill.name.is_empty() {
"Unknown"
} else {
&self.skill.name
}
}
pub fn display_description(&self) -> &str {
if self.skill.description.is_empty() {
"No description provided"
} else {
&self.skill.description
}
}
pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
let mut errors = Vec::new();
if self.skill.name.is_empty() {
errors.push(ConfigError {
field: "skill.name".to_string(),
message: "is required".to_string(),
});
} else if self.skill.name.len() < 3 || self.skill.name.len() > 50 {
errors.push(ConfigError {
field: "skill.name".to_string(),
message: "must be 3-50 characters".to_string(),
});
} else if !self
.skill
.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
errors.push(ConfigError {
field: "skill.name".to_string(),
message: "must contain only lowercase letters, digits, and hyphens".to_string(),
});
}
if self.skill.description.is_empty() {
errors.push(ConfigError {
field: "skill.description".to_string(),
message: "is required".to_string(),
});
} else if self.skill.description.len() > 500 {
errors.push(ConfigError {
field: "skill.description".to_string(),
message: "must be less than 500 characters".to_string(),
});
}
if !self.skill.version.is_empty() && self.skill.version != "0.1.0" {
let parts: Vec<&str> = self.skill.version.split('.').collect();
if parts.len() != 3 || !parts.iter().all(|p| p.parse::<u32>().is_ok()) {
errors.push(ConfigError {
field: "skill.version".to_string(),
message: "must follow semantic versioning (e.g., 1.0.0)".to_string(),
});
}
}
if let Some(exec) = &self.execution {
if exec.timeout == 0 {
errors.push(ConfigError {
field: "execution.timeout".to_string(),
message: "must be greater than 0".to_string(),
});
} else if exec.timeout > 300 {
errors.push(ConfigError {
field: "execution.timeout".to_string(),
message: "must be less than 300 seconds".to_string(),
});
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn has_tag(&self, tag: &str) -> bool {
self.skill.tags.iter().any(|t| t == tag)
}
pub fn matches_trigger(&self, keyword: &str) -> bool {
self.triggers
.iter()
.any(|t| t.contains(keyword) || keyword.contains(t))
}
pub fn summary(&self) -> String {
format!(
"{} v{} - {}",
self.display_name(),
self.skill.version,
self.display_description()
)
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_minimal_config() {
let config = SkillConfig {
skill: SkillMeta {
name: "test-skill".to_string(),
description: "Test skill".to_string(),
..Default::default()
},
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_missing_name() {
let config = SkillConfig {
skill: SkillMeta {
name: "".to_string(),
description: "Test".to_string(),
..Default::default()
},
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_merge_configs() {
let config1 = SkillConfig {
skill: SkillMeta {
name: "skill1".to_string(),
description: "Description 1".to_string(),
tags: vec!["tag1".to_string()],
..Default::default()
},
triggers: vec!["trigger1".to_string()],
..Default::default()
};
let config2 = SkillConfig {
skill: SkillMeta {
name: "skill2".to_string(),
description: "Description 2".to_string(),
tags: vec!["tag2".to_string()],
..Default::default()
},
triggers: vec!["trigger2".to_string()],
..Default::default()
};
let merged = config1.merge(&config2);
assert_eq!(merged.skill.name, "skill1");
assert!(merged.skill.tags.contains(&"tag1".to_string()));
assert!(merged.skill.tags.contains(&"tag2".to_string()));
assert!(merged.triggers.contains(&"trigger1".to_string()));
assert!(merged.triggers.contains(&"trigger2".to_string()));
}
#[test]
fn test_has_tag() {
let config = SkillConfig {
skill: SkillMeta {
name: "test".to_string(),
description: "Test".to_string(),
tags: vec!["weather".to_string(), "api".to_string()],
..Default::default()
},
..Default::default()
};
assert!(config.has_tag("weather"));
assert!(config.has_tag("api"));
assert!(!config.has_tag("unknown"));
}
#[test]
fn test_matches_trigger() {
let config = SkillConfig {
skill: SkillMeta {
name: "test".to_string(),
description: "Test".to_string(),
..Default::default()
},
triggers: vec!["天气".to_string(), "天气查询".to_string()],
..Default::default()
};
assert!(config.matches_trigger("北京天气"));
assert!(config.matches_trigger("天气查询"));
assert!(!config.matches_trigger("计算"));
}
}