tycode_core/skills/
parser.rs1use std::path::Path;
2
3use anyhow::{anyhow, Context, Result};
4use serde::Deserialize;
5
6use super::types::{SkillInstructions, SkillMetadata, SkillSource};
7
8#[derive(Debug, Deserialize)]
10struct RawFrontmatter {
11 name: String,
12 description: String,
13}
14
15pub fn parse_skill_file(
28 path: &Path,
29 source: SkillSource,
30 enabled: bool,
31) -> Result<SkillInstructions> {
32 let content = std::fs::read_to_string(path)
33 .with_context(|| format!("Failed to read skill file: {}", path.display()))?;
34
35 parse_skill_content(&content, path, source, enabled)
36}
37
38pub fn parse_skill_content(
40 content: &str,
41 path: &Path,
42 source: SkillSource,
43 enabled: bool,
44) -> Result<SkillInstructions> {
45 let (frontmatter, instructions) = extract_frontmatter(content)?;
46
47 let raw: RawFrontmatter = serde_yaml::from_str(&frontmatter)
48 .with_context(|| format!("Failed to parse YAML frontmatter in {}", path.display()))?;
49
50 if !SkillMetadata::is_valid_name(&raw.name) {
52 return Err(anyhow!(
53 "Invalid skill name '{}': must be lowercase letters, numbers, and hyphens only (max {} chars)",
54 raw.name,
55 super::types::MAX_SKILL_NAME_LENGTH
56 ));
57 }
58
59 if !SkillMetadata::is_valid_description(&raw.description) {
61 return Err(anyhow!(
62 "Invalid skill description: must be non-empty and max {} chars",
63 super::types::MAX_SKILL_DESCRIPTION_LENGTH
64 ));
65 }
66
67 let skill_dir = path
68 .parent()
69 .ok_or_else(|| anyhow!("Skill file has no parent directory"))?;
70
71 let reference_files = discover_reference_files(skill_dir);
73
74 let scripts = discover_scripts(skill_dir);
76
77 Ok(SkillInstructions {
78 metadata: SkillMetadata {
79 name: raw.name,
80 description: raw.description,
81 source,
82 path: path.to_path_buf(),
83 enabled,
84 },
85 instructions,
86 reference_files,
87 scripts,
88 })
89}
90
91fn extract_frontmatter(content: &str) -> Result<(String, String)> {
95 let content = content.trim();
96
97 if !content.starts_with("---") {
98 return Err(anyhow!("SKILL.md must start with YAML frontmatter (---)"));
99 }
100
101 let rest = &content[3..];
103 let end_pos = rest
104 .find("\n---")
105 .ok_or_else(|| anyhow!("SKILL.md frontmatter not closed (missing ---)"))?;
106
107 let frontmatter = rest[..end_pos].trim().to_string();
108 let body = rest[end_pos + 4..].trim().to_string();
109
110 if frontmatter.is_empty() {
111 return Err(anyhow!("SKILL.md frontmatter is empty"));
112 }
113
114 Ok((frontmatter, body))
115}
116
117fn discover_reference_files(skill_dir: &Path) -> Vec<std::path::PathBuf> {
119 let mut files = Vec::new();
120
121 if let Ok(entries) = std::fs::read_dir(skill_dir) {
122 for entry in entries.flatten() {
123 let path = entry.path();
124 if path.is_file() {
125 if let Some(ext) = path.extension() {
126 if ext == "md" {
127 if let Some(name) = path.file_name() {
129 if name.to_string_lossy().to_uppercase() != "SKILL.MD" {
130 files.push(path);
131 }
132 }
133 }
134 }
135 }
136 }
137 }
138
139 files.sort();
140 files
141}
142
143fn discover_scripts(skill_dir: &Path) -> Vec<std::path::PathBuf> {
145 let scripts_dir = skill_dir.join("scripts");
146 let mut files = Vec::new();
147
148 if scripts_dir.is_dir() {
149 if let Ok(entries) = std::fs::read_dir(&scripts_dir) {
150 for entry in entries.flatten() {
151 let path = entry.path();
152 if path.is_file() {
153 files.push(path);
154 }
155 }
156 }
157 }
158
159 files.sort();
160 files
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn test_extract_frontmatter_valid() {
169 let content = r#"---
170name: test-skill
171description: A test skill
172---
173
174# Instructions
175
176Some instructions here.
177"#;
178 let (frontmatter, body) = extract_frontmatter(content).unwrap();
179 assert!(frontmatter.contains("name: test-skill"));
180 assert!(frontmatter.contains("description: A test skill"));
181 assert!(body.contains("# Instructions"));
182 assert!(body.contains("Some instructions here."));
183 }
184
185 #[test]
186 fn test_extract_frontmatter_no_start() {
187 let content = "# No frontmatter\nJust content";
188 assert!(extract_frontmatter(content).is_err());
189 }
190
191 #[test]
192 fn test_extract_frontmatter_no_end() {
193 let content = "---\nname: test\n# No closing delimiter";
194 assert!(extract_frontmatter(content).is_err());
195 }
196
197 #[test]
198 fn test_parse_skill_content_valid() {
199 let content = r#"---
200name: my-skill
201description: Does something useful when you ask
202---
203
204# My Skill
205
206Follow these instructions.
207"#;
208 let path = Path::new("/test/skills/my-skill/SKILL.md");
209 let result = parse_skill_content(content, path, SkillSource::User, true).unwrap();
210
211 assert_eq!(result.metadata.name, "my-skill");
212 assert_eq!(
213 result.metadata.description,
214 "Does something useful when you ask"
215 );
216 assert!(result.metadata.enabled);
217 assert!(result.instructions.contains("# My Skill"));
218 }
219
220 #[test]
221 fn test_parse_skill_content_invalid_name() {
222 let content = r#"---
223name: Invalid_Name
224description: Has invalid name
225---
226
227Instructions
228"#;
229 let path = Path::new("/test/SKILL.md");
230 let result = parse_skill_content(content, path, SkillSource::User, true);
231 assert!(result.is_err());
232 assert!(result
233 .unwrap_err()
234 .to_string()
235 .contains("Invalid skill name"));
236 }
237
238 #[test]
239 fn test_parse_skill_content_empty_description() {
240 let content = r#"---
241name: valid-name
242description: ""
243---
244
245Instructions
246"#;
247 let path = Path::new("/test/SKILL.md");
248 let result = parse_skill_content(content, path, SkillSource::User, true);
249 assert!(result.is_err());
250 }
251}