use anyhow::{Context, Result};
use regex::Regex;
use serde::Deserialize;
use std::fs;
use std::path::Path;
use std::sync::LazyLock;
fn never_match_regex() -> Regex {
Regex::new("$^").unwrap_or_else(|_| unreachable!("$^ is valid"))
}
static YAML_CONTINUATION_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\n(\s+):(\s+)").unwrap_or_else(|_| never_match_regex()));
static YAML_FRONT_MATTER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)^---\s*\n(.*?)\n---").unwrap_or_else(|_| never_match_regex())
});
static ALLOWED_TOOLS_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"Bash\(([^)]+)\)").unwrap_or_else(|_| never_match_regex()));
#[derive(Deserialize, Debug, Clone, Default)]
#[allow(dead_code)]
struct FrontMatter {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub compatibility: Option<String>,
#[serde(default)]
pub entry_point: Option<String>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default, rename = "allowed-tools")]
pub allowed_tools: Option<String>,
#[serde(default, rename = "requires_elevated_permissions")]
pub requires_elevated_permissions: Option<bool>,
#[serde(default)]
pub capabilities: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct BashToolPattern {
pub command_prefix: String,
pub raw_pattern: String,
}
pub fn parse_allowed_tools(raw: &str) -> Vec<BashToolPattern> {
let mut patterns = Vec::new();
for cap in ALLOWED_TOOLS_RE.captures_iter(raw) {
if let Some(inner) = cap.get(1) {
let pattern_str = inner.as_str().trim();
let command_prefix = if let Some(idx) = pattern_str.find(':') {
pattern_str[..idx].trim().to_string()
} else {
pattern_str.to_string()
};
if !command_prefix.is_empty() {
patterns.push(BashToolPattern {
command_prefix,
raw_pattern: pattern_str.to_string(),
});
}
}
}
patterns
}
#[derive(Debug, Clone)]
pub struct SkillMetadata {
pub name: String,
pub entry_point: String,
pub language: Option<String>,
pub description: Option<String>,
pub version: Option<String>,
pub compatibility: Option<String>,
pub network: NetworkPolicy,
pub resolved_packages: Option<Vec<String>>,
pub allowed_tools: Option<String>,
pub requires_elevated_permissions: bool,
pub capabilities: Vec<String>,
}
impl SkillMetadata {
pub fn is_bash_tool_skill(&self) -> bool {
self.allowed_tools.is_some() && self.entry_point.is_empty()
}
pub fn get_bash_patterns(&self) -> Vec<BashToolPattern> {
match &self.allowed_tools {
Some(raw) => parse_allowed_tools(raw),
None => Vec::new(),
}
}
pub fn uses_playwright(&self) -> bool {
if let Some(ref packages) = self.resolved_packages {
if packages
.iter()
.any(|p| p.to_lowercase().trim() == "playwright")
{
return true;
}
}
if let Some(ref compat) = self.compatibility {
if compat.to_lowercase().contains("playwright") {
return true;
}
}
false
}
}
#[derive(Debug, Clone, Default)]
pub struct NetworkPolicy {
pub enabled: bool,
pub outbound: Vec<String>,
}
fn parse_compatibility_for_network(compatibility: Option<&str>) -> NetworkPolicy {
let Some(compat) = compatibility else {
return NetworkPolicy::default();
};
let compat_lower = compat.to_lowercase();
let needs_network = compat_lower.contains("network")
|| compat_lower.contains("internet")
|| compat_lower.contains("http")
|| compat_lower.contains("api")
|| compat_lower.contains("web")
|| compat_lower.contains("网络")
|| compat_lower.contains("联网")
|| compat_lower.contains("网页")
|| compat_lower.contains("在线");
if needs_network {
NetworkPolicy {
enabled: true,
outbound: vec!["*".to_string()],
}
} else {
NetworkPolicy::default()
}
}
fn parse_compatibility_for_language(compatibility: Option<&str>) -> Option<String> {
let compat = compatibility?;
let compat_lower = compat.to_lowercase();
if compat_lower.contains("python") {
Some("python".to_string())
} else if compat_lower.contains("node")
|| compat_lower.contains("javascript")
|| compat_lower.contains("typescript")
{
Some("node".to_string())
} else if compat_lower.contains("bash") || compat_lower.contains("shell") {
Some("bash".to_string())
} else {
None
}
}
fn detect_entry_point(skill_dir: &Path) -> Option<String> {
let scripts_dir = skill_dir.join("scripts");
if !scripts_dir.exists() {
return None;
}
for ext in [".py", ".js", ".ts", ".sh"] {
let main_file = scripts_dir.join(format!("main{}", ext));
if main_file.exists() {
return Some(format!("scripts/main{}", ext));
}
}
for ext in [".py", ".js", ".ts", ".sh"] {
let index_file = scripts_dir.join(format!("index{}", ext));
if index_file.exists() {
return Some(format!("scripts/index{}", ext));
}
}
let mut script_files = Vec::new();
if let Ok(entries) = fs::read_dir(&scripts_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy();
if ["py", "js", "ts", "sh"].contains(&ext_str.as_ref()) {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if !name.starts_with("test_")
&& !name.ends_with("_test.py")
&& name != "__init__.py"
&& !name.starts_with('.')
{
script_files.push(format!("scripts/{}", name));
}
}
}
}
}
if script_files.len() == 1 {
return Some(script_files.remove(0));
}
None
}
fn detect_language_from_entry_point(entry_point: &str) -> Option<String> {
if entry_point.ends_with(".py") {
Some("python".to_string())
} else if entry_point.ends_with(".js") || entry_point.ends_with(".ts") {
Some("node".to_string())
} else if entry_point.ends_with(".sh") {
Some("bash".to_string())
} else {
None
}
}
pub fn parse_skill_metadata(skill_dir: &Path) -> Result<SkillMetadata> {
let skill_md_path = skill_dir.join("SKILL.md");
if !skill_md_path.exists() {
anyhow::bail!("SKILL.md not found in directory: {}", skill_dir.display());
}
let content = fs::read_to_string(&skill_md_path)
.with_context(|| format!("Failed to read SKILL.md: {}", skill_md_path.display()))?;
extract_yaml_front_matter_with_detection(&content, skill_dir)
}
fn merge_openclaw_requires(
compat: Option<&str>,
metadata: Option<&serde_json::Value>,
) -> Option<String> {
let openclaw = metadata
.and_then(|m| m.get("openclaw"))
.and_then(|o| o.get("requires"));
let Some(openclaw) = openclaw else {
return compat.map(String::from);
};
let mut adds = Vec::new();
if let Some(bins) = openclaw.get("bins").and_then(|v| v.as_array()) {
let s: Vec<_> = bins.iter().filter_map(|b| b.as_str()).collect();
if !s.is_empty() {
adds.push(format!("Requires bins: {}", s.join(", ")));
}
}
if let Some(env) = openclaw.get("env").and_then(|v| v.as_array()) {
let s: Vec<_> = env.iter().filter_map(|e| e.as_str()).collect();
if !s.is_empty() {
adds.push(format!("Requires env: {}", s.join(", ")));
}
}
if adds.is_empty() {
return compat.map(String::from);
}
let base = compat.unwrap_or("");
let merged = if base.is_empty() {
adds.join(". ")
} else {
format!("{}. {}", base, adds.join(". "))
};
Some(merged)
}
fn infer_capabilities_from_compatibility(
compatibility: &str,
name: &str,
description: &str,
) -> Vec<String> {
let mut caps = std::collections::HashSet::new();
let s = format!("{} {} {}", compatibility, name, description).to_lowercase();
let rules: &[(&str, &str)] = &[
("python", "python"),
("network", "web"),
("网络", "web"),
("http", "web"),
("internet", "web"),
("node.js", "node"),
("nodejs", "node"),
("playwright", "browser"),
("agent-browser", "browser"),
("chromium", "browser"),
("browser", "browser"),
("pandas", "data"),
("numpy", "data"),
("data-analysis", "data"),
("calculator", "calc"),
("计算", "calc"),
("arithmetic", "calc"),
("math", "calc"),
];
for (keyword, tag) in rules {
if s.contains(keyword) {
caps.insert(tag.to_string());
}
}
let mut v: Vec<_> = caps.into_iter().collect();
v.sort();
v
}
#[cfg(test)]
fn extract_yaml_front_matter(content: &str) -> Result<SkillMetadata> {
extract_yaml_front_matter_impl(content, None)
}
fn extract_yaml_front_matter_with_detection(
content: &str,
skill_dir: &Path,
) -> Result<SkillMetadata> {
extract_yaml_front_matter_impl(content, Some(skill_dir))
}
fn normalize_yaml_continuation_lines(yaml: &str) -> String {
YAML_CONTINUATION_RE.replace_all(yaml, " ").to_string()
}
fn extract_yaml_front_matter_impl(
content: &str,
skill_dir: Option<&Path>,
) -> Result<SkillMetadata> {
let captures = YAML_FRONT_MATTER_RE
.captures(content)
.ok_or_else(|| anyhow::anyhow!("No YAML front matter found in SKILL.md"))?;
let yaml_content = captures
.get(1)
.ok_or_else(|| anyhow::anyhow!("Failed to extract YAML content"))?
.as_str();
let yaml_content = normalize_yaml_continuation_lines(yaml_content);
let front_matter: FrontMatter =
serde_yaml::from_str(&yaml_content).with_context(|| "Failed to parse YAML front matter")?;
let mut entry_point = String::new();
if let Some(dir) = skill_dir {
if let Some(ref ep) = front_matter.entry_point {
let ep = ep.trim();
if !ep.is_empty() && dir.join(ep).is_file() {
entry_point = ep.to_string();
}
}
if entry_point.is_empty() {
if let Some(detected) = detect_entry_point(dir) {
entry_point = detected;
}
}
}
let compatibility = merge_openclaw_requires(
front_matter.compatibility.as_deref(),
front_matter.metadata.as_ref(),
);
let language = parse_compatibility_for_language(compatibility.as_deref())
.or_else(|| detect_language_from_entry_point(&entry_point));
let network = parse_compatibility_for_network(compatibility.as_deref());
let resolved_packages =
skill_dir.and_then(|dir| read_lock_file_packages(dir, compatibility.as_deref()));
let requires_elevated = front_matter.requires_elevated_permissions.unwrap_or(false);
let capabilities = if !front_matter.capabilities.is_empty() {
front_matter.capabilities.clone()
} else {
front_matter
.metadata
.as_ref()
.and_then(|m| m.get("capabilities"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.filter(|v: &Vec<String>| !v.is_empty())
.unwrap_or_else(|| {
infer_capabilities_from_compatibility(
compatibility.as_deref().unwrap_or(""),
&front_matter.name,
front_matter.description.as_deref().unwrap_or(""),
)
})
};
let metadata = SkillMetadata {
name: front_matter.name.clone(),
entry_point,
language,
description: front_matter.description.clone(),
version: front_matter
.metadata
.as_ref()
.and_then(|m| m.get("version"))
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
compatibility,
network,
resolved_packages,
allowed_tools: front_matter.allowed_tools.clone(),
requires_elevated_permissions: requires_elevated,
capabilities,
};
if metadata.name.is_empty() {
anyhow::bail!("Skill name is required in SKILL.md");
}
Ok(metadata)
}
fn read_lock_file_packages(skill_dir: &Path, compatibility: Option<&str>) -> Option<Vec<String>> {
let lock_path = skill_dir.join(".skilllite.lock");
let content = fs::read_to_string(&lock_path).ok()?;
let lock: serde_json::Value = serde_json::from_str(&content).ok()?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(compatibility.unwrap_or("").as_bytes());
let current_hash = hex::encode(hasher.finalize());
if lock.get("compatibility_hash")?.as_str()? != current_hash {
return None; }
let arr = lock.get("resolved_packages")?.as_array()?;
let packages: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
if packages.is_empty() {
None
} else {
Some(packages)
}
}
pub fn detect_language(skill_dir: &Path, metadata: &SkillMetadata) -> String {
if let Some(ref lang) = metadata.language {
return lang.clone();
}
if metadata.entry_point.ends_with(".py") {
return "python".to_string();
}
if metadata.entry_point.ends_with(".js") || metadata.entry_point.ends_with(".ts") {
return "node".to_string();
}
if metadata.entry_point.ends_with(".sh") {
return "bash".to_string();
}
let scripts_dir = skill_dir.join("scripts");
if scripts_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&scripts_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension() {
match ext.to_string_lossy().as_ref() {
"py" => return "python".to_string(),
"js" | "ts" => return "node".to_string(),
"sh" => return "bash".to_string(),
_ => {}
}
}
}
}
}
"python".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_infer_capabilities_from_compatibility() {
let caps = infer_capabilities_from_compatibility(
"Requires Python 3.x, network access",
"test",
"",
);
assert!(caps.contains(&"python".to_string()));
assert!(caps.contains(&"web".to_string()));
let caps = infer_capabilities_from_compatibility("", "calculator", "");
assert_eq!(caps, vec!["calc"]);
let caps = infer_capabilities_from_compatibility("", "foo", "basic arithmetic operations");
assert_eq!(caps, vec!["calc"]);
}
#[test]
fn test_parse_yaml_continuation_lines() {
let content = r#"---
name: agent-browser
description: Browser automation CLI for AI agents.
: Requires Node.js with agent-browser Use when the user needs to interact with websites.
allowed-tools: Bash(agent-browser:*)
---
"#;
let metadata =
extract_yaml_front_matter(content).expect("continuation lines should be normalized");
assert_eq!(metadata.name, "agent-browser");
assert!(metadata
.description
.as_ref()
.expect("test skill has description")
.contains("Browser automation CLI"));
assert!(metadata
.description
.as_ref()
.expect("test skill has description")
.contains("Requires Node.js"));
}
#[test]
fn test_parse_yaml_front_matter_with_compatibility() {
let content = r#"---
name: test-skill
description: A test skill for testing
compatibility: Requires Python 3.x with requests library, network access
---
# Test Skill
This is a test skill.
"#;
let metadata =
extract_yaml_front_matter(content).expect("test YAML parsing should succeed");
assert_eq!(metadata.name, "test-skill");
assert_eq!(metadata.language, Some("python".to_string()));
assert!(metadata.network.enabled);
assert_eq!(metadata.network.outbound, vec!["*"]);
assert!(metadata.capabilities.contains(&"python".to_string()));
assert!(metadata.capabilities.contains(&"web".to_string()));
}
#[test]
fn test_parse_compatibility_for_network() {
assert!(parse_compatibility_for_network(Some("Requires network access")).enabled);
assert!(parse_compatibility_for_network(Some("Requires internet")).enabled);
assert!(parse_compatibility_for_network(Some("Requires http client")).enabled);
assert!(parse_compatibility_for_network(Some("Requires API access")).enabled);
assert!(parse_compatibility_for_network(Some("Requires web access")).enabled);
assert!(parse_compatibility_for_network(Some("需网络权限")).enabled);
assert!(parse_compatibility_for_network(Some("Python 3.x,需网络权限")).enabled);
assert!(parse_compatibility_for_network(Some("需要联网")).enabled);
assert!(parse_compatibility_for_network(Some("需要网页访问")).enabled);
assert!(parse_compatibility_for_network(Some("在线服务")).enabled);
assert!(!parse_compatibility_for_network(Some("Requires git, docker")).enabled);
assert!(!parse_compatibility_for_network(Some("Requires Python 3.x")).enabled);
assert!(!parse_compatibility_for_network(None).enabled);
}
#[test]
fn test_parse_compatibility_for_language() {
assert_eq!(
parse_compatibility_for_language(Some("Requires Python 3.x")),
Some("python".to_string())
);
assert_eq!(
parse_compatibility_for_language(Some("Requires Node.js")),
Some("node".to_string())
);
assert_eq!(
parse_compatibility_for_language(Some("Requires JavaScript")),
Some("node".to_string())
);
assert_eq!(
parse_compatibility_for_language(Some("Requires bash")),
Some("bash".to_string())
);
assert_eq!(
parse_compatibility_for_language(Some("Requires git, docker")),
None
);
assert_eq!(parse_compatibility_for_language(None), None);
}
#[test]
fn test_default_network_policy() {
let content = r#"---
name: simple-skill
description: A simple skill
---
"#;
let metadata =
extract_yaml_front_matter(content).expect("test YAML parsing should succeed");
assert!(!metadata.network.enabled);
assert!(metadata.network.outbound.is_empty());
}
#[test]
fn test_parse_allowed_tools_single() {
let patterns = parse_allowed_tools("Bash(agent-browser:*)");
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].command_prefix, "agent-browser");
assert_eq!(patterns[0].raw_pattern, "agent-browser:*");
}
#[test]
fn test_parse_allowed_tools_multiple() {
let patterns = parse_allowed_tools("Bash(agent-browser:*), Bash(npm:*)");
assert_eq!(patterns.len(), 2);
assert_eq!(patterns[0].command_prefix, "agent-browser");
assert_eq!(patterns[1].command_prefix, "npm");
}
#[test]
fn test_parse_allowed_tools_mixed() {
let patterns = parse_allowed_tools("Read, Edit, Bash(mycli:*)");
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].command_prefix, "mycli");
}
#[test]
fn test_parse_allowed_tools_no_colon() {
let patterns = parse_allowed_tools("Bash(simple-tool)");
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].command_prefix, "simple-tool");
}
#[test]
fn test_parse_allowed_tools_empty() {
let patterns = parse_allowed_tools("Read, Edit");
assert!(patterns.is_empty());
}
#[test]
fn test_bash_tool_skill_yaml() {
let content = r#"---
name: agent-browser
description: Headless browser automation for AI agents
allowed-tools: Bash(agent-browser:*)
---
# Agent Browser
Use agent-browser CLI to automate web browsing.
"#;
let metadata =
extract_yaml_front_matter(content).expect("bash tool skill YAML should parse");
assert_eq!(metadata.name, "agent-browser");
assert!(metadata.entry_point.is_empty());
assert_eq!(
metadata.allowed_tools,
Some("Bash(agent-browser:*)".to_string())
);
assert!(metadata.is_bash_tool_skill());
let patterns = metadata.get_bash_patterns();
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].command_prefix, "agent-browser");
}
#[test]
fn test_not_bash_tool_skill_with_entry_point() {
let content = r#"---
name: regular-skill
description: A regular skill with scripts
compatibility: Requires Python 3.x
---
"#;
let metadata = extract_yaml_front_matter(content).expect("regular skill YAML should parse");
assert!(!metadata.is_bash_tool_skill());
}
#[test]
fn test_openclaw_metadata_merge() {
let content = r#"---
name: nano-banana-pro
description: Generate or edit images via Gemini 3 Pro Image
metadata:
openclaw:
requires:
bins: [uv]
env: [GEMINI_API_KEY]
config: [browser.enabled]
primaryEnv: GEMINI_API_KEY
---
"#;
let metadata =
extract_yaml_front_matter(content).expect("OpenClaw format YAML should parse");
assert_eq!(metadata.name, "nano-banana-pro");
assert_eq!(
metadata.compatibility.as_deref(),
Some("Requires bins: uv. Requires env: GEMINI_API_KEY")
);
}
#[test]
fn test_openclaw_metadata_merge_with_base_compatibility() {
let content = r#"---
name: test-skill
description: Test
compatibility: Requires Python 3.x
metadata:
openclaw:
requires:
bins: [uv]
env: [API_KEY]
---
"#;
let metadata = extract_yaml_front_matter(content)
.expect("OpenClaw format with base compat should parse");
assert_eq!(
metadata.compatibility.as_deref(),
Some("Requires Python 3.x. Requires bins: uv. Requires env: API_KEY")
);
assert_eq!(metadata.language, Some("python".to_string()));
}
#[test]
fn test_entry_point_from_front_matter() {
let dir = tempfile::tempdir().expect("temp dir");
let skill_dir = dir.path();
std::fs::create_dir_all(skill_dir.join("scripts")).expect("create scripts");
std::fs::write(skill_dir.join("scripts/entry.py"), "").expect("write entry.py");
let content = r#"---
name: my-skill
entry_point: scripts/entry.py
---
# Doc
"#;
std::fs::write(skill_dir.join("SKILL.md"), content).expect("write SKILL.md");
let meta = parse_skill_metadata(skill_dir).expect("parse skill metadata");
assert_eq!(meta.entry_point, "scripts/entry.py");
}
#[test]
fn test_entry_point_no_explicit_uses_directory_convention() {
let dir = tempfile::tempdir().expect("temp dir");
let skill_dir = dir.path();
std::fs::create_dir_all(skill_dir.join("scripts")).expect("create scripts");
std::fs::write(skill_dir.join("scripts/main.py"), "").expect("write main.py");
let content = r#"---
name: my-skill
---
"#;
std::fs::write(skill_dir.join("SKILL.md"), content).expect("write SKILL.md");
let meta = parse_skill_metadata(skill_dir).expect("parse skill metadata");
assert_eq!(meta.entry_point, "scripts/main.py");
}
}