Skip to main content

oxios_kernel/skill/
frontmatter.rs

1#![allow(missing_docs, dead_code)]
2//! Format-specific frontmatter types and unified parsing pipeline.
3
4use super::format::{resolve_format, SkillFormat};
5use super::types::*;
6use anyhow::{Context, Result};
7use serde::Deserialize;
8use serde_yaml::Value;
9use std::path::Path;
10
11// ─── YAML helpers ──────────────────────────────────────────────
12
13#[derive(Deserialize, Default)]
14#[serde(rename_all = "kebab-case")]
15pub(crate) struct YamlRequirements {
16    pub bins: Option<Vec<String>>,
17    #[serde(default, rename = "anyBins")]
18    pub any_bins: Option<Vec<String>>,
19    pub env: Option<Vec<String>>,
20    pub config: Option<Vec<String>>,
21}
22impl YamlRequirements {
23    pub fn into_requirements(self) -> Requirements {
24        Requirements {
25            bins: self.bins.unwrap_or_default(),
26            any_bins: self.any_bins.unwrap_or_default(),
27            env: self.env.unwrap_or_default(),
28            config: self.config.unwrap_or_default(),
29        }
30    }
31}
32
33#[derive(Deserialize)]
34#[serde(rename_all = "kebab-case")]
35pub(crate) struct YamlInstallSpec {
36    pub kind: Option<String>,
37    pub formula: Option<String>,
38    pub package: Option<String>,
39    pub module: Option<String>,
40    pub url: Option<String>,
41    pub archive: Option<String>,
42    pub extract: Option<bool>,
43    #[serde(rename = "stripComponents")]
44    pub strip_components: Option<u32>,
45    #[serde(rename = "targetDir")]
46    pub target_dir: Option<String>,
47    pub os: Option<Vec<String>>,
48}
49impl From<YamlInstallSpec> for SkillInstallSpec {
50    fn from(y: YamlInstallSpec) -> Self {
51        SkillInstallSpec {
52            kind: match y.kind.as_deref() {
53                Some("brew") => InstallKind::Brew,
54                Some("node") => InstallKind::Node,
55                Some("go") => InstallKind::Go,
56                Some("uv") => InstallKind::Uv,
57                Some("download") => InstallKind::Download,
58                _ => InstallKind::Brew,
59            },
60            formula: y.formula,
61            package: y.package,
62            module: y.module,
63            url: y.url,
64            archive: y.archive,
65            extract: y.extract,
66            strip_components: y.strip_components,
67            target_dir: y.target_dir,
68            os: y.os.unwrap_or_default(),
69        }
70    }
71}
72
73// ─── ParsedSkill ──────────────────────────────────────────────
74
75pub struct ParsedSkill {
76    pub name: String,
77    pub description: String,
78    pub metadata: SkillMetadata,
79    pub invocation: SkillInvocationPolicy,
80    pub format: SkillFormat,
81    pub raw_yaml: Value,
82}
83
84// ─── Oxios ────────────────────────────────────────────────────
85
86#[derive(Deserialize)]
87#[serde(rename_all = "kebab-case")]
88struct OxiosFm {
89    name: Option<String>,
90    description: Option<String>,
91    author: Option<String>,
92    version: Option<String>,
93    emoji: Option<String>,
94    homepage: Option<String>,
95    requires: Option<YamlRequirements>,
96    install: Option<Vec<YamlInstallSpec>>,
97    os: Option<Vec<String>>,
98    always: Option<bool>,
99    #[serde(rename = "primaryEnv")]
100    primary_env: Option<String>,
101    #[serde(rename = "skillKey")]
102    skill_key: Option<String>,
103    #[serde(rename = "user-invocable")]
104    user_invocable: Option<bool>,
105    #[serde(rename = "disable-model-invocation")]
106    disable_model_invocation: Option<bool>,
107}
108impl OxiosFm {
109    fn into_parsed(self, raw: Value) -> ParsedSkill {
110        ParsedSkill {
111            name: self.name.unwrap_or_default(),
112            description: self.description.unwrap_or_default(),
113            metadata: SkillMetadata {
114                author: self.author,
115                version: self.version,
116                emoji: self.emoji,
117                homepage: self.homepage,
118                requires: self.requires.unwrap_or_default().into_requirements(),
119                install: self
120                    .install
121                    .unwrap_or_default()
122                    .into_iter()
123                    .map(Into::into)
124                    .collect(),
125                os: self.os.unwrap_or_default(),
126                always: self.always.unwrap_or(false),
127                primary_env: self.primary_env,
128                skill_key: self.skill_key,
129            },
130            invocation: SkillInvocationPolicy {
131                user_invocable: self.user_invocable.unwrap_or(true),
132                disable_model_invocation: self.disable_model_invocation.unwrap_or(false),
133            },
134            format: SkillFormat::Oxios,
135            raw_yaml: raw,
136        }
137    }
138}
139
140// ─── OpenClaw ─────────────────────────────────────────────────
141
142#[derive(Deserialize)]
143struct OpenClawFm {
144    name: Option<String>,
145    description: Option<String>,
146    metadata: Option<OcMeta>,
147}
148#[derive(Deserialize)]
149struct OcMeta {
150    openclaw: Option<OcRuntime>,
151    clawdbot: Option<OcRuntime>,
152    clawdis: Option<OcRuntime>,
153}
154#[derive(Deserialize)]
155#[serde(rename_all = "kebab-case")]
156struct OcRuntime {
157    requires: Option<YamlRequirements>,
158    install: Option<Vec<YamlInstallSpec>>,
159    #[serde(rename = "primaryEnv")]
160    primary_env: Option<String>,
161    #[serde(rename = "envVars")]
162    env_vars: Option<Vec<OcEnvVar>>,
163    always: Option<bool>,
164    #[serde(rename = "skillKey")]
165    skill_key: Option<String>,
166    emoji: Option<String>,
167    version: Option<String>,
168    author: Option<String>,
169    homepage: Option<String>,
170}
171#[derive(Deserialize)]
172struct OcEnvVar {
173    name: String,
174    #[serde(default = "default_true")]
175    required: bool,
176}
177
178impl OpenClawFm {
179    fn into_parsed(self, raw: Value) -> ParsedSkill {
180        let rt = self
181            .metadata
182            .and_then(|m| m.openclaw.or(m.clawdbot).or(m.clawdis));
183        let (reqs, install, penv, sk, alw, em, ver, auth, hp, evars) = match rt {
184            Some(r) => (
185                r.requires.unwrap_or_default(),
186                r.install.unwrap_or_default(),
187                r.primary_env,
188                r.skill_key,
189                r.always.unwrap_or(false),
190                r.emoji,
191                r.version,
192                r.author,
193                r.homepage,
194                r.env_vars.unwrap_or_default(),
195            ),
196            None => Default::default(),
197        };
198        let mut env = reqs.env.unwrap_or_default();
199        for ev in &evars {
200            if ev.required && !env.contains(&ev.name) {
201                env.push(ev.name.clone());
202            }
203        }
204        ParsedSkill {
205            name: self.name.unwrap_or_default(),
206            description: self.description.unwrap_or_default(),
207            metadata: SkillMetadata {
208                author: auth,
209                version: ver,
210                emoji: em,
211                homepage: hp,
212                requires: Requirements {
213                    bins: reqs.bins.unwrap_or_default(),
214                    any_bins: reqs.any_bins.unwrap_or_default(),
215                    env,
216                    config: reqs.config.unwrap_or_default(),
217                },
218                install: install.into_iter().map(Into::into).collect(),
219                primary_env: penv,
220                skill_key: sk,
221                always: alw,
222                ..Default::default()
223            },
224            invocation: SkillInvocationPolicy::default(),
225            format: SkillFormat::OpenClaw,
226            raw_yaml: raw,
227        }
228    }
229}
230
231// ─── Claude Code ──────────────────────────────────────────────
232
233#[derive(Deserialize)]
234#[serde(rename_all = "kebab-case")]
235struct ClaudeFm {
236    name: Option<String>,
237    description: Option<String>,
238    allowed_tools: Option<Value>,
239    arguments: Option<Value>,
240    #[serde(rename = "when_to_use")]
241    when_to_use: Option<String>,
242    argument_hint: Option<String>,
243    model: Option<String>,
244    effort: Option<String>,
245    context: Option<String>,
246    agent: Option<String>,
247    paths: Option<Value>,
248    hooks: Option<Value>,
249    shell: Option<String>,
250    #[serde(rename = "disable-model-invocation")]
251    disable_model_invocation: Option<bool>,
252    #[serde(rename = "user-invocable")]
253    user_invocable: Option<bool>,
254    license: Option<String>,
255    compatibility: Option<String>,
256}
257impl ClaudeFm {
258    fn into_parsed(self, raw: Value) -> ParsedSkill {
259        let description = match &self.when_to_use {
260            Some(wtu) if !wtu.is_empty() => {
261                let b = self.description.as_deref().unwrap_or("");
262                if b.contains(wtu) {
263                    b.to_string()
264                } else {
265                    format!("{b} {wtu}")
266                }
267            }
268            _ => self.description.unwrap_or_default(),
269        };
270        ParsedSkill {
271            name: self.name.unwrap_or_default(),
272            description,
273            metadata: SkillMetadata::default(),
274            invocation: SkillInvocationPolicy {
275                user_invocable: self.user_invocable.unwrap_or(true),
276                disable_model_invocation: self.disable_model_invocation.unwrap_or(false),
277            },
278            format: SkillFormat::ClaudeCode,
279            raw_yaml: raw,
280        }
281    }
282}
283
284// ─── Agent Skills standard ────────────────────────────────────
285
286#[derive(Deserialize)]
287struct StandardFm {
288    name: Option<String>,
289    description: Option<String>,
290    license: Option<String>,
291    compatibility: Option<String>,
292    metadata: Option<Value>,
293}
294impl StandardFm {
295    fn into_parsed(self, raw: Value) -> ParsedSkill {
296        ParsedSkill {
297            name: self.name.unwrap_or_default(),
298            description: self.description.unwrap_or_default(),
299            metadata: SkillMetadata::default(),
300            invocation: SkillInvocationPolicy::default(),
301            format: SkillFormat::AgentSkills,
302            raw_yaml: raw,
303        }
304    }
305}
306
307// ─── Pipeline ─────────────────────────────────────────────────
308
309pub fn parse_skill(content: &str, skill_dir: &Path) -> Result<(ParsedSkill, String)> {
310    let (yaml_str, body) = split_frontmatter(content)?;
311    if yaml_str.trim().is_empty() {
312        return Ok((
313            ParsedSkill {
314                name: String::new(),
315                description: String::new(),
316                metadata: SkillMetadata::default(),
317                invocation: SkillInvocationPolicy::default(),
318                format: SkillFormat::AgentSkills,
319                raw_yaml: Value::Null,
320            },
321            body,
322        ));
323    }
324    let value: Value =
325        serde_yaml::from_str(&yaml_str).with_context(|| "invalid YAML frontmatter")?;
326    let format = resolve_format(&value, skill_dir);
327    let parsed = match format {
328        SkillFormat::Oxios => {
329            let fm: OxiosFm =
330                serde_yaml::from_value(value.clone()).with_context(|| "Oxios frontmatter")?;
331            fm.into_parsed(value)
332        }
333        SkillFormat::OpenClaw => {
334            let fm: OpenClawFm =
335                serde_yaml::from_value(value.clone()).with_context(|| "OpenClaw frontmatter")?;
336            fm.into_parsed(value)
337        }
338        SkillFormat::ClaudeCode => {
339            let fm: ClaudeFm =
340                serde_yaml::from_value(value.clone()).with_context(|| "Claude frontmatter")?;
341            fm.into_parsed(value)
342        }
343        SkillFormat::AgentSkills => {
344            let fm: StandardFm =
345                serde_yaml::from_value(value.clone()).with_context(|| "Standard frontmatter")?;
346            fm.into_parsed(value)
347        }
348    };
349    Ok((parsed, sanitize_body(&body, format)))
350}
351
352fn split_frontmatter(content: &str) -> Result<(String, String)> {
353    let trimmed = content.trim_start();
354    if !trimmed.starts_with("---") {
355        return Ok((String::new(), content.to_string()));
356    }
357    let after = &trimmed[3..];
358    let end = after.find("---").context("unclosed frontmatter")?;
359    Ok((
360        after[..end].to_string(),
361        after[end + 3..].trim_start().to_string(),
362    ))
363}
364
365fn sanitize_body(body: &str, format: SkillFormat) -> String {
366    if format != SkillFormat::ClaudeCode {
367        return body.to_string();
368    }
369    let mut result = String::with_capacity(body.len());
370    let mut chars = body.chars().peekable();
371    while let Some(c) = chars.next() {
372        if c == '!' && chars.peek() == Some(&'`') {
373            chars.next();
374            let mut cmd = String::new();
375            let mut found = false;
376            for cc in chars.by_ref() {
377                if cc == '`' {
378                    found = true;
379                    break;
380                }
381                cmd.push(cc);
382            }
383            if found {
384                result.push_str(&format!(
385                    "<!-- !`{cmd}` (Claude Code dynamic injection, not active in Oxios) -->"
386                ));
387            } else {
388                result.push('!');
389                result.push('`');
390                result.push_str(&cmd);
391            }
392        } else {
393            result.push(c);
394        }
395    }
396    result
397}
398
399// ─── Tests ────────────────────────────────────────────────────
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    #[test]
405    fn test_split() {
406        let (y, b) = split_frontmatter("---\nname: x\n---\n\nBody\n").unwrap();
407        assert!(y.contains("name"));
408        assert!(b.contains("Body"));
409    }
410    #[test]
411    fn test_split_none() {
412        let (y, _) = split_frontmatter("# No fm").unwrap();
413        assert!(y.is_empty());
414    }
415    #[test]
416    fn test_split_unclosed() {
417        assert!(split_frontmatter("---\nname: x").is_err());
418    }
419    #[test]
420    fn test_oxios_basic() {
421        let d = tempfile::tempdir().unwrap();
422        // Oxios format is detected by presence of `requires`, `install`, `primaryEnv`, or `skillKey` keys.
423        let (p, b) = parse_skill(
424            "---\nname: test\ndescription: desc\nrequires:\n  bins:\n    - git\n---\n\nBody\n",
425            d.path(),
426        )
427        .unwrap();
428        assert_eq!(p.format, SkillFormat::Oxios);
429        assert_eq!(p.name, "test");
430        assert!(b.contains("Body"));
431    }
432    #[test]
433    fn test_oxios_full() {
434        let d = tempfile::tempdir().unwrap();
435        let c = "---\nname: cr\ndescription: review\nauthor: me\nrequires:\n  bins:\n    - git\n  env:\n    - TOKEN\ninstall:\n  - kind: brew\n    formula: git\nalways: false\n---\n\n# Review\n";
436        let (p, _) = parse_skill(c, d.path()).unwrap();
437        assert_eq!(p.metadata.requires.bins, vec!["git"]);
438        assert_eq!(p.metadata.requires.env, vec!["TOKEN"]);
439        assert_eq!(p.metadata.install.len(), 1);
440    }
441    #[test]
442    fn test_openclaw_nested() {
443        let d = tempfile::tempdir().unwrap();
444        let c = "---\nname: todo\nmetadata:\n  openclaw:\n    requires:\n      env:\n        - KEY\n    primaryEnv: KEY\n---\n\n# Body\n";
445        let (p, _) = parse_skill(c, d.path()).unwrap();
446        assert_eq!(p.format, SkillFormat::OpenClaw);
447        assert_eq!(p.metadata.requires.env, vec!["KEY"]);
448        assert_eq!(p.metadata.primary_env.as_deref(), Some("KEY"));
449    }
450    #[test]
451    fn test_openclaw_envvars_merge() {
452        let d = tempfile::tempdir().unwrap();
453        // envVars is a separate field in OpenClaw runtime; must also have requires.env or
454        // the merge logic adds envVar names to the env list.
455        let c = "---\nname: t\nmetadata:\n  openclaw:\n    requires:\n      env:\n        - KEY\n    envVars:\n      - name: AUTO\n        required: true\n---\n\n";
456        let (p, _) = parse_skill(c, d.path()).unwrap();
457        assert!(
458            p.metadata.requires.env.contains(&"KEY".to_string()),
459            "KEY from requires.env should be present"
460        );
461        assert!(
462            p.metadata.requires.env.contains(&"AUTO".to_string()),
463            "AUTO from envVars should be merged"
464        );
465    }
466    #[test]
467    fn test_claude() {
468        let d = tempfile::tempdir().unwrap();
469        let c = "---\nname: deploy\nallowed-tools: Bash\ndisable-model-invocation: true\n---\n\nDeploy.\n";
470        let (p, _) = parse_skill(c, d.path()).unwrap();
471        assert_eq!(p.format, SkillFormat::ClaudeCode);
472        assert!(p.invocation.disable_model_invocation);
473    }
474    #[test]
475    fn test_claude_when_to_use() {
476        let d = tempfile::tempdir().unwrap();
477        // when_to_use key triggers ClaudeCode format detection.
478        // ClaudeCode's into_parsed appends when_to_use to description.
479        let c = "---\nname: s\ndescription: Sum\nwhen_to_use: use when changed\n---\n\n";
480        let (p, _) = parse_skill(c, d.path()).unwrap();
481        assert_eq!(
482            p.format,
483            SkillFormat::ClaudeCode,
484            "should be detected as ClaudeCode"
485        );
486        // description should be "Sum use when changed"
487        assert!(
488            p.description.contains("Sum"),
489            "should contain base description"
490        );
491        assert!(
492            p.description.contains("changed"),
493            "should contain when_to_use content"
494        );
495    }
496    #[test]
497    fn test_sanitize() {
498        let safe = sanitize_body("See !`git diff`\n", SkillFormat::ClaudeCode);
499        assert!(safe.contains("<!--"));
500        assert!(!safe.contains("!["));
501    }
502    #[test]
503    fn test_sanitize_skip() {
504        assert_eq!(sanitize_body("a!`b`", SkillFormat::Oxios), "a!`b`");
505    }
506    #[test]
507    fn test_standard() {
508        // name + description only → no Oxios/Claude/OpenClaw keys → AgentSkills format.
509        let d = tempfile::tempdir().unwrap();
510        let (p, _) = parse_skill("---\nname: s\ndescription: d\n---\n\n", d.path()).unwrap();
511        assert_eq!(p.format, SkillFormat::AgentSkills);
512    }
513    #[test]
514    fn test_oxios_name_desc_only() {
515        // name + description without requires/install → falls through to AgentSkills.
516        let d = tempfile::tempdir().unwrap();
517        let (p, _) = parse_skill(
518            "---\nname: test\ndescription: desc\n---\n\nBody\n",
519            d.path(),
520        )
521        .unwrap();
522        assert_eq!(
523            p.format,
524            SkillFormat::AgentSkills,
525            "name+description only should be AgentSkills, not Oxios"
526        );
527        assert_eq!(p.name, "test");
528    }
529}