1 //! User-defined slash commands for siGit Code.
2 //!
3 //! A command is a Markdown file with optional YAML frontmatter:
4 //!
5 //! ```markdown
6 //! ---
7 //! description: Review a PR for correctness bugs
8 //! argument-hint: <pr-number>
9 //! ---
10 //!
11 //! Review PR #$1 for correctness bugs. Focus on the diff, not style.
12 //! ```
13 //!
14 //! Discovered from, in priority order (earlier wins on name clashes):
15 //!
16 //! - `<cwd>/.sigit/commands/` and `<cwd>/.claude/commands/` (project-local)
17 //! - `$SIGIT_CONFIG_DIR/commands/` (default `~/.config/sigit/commands/`)
18 //! - `~/.claude/commands/` (shared with the broader Claude Code ecosystem)
19 //!
20 //! Subdirectories namespace the command name with `:`, e.g.
21 //! `.sigit/commands/git/commit.md` is invoked as `/git:commit`.
22 //!
23 //! Unlike [`crate::skills`] (progressive disclosure via a tool call),
24 //! invoking a custom command works like the built-in `/init`: the caller
25 //! resolves the command, renders its body against the typed arguments, and
26 //! feeds the result to the model as if the user had typed it directly —
27 //! going through the ordinary tool-calling turn and permission checks. There
28 //! is no separate execution sandbox; a command is just a canned prompt.
29
30 use std::path::{Path, PathBuf};
31
32 use crate::frontmatter::{extract_frontmatter, parse_frontmatter_fields, strip_frontmatter};
33
34 /// A discovered custom command.
35 #[derive(Debug, Clone, PartialEq, Eq)]
36 pub struct CustomCommand {
37 /// The invocation name, without a leading `/` (e.g. `"review"` or
38 /// `"git:commit"` for a command nested under a `git/` subdirectory).
39 pub name: String,
40 /// Optional one-line description from frontmatter, shown in `/commands`
41 /// and advertised to ACP clients so they forward the command at all.
42 pub description: Option<String>,
43 /// Optional `argument-hint` from frontmatter (e.g. `"<pr-number>"`),
44 /// shown as the input hint for ACP clients.
45 pub argument_hint: Option<String>,
46 /// The Markdown body (frontmatter stripped): the prompt template.
47 pub body: String,
48 /// Where this command's file lives on disk, for diagnostics.
49 pub path: PathBuf,
50 }
51
52 /// Discover all custom commands across the known roots. Earlier roots win
53 /// when two commands share a `name`. Sorted by name for stable output.
54 pub fn discover_commands() -> Vec<CustomCommand> {
55 let mut commands: Vec<CustomCommand> = Vec::new();
56 let mut seen_names: Vec<String> = Vec::new();
57
58 for root in command_roots() {
59 collect_commands_from_root(&root, &root, &mut commands, &mut seen_names);
60 }
61
62 commands.sort_by(|a, b| a.name.cmp(&b.name));
63 commands
64 }
65
66 /// Re-discover commands and find one by name. Accepts the name with or
67 /// without a leading `/` so callers can pass the raw token straight out of
68 /// slash-command parsing.
69 pub fn resolve_command(name: &str) -> Option<CustomCommand> {
70 let name = name.strip_prefix('/').unwrap_or(name);
71 discover_commands().into_iter().find(|c| c.name == name)
72 }
73
74 /// Render a command body against the raw argument string the user typed
75 /// after the command name (everything on the line past the first
76 /// whitespace; `None` if nothing followed).
77 ///
78 /// Supports `$ARGUMENTS` (the whole argument string) and positional `$1`
79 /// through `$9` (whitespace-split). If the body uses neither placeholder and
80 /// arguments were supplied anyway, they're appended so nothing typed is
81 /// silently dropped.
82 pub fn render(body: &str, arguments: Option<&str>) -> String {
83 let arguments = arguments.unwrap_or("");
84 let positional: Vec<&str> = arguments.split_whitespace().collect();
85
86 // Substitute in a single left-to-right pass over the template so text we
87 // insert is never re-scanned for further placeholders. A second pass (as
88 // this once did) would re-expand a literal `$1` that appeared inside the
89 // user's own `$ARGUMENTS`, corrupting prompts that mention shell
90 // positional variables.
91 let mut result = String::with_capacity(body.len());
92 let mut substituted = false;
93 let bytes = body.as_bytes();
94 let mut i = 0;
95 while i < body.len() {
96 if bytes[i] == b'$' {
97 if body[i..].starts_with("$ARGUMENTS") {
98 result.push_str(arguments);
99 substituted = true;
100 i += "$ARGUMENTS".len();
101 continue;
102 }
103 // Positional `$1`..`$9`, but only when that argument was supplied;
104 // otherwise leave the placeholder untouched (as before).
105 if let Some(&d) = bytes.get(i + 1)
106 && d.is_ascii_digit()
107 && d != b'0'
108 && let Some(arg) = positional.get((d - b'1') as usize)
109 {
110 result.push_str(arg);
111 substituted = true;
112 i += 2;
113 continue;
114 }
115 }
116 let ch = body[i..].chars().next().unwrap();
117 result.push(ch);
118 i += ch.len_utf8();
119 }
120
121 if !substituted && !arguments.is_empty() {
122 result.push_str("\n\n");
123 result.push_str(arguments);
124 }
125
126 result
127 }
128
129 /// Human-readable list of discovered commands, for the `/commands` slash
130 /// command.
131 pub fn format_commands_list() -> String {
132 let commands = discover_commands();
133 if commands.is_empty() {
134 return "No custom commands found. Add a Markdown file under \
135 .sigit/commands/ or .claude/commands/ in your project \
136 (e.g. .sigit/commands/review.md), or under \
137 ~/.config/sigit/commands/."
138 .to_string();
139 }
140
141 let mut lines = vec![format!("{} custom command(s) available:", commands.len())];
142 for command in &commands {
143 let hint = command
144 .argument_hint
145 .as_deref()
146 .map(|h| format!(" {h}"))
147 .unwrap_or_default();
148 let description = command.description.as_deref().unwrap_or("(no description)");
149 lines.push(format!("- /{}{hint} — {description}", command.name));
150 }
151 lines.join("\n")
152 }
153
154 /// The command directories to scan, in priority order.
155 fn command_roots() -> Vec<PathBuf> {
156 let mut roots = Vec::new();
157
158 // Project-local roots win over user-global ones. A multi-root project has
159 // more than one of them (see `workspace.rs`), ordered primary root first.
160 for dir in crate::workspace::project_dirs() {
161 roots.push(dir.join(".sigit").join("commands"));
162 roots.push(dir.join(".claude").join("commands"));
163 }
164
165 // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
166 if let Some(config_dir) = sigit_config_dir() {
167 roots.push(config_dir.join("commands"));
168 }
169
170 // Shared with the broader Claude Code ecosystem.
171 if let Some(home) = home_dir() {
172 roots.push(home.join(".claude").join("commands"));
173 }
174
175 roots
176 }
177
178 fn sigit_config_dir() -> Option<PathBuf> {
179 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
180 && !dir.is_empty()
181 {
182 return Some(PathBuf::from(dir));
183 }
184 home_dir().map(|home| home.join(".config").join("sigit"))
185 }
186
187 fn home_dir() -> Option<PathBuf> {
188 std::env::var("HOME").ok().map(PathBuf::from)
189 }
190
191 /// Recursively scan a root for `*.md` files, appending newly-seen commands.
192 /// `root` stays fixed across the recursion so nested names are relative to
193 /// it; `dir` is the directory currently being walked.
194 fn collect_commands_from_root(
195 root: &Path,
196 dir: &Path,
197 commands: &mut Vec<CustomCommand>,
198 seen_names: &mut Vec<String>,
199 ) {
200 let entries = match std::fs::read_dir(dir) {
201 Ok(entries) => entries,
202 // Most roots won't exist; that's expected, not an error.
203 Err(_) => return,
204 };
205
206 let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
207 // Stable, predictable discovery order regardless of filesystem order.
208 paths.sort();
209
210 for path in paths {
211 if path.is_dir() {
212 collect_commands_from_root(root, &path, commands, seen_names);
213 continue;
214 }
215 if path.extension().and_then(|e| e.to_str()) != Some("md") {
216 continue;
217 }
218
219 let Some(name) = command_name(root, &path) else {
220 continue;
221 };
222
223 let contents = match std::fs::read_to_string(&path) {
224 Ok(contents) => contents,
225 Err(error) => {
226 log::warn!("skipping command at {}: {error}", path.display());
227 continue;
228 }
229 };
230
231 let command = parse_command(&contents, &name, &path);
232
233 // First root to define a name wins; later duplicates are ignored.
234 if seen_names.iter().any(|n| n == &command.name) {
235 log::debug!(
236 "command \"{}\" at {} shadowed by an earlier definition",
237 command.name,
238 path.display()
239 );
240 continue;
241 }
242
243 seen_names.push(command.name.clone());
244 commands.push(command);
245 }
246 }
247
248 /// Derive a namespaced command name from a file's path relative to its root:
249 /// `git/commit.md` under root `.sigit/commands` becomes `"git:commit"`.
250 fn command_name(root: &Path, path: &Path) -> Option<String> {
251 let relative = path.strip_prefix(root).ok()?;
252 let without_ext = relative.with_extension("");
253 let parts: Vec<String> = without_ext
254 .components()
255 .map(|c| c.as_os_str().to_string_lossy().into_owned())
256 .collect();
257 if parts.is_empty() || parts.iter().any(|p| p.is_empty()) {
258 return None;
259 }
260 Some(parts.join(":"))
261 }
262
263 /// Parse a command file into a [`CustomCommand`]. Frontmatter is entirely
264 /// optional — a command with no frontmatter at all is just its raw body with
265 /// no description or argument hint.
266 fn parse_command(contents: &str, name: &str, path: &Path) -> CustomCommand {
267 let mut description = None;
268 let mut argument_hint = None;
269
270 if let Some(frontmatter) = extract_frontmatter(contents) {
271 let fields = parse_frontmatter_fields(frontmatter);
272 description = fields
273 .iter()
274 .find(|(k, _)| k == "description")
275 .map(|(_, v)| v.clone())
276 .filter(|v| !v.is_empty());
277 argument_hint = fields
278 .iter()
279 .find(|(k, _)| k == "argument-hint")
280 .map(|(_, v)| v.clone())
281 .filter(|v| !v.is_empty());
282 }
283
284 let body = strip_frontmatter(contents).trim().to_string();
285
286 CustomCommand {
287 name: name.to_string(),
288 description,
289 argument_hint,
290 body,
291 path: path.to_path_buf(),
292 }
293 }
294
295 #[cfg(test)]
296 mod tests {
297 use super::*;
298 use std::fs;
299
300 fn unique_dir(name: &str) -> PathBuf {
301 let nanos = std::time::SystemTime::now()
302 .duration_since(std::time::UNIX_EPOCH)
303 .unwrap()
304 .as_nanos();
305 std::env::temp_dir().join(format!("sigit-commands-test-{name}-{nanos}"))
306 }
307
308 #[test]
309 fn render_substitutes_arguments_placeholder() {
310 let body = "Review PR: $ARGUMENTS";
311 assert_eq!(render(body, Some("#42")), "Review PR: #42");
312 assert_eq!(render(body, None), "Review PR: ");
313 }
314
315 #[test]
316 fn render_substitutes_positional_placeholders() {
317 let body = "Compare $1 against $2.";
318 assert_eq!(
319 render(body, Some("main feature-x")),
320 "Compare main against feature-x."
321 );
322 }
323
324 #[test]
325 fn render_does_not_re_expand_positional_vars_inside_arguments() {
326 // A literal `$1` typed by the user must survive: the single pass
327 // substitutes $ARGUMENTS once and never re-scans the inserted text.
328 let body = "Explain: $ARGUMENTS";
329 assert_eq!(
330 render(body, Some("what does $1 mean in bash")),
331 "Explain: what does $1 mean in bash"
332 );
333 }
334
335 #[test]
336 fn render_leaves_unsupplied_positional_placeholder_literal() {
337 let body = "Compare $1 against $2.";
338 // Only one argument supplied: $2 stays untouched, like the old pass.
339 assert_eq!(render(body, Some("main")), "Compare main against $2.");
340 }
341
342 #[test]
343 fn render_appends_arguments_when_no_placeholder_present() {
344 let body = "Run the linter.";
345 assert_eq!(render(body, Some("--fix")), "Run the linter.\n\n--fix");
346 // No arguments given, nothing appended.
347 assert_eq!(render(body, None), "Run the linter.");
348 }
349
350 #[test]
351 fn command_name_namespaces_by_subdirectory() {
352 let root = Path::new("/proj/.sigit/commands");
353 assert_eq!(
354 command_name(root, &root.join("review.md")),
355 Some("review".to_string())
356 );
357 assert_eq!(
358 command_name(root, &root.join("git").join("commit.md")),
359 Some("git:commit".to_string())
360 );
361 }
362
363 #[test]
364 fn parse_command_reads_frontmatter_and_body() {
365 let contents =
366 "---\ndescription: Review a PR\nargument-hint: <pr-number>\n---\n\nReview PR #$1.\n";
367 let command = parse_command(contents, "review", Path::new("/x/review.md"));
368 assert_eq!(command.description.as_deref(), Some("Review a PR"));
369 assert_eq!(command.argument_hint.as_deref(), Some("<pr-number>"));
370 assert_eq!(command.body, "Review PR #$1.");
371 }
372
373 #[test]
374 fn parse_command_without_frontmatter_is_just_the_body() {
375 let contents = "Just do the thing.\n";
376 let command = parse_command(contents, "thing", Path::new("/x/thing.md"));
377 assert!(command.description.is_none());
378 assert!(command.argument_hint.is_none());
379 assert_eq!(command.body, "Just do the thing.");
380 }
381
382 #[test]
383 fn discover_and_resolve_roundtrip() {
384 let _guard = crate::ENV_TEST_LOCK
385 .lock()
386 .unwrap_or_else(|poisoned| poisoned.into_inner());
387 let root = unique_dir("roundtrip");
388 let commands_root = root.join(".sigit").join("commands");
389 fs::create_dir_all(commands_root.join("git")).unwrap();
390 fs::write(
391 commands_root.join("review.md"),
392 "---\ndescription: Review the diff\n---\n\nReview: $ARGUMENTS\n",
393 )
394 .unwrap();
395 fs::write(
396 commands_root.join("git").join("commit.md"),
397 "Commit with message: $ARGUMENTS\n",
398 )
399 .unwrap();
400
401 // discover_commands() reads the current directory, so run from `root`.
402 let prev = std::env::current_dir().unwrap();
403 std::env::set_current_dir(&root).unwrap();
404
405 let commands = discover_commands();
406 assert_eq!(commands.len(), 2);
407 let review = commands.iter().find(|c| c.name == "review").unwrap();
408 assert_eq!(review.description.as_deref(), Some("Review the diff"));
409 let commit = commands.iter().find(|c| c.name == "git:commit").unwrap();
410 assert_eq!(commit.body, "Commit with message: $ARGUMENTS");
411
412 let resolved = resolve_command("/review").expect("resolve by slash-prefixed name");
413 assert_eq!(resolved.name, "review");
414 let resolved_bare = resolve_command("git:commit").expect("resolve by bare name");
415 assert_eq!(resolved_bare.name, "git:commit");
416 assert!(resolve_command("nope").is_none());
417
418 std::env::set_current_dir(prev).unwrap();
419 let _ = fs::remove_dir_all(&root);
420 }
421
422 #[test]
423 fn format_commands_list_reports_none_found() {
424 let _guard = crate::ENV_TEST_LOCK
425 .lock()
426 .unwrap_or_else(|poisoned| poisoned.into_inner());
427 let root = unique_dir("empty");
428 fs::create_dir_all(&root).unwrap();
429 let prev = std::env::current_dir().unwrap();
430 std::env::set_current_dir(&root).unwrap();
431
432 assert!(format_commands_list().contains("No custom commands found"));
433
434 std::env::set_current_dir(prev).unwrap();
435 let _ = fs::remove_dir_all(&root);
436 }
437 }