Skip to main content

zeroclaw_runtime/skills/
frontmatter.rs

1//! Canonical `SKILL.md` frontmatter.
2//!
3//! Per the open Agent Skills spec (agentskills.io), `name` and `description`
4//! are required; everything else is conventional. We keep the shape **flat**
5//! — `license`, `author`, `version`, `category` at the top level — so the
6//! existing hand-rolled parser in `super::parse_simple_frontmatter` (which
7//! deliberately avoids a full YAML dep) covers every field. The
8//! `zeroclaw-labs/zeroclaw-skills` registry nests these under a `metadata:`
9//! block; that registry is ours and follows this flat shape going forward.
10//!
11//! The struct is the single source of truth: [`SkillFrontmatter::prop_fields`]
12//! enumerates the same field set that drives the dashboard form, CLI flags
13//! on `zeroclaw skills add`, and the TUI form. Adding a field here = all
14//! three surfaces gain it via `prop_fields`.
15
16use serde::{Deserialize, Serialize};
17use zeroclaw_config::traits::{PropFieldInfo, PropKind};
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
20pub struct SkillFrontmatter {
21    pub name: String,
22    pub description: String,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub license: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub author: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub version: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub category: Option<String>,
31}
32
33impl SkillFrontmatter {
34    /// Field set in canonical order. Surfaces iterate this to build flag
35    /// lists / forms / pickers. Drift-checked by `prop_fields_matches_struct`.
36    pub fn prop_fields() -> Vec<PropFieldInfo> {
37        vec![
38            field(
39                "name",
40                "String",
41                true,
42                "Skill identifier (lowercase, hyphens only).",
43            ),
44            field(
45                "description",
46                "String",
47                true,
48                "What the skill does and when to use it. Written in third person; injected into the system prompt for skill discovery.",
49            ),
50            field(
51                "license",
52                "Option<String>",
53                false,
54                "SPDX license identifier (e.g. MIT).",
55            ),
56            field(
57                "author",
58                "Option<String>",
59                false,
60                "Skill author handle or organisation.",
61            ),
62            field(
63                "version",
64                "Option<String>",
65                false,
66                "SemVer version of the skill. Defaults to 0.1.0 on scaffold.",
67            ),
68            field(
69                "category",
70                "Option<String>",
71                false,
72                "Skill category for registry grouping (e.g. coding, ops).",
73            ),
74        ]
75    }
76}
77
78fn field(
79    name: &'static str,
80    type_hint: &'static str,
81    required: bool,
82    description: &'static str,
83) -> PropFieldInfo {
84    PropFieldInfo {
85        name: name.to_string(),
86        category: "skill-frontmatter",
87        display_value: if required {
88            String::from("<required>")
89        } else {
90            String::new()
91        },
92        type_hint,
93        kind: PropKind::String,
94        is_secret: false,
95        enum_variants: None,
96        description,
97        derived_from_secret: false,
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn prop_fields_matches_struct() {
107        // Drift check: when a field is added to SkillFrontmatter, prop_fields
108        // must be updated to match. The expected count tracks every field.
109        let fields = SkillFrontmatter::prop_fields();
110        assert_eq!(
111            fields.len(),
112            6,
113            "SkillFrontmatter::prop_fields drifted from struct definition; \
114             update both when adding/removing fields"
115        );
116    }
117
118    #[test]
119    fn serializes_minimal_skill_without_optional_fields() {
120        let fm = SkillFrontmatter {
121            name: "code-review".into(),
122            description: "Review pull requests.".into(),
123            ..Default::default()
124        };
125        let json = serde_json::to_value(&fm).unwrap();
126        assert_eq!(json["name"], "code-review");
127        assert_eq!(json["description"], "Review pull requests.");
128        assert!(json.get("license").is_none());
129        assert!(json.get("author").is_none());
130    }
131}