1#![allow(missing_docs, dead_code)]
2use 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#[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
82pub 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#[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#[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#[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#[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
320pub 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#[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 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 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 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 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 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 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}