1use anyhow::{bail, Context, Result};
21use serde::{Deserialize, Serialize};
22use std::fmt;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26pub const MAX_NAME_LENGTH: usize = 64;
28
29pub const MAX_DESCRIPTION_LENGTH: usize = 1024;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SkillFrontmatter {
39 pub name: String,
42
43 pub description: String,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub license: Option<String>,
49
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub compatibility: Option<String>,
53
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub metadata: Option<toml::Value>,
57
58 #[serde(skip_serializing_if = "Option::is_none")]
60 #[serde(rename = "allowed-tools")]
61 pub allowed_tools: Option<String>,
62
63 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
65 #[serde(rename = "disable-model-invocation")]
66 pub disable_model_invocation: bool,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum ValidationSeverity {
75 Error,
77 Warning,
79}
80
81impl fmt::Display for ValidationSeverity {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 ValidationSeverity::Error => write!(f, "error"),
85 ValidationSeverity::Warning => write!(f, "warning"),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ValidationFinding {
93 pub severity: ValidationSeverity,
95 pub message: String,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub path: Option<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ValidationResult {
105 pub valid: bool,
107 pub findings: Vec<ValidationFinding>,
109}
110
111impl ValidationResult {
112 pub fn pass() -> Self {
114 Self {
115 valid: true,
116 findings: Vec::new(),
117 }
118 }
119
120 pub fn fail(findings: Vec<ValidationFinding>) -> Self {
122 let has_errors = findings.iter().any(|f| f.severity == ValidationSeverity::Error);
123 Self {
124 valid: !has_errors,
125 findings,
126 }
127 }
128
129 pub fn add(&mut self, severity: ValidationSeverity, message: impl Into<String>) {
131 if severity == ValidationSeverity::Error {
132 self.valid = false;
133 }
134 self.findings.push(ValidationFinding {
135 severity,
136 message: message.into(),
137 path: None,
138 });
139 }
140
141 pub fn add_with_path(
143 &mut self,
144 severity: ValidationSeverity,
145 message: impl Into<String>,
146 path: impl Into<String>,
147 ) {
148 if severity == ValidationSeverity::Error {
149 self.valid = false;
150 }
151 self.findings.push(ValidationFinding {
152 severity,
153 message: message.into(),
154 path: Some(path.into()),
155 });
156 }
157
158 pub fn has_errors(&self) -> bool {
160 self.findings
161 .iter()
162 .any(|f| f.severity == ValidationSeverity::Error)
163 }
164
165 pub fn has_warnings(&self) -> bool {
167 self.findings
168 .iter()
169 .any(|f| f.severity == ValidationSeverity::Warning)
170 }
171}
172
173pub struct SkillValidator;
175
176impl SkillValidator {
177 pub fn validate_name(name: &str) -> Vec<ValidationFinding> {
179 let mut findings = Vec::new();
180
181 if name.is_empty() {
182 findings.push(ValidationFinding {
183 severity: ValidationSeverity::Error,
184 message: "name is required".to_string(),
185 path: None,
186 });
187 return findings;
188 }
189
190 if name.len() > MAX_NAME_LENGTH {
191 findings.push(ValidationFinding {
192 severity: ValidationSeverity::Warning,
193 message: format!(
194 "name exceeds {} characters ({} chars)",
195 MAX_NAME_LENGTH,
196 name.len()
197 ),
198 path: None,
199 });
200 }
201
202 if !name
203 .chars()
204 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
205 {
206 findings.push(ValidationFinding {
207 severity: ValidationSeverity::Warning,
208 message: "name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"
209 .to_string(),
210 path: None,
211 });
212 }
213
214 if name.starts_with('-') || name.ends_with('-') {
215 findings.push(ValidationFinding {
216 severity: ValidationSeverity::Warning,
217 message: "name must not start or end with a hyphen".to_string(),
218 path: None,
219 });
220 }
221
222 if name.contains("--") {
223 findings.push(ValidationFinding {
224 severity: ValidationSeverity::Warning,
225 message: "name must not contain consecutive hyphens".to_string(),
226 path: None,
227 });
228 }
229
230 findings
231 }
232
233 pub fn validate_description(description: &str) -> Vec<ValidationFinding> {
235 let mut findings = Vec::new();
236
237 if description.trim().is_empty() {
238 findings.push(ValidationFinding {
239 severity: ValidationSeverity::Error,
240 message: "description is required".to_string(),
241 path: None,
242 });
243 } else if description.len() > MAX_DESCRIPTION_LENGTH {
244 findings.push(ValidationFinding {
245 severity: ValidationSeverity::Warning,
246 message: format!(
247 "description exceeds {} characters ({} chars)",
248 MAX_DESCRIPTION_LENGTH,
249 description.len()
250 ),
251 path: None,
252 });
253 }
254
255 findings
256 }
257
258 pub fn validate_name_matches_dir(
260 name: &str,
261 dir_path: &Path,
262 ) -> Vec<ValidationFinding> {
263 let dir_name = dir_path
264 .file_name()
265 .unwrap_or_default()
266 .to_string_lossy();
267
268 if name != dir_name {
269 vec![ValidationFinding {
270 severity: ValidationSeverity::Warning,
271 message: format!(
272 "name \"{}\" does not match parent directory \"{}\"",
273 name, dir_name
274 ),
275 path: Some(dir_path.to_string_lossy().to_string()),
276 }]
277 } else {
278 Vec::new()
279 }
280 }
281
282 pub fn validate_skill_dir(dir: &Path) -> ValidationResult {
284 let mut result = ValidationResult::pass();
285
286 let skill_file = dir.join("SKILL.md");
288 if !skill_file.exists() {
289 result.add_with_path(
290 ValidationSeverity::Error,
291 "SKILL.md not found in skill directory",
292 dir.to_string_lossy(),
293 );
294 return result;
295 }
296
297 let content = match fs::read_to_string(&skill_file) {
299 Ok(c) => c,
300 Err(e) => {
301 result.add_with_path(
302 ValidationSeverity::Error,
303 format!("Failed to read SKILL.md: {}", e),
304 skill_file.to_string_lossy(),
305 );
306 return result;
307 }
308 };
309
310 let frontmatter = match Self::parse_frontmatter(&content) {
312 Ok(fm) => fm,
313 Err(e) => {
314 result.add_with_path(
315 ValidationSeverity::Error,
316 format!("Failed to parse frontmatter: {}", e),
317 skill_file.to_string_lossy(),
318 );
319 return result;
320 }
321 };
322
323 for finding in Self::validate_name(&frontmatter.name) {
325 result.add(finding.severity, finding.message);
326 }
327
328 for finding in Self::validate_name_matches_dir(&frontmatter.name, dir) {
330 result.findings.push(finding);
331 if result.has_errors() {
332 result.valid = false;
333 }
334 }
335
336 for finding in Self::validate_description(&frontmatter.description) {
338 result.add(finding.severity, finding.message);
339 }
340
341 if let Some(ref compat) = frontmatter.compatibility {
343 if compat.len() > 500 {
344 result.add(
345 ValidationSeverity::Warning,
346 format!(
347 "compatibility exceeds 500 characters ({} chars)",
348 compat.len()
349 ),
350 );
351 }
352 }
353
354 result
355 }
356
357 fn parse_frontmatter(content: &str) -> Result<SkillFrontmatter> {
359 let trimmed = content.trim();
360
361 if !trimmed.starts_with("---") {
363 bail!("SKILL.md must start with YAML frontmatter (---)");
364 }
365
366 let rest = &trimmed[3..];
368 let end = rest
369 .find("---")
370 .context("Unclosed frontmatter — missing closing ---")?;
371
372 let yaml_str = &rest[..end];
373
374 let frontmatter: SkillFrontmatter =
376 serde_yaml::from_str(yaml_str).context("Invalid YAML frontmatter")?;
377
378 Ok(frontmatter)
379 }
380}
381
382pub struct SkillBuilder {
386 name: String,
387 description: String,
388 body: String,
389 license: Option<String>,
390 compatibility: Option<String>,
391 allowed_tools: Vec<String>,
392 disable_model_invocation: bool,
393}
394
395impl SkillBuilder {
396 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
398 Self {
399 name: name.into(),
400 description: description.into(),
401 body: String::new(),
402 license: None,
403 compatibility: None,
404 allowed_tools: Vec::new(),
405 disable_model_invocation: false,
406 }
407 }
408
409 pub fn body(mut self, body: impl Into<String>) -> Self {
411 self.body = body.into();
412 self
413 }
414
415 pub fn license(mut self, license: impl Into<String>) -> Self {
417 self.license = Some(license.into());
418 self
419 }
420
421 pub fn compatibility(mut self, compat: impl Into<String>) -> Self {
423 self.compatibility = Some(compat.into());
424 self
425 }
426
427 pub fn allowed_tool(mut self, tool: impl Into<String>) -> Self {
429 self.allowed_tools.push(tool.into());
430 self
431 }
432
433 pub fn disable_model_invocation(mut self, disabled: bool) -> Self {
435 self.disable_model_invocation = disabled;
436 self
437 }
438
439 pub fn validate(&self) -> ValidationResult {
441 let mut result = ValidationResult::pass();
442
443 for finding in SkillValidator::validate_name(&self.name) {
444 result.add(finding.severity, finding.message);
445 }
446
447 for finding in SkillValidator::validate_description(&self.description) {
448 result.add(finding.severity, finding.message);
449 }
450
451 if self.body.trim().is_empty() {
452 result.add(ValidationSeverity::Warning, "skill body is empty");
453 }
454
455 result
456 }
457
458 pub fn render_skill_md(&self) -> String {
460 let mut md = String::with_capacity(1024);
461
462 md.push_str("---\n");
464 md.push_str(&format!("name: {}\n", self.name));
465 md.push_str(&format!("description: {}\n", escape_yaml_string(&self.description)));
466
467 if let Some(ref license) = self.license {
468 md.push_str(&format!("license: {}\n", escape_yaml_string(license)));
469 }
470
471 if let Some(ref compat) = self.compatibility {
472 md.push_str(&format!("compatibility: {}\n", escape_yaml_string(compat)));
473 }
474
475 if !self.allowed_tools.is_empty() {
476 md.push_str(&format!(
477 "allowed-tools: {}\n",
478 self.allowed_tools.join(" ")
479 ));
480 }
481
482 if self.disable_model_invocation {
483 md.push_str("disable-model-invocation: true\n");
484 }
485
486 md.push_str("---\n\n");
487
488 if !self.body.is_empty() {
490 md.push_str(&self.body);
491 if !self.body.ends_with('\n') {
492 md.push('\n');
493 }
494 } else {
495 md.push_str(&format!("# {}\n\n", capitalize_words(&self.name)));
497 md.push_str(&self.description);
498 md.push_str("\n\n## Usage\n\nTODO: Add usage instructions.\n");
499 }
500
501 md
502 }
503
504 pub fn build(&self, parent_dir: &Path) -> Result<PathBuf> {
506 let validation = self.validate();
508 if validation.has_errors() {
509 let errors: Vec<&str> = validation
510 .findings
511 .iter()
512 .filter(|f| f.severity == ValidationSeverity::Error)
513 .map(|f| f.message.as_str())
514 .collect();
515 bail!("Validation errors: {}", errors.join("; "));
516 }
517
518 let skill_dir = parent_dir.join(&self.name);
519 fs::create_dir_all(&skill_dir)
520 .with_context(|| format!("Failed to create skill directory {}", skill_dir.display()))?;
521
522 let skill_md = self.render_skill_md();
523 let skill_path = skill_dir.join("SKILL.md");
524
525 fs::write(&skill_path, &skill_md)
526 .with_context(|| format!("Failed to write {}", skill_path.display()))?;
527
528 Ok(skill_dir)
529 }
530}
531
532pub struct AgentSkill;
536
537impl AgentSkill {
538 pub fn new() -> Self {
540 Self
541 }
542
543 pub fn skill_prompt() -> String {
545 r#"# Agent Skill
546
547You are running the **agent-skill** skill. You create, validate, and
548manage Agent Skills following the [Agent Skills standard](https://agentskills.io/specification).
549
550## Skill Structure
551
552A skill is a directory with a `SKILL.md` file:
553
554```
555my-skill/
556├── SKILL.md # Required: frontmatter + instructions
557├── scripts/ # Optional: helper scripts
558│ └── process.sh
559├── references/ # Optional: detailed docs loaded on-demand
560│ └── api-reference.md
561└── assets/ # Optional: templates, configs
562 └── template.json
563```
564
565## SKILL.md Format
566
567```markdown
568---
569name: my-skill
570description: What this skill does and when to use it. Be specific.
571---
572
573# My Skill
574
575## Usage
576
577Do X then Y.
578```
579
580## Frontmatter Rules
581
582| Field | Required | Rules |
583|-------|----------|-------|
584| `name` | Yes | 1-64 chars, lowercase a-z/0-9/hyphens, no leading/trailing hyphens, no consecutive hyphens, must match parent directory |
585| `description` | Yes | 1-1024 chars, describes what the skill does and when to use it |
586| `license` | No | License identifier |
587| `compatibility` | No | Max 500 chars, environment requirements |
588| `allowed-tools` | No | Space-delimited pre-approved tools |
589| `disable-model-invocation` | No | When true, hidden from system prompt |
590
591## Validation Checklist
592
593- [ ] Name matches parent directory name
594- [ ] Name is lowercase, 1-64 chars, valid characters only
595- [ ] Description is present and specific
596- [ ] SKILL.md starts and ends with `---` frontmatter delimiters
597- [ ] Instructions are clear and actionable
598- [ ] Relative paths are used for files within the skill directory
599"#
600 .to_string()
601 }
602}
603
604impl Default for AgentSkill {
605 fn default() -> Self {
606 Self::new()
607 }
608}
609
610impl fmt::Debug for AgentSkill {
611 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
612 f.debug_struct("AgentSkill").finish()
613 }
614}
615
616fn escape_yaml_string(s: &str) -> String {
620 if s.contains(':') || s.contains('#') || s.contains('"') || s.contains('\'') || s.contains('\n')
621 {
622 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
623 } else {
624 s.to_string()
625 }
626}
627
628fn capitalize_words(s: &str) -> String {
630 s.split('-')
631 .map(|word| {
632 let mut chars = word.chars();
633 match chars.next() {
634 None => String::new(),
635 Some(first) => {
636 let upper: String = first.to_uppercase().collect();
637 upper + &chars.as_str().to_lowercase()
638 }
639 }
640 })
641 .collect::<Vec<_>>()
642 .join(" ")
643}
644
645#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
652 fn test_validate_name_valid() {
653 let findings = SkillValidator::validate_name("pdf-tools");
654 assert!(findings.is_empty());
655 }
656
657 #[test]
658 fn test_validate_name_empty() {
659 let findings = SkillValidator::validate_name("");
660 assert!(findings.iter().any(|f| f.severity == ValidationSeverity::Error));
661 }
662
663 #[test]
664 fn test_validate_name_too_long() {
665 let name = "a".repeat(65);
666 let findings = SkillValidator::validate_name(&name);
667 assert!(findings.iter().any(|f| f.message.contains("exceeds 64 characters")));
668 }
669
670 #[test]
671 fn test_validate_name_at_limit() {
672 let name = "a".repeat(64);
673 let findings = SkillValidator::validate_name(&name);
674 assert!(!findings.iter().any(|f| f.message.contains("exceeds")));
675 }
676
677 #[test]
678 fn test_validate_name_uppercase() {
679 let findings = SkillValidator::validate_name("My-Skill");
680 assert!(findings.iter().any(|f| f.message.contains("invalid characters")));
681 }
682
683 #[test]
684 fn test_validate_name_leading_hyphen() {
685 let findings = SkillValidator::validate_name("-skill");
686 assert!(findings.iter().any(|f| f.message.contains("start or end with a hyphen")));
687 }
688
689 #[test]
690 fn test_validate_name_trailing_hyphen() {
691 let findings = SkillValidator::validate_name("skill-");
692 assert!(findings.iter().any(|f| f.message.contains("start or end with a hyphen")));
693 }
694
695 #[test]
696 fn test_validate_name_consecutive_hyphens() {
697 let findings = SkillValidator::validate_name("my--skill");
698 assert!(findings.iter().any(|f| f.message.contains("consecutive hyphens")));
699 }
700
701 #[test]
702 fn test_validate_name_with_numbers() {
703 let findings = SkillValidator::validate_name("pdf2text");
704 assert!(findings.is_empty());
705 }
706
707 #[test]
708 fn test_validate_description_valid() {
709 let findings = SkillValidator::validate_description("A useful skill");
710 assert!(findings.is_empty());
711 }
712
713 #[test]
714 fn test_validate_description_empty() {
715 let findings = SkillValidator::validate_description("");
716 assert!(findings.iter().any(|f| f.severity == ValidationSeverity::Error));
717 }
718
719 #[test]
720 fn test_validate_description_whitespace_only() {
721 let findings = SkillValidator::validate_description(" ");
722 assert!(findings.iter().any(|f| f.severity == ValidationSeverity::Error));
723 }
724
725 #[test]
726 fn test_validate_description_too_long() {
727 let desc = "x".repeat(1025);
728 let findings = SkillValidator::validate_description(&desc);
729 assert!(findings.iter().any(|f| f.message.contains("exceeds 1024 characters")));
730 }
731
732 #[test]
733 fn test_validate_description_at_limit() {
734 let desc = "x".repeat(1024);
735 let findings = SkillValidator::validate_description(&desc);
736 assert!(!findings.iter().any(|f| f.message.contains("exceeds")));
737 }
738
739 #[test]
740 fn test_validate_name_matches_dir() {
741 let tmp = tempfile::tempdir().unwrap();
742 let dir = tmp.path().join("my-skill");
743 fs::create_dir_all(&dir).unwrap();
744 let findings = SkillValidator::validate_name_matches_dir("my-skill", &dir);
745 assert!(findings.is_empty());
746 }
747
748 #[test]
749 fn test_validate_name_mismatches_dir() {
750 let tmp = tempfile::tempdir().unwrap();
751 let dir = tmp.path().join("other-skill");
752 fs::create_dir_all(&dir).unwrap();
753 let findings = SkillValidator::validate_name_matches_dir("my-skill", &dir);
754 assert!(!findings.is_empty());
755 assert!(findings[0].message.contains("does not match parent directory"));
756 }
757
758 #[test]
759 fn test_validate_skill_dir_valid() {
760 let tmp = tempfile::tempdir().unwrap();
761 let dir = tmp.path().join("my-skill");
762 fs::create_dir_all(&dir).unwrap();
763 fs::write(
764 dir.join("SKILL.md"),
765 "---\nname: my-skill\ndescription: A test skill\n---\n\n# My Skill\n",
766 )
767 .unwrap();
768 let result = SkillValidator::validate_skill_dir(&dir);
769 assert!(result.valid);
770 assert!(result.findings.is_empty());
771 }
772
773 #[test]
774 fn test_validate_skill_dir_no_skill_md() {
775 let tmp = tempfile::tempdir().unwrap();
776 let dir = tmp.path().join("my-skill");
777 fs::create_dir_all(&dir).unwrap();
778 let result = SkillValidator::validate_skill_dir(&dir);
779 assert!(!result.valid);
780 assert!(result.findings.iter().any(|f| f.message.contains("SKILL.md not found")));
781 }
782
783 #[test]
784 fn test_validation_result_pass() {
785 let result = ValidationResult::pass();
786 assert!(result.valid);
787 assert!(result.findings.is_empty());
788 }
789
790 #[test]
791 fn test_validation_result_add_error() {
792 let mut result = ValidationResult::pass();
793 result.add(ValidationSeverity::Error, "something wrong");
794 assert!(!result.valid);
795 assert!(result.has_errors());
796 }
797
798 #[test]
799 fn test_validation_result_add_warning() {
800 let mut result = ValidationResult::pass();
801 result.add(ValidationSeverity::Warning, "minor issue");
802 assert!(result.valid);
803 assert!(!result.has_errors());
804 assert!(result.has_warnings());
805 }
806
807 #[test]
808 fn test_builder_new() {
809 let builder = SkillBuilder::new("my-skill", "A test skill");
810 assert_eq!(builder.name, "my-skill");
811 assert_eq!(builder.description, "A test skill");
812 }
813
814 #[test]
815 fn test_builder_validate_valid() {
816 let builder = SkillBuilder::new("my-skill", "A test skill");
817 let result = builder.validate();
818 assert!(result.valid);
819 }
820
821 #[test]
822 fn test_builder_validate_bad_name() {
823 let builder = SkillBuilder::new("MY-SKILL", "A test skill");
824 let result = builder.validate();
825 assert!(result.has_warnings());
826 }
827
828 #[test]
829 fn test_builder_validate_empty_description() {
830 let builder = SkillBuilder::new("my-skill", "");
831 let result = builder.validate();
832 assert!(result.has_errors());
833 }
834
835 #[test]
836 fn test_builder_render_skill_md() {
837 let builder = SkillBuilder::new("pdf-tools", "Extract text from PDFs")
838 .body("# PDF Tools\n\n## Usage\n\n```bash\npdftotext input.pdf\n```");
839 let md = builder.render_skill_md();
840 assert!(md.starts_with("---\n"));
841 assert!(md.contains("name: pdf-tools"));
842 assert!(md.contains("description: Extract text from PDFs"));
843 assert!(md.contains("# PDF Tools"));
844 }
845
846 #[test]
847 fn test_builder_render_with_options() {
848 let builder = SkillBuilder::new("my-skill", "Test")
849 .license("MIT")
850 .compatibility("Node.js >= 18")
851 .allowed_tool("read")
852 .allowed_tool("bash")
853 .disable_model_invocation(true);
854 let md = builder.render_skill_md();
855 assert!(md.contains("license: MIT"));
856 assert!(md.contains("compatibility: Node.js >= 18"));
857 assert!(md.contains("allowed-tools: read bash"));
858 assert!(md.contains("disable-model-invocation: true"));
859 }
860
861 #[test]
862 fn test_builder_build() {
863 let tmp = tempfile::tempdir().unwrap();
864 let builder = SkillBuilder::new("my-skill", "A test skill for building");
865 let dir = builder.build(tmp.path()).unwrap();
866 assert!(dir.exists());
867 assert_eq!(dir.file_name().unwrap(), "my-skill");
868 let skill_md = dir.join("SKILL.md");
869 assert!(skill_md.exists());
870 let content = fs::read_to_string(&skill_md).unwrap();
871 assert!(content.contains("name: my-skill"));
872 }
873
874 #[test]
875 fn test_builder_build_invalid_fails() {
876 let tmp = tempfile::tempdir().unwrap();
877 let builder = SkillBuilder::new("MY BAD SKILL", "");
878 assert!(builder.build(tmp.path()).is_err());
879 }
880
881 #[test]
882 fn test_skill_prompt_not_empty() {
883 let prompt = AgentSkill::skill_prompt();
884 assert!(prompt.contains("Agent Skill"));
885 assert!(prompt.contains("SKILL.md"));
886 }
887
888 #[test]
889 fn test_parse_frontmatter_valid() {
890 let content = "---\nname: my-skill\ndescription: A test\n---\n\n# Body";
891 let fm = SkillValidator::parse_frontmatter(content).unwrap();
892 assert_eq!(fm.name, "my-skill");
893 assert_eq!(fm.description, "A test");
894 }
895
896 #[test]
897 fn test_parse_frontmatter_no_delimiter() {
898 let content = "# Just markdown";
899 assert!(SkillValidator::parse_frontmatter(content).is_err());
900 }
901
902 #[test]
903 fn test_parse_frontmatter_unclosed() {
904 let content = "---\nname: my-skill";
905 assert!(SkillValidator::parse_frontmatter(content).is_err());
906 }
907
908 #[test]
909 fn test_capitalize_words() {
910 assert_eq!(capitalize_words("pdf-tools"), "Pdf Tools");
911 assert_eq!(capitalize_words("my-skill"), "My Skill");
912 assert_eq!(capitalize_words("a"), "A");
913 assert_eq!(capitalize_words(""), "");
914 }
915
916 #[test]
917 fn test_full_create_and_validate() {
918 let tmp = tempfile::tempdir().unwrap();
919 let builder = SkillBuilder::new("web-search", "Search the web using Brave Search API")
920 .body("# Web Search\n\n## Usage\n\n```bash\n./scripts/search.sh \"query\"\n```")
921 .allowed_tool("bash")
922 .allowed_tool("read");
923 let dir = builder.build(tmp.path()).unwrap();
924 let result = SkillValidator::validate_skill_dir(&dir);
925 assert!(result.valid, "Validation failed: {:?}", result.findings);
926 }
927}