1use std::collections::BTreeMap;
10use std::fmt;
11
12use yaml_rust2::{Yaml, YamlLoader};
13
14use crate::markdown::Document;
15use crate::path::PackagePath;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct Skill {
21 pub directory: String,
25 pub path: PackagePath,
27 pub meta: SkillMeta,
29 pub body: Document,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct SkillMeta {
39 pub name: String,
42 pub description: String,
44 pub license: Option<String>,
46 pub compatibility: Option<String>,
48 pub allowed_tools: Option<String>,
50 pub metadata: BTreeMap<String, String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum SkillInvalid {
58 NotUtf8,
60 NoFrontmatter,
62 UnterminatedFrontmatter,
64 BadYaml {
66 detail: String,
68 },
69 NotAMapping,
71 UnknownField {
73 field: String,
75 },
76 MissingField {
78 field: &'static str,
80 },
81 WrongType {
83 field: String,
85 expected: &'static str,
87 },
88 BadName {
90 reason: String,
92 },
93 NameMismatch {
95 name: String,
97 directory: String,
99 },
100 BadDescription {
102 reason: String,
104 },
105 BadCompatibility {
107 reason: String,
109 },
110}
111
112impl fmt::Display for SkillInvalid {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 match self {
115 Self::NotUtf8 => f.write_str("SKILL.md is not UTF-8 text"),
116 Self::NoFrontmatter => f.write_str("SKILL.md must begin with YAML frontmatter (`---`)"),
117 Self::UnterminatedFrontmatter => {
118 f.write_str("SKILL.md frontmatter is not closed with `---`")
119 }
120 Self::BadYaml { detail } => write!(f, "invalid YAML in frontmatter: {detail}"),
121 Self::NotAMapping => f.write_str("frontmatter must be a YAML mapping"),
122 Self::UnknownField { field } => {
123 write!(f, "unexpected frontmatter field `{field}`")
124 }
125 Self::MissingField { field } => {
126 write!(f, "missing required frontmatter field `{field}`")
127 }
128 Self::WrongType { field, expected } => {
129 write!(f, "frontmatter field `{field}` must be {expected}")
130 }
131 Self::BadName { reason } => write!(f, "invalid skill name: {reason}"),
132 Self::NameMismatch { name, directory } => {
133 write!(f, "frontmatter name {name:?} must match directory name {directory:?}")
134 }
135 Self::BadDescription { reason } => write!(f, "invalid description: {reason}"),
136 Self::BadCompatibility { reason } => write!(f, "invalid compatibility: {reason}"),
137 }
138 }
139}
140
141impl std::error::Error for SkillInvalid {}
142
143pub fn parse_skill_md(
146 directory: &str,
147 bytes: &[u8],
148) -> Result<(SkillMeta, Document), SkillInvalid> {
149 let text = std::str::from_utf8(bytes).map_err(|_| SkillInvalid::NotUtf8)?;
150 let (frontmatter, body) = split_frontmatter(text)?;
151
152 let docs = YamlLoader::load_from_str(frontmatter)
153 .map_err(|e| SkillInvalid::BadYaml { detail: e.to_string() })?;
154 let mapping = match docs.first() {
155 Some(Yaml::Hash(mapping)) => mapping,
156 Some(_) => return Err(SkillInvalid::NotAMapping),
157 None => return Err(SkillInvalid::NotAMapping),
158 };
159
160 let mut name = None;
161 let mut description = None;
162 let mut license = None;
163 let mut compatibility = None;
164 let mut allowed_tools = None;
165 let mut metadata = BTreeMap::new();
166
167 for (key, value) in mapping {
168 let Yaml::String(key) = key else {
169 return Err(SkillInvalid::NotAMapping);
170 };
171 match key.as_str() {
172 "name" => name = Some(expect_str(key, value)?),
173 "description" => description = Some(expect_str(key, value)?),
174 "license" => license = Some(expect_str(key, value)?),
175 "compatibility" => compatibility = Some(expect_str(key, value)?),
176 "allowed-tools" => allowed_tools = Some(expect_str(key, value)?),
177 "metadata" => {
178 let Yaml::Hash(entries) = value else {
179 return Err(SkillInvalid::WrongType {
180 field: "metadata".to_owned(),
181 expected: "a mapping of strings to strings",
182 });
183 };
184 for (k, v) in entries {
185 let (Some(k), Some(v)) = (scalar_to_string(k), scalar_to_string(v)) else {
186 return Err(SkillInvalid::WrongType {
187 field: "metadata".to_owned(),
188 expected: "a mapping of strings to strings",
189 });
190 };
191 metadata.insert(k, v);
192 }
193 }
194 unknown => return Err(SkillInvalid::UnknownField { field: unknown.to_owned() }),
195 }
196 }
197
198 let name = name.ok_or(SkillInvalid::MissingField { field: "name" })?;
199 let description = description.ok_or(SkillInvalid::MissingField { field: "description" })?;
200
201 validate_name(&name)?;
202 if name != directory {
203 return Err(SkillInvalid::NameMismatch { name, directory: directory.to_owned() });
204 }
205 validate_description(&description)?;
206 if let Some(compat) = &compatibility {
207 let count = compat.chars().count();
208 if compat.trim().is_empty() {
209 return Err(SkillInvalid::BadCompatibility { reason: "empty".to_owned() });
210 }
211 if count > 500 {
212 return Err(SkillInvalid::BadCompatibility {
213 reason: format!("{count} characters; the limit is 500"),
214 });
215 }
216 }
217
218 Ok((
219 SkillMeta { name, description, license, compatibility, allowed_tools, metadata },
220 Document::parse(body),
221 ))
222}
223
224fn split_frontmatter(text: &str) -> Result<(&str, &str), SkillInvalid> {
226 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
227 let mut lines = text.split_inclusive('\n');
228 let first = lines.next().unwrap_or("");
229 if first.trim_end() != "---" {
230 return Err(SkillInvalid::NoFrontmatter);
231 }
232 let after_open = &text[first.len()..];
233 let mut offset = 0;
234 for line in after_open.split_inclusive('\n') {
235 if line.trim_end() == "---" {
236 let frontmatter = &after_open[..offset];
237 let body = &after_open[offset + line.len()..];
238 return Ok((frontmatter, body.trim_start_matches('\n')));
239 }
240 offset += line.len();
241 }
242 Err(SkillInvalid::UnterminatedFrontmatter)
243}
244
245fn expect_str(field: &str, value: &Yaml) -> Result<String, SkillInvalid> {
246 match value {
247 Yaml::String(s) => Ok(s.clone()),
248 _ => Err(SkillInvalid::WrongType { field: field.to_owned(), expected: "a string" }),
249 }
250}
251
252fn scalar_to_string(value: &Yaml) -> Option<String> {
255 match value {
256 Yaml::String(s) => Some(s.clone()),
257 Yaml::Integer(i) => Some(i.to_string()),
258 Yaml::Real(r) => Some(r.clone()),
259 Yaml::Boolean(b) => Some(b.to_string()),
260 _ => None,
261 }
262}
263
264fn validate_name(name: &str) -> Result<(), SkillInvalid> {
267 let bad = |reason: &str| SkillInvalid::BadName { reason: reason.to_owned() };
268 if name.trim().is_empty() {
269 return Err(bad("empty"));
270 }
271 let count = name.chars().count();
272 if count > 64 {
273 return Err(bad("longer than 64 characters"));
274 }
275 if name.chars().any(|c| c.is_uppercase()) {
276 return Err(bad("must be lowercase"));
277 }
278 if !name.chars().all(|c| c.is_alphanumeric() || c == '-') {
279 return Err(bad("only letters, digits, and hyphens are allowed"));
280 }
281 if name.starts_with('-') || name.ends_with('-') {
282 return Err(bad("must not start or end with a hyphen"));
283 }
284 if name.contains("--") {
285 return Err(bad("must not contain consecutive hyphens"));
286 }
287 Ok(())
288}
289
290fn validate_description(description: &str) -> Result<(), SkillInvalid> {
291 if description.trim().is_empty() {
292 return Err(SkillInvalid::BadDescription { reason: "empty".to_owned() });
293 }
294 let count = description.chars().count();
295 if count > 1024 {
296 return Err(SkillInvalid::BadDescription {
297 reason: format!("{count} characters; the limit is 1024"),
298 });
299 }
300 Ok(())
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 const VALID: &str =
308 "---\nname: deploy\ndescription: Deploys things. Use when deploying.\n---\n\nBody here.\n";
309
310 #[test]
311 fn valid_skill() {
312 let (meta, body) = parse_skill_md("deploy", VALID.as_bytes()).unwrap();
313 assert_eq!(meta.name, "deploy");
314 assert_eq!(body.source(), "Body here.\n");
315 }
316
317 #[test]
318 fn name_must_match_directory() {
319 let err = parse_skill_md("other", VALID.as_bytes()).unwrap_err();
320 assert!(matches!(err, SkillInvalid::NameMismatch { .. }));
321 }
322
323 #[test]
324 fn frontmatter_is_required() {
325 assert_eq!(parse_skill_md("x", b"# Just markdown"), Err(SkillInvalid::NoFrontmatter));
326 assert_eq!(
327 parse_skill_md("x", b"---\nname: x\n"),
328 Err(SkillInvalid::UnterminatedFrontmatter)
329 );
330 }
331
332 #[test]
333 fn unknown_fields_are_rejected() {
334 let text = "---\nname: x\ndescription: d\nversion: 1.0\n---\n";
335 assert_eq!(
336 parse_skill_md("x", text.as_bytes()),
337 Err(SkillInvalid::UnknownField { field: "version".to_owned() })
338 );
339 }
340
341 #[test]
342 fn metadata_scalars_coerce() {
343 let text = "---\nname: x\ndescription: d\nmetadata:\n version: 1.0\n pinned: true\n---\n";
344 let (meta, _) = parse_skill_md("x", text.as_bytes()).unwrap();
345 assert_eq!(meta.metadata.get("version").map(String::as_str), Some("1.0"));
346 assert_eq!(meta.metadata.get("pinned").map(String::as_str), Some("true"));
347 }
348
349 #[test]
350 fn description_length_limit() {
351 let text = format!("---\nname: x\ndescription: {}\n---\n", "d".repeat(1025));
352 assert!(matches!(
353 parse_skill_md("x", text.as_bytes()),
354 Err(SkillInvalid::BadDescription { .. })
355 ));
356 }
357
358 #[test]
359 fn triple_dash_inside_body_is_fine() {
360 let text = "---\nname: x\ndescription: d\n---\n\nA body with --- a horizontal rule.\n";
361 let (_, body) = parse_skill_md("x", text.as_bytes()).unwrap();
362 assert!(body.source().contains("horizontal rule"));
363 }
364}