use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
const SKILL_FILE: &str = "SKILL.md";
pub fn skills_for_cwd(cwd: &str) -> Vec<String> {
let mut out: BTreeSet<String> = BTreeSet::new();
if !cwd.is_empty() && cwd != "?" {
let project_root = Path::new(cwd).join(".claude").join("skills");
collect_into(&project_root, &mut out);
}
if let Some(home) = dirs::home_dir() {
let global_root = home.join(".claude").join("skills");
collect_into(&global_root, &mut out);
}
out.into_iter().collect()
}
fn collect_into(root: &Path, out: &mut BTreeSet<String>) {
let rd = match std::fs::read_dir(root) {
Ok(rd) => rd,
Err(_) => return,
};
for ent in rd.flatten() {
let ft = match ent.file_type() { Ok(ft) => ft, Err(_) => continue };
if ft.is_symlink() || !ft.is_dir() { continue; }
let dir = ent.path();
let skill_md: PathBuf = dir.join(SKILL_FILE);
if skill_md.is_file() {
if let Some(name) = dir.file_name().and_then(|s| s.to_str()) {
let clean = crate::format::sanitize_control(name);
if !clean.is_empty() {
out.insert(clean);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn empty_when_no_skills_dir() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_string_lossy().into_owned();
let s = skills_for_cwd(&cwd);
assert!(s.iter().all(|n| !n.is_empty()));
}
#[test]
fn detects_project_local_skill() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join(".claude").join("skills").join("frontend-design");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "# frontend-design\n").unwrap();
let s = skills_for_cwd(tmp.path().to_str().unwrap());
assert!(s.contains(&"frontend-design".to_string()),
"expected frontend-design in {:?}", s);
}
#[test]
fn ignores_dirs_without_skill_md() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join(".claude").join("skills").join("not-a-skill");
fs::create_dir_all(&skill_dir).unwrap();
let s = skills_for_cwd(tmp.path().to_str().unwrap());
assert!(!s.contains(&"not-a-skill".to_string()));
}
}