use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ParsedSkill {
pub frontmatter: HashMap<String, String>,
#[allow(dead_code)]
pub lists: HashMap<String, Vec<String>>,
pub body: String,
}
#[derive(Debug, Default)]
struct Frontmatter {
values: HashMap<String, String>,
lists: HashMap<String, Vec<String>>,
}
pub fn parse_skill_file(path: &Path) -> Result<ParsedSkill, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
parse_skill_content(&content)
}
pub fn parse_skill_content(content: &str) -> Result<ParsedSkill, String> {
let content = content.trim_start_matches('\u{FEFF}');
let (frontmatter_str, body) = match split_frontmatter(content) {
Some((fm, body)) => (fm, body),
None => return Err("No YAML frontmatter found (expected --- delimiters)".to_string()),
};
let Frontmatter { values, lists } = parse_yaml_frontmatter(frontmatter_str)?;
let body = body.trim().to_string();
if body.is_empty() {
return Err("Skill body is empty after frontmatter".to_string());
}
Ok(ParsedSkill {
frontmatter: values,
lists,
body,
})
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let content = content.strip_prefix("---")?;
let close_pos = content.find("\n---\n").or_else(|| {
if content.ends_with("\n---") {
Some(content.len() - 4)
} else {
None
}
})?;
let fm = content[..close_pos].trim();
let after_close = close_pos + 4; let body = if after_close < content.len() {
content[after_close..].trim()
} else {
""
};
Some((fm, body))
}
fn parse_yaml_frontmatter(fm: &str) -> Result<Frontmatter, String> {
let mut out = Frontmatter::default();
let mut open_key: Option<String> = None;
for raw in fm.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(item) = block_sequence_item(line) {
match &open_key {
Some(key) => {
let item = strip_quotes(item.trim())?;
out.lists.entry(key.clone()).or_default().push(item);
}
None => crate::diag::warn(format!(
"skill frontmatter: sequence item without a key, ignored: {}",
line
)),
}
continue;
}
if raw.starts_with(' ') || raw.starts_with('\t') {
crate::diag::warn(format!(
"skill frontmatter: nested mapping not supported, ignored: {}",
line
));
open_key = None;
continue;
}
let colon_pos = line
.find(": ")
.or_else(|| {
if line.ends_with(':') {
Some(line.len() - 1)
} else {
None
}
})
.ok_or_else(|| format!("Invalid frontmatter line (no colon): {}", line))?;
let key = line[..colon_pos].trim().to_string();
let value = if colon_pos + 2 < line.len() {
line[colon_pos + 2..].trim().to_string()
} else {
String::new()
};
if value.is_empty() {
out.values.insert(key.clone(), String::new());
open_key = Some(key);
continue;
}
open_key = None;
if let Some(items) = parse_flow_sequence(&value)? {
out.values.insert(key.clone(), items.join(", "));
out.lists.insert(key, items);
continue;
}
out.values.insert(key, strip_quotes(&value)?);
}
for (key, items) in &out.lists {
out.values.insert(key.clone(), items.join(", "));
}
Ok(out)
}
fn block_sequence_item(line: &str) -> Option<&str> {
line.strip_prefix("- ")
.or_else(|| if line == "-" { Some("") } else { None })
}
fn parse_flow_sequence(value: &str) -> Result<Option<Vec<String>>, String> {
if !value.starts_with('[') {
return Ok(None);
}
if !value.ends_with(']') {
crate::diag::warn(format!(
"skill frontmatter: unterminated flow sequence kept as text: {}",
value
));
return Ok(None);
}
let inner = &value[1..value.len() - 1];
let mut items: Vec<String> = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut depth = 0usize;
for c in inner.chars() {
match c {
'\'' | '"' if quote.is_none() => {
quote = Some(c);
current.push(c);
}
_ if Some(c) == quote => {
quote = None;
current.push(c);
}
'[' | '{' if quote.is_none() => {
depth += 1;
current.push(c);
}
']' | '}' if quote.is_none() => {
depth = depth.saturating_sub(1);
current.push(c);
}
',' if quote.is_none() && depth == 0 => {
items.push(strip_quotes(current.trim())?);
current.clear();
}
_ => current.push(c),
}
}
if quote.is_some() {
return Err(format!("Unterminated quote in flow sequence: {}", value));
}
if !current.trim().is_empty() {
items.push(strip_quotes(current.trim())?);
}
Ok(Some(items))
}
fn strip_quotes(s: &str) -> Result<String, String> {
if s.starts_with('"') {
if s.ends_with('"') {
Ok(s[1..s.len() - 1].to_string())
} else {
Err(format!("Mismatched double quotes in: {}", s))
}
} else if s.starts_with('\'') {
if s.ends_with('\'') {
Ok(s[1..s.len() - 1].to_string())
} else {
Err(format!("Mismatched single quotes in: {}", s))
}
} else {
Ok(s.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SKILL_WITH_FRONTMATTER: &str = r#"---
name: elliot-dev
description: Senior software engineer for story execution.
user-invocable: true
argument-hint: "[story name]"
---
# Elliot — Senior Software Engineer
## Overview
You are Elliot, the Senior Software Engineer."#;
const SKILL_MINIMAL: &str = r#"---
name: soroban
description: Soroban smart contract development
---
# Soroban Smart Contracts
Content here."#;
const SKILL_NO_BODY: &str = r#"---
name: empty
description: has no body
---"#;
const SKILL_NO_FRONTMATTER: &str = r#"# Just a markdown file
No frontmatter here."#;
#[test]
fn parses_frontmatter_and_body() {
let parsed = parse_skill_content(SKILL_WITH_FRONTMATTER).unwrap();
assert_eq!(parsed.frontmatter.get("name").unwrap(), "elliot-dev");
assert_eq!(
parsed.frontmatter.get("description").unwrap(),
"Senior software engineer for story execution."
);
assert_eq!(parsed.frontmatter.get("user-invocable").unwrap(), "true");
assert_eq!(
parsed.frontmatter.get("argument-hint").unwrap(),
"[story name]"
);
assert!(parsed.body.starts_with("# Elliot"));
}
#[test]
fn parses_minimal_skill() {
let parsed = parse_skill_content(SKILL_MINIMAL).unwrap();
assert_eq!(parsed.frontmatter.get("name").unwrap(), "soroban");
assert!(parsed.body.contains("Content here"));
}
#[test]
fn rejects_empty_body() {
let err = parse_skill_content(SKILL_NO_BODY).unwrap_err();
assert!(err.contains("empty"), "got {}", err);
}
#[test]
fn rejects_missing_frontmatter() {
let err = parse_skill_content(SKILL_NO_FRONTMATTER).unwrap_err();
assert!(err.contains("No YAML frontmatter"), "got {}", err);
}
#[test]
fn handles_bom() {
let with_bom = format!("\u{FEFF}{}", SKILL_MINIMAL);
let parsed = parse_skill_content(&with_bom).unwrap();
assert_eq!(parsed.frontmatter.get("name").unwrap(), "soroban");
}
fn values(fm: &str) -> HashMap<String, String> {
parse_yaml_frontmatter(fm).unwrap().values
}
fn lists(fm: &str) -> HashMap<String, Vec<String>> {
parse_yaml_frontmatter(fm).unwrap().lists
}
#[test]
fn parses_scalar_value_shapes() {
let cases = [
("name: 'my-skill'", "name", "my-skill"),
("description: \"a skill\"", "description", "a skill"),
("description: foo: bar: baz", "description", "foo: bar: baz"),
("description: test", "description", "test"),
];
for (fm, key, expected) in cases {
let map = values(fm);
assert_eq!(map.get(key).unwrap(), expected, "input: {}", fm);
}
}
#[test]
fn rejects_mismatched_quotes() {
let fm = "name: \"hello'";
let err = parse_yaml_frontmatter(fm).unwrap_err();
assert!(err.contains("Mismatched"), "{}", err);
}
#[test]
fn handles_empty_value() {
let fm = "name:\ndescription: test";
let map = values(fm);
assert_eq!(map.get("name").unwrap(), "");
assert_eq!(map.get("description").unwrap(), "test");
}
#[test]
fn skips_blank_lines_and_comments() {
let fm = "\n# comment\nname: test\n\n";
assert_eq!(values(fm).get("name").unwrap(), "test");
}
#[test]
fn parses_a_flow_sequence() {
let fm = "name: test\nallowed-tools: [read_file, write_file]";
assert_eq!(
lists(fm).get("allowed-tools").unwrap(),
&vec!["read_file".to_string(), "write_file".to_string()]
);
assert_eq!(
values(fm).get("allowed-tools").unwrap(),
"read_file, write_file"
);
}
#[test]
fn a_quoted_comma_does_not_split_a_flow_sequence() {
let fm = r#"tags: ["a, b", 'c, d', e]"#;
assert_eq!(
lists(fm).get("tags").unwrap(),
&vec!["a, b".to_string(), "c, d".to_string(), "e".to_string()]
);
}
#[test]
fn a_nested_flow_collection_stays_one_element() {
let fm = "matrix: [[a, b], {k: v}]";
assert_eq!(
lists(fm).get("matrix").unwrap(),
&vec!["[a, b]".to_string(), "{k: v}".to_string()]
);
}
#[test]
fn an_empty_flow_sequence_yields_no_items() {
let fm = "tools: []";
assert!(lists(fm).get("tools").unwrap().is_empty());
assert_eq!(values(fm).get("tools").unwrap(), "");
}
#[test]
fn a_trailing_comma_yields_no_extra_item() {
let fm = "tools: [a, b,]";
assert_eq!(lists(fm).get("tools").unwrap().len(), 2);
}
#[test]
fn parses_a_block_sequence() {
let fm = "name: test\nallowed-tools:\n - read_file\n - write_file\ndescription: after";
let parsed = parse_yaml_frontmatter(fm).unwrap();
assert_eq!(
parsed.lists.get("allowed-tools").unwrap(),
&vec!["read_file".to_string(), "write_file".to_string()]
);
assert_eq!(parsed.values.get("name").unwrap(), "test");
assert_eq!(
parsed.values.get("description").unwrap(),
"after",
"a key after the sequence must still parse"
);
}
#[test]
fn a_block_sequence_item_may_be_quoted() {
let fm = "tags:\n - \"a, b\"\n - 'c'";
assert_eq!(
lists(fm).get("tags").unwrap(),
&vec!["a, b".to_string(), "c".to_string()]
);
}
#[test]
fn a_skill_with_a_block_sequence_loads() {
let content = "---\nname: seq\ndescription: has a sequence\nallowed-tools:\n - read_file\n---\n\nBody.";
let parsed = parse_skill_content(content).unwrap();
assert_eq!(parsed.frontmatter.get("name").unwrap(), "seq");
assert_eq!(
parsed.lists.get("allowed-tools").unwrap(),
&vec!["read_file".to_string()]
);
}
#[test]
fn a_nested_mapping_is_skipped_and_recorded() {
let _guard = crate::diag::test_lock();
crate::diag::drain();
let fm = "name: test\nagent:\n name: Tyler\ndescription: after";
let parsed = parse_yaml_frontmatter(fm).unwrap();
assert_eq!(parsed.values.get("name").unwrap(), "test");
assert_eq!(parsed.values.get("description").unwrap(), "after");
assert!(!parsed.lists.contains_key("agent"));
let warnings = crate::diag::drain();
assert!(
warnings.iter().any(|w| w.contains("nested mapping")),
"expected a recorded warning, got {:?}",
warnings
);
}
#[test]
fn an_unterminated_quote_in_a_flow_sequence_is_an_error() {
let err = parse_yaml_frontmatter("tags: [\"a, b]").unwrap_err();
assert!(err.contains("Unterminated"), "got {}", err);
}
#[test]
fn parses_file_from_disk() {
let path = Path::new("/home/dionebastos/.claude/skills/elliot-dev/SKILL.md");
if path.exists() {
let parsed = parse_skill_file(path).unwrap();
assert_eq!(parsed.frontmatter.get("name").unwrap(), "elliot-dev");
assert!(!parsed.body.is_empty());
}
}
}