use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::instance::{Capabilities, ConfigValue, InstanceConfig, InstanceMetadata};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SkillRuntime {
#[default]
Wasm,
Docker,
Native,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerRuntimeConfig {
pub image: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub entrypoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<String>>,
#[serde(default)]
pub volumes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default)]
pub environment: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cpus: Option<String>,
#[serde(default = "default_network")]
pub network: String,
#[serde(default = "default_true")]
pub rm: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gpus: Option<String>,
#[serde(default)]
pub read_only: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default)]
pub extra_args: Vec<String>,
}
fn default_network() -> String {
"none".to_string()
}
fn default_true() -> bool {
true
}
impl Default for DockerRuntimeConfig {
fn default() -> Self {
Self {
image: String::new(),
entrypoint: None,
command: None,
volumes: Vec::new(),
working_dir: None,
environment: Vec::new(),
memory: None,
cpus: None,
network: default_network(),
rm: true,
user: None,
gpus: None,
read_only: false,
platform: None,
extra_args: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SkillManifest {
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub defaults: ManifestDefaults,
#[serde(default)]
pub skills: HashMap<String, SkillDefinition>,
#[serde(skip)]
pub base_dir: PathBuf,
}
fn default_version() -> String {
"1".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ManifestDefaults {
#[serde(default)]
pub capabilities: ManifestCapabilities,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceRequirement {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub optional: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_port: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillDefinition {
pub source: String,
#[serde(default)]
pub runtime: SkillRuntime,
#[serde(rename = "ref")]
pub git_ref: Option<String>,
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub docker: Option<DockerRuntimeConfig>,
#[serde(default)]
pub instances: HashMap<String, InstanceDefinition>,
#[serde(default = "default_instance_name")]
pub default_instance: String,
#[serde(default)]
pub services: Vec<ServiceRequirement>,
}
fn default_instance_name() -> String {
"default".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InstanceDefinition {
#[serde(default)]
pub config: HashMap<String, String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub capabilities: ManifestCapabilities,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ManifestCapabilities {
#[serde(default)]
pub network_access: bool,
#[serde(default)]
pub allowed_paths: Vec<String>,
pub max_concurrent_requests: Option<usize>,
}
impl SkillManifest {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read manifest file: {}", path.display()))?;
let mut manifest = Self::from_str(&content)?;
manifest.base_dir = path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
if let Ok(canonical) = std::fs::canonicalize(&manifest.base_dir) {
manifest.base_dir = canonical;
}
Ok(manifest)
}
pub fn from_str(content: &str) -> Result<Self> {
toml::from_str(content).context("Failed to parse manifest TOML")
}
pub fn find(start_dir: &Path) -> Option<PathBuf> {
let mut current = start_dir.to_path_buf();
loop {
let manifest_path = current.join(".skill-engine.toml");
if manifest_path.exists() {
return Some(manifest_path);
}
let alt_path = current.join("skill-engine.toml");
if alt_path.exists() {
return Some(alt_path);
}
if !current.pop() {
break;
}
}
None
}
pub fn skill_names(&self) -> Vec<&str> {
self.skills.keys().map(|s| s.as_str()).collect()
}
pub fn get_skill(&self, name: &str) -> Option<&SkillDefinition> {
self.skills.get(name)
}
pub fn resolve_instance(
&self,
skill_name: &str,
instance_name: Option<&str>,
) -> Result<ResolvedInstance> {
let skill = self
.skills
.get(skill_name)
.with_context(|| format!("Skill '{}' not found in manifest", skill_name))?;
let instance_name = instance_name.unwrap_or(&skill.default_instance);
let instance_def = skill
.instances
.get(instance_name)
.cloned()
.unwrap_or_default();
let mut config = HashMap::new();
for (key, value) in &instance_def.config {
config.insert(
key.clone(),
ConfigValue {
value: expand_env_vars(value)?,
secret: is_likely_secret(key),
},
);
}
let mut environment = HashMap::new();
for (key, value) in &self.defaults.env {
environment.insert(key.clone(), expand_env_vars(value)?);
}
for (key, value) in &instance_def.env {
environment.insert(key.clone(), expand_env_vars(value)?);
}
let capabilities = Capabilities {
network_access: instance_def.capabilities.network_access
|| self.defaults.capabilities.network_access,
allowed_paths: instance_def
.capabilities
.allowed_paths
.iter()
.chain(self.defaults.capabilities.allowed_paths.iter())
.map(|p| PathBuf::from(expand_env_vars(p).unwrap_or_default()))
.collect(),
max_concurrent_requests: instance_def
.capabilities
.max_concurrent_requests
.or(self.defaults.capabilities.max_concurrent_requests)
.unwrap_or(10),
};
let resolved_source = if skill.source.starts_with("./") || skill.source.starts_with("../") {
self.base_dir.join(&skill.source).to_string_lossy().to_string()
} else {
skill.source.clone()
};
let docker_config = if let Some(ref docker) = skill.docker {
Some(DockerRuntimeConfig {
image: expand_env_vars(&docker.image)?,
entrypoint: docker.entrypoint.clone(),
command: docker.command.clone(),
volumes: docker
.volumes
.iter()
.map(|v| expand_env_vars(v))
.collect::<Result<Vec<_>>>()?,
working_dir: docker.working_dir.clone(),
environment: docker
.environment
.iter()
.map(|e| expand_env_vars(e))
.collect::<Result<Vec<_>>>()?,
memory: docker.memory.clone(),
cpus: docker.cpus.clone(),
network: docker.network.clone(),
rm: docker.rm,
user: docker.user.clone(),
gpus: docker.gpus.clone(),
read_only: docker.read_only,
platform: docker.platform.clone(),
extra_args: docker.extra_args.clone(),
})
} else {
None
};
Ok(ResolvedInstance {
skill_name: skill_name.to_string(),
instance_name: instance_name.to_string(),
source: resolved_source,
git_ref: skill.git_ref.clone(),
config: InstanceConfig {
metadata: InstanceMetadata {
skill_name: skill_name.to_string(),
skill_version: String::new(),
instance_name: instance_name.to_string(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
config,
environment,
capabilities,
},
runtime: skill.runtime.clone(),
docker: docker_config,
})
}
pub fn list_skills(&self) -> Vec<SkillInfo> {
self.skills
.iter()
.map(|(name, def)| SkillInfo {
name: name.clone(),
source: def.source.clone(),
description: def.description.clone(),
instances: def.instances.keys().cloned().collect(),
default_instance: def.default_instance.clone(),
runtime: def.runtime.clone(),
})
.collect()
}
}
#[derive(Debug, Clone)]
pub struct ResolvedInstance {
pub skill_name: String,
pub instance_name: String,
pub source: String,
pub git_ref: Option<String>,
pub config: InstanceConfig,
pub runtime: SkillRuntime,
pub docker: Option<DockerRuntimeConfig>,
}
#[derive(Debug, Clone)]
pub struct SkillInfo {
pub name: String,
pub source: String,
pub description: Option<String>,
pub instances: Vec<String>,
pub default_instance: String,
pub runtime: SkillRuntime,
}
pub fn expand_env_vars(input: &str) -> Result<String> {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' && chars.peek() == Some(&'{') {
chars.next();
let mut var_expr = String::new();
let mut depth = 1;
while let Some(c) = chars.next() {
if c == '{' {
depth += 1;
var_expr.push(c);
} else if c == '}' {
depth -= 1;
if depth == 0 {
break;
}
var_expr.push(c);
} else {
var_expr.push(c);
}
}
let value = if let Some(pos) = var_expr.find(":-") {
let var_name = &var_expr[..pos];
let default_value = &var_expr[pos + 2..];
std::env::var(var_name).unwrap_or_else(|_| default_value.to_string())
} else if let Some(pos) = var_expr.find(":?") {
let var_name = &var_expr[..pos];
let error_msg = &var_expr[pos + 2..];
std::env::var(var_name)
.with_context(|| format!("Environment variable {} not set: {}", var_name, error_msg))?
} else {
std::env::var(&var_expr)
.with_context(|| format!("Environment variable {} not set", var_expr))?
};
result.push_str(&value);
} else {
result.push(c);
}
}
Ok(result)
}
fn is_likely_secret(key: &str) -> bool {
let key_lower = key.to_lowercase();
key_lower.contains("secret")
|| key_lower.contains("password")
|| key_lower.contains("token")
|| key_lower.contains("key")
|| key_lower.contains("credential")
|| key_lower.contains("auth")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_manifest() {
let toml = r#"
version = "1"
[skills.hello]
source = "./examples/hello-skill"
[skills.aws]
source = "github:example/aws-skill@v1.0.0"
description = "AWS operations skill"
[skills.aws.instances.prod]
config.region = "us-east-1"
capabilities.network_access = true
[skills.aws.instances.dev]
config.region = "us-west-2"
"#;
let manifest = SkillManifest::from_str(toml).unwrap();
assert_eq!(manifest.skills.len(), 2);
assert!(manifest.skills.contains_key("hello"));
assert!(manifest.skills.contains_key("aws"));
let aws = &manifest.skills["aws"];
assert_eq!(aws.source, "github:example/aws-skill@v1.0.0");
assert_eq!(aws.instances.len(), 2);
}
#[test]
fn test_expand_env_vars() {
std::env::set_var("TEST_VAR", "hello");
assert_eq!(expand_env_vars("${TEST_VAR}").unwrap(), "hello");
assert_eq!(expand_env_vars("prefix_${TEST_VAR}_suffix").unwrap(), "prefix_hello_suffix");
assert_eq!(expand_env_vars("${MISSING:-default}").unwrap(), "default");
assert!(expand_env_vars("${MISSING}").is_err());
assert!(expand_env_vars("${MISSING:?custom error}").is_err());
std::env::remove_var("TEST_VAR");
}
#[test]
fn test_is_likely_secret() {
assert!(is_likely_secret("api_key"));
assert!(is_likely_secret("AWS_SECRET_ACCESS_KEY"));
assert!(is_likely_secret("password"));
assert!(is_likely_secret("auth_token"));
assert!(!is_likely_secret("region"));
assert!(!is_likely_secret("bucket_name"));
}
#[test]
fn test_parse_docker_runtime_skill() {
let toml = r#"
version = "1"
[skills.ffmpeg]
source = "docker:jrottenberg/ffmpeg:5-alpine"
runtime = "docker"
description = "FFmpeg video processing"
[skills.ffmpeg.docker]
image = "jrottenberg/ffmpeg:5-alpine"
entrypoint = "/usr/local/bin/ffmpeg"
volumes = ["/workdir:/workdir"]
working_dir = "/workdir"
memory = "512m"
cpus = "2"
network = "none"
rm = true
read_only = true
"#;
let manifest = SkillManifest::from_str(toml).unwrap();
assert!(manifest.skills.contains_key("ffmpeg"));
let ffmpeg = &manifest.skills["ffmpeg"];
assert_eq!(ffmpeg.runtime, SkillRuntime::Docker);
assert!(ffmpeg.docker.is_some());
let docker = ffmpeg.docker.as_ref().unwrap();
assert_eq!(docker.image, "jrottenberg/ffmpeg:5-alpine");
assert_eq!(docker.entrypoint, Some("/usr/local/bin/ffmpeg".to_string()));
assert_eq!(docker.memory, Some("512m".to_string()));
assert_eq!(docker.cpus, Some("2".to_string()));
assert_eq!(docker.network, "none");
assert!(docker.rm);
assert!(docker.read_only);
}
#[test]
fn test_skill_runtime_default() {
let toml = r#"
[skills.hello]
source = "./examples/hello-skill"
"#;
let manifest = SkillManifest::from_str(toml).unwrap();
let hello = &manifest.skills["hello"];
assert_eq!(hello.runtime, SkillRuntime::Wasm);
}
#[test]
fn test_native_runtime_skill() {
let toml = r#"
[skills.kubernetes]
source = "./examples/kubernetes-skill"
runtime = "native"
description = "Kubernetes management"
"#;
let manifest = SkillManifest::from_str(toml).unwrap();
let k8s = &manifest.skills["kubernetes"];
assert_eq!(k8s.runtime, SkillRuntime::Native);
}
#[test]
fn test_docker_config_defaults() {
let config = DockerRuntimeConfig::default();
assert_eq!(config.network, "none");
assert!(config.rm);
assert!(!config.read_only);
assert!(config.volumes.is_empty());
assert!(config.environment.is_empty());
}
#[test]
fn test_docker_with_env_expansion() {
std::env::set_var("TEST_WORKDIR", "/tmp/test");
std::env::set_var("TEST_IMAGE", "alpine:latest");
let toml = r#"
[skills.test]
source = "docker:${TEST_IMAGE}"
runtime = "docker"
[skills.test.docker]
image = "${TEST_IMAGE}"
volumes = ["${TEST_WORKDIR}:/workdir"]
"#;
let manifest = SkillManifest::from_str(toml).unwrap();
let resolved = manifest.resolve_instance("test", None).unwrap();
assert_eq!(resolved.runtime, SkillRuntime::Docker);
let docker = resolved.docker.as_ref().unwrap();
assert_eq!(docker.image, "alpine:latest");
assert_eq!(docker.volumes, vec!["/tmp/test:/workdir"]);
std::env::remove_var("TEST_WORKDIR");
std::env::remove_var("TEST_IMAGE");
}
#[test]
fn test_docker_with_gpu() {
let toml = r#"
[skills.ml]
source = "docker:nvidia/cuda:12.0-runtime"
runtime = "docker"
[skills.ml.docker]
image = "nvidia/cuda:12.0-runtime"
gpus = "all"
memory = "8g"
"#;
let manifest = SkillManifest::from_str(toml).unwrap();
let ml = &manifest.skills["ml"];
let docker = ml.docker.as_ref().unwrap();
assert_eq!(docker.gpus, Some("all".to_string()));
assert_eq!(docker.memory, Some("8g".to_string()));
}
#[test]
fn test_docker_extra_args() {
let toml = r#"
[skills.custom]
source = "docker:myimage"
runtime = "docker"
[skills.custom.docker]
image = "myimage:latest"
extra_args = ["--cap-add=SYS_PTRACE", "--security-opt=seccomp=unconfined"]
"#;
let manifest = SkillManifest::from_str(toml).unwrap();
let docker = manifest.skills["custom"].docker.as_ref().unwrap();
assert_eq!(docker.extra_args.len(), 2);
assert!(docker.extra_args.contains(&"--cap-add=SYS_PTRACE".to_string()));
}
}