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