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