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