use std::path::Path;
use crate::error::PluginError;
use regex::Regex;
use serde::Deserialize;
#[derive(Debug, Clone, Default)]
pub struct SkillManifest {
pub name: String,
pub description: String,
pub version: Option<String>,
pub author: Option<String>,
pub license: Option<String>,
pub compatibility: Option<String>,
pub platforms: Vec<String>,
pub required_environment_variables: Vec<RequiredEnvVar>,
pub collect_secrets: Vec<CollectSecret>,
pub setup_help: Option<String>,
pub tags: Vec<String>,
pub related_skills: Vec<String>,
pub category: Option<String>,
pub body: String,
}
#[derive(Debug, Clone, Default)]
pub struct RequiredEnvVar {
pub name: String,
pub prompt: String,
pub help: String,
pub required_for: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct CollectSecret {
pub env_var: String,
pub prompt: String,
pub help: String,
pub secret: bool,
}
#[derive(Debug, Deserialize, Default)]
struct SkillFrontmatter {
name: Option<String>,
description: Option<String>,
version: Option<String>,
author: Option<String>,
license: Option<String>,
compatibility: Option<String>,
#[serde(default)]
platforms: PlatformList,
#[serde(default)]
prerequisites: Option<LegacyPrerequisites>,
#[serde(default)]
required_environment_variables: Vec<RequiredEnvVarRaw>,
#[serde(default)]
setup: Option<SetupBlock>,
#[serde(default)]
metadata: Option<MetadataBlock>,
}
#[derive(Debug, Deserialize, Default)]
struct LegacyPrerequisites {
#[serde(default)]
env_vars: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(untagged)]
enum PlatformList {
#[default]
Missing,
One(String),
Many(Vec<String>),
}
impl PlatformList {
fn into_vec(self) -> Vec<String> {
match self {
Self::Missing => Vec::new(),
Self::One(value) => vec![value],
Self::Many(values) => values,
}
}
}
#[derive(Debug, Deserialize, Default)]
struct RequiredEnvVarRaw {
#[serde(default)]
name: Option<String>,
#[serde(default)]
env_var: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
help: Option<String>,
#[serde(default)]
required_for: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct SetupBlock {
#[serde(default)]
help: Option<String>,
#[serde(default)]
collect_secrets: Vec<CollectSecretRaw>,
}
#[derive(Debug, Deserialize, Default)]
struct CollectSecretRaw {
env_var: String,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
help: Option<String>,
#[serde(default)]
provider_url: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default = "default_secret_true")]
secret: bool,
}
#[derive(Debug, Deserialize, Default)]
struct MetadataBlock {
#[serde(default)]
hermes: Option<HermesMetadataBlock>,
}
#[derive(Debug, Deserialize, Default)]
struct HermesMetadataBlock {
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
related_skills: Vec<String>,
#[serde(default)]
category: Option<String>,
}
fn default_secret_true() -> bool {
true
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let stripped = content.strip_prefix("---\n")?;
let end = stripped.find("\n---\n")?;
Some((&stripped[..end], &stripped[end + 5..]))
}
fn parse_simple_frontmatter(frontmatter: &str) -> SkillFrontmatter {
let mut raw = serde_json::Map::new();
for line in frontmatter.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
raw.insert(
key.trim().to_string(),
serde_json::Value::String(value.trim().trim_matches('"').to_string()),
);
}
serde_json::from_value(serde_json::Value::Object(raw)).unwrap_or_default()
}
fn parse_frontmatter(content: &str) -> SkillFrontmatter {
let Some((frontmatter, _body)) = split_frontmatter(content) else {
return SkillFrontmatter::default();
};
serde_yml::from_str(frontmatter).unwrap_or_else(|_| parse_simple_frontmatter(frontmatter))
}
fn skill_body(content: &str) -> &str {
split_frontmatter(content)
.map(|(_, body)| body)
.unwrap_or(content)
}
pub fn parse_skill_manifest(path: &Path) -> Result<SkillManifest, PluginError> {
let content = std::fs::read_to_string(path)?;
parse_skill_manifest_str(path, &content)
}
pub fn parse_skill_manifest_str(path: &Path, content: &str) -> Result<SkillManifest, PluginError> {
let frontmatter = parse_frontmatter(content);
let body = skill_body(content);
let name = frontmatter.name.unwrap_or_else(|| {
path.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.unwrap_or("skill")
.to_string()
});
let description = frontmatter.description.unwrap_or_default();
let name_re = Regex::new(r"^[a-z0-9][a-z0-9._-]*$").expect("valid regex");
if body.trim().is_empty() {
return Err(PluginError::InvalidSkill {
path: path.to_path_buf(),
message: "SKILL.md must have content after the frontmatter.".into(),
});
}
if name.len() > 64 || !name_re.is_match(&name) {
return Err(PluginError::InvalidSkill {
path: path.to_path_buf(),
message: format!("name '{name}' does not match pattern ^[a-z0-9][a-z0-9._-]*$"),
});
}
if description.len() > 1024 {
return Err(PluginError::InvalidSkill {
path: path.to_path_buf(),
message: "description exceeds 1024 characters".into(),
});
}
let env_name_re = Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").expect("valid regex");
let mut required_environment_variables = Vec::new();
let setup_help = frontmatter
.setup
.as_ref()
.and_then(|setup| setup.help.clone());
if frontmatter.required_environment_variables.is_empty() {
if let Some(prerequisites) = frontmatter.prerequisites {
for env_var in prerequisites.env_vars {
required_environment_variables.push(RequiredEnvVar {
prompt: format!("Enter value for {env_var}"),
name: env_var,
help: setup_help.clone().unwrap_or_default(),
required_for: None,
});
}
}
} else {
for entry in frontmatter.required_environment_variables {
let name = entry
.name
.or(entry.env_var)
.unwrap_or_default()
.trim()
.to_string();
if !env_name_re.is_match(&name) {
return Err(PluginError::InvalidSkill {
path: path.to_path_buf(),
message: format!("invalid environment variable name '{name}'"),
});
}
required_environment_variables.push(RequiredEnvVar {
prompt: entry
.prompt
.unwrap_or_else(|| format!("Enter value for {name}")),
help: entry
.help
.or_else(|| setup_help.clone())
.unwrap_or_default(),
required_for: entry.required_for,
name,
});
}
}
let collect_secrets = frontmatter
.setup
.map(|setup| {
setup
.collect_secrets
.into_iter()
.map(|entry| CollectSecret {
prompt: entry
.prompt
.unwrap_or_else(|| format!("Enter value for {}", entry.env_var)),
help: entry
.help
.or(entry.provider_url)
.or(entry.url)
.or_else(|| setup.help.clone())
.unwrap_or_default(),
secret: entry.secret,
env_var: entry.env_var,
})
.collect()
})
.unwrap_or_default();
let hermes_metadata = frontmatter
.metadata
.and_then(|metadata| metadata.hermes)
.unwrap_or_default();
Ok(SkillManifest {
name,
description,
version: frontmatter.version,
author: frontmatter.author,
license: frontmatter.license,
compatibility: frontmatter.compatibility,
platforms: frontmatter.platforms.into_vec(),
required_environment_variables,
collect_secrets,
setup_help,
tags: hermes_metadata.tags,
related_skills: hermes_metadata.related_skills,
category: hermes_metadata.category,
body: body.trim().to_string(),
})
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::process::Command;
use std::sync::OnceLock;
use super::*;
const REAL_HERMES_REPO_URL: &str = "https://github.com/NousResearch/hermes-agent";
const REAL_HERMES_REF: &str = "268ee6bdce013c74c9a8dfbb13fd850423189322";
fn real_hermes_repo() -> &'static PathBuf {
static REPO: OnceLock<PathBuf> = OnceLock::new();
REPO.get_or_init(|| {
let path = std::env::temp_dir()
.join(format!("lingshu-hermes-agent-skills-{REAL_HERMES_REF}"));
if path.exists() {
return path;
}
let status = Command::new("git")
.args([
"clone",
"--depth",
"1",
REAL_HERMES_REPO_URL,
path.to_str().expect("utf8 path"),
])
.status()
.expect("git clone hermes-agent");
if !status.success() {
assert!(
path.join("skills").is_dir() || path.join("optional-skills").is_dir(),
"failed to clone hermes-agent fixtures"
);
return path;
}
let status = Command::new("git")
.args([
"-C",
path.to_str().expect("utf8 path"),
"checkout",
REAL_HERMES_REF,
])
.status()
.expect("git checkout hermes-agent ref");
if status.success() {
return path;
}
let fetch = Command::new("git")
.args([
"-C",
path.to_str().expect("utf8 path"),
"fetch",
"--depth",
"1",
"origin",
REAL_HERMES_REF,
])
.status()
.expect("git fetch hermes-agent ref");
if fetch.success() {
let checkout = Command::new("git")
.args([
"-C",
path.to_str().expect("utf8 path"),
"checkout",
"FETCH_HEAD",
])
.status()
.expect("git checkout fetched hermes-agent ref");
assert!(
checkout.success(),
"failed to checkout fetched hermes-agent ref"
);
return path;
}
eprintln!(
"warning: failed to resolve pinned hermes-agent ref {REAL_HERMES_REF}; using cloned default branch HEAD"
);
path
})
}
#[test]
fn parses_env_var_alias_and_setup_help_fallback() {
let path = Path::new("/tmp/demo/SKILL.md");
let skill = parse_skill_manifest_str(
path,
r#"---
name: demo
description: Demo
required_environment_variables:
- env_var: DEMO_TOKEN
setup:
help: https://example.com/help
collect_secrets:
- env_var: SECOND_TOKEN
---
# Demo
Use the demo workflow.
"#,
)
.expect("skill parses");
assert_eq!(skill.required_environment_variables[0].name, "DEMO_TOKEN");
assert_eq!(skill.collect_secrets[0].help, "https://example.com/help");
}
#[test]
fn rejects_empty_body() {
let path = Path::new("/tmp/demo/SKILL.md");
let error = parse_skill_manifest_str(path, "---\nname: demo\ndescription: Demo\n---\n\n")
.expect_err("empty body rejected");
assert!(
error
.to_string()
.contains("SKILL.md must have content after the frontmatter.")
);
}
#[test]
fn missing_frontmatter_is_treated_as_body() {
let path = Path::new("/tmp/demo/SKILL.md");
let skill = parse_skill_manifest_str(path, "# Demo\n\nBody.\n").expect("skill parses");
assert_eq!(skill.name, "demo");
assert_eq!(skill.description, "");
assert_eq!(skill.body, "# Demo\n\nBody.");
}
#[test]
fn malformed_frontmatter_falls_back_to_simple_key_value_parsing() {
let path = Path::new("/tmp/demo/SKILL.md");
let skill = parse_skill_manifest_str(
path,
"---\nname: demo\ndescription: broken: yaml: here\nplatforms: macos\n---\n\nBody.\n",
)
.expect("skill parses");
assert_eq!(skill.name, "demo");
assert_eq!(skill.description, "broken: yaml: here");
assert_eq!(skill.platforms, vec!["macos"]);
}
#[test]
fn parses_hermes_metadata_fields() {
let path = Path::new("/tmp/demo/SKILL.md");
let skill = parse_skill_manifest_str(
path,
r#"---
name: demo
description: Demo
compatibility: Requires macOS 13+
metadata:
hermes:
tags: [GitHub, Issues]
related_skills: [github-auth, github-pr-workflow]
category: version-control
---
Body.
"#,
)
.expect("skill parses");
assert_eq!(skill.compatibility.as_deref(), Some("Requires macOS 13+"));
assert_eq!(skill.tags, vec!["GitHub", "Issues"]);
assert_eq!(
skill.related_skills,
vec!["github-auth", "github-pr-workflow"]
);
assert_eq!(skill.category.as_deref(), Some("version-control"));
}
#[test]
fn setup_help_falls_back_for_required_environment_variables() {
let path = Path::new("/tmp/demo/SKILL.md");
let skill = parse_skill_manifest_str(
path,
r#"---
name: demo
description: Demo
required_environment_variables:
- name: DEMO_TOKEN
setup:
help: https://example.com/setup
---
Body.
"#,
)
.expect("skill parses");
assert_eq!(
skill.required_environment_variables[0].help,
"https://example.com/setup"
);
}
#[test]
fn parses_real_github_issues_skill_without_mutating_it() {
let skill_path = real_hermes_repo().join("skills/github/github-issues/SKILL.md");
let before = std::fs::read_to_string(&skill_path).expect("read skill before parse");
let skill = parse_skill_manifest(&skill_path).expect("parse real github-issues skill");
let after = std::fs::read_to_string(&skill_path).expect("read skill after parse");
assert_eq!(skill.name, "github-issues");
assert!(skill.tags.iter().any(|tag| tag == "GitHub"));
assert_eq!(
skill.related_skills,
vec!["github-auth", "github-pr-workflow"]
);
assert!(skill.body.contains("~/.hermes/.env"));
let injected = crate::skill::inject::build_prompt_fragment(&skill.name, &skill.body);
assert!(injected.contains("~/.lingshu/.env"));
assert_eq!(before, after);
}
#[test]
fn parses_real_1password_setup_block() {
let skill_path = real_hermes_repo().join("optional-skills/security/1password/SKILL.md");
let skill = parse_skill_manifest(&skill_path).expect("parse real 1password skill");
assert_eq!(skill.name, "1password");
assert_eq!(skill.category.as_deref(), Some("security"));
assert_eq!(
skill.setup_help.as_deref(),
Some(
"Create a service account at https://my.1password.com → Settings → Service Accounts"
)
);
assert_eq!(skill.collect_secrets.len(), 1);
assert_eq!(skill.collect_secrets[0].env_var, "OP_SERVICE_ACCOUNT_TOKEN");
assert_eq!(
skill.collect_secrets[0].help,
"https://developer.1password.com/docs/service-accounts/"
);
assert!(skill.collect_secrets[0].secret);
}
}