1use anyhow::{Context, Result};
35use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38use std::path::Path;
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct SkillMdFrontmatter {
43 pub name: String,
45
46 pub description: String,
48
49 #[serde(default, rename = "allowed-tools")]
51 pub allowed_tools: Option<String>,
52
53 #[serde(flatten)]
55 pub extra: HashMap<String, serde_yaml::Value>,
56}
57
58#[derive(Debug, Clone, Default)]
60pub struct SkillMdContent {
61 pub frontmatter: SkillMdFrontmatter,
63
64 pub body: String,
66
67 pub tool_docs: HashMap<String, ToolDocumentation>,
69
70 pub examples: Vec<CodeExample>,
72
73 pub when_to_use: Option<String>,
75
76 pub configuration: Option<String>,
78}
79
80#[derive(Debug, Clone, Default)]
82pub struct ToolDocumentation {
83 pub name: String,
85
86 pub description: String,
88
89 pub usage: Option<String>,
91
92 pub parameters: Vec<ParameterDoc>,
94
95 pub examples: Vec<CodeExample>,
97}
98
99#[derive(Debug, Clone, PartialEq, Default)]
101pub enum ParameterType {
102 #[default]
103 String,
104 Integer,
105 Number,
106 Boolean,
107 Array,
108 Object,
109}
110
111impl std::fmt::Display for ParameterType {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 ParameterType::String => write!(f, "string"),
115 ParameterType::Integer => write!(f, "integer"),
116 ParameterType::Number => write!(f, "number"),
117 ParameterType::Boolean => write!(f, "boolean"),
118 ParameterType::Array => write!(f, "array"),
119 ParameterType::Object => write!(f, "object"),
120 }
121 }
122}
123
124#[derive(Debug, Clone)]
126pub struct ParameterDoc {
127 pub name: String,
129
130 pub required: bool,
132
133 pub param_type: ParameterType,
135
136 pub description: String,
138
139 pub default: Option<String>,
141
142 pub allowed_values: Vec<String>,
144}
145
146#[derive(Debug, Clone)]
148pub struct CodeExample {
149 pub language: Option<String>,
151
152 pub code: String,
154
155 pub description: Option<String>,
157}
158
159pub fn parse_skill_md(path: &Path) -> Result<SkillMdContent> {
161 let content = std::fs::read_to_string(path)
162 .with_context(|| format!("Failed to read SKILL.md: {}", path.display()))?;
163
164 parse_skill_md_content(&content)
165}
166
167pub fn parse_skill_md_content(content: &str) -> Result<SkillMdContent> {
169 let (frontmatter, body) = extract_frontmatter(content)?;
171
172 let tool_docs = extract_tool_sections(&body);
174 let examples = extract_code_examples(&body);
175 let when_to_use = extract_section(&body, "When to Use");
176 let configuration = extract_section(&body, "Configuration");
177
178 Ok(SkillMdContent {
179 frontmatter,
180 body,
181 tool_docs,
182 examples,
183 when_to_use,
184 configuration,
185 })
186}
187
188fn extract_frontmatter(content: &str) -> Result<(SkillMdFrontmatter, String)> {
190 let content = content.trim();
191
192 if !content.starts_with("---") {
194 return Ok((SkillMdFrontmatter::default(), content.to_string()));
196 }
197
198 let after_first = &content[3..];
200 let end_pos = after_first
201 .find("\n---")
202 .or_else(|| after_first.find("\r\n---"))
203 .context("SKILL.md has opening --- but no closing ---")?;
204
205 let yaml_content = &after_first[..end_pos].trim();
206 let body_start = 3 + end_pos + 4; let body = if body_start < content.len() {
208 content[body_start..].trim().to_string()
209 } else {
210 String::new()
211 };
212
213 let frontmatter: SkillMdFrontmatter = serde_yaml::from_str(yaml_content)
215 .with_context(|| format!("Failed to parse SKILL.md frontmatter: {}", yaml_content))?;
216
217 Ok((frontmatter, body))
218}
219
220fn extract_tool_sections(markdown: &str) -> HashMap<String, ToolDocumentation> {
222 let mut tools = HashMap::new();
223 let parser = Parser::new(markdown);
224
225 let mut _current_h2: Option<String> = None;
226 let mut _current_h3: Option<String> = None;
227 let mut current_tool: Option<ToolDocumentation> = None;
228 let mut in_tools_section = false;
229 let mut collecting_text = false;
230 let mut current_text = String::new();
231 let mut in_code_block = false;
232 let mut code_lang: Option<String> = None;
233 let mut code_content = String::new();
234 let mut h3_tool_candidate: Option<ToolDocumentation> = None;
235
236 for event in parser {
237 match event {
238 Event::Start(Tag::Heading { level, .. }) => {
239 if let Some(tool) = current_tool.take() {
241 if !tool.name.is_empty() {
242 tools.insert(tool.name.clone(), tool);
243 }
244 }
245
246 collecting_text = true;
247 current_text.clear();
248
249 match level {
250 HeadingLevel::H2 => {
251 _current_h3 = None;
252 }
253 HeadingLevel::H3 => {}
254 _ => {}
255 }
256 }
257 Event::End(TagEnd::Heading(level)) => {
258 collecting_text = false;
259 let heading = current_text.trim().to_string();
260
261 match level {
262 HeadingLevel::H2 => {
263 if let Some(h3_tool) = h3_tool_candidate.take() {
265 if !h3_tool.name.is_empty() {
266 tools.insert(h3_tool.name.clone(), h3_tool);
267 }
268 }
269 _current_h2 = Some(heading.clone());
270 in_tools_section = heading.to_lowercase().contains("tools");
271 }
272 HeadingLevel::H3 if in_tools_section => {
273 if let Some(h3_tool) = h3_tool_candidate.take() {
275 if !h3_tool.name.is_empty() {
276 tools.insert(h3_tool.name.clone(), h3_tool);
277 }
278 }
279 _current_h3 = Some(heading.clone());
281 h3_tool_candidate = Some(ToolDocumentation {
282 name: heading,
283 ..Default::default()
284 });
285 }
286 HeadingLevel::H4 if in_tools_section => {
287 h3_tool_candidate = None;
289 current_tool = Some(ToolDocumentation {
291 name: heading,
292 ..Default::default()
293 });
294 }
295 _ => {}
296 }
297 }
298 Event::Start(Tag::CodeBlock(kind)) => {
299 in_code_block = true;
300 code_lang = match kind {
301 pulldown_cmark::CodeBlockKind::Fenced(lang) => {
302 let lang_str = lang.to_string();
303 if lang_str.is_empty() {
304 None
305 } else {
306 Some(lang_str)
307 }
308 }
309 _ => None,
310 };
311 code_content.clear();
312 }
313 Event::End(TagEnd::CodeBlock) => {
314 in_code_block = false;
315 if let Some(ref mut tool) = current_tool {
316 tool.examples.push(CodeExample {
317 language: code_lang.take(),
318 code: code_content.clone(),
319 description: None,
320 });
321 }
322 }
323 Event::Text(text) => {
324 if collecting_text {
325 current_text.push_str(&text);
326 } else if in_code_block {
327 code_content.push_str(&text);
328 } else if let Some(ref mut tool) = current_tool {
329 if tool.description.is_empty() && !text.trim().is_empty() {
331 tool.description = text.trim().to_string();
332 }
333 }
334 }
335 Event::Code(code) => {
336 if collecting_text {
337 current_text.push_str(&code);
338 }
339 }
340 _ => {}
341 }
342 }
343
344 if let Some(tool) = current_tool {
346 if !tool.name.is_empty() {
347 tools.insert(tool.name.clone(), tool);
348 }
349 } else if let Some(h3_tool) = h3_tool_candidate {
350 if !h3_tool.name.is_empty() {
352 tools.insert(h3_tool.name.clone(), h3_tool);
353 }
354 }
355
356 extract_tool_parameters(markdown, &mut tools);
358
359 tools
360}
361
362fn extract_tool_parameters(markdown: &str, tools: &mut HashMap<String, ToolDocumentation>) {
365 for (tool_name, tool_doc) in tools.iter_mut() {
366 if let Some(tool_section) = extract_tool_section_content(markdown, tool_name) {
368 if let Some(params_text) = extract_parameters_section(&tool_section) {
370 tool_doc.parameters = parse_parameters(¶ms_text);
371 }
372 }
373 }
374}
375
376fn extract_tool_section_content(markdown: &str, tool_name: &str) -> Option<String> {
378 let lines: Vec<&str> = markdown.lines().collect();
379 let mut start_idx: Option<usize> = None;
380 let mut section_level: Option<usize> = None;
381
382 for (idx, line) in lines.iter().enumerate() {
384 let trimmed = line.trim();
385 if (trimmed.starts_with("### ") || trimmed.starts_with("#### "))
387 && trimmed.trim_start_matches('#').trim() == tool_name {
388 start_idx = Some(idx);
389 section_level = Some(trimmed.chars().take_while(|c| *c == '#').count());
390 break;
391 }
392 }
393
394 let start_idx = start_idx?;
395 let section_level = section_level?;
396
397 let mut end_idx = lines.len();
399 for (idx, line) in lines.iter().enumerate().skip(start_idx + 1) {
400 let trimmed = line.trim();
401 if trimmed.starts_with('#') {
402 let level = trimmed.chars().take_while(|c| *c == '#').count();
403 if level <= section_level {
404 end_idx = idx;
405 break;
406 }
407 }
408 }
409
410 let section_lines = &lines[start_idx..end_idx];
412 Some(section_lines.join("\n"))
413}
414
415fn extract_parameters_section(tool_section: &str) -> Option<String> {
417 let lines: Vec<&str> = tool_section.lines().collect();
418 let mut params_start: Option<usize> = None;
419
420 for (idx, line) in lines.iter().enumerate() {
422 let trimmed = line.trim();
423 if trimmed.starts_with("**Parameters") && trimmed.contains(':') {
424 params_start = Some(idx);
425 break;
426 }
427 }
428
429 let params_start = params_start?;
430
431 let mut params_end = lines.len();
433 for (idx, line) in lines.iter().enumerate().skip(params_start + 1) {
434 let trimmed = line.trim();
435 if trimmed.starts_with("**") && !trimmed.starts_with("**Parameters") {
437 params_end = idx;
438 break;
439 }
440 if trimmed.starts_with("```") {
442 params_end = idx;
443 break;
444 }
445 }
446
447 let params_lines = &lines[params_start..params_end];
448 Some(params_lines.join("\n"))
449}
450
451fn extract_code_examples(markdown: &str) -> Vec<CodeExample> {
453 let parser = Parser::new(markdown);
454 let mut examples = Vec::new();
455 let mut in_code_block = false;
456 let mut code_lang: Option<String> = None;
457 let mut code_content = String::new();
458
459 for event in parser {
460 match event {
461 Event::Start(Tag::CodeBlock(kind)) => {
462 in_code_block = true;
463 code_lang = match kind {
464 pulldown_cmark::CodeBlockKind::Fenced(lang) => {
465 let lang_str = lang.to_string();
466 if lang_str.is_empty() {
467 None
468 } else {
469 Some(lang_str)
470 }
471 }
472 _ => None,
473 };
474 code_content.clear();
475 }
476 Event::End(TagEnd::CodeBlock) => {
477 in_code_block = false;
478 examples.push(CodeExample {
479 language: code_lang.take(),
480 code: code_content.clone(),
481 description: None,
482 });
483 }
484 Event::Text(text) if in_code_block => {
485 code_content.push_str(&text);
486 }
487 _ => {}
488 }
489 }
490
491 examples
492}
493
494fn extract_section(markdown: &str, section_name: &str) -> Option<String> {
496 let parser = Parser::new(markdown);
497 let mut in_target_section = false;
498 let mut content = String::new();
499 let mut collecting_heading = false;
500 let mut heading_text = String::new();
501 let mut target_level: Option<HeadingLevel> = None;
502
503 for event in parser {
504 match event {
505 Event::Start(Tag::Heading { level, .. }) => {
506 if in_target_section {
507 if let Some(target) = target_level {
509 if level <= target {
510 break;
511 }
512 }
513 }
514 collecting_heading = true;
515 heading_text.clear();
516 }
517 Event::End(TagEnd::Heading(level)) => {
518 collecting_heading = false;
519 if heading_text.to_lowercase().contains(§ion_name.to_lowercase()) {
520 in_target_section = true;
521 target_level = Some(level);
522 }
523 }
524 Event::Text(text) => {
525 if collecting_heading {
526 heading_text.push_str(&text);
527 } else if in_target_section {
528 content.push_str(&text);
529 }
530 }
531 Event::SoftBreak | Event::HardBreak if in_target_section => {
532 content.push('\n');
533 }
534 Event::Start(Tag::Paragraph) if in_target_section => {}
535 Event::End(TagEnd::Paragraph) if in_target_section => {
536 content.push('\n');
537 }
538 Event::Start(Tag::Item) if in_target_section => {
539 content.push_str("- ");
540 }
541 Event::End(TagEnd::Item) if in_target_section => {
542 content.push('\n');
543 }
544 _ => {}
545 }
546 }
547
548 if content.trim().is_empty() {
549 None
550 } else {
551 Some(content.trim().to_string())
552 }
553}
554
555pub fn parse_parameters(text: &str) -> Vec<ParameterDoc> {
564 let mut params = Vec::new();
565
566 for line in text.lines() {
567 let line = line.trim();
568 if !line.starts_with('-') && !line.starts_with('*') {
569 continue;
570 }
571
572 let line = line.trim_start_matches('-').trim_start_matches('*').trim();
574
575 let (name, rest) = if line.starts_with('`') {
577 if let Some(end) = line[1..].find('`') {
578 let name = &line[1..=end];
579 let rest = &line[end + 2..];
580 (name.to_string(), rest.trim())
581 } else {
582 continue;
583 }
584 } else if line.starts_with("**") {
585 if let Some(end) = line[2..].find("**") {
586 let name = &line[2..end + 2];
587 let rest = &line[end + 4..];
588 (name.to_string(), rest.trim())
589 } else {
590 continue;
591 }
592 } else {
593 continue;
594 };
595
596 let rest_lower = rest.to_lowercase();
597
598 let required = rest_lower.contains("required");
600
601 let param_type = if rest_lower.contains("integer") || rest_lower.contains("int)") {
604 ParameterType::Integer
605 } else if rest_lower.contains("number") || rest_lower.contains("float") {
606 ParameterType::Number
607 } else if rest_lower.contains("boolean") || rest_lower.contains("bool") {
608 ParameterType::Boolean
609 } else if rest_lower.contains("array") || rest_lower.contains("list") {
610 ParameterType::Array
611 } else if rest_lower.contains("object") || rest_lower.contains("json") {
612 ParameterType::Object
613 } else {
614 ParameterType::String
615 };
616
617 let default = if let Some(pos) = rest_lower.find("default:") {
620 let after = &rest[pos + 8..];
621 let end = after.find(|c: char| c == ',' || c == ')').unwrap_or(after.len());
623 Some(after[..end].trim().to_string())
624 } else if let Some(pos) = rest_lower.find("default=") {
625 let after = &rest[pos + 8..];
626 let end = after.find(|c: char| c == ',' || c == ')').unwrap_or(after.len());
627 Some(after[..end].trim().to_string())
628 } else {
629 None
630 };
631
632 let allowed_values = if let Some(pos) = rest_lower.find("enum:") {
635 let after = &rest[pos + 5..];
636 let end = after.find(')').unwrap_or(after.len());
637 after[..end]
638 .split('|')
639 .map(|s| s.trim().to_string())
640 .filter(|s| !s.is_empty())
641 .collect()
642 } else {
643 Vec::new()
644 };
645
646 let description = if let Some(colon_pos) = rest.find(':') {
648 let before_colon = &rest[..colon_pos];
650 let open_parens = before_colon.matches('(').count();
651 let close_parens = before_colon.matches(')').count();
652
653 if open_parens > close_parens {
654 if let Some(paren_end) = rest.find(')') {
656 if let Some(next_colon) = rest[paren_end..].find(':') {
657 rest[paren_end + next_colon + 1..].trim().to_string()
658 } else {
659 rest[paren_end + 1..].trim().to_string()
660 }
661 } else {
662 rest.to_string()
663 }
664 } else {
665 rest[colon_pos + 1..].trim().to_string()
666 }
667 } else {
668 rest.to_string()
669 };
670
671 params.push(ParameterDoc {
672 name,
673 required,
674 param_type,
675 description,
676 default,
677 allowed_values,
678 });
679 }
680
681 params
682}
683
684pub fn find_skill_md(skill_dir: &Path) -> Option<std::path::PathBuf> {
686 let skill_md = skill_dir.join("SKILL.md");
687 if skill_md.exists() {
688 return Some(skill_md);
689 }
690
691 let skill_md_lower = skill_dir.join("skill.md");
693 if skill_md_lower.exists() {
694 return Some(skill_md_lower);
695 }
696
697 None
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 #[test]
705 fn test_parse_frontmatter() {
706 let content = r#"---
707name: test-skill
708description: A test skill for unit testing
709allowed-tools: Read, Bash
710---
711
712# Test Skill
713
714This is the body content.
715"#;
716
717 let result = parse_skill_md_content(content).unwrap();
718 assert_eq!(result.frontmatter.name, "test-skill");
719 assert_eq!(result.frontmatter.description, "A test skill for unit testing");
720 assert_eq!(result.frontmatter.allowed_tools, Some("Read, Bash".to_string()));
721 assert!(result.body.contains("# Test Skill"));
722 }
723
724 #[test]
725 fn test_parse_no_frontmatter() {
726 let content = r#"# Just a Markdown File
727
728No frontmatter here.
729"#;
730
731 let result = parse_skill_md_content(content).unwrap();
732 assert!(result.frontmatter.name.is_empty());
733 assert!(result.body.contains("# Just a Markdown File"));
734 }
735
736 #[test]
737 fn test_extract_tool_sections() {
738 let markdown = r#"
739# Skill
740
741## Tools Provided
742
743### get
744Get resources from the cluster.
745
746### delete
747Delete resources from the cluster.
748
749## Configuration
750
751Some config info.
752"#;
753
754 let tools = extract_tool_sections(markdown);
755 assert!(tools.contains_key("get"));
756 assert!(tools.contains_key("delete"));
757 assert_eq!(tools.get("get").unwrap().description, "Get resources from the cluster.");
758 }
759
760 #[test]
761 fn test_extract_code_examples() {
762 let markdown = r#"
763# Example
764
765```bash
766skill run kubernetes get resource=pods
767```
768
769Some text.
770
771```json
772{"key": "value"}
773```
774"#;
775
776 let examples = extract_code_examples(markdown);
777 assert_eq!(examples.len(), 2);
778 assert_eq!(examples[0].language, Some("bash".to_string()));
779 assert!(examples[0].code.contains("skill run"));
780 assert_eq!(examples[1].language, Some("json".to_string()));
781 }
782
783 #[test]
784 fn test_extract_section() {
785 let markdown = r#"
786# Skill
787
788## When to Use
789
790Use this skill when you need to:
791- Manage Kubernetes resources
792- Deploy applications
793
794## Configuration
795
796Set up credentials first.
797"#;
798
799 let when_to_use = extract_section(markdown, "When to Use").unwrap();
800 assert!(when_to_use.contains("Manage Kubernetes"));
801 assert!(when_to_use.contains("Deploy applications"));
802 }
803
804 #[test]
805 fn test_parse_parameters() {
806 let text = r#"
807**Parameters**:
808- `resource` (required): The resource type to get
809- `namespace` (optional): Kubernetes namespace
810- `output` (optional): Output format
811"#;
812
813 let params = parse_parameters(text);
814 assert_eq!(params.len(), 3);
815 assert_eq!(params[0].name, "resource");
816 assert!(params[0].required);
817 assert_eq!(params[0].param_type, ParameterType::String);
818 assert_eq!(params[1].name, "namespace");
819 assert!(!params[1].required);
820 }
821
822 #[test]
823 fn test_parse_parameters_with_types() {
824 let text = r#"
825**Parameters**:
826- `count` (required, integer): Number of items to return
827- `enabled` (optional, boolean, default: true): Enable feature
828- `replicas` (required, integer): Desired replica count
829- `format` (optional, enum: json|yaml|table): Output format
830"#;
831
832 let params = parse_parameters(text);
833 assert_eq!(params.len(), 4);
834
835 assert_eq!(params[0].name, "count");
836 assert!(params[0].required);
837 assert_eq!(params[0].param_type, ParameterType::Integer);
838
839 assert_eq!(params[1].name, "enabled");
840 assert!(!params[1].required);
841 assert_eq!(params[1].param_type, ParameterType::Boolean);
842 assert_eq!(params[1].default, Some("true".to_string()));
843
844 assert_eq!(params[2].name, "replicas");
845 assert_eq!(params[2].param_type, ParameterType::Integer);
846
847 assert_eq!(params[3].name, "format");
848 assert_eq!(params[3].allowed_values, vec!["json", "yaml", "table"]);
849 }
850}