use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::evidence::hasher::sha256_bytes;
pub struct BundledSkill {
pub name: &'static str,
pub body: &'static str,
}
macro_rules! bundled {
($name:literal) => {
BundledSkill {
name: $name,
body: include_str!(concat!("../skills/", $name, ".md")),
}
};
}
pub const BUNDLED: &[BundledSkill] = &[
bundled!("capture-sweep"),
bundled!("attestation-review"),
bundled!("policy-annual-review"),
bundled!("report"),
bundled!("bootstrap"),
bundled!("inventory-seed"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillSource {
Local,
Bundled,
}
impl SkillSource {
pub fn as_str(self) -> &'static str {
match self {
SkillSource::Local => "local",
SkillSource::Bundled => "bundled",
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedSkill {
pub name: String,
pub body: String,
pub source: SkillSource,
pub path: Option<PathBuf>,
pub sha256: String,
}
pub fn resolve(root: &Path, name: &str) -> Option<ResolvedSkill> {
let local = root.join("skills").join(format!("{name}.md"));
if local.is_file() {
match std::fs::read(&local) {
Ok(bytes) => {
return Some(ResolvedSkill {
name: name.to_string(),
sha256: sha256_bytes(&bytes),
body: String::from_utf8_lossy(&bytes).into_owned(),
source: SkillSource::Local,
path: Some(local),
});
}
Err(e) => {
tracing::warn!(
path = %local.display(),
error = %e,
"skill `{name}` exists locally but could not be read; falling back to bundled"
);
}
}
}
BUNDLED
.iter()
.find(|s| s.name == name)
.map(|s| ResolvedSkill {
name: name.to_string(),
body: s.body.to_string(),
source: SkillSource::Bundled,
path: None,
sha256: sha256_bytes(s.body.as_bytes()),
})
}
pub fn exists(root: &Path, name: &str) -> bool {
root.join("skills").join(format!("{name}.md")).is_file()
|| BUNDLED.iter().any(|s| s.name == name)
}
pub fn frontmatter(body: &str) -> Option<&str> {
let rest = body.strip_prefix("---\n")?;
let end = rest.find("\n---")?;
Some(&rest[..end])
}
pub fn description(body: &str) -> Option<String> {
let fm = frontmatter(body)?;
let parsed: serde_yaml::Value = serde_yaml::from_str(fm).ok()?;
parsed
.get("description")
.and_then(|v| v.as_str())
.map(str::to_string)
}
pub fn requires_features(body: &str) -> Vec<String> {
let Some(fm) = frontmatter(body) else {
return Vec::new();
};
let Ok(parsed) = serde_yaml::from_str::<serde_yaml::Value>(fm) else {
return Vec::new();
};
parsed
.get("requires_features")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_bundled_skill_has_matching_frontmatter_name() {
for s in BUNDLED {
let fm = frontmatter(s.body)
.unwrap_or_else(|| panic!("bundled skill {} lacks frontmatter", s.name));
let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
assert_eq!(
parsed.get("name").and_then(|v| v.as_str()),
Some(s.name),
"bundled skill `{}` frontmatter name must match its registry key",
s.name
);
}
}
#[test]
fn resolve_falls_back_to_bundled() {
let tmp = tempfile::tempdir().unwrap();
let r = resolve(tmp.path(), "capture-sweep").expect("capture-sweep is bundled");
assert_eq!(r.source, SkillSource::Bundled);
assert!(!r.sha256.is_empty());
assert!(r.body.contains("# Capture sweep"));
}
#[test]
fn resolve_local_overrides_bundled() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("skills")).unwrap();
let body = "---\nname: capture-sweep\n---\n# local override\n";
std::fs::write(tmp.path().join("skills/capture-sweep.md"), body).unwrap();
let r = resolve(tmp.path(), "capture-sweep").expect("resolves locally");
assert_eq!(r.source, SkillSource::Local);
assert_eq!(r.body, body);
assert_eq!(r.sha256, sha256_bytes(body.as_bytes()));
}
#[test]
fn resolve_unknown_is_none() {
let tmp = tempfile::tempdir().unwrap();
assert!(resolve(tmp.path(), "no-such-skill").is_none());
}
#[test]
fn frontmatter_extract_basic() {
let body = "---\nname: x\nrequires_features: [a, b]\n---\n# body";
assert_eq!(requires_features(body), vec!["a", "b"]);
}
}