1use std::collections::BTreeMap;
83use std::fs;
84use std::path::{Path, PathBuf};
85
86use serde::Serialize;
87
88use crate::artifacts::{frontmatter_entries, split_frontmatter};
89use crate::error::{Error, Result};
90
91#[derive(Debug, Clone)]
95pub struct SkillsRoot {
96 path: PathBuf,
97}
98
99impl SkillsRoot {
100 pub fn home() -> Result<Self> {
103 let home = home_dir().ok_or_else(|| Error::Artifacts {
104 message: "could not determine user home directory".to_string(),
105 })?;
106 Ok(Self {
107 path: home.join(".claude").join("skills"),
108 })
109 }
110
111 pub fn at(path: impl Into<PathBuf>) -> Self {
114 Self { path: path.into() }
115 }
116
117 pub fn scheduled_tasks_home() -> Result<Self> {
128 let home = home_dir().ok_or_else(|| Error::Artifacts {
129 message: "could not determine user home directory".to_string(),
130 })?;
131 Ok(Self {
132 path: home.join(".claude").join("scheduled-tasks"),
133 })
134 }
135
136 pub fn path(&self) -> &Path {
138 &self.path
139 }
140
141 pub fn list(&self) -> Result<Vec<SkillSummary>> {
152 let entries = match fs::read_dir(&self.path) {
153 Ok(it) => it,
154 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
155 Err(e) => return Err(e.into()),
156 };
157
158 let mut out = Vec::new();
159 for entry in entries.flatten() {
160 let dir = entry.path();
161 if !dir.is_dir() {
162 continue;
163 }
164 let stem = match dir.file_name().and_then(|s| s.to_str()) {
165 Some(s) => s.to_string(),
166 None => continue,
167 };
168 let skill_md = dir.join("SKILL.md");
169 if !skill_md.is_file() {
170 continue;
171 }
172 match parse_skill_file(&skill_md, &dir, &stem) {
173 Ok(skill) => out.push(SkillSummary::from_skill(&skill)),
174 Err(e) => tracing::warn!(?skill_md, "skipping skill: {e}"),
175 }
176 }
177 out.sort_by(|a, b| a.dir_stem.cmp(&b.dir_stem));
178 Ok(out)
179 }
180
181 pub fn get(&self, dir_stem: &str) -> Result<Skill> {
186 let dir = self.path.join(dir_stem);
187 let skill_md = dir.join("SKILL.md");
188 if !skill_md.is_file() {
189 return Err(Error::Artifacts {
190 message: format!("no skill at {}", dir.display()),
191 });
192 }
193 parse_skill_file(&skill_md, &dir, dir_stem)
194 }
195}
196
197#[derive(Debug, Clone, Serialize)]
200pub struct SkillSummary {
201 pub dir_stem: String,
204 pub name: String,
206 pub description: Option<String>,
208 pub dir_path: PathBuf,
210 pub file_path: PathBuf,
212 pub size_bytes: u64,
214 pub has_assets: bool,
220}
221
222impl SkillSummary {
223 fn from_skill(s: &Skill) -> Self {
224 let size_bytes = fs::metadata(&s.file_path)
225 .map(|m| m.len())
226 .unwrap_or_default();
227 Self {
228 dir_stem: s.dir_stem.clone(),
229 name: s.name.clone(),
230 description: s.description.clone(),
231 dir_path: s.dir_path.clone(),
232 file_path: s.file_path.clone(),
233 size_bytes,
234 has_assets: s.has_assets,
235 }
236 }
237}
238
239#[derive(Debug, Clone, Serialize)]
241pub struct Skill {
242 pub dir_stem: String,
245 pub name: String,
247 pub description: Option<String>,
249 pub dir_path: PathBuf,
251 pub file_path: PathBuf,
253 pub body: String,
256 pub extra: BTreeMap<String, String>,
259 pub has_assets: bool,
264}
265
266fn parse_skill_file(file_path: &Path, dir_path: &Path, dir_stem: &str) -> Result<Skill> {
267 let raw = fs::read_to_string(file_path)?;
268 let (frontmatter, body) = split_frontmatter(&raw);
269
270 let mut name = dir_stem.to_string();
271 let mut description = None;
272 let mut extra = BTreeMap::new();
273
274 if let Some(fm) = frontmatter {
275 for (key, value) in frontmatter_entries(fm) {
276 match key.as_str() {
277 "name" if !value.is_empty() => name = value,
278 "description" if !value.is_empty() => description = Some(value),
279 _ => {
280 extra.insert(key, value);
281 }
282 }
283 }
284 }
285
286 Ok(Skill {
287 dir_stem: dir_stem.to_string(),
288 name,
289 description,
290 dir_path: dir_path.to_path_buf(),
291 file_path: file_path.to_path_buf(),
292 body: body.trim().to_string(),
293 extra,
294 has_assets: directory_has_assets(dir_path),
295 })
296}
297
298fn directory_has_assets(dir: &Path) -> bool {
299 let entries = match fs::read_dir(dir) {
300 Ok(it) => it,
301 Err(_) => return false,
302 };
303 for entry in entries.flatten() {
304 let name = entry.file_name();
305 if name == "SKILL.md" {
307 continue;
308 }
309 return true;
310 }
311 false
312}
313
314fn home_dir() -> Option<PathBuf> {
315 if let Ok(h) = std::env::var("HOME")
316 && !h.is_empty()
317 {
318 return Some(PathBuf::from(h));
319 }
320 if let Ok(h) = std::env::var("USERPROFILE")
321 && !h.is_empty()
322 {
323 return Some(PathBuf::from(h));
324 }
325 None
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use std::io::Write;
332
333 fn write_skill(root: &Path, stem: &str, contents: &str) -> PathBuf {
334 let dir = root.join(stem);
335 fs::create_dir_all(&dir).expect("create skill dir");
336 let path = dir.join("SKILL.md");
337 let mut f = fs::File::create(&path).expect("create SKILL.md");
338 f.write_all(contents.as_bytes()).expect("write SKILL.md");
339 path
340 }
341
342 fn fixture_root() -> tempfile::TempDir {
343 let tmp = tempfile::tempdir().expect("tempdir");
344 write_skill(
345 tmp.path(),
346 "recall",
347 "---\nname: recall\ndescription: Search mente for memories\n---\n\nSearch for: $ARGUMENTS\n",
348 );
349 write_skill(
350 tmp.path(),
351 "no-frontmatter",
352 "Just a body, no frontmatter at all.\n",
353 );
354 write_skill(
355 tmp.path(),
356 "weird",
357 "---\nname: weird\ndescription: has extras\ncustom_key: custom_value\n---\nbody\n",
358 );
359 write_skill(
361 tmp.path(),
362 "bundled",
363 "---\nname: bundled\ndescription: has scripts\n---\nbody\n",
364 );
365 let scripts = tmp.path().join("bundled").join("scripts");
366 fs::create_dir_all(&scripts).expect("create scripts dir");
367 fs::write(scripts.join("helper.sh"), "#!/bin/sh\n").expect("write helper");
368 let bogus = tmp.path().join("not-a-skill");
370 fs::create_dir_all(&bogus).expect("create bogus");
371 fs::write(bogus.join("README.md"), "not a skill").expect("write README");
372 fs::write(tmp.path().join("loose-file.md"), "ignore me").expect("write loose");
374 tmp
375 }
376
377 #[test]
378 fn list_returns_only_skill_dirs_sorted() {
379 let tmp = fixture_root();
380 let root = SkillsRoot::at(tmp.path());
381 let skills = root.list().expect("list");
382 let stems: Vec<&str> = skills.iter().map(|s| s.dir_stem.as_str()).collect();
383 assert_eq!(stems, ["bundled", "no-frontmatter", "recall", "weird"]);
384 }
385
386 #[test]
387 fn list_missing_root_returns_empty() {
388 let tmp = tempfile::tempdir().expect("tempdir");
389 let root = SkillsRoot::at(tmp.path().join("does-not-exist"));
390 let skills = root.list().expect("list");
391 assert!(skills.is_empty());
392 }
393
394 #[test]
395 fn list_typed_metadata() {
396 let tmp = fixture_root();
397 let root = SkillsRoot::at(tmp.path());
398 let skills = root.list().expect("list");
399 let recall = skills
400 .iter()
401 .find(|s| s.dir_stem == "recall")
402 .expect("recall");
403 assert_eq!(recall.name, "recall");
404 assert_eq!(
405 recall.description.as_deref(),
406 Some("Search mente for memories")
407 );
408 assert!(recall.size_bytes > 0);
409 assert!(!recall.has_assets);
410 }
411
412 #[test]
413 fn list_detects_bundled_assets() {
414 let tmp = fixture_root();
415 let root = SkillsRoot::at(tmp.path());
416 let skills = root.list().expect("list");
417 let bundled = skills
418 .iter()
419 .find(|s| s.dir_stem == "bundled")
420 .expect("bundled");
421 assert!(bundled.has_assets, "expected has_assets=true for bundled");
422 }
423
424 #[test]
425 fn list_no_frontmatter_falls_back_to_stem() {
426 let tmp = fixture_root();
427 let root = SkillsRoot::at(tmp.path());
428 let skills = root.list().expect("list");
429 let nf = skills
430 .iter()
431 .find(|s| s.dir_stem == "no-frontmatter")
432 .expect("no-frontmatter");
433 assert_eq!(nf.name, "no-frontmatter");
434 assert_eq!(nf.description, None);
435 }
436
437 #[test]
438 fn get_returns_full_skill_with_body() {
439 let tmp = fixture_root();
440 let root = SkillsRoot::at(tmp.path());
441 let skill = root.get("recall").expect("get recall");
442 assert_eq!(skill.name, "recall");
443 assert_eq!(skill.body, "Search for: $ARGUMENTS");
444 assert!(!skill.has_assets);
445 }
446
447 #[test]
448 fn get_no_frontmatter_returns_full_body() {
449 let tmp = fixture_root();
450 let root = SkillsRoot::at(tmp.path());
451 let skill = root.get("no-frontmatter").expect("get");
452 assert_eq!(skill.body, "Just a body, no frontmatter at all.");
453 assert_eq!(skill.name, "no-frontmatter");
454 }
455
456 #[test]
457 fn get_unknown_id_errors() {
458 let tmp = fixture_root();
459 let root = SkillsRoot::at(tmp.path());
460 let err = root.get("nope").unwrap_err();
461 assert!(err.to_string().to_lowercase().contains("no skill"));
462 }
463
464 #[test]
465 fn extra_keys_round_trip_as_strings() {
466 let tmp = fixture_root();
467 let root = SkillsRoot::at(tmp.path());
468 let skill = root.get("weird").expect("get weird");
469 assert_eq!(
470 skill.extra.get("custom_key").map(String::as_str),
471 Some("custom_value")
472 );
473 }
474
475 #[test]
476 fn folded_description_with_colons_is_one_value() {
477 let tmp = tempfile::tempdir().expect("tempdir");
478 write_skill(
479 tmp.path(),
480 "folded",
481 concat!(
482 "---\n",
483 "name: folded\n",
484 "description: >-\n",
485 " Use when surveying a codebase against a rubric. Read-only: never\n",
486 " edits files, opens PRs, or commits.\n",
487 "---\n\nBody.\n",
488 ),
489 );
490 let root = SkillsRoot::at(tmp.path());
491 let skill = root.get("folded").expect("get");
492 assert_eq!(
493 skill.description.as_deref(),
494 Some(
495 "Use when surveying a codebase against a rubric. Read-only: never \
496 edits files, opens PRs, or commits."
497 )
498 );
499 assert!(skill.extra.is_empty(), "extra: {:?}", skill.extra);
500 assert_eq!(skill.body, "Body.");
501 }
502
503 #[test]
504 fn literal_description_preserves_newlines() {
505 let tmp = tempfile::tempdir().expect("tempdir");
506 write_skill(
507 tmp.path(),
508 "lit",
509 "---\nname: lit\ndescription: |-\n one\n two: three\n---\nbody\n",
510 );
511 let root = SkillsRoot::at(tmp.path());
512 let skill = root.get("lit").expect("get");
513 assert_eq!(skill.description.as_deref(), Some("one\ntwo: three"));
514 }
515
516 #[test]
517 fn empty_value_keys_dont_overwrite_defaults() {
518 let tmp = tempfile::tempdir().expect("tempdir");
519 write_skill(
520 tmp.path(),
521 "empty-name",
522 "---\nname:\ndescription: keeps stem as name\n---\nbody\n",
523 );
524 let root = SkillsRoot::at(tmp.path());
525 let skill = root.get("empty-name").expect("get");
526 assert_eq!(skill.name, "empty-name");
527 }
528
529 #[test]
530 fn scheduled_tasks_home_points_at_scheduled_tasks() {
531 if let Ok(root) = SkillsRoot::scheduled_tasks_home() {
532 assert!(root.path().ends_with(".claude/scheduled-tasks"));
533 }
534 }
535
536 #[test]
537 fn list_ignores_dirs_without_skill_md() {
538 let tmp = fixture_root();
539 let root = SkillsRoot::at(tmp.path());
541 let skills = root.list().expect("list");
542 assert!(!skills.iter().any(|s| s.dir_stem == "not-a-skill"));
543 }
544}