use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
collections::HashMap,
fs,
path::{Component, Path, PathBuf},
};
const SKILL_DOC: &str = "SKILL.md";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillIndexEntry {
pub id: String,
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillReadResult {
pub skill_id: String,
pub path: Option<String>,
pub content: String,
pub resources: Vec<SkillResourceIndexEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillResourceIndexEntry {
pub path: String,
pub description: Option<String>,
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum SkillError {
#[error("skill root does not exist: {0}")]
RootNotFound(String),
#[error("skill not found: {0}")]
NotFound(String),
#[error("skill resource not found: {skill_id}:{path}")]
ResourceNotFound { skill_id: String, path: String },
#[error("invalid skill path: {0}")]
InvalidPath(String),
#[error("invalid skill metadata in {skill_id}: {message}")]
InvalidMetadata { skill_id: String, message: String },
#[error("skill io error: {0}")]
Io(String),
}
#[derive(Debug, Clone)]
struct SkillEntry {
id: String,
name: String,
description: String,
dir: PathBuf,
}
#[derive(Debug, Default)]
pub struct SkillManager {
skills: HashMap<String, SkillEntry>,
}
impl SkillManager {
pub fn discover_dir(path: impl AsRef<Path>) -> Result<Self, SkillError> {
Self::discover_dirs([path])
}
pub fn discover_dirs<I, P>(paths: I) -> Result<Self, SkillError>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let mut manager = Self::default();
for path in paths {
manager.discover_one(path.as_ref())?;
}
Ok(manager)
}
pub fn list(&self) -> Vec<SkillIndexEntry> {
let mut entries = self
.skills
.values()
.map(|skill| SkillIndexEntry {
id: skill.id.clone(),
name: skill.name.clone(),
description: skill.description.clone(),
})
.collect::<Vec<_>>();
entries.sort_by(|a, b| a.id.cmp(&b.id));
entries
}
pub fn read(&self, skill_id: &str, path: Option<&str>) -> Result<SkillReadResult, SkillError> {
let skill = self
.skills
.get(skill_id)
.ok_or_else(|| SkillError::NotFound(skill_id.to_string()))?;
let resources = resource_index(&skill.dir)?;
match path {
Some(path) => {
validate_relative_path(path)?;
let resource_path = skill.dir.join(path);
if !resource_path.is_file() || path == SKILL_DOC {
return Err(SkillError::ResourceNotFound {
skill_id: skill_id.to_string(),
path: path.to_string(),
});
}
Ok(SkillReadResult {
skill_id: skill.id.clone(),
path: Some(path.to_string()),
content: fs::read_to_string(resource_path)
.map_err(|e| SkillError::Io(e.to_string()))?,
resources,
})
}
None => Ok(SkillReadResult {
skill_id: skill.id.clone(),
path: None,
content: fs::read_to_string(skill.dir.join(SKILL_DOC))
.map_err(|e| SkillError::Io(e.to_string()))?,
resources,
}),
}
}
fn discover_one(&mut self, root: &Path) -> Result<(), SkillError> {
if !root.exists() {
return Err(SkillError::RootNotFound(root.display().to_string()));
}
if root.join(SKILL_DOC).is_file() {
self.insert_skill_dir(root)?;
}
if root.is_dir() {
for entry in fs::read_dir(root).map_err(|e| SkillError::Io(e.to_string()))? {
let entry = entry.map_err(|e| SkillError::Io(e.to_string()))?;
let path = entry.path();
if path.is_dir() && path.join(SKILL_DOC).is_file() {
self.insert_skill_dir(&path)?;
}
}
}
Ok(())
}
fn insert_skill_dir(&mut self, dir: &Path) -> Result<(), SkillError> {
let id = dir
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| SkillError::InvalidPath(dir.display().to_string()))?
.to_string();
let doc =
fs::read_to_string(dir.join(SKILL_DOC)).map_err(|e| SkillError::Io(e.to_string()))?;
let (name, description) = parse_skill_doc(&id, &doc)?;
self.skills.insert(
id.clone(),
SkillEntry {
id,
name,
description,
dir: dir.to_path_buf(),
},
);
Ok(())
}
}
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
name: String,
description: String,
}
impl SkillReadResult {
pub fn into_value(self) -> serde_json::Value {
json!({
"skill_id": self.skill_id,
"path": self.path,
"content": self.content,
"resources": self.resources,
})
}
}
fn parse_skill_doc(id: &str, doc: &str) -> Result<(String, String), SkillError> {
let frontmatter = extract_frontmatter(id, doc)?;
let metadata = serde_yaml::from_str::<SkillFrontmatter>(&frontmatter).map_err(|e| {
SkillError::InvalidMetadata {
skill_id: id.to_string(),
message: e.to_string(),
}
})?;
let name = metadata.name.trim();
let description = metadata.description.trim();
if name.is_empty() {
return Err(SkillError::InvalidMetadata {
skill_id: id.to_string(),
message: "missing or empty `name`".to_string(),
});
}
if description.is_empty() {
return Err(SkillError::InvalidMetadata {
skill_id: id.to_string(),
message: "missing or empty `description`".to_string(),
});
}
Ok((name.to_string(), description.to_string()))
}
fn extract_frontmatter(id: &str, doc: &str) -> Result<String, SkillError> {
let doc = doc.strip_prefix('\u{feff}').unwrap_or(doc);
let mut lines = doc.lines();
match lines.next() {
Some(line) if line.trim() == "---" => {}
_ => {
return Err(SkillError::InvalidMetadata {
skill_id: id.to_string(),
message: "`SKILL.md` must start with YAML frontmatter".to_string(),
});
}
}
let mut frontmatter = Vec::new();
for line in lines {
if line.trim() == "---" {
return Ok(frontmatter.join("\n"));
}
frontmatter.push(line);
}
Err(SkillError::InvalidMetadata {
skill_id: id.to_string(),
message: "missing closing frontmatter delimiter".to_string(),
})
}
fn resource_index(skill_dir: &Path) -> Result<Vec<SkillResourceIndexEntry>, SkillError> {
let mut out = Vec::new();
collect_resources(skill_dir, skill_dir, &mut out)?;
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
fn collect_resources(
base: &Path,
dir: &Path,
out: &mut Vec<SkillResourceIndexEntry>,
) -> Result<(), SkillError> {
for entry in fs::read_dir(dir).map_err(|e| SkillError::Io(e.to_string()))? {
let entry = entry.map_err(|e| SkillError::Io(e.to_string()))?;
let path = entry.path();
if path.is_dir() {
collect_resources(base, &path, out)?;
continue;
}
let rel = path
.strip_prefix(base)
.map_err(|e| SkillError::InvalidPath(e.to_string()))?;
let rel = rel.to_string_lossy().replace('\\', "/");
if rel == SKILL_DOC {
continue;
}
out.push(SkillResourceIndexEntry {
path: rel,
description: None,
});
}
Ok(())
}
fn validate_relative_path(path: &str) -> Result<(), SkillError> {
let path = Path::new(path);
if path.is_absolute() {
return Err(SkillError::InvalidPath(path.display().to_string()));
}
for component in path.components() {
if matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
) {
return Err(SkillError::InvalidPath(path.display().to_string()));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_required_yaml_frontmatter() {
let doc = r#"---
name: "docs"
description: |
Work with document files.
---
# Documents
main skill instructions
"#;
let (name, description) = parse_skill_doc("docs", doc).unwrap();
assert_eq!(name, "docs");
assert_eq!(description, "Work with document files.");
}
#[test]
fn rejects_skill_doc_without_frontmatter() {
let err = parse_skill_doc("docs", "# Documents\n\nWork with document files").unwrap_err();
assert!(matches!(
err,
SkillError::InvalidMetadata { skill_id, .. } if skill_id == "docs"
));
}
}