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