Skip to main content

stakpak_api/local/skills/
parser.rs

1use std::collections::HashMap;
2
3use serde::Deserialize;
4
5#[derive(Deserialize, Debug, Clone)]
6pub struct SkillFrontmatter {
7    pub name: String,
8    /// Should describe both what the skill does and when to use it
9    pub description: String,
10    /// License name or reference to a bundled license file.
11    pub license: Option<String>,
12    /// Environment requirements (intended product, system packages, network access, etc.). Max 500 chars.
13    pub compatibility: Option<String>,
14    /// Arbitrary key-value mapping for additional metadata.
15    pub metadata: Option<HashMap<String, String>>,
16    /// Space-delimited list of pre-approved tools the skill may use. (Experimental)
17    #[serde(default, rename = "allowed-tools")]
18    pub allowed_tools: Option<String>,
19    #[serde(default)]
20    pub tags: Vec<String>,
21}
22
23/// Validate that a skill name conforms to the Agent Skills spec:
24/// - 1-64 characters
25/// - Lowercase alphanumeric and hyphens only
26/// - Must not start or end with a hyphen
27/// - Must not contain consecutive hyphens
28fn validate_name(name: &str) -> Result<(), String> {
29    if name.is_empty() {
30        return Err("Skill name must not be empty".to_string());
31    }
32    if name.len() > 64 {
33        return Err(format!(
34            "Skill name must be at most 64 characters, got {}",
35            name.len()
36        ));
37    }
38    if name.starts_with('-') || name.ends_with('-') {
39        return Err(format!(
40            "Skill name '{}' must not start or end with a hyphen",
41            name
42        ));
43    }
44    if name.contains("--") {
45        return Err(format!(
46            "Skill name '{}' must not contain consecutive hyphens",
47            name
48        ));
49    }
50    for ch in name.chars() {
51        if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '-' {
52            return Err(format!(
53                "Skill name '{}' contains invalid character '{}'. Only lowercase letters, numbers, and hyphens are allowed",
54                name, ch
55            ));
56        }
57    }
58    Ok(())
59}
60
61/// Validate that the description is non-empty and within the max length.
62fn validate_description(description: &str) -> Result<(), String> {
63    if description.is_empty() {
64        return Err("Skill description must not be empty".to_string());
65    }
66    if description.len() > 1024 {
67        return Err(format!(
68            "Skill description must be at most 1024 characters, got {}",
69            description.len()
70        ));
71    }
72    Ok(())
73}
74
75/// Validate that the compatibility field (if present) is within the max length.
76fn validate_compatibility(compatibility: &Option<String>) -> Result<(), String> {
77    if let Some(compat) = compatibility {
78        if compat.is_empty() {
79            return Err("Skill compatibility field, if provided, must not be empty".to_string());
80        }
81        if compat.len() > 500 {
82            return Err(format!(
83                "Skill compatibility must be at most 500 characters, got {}",
84                compat.len()
85            ));
86        }
87    }
88    Ok(())
89}
90
91/// Validate that the skill name matches the parent directory name.
92pub fn validate_name_matches_directory(name: &str, dir_name: &str) -> Result<(), String> {
93    if name != dir_name {
94        return Err(format!(
95            "Skill name '{}' must match the parent directory name '{}'",
96            name, dir_name
97        ));
98    }
99    Ok(())
100}
101
102pub fn parse_skill_md(content: &str) -> Result<(SkillFrontmatter, String), String> {
103    let trimmed = content.trim_start();
104    if !trimmed.starts_with("---") {
105        return Err("SKILL.md must start with YAML frontmatter (---)".to_string());
106    }
107
108    // Find the closing ---
109    // SAFETY: trimmed starts with "---" (3 ASCII bytes), so index 3 is always a valid char boundary.
110    let after_first = trimmed
111        .get(3..)
112        .ok_or_else(|| "SKILL.md frontmatter unexpectedly short".to_string())?;
113    let end_idx = after_first
114        .find("\n---")
115        .ok_or_else(|| "SKILL.md frontmatter missing closing ---".to_string())?;
116
117    // end_idx comes from .find() on after_first, so it's a valid char boundary.
118    let yaml_str = after_first
119        .get(..end_idx)
120        .ok_or_else(|| "SKILL.md frontmatter invalid boundary".to_string())?;
121    let body_start = end_idx + 4; // skip "\n---"
122    let body = after_first
123        .get(body_start..)
124        .unwrap_or("")
125        .trim_start_matches('\n')
126        .to_string();
127
128    let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml_str)
129        .map_err(|e| format!("Failed to parse frontmatter: {}", e))?;
130
131    // Validate required fields
132    validate_name(&frontmatter.name)?;
133    validate_description(&frontmatter.description)?;
134
135    // Validate optional fields
136    validate_compatibility(&frontmatter.compatibility)?;
137
138    Ok((frontmatter, body))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_parse_valid_skill_md() {
147        let content = r#"---
148name: terraform-aws
149description: Best practices for Terraform on AWS
150tags: [terraform, aws, iac]
151---
152
153# Terraform AWS Instructions
154
155Step-by-step guidance here...
156"#;
157        let (fm, body) = parse_skill_md(content).unwrap();
158        assert_eq!(fm.name, "terraform-aws");
159        assert_eq!(fm.description, "Best practices for Terraform on AWS");
160        assert_eq!(fm.tags, vec!["terraform", "aws", "iac"]);
161        assert!(body.starts_with("# Terraform AWS Instructions"));
162    }
163
164    #[test]
165    fn test_parse_all_optional_fields() {
166        let content = r#"---
167name: pdf-processing
168description: Extract text and tables from PDF files, fill forms, merge documents.
169license: Apache-2.0
170compatibility: Requires poppler-utils and python3
171metadata:
172  author: example-org
173  version: "1.0"
174allowed-tools: Bash(git:*) Bash(jq:*) Read
175tags: [pdf, extraction]
176---
177
178# PDF Processing
179
180Instructions here.
181"#;
182        let (fm, body) = parse_skill_md(content).unwrap();
183        assert_eq!(fm.name, "pdf-processing");
184        assert_eq!(fm.license, Some("Apache-2.0".to_string()));
185        assert_eq!(
186            fm.compatibility,
187            Some("Requires poppler-utils and python3".to_string())
188        );
189        let metadata = fm.metadata.as_ref().unwrap();
190        assert_eq!(metadata.get("author"), Some(&"example-org".to_string()));
191        assert_eq!(metadata.get("version"), Some(&"1.0".to_string()));
192        assert_eq!(
193            fm.allowed_tools,
194            Some("Bash(git:*) Bash(jq:*) Read".to_string())
195        );
196        assert_eq!(fm.tags, vec!["pdf", "extraction"]);
197        assert!(body.starts_with("# PDF Processing"));
198    }
199
200    #[test]
201    fn test_parse_no_tags() {
202        let content = "---\nname: simple\ndescription: A simple skill\n---\n\nBody here.\n";
203        let (fm, body) = parse_skill_md(content).unwrap();
204        assert_eq!(fm.name, "simple");
205        assert!(fm.tags.is_empty());
206        assert!(fm.license.is_none());
207        assert!(fm.compatibility.is_none());
208        assert!(fm.metadata.is_none());
209        assert!(fm.allowed_tools.is_none());
210        assert_eq!(body, "Body here.\n");
211    }
212
213    #[test]
214    fn test_parse_missing_frontmatter() {
215        let content = "# No frontmatter\n\nJust markdown.";
216        let result = parse_skill_md(content);
217        assert!(result.is_err());
218    }
219
220    #[test]
221    fn test_parse_missing_closing() {
222        let content = "---\nname: broken\ndescription: oops\n";
223        let result = parse_skill_md(content);
224        assert!(result.is_err());
225    }
226
227    #[test]
228    fn test_parse_empty_name() {
229        let content = "---\nname: \"\"\ndescription: has desc\n---\n\nBody";
230        let result = parse_skill_md(content);
231        assert!(result.is_err());
232        assert!(result.unwrap_err().contains("must not be empty"));
233    }
234
235    #[test]
236    fn test_parse_empty_description() {
237        let content = "---\nname: test\ndescription: \"\"\n---\n\nBody";
238        let result = parse_skill_md(content);
239        assert!(result.is_err());
240        assert!(result.unwrap_err().contains("must not be empty"));
241    }
242
243    // --- Name validation tests ---
244
245    #[test]
246    fn test_name_uppercase_rejected() {
247        let content = "---\nname: PDF-Processing\ndescription: A skill\n---\n\nBody";
248        let result = parse_skill_md(content);
249        assert!(result.is_err());
250        assert!(result.unwrap_err().contains("invalid character"));
251    }
252
253    #[test]
254    fn test_name_starts_with_hyphen_rejected() {
255        let content = "---\nname: -pdf\ndescription: A skill\n---\n\nBody";
256        let result = parse_skill_md(content);
257        assert!(result.is_err());
258        assert!(result.unwrap_err().contains("must not start or end"));
259    }
260
261    #[test]
262    fn test_name_ends_with_hyphen_rejected() {
263        let content = "---\nname: pdf-\ndescription: A skill\n---\n\nBody";
264        let result = parse_skill_md(content);
265        assert!(result.is_err());
266        assert!(result.unwrap_err().contains("must not start or end"));
267    }
268
269    #[test]
270    fn test_name_consecutive_hyphens_rejected() {
271        let content = "---\nname: pdf--processing\ndescription: A skill\n---\n\nBody";
272        let result = parse_skill_md(content);
273        assert!(result.is_err());
274        assert!(result.unwrap_err().contains("consecutive hyphens"));
275    }
276
277    #[test]
278    fn test_name_too_long_rejected() {
279        let long_name = "a".repeat(65);
280        let content = format!(
281            "---\nname: {}\ndescription: A skill\n---\n\nBody",
282            long_name
283        );
284        let result = parse_skill_md(&content);
285        assert!(result.is_err());
286        assert!(result.unwrap_err().contains("at most 64 characters"));
287    }
288
289    #[test]
290    fn test_name_max_length_accepted() {
291        let max_name = "a".repeat(64);
292        let content = format!("---\nname: {}\ndescription: A skill\n---\n\nBody", max_name);
293        let result = parse_skill_md(&content);
294        assert!(result.is_ok());
295    }
296
297    #[test]
298    fn test_name_with_numbers_accepted() {
299        let content = "---\nname: skill-v2\ndescription: A skill\n---\n\nBody";
300        let result = parse_skill_md(content);
301        assert!(result.is_ok());
302        assert_eq!(result.unwrap().0.name, "skill-v2");
303    }
304
305    #[test]
306    fn test_name_with_spaces_rejected() {
307        let content = "---\nname: \"my skill\"\ndescription: A skill\n---\n\nBody";
308        let result = parse_skill_md(content);
309        assert!(result.is_err());
310        assert!(result.unwrap_err().contains("invalid character"));
311    }
312
313    #[test]
314    fn test_name_with_underscores_rejected() {
315        let content = "---\nname: my_skill\ndescription: A skill\n---\n\nBody";
316        let result = parse_skill_md(content);
317        assert!(result.is_err());
318        assert!(result.unwrap_err().contains("invalid character"));
319    }
320
321    // --- Description validation tests ---
322
323    #[test]
324    fn test_description_too_long_rejected() {
325        let long_desc = "a".repeat(1025);
326        let content = format!("---\nname: test\ndescription: {}\n---\n\nBody", long_desc);
327        let result = parse_skill_md(&content);
328        assert!(result.is_err());
329        assert!(result.unwrap_err().contains("at most 1024 characters"));
330    }
331
332    #[test]
333    fn test_description_max_length_accepted() {
334        let max_desc = "a".repeat(1024);
335        let content = format!("---\nname: test\ndescription: {}\n---\n\nBody", max_desc);
336        let result = parse_skill_md(&content);
337        assert!(result.is_ok());
338    }
339
340    // --- Compatibility validation tests ---
341
342    #[test]
343    fn test_compatibility_too_long_rejected() {
344        let long_compat = "a".repeat(501);
345        let content = format!(
346            "---\nname: test\ndescription: A skill\ncompatibility: {}\n---\n\nBody",
347            long_compat
348        );
349        let result = parse_skill_md(&content);
350        assert!(result.is_err());
351        assert!(result.unwrap_err().contains("at most 500 characters"));
352    }
353
354    #[test]
355    fn test_compatibility_max_length_accepted() {
356        let max_compat = "a".repeat(500);
357        let content = format!(
358            "---\nname: test\ndescription: A skill\ncompatibility: {}\n---\n\nBody",
359            max_compat
360        );
361        let result = parse_skill_md(&content);
362        assert!(result.is_ok());
363    }
364
365    #[test]
366    fn test_compatibility_empty_rejected() {
367        let content = "---\nname: test\ndescription: A skill\ncompatibility: \"\"\n---\n\nBody";
368        let result = parse_skill_md(content);
369        assert!(result.is_err());
370        assert!(result.unwrap_err().contains("must not be empty"));
371    }
372
373    // --- Directory name matching ---
374
375    #[test]
376    fn test_name_matches_directory_ok() {
377        let result = validate_name_matches_directory("terraform", "terraform");
378        assert!(result.is_ok());
379    }
380
381    #[test]
382    fn test_name_mismatches_directory() {
383        let result = validate_name_matches_directory("terraform", "tf-skill");
384        assert!(result.is_err());
385        assert!(
386            result
387                .unwrap_err()
388                .contains("must match the parent directory name")
389        );
390    }
391}