use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::helpers::dirs_home;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillSource {
User,
Plugin,
Project,
}
impl SkillSource {
pub fn label(&self) -> &'static str {
match self {
Self::User => "user",
Self::Plugin => "plugin",
Self::Project => "project",
}
}
}
#[derive(Debug, Clone)]
pub struct DiscoveredSkill {
pub name: String,
pub description: String,
pub path: PathBuf,
pub source: SkillSource,
pub plugin: Option<String>,
pub size_bytes: u64,
}
impl DiscoveredSkill {
pub fn semantic_key(&self) -> String {
format!("skill:{}", self.name.to_lowercase().replace(' ', "-"))
}
pub fn within_share_limit(&self) -> bool {
self.size_bytes <= 32 * 1024
}
}
pub fn discover(project_root: Option<&Path>) -> Vec<DiscoveredSkill> {
let mut out = Vec::new();
let home = dirs_home();
scan_skill_root(
&home.join(".claude/skills"),
SkillSource::User,
None,
&mut out,
);
let plugins_root = home.join(".claude/plugins");
if let Ok(entries) = fs::read_dir(&plugins_root) {
for entry in entries.flatten() {
let plugin_dir = entry.path();
if !plugin_dir.is_dir() {
continue;
}
let plugin_name = plugin_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
scan_skill_root(
&plugin_dir.join("skills"),
SkillSource::Plugin,
Some(plugin_name),
&mut out,
);
}
}
if let Some(root) = project_root {
scan_skill_root(
&root.join(".claude/skills"),
SkillSource::Project,
None,
&mut out,
);
}
out.sort_by_key(|a| a.name.to_lowercase());
out
}
fn scan_skill_root(
root: &Path,
source: SkillSource,
plugin: Option<String>,
out: &mut Vec<DiscoveredSkill>,
) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let candidates = [path.join("SKILL.md"), path.join("skill.md")];
for candidate in &candidates {
if candidate.exists() {
if let Some(skill) = load_skill(candidate, source, plugin.clone(), Some(&path))
{
out.push(skill);
}
break;
}
}
} else if path.extension().and_then(|e| e.to_str()) == Some("md") {
if let Some(skill) = load_skill(&path, source, plugin.clone(), None) {
out.push(skill);
}
}
}
}
fn load_skill(
path: &Path,
source: SkillSource,
plugin: Option<String>,
dir_for_fallback_name: Option<&Path>,
) -> Option<DiscoveredSkill> {
let body = fs::read_to_string(path).ok()?;
let fm = parse_frontmatter(&body);
let fallback_name = dir_for_fallback_name
.and_then(|d| d.file_name())
.or_else(|| path.file_stem())
.and_then(|n| n.to_str())
.unwrap_or("(unnamed)")
.to_string();
let name = fm.get("name").cloned().unwrap_or(fallback_name);
let description = fm.get("description").cloned().unwrap_or_default();
let size_bytes = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
Some(DiscoveredSkill {
name,
description,
path: path.to_path_buf(),
source,
plugin,
size_bytes,
})
}
fn parse_frontmatter(content: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return map;
}
let after_open = &trimmed[3..].trim_start_matches('\r');
let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
let Some(close_pos) = after_open.find("\n---") else {
return map;
};
let yaml = &after_open[..close_pos];
for line in yaml.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once(':') {
let key = k.trim().to_string();
let value = v.trim().trim_matches('"').trim_matches('\'').to_string();
if !key.is_empty() && !value.is_empty() {
map.insert(key, value);
}
}
}
map
}
pub fn is_shared(skill: &DiscoveredSkill, shared_keys: &std::collections::HashSet<String>) -> bool {
shared_keys.contains(&skill.semantic_key())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn write_skill(dir: &Path, name: &str, frontmatter: &str, body: &str) -> PathBuf {
let skill_dir = dir.join(name);
fs::create_dir_all(&skill_dir).unwrap();
let path = skill_dir.join("SKILL.md");
let mut f = fs::File::create(&path).unwrap();
writeln!(f, "---\n{frontmatter}\n---").unwrap();
writeln!(f, "{body}").unwrap();
path
}
#[test]
fn parses_frontmatter_and_falls_back_to_dir_name() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("skills");
fs::create_dir_all(&root).unwrap();
write_skill(
&root,
"demo-skill",
"name: Demo Skill\ndescription: A demo.\nversion: 1.0",
"body",
);
write_skill(&root, "no-frontmatter", "", "just body");
let mut out = Vec::new();
scan_skill_root(&root, SkillSource::User, None, &mut out);
out.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(out.len(), 2);
assert_eq!(out[0].name, "Demo Skill");
assert_eq!(out[0].description, "A demo.");
assert_eq!(out[0].source, SkillSource::User);
assert_eq!(out[1].name, "no-frontmatter");
assert_eq!(out[1].description, "");
}
#[test]
fn semantic_key_matches_hive_shape() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("skills");
fs::create_dir_all(&root).unwrap();
write_skill(&root, "x", "name: Session Monitoring\n", "");
let mut out = Vec::new();
scan_skill_root(&root, SkillSource::User, None, &mut out);
assert_eq!(out[0].semantic_key(), "skill:session-monitoring");
}
#[test]
fn within_share_limit_respects_32k() {
let mut skill = DiscoveredSkill {
name: "a".into(),
description: String::new(),
path: PathBuf::new(),
source: SkillSource::User,
plugin: None,
size_bytes: 0,
};
assert!(skill.within_share_limit());
skill.size_bytes = 32 * 1024;
assert!(skill.within_share_limit());
skill.size_bytes = 32 * 1024 + 1;
assert!(!skill.within_share_limit());
}
}