bamboo-tools 2026.4.25

Tool execution and integrations for the Bamboo agent framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
//! Slash command management for Bamboo agents.
//!
//! This module provides functionality for discovering, loading, and managing
//! slash commands from both project-level and user-level directories.
//!
//! # Command Discovery
//!
//! Commands are discovered from multiple locations:
//! - **Default commands**: Built-in commands like `/add-dir`, `/init`, `/review`
//! - **Project commands**: `.claude/commands/` directory in the project root
//! - **User commands**: `~/.claude/commands/` directory in the user's home
//!
//! # Command Format
//!
//! Commands are Markdown files with optional YAML frontmatter:
//!
//! ```markdown
//! ---
//! description: Build the project
//! allowed-tools:
//!   - execute_command
//!   - read_file
//! ---
//!
//! Run the following command to build:
//! !`cargo build --release`
//! ```
//!
//! # Namespaced Commands
//!
//! Commands can be organized in subdirectories to create namespaces:
//! - `commands/dev/build.md` → `/dev:build`
//! - `commands/team/review.md` → `/team:review`
//!
//! # Example
//!
//! ```rust,ignore
//! use bamboo_agent::commands::slash_commands::slash_commands_list;
//!
//! #[tokio::main]
//! async fn main() {
//!     // List all available commands
//!     let commands = slash_commands_list(Some("./my-project".to_string()))
//!         .await
//!         .unwrap();
//!
//!     for cmd in commands {
//!         println!("{}: {:?}", cmd.full_command, cmd.description);
//!     }
//! }
//! ```

use anyhow::{Context, Result};
use dirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, error, info};

/// Represents a slash command with its metadata and content.
///
/// A slash command is a reusable prompt template that can be invoked
/// by the user using the `/` prefix. Commands can be namespaced and
/// may include special syntax for bash commands, file references,
/// and argument placeholders.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlashCommand {
    /// Unique identifier for the command (e.g., "project-commands-build-md").
    pub id: String,

    /// The command name without namespace (e.g., "build").
    pub name: String,

    /// The full command including namespace prefix (e.g., "/dev:build").
    pub full_command: String,

    /// The scope where the command is defined: "default", "project", or "user".
    pub scope: String,

    /// Optional namespace for the command (e.g., "dev" for "/dev:build").
    pub namespace: Option<String>,

    /// Absolute path to the command file on disk.
    pub file_path: String,

    /// The Markdown content of the command (body only, without frontmatter).
    pub content: String,

    /// Human-readable description of what the command does.
    pub description: Option<String>,

    /// List of tool names that this command is allowed to use.
    /// If empty, all tools are allowed.
    pub allowed_tools: Vec<String>,

    /// Whether the command contains bash command syntax (!`...`).
    pub has_bash_commands: bool,

    /// Whether the command contains file reference syntax (@file).
    pub has_file_references: bool,

    /// Whether the command accepts $ARGUMENTS placeholder.
    pub accepts_arguments: bool,
}

/// YAML frontmatter metadata for a command.
///
/// This structure represents the optional YAML frontmatter that can
/// appear at the beginning of a command Markdown file, enclosed by `---`.
#[derive(Debug, Deserialize)]
struct CommandFrontmatter {
    /// List of tool names that this command is allowed to invoke.
    #[serde(rename = "allowed-tools")]
    allowed_tools: Option<Vec<String>>,

    /// Human-readable description of the command's purpose.
    description: Option<String>,
}

/// Parse Markdown content with optional YAML frontmatter.
///
/// Extracts YAML metadata enclosed between `---` delimiters at the
/// beginning of the file, returning both the parsed frontmatter
/// and the remaining body content.
///
/// # Arguments
///
/// * `content` - The raw file content to parse.
///
/// # Returns
///
/// A tuple containing:
/// - `Option<CommandFrontmatter>` - Parsed frontmatter if present and valid.
/// - `String` - The body content (everything after the frontmatter).
///
/// # Example
///
/// ```ignore
/// let content = "---\ndescription: Test\n---\nBody content";
/// let (frontmatter, body) = parse_markdown_with_frontmatter(content)?;
/// assert_eq!(body, "Body content");
/// ```
fn parse_markdown_with_frontmatter(content: &str) -> Result<(Option<CommandFrontmatter>, String)> {
    let lines: Vec<&str> = content.lines().collect();

    if lines.is_empty() || lines[0] != "---" {
        return Ok((None, content.to_string()));
    }

    let mut frontmatter_end = None;
    for (i, line) in lines.iter().enumerate().skip(1) {
        if *line == "---" {
            frontmatter_end = Some(i);
            break;
        }
    }

    if let Some(end) = frontmatter_end {
        let frontmatter_content = lines[1..end].join("\n");
        let body_content = lines[(end + 1)..].join("\n");

        match serde_yaml::from_str::<CommandFrontmatter>(&frontmatter_content) {
            Ok(frontmatter) => Ok((Some(frontmatter), body_content)),
            Err(e) => {
                debug!("Failed to parse frontmatter: {}", e);
                Ok((None, content.to_string()))
            }
        }
    } else {
        Ok((None, content.to_string()))
    }
}

/// Extract command name and namespace from file path.
///
/// Parses the file path relative to the base commands directory to
/// determine the command name and optional namespace hierarchy.
///
/// # Arguments
///
/// * `file_path` - Absolute path to the command file.
/// * `base_path` - Base directory for commands (e.g., `.claude/commands`).
///
/// # Returns
///
/// A tuple of `(name, namespace)` where:
/// - `name` is the command name (filename without extension).
/// - `namespace` is `None` for top-level commands, or `Some("ns1:ns2")`
///   for nested commands.
///
/// # Examples
///
/// - `commands/build.md` → `("build", None)`
/// - `commands/dev/build.md` → `("build", Some("dev"))`
/// - `commands/team/dev/build.md` → `("build", Some("team:dev"))`
fn extract_command_info(file_path: &Path, base_path: &Path) -> Result<(String, Option<String>)> {
    let relative_path = file_path
        .strip_prefix(base_path)
        .context("Failed to get relative path")?;

    let path_without_ext = relative_path
        .with_extension("")
        .to_string_lossy()
        .to_string();

    let components: Vec<&str> = path_without_ext.split(std::path::MAIN_SEPARATOR).collect();

    if components.is_empty() {
        return Err(anyhow::anyhow!("Invalid command path"));
    }

    if components.len() == 1 {
        Ok((components[0].to_string(), None))
    } else {
        let Some((command_name, namespace_parts)) = components.split_last() else {
            return Err(anyhow::anyhow!("Invalid command path"));
        };
        let namespace = namespace_parts.join(":");
        Ok(((*command_name).to_string(), Some(namespace)))
    }
}

/// Load a slash command from a Markdown file.
///
/// Reads the file content, parses frontmatter, extracts command metadata,
/// and constructs a complete `SlashCommand` instance.
///
/// # Arguments
///
/// * `file_path` - Path to the command Markdown file.
/// * `base_path` - Base commands directory for calculating relative paths.
/// * `scope` - The command scope ("default", "project", or "user").
///
/// # Returns
///
/// A `SlashCommand` with all metadata populated.
///
/// # Errors
///
/// Returns an error if:
/// - The file cannot be read.
/// - The file path is invalid.
/// - YAML frontmatter is malformed (logs warning and continues without it).
fn load_command_from_file(file_path: &Path, base_path: &Path, scope: &str) -> Result<SlashCommand> {
    debug!("Loading command from: {:?}", file_path);

    let content = fs::read_to_string(file_path).context("Failed to read command file")?;

    let (frontmatter, body) = parse_markdown_with_frontmatter(&content)?;

    let (name, namespace) = extract_command_info(file_path, base_path)?;

    let full_command = match &namespace {
        Some(ns) => format!("/{ns}:{name}"),
        None => format!("/{name}"),
    };

    let id = format!(
        "{}-{}",
        scope,
        file_path.to_string_lossy().replace(['/', '\\'], "-")
    );

    let has_bash_commands = body.contains("!`");
    let has_file_references = body.contains('@');
    let accepts_arguments = body.contains("$ARGUMENTS");

    let (description, allowed_tools) = if let Some(fm) = frontmatter {
        (fm.description, fm.allowed_tools.unwrap_or_default())
    } else {
        (None, Vec::new())
    };

    Ok(SlashCommand {
        id,
        name,
        full_command,
        scope: scope.to_string(),
        namespace,
        file_path: file_path.to_string_lossy().to_string(),
        content: body,
        description,
        allowed_tools,
        has_bash_commands,
        has_file_references,
        accepts_arguments,
    })
}

/// Recursively find all Markdown files in a directory.
///
/// Traverses the directory tree and collects all `.md` files,
/// skipping hidden files and directories (starting with `.`).
///
/// # Arguments
///
/// * `dir` - Directory to search.
/// * `files` - Vector to collect found file paths.
///
/// # Returns
///
/// `Ok(())` on success, or an error if directory traversal fails.
fn find_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
    if !dir.exists() {
        return Ok(());
    }

    let mut stack = vec![dir.to_path_buf()];
    let mut visited = std::collections::HashSet::new();

    while let Some(current_dir) = stack.pop() {
        let canonical = match fs::canonicalize(&current_dir) {
            Ok(path) => path,
            Err(_) => current_dir.clone(),
        };
        if !visited.insert(canonical) {
            continue;
        }

        for entry in fs::read_dir(&current_dir)? {
            let entry = entry?;
            let path = entry.path();

            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                if name.starts_with('.') {
                    continue;
                }
            }

            let metadata = fs::symlink_metadata(&path)?;
            if metadata.file_type().is_symlink() {
                continue;
            }

            if metadata.is_dir() {
                stack.push(path);
            } else if metadata.is_file() && path.extension().is_some_and(|ext| ext == "md") {
                files.push(path);
            }
        }
    }

    Ok(())
}

/// Create the list of default built-in commands.
///
/// These commands are always available and do not require files on disk:
/// - `/add-dir` - Add additional working directories.
/// - `/init` - Initialize project with CLAUDE.md guide.
/// - `/review` - Request code review.
fn create_default_commands() -> Vec<SlashCommand> {
    vec![
        SlashCommand {
            id: "default-add-dir".to_string(),
            name: "add-dir".to_string(),
            full_command: "/add-dir".to_string(),
            scope: "default".to_string(),
            namespace: None,
            file_path: "".to_string(),
            content: "Add additional working directories".to_string(),
            description: Some("Add additional working directories".to_string()),
            allowed_tools: vec![],
            has_bash_commands: false,
            has_file_references: false,
            accepts_arguments: false,
        },
        SlashCommand {
            id: "default-init".to_string(),
            name: "init".to_string(),
            full_command: "/init".to_string(),
            scope: "default".to_string(),
            namespace: None,
            file_path: "".to_string(),
            content: "Initialize project with CLAUDE.md guide".to_string(),
            description: Some("Initialize project with CLAUDE.md guide".to_string()),
            allowed_tools: vec![],
            has_bash_commands: false,
            has_file_references: false,
            accepts_arguments: false,
        },
        SlashCommand {
            id: "default-review".to_string(),
            name: "review".to_string(),
            full_command: "/review".to_string(),
            scope: "default".to_string(),
            namespace: None,
            file_path: "".to_string(),
            content: "Request code review".to_string(),
            description: Some("Request code review".to_string()),
            allowed_tools: vec![],
            has_bash_commands: false,
            has_file_references: false,
            accepts_arguments: false,
        },
    ]
}

/// List all available slash commands.
///
/// Discovers and loads slash commands from:
/// 1. Default built-in commands.
/// 2. Project-level commands in `.claude/commands/`.
/// 3. User-level commands in `~/.claude/commands/`.
///
/// Commands from all sources are merged, with project and user commands
/// taking precedence over defaults.
///
/// # Arguments
///
/// * `project_path` - Optional path to the project root directory.
///
/// # Returns
///
/// A vector of all discovered `SlashCommand` instances.
///
/// # Errors
///
/// Returns an error string if command loading fails critically.
///
/// # Example
///
/// ```rust,ignore
/// let commands = slash_commands_list(Some("./my-project".to_string())).await?;
/// for cmd in commands {
///     println!("{} - {:?}", cmd.full_command, cmd.description);
/// }
/// ```
pub async fn slash_commands_list(
    project_path: Option<String>,
) -> Result<Vec<SlashCommand>, String> {
    info!("Discovering slash commands");
    let mut commands = Vec::new();

    commands.extend(create_default_commands());

    if let Some(proj_path) = project_path {
        let project_commands_dir = PathBuf::from(&proj_path).join(".claude").join("commands");
        if project_commands_dir.exists() {
            debug!("Scanning project commands at: {:?}", project_commands_dir);

            let mut md_files = Vec::new();
            if let Err(e) = find_markdown_files(&project_commands_dir, &mut md_files) {
                error!("Failed to find project command files: {}", e);
            } else {
                for file_path in md_files {
                    match load_command_from_file(&file_path, &project_commands_dir, "project") {
                        Ok(cmd) => {
                            debug!("Loaded project command: {}", cmd.full_command);
                            commands.push(cmd);
                        }
                        Err(e) => {
                            error!("Failed to load command from {:?}: {}", file_path, e);
                        }
                    }
                }
            }
        }
    }

    if let Some(home_dir) = dirs::home_dir() {
        let user_commands_dir = home_dir.join(".claude").join("commands");
        if user_commands_dir.exists() {
            debug!("Scanning user commands at: {:?}", user_commands_dir);

            let mut md_files = Vec::new();
            if let Err(e) = find_markdown_files(&user_commands_dir, &mut md_files) {
                error!("Failed to find user command files: {}", e);
            } else {
                for file_path in md_files {
                    match load_command_from_file(&file_path, &user_commands_dir, "user") {
                        Ok(cmd) => {
                            debug!("Loaded user command: {}", cmd.full_command);
                            commands.push(cmd);
                        }
                        Err(e) => {
                            error!("Failed to load command from {:?}: {}", file_path, e);
                        }
                    }
                }
            }
        }
    }

    info!("Found {} slash commands", commands.len());
    Ok(commands)
}

/// Get a specific slash command by its ID.
///
/// Searches through all available commands to find one matching
/// the given command ID.
///
/// # Arguments
///
/// * `command_id` - The unique identifier of the command (e.g., "project-commands-build-md").
///
/// # Returns
///
/// The matching `SlashCommand` if found.
///
/// # Errors
///
/// Returns an error string if:
/// - The command ID format is invalid.
/// - No command with the given ID is found.
///
/// # Example
///
/// ```rust,ignore
/// let command = slash_command_get("project-commands-build-md".to_string()).await?;
/// println!("Command: {}", command.full_command);
/// ```
pub async fn slash_command_get(command_id: String) -> Result<SlashCommand, String> {
    debug!("Getting slash command: {}", command_id);

    let parts: Vec<&str> = command_id.split('-').collect();
    if parts.len() < 2 {
        return Err("Invalid command ID".to_string());
    }

    let commands = slash_commands_list(None).await?;

    commands
        .into_iter()
        .find(|cmd| cmd.id == command_id)
        .ok_or_else(|| format!("Command not found: {}", command_id))
}

/// Save a new slash command to disk.
///
/// Creates a new command file with optional YAML frontmatter and
/// writes it to the appropriate directory based on scope.
///
/// # Arguments
///
/// * `scope` - Where to save the command: "project" or "user".
/// * `name` - The command name (filename without extension).
/// * `namespace` - Optional namespace hierarchy (e.g., "dev" for `/dev:build`).
/// * `content` - The Markdown body content of the command.
/// * `description` - Optional description for the frontmatter.
/// * `allowed_tools` - List of tools the command can use (empty = all).
/// * `project_path` - Required if scope is "project".
///
/// # Returns
///
/// The newly created `SlashCommand` instance.
///
/// # Errors
///
/// Returns an error string if:
/// - The command name is empty.
/// - The scope is invalid (not "project" or "user").
/// - The project path is missing for project scope.
/// - Directory creation or file writing fails.
///
/// # Example
///
/// ```rust,ignore
/// let command = slash_command_save(
///     "project".to_string(),
///     "build".to_string(),
///     Some("dev".to_string()),
///     "Run cargo build".to_string(),
///     Some("Build the project".to_string()),
///     vec!["execute_command".to_string()],
///     Some("./my-project".to_string()),
/// ).await?;
/// ```
pub async fn slash_command_save(
    scope: String,
    name: String,
    namespace: Option<String>,
    content: String,
    description: Option<String>,
    allowed_tools: Vec<String>,
    project_path: Option<String>,
) -> Result<SlashCommand, String> {
    info!("Saving slash command: {} in scope: {}", name, scope);

    if name.is_empty() {
        return Err("Command name cannot be empty".to_string());
    }

    if !["project", "user"].contains(&scope.as_str()) {
        return Err("Invalid scope. Must be 'project' or 'user'".to_string());
    }

    let base_dir = if scope == "project" {
        if let Some(proj_path) = project_path {
            PathBuf::from(proj_path).join(".claude").join("commands")
        } else {
            return Err("Project path required for project scope".to_string());
        }
    } else {
        dirs::home_dir()
            .ok_or_else(|| "Could not find home directory".to_string())?
            .join(".claude")
            .join("commands")
    };

    let mut file_path = base_dir.clone();
    if let Some(ns) = &namespace {
        for component in ns.split(':') {
            file_path = file_path.join(component);
        }
    }

    fs::create_dir_all(&file_path).map_err(|e| format!("Failed to create directories: {}", e))?;

    file_path = file_path.join(format!("{}.md", name));

    let mut full_content = String::new();

    if description.is_some() || !allowed_tools.is_empty() {
        full_content.push_str("---\n");

        if let Some(desc) = &description {
            full_content.push_str(&format!("description: {}\n", desc));
        }

        if !allowed_tools.is_empty() {
            full_content.push_str("allowed-tools:\n");
            for tool in &allowed_tools {
                full_content.push_str(&format!("  - {}\n", tool));
            }
        }

        full_content.push_str("---\n\n");
    }

    full_content.push_str(&content);

    fs::write(&file_path, &full_content)
        .map_err(|e| format!("Failed to write command file: {}", e))?;

    load_command_from_file(&file_path, &base_dir, &scope)
        .map_err(|e| format!("Failed to load saved command: {}", e))
}

/// Delete a slash command from disk.
///
/// Removes the command file and cleans up any empty parent directories
/// that were created for namespacing.
///
/// # Arguments
///
/// * `command_id` - The unique identifier of the command to delete.
/// * `project_path` - Required if deleting a project-scoped command.
///
/// # Returns
///
/// A success message indicating which command was deleted.
///
/// # Errors
///
/// Returns an error string if:
/// - The command is not found.
/// - A project command is deleted without providing project_path.
/// - File deletion fails.
///
/// # Example
///
/// ```rust,ignore
/// let result = slash_command_delete(
///     "project-commands-build-md".to_string(),
///     Some("./my-project".to_string()),
/// ).await?;
/// println!("{}", result); // "Deleted command: /dev:build"
/// ```
pub async fn slash_command_delete(
    command_id: String,
    project_path: Option<String>,
) -> Result<String, String> {
    info!("Deleting slash command: {}", command_id);

    let is_project_command = command_id.starts_with("project-");

    if is_project_command && project_path.is_none() {
        return Err("Project path required to delete project commands".to_string());
    }

    let commands = slash_commands_list(project_path).await?;

    let command = commands
        .into_iter()
        .find(|cmd| cmd.id == command_id)
        .ok_or_else(|| format!("Command not found: {}", command_id))?;

    fs::remove_file(&command.file_path)
        .map_err(|e| format!("Failed to delete command file: {}", e))?;

    if let Some(parent) = Path::new(&command.file_path).parent() {
        let _ = remove_empty_dirs(parent);
    }

    Ok(format!("Deleted command: {}", command.full_command))
}

/// Recursively remove empty directories after command deletion.
///
/// Walks up the directory tree from the deleted command's location
/// and removes any empty parent directories created for namespacing.
///
/// # Arguments
///
/// * `dir` - Directory to check and potentially remove.
///
/// # Returns
///
/// `Ok(())` on success, or an error if directory operations fail.
fn remove_empty_dirs(dir: &Path) -> Result<()> {
    if !dir.exists() {
        return Ok(());
    }

    let is_empty = fs::read_dir(dir)?.next().is_none();

    if is_empty {
        fs::remove_dir(dir)?;

        if let Some(parent) = dir.parent() {
            let _ = remove_empty_dirs(parent);
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn test_extract_command_info_with_namespace() {
        let base_path = PathBuf::from("/home/user/.claude/commands");
        let file_path = PathBuf::from("/home/user/.claude/commands/dev/build.md");

        let (name, namespace) = extract_command_info(&file_path, &base_path).unwrap();

        assert_eq!(name, "build");
        assert_eq!(namespace, Some("dev".to_string()));
    }

    #[test]
    fn test_extract_command_info_without_namespace() {
        let base_path = PathBuf::from("/home/user/.claude/commands");
        let file_path = PathBuf::from("/home/user/.claude/commands/help.md");

        let (name, namespace) = extract_command_info(&file_path, &base_path).unwrap();

        assert_eq!(name, "help");
        assert_eq!(namespace, None);
    }

    #[test]
    fn test_extract_command_info_nested_namespace() {
        let base_path = PathBuf::from("/home/user/.claude/commands");
        let file_path = PathBuf::from("/home/user/.claude/commands/team/dev/build.md");

        let (name, namespace) = extract_command_info(&file_path, &base_path).unwrap();

        assert_eq!(name, "build");
        assert_eq!(namespace, Some("team:dev".to_string()));
    }

    #[test]
    fn test_extract_command_info_cross_platform_path_separator() {
        // This test verifies that the path separator handling works correctly
        // The fix uses std::path::MAIN_SEPARATOR which is '/' on Unix and '\\' on Windows
        let base_path = PathBuf::from("/base");
        let file_path = PathBuf::from("/base/category/command.md");

        let (name, namespace) = extract_command_info(&file_path, &base_path).unwrap();

        assert_eq!(name, "command");
        assert_eq!(namespace, Some("category".to_string()));
    }

    #[test]
    fn test_command_id_replaces_both_separators() {
        // Test that both '/' and '\\' are replaced with '-' in command IDs
        // This ensures cross-platform compatibility
        let path_with_forward_slash = "/home/user/file.md";
        let path_with_backslash = "\\home\\user\\file.md";

        let id_forward = path_with_forward_slash.replace(['/', '\\'], "-");
        let id_backslash = path_with_backslash.replace(['/', '\\'], "-");

        assert_eq!(id_forward, "-home-user-file.md");
        assert_eq!(id_backslash, "-home-user-file.md");
    }

    #[cfg(unix)]
    #[test]
    fn test_find_markdown_files_skips_symlink_loops() {
        use std::os::unix::fs::symlink;

        let dir = tempfile::tempdir().unwrap();
        let commands_root = dir.path().join("commands");
        let nested = commands_root.join("nested");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(nested.join("hello.md"), "# hello").unwrap();

        // Create a cycle: nested/loop -> commands_root
        symlink(&commands_root, nested.join("loop")).unwrap();

        let mut files = Vec::new();
        find_markdown_files(&commands_root, &mut files).unwrap();

        assert_eq!(files.len(), 1);
        assert!(files[0].ends_with("hello.md"));
    }
}