use hashbrown::HashMap;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value as JsonValue;
use std::path::PathBuf;
pub use crate::model::{SkillErrorInfo, SkillMetadata, SkillScope};
pub type SkillManifestMetadata = HashMap<String, JsonValue>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SkillVariety {
#[default]
AgentSkill,
SystemUtility,
BuiltIn,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillNetworkPolicy {
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default)]
pub denied_domains: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SkillPermissionProfile {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_boxed_file_system_permissions_opt"
)]
pub file_system: Option<Box<SkillFileSystemPermissions>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SkillFileSystemPermissions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub read: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub write: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillManifest {
pub name: String,
pub description: String,
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "default-version")]
#[serde(alias = "default_version")]
pub default_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "latest-version")]
#[serde(alias = "latest_version")]
pub latest_version: Option<String>,
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "vtcode-native")]
#[serde(alias = "vtcode_native")]
pub vtcode_native: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "allowed-tools")]
#[serde(alias = "allowed_tools")]
pub allowed_tools: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "disable-model-invocation")]
#[serde(alias = "disable_model_invocation")]
pub disable_model_invocation: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "when-to-use")]
#[serde(alias = "when_to_use")]
pub when_to_use: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "when-not-to-use")]
#[serde(alias = "when_not_to_use")]
pub when_not_to_use: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "argument-hint")]
#[serde(alias = "argument_hint")]
pub argument_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "user-invocable")]
#[serde(alias = "user_invocable")]
pub user_invocable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "requires-container")]
#[serde(alias = "requires_container")]
pub requires_container: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "disallow-container")]
#[serde(alias = "disallow_container")]
pub disallow_container: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compatibility: Option<String>,
#[serde(default)]
pub variety: SkillVariety,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<SkillManifestMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "network")]
#[serde(alias = "network_policy")]
pub network_policy: Option<Box<SkillNetworkPolicy>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "permission_profile",
deserialize_with = "deserialize_boxed_permission_profile_opt"
)]
pub permissions: Option<Box<SkillPermissionProfile>>,
}
impl SkillPermissionProfile {
fn is_empty(&self) -> bool {
self.file_system.is_none()
}
fn into_boxed_if_non_empty(self) -> Option<Box<Self>> {
(!self.is_empty()).then_some(Box::new(self))
}
}
impl SkillFileSystemPermissions {
fn is_empty(&self) -> bool {
self.read.is_empty() && self.write.is_empty()
}
fn into_boxed_if_non_empty(self) -> Option<Box<Self>> {
(!self.is_empty()).then_some(Box::new(self))
}
}
fn deserialize_boxed_permission_profile_opt<'de, D>(
deserializer: D,
) -> Result<Option<Box<SkillPermissionProfile>>, D::Error>
where
D: Deserializer<'de>,
{
Option::<SkillPermissionProfile>::deserialize(deserializer)
.map(|value| value.and_then(SkillPermissionProfile::into_boxed_if_non_empty))
}
fn deserialize_boxed_file_system_permissions_opt<'de, D>(
deserializer: D,
) -> Result<Option<Box<SkillFileSystemPermissions>>, D::Error>
where
D: Deserializer<'de>,
{
Option::<SkillFileSystemPermissions>::deserialize(deserializer)
.map(|value| value.and_then(SkillFileSystemPermissions::into_boxed_if_non_empty))
}
impl Default for SkillManifest {
fn default() -> Self {
Self {
name: String::new(),
description: String::new(),
version: None,
default_version: None,
latest_version: None,
author: None,
license: None,
model: None,
mode: None,
vtcode_native: None,
allowed_tools: None,
disable_model_invocation: None,
when_to_use: None,
when_not_to_use: None,
argument_hint: None,
user_invocable: None,
context: None,
agent: None,
hooks: None,
requires_container: None,
disallow_container: None,
compatibility: None,
variety: SkillVariety::AgentSkill,
metadata: None,
tools: None,
network_policy: None,
permissions: None,
}
}
}
impl SkillManifest {
pub fn validate(&self) -> anyhow::Result<()> {
self.validate_name()?;
self.validate_description()?;
self.validate_optional_fields()?;
Ok(())
}
fn validate_name(&self) -> anyhow::Result<()> {
if self.name.is_empty() {
anyhow::bail!("name is required and must not be empty");
}
if self.name.len() > 64 {
anyhow::bail!("name exceeds maximum length: {} characters (max 64)", self.name.len());
}
if !self.name.chars().all(|c| c.is_lowercase() || c.is_numeric() || c == '-') {
anyhow::bail!(
"name contains invalid characters: '{}'\nMust contain only lowercase letters, numbers, and hyphens",
self.name
);
}
if self.name.contains("--") {
anyhow::bail!("name contains consecutive hyphens: '{}'\nHyphens must not appear consecutively", self.name);
}
if self.name.starts_with('-') {
anyhow::bail!("name starts with hyphen: '{}'\nMust not start with a hyphen", self.name);
}
if self.name.ends_with('-') {
anyhow::bail!("name ends with hyphen: '{}'\nMust not end with a hyphen", self.name);
}
if self.name.contains("anthropic") || self.name.contains("claude") {
anyhow::bail!("name contains reserved word: '{}'\nMust not contain 'anthropic' or 'claude'", self.name);
}
Ok(())
}
fn validate_description(&self) -> anyhow::Result<()> {
if self.description.is_empty() {
anyhow::bail!("description is required and must not be empty");
}
if self.description.len() > 1024 {
anyhow::bail!("description exceeds maximum length: {} characters (max 1024)", self.description.len());
}
Ok(())
}
fn validate_optional_fields(&self) -> anyhow::Result<()> {
if self.hooks.is_some() {
anyhow::bail!("hooks are not supported in VT Code skills");
}
if let Some(allowed_tools) = &self.allowed_tools {
let tools: Vec<&str> = allowed_tools.split_whitespace().collect();
if tools.len() > 16 {
anyhow::bail!("allowed-tools exceeds maximum tool count: {} tools (max 16)", tools.len());
}
if tools.is_empty() {
anyhow::bail!("allowed-tools must not be empty if specified");
}
}
if let Some(license) = &self.license
&& license.len() > 512
{
anyhow::bail!("license exceeds maximum length: {} characters (max 512)", license.len());
}
if let Some(compatibility) = &self.compatibility
&& (compatibility.is_empty() || compatibility.len() > 500)
{
anyhow::bail!(
"compatibility must be between 1-500 characters if provided, got {} characters",
compatibility.len()
);
}
Ok(())
}
pub fn validate_directory_name_match(&self, skill_path: &std::path::Path) -> anyhow::Result<()> {
let tool_json = skill_path.join("tool.json");
if tool_json.exists() {
return Ok(());
}
let parent_dir = skill_path
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine parent directory of: {skill_path:?}"))?;
let dir_name = parent_dir
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow::anyhow!("Cannot extract directory name from: {parent_dir:?}"))?;
if dir_name != self.name {
anyhow::bail!(
"Skill name '{}' does not match directory name '{}'\nPer Agent Skills spec, the name field must match the parent directory name",
self.name,
dir_name
);
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceType {
Markdown,
Script,
Reference,
Asset,
Other(String),
}
#[derive(Debug, Clone)]
pub struct SkillResource {
pub path: String,
pub resource_type: ResourceType,
pub content: Option<Vec<u8>>,
}
#[derive(Debug, Clone)]
pub struct Skill {
pub manifest: SkillManifest,
pub path: PathBuf,
pub scope: SkillScope,
pub instructions: String,
pub variety: SkillVariety,
pub resources: HashMap<String, SkillResource>,
}
impl Skill {
pub fn new(manifest: SkillManifest, path: PathBuf, instructions: String) -> anyhow::Result<Self> {
manifest.validate()?;
let path_str = path.to_string_lossy();
let scope = if path.starts_with(PathBuf::from("/etc/codex/skills")) {
SkillScope::Admin
} else if path_str.contains("/skills/.system/")
|| path_str.ends_with("/skills/.system")
|| path_str.contains("\\skills\\.system\\")
{
SkillScope::System
} else if path_str.contains("/.agents/skills/") || path_str.contains("\\.agents\\skills\\") {
SkillScope::Repo
} else {
SkillScope::User
};
Ok(Skill {
variety: manifest.variety,
manifest,
path,
scope,
instructions,
resources: HashMap::new(),
})
}
pub fn with_scope(
manifest: SkillManifest,
path: PathBuf,
scope: SkillScope,
instructions: String,
) -> anyhow::Result<Self> {
manifest.validate()?;
Ok(Skill {
variety: manifest.variety,
manifest,
path,
scope,
instructions,
resources: HashMap::new(),
})
}
pub fn add_resource(&mut self, path: String, resource: SkillResource) {
self.resources.insert(path, resource);
}
pub fn name(&self) -> &str {
&self.manifest.name
}
pub fn description(&self) -> &str {
&self.manifest.description
}
pub fn instruction_tokens(&self) -> usize {
self.instructions.len() / 4
}
pub fn has_resource(&self, path: &str) -> bool {
self.resources.contains_key(path)
}
pub fn get_resource(&self, path: &str) -> Option<&SkillResource> {
self.resources.get(path)
}
pub fn list_resources(&self) -> Vec<&str> {
self.resources.keys().map(|s| s.as_str()).collect()
}
}
#[derive(Debug, Clone)]
pub enum SkillContext {
MetadataOnly(SkillManifest, PathBuf),
WithInstructions(Skill),
Full(Skill),
}
impl SkillContext {
pub fn manifest(&self) -> &SkillManifest {
match self {
SkillContext::MetadataOnly(m, _) => m,
SkillContext::WithInstructions(s) => &s.manifest,
SkillContext::Full(s) => &s.manifest,
}
}
pub fn path(&self) -> &PathBuf {
match self {
SkillContext::MetadataOnly(_, p) => p,
SkillContext::WithInstructions(s) => &s.path,
SkillContext::Full(s) => &s.path,
}
}
pub fn skill(&self) -> Option<&Skill> {
match self {
SkillContext::MetadataOnly(_, _) => None,
SkillContext::WithInstructions(s) => Some(s),
SkillContext::Full(s) => Some(s),
}
}
pub fn tokens(&self) -> usize {
match self {
SkillContext::MetadataOnly(_, _) => 100,
SkillContext::WithInstructions(s) => 100 + s.instruction_tokens(),
SkillContext::Full(s) => 100 + s.instruction_tokens() + (s.resources.len() * 50),
}
}
}
#[derive(Debug, Clone)]
pub struct SkillRegistryEntry {
pub skill: Skill,
pub enabled: bool,
pub load_time: std::time::SystemTime,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_validation_valid() {
let m = SkillManifest {
name: "my-skill".to_string(),
description: "A test skill".to_string(),
..Default::default()
};
m.validate().unwrap();
}
#[test]
fn test_manifest_validation_invalid_name_length() {
let m = SkillManifest {
name: "a".repeat(65),
description: "Valid description".to_string(),
..Default::default()
};
assert!(m.validate().is_err());
}
#[test]
fn test_manifest_validation_reserved_word() {
let m = SkillManifest {
name: "anthropic-skill".to_string(),
description: "Valid description".to_string(),
..Default::default()
};
assert!(m.validate().is_err());
}
#[test]
fn test_skill_context_tokens() {
let manifest = SkillManifest {
name: "test".to_string(),
description: "Test".to_string(),
..Default::default()
};
let meta_ctx = SkillContext::MetadataOnly(manifest.clone(), PathBuf::from("/test"));
assert_eq!(meta_ctx.tokens(), 100);
}
#[test]
fn test_compatibility_validation() {
let m = SkillManifest {
name: "test-skill".to_string(),
description: "Test description".to_string(),
compatibility: Some("Designed for VT Code".to_string()),
..Default::default()
};
m.validate().unwrap();
let m = SkillManifest {
name: "test-skill".to_string(),
description: "Test description".to_string(),
compatibility: Some("".to_string()),
..Default::default()
};
assert!(m.validate().is_err());
let m = SkillManifest {
name: "test-skill".to_string(),
description: "Test description".to_string(),
compatibility: Some("a".repeat(501)),
..Default::default()
};
assert!(m.validate().is_err());
}
#[test]
fn test_allowed_tools_string_format() {
let m = SkillManifest {
name: "test-skill".to_string(),
description: "Test description".to_string(),
allowed_tools: Some("Read Write Bash".to_string()),
..Default::default()
};
m.validate().unwrap();
let m = SkillManifest {
name: "test-skill".to_string(),
description: "Test description".to_string(),
allowed_tools: Some("".to_string()),
..Default::default()
};
assert!(m.validate().is_err());
let tools = (0..17).map(|i| format!("Tool{i}")).collect::<Vec<_>>().join(" ");
let m = SkillManifest {
name: "test-skill".to_string(),
description: "Test description".to_string(),
allowed_tools: Some(tools),
..Default::default()
};
assert!(m.validate().is_err());
}
#[test]
fn test_hooks_rejected() {
let m = SkillManifest {
name: "test-skill".to_string(),
description: "Test description".to_string(),
hooks: Some(serde_json::json!({
"pre_tool_use": [
{ "command": "echo pre" }
]
})),
..Default::default()
};
assert!(m.validate().is_err());
}
#[test]
fn test_metadata_field() {
let mut metadata = HashMap::new();
metadata.insert("author".to_string(), serde_json::json!("Test Author"));
metadata.insert("version".to_string(), serde_json::json!("1.0.0"));
let m = SkillManifest {
name: "test-skill".to_string(),
description: "Test description".to_string(),
version: Some("1.0.0".to_string()),
author: Some("Test Author".to_string()),
metadata: Some(metadata),
tools: None,
..Default::default()
};
m.validate().unwrap();
}
#[test]
fn empty_permissions_deserialize_to_none() {
let manifest: SkillManifest = serde_json::from_str(
r#"{
"name": "test-skill",
"description": "Test description",
"permissions": {},
"network": {}
}"#,
)
.expect("manifest should deserialize");
assert!(manifest.permissions.is_none());
assert!(manifest.network_policy.is_some());
}
#[test]
fn empty_file_system_permissions_deserialize_to_none() {
let manifest: SkillManifest = serde_json::from_str(
r#"{
"name": "test-skill",
"description": "Test description",
"permissions": {
"file_system": {}
}
}"#,
)
.expect("manifest should deserialize");
assert!(manifest.permissions.is_none());
}
#[test]
fn boxed_skill_fields_are_smaller_than_inline_options() {
use std::mem::size_of;
assert!(size_of::<Option<Box<SkillNetworkPolicy>>>() < size_of::<Option<SkillNetworkPolicy>>());
assert!(size_of::<Option<Box<SkillPermissionProfile>>>() < size_of::<Option<SkillPermissionProfile>>());
assert!(size_of::<Option<Box<SkillFileSystemPermissions>>>() < size_of::<Option<SkillFileSystemPermissions>>());
}
}