skill_runtime/
manifest.rs

1//! Declarative skill manifest for stateless environments.
2//!
3//! This module provides support for `.skill-engine.toml` files that declare
4//! skills and their configurations. This is useful for:
5//!
6//! - CI/CD pipelines where no persistent state exists
7//! - Checking skill configurations into version control
8//! - Sharing skill setups across teams
9//! - Reproducible environments
10//!
11//! # Example `.skill-engine.toml`
12//!
13//! ```toml
14//! # Skills configuration manifest
15//! # This file can be checked into version control
16//!
17//! [skills.hello]
18//! # Local skill from path
19//! source = "./examples/hello-skill"
20//!
21//! [skills.github-ops]
22//! # Skill from GitHub
23//! source = "github:org/skill-github@v1.0.0"
24//!
25//! [skills.aws]
26//! # Skill from git with explicit ref
27//! source = "https://github.com/example/skill-aws.git"
28//! ref = "main"
29//!
30//! # Instance configurations for aws skill
31//! [skills.aws.instances.prod]
32//! config.region = "us-east-1"
33//! config.profile = "${AWS_PROFILE}"  # Environment variable reference
34//! env.AWS_ACCESS_KEY_ID = "${AWS_ACCESS_KEY_ID}"
35//! env.AWS_SECRET_ACCESS_KEY = "${AWS_SECRET_ACCESS_KEY}"
36//! capabilities.network_access = true
37//!
38//! [skills.aws.instances.dev]
39//! config.region = "us-west-2"
40//! config.profile = "dev"
41//! capabilities.network_access = true
42//! ```
43
44use anyhow::{Context, Result};
45use serde::{Deserialize, Serialize};
46use std::collections::HashMap;
47use std::path::{Path, PathBuf};
48
49use crate::instance::{Capabilities, ConfigValue, InstanceConfig, InstanceMetadata};
50
51/// Runtime type for skill execution
52#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
53#[serde(rename_all = "lowercase")]
54pub enum SkillRuntime {
55    /// WebAssembly runtime (default)
56    #[default]
57    Wasm,
58    /// Docker container runtime
59    Docker,
60    /// Native command execution (SKILL.md-based)
61    Native,
62}
63
64/// Docker runtime configuration
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DockerRuntimeConfig {
67    /// Docker image to use (e.g., "python:3.11-slim", "jrottenberg/ffmpeg:5-alpine")
68    pub image: String,
69
70    /// Container entrypoint (overrides image default)
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub entrypoint: Option<String>,
73
74    /// Command to run (overrides image CMD)
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub command: Option<Vec<String>>,
77
78    /// Volume mounts in "host:container" format
79    /// Supports env var expansion: "${SKILL_WORKDIR}:/workdir"
80    #[serde(default)]
81    pub volumes: Vec<String>,
82
83    /// Working directory inside container
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub working_dir: Option<String>,
86
87    /// Environment variables (KEY=value format)
88    #[serde(default)]
89    pub environment: Vec<String>,
90
91    /// Memory limit (e.g., "512m", "1g", "2048m")
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub memory: Option<String>,
94
95    /// CPU limit (e.g., "0.5", "2", "1.5")
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub cpus: Option<String>,
98
99    /// Network mode: none, bridge, host (default: none for security)
100    #[serde(default = "default_network")]
101    pub network: String,
102
103    /// Remove container after execution (default: true)
104    #[serde(default = "default_true")]
105    pub rm: bool,
106
107    /// User to run as (uid:gid format, e.g., "1000:1000" or "node")
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub user: Option<String>,
110
111    /// GPU access ("all" or device IDs like "0,1")
112    /// Requires nvidia-container-runtime
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub gpus: Option<String>,
115
116    /// Read-only root filesystem (default: false)
117    #[serde(default)]
118    pub read_only: bool,
119
120    /// Platform for multi-arch images (e.g., "linux/amd64", "linux/arm64")
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub platform: Option<String>,
123
124    /// Additional docker run arguments (advanced use)
125    #[serde(default)]
126    pub extra_args: Vec<String>,
127}
128
129fn default_network() -> String {
130    "none".to_string()
131}
132
133fn default_true() -> bool {
134    true
135}
136
137impl Default for DockerRuntimeConfig {
138    fn default() -> Self {
139        Self {
140            image: String::new(),
141            entrypoint: None,
142            command: None,
143            volumes: Vec::new(),
144            working_dir: None,
145            environment: Vec::new(),
146            memory: None,
147            cpus: None,
148            network: default_network(),
149            rm: true,
150            user: None,
151            gpus: None,
152            read_only: false,
153            platform: None,
154            extra_args: Vec::new(),
155        }
156    }
157}
158
159/// Root manifest structure
160#[derive(Debug, Clone, Serialize, Deserialize, Default)]
161pub struct SkillManifest {
162    /// Manifest version (for future compatibility)
163    #[serde(default = "default_version")]
164    pub version: String,
165
166    /// Global defaults applied to all skills
167    #[serde(default)]
168    pub defaults: ManifestDefaults,
169
170    /// Skill definitions
171    #[serde(default)]
172    pub skills: HashMap<String, SkillDefinition>,
173
174    /// Base directory for resolving relative paths (set during load)
175    #[serde(skip)]
176    pub base_dir: PathBuf,
177}
178
179fn default_version() -> String {
180    "1".to_string()
181}
182
183/// Global defaults for all skills
184#[derive(Debug, Clone, Serialize, Deserialize, Default)]
185pub struct ManifestDefaults {
186    /// Default capabilities for all instances
187    #[serde(default)]
188    pub capabilities: ManifestCapabilities,
189
190    /// Default environment variables for all instances
191    #[serde(default)]
192    pub env: HashMap<String, String>,
193}
194
195/// Host service requirement for a skill
196///
197/// Skills can declare dependencies on host services (like kubectl-proxy)
198/// that must be running for the skill to function properly.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ServiceRequirement {
201    /// Service name (e.g., "kubectl-proxy")
202    pub name: String,
203
204    /// Human-readable description of what this service provides
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub description: Option<String>,
207
208    /// If true, the service enhances functionality but isn't required
209    /// If false (default), the skill won't work properly without this service
210    #[serde(default)]
211    pub optional: bool,
212
213    /// Default port the service runs on
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub default_port: Option<u16>,
216}
217
218/// Skill definition in manifest
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct SkillDefinition {
221    /// Skill source: local path, git URL, registry reference, or docker image
222    /// Examples:
223    ///   - "./path/to/skill"
224    ///   - "github:user/repo"
225    ///   - "github:user/repo@v1.0.0"
226    ///   - "https://github.com/user/repo.git"
227    ///   - "docker:python:3.11-slim" (for docker runtime)
228    pub source: String,
229
230    /// Runtime type for this skill (wasm, docker, or native)
231    #[serde(default)]
232    pub runtime: SkillRuntime,
233
234    /// Git ref (branch, tag, commit) - only for git sources
235    #[serde(rename = "ref")]
236    pub git_ref: Option<String>,
237
238    /// Description of this skill
239    pub description: Option<String>,
240
241    /// Docker runtime configuration (required when runtime = "docker")
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub docker: Option<DockerRuntimeConfig>,
244
245    /// Instance configurations for this skill
246    #[serde(default)]
247    pub instances: HashMap<String, InstanceDefinition>,
248
249    /// Default instance name (defaults to "default")
250    #[serde(default = "default_instance_name")]
251    pub default_instance: String,
252
253    /// Host services this skill requires (e.g., kubectl-proxy)
254    #[serde(default)]
255    pub services: Vec<ServiceRequirement>,
256}
257
258fn default_instance_name() -> String {
259    "default".to_string()
260}
261
262/// Instance definition within a skill
263#[derive(Debug, Clone, Serialize, Deserialize, Default)]
264pub struct InstanceDefinition {
265    /// Configuration values (supports ${ENV_VAR} syntax)
266    #[serde(default)]
267    pub config: HashMap<String, String>,
268
269    /// Environment variables (supports ${ENV_VAR} syntax)
270    #[serde(default)]
271    pub env: HashMap<String, String>,
272
273    /// Capabilities for this instance
274    #[serde(default)]
275    pub capabilities: ManifestCapabilities,
276
277    /// Description of this instance
278    pub description: Option<String>,
279}
280
281/// Capabilities in manifest format
282#[derive(Debug, Clone, Serialize, Deserialize, Default)]
283pub struct ManifestCapabilities {
284    /// Allow network access
285    #[serde(default)]
286    pub network_access: bool,
287
288    /// Allowed filesystem paths
289    #[serde(default)]
290    pub allowed_paths: Vec<String>,
291
292    /// Max concurrent requests
293    pub max_concurrent_requests: Option<usize>,
294}
295
296impl SkillManifest {
297    /// Load manifest from file
298    pub fn load(path: &Path) -> Result<Self> {
299        let content = std::fs::read_to_string(path)
300            .with_context(|| format!("Failed to read manifest file: {}", path.display()))?;
301
302        let mut manifest = Self::from_str(&content)?;
303
304        // Set base_dir to the manifest file's parent directory
305        manifest.base_dir = path
306            .parent()
307            .map(|p| p.to_path_buf())
308            .unwrap_or_else(|| PathBuf::from("."));
309
310        // Canonicalize if possible
311        if let Ok(canonical) = std::fs::canonicalize(&manifest.base_dir) {
312            manifest.base_dir = canonical;
313        }
314
315        Ok(manifest)
316    }
317
318    /// Parse manifest from string
319    pub fn from_str(content: &str) -> Result<Self> {
320        toml::from_str(content).context("Failed to parse manifest TOML")
321    }
322
323    /// Find manifest file in current or parent directories
324    pub fn find(start_dir: &Path) -> Option<PathBuf> {
325        let mut current = start_dir.to_path_buf();
326
327        loop {
328            // Check for .skill-engine.toml
329            let manifest_path = current.join(".skill-engine.toml");
330            if manifest_path.exists() {
331                return Some(manifest_path);
332            }
333
334            // Check for skill-engine.toml
335            let alt_path = current.join("skill-engine.toml");
336            if alt_path.exists() {
337                return Some(alt_path);
338            }
339
340            // Move to parent directory
341            if !current.pop() {
342                break;
343            }
344        }
345
346        None
347    }
348
349    /// Get all skill names defined in the manifest
350    pub fn skill_names(&self) -> Vec<&str> {
351        self.skills.keys().map(|s| s.as_str()).collect()
352    }
353
354    /// Get skill definition by name
355    pub fn get_skill(&self, name: &str) -> Option<&SkillDefinition> {
356        self.skills.get(name)
357    }
358
359    /// Resolve a skill's instance configuration
360    ///
361    /// This expands environment variable references and merges with defaults.
362    pub fn resolve_instance(
363        &self,
364        skill_name: &str,
365        instance_name: Option<&str>,
366    ) -> Result<ResolvedInstance> {
367        let skill = self
368            .skills
369            .get(skill_name)
370            .with_context(|| format!("Skill '{}' not found in manifest", skill_name))?;
371
372        let instance_name = instance_name.unwrap_or(&skill.default_instance);
373
374        // Get instance definition, or create empty one if using default
375        let instance_def = skill
376            .instances
377            .get(instance_name)
378            .cloned()
379            .unwrap_or_default();
380
381        // Build resolved config
382        let mut config = HashMap::new();
383        for (key, value) in &instance_def.config {
384            config.insert(
385                key.clone(),
386                ConfigValue {
387                    value: expand_env_vars(value)?,
388                    secret: is_likely_secret(key),
389                },
390            );
391        }
392
393        // Build resolved environment
394        let mut environment = HashMap::new();
395
396        // Add global defaults first
397        for (key, value) in &self.defaults.env {
398            environment.insert(key.clone(), expand_env_vars(value)?);
399        }
400
401        // Add instance-specific env vars (override defaults)
402        for (key, value) in &instance_def.env {
403            environment.insert(key.clone(), expand_env_vars(value)?);
404        }
405
406        // Build capabilities
407        let capabilities = Capabilities {
408            network_access: instance_def.capabilities.network_access
409                || self.defaults.capabilities.network_access,
410            allowed_paths: instance_def
411                .capabilities
412                .allowed_paths
413                .iter()
414                .chain(self.defaults.capabilities.allowed_paths.iter())
415                .map(|p| PathBuf::from(expand_env_vars(p).unwrap_or_default()))
416                .collect(),
417            max_concurrent_requests: instance_def
418                .capabilities
419                .max_concurrent_requests
420                .or(self.defaults.capabilities.max_concurrent_requests)
421                .unwrap_or(10),
422        };
423
424        // Resolve relative paths against base_dir
425        let resolved_source = if skill.source.starts_with("./") || skill.source.starts_with("../") {
426            self.base_dir.join(&skill.source).to_string_lossy().to_string()
427        } else {
428            skill.source.clone()
429        };
430
431        // Resolve Docker config with env var expansion
432        let docker_config = if let Some(ref docker) = skill.docker {
433            Some(DockerRuntimeConfig {
434                image: expand_env_vars(&docker.image)?,
435                entrypoint: docker.entrypoint.clone(),
436                command: docker.command.clone(),
437                volumes: docker
438                    .volumes
439                    .iter()
440                    .map(|v| expand_env_vars(v))
441                    .collect::<Result<Vec<_>>>()?,
442                working_dir: docker.working_dir.clone(),
443                environment: docker
444                    .environment
445                    .iter()
446                    .map(|e| expand_env_vars(e))
447                    .collect::<Result<Vec<_>>>()?,
448                memory: docker.memory.clone(),
449                cpus: docker.cpus.clone(),
450                network: docker.network.clone(),
451                rm: docker.rm,
452                user: docker.user.clone(),
453                gpus: docker.gpus.clone(),
454                read_only: docker.read_only,
455                platform: docker.platform.clone(),
456                extra_args: docker.extra_args.clone(),
457            })
458        } else {
459            None
460        };
461
462        Ok(ResolvedInstance {
463            skill_name: skill_name.to_string(),
464            instance_name: instance_name.to_string(),
465            source: resolved_source,
466            git_ref: skill.git_ref.clone(),
467            config: InstanceConfig {
468                metadata: InstanceMetadata {
469                    skill_name: skill_name.to_string(),
470                    skill_version: String::new(),
471                    instance_name: instance_name.to_string(),
472                    created_at: chrono::Utc::now(),
473                    updated_at: chrono::Utc::now(),
474                },
475                config,
476                environment,
477                capabilities,
478            },
479            runtime: skill.runtime.clone(),
480            docker: docker_config,
481        })
482    }
483
484    /// List all skills with their resolved sources
485    pub fn list_skills(&self) -> Vec<SkillInfo> {
486        self.skills
487            .iter()
488            .map(|(name, def)| SkillInfo {
489                name: name.clone(),
490                source: def.source.clone(),
491                description: def.description.clone(),
492                instances: def.instances.keys().cloned().collect(),
493                default_instance: def.default_instance.clone(),
494                runtime: def.runtime.clone(),
495            })
496            .collect()
497    }
498}
499
500/// Resolved instance ready for execution
501#[derive(Debug, Clone)]
502pub struct ResolvedInstance {
503    pub skill_name: String,
504    pub instance_name: String,
505    pub source: String,
506    pub git_ref: Option<String>,
507    pub config: InstanceConfig,
508    /// Runtime type (wasm, docker, or native)
509    pub runtime: SkillRuntime,
510    /// Docker configuration (when runtime = docker)
511    pub docker: Option<DockerRuntimeConfig>,
512}
513
514/// Summary info about a skill
515#[derive(Debug, Clone)]
516pub struct SkillInfo {
517    pub name: String,
518    pub source: String,
519    pub description: Option<String>,
520    pub instances: Vec<String>,
521    pub default_instance: String,
522    pub runtime: SkillRuntime,
523}
524
525/// Expand environment variable references in a string.
526///
527/// Supports formats:
528/// - `${VAR}` - Required env var, errors if not set
529/// - `${VAR:-default}` - With default value
530/// - `${VAR:?error message}` - Required with custom error
531pub fn expand_env_vars(input: &str) -> Result<String> {
532    let mut result = String::with_capacity(input.len());
533    let mut chars = input.chars().peekable();
534
535    while let Some(c) = chars.next() {
536        if c == '$' && chars.peek() == Some(&'{') {
537            chars.next(); // consume '{'
538
539            let mut var_expr = String::new();
540            let mut depth = 1;
541
542            while let Some(c) = chars.next() {
543                if c == '{' {
544                    depth += 1;
545                    var_expr.push(c);
546                } else if c == '}' {
547                    depth -= 1;
548                    if depth == 0 {
549                        break;
550                    }
551                    var_expr.push(c);
552                } else {
553                    var_expr.push(c);
554                }
555            }
556
557            // Parse the variable expression
558            let value = if let Some(pos) = var_expr.find(":-") {
559                // ${VAR:-default}
560                let var_name = &var_expr[..pos];
561                let default_value = &var_expr[pos + 2..];
562                std::env::var(var_name).unwrap_or_else(|_| default_value.to_string())
563            } else if let Some(pos) = var_expr.find(":?") {
564                // ${VAR:?error}
565                let var_name = &var_expr[..pos];
566                let error_msg = &var_expr[pos + 2..];
567                std::env::var(var_name)
568                    .with_context(|| format!("Environment variable {} not set: {}", var_name, error_msg))?
569            } else {
570                // ${VAR}
571                std::env::var(&var_expr)
572                    .with_context(|| format!("Environment variable {} not set", var_expr))?
573            };
574
575            result.push_str(&value);
576        } else {
577            result.push(c);
578        }
579    }
580
581    Ok(result)
582}
583
584/// Check if a config key is likely a secret
585fn is_likely_secret(key: &str) -> bool {
586    let key_lower = key.to_lowercase();
587    key_lower.contains("secret")
588        || key_lower.contains("password")
589        || key_lower.contains("token")
590        || key_lower.contains("key")
591        || key_lower.contains("credential")
592        || key_lower.contains("auth")
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn test_parse_manifest() {
601        let toml = r#"
602            version = "1"
603
604            [skills.hello]
605            source = "./examples/hello-skill"
606
607            [skills.aws]
608            source = "github:example/aws-skill@v1.0.0"
609            description = "AWS operations skill"
610
611            [skills.aws.instances.prod]
612            config.region = "us-east-1"
613            capabilities.network_access = true
614
615            [skills.aws.instances.dev]
616            config.region = "us-west-2"
617        "#;
618
619        let manifest = SkillManifest::from_str(toml).unwrap();
620        assert_eq!(manifest.skills.len(), 2);
621        assert!(manifest.skills.contains_key("hello"));
622        assert!(manifest.skills.contains_key("aws"));
623
624        let aws = &manifest.skills["aws"];
625        assert_eq!(aws.source, "github:example/aws-skill@v1.0.0");
626        assert_eq!(aws.instances.len(), 2);
627    }
628
629    #[test]
630    fn test_expand_env_vars() {
631        std::env::set_var("TEST_VAR", "hello");
632
633        assert_eq!(expand_env_vars("${TEST_VAR}").unwrap(), "hello");
634        assert_eq!(expand_env_vars("prefix_${TEST_VAR}_suffix").unwrap(), "prefix_hello_suffix");
635        assert_eq!(expand_env_vars("${MISSING:-default}").unwrap(), "default");
636        assert!(expand_env_vars("${MISSING}").is_err());
637        assert!(expand_env_vars("${MISSING:?custom error}").is_err());
638
639        std::env::remove_var("TEST_VAR");
640    }
641
642    #[test]
643    fn test_is_likely_secret() {
644        assert!(is_likely_secret("api_key"));
645        assert!(is_likely_secret("AWS_SECRET_ACCESS_KEY"));
646        assert!(is_likely_secret("password"));
647        assert!(is_likely_secret("auth_token"));
648        assert!(!is_likely_secret("region"));
649        assert!(!is_likely_secret("bucket_name"));
650    }
651
652    #[test]
653    fn test_parse_docker_runtime_skill() {
654        let toml = r#"
655            version = "1"
656
657            [skills.ffmpeg]
658            source = "docker:jrottenberg/ffmpeg:5-alpine"
659            runtime = "docker"
660            description = "FFmpeg video processing"
661
662            [skills.ffmpeg.docker]
663            image = "jrottenberg/ffmpeg:5-alpine"
664            entrypoint = "/usr/local/bin/ffmpeg"
665            volumes = ["/workdir:/workdir"]
666            working_dir = "/workdir"
667            memory = "512m"
668            cpus = "2"
669            network = "none"
670            rm = true
671            read_only = true
672        "#;
673
674        let manifest = SkillManifest::from_str(toml).unwrap();
675        assert!(manifest.skills.contains_key("ffmpeg"));
676
677        let ffmpeg = &manifest.skills["ffmpeg"];
678        assert_eq!(ffmpeg.runtime, SkillRuntime::Docker);
679        assert!(ffmpeg.docker.is_some());
680
681        let docker = ffmpeg.docker.as_ref().unwrap();
682        assert_eq!(docker.image, "jrottenberg/ffmpeg:5-alpine");
683        assert_eq!(docker.entrypoint, Some("/usr/local/bin/ffmpeg".to_string()));
684        assert_eq!(docker.memory, Some("512m".to_string()));
685        assert_eq!(docker.cpus, Some("2".to_string()));
686        assert_eq!(docker.network, "none");
687        assert!(docker.rm);
688        assert!(docker.read_only);
689    }
690
691    #[test]
692    fn test_skill_runtime_default() {
693        let toml = r#"
694            [skills.hello]
695            source = "./examples/hello-skill"
696        "#;
697
698        let manifest = SkillManifest::from_str(toml).unwrap();
699        let hello = &manifest.skills["hello"];
700        assert_eq!(hello.runtime, SkillRuntime::Wasm);
701    }
702
703    #[test]
704    fn test_native_runtime_skill() {
705        let toml = r#"
706            [skills.kubernetes]
707            source = "./examples/kubernetes-skill"
708            runtime = "native"
709            description = "Kubernetes management"
710        "#;
711
712        let manifest = SkillManifest::from_str(toml).unwrap();
713        let k8s = &manifest.skills["kubernetes"];
714        assert_eq!(k8s.runtime, SkillRuntime::Native);
715    }
716
717    #[test]
718    fn test_docker_config_defaults() {
719        let config = DockerRuntimeConfig::default();
720        assert_eq!(config.network, "none");
721        assert!(config.rm);
722        assert!(!config.read_only);
723        assert!(config.volumes.is_empty());
724        assert!(config.environment.is_empty());
725    }
726
727    #[test]
728    fn test_docker_with_env_expansion() {
729        std::env::set_var("TEST_WORKDIR", "/tmp/test");
730        std::env::set_var("TEST_IMAGE", "alpine:latest");
731
732        let toml = r#"
733            [skills.test]
734            source = "docker:${TEST_IMAGE}"
735            runtime = "docker"
736
737            [skills.test.docker]
738            image = "${TEST_IMAGE}"
739            volumes = ["${TEST_WORKDIR}:/workdir"]
740        "#;
741
742        let manifest = SkillManifest::from_str(toml).unwrap();
743        let resolved = manifest.resolve_instance("test", None).unwrap();
744
745        assert_eq!(resolved.runtime, SkillRuntime::Docker);
746        let docker = resolved.docker.as_ref().unwrap();
747        assert_eq!(docker.image, "alpine:latest");
748        assert_eq!(docker.volumes, vec!["/tmp/test:/workdir"]);
749
750        std::env::remove_var("TEST_WORKDIR");
751        std::env::remove_var("TEST_IMAGE");
752    }
753
754    #[test]
755    fn test_docker_with_gpu() {
756        let toml = r#"
757            [skills.ml]
758            source = "docker:nvidia/cuda:12.0-runtime"
759            runtime = "docker"
760
761            [skills.ml.docker]
762            image = "nvidia/cuda:12.0-runtime"
763            gpus = "all"
764            memory = "8g"
765        "#;
766
767        let manifest = SkillManifest::from_str(toml).unwrap();
768        let ml = &manifest.skills["ml"];
769        let docker = ml.docker.as_ref().unwrap();
770        assert_eq!(docker.gpus, Some("all".to_string()));
771        assert_eq!(docker.memory, Some("8g".to_string()));
772    }
773
774    #[test]
775    fn test_docker_extra_args() {
776        let toml = r#"
777            [skills.custom]
778            source = "docker:myimage"
779            runtime = "docker"
780
781            [skills.custom.docker]
782            image = "myimage:latest"
783            extra_args = ["--cap-add=SYS_PTRACE", "--security-opt=seccomp=unconfined"]
784        "#;
785
786        let manifest = SkillManifest::from_str(toml).unwrap();
787        let docker = manifest.skills["custom"].docker.as_ref().unwrap();
788        assert_eq!(docker.extra_args.len(), 2);
789        assert!(docker.extra_args.contains(&"--cap-add=SYS_PTRACE".to_string()));
790    }
791}