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;