Skip to main content

cascade_agent/skills/
parser.rs

1//! SKILL.md frontmatter parser.
2
3use std::path::Path;
4
5use gray_matter::{engine::YAML, Matter};
6use tracing::{debug, warn};
7
8use crate::error::{AgentError, Result};
9
10use super::types::{Skill, SkillMetadata};
11
12const SKILL_FILE: &str = "SKILL.md";
13
14/// Well-known executable names searched in priority order.
15const EXECUTABLE_NAMES: &[&str] = &["run.sh", "run.py", "run", "bin"];
16
17/// Parse a `SKILL.md` file into a fully resolved [`Skill`].
18///
19/// The frontmatter (YAML between `---` delimiters) is deserialised into
20/// [`SkillMetadata`].  Everything after the closing delimiter becomes the
21/// `instructions` field.  The skill directory is then scanned for an executable.
22pub fn parse_skill_file(path: &Path) -> Result<Skill> {
23    let content = std::fs::read_to_string(path)
24        .map_err(|e| AgentError::SkillError(format!("Cannot read {}: {}", path.display(), e)))?;
25
26    let matter: Matter<YAML> = Matter::new();
27    let parsed = matter.parse::<SkillMetadata>(&content).map_err(|e| {
28        AgentError::SkillError(format!(
29            "Frontmatter parse error in {}: {}",
30            path.display(),
31            e
32        ))
33    })?;
34
35    let metadata = parsed.data.ok_or_else(|| {
36        AgentError::SkillError(format!(
37            "No valid YAML frontmatter found in {}",
38            path.display()
39        ))
40    })?;
41
42    if metadata.name.is_empty() {
43        return Err(AgentError::SkillError(format!(
44            "Skill metadata in {} is missing a 'name' field",
45            path.display()
46        )));
47    }
48
49    let instructions = parsed.content;
50    let directory = path.parent().unwrap_or(path).to_path_buf();
51
52    // Scan the skill directory for an executable.
53    let executable_path = find_executable(&directory);
54
55    if let Some(ref exe) = executable_path {
56        debug!(
57            skill = %metadata.name,
58            "Found executable for skill: {}",
59            exe.display()
60        );
61    }
62
63    Ok(Skill {
64        metadata,
65        instructions,
66        executable_path,
67        directory,
68    })
69}
70
71/// Search the skill directory for an executable in priority order:
72/// 1. Well-known names (`run.sh`, `run.py`, `run`, `bin`)
73/// 2. Any regular file with the execute permission bit set
74fn find_executable(dir: &Path) -> Option<std::path::PathBuf> {
75    if !dir.is_dir() {
76        return None;
77    }
78
79    let read_dir = match std::fs::read_dir(dir) {
80        Ok(rd) => rd,
81        Err(e) => {
82            warn!("Cannot read skill directory {}: {}", dir.display(), e);
83            return None;
84        }
85    };
86
87    // First pass: look for well-known names.
88    for name in EXECUTABLE_NAMES {
89        let candidate = dir.join(name);
90        if candidate.is_file() {
91            return Some(candidate);
92        }
93    }
94
95    // Second pass: look for any file with execute permission.
96    for entry in read_dir.flatten() {
97        let path = entry.path();
98        if path.is_file() {
99            use std::os::unix::fs::PermissionsExt;
100            let mode = entry.metadata().ok()?.permissions().mode();
101            if mode & 0o111 != 0 {
102                debug!("Found executable by permission bit: {}", path.display());
103                return Some(path);
104            }
105        }
106    }
107
108    None
109}
110
111/// Re-serialize the metadata as YAML frontmatter for writing SKILL.md.
112pub fn serialize_skill_md(metadata: &SkillMetadata, body: &str) -> String {
113    let yaml = serde_yaml::to_string(metadata).unwrap_or_default();
114    format!("---\n{yaml}---\n{body}")
115}
116
117/// Read a SKILL.md file and split it into (frontmatter_yaml_string, body).
118pub fn read_skill_parts(path: &Path) -> Result<(String, String)> {
119    let content = std::fs::read_to_string(path)
120        .map_err(|e| AgentError::SkillError(format!("Cannot read {}: {}", path.display(), e)))?;
121
122    let matter: Matter<YAML> = Matter::new();
123    let parsed = matter.parse::<serde_yaml::Value>(&content).map_err(|e| {
124        AgentError::SkillError(format!(
125            "Frontmatter parse error in {}: {}",
126            path.display(),
127            e
128        ))
129    })?;
130
131    // Reconstruct the original frontmatter text from parsed.matter
132    let frontmatter = if parsed.matter.is_empty() {
133        String::new()
134    } else {
135        format!("---\n{}\n---\n", parsed.matter)
136    };
137
138    Ok((frontmatter, parsed.content))
139}
140
141/// The expected SKILL.md filename (exposed for use by SkillManager).
142pub fn skill_file_name() -> &'static str {
143    SKILL_FILE
144}