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