Skip to main content

bamboo_tools/
slash_commands.rs

1//! Slash command management for Bamboo agents.
2//!
3//! This module provides functionality for discovering, loading, and managing
4//! slash commands from both project-level and user-level directories.
5//!
6//! # Command Discovery
7//!
8//! Commands are discovered from multiple locations:
9//! - **Default commands**: Built-in commands like `/add-dir`, `/init`, `/review`
10//! - **Project commands**: `.claude/commands/` directory in the project root
11//! - **User commands**: `~/.claude/commands/` directory in the user's home
12//!
13//! # Command Format
14//!
15//! Commands are Markdown files with optional YAML frontmatter:
16//!
17//! ```markdown
18//! ---
19//! description: Build the project
20//! allowed-tools:
21//!   - execute_command
22//!   - read_file
23//! ---
24//!
25//! Run the following command to build:
26//! !`cargo build --release`
27//! ```
28//!
29//! # Namespaced Commands
30//!
31//! Commands can be organized in subdirectories to create namespaces:
32//! - `commands/dev/build.md` → `/dev:build`
33//! - `commands/team/review.md` → `/team:review`
34//!
35//! # Example
36//!
37//! ```rust,ignore
38//! use bamboo_agent::commands::slash_commands::slash_commands_list;
39//!
40//! #[tokio::main]
41//! async fn main() {
42//!     // List all available commands
43//!     let commands = slash_commands_list(Some("./my-project".to_string()))
44//!         .await
45//!         .unwrap();
46//!
47//!     for cmd in commands {
48//!         println!("{}: {:?}", cmd.full_command, cmd.description);
49//!     }
50//! }
51//! ```
52
53use anyhow::{Context, Result};
54use dirs;
55use serde::{Deserialize, Serialize};
56use std::fs;
57use std::path::{Path, PathBuf};
58use tracing::{debug, error, info};
59
60/// Represents a slash command with its metadata and content.
61///
62/// A slash command is a reusable prompt template that can be invoked
63/// by the user using the `/` prefix. Commands can be namespaced and
64/// may include special syntax for bash commands, file references,
65/// and argument placeholders.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct SlashCommand {
68    /// Unique identifier for the command (e.g., "project-commands-build-md").
69    pub id: String,
70
71    /// The command name without namespace (e.g., "build").
72    pub name: String,
73
74    /// The full command including namespace prefix (e.g., "/dev:build").
75    pub full_command: String,
76
77    /// The scope where the command is defined: "default", "project", or "user".
78    pub scope: String,
79
80    /// Optional namespace for the command (e.g., "dev" for "/dev:build").
81    pub namespace: Option<String>,
82
83    /// Absolute path to the command file on disk.
84    pub file_path: String,
85
86    /// The Markdown content of the command (body only, without frontmatter).
87    pub content: String,
88
89    /// Human-readable description of what the command does.
90    pub description: Option<String>,
91
92    /// List of tool names that this command is allowed to use.
93    /// If empty, all tools are allowed.
94    pub allowed_tools: Vec<String>,
95
96    /// Whether the command contains bash command syntax (!`...`).
97    pub has_bash_commands: bool,
98
99    /// Whether the command contains file reference syntax (@file).
100    pub has_file_references: bool,
101
102    /// Whether the command accepts $ARGUMENTS placeholder.
103    pub accepts_arguments: bool,
104}
105
106/// YAML frontmatter metadata for a command.
107///
108/// This structure represents the optional YAML frontmatter that can
109/// appear at the beginning of a command Markdown file, enclosed by `---`.
110#[derive(Debug, Deserialize)]
111struct CommandFrontmatter {
112    /// List of tool names that this command is allowed to invoke.
113    #[serde(rename = "allowed-tools")]
114    allowed_tools: Option<Vec<String>>,
115
116    /// Human-readable description of the command's purpose.
117    description: Option<String>,
118}
119
120/// Parse Markdown content with optional YAML frontmatter.
121///
122/// Extracts YAML metadata enclosed between `---` delimiters at the
123/// beginning of the file, returning both the parsed frontmatter
124/// and the remaining body content.
125///
126/// # Arguments
127///
128/// * `content` - The raw file content to parse.
129///
130/// # Returns
131///
132/// A tuple containing:
133/// - `Option<CommandFrontmatter>` - Parsed frontmatter if present and valid.
134/// - `String` - The body content (everything after the frontmatter).
135///
136/// # Example
137///
138/// ```ignore
139/// let content = "---\ndescription: Test\n---\nBody content";
140/// let (frontmatter, body) = parse_markdown_with_frontmatter(content)?;
141/// assert_eq!(body, "Body content");
142/// ```
143fn parse_markdown_with_frontmatter(content: &str) -> Result<(Option<CommandFrontmatter>, String)> {
144    let lines: Vec<&str> = content.lines().collect();
145
146    if lines.is_empty() || lines[0] != "---" {
147        return Ok((None, content.to_string()));
148    }
149
150    let mut frontmatter_end = None;
151    for (i, line) in lines.iter().enumerate().skip(1) {
152        if *line == "---" {
153            frontmatter_end = Some(i);
154            break;
155        }
156    }
157
158    if let Some(end) = frontmatter_end {
159        let frontmatter_content = lines[1..end].join("\n");
160        let body_content = lines[(end + 1)..].join("\n");
161
162        match serde_yaml::from_str::<CommandFrontmatter>(&frontmatter_content) {
163            Ok(frontmatter) => Ok((Some(frontmatter), body_content)),
164            Err(e) => {
165                debug!("Failed to parse frontmatter: {}", e);
166                Ok((None, content.to_string()))
167            }
168        }
169    } else {
170        Ok((None, content.to_string()))
171    }
172}
173
174/// Extract command name and namespace from file path.
175///
176/// Parses the file path relative to the base commands directory to
177/// determine the command name and optional namespace hierarchy.
178///
179/// # Arguments
180///
181/// * `file_path` - Absolute path to the command file.
182/// * `base_path` - Base directory for commands (e.g., `.claude/commands`).
183///
184/// # Returns
185///
186/// A tuple of `(name, namespace)` where:
187/// - `name` is the command name (filename without extension).
188/// - `namespace` is `None` for top-level commands, or `Some("ns1:ns2")`
189///   for nested commands.
190///
191/// # Examples
192///
193/// - `commands/build.md` → `("build", None)`
194/// - `commands/dev/build.md` → `("build", Some("dev"))`
195/// - `commands/team/dev/build.md` → `("build", Some("team:dev"))`
196fn extract_command_info(file_path: &Path, base_path: &Path) -> Result<(String, Option<String>)> {
197    let relative_path = file_path
198        .strip_prefix(base_path)
199        .context("Failed to get relative path")?;
200
201    let path_without_ext = relative_path
202        .with_extension("")
203        .to_string_lossy()
204        .to_string();
205
206    let components: Vec<&str> = path_without_ext.split(std::path::MAIN_SEPARATOR).collect();
207
208    if components.is_empty() {
209        return Err(anyhow::anyhow!("Invalid command path"));
210    }
211
212    if components.len() == 1 {
213        Ok((components[0].to_string(), None))
214    } else {
215        let Some((command_name, namespace_parts)) = components.split_last() else {
216            return Err(anyhow::anyhow!("Invalid command path"));
217        };
218        let namespace = namespace_parts.join(":");
219        Ok(((*command_name).to_string(), Some(namespace)))
220    }
221}
222
223/// Load a slash command from a Markdown file.
224///
225/// Reads the file content, parses frontmatter, extracts command metadata,
226/// and constructs a complete `SlashCommand` instance.
227///
228/// # Arguments
229///
230/// * `file_path` - Path to the command Markdown file.
231/// * `base_path` - Base commands directory for calculating relative paths.
232/// * `scope` - The command scope ("default", "project", or "user").
233///
234/// # Returns
235///
236/// A `SlashCommand` with all metadata populated.
237///
238/// # Errors
239///
240/// Returns an error if:
241/// - The file cannot be read.
242/// - The file path is invalid.
243/// - YAML frontmatter is malformed (logs warning and continues without it).
244fn load_command_from_file(file_path: &Path, base_path: &Path, scope: &str) -> Result<SlashCommand> {
245    debug!("Loading command from: {:?}", file_path);
246
247    let content = fs::read_to_string(file_path).context("Failed to read command file")?;
248
249    let (frontmatter, body) = parse_markdown_with_frontmatter(&content)?;
250
251    let (name, namespace) = extract_command_info(file_path, base_path)?;
252
253    let full_command = match &namespace {
254        Some(ns) => format!("/{ns}:{name}"),
255        None => format!("/{name}"),
256    };
257
258    let id = format!(
259        "{}-{}",
260        scope,
261        file_path.to_string_lossy().replace(['/', '\\'], "-")
262    );
263
264    let has_bash_commands = body.contains("!`");
265    let has_file_references = body.contains('@');
266    let accepts_arguments = body.contains("$ARGUMENTS");
267
268    let (description, allowed_tools) = if let Some(fm) = frontmatter {
269        (fm.description, fm.allowed_tools.unwrap_or_default())
270    } else {
271        (None, Vec::new())
272    };
273
274    Ok(SlashCommand {
275        id,
276        name,
277        full_command,
278        scope: scope.to_string(),
279        namespace,
280        file_path: file_path.to_string_lossy().to_string(),
281        content: body,
282        description,
283        allowed_tools,
284        has_bash_commands,
285        has_file_references,
286        accepts_arguments,
287    })
288}
289
290/// Recursively find all Markdown files in a directory.
291///
292/// Traverses the directory tree and collects all `.md` files,
293/// skipping hidden files and directories (starting with `.`).
294///
295/// # Arguments
296///
297/// * `dir` - Directory to search.
298/// * `files` - Vector to collect found file paths.
299///
300/// # Returns
301///
302/// `Ok(())` on success, or an error if directory traversal fails.
303fn find_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
304    if !dir.exists() {
305        return Ok(());
306    }
307
308    let mut stack = vec![dir.to_path_buf()];
309    let mut visited = std::collections::HashSet::new();
310
311    while let Some(current_dir) = stack.pop() {
312        let canonical = match fs::canonicalize(&current_dir) {
313            Ok(path) => path,
314            Err(_) => current_dir.clone(),
315        };
316        if !visited.insert(canonical) {
317            continue;
318        }
319
320        for entry in fs::read_dir(&current_dir)? {
321            let entry = entry?;
322            let path = entry.path();
323
324            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
325                if name.starts_with('.') {
326                    continue;
327                }
328            }
329
330            let metadata = fs::symlink_metadata(&path)?;
331            if metadata.file_type().is_symlink() {
332                continue;
333            }
334
335            if metadata.is_dir() {
336                stack.push(path);
337            } else if metadata.is_file() && path.extension().is_some_and(|ext| ext == "md") {
338                files.push(path);
339            }
340        }
341    }
342
343    Ok(())
344}
345
346/// Create the list of default built-in commands.
347///
348/// These commands are always available and do not require files on disk:
349/// - `/add-dir` - Add additional working directories.
350/// - `/init` - Initialize project with CLAUDE.md guide.
351/// - `/review` - Request code review.
352fn create_default_commands() -> Vec<SlashCommand> {
353    vec![
354        SlashCommand {
355            id: "default-add-dir".to_string(),
356            name: "add-dir".to_string(),
357            full_command: "/add-dir".to_string(),
358            scope: "default".to_string(),
359            namespace: None,
360            file_path: "".to_string(),
361            content: "Add additional working directories".to_string(),
362            description: Some("Add additional working directories".to_string()),
363            allowed_tools: vec![],
364            has_bash_commands: false,
365            has_file_references: false,
366            accepts_arguments: false,
367        },
368        SlashCommand {
369            id: "default-init".to_string(),
370            name: "init".to_string(),
371            full_command: "/init".to_string(),
372            scope: "default".to_string(),
373            namespace: None,
374            file_path: "".to_string(),
375            content: "Initialize project with CLAUDE.md guide".to_string(),
376            description: Some("Initialize project with CLAUDE.md guide".to_string()),
377            allowed_tools: vec![],
378            has_bash_commands: false,
379            has_file_references: false,
380            accepts_arguments: false,
381        },
382        SlashCommand {
383            id: "default-review".to_string(),
384            name: "review".to_string(),
385            full_command: "/review".to_string(),
386            scope: "default".to_string(),
387            namespace: None,
388            file_path: "".to_string(),
389            content: "Request code review".to_string(),
390            description: Some("Request code review".to_string()),
391            allowed_tools: vec![],
392            has_bash_commands: false,
393            has_file_references: false,
394            accepts_arguments: false,
395        },
396        SlashCommand {
397            id: "default-plan".to_string(),
398            name: "plan".to_string(),
399            full_command: "/plan".to_string(),
400            scope: "default".to_string(),
401            namespace: None,
402            file_path: "".to_string(),
403            content: "I want you to enter plan mode. This is a complex task that requires exploration and design before implementation.\n\nUse the EnterPlanMode tool to switch to read-only exploration. Then:\n1. Explore the codebase to understand the problem\n2. Design a concrete implementation approach\n3. Break the work into steps using the Task tool\n4. Write the plan to the plan file\n5. Exit plan mode when ready for my review".to_string(),
404            description: Some("Enter plan mode for complex tasks".to_string()),
405            allowed_tools: vec![],
406            has_bash_commands: false,
407            has_file_references: false,
408            accepts_arguments: false,
409        },
410    ]
411}
412
413/// List all available slash commands.
414///
415/// Discovers and loads slash commands from:
416/// 1. Default built-in commands.
417/// 2. Project-level commands in `.claude/commands/`.
418/// 3. User-level commands in `~/.claude/commands/`.
419///
420/// Commands from all sources are merged, with project and user commands
421/// taking precedence over defaults.
422///
423/// # Arguments
424///
425/// * `project_path` - Optional path to the project root directory.
426///
427/// # Returns
428///
429/// A vector of all discovered `SlashCommand` instances.
430///
431/// # Errors
432///
433/// Returns an error string if command loading fails critically.
434///
435/// # Example
436///
437/// ```rust,ignore
438/// let commands = slash_commands_list(Some("./my-project".to_string())).await?;
439/// for cmd in commands {
440///     println!("{} - {:?}", cmd.full_command, cmd.description);
441/// }
442/// ```
443pub async fn slash_commands_list(
444    project_path: Option<String>,
445) -> Result<Vec<SlashCommand>, String> {
446    info!("Discovering slash commands");
447    let mut commands = Vec::new();
448
449    commands.extend(create_default_commands());
450
451    if let Some(proj_path) = project_path {
452        let project_commands_dir = PathBuf::from(&proj_path).join(".claude").join("commands");
453        if project_commands_dir.exists() {
454            debug!("Scanning project commands at: {:?}", project_commands_dir);
455
456            let mut md_files = Vec::new();
457            if let Err(e) = find_markdown_files(&project_commands_dir, &mut md_files) {
458                error!("Failed to find project command files: {}", e);
459            } else {
460                for file_path in md_files {
461                    match load_command_from_file(&file_path, &project_commands_dir, "project") {
462                        Ok(cmd) => {
463                            debug!("Loaded project command: {}", cmd.full_command);
464                            commands.push(cmd);
465                        }
466                        Err(e) => {
467                            error!("Failed to load command from {:?}: {}", file_path, e);
468                        }
469                    }
470                }
471            }
472        }
473    }
474
475    if let Some(home_dir) = dirs::home_dir() {
476        let user_commands_dir = home_dir.join(".claude").join("commands");
477        if user_commands_dir.exists() {
478            debug!("Scanning user commands at: {:?}", user_commands_dir);
479
480            let mut md_files = Vec::new();
481            if let Err(e) = find_markdown_files(&user_commands_dir, &mut md_files) {
482                error!("Failed to find user command files: {}", e);
483            } else {
484                for file_path in md_files {
485                    match load_command_from_file(&file_path, &user_commands_dir, "user") {
486                        Ok(cmd) => {
487                            debug!("Loaded user command: {}", cmd.full_command);
488                            commands.push(cmd);
489                        }
490                        Err(e) => {
491                            error!("Failed to load command from {:?}: {}", file_path, e);
492                        }
493                    }
494                }
495            }
496        }
497    }
498
499    info!("Found {} slash commands", commands.len());
500    Ok(commands)
501}
502
503/// Get a specific slash command by its ID.
504///
505/// Searches through all available commands to find one matching
506/// the given command ID.
507///
508/// # Arguments
509///
510/// * `command_id` - The unique identifier of the command (e.g., "project-commands-build-md").
511///
512/// # Returns
513///
514/// The matching `SlashCommand` if found.
515///
516/// # Errors
517///
518/// Returns an error string if:
519/// - The command ID format is invalid.
520/// - No command with the given ID is found.
521///
522/// # Example
523///
524/// ```rust,ignore
525/// let command = slash_command_get("project-commands-build-md".to_string()).await?;
526/// println!("Command: {}", command.full_command);
527/// ```
528pub async fn slash_command_get(command_id: String) -> Result<SlashCommand, String> {
529    debug!("Getting slash command: {}", command_id);
530
531    let parts: Vec<&str> = command_id.split('-').collect();
532    if parts.len() < 2 {
533        return Err("Invalid command ID".to_string());
534    }
535
536    let commands = slash_commands_list(None).await?;
537
538    commands
539        .into_iter()
540        .find(|cmd| cmd.id == command_id)
541        .ok_or_else(|| format!("Command not found: {}", command_id))
542}
543
544/// Save a new slash command to disk.
545///
546/// Creates a new command file with optional YAML frontmatter and
547/// writes it to the appropriate directory based on scope.
548///
549/// # Arguments
550///
551/// * `scope` - Where to save the command: "project" or "user".
552/// * `name` - The command name (filename without extension).
553/// * `namespace` - Optional namespace hierarchy (e.g., "dev" for `/dev:build`).
554/// * `content` - The Markdown body content of the command.
555/// * `description` - Optional description for the frontmatter.
556/// * `allowed_tools` - List of tools the command can use (empty = all).
557/// * `project_path` - Required if scope is "project".
558///
559/// # Returns
560///
561/// The newly created `SlashCommand` instance.
562///
563/// # Errors
564///
565/// Returns an error string if:
566/// - The command name is empty.
567/// - The scope is invalid (not "project" or "user").
568/// - The project path is missing for project scope.
569/// - Directory creation or file writing fails.
570///
571/// # Example
572///
573/// ```rust,ignore
574/// let command = slash_command_save(
575///     "project".to_string(),
576///     "build".to_string(),
577///     Some("dev".to_string()),
578///     "Run cargo build".to_string(),
579///     Some("Build the project".to_string()),
580///     vec!["execute_command".to_string()],
581///     Some("./my-project".to_string()),
582/// ).await?;
583/// ```
584pub async fn slash_command_save(
585    scope: String,
586    name: String,
587    namespace: Option<String>,
588    content: String,
589    description: Option<String>,
590    allowed_tools: Vec<String>,
591    project_path: Option<String>,
592) -> Result<SlashCommand, String> {
593    info!("Saving slash command: {} in scope: {}", name, scope);
594
595    if name.is_empty() {
596        return Err("Command name cannot be empty".to_string());
597    }
598
599    if !["project", "user"].contains(&scope.as_str()) {
600        return Err("Invalid scope. Must be 'project' or 'user'".to_string());
601    }
602
603    let base_dir = if scope == "project" {
604        if let Some(proj_path) = project_path {
605            PathBuf::from(proj_path).join(".claude").join("commands")
606        } else {
607            return Err("Project path required for project scope".to_string());
608        }
609    } else {
610        dirs::home_dir()
611            .ok_or_else(|| "Could not find home directory".to_string())?
612            .join(".claude")
613            .join("commands")
614    };
615
616    let mut file_path = base_dir.clone();
617    if let Some(ns) = &namespace {
618        for component in ns.split(':') {
619            file_path = file_path.join(component);
620        }
621    }
622
623    fs::create_dir_all(&file_path).map_err(|e| format!("Failed to create directories: {}", e))?;
624
625    file_path = file_path.join(format!("{}.md", name));
626
627    let mut full_content = String::new();
628
629    if description.is_some() || !allowed_tools.is_empty() {
630        full_content.push_str("---\n");
631
632        if let Some(desc) = &description {
633            full_content.push_str(&format!("description: {}\n", desc));
634        }
635
636        if !allowed_tools.is_empty() {
637            full_content.push_str("allowed-tools:\n");
638            for tool in &allowed_tools {
639                full_content.push_str(&format!("  - {}\n", tool));
640            }
641        }
642
643        full_content.push_str("---\n\n");
644    }
645
646    full_content.push_str(&content);
647
648    fs::write(&file_path, &full_content)
649        .map_err(|e| format!("Failed to write command file: {}", e))?;
650
651    load_command_from_file(&file_path, &base_dir, &scope)
652        .map_err(|e| format!("Failed to load saved command: {}", e))
653}
654
655/// Delete a slash command from disk.
656///
657/// Removes the command file and cleans up any empty parent directories
658/// that were created for namespacing.
659///
660/// # Arguments
661///
662/// * `command_id` - The unique identifier of the command to delete.
663/// * `project_path` - Required if deleting a project-scoped command.
664///
665/// # Returns
666///
667/// A success message indicating which command was deleted.
668///
669/// # Errors
670///
671/// Returns an error string if:
672/// - The command is not found.
673/// - A project command is deleted without providing project_path.
674/// - File deletion fails.
675///
676/// # Example
677///
678/// ```rust,ignore
679/// let result = slash_command_delete(
680///     "project-commands-build-md".to_string(),
681///     Some("./my-project".to_string()),
682/// ).await?;
683/// println!("{}", result); // "Deleted command: /dev:build"
684/// ```
685pub async fn slash_command_delete(
686    command_id: String,
687    project_path: Option<String>,
688) -> Result<String, String> {
689    info!("Deleting slash command: {}", command_id);
690
691    let is_project_command = command_id.starts_with("project-");
692
693    if is_project_command && project_path.is_none() {
694        return Err("Project path required to delete project commands".to_string());
695    }
696
697    let commands = slash_commands_list(project_path).await?;
698
699    let command = commands
700        .into_iter()
701        .find(|cmd| cmd.id == command_id)
702        .ok_or_else(|| format!("Command not found: {}", command_id))?;
703
704    fs::remove_file(&command.file_path)
705        .map_err(|e| format!("Failed to delete command file: {}", e))?;
706
707    if let Some(parent) = Path::new(&command.file_path).parent() {
708        let _ = remove_empty_dirs(parent);
709    }
710
711    Ok(format!("Deleted command: {}", command.full_command))
712}
713
714/// Recursively remove empty directories after command deletion.
715///
716/// Walks up the directory tree from the deleted command's location
717/// and removes any empty parent directories created for namespacing.
718///
719/// # Arguments
720///
721/// * `dir` - Directory to check and potentially remove.
722///
723/// # Returns
724///
725/// `Ok(())` on success, or an error if directory operations fail.
726fn remove_empty_dirs(dir: &Path) -> Result<()> {
727    if !dir.exists() {
728        return Ok(());
729    }
730
731    let is_empty = fs::read_dir(dir)?.next().is_none();
732
733    if is_empty {
734        fs::remove_dir(dir)?;
735
736        if let Some(parent) = dir.parent() {
737            let _ = remove_empty_dirs(parent);
738        }
739    }
740
741    Ok(())
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use std::path::PathBuf;
748
749    #[test]
750    fn test_extract_command_info_with_namespace() {
751        let base_path = PathBuf::from("/home/user/.claude/commands");
752        let file_path = PathBuf::from("/home/user/.claude/commands/dev/build.md");
753
754        let (name, namespace) = extract_command_info(&file_path, &base_path).unwrap();
755
756        assert_eq!(name, "build");
757        assert_eq!(namespace, Some("dev".to_string()));
758    }
759
760    #[test]
761    fn test_extract_command_info_without_namespace() {
762        let base_path = PathBuf::from("/home/user/.claude/commands");
763        let file_path = PathBuf::from("/home/user/.claude/commands/help.md");
764
765        let (name, namespace) = extract_command_info(&file_path, &base_path).unwrap();
766
767        assert_eq!(name, "help");
768        assert_eq!(namespace, None);
769    }
770
771    #[test]
772    fn test_extract_command_info_nested_namespace() {
773        let base_path = PathBuf::from("/home/user/.claude/commands");
774        let file_path = PathBuf::from("/home/user/.claude/commands/team/dev/build.md");
775
776        let (name, namespace) = extract_command_info(&file_path, &base_path).unwrap();
777
778        assert_eq!(name, "build");
779        assert_eq!(namespace, Some("team:dev".to_string()));
780    }
781
782    #[test]
783    fn test_extract_command_info_cross_platform_path_separator() {
784        // This test verifies that the path separator handling works correctly
785        // The fix uses std::path::MAIN_SEPARATOR which is '/' on Unix and '\\' on Windows
786        let base_path = PathBuf::from("/base");
787        let file_path = PathBuf::from("/base/category/command.md");
788
789        let (name, namespace) = extract_command_info(&file_path, &base_path).unwrap();
790
791        assert_eq!(name, "command");
792        assert_eq!(namespace, Some("category".to_string()));
793    }
794
795    #[test]
796    fn test_command_id_replaces_both_separators() {
797        // Test that both '/' and '\\' are replaced with '-' in command IDs
798        // This ensures cross-platform compatibility
799        let path_with_forward_slash = "/home/user/file.md";
800        let path_with_backslash = "\\home\\user\\file.md";
801
802        let id_forward = path_with_forward_slash.replace(['/', '\\'], "-");
803        let id_backslash = path_with_backslash.replace(['/', '\\'], "-");
804
805        assert_eq!(id_forward, "-home-user-file.md");
806        assert_eq!(id_backslash, "-home-user-file.md");
807    }
808
809    #[cfg(unix)]
810    #[test]
811    fn test_find_markdown_files_skips_symlink_loops() {
812        use std::os::unix::fs::symlink;
813
814        let dir = tempfile::tempdir().unwrap();
815        let commands_root = dir.path().join("commands");
816        let nested = commands_root.join("nested");
817        std::fs::create_dir_all(&nested).unwrap();
818        std::fs::write(nested.join("hello.md"), "# hello").unwrap();
819
820        // Create a cycle: nested/loop -> commands_root
821        symlink(&commands_root, nested.join("loop")).unwrap();
822
823        let mut files = Vec::new();
824        find_markdown_files(&commands_root, &mut files).unwrap();
825
826        assert_eq!(files.len(), 1);
827        assert!(files[0].ends_with("hello.md"));
828    }
829}