use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMeta {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct Skill {
pub meta: SkillMeta,
pub content: String,
pub path: PathBuf,
}
fn parse_frontmatter(content: &str) -> Result<(SkillMeta, String)> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Ok((
SkillMeta {
name: String::new(),
description: String::new(),
},
content.to_string(),
));
}
let after_open = &trimmed[3..];
let closing_pos = after_open.find("---").context("unclosed frontmatter")?;
let yaml_content = &after_open[..closing_pos];
let rest = &after_open[closing_pos + 3..];
let mut name = String::new();
let mut description = String::new();
for line in yaml_content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(val) = line.strip_prefix("name:") {
name = val.trim().trim_matches('"').trim_matches('\'').to_string();
} else if let Some(val) = line.strip_prefix("description:") {
description = val.trim().trim_matches('"').trim_matches('\'').to_string();
}
}
Ok((
SkillMeta { name, description },
rest.trim_start().to_string(),
))
}
#[derive(Clone)]
pub struct SkillStore {
skills_dir: PathBuf,
}
impl std::fmt::Debug for SkillStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SkillStore")
.field("skills_dir", &self.skills_dir)
.finish()
}
}
impl SkillStore {
pub fn new(skills_dir: PathBuf) -> Result<Self> {
Ok(Self { skills_dir })
}
pub async fn init_defaults(&self, defaults_dir: &PathBuf) -> Result<()> {
if !self.skills_dir.exists() {
fs::create_dir_all(&self.skills_dir).await?;
}
{
let mut entries = fs::read_dir(&self.skills_dir).await?;
let mut count = 0;
while entries.next_entry().await?.is_some() {
count += 1;
}
if count > 0 {
return Ok(()); }
}
if defaults_dir.exists() {
let mut entries = fs::read_dir(defaults_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let src = entry.path();
if src.is_dir() {
let skill_name = src
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let dest = self.skills_dir.join(skill_name);
fs::create_dir_all(&dest).await?;
let mut skill_files = fs::read_dir(&src).await?;
while let Some(sfile) = skill_files.next_entry().await? {
if sfile.file_name() == "SKILL.md" {
let content = fs::read_to_string(sfile.path()).await?;
let dest_file = dest.join("SKILL.md");
if !dest_file.exists() {
fs::write(&dest_file, content).await?;
}
}
}
}
}
}
Ok(())
}
pub async fn list_skills(&self) -> Result<Vec<SkillMeta>> {
let mut skills = Vec::new();
if !self.skills_dir.exists() {
return Ok(skills);
}
let mut entries = fs::read_dir(&self.skills_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
if let Ok(content) = fs::read_to_string(&skill_file).await {
if let Ok((meta, _)) = parse_frontmatter(&content) {
if !meta.name.is_empty() {
skills.push(meta);
}
}
}
}
}
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
Ok(skills)
}
pub async fn load_skill(&self, name: &str) -> Result<Option<Skill>> {
let skill_path = self.skills_dir.join(name).join("SKILL.md");
if !skill_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&skill_path).await?;
let (meta, content) = parse_frontmatter(&content)?;
Ok(Some(Skill {
meta,
content,
path: skill_path,
}))
}
pub async fn create_skill(&self, name: &str, description: &str, content: &str) -> Result<()> {
fs::create_dir_all(self.skills_dir.join(name)).await?;
let skill_file = self.skills_dir.join(name).join("SKILL.md");
let frontmatter = format!(
"---\nname: {}\ndescription: {}\n---\n\n{}",
name, description, content
);
fs::write(&skill_file, frontmatter).await?;
Ok(())
}
pub async fn delete_skill(&self, name: &str) -> Result<()> {
let skill_dir = self.skills_dir.join(name);
if skill_dir.exists() {
fs::remove_dir_all(&skill_dir).await?;
}
Ok(())
}
pub fn path(&self) -> &PathBuf {
&self.skills_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_frontmatter_with_metadata() {
let content = r#"---
name: code-review
description: Guidelines for reviewing code changes
---
# Code Review
Follow these steps to review code effectively.
"#;
let (meta, rest) = parse_frontmatter(content).unwrap();
assert_eq!(meta.name, "code-review");
assert_eq!(meta.description, "Guidelines for reviewing code changes");
assert!(rest.contains("Code Review"));
}
#[test]
fn test_parse_frontmatter_no_metadata() {
let content = "# Just a Title\n\nSome content";
let (meta, rest) = parse_frontmatter(content).unwrap();
assert!(meta.name.is_empty());
assert!(rest.contains("Just a Title"));
}
#[test]
fn test_parse_frontmatter_quoted_values() {
let content = r#"---
name: "test-skill"
description: 'A test skill'
---
Content here
"#;
let (meta, _) = parse_frontmatter(content).unwrap();
assert_eq!(meta.name, "test-skill");
assert_eq!(meta.description, "A test skill");
}
}