use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillManifest {
pub name: String,
pub description: Option<String>,
pub version: Option<String>,
pub requires_rsclaw: Option<String>,
#[serde(default)]
pub tools: Vec<ToolSpec>,
#[serde(default, flatten)]
pub extra: HashMap<String, Value>,
#[serde(skip)]
pub dir: PathBuf,
#[serde(skip)]
pub prompt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub command: String,
pub input_schema: Option<Value>,
#[serde(default = "default_timeout")]
pub timeout_seconds: u32,
#[serde(default = "default_true")]
pub stdin_json: bool,
}
fn default_timeout() -> u32 {
30
}
fn default_true() -> bool {
true
}
pub fn parse_skill_md(path: &Path) -> Result<SkillManifest> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("cannot read SKILL.md: {}", path.display()))?;
let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
let (front_matter, prompt) = split_front_matter(&content);
let mut manifest: SkillManifest = if let Some(fm) = front_matter {
serde_yaml_ng::from_str(&fm)
.with_context(|| format!("YAML front-matter error in {}", path.display()))?
} else {
let name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_owned();
SkillManifest {
name,
description: None,
version: None,
requires_rsclaw: None,
tools: Vec::new(),
extra: HashMap::new(),
dir: PathBuf::new(),
prompt: String::new(),
}
};
manifest.dir = dir;
manifest.prompt = prompt.to_owned();
Ok(manifest)
}
fn split_front_matter(content: &str) -> (Option<String>, &str) {
let mut lines = content.splitn(3, "---\n");
match (lines.next(), lines.next(), lines.next()) {
(Some(""), Some(fm), Some(rest)) => (Some(fm.to_owned()), rest),
_ => (None, content),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_full_skill_md() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("SKILL.md");
std::fs::write(
&path,
r#"---
name: demo-skill
description: A demo skill
version: "1.0.0"
tools:
- name: greet
description: Say hello
command: ./greet.sh
timeout_seconds: 10
---
# Demo Skill
Use this skill to greet people.
"#,
)
.expect("write");
let m = parse_skill_md(&path).expect("parse");
assert_eq!(m.name, "demo-skill");
assert_eq!(m.version.as_deref(), Some("1.0.0"));
assert_eq!(m.tools.len(), 1);
assert_eq!(m.tools[0].name, "greet");
assert!(m.prompt.contains("Demo Skill"));
}
#[test]
fn parse_skill_md_no_frontmatter() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("SKILL.md");
std::fs::write(&path, "# Minimal\nJust some text.").expect("write");
let m = parse_skill_md(&path).expect("parse");
assert!(m.prompt.contains("Minimal"));
assert!(m.tools.is_empty());
}
}