use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::artifacts::split_frontmatter;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct SkillsRoot {
path: PathBuf,
}
impl SkillsRoot {
pub fn home() -> Result<Self> {
let home = home_dir().ok_or_else(|| Error::Artifacts {
message: "could not determine user home directory".to_string(),
})?;
Ok(Self {
path: home.join(".claude").join("skills"),
})
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn list(&self) -> Result<Vec<SkillSummary>> {
let entries = match fs::read_dir(&self.path) {
Ok(it) => it,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut out = Vec::new();
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let stem = match dir.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
let skill_md = dir.join("SKILL.md");
if !skill_md.is_file() {
continue;
}
match parse_skill_file(&skill_md, &dir, &stem) {
Ok(skill) => out.push(SkillSummary::from_skill(&skill)),
Err(e) => tracing::warn!(?skill_md, "skipping skill: {e}"),
}
}
out.sort_by(|a, b| a.dir_stem.cmp(&b.dir_stem));
Ok(out)
}
pub fn get(&self, dir_stem: &str) -> Result<Skill> {
let dir = self.path.join(dir_stem);
let skill_md = dir.join("SKILL.md");
if !skill_md.is_file() {
return Err(Error::Artifacts {
message: format!("no skill at {}", dir.display()),
});
}
parse_skill_file(&skill_md, &dir, dir_stem)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SkillSummary {
pub dir_stem: String,
pub name: String,
pub description: Option<String>,
pub dir_path: PathBuf,
pub file_path: PathBuf,
pub size_bytes: u64,
pub has_assets: bool,
}
impl SkillSummary {
fn from_skill(s: &Skill) -> Self {
let size_bytes = fs::metadata(&s.file_path)
.map(|m| m.len())
.unwrap_or_default();
Self {
dir_stem: s.dir_stem.clone(),
name: s.name.clone(),
description: s.description.clone(),
dir_path: s.dir_path.clone(),
file_path: s.file_path.clone(),
size_bytes,
has_assets: s.has_assets,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Skill {
pub dir_stem: String,
pub name: String,
pub description: Option<String>,
pub dir_path: PathBuf,
pub file_path: PathBuf,
pub body: String,
pub extra: BTreeMap<String, String>,
pub has_assets: bool,
}
fn parse_skill_file(file_path: &Path, dir_path: &Path, dir_stem: &str) -> Result<Skill> {
let raw = fs::read_to_string(file_path)?;
let (frontmatter, body) = split_frontmatter(&raw);
let mut name = dir_stem.to_string();
let mut description = None;
let mut extra = BTreeMap::new();
if let Some(fm) = frontmatter {
for line in fm.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Some((k, v)) = trimmed.split_once(':') else {
continue;
};
let key = k.trim();
let value = v.trim().to_string();
match key {
"name" if !value.is_empty() => name = value,
"description" if !value.is_empty() => description = Some(value),
_ if !key.is_empty() => {
extra.insert(key.to_string(), value);
}
_ => {}
}
}
}
Ok(Skill {
dir_stem: dir_stem.to_string(),
name,
description,
dir_path: dir_path.to_path_buf(),
file_path: file_path.to_path_buf(),
body: body.trim().to_string(),
extra,
has_assets: directory_has_assets(dir_path),
})
}
fn directory_has_assets(dir: &Path) -> bool {
let entries = match fs::read_dir(dir) {
Ok(it) => it,
Err(_) => return false,
};
for entry in entries.flatten() {
let name = entry.file_name();
if name == "SKILL.md" {
continue;
}
return true;
}
false
}
fn home_dir() -> Option<PathBuf> {
if let Ok(h) = std::env::var("HOME")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
if let Ok(h) = std::env::var("USERPROFILE")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_skill(root: &Path, stem: &str, contents: &str) -> PathBuf {
let dir = root.join(stem);
fs::create_dir_all(&dir).expect("create skill dir");
let path = dir.join("SKILL.md");
let mut f = fs::File::create(&path).expect("create SKILL.md");
f.write_all(contents.as_bytes()).expect("write SKILL.md");
path
}
fn fixture_root() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
write_skill(
tmp.path(),
"recall",
"---\nname: recall\ndescription: Search mente for memories\n---\n\nSearch for: $ARGUMENTS\n",
);
write_skill(
tmp.path(),
"no-frontmatter",
"Just a body, no frontmatter at all.\n",
);
write_skill(
tmp.path(),
"weird",
"---\nname: weird\ndescription: has extras\ncustom_key: custom_value\n---\nbody\n",
);
write_skill(
tmp.path(),
"bundled",
"---\nname: bundled\ndescription: has scripts\n---\nbody\n",
);
let scripts = tmp.path().join("bundled").join("scripts");
fs::create_dir_all(&scripts).expect("create scripts dir");
fs::write(scripts.join("helper.sh"), "#!/bin/sh\n").expect("write helper");
let bogus = tmp.path().join("not-a-skill");
fs::create_dir_all(&bogus).expect("create bogus");
fs::write(bogus.join("README.md"), "not a skill").expect("write README");
fs::write(tmp.path().join("loose-file.md"), "ignore me").expect("write loose");
tmp
}
#[test]
fn list_returns_only_skill_dirs_sorted() {
let tmp = fixture_root();
let root = SkillsRoot::at(tmp.path());
let skills = root.list().expect("list");
let stems: Vec<&str> = skills.iter().map(|s| s.dir_stem.as_str()).collect();
assert_eq!(stems, ["bundled", "no-frontmatter", "recall", "weird"]);
}
#[test]
fn list_missing_root_returns_empty() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = SkillsRoot::at(tmp.path().join("does-not-exist"));
let skills = root.list().expect("list");
assert!(skills.is_empty());
}
#[test]
fn list_typed_metadata() {
let tmp = fixture_root();
let root = SkillsRoot::at(tmp.path());
let skills = root.list().expect("list");
let recall = skills
.iter()
.find(|s| s.dir_stem == "recall")
.expect("recall");
assert_eq!(recall.name, "recall");
assert_eq!(
recall.description.as_deref(),
Some("Search mente for memories")
);
assert!(recall.size_bytes > 0);
assert!(!recall.has_assets);
}
#[test]
fn list_detects_bundled_assets() {
let tmp = fixture_root();
let root = SkillsRoot::at(tmp.path());
let skills = root.list().expect("list");
let bundled = skills
.iter()
.find(|s| s.dir_stem == "bundled")
.expect("bundled");
assert!(bundled.has_assets, "expected has_assets=true for bundled");
}
#[test]
fn list_no_frontmatter_falls_back_to_stem() {
let tmp = fixture_root();
let root = SkillsRoot::at(tmp.path());
let skills = root.list().expect("list");
let nf = skills
.iter()
.find(|s| s.dir_stem == "no-frontmatter")
.expect("no-frontmatter");
assert_eq!(nf.name, "no-frontmatter");
assert_eq!(nf.description, None);
}
#[test]
fn get_returns_full_skill_with_body() {
let tmp = fixture_root();
let root = SkillsRoot::at(tmp.path());
let skill = root.get("recall").expect("get recall");
assert_eq!(skill.name, "recall");
assert_eq!(skill.body, "Search for: $ARGUMENTS");
assert!(!skill.has_assets);
}
#[test]
fn get_no_frontmatter_returns_full_body() {
let tmp = fixture_root();
let root = SkillsRoot::at(tmp.path());
let skill = root.get("no-frontmatter").expect("get");
assert_eq!(skill.body, "Just a body, no frontmatter at all.");
assert_eq!(skill.name, "no-frontmatter");
}
#[test]
fn get_unknown_id_errors() {
let tmp = fixture_root();
let root = SkillsRoot::at(tmp.path());
let err = root.get("nope").unwrap_err();
assert!(err.to_string().to_lowercase().contains("no skill"));
}
#[test]
fn extra_keys_round_trip_as_strings() {
let tmp = fixture_root();
let root = SkillsRoot::at(tmp.path());
let skill = root.get("weird").expect("get weird");
assert_eq!(
skill.extra.get("custom_key").map(String::as_str),
Some("custom_value")
);
}
#[test]
fn empty_value_keys_dont_overwrite_defaults() {
let tmp = tempfile::tempdir().expect("tempdir");
write_skill(
tmp.path(),
"empty-name",
"---\nname:\ndescription: keeps stem as name\n---\nbody\n",
);
let root = SkillsRoot::at(tmp.path());
let skill = root.get("empty-name").expect("get");
assert_eq!(skill.name, "empty-name");
}
#[test]
fn list_ignores_dirs_without_skill_md() {
let tmp = fixture_root();
let root = SkillsRoot::at(tmp.path());
let skills = root.list().expect("list");
assert!(!skills.iter().any(|s| s.dir_stem == "not-a-skill"));
}
}