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    /// Loads commands from all levels (enterprise + user + project).
160    /// Priority: project > user > enterprise (later overrides earlier).
161    pub async fn load(&mut self, project_dir: &Path) -> Result<()> {
162        // 1. Enterprise level (lowest priority)
163        if let Some(enterprise_path) = crate::context::enterprise_base_path() {
164            self.load_from(&enterprise_path).await?;
165        }
166
167        // 2. User level
168        if let Some(home) = crate::common::home_dir() {
169            self.load_from(&home.join(".claude")).await?;
170        }
171
172        // 3. Project level (highest priority)
173        self.load_from(project_dir).await?;
174
175        Ok(())
176    }
177
178    /// Loads commands from a single base directory.
179    /// Use this for loading from a specific resource level.
180    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        // Also check direct commands directory (for enterprise/user paths)
187        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}