Skip to main content

enact_runner/
commands.rs

1//! Slash commands — user-invokable commands from ~/.enact/commands/, .enact/commands/, and plugins.
2//!
3//! Command files are Markdown with optional YAML frontmatter. Invoked as `/command-name` or
4//! `/plugin:command-name`. Supports $ARGUMENTS, $1, $2, ... substitution.
5
6use enact_config::enact_home;
7use enact_plugins::load_plugins;
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11/// Single command definition (from a Markdown file + frontmatter).
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CommandDef {
14    /// Command name (from filename or frontmatter); may be namespaced e.g. plugin:cmd.
15    pub name: String,
16    #[serde(default)]
17    pub description: Option<String>,
18    /// Body content (Markdown) after frontmatter; may contain $ARGUMENTS, $1, $2.
19    pub content: String,
20    /// Allowed tools for this command (optional restriction).
21    #[serde(default)]
22    pub allowed_tools: Vec<String>,
23    /// Model override (optional).
24    #[serde(default)]
25    pub model: Option<String>,
26}
27
28/// Loads commands from enact_home/commands/ and optionally project .enact/commands/.
29/// Project commands take precedence (same name = project wins).
30pub fn load_commands(project_dir: Option<&Path>) -> Vec<CommandDef> {
31    let home = enact_home();
32    let global_dir = home.join("commands");
33    let mut commands = load_commands_from_dir(&global_dir, None);
34    if let Some(proj) = project_dir {
35        let project_commands_dir = proj.join(".enact").join("commands");
36        let project_commands = load_commands_from_dir(&project_commands_dir, None);
37        // Merge: project commands override global by name
38        for pc in project_commands {
39            if let Some(pos) = commands.iter().position(|c| c.name == pc.name) {
40                commands[pos] = pc;
41            } else {
42                commands.push(pc);
43            }
44        }
45    }
46    commands
47}
48
49/// Load commands for the run path: global + project + plugin namespaced commands.
50pub fn load_commands_for_run(project_dir: Option<&Path>) -> Vec<CommandDef> {
51    let mut commands = load_commands(project_dir);
52    for plugin in load_plugins(project_dir) {
53        let dir = plugin.commands_dir();
54        let prefix = format!("{}:", plugin.manifest.name);
55        let plugin_cmds = load_commands_from_dir(dir.as_path(), Some(prefix.as_str()));
56        commands.extend(plugin_cmds);
57    }
58    commands
59}
60
61/// Load command definitions from a directory (each .md file = one command).
62/// If name_prefix is Some(e.g. "plugin:"), each command name becomes prefix + stem.
63fn load_commands_from_dir(dir: &Path, name_prefix: Option<&str>) -> Vec<CommandDef> {
64    let mut out = Vec::new();
65    if !dir.is_dir() {
66        return out;
67    }
68    let entries = match std::fs::read_dir(dir) {
69        Ok(e) => e,
70        Err(_) => return out,
71    };
72    for entry in entries.flatten() {
73        let path = entry.path();
74        if path.extension().is_some_and(|e| e == "md") {
75            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
76                if let Ok(mut def) = parse_command_file(&path, name) {
77                    if let Some(prefix) = name_prefix {
78                        def.name = format!("{}{}", prefix, def.name);
79                    }
80                    out.push(def);
81                }
82            }
83        }
84    }
85    out
86}
87
88/// Parse a single command file: optional YAML frontmatter between ---, then content.
89fn parse_command_file(path: &Path, default_name: &str) -> anyhow::Result<CommandDef> {
90    let s = std::fs::read_to_string(path)?;
91    let (frontmatter, content) = split_frontmatter(&s);
92    let name = default_name.to_string();
93    let mut description = None;
94    let mut allowed_tools = Vec::new();
95    let mut model = None;
96    if let Some(fm) = frontmatter {
97        if let Ok(parsed) = serde_yaml::from_str::<serde_yaml::Value>(&fm) {
98            if let Some(desc) = parsed.get("description").and_then(|v| v.as_str()) {
99                description = Some(desc.to_string());
100            }
101            if let Some(tools) = parsed.get("allowed_tools").and_then(|v| v.as_sequence()) {
102                allowed_tools = tools
103                    .iter()
104                    .filter_map(|v| v.as_str().map(String::from))
105                    .collect();
106            }
107            if let Some(m) = parsed.get("model").and_then(|v| v.as_str()) {
108                model = Some(m.to_string());
109            }
110        }
111    }
112    Ok(CommandDef {
113        name,
114        description,
115        content: content.trim().to_string(),
116        allowed_tools,
117        model,
118    })
119}
120
121fn split_frontmatter(s: &str) -> (Option<String>, &str) {
122    let s = s.trim_start();
123    if !s.starts_with("---") {
124        return (None, s);
125    }
126    let rest = &s[3..];
127    let end = rest.find("\n---").unwrap_or(0);
128    let (fm, content) = if end > 0 {
129        let fm = rest[..end].trim();
130        let content_start = rest[end + 4..].trim_start();
131        (fm, content_start)
132    } else {
133        ("", rest)
134    };
135    (
136        if fm.is_empty() {
137            None
138        } else {
139            Some(fm.to_string())
140        },
141        content,
142    )
143}
144
145/// Dispatch: if input starts with /name or /project:name, expand command and return new content; else None.
146pub fn dispatch(input: &str, commands: &[CommandDef]) -> Option<String> {
147    let (raw_name, args) = parse_slash_invocation(input)?;
148    // Support /scope:command-name (e.g. plugin:skill); lookup by command name (part after last colon).
149    let lookup_name = raw_name.rsplit(':').next().unwrap_or(&raw_name).to_string();
150    let cmd = commands
151        .iter()
152        .find(|c| c.name == lookup_name || c.name == raw_name)?;
153    let expanded = substitute_args(&cmd.content, &args);
154    Some(expanded)
155}
156
157/// Parse `/name args...` input into `(name, args)`.
158pub fn parse_slash_invocation(input: &str) -> Option<(String, String)> {
159    let input = input.trim();
160    if !input.starts_with('/') {
161        return None;
162    }
163    let rest = input[1..].trim_start();
164    let (raw_name, args) = split_first_word(rest);
165    if raw_name.is_empty() {
166        return None;
167    }
168    Some((raw_name, args.to_string()))
169}
170
171fn split_first_word(s: &str) -> (String, &str) {
172    let s = s.trim_start();
173    if s.is_empty() {
174        return (String::new(), s);
175    }
176    let first = s
177        .split_whitespace()
178        .next()
179        .map(String::from)
180        .unwrap_or_default();
181    let args = s.get(first.len()..).unwrap_or("").trim_start();
182    (first, args)
183}
184
185/// Replace $ARGUMENTS and $1, $2, ... in content.
186fn substitute_args(content: &str, arguments: &str) -> String {
187    let parts: Vec<&str> = arguments.split_whitespace().collect();
188    let mut out = content.to_string();
189    out = out.replace("$ARGUMENTS", arguments);
190    for (i, part) in parts.iter().enumerate() {
191        let placeholder = format!("${}", i + 1);
192        out = out.replace(&placeholder, part);
193    }
194    out
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_substitute_args() {
203        let s = substitute_args("Run $1 with $2 and rest: $ARGUMENTS", "foo bar baz");
204        assert!(s.contains("foo"));
205        assert!(s.contains("bar"));
206        assert!(s.contains("baz"));
207        assert!(s.contains("foo bar baz"));
208    }
209
210    #[test]
211    fn split_frontmatter_empty() {
212        let (fm, content) = split_frontmatter("hello");
213        assert!(fm.is_none());
214        assert_eq!(content, "hello");
215    }
216
217    #[test]
218    fn split_frontmatter_with_fm() {
219        let (fm, _) = split_frontmatter("---\ndescription: x\n---\nbody");
220        assert!(fm.is_some());
221        assert!(fm.unwrap().contains("description"));
222    }
223
224    #[test]
225    fn parse_slash_invocation_parses_name_and_args() {
226        let parsed = parse_slash_invocation("/my-plugin:skill do this now").unwrap();
227        assert_eq!(parsed.0, "my-plugin:skill");
228        assert_eq!(parsed.1, "do this now");
229    }
230
231    #[test]
232    fn parse_slash_invocation_returns_none_for_non_slash() {
233        assert!(parse_slash_invocation("hello").is_none());
234    }
235}