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_all(&mut self, project_dir: &Path) -> Result<()> {
160 let project_commands = project_dir.join(".claude").join("commands");
161 if project_commands.exists() {
162 self.load_directory(&project_commands, "").await?;
163 }
164
165 if let Some(home) = crate::common::home_dir() {
166 let user_commands = home.join(".claude").join("commands");
167 if user_commands.exists() {
168 self.load_directory(&user_commands, "").await?;
169 }
170 }
171
172 Ok(())
173 }
174
175 fn load_directory<'a>(
176 &'a mut self,
177 dir: &'a Path,
178 namespace: &'a str,
179 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
180 Box::pin(async move {
181 let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
182 crate::Error::Config(format!("Failed to read commands directory: {}", e))
183 })?;
184
185 while let Some(entry) = entries.next_entry().await.map_err(|e| {
186 crate::Error::Config(format!("Failed to read directory entry: {}", e))
187 })? {
188 let path = entry.path();
189
190 if path.is_dir() {
191 let dir_name = path
192 .file_name()
193 .and_then(|n| n.to_str())
194 .unwrap_or_default();
195 let new_namespace = if namespace.is_empty() {
196 dir_name.to_string()
197 } else {
198 format!("{}:{}", namespace, dir_name)
199 };
200 self.load_directory(&path, &new_namespace).await?;
201 } else if path.extension().map(|e| e == "md").unwrap_or(false)
202 && let Ok(cmd) = self.load_file(&path, namespace).await
203 {
204 self.commands.insert(cmd.name.clone(), cmd);
205 }
206 }
207
208 Ok(())
209 })
210 }
211
212 async fn load_file(&self, path: &Path, namespace: &str) -> Result<SlashCommand> {
213 let content = tokio::fs::read_to_string(path)
214 .await
215 .map_err(|e| crate::Error::Config(format!("Failed to read command file: {}", e)))?;
216
217 let file_name = path
218 .file_stem()
219 .and_then(|n| n.to_str())
220 .unwrap_or("unknown");
221
222 let name = if namespace.is_empty() {
223 file_name.to_string()
224 } else {
225 format!("{}:{}", namespace, file_name)
226 };
227
228 let (frontmatter, body) = self.parse_frontmatter(&content)?;
229
230 Ok(SlashCommand {
231 name,
232 description: frontmatter.description,
233 content: body,
234 location: path.to_path_buf(),
235 allowed_tools: frontmatter.allowed_tools,
236 argument_hint: frontmatter.argument_hint,
237 model: frontmatter.model,
238 })
239 }
240
241 fn parse_frontmatter(&self, content: &str) -> Result<(CommandFrontmatter, String)> {
242 if let Some(after_first) = content.strip_prefix("---")
243 && let Some(end_pos) = after_first.find("---")
244 {
245 let frontmatter_str = after_first[..end_pos].trim();
246 let body = after_first[end_pos + 3..].trim().to_string();
247
248 let frontmatter: CommandFrontmatter = serde_yaml_ng::from_str(frontmatter_str)
249 .map_err(|e| crate::Error::Config(format!("Invalid command frontmatter: {}", e)))?;
250
251 return Ok((frontmatter, body));
252 }
253
254 Ok((CommandFrontmatter::default(), content.to_string()))
255 }
256
257 pub fn get(&self, name: &str) -> Option<&SlashCommand> {
258 self.commands.get(name)
259 }
260
261 pub fn list(&self) -> Vec<&SlashCommand> {
262 self.commands.values().collect()
263 }
264
265 pub fn exists(&self, name: &str) -> bool {
266 self.commands.contains_key(name)
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn test_argument_substitution() {
276 let cmd = SlashCommand {
277 name: "test".to_string(),
278 description: Some("Test command".to_string()),
279 content: "Fix the issue: $ARGUMENTS".to_string(),
280 location: PathBuf::from("/test"),
281 allowed_tools: vec![],
282 argument_hint: None,
283 model: None,
284 };
285
286 let result = cmd.execute("bug in login");
287 assert_eq!(result, "Fix the issue: bug in login");
288 }
289
290 #[test]
291 fn test_multiple_argument_substitution() {
292 let cmd = SlashCommand {
293 name: "test".to_string(),
294 description: None,
295 content: "First: $ARGUMENTS\nSecond: $ARGUMENTS".to_string(),
296 location: PathBuf::from("/test"),
297 allowed_tools: vec![],
298 argument_hint: None,
299 model: None,
300 };
301
302 let result = cmd.execute("value");
303 assert!(result.contains("First: value"));
304 assert!(result.contains("Second: value"));
305 }
306
307 #[test]
308 fn test_positional_arguments() {
309 let cmd = SlashCommand {
310 name: "assign".to_string(),
311 description: Some("Assign issue".to_string()),
312 content: "Issue: $1, Priority: $2, Assignee: $3".to_string(),
313 location: PathBuf::from("/test"),
314 allowed_tools: vec![],
315 argument_hint: Some("[issue] [priority] [assignee]".to_string()),
316 model: None,
317 };
318
319 let result = cmd.execute("123 high alice");
320 assert_eq!(result, "Issue: 123, Priority: high, Assignee: alice");
321 }
322
323 #[test]
324 fn test_mixed_arguments() {
325 let cmd = SlashCommand {
326 name: "review".to_string(),
327 description: None,
328 content: "PR #$1 with args: $ARGUMENTS".to_string(),
329 location: PathBuf::from("/test"),
330 allowed_tools: vec![],
331 argument_hint: None,
332 model: None,
333 };
334
335 let result = cmd.execute("456 high priority");
336 assert_eq!(result, "PR #456 with args: 456 high priority");
337 }
338
339 #[tokio::test]
340 async fn test_file_references() {
341 use tempfile::tempdir;
342 use tokio::fs;
343
344 let dir = tempdir().unwrap();
345 fs::write(dir.path().join("config.txt"), "test-config")
346 .await
347 .unwrap();
348
349 let cmd = SlashCommand {
350 name: "test".to_string(),
351 description: None,
352 content: "Config:\n@config.txt\nEnd".to_string(),
353 location: PathBuf::from("/test"),
354 allowed_tools: vec![],
355 argument_hint: None,
356 model: None,
357 };
358
359 let result = cmd.execute_full("", dir.path()).await;
360 assert!(result.contains("test-config"));
361 assert!(result.contains("End"));
362 }
363
364 #[tokio::test]
365 async fn test_bash_backticks() {
366 use tempfile::tempdir;
367
368 let dir = tempdir().unwrap();
369
370 let cmd = SlashCommand {
371 name: "status".to_string(),
372 description: None,
373 content: "Echo: !`echo hello`\nPwd: !`pwd`".to_string(),
374 location: PathBuf::from("/test"),
375 allowed_tools: vec![],
376 argument_hint: None,
377 model: None,
378 };
379
380 let result = cmd.execute_full("", dir.path()).await;
381 assert!(result.contains("Echo: hello"));
382 assert!(result.contains(&dir.path().to_string_lossy().to_string()));
383 }
384
385 #[tokio::test]
386 async fn test_bash_backtick_error() {
387 use tempfile::tempdir;
388
389 let dir = tempdir().unwrap();
390
391 let cmd = SlashCommand {
392 name: "fail".to_string(),
393 description: None,
394 content: "Result: !`exit 1`".to_string(),
395 location: PathBuf::from("/test"),
396 allowed_tools: vec![],
397 argument_hint: None,
398 model: None,
399 };
400
401 let result = cmd.execute_full("", dir.path()).await;
402 assert!(result.contains("[Error:") || result.contains("Result:"));
403 }
404}