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 commands win over user-global ones.
159 if let Ok(cwd) = std::env::current_dir() {
160 roots.push(cwd.join(".sigit").join("commands"));
161 roots.push(cwd.join(".claude").join("commands"));
162 }
163
164 // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
165 if let Some(config_dir) = sigit_config_dir() {
166 roots.push(config_dir.join("commands"));
167 }
168
169 // Shared with the broader Claude Code ecosystem.
170 if let Some(home) = home_dir() {
171 roots.push(home.join(".claude").join("commands"));
172 }
173
174 roots
175 }
176
177 fn sigit_config_dir() -> Option<PathBuf> {
178 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
179 && !dir.is_empty()
180 {
181 return Some(PathBuf::from(dir));
182 }
183 home_dir().map(|home| home.join(".config").join("sigit"))
184 }
185
186 fn home_dir() -> Option<PathBuf> {
187 std::env::var("HOME").ok().map(PathBuf::from)
188 }
189
190 /// Recursively scan a root for `*.md` files, appending newly-seen commands.
191 /// `root` stays fixed across the recursion so nested names are relative to
192 /// it; `dir` is the directory currently being walked.
193 fn collect_commands_from_root(
194 root: &Path,
195 dir: &Path,
196 commands: &mut Vec<CustomCommand>,
197 seen_names: &mut Vec<String>,
198 ) {
199 let entries = match std::fs::read_dir(dir) {
200 Ok(entries) => entries,
201 // Most roots won't exist; that's expected, not an error.
202 Err(_) => return,
203 };
204
205 let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
206 // Stable, predictable discovery order regardless of filesystem order.
207 paths.sort();
208
209 for path in paths {
210 if path.is_dir() {
211 collect_commands_from_root(root, &path, commands, seen_names);
212 continue;
213 }
214 if path.extension().and_then(|e| e.to_str()) != Some("md") {
215 continue;
216 }
217
218 let Some(name) = command_name(root, &path) else {
219 continue;
220 };
221
222 let contents = match std::fs::read_to_string(&path) {
223 Ok(contents) => contents,
224 Err(error) => {
225 log::warn!("skipping command at {}: {error}", path.display());
226 continue;
227 }
228 };
229
230 let command = parse_command(&contents, &name, &path);
231
232 // First root to define a name wins; later duplicates are ignored.
233 if seen_names.iter().any(|n| n == &command.name) {
234 log::debug!(
235 "command \"{}\" at {} shadowed by an earlier definition",
236 command.name,
237 path.display()
238 );
239 continue;
240 }
241
242 seen_names.push(command.name.clone());
243 commands.push(command);
244 }
245 }
246
247 /// Derive a namespaced command name from a file's path relative to its root:
248 /// `git/commit.md` under root `.sigit/commands` becomes `"git:commit"`.
249 fn command_name(root: &Path, path: &Path) -> Option<String> {
250 let relative = path.strip_prefix(root).ok()?;
251 let without_ext = relative.with_extension("");
252 let parts: Vec<String> = without_ext
253 .components()
254 .map(|c| c.as_os_str().to_string_lossy().into_owned())
255 .collect();
256 if parts.is_empty() || parts.iter().any(|p| p.is_empty()) {
257 return None;
258 }
259 Some(parts.join(":"))
260 }
261
262 /// Parse a command file into a [`CustomCommand`]. Frontmatter is entirely
263 /// optional — a command with no frontmatter at all is just its raw body with
264 /// no description or argument hint.
265 fn parse_command(contents: &str, name: &str, path: &Path) -> CustomCommand {
266 let mut description = None;
267 let mut argument_hint = None;
268
269 if let Some(frontmatter) = extract_frontmatter(contents) {
270 let fields = parse_frontmatter_fields(frontmatter);
271 description = fields
272 .iter()
273 .find(|(k, _)| k == "description")
274 .map(|(_, v)| v.clone())
275 .filter(|v| !v.is_empty());
276 argument_hint = fields
277 .iter()
278 .find(|(k, _)| k == "argument-hint")
279 .map(|(_, v)| v.clone())
280 .filter(|v| !v.is_empty());
281 }
282
283 let body = strip_frontmatter(contents).trim().to_string();
284
285 CustomCommand {
286 name: name.to_string(),
287 description,
288 argument_hint,
289 body,
290 path: path.to_path_buf(),
291 }
292 }
293
294 #[cfg(test)]
295 mod tests {
296 use super::*;
297 use std::fs;
298
299 fn unique_dir(name: &str) -> PathBuf {
300 let nanos = std::time::SystemTime::now()
301 .duration_since(std::time::UNIX_EPOCH)
302 .unwrap()
303 .as_nanos();
304 std::env::temp_dir().join(format!("sigit-commands-test-{name}-{nanos}"))
305 }
306
307 #[test]
308 fn render_substitutes_arguments_placeholder() {
309 let body = "Review PR: $ARGUMENTS";
310 assert_eq!(render(body, Some("#42")), "Review PR: #42");
311 assert_eq!(render(body, None), "Review PR: ");
312 }
313
314 #[test]
315 fn render_substitutes_positional_placeholders() {
316 let body = "Compare $1 against $2.";
317 assert_eq!(
318 render(body, Some("main feature-x")),
319 "Compare main against feature-x."
320 );
321 }
322
323 #[test]
324 fn render_does_not_re_expand_positional_vars_inside_arguments() {
325 // A literal `$1` typed by the user must survive: the single pass
326 // substitutes $ARGUMENTS once and never re-scans the inserted text.
327 let body = "Explain: $ARGUMENTS";
328 assert_eq!(
329 render(body, Some("what does $1 mean in bash")),
330 "Explain: what does $1 mean in bash"
331 );
332 }
333
334 #[test]
335 fn render_leaves_unsupplied_positional_placeholder_literal() {
336 let body = "Compare $1 against $2.";
337 // Only one argument supplied: $2 stays untouched, like the old pass.
338 assert_eq!(render(body, Some("main")), "Compare main against $2.");
339 }
340
341 #[test]
342 fn render_appends_arguments_when_no_placeholder_present() {
343 let body = "Run the linter.";
344 assert_eq!(render(body, Some("--fix")), "Run the linter.\n\n--fix");
345 // No arguments given, nothing appended.
346 assert_eq!(render(body, None), "Run the linter.");
347 }
348
349 #[test]
350 fn command_name_namespaces_by_subdirectory() {
351 let root = Path::new("/proj/.sigit/commands");
352 assert_eq!(
353 command_name(root, &root.join("review.md")),
354 Some("review".to_string())
355 );
356 assert_eq!(
357 command_name(root, &root.join("git").join("commit.md")),
358 Some("git:commit".to_string())
359 );
360 }
361
362 #[test]
363 fn parse_command_reads_frontmatter_and_body() {
364 let contents =
365 "---\ndescription: Review a PR\nargument-hint: <pr-number>\n---\n\nReview PR #$1.\n";
366 let command = parse_command(contents, "review", Path::new("/x/review.md"));
367 assert_eq!(command.description.as_deref(), Some("Review a PR"));
368 assert_eq!(command.argument_hint.as_deref(), Some("<pr-number>"));
369 assert_eq!(command.body, "Review PR #$1.");
370 }
371
372 #[test]
373 fn parse_command_without_frontmatter_is_just_the_body() {
374 let contents = "Just do the thing.\n";
375 let command = parse_command(contents, "thing", Path::new("/x/thing.md"));
376 assert!(command.description.is_none());
377 assert!(command.argument_hint.is_none());
378 assert_eq!(command.body, "Just do the thing.");
379 }
380
381 #[test]
382 fn discover_and_resolve_roundtrip() {
383 let _guard = crate::ENV_TEST_LOCK
384 .lock()
385 .unwrap_or_else(|poisoned| poisoned.into_inner());
386 let root = unique_dir("roundtrip");
387 let commands_root = root.join(".sigit").join("commands");
388 fs::create_dir_all(commands_root.join("git")).unwrap();
389 fs::write(
390 commands_root.join("review.md"),
391 "---\ndescription: Review the diff\n---\n\nReview: $ARGUMENTS\n",
392 )
393 .unwrap();
394 fs::write(
395 commands_root.join("git").join("commit.md"),
396 "Commit with message: $ARGUMENTS\n",
397 )
398 .unwrap();
399
400 // discover_commands() reads the current directory, so run from `root`.
401 let prev = std::env::current_dir().unwrap();
402 std::env::set_current_dir(&root).unwrap();
403
404 let commands = discover_commands();
405 assert_eq!(commands.len(), 2);
406 let review = commands.iter().find(|c| c.name == "review").unwrap();
407 assert_eq!(review.description.as_deref(), Some("Review the diff"));
408 let commit = commands.iter().find(|c| c.name == "git:commit").unwrap();
409 assert_eq!(commit.body, "Commit with message: $ARGUMENTS");
410
411 let resolved = resolve_command("/review").expect("resolve by slash-prefixed name");
412 assert_eq!(resolved.name, "review");
413 let resolved_bare = resolve_command("git:commit").expect("resolve by bare name");
414 assert_eq!(resolved_bare.name, "git:commit");
415 assert!(resolve_command("nope").is_none());
416
417 std::env::set_current_dir(prev).unwrap();
418 let _ = fs::remove_dir_all(&root);
419 }
420
421 #[test]
422 fn format_commands_list_reports_none_found() {
423 let _guard = crate::ENV_TEST_LOCK
424 .lock()
425 .unwrap_or_else(|poisoned| poisoned.into_inner());
426 let root = unique_dir("empty");
427 fs::create_dir_all(&root).unwrap();
428 let prev = std::env::current_dir().unwrap();
429 std::env::set_current_dir(&root).unwrap();
430
431 assert!(format_commands_list().contains("No custom commands found"));
432
433 std::env::set_current_dir(prev).unwrap();
434 let _ = fs::remove_dir_all(&root);
435 }
436 }