agent_first_data/
skill_validation.rs1use noyalib::{ParserConfig, from_str_with_config};
9use serde::Deserialize;
10use std::collections::BTreeMap;
11use std::fmt::{Display, Formatter};
12
13#[cfg(feature = "skill-admin")]
14pub use crate::skill_admin::*;
15
16#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
18#[serde(deny_unknown_fields)]
19pub struct SkillMetadata {
20 pub name: String,
22 pub description: String,
24 pub license: Option<String>,
26 pub compatibility: Option<String>,
28 #[serde(default)]
30 pub metadata: BTreeMap<String, String>,
31 #[serde(rename = "allowed-tools")]
33 pub allowed_tools: Option<String>,
34 #[serde(rename = "disable-model-invocation")]
36 pub disable_model_invocation: Option<bool>,
37 #[serde(rename = "user-invocable")]
39 pub user_invocable: Option<bool>,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct SkillValidationError {
45 message: String,
46}
47
48impl SkillValidationError {
49 fn new(message: impl Into<String>) -> Self {
50 Self {
51 message: message.into(),
52 }
53 }
54
55 #[must_use]
57 pub fn message(&self) -> &str {
58 &self.message
59 }
60}
61
62impl Display for SkillValidationError {
63 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
64 formatter.write_str(&self.message)
65 }
66}
67
68impl std::error::Error for SkillValidationError {}
69
70pub fn validate_skill(text: &str) -> Result<SkillMetadata, SkillValidationError> {
76 let frontmatter = extract_frontmatter(text)?;
77 let metadata = from_str_with_config::<SkillMetadata>(frontmatter, &ParserConfig::strict())
78 .map_err(|error| {
79 SkillValidationError::new(format!("invalid YAML front matter: {error}"))
80 })?;
81 validate_metadata(&metadata)?;
82 Ok(metadata)
83}
84
85pub fn validate_skill_named(
87 text: &str,
88 expected_name: &str,
89) -> Result<SkillMetadata, SkillValidationError> {
90 let metadata = validate_skill(text)?;
91 if metadata.name != expected_name {
92 return Err(SkillValidationError::new(format!(
93 "skill name {:?} does not match directory name {expected_name:?}",
94 metadata.name
95 )));
96 }
97 Ok(metadata)
98}
99
100fn extract_frontmatter(text: &str) -> Result<&str, SkillValidationError> {
101 let opening_end = text
102 .find('\n')
103 .ok_or_else(|| SkillValidationError::new("missing YAML front matter"))?;
104 if text[..opening_end].trim_end_matches('\r') != "---" {
105 return Err(SkillValidationError::new(
106 "missing opening --- YAML front matter delimiter",
107 ));
108 }
109
110 let frontmatter_start = opening_end + 1;
111 let mut line_start = frontmatter_start;
112 while line_start <= text.len() {
113 let line_end = text[line_start..]
114 .find('\n')
115 .map_or(text.len(), |offset| line_start + offset);
116 if text[line_start..line_end].trim_end_matches('\r') == "---" {
117 return Ok(&text[frontmatter_start..line_start]);
118 }
119 if line_end == text.len() {
120 break;
121 }
122 line_start = line_end + 1;
123 }
124 Err(SkillValidationError::new(
125 "missing closing --- YAML front matter delimiter",
126 ))
127}
128
129fn validate_metadata(metadata: &SkillMetadata) -> Result<(), SkillValidationError> {
130 validate_name(&metadata.name)?;
131 validate_length("description", &metadata.description, 1, 1024)?;
132 if let Some(compatibility) = metadata.compatibility.as_deref() {
133 validate_length("compatibility", compatibility, 1, 500)?;
134 }
135 Ok(())
136}
137
138fn validate_name(name: &str) -> Result<(), SkillValidationError> {
139 let length = name.chars().count();
140 if !(1..=64).contains(&length) {
141 return Err(SkillValidationError::new(
142 "name must contain between 1 and 64 characters",
143 ));
144 }
145 if !name
146 .bytes()
147 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
148 {
149 return Err(SkillValidationError::new(
150 "name may contain only lowercase ASCII letters, digits, and hyphens",
151 ));
152 }
153 if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
154 return Err(SkillValidationError::new(
155 "name must not start or end with a hyphen or contain consecutive hyphens",
156 ));
157 }
158 Ok(())
159}
160
161fn validate_length(
162 field: &str,
163 value: &str,
164 minimum: usize,
165 maximum: usize,
166) -> Result<(), SkillValidationError> {
167 let length = value.chars().count();
168 if !(minimum..=maximum).contains(&length) {
169 return Err(SkillValidationError::new(format!(
170 "{field} must contain between {minimum} and {maximum} characters"
171 )));
172 }
173 Ok(())
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 const VALID: &str = concat!(
181 "---\n",
182 "name: code-review\n",
183 "description: Reviews code. Use when checking a patch.\n",
184 "license: MIT\n",
185 "compatibility: Requires git\n",
186 "metadata:\n",
187 " author: example-org\n",
188 "allowed-tools: Bash(git:*) Read\n",
189 "disable-model-invocation: false\n",
190 "user-invocable: true\n",
191 "---\n",
192 "# Code review\n",
193 );
194
195 #[test]
196 fn validates_spec_fields() {
197 let result = validate_skill_named(VALID, "code-review");
198 assert_eq!(
199 result.map(|metadata| metadata.name),
200 Ok("code-review".to_string())
201 );
202 }
203
204 #[test]
205 fn rejects_duplicate_or_unknown_fields() {
206 let duplicate = "---\nname: first\nname: second\ndescription: valid\n---\n";
207 assert!(validate_skill(duplicate).is_err());
208
209 let unknown = "---\nname: first\ndescription: valid\nversion: 1\n---\n";
210 assert!(validate_skill(unknown).is_err());
211 }
212
213 #[test]
214 fn rejects_invalid_names_and_directory_mismatches() {
215 let invalid = "---\nname: Bad--Name\ndescription: valid\n---\n";
216 assert!(validate_skill(invalid).is_err());
217 assert!(validate_skill_named(VALID, "other-name").is_err());
218 }
219
220 #[test]
221 fn supports_crlf_frontmatter() {
222 let input = "---\r\nname: code-review\r\ndescription: valid\r\n---\r\n# Body\r\n";
223 assert!(validate_skill(input).is_ok());
224 }
225}