use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SkillCommand {
pub name: String,
pub description: String,
pub aliases: Vec<String>,
pub argument_hint: String,
pub allowed_tools: Option<Vec<String>>,
pub prompt_template: String,
pub source_path: PathBuf,
}
impl SkillCommand {
pub fn expand(&self, args: &str) -> String {
self.prompt_template
.replace("$ARGUMENTS", args)
.replace("{{args}}", args)
}
}
pub struct SkillCommandLoader;
impl SkillCommandLoader {
pub fn load(workspace: &Path) -> Vec<SkillCommand> {
let mut commands: Vec<SkillCommand> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let project_dir = workspace.join(".recursive").join("skills");
for skill in Self::load_dir(&project_dir) {
if seen.insert(skill.name.clone()) {
commands.push(skill);
}
}
if let Some(home) = dirs::home_dir() {
let user_dir = home.join(".recursive").join("skills");
for skill in Self::load_dir(&user_dir) {
if seen.insert(skill.name.clone()) {
commands.push(skill);
}
}
}
commands
}
pub fn load_dir(dir: &Path) -> Vec<SkillCommand> {
let entries = match std::fs::read_dir(dir) {
Ok(it) => it,
Err(err) => {
if dir.exists() {
tracing::warn!(
target: "recursive::tui::skill_commands",
dir = %dir.display(),
error = %err,
"skill_commands: failed to read directory"
);
}
return Vec::new();
}
};
let mut skills: Vec<SkillCommand> = entries
.flatten()
.filter(|e| {
e.path()
.extension()
.map(|x| x.eq_ignore_ascii_case("md"))
.unwrap_or(false)
})
.filter_map(|e| Self::parse_file(&e.path()))
.collect();
skills.sort_by(|a, b| a.name.cmp(&b.name));
skills
}
pub fn parse_file(path: &Path) -> Option<SkillCommand> {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(err) => {
tracing::warn!(
target: "recursive::tui::skill_commands",
path = %path.display(),
error = %err,
"skill_commands: failed to read .md file; skipping"
);
return None;
}
};
let parsed = Self::parse_content(path, &raw);
if parsed.is_none() {
tracing::warn!(
target: "recursive::tui::skill_commands",
path = %path.display(),
"skill_commands: front-matter / filename produced an empty command name; skipping"
);
}
parsed
}
pub fn parse_content(path: &Path, content: &str) -> Option<SkillCommand> {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let (front, body) = split_frontmatter(content);
let mut name = stem.clone();
let mut description = String::new();
let mut aliases: Vec<String> = Vec::new();
let mut argument_hint = String::new();
let mut allowed_tools: Option<Vec<String>> = None;
if let Some(fm) = front {
for line in fm.lines() {
let line = line.trim();
if let Some((k, v)) = line.split_once(':') {
let k = k.trim();
let v = v.trim();
match k {
"name" => name = v.to_string(),
"description" => description = v.to_string(),
"argument_hint" => argument_hint = v.trim_matches('"').to_string(),
"aliases" => {
aliases = parse_inline_list(v);
}
"allowed_tools" => {
let tools = parse_inline_list(v);
if !tools.is_empty() {
allowed_tools = Some(tools);
}
}
_ => {} }
}
}
}
if description.is_empty() {
description = body
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim()
.trim_start_matches('#')
.trim()
.to_string();
}
name = name
.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect();
let name = name.trim_matches('-').to_string();
if name.is_empty() {
return None;
}
Some(SkillCommand {
name,
description,
aliases,
argument_hint,
allowed_tools,
prompt_template: body.trim().to_string(),
source_path: path.to_path_buf(),
})
}
}
fn split_frontmatter(content: &str) -> (Option<&str>, &str) {
let content = content.trim_start();
if !content.starts_with("---") {
return (None, content);
}
let rest = &content["---".len()..];
if let Some(pos) = rest.find("\n---") {
let fm = &rest[..pos];
let body = &rest[pos + "\n---".len()..];
(Some(fm.trim()), body)
} else {
(None, content)
}
}
fn parse_inline_list(s: &str) -> Vec<String> {
let s = s.trim();
if s.starts_with('[') && s.ends_with(']') {
let inner = &s[1..s.len() - 1];
inner
.split(',')
.map(|t| t.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|t| !t.is_empty())
.collect()
} else if s.is_empty() {
Vec::new()
} else {
vec![s.trim_matches('"').trim_matches('\'').to_string()]
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn fake_path(name: &str) -> PathBuf {
PathBuf::from(format!("/fake/{name}.md"))
}
#[test]
fn parse_skill_with_full_frontmatter() {
let content = r#"---
name: refactor
description: Refactor code for clarity
aliases: [rf, refac]
argument_hint: "<file>"
allowed_tools: [read_file, apply_patch]
---
Refactor this:
$ARGUMENTS
"#;
let skill = SkillCommandLoader::parse_content(&fake_path("refactor"), content).unwrap();
assert_eq!(skill.name, "refactor");
assert_eq!(skill.description, "Refactor code for clarity");
assert_eq!(skill.aliases, vec!["rf", "refac"]);
assert_eq!(skill.argument_hint, "<file>");
assert_eq!(
skill.allowed_tools.as_deref().unwrap(),
&["read_file", "apply_patch"]
);
assert!(skill.prompt_template.contains("$ARGUMENTS"));
}
#[test]
fn parse_skill_without_frontmatter_uses_filename_stem() {
let content = "Explain the code at $ARGUMENTS\n";
let skill = SkillCommandLoader::parse_content(&fake_path("explain"), content).unwrap();
assert_eq!(skill.name, "explain");
assert!(skill.description.contains("Explain"));
assert!(skill.prompt_template.contains("$ARGUMENTS"));
}
#[test]
fn parse_skill_description_fallback_to_first_body_line() {
let content = "---\nname: hello\n---\n\nFirst line description\n\nMore content\n";
let skill = SkillCommandLoader::parse_content(&fake_path("hello"), content).unwrap();
assert_eq!(skill.description, "First line description");
}
#[test]
fn expand_substitutes_dollar_arguments() {
let skill = SkillCommand {
name: "test".into(),
description: "test".into(),
aliases: vec![],
argument_hint: "".into(),
allowed_tools: None,
prompt_template: "Fix $ARGUMENTS for me".into(),
source_path: fake_path("test"),
};
assert_eq!(skill.expand("src/lib.rs"), "Fix src/lib.rs for me");
}
#[test]
fn expand_substitutes_mustache_args() {
let skill = SkillCommand {
name: "test".into(),
description: "test".into(),
aliases: vec![],
argument_hint: "".into(),
allowed_tools: None,
prompt_template: "Review {{args}}".into(),
source_path: fake_path("test"),
};
assert_eq!(skill.expand("my-file.rs"), "Review my-file.rs");
}
#[test]
fn expand_empty_args_leaves_placeholder_in_place() {
let skill = SkillCommand {
name: "test".into(),
description: "test".into(),
aliases: vec![],
argument_hint: "".into(),
allowed_tools: None,
prompt_template: "Do the thing with $ARGUMENTS".into(),
source_path: fake_path("test"),
};
assert_eq!(skill.expand(""), "Do the thing with ");
}
#[test]
fn aliases_parsed_from_frontmatter() {
let content = "---\nname: review\naliases: [rev, r]\n---\nReview $ARGUMENTS\n";
let skill = SkillCommandLoader::parse_content(&fake_path("review"), content).unwrap();
assert_eq!(skill.aliases, vec!["rev", "r"]);
}
#[test]
fn single_alias_without_brackets() {
let content = "---\nname: check\naliases: chk\n---\nCheck $ARGUMENTS\n";
let skill = SkillCommandLoader::parse_content(&fake_path("check"), content).unwrap();
assert_eq!(skill.aliases, vec!["chk"]);
}
#[test]
fn name_with_spaces_becomes_hyphenated() {
let content = "---\nname: my skill\n---\nDo stuff\n";
let skill = SkillCommandLoader::parse_content(&fake_path("my-skill"), content).unwrap();
assert_eq!(skill.name, "my-skill");
}
#[test]
fn load_dir_returns_empty_for_nonexistent_directory() {
let skills = SkillCommandLoader::load_dir(Path::new("/nonexistent/path/xyz"));
assert!(skills.is_empty());
}
#[test]
fn load_dir_sorts_by_name() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("zzz.md"), "Do ZZZ with $ARGUMENTS").unwrap();
std::fs::write(dir.path().join("aaa.md"), "Do AAA with $ARGUMENTS").unwrap();
std::fs::write(dir.path().join("mmm.md"), "Do MMM with $ARGUMENTS").unwrap();
let skills = SkillCommandLoader::load_dir(dir.path());
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
}
#[test]
fn split_frontmatter_parses_standard_delimiters() {
let content = "---\nkey: value\n---\nbody text\n";
let (fm, body) = split_frontmatter(content);
assert_eq!(fm, Some("key: value"));
assert!(body.contains("body text"));
}
#[test]
fn split_frontmatter_returns_none_when_no_delimiter() {
let content = "just body\nno front matter";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_none());
assert!(body.contains("just body"));
}
}