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
28#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct SkillManifest {
60 pub name: String,
62 pub description: String,
64 pub version: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 #[serde(rename = "default-version")]
69 #[serde(alias = "default_version")]
70 pub default_version: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 #[serde(rename = "latest-version")]
74 #[serde(alias = "latest_version")]
75 pub latest_version: Option<String>,
76 pub author: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub license: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub model: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub mode: Option<bool>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 #[serde(rename = "vtcode-native")]
90 #[serde(alias = "vtcode_native")]
91 pub vtcode_native: Option<bool>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 #[serde(rename = "allowed-tools")]
95 #[serde(alias = "allowed_tools")]
96 pub allowed_tools: Option<String>,
97 #[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 #[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
114 #[serde(rename = "argument-hint")]
115 #[serde(alias = "argument_hint")]
116 pub argument_hint: Option<String>,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 #[serde(rename = "user-invocable")]
120 #[serde(alias = "user_invocable")]
121 pub user_invocable: Option<bool>,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub context: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub agent: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub hooks: Option<JsonValue>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 #[serde(rename = "requires-container")]
134 #[serde(alias = "requires_container")]
135 pub requires_container: Option<bool>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 #[serde(rename = "disallow-container")]
139 #[serde(alias = "disallow_container")]
140 pub disallow_container: Option<bool>,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub compatibility: Option<String>,
144 #[serde(default)]
146 pub variety: SkillVariety,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub metadata: Option<SkillManifestMetadata>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub tools: Option<Vec<String>>,
153 #[serde(skip_serializing_if = "Option::is_none")]
155 #[serde(rename = "network")]
156 #[serde(alias = "network_policy")]
157 pub network_policy: Option<Box<SkillNetworkPolicy>>,
158 #[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 pub fn validate(&self) -> anyhow::Result<()> {
245 self.validate_name()?;
246 self.validate_description()?;
247 self.validate_optional_fields()?;
248 Ok(())
249 }
250
251 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 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 if self.name.contains("--") {
271 anyhow::bail!("name contains consecutive hyphens: '{}'\nHyphens must not appear consecutively", self.name);
272 }
273
274 if self.name.starts_with('-') {
276 anyhow::bail!("name starts with hyphen: '{}'\nMust not start with a hyphen", self.name);
277 }
278
279 if self.name.ends_with('-') {
281 anyhow::bail!("name ends with hyphen: '{}'\nMust not end with a hyphen", self.name);
282 }
283
284 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 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 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 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 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 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 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 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
376pub(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#[derive(Debug, Clone, PartialEq, Eq)]
392pub enum ResourceType {
393 Markdown,
395 Script,
397 Reference,
399 Asset,
401 Other(String),
403}
404
405#[derive(Debug, Clone)]
407pub struct SkillResource {
408 pub path: String,
410 pub resource_type: ResourceType,
412 pub content: Option<Vec<u8>>,
414}
415
416#[derive(Debug, Clone)]
418pub struct Skill {
419 pub manifest: SkillManifest,
421
422 pub path: PathBuf,
424
425 pub scope: SkillScope,
427
428 pub instructions: String,
430
431 pub variety: SkillVariety,
433
434 resources: HashMap<String, SkillResource>,
436}
437
438impl Skill {
439 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 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 pub fn add_resource(&mut self, path: String, resource: SkillResource) {
485 self.resources.insert(path, resource);
486 }
487
488 pub fn name(&self) -> &str {
490 &self.manifest.name
491 }
492
493 pub fn description(&self) -> &str {
495 &self.manifest.description
496 }
497
498 fn instruction_tokens(&self) -> usize {
500 self.instructions.len() / 4
502 }
503
504 pub fn has_resource(&self, path: &str) -> bool {
506 self.resources.contains_key(path)
507 }
508
509 pub fn get_resource(&self, path: &str) -> Option<&SkillResource> {
511 self.resources.get(path)
512 }
513
514 pub fn list_resources(&self) -> Vec<&str> {
516 self.resources.keys().map(|s| s.as_str()).collect()
517 }
518}
519
520#[derive(Debug, Clone)]
522pub enum SkillContext {
523 MetadataOnly(SkillManifest, PathBuf),
525 WithInstructions(Skill),
527 Full(Skill),
529}
530
531impl SkillContext {
532 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 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 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 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#[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 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 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 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 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 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 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}