Skip to main content

opendev_runtime/custom_commands/
mod.rs

1//! Custom commands loaded from `.opendev/commands/`.
2//!
3//! Text files in the commands directories become slash commands. Supports:
4//! - YAML frontmatter for metadata (`description`, `model`, `agent`, `subtask`)
5//! - `$1`, `$2`, etc. for positional arguments
6//! - `$ARGUMENTS` for all arguments
7//! - Context variable substitution (`$KEY` → value)
8//! - Shell substitution: `!`cmd`` executes the command and inlines its output
9//!
10//! # Example
11//!
12//! `.opendev/commands/review.md` contains:
13//! ```text
14//! ---
15//! description: "Code review with security focus"
16//! model: gpt-4o
17//! subtask: true
18//! ---
19//!
20//! Review this code for: $ARGUMENTS
21//! Current branch: !`git branch --show-current`
22//! Focus on security and performance.
23//! ```
24
25mod expansion;
26
27use std::collections::HashMap;
28use std::fs;
29use std::path::{Path, PathBuf};
30
31use tracing::debug;
32
33use expansion::parse_frontmatter;
34
35/// A custom command loaded from a text file.
36#[derive(Debug, Clone)]
37pub struct CustomCommand {
38    /// Command name (derived from filename).
39    pub name: String,
40    /// Template text with placeholder variables (frontmatter stripped).
41    pub template: String,
42    /// Source identifier (e.g. "project:review.md").
43    pub source: String,
44    /// Human-readable description (from frontmatter or first `#` line).
45    pub description: String,
46    /// Optional model override for this command.
47    pub model: Option<String>,
48    /// Optional agent override for this command.
49    pub agent: Option<String>,
50    /// Whether this command should run as a subtask (restricted permissions).
51    pub subtask: bool,
52}
53
54/// Summary info for listing commands.
55#[derive(Debug, Clone)]
56pub struct CommandInfo {
57    /// Command name.
58    pub name: String,
59    /// Human-readable description.
60    pub description: String,
61    /// Source identifier.
62    pub source: String,
63    /// Optional model override.
64    pub model: Option<String>,
65    /// Optional agent override.
66    pub agent: Option<String>,
67}
68
69/// Loads and manages custom commands from command directories.
70pub struct CustomCommandLoader {
71    working_dir: PathBuf,
72    commands: Option<HashMap<String, CustomCommand>>,
73}
74
75impl CustomCommandLoader {
76    /// Create a new loader rooted at the given working directory.
77    pub fn new(working_dir: &Path) -> Self {
78        Self {
79            working_dir: working_dir.to_path_buf(),
80            commands: None,
81        }
82    }
83
84    /// Load all custom commands from command directories.
85    ///
86    /// Scans `.opendev/commands/` under the project directory (higher priority)
87    /// and then the global directory. Results are cached.
88    pub fn load_commands(&mut self) -> &HashMap<String, CustomCommand> {
89        if let Some(ref cmds) = self.commands {
90            return cmds;
91        }
92
93        let mut commands = HashMap::new();
94        let dirs = self.get_command_dirs();
95
96        for (cmd_dir, source) in dirs {
97            if let Ok(entries) = fs::read_dir(&cmd_dir) {
98                let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
99                paths.sort();
100
101                for path in paths {
102                    if !path.is_file() {
103                        continue;
104                    }
105
106                    // Only .md, .txt, or extensionless files
107                    let ext = path.extension().and_then(|e| e.to_str());
108                    match ext {
109                        Some("md") | Some("txt") | None => {}
110                        _ => continue,
111                    }
112
113                    let stem = match path.file_stem().and_then(|s| s.to_str()) {
114                        Some(s) => s.to_string(),
115                        None => continue,
116                    };
117
118                    // Skip hidden/private files
119                    if stem.starts_with('.') || stem.starts_with('_') {
120                        continue;
121                    }
122
123                    match fs::read_to_string(&path) {
124                        Ok(raw_content) => {
125                            let (frontmatter, template) = parse_frontmatter(&raw_content);
126
127                            // Description: frontmatter > first # line > empty
128                            let description = frontmatter
129                                .get("description")
130                                .cloned()
131                                .or_else(|| {
132                                    template
133                                        .trim()
134                                        .lines()
135                                        .next()
136                                        .filter(|line| line.starts_with('#'))
137                                        .map(|line| line.trim_start_matches('#').trim().to_string())
138                                })
139                                .unwrap_or_default();
140
141                            let model = frontmatter.get("model").cloned();
142                            let agent = frontmatter.get("agent").cloned();
143                            let subtask = frontmatter.get("subtask").is_some_and(|v| v == "true");
144
145                            let file_name =
146                                path.file_name().and_then(|n| n.to_str()).unwrap_or(&stem);
147
148                            let source_label = format!("{}:{}", source, file_name);
149
150                            // Project commands have higher priority (loaded first),
151                            // so don't overwrite if already present.
152                            commands.entry(stem.clone()).or_insert(CustomCommand {
153                                name: stem,
154                                template,
155                                source: source_label,
156                                description,
157                                model,
158                                agent,
159                                subtask,
160                            });
161                        }
162                        Err(e) => {
163                            debug!("Failed to load command {:?}: {}", path, e);
164                        }
165                    }
166                }
167            }
168        }
169
170        if !commands.is_empty() {
171            let names: Vec<&str> = commands.keys().map(|s| s.as_str()).collect();
172            debug!("Loaded {} custom commands: {:?}", commands.len(), names);
173        }
174
175        self.commands = Some(commands);
176        // SAFETY: we just set self.commands to Some on the line above
177        self.commands
178            .as_ref()
179            .expect("commands was just set to Some")
180    }
181
182    /// Get a custom command by name.
183    pub fn get_command(&mut self, name: &str) -> Option<&CustomCommand> {
184        self.load_commands().get(name)
185    }
186
187    /// List all available custom commands.
188    pub fn list_commands(&mut self) -> Vec<CommandInfo> {
189        self.load_commands()
190            .values()
191            .map(|cmd| CommandInfo {
192                name: cmd.name.clone(),
193                description: cmd.description.clone(),
194                source: cmd.source.clone(),
195                model: cmd.model.clone(),
196                agent: cmd.agent.clone(),
197            })
198            .collect()
199    }
200
201    /// Force reload of custom commands (clears cache).
202    pub fn reload(&mut self) {
203        self.commands = None;
204    }
205
206    /// Get command directories in priority order: project-local first, then global.
207    ///
208    /// Searches `.opendev/commands/` at project and global levels.
209    fn get_command_dirs(&self) -> Vec<(PathBuf, &'static str)> {
210        let mut dirs = Vec::new();
211
212        // Project-local commands (highest priority)
213        let local = self.working_dir.join(".opendev/commands");
214        if local.is_dir() {
215            dirs.push((local, "project"));
216        }
217
218        // User-global commands
219        if let Some(home) = dirs_next::home_dir() {
220            let global = home.join(".opendev/commands");
221            if global.is_dir() {
222                dirs.push((global, "global"));
223            }
224        }
225
226        dirs
227    }
228}
229
230#[cfg(test)]
231mod tests;