use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMetadata {
pub name: String,
pub description: String,
pub path: PathBuf,
}
pub struct SkillScanner {
search_dirs: Vec<PathBuf>,
}
impl SkillScanner {
pub fn new(search_dirs: Vec<PathBuf>) -> Self {
Self { search_dirs }
}
pub fn scan(&self) -> Vec<SkillMetadata> {
let mut skills = Vec::new();
for dir in &self.search_dirs {
let expanded_dir = crate::spec_ai_plugin::expand_tilde(dir);
if !expanded_dir.exists() {
continue;
}
for entry in WalkDir::new(expanded_dir)
.max_depth(3) .into_iter()
.filter_map(|e| e.ok())
{
if entry.file_name() == "SKILL.md" {
if let Ok(metadata) = self.parse_skill_file(entry.path()) {
skills.push(metadata);
}
}
}
}
skills
}
fn parse_skill_file(&self, path: &Path) -> anyhow::Result<SkillMetadata> {
let content = std::fs::read_to_string(path)?;
let mut name = String::new();
let mut description = String::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line.starts_with("# ") && name.is_empty() {
name = line.trim_start_matches("# ").to_string();
} else if !name.is_empty() && description.is_empty() && !line.starts_with('#') {
description = line.to_string();
}
if !name.is_empty() && !description.is_empty() {
break;
}
}
if name.is_empty() {
name = path
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or("unknown-skill")
.to_string();
}
Ok(SkillMetadata {
name,
description,
path: path.to_path_buf(),
})
}
}