1use anyhow::{Context, Result};
54use dirs;
55use serde::{Deserialize, Serialize};
56use std::fs;
57use std::path::{Path, PathBuf};
58use tracing::{debug, error, info};
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct SlashCommand {
68 pub id: String,
70
71 pub name: String,
73
74 pub full_command: String,
76
77 pub scope: String,
79
80 pub namespace: Option<String>,
82
83 pub file_path: String,
85
86 pub content: String,
88
89 pub description: Option<String>,
91
92 pub allowed_tools: Vec<String>,
95
96 pub has_bash_commands: bool,
98
99 pub has_file_references: bool,
101
102 pub accepts_arguments: bool,
104}
105
106#[derive(Debug, Deserialize)]
111struct CommandFrontmatter {
112 #[serde(rename = "allowed-tools")]
114 allowed_tools: Option<Vec<String>>,
115
116 description: Option<String>,
118}
119
120fn 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
174fn 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
223fn 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
290fn 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(¤t_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(¤t_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
346fn 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
413pub 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
503pub 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
544pub 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
655pub 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
714fn 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 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 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 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}