1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6use std::sync::OnceLock;
7
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use tokio::process::Command;
11
12use crate::Result;
13
14fn backtick_regex() -> &'static Regex {
15 static RE: OnceLock<Regex> = OnceLock::new();
16 RE.get_or_init(|| Regex::new(r"!\`([^`]+)\`").expect("valid backtick regex"))
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SlashCommand {
21 pub name: String,
22 pub description: Option<String>,
23 pub content: String,
24 pub location: PathBuf,
25 #[serde(default)]
26 pub allowed_tools: Vec<String>,
27 #[serde(default)]
28 pub argument_hint: Option<String>,
29 #[serde(default)]
30 pub model: Option<String>,
31}
32
33impl SlashCommand {
34 pub fn execute(&self, arguments: &str) -> String {
35 let mut result = self.content.clone();
36 let args: Vec<&str> = arguments.split_whitespace().collect();
37
38 for (i, arg) in args.iter().take(9).enumerate() {
39 result = result.replace(&format!("${}", i + 1), arg);
40 }
41
42 result.replace("$ARGUMENTS", arguments)
43 }
44
45 pub async fn execute_full(&self, arguments: &str, base_dir: &Path) -> String {
46 let mut result = self.content.clone();
47
48 result = Self::process_bash_backticks(&result, base_dir).await;
49 result = Self::process_file_references(&result, base_dir).await;
50
51 let args: Vec<&str> = arguments.split_whitespace().collect();
52 for (i, arg) in args.iter().take(9).enumerate() {
53 result = result.replace(&format!("${}", i + 1), arg);
54 }
55
56 result.replace("$ARGUMENTS", arguments)
57 }
58
59 async fn process_bash_backticks(content: &str, working_dir: &Path) -> String {
60 let backtick_re = backtick_regex();
61 let mut result = content.to_string();
62 let mut replacements = Vec::new();
63
64 for cap in backtick_re.captures_iter(content) {
65 let full_match = cap.get(0).expect("capture group 0 always exists").as_str();
66 let cmd = &cap[1];
67
68 let output = match Command::new("sh")
69 .arg("-c")
70 .arg(cmd)
71 .current_dir(working_dir)
72 .stdout(Stdio::piped())
73 .stderr(Stdio::piped())
74 .output()
75 .await
76 {
77 Ok(output) => {
78 let stdout = String::from_utf8_lossy(&output.stdout);
79 let stderr = String::from_utf8_lossy(&output.stderr);
80 if output.status.success() {
81 stdout.trim().to_string()
82 } else {
83 format!("[Error: {}]\n{}", stderr.trim(), stdout.trim())
84 }
85 }
86 Err(e) => format!("[Failed to execute: {}]", e),
87 };
88
89 replacements.push((full_match.to_string(), output));
90 }
91
92 for (pattern, replacement) in replacements {
93 result = result.replace(&pattern, &replacement);
94 }
95
96 result
97 }
98
99 async fn process_file_references(content: &str, base_dir: &Path) -> String {
100 let mut result = String::new();
101
102 for line in content.lines() {
103 let trimmed = line.trim();
104
105 if trimmed.starts_with('@') && !trimmed.starts_with("@@") {
106 let path_str = trimmed.trim_start_matches('@').trim();
107 if !path_str.is_empty() {
108 let full_path = if path_str.starts_with("~/") {
109 if let Some(home) = crate::common::home_dir() {
110 home.join(path_str.strip_prefix("~/").unwrap_or(path_str))
111 } else {
112 base_dir.join(path_str)
113 }
114 } else if path_str.starts_with('/') {
115 PathBuf::from(path_str)
116 } else {
117 base_dir.join(path_str)
118 };
119
120 if let Ok(file_content) = tokio::fs::read_to_string(&full_path).await {
121 result.push_str(&file_content);
122 result.push('\n');
123 continue;
124 }
125 }
126 }
127
128 result.push_str(line);
129 result.push('\n');
130 }
131
132 result
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137#[serde(rename_all = "kebab-case")]
138struct CommandFrontmatter {
139 #[serde(default)]
140 allowed_tools: Vec<String>,
141 #[serde(default)]
142 argument_hint: Option<String>,
143 #[serde(default)]
144 description: Option<String>,
145 #[serde(default)]
146 model: Option<String>,
147}
148
149#[derive(Debug, Default)]
150pub struct CommandLoader {
151 commands: HashMap<String, SlashCommand>,
152}
153
154impl CommandLoader {
155 pub fn new() -> Self {
156 Self::default()
157 }
158
159 pub async fn load(&mut self, project_dir: &Path) -> Result<()> {
162 if let Some(enterprise_path) = crate::context::enterprise_base_path() {
164 self.load_from(&enterprise_path).await?;
165 }
166
167 if let Some(home) = crate::common::home_dir() {
169 self.load_from(&home.join(".claude")).await?;
170 }
171
172 self.load_from(project_dir).await?;
174
175 Ok(())
176 }
177
178 pub async fn load_from(&mut self, base_dir: &Path) -> Result<()> {
181 let commands_dir = base_dir.join(".claude").join("commands");
182 if commands_dir.exists() {
183 self.load_directory(&commands_dir, "").await?;
184 }
185
186 let direct_commands = base_dir.join("commands");
188 if direct_commands.exists() {
189 self.load_directory(&direct_commands, "").await?;
190 }
191
192 Ok(())
193 }
194
195 fn load_directory<'a>(
196 &'a mut self,
197 dir: &'a Path,
198 namespace: &'a str,
199 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
200 Box::pin(async move {
201 let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
202 crate::Error::Config(format!("Failed to read commands directory: {}", e))
203 })?;
204
205 while let Some(entry) = entries.next_entry().await.map_err(|e| {
206 crate::Error::Config(format!("Failed to read directory entry: {}", e))
207 })? {
208 let path = entry.path();
209
210 if path.is_dir() {
211 let dir_name = path
212 .file_name()
213 .and_then(|n| n.to_str())
214 .unwrap_or_default();
215 let new_namespace = if namespace.is_empty() {
216 dir_name.to_string()
217 } else {
218 format!("{}:{}", namespace, dir_name)
219 };
220 self.load_directory(&path, &new_namespace).await?;
221 } else if path.extension().map(|e| e == "md").unwrap_or(false)
222 && let Ok(cmd) = self.load_file(&path, namespace).await
223 {
224 self.commands.insert(cmd.name.clone(), cmd);
225 }
226 }
227
228 Ok(())
229 })
230 }
231
232 async fn load_file(&self, path: &Path, namespace: &str) -> Result<SlashCommand> {
233 let content = tokio::fs::read_to_string(path)
234 .await
235 .map_err(|e| crate::Error::Config(format!("Failed to read command file: {}", e)))?;
236
237 let file_name = path
238 .file_stem()
239 .and_then(|n| n.to_str())
240 .unwrap_or("unknown");
241
242 let name = if namespace.is_empty() {
243 file_name.to_string()
244 } else {
245 format!("{}:{}", namespace, file_name)
246 };
247
248 let (frontmatter, body) = self.parse_frontmatter(&content)?;
249
250 Ok(SlashCommand {
251 name,
252 description: frontmatter.description,
253 content: body,
254 location: path.to_path_buf(),
255 allowed_tools: frontmatter.allowed_tools,
256 argument_hint: frontmatter.argument_hint,
257 model: frontmatter.model,
258 })
259 }
260
261 fn parse_frontmatter(&self, content: &str) -> Result<(CommandFrontmatter, String)> {
262 if let Some(after_first) = content.strip_prefix("---")
263 && let Some(end_pos) = after_first.find("---")
264 {
265 let frontmatter_str = after_first[..end_pos].trim();
266 let body = after_first[end_pos + 3..].trim().to_string();
267
268 let frontmatter: CommandFrontmatter = serde_yaml_ng::from_str(frontmatter_str)
269 .map_err(|e| crate::Error::Config(format!("Invalid command frontmatter: {}", e)))?;
270
271 return Ok((frontmatter, body));
272 }
273
274 Ok((CommandFrontmatter::default(), content.to_string()))
275 }
276
277 pub fn get(&self, name: &str) -> Option<&SlashCommand> {
278 self.commands.get(name)
279 }
280
281 pub fn list(&self) -> Vec<&SlashCommand> {
282 self.commands.values().collect()
283 }
284
285 pub fn exists(&self, name: &str) -> bool {
286 self.commands.contains_key(name)
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn test_argument_substitution() {
296 let cmd = SlashCommand {
297 name: "test".to_string(),
298 description: Some("Test command".to_string()),
299 content: "Fix the issue: $ARGUMENTS".to_string(),
300 location: PathBuf::from("/test"),
301 allowed_tools: vec![],
302 argument_hint: None,
303 model: None,
304 };
305
306 let result = cmd.execute("bug in login");
307 assert_eq!(result, "Fix the issue: bug in login");
308 }
309
310 #[test]
311 fn test_multiple_argument_substitution() {
312 let cmd = SlashCommand {
313 name: "test".to_string(),
314 description: None,
315 content: "First: $ARGUMENTS\nSecond: $ARGUMENTS".to_string(),
316 location: PathBuf::from("/test"),
317 allowed_tools: vec![],
318 argument_hint: None,
319 model: None,
320 };
321
322 let result = cmd.execute("value");
323 assert!(result.contains("First: value"));
324 assert!(result.contains("Second: value"));
325 }
326
327 #[test]
328 fn test_positional_arguments() {
329 let cmd = SlashCommand {
330 name: "assign".to_string(),
331 description: Some("Assign issue".to_string()),
332 content: "Issue: $1, Priority: $2, Assignee: $3".to_string(),
333 location: PathBuf::from("/test"),
334 allowed_tools: vec![],
335 argument_hint: Some("[issue] [priority] [assignee]".to_string()),
336 model: None,
337 };
338
339 let result = cmd.execute("123 high alice");
340 assert_eq!(result, "Issue: 123, Priority: high, Assignee: alice");
341 }
342
343 #[test]
344 fn test_mixed_arguments() {
345 let cmd = SlashCommand {
346 name: "review".to_string(),
347 description: None,
348 content: "PR #$1 with args: $ARGUMENTS".to_string(),
349 location: PathBuf::from("/test"),
350 allowed_tools: vec![],
351 argument_hint: None,
352 model: None,
353 };
354
355 let result = cmd.execute("456 high priority");
356 assert_eq!(result, "PR #456 with args: 456 high priority");
357 }
358
359 #[tokio::test]
360 async fn test_file_references() {
361 use tempfile::tempdir;
362 use tokio::fs;
363
364 let dir = tempdir().unwrap();
365 fs::write(dir.path().join("config.txt"), "test-config")
366 .await
367 .unwrap();
368
369 let cmd = SlashCommand {
370 name: "test".to_string(),
371 description: None,
372 content: "Config:\n@config.txt\nEnd".to_string(),
373 location: PathBuf::from("/test"),
374 allowed_tools: vec![],
375 argument_hint: None,
376 model: None,
377 };
378
379 let result = cmd.execute_full("", dir.path()).await;
380 assert!(result.contains("test-config"));
381 assert!(result.contains("End"));
382 }
383
384 #[tokio::test]
385 async fn test_bash_backticks() {
386 use tempfile::tempdir;
387
388 let dir = tempdir().unwrap();
389
390 let cmd = SlashCommand {
391 name: "status".to_string(),
392 description: None,
393 content: "Echo: !`echo hello`\nPwd: !`pwd`".to_string(),
394 location: PathBuf::from("/test"),
395 allowed_tools: vec![],
396 argument_hint: None,
397 model: None,
398 };
399
400 let result = cmd.execute_full("", dir.path()).await;
401 assert!(result.contains("Echo: hello"));
402 assert!(result.contains(&dir.path().to_string_lossy().to_string()));
403 }
404
405 #[tokio::test]
406 async fn test_bash_backtick_error() {
407 use tempfile::tempdir;
408
409 let dir = tempdir().unwrap();
410
411 let cmd = SlashCommand {
412 name: "fail".to_string(),
413 description: None,
414 content: "Result: !`exit 1`".to_string(),
415 location: PathBuf::from("/test"),
416 allowed_tools: vec![],
417 argument_hint: None,
418 model: None,
419 };
420
421 let result = cmd.execute_full("", dir.path()).await;
422 assert!(result.contains("[Error:") || result.contains("Result:"));
423 }
424}