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}
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 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#[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#[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#[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
309pub 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#[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 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 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 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 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 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 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}