1use std::path::{Path, PathBuf};
33
34use anyhow::{Context, Result};
35
36#[derive(Debug, Clone)]
38pub struct Skill {
39 pub name: String,
42 pub description: String,
44 pub dir: PathBuf,
46 pub body: String,
48 pub source_file: PathBuf,
50}
51
52impl Skill {
53 pub fn skill_md(&self) -> PathBuf {
55 self.source_file.clone()
56 }
57}
58
59pub fn discover_skills(roots: &[PathBuf]) -> Vec<Skill> {
69 let mut out: Vec<Skill> = Vec::new();
70
71 for root in roots {
72 if !root.is_dir() {
73 continue;
74 }
75 let entries = match std::fs::read_dir(root) {
76 Ok(e) => e,
77 Err(e) => {
78 eprintln!("[warn] could not read skills dir {}: {e}", root.display());
79 continue;
80 }
81 };
82 for entry in entries.flatten() {
83 let path = entry.path();
84
85 if path.is_dir() {
86 let skill_md = path.join("SKILL.md");
88 if skill_md.is_file() {
89 match load_skill_from_file(&skill_md, &path) {
90 Ok(skill) => {
91 add_skill(&mut out, skill);
92 }
93 Err(e) => {
94 eprintln!("[warn] skipping skill at {}: {e}", path.display());
95 }
96 }
97 continue;
98 }
99
100 load_multi_file_skills(&path, &mut out);
102 } else if path.is_file() {
103 let ext = path.extension().and_then(|e| e.to_str());
105 if ext != Some("md") {
106 continue;
107 }
108 match load_skill_from_file(&path, root) {
109 Ok(skill) => {
110 add_skill(&mut out, skill);
111 }
112 Err(e) => {
113 let raw = std::fs::read_to_string(&path).unwrap_or_default();
114 if raw.trim_start().starts_with("---") {
115 eprintln!("[warn] skipping skill file {}: {e}", path.display());
116 }
117 }
118 }
119 }
120 }
121 }
122
123 out.sort_by(|a, b| a.name.cmp(&b.name));
124 out
125}
126
127fn add_skill(out: &mut Vec<Skill>, skill: Skill) {
129 if out.iter().any(|s| s.name == skill.name) {
130 eprintln!(
131 "[warn] duplicate skill name '{}' at {} (ignored)",
132 skill.name,
133 skill.source_file.display()
134 );
135 return;
136 }
137 out.push(skill);
138}
139
140fn load_multi_file_skills(dir: &Path, out: &mut Vec<Skill>) {
143 let entries = match std::fs::read_dir(dir) {
144 Ok(e) => e,
145 Err(e) => {
146 eprintln!("[warn] could not read skill dir {}: {e}", dir.display());
147 return;
148 }
149 };
150
151 for entry in entries.flatten() {
152 let path = entry.path();
153 if !path.is_file() {
154 continue;
155 }
156
157 let ext = path.extension().and_then(|e| e.to_str());
159 if ext != Some("md") {
160 continue;
161 }
162
163 if path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
165 continue;
166 }
167
168 match load_skill_from_file(&path, dir) {
169 Ok(skill) => {
170 add_skill(out, skill);
171 }
172 Err(e) => {
173 let raw = std::fs::read_to_string(&path).unwrap_or_default();
175 if raw.trim_start().starts_with("---") {
176 eprintln!("[warn] skipping skill file {}: {e}", path.display());
177 }
178 }
179 }
180 }
181}
182
183pub fn load_skill_from_file(md_path: &Path, dir: &Path) -> Result<Skill> {
186 let raw = std::fs::read_to_string(md_path)
187 .with_context(|| format!("reading {}", md_path.display()))?;
188 let (front, body) = split_frontmatter(&raw)
189 .with_context(|| format!("parsing frontmatter of {}", md_path.display()))?;
190
191 let name = front
193 .get("name")
194 .cloned()
195 .filter(|s| !s.is_empty())
196 .or_else(|| {
197 md_path
198 .file_stem()
199 .and_then(|n| n.to_str())
200 .map(|s| s.to_string())
201 })
202 .or_else(|| {
203 dir.file_name()
204 .and_then(|n| n.to_str())
205 .map(|s| s.to_string())
206 })
207 .ok_or_else(|| anyhow::anyhow!("skill has no 'name' in frontmatter"))?;
208
209 let description = front
210 .get("description")
211 .cloned()
212 .unwrap_or_else(|| "(no description)".to_string());
213
214 Ok(Skill {
215 name,
216 description,
217 dir: dir.to_path_buf(),
218 body: body.to_string(),
219 source_file: md_path.to_path_buf(),
220 })
221}
222
223pub fn load_skill(dir: &Path) -> Result<Skill> {
225 let md_path = dir.join("SKILL.md");
226 load_skill_from_file(&md_path, dir)
227}
228
229fn split_frontmatter(raw: &str) -> Result<(std::collections::BTreeMap<String, String>, &str)> {
243 let mut front = std::collections::BTreeMap::new();
244
245 let trimmed = raw.trim_start_matches('\u{feff}'); let Some(rest) = trimmed.strip_prefix("---") else {
247 return Ok((front, trimmed));
248 };
249 let rest = rest
251 .strip_prefix('\n')
252 .or_else(|| rest.strip_prefix("\r\n"));
253 let Some(rest) = rest else {
254 return Ok((front, trimmed));
255 };
256
257 let mut end_idx: Option<usize> = None;
259 let mut cursor = 0usize;
260 for line in rest.split_inclusive('\n') {
261 let trimmed_line = line.trim_end_matches(['\n', '\r']);
262 if trimmed_line == "---" {
263 end_idx = Some(cursor + line.len());
264 break;
265 }
266 cursor += line.len();
267 }
268 let Some(end) = end_idx else {
269 return Ok((front, trimmed));
271 };
272
273 let front_block = &rest[..cursor];
274 let body = rest[end..].trim_start_matches(['\n', '\r']);
275
276 for line in front_block.lines() {
277 let line = line.trim();
278 if line.is_empty() || line.starts_with('#') {
279 continue;
280 }
281 let Some((k, v)) = line.split_once(':') else {
282 continue;
283 };
284 let key = k.trim().to_string();
285 let val = unquote(v.trim());
286 if !key.is_empty() {
287 front.insert(key, val);
288 }
289 }
290
291 Ok((front, body))
292}
293
294fn unquote(s: &str) -> String {
295 let bytes = s.as_bytes();
296 if bytes.len() >= 2
297 && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
298 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
299 {
300 return s[1..s.len() - 1].to_string();
301 }
302 s.to_string()
303}
304
305pub fn format_catalogue(skills: &[Skill]) -> Option<String> {
309 if skills.is_empty() {
310 return None;
311 }
312 let mut s = String::from(
313 "Use the `skill` tool with the skill's name to load its full instructions:
314",
315 );
316 for sk in skills {
317 s.push_str(&format!(
318 "- {}: {}
319",
320 sk.name, sk.description
321 ));
322 }
323 Some(s)
324}
325
326pub fn list_skill_files(dir: &Path) -> Vec<String> {
330 let mut out = Vec::new();
331 walk(dir, dir, &mut out);
332 out.sort();
333 out
334}
335
336fn walk(root: &Path, cur: &Path, out: &mut Vec<String>) {
337 let entries = match std::fs::read_dir(cur) {
338 Ok(e) => e,
339 Err(_) => return,
340 };
341 for entry in entries.flatten() {
342 let p = entry.path();
343 let file_type = match entry.file_type() {
344 Ok(t) => t,
345 Err(_) => continue,
346 };
347 if file_type.is_dir() {
348 walk(root, &p, out);
349 } else if file_type.is_file()
350 && let Ok(rel) = p.strip_prefix(root)
351 {
352 out.push(rel.display().to_string());
353 }
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use tempfile::tempdir;
361
362 fn write_file(path: &Path, body: &str) {
363 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
364 std::fs::write(path, body).unwrap();
365 }
366
367 #[test]
368 fn parses_basic_frontmatter() {
369 let (front, body) =
370 split_frontmatter("---\nname: foo\ndescription: hi there\n---\nbody text\n").unwrap();
371 assert_eq!(front.get("name").unwrap(), "foo");
372 assert_eq!(front.get("description").unwrap(), "hi there");
373 assert_eq!(body, "body text\n");
374 }
375
376 #[test]
377 fn quoted_values_are_unwrapped() {
378 let (front, _) =
379 split_frontmatter("---\nname: 'foo bar'\ndescription: \"baz\"\n---\nx").unwrap();
380 assert_eq!(front.get("name").unwrap(), "foo bar");
381 assert_eq!(front.get("description").unwrap(), "baz");
382 }
383
384 #[test]
385 fn missing_frontmatter_returns_whole_body() {
386 let (front, body) = split_frontmatter("just markdown\nno front").unwrap();
387 assert!(front.is_empty());
388 assert_eq!(body, "just markdown\nno front");
389 }
390
391 #[test]
392 fn unclosed_frontmatter_falls_back_to_body() {
393 let (front, body) = split_frontmatter("---\nname: foo\nbody without close").unwrap();
394 assert!(front.is_empty());
395 assert!(body.starts_with("---"));
396 }
397
398 #[test]
399 fn discover_loads_skill_directory() {
400 let tmp = tempdir().unwrap();
401 let root = tmp.path().join("skills");
402 write_file(
403 &root.join("greet/SKILL.md"),
404 "---\nname: greet\ndescription: say hi\n---\nSay hello to the user.\n",
405 );
406 write_file(&root.join("greet/extra.txt"), "support file");
407
408 let skills = discover_skills(&[root]);
409 assert_eq!(skills.len(), 1);
410 assert_eq!(skills[0].name, "greet");
411 assert_eq!(skills[0].description, "say hi");
412 assert!(skills[0].body.contains("Say hello"));
413 let files = list_skill_files(&skills[0].dir);
414 assert!(files.iter().any(|f| f == "SKILL.md"));
415 assert!(files.iter().any(|f| f == "extra.txt"));
416 }
417
418 #[test]
419 fn discover_loads_multi_file_skills() {
420 let tmp = tempdir().unwrap();
421 let root = tmp.path().join("skills");
422 write_file(
423 &root.join("om/debug.md"),
424 "---\nname: debug\ndescription: debug issues\n---\nDebug workflow.\n",
425 );
426 write_file(
427 &root.join("om/feature.md"),
428 "---\nname: feature\ndescription: build features\n---\nFeature workflow.\n",
429 );
430
431 let skills = discover_skills(&[root]);
432 assert_eq!(skills.len(), 2);
433
434 let debug_skill = skills.iter().find(|s| s.name == "debug").unwrap();
435 assert_eq!(debug_skill.description, "debug issues");
436 assert!(debug_skill.body.contains("Debug workflow"));
437
438 let feature_skill = skills.iter().find(|s| s.name == "feature").unwrap();
439 assert_eq!(feature_skill.description, "build features");
440 assert!(feature_skill.body.contains("Feature workflow"));
441 }
442
443 #[test]
444 fn multi_file_skill_name_from_filename() {
445 let tmp = tempdir().unwrap();
446 let root = tmp.path().join("skills");
447 write_file(
449 &root.join("utils/helper.md"),
450 "---\ndescription: a helper\n---\nHelper content.\n",
451 );
452
453 let skills = discover_skills(&[root]);
454 assert_eq!(skills.len(), 1);
455 assert_eq!(skills[0].name, "helper");
456 }
457
458 #[test]
459 fn duplicate_names_are_dropped() {
460 let tmp = tempdir().unwrap();
461 let a = tmp.path().join("a");
462 let b = tmp.path().join("b");
463 write_file(
464 &a.join("x/SKILL.md"),
465 "---\nname: x\ndescription: first\n---\nA\n",
466 );
467 write_file(
468 &b.join("x/SKILL.md"),
469 "---\nname: x\ndescription: second\n---\nB\n",
470 );
471 let skills = discover_skills(&[a, b]);
472 assert_eq!(skills.len(), 1);
473 assert_eq!(skills[0].description, "first");
474 }
475
476 #[test]
477 fn missing_root_is_skipped() {
478 let skills = discover_skills(&[PathBuf::from("/definitely/not/here")]);
479 assert!(skills.is_empty());
480 }
481
482 #[test]
483 fn discover_loads_standalone_md_files() {
484 let tmp = tempdir().unwrap();
485 let root = tmp.path().join("skills");
486 write_file(
488 &root.join("om.md"),
489 "---\nname: om\ndescription: main entry\n---\nOpenMatrix entry point.\n",
490 );
491 write_file(
492 &root.join("openmatrix.md"),
493 "---\nname: openmatrix\ndescription: detect dev tasks\n---\nDetect development tasks.\n",
494 );
495
496 let skills = discover_skills(&[root]);
497 assert_eq!(skills.len(), 2);
498
499 let om = skills.iter().find(|s| s.name == "om").unwrap();
500 assert_eq!(om.description, "main entry");
501 assert!(om.body.contains("OpenMatrix entry point"));
502
503 let openmatrix = skills.iter().find(|s| s.name == "openmatrix").unwrap();
504 assert_eq!(openmatrix.description, "detect dev tasks");
505 }
506
507 #[test]
508 fn discover_mixed_formats() {
509 let tmp = tempdir().unwrap();
510 let root = tmp.path().join("skills");
511 write_file(
513 &root.join("debug/SKILL.md"),
514 "---\nname: debug\ndescription: debug tool\n---\nDebug.\n",
515 );
516 write_file(
518 &root.join("om/feature.md"),
519 "---\nname: om:feature\ndescription: build features\n---\nFeature.\n",
520 );
521 write_file(
523 &root.join("openmatrix.md"),
524 "---\nname: openmatrix\ndescription: detect tasks\n---\nDetect.\n",
525 );
526
527 let skills = discover_skills(&[root]);
528 assert_eq!(skills.len(), 3);
529 assert!(skills.iter().any(|s| s.name == "debug"));
530 assert!(skills.iter().any(|s| s.name == "om:feature"));
531 assert!(skills.iter().any(|s| s.name == "openmatrix"));
532 }
533
534 #[test]
535 fn catalogue_renders_or_skips() {
536 assert!(format_catalogue(&[]).is_none());
537 let s = Skill {
538 name: "demo".into(),
539 description: "does stuff".into(),
540 dir: PathBuf::from("/tmp"),
541 body: String::new(),
542 source_file: PathBuf::from("/tmp/demo.md"),
543 };
544 let cat = format_catalogue(&[s]).unwrap();
545 assert!(cat.contains("Use the `skill` tool"));
546 assert!(cat.contains("demo: does stuff"));
547 assert!(!cat.contains("Available skills"));
548 }
549}