返回 CodeWhale
frontmatter.rs
根目录 / crates / tui / src / skills / frontmatter.rs
1 //! Shared frontmatter reader for Skills, agent profiles, and installation.
2 //! This leaf is also included by the installation acceptance harness.
3
4 use std::collections::HashMap;
5 use std::path::{Component, Path};
6
7 /// Parsed frontmatter: lowercased metadata keys and the body after the fence.
8 pub(crate) type Frontmatter<'a> = (HashMap<String, String>, &'a str);
9
10 #[derive(Clone, Copy, PartialEq, Eq)]
11 pub(crate) enum SkillValidationMode {
12 Lenient,
13 Strict,
14 }
15
16 /// Runtime accepts incomplete authoring metadata with visible warnings;
17 /// installation requires frontmatter, a path-safe name, and a description.
18 /// Neither mode grants tools, model selection, execution, or fork authority.
19 pub(crate) fn validate_skill_frontmatter(
20 metadata: Option<&HashMap<String, String>>,
21 path: Option<&Path>,
22 mode: SkillValidationMode,
23 ) -> Result<Vec<String>, String> {
24 let Some(metadata) = metadata else {
25 return match mode {
26 SkillValidationMode::Strict => {
27 Err("SKILL.md is missing the leading '---' frontmatter fence".into())
28 }
29 SkillValidationMode::Lenient => Ok(vec![
30 "missing frontmatter; using the Markdown heading as the skill name".into(),
31 "missing description".into(),
32 ]),
33 };
34 };
35 let name = metadata
36 .get("name")
37 .filter(|name| !name.trim().is_empty())
38 .ok_or_else(|| "missing required frontmatter field: name".to_string())?;
39 if mode == SkillValidationMode::Strict && !is_path_safe_skill_name(name) {
40 return Err(format!(
41 "SKILL.md `name` must be a single path-safe segment (got '{name}')"
42 ));
43 }
44 let mut warnings = Vec::new();
45 if !metadata
46 .get("description")
47 .is_some_and(|value| !value.trim().is_empty())
48 {
49 if mode == SkillValidationMode::Strict {
50 return Err("missing required frontmatter field: description".into());
51 }
52 warnings.push("missing description".into());
53 }
54 if let Some(directory) = path
55 .and_then(Path::parent)
56 .and_then(Path::file_name)
57 .and_then(|name| name.to_str())
58 && directory != name
59 {
60 warnings.push(format!(
61 "frontmatter name `{name}` differs from directory `{directory}`"
62 ));
63 }
64 let mut keys: Vec<_> = metadata.keys().collect();
65 keys.sort();
66 for key in keys {
67 if key.starts_with("metadata.") || key.starts_with("x-") || key.starts_with("description_")
68 {
69 continue;
70 }
71 let warning = match key.as_str() {
72 "name" | "description" | "invocation" | "aliases-for" | "license" | "compatibility"
73 | "metadata" | "when_to_use" | "argument-hint" => None,
74 "disable-model-invocation" | "user-invocable" => {
75 if parse_frontmatter_bool(&metadata[key]).is_none() {
76 let warning = format!("invalid boolean `{key}`; invocation fails closed");
77 if mode == SkillValidationMode::Strict {
78 return Err(warning);
79 }
80 Some(warning)
81 } else {
82 None
83 }
84 }
85 "allowed-tools" | "disallowed-tools" => Some(format!(
86 "`{key}` ignored: skills grant no tool or approval authority"
87 )),
88 "model" => Some("`model` ignored: skills do not select providers or models".into()),
89 "context" | "agent" => Some(format!(
90 "`{key}` unsupported: skills do not create a separate execution context"
91 )),
92 _ => Some(format!("unknown frontmatter key `{key}` ignored")),
93 };
94 if let Some(warning) = warning {
95 warnings.push(warning);
96 }
97 }
98 Ok(warnings)
99 }
100
101 pub(crate) fn parse_frontmatter_bool(value: &str) -> Option<bool> {
102 match value.trim().to_ascii_lowercase().as_str() {
103 "true" | "yes" | "on" | "1" => Some(true),
104 "false" | "no" | "off" | "0" => Some(false),
105 _ => None,
106 }
107 }
108
109 pub(crate) fn is_path_safe_skill_name(name: &str) -> bool {
110 if name.is_empty()
111 || name.trim() != name
112 || name.chars().any(char::is_whitespace)
113 || name.contains(['/', '\\'])
114 {
115 return false;
116 }
117 let mut components = Path::new(name).components();
118 matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
119 }
120
121 /// Split a Markdown file into its `---` frontmatter metadata and body.
122 ///
123 /// Returns `Ok(None)` when the file does not open with a `---` fence. Keys are
124 /// lowercased; values are unquoted, and YAML block scalars (`>`, `|`, with
125 /// chomping) are folded the way `SKILL.md` has always read them. This is the
126 /// one frontmatter reader: skills and Claude Code agent files both use it.
127 pub(crate) fn parse_frontmatter(
128 content: &str,
129 ) -> std::result::Result<Option<Frontmatter<'_>>, String> {
130 let content = content
131 .strip_prefix('\u{feff}')
132 .unwrap_or(content)
133 .trim_start();
134 let opening = content.split_inclusive('\n').next().unwrap_or_default();
135 if opening.trim_end() != "---" {
136 return Ok(None);
137 }
138 let rest = &content[opening.len()..];
139 let mut offset = 0;
140 let end = rest
141 .split_inclusive('\n')
142 .find_map(|line| {
143 let start = offset;
144 offset += line.len();
145 (line.trim_end() == "---").then_some(start)
146 })
147 .ok_or_else(|| "missing frontmatter closing delimiter".to_string())?;
148 let frontmatter = &rest[..end];
149 let body = &rest[end + 3..];
150
151 let mut metadata = HashMap::new();
152 let indentation = |line: &str| line.chars().take_while(|ch| ch.is_whitespace()).count();
153 let lines: Vec<&str> = frontmatter.lines().collect();
154 let mut i = 0;
155 let mut maps: Vec<(usize, String)> = Vec::new();
156 while i < lines.len() {
157 let raw = lines[i];
158 let line = raw.trim();
159 if line.is_empty() || line.starts_with('#') {
160 i += 1;
161 continue;
162 }
163 if let Some((key, value)) = line.split_once(':') {
164 let indent = indentation(raw);
165 while maps
166 .last()
167 .is_some_and(|(parent_indent, _)| *parent_indent >= indent)
168 {
169 maps.pop();
170 }
171 let key = key.trim().to_ascii_lowercase();
172 let key = maps
173 .last()
174 .map_or_else(|| key.clone(), |(_, parent)| format!("{parent}.{key}"));
175 let value = value.trim();
176 // Check for YAML block scalar indicators: > (folded), | (literal),
177 // optionally with chomping: >-, >+, |-, |+
178 let is_block_scalar = matches!(value, ">" | "|" | ">-" | ">+" | "|-" | "|+");
179 if is_block_scalar {
180 let is_folded = value.starts_with('>');
181 let chomp = if value.ends_with('-') {
182 "strip"
183 } else if value.ends_with('+') {
184 "keep"
185 } else {
186 "clip"
187 };
188 // Determine the base indentation from the key line
189 let base_indent = indentation(raw);
190 let mut block_lines: Vec<&str> = Vec::new();
191 let mut content_indent: Option<usize> = None;
192 i += 1;
193 while i < lines.len() {
194 let raw_line = lines[i];
195 if raw_line.trim().is_empty() {
196 // Empty lines are part of the block
197 block_lines.push("");
198 i += 1;
199 continue;
200 }
201 let line_indent = indentation(raw_line);
202 if line_indent > base_indent {
203 // Track content indent from the first non-empty
204 // line so we strip only that one level of
205 // leading whitespace, preserving any deeper
206 // relative indentation (YAML §8.1.2).
207 if content_indent.is_none() {
208 content_indent = Some(line_indent);
209 }
210 block_lines.push(raw_line);
211 i += 1;
212 } else {
213 break;
214 }
215 }
216 let content_indent = content_indent.unwrap_or(base_indent);
217 // Strip only the content indent from each non-empty
218 // line so nested indentation survives.
219 let block_lines: Vec<&str> = block_lines
220 .iter()
221 .map(|raw| {
222 if raw.is_empty() {
223 ""
224 } else {
225 let indent = indentation(raw);
226 let strip = std::cmp::min(indent, content_indent);
227 let byte = raw.char_indices().nth(strip).map_or(raw.len(), |(i, _)| i);
228 &raw[byte..]
229 }
230 })
231 .collect();
232 // Apply chomping to trailing empty lines before folding.
233 // Chomping operates on the raw block_lines (before join), so
234 // strip / keep / clip behave per the YAML spec.
235 let block_lines = if matches!(chomp, "strip") {
236 // strip: remove all trailing empty lines
237 let mut lines = block_lines;
238 while lines.last().is_some_and(|s| s.is_empty()) {
239 lines.pop();
240 }
241 lines
242 } else if matches!(chomp, "keep") {
243 // keep: no modification
244 block_lines
245 } else {
246 // clip: keep at most one trailing empty line
247 let mut lines = block_lines;
248 while lines.len() >= 2
249 && lines[lines.len() - 1].is_empty()
250 && lines[lines.len() - 2].is_empty()
251 {
252 lines.pop();
253 }
254 lines
255 };
256 let description = if is_folded {
257 // Folded: join non-empty lines with spaces; empty
258 // lines become paragraph breaks.
259 let mut result = String::new();
260 let mut pending_space = false;
261 for line in &block_lines {
262 if line.is_empty() {
263 result.push('\n');
264 pending_space = false;
265 } else {
266 if pending_space {
267 result.push(' ');
268 }
269 result.push_str(line);
270 pending_space = true;
271 }
272 }
273 result
274 } else {
275 // Literal: join with newlines.
276 block_lines.join("\n")
277 };
278 metadata.insert(key, description);
279 } else if value.is_empty()
280 && lines
281 .get(i + 1)
282 .is_some_and(|next| is_block_sequence_item(next))
283 {
284 // A block sequence (`tools:` then ` - Read` lines) becomes
285 // one comma-separated value, the same as the flow form
286 // `tools: Read, Grep`. Dropping it would read as "no list".
287 let mut items = Vec::new();
288 i += 1;
289 while let Some(next) = lines.get(i).filter(|next| is_block_sequence_item(next)) {
290 let item = next.trim()[1..].trim();
291 let item = item
292 .strip_prefix('"')
293 .and_then(|v| v.strip_suffix('"'))
294 .or_else(|| item.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
295 .unwrap_or(item);
296 if !item.is_empty() {
297 items.push(item);
298 }
299 i += 1;
300 }
301 metadata.insert(key, items.join(", "));
302 } else if value.is_empty() {
303 // Child fields retain their map path. In particular,
304 // metadata.name must never replace the skill's own name.
305 metadata.insert(key.clone(), String::new());
306 maps.push((indent, key));
307 i += 1;
308 } else if value.starts_with('[') {
309 // Reuse the installed YAML reader for quoted flow items rather
310 // than splitting commas inside quoted tool names or aliases.
311 let documents = yaml_rust2::YamlLoader::load_from_str(value)
312 .map_err(|err| format!("invalid frontmatter sequence `{key}`: {err}"))?;
313 let items = documents
314 .first()
315 .and_then(yaml_rust2::Yaml::as_vec)
316 .ok_or_else(|| format!("frontmatter `{key}` must be a flow sequence"))?;
317 let values: Result<Vec<_>, _> = items
318 .iter()
319 .map(|item| {
320 item.as_str().ok_or_else(|| {
321 format!("frontmatter `{key}` sequence items must be strings")
322 })
323 })
324 .collect();
325 metadata.insert(key, values?.join(", "));
326 i += 1;
327 } else {
328 let unquoted = match value {
329 v if (v.starts_with('"') && v.ends_with('"') && v.len() >= 2)
330 || (v.starts_with('\'') && v.ends_with('\'') && v.len() >= 2) =>
331 {
332 &v[1..v.len() - 1]
333 }
334 _ => value,
335 };
336 i += 1;
337 let mut text = unquoted.to_string();
338 // Wrapped plain scalars continue at a deeper indentation.
339 // A colon in that continuation belongs to the value, not a
340 // new metadata key. Quoted/flow values retain their grammar.
341 if !value.is_empty() && !value.starts_with(['"', '\'', '[', '{']) {
342 while let Some(next) = lines.get(i) {
343 if next.trim().is_empty() || indentation(next) <= indentation(raw) {
344 break;
345 }
346 if !next.trim_start().starts_with('#') {
347 text.push(' ');
348 text.push_str(next.trim());
349 }
350 i += 1;
351 }
352 }
353 metadata.insert(key, text);
354 }
355 } else {
356 i += 1;
357 }
358 }
359
360 Ok(Some((metadata, body)))
361 }
362
363 /// A YAML block-sequence entry: `- item` (or a bare `-`) on its own line.
364 fn is_block_sequence_item(line: &str) -> bool {
365 let line = line.trim();
366 line == "-" || line.starts_with("- ")
367 }
368
369 #[cfg(test)]
370 mod tests {
371 use super::*;
372
373 #[test]
374 fn nested_metadata_does_not_override_name_or_description() {
375 let (metadata, _) = parse_frontmatter("---\nname: outer\ndescription: real routing\nmetadata:\n name: impostor\n description: hidden\n nested:\n name: deeper\nlicense: MIT\n---\nbody").unwrap().unwrap();
376 assert_eq!(metadata["name"], "outer");
377 assert_eq!(metadata["description"], "real routing");
378 assert_eq!(metadata["metadata.name"], "impostor");
379 assert_eq!(metadata["metadata.nested.name"], "deeper");
380 assert_eq!(metadata["license"], "MIT");
381 }
382
383 #[test]
384 fn flow_list_parsed() {
385 let (metadata, _) = parse_frontmatter("---\nname: demo\nallowed-tools: [Read, 'Bash(ls *)', \"Grep, Glob\"]\naliases-for: [other, another]\n---\nbody").unwrap().unwrap();
386 assert_eq!(metadata["allowed-tools"], "Read, Bash(ls *), Grep, Glob");
387 assert_eq!(metadata["aliases-for"], "other, another");
388 assert!(parse_frontmatter("---\nname: demo\nallowed-tools: [Read\n---\nbody").is_err());
389 }
390
391 #[test]
392 fn metadata_short_description_captured() {
393 // Actual Codex sample frontmatter, retaining its nested metadata map.
394 let (metadata, _) = parse_frontmatter(include_str!(
395 "../../tests/fixtures/skills/codex-skill-creator.md"
396 ))
397 .unwrap()
398 .unwrap();
399 assert_eq!(
400 metadata["metadata.short-description"],
401 "Create or update a skill"
402 );
403 assert!(!metadata.contains_key("short-description"));
404 assert!(
405 validate_skill_frontmatter(Some(&metadata), None, SkillValidationMode::Strict)
406 .unwrap()
407 .is_empty()
408 );
409 }
410
411 #[test]
412 fn real_claude_fixture_preserves_block_sequence_and_authority_warning() {
413 let (metadata, _) =
414 parse_frontmatter(include_str!("../../tests/fixtures/skills/claude-access.md"))
415 .unwrap()
416 .unwrap();
417 assert_eq!(metadata["name"], "access");
418 assert_eq!(
419 metadata["allowed-tools"],
420 "Read, Write, Bash(ls *), Bash(mkdir *)"
421 );
422 let warnings =
423 validate_skill_frontmatter(Some(&metadata), None, SkillValidationMode::Strict).unwrap();
424 assert_eq!(warnings.len(), 1);
425 assert!(warnings[0].contains("grant no tool or approval authority"));
426 }
427
428 #[test]
429 fn runtime_and_install_validation_agree() {
430 let (metadata, _) = parse_frontmatter("---\nname: valid\ndescription: routing\n---\nbody")
431 .unwrap()
432 .unwrap();
433 assert_eq!(
434 validate_skill_frontmatter(Some(&metadata), None, SkillValidationMode::Lenient),
435 validate_skill_frontmatter(Some(&metadata), None, SkillValidationMode::Strict)
436 );
437 let mut missing = metadata.clone();
438 missing.remove("description");
439 assert_eq!(
440 validate_skill_frontmatter(Some(&missing), None, SkillValidationMode::Lenient).unwrap(),
441 vec!["missing description"]
442 );
443 assert!(
444 validate_skill_frontmatter(Some(&missing), None, SkillValidationMode::Strict)
445 .unwrap_err()
446 .contains("description")
447 );
448 missing.remove("name");
449 assert!(
450 validate_skill_frontmatter(Some(&missing), None, SkillValidationMode::Lenient).is_err()
451 );
452 let mut unsafe_name = metadata;
453 unsafe_name.insert("name".into(), "../escape".into());
454 assert!(
455 validate_skill_frontmatter(Some(&unsafe_name), None, SkillValidationMode::Strict)
456 .is_err()
457 );
458 }
459
460 #[test]
461 fn unknown_keys_warn_once_spec_keys_silent() {
462 let (metadata, _) = parse_frontmatter("---\nname: demo\ndescription: routing\nlicense: MIT\ncompatibility: Codewhale\nmetadata:\n vendor-field: fine\nx-custom: fine\nallowed-tools: [Read]\nmodel: example\ncontext: fork\nmystery: ignored\n---\nbody").unwrap().unwrap();
463 let warnings =
464 validate_skill_frontmatter(Some(&metadata), None, SkillValidationMode::Lenient)
465 .unwrap();
466 assert_eq!(warnings.len(), 4, "{warnings:?}");
467 for key in ["allowed-tools", "model", "context", "mystery"] {
468 assert_eq!(
469 warnings
470 .iter()
471 .filter(|warning| warning.contains(key))
472 .count(),
473 1
474 );
475 }
476 }
477
478 #[test]
479 fn invalid_invocation_booleans_fail_closed() {
480 let (metadata, _) = parse_frontmatter(
481 "---\nname: demo\ndescription: routing\nuser-invocable: maybe\n---\nbody",
482 )
483 .unwrap()
484 .unwrap();
485 assert!(
486 validate_skill_frontmatter(Some(&metadata), None, SkillValidationMode::Lenient)
487 .unwrap()[0]
488 .contains("fails closed")
489 );
490 assert!(
491 validate_skill_frontmatter(Some(&metadata), None, SkillValidationMode::Strict).is_err()
492 );
493 for value in ["yes", "on", "1", "true"] {
494 assert_eq!(parse_frontmatter_bool(value), Some(true));
495 }
496 for value in ["no", "off", "0", "false"] {
497 assert_eq!(parse_frontmatter_bool(value), Some(false));
498 }
499 }
500 }
501
501 lines RUST