use anyhow::{Context, Result};
use dirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, error, info};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlashCommand {
pub id: String,
pub name: String,
pub full_command: String,
pub scope: String,
pub namespace: Option<String>,
pub file_path: String,
pub content: String,
pub description: Option<String>,
pub allowed_tools: Vec<String>,
pub has_bash_commands: bool,
pub has_file_references: bool,
pub accepts_arguments: bool,
}
#[derive(Debug, Deserialize)]
struct CommandFrontmatter {
#[serde(rename = "allowed-tools")]
allowed_tools: Option<Vec<String>>,
description: Option<String>,
}
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()))
}
}
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)))
}
}
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,
})
}
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(¤t_dir) {
Ok(path) => path,
Err(_) => current_dir.clone(),
};
if !visited.insert(canonical) {
continue;
}
for entry in fs::read_dir(¤t_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(())
}
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,
},
SlashCommand {
id: "default-plan".to_string(),
name: "plan".to_string(),
full_command: "/plan".to_string(),
scope: "default".to_string(),
namespace: None,
file_path: "".to_string(),
content: "I want an evidence-grounded plan before implementation. Call the Plan tool with a self-contained version of my request so a runtime-enforced read-only planner child can inspect the current workspace. Keep this root session in normal orchestration mode. When the planner returns, review its evidence and present the concrete plan, tests, risks, assumptions, and non-goals to me.".to_string(),
description: Some("Delegate complex planning to a read-only child".to_string()),
allowed_tools: vec![],
has_bash_commands: false,
has_file_references: false,
accepts_arguments: false,
},
]
}
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)
}
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))
}
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))
}
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))
}
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() {
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() {
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();
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"));
}
}