feat: add user-defined slash commands (.sigit/commands/*.md)

Custom slash commands are Markdown files with optional YAML frontmatter (description, argument-hint) and a prompt-template body. Discovered from .sigit/commands/ and .claude/commands/ in the project, then $SIGIT_CONFIG_DIR/commands/ and ~/.claude/commands/ (personal). A subdirectory namespaces the command with `:` — .sigit/commands/git/commit.md becomes /git:commit. Invoking one works like the existing /init special case: the body is rendered against whatever followed the command on the line ($ARGUMENTS for the whole string, $1.."$9" for positional words) and fed to the model as a normal turn through the ordinary tools and permission checks — no separate execution sandbox. Resolution sits behind parse_slash's existing Unknown(String) fallback (now Unknown(String, Option<String>) so the argument survives to the lookup) in both the TUI and ACP paths, so a command name only reaches "unknown command" once it fails to resolve as both a built-in and a custom command. Custom commands are also appended to advertise_commands so ACP clients like Zed actually forward them, skipping (with a warning) any that collide with a built-in name. Also adds /commands to list what's discovered, mirroring /skills. Extracted the SKILL.md frontmatter parser out of skills.rs into a new frontmatter.rs shared by both formats, since commands.rs needs the same "YAML frontmatter + Markdown body" parsing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

paydii committed Jul 10, 2026 at 19:25 UTC 51039d77ab55f412921238b258bcc8b632462e72
6 files changed +699 -164
.agents/AGENTS.md
+23 -6
@@ -119,6 +119,21 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
119 calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the
120 `*_as_specs`/`build_tool_specs` layer (not in `all_tools()`) so its description can be dynamic,
121 and only when at least one skill exists.
122 +- **`src/commands.rs`** — user-defined slash commands. Discovers Markdown files (each an
123 + optional YAML frontmatter block — `description`, `argument-hint` — followed by a prompt-template
124 + body) from `.sigit/commands/` and `.claude/commands/` in the cwd, `$SIGIT_CONFIG_DIR/commands/`,
125 + and `~/.claude/commands/`. A subdirectory namespaces the command with `:`
126 + (`.sigit/commands/git/commit.md` → `/git:commit`). Unlike skills there's no tool-call
127 + indirection: invoking one works exactly like the built-in `/init` — `commands::render`
128 + substitutes `$ARGUMENTS`/`$1..$9` in the body against whatever followed the command on the
129 + input line, and the result is fed to the model as a normal turn through the ordinary tools and
130 + permission checks. Resolution happens where each surface (`main.rs`/`chat.rs`) already
131 + special-cases `/init`: an unrecognized slash command is tried against `commands::resolve_command`
132 + before falling back to "unknown command". A custom command sharing a name with a built-in is
133 + unreachable (built-ins match first in `parse_slash`) and is skipped when advertised to ACP
134 + clients, with a warning logged.
135 +- **`src/frontmatter.rs`** — shared "YAML frontmatter + Markdown body" parsing used by both
136 + `src/skills.rs` (`SKILL.md`) and `src/commands.rs` (`.sigit/commands/*.md`).
137 - **`src/mcp.rs`** — [Model Context Protocol](https://modelcontextprotocol.io) *client*. Two
138 transports: **Streamable HTTP** (one JSON-RPC POST endpoint, `url` in `mcp.toml`; replies are
139 `application/json` or SSE) and **stdio** (`command` + optional `args`/`[server.env]` in
@@ -165,12 +180,14 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
180 - **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
181 - **`src/models.rs`** — model-picker types shared across platforms.
182
168 -Slash commands (`/help`, `/models`, `/skills`, `/mcp`, `/login`, `/logout`, `/whoami`, `/reload`,
169 -`/plan`, `/permissions`, `/init`, `/clear`, `/status`) are advertised via `advertise_commands` in
170 -`main.rs` and handled in both the TUI and ACP sessions. `/init` is special: instead of replying
171 -directly it substitutes `instructions::INIT_PROMPT` for the user text and runs a normal agent
172 -turn that explores the repo and writes (or improves) `AGENTS.md` through the ordinary tools and
173 -permission checks.
183 +Slash commands (`/help`, `/models`, `/skills`, `/commands`, `/mcp`, `/login`, `/logout`, `/whoami`,
184 +`/reload`, `/plan`, `/permissions`, `/init`, `/clear`, `/status`) are advertised via
185 +`advertise_commands` in `main.rs` and handled in both the TUI and ACP sessions. `/init` is
186 +special: instead of replying directly it substitutes `instructions::INIT_PROMPT` for the user
187 +text and runs a normal agent turn that explores the repo and writes (or improves) `AGENTS.md`
188 +through the ordinary tools and permission checks. User-defined commands (see `src/commands.rs`)
189 +get the same treatment via the `SlashCommand::Unknown` fallback path, so anyone can add their own
190 +`/name` commands without touching the built-in command list.
191
192 ## Model cache (macOS)
193
src/chat.rs
+40 -5
@@ -1356,6 +1356,8 @@ mod tui {
1356 Local(Option<bool>),
1357 /// List discovered Agent Skills.
1358 Skills,
1359 + /// List discovered user-defined commands (`.sigit/commands/*.md`).
1360 + Commands,
1361 /// List configured MCP servers and their tools.
1362 Mcp,
1363 /// explicitly load the selected (or default) on-device model
@@ -1383,7 +1385,10 @@ mod tui {
1385 /// turn with [`crate::instructions::INIT_PROMPT`] as the user text.
1386 Init,
1387 Exit,
1386 - Unknown(String),
1388 + /// An unrecognized command word, plus whatever followed it on the
1389 + /// line — a caller may still resolve it as a user-defined command
1390 + /// before falling back to "unknown command".
1391 + Unknown(String, Option<String>),
1392 }
1393
1394 fn parse_slash(input: &str) -> Option<SlashCommand> {
@@ -1401,6 +1406,7 @@ mod tui {
1406 "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
1407 "/local" => SlashCommand::Local(parse_on_off(arg)),
1408 "/skills" => SlashCommand::Skills,
1409 + "/commands" => SlashCommand::Commands,
1410 "/mcp" => SlashCommand::Mcp,
1411 "/load" => SlashCommand::Load,
1412 "/login" => SlashCommand::Login(arg.map(str::to_string)),
@@ -1414,7 +1420,7 @@ mod tui {
1420 "/resume" => SlashCommand::Resume,
1421 "/init" => SlashCommand::Init,
1422 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
1417 - other => SlashCommand::Unknown(other.to_string()),
1423 + other => SlashCommand::Unknown(other.to_string(), arg.map(str::to_string)),
1424 })
1425 }
1426
@@ -2120,6 +2126,7 @@ mod tui {
2126 /models N — switch to model N\n\
2127 /local [on|off]— toggle on-device inference mode\n\
2128 /skills — list available Agent Skills\n\
2129 + /commands — list user-defined commands (.sigit/commands/*.md)\n\
2130 /mcp — list MCP servers and their tools\n\
2131 /load — load the selected on-device model\n\
2132 /login E P — sign in to siGit Code Cloud\n\
@@ -2237,6 +2244,10 @@ mod tui {
2244 app.messages
2245 .push(ChatMessage::system(crate::skills::format_skills_list()));
2246 }
2247 + SlashCommand::Commands => {
2248 + app.messages
2249 + .push(ChatMessage::system(crate::commands::format_commands_list()));
2250 + }
2251 SlashCommand::Mcp => {
2252 app.messages
2253 .push(ChatMessage::system(crate::mcp::status_summary()));
@@ -2358,9 +2369,10 @@ mod tui {
2369 SlashCommand::Exit => {
2370 app.quit = true;
2371 }
2361 - SlashCommand::Unknown(cmd) => {
2362 - app.messages
2363 - .push(ChatMessage::system(format!("unknown command: {cmd}")));
2372 + SlashCommand::Unknown(cmd, _) => {
2373 + app.messages.push(ChatMessage::system(format!(
2374 + "unknown command: {cmd}. Type /commands to list user-defined commands."
2375 + )));
2376 }
2377 }
2378 }
@@ -3027,12 +3039,35 @@ mod tui {
3039 // `/init` runs a real agent turn: the transcript
3040 // shows the command the user typed, but the model
3041 // receives the canned AGENTS.md-generation prompt.
3042 + // User-defined commands (`.sigit/commands/*.md`)
3043 + // get the same treatment once `parse_slash`
3044 + // reports the word unrecognized.
3045 let mut inference_text = text.clone();
3046 match parse_slash(&text) {
3047 Some(SlashCommand::Init) => {
3048 inference_text =
3049 crate::instructions::INIT_PROMPT.to_string();
3050 }
3051 + Some(SlashCommand::Unknown(command, argument)) => {
3052 + match crate::commands::resolve_command(&command) {
3053 + Some(custom) => {
3054 + inference_text = crate::commands::render(
3055 + &custom.body,
3056 + argument.as_deref(),
3057 + );
3058 + }
3059 + None => {
3060 + exec_slash(
3061 + &mut app,
3062 + SlashCommand::Unknown(command, argument),
3063 + Arc::clone(&engine),
3064 + terminal,
3065 + )
3066 + .await;
3067 + continue;
3068 + }
3069 + }
3070 + }
3071 Some(cmd) => {
3072 exec_slash(&mut app, cmd, Arc::clone(&engine), terminal)
3073 .await;
src/commands.rs new
+392
@@ -0,0 +1,392 @@
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 +}
src/frontmatter.rs new
+169
@@ -0,0 +1,169 @@
1 +//! Shared parsing for Markdown files with a leading YAML-ish frontmatter
2 +//! block, e.g.:
3 +//!
4 +//! ```markdown
5 +//! ---
6 +//! name: foo
7 +//! description: bar
8 +//! ---
9 +//!
10 +//! Body text.
11 +//! ```
12 +//!
13 +//! Used by [`crate::skills`] (`SKILL.md`) and [`crate::commands`]
14 +//! (`.sigit/commands/*.md`) — both need the same "top-level `key: value`
15 +//! scalars, then a Markdown body" shape, so the parsing lives here once
16 +//! rather than being copied per format.
17 +
18 +/// Extract the YAML frontmatter block from `contents`: the text between a
19 +/// leading `---` line and the next `---` line. Returns `None` if absent.
20 +pub fn extract_frontmatter(contents: &str) -> Option<&str> {
21 + // Strip an optional UTF-8 BOM and leading blank lines before the opener.
22 + let trimmed = contents.trim_start_matches('\u{feff}');
23 + let mut rest = trimmed;
24 + loop {
25 + let line_end = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
26 + let (line, after) = rest.split_at(line_end);
27 + if line.trim().is_empty() {
28 + rest = after;
29 + continue;
30 + }
31 + if line.trim() != "---" {
32 + return None;
33 + }
34 + // `after` now begins just past the opening `---` line.
35 + let body = after;
36 + let mut search = body;
37 + let mut offset = 0;
38 + loop {
39 + let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
40 + let (l, a) = search.split_at(end);
41 + if l.trim() == "---" {
42 + return Some(&body[..offset]);
43 + }
44 + if a.is_empty() {
45 + return None;
46 + }
47 + offset += end;
48 + search = a;
49 + }
50 + }
51 +}
52 +
53 +/// Parse top-level `key: value` scalar pairs from frontmatter, skipping
54 +/// nested mappings (indented lines) and comments. Quoted values are
55 +/// unquoted.
56 +pub fn parse_frontmatter_fields(frontmatter: &str) -> Vec<(String, String)> {
57 + let mut fields = Vec::new();
58 + for line in frontmatter.lines() {
59 + // Indented lines belong to a nested mapping/sequence — skip them.
60 + if line.starts_with(char::is_whitespace) {
61 + continue;
62 + }
63 + let line = line.trim_end();
64 + if line.is_empty() || line.starts_with('#') {
65 + continue;
66 + }
67 + let Some((key, value)) = line.split_once(':') else {
68 + continue;
69 + };
70 + let key = key.trim();
71 + if key.is_empty() {
72 + continue;
73 + }
74 + let value = unquote(value.trim());
75 + fields.push((key.to_string(), value));
76 + }
77 + fields
78 +}
79 +
80 +/// Strip a single layer of matching single or double quotes; otherwise
81 +/// return the value unchanged.
82 +pub fn unquote(value: &str) -> String {
83 + if value.len() >= 2 {
84 + let bytes = value.as_bytes();
85 + let first = bytes[0];
86 + let last = bytes[value.len() - 1];
87 + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
88 + return value[1..value.len() - 1].to_string();
89 + }
90 + }
91 + value.to_string()
92 +}
93 +
94 +/// Return the content after the frontmatter block, or the whole input if
95 +/// there is no frontmatter.
96 +pub fn strip_frontmatter(contents: &str) -> &str {
97 + let trimmed = contents.trim_start_matches('\u{feff}');
98 + let after_opener = match trimmed.strip_prefix("---") {
99 + Some(rest) => match rest.strip_prefix('\n') {
100 + Some(rest) => rest,
101 + None => return contents,
102 + },
103 + None => return contents,
104 + };
105 + // Find the closing `---` line.
106 + let mut search = after_opener;
107 + let mut offset = 0;
108 + loop {
109 + let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
110 + let (line, after) = search.split_at(end);
111 + if line.trim() == "---" {
112 + return &after_opener[offset + end..];
113 + }
114 + if after.is_empty() {
115 + return contents;
116 + }
117 + offset += end;
118 + search = after;
119 + }
120 +}
121 +
122 +#[cfg(test)]
123 +mod tests {
124 + use super::*;
125 +
126 + #[test]
127 + fn extract_frontmatter_reads_block() {
128 + let md = "---\nname: foo\ndescription: bar\n---\n\nBody here.\n";
129 + let fm = extract_frontmatter(md).expect("frontmatter");
130 + assert!(fm.contains("name: foo"));
131 + assert!(fm.contains("description: bar"));
132 + assert!(!fm.contains("Body here"));
133 + }
134 +
135 + #[test]
136 + fn extract_frontmatter_none_without_delimiter() {
137 + assert!(extract_frontmatter("no frontmatter here").is_none());
138 + assert!(extract_frontmatter("---\nname: foo\n").is_none());
139 + }
140 +
141 + #[test]
142 + fn parse_fields_handles_quotes_and_nesting() {
143 + let fm = "name: pdf-processing\ndescription: \"Extract PDF text\"\nmetadata:\n author: me\n version: \"1.0\"\nlicense: Apache-2.0\n";
144 + let fields = parse_frontmatter_fields(fm);
145 + let get = |k: &str| {
146 + fields
147 + .iter()
148 + .find(|(key, _)| key == k)
149 + .map(|(_, v)| v.clone())
150 + };
151 + assert_eq!(get("name").as_deref(), Some("pdf-processing"));
152 + assert_eq!(get("description").as_deref(), Some("Extract PDF text"));
153 + assert_eq!(get("license").as_deref(), Some("Apache-2.0"));
154 + // Nested keys under `metadata:` are skipped.
155 + assert!(get("author").is_none());
156 + assert!(get("version").is_none());
157 + }
158 +
159 + #[test]
160 + fn strip_frontmatter_returns_body() {
161 + let md = "---\nname: foo\ndescription: bar\n---\n\n# Heading\n\nText.\n";
162 + assert_eq!(strip_frontmatter(md).trim(), "# Heading\n\nText.");
163 + }
164 +
165 + #[test]
166 + fn strip_frontmatter_passes_through_without_block() {
167 + assert_eq!(strip_frontmatter("just body"), "just body");
168 + }
169 +}
src/main.rs
+73 -5
@@ -31,7 +31,9 @@
31 mod account;
32 mod backend;
33 mod chat;
34 +mod commands;
35 mod credentials;
36 +mod frontmatter;
37 mod headless;
38 mod instructions;
39 mod mcp;
@@ -772,7 +774,7 @@ impl SiGitAgent {
774 UnstructuredCommandInput::new(hint),
775 ))
776 };
775 - let commands = vec![
777 + let mut commands = vec![
778 AvailableCommand::new("help", "Show available commands"),
779 AvailableCommand::new("models", "List available models").input(
780 AvailableCommandInput::Unstructured(UnstructuredCommandInput::new(
@@ -785,6 +787,10 @@ impl SiGitAgent {
787 "on|off (optional)",
788 ),
789 AvailableCommand::new("skills", "List available Agent Skills"),
790 + AvailableCommand::new(
791 + "commands",
792 + "List user-defined commands (.sigit/commands/*.md)",
793 + ),
794 AvailableCommand::new("mcp", "List MCP servers and their tools"),
795 AvailableCommand::new("load", "Load the selected on-device model"),
796 with_hint("login", "Sign in to siGit Code Cloud", "<email> <password>"),
@@ -802,6 +808,32 @@ impl SiGitAgent {
808 AvailableCommand::new("clear", "Wipe the conversation history"),
809 AvailableCommand::new("status", "Show engine status"),
810 ];
811 +
812 + // User-defined commands (.sigit/commands/*.md etc.) are appended so
813 + // clients that only forward advertised commands (Zed) can actually
814 + // send them. A custom command sharing a name with a built-in is
815 + // unreachable — parse_slash always matches built-ins first — so skip
816 + // advertising the shadowed duplicate rather than confuse the client.
817 + let built_in_names: Vec<String> = commands.iter().map(|c| c.name.to_string()).collect();
818 + for command in commands::discover_commands() {
819 + if built_in_names.contains(&command.name) {
820 + log::warn!(
821 + "custom command \"{}\" at {} is shadowed by a built-in command of the same name",
822 + command.name,
823 + command.path.display()
824 + );
825 + continue;
826 + }
827 + let description = command
828 + .description
829 + .clone()
830 + .unwrap_or_else(|| "User-defined command".to_string());
831 + commands.push(match &command.argument_hint {
832 + Some(hint) => with_hint(&command.name, &description, hint),
833 + None => AvailableCommand::new(&command.name, &description),
834 + });
835 + }
836 +
837 self.send_tool_call_update(
838 cx,
839 session_id,
@@ -1275,6 +1307,24 @@ impl SiGitAgent {
1307 // exploration and file write go through the ordinary tools and
1308 // permission checks.
1309 Some(SlashCommand::Init) => instructions::INIT_PROMPT.to_string(),
1310 + // A word `parse_slash` didn't recognize might be a user-defined
1311 + // command (`.sigit/commands/*.md`) — same substitute-and-run-a-
1312 + // real-turn treatment as `/init`. Only fall back to "unknown
1313 + // command" once that lookup also misses.
1314 + Some(SlashCommand::Unknown(command, argument)) => {
1315 + match commands::resolve_command(&command) {
1316 + Some(custom) => commands::render(&custom.body, argument.as_deref()),
1317 + None => {
1318 + return exec_slash_acp(
1319 + self,
1320 + cx,
1321 + session_id,
1322 + SlashCommand::Unknown(command, argument),
1323 + )
1324 + .await;
1325 + }
1326 + }
1327 + }
1328 Some(command) => return exec_slash_acp(self, cx, session_id, command).await,
1329 None => user_text,
1330 };
@@ -2224,6 +2274,8 @@ enum SlashCommand {
2274 Local(Option<bool>),
2275 /// List discovered Agent Skills.
2276 Skills,
2277 + /// List discovered user-defined commands (`.sigit/commands/*.md`).
2278 + Commands,
2279 /// List configured MCP servers and their tools.
2280 Mcp,
2281 /// Explicitly load the selected (or default) on-device model.
@@ -2245,7 +2297,10 @@ enum SlashCommand {
2297 /// with [`instructions::INIT_PROMPT`] as the user text.
2298 Init,
2299 Exit,
2248 - Unknown(String),
2300 + /// An unrecognized command word, plus whatever followed it on the line
2301 + /// (untouched — a caller may still resolve it as a user-defined command
2302 + /// before falling back to "unknown command").
2303 + Unknown(String, Option<String>),
2304 }
2305
2306 fn parse_slash(input: &str) -> Option<SlashCommand> {
@@ -2263,6 +2318,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
2318 "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
2319 "/local" => SlashCommand::Local(parse_on_off(argument)),
2320 "/skills" => SlashCommand::Skills,
2321 + "/commands" => SlashCommand::Commands,
2322 "/mcp" => SlashCommand::Mcp,
2323 "/load" => SlashCommand::Load,
2324 "/login" => SlashCommand::Login(argument.map(str::to_string)),
@@ -2274,7 +2330,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
2330 "/compact" => SlashCommand::Compact,
2331 "/init" => SlashCommand::Init,
2332 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
2277 - other => SlashCommand::Unknown(other.to_string()),
2333 + other => SlashCommand::Unknown(other.to_string(), argument.map(str::to_string)),
2334 })
2335 }
2336
@@ -2375,6 +2431,7 @@ async fn exec_slash_acp(
2431 /models N - switch to model N\n\
2432 /local [on|off]- toggle on-device inference mode\n\
2433 /skills - list available Agent Skills\n\
2434 + /commands - list user-defined commands (.sigit/commands/*.md)\n\
2435 /mcp - list MCP servers and their tools\n\
2436 /load - load the selected on-device model\n\
2437 /login E P - sign in to siGit Code Cloud\n\
@@ -2464,6 +2521,11 @@ async fn exec_slash_acp(
2521 .send_assistant_message(cx, session_id, skills::format_skills_list())
2522 .ok();
2523 }
2524 + SlashCommand::Commands => {
2525 + agent
2526 + .send_assistant_message(cx, session_id, commands::format_commands_list())
2527 + .ok();
2528 + }
2529 SlashCommand::Mcp => {
2530 agent
2531 .send_assistant_message(cx, session_id, mcp::status_summary())
@@ -2661,9 +2723,15 @@ async fn exec_slash_acp(
2723 .send_assistant_message(cx, session_id, "Send /init as a prompt to run it.")
2724 .ok();
2725 }
2664 - SlashCommand::Unknown(command) => {
2726 + SlashCommand::Unknown(command, _) => {
2727 agent
2666 - .send_assistant_message(cx, session_id, format!("unknown command: {command}"))
2728 + .send_assistant_message(
2729 + cx,
2730 + session_id,
2731 + format!(
2732 + "unknown command: {command}. Type /commands to list user-defined commands."
2733 + ),
2734 + )
2735 .ok();
2736 }
2737 }
src/skills.rs
+2 -148
@@ -25,6 +25,8 @@ use std::path::{Path, PathBuf};
25
26 use serde_json::{Value, json};
27
28 +use crate::frontmatter::{extract_frontmatter, parse_frontmatter_fields, strip_frontmatter};
29 +
30 /// The agent-facing tool name used to activate a skill.
31 pub const SKILL_TOOL_NAME: &str = "skill";
32
@@ -355,82 +357,6 @@ fn validate_name(name: &str) -> Result<(), String> {
357 Ok(())
358 }
359
358 -/// Extract the YAML frontmatter block from a `SKILL.md`: the text between a
359 -/// leading `---` line and the next `---` line. Returns `None` if absent.
360 -fn extract_frontmatter(contents: &str) -> Option<&str> {
361 - // Strip an optional UTF-8 BOM and leading blank lines before the opener.
362 - let trimmed = contents.trim_start_matches('\u{feff}');
363 - let mut rest = trimmed;
364 - loop {
365 - let line_end = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
366 - let (line, after) = rest.split_at(line_end);
367 - if line.trim().is_empty() {
368 - rest = after;
369 - continue;
370 - }
371 - if line.trim() != "---" {
372 - return None;
373 - }
374 - // `after` now begins just past the opening `---` line.
375 - let body = after;
376 - let mut search = body;
377 - let mut offset = 0;
378 - loop {
379 - let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
380 - let (l, a) = search.split_at(end);
381 - if l.trim() == "---" {
382 - return Some(&body[..offset]);
383 - }
384 - if a.is_empty() {
385 - return None;
386 - }
387 - offset += end;
388 - search = a;
389 - }
390 - }
391 -}
392 -
393 -/// Parse top-level `key: value` scalar pairs from frontmatter, skipping nested
394 -/// mappings (indented lines) and comments. Quoted values are unquoted. We only
395 -/// need scalar metadata (`name`, `description`, `license`, `compatibility`).
396 -fn parse_frontmatter_fields(frontmatter: &str) -> Vec<(String, String)> {
397 - let mut fields = Vec::new();
398 - for line in frontmatter.lines() {
399 - // Indented lines belong to a nested mapping/sequence — skip them.
400 - if line.starts_with(char::is_whitespace) {
401 - continue;
402 - }
403 - let line = line.trim_end();
404 - if line.is_empty() || line.starts_with('#') {
405 - continue;
406 - }
407 - let Some((key, value)) = line.split_once(':') else {
408 - continue;
409 - };
410 - let key = key.trim();
411 - if key.is_empty() {
412 - continue;
413 - }
414 - let value = unquote(value.trim());
415 - fields.push((key.to_string(), value));
416 - }
417 - fields
418 -}
419 -
420 -/// Strip a single layer of matching single or double quotes; otherwise return
421 -/// the value unchanged. Also drops a trailing `# comment` on unquoted scalars.
422 -fn unquote(value: &str) -> String {
423 - if value.len() >= 2 {
424 - let bytes = value.as_bytes();
425 - let first = bytes[0];
426 - let last = bytes[value.len() - 1];
427 - if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
428 - return value[1..value.len() - 1].to_string();
429 - }
430 - }
431 - value.to_string()
432 -}
433 -
360 /// Read the Markdown body of a `SKILL.md` (everything after the frontmatter),
361 /// falling back to the whole file if no frontmatter delimiter is found.
362 fn read_skill_body(skill_md: &Path) -> Result<String, String> {
@@ -438,34 +364,6 @@ fn read_skill_body(skill_md: &Path) -> Result<String, String> {
364 Ok(strip_frontmatter(&contents).trim().to_string())
365 }
366
441 -/// Return the content after the frontmatter block, or the whole input if there
442 -/// is no frontmatter.
443 -fn strip_frontmatter(contents: &str) -> &str {
444 - let trimmed = contents.trim_start_matches('\u{feff}');
445 - let after_opener = match trimmed.strip_prefix("---") {
446 - Some(rest) => match rest.strip_prefix('\n') {
447 - Some(rest) => rest,
448 - None => return contents,
449 - },
450 - None => return contents,
451 - };
452 - // Find the closing `---` line.
453 - let mut search = after_opener;
454 - let mut offset = 0;
455 - loop {
456 - let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
457 - let (line, after) = search.split_at(end);
458 - if line.trim() == "---" {
459 - return &after_opener[offset + end..];
460 - }
461 - if after.is_empty() {
462 - return contents;
463 - }
464 - offset += end;
465 - search = after;
466 - }
467 -}
468 -
367 #[cfg(test)]
368 mod tests {
369 use super::*;
@@ -499,39 +397,6 @@ mod tests {
397 assert!(validate_name(&"a".repeat(65)).is_err());
398 }
399
502 - #[test]
503 - fn extract_frontmatter_reads_block() {
504 - let md = "---\nname: foo\ndescription: bar\n---\n\nBody here.\n";
505 - let fm = extract_frontmatter(md).expect("frontmatter");
506 - assert!(fm.contains("name: foo"));
507 - assert!(fm.contains("description: bar"));
508 - assert!(!fm.contains("Body here"));
509 - }
510 -
511 - #[test]
512 - fn extract_frontmatter_none_without_delimiter() {
513 - assert!(extract_frontmatter("no frontmatter here").is_none());
514 - assert!(extract_frontmatter("---\nname: foo\n").is_none());
515 - }
516 -
517 - #[test]
518 - fn parse_fields_handles_quotes_and_nesting() {
519 - let fm = "name: pdf-processing\ndescription: \"Extract PDF text\"\nmetadata:\n author: me\n version: \"1.0\"\nlicense: Apache-2.0\n";
520 - let fields = parse_frontmatter_fields(fm);
521 - let get = |k: &str| {
522 - fields
523 - .iter()
524 - .find(|(key, _)| key == k)
525 - .map(|(_, v)| v.clone())
526 - };
527 - assert_eq!(get("name").as_deref(), Some("pdf-processing"));
528 - assert_eq!(get("description").as_deref(), Some("Extract PDF text"));
529 - assert_eq!(get("license").as_deref(), Some("Apache-2.0"));
530 - // Nested keys under `metadata:` are skipped.
531 - assert!(get("author").is_none());
532 - assert!(get("version").is_none());
533 - }
534 -
400 #[test]
401 fn parse_skill_requires_name_and_description() {
402 let dir = Path::new("/tmp/example-skill");
@@ -547,17 +412,6 @@ mod tests {
412 assert_eq!(skill.description, "does things");
413 }
414
550 - #[test]
551 - fn strip_frontmatter_returns_body() {
552 - let md = "---\nname: foo\ndescription: bar\n---\n\n# Heading\n\nText.\n";
553 - assert_eq!(strip_frontmatter(md).trim(), "# Heading\n\nText.");
554 - }
555 -
556 - #[test]
557 - fn strip_frontmatter_passes_through_without_block() {
558 - assert_eq!(strip_frontmatter("just body"), "just body");
559 - }
560 -
415 #[test]
416 fn discover_and_activate_roundtrip() {
417 let root = unique_dir("roundtrip");