Skip to main content

recursive/tui/
skill_commands.rs

1//! Skill-backed slash commands for the TUI (Goal 169).
2//!
3//! A *skill* is a Markdown file with optional YAML front-matter that
4//! describes a reusable prompt template.  Every `*.md` file found in the
5//! standard skill directories is automatically registered as a `/name`
6//! slash command.
7//!
8//! ## Search paths (priority order — first name wins on collision)
9//!
10//! 1. `<workspace>/.recursive/skills/` — project-level (committed to repo)
11//! 2. `~/.recursive/skills/` — user-level (global)
12//! 3. Built-in commands from `CommandRegistry::default_set()` (never shadowed
13//!    by skills)
14//!
15//! ## Skill file format
16//!
17//! ```markdown
18//! ---
19//! name: refactor          # defaults to filename stem
20//! description: Refactor the selected code for clarity
21//! aliases: [rf]
22//! argument_hint: "<file-or-description>"
23//! allowed_tools: [read_file, apply_patch, run_shell]
24//! ---
25//!
26//! Refactor the following with these goals:
27//! - Single responsibility
28//!
29//! $ARGUMENTS
30//! ```
31//!
32//! `$ARGUMENTS` (or `{{args}}`) is replaced with whatever the user types
33//! after the command name.
34
35use std::path::{Path, PathBuf};
36
37// ──────────────────────────────────────────────────────────────────────────
38// SkillCommand
39// ──────────────────────────────────────────────────────────────────────────
40
41/// A skill-backed slash command parsed from a `.md` file.
42#[derive(Debug, Clone)]
43pub struct SkillCommand {
44    /// Command name (no leading `/`).
45    pub name: String,
46    /// Short description shown in `/help` and the completion popup.
47    pub description: String,
48    /// Alternative invocation names.
49    pub aliases: Vec<String>,
50    /// Argument hint shown after the command name in usage.
51    pub argument_hint: String,
52    /// Optional explicit tool allow-list (enforcement deferred to v2).
53    pub allowed_tools: Option<Vec<String>>,
54    /// The prompt template body (with `$ARGUMENTS` / `{{args}}` intact).
55    pub prompt_template: String,
56    /// Filesystem path the skill was loaded from.
57    pub source_path: PathBuf,
58}
59
60impl SkillCommand {
61    /// Expand the prompt template, substituting `args` for `$ARGUMENTS` /
62    /// `{{args}}`.
63    pub fn expand(&self, args: &str) -> String {
64        self.prompt_template
65            .replace("$ARGUMENTS", args)
66            .replace("{{args}}", args)
67    }
68}
69
70// ──────────────────────────────────────────────────────────────────────────
71// SkillCommandLoader
72// ──────────────────────────────────────────────────────────────────────────
73
74/// Loads [`SkillCommand`]s from the standard search paths.
75pub struct SkillCommandLoader;
76
77impl SkillCommandLoader {
78    /// Load all skill files from the standard search paths.
79    ///
80    /// Project-level skills (`.recursive/skills/`) take priority over
81    /// user-level skills (`~/.recursive/skills/`).  Name collisions are
82    /// resolved by first-seen wins (project > user).
83    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        // 1. Project-level.
88        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        // 2. User-level.
96        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    /// Load all `*.md` skill files from a single directory.
109    ///
110    /// Files that fail to read or parse are skipped with a `tracing::warn!`
111    /// so users debugging "why isn't my skill showing up?" can find the
112    /// reason in the log instead of having to bisect the directory.
113    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                    // Permissions or some other real failure — surface it.
119                    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    /// Parse a single skill file. Returns `None` on IO / parse errors and
146    /// emits a `tracing::warn!` describing why the file was skipped.
147    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    /// Parse skill content (separated from IO for easy unit-testing).
172    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        // Split front-matter from body.
180        let (front, body) = split_frontmatter(content);
181
182        // Defaults from filename stem.
183        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        // Parse YAML front-matter if present.
190        if let Some(fm) = front {
191            // We parse a minimal subset of YAML manually to avoid pulling in
192            // a YAML dependency (project policy: no new deps without
193            // justification).  The format is simple enough for line-by-line
194            // parsing: `key: value` with optional list values on one line.
195            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                            // Parse `[a, b, c]` inline list.
206                            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                        _ => {} // ignore unknown fields
215                    }
216                }
217            }
218        }
219
220        // Fall back to first non-blank line of body for description.
221        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        // Sanitize name: lowercase, replace spaces/underscores with hyphens.
233        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        // Strip leading/trailing hyphens.
245        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
262// ──────────────────────────────────────────────────────────────────────────
263// Helpers
264// ──────────────────────────────────────────────────────────────────────────
265
266/// Split `---\n<front-matter>\n---\n<body>` into `(Some(front), body)`.
267/// Returns `(None, full_content)` when no front-matter delimiter is found.
268fn split_frontmatter(content: &str) -> (Option<&str>, &str) {
269    let content = content.trim_start();
270    if !content.starts_with("---") {
271        return (None, content);
272    }
273    // Skip the opening `---`.
274    let rest = &content["---".len()..];
275    // Find the closing `---`.
276    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
285/// Parse an inline YAML list value like `[a, b, c]` or `a` into a `Vec<String>`.
286fn 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// ──────────────────────────────────────────────────────────────────────────
303// Tests
304// ──────────────────────────────────────────────────────────────────────────
305
306#[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    // ── parse_content: happy path ─────────────────────────────────────────
316
317    #[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        // Description falls back to first non-blank body line.
349        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    // ── expand ────────────────────────────────────────────────────────────
361
362    #[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    // ── alias resolution ──────────────────────────────────────────────────
405
406    #[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    // ── name sanitization ─────────────────────────────────────────────────
421
422    #[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    // ── load_dir ──────────────────────────────────────────────────────────
430
431    #[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    // ── split_frontmatter ─────────────────────────────────────────────────
451
452    #[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}