Skip to main content

vtcode_skills/
types.rs

1//! Agent Skills type definitions
2//!
3//! Defines core types for Agent Skills integration, including
4//! skill metadata, manifest parsing, and resource management.
5
6use hashbrown::HashMap;
7use serde::{Deserialize, Deserializer, Serialize};
8use serde_json::Value as JsonValue;
9use std::path::PathBuf;
10
11pub use crate::model::{SkillErrorInfo, SkillMetadata, SkillScope};
12
13pub type SkillManifestMetadata = HashMap<String, JsonValue>;
14
15/// Skill variety indicating the type of skill
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum SkillVariety {
19    /// High-level instruction-based skill (Agent Skills spec)
20    #[default]
21    AgentSkill,
22    /// Low-level CLI tool bridge
23    SystemUtility,
24    /// Native VT Code functionality
25    BuiltIn,
26}
27
28impl SkillVariety {
29    /// Stable snake_case label for listings and metrics.
30    #[must_use]
31    pub fn as_str(self) -> &'static str {
32        match self {
33            SkillVariety::AgentSkill => "agent_skill",
34            SkillVariety::SystemUtility => "system_utility",
35            SkillVariety::BuiltIn => "built_in",
36        }
37    }
38}
39
40/// Network access policy for a skill
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct SkillNetworkPolicy {
43    #[serde(default)]
44    pub allowed_domains: Vec<String>,
45    #[serde(default)]
46    pub denied_domains: Vec<String>,
47}
48
49/// Additional sandbox permissions requested by a skill.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
51pub struct SkillPermissionProfile {
52    #[serde(
53        default,
54        skip_serializing_if = "Option::is_none",
55        deserialize_with = "deserialize_boxed_file_system_permissions_opt"
56    )]
57    pub file_system: Option<Box<SkillFileSystemPermissions>>,
58}
59
60/// Skill-scoped filesystem permission additions.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
62pub struct SkillFileSystemPermissions {
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub read: Vec<PathBuf>,
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub write: Vec<PathBuf>,
67}
68
69/// Skill manifest metadata from SKILL.md frontmatter
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct SkillManifest {
72    /// Unique identifier (lowercase, hyphens, max 64 chars)
73    pub name: String,
74    /// What the skill does and when to use it (max 1024 chars)
75    pub description: String,
76    /// Optional version string
77    pub version: Option<String>,
78    /// Optional default version for pinning
79    #[serde(skip_serializing_if = "Option::is_none")]
80    #[serde(rename = "default-version")]
81    #[serde(alias = "default_version")]
82    pub default_version: Option<String>,
83    /// Optional latest known version
84    #[serde(skip_serializing_if = "Option::is_none")]
85    #[serde(rename = "latest-version")]
86    #[serde(alias = "latest_version")]
87    pub latest_version: Option<String>,
88    /// Optional author name
89    pub author: Option<String>,
90    /// Optional license string for the skill
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub license: Option<String>,
93    /// Optional model preference (inherits session if unset)
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub model: Option<String>,
96    /// Marks the skill as a mode command (displayed prominently)
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub mode: Option<bool>,
99    /// Indicates if skill uses VT Code native features (not container skills)
100    #[serde(skip_serializing_if = "Option::is_none")]
101    #[serde(rename = "vtcode-native")]
102    #[serde(alias = "vtcode_native")]
103    pub vtcode_native: Option<bool>,
104    /// Space-delimited list of pre-approved tools (Agent Skills spec)
105    #[serde(skip_serializing_if = "Option::is_none")]
106    #[serde(rename = "allowed-tools")]
107    #[serde(alias = "allowed_tools")]
108    pub allowed_tools: Option<String>,
109    /// Optional guard to disable direct model invocations when skill is active
110    #[serde(skip_serializing_if = "Option::is_none")]
111    #[serde(rename = "disable-model-invocation")]
112    #[serde(alias = "disable_model_invocation")]
113    pub disable_model_invocation: Option<bool>,
114    /// Optional guidance on when to use the skill (Claude frontmatter best practice)
115    #[serde(skip_serializing_if = "Option::is_none")]
116    #[serde(rename = "when-to-use")]
117    #[serde(alias = "when_to_use")]
118    pub when_to_use: Option<String>,
119    /// Optional guidance on when NOT to use the skill
120    #[serde(skip_serializing_if = "Option::is_none")]
121    #[serde(rename = "when-not-to-use")]
122    #[serde(alias = "when_not_to_use")]
123    pub when_not_to_use: Option<String>,
124    /// Optional argument hint for slash command style usage
125    #[serde(skip_serializing_if = "Option::is_none")]
126    #[serde(rename = "argument-hint")]
127    #[serde(alias = "argument_hint")]
128    pub argument_hint: Option<String>,
129    /// Optional toggle for user menu visibility
130    #[serde(skip_serializing_if = "Option::is_none")]
131    #[serde(rename = "user-invocable")]
132    #[serde(alias = "user_invocable")]
133    pub user_invocable: Option<bool>,
134    /// Optional execution context ("fork" for isolated execution)
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub context: Option<String>,
137    /// Optional profile identifier when context is forked
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub agent: Option<String>,
140    /// Optional hooks configuration (raw payload)
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub hooks: Option<JsonValue>,
143    /// Indicates the skill explicitly requires container skills
144    #[serde(skip_serializing_if = "Option::is_none")]
145    #[serde(rename = "requires-container")]
146    #[serde(alias = "requires_container")]
147    pub requires_container: Option<bool>,
148    /// Indicates the skill should not be run inside a container (force VT Code-native path)
149    #[serde(skip_serializing_if = "Option::is_none")]
150    #[serde(rename = "disallow-container")]
151    #[serde(alias = "disallow_container")]
152    pub disallow_container: Option<bool>,
153    /// Environment/platform requirements (1-500 chars, Agent Skills spec)
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub compatibility: Option<String>,
156    /// The variety of this skill
157    #[serde(default)]
158    pub variety: SkillVariety,
159    /// Arbitrary JSON-compatible metadata (Agent Skills spec)
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub metadata: Option<SkillManifestMetadata>,
162    /// Tool dependencies for this skill (Agent Skills spec extension)
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub tools: Option<Vec<String>>,
165    /// Optional network access policy
166    #[serde(skip_serializing_if = "Option::is_none")]
167    #[serde(rename = "network")]
168    #[serde(alias = "network_policy")]
169    pub network_policy: Option<Box<SkillNetworkPolicy>>,
170    /// Optional additive sandbox permissions for command tools.
171    #[serde(
172        default,
173        skip_serializing_if = "Option::is_none",
174        alias = "permission_profile",
175        deserialize_with = "deserialize_boxed_permission_profile_opt"
176    )]
177    pub permissions: Option<Box<SkillPermissionProfile>>,
178}
179
180impl SkillPermissionProfile {
181    fn is_empty(&self) -> bool {
182        self.file_system.is_none()
183    }
184
185    fn into_boxed_if_non_empty(self) -> Option<Box<Self>> {
186        (!self.is_empty()).then_some(Box::new(self))
187    }
188}
189
190impl SkillFileSystemPermissions {
191    fn is_empty(&self) -> bool {
192        self.read.is_empty() && self.write.is_empty()
193    }
194
195    fn into_boxed_if_non_empty(self) -> Option<Box<Self>> {
196        (!self.is_empty()).then_some(Box::new(self))
197    }
198}
199
200fn deserialize_boxed_permission_profile_opt<'de, D>(
201    deserializer: D,
202) -> Result<Option<Box<SkillPermissionProfile>>, D::Error>
203where
204    D: Deserializer<'de>,
205{
206    Option::<SkillPermissionProfile>::deserialize(deserializer)
207        .map(|value| value.and_then(SkillPermissionProfile::into_boxed_if_non_empty))
208}
209
210fn deserialize_boxed_file_system_permissions_opt<'de, D>(
211    deserializer: D,
212) -> Result<Option<Box<SkillFileSystemPermissions>>, D::Error>
213where
214    D: Deserializer<'de>,
215{
216    Option::<SkillFileSystemPermissions>::deserialize(deserializer)
217        .map(|value| value.and_then(SkillFileSystemPermissions::into_boxed_if_non_empty))
218}
219
220impl Default for SkillManifest {
221    fn default() -> Self {
222        Self {
223            name: String::new(),
224            description: String::new(),
225            version: None,
226            default_version: None,
227            latest_version: None,
228            author: None,
229            license: None,
230            model: None,
231            mode: None,
232            vtcode_native: None,
233            allowed_tools: None,
234            disable_model_invocation: None,
235            when_to_use: None,
236            when_not_to_use: None,
237            argument_hint: None,
238            user_invocable: None,
239            context: None,
240            agent: None,
241            hooks: None,
242            requires_container: None,
243            disallow_container: None,
244            compatibility: None,
245            variety: SkillVariety::AgentSkill,
246            metadata: None,
247            tools: None,
248            network_policy: None,
249            permissions: None,
250        }
251    }
252}
253
254impl SkillManifest {
255    /// Validate manifest against the Agent Skills specification.
256    pub fn validate(&self) -> anyhow::Result<()> {
257        self.validate_name()?;
258        self.validate_description()?;
259        self.validate_optional_fields()?;
260        Ok(())
261    }
262
263    /// Validate name field per Agent Skills spec
264    fn validate_name(&self) -> anyhow::Result<()> {
265        if self.name.is_empty() {
266            anyhow::bail!("name is required and must not be empty");
267        }
268
269        if self.name.len() > 64 {
270            anyhow::bail!("name exceeds maximum length: {} characters (max 64)", self.name.len());
271        }
272
273        // Check for lowercase letters, numbers, and hyphens only
274        if !self.name.chars().all(|c| c.is_lowercase() || c.is_numeric() || c == '-') {
275            anyhow::bail!(
276                "name contains invalid characters: '{}'\nMust contain only lowercase letters, numbers, and hyphens",
277                self.name
278            );
279        }
280
281        // Check for consecutive hyphens
282        if self.name.contains("--") {
283            anyhow::bail!("name contains consecutive hyphens: '{}'\nHyphens must not appear consecutively", self.name);
284        }
285
286        // Check for leading hyphen
287        if self.name.starts_with('-') {
288            anyhow::bail!("name starts with hyphen: '{}'\nMust not start with a hyphen", self.name);
289        }
290
291        // Check for trailing hyphen
292        if self.name.ends_with('-') {
293            anyhow::bail!("name ends with hyphen: '{}'\nMust not end with a hyphen", self.name);
294        }
295
296        // Check for reserved words
297        if self.name.contains("anthropic") || self.name.contains("claude") {
298            anyhow::bail!("name contains reserved word: '{}'\nMust not contain 'anthropic' or 'claude'", self.name);
299        }
300
301        Ok(())
302    }
303
304    /// Validate description field per Agent Skills spec
305    fn validate_description(&self) -> anyhow::Result<()> {
306        if self.description.is_empty() {
307            anyhow::bail!("description is required and must not be empty");
308        }
309
310        if self.description.len() > 1024 {
311            anyhow::bail!("description exceeds maximum length: {} characters (max 1024)", self.description.len());
312        }
313
314        Ok(())
315    }
316
317    /// Validate optional fields per the strict SKILL.md spec supported by VT Code.
318    fn validate_optional_fields(&self) -> anyhow::Result<()> {
319        if self.hooks.is_some() {
320            anyhow::bail!("hooks are not supported in VT Code skills");
321        }
322
323        // Validate allowed-tools field
324        if let Some(allowed_tools) = &self.allowed_tools {
325            let tools: Vec<&str> = allowed_tools.split_whitespace().collect();
326
327            if tools.len() > 16 {
328                anyhow::bail!("allowed-tools exceeds maximum tool count: {} tools (max 16)", tools.len());
329            }
330
331            if tools.is_empty() {
332                anyhow::bail!("allowed-tools must not be empty if specified");
333            }
334        }
335
336        // Validate license field
337        if let Some(license) = &self.license
338            && license.len() > 512
339        {
340            anyhow::bail!("license exceeds maximum length: {} characters (max 512)", license.len());
341        }
342
343        // Validate compatibility field
344        if let Some(compatibility) = &self.compatibility
345            && (compatibility.is_empty() || compatibility.len() > 500)
346        {
347            anyhow::bail!(
348                "compatibility must be between 1-500 characters if provided, got {} characters",
349                compatibility.len()
350            );
351        }
352
353        Ok(())
354    }
355
356    /// Validate that the skill's own directory name matches the manifest name.
357    ///
358    /// Accepts either the skill directory or its `SKILL.md` path; the path is
359    /// resolved to the skill directory first so the check can never compare
360    /// against the wrong (parent) directory. This is a requirement per the
361    /// Agent Skills specification.
362    pub(crate) fn validate_directory_name_match(&self, skill_path: &std::path::Path) -> anyhow::Result<()> {
363        let skill_dir = resolve_skill_dir(skill_path);
364
365        // For CLI tools, the directory name might not match the skill name.
366        // Check if this is a CLI tool by looking for tool.json.
367        if skill_dir.join("tool.json").exists() {
368            return Ok(());
369        }
370
371        let dir_name = skill_dir
372            .file_name()
373            .and_then(|name| name.to_str())
374            .ok_or_else(|| anyhow::anyhow!("Cannot extract directory name from: {skill_dir:?}"))?;
375
376        if dir_name != self.name {
377            anyhow::bail!(
378                "Skill name '{}' does not match directory name '{}'\nPer Agent Skills spec, the name field must match the skill's directory name",
379                self.name,
380                dir_name
381            );
382        }
383
384        Ok(())
385    }
386}
387
388/// Resolve the skill's own directory from either the skill directory or its
389/// `SKILL.md` path.
390///
391/// The Agent Skills spec requires the manifest `name` to match the skill's
392/// directory, and file references resolve against that directory. A `SKILL.md`
393/// path must therefore be reduced to its parent, while a directory path is
394/// returned unchanged (reducing it would yield the wrong grandparent).
395pub(crate) fn resolve_skill_dir(skill_path: &std::path::Path) -> &std::path::Path {
396    match skill_path.file_name().and_then(|name| name.to_str()) {
397        Some("SKILL.md") => skill_path.parent().unwrap_or(skill_path),
398        _ => skill_path,
399    }
400}
401
402/// Resource types bundled with a skill
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub enum ResourceType {
405    /// Markdown documentation or additional instructions
406    Markdown,
407    /// Executable script (Python, Shell, etc.)
408    Script,
409    /// Reference data (JSON, schemas, templates, etc.)
410    Reference,
411    /// Static assets (images, data files, templates)
412    Asset,
413    /// Other resource types
414    Other(String),
415}
416
417/// A skill resource (Level 3: on-demand loading)
418#[derive(Debug, Clone)]
419pub struct SkillResource {
420    /// Relative path within skill directory
421    pub path: String,
422    /// Content type
423    pub resource_type: ResourceType,
424    /// Cached content (None until loaded)
425    pub content: Option<Vec<u8>>,
426}
427
428/// Complete skill definition with progressive disclosure levels
429#[derive(Debug, Clone)]
430pub struct Skill {
431    /// Level 1: Metadata (~100 tokens, always loaded)
432    pub manifest: SkillManifest,
433
434    /// Absolute path to skill directory
435    pub path: PathBuf,
436
437    /// Skill scope (user-level or repo-level)
438    pub scope: SkillScope,
439
440    /// Level 2: Instructions from SKILL.md body (<5K tokens, loaded when triggered)
441    pub instructions: String,
442
443    /// Skill variety
444    pub variety: SkillVariety,
445
446    /// Level 3: Bundled resources (lazy-loaded on demand)
447    resources: HashMap<String, SkillResource>,
448}
449
450impl Skill {
451    /// Create a new skill
452    pub fn new(manifest: SkillManifest, path: PathBuf, instructions: String) -> anyhow::Result<Self> {
453        manifest.validate()?;
454        let path_str = path.to_string_lossy();
455        let scope = if path.starts_with(PathBuf::from("/etc/codex/skills")) {
456            SkillScope::Admin
457        } else if path_str.contains("/skills/.system/")
458            || path_str.ends_with("/skills/.system")
459            || path_str.contains("\\skills\\.system\\")
460        {
461            SkillScope::System
462        } else if path_str.contains("/.agents/skills/") || path_str.contains("\\.agents\\skills\\") {
463            SkillScope::Repo
464        } else {
465            SkillScope::User
466        };
467        Ok(Skill {
468            variety: manifest.variety,
469            manifest,
470            path,
471            scope,
472            instructions,
473            resources: HashMap::new(),
474        })
475    }
476
477    /// Create a new skill with explicit scope
478    pub fn with_scope(
479        manifest: SkillManifest,
480        path: PathBuf,
481        scope: SkillScope,
482        instructions: String,
483    ) -> anyhow::Result<Self> {
484        manifest.validate()?;
485        Ok(Skill {
486            variety: manifest.variety,
487            manifest,
488            path,
489            scope,
490            instructions,
491            resources: HashMap::new(),
492        })
493    }
494
495    /// Add a resource to the skill
496    pub fn add_resource(&mut self, path: String, resource: SkillResource) {
497        self.resources.insert(path, resource);
498    }
499
500    /// Get skill's display name
501    pub fn name(&self) -> &str {
502        &self.manifest.name
503    }
504
505    /// Get skill's description
506    pub fn description(&self) -> &str {
507        &self.manifest.description
508    }
509
510    /// Estimate token count for instructions (approximate)
511    fn instruction_tokens(&self) -> usize {
512        // Rough estimate: ~1 token per 4 characters
513        self.instructions.len() / 4
514    }
515
516    /// Check if skill has a resource
517    pub fn has_resource(&self, path: &str) -> bool {
518        self.resources.contains_key(path)
519    }
520
521    /// Get resource by path
522    pub fn get_resource(&self, path: &str) -> Option<&SkillResource> {
523        self.resources.get(path)
524    }
525
526    /// List all available resources
527    pub fn list_resources(&self) -> Vec<&str> {
528        self.resources.keys().map(|s| s.as_str()).collect()
529    }
530}
531
532/// Progressive disclosure context for skill loading
533#[derive(Debug, Clone)]
534pub enum SkillContext {
535    /// Level 1: Only metadata (~100 tokens)
536    MetadataOnly(SkillManifest, PathBuf),
537    /// Level 2: Metadata + instructions (<5K tokens)
538    WithInstructions(Skill),
539    /// Level 3: Full skill with resources loaded
540    Full(Skill),
541}
542
543impl SkillContext {
544    /// Get manifest from any context level
545    pub fn manifest(&self) -> &SkillManifest {
546        match self {
547            SkillContext::MetadataOnly(m, _) => m,
548            SkillContext::WithInstructions(s) => &s.manifest,
549            SkillContext::Full(s) => &s.manifest,
550        }
551    }
552
553    /// Get path from any context level
554    pub fn path(&self) -> &PathBuf {
555        match self {
556            SkillContext::MetadataOnly(_, p) => p,
557            SkillContext::WithInstructions(s) => &s.path,
558            SkillContext::Full(s) => &s.path,
559        }
560    }
561
562    /// Get skill (requires Level 2+)
563    pub fn skill(&self) -> Option<&Skill> {
564        match self {
565            SkillContext::MetadataOnly(_, _) => None,
566            SkillContext::WithInstructions(s) => Some(s),
567            SkillContext::Full(s) => Some(s),
568        }
569    }
570
571    /// Estimated tokens consumed in system prompt
572    fn tokens(&self) -> usize {
573        match self {
574            SkillContext::MetadataOnly(_, _) => 100,
575            SkillContext::WithInstructions(s) => 100 + s.instruction_tokens(),
576            SkillContext::Full(s) => 100 + s.instruction_tokens() + (s.resources.len() * 50),
577        }
578    }
579}
580
581/// Skill registry entry (loaded skill + metadata)
582#[derive(Debug, Clone)]
583pub struct SkillRegistryEntry {
584    skill: Skill,
585    enabled: bool,
586    load_time: std::time::SystemTime,
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[test]
594    fn test_manifest_validation_valid() {
595        let m = SkillManifest {
596            name: "my-skill".to_string(),
597            description: "A test skill".to_string(),
598            ..Default::default()
599        };
600        m.validate().unwrap();
601    }
602
603    #[test]
604    fn test_manifest_validation_invalid_name_length() {
605        let m = SkillManifest {
606            name: "a".repeat(65),
607            description: "Valid description".to_string(),
608            ..Default::default()
609        };
610        assert!(m.validate().is_err());
611    }
612
613    #[test]
614    fn test_manifest_validation_reserved_word() {
615        let m = SkillManifest {
616            name: "anthropic-skill".to_string(),
617            description: "Valid description".to_string(),
618            ..Default::default()
619        };
620        assert!(m.validate().is_err());
621    }
622
623    #[test]
624    fn test_skill_context_tokens() {
625        let manifest = SkillManifest {
626            name: "test".to_string(),
627            description: "Test".to_string(),
628            ..Default::default()
629        };
630
631        let meta_ctx = SkillContext::MetadataOnly(manifest.clone(), PathBuf::from("/test"));
632        assert_eq!(meta_ctx.tokens(), 100);
633    }
634
635    #[test]
636    fn test_compatibility_validation() {
637        // Valid compatibility
638        let m = SkillManifest {
639            name: "test-skill".to_string(),
640            description: "Test description".to_string(),
641            compatibility: Some("Designed for VT Code".to_string()),
642            ..Default::default()
643        };
644        m.validate().unwrap();
645
646        // Invalid: empty compatibility
647        let m = SkillManifest {
648            name: "test-skill".to_string(),
649            description: "Test description".to_string(),
650            compatibility: Some("".to_string()),
651            ..Default::default()
652        };
653        assert!(m.validate().is_err());
654
655        // Invalid: too long (> 500 chars)
656        let m = SkillManifest {
657            name: "test-skill".to_string(),
658            description: "Test description".to_string(),
659            compatibility: Some("a".repeat(501)),
660            ..Default::default()
661        };
662        assert!(m.validate().is_err());
663    }
664
665    #[test]
666    fn test_allowed_tools_string_format() {
667        // Valid space-delimited string
668        let m = SkillManifest {
669            name: "test-skill".to_string(),
670            description: "Test description".to_string(),
671            allowed_tools: Some("Read Write Bash".to_string()),
672            ..Default::default()
673        };
674        m.validate().unwrap();
675
676        // Invalid: empty string
677        let m = SkillManifest {
678            name: "test-skill".to_string(),
679            description: "Test description".to_string(),
680            allowed_tools: Some("".to_string()),
681            ..Default::default()
682        };
683        assert!(m.validate().is_err());
684
685        // Invalid: too many tools (> 16)
686        let tools = (0..17).map(|i| format!("Tool{i}")).collect::<Vec<_>>().join(" ");
687        let m = SkillManifest {
688            name: "test-skill".to_string(),
689            description: "Test description".to_string(),
690            allowed_tools: Some(tools),
691            ..Default::default()
692        };
693        assert!(m.validate().is_err());
694    }
695
696    #[test]
697    fn test_hooks_rejected() {
698        let m = SkillManifest {
699            name: "test-skill".to_string(),
700            description: "Test description".to_string(),
701            hooks: Some(serde_json::json!({
702                "pre_tool_use": [
703                    { "command": "echo pre" }
704                ]
705            })),
706            ..Default::default()
707        };
708        assert!(m.validate().is_err());
709    }
710
711    #[test]
712    fn test_metadata_field() {
713        let mut metadata = HashMap::new();
714        metadata.insert("author".to_string(), serde_json::json!("Test Author"));
715        metadata.insert("version".to_string(), serde_json::json!("1.0.0"));
716
717        let m = SkillManifest {
718            name: "test-skill".to_string(),
719            description: "Test description".to_string(),
720            version: Some("1.0.0".to_string()),
721            author: Some("Test Author".to_string()),
722            metadata: Some(metadata),
723            tools: None,
724            ..Default::default()
725        };
726        m.validate().unwrap();
727    }
728
729    #[test]
730    fn empty_permissions_deserialize_to_none() {
731        let manifest: SkillManifest = serde_json::from_str(
732            r#"{
733                "name": "test-skill",
734                "description": "Test description",
735                "permissions": {},
736                "network": {}
737            }"#,
738        )
739        .expect("manifest should deserialize");
740
741        assert!(manifest.permissions.is_none());
742        assert!(manifest.network_policy.is_some());
743    }
744
745    #[test]
746    fn empty_file_system_permissions_deserialize_to_none() {
747        let manifest: SkillManifest = serde_json::from_str(
748            r#"{
749                "name": "test-skill",
750                "description": "Test description",
751                "permissions": {
752                    "file_system": {}
753                }
754            }"#,
755        )
756        .expect("manifest should deserialize");
757
758        assert!(manifest.permissions.is_none());
759    }
760
761    #[test]
762    fn boxed_skill_fields_are_smaller_than_inline_options() {
763        use std::mem::size_of;
764
765        assert!(size_of::<Option<Box<SkillNetworkPolicy>>>() < size_of::<Option<SkillNetworkPolicy>>());
766        assert!(size_of::<Option<Box<SkillPermissionProfile>>>() < size_of::<Option<SkillPermissionProfile>>());
767        assert!(size_of::<Option<Box<SkillFileSystemPermissions>>>() < size_of::<Option<SkillFileSystemPermissions>>());
768    }
769
770    #[test]
771    fn skill_variety_labels_match_serde_snake_case() {
772        assert_eq!(SkillVariety::AgentSkill.as_str(), "agent_skill");
773        assert_eq!(SkillVariety::SystemUtility.as_str(), "system_utility");
774        assert_eq!(SkillVariety::BuiltIn.as_str(), "built_in");
775    }
776}