1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum SkillVariety {
19 #[default]
21 AgentSkill,
22 SystemUtility,
24 BuiltIn,
26}
27
28impl SkillVariety {
29 #[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct SkillManifest {
72 pub name: String,
74 pub description: String,
76 pub version: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 #[serde(rename = "default-version")]
81 #[serde(alias = "default_version")]
82 pub default_version: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 #[serde(rename = "latest-version")]
86 #[serde(alias = "latest_version")]
87 pub latest_version: Option<String>,
88 pub author: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub license: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub model: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub mode: Option<bool>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 #[serde(rename = "vtcode-native")]
102 #[serde(alias = "vtcode_native")]
103 pub vtcode_native: Option<bool>,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 #[serde(rename = "allowed-tools")]
107 #[serde(alias = "allowed_tools")]
108 pub allowed_tools: Option<String>,
109 #[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 #[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
126 #[serde(rename = "argument-hint")]
127 #[serde(alias = "argument_hint")]
128 pub argument_hint: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 #[serde(rename = "user-invocable")]
132 #[serde(alias = "user_invocable")]
133 pub user_invocable: Option<bool>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub context: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub agent: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub hooks: Option<JsonValue>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 #[serde(rename = "requires-container")]
146 #[serde(alias = "requires_container")]
147 pub requires_container: Option<bool>,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 #[serde(rename = "disallow-container")]
151 #[serde(alias = "disallow_container")]
152 pub disallow_container: Option<bool>,
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub compatibility: Option<String>,
156 #[serde(default)]
158 pub variety: SkillVariety,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub metadata: Option<SkillManifestMetadata>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub tools: Option<Vec<String>>,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 #[serde(rename = "network")]
168 #[serde(alias = "network_policy")]
169 pub network_policy: Option<Box<SkillNetworkPolicy>>,
170 #[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 pub fn validate(&self) -> anyhow::Result<()> {
257 self.validate_name()?;
258 self.validate_description()?;
259 self.validate_optional_fields()?;
260 Ok(())
261 }
262
263 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 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 if self.name.contains("--") {
283 anyhow::bail!("name contains consecutive hyphens: '{}'\nHyphens must not appear consecutively", self.name);
284 }
285
286 if self.name.starts_with('-') {
288 anyhow::bail!("name starts with hyphen: '{}'\nMust not start with a hyphen", self.name);
289 }
290
291 if self.name.ends_with('-') {
293 anyhow::bail!("name ends with hyphen: '{}'\nMust not end with a hyphen", self.name);
294 }
295
296 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 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 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 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 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 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 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 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
388pub(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#[derive(Debug, Clone, PartialEq, Eq)]
404pub enum ResourceType {
405 Markdown,
407 Script,
409 Reference,
411 Asset,
413 Other(String),
415}
416
417#[derive(Debug, Clone)]
419pub struct SkillResource {
420 pub path: String,
422 pub resource_type: ResourceType,
424 pub content: Option<Vec<u8>>,
426}
427
428#[derive(Debug, Clone)]
430pub struct Skill {
431 pub manifest: SkillManifest,
433
434 pub path: PathBuf,
436
437 pub scope: SkillScope,
439
440 pub instructions: String,
442
443 pub variety: SkillVariety,
445
446 resources: HashMap<String, SkillResource>,
448}
449
450impl Skill {
451 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 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 pub fn add_resource(&mut self, path: String, resource: SkillResource) {
497 self.resources.insert(path, resource);
498 }
499
500 pub fn name(&self) -> &str {
502 &self.manifest.name
503 }
504
505 pub fn description(&self) -> &str {
507 &self.manifest.description
508 }
509
510 fn instruction_tokens(&self) -> usize {
512 self.instructions.len() / 4
514 }
515
516 pub fn has_resource(&self, path: &str) -> bool {
518 self.resources.contains_key(path)
519 }
520
521 pub fn get_resource(&self, path: &str) -> Option<&SkillResource> {
523 self.resources.get(path)
524 }
525
526 pub fn list_resources(&self) -> Vec<&str> {
528 self.resources.keys().map(|s| s.as_str()).collect()
529 }
530}
531
532#[derive(Debug, Clone)]
534pub enum SkillContext {
535 MetadataOnly(SkillManifest, PathBuf),
537 WithInstructions(Skill),
539 Full(Skill),
541}
542
543impl SkillContext {
544 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 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 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 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#[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 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 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 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 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 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 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}