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