1use enact_config::enact_home;
7use enact_plugins::load_plugins;
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CommandDef {
14 pub name: String,
16 #[serde(default)]
17 pub description: Option<String>,
18 pub content: String,
20 #[serde(default)]
22 pub allowed_tools: Vec<String>,
23 #[serde(default)]
25 pub model: Option<String>,
26}
27
28pub 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 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
49pub 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
61fn 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
88fn 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
145pub fn dispatch(input: &str, commands: &[CommandDef]) -> Option<String> {
147 let (raw_name, args) = parse_slash_invocation(input)?;
148 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
157pub 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
185fn 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}