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 mut result = body.to_string();
85
+ let mut substituted = false;
86
+
87
+ if result.contains("$ARGUMENTS") {
88
+ result = result.replace("$ARGUMENTS", arguments);
89
+ substituted = true;
90
+ }
91
+
92
+ let positional: Vec<&str> = arguments.split_whitespace().collect();
93
+ for (i, arg) in positional.iter().enumerate() {
94
+ let placeholder = format!("${}", i + 1);
95
+ if result.contains(&placeholder) {
96
+ result = result.replace(&placeholder, arg);
97
+ substituted = true;
98
+ }
99
+ }
100
+
101
+ if !substituted && !arguments.is_empty() {
102
+ result.push_str("\n\n");
103
+ result.push_str(arguments);
104
+ }
105
+
106
+ result
107
+}
108
+
109
+/// Human-readable list of discovered commands, for the `/commands` slash
110
+/// command.
111
+pub fn format_commands_list() -> String {
112
+ let commands = discover_commands();
113
+ if commands.is_empty() {
114
+ return "No custom commands found. Add a Markdown file under \
115
+ .sigit/commands/ or .claude/commands/ in your project \
116
+ (e.g. .sigit/commands/review.md), or under \
117
+ ~/.config/sigit/commands/."
118
+ .to_string();
119
+ }
120
+
121
+ let mut lines = vec![format!("{} custom command(s) available:", commands.len())];
122
+ for command in &commands {
123
+ let hint = command
124
+ .argument_hint
125
+ .as_deref()
126
+ .map(|h| format!(" {h}"))
127
+ .unwrap_or_default();
128
+ let description = command.description.as_deref().unwrap_or("(no description)");
129
+ lines.push(format!("- /{}{hint} — {description}", command.name));
130
+ }
131
+ lines.join("\n")
132
+}
133
+
134
+/// The command directories to scan, in priority order.
135
+fn command_roots() -> Vec<PathBuf> {
136
+ let mut roots = Vec::new();
137
+
138
+ // Project-local commands win over user-global ones.
139
+ if let Ok(cwd) = std::env::current_dir() {
140
+ roots.push(cwd.join(".sigit").join("commands"));
141
+ roots.push(cwd.join(".claude").join("commands"));
142
+ }
143
+
144
+ // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
145
+ if let Some(config_dir) = sigit_config_dir() {
146
+ roots.push(config_dir.join("commands"));
147
+ }
148
+
149
+ // Shared with the broader Claude Code ecosystem.
150
+ if let Some(home) = home_dir() {
151
+ roots.push(home.join(".claude").join("commands"));
152
+ }
153
+
154
+ roots
155
+}
156
+
157
+fn sigit_config_dir() -> Option<PathBuf> {
158
+ if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
159
+ && !dir.is_empty()
160
+ {
161
+ return Some(PathBuf::from(dir));
162
+ }
163
+ home_dir().map(|home| home.join(".config").join("sigit"))
164
+}
165
+
166
+fn home_dir() -> Option<PathBuf> {
167
+ std::env::var("HOME").ok().map(PathBuf::from)
168
+}
169
+
170
+/// Recursively scan a root for `*.md` files, appending newly-seen commands.
171
+/// `root` stays fixed across the recursion so nested names are relative to
172
+/// it; `dir` is the directory currently being walked.
173
+fn collect_commands_from_root(
174
+ root: &Path,
175
+ dir: &Path,
176
+ commands: &mut Vec<CustomCommand>,
177
+ seen_names: &mut Vec<String>,
178
+) {
179
+ let entries = match std::fs::read_dir(dir) {
180
+ Ok(entries) => entries,
181
+ // Most roots won't exist; that's expected, not an error.
182
+ Err(_) => return,
183
+ };
184
+
185
+ let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
186
+ // Stable, predictable discovery order regardless of filesystem order.
187
+ paths.sort();
188
+
189
+ for path in paths {
190
+ if path.is_dir() {
191
+ collect_commands_from_root(root, &path, commands, seen_names);
192
+ continue;
193
+ }
194
+ if path.extension().and_then(|e| e.to_str()) != Some("md") {
195
+ continue;
196
+ }
197
+
198
+ let Some(name) = command_name(root, &path) else {
199
+ continue;
200
+ };
201
+
202
+ let contents = match std::fs::read_to_string(&path) {
203
+ Ok(contents) => contents,
204
+ Err(error) => {
205
+ log::warn!("skipping command at {}: {error}", path.display());
206
+ continue;
207
+ }
208
+ };
209
+
210
+ let command = parse_command(&contents, &name, &path);
211
+
212
+ // First root to define a name wins; later duplicates are ignored.
213
+ if seen_names.iter().any(|n| n == &command.name) {
214
+ log::debug!(
215
+ "command \"{}\" at {} shadowed by an earlier definition",
216
+ command.name,
217
+ path.display()
218
+ );
219
+ continue;
220
+ }
221
+
222
+ seen_names.push(command.name.clone());
223
+ commands.push(command);
224
+ }
225
+}
226
+
227
+/// Derive a namespaced command name from a file's path relative to its root:
228
+/// `git/commit.md` under root `.sigit/commands` becomes `"git:commit"`.
229
+fn command_name(root: &Path, path: &Path) -> Option<String> {
230
+ let relative = path.strip_prefix(root).ok()?;
231
+ let without_ext = relative.with_extension("");
232
+ let parts: Vec<String> = without_ext
233
+ .components()
234
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
235
+ .collect();
236
+ if parts.is_empty() || parts.iter().any(|p| p.is_empty()) {
237
+ return None;
238
+ }
239
+ Some(parts.join(":"))
240
+}
241
+
242
+/// Parse a command file into a [`CustomCommand`]. Frontmatter is entirely
243
+/// optional — a command with no frontmatter at all is just its raw body with
244
+/// no description or argument hint.
245
+fn parse_command(contents: &str, name: &str, path: &Path) -> CustomCommand {
246
+ let mut description = None;
247
+ let mut argument_hint = None;
248
+
249
+ if let Some(frontmatter) = extract_frontmatter(contents) {
250
+ let fields = parse_frontmatter_fields(frontmatter);
251
+ description = fields
252
+ .iter()
253
+ .find(|(k, _)| k == "description")
254
+ .map(|(_, v)| v.clone())
255
+ .filter(|v| !v.is_empty());
256
+ argument_hint = fields
257
+ .iter()
258
+ .find(|(k, _)| k == "argument-hint")
259
+ .map(|(_, v)| v.clone())
260
+ .filter(|v| !v.is_empty());
261
+ }
262
+
263
+ let body = strip_frontmatter(contents).trim().to_string();
264
+
265
+ CustomCommand {
266
+ name: name.to_string(),
267
+ description,
268
+ argument_hint,
269
+ body,
270
+ path: path.to_path_buf(),
271
+ }
272
+}
273
+
274
+#[cfg(test)]
275
+mod tests {
276
+ use super::*;
277
+ use std::fs;
278
+
279
+ fn unique_dir(name: &str) -> PathBuf {
280
+ let nanos = std::time::SystemTime::now()
281
+ .duration_since(std::time::UNIX_EPOCH)
282
+ .unwrap()
283
+ .as_nanos();
284
+ std::env::temp_dir().join(format!("sigit-commands-test-{name}-{nanos}"))
285
+ }
286
+
287
+ #[test]
288
+ fn render_substitutes_arguments_placeholder() {
289
+ let body = "Review PR: $ARGUMENTS";
290
+ assert_eq!(render(body, Some("#42")), "Review PR: #42");
291
+ assert_eq!(render(body, None), "Review PR: ");
292
+ }
293
+
294
+ #[test]
295
+ fn render_substitutes_positional_placeholders() {
296
+ let body = "Compare $1 against $2.";
297
+ assert_eq!(
298
+ render(body, Some("main feature-x")),
299
+ "Compare main against feature-x."
300
+ );
301
+ }
302
+
303
+ #[test]
304
+ fn render_appends_arguments_when_no_placeholder_present() {
305
+ let body = "Run the linter.";
306
+ assert_eq!(render(body, Some("--fix")), "Run the linter.\n\n--fix");
307
+ // No arguments given, nothing appended.
308
+ assert_eq!(render(body, None), "Run the linter.");
309
+ }
310
+
311
+ #[test]
312
+ fn command_name_namespaces_by_subdirectory() {
313
+ let root = Path::new("/proj/.sigit/commands");
314
+ assert_eq!(
315
+ command_name(root, &root.join("review.md")),
316
+ Some("review".to_string())
317
+ );
318
+ assert_eq!(
319
+ command_name(root, &root.join("git").join("commit.md")),
320
+ Some("git:commit".to_string())
321
+ );
322
+ }
323
+
324
+ #[test]
325
+ fn parse_command_reads_frontmatter_and_body() {
326
+ let contents =
327
+ "---\ndescription: Review a PR\nargument-hint: <pr-number>\n---\n\nReview PR #$1.\n";
328
+ let command = parse_command(contents, "review", Path::new("/x/review.md"));
329
+ assert_eq!(command.description.as_deref(), Some("Review a PR"));
330
+ assert_eq!(command.argument_hint.as_deref(), Some("<pr-number>"));
331
+ assert_eq!(command.body, "Review PR #$1.");
332
+ }
333
+
334
+ #[test]
335
+ fn parse_command_without_frontmatter_is_just_the_body() {
336
+ let contents = "Just do the thing.\n";
337
+ let command = parse_command(contents, "thing", Path::new("/x/thing.md"));
338
+ assert!(command.description.is_none());
339
+ assert!(command.argument_hint.is_none());
340
+ assert_eq!(command.body, "Just do the thing.");
341
+ }
342
+
343
+ #[test]
344
+ fn discover_and_resolve_roundtrip() {
345
+ let root = unique_dir("roundtrip");
346
+ let commands_root = root.join(".sigit").join("commands");
347
+ fs::create_dir_all(commands_root.join("git")).unwrap();
348
+ fs::write(
349
+ commands_root.join("review.md"),
350
+ "---\ndescription: Review the diff\n---\n\nReview: $ARGUMENTS\n",
351
+ )
352
+ .unwrap();
353
+ fs::write(
354
+ commands_root.join("git").join("commit.md"),
355
+ "Commit with message: $ARGUMENTS\n",
356
+ )
357
+ .unwrap();
358
+
359
+ // discover_commands() reads the current directory, so run from `root`.
360
+ let prev = std::env::current_dir().unwrap();
361
+ std::env::set_current_dir(&root).unwrap();
362
+
363
+ let commands = discover_commands();
364
+ assert_eq!(commands.len(), 2);
365
+ let review = commands.iter().find(|c| c.name == "review").unwrap();
366
+ assert_eq!(review.description.as_deref(), Some("Review the diff"));
367
+ let commit = commands.iter().find(|c| c.name == "git:commit").unwrap();
368
+ assert_eq!(commit.body, "Commit with message: $ARGUMENTS");
369
+
370
+ let resolved = resolve_command("/review").expect("resolve by slash-prefixed name");
371
+ assert_eq!(resolved.name, "review");
372
+ let resolved_bare = resolve_command("git:commit").expect("resolve by bare name");
373
+ assert_eq!(resolved_bare.name, "git:commit");
374
+ assert!(resolve_command("nope").is_none());
375
+
376
+ std::env::set_current_dir(prev).unwrap();
377
+ let _ = fs::remove_dir_all(&root);
378
+ }
379
+
380
+ #[test]
381
+ fn format_commands_list_reports_none_found() {
382
+ let root = unique_dir("empty");
383
+ fs::create_dir_all(&root).unwrap();
384
+ let prev = std::env::current_dir().unwrap();
385
+ std::env::set_current_dir(&root).unwrap();
386
+
387
+ assert!(format_commands_list().contains("No custom commands found"));
388
+
389
+ std::env::set_current_dir(prev).unwrap();
390
+ let _ = fs::remove_dir_all(&root);
391
+ }
392
+}