use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SkillMeta {
pub name: String,
pub description: String,
pub tags: Vec<String>,
pub body_excerpt: String,
pub path: PathBuf,
pub source: SkillSource,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SkillSource {
Project,
User,
Plugin,
}
impl std::fmt::Display for SkillSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkillSource::Project => write!(f, "project"),
SkillSource::User => write!(f, "user"),
SkillSource::Plugin => write!(f, "plugin"),
}
}
}
pub fn discover_all(working_dir: &Path) -> Vec<SkillMeta> {
let mut skills = Vec::new();
let project_dir = working_dir.join(".claude").join("skills");
skills.extend(scan_directory(&project_dir, SkillSource::Project));
let user_dir = crate::config::config_dir().join("skills");
skills.extend(scan_directory(&user_dir, SkillSource::User));
let plugins_dir = crate::config::config_dir().join("plugins");
if let Ok(entries) = std::fs::read_dir(&plugins_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let plugin_skills_dir = path.join("skills");
if plugin_skills_dir.exists() {
skills.extend(scan_directory(&plugin_skills_dir, SkillSource::Plugin));
}
}
}
tracing::info!(
project_count = skills
.iter()
.filter(|s| s.source == SkillSource::Project)
.count(),
user_count = skills
.iter()
.filter(|s| s.source == SkillSource::User)
.count(),
plugin_count = skills
.iter()
.filter(|s| s.source == SkillSource::Plugin)
.count(),
"Skills discovered",
);
skills
}
fn scan_directory(dir: &Path, source: SkillSource) -> Vec<SkillMeta> {
let mut skills = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return skills,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
match parse_skill_md(&skill_md, source.clone()) {
Ok(meta) => {
tracing::debug!(name = %meta.name, source = %meta.source, "Skill found");
skills.push(meta);
}
Err(e) => {
tracing::warn!("Failed to parse {:?}: {e}", skill_md);
}
}
}
skills
}
fn parse_skill_md(path: &Path, source: SkillSource) -> anyhow::Result<SkillMeta> {
let content = std::fs::read_to_string(path)?;
let frontmatter = extract_frontmatter(&content)
.ok_or_else(|| anyhow::anyhow!("No YAML frontmatter found"))?;
let name = extract_yaml_field(&frontmatter, "name")
.ok_or_else(|| anyhow::anyhow!("Missing 'name' field in frontmatter"))?;
let description = extract_yaml_field(&frontmatter, "description")
.ok_or_else(|| anyhow::anyhow!("Missing 'description' field in frontmatter"))?;
let tags: Vec<String> = {
let t = crate::config::extract_yaml_list(&frontmatter, "tags");
if t.is_empty() {
crate::config::extract_yaml_list(&frontmatter, "categories")
} else {
t
}
};
if name.len() > 64 {
anyhow::bail!("Skill name too long (max 64 chars)");
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
anyhow::bail!("Skill name must contain only lowercase letters, digits, and hyphens");
}
if description.is_empty() || description.len() > 1024 {
anyhow::bail!("Description must be 1-1024 characters");
}
let body_excerpt = {
let trimmed = content.trim_start();
let after_first = &trimmed[3..]; if let Some(end) = after_first.find("---") {
let body = after_first[end + 3..].trim();
let mut byte_end = body.len().min(800);
while byte_end > 0 && !body.is_char_boundary(byte_end) {
byte_end -= 1;
}
body[..byte_end].to_string()
} else {
String::new()
}
};
Ok(SkillMeta {
name,
description,
tags,
body_excerpt,
path: path.to_path_buf(),
source,
})
}
pub fn load_skill_body(path: &Path) -> anyhow::Result<String> {
let content = std::fs::read_to_string(path)?;
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Ok(content);
}
if let Some(end) = trimmed[3..].find("---") {
let body_start = 3 + end + 3;
Ok(trimmed[body_start..].trim().to_string())
} else {
Ok(content)
}
}
pub fn list_skill_resources(skill_path: &Path) -> Vec<PathBuf> {
let dir = match skill_path.parent() {
Some(d) => d,
None => return Vec::new(),
};
let mut resources = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path.file_name().map(|n| n.to_string_lossy().to_string())
!= Some("SKILL.md".to_string())
{
resources.push(path);
}
}
}
resources
}
fn extract_frontmatter(content: &str) -> Option<String> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_first = &trimmed[3..];
let end = after_first.find("---")?;
Some(after_first[..end].to_string())
}
fn extract_yaml_field(yaml: &str, field: &str) -> Option<String> {
for line in yaml.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix(field) {
let rest = rest.trim_start();
if let Some(value) = rest.strip_prefix(':') {
let value = value.trim();
let value = value.trim_matches('"').trim_matches('\'');
if !value.is_empty() {
return Some(value.to_string());
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_extract_frontmatter() {
let content = "---\nname: test\ndescription: A test\n---\n\n# Body";
let fm = extract_frontmatter(content).unwrap();
assert!(fm.contains("name: test"));
assert!(fm.contains("description: A test"));
}
#[test]
fn test_extract_frontmatter_none() {
assert!(extract_frontmatter("# No frontmatter").is_none());
}
#[test]
fn test_extract_yaml_field() {
let yaml = "name: my-skill\ndescription: Does things";
assert_eq!(extract_yaml_field(yaml, "name").unwrap(), "my-skill");
assert_eq!(
extract_yaml_field(yaml, "description").unwrap(),
"Does things"
);
}
#[test]
fn test_extract_yaml_field_quoted() {
let yaml = "name: \"my-skill\"\ndescription: 'quoted desc'";
assert_eq!(extract_yaml_field(yaml, "name").unwrap(), "my-skill");
assert_eq!(
extract_yaml_field(yaml, "description").unwrap(),
"quoted desc"
);
}
#[test]
fn test_extract_yaml_field_missing() {
let yaml = "name: test";
assert!(extract_yaml_field(yaml, "description").is_none());
}
#[test]
fn test_parse_skill_md() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("my-skill");
fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md");
fs::write(&skill_md, "---\nname: my-skill\ndescription: A test skill for demos\n---\n\n# Instructions\nDo the thing.").unwrap();
let meta = parse_skill_md(&skill_md, SkillSource::Project).unwrap();
assert_eq!(meta.name, "my-skill");
assert_eq!(meta.description, "A test skill for demos");
assert_eq!(meta.source, SkillSource::Project);
}
#[test]
fn test_parse_skill_md_invalid_name() {
let dir = tempfile::tempdir().unwrap();
let skill_md = dir.path().join("SKILL.md");
fs::write(
&skill_md,
"---\nname: Bad Name!\ndescription: Invalid\n---\n",
)
.unwrap();
assert!(parse_skill_md(&skill_md, SkillSource::Project).is_err());
}
#[test]
fn test_load_skill_body() {
let dir = tempfile::tempdir().unwrap();
let skill_md = dir.path().join("SKILL.md");
fs::write(
&skill_md,
"---\nname: test\ndescription: Test\n---\n\n# Instructions\nDo stuff.",
)
.unwrap();
let body = load_skill_body(&skill_md).unwrap();
assert!(body.contains("# Instructions"));
assert!(body.contains("Do stuff."));
assert!(!body.contains("name:"));
}
#[test]
fn test_discover_all() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join(".claude").join("skills").join("test-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: test-skill\ndescription: A test\n---\n# Body",
)
.unwrap();
let skills = discover_all(dir.path());
let project_skills: Vec<_> = skills
.iter()
.filter(|s| s.source == SkillSource::Project)
.collect();
assert_eq!(project_skills.len(), 1);
assert_eq!(project_skills[0].name, "test-skill");
assert_eq!(project_skills[0].source, SkillSource::Project);
}
#[test]
fn test_list_skill_resources() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("my-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: x\ndescription: x\n---",
)
.unwrap();
fs::write(skill_dir.join("REFERENCE.md"), "# Reference").unwrap();
fs::write(skill_dir.join("helper.py"), "print('hi')").unwrap();
let resources = list_skill_resources(&skill_dir.join("SKILL.md"));
assert_eq!(resources.len(), 2);
}
}