1use std::path::{Path, PathBuf};
43
44use anyhow::{Context, Result};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum SkillType {
49 Rigid,
51 #[default]
53 Flexible,
54}
55
56impl SkillType {
57 pub fn from_str(s: &str) -> Self {
59 match s.trim().to_lowercase().as_str() {
60 "rigid" => Self::Rigid,
61 "flexible" => Self::Flexible,
62 _ => Self::default(),
63 }
64 }
65
66 pub fn as_str(&self) -> &'static str {
68 match self {
69 Self::Rigid => "rigid",
70 Self::Flexible => "flexible",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
78pub enum SkillPriority {
79 Process = 1,
81 #[default]
83 Implementation = 2,
84}
85
86impl SkillPriority {
87 pub fn from_str(s: &str) -> Self {
89 match s.trim().to_lowercase().as_str() {
90 "process" => Self::Process,
91 "implementation" => Self::Implementation,
92 _ => Self::default(),
93 }
94 }
95
96 pub fn as_str(&self) -> &'static str {
98 match self {
99 Self::Process => "process",
100 Self::Implementation => "implementation",
101 }
102 }
103
104 pub fn display_label(&self) -> &'static str {
106 match self {
107 Self::Process => "Process",
108 Self::Implementation => "Implementation",
109 }
110 }
111}
112
113#[derive(Debug, Clone, PartialEq)]
115pub struct Skill {
116 pub name: String,
119 pub description: String,
121 pub trigger: Option<String>,
124 pub skill_type: SkillType,
126 pub priority: SkillPriority,
128 pub mandatory: bool,
130 pub dir: PathBuf,
132 pub body: String,
134 pub source_file: PathBuf,
136}
137
138impl Skill {
139 pub fn skill_md(&self) -> PathBuf {
141 self.source_file.clone()
142 }
143}
144
145pub fn discover_skills(roots: &[PathBuf]) -> Vec<Skill> {
155 let mut out: Vec<Skill> = Vec::new();
156
157 for root in roots {
158 if !root.is_dir() {
159 continue;
160 }
161 let entries = match std::fs::read_dir(root) {
162 Ok(e) => e,
163 Err(e) => {
164 eprintln!("[warn] could not read skills dir {}: {e}", root.display());
165 continue;
166 }
167 };
168 for entry in entries.flatten() {
169 let path = entry.path();
170
171 if path.is_dir() {
172 let skill_md = path.join("SKILL.md");
174 if skill_md.is_file() {
175 match load_skill_from_file(&skill_md, &path) {
176 Ok(skill) => {
177 add_skill(&mut out, skill);
178 }
179 Err(e) => {
180 eprintln!("[warn] skipping skill at {}: {e}", path.display());
181 }
182 }
183 }
186
187 load_multi_file_skills(&path, &mut out);
189 } else if path.is_file() {
190 let ext = path.extension().and_then(|e| e.to_str());
192 if ext != Some("md") {
193 continue;
194 }
195 match load_skill_from_file(&path, root) {
196 Ok(skill) => {
197 add_skill(&mut out, skill);
198 }
199 Err(e) => {
200 let raw = std::fs::read_to_string(&path).unwrap_or_default();
201 if raw.trim_start().starts_with("---") {
202 eprintln!("[warn] skipping skill file {}: {e}", path.display());
203 }
204 }
205 }
206 }
207 }
208 }
209
210 out.sort_by(|a, b| a.name.cmp(&b.name));
211 out
212}
213
214fn add_skill(out: &mut Vec<Skill>, skill: Skill) {
216 if out.iter().any(|s| s.name == skill.name) {
217 eprintln!(
218 "[warn] duplicate skill name '{}' at {} (ignored)",
219 skill.name,
220 skill.source_file.display()
221 );
222 return;
223 }
224 out.push(skill);
225}
226
227fn load_multi_file_skills(dir: &Path, out: &mut Vec<Skill>) {
232 let entries = match std::fs::read_dir(dir) {
233 Ok(e) => e,
234 Err(e) => {
235 eprintln!("[warn] could not read skill dir {}: {e}", dir.display());
236 return;
237 }
238 };
239
240 for entry in entries.flatten() {
241 let path = entry.path();
242 if !path.is_file() {
243 continue;
244 }
245
246 let ext = path.extension().and_then(|e| e.to_str());
248 if ext != Some("md") {
249 continue;
250 }
251
252 if path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
254 continue;
255 }
256
257 match load_skill_from_file(&path, dir) {
258 Ok(skill) => {
259 add_skill(out, skill);
260 }
261 Err(e) => {
262 let raw = std::fs::read_to_string(&path).unwrap_or_default();
264 if raw.trim_start().starts_with("---") {
265 eprintln!("[warn] skipping skill file {}: {e}", path.display());
266 }
267 }
268 }
269 }
270}
271
272pub fn load_skill_from_file(md_path: &Path, dir: &Path) -> Result<Skill> {
275 let raw = std::fs::read_to_string(md_path)
276 .with_context(|| format!("reading {}", md_path.display()))?;
277 let (front, body) = split_frontmatter(&raw)
278 .with_context(|| format!("parsing frontmatter of {}", md_path.display()))?;
279
280 let name = front
282 .get("name")
283 .cloned()
284 .filter(|s| !s.is_empty())
285 .or_else(|| {
286 md_path
287 .file_stem()
288 .and_then(|n| n.to_str())
289 .map(|s| s.to_string())
290 })
291 .or_else(|| {
292 dir.file_name()
293 .and_then(|n| n.to_str())
294 .map(|s| s.to_string())
295 })
296 .ok_or_else(|| anyhow::anyhow!("skill has no 'name' in frontmatter"))?;
297
298 let description = front
299 .get("description")
300 .cloned()
301 .unwrap_or_else(|| "(no description)".to_string());
302
303 let trigger = front.get("trigger").cloned();
305
306 let skill_type = front
308 .get("type")
309 .map(|s| SkillType::from_str(s))
310 .unwrap_or_default();
311
312 let priority = front
314 .get("priority")
315 .map(|s| SkillPriority::from_str(s))
316 .unwrap_or_default();
317
318 let mandatory = front
320 .get("mandatory")
321 .map(|s| s.trim().to_lowercase() == "true")
322 .unwrap_or(false);
323
324 Ok(Skill {
325 name,
326 description,
327 trigger,
328 skill_type,
329 priority,
330 mandatory,
331 dir: dir.to_path_buf(),
332 body: body.to_string(),
333 source_file: md_path.to_path_buf(),
334 })
335}
336
337pub fn load_skill(dir: &Path) -> Result<Skill> {
339 let md_path = dir.join("SKILL.md");
340 load_skill_from_file(&md_path, dir)
341}
342
343fn split_frontmatter(raw: &str) -> Result<(std::collections::BTreeMap<String, String>, &str)> {
357 let mut front = std::collections::BTreeMap::new();
358
359 let trimmed = raw.trim_start_matches('\u{feff}'); let Some(rest) = trimmed.strip_prefix("---") else {
361 return Ok((front, trimmed));
362 };
363 let rest = rest
365 .strip_prefix('\n')
366 .or_else(|| rest.strip_prefix("\r\n"));
367 let Some(rest) = rest else {
368 return Ok((front, trimmed));
369 };
370
371 let mut end_idx: Option<usize> = None;
373 let mut cursor = 0usize;
374 for line in rest.split_inclusive('\n') {
375 let trimmed_line = line.trim_end_matches(['\n', '\r']);
376 if trimmed_line == "---" {
377 end_idx = Some(cursor + line.len());
378 break;
379 }
380 cursor += line.len();
381 }
382 let Some(end) = end_idx else {
383 return Ok((front, trimmed));
385 };
386
387 let front_block = &rest[..cursor];
388 let body = rest[end..].trim_start_matches(['\n', '\r']);
389
390 for line in front_block.lines() {
391 let line = line.trim();
392 if line.is_empty() || line.starts_with('#') {
393 continue;
394 }
395 let Some((k, v)) = line.split_once(':') else {
396 continue;
397 };
398 let key = k.trim().to_string();
399 let val = unquote(v.trim());
400 if !key.is_empty() {
401 front.insert(key, val);
402 }
403 }
404
405 Ok((front, body))
406}
407
408fn unquote(s: &str) -> String {
409 let bytes = s.as_bytes();
410 if bytes.len() >= 2
411 && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
412 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
413 {
414 return s[1..s.len() - 1].to_string();
415 }
416 s.to_string()
417}
418
419pub fn format_catalogue(skills: &[Skill]) -> Option<String> {
426 if skills.is_empty() {
427 return None;
428 }
429
430 let mut sorted_skills = skills.to_vec();
432 sorted_skills.sort_by_key(|s| s.priority);
433
434 let mut s = String::from(
435 "Use the `skill` tool with the skill's name to load its full instructions.\n\n",
436 );
437
438 let process_skills: Vec<_> = sorted_skills.iter().filter(|s| s.priority == SkillPriority::Process).collect();
440 let impl_skills: Vec<_> = sorted_skills.iter().filter(|s| s.priority == SkillPriority::Implementation).collect();
441
442 if !process_skills.is_empty() {
443 s.push_str("**Process Skills** (invoke first for workflow guidance):\n");
444 for sk in process_skills {
445 let mandatory_marker = if sk.mandatory { "⚠️ " } else { "" };
446 let type_marker = if sk.skill_type == SkillType::Rigid { "[rigid] " } else { "" };
447 s.push_str(&format!(
448 "- {}{}{}: {}\n",
449 mandatory_marker, type_marker, sk.name, sk.description
450 ));
451 }
452 s.push_str("\n");
453 }
454
455 if !impl_skills.is_empty() {
456 s.push_str("**Implementation Skills** (invoke after process skills):\n");
457 for sk in impl_skills {
458 let mandatory_marker = if sk.mandatory { "⚠️ " } else { "" };
459 let type_marker = if sk.skill_type == SkillType::Rigid { "[rigid] " } else { "" };
460 s.push_str(&format!(
461 "- {}{}{}: {}\n",
462 mandatory_marker, type_marker, sk.name, sk.description
463 ));
464 }
465 }
466
467 Some(s)
468}
469
470pub fn list_skill_files(dir: &Path) -> Vec<String> {
474 let mut out = Vec::new();
475 walk(dir, dir, &mut out);
476 out.sort();
477 out
478}
479
480fn walk(root: &Path, cur: &Path, out: &mut Vec<String>) {
481 let entries = match std::fs::read_dir(cur) {
482 Ok(e) => e,
483 Err(_) => return,
484 };
485 for entry in entries.flatten() {
486 let p = entry.path();
487 let file_type = match entry.file_type() {
488 Ok(t) => t,
489 Err(_) => continue,
490 };
491 if file_type.is_dir() {
492 walk(root, &p, out);
493 } else if file_type.is_file()
494 && let Ok(rel) = p.strip_prefix(root)
495 {
496 out.push(rel.display().to_string());
497 }
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use tempfile::tempdir;
505
506 fn write_file(path: &Path, body: &str) {
507 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
508 std::fs::write(path, body).unwrap();
509 }
510
511 #[test]
512 fn parses_basic_frontmatter() {
513 let (front, body) =
514 split_frontmatter("---\nname: foo\ndescription: hi there\n---\nbody text\n").unwrap();
515 assert_eq!(front.get("name").unwrap(), "foo");
516 assert_eq!(front.get("description").unwrap(), "hi there");
517 assert_eq!(body, "body text\n");
518 }
519
520 #[test]
521 fn quoted_values_are_unwrapped() {
522 let (front, _) =
523 split_frontmatter("---\nname: 'foo bar'\ndescription: \"baz\"\n---\nx").unwrap();
524 assert_eq!(front.get("name").unwrap(), "foo bar");
525 assert_eq!(front.get("description").unwrap(), "baz");
526 }
527
528 #[test]
529 fn missing_frontmatter_returns_whole_body() {
530 let (front, body) = split_frontmatter("just markdown\nno front").unwrap();
531 assert!(front.is_empty());
532 assert_eq!(body, "just markdown\nno front");
533 }
534
535 #[test]
536 fn unclosed_frontmatter_falls_back_to_body() {
537 let (front, body) = split_frontmatter("---\nname: foo\nbody without close").unwrap();
538 assert!(front.is_empty());
539 assert!(body.starts_with("---"));
540 }
541
542 #[test]
543 fn discover_loads_skill_directory() {
544 let tmp = tempdir().unwrap();
545 let root = tmp.path().join("skills");
546 write_file(
547 &root.join("greet/SKILL.md"),
548 "---\nname: greet\ndescription: say hi\n---\nSay hello to the user.\n",
549 );
550 write_file(&root.join("greet/extra.txt"), "support file");
551
552 let skills = discover_skills(&[root]);
553 assert_eq!(skills.len(), 1);
554 assert_eq!(skills[0].name, "greet");
555 assert_eq!(skills[0].description, "say hi");
556 assert!(skills[0].body.contains("Say hello"));
557 let files = list_skill_files(&skills[0].dir);
558 assert!(files.iter().any(|f| f == "SKILL.md"));
559 assert!(files.iter().any(|f| f == "extra.txt"));
560 }
561
562 #[test]
563 fn discover_loads_multi_file_skills() {
564 let tmp = tempdir().unwrap();
565 let root = tmp.path().join("skills");
566 write_file(
567 &root.join("om/debug.md"),
568 "---\nname: debug\ndescription: debug issues\n---\nDebug workflow.\n",
569 );
570 write_file(
571 &root.join("om/feature.md"),
572 "---\nname: feature\ndescription: build features\n---\nFeature workflow.\n",
573 );
574
575 let skills = discover_skills(&[root]);
576 assert_eq!(skills.len(), 2);
577
578 let debug_skill = skills.iter().find(|s| s.name == "debug").unwrap();
579 assert_eq!(debug_skill.description, "debug issues");
580 assert!(debug_skill.body.contains("Debug workflow"));
581
582 let feature_skill = skills.iter().find(|s| s.name == "feature").unwrap();
583 assert_eq!(feature_skill.description, "build features");
584 assert!(feature_skill.body.contains("Feature workflow"));
585 }
586
587 #[test]
588 fn multi_file_skill_name_from_filename() {
589 let tmp = tempdir().unwrap();
590 let root = tmp.path().join("skills");
591 write_file(
593 &root.join("utils/helper.md"),
594 "---\ndescription: a helper\n---\nHelper content.\n",
595 );
596
597 let skills = discover_skills(&[root]);
598 assert_eq!(skills.len(), 1);
599 assert_eq!(skills[0].name, "helper");
600 }
601
602 #[test]
603 fn duplicate_names_are_dropped() {
604 let tmp = tempdir().unwrap();
605 let a = tmp.path().join("a");
606 let b = tmp.path().join("b");
607 write_file(
608 &a.join("x/SKILL.md"),
609 "---\nname: x\ndescription: first\n---\nA\n",
610 );
611 write_file(
612 &b.join("x/SKILL.md"),
613 "---\nname: x\ndescription: second\n---\nB\n",
614 );
615 let skills = discover_skills(&[a, b]);
616 assert_eq!(skills.len(), 1);
617 assert_eq!(skills[0].description, "first");
618 }
619
620 #[test]
621 fn missing_root_is_skipped() {
622 let skills = discover_skills(&[PathBuf::from("/definitely/not/here")]);
623 assert!(skills.is_empty());
624 }
625
626 #[test]
627 fn discover_loads_standalone_md_files() {
628 let tmp = tempdir().unwrap();
629 let root = tmp.path().join("skills");
630 write_file(
632 &root.join("om.md"),
633 "---\nname: om\ndescription: main entry\n---\nOpenMatrix entry point.\n",
634 );
635 write_file(
636 &root.join("openmatrix.md"),
637 "---\nname: openmatrix\ndescription: detect dev tasks\n---\nDetect development tasks.\n",
638 );
639
640 let skills = discover_skills(&[root]);
641 assert_eq!(skills.len(), 2);
642
643 let om = skills.iter().find(|s| s.name == "om").unwrap();
644 assert_eq!(om.description, "main entry");
645 assert!(om.body.contains("OpenMatrix entry point"));
646
647 let openmatrix = skills.iter().find(|s| s.name == "openmatrix").unwrap();
648 assert_eq!(openmatrix.description, "detect dev tasks");
649 }
650
651 #[test]
652 fn discover_mixed_formats() {
653 let tmp = tempdir().unwrap();
654 let root = tmp.path().join("skills");
655 write_file(
657 &root.join("debug/SKILL.md"),
658 "---\nname: debug\ndescription: debug tool\n---\nDebug.\n",
659 );
660 write_file(
662 &root.join("om/feature.md"),
663 "---\nname: om:feature\ndescription: build features\n---\nFeature.\n",
664 );
665 write_file(
667 &root.join("openmatrix.md"),
668 "---\nname: openmatrix\ndescription: detect tasks\n---\nDetect.\n",
669 );
670
671 let skills = discover_skills(&[root]);
672 assert_eq!(skills.len(), 3);
673 assert!(skills.iter().any(|s| s.name == "debug"));
674 assert!(skills.iter().any(|s| s.name == "om:feature"));
675 assert!(skills.iter().any(|s| s.name == "openmatrix"));
676 }
677
678 #[test]
679 fn catalogue_renders_or_skips() {
680 assert!(format_catalogue(&[]).is_none());
681 let s = Skill {
682 name: "demo".into(),
683 description: "does stuff".into(),
684 trigger: None,
685 skill_type: SkillType::Flexible,
686 priority: SkillPriority::Implementation,
687 mandatory: false,
688 dir: PathBuf::from("/tmp"),
689 body: String::new(),
690 source_file: PathBuf::from("/tmp/demo.md"),
691 };
692 let cat = format_catalogue(&[s]).unwrap();
693 assert!(cat.contains("Use the `skill` tool"));
694 assert!(cat.contains("demo: does stuff"));
695 assert!(cat.contains("Implementation Skills"));
696 }
697
698 #[test]
699 fn catalogue_groups_by_priority() {
700 let process_skill = Skill {
701 name: "brainstorm".into(),
702 description: "brainstorm ideas".into(),
703 trigger: None,
704 skill_type: SkillType::Flexible,
705 priority: SkillPriority::Process,
706 mandatory: false,
707 dir: PathBuf::from("/tmp"),
708 body: String::new(),
709 source_file: PathBuf::from("/tmp/brainstorm.md"),
710 };
711 let impl_skill = Skill {
712 name: "frontend".into(),
713 description: "frontend design".into(),
714 trigger: None,
715 skill_type: SkillType::Rigid,
716 priority: SkillPriority::Implementation,
717 mandatory: true,
718 dir: PathBuf::from("/tmp"),
719 body: String::new(),
720 source_file: PathBuf::from("/tmp/frontend.md"),
721 };
722 let cat = format_catalogue(&[impl_skill, process_skill]).unwrap();
723 let process_idx = cat.find("Process Skills").unwrap();
725 let impl_idx = cat.find("Implementation Skills").unwrap();
726 assert!(process_idx < impl_idx, "Process skills should come before Implementation");
727 assert!(cat.contains("⚠️"), "Mandatory skill should have marker");
729 assert!(cat.contains("[rigid]"), "Rigid skill should have marker");
731 }
732
733 #[test]
734 fn skill_type_parsing() {
735 assert_eq!(SkillType::from_str("rigid"), SkillType::Rigid);
736 assert_eq!(SkillType::from_str("RIGID"), SkillType::Rigid);
737 assert_eq!(SkillType::from_str("flexible"), SkillType::Flexible);
738 assert_eq!(SkillType::from_str("unknown"), SkillType::Flexible);
739 }
740
741 #[test]
742 fn skill_priority_parsing() {
743 assert_eq!(SkillPriority::from_str("process"), SkillPriority::Process);
744 assert_eq!(SkillPriority::from_str("PROCESS"), SkillPriority::Process);
745 assert_eq!(SkillPriority::from_str("implementation"), SkillPriority::Implementation);
746 assert_eq!(SkillPriority::from_str("unknown"), SkillPriority::Implementation);
747 }
748
749 #[test]
750 fn skill_priority_ordering() {
751 assert!(SkillPriority::Process < SkillPriority::Implementation);
752 }
753}