1use std::collections::BTreeMap;
73use std::fs;
74use std::path::{Path, PathBuf};
75
76use serde::Serialize;
77
78use crate::artifacts::split_frontmatter;
79use crate::error::{Error, Result};
80
81#[derive(Debug, Clone)]
85pub struct SkillsRoot {
86 path: PathBuf,
87}
88
89impl SkillsRoot {
90 pub fn home() -> Result<Self> {
93 let home = home_dir().ok_or_else(|| Error::Artifacts {
94 message: "could not determine user home directory".to_string(),
95 })?;
96 Ok(Self {
97 path: home.join(".claude").join("skills"),
98 })
99 }
100
101 pub fn at(path: impl Into<PathBuf>) -> Self {
104 Self { path: path.into() }
105 }
106
107 pub fn path(&self) -> &Path {
109 &self.path
110 }
111
112 pub fn list(&self) -> Result<Vec<SkillSummary>> {
123 let entries = match fs::read_dir(&self.path) {
124 Ok(it) => it,
125 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
126 Err(e) => return Err(e.into()),
127 };
128
129 let mut out = Vec::new();
130 for entry in entries.flatten() {
131 let dir = entry.path();
132 if !dir.is_dir() {
133 continue;
134 }
135 let stem = match dir.file_name().and_then(|s| s.to_str()) {
136 Some(s) => s.to_string(),
137 None => continue,
138 };
139 let skill_md = dir.join("SKILL.md");
140 if !skill_md.is_file() {
141 continue;
142 }
143 match parse_skill_file(&skill_md, &dir, &stem) {
144 Ok(skill) => out.push(SkillSummary::from_skill(&skill)),
145 Err(e) => tracing::warn!(?skill_md, "skipping skill: {e}"),
146 }
147 }
148 out.sort_by(|a, b| a.dir_stem.cmp(&b.dir_stem));
149 Ok(out)
150 }
151
152 pub fn get(&self, dir_stem: &str) -> Result<Skill> {
157 let dir = self.path.join(dir_stem);
158 let skill_md = dir.join("SKILL.md");
159 if !skill_md.is_file() {
160 return Err(Error::Artifacts {
161 message: format!("no skill at {}", dir.display()),
162 });
163 }
164 parse_skill_file(&skill_md, &dir, dir_stem)
165 }
166}
167
168#[derive(Debug, Clone, Serialize)]
171pub struct SkillSummary {
172 pub dir_stem: String,
175 pub name: String,
177 pub description: Option<String>,
179 pub dir_path: PathBuf,
181 pub file_path: PathBuf,
183 pub size_bytes: u64,
185 pub has_assets: bool,
191}
192
193impl SkillSummary {
194 fn from_skill(s: &Skill) -> Self {
195 let size_bytes = fs::metadata(&s.file_path)
196 .map(|m| m.len())
197 .unwrap_or_default();
198 Self {
199 dir_stem: s.dir_stem.clone(),
200 name: s.name.clone(),
201 description: s.description.clone(),
202 dir_path: s.dir_path.clone(),
203 file_path: s.file_path.clone(),
204 size_bytes,
205 has_assets: s.has_assets,
206 }
207 }
208}
209
210#[derive(Debug, Clone, Serialize)]
212pub struct Skill {
213 pub dir_stem: String,
216 pub name: String,
218 pub description: Option<String>,
220 pub dir_path: PathBuf,
222 pub file_path: PathBuf,
224 pub body: String,
227 pub extra: BTreeMap<String, String>,
230 pub has_assets: bool,
235}
236
237fn parse_skill_file(file_path: &Path, dir_path: &Path, dir_stem: &str) -> Result<Skill> {
238 let raw = fs::read_to_string(file_path)?;
239 let (frontmatter, body) = split_frontmatter(&raw);
240
241 let mut name = dir_stem.to_string();
242 let mut description = None;
243 let mut extra = BTreeMap::new();
244
245 if let Some(fm) = frontmatter {
246 for line in fm.lines() {
247 let trimmed = line.trim();
248 if trimmed.is_empty() {
249 continue;
250 }
251 let Some((k, v)) = trimmed.split_once(':') else {
252 continue;
253 };
254 let key = k.trim();
255 let value = v.trim().to_string();
256 match key {
257 "name" if !value.is_empty() => name = value,
258 "description" if !value.is_empty() => description = Some(value),
259 _ if !key.is_empty() => {
260 extra.insert(key.to_string(), value);
261 }
262 _ => {}
263 }
264 }
265 }
266
267 Ok(Skill {
268 dir_stem: dir_stem.to_string(),
269 name,
270 description,
271 dir_path: dir_path.to_path_buf(),
272 file_path: file_path.to_path_buf(),
273 body: body.trim().to_string(),
274 extra,
275 has_assets: directory_has_assets(dir_path),
276 })
277}
278
279fn directory_has_assets(dir: &Path) -> bool {
280 let entries = match fs::read_dir(dir) {
281 Ok(it) => it,
282 Err(_) => return false,
283 };
284 for entry in entries.flatten() {
285 let name = entry.file_name();
286 if name == "SKILL.md" {
288 continue;
289 }
290 return true;
291 }
292 false
293}
294
295fn home_dir() -> Option<PathBuf> {
296 if let Ok(h) = std::env::var("HOME")
297 && !h.is_empty()
298 {
299 return Some(PathBuf::from(h));
300 }
301 if let Ok(h) = std::env::var("USERPROFILE")
302 && !h.is_empty()
303 {
304 return Some(PathBuf::from(h));
305 }
306 None
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use std::io::Write;
313
314 fn write_skill(root: &Path, stem: &str, contents: &str) -> PathBuf {
315 let dir = root.join(stem);
316 fs::create_dir_all(&dir).expect("create skill dir");
317 let path = dir.join("SKILL.md");
318 let mut f = fs::File::create(&path).expect("create SKILL.md");
319 f.write_all(contents.as_bytes()).expect("write SKILL.md");
320 path
321 }
322
323 fn fixture_root() -> tempfile::TempDir {
324 let tmp = tempfile::tempdir().expect("tempdir");
325 write_skill(
326 tmp.path(),
327 "recall",
328 "---\nname: recall\ndescription: Search mente for memories\n---\n\nSearch for: $ARGUMENTS\n",
329 );
330 write_skill(
331 tmp.path(),
332 "no-frontmatter",
333 "Just a body, no frontmatter at all.\n",
334 );
335 write_skill(
336 tmp.path(),
337 "weird",
338 "---\nname: weird\ndescription: has extras\ncustom_key: custom_value\n---\nbody\n",
339 );
340 write_skill(
342 tmp.path(),
343 "bundled",
344 "---\nname: bundled\ndescription: has scripts\n---\nbody\n",
345 );
346 let scripts = tmp.path().join("bundled").join("scripts");
347 fs::create_dir_all(&scripts).expect("create scripts dir");
348 fs::write(scripts.join("helper.sh"), "#!/bin/sh\n").expect("write helper");
349 let bogus = tmp.path().join("not-a-skill");
351 fs::create_dir_all(&bogus).expect("create bogus");
352 fs::write(bogus.join("README.md"), "not a skill").expect("write README");
353 fs::write(tmp.path().join("loose-file.md"), "ignore me").expect("write loose");
355 tmp
356 }
357
358 #[test]
359 fn list_returns_only_skill_dirs_sorted() {
360 let tmp = fixture_root();
361 let root = SkillsRoot::at(tmp.path());
362 let skills = root.list().expect("list");
363 let stems: Vec<&str> = skills.iter().map(|s| s.dir_stem.as_str()).collect();
364 assert_eq!(stems, ["bundled", "no-frontmatter", "recall", "weird"]);
365 }
366
367 #[test]
368 fn list_missing_root_returns_empty() {
369 let tmp = tempfile::tempdir().expect("tempdir");
370 let root = SkillsRoot::at(tmp.path().join("does-not-exist"));
371 let skills = root.list().expect("list");
372 assert!(skills.is_empty());
373 }
374
375 #[test]
376 fn list_typed_metadata() {
377 let tmp = fixture_root();
378 let root = SkillsRoot::at(tmp.path());
379 let skills = root.list().expect("list");
380 let recall = skills
381 .iter()
382 .find(|s| s.dir_stem == "recall")
383 .expect("recall");
384 assert_eq!(recall.name, "recall");
385 assert_eq!(
386 recall.description.as_deref(),
387 Some("Search mente for memories")
388 );
389 assert!(recall.size_bytes > 0);
390 assert!(!recall.has_assets);
391 }
392
393 #[test]
394 fn list_detects_bundled_assets() {
395 let tmp = fixture_root();
396 let root = SkillsRoot::at(tmp.path());
397 let skills = root.list().expect("list");
398 let bundled = skills
399 .iter()
400 .find(|s| s.dir_stem == "bundled")
401 .expect("bundled");
402 assert!(bundled.has_assets, "expected has_assets=true for bundled");
403 }
404
405 #[test]
406 fn list_no_frontmatter_falls_back_to_stem() {
407 let tmp = fixture_root();
408 let root = SkillsRoot::at(tmp.path());
409 let skills = root.list().expect("list");
410 let nf = skills
411 .iter()
412 .find(|s| s.dir_stem == "no-frontmatter")
413 .expect("no-frontmatter");
414 assert_eq!(nf.name, "no-frontmatter");
415 assert_eq!(nf.description, None);
416 }
417
418 #[test]
419 fn get_returns_full_skill_with_body() {
420 let tmp = fixture_root();
421 let root = SkillsRoot::at(tmp.path());
422 let skill = root.get("recall").expect("get recall");
423 assert_eq!(skill.name, "recall");
424 assert_eq!(skill.body, "Search for: $ARGUMENTS");
425 assert!(!skill.has_assets);
426 }
427
428 #[test]
429 fn get_no_frontmatter_returns_full_body() {
430 let tmp = fixture_root();
431 let root = SkillsRoot::at(tmp.path());
432 let skill = root.get("no-frontmatter").expect("get");
433 assert_eq!(skill.body, "Just a body, no frontmatter at all.");
434 assert_eq!(skill.name, "no-frontmatter");
435 }
436
437 #[test]
438 fn get_unknown_id_errors() {
439 let tmp = fixture_root();
440 let root = SkillsRoot::at(tmp.path());
441 let err = root.get("nope").unwrap_err();
442 assert!(err.to_string().to_lowercase().contains("no skill"));
443 }
444
445 #[test]
446 fn extra_keys_round_trip_as_strings() {
447 let tmp = fixture_root();
448 let root = SkillsRoot::at(tmp.path());
449 let skill = root.get("weird").expect("get weird");
450 assert_eq!(
451 skill.extra.get("custom_key").map(String::as_str),
452 Some("custom_value")
453 );
454 }
455
456 #[test]
457 fn empty_value_keys_dont_overwrite_defaults() {
458 let tmp = tempfile::tempdir().expect("tempdir");
459 write_skill(
460 tmp.path(),
461 "empty-name",
462 "---\nname:\ndescription: keeps stem as name\n---\nbody\n",
463 );
464 let root = SkillsRoot::at(tmp.path());
465 let skill = root.get("empty-name").expect("get");
466 assert_eq!(skill.name, "empty-name");
467 }
468
469 #[test]
470 fn list_ignores_dirs_without_skill_md() {
471 let tmp = fixture_root();
472 let root = SkillsRoot::at(tmp.path());
474 let skills = root.list().expect("list");
475 assert!(!skills.iter().any(|s| s.dir_stem == "not-a-skill"));
476 }
477}