agent_sdk/skills/
parser.rs

1//! Skill file parser for markdown with YAML frontmatter.
2//!
3//! This parser supports skill files from multiple coding agents:
4//! - Claude Code style (YAML frontmatter with markdown body)
5//! - Cursor style (similar YAML frontmatter)
6//! - Amp style (may include `system_prompt` in frontmatter)
7//! - Codex style (may use `id` instead of `name`)
8//!
9//! The parser handles common field name variations:
10//! - `name`, `id`, `title` -> name
11//! - `description`, `desc`, `summary` -> description
12//! - `system_prompt`, `prompt`, `instructions` -> can be in frontmatter
13//! - `tools`, `allowed_tools`, `denied_tools` -> tool configuration
14
15use anyhow::{Context, Result, bail};
16use serde::Deserialize;
17use std::collections::HashMap;
18
19use super::Skill;
20
21/// Frontmatter structure parsed from YAML.
22///
23/// Supports multiple naming conventions for compatibility with
24/// Claude Code, Cursor, Amp, and Codex skill formats.
25#[derive(Debug, Deserialize)]
26pub struct SkillFrontmatter {
27    /// Skill name - supports `name`, `id`, or `title`.
28    #[serde(alias = "id", alias = "title")]
29    pub name: Option<String>,
30
31    /// Skill description - supports `description`, `desc`, or `summary`.
32    #[serde(default, alias = "desc", alias = "summary")]
33    pub description: Option<String>,
34
35    /// System prompt in frontmatter (Amp style).
36    /// If present, overrides the markdown body.
37    #[serde(default, alias = "prompt", alias = "instructions")]
38    pub system_prompt: Option<String>,
39
40    /// List of tools to enable (optional).
41    #[serde(default)]
42    pub tools: Vec<String>,
43
44    /// Whitelist of allowed tools (optional).
45    /// Also supports `enabled_tools` alias.
46    #[serde(default, alias = "enabled_tools")]
47    pub allowed_tools: Option<Vec<String>>,
48
49    /// Blacklist of denied tools (optional).
50    /// Also supports `disabled_tools` or `blocked_tools` alias.
51    #[serde(default, alias = "disabled_tools", alias = "blocked_tools")]
52    pub denied_tools: Option<Vec<String>>,
53
54    /// Additional metadata fields.
55    #[serde(flatten)]
56    pub extra: HashMap<String, serde_json::Value>,
57}
58
59/// Parse a skill file content into frontmatter and body.
60///
61/// The file format is:
62/// ```text
63/// ---
64/// name: skill-name
65/// description: Optional description
66/// tools: [tool1, tool2]
67/// ---
68///
69/// # Markdown content here
70///
71/// This becomes the system prompt.
72/// ```
73///
74/// # Compatibility
75///
76/// This parser supports multiple skill file formats:
77/// - **Claude Code**: Standard YAML frontmatter + markdown body
78/// - **Cursor**: Similar format, may use `title` instead of `name`
79/// - **Amp**: May include `system_prompt` or `instructions` in frontmatter
80/// - **Codex**: May use `id` instead of `name`
81///
82/// # Errors
83///
84/// Returns an error if:
85/// - The file doesn't start with `---`
86/// - The YAML frontmatter is invalid
87/// - Required fields are missing (must have `name`, `id`, or `title`)
88pub fn parse_skill_file(content: &str) -> Result<Skill> {
89    let content = content.trim();
90
91    // Check for frontmatter delimiter
92    if !content.starts_with("---") {
93        bail!("Skill file must start with YAML frontmatter (---)");
94    }
95
96    // Find the closing delimiter
97    let after_first = &content[3..];
98    let end_index = after_first
99        .find("---")
100        .context("Missing closing frontmatter delimiter (---)")?;
101
102    let yaml_content = &after_first[..end_index].trim();
103    let body = after_first[end_index + 3..].trim();
104
105    // Parse YAML frontmatter
106    let frontmatter: SkillFrontmatter =
107        serde_yaml::from_str(yaml_content).context("Failed to parse YAML frontmatter")?;
108
109    // Name is required (can come from name, id, or title via aliases)
110    let name = frontmatter
111        .name
112        .context("Skill must have a 'name', 'id', or 'title' field")?;
113
114    // System prompt: prefer frontmatter field, fall back to body
115    let system_prompt = frontmatter
116        .system_prompt
117        .filter(|s| !s.is_empty())
118        .unwrap_or_else(|| body.to_string());
119
120    // Extra fields are already serde_json::Value from the flatten
121    let metadata: HashMap<String, serde_json::Value> = frontmatter.extra;
122
123    Ok(Skill {
124        name,
125        description: frontmatter.description.unwrap_or_default(),
126        system_prompt,
127        tools: frontmatter.tools,
128        allowed_tools: frontmatter.allowed_tools,
129        denied_tools: frontmatter.denied_tools,
130        metadata,
131    })
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_parse_simple_skill() -> Result<()> {
140        let content = "---
141name: test-skill
142description: A test skill
143---
144
145You are a helpful assistant.
146";
147
148        let skill = parse_skill_file(content)?;
149
150        assert_eq!(skill.name, "test-skill");
151        assert_eq!(skill.description, "A test skill");
152        assert_eq!(skill.system_prompt, "You are a helpful assistant.");
153        assert!(skill.tools.is_empty());
154        assert!(skill.allowed_tools.is_none());
155        assert!(skill.denied_tools.is_none());
156
157        Ok(())
158    }
159
160    #[test]
161    fn test_parse_skill_with_tools() -> Result<()> {
162        let content = "---
163name: code-review
164description: Review code for quality
165tools:
166  - read
167  - grep
168  - glob
169denied_tools:
170  - bash
171  - write
172---
173
174# Code Review
175
176You are an expert code reviewer.
177
178## Guidelines
179
1801. Check for security issues
1812. Look for performance problems
182";
183
184        let skill = parse_skill_file(content)?;
185
186        assert_eq!(skill.name, "code-review");
187        assert_eq!(skill.description, "Review code for quality");
188        assert_eq!(skill.tools, vec!["read", "grep", "glob"]);
189        assert_eq!(
190            skill.denied_tools,
191            Some(vec!["bash".into(), "write".into()])
192        );
193        assert!(skill.system_prompt.contains("# Code Review"));
194        assert!(skill.system_prompt.contains("## Guidelines"));
195
196        Ok(())
197    }
198
199    #[test]
200    fn test_parse_skill_with_allowed_tools() -> Result<()> {
201        let content = "---
202name: restricted
203allowed_tools:
204  - read
205  - grep
206---
207
208Only read operations allowed.
209";
210
211        let skill = parse_skill_file(content)?;
212
213        assert_eq!(skill.name, "restricted");
214        assert_eq!(
215            skill.allowed_tools,
216            Some(vec!["read".into(), "grep".into()])
217        );
218
219        Ok(())
220    }
221
222    #[test]
223    fn test_parse_skill_with_extra_metadata() -> Result<()> {
224        let content = "---
225name: custom
226version: \"1.0\"
227author: test
228custom_field: 42
229---
230
231Custom skill.
232";
233
234        let skill = parse_skill_file(content)?;
235
236        assert_eq!(skill.name, "custom");
237        assert_eq!(
238            skill.metadata.get("version").and_then(|v| v.as_str()),
239            Some("1.0")
240        );
241        assert_eq!(
242            skill.metadata.get("author").and_then(|v| v.as_str()),
243            Some("test")
244        );
245        assert_eq!(
246            skill
247                .metadata
248                .get("custom_field")
249                .and_then(serde_json::Value::as_i64),
250            Some(42)
251        );
252
253        Ok(())
254    }
255
256    #[test]
257    fn test_parse_missing_frontmatter() {
258        let content = "No frontmatter here";
259        let result = parse_skill_file(content);
260        assert!(result.is_err());
261        assert!(result.unwrap_err().to_string().contains("must start with"));
262    }
263
264    #[test]
265    fn test_parse_missing_closing_delimiter() {
266        let content = "---
267name: broken
268";
269        let result = parse_skill_file(content);
270        assert!(result.is_err());
271        assert!(
272            result
273                .unwrap_err()
274                .to_string()
275                .contains("closing frontmatter")
276        );
277    }
278
279    #[test]
280    fn test_parse_invalid_yaml() {
281        let content = "---
282name: [invalid yaml
283---
284
285Body
286";
287        let result = parse_skill_file(content);
288        assert!(result.is_err());
289    }
290
291    #[test]
292    fn test_parse_missing_name() {
293        let content = "---
294description: No name field
295---
296
297Body
298";
299        let result = parse_skill_file(content);
300        assert!(result.is_err());
301    }
302
303    #[test]
304    fn test_parse_empty_body() -> Result<()> {
305        let content = "---
306name: minimal
307---
308";
309
310        let skill = parse_skill_file(content)?;
311
312        assert_eq!(skill.name, "minimal");
313        assert!(skill.system_prompt.is_empty());
314
315        Ok(())
316    }
317
318    #[test]
319    fn test_parse_preserves_markdown_formatting() -> Result<()> {
320        let content = r#"---
321name: formatted
322---
323
324# Header
325
326- List item 1
327- List item 2
328
329```rust
330fn main() {
331    println!("Hello");
332}
333```
334
335**Bold** and *italic* text.
336"#;
337
338        let skill = parse_skill_file(content)?;
339
340        assert!(skill.system_prompt.contains("# Header"));
341        assert!(skill.system_prompt.contains("- List item 1"));
342        assert!(skill.system_prompt.contains("```rust"));
343        assert!(skill.system_prompt.contains("**Bold**"));
344
345        Ok(())
346    }
347
348    // ==========================================
349    // Compatibility tests for other skill formats
350    // ==========================================
351
352    #[test]
353    fn test_parse_with_id_instead_of_name() -> Result<()> {
354        // Codex-style: uses `id` instead of `name`
355        let content = "---
356id: codex-skill
357description: A Codex-style skill
358---
359
360Codex instructions here.
361";
362
363        let skill = parse_skill_file(content)?;
364
365        assert_eq!(skill.name, "codex-skill");
366        assert_eq!(skill.description, "A Codex-style skill");
367
368        Ok(())
369    }
370
371    #[test]
372    fn test_parse_with_title_instead_of_name() -> Result<()> {
373        // Cursor-style: uses `title` instead of `name`
374        let content = "---
375title: cursor-skill
376summary: A Cursor-style skill
377---
378
379Cursor instructions here.
380";
381
382        let skill = parse_skill_file(content)?;
383
384        assert_eq!(skill.name, "cursor-skill");
385        assert_eq!(skill.description, "A Cursor-style skill");
386
387        Ok(())
388    }
389
390    #[test]
391    fn test_parse_with_system_prompt_in_frontmatter() -> Result<()> {
392        // Amp-style: system_prompt in frontmatter
393        let content = "---
394name: amp-skill
395system_prompt: This is the system prompt from frontmatter.
396---
397
398This body is ignored when system_prompt is in frontmatter.
399";
400
401        let skill = parse_skill_file(content)?;
402
403        assert_eq!(skill.name, "amp-skill");
404        assert_eq!(
405            skill.system_prompt,
406            "This is the system prompt from frontmatter."
407        );
408
409        Ok(())
410    }
411
412    #[test]
413    fn test_parse_with_instructions_alias() -> Result<()> {
414        // Alternative: uses `instructions` for system prompt
415        let content = "---
416name: instructions-skill
417instructions: Use these instructions.
418---
419
420Body ignored.
421";
422
423        let skill = parse_skill_file(content)?;
424
425        assert_eq!(skill.system_prompt, "Use these instructions.");
426
427        Ok(())
428    }
429
430    #[test]
431    fn test_parse_with_enabled_disabled_tools() -> Result<()> {
432        // Alternative tool naming
433        let content = "---
434name: tool-aliases
435enabled_tools:
436  - read
437  - grep
438disabled_tools:
439  - bash
440---
441
442Body content.
443";
444
445        let skill = parse_skill_file(content)?;
446
447        assert_eq!(
448            skill.allowed_tools,
449            Some(vec!["read".into(), "grep".into()])
450        );
451        assert_eq!(skill.denied_tools, Some(vec!["bash".into()]));
452
453        Ok(())
454    }
455
456    #[test]
457    fn test_parse_empty_system_prompt_in_frontmatter_uses_body() -> Result<()> {
458        // If system_prompt is empty in frontmatter, use body
459        let content = "---
460name: empty-prompt
461system_prompt: \"\"
462---
463
464This body should be used.
465";
466
467        let skill = parse_skill_file(content)?;
468
469        assert_eq!(skill.system_prompt, "This body should be used.");
470
471        Ok(())
472    }
473}