use std::collections::BTreeMap;
use std::path::Path;
use crate::platform::web::embedded::PLATFORM_SKILL_ASSETS;
pub const PROJECT_SKILLS_DIR: &str = "skills";
pub const SKILL_FILE: &str = "SKILL.md";
pub const MAX_BODY_LINES: usize = 500;
pub const MAX_DESCRIPTION_CHARS: usize = 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSource {
Blessed,
Project,
}
impl SkillSource {
pub fn label(&self) -> &'static str {
match self {
SkillSource::Blessed => "blessed",
SkillSource::Project => "project",
}
}
}
#[derive(Debug, Clone)]
pub struct SkillMeta {
pub name: String,
pub description: String,
pub license: Option<String>,
pub source: SkillSource,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Frontmatter {
pub name: Option<String>,
pub description: Option<String>,
pub license: Option<String>,
pub extra: BTreeMap<String, String>,
pub body: String,
}
pub fn parse_frontmatter(text: &str) -> Frontmatter {
let mut out = Frontmatter::default();
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let Some(rest) = text.strip_prefix("---") else {
out.body = text.to_string();
return out;
};
let rest = rest.trim_start_matches(['\r', '\n']);
let Some(end) = rest.find("\n---") else {
out.body = text.to_string();
return out;
};
let (front, after) = rest.split_at(end);
out.body = after
.trim_start_matches('\n')
.trim_start_matches("---")
.trim_start_matches(['\r', '\n'])
.to_string();
let mut parent: Option<String> = None;
for line in front.lines() {
if line.trim().is_empty() || line.trim_start().starts_with('#') {
continue;
}
let indented = line.starts_with(' ') || line.starts_with('\t');
let Some((key, value)) = line.split_once(':') else { continue };
let key = key.trim();
let value = unquote(value.trim());
if indented {
if let Some(p) = &parent {
out.extra.insert(format!("{p}.{key}"), value);
}
continue;
}
if value.is_empty() {
parent = Some(key.to_string());
continue;
}
parent = None;
match key {
"name" => out.name = Some(value),
"description" => out.description = Some(value),
"license" => out.license = Some(value),
other => {
out.extra.insert(other.to_string(), value);
}
}
}
out
}
fn unquote(value: &str) -> String {
let v = value.trim();
if v.len() >= 2 {
let first = v.as_bytes()[0];
let last = v.as_bytes()[v.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return v[1..v.len() - 1].to_string();
}
}
v.to_string()
}
pub fn blessed_skills() -> Vec<SkillMeta> {
let mut out = Vec::new();
for asset in PLATFORM_SKILL_ASSETS {
let Some(folder) = asset.path.strip_suffix(&format!("/{SKILL_FILE}")) else { continue };
if folder.contains('/') {
continue;
}
let text = String::from_utf8_lossy(asset.bytes);
let fm = parse_frontmatter(&text);
out.push(SkillMeta {
name: folder.to_string(),
description: fm.description.unwrap_or_default(),
license: fm.license,
source: SkillSource::Blessed,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn blessed_file(name: &str, path: &str) -> Option<String> {
let rel = skill_file_path(name, path)?;
PLATFORM_SKILL_ASSETS
.iter()
.find(|a| a.path == rel)
.map(|a| String::from_utf8_lossy(a.bytes).into_owned())
}
pub fn project_skills(source_root: &Path) -> Vec<SkillMeta> {
let mut out = Vec::new();
let dir = source_root.join(PROJECT_SKILLS_DIR);
let Ok(entries) = std::fs::read_dir(&dir) else { return out };
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
if !valid_name(name) {
continue;
}
let Ok(text) = std::fs::read_to_string(path.join(SKILL_FILE)) else { continue };
let fm = parse_frontmatter(&text);
out.push(SkillMeta {
name: name.to_string(),
description: fm.description.unwrap_or_default(),
license: fm.license,
source: SkillSource::Project,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn project_file(source_root: &Path, name: &str, path: &str) -> Option<String> {
let rel = skill_file_path(name, path)?;
std::fs::read_to_string(source_root.join(PROJECT_SKILLS_DIR).join(rel)).ok()
}
pub fn list(source_root: &Path) -> Vec<SkillMeta> {
let mut out = project_skills(source_root);
for blessed in blessed_skills() {
if !out.iter().any(|s| s.name == blessed.name) {
out.push(blessed);
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn read(source_root: &Path, name: &str, path: &str) -> Option<(SkillSource, String)> {
if let Some(text) = project_file(source_root, name, path) {
return Some((SkillSource::Project, text));
}
blessed_file(name, path).map(|text| (SkillSource::Blessed, text))
}
pub fn valid_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !name.starts_with('-')
&& !name.ends_with('-')
}
fn skill_file_path(name: &str, path: &str) -> Option<String> {
if !valid_name(name) {
return None;
}
let path = path.trim();
let path = if path.is_empty() { SKILL_FILE } else { path };
if path.starts_with('/')
|| path.contains('\\')
|| path.split('/').any(|seg| seg.is_empty() || seg == "." || seg == "..")
{
return None;
}
Some(format!("{name}/{path}"))
}
pub fn render_listing(skills: &[SkillMeta]) -> String {
let mut out = String::new();
for s in skills {
let tag = match s.source {
SkillSource::Blessed => "",
SkillSource::Project => " (project)",
};
out.push_str(&format!("- `{}`{tag} — {}\n", s.name, s.description));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frontmatter_parses_flat_keys_quotes_and_nested_metadata() {
let fm = parse_frontmatter(
"---\nname: zebflow-basic\ndescription: \"When: every session. What: orient.\"\nlicense: MIT\nmetadata:\n version: \"1\"\n derived_from: none\n---\n# Body\n\ntext\n",
);
assert_eq!(fm.name.as_deref(), Some("zebflow-basic"));
assert_eq!(fm.description.as_deref(), Some("When: every session. What: orient."));
assert_eq!(fm.license.as_deref(), Some("MIT"));
assert_eq!(fm.extra.get("metadata.version").map(String::as_str), Some("1"));
assert!(fm.body.starts_with("# Body"));
}
#[test]
fn a_file_without_frontmatter_is_all_body() {
let fm = parse_frontmatter("# Just a doc\n");
assert!(fm.name.is_none());
assert_eq!(fm.body, "# Just a doc\n");
}
#[test]
fn names_follow_the_format() {
assert!(valid_name("zebflow-basic"));
assert!(!valid_name("Zebflow"));
assert!(!valid_name("-x"));
assert!(!valid_name("a b"));
assert!(!valid_name(""));
}
#[test]
fn a_path_cannot_leave_the_skill_folder() {
assert_eq!(skill_file_path("s", "").as_deref(), Some("s/SKILL.md"));
assert_eq!(skill_file_path("s", "references/a.md").as_deref(), Some("s/references/a.md"));
assert!(skill_file_path("s", "../other/SKILL.md").is_none());
assert!(skill_file_path("s", "/etc/passwd").is_none());
assert!(skill_file_path("../s", "").is_none());
}
#[test]
fn a_project_skill_shadows_a_blessed_one_by_name() {
let tmp = tempfile::tempdir().expect("tmp");
let Some(first) = blessed_skills().first().cloned() else { return };
let dir = tmp.path().join(PROJECT_SKILLS_DIR).join(&first.name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(SKILL_FILE), "---\nname: x\ndescription: mine\n---\nours\n").unwrap();
let listed = list(tmp.path());
let hit = listed.iter().find(|s| s.name == first.name).expect("listed");
assert_eq!(hit.source, SkillSource::Project);
assert_eq!(hit.description, "mine");
assert_eq!(listed.iter().filter(|s| s.name == first.name).count(), 1);
let (source, text) = read(tmp.path(), &first.name, "").expect("read");
assert_eq!(source, SkillSource::Project);
assert!(text.contains("ours"));
}
#[test]
fn every_blessed_skill_is_well_formed() {
let skills = blessed_skills();
for s in &skills {
let text = blessed_file(&s.name, "").expect("SKILL.md");
let fm = parse_frontmatter(&text);
assert!(valid_name(&s.name), "{}: bad folder name", s.name);
assert_eq!(fm.name.as_deref(), Some(s.name.as_str()), "{}: frontmatter name must equal the folder", s.name);
let description = fm.description.unwrap_or_default();
assert!(!description.is_empty() && description.len() <= MAX_DESCRIPTION_CHARS, "{}: description missing or over {} chars", s.name, MAX_DESCRIPTION_CHARS);
assert!(fm.license.is_some(), "{}: license is required", s.name);
let lines = fm.body.lines().count();
assert!(lines <= MAX_BODY_LINES, "{}: body is {lines} lines; over {}", s.name, MAX_BODY_LINES);
}
}
}