claude_agent/skills/
commands.rs

1//! Slash command system - user-defined commands from .claude/commands/.
2
3use 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}