1use 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
15pub 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#[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
54pub 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 manifest.validate_directory_name_match(&skill_md)?;
71
72 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
96pub fn parse_skill_content(content: &str) -> anyhow::Result<(SkillManifest, String)> {
98 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 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 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
199pub 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}