1use std::path::{Path, PathBuf};
36
37#[derive(Debug, Clone)]
43pub struct SkillCommand {
44 pub name: String,
46 pub description: String,
48 pub aliases: Vec<String>,
50 pub argument_hint: String,
52 pub allowed_tools: Option<Vec<String>>,
54 pub prompt_template: String,
56 pub source_path: PathBuf,
58}
59
60impl SkillCommand {
61 pub fn expand(&self, args: &str) -> String {
64 self.prompt_template
65 .replace("$ARGUMENTS", args)
66 .replace("{{args}}", args)
67 }
68}
69
70pub struct SkillCommandLoader;
76
77impl SkillCommandLoader {
78 pub fn load(workspace: &Path) -> Vec<SkillCommand> {
84 let mut commands: Vec<SkillCommand> = Vec::new();
85 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
86
87 let project_dir = workspace.join(".recursive").join("skills");
89 for skill in Self::load_dir(&project_dir) {
90 if seen.insert(skill.name.clone()) {
91 commands.push(skill);
92 }
93 }
94
95 if let Some(home) = dirs::home_dir() {
97 let user_dir = home.join(".recursive").join("skills");
98 for skill in Self::load_dir(&user_dir) {
99 if seen.insert(skill.name.clone()) {
100 commands.push(skill);
101 }
102 }
103 }
104
105 commands
106 }
107
108 pub fn load_dir(dir: &Path) -> Vec<SkillCommand> {
114 let entries = match std::fs::read_dir(dir) {
115 Ok(it) => it,
116 Err(err) => {
117 if dir.exists() {
118 tracing::warn!(
120 target: "recursive::tui::skill_commands",
121 dir = %dir.display(),
122 error = %err,
123 "skill_commands: failed to read directory"
124 );
125 }
126 return Vec::new();
127 }
128 };
129
130 let mut skills: Vec<SkillCommand> = entries
131 .flatten()
132 .filter(|e| {
133 e.path()
134 .extension()
135 .map(|x| x.eq_ignore_ascii_case("md"))
136 .unwrap_or(false)
137 })
138 .filter_map(|e| Self::parse_file(&e.path()))
139 .collect();
140
141 skills.sort_by(|a, b| a.name.cmp(&b.name));
142 skills
143 }
144
145 pub fn parse_file(path: &Path) -> Option<SkillCommand> {
148 let raw = match std::fs::read_to_string(path) {
149 Ok(s) => s,
150 Err(err) => {
151 tracing::warn!(
152 target: "recursive::tui::skill_commands",
153 path = %path.display(),
154 error = %err,
155 "skill_commands: failed to read .md file; skipping"
156 );
157 return None;
158 }
159 };
160 let parsed = Self::parse_content(path, &raw);
161 if parsed.is_none() {
162 tracing::warn!(
163 target: "recursive::tui::skill_commands",
164 path = %path.display(),
165 "skill_commands: front-matter / filename produced an empty command name; skipping"
166 );
167 }
168 parsed
169 }
170
171 pub fn parse_content(path: &Path, content: &str) -> Option<SkillCommand> {
173 let stem = path
174 .file_stem()
175 .and_then(|s| s.to_str())
176 .unwrap_or("unknown")
177 .to_string();
178
179 let (front, body) = split_frontmatter(content);
181
182 let mut name = stem.clone();
184 let mut description = String::new();
185 let mut aliases: Vec<String> = Vec::new();
186 let mut argument_hint = String::new();
187 let mut allowed_tools: Option<Vec<String>> = None;
188
189 if let Some(fm) = front {
191 for line in fm.lines() {
196 let line = line.trim();
197 if let Some((k, v)) = line.split_once(':') {
198 let k = k.trim();
199 let v = v.trim();
200 match k {
201 "name" => name = v.to_string(),
202 "description" => description = v.to_string(),
203 "argument_hint" => argument_hint = v.trim_matches('"').to_string(),
204 "aliases" => {
205 aliases = parse_inline_list(v);
207 }
208 "allowed_tools" => {
209 let tools = parse_inline_list(v);
210 if !tools.is_empty() {
211 allowed_tools = Some(tools);
212 }
213 }
214 _ => {} }
216 }
217 }
218 }
219
220 if description.is_empty() {
222 description = body
223 .lines()
224 .find(|l| !l.trim().is_empty())
225 .unwrap_or("")
226 .trim()
227 .trim_start_matches('#')
228 .trim()
229 .to_string();
230 }
231
232 name = name
234 .to_lowercase()
235 .chars()
236 .map(|c| {
237 if c.is_alphanumeric() || c == '-' {
238 c
239 } else {
240 '-'
241 }
242 })
243 .collect();
244 let name = name.trim_matches('-').to_string();
246 if name.is_empty() {
247 return None;
248 }
249
250 Some(SkillCommand {
251 name,
252 description,
253 aliases,
254 argument_hint,
255 allowed_tools,
256 prompt_template: body.trim().to_string(),
257 source_path: path.to_path_buf(),
258 })
259 }
260}
261
262fn split_frontmatter(content: &str) -> (Option<&str>, &str) {
269 let content = content.trim_start();
270 if !content.starts_with("---") {
271 return (None, content);
272 }
273 let rest = &content["---".len()..];
275 if let Some(pos) = rest.find("\n---") {
277 let fm = &rest[..pos];
278 let body = &rest[pos + "\n---".len()..];
279 (Some(fm.trim()), body)
280 } else {
281 (None, content)
282 }
283}
284
285fn parse_inline_list(s: &str) -> Vec<String> {
287 let s = s.trim();
288 if s.starts_with('[') && s.ends_with(']') {
289 let inner = &s[1..s.len() - 1];
290 inner
291 .split(',')
292 .map(|t| t.trim().trim_matches('"').trim_matches('\'').to_string())
293 .filter(|t| !t.is_empty())
294 .collect()
295 } else if s.is_empty() {
296 Vec::new()
297 } else {
298 vec![s.trim_matches('"').trim_matches('\'').to_string()]
299 }
300}
301
302#[cfg(test)]
307mod tests {
308 use super::*;
309 use std::path::PathBuf;
310
311 fn fake_path(name: &str) -> PathBuf {
312 PathBuf::from(format!("/fake/{name}.md"))
313 }
314
315 #[test]
318 fn parse_skill_with_full_frontmatter() {
319 let content = r#"---
320name: refactor
321description: Refactor code for clarity
322aliases: [rf, refac]
323argument_hint: "<file>"
324allowed_tools: [read_file, apply_patch]
325---
326
327Refactor this:
328
329$ARGUMENTS
330"#;
331 let skill = SkillCommandLoader::parse_content(&fake_path("refactor"), content).unwrap();
332 assert_eq!(skill.name, "refactor");
333 assert_eq!(skill.description, "Refactor code for clarity");
334 assert_eq!(skill.aliases, vec!["rf", "refac"]);
335 assert_eq!(skill.argument_hint, "<file>");
336 assert_eq!(
337 skill.allowed_tools.as_deref().unwrap(),
338 &["read_file", "apply_patch"]
339 );
340 assert!(skill.prompt_template.contains("$ARGUMENTS"));
341 }
342
343 #[test]
344 fn parse_skill_without_frontmatter_uses_filename_stem() {
345 let content = "Explain the code at $ARGUMENTS\n";
346 let skill = SkillCommandLoader::parse_content(&fake_path("explain"), content).unwrap();
347 assert_eq!(skill.name, "explain");
348 assert!(skill.description.contains("Explain"));
350 assert!(skill.prompt_template.contains("$ARGUMENTS"));
351 }
352
353 #[test]
354 fn parse_skill_description_fallback_to_first_body_line() {
355 let content = "---\nname: hello\n---\n\nFirst line description\n\nMore content\n";
356 let skill = SkillCommandLoader::parse_content(&fake_path("hello"), content).unwrap();
357 assert_eq!(skill.description, "First line description");
358 }
359
360 #[test]
363 fn expand_substitutes_dollar_arguments() {
364 let skill = SkillCommand {
365 name: "test".into(),
366 description: "test".into(),
367 aliases: vec![],
368 argument_hint: "".into(),
369 allowed_tools: None,
370 prompt_template: "Fix $ARGUMENTS for me".into(),
371 source_path: fake_path("test"),
372 };
373 assert_eq!(skill.expand("src/lib.rs"), "Fix src/lib.rs for me");
374 }
375
376 #[test]
377 fn expand_substitutes_mustache_args() {
378 let skill = SkillCommand {
379 name: "test".into(),
380 description: "test".into(),
381 aliases: vec![],
382 argument_hint: "".into(),
383 allowed_tools: None,
384 prompt_template: "Review {{args}}".into(),
385 source_path: fake_path("test"),
386 };
387 assert_eq!(skill.expand("my-file.rs"), "Review my-file.rs");
388 }
389
390 #[test]
391 fn expand_empty_args_leaves_placeholder_in_place() {
392 let skill = SkillCommand {
393 name: "test".into(),
394 description: "test".into(),
395 aliases: vec![],
396 argument_hint: "".into(),
397 allowed_tools: None,
398 prompt_template: "Do the thing with $ARGUMENTS".into(),
399 source_path: fake_path("test"),
400 };
401 assert_eq!(skill.expand(""), "Do the thing with ");
402 }
403
404 #[test]
407 fn aliases_parsed_from_frontmatter() {
408 let content = "---\nname: review\naliases: [rev, r]\n---\nReview $ARGUMENTS\n";
409 let skill = SkillCommandLoader::parse_content(&fake_path("review"), content).unwrap();
410 assert_eq!(skill.aliases, vec!["rev", "r"]);
411 }
412
413 #[test]
414 fn single_alias_without_brackets() {
415 let content = "---\nname: check\naliases: chk\n---\nCheck $ARGUMENTS\n";
416 let skill = SkillCommandLoader::parse_content(&fake_path("check"), content).unwrap();
417 assert_eq!(skill.aliases, vec!["chk"]);
418 }
419
420 #[test]
423 fn name_with_spaces_becomes_hyphenated() {
424 let content = "---\nname: my skill\n---\nDo stuff\n";
425 let skill = SkillCommandLoader::parse_content(&fake_path("my-skill"), content).unwrap();
426 assert_eq!(skill.name, "my-skill");
427 }
428
429 #[test]
432 fn load_dir_returns_empty_for_nonexistent_directory() {
433 let skills = SkillCommandLoader::load_dir(Path::new("/nonexistent/path/xyz"));
434 assert!(skills.is_empty());
435 }
436
437 #[test]
438 fn load_dir_sorts_by_name() {
439 let dir = tempfile::tempdir().unwrap();
440 std::fs::write(dir.path().join("zzz.md"), "Do ZZZ with $ARGUMENTS").unwrap();
441 std::fs::write(dir.path().join("aaa.md"), "Do AAA with $ARGUMENTS").unwrap();
442 std::fs::write(dir.path().join("mmm.md"), "Do MMM with $ARGUMENTS").unwrap();
443 let skills = SkillCommandLoader::load_dir(dir.path());
444 let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
445 let mut sorted = names.clone();
446 sorted.sort();
447 assert_eq!(names, sorted);
448 }
449
450 #[test]
453 fn split_frontmatter_parses_standard_delimiters() {
454 let content = "---\nkey: value\n---\nbody text\n";
455 let (fm, body) = split_frontmatter(content);
456 assert_eq!(fm, Some("key: value"));
457 assert!(body.contains("body text"));
458 }
459
460 #[test]
461 fn split_frontmatter_returns_none_when_no_delimiter() {
462 let content = "just body\nno front matter";
463 let (fm, body) = split_frontmatter(content);
464 assert!(fm.is_none());
465 assert!(body.contains("just body"));
466 }
467}