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.strip_prefix('\n').or_else(|| rest.strip_prefix("\r\n"));
251 let Some(rest) = rest else {
252 return Ok((front, trimmed));
253 };
254
255 let mut end_idx: Option<usize> = None;
257 let mut cursor = 0usize;
258 for line in rest.split_inclusive('\n') {
259 let trimmed_line = line.trim_end_matches(['\n', '\r']);
260 if trimmed_line == "---" {
261 end_idx = Some(cursor + line.len());
262 break;
263 }
264 cursor += line.len();
265 }
266 let Some(end) = end_idx else {
267 return Ok((front, trimmed));
269 };
270
271 let front_block = &rest[..cursor];
272 let body = rest[end..].trim_start_matches(['\n', '\r']);
273
274 for line in front_block.lines() {
275 let line = line.trim();
276 if line.is_empty() || line.starts_with('#') {
277 continue;
278 }
279 let Some((k, v)) = line.split_once(':') else {
280 continue;
281 };
282 let key = k.trim().to_string();
283 let val = unquote(v.trim());
284 if !key.is_empty() {
285 front.insert(key, val);
286 }
287 }
288
289 Ok((front, body))
290}
291
292fn unquote(s: &str) -> String {
293 let bytes = s.as_bytes();
294 if bytes.len() >= 2
295 && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
296 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
297 {
298 return s[1..s.len() - 1].to_string();
299 }
300 s.to_string()
301}
302
303pub fn format_catalogue(skills: &[Skill]) -> Option<String> {
307 if skills.is_empty() {
308 return None;
309 }
310 let mut s = String::from(
311 "Use the `skill` tool with the skill's name to load its full instructions:
312",
313 );
314 for sk in skills {
315 s.push_str(&format!("- {}: {}
316", sk.name, sk.description));
317 }
318 Some(s)
319}
320
321pub fn list_skill_files(dir: &Path) -> Vec<String> {
325 let mut out = Vec::new();
326 walk(dir, dir, &mut out);
327 out.sort();
328 out
329}
330
331fn walk(root: &Path, cur: &Path, out: &mut Vec<String>) {
332 let entries = match std::fs::read_dir(cur) {
333 Ok(e) => e,
334 Err(_) => return,
335 };
336 for entry in entries.flatten() {
337 let p = entry.path();
338 let file_type = match entry.file_type() {
339 Ok(t) => t,
340 Err(_) => continue,
341 };
342 if file_type.is_dir() {
343 walk(root, &p, out);
344 } else if file_type.is_file()
345 && let Ok(rel) = p.strip_prefix(root) {
346 out.push(rel.display().to_string());
347 }
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use tempfile::tempdir;
355
356 fn write_file(path: &Path, body: &str) {
357 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
358 std::fs::write(path, body).unwrap();
359 }
360
361 #[test]
362 fn parses_basic_frontmatter() {
363 let (front, body) =
364 split_frontmatter("---\nname: foo\ndescription: hi there\n---\nbody text\n").unwrap();
365 assert_eq!(front.get("name").unwrap(), "foo");
366 assert_eq!(front.get("description").unwrap(), "hi there");
367 assert_eq!(body, "body text\n");
368 }
369
370 #[test]
371 fn quoted_values_are_unwrapped() {
372 let (front, _) =
373 split_frontmatter("---\nname: 'foo bar'\ndescription: \"baz\"\n---\nx").unwrap();
374 assert_eq!(front.get("name").unwrap(), "foo bar");
375 assert_eq!(front.get("description").unwrap(), "baz");
376 }
377
378 #[test]
379 fn missing_frontmatter_returns_whole_body() {
380 let (front, body) = split_frontmatter("just markdown\nno front").unwrap();
381 assert!(front.is_empty());
382 assert_eq!(body, "just markdown\nno front");
383 }
384
385 #[test]
386 fn unclosed_frontmatter_falls_back_to_body() {
387 let (front, body) = split_frontmatter("---\nname: foo\nbody without close").unwrap();
388 assert!(front.is_empty());
389 assert!(body.starts_with("---"));
390 }
391
392 #[test]
393 fn discover_loads_skill_directory() {
394 let tmp = tempdir().unwrap();
395 let root = tmp.path().join("skills");
396 write_file(
397 &root.join("greet/SKILL.md"),
398 "---\nname: greet\ndescription: say hi\n---\nSay hello to the user.\n",
399 );
400 write_file(&root.join("greet/extra.txt"), "support file");
401
402 let skills = discover_skills(&[root]);
403 assert_eq!(skills.len(), 1);
404 assert_eq!(skills[0].name, "greet");
405 assert_eq!(skills[0].description, "say hi");
406 assert!(skills[0].body.contains("Say hello"));
407 let files = list_skill_files(&skills[0].dir);
408 assert!(files.iter().any(|f| f == "SKILL.md"));
409 assert!(files.iter().any(|f| f == "extra.txt"));
410 }
411
412 #[test]
413 fn discover_loads_multi_file_skills() {
414 let tmp = tempdir().unwrap();
415 let root = tmp.path().join("skills");
416 write_file(
417 &root.join("om/debug.md"),
418 "---\nname: debug\ndescription: debug issues\n---\nDebug workflow.\n",
419 );
420 write_file(
421 &root.join("om/feature.md"),
422 "---\nname: feature\ndescription: build features\n---\nFeature workflow.\n",
423 );
424
425 let skills = discover_skills(&[root]);
426 assert_eq!(skills.len(), 2);
427
428 let debug_skill = skills.iter().find(|s| s.name == "debug").unwrap();
429 assert_eq!(debug_skill.description, "debug issues");
430 assert!(debug_skill.body.contains("Debug workflow"));
431
432 let feature_skill = skills.iter().find(|s| s.name == "feature").unwrap();
433 assert_eq!(feature_skill.description, "build features");
434 assert!(feature_skill.body.contains("Feature workflow"));
435 }
436
437 #[test]
438 fn multi_file_skill_name_from_filename() {
439 let tmp = tempdir().unwrap();
440 let root = tmp.path().join("skills");
441 write_file(
443 &root.join("utils/helper.md"),
444 "---\ndescription: a helper\n---\nHelper content.\n",
445 );
446
447 let skills = discover_skills(&[root]);
448 assert_eq!(skills.len(), 1);
449 assert_eq!(skills[0].name, "helper");
450 }
451
452 #[test]
453 fn duplicate_names_are_dropped() {
454 let tmp = tempdir().unwrap();
455 let a = tmp.path().join("a");
456 let b = tmp.path().join("b");
457 write_file(
458 &a.join("x/SKILL.md"),
459 "---\nname: x\ndescription: first\n---\nA\n",
460 );
461 write_file(
462 &b.join("x/SKILL.md"),
463 "---\nname: x\ndescription: second\n---\nB\n",
464 );
465 let skills = discover_skills(&[a, b]);
466 assert_eq!(skills.len(), 1);
467 assert_eq!(skills[0].description, "first");
468 }
469
470 #[test]
471 fn missing_root_is_skipped() {
472 let skills = discover_skills(&[PathBuf::from("/definitely/not/here")]);
473 assert!(skills.is_empty());
474 }
475
476 #[test]
477 fn discover_loads_standalone_md_files() {
478 let tmp = tempdir().unwrap();
479 let root = tmp.path().join("skills");
480 write_file(
482 &root.join("om.md"),
483 "---\nname: om\ndescription: main entry\n---\nOpenMatrix entry point.\n",
484 );
485 write_file(
486 &root.join("openmatrix.md"),
487 "---\nname: openmatrix\ndescription: detect dev tasks\n---\nDetect development tasks.\n",
488 );
489
490 let skills = discover_skills(&[root]);
491 assert_eq!(skills.len(), 2);
492
493 let om = skills.iter().find(|s| s.name == "om").unwrap();
494 assert_eq!(om.description, "main entry");
495 assert!(om.body.contains("OpenMatrix entry point"));
496
497 let openmatrix = skills.iter().find(|s| s.name == "openmatrix").unwrap();
498 assert_eq!(openmatrix.description, "detect dev tasks");
499 }
500
501 #[test]
502 fn discover_mixed_formats() {
503 let tmp = tempdir().unwrap();
504 let root = tmp.path().join("skills");
505 write_file(
507 &root.join("debug/SKILL.md"),
508 "---\nname: debug\ndescription: debug tool\n---\nDebug.\n",
509 );
510 write_file(
512 &root.join("om/feature.md"),
513 "---\nname: om:feature\ndescription: build features\n---\nFeature.\n",
514 );
515 write_file(
517 &root.join("openmatrix.md"),
518 "---\nname: openmatrix\ndescription: detect tasks\n---\nDetect.\n",
519 );
520
521 let skills = discover_skills(&[root]);
522 assert_eq!(skills.len(), 3);
523 assert!(skills.iter().any(|s| s.name == "debug"));
524 assert!(skills.iter().any(|s| s.name == "om:feature"));
525 assert!(skills.iter().any(|s| s.name == "openmatrix"));
526 }
527
528 #[test]
529 fn catalogue_renders_or_skips() {
530 assert!(format_catalogue(&[]).is_none());
531 let s = Skill {
532 name: "demo".into(),
533 description: "does stuff".into(),
534 dir: PathBuf::from("/tmp"),
535 body: String::new(),
536 source_file: PathBuf::from("/tmp/demo.md"),
537 };
538 let cat = format_catalogue(&[s]).unwrap();
539 assert!(cat.contains("Use the `skill` tool"));
540 assert!(cat.contains("demo: does stuff"));
541 assert!(!cat.contains("Available skills"));
542 }
543}