1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
53#[serde(rename_all = "lowercase")]
54pub enum SkillRuntime {
55 #[default]
57 Wasm,
58 Docker,
60 Native,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DockerRuntimeConfig {
67 pub image: String,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub entrypoint: Option<String>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub command: Option<Vec<String>>,
77
78 #[serde(default)]
81 pub volumes: Vec<String>,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub working_dir: Option<String>,
86
87 #[serde(default)]
89 pub environment: Vec<String>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub memory: Option<String>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub cpus: Option<String>,
98
99 #[serde(default = "default_network")]
101 pub network: String,
102
103 #[serde(default = "default_true")]
105 pub rm: bool,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub user: Option<String>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
114 pub gpus: Option<String>,
115
116 #[serde(default)]
118 pub read_only: bool,
119
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub platform: Option<String>,
123
124 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
161pub struct SkillManifest {
162 #[serde(default = "default_version")]
164 pub version: String,
165
166 #[serde(default)]
168 pub defaults: ManifestDefaults,
169
170 #[serde(default)]
172 pub skills: HashMap<String, SkillDefinition>,
173
174 #[serde(skip)]
176 pub base_dir: PathBuf,
177}
178
179fn default_version() -> String {
180 "1".to_string()
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, Default)]
185pub struct ManifestDefaults {
186 #[serde(default)]
188 pub capabilities: ManifestCapabilities,
189
190 #[serde(default)]
192 pub env: HashMap<String, String>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ServiceRequirement {
201 pub name: String,
203
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub description: Option<String>,
207
208 #[serde(default)]
211 pub optional: bool,
212
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub default_port: Option<u16>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct SkillDefinition {
221 pub source: String,
229
230 #[serde(default)]
232 pub runtime: SkillRuntime,
233
234 #[serde(rename = "ref")]
236 pub git_ref: Option<String>,
237
238 pub description: Option<String>,
240
241 #[serde(skip_serializing_if = "Option::is_none")]
243 pub docker: Option<DockerRuntimeConfig>,
244
245 #[serde(default)]
247 pub instances: HashMap<String, InstanceDefinition>,
248
249 #[serde(default = "default_instance_name")]
251 pub default_instance: String,
252
253 #[serde(default)]
255 pub services: Vec<ServiceRequirement>,
256}
257
258fn default_instance_name() -> String {
259 "default".to_string()
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, Default)]
264pub struct InstanceDefinition {
265 #[serde(default)]
267 pub config: HashMap<String, String>,
268
269 #[serde(default)]
271 pub env: HashMap<String, String>,
272
273 #[serde(default)]
275 pub capabilities: ManifestCapabilities,
276
277 pub description: Option<String>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, Default)]
283pub struct ManifestCapabilities {
284 #[serde(default)]
286 pub network_access: bool,
287
288 #[serde(default)]
290 pub allowed_paths: Vec<String>,
291
292 pub max_concurrent_requests: Option<usize>,
294}
295
296impl SkillManifest {
297 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 manifest.base_dir = path
306 .parent()
307 .map(|p| p.to_path_buf())
308 .unwrap_or_else(|| PathBuf::from("."));
309
310 if let Ok(canonical) = std::fs::canonicalize(&manifest.base_dir) {
312 manifest.base_dir = canonical;
313 }
314
315 Ok(manifest)
316 }
317
318 pub fn from_str(content: &str) -> Result<Self> {
320 toml::from_str(content).context("Failed to parse manifest TOML")
321 }
322
323 pub fn find(start_dir: &Path) -> Option<PathBuf> {
325 let mut current = start_dir.to_path_buf();
326
327 loop {
328 let manifest_path = current.join(".skill-engine.toml");
330 if manifest_path.exists() {
331 return Some(manifest_path);
332 }
333
334 let alt_path = current.join("skill-engine.toml");
336 if alt_path.exists() {
337 return Some(alt_path);
338 }
339
340 if !current.pop() {
342 break;
343 }
344 }
345
346 None
347 }
348
349 pub fn skill_names(&self) -> Vec<&str> {
351 self.skills.keys().map(|s| s.as_str()).collect()
352 }
353
354 pub fn get_skill(&self, name: &str) -> Option<&SkillDefinition> {
356 self.skills.get(name)
357 }
358
359 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 let instance_def = skill
376 .instances
377 .get(instance_name)
378 .cloned()
379 .unwrap_or_default();
380
381 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 let mut environment = HashMap::new();
395
396 for (key, value) in &self.defaults.env {
398 environment.insert(key.clone(), expand_env_vars(value)?);
399 }
400
401 for (key, value) in &instance_def.env {
403 environment.insert(key.clone(), expand_env_vars(value)?);
404 }
405
406 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 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 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 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#[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 pub runtime: SkillRuntime,
510 pub docker: Option<DockerRuntimeConfig>,
512}
513
514#[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
525pub 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(); 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 let value = if let Some(pos) = var_expr.find(":-") {
559 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 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 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
584fn 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}