agent_sdk/skills/
parser.rs1use anyhow::{Context, Result, bail};
16use serde::Deserialize;
17use std::collections::HashMap;
18
19use super::Skill;
20
21#[derive(Debug, Deserialize)]
26pub struct SkillFrontmatter {
27 #[serde(alias = "id", alias = "title")]
29 pub name: Option<String>,
30
31 #[serde(default, alias = "desc", alias = "summary")]
33 pub description: Option<String>,
34
35 #[serde(default, alias = "prompt", alias = "instructions")]
38 pub system_prompt: Option<String>,
39
40 #[serde(default)]
42 pub tools: Vec<String>,
43
44 #[serde(default, alias = "enabled_tools")]
47 pub allowed_tools: Option<Vec<String>>,
48
49 #[serde(default, alias = "disabled_tools", alias = "blocked_tools")]
52 pub denied_tools: Option<Vec<String>>,
53
54 #[serde(flatten)]
56 pub extra: HashMap<String, serde_json::Value>,
57}
58
59pub fn parse_skill_file(content: &str) -> Result<Skill> {
89 let content = content.trim();
90
91 if !content.starts_with("---") {
93 bail!("Skill file must start with YAML frontmatter (---)");
94 }
95
96 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 let frontmatter: SkillFrontmatter =
107 serde_yaml::from_str(yaml_content).context("Failed to parse YAML frontmatter")?;
108
109 let name = frontmatter
111 .name
112 .context("Skill must have a 'name', 'id', or 'title' field")?;
113
114 let system_prompt = frontmatter
116 .system_prompt
117 .filter(|s| !s.is_empty())
118 .unwrap_or_else(|| body.to_string());
119
120 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 #[test]
353 fn test_parse_with_id_instead_of_name() -> Result<()> {
354 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 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 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 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 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 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}