1use crate::file_references::FileReferenceValidator;
6use crate::types::{SkillManifest, SkillManifestMetadata};
7use anyhow::Context;
8use serde::{Deserialize, Deserializer, Serialize};
9use serde_json::Value as JsonValue;
10use std::fs;
11use std::path::Path;
12
13pub(crate) const SUPPORTED_FRONTMATTER_KEYS: &[&str] = &[
15 "name",
16 "description",
17 "license",
18 "allowed-tools",
19 "argument-hint",
20 "disable-model-invocation",
21 "compatibility",
22 "hooks",
23 "metadata",
24];
25
26fn deserialize_argument_hint_opt<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
32where
33 D: Deserializer<'de>,
34{
35 let value = Option::<JsonValue>::deserialize(deserializer)?;
36 Ok(value.and_then(|value| match value {
37 JsonValue::Null => None,
38 JsonValue::String(s) => Some(s),
39 JsonValue::Bool(b) => Some(b.to_string()),
40 JsonValue::Number(n) => Some(n.to_string()),
41 JsonValue::Array(items) => {
42 let parts: Vec<String> = items
43 .into_iter()
44 .filter_map(|item| match item {
45 JsonValue::Null => None,
46 JsonValue::String(s) => Some(s),
47 JsonValue::Bool(b) => Some(b.to_string()),
48 JsonValue::Number(n) => Some(n.to_string()),
49 other => Some(other.to_string()),
50 })
51 .collect();
52 if parts.is_empty() { None } else { Some(parts.join(" ")) }
53 }
54 other => Some(other.to_string()),
56 }))
57}
58
59#[derive(Debug, Serialize, Deserialize)]
61pub struct SkillYaml {
62 pub(crate) name: String,
63 pub(crate) description: String,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 license: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 #[serde(rename = "allowed-tools")]
68 allowed_tools: Option<AllowedToolsField>,
69 #[serde(
70 skip_serializing_if = "Option::is_none",
71 default,
72 deserialize_with = "deserialize_argument_hint_opt"
73 )]
74 #[serde(rename = "argument-hint")]
75 #[serde(alias = "argument_hint")]
76 argument_hint: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 #[serde(rename = "disable-model-invocation")]
79 #[serde(alias = "disable_model_invocation")]
80 disable_model_invocation: Option<bool>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 compatibility: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 hooks: Option<JsonValue>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 metadata: Option<SkillManifestMetadata>,
87}
88
89#[derive(Debug, Serialize, Deserialize)]
90#[serde(untagged)]
91pub enum AllowedToolsField {
92 List(Vec<String>),
93 String(String),
94}
95
96pub fn parse_skill_file(skill_path: &Path) -> anyhow::Result<(SkillManifest, String)> {
98 let skill_md = skill_path.join("SKILL.md");
99 anyhow::ensure!(skill_md.exists(), "SKILL.md not found at {}", skill_md.display());
100
101 let content =
102 fs::read_to_string(&skill_md).context(format!("Failed to read SKILL.md at {}", skill_md.display()))?;
103
104 let (manifest, instructions) = parse_skill_content(&content)?;
105
106 if let Err(err) = manifest.validate_directory_name_match(&skill_md) {
113 tracing::warn!("{}; loading skill anyway", err);
114 }
115
116 let skill_root = skill_md.parent().unwrap_or_else(|| Path::new("."));
119 let reference_validator = FileReferenceValidator::new(skill_root.to_path_buf());
120 let reference_errors = reference_validator.validate_references(&instructions);
121
122 if !reference_errors.is_empty() {
123 let sample_count = reference_errors.len().min(3);
124 let sample = &reference_errors[..sample_count];
125 tracing::warn!(
126 warning_count = reference_errors.len(),
127 sample = ?sample,
128 "File reference validation warnings detected (showing first {})",
129 sample_count
130 );
131 tracing::debug!(
132 warnings = ?reference_errors,
133 "File reference validation warnings (full list)"
134 );
135 }
136
137 Ok((manifest, instructions))
138}
139
140fn collect_unknown_frontmatter_keys(yaml_str: &str) -> Vec<&str> {
147 let mut unknown_keys: Vec<&str> = Vec::new();
148 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
149 for line in yaml_str.lines() {
150 match line.as_bytes().first() {
153 None => continue,
154 Some(&b) if b == b' ' || b == b'\t' => continue,
155 _ => {}
156 }
157 let trimmed = line.trim();
158 if trimmed.is_empty() || trimmed.starts_with('#') {
159 continue;
160 }
161 if let Some(colon_pos) = trimmed.find(':') {
162 let key = trimmed[..colon_pos].trim();
163 if !key.is_empty()
164 && !key.starts_with('#')
165 && !SUPPORTED_FRONTMATTER_KEYS.contains(&key)
166 && seen.insert(key)
167 {
168 unknown_keys.push(key);
169 }
170 }
171 }
172 unknown_keys
173}
174
175fn validate_frontmatter_keys(yaml_str: &str) {
185 let unknown_keys = collect_unknown_frontmatter_keys(yaml_str);
186 if !unknown_keys.is_empty() {
187 tracing::warn!(
188 unknown_keys = ?unknown_keys,
189 supported = ?SUPPORTED_FRONTMATTER_KEYS,
190 "SKILL.md frontmatter has {} unknown top-level key(s); they are ignored but may indicate a typo or a field this vtcode version does not recognize yet",
191 unknown_keys.len()
192 );
193 }
194}
195
196pub fn parse_skill_content(content: &str) -> anyhow::Result<(SkillManifest, String)> {
198 let parts: Vec<&str> = content.splitn(3, "---").collect();
200
201 anyhow::ensure!(parts.len() >= 3, "SKILL.md must start with YAML frontmatter: --- ... ---");
202
203 let yaml_str = parts[1].trim();
204 let instructions = parts[2].trim_start().to_string();
205
206 validate_frontmatter_keys(yaml_str);
210
211 let yaml: SkillYaml = match serde_saphyr::from_str(yaml_str) {
213 Ok(yaml) => yaml,
214 Err(first_err) => match fold_bare_description_to_block_scalar(yaml_str) {
215 Some(fixed) => {
216 tracing::debug!("SKILL.md frontmatter needed description block-scalar fallback to parse");
217 serde_saphyr::from_str(&fixed)
218 .with_context(|| format!("Failed to parse SKILL.md YAML frontmatter ({first_err:#})"))?
219 }
220 None => return Err(first_err).context("Failed to parse SKILL.md YAML frontmatter"),
221 },
222 };
223
224 let name = yaml.name.trim().to_string();
225 anyhow::ensure!(!name.is_empty(), "name is required and must not be empty");
226
227 let description = yaml.description.trim().to_string();
228 anyhow::ensure!(!description.is_empty(), "description is required and must not be empty");
229
230 let allowed_tools_string = yaml.allowed_tools.map(normalize_allowed_tools).transpose()?;
234
235 let argument_hint = yaml
236 .argument_hint
237 .map(|hint| hint.trim().to_string())
238 .filter(|hint| !hint.is_empty());
239
240 let manifest = SkillManifest {
241 name,
242 description,
243 version: None,
244 default_version: None,
245 latest_version: None,
246 author: None,
247 license: yaml.license,
248 model: None,
249 mode: None,
250 vtcode_native: None,
251 allowed_tools: allowed_tools_string,
252 disable_model_invocation: yaml.disable_model_invocation,
253 when_to_use: None,
254 when_not_to_use: None,
255 argument_hint,
256 user_invocable: None,
257 context: None,
258 agent: None,
259 hooks: yaml.hooks,
260 requires_container: None,
261 disallow_container: None,
262 compatibility: yaml.compatibility,
263 variety: crate::types::SkillVariety::AgentSkill,
264 metadata: yaml.metadata,
265 tools: None,
266 network_policy: None,
267 permissions: None,
268 };
269
270 manifest.validate()?;
271
272 Ok((manifest, instructions))
273}
274fn looks_like_top_level_key(line: &str) -> bool {
281 match line.as_bytes().first() {
282 None | Some(b' ') | Some(b'\t') | Some(b'#') | Some(b'-') => return false,
283 _ => {}
284 }
285 let trimmed = line.trim_start();
286 let Some(colon_pos) = trimmed.find(':') else {
287 return false;
288 };
289 if colon_pos == 0 {
290 return false;
291 }
292 matches!(trimmed.as_bytes().get(colon_pos + 1), None | Some(b' ') | Some(b'\t'))
293}
294
295fn fold_bare_description_to_block_scalar(yaml_str: &str) -> Option<String> {
304 const KEY: &str = "description:";
305 let lines: Vec<&str> = yaml_str.lines().collect();
306 let desc_idx = lines.iter().position(|line| {
307 if matches!(line.as_bytes().first(), None | Some(b' ') | Some(b'\t') | Some(b'#')) {
308 return false;
309 }
310 let trimmed = line.trim_start();
311 if !trimmed.starts_with(KEY) {
312 return false;
313 }
314 matches!(trimmed.as_bytes().get(KEY.len()), None | Some(b' ') | Some(b'\t'))
315 })?;
316 let first_value = lines[desc_idx].trim_start()[KEY.len()..].trim_start();
317 if first_value.is_empty() || first_value.starts_with(['|', '>', '"', '\'']) || !first_value.contains(':') {
320 return None;
321 }
322
323 let mut rebuilt: Vec<String> = lines[..desc_idx].iter().map(|line| line.to_string()).collect();
324 rebuilt.push("description: |".to_string());
325 rebuilt.push(format!(" {first_value}"));
326 let mut folding = true;
327 for line in &lines[desc_idx + 1..] {
328 if folding && looks_like_top_level_key(line) {
329 folding = false;
330 }
331 if folding && !line.is_empty() {
332 rebuilt.push(format!(" {}", line.trim_start()));
333 } else {
334 rebuilt.push(line.to_string());
335 }
336 }
337 Some(rebuilt.join("\n"))
338}
339
340fn normalize_allowed_tools(field: AllowedToolsField) -> anyhow::Result<String> {
341 match field {
342 AllowedToolsField::List(tools) => {
343 let normalized = tools.join(" ");
344 if normalized.trim().is_empty() {
345 return Err(anyhow::anyhow!("allowed-tools must not be empty if specified"));
346 }
347 tracing::debug!("normalized allowed-tools from YAML list to space-delimited string");
348 Ok(normalized)
349 }
350 AllowedToolsField::String(value) => {
351 let trimmed = value.trim();
352 if trimmed.is_empty() {
353 return Err(anyhow::anyhow!("allowed-tools must not be empty if specified"));
354 }
355 let has_commas = trimmed.contains(',');
356 if has_commas {
357 tracing::debug!("normalized allowed-tools from comma-separated to space-delimited");
358 }
359 let parts = if has_commas {
360 trimmed
361 .split(',')
362 .map(|part| part.trim())
363 .filter(|part| !part.is_empty())
364 .collect::<Vec<_>>()
365 } else {
366 trimmed.split_whitespace().collect::<Vec<_>>()
367 };
368 Ok(parts.join(" "))
369 }
370 }
371}
372
373pub fn generate_skill_template(name: &str, description: &str) -> String {
375 let skill_title = name
376 .split('-')
377 .filter(|word| !word.is_empty())
378 .map(|word| {
379 let mut chars = word.chars();
380 match chars.next() {
381 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
382 None => String::new(),
383 }
384 })
385 .collect::<Vec<_>>()
386 .join(" ");
387
388 format!(
389 r#"---
390name: {name}
391description: {description}
392license: Apache-2.0
393# Optional fields (uncomment to use):
394# compatibility: "Requires git and network access"
395# allowed-tools: "Read Write Bash"
396# argument-hint: "[expected argument]"
397# disable-model-invocation: true
398# metadata:
399# author: your-team
400# version: "1.0"
401---
402
403# {skill_title}
404
405## Purpose
406
407Summarize the workflow, expected inputs, and the artifact or outcome this skill should produce.
408
409## Workflow
410
4111. Confirm the request matches the routing guidance above.
4122. Keep core instructions here; move detailed reference material into bundled files.
4133. Prefer reusable scripts, templates, or assets over re-describing large procedures in prose.
4144. Produce the expected artifact or outcome and note any important constraints.
415
416## Resources
417
418- `scripts/`: deterministic helpers for repeatable or fragile steps
419- `references/`: detailed docs loaded only when needed
420- `assets/`: reusable output skeletons, examples, or supporting files
421
422## Example
423
424**Input:** [Describe the request or files]
425**Output/Artifact:** [Describe the result this skill should produce]
426
427## Gotchas
428
429- List non-obvious facts the agent will get wrong without being told.
430- Example: soft-delete filters, ID aliases across systems, health-check quirks.
431
432## Output Format
433
434Use this shape for the final artifact; adapt sections as needed:
435
436```markdown
437# [Title]
438
439## Summary
440[One-paragraph overview]
441
442## Findings
443- Finding with supporting data
444
445## Next Steps
4461. Specific actionable step
447```
448
449For longer templates, store them under `assets/` and reference them here.
450
451## Checklist
452
453- [ ] Confirm trigger matches `description` routing
454- [ ] Load only needed `references/` files
455- [ ] Run bundled `scripts/` instead of retyping logic
456- [ ] Validate output before finishing
457
458## Validation
459
4601. Make the change or produce the artifact.
4612. Run validation: `python3 scripts/validate.py --help` (or skill-specific validator).
4623. If validation fails, fix and re-run. Only finish when validation passes.
463
464## Notes
465
466- Keep SKILL.md concise; move deep detail into `references/` files.
467- If output needs a fixed shape, store a starter template or asset alongside the skill.
468- Prefer one default tool path with a brief escape hatch over a menu of options.
469- Describe reusable procedures, not single-instance answers.
470""#
471 )
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use serde_json::json;
478
479 #[test]
480 fn test_parse_valid_skill() {
481 let content = r#"---
482name: test-skill
483description: A test skill for parsing
484---
485
486# Test Skill
487
488## Instructions
489This is the instruction section.
490
491## Examples
492- Example 1
493- Example 2
494"#;
495
496 let (manifest, instructions) = parse_skill_content(content).unwrap();
497
498 assert_eq!(manifest.name, "test-skill");
499 assert_eq!(manifest.description, "A test skill for parsing");
500 assert!(instructions.contains("# Test Skill"));
501 assert!(instructions.contains("## Instructions"));
502 }
503
504 #[test]
505 fn test_parse_missing_frontmatter() {
506 let content = "This is not valid";
507 let result = parse_skill_content(content);
508 result.unwrap_err();
509 }
510
511 #[test]
512 fn test_parse_skill_accepts_non_spec_fields_with_warning() {
513 let content = r#"---
516name: sandboxed-skill
517description: A skill with unsupported fields
518permissions:
519 file_system:
520 write:
521 - outputs
522---
523
524# Instructions
525"#;
526
527 let (manifest, _) = parse_skill_content(content)
528 .expect("unknown frontmatter keys should be accepted for forward compatibility");
529 assert_eq!(manifest.name, "sandboxed-skill");
530 }
531
532 #[test]
533 fn test_parse_invalid_yaml() {
534 let content = r#"---
535invalid: yaml: content: here
536missing_required_fields: true
537---
538
539# Instructions
540"#;
541
542 let result = parse_skill_content(content);
543 result.unwrap_err();
544 }
545
546 #[test]
547 fn test_parse_skill_metadata_accepts_arrays_and_maps() {
548 let content = r#"---
549name: rust-skills
550description: Rust guidance
551license: MIT
552metadata:
553 author: leonardomso
554 version: "1.0.0"
555 sources:
556 - Rust API Guidelines
557 - Rust Performance Book
558---
559
560# Rust Best Practices
561"#;
562
563 let (manifest, _) = parse_skill_content(content).expect("metadata arrays should parse");
564 let metadata = manifest.metadata.expect("metadata should be present");
565
566 assert_eq!(metadata.get("author"), Some(&json!("leonardomso")));
567 assert_eq!(metadata.get("version"), Some(&json!("1.0.0")));
568 assert_eq!(metadata.get("sources"), Some(&json!(["Rust API Guidelines", "Rust Performance Book"])));
569 }
570
571 #[test]
572 fn test_parse_skill_disable_model_invocation_flag() {
573 let content = r#"---
574name: command-skill
575description: A skill hidden from model-driven activation
576disable-model-invocation: true
577---
578
579# Command Skill
580"#;
581
582 let (manifest, _) = parse_skill_content(content).expect("flag should parse");
583 assert_eq!(manifest.disable_model_invocation, Some(true));
584 }
585
586 #[test]
587 fn test_parse_skill_description_with_bare_colon() {
588 let content = r#"---
589name: colon-skill
590description: Use this skill when: the user asks about PDFs
591---
592
593# Body
594"#;
595
596 let (manifest, _) = parse_skill_content(content).expect("bare colon should fold to block scalar");
597 assert_eq!(manifest.name, "colon-skill");
598 assert_eq!(manifest.description, "Use this skill when: the user asks about PDFs");
599 }
600
601 #[test]
602 fn test_parse_skill_multiline_description_with_colon() {
603 let content = "---\nname: multi-skill\ndescription: First line: overview\ncontinued second line\nlicense: MIT\n---\n\n# Body\n";
604
605 let (manifest, _) = parse_skill_content(content).expect("multiline description should fold");
606 assert_eq!(manifest.name, "multi-skill");
607 assert!(manifest.description.contains("First line: overview"), "got: {}", manifest.description);
608 assert!(manifest.description.contains("continued second line"), "got: {}", manifest.description);
609 assert_eq!(manifest.license.as_deref(), Some("MIT"));
610 }
611
612 #[test]
613 fn test_parse_skill_garbage_frontmatter_still_fails() {
614 let content = "---\nname: [unclosed\ndescription: Broken\n---\n\n# Body\n";
615
616 let err = parse_skill_content(content).expect_err("unrelated YAML failure must still error");
617 assert!(err.to_string().contains("frontmatter"), "got: {err:#}");
618 }
619
620 #[test]
621 fn test_generate_template() {
622 let template = generate_skill_template("my-skill", "Does cool things");
623 assert!(template.contains("name: my-skill"));
624 assert!(template.contains("description: Does cool things"));
625 assert!(template.contains("license: Apache-2.0"));
626 assert!(template.contains("## Workflow"));
627 assert!(template.contains("assets/`: reusable output skeletons"));
628 assert!(template.contains("## Gotchas"));
629 assert!(template.contains("## Output Format"));
630 assert!(template.contains("## Checklist"));
631 assert!(template.contains("## Validation"));
632 }
633
634 #[test]
635 fn collect_unknown_frontmatter_keys_ignores_nested_keys() {
636 let yaml = "name: test\ndescription: test\nmetadata:\n author: leo\n version: \"1.0\"\n sources:\n - a\n - b\n category: foo\n backend: bar\n";
641 let unknown = collect_unknown_frontmatter_keys(yaml);
642 assert!(unknown.is_empty(), "nested keys under a supported parent must not be flagged, got {unknown:?}");
643 }
644
645 #[test]
646 fn collect_unknown_frontmatter_keys_flags_top_level_only() {
647 let yaml =
650 "name: test\ndescription: test\npermissions:\n file_system:\n write:\n - outputs\nbackend: foo\n";
651 let unknown = collect_unknown_frontmatter_keys(yaml);
652 assert_eq!(unknown, vec!["permissions", "backend"]);
653 }
654
655 #[test]
656 fn collect_unknown_frontmatter_keys_deduplicates() {
657 let yaml = "name: test\ndescription: test\nbackend: foo\nbackend: bar\n";
658 let unknown = collect_unknown_frontmatter_keys(yaml);
659 assert_eq!(unknown, vec!["backend"]);
660 }
661
662 #[test]
663 fn collect_unknown_frontmatter_keys_skips_comments_and_blank_lines() {
664 let yaml = "# a comment\nname: test\n\ndescription: test\n# another\n";
665 let unknown = collect_unknown_frontmatter_keys(yaml);
666 assert!(unknown.is_empty());
667 }
668
669 #[test]
670 fn collect_unknown_frontmatter_keys_preserves_first_seen_order() {
671 let yaml = "name: test\ndescription: test\nzee: 1\nalpha: 2\nmid: 3\n";
672 let unknown = collect_unknown_frontmatter_keys(yaml);
673 assert_eq!(unknown, vec!["zee", "alpha", "mid"]);
674 }
675
676 #[test]
677 fn collect_unknown_frontmatter_keys_accepts_argument_hint() {
678 let yaml = "name: test\ndescription: test\nargument-hint: \"<input>\"\n";
679 let unknown = collect_unknown_frontmatter_keys(yaml);
680 assert!(unknown.is_empty(), "argument-hint is supported, got {unknown:?}");
681 }
682
683 #[test]
684 fn parse_skill_content_accepts_codemod_style_frontmatter() {
685 let content = r#"---
686name: codemod
687description: Use Codemod CLI whenever the user wants to migrate something.
688allowed-tools:
689 - Bash(codemod *)
690argument-hint: "<migration-intent>"
691---
692
693# Codemod
694"#;
695 let (manifest, _) = parse_skill_content(content).expect("codemod-style frontmatter should parse");
696 assert_eq!(manifest.allowed_tools.as_deref(), Some("Bash(codemod *)"));
697 assert_eq!(manifest.argument_hint.as_deref(), Some("<migration-intent>"));
698 }
699
700 #[test]
701 fn parse_skill_content_coerces_sequence_argument_hint() {
702 let content =
703 "---\nname: seq-skill\ndescription: Test skill\nargument-hint:\n - topic\n - foo\n---\n\n# Body\n";
704 let (manifest, _) = parse_skill_content(content).expect("sequence argument-hint should coerce");
705 assert_eq!(manifest.argument_hint.as_deref(), Some("topic foo"));
706 }
707
708 #[test]
709 fn parse_skill_content_rejects_empty_allowed_tools_list() {
710 let content = "---\nname: empty-tools\ndescription: Test skill\nallowed-tools: []\n---\n\n# Body\n";
711 let err = parse_skill_content(content).expect_err("empty allowed-tools list must fail");
712 assert!(err.to_string().contains("allowed-tools"), "got: {err:#}");
713 }
714
715 #[test]
716 fn parse_skill_file_loads_despite_directory_name_mismatch() {
717 let tmp = tempfile::TempDir::new().expect("temp dir");
720 let skill_dir = tmp.path().join("renamed-dir");
721 fs::create_dir(&skill_dir).expect("create skill dir");
722 fs::write(skill_dir.join("SKILL.md"), "---\nname: original-name\ndescription: Test skill\n---\n\n# Body\n")
723 .expect("write SKILL.md");
724
725 let (manifest, _) = parse_skill_file(&skill_dir).expect("mismatched directory must still load");
726 assert_eq!(manifest.name, "original-name");
727 }
728}