Skip to main content

vtcode_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::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
13/// Supported YAML frontmatter keys for SKILL.md validation.
14pub(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
26/// Coerce `argument-hint` to a string.
27///
28/// Claude Code coerces non-string values (e.g. YAML sequences such as
29/// `[topic: foo | bar]`) to a string instead of failing; match that so
30/// third-party skills do not fail to parse here.
31fn 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        // Objects have no scalar form; keep compact JSON rather than failing.
55        other => Some(other.to_string()),
56    }))
57}
58
59/// YAML frontmatter structure for SKILL.md
60#[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
96/// Parse SKILL.md file and extract manifest + instructions
97pub 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    // Directory-name match is a spec SHOULD, not a load gate: warn and load
107    // anyway so skills authored for other clients (whose directory was renamed
108    // on install) still work. `vtcode skills validate` still surfaces the
109    // mismatch via the comprehensive validator. Safe to load: `Skill::new`
110    // does not depend on the directory name, and discovery keys collisions by
111    // manifest name.
112    if let Err(err) = manifest.validate_directory_name_match(&skill_md) {
113        tracing::warn!("{}; loading skill anyway", err);
114    }
115
116    // Validate file references in instructions
117    // For traditional skills (SKILL.md files), validate references
118    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
140/// Collect unknown top-level frontmatter keys from a YAML string.
141///
142/// Only keys at column 0 are examined; nested keys indented under a supported
143/// parent (e.g. `metadata:`) are not flagged. Returns keys in first-seen
144/// order, deduplicated. This is a pure helper extracted so the filtering logic
145/// is independently testable without capturing `tracing` output.
146fn 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        // Only top-level keys begin at column 0; indented lines are nested
151        // under a parent (e.g. `metadata:`) and must not be flagged.
152        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
175/// Validate that all YAML frontmatter keys are in the supported set.
176///
177/// Unknown **top-level** keys are logged as a single consolidated warning but
178/// do not fail parsing, preserving forward compatibility when newer vtcode
179/// versions add new fields. Nested keys under a supported parent (e.g.
180/// `metadata:`) are not flagged. Consolidating to one warning per skill (with
181/// all unknown keys listed once) avoids the per-key log spam that previously
182/// produced ~180 warning lines per startup, each repeating the full
183/// supported-keys list.
184fn 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
196/// Parse SKILL.md content string
197pub fn parse_skill_content(content: &str) -> anyhow::Result<(SkillManifest, String)> {
198    // Split YAML frontmatter (between --- markers)
199    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 before parsing. This replaces the stricter
207    // #[serde(deny_unknown_fields)] with a forward-compatible approach:
208    // unknown keys are warned about but do not fail parsing.
209    validate_frontmatter_keys(yaml_str);
210
211    // Parse YAML frontmatter
212    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    // Convert allowed-tools into space-delimited string for compatibility.
231    // Both the space-delimited string and the YAML list forms are accepted
232    // (Claude Code supports YAML lists); normalization is silent.
233    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}
274/// Whether `line` looks like a new top-level `key:` mapping entry.
275///
276/// Column-0 lines starting a mapping key terminate description folding; `- `
277/// sequence entries, comments, blank lines, and `scheme://...` runs (colon not
278/// followed by space/end) stay inside the folded value, matching YAML plain
279/// scalar rules closely enough for a last-resort retry.
280fn 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
295/// Retry helper for cross-client SKILL.md files whose unquoted `description:`
296/// value contains a colon (invalid YAML that lenient parsers accept, e.g.
297/// `description: Use this skill when: the user asks about PDFs`).
298///
299/// Rewrites the description value as a `|` block scalar and returns the
300/// rewritten frontmatter, or `None` when there is no bare top-level
301/// `description:` key to repair. Only runs after a hard parse failure, so it
302/// can never break a file that already parses.
303fn 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    // Only engage for the classic failure: an unquoted scalar containing a
318    // colon. Explicit `|`/`>`/quoted scalars and empty values fail elsewhere.
319    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
373/// Generate a skill template with YAML frontmatter
374pub 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        // Unknown frontmatter keys are now warned about but do not fail parsing,
514        // preserving forward compatibility when newer vtcode versions add fields.
515        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        // Nested keys under `metadata:` (a supported key) must NOT be flagged.
637        // This is the regression that produced ~180 false-positive warning
638        // lines per startup: author/version/sources/category are nested under
639        // metadata in well-formed third-party skills, not top-level.
640        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        // `permissions` and `backend` are top-level unknown keys; `file_system`
648        // and `write` are nested under `permissions` and must be skipped.
649        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        // Agent Skills client guide: directory-name mismatch warns but loads,
718        // so skills renamed on install (cross-client) still work.
719        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}