Skip to main content

oxi/skills/
agent_skill.rs

1//! Agent skill for oxi — creating and managing Agent Skills
2//!
3//! Implements the [Agent Skills standard](https://agentskills.io/specification).
4//! This skill enables the agent to create, validate, and manage skills that
5//! follow the standard skill directory format:
6//!
7//! ```text
8//! my-skill/
9//! ├── SKILL.md        # Required: frontmatter + instructions
10//! ├── scripts/        # Optional: helper scripts
11//! └── references/     # Optional: detailed docs
12//! ```
13//!
14//! The module provides:
15//! - [`SkillFrontmatter`] — frontmatter schema for SKILL.md
16//! - [`SkillValidator`] — validates skills against the spec
17//! - [`SkillBuilder`] — creates new skill directories with valid SKILL.md
18//! - [`AgentSkill`] — skill prompt generator
19
20use anyhow::{bail, Context, Result};
21use serde::{Deserialize, Serialize};
22use std::fmt;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26/// Maximum skill name length per the Agent Skills spec.
27pub const MAX_NAME_LENGTH: usize = 64;
28
29/// Maximum description length per the Agent Skills spec.
30pub const MAX_DESCRIPTION_LENGTH: usize = 1024;
31
32// ── Frontmatter ────────────────────────────────────────────────────────
33
34/// Frontmatter schema for SKILL.md files.
35///
36/// Implements the [Agent Skills specification](https://agentskills.io/specification#frontmatter-required).
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SkillFrontmatter {
39    /// Skill name. Must match parent directory name. Lowercase a-z, 0-9, hyphens.
40    /// Max 64 chars. No leading/trailing hyphens. No consecutive hyphens.
41    pub name: String,
42
43    /// What this skill does and when to use it. Max 1024 chars.
44    pub description: String,
45
46    /// License name or reference to bundled file.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub license: Option<String>,
49
50    /// Environment requirements. Max 500 chars.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub compatibility: Option<String>,
53
54    /// Arbitrary key-value metadata.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub metadata: Option<toml::Value>,
57
58    /// Space-delimited list of pre-approved tools (experimental).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    #[serde(rename = "allowed-tools")]
61    pub allowed_tools: Option<String>,
62
63    /// When true, skill is hidden from system prompt. Users must use /skill:name.
64    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
65    #[serde(rename = "disable-model-invocation")]
66    pub disable_model_invocation: bool,
67}
68
69// ── Validation ─────────────────────────────────────────────────────────
70
71/// Severity of a validation finding.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum ValidationSeverity {
75    /// Error — skill will not load.
76    Error,
77    /// Warning — skill loads but may not work correctly.
78    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/// A single validation finding.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ValidationFinding {
93    /// Severity of the finding.
94    pub severity: ValidationSeverity,
95    /// Human-readable description.
96    pub message: String,
97    /// Optional file path that triggered the finding.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub path: Option<String>,
100}
101
102/// Result of validating a skill.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ValidationResult {
105    /// Whether the skill passed validation (no errors).
106    pub valid: bool,
107    /// All findings (errors and warnings).
108    pub findings: Vec<ValidationFinding>,
109}
110
111impl ValidationResult {
112    /// Create a passing result.
113    pub fn pass() -> Self {
114        Self {
115            valid: true,
116            findings: Vec::new(),
117        }
118    }
119
120    /// Create a failing result with findings.
121    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    /// Add a finding.
130    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    /// Add a finding with a path.
142    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    /// Check if there are any errors.
159    pub fn has_errors(&self) -> bool {
160        self.findings
161            .iter()
162            .any(|f| f.severity == ValidationSeverity::Error)
163    }
164
165    /// Check if there are any warnings.
166    pub fn has_warnings(&self) -> bool {
167        self.findings
168            .iter()
169            .any(|f| f.severity == ValidationSeverity::Warning)
170    }
171}
172
173/// Validates skills against the Agent Skills specification.
174pub struct SkillValidator;
175
176impl SkillValidator {
177    /// Validate a skill name per the Agent Skills spec.
178    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    /// Validate a skill description per the Agent Skills spec.
234    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    /// Validate that a skill name matches its parent directory.
259    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    /// Validate a complete skill directory.
283    pub fn validate_skill_dir(dir: &Path) -> ValidationResult {
284        let mut result = ValidationResult::pass();
285
286        // Check SKILL.md exists
287        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        // Read and parse SKILL.md
298        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        // Parse frontmatter
311        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        // Validate name
324        for finding in Self::validate_name(&frontmatter.name) {
325            result.add(finding.severity, finding.message);
326        }
327
328        // Validate name matches directory
329        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        // Validate description
337        for finding in Self::validate_description(&frontmatter.description) {
338            result.add(finding.severity, finding.message);
339        }
340
341        // Validate compatibility length
342        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    /// Parse YAML frontmatter from SKILL.md content.
358    fn parse_frontmatter(content: &str) -> Result<SkillFrontmatter> {
359        let trimmed = content.trim();
360
361        // Must start with ---
362        if !trimmed.starts_with("---") {
363            bail!("SKILL.md must start with YAML frontmatter (---)");
364        }
365
366        // Find closing ---
367        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        // Parse as YAML
375        let frontmatter: SkillFrontmatter =
376            serde_yaml::from_str(yaml_str).context("Invalid YAML frontmatter")?;
377
378        Ok(frontmatter)
379    }
380}
381
382// ── Skill builder ──────────────────────────────────────────────────────
383
384/// Builds new skill directories with valid SKILL.md files.
385pub 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    /// Create a new skill builder with the required fields.
397    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    /// Set the skill body (markdown content after frontmatter).
410    pub fn body(mut self, body: impl Into<String>) -> Self {
411        self.body = body.into();
412        self
413    }
414
415    /// Set the license.
416    pub fn license(mut self, license: impl Into<String>) -> Self {
417        self.license = Some(license.into());
418        self
419    }
420
421    /// Set compatibility requirements.
422    pub fn compatibility(mut self, compat: impl Into<String>) -> Self {
423        self.compatibility = Some(compat.into());
424        self
425    }
426
427    /// Add an allowed tool.
428    pub fn allowed_tool(mut self, tool: impl Into<String>) -> Self {
429        self.allowed_tools.push(tool.into());
430        self
431    }
432
433    /// Set whether model invocation is disabled.
434    pub fn disable_model_invocation(mut self, disabled: bool) -> Self {
435        self.disable_model_invocation = disabled;
436        self
437    }
438
439    /// Validate the builder state before building.
440    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    /// Generate the SKILL.md content.
459    pub fn render_skill_md(&self) -> String {
460        let mut md = String::with_capacity(1024);
461
462        // Frontmatter
463        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        // Body
489        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            // Default body template
496            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    /// Build the skill directory on disk.
505    pub fn build(&self, parent_dir: &Path) -> Result<PathBuf> {
506        // Validate first
507        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
532// ── Agent skill ────────────────────────────────────────────────────────
533
534/// The agent-skill skill struct.
535pub struct AgentSkill;
536
537impl AgentSkill {
538    /// Create a new agent-skill instance.
539    pub fn new() -> Self {
540        Self
541    }
542
543    /// Generate the system-prompt fragment for the agent-skill skill.
544    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
616// ── Helpers ────────────────────────────────────────────────────────────
617
618/// Escape a string for safe inclusion in YAML value position.
619fn 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
628/// Capitalize the first letter of each word in a hyphenated name.
629fn 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// ── Tests ──────────────────────────────────────────────────────────────
646
647#[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}