Skip to main content

vtcode_core/skills/
manifest.rs

1//! SKILL.md manifest parsing
2//!
3//! Parses YAML frontmatter from SKILL.md files to extract skill metadata and instructions.
4
5use crate::skills::file_references::FileReferenceValidator;
6use crate::skills::types::{SkillManifest, SkillManifestMetadata};
7use anyhow::Context;
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::Path;
11use std::sync::atomic::{AtomicBool, Ordering};
12
13static ALLOWED_TOOLS_ARRAY_WARNED: AtomicBool = AtomicBool::new(false);
14
15/// Supported YAML frontmatter keys for SKILL.md validation.
16pub const SUPPORTED_FRONTMATTER_KEYS: &[&str] = &[
17    "name",
18    "description",
19    "license",
20    "allowed-tools",
21    "disable-model-invocation",
22    "compatibility",
23    "metadata",
24];
25
26/// YAML frontmatter structure for SKILL.md
27#[derive(Debug, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct SkillYaml {
30    pub name: String,
31    pub description: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub license: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    #[serde(rename = "allowed-tools")]
36    pub allowed_tools: Option<AllowedToolsField>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    #[serde(rename = "disable-model-invocation")]
39    #[serde(alias = "disable_model_invocation")]
40    pub disable_model_invocation: Option<bool>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub compatibility: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub metadata: Option<SkillManifestMetadata>,
45}
46
47#[derive(Debug, Serialize, Deserialize)]
48#[serde(untagged)]
49pub enum AllowedToolsField {
50    List(Vec<String>),
51    String(String),
52}
53
54/// Parse SKILL.md file and extract manifest + instructions
55pub fn parse_skill_file(skill_path: &Path) -> anyhow::Result<(SkillManifest, String)> {
56    let skill_md = skill_path.join("SKILL.md");
57    anyhow::ensure!(
58        skill_md.exists(),
59        "SKILL.md not found at {}",
60        skill_md.display()
61    );
62
63    let content = fs::read_to_string(&skill_md)
64        .context(format!("Failed to read SKILL.md at {}", skill_md.display()))?;
65
66    let (manifest, instructions) = parse_skill_content(&content)?;
67
68    // Validate directory name matches per Agent Skills spec
69    // For traditional skills (not CLI tools), the name must match the directory
70    manifest.validate_directory_name_match(&skill_md)?;
71
72    // Validate file references in instructions
73    // For traditional skills (SKILL.md files), validate references
74    let skill_root = skill_md.parent().unwrap_or_else(|| Path::new("."));
75    let reference_validator = FileReferenceValidator::new(skill_root.to_path_buf());
76    let reference_errors = reference_validator.validate_references(&instructions);
77
78    if !reference_errors.is_empty() {
79        let sample_count = reference_errors.len().min(3);
80        let sample = &reference_errors[..sample_count];
81        tracing::warn!(
82            warning_count = reference_errors.len(),
83            sample = ?sample,
84            "File reference validation warnings detected (showing first {})",
85            sample_count
86        );
87        tracing::debug!(
88            warnings = ?reference_errors,
89            "File reference validation warnings (full list)"
90        );
91    }
92
93    Ok((manifest, instructions))
94}
95
96/// Parse SKILL.md content string
97pub fn parse_skill_content(content: &str) -> anyhow::Result<(SkillManifest, String)> {
98    // Split YAML frontmatter (between --- markers)
99    let parts: Vec<&str> = content.splitn(3, "---").collect();
100
101    anyhow::ensure!(
102        parts.len() >= 3,
103        "SKILL.md must start with YAML frontmatter: --- ... ---"
104    );
105
106    let yaml_str = parts[1].trim();
107    let instructions = parts[2].trim_start().to_string();
108
109    // Parse YAML frontmatter
110    let yaml: SkillYaml =
111        serde_saphyr::from_str(yaml_str).context("Failed to parse SKILL.md YAML frontmatter")?;
112
113    let name = yaml.name.trim().to_string();
114    anyhow::ensure!(!name.is_empty(), "name is required and must not be empty");
115
116    let description = yaml.description.trim().to_string();
117    anyhow::ensure!(
118        !description.is_empty(),
119        "description is required and must not be empty"
120    );
121
122    // Convert allowed-tools into space-delimited string for compatibility
123    let allowed_tools_string = yaml
124        .allowed_tools
125        .map(normalize_allowed_tools)
126        .transpose()?;
127
128    let manifest = SkillManifest {
129        name,
130        description,
131        version: None,
132        default_version: None,
133        latest_version: None,
134        author: None,
135        license: yaml.license,
136        model: None,
137        mode: None,
138        vtcode_native: None,
139        allowed_tools: allowed_tools_string,
140        disable_model_invocation: yaml.disable_model_invocation,
141        when_to_use: None,
142        when_not_to_use: None,
143        argument_hint: None,
144        user_invocable: None,
145        context: None,
146        agent: None,
147        hooks: None,
148        requires_container: None,
149        disallow_container: None,
150        compatibility: yaml.compatibility,
151        variety: crate::skills::types::SkillVariety::AgentSkill,
152        metadata: yaml.metadata,
153        tools: None,
154        network_policy: None,
155        permissions: None,
156    };
157
158    manifest.validate()?;
159
160    Ok((manifest, instructions))
161}
162fn normalize_allowed_tools(field: AllowedToolsField) -> anyhow::Result<String> {
163    match field {
164        AllowedToolsField::List(tools) => {
165            if !tools.is_empty() && !ALLOWED_TOOLS_ARRAY_WARNED.swap(true, Ordering::Relaxed) {
166                tracing::warn!(
167                    "allowed-tools uses deprecated array format, please use a string instead"
168                );
169            }
170            Ok(tools.join(" "))
171        }
172        AllowedToolsField::String(value) => {
173            let trimmed = value.trim();
174            if trimmed.is_empty() {
175                return Err(anyhow::anyhow!(
176                    "allowed-tools must not be empty if specified"
177                ));
178            }
179            let has_commas = trimmed.contains(',');
180            if has_commas {
181                tracing::warn!(
182                    "allowed-tools uses comma-separated format; normalizing to space-delimited"
183                );
184            }
185            let parts = if has_commas {
186                trimmed
187                    .split(',')
188                    .map(|part| part.trim())
189                    .filter(|part| !part.is_empty())
190                    .collect::<Vec<_>>()
191            } else {
192                trimmed.split_whitespace().collect::<Vec<_>>()
193            };
194            Ok(parts.join(" "))
195        }
196    }
197}
198
199/// Generate a skill template with YAML frontmatter
200pub fn generate_skill_template(name: &str, description: &str) -> String {
201    let skill_title = name
202        .split('-')
203        .filter(|word| !word.is_empty())
204        .map(|word| {
205            let mut chars = word.chars();
206            match chars.next() {
207                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
208                None => String::new(),
209            }
210        })
211        .collect::<Vec<_>>()
212        .join(" ");
213
214    format!(
215        r#"---
216name: {}
217description: {}
218license: Apache-2.0
219# Optional fields (uncomment to use):
220# compatibility: "Requires git and network access"
221# allowed-tools: "Read Write Bash"
222# disable-model-invocation: true
223# metadata:
224#   author: your-team
225#   version: "1.0"
226---
227
228# {}
229
230## Purpose
231
232Summarize the workflow, expected inputs, and the artifact or outcome this skill should produce.
233
234## Workflow
235
2361. Confirm the request matches the routing guidance above.
2372. Keep core instructions here; move detailed reference material into bundled files.
2383. Prefer reusable scripts, templates, or assets over re-describing large procedures in prose.
2394. Produce the expected artifact or outcome and note any important constraints.
240
241## Resources
242
243- `scripts/`: deterministic helpers for repeatable or fragile steps
244- `references/`: detailed docs loaded only when needed
245- `assets/`: reusable output skeletons, examples, or supporting files
246
247## Example
248
249**Input:** [Describe the request or files]
250**Output/Artifact:** [Describe the result this skill should produce]
251
252## Notes
253
254- Keep SKILL.md concise; move deep detail into `references/` files.
255- If output needs a fixed shape, store a starter template or asset alongside the skill.
256"#,
257        name, description, skill_title
258    )
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use serde_json::json;
265
266    #[test]
267    fn test_parse_valid_skill() {
268        let content = r#"---
269name: test-skill
270description: A test skill for parsing
271---
272
273# Test Skill
274
275## Instructions
276This is the instruction section.
277
278## Examples
279- Example 1
280- Example 2
281"#;
282
283        let (manifest, instructions) = parse_skill_content(content).unwrap();
284
285        assert_eq!(manifest.name, "test-skill");
286        assert_eq!(manifest.description, "A test skill for parsing");
287        assert!(instructions.contains("# Test Skill"));
288        assert!(instructions.contains("## Instructions"));
289    }
290
291    #[test]
292    fn test_parse_missing_frontmatter() {
293        let content = "This is not valid";
294        let result = parse_skill_content(content);
295        result.unwrap_err();
296    }
297
298    #[test]
299    fn test_parse_skill_rejects_non_spec_fields() {
300        let content = r#"---
301name: sandboxed-skill
302description: A skill with unsupported fields
303permissions:
304  file_system:
305    write:
306      - outputs
307---
308
309# Instructions
310"#;
311
312        let result = parse_skill_content(content);
313        result.unwrap_err();
314    }
315
316    #[test]
317    fn test_parse_invalid_yaml() {
318        let content = r#"---
319invalid: yaml: content: here
320missing_required_fields: true
321---
322
323# Instructions
324"#;
325
326        let result = parse_skill_content(content);
327        result.unwrap_err();
328    }
329
330    #[test]
331    fn test_parse_skill_metadata_accepts_arrays_and_maps() {
332        let content = r#"---
333name: rust-skills
334description: Rust guidance
335license: MIT
336metadata:
337  author: leonardomso
338  version: "1.0.0"
339  sources:
340    - Rust API Guidelines
341    - Rust Performance Book
342---
343
344# Rust Best Practices
345"#;
346
347        let (manifest, _) = parse_skill_content(content).expect("metadata arrays should parse");
348        let metadata = manifest.metadata.expect("metadata should be present");
349
350        assert_eq!(metadata.get("author"), Some(&json!("leonardomso")));
351        assert_eq!(metadata.get("version"), Some(&json!("1.0.0")));
352        assert_eq!(
353            metadata.get("sources"),
354            Some(&json!(["Rust API Guidelines", "Rust Performance Book"]))
355        );
356    }
357
358    #[test]
359    fn test_parse_skill_disable_model_invocation_flag() {
360        let content = r#"---
361name: command-skill
362description: A skill hidden from model-driven activation
363disable-model-invocation: true
364---
365
366# Command Skill
367"#;
368
369        let (manifest, _) = parse_skill_content(content).expect("flag should parse");
370        assert_eq!(manifest.disable_model_invocation, Some(true));
371    }
372
373    #[test]
374    fn test_generate_template() {
375        let template = generate_skill_template("my-skill", "Does cool things");
376        assert!(template.contains("name: my-skill"));
377        assert!(template.contains("description: Does cool things"));
378        assert!(template.contains("license: Apache-2.0"));
379        assert!(template.contains("## Workflow"));
380        assert!(template.contains("assets/`: reusable output skeletons"));
381    }
382}