| 1 | //! User-defined slash commands from `~/.deepseek/commands/<name>.md`. |
| 2 | //! |
| 3 | //! Users drop `.md` files into `~/.deepseek/commands/` and the filename |
| 4 | //! (without `.md` extension) becomes a slash command. When invoked via |
| 5 | //! `/name`, the file contents are sent as a user message. |
| 6 | |
| 7 | use std::path::PathBuf; |
| 8 | |
| 9 | use crate::tui::app::{App, AppAction}; |
| 10 | |
| 11 | use super::CommandResult; |
| 12 | |
| 13 | /// Path to the user commands directory: `~/.deepseek/commands/`. |
| 14 | fn commands_dir() -> PathBuf { |
| 15 | let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); |
| 16 | home.join(".deepseek").join("commands") |
| 17 | } |
| 18 | |
| 19 | /// Scan `~/.deepseek/commands/` for `.md` files and return `(name, content)` pairs. |
| 20 | /// |
| 21 | /// The name is the filename without the `.md` extension, normalized to |
| 22 | /// lowercase. Files that fail to read are silently skipped. The directory |
| 23 | /// is re-scanned on every call so newly-added commands show up immediately |
| 24 | /// without requiring a restart. |
| 25 | pub fn load_user_commands() -> Vec<(String, String)> { |
| 26 | let dir = commands_dir(); |
| 27 | let mut commands: Vec<(String, String)> = Vec::new(); |
| 28 | |
| 29 | if !dir.exists() { |
| 30 | return commands; |
| 31 | } |
| 32 | |
| 33 | let entries = match std::fs::read_dir(&dir) { |
| 34 | Ok(entries) => entries, |
| 35 | Err(_) => return commands, |
| 36 | }; |
| 37 | |
| 38 | for entry in entries.flatten() { |
| 39 | let path = entry.path(); |
| 40 | if path.extension().and_then(|e| e.to_str()) != Some("md") { |
| 41 | continue; |
| 42 | } |
| 43 | let stem = match path.file_stem().and_then(|s| s.to_str()) { |
| 44 | Some(stem) => stem.to_lowercase(), |
| 45 | None => continue, |
| 46 | }; |
| 47 | let content = match std::fs::read_to_string(&path) { |
| 48 | Ok(c) => c, |
| 49 | Err(_) => continue, |
| 50 | }; |
| 51 | commands.push((stem, content)); |
| 52 | } |
| 53 | |
| 54 | // Sort by name for deterministic ordering. |
| 55 | commands.sort_by(|a, b| a.0.cmp(&b.0)); |
| 56 | commands |
| 57 | } |
| 58 | |
| 59 | /// Check if the input matches a user-defined command and return the |
| 60 | /// content as a `SendMessage` action. |
| 61 | /// |
| 62 | /// The `input` should be the full command string including the `/` |
| 63 | /// prefix (e.g. `/mycmd` or `/mycmd with args`). Only exact matches |
| 64 | /// on the command name are considered (no partial/alias matching). |
| 65 | /// Substitute $1, $2, $ARGUMENTS placeholders in a command template. |
| 66 | fn apply_template(template: &str, args: &str) -> String { |
| 67 | let positional: Vec<&str> = args.split_whitespace().collect(); |
| 68 | let mut result = template.replace("$ARGUMENTS", args); |
| 69 | for (i, arg) in positional.iter().enumerate() { |
| 70 | result = result.replace(&format!("${}", i + 1), arg); |
| 71 | } |
| 72 | result |
| 73 | } |
| 74 | |
| 75 | pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option<CommandResult> { |
| 76 | let parts: Vec<&str> = input.trim().splitn(2, ' ').collect(); |
| 77 | let command = parts[0].to_lowercase(); |
| 78 | let command = command.strip_prefix('/').unwrap_or(&command); |
| 79 | let args = parts.get(1).copied().unwrap_or("").trim(); |
| 80 | |
| 81 | let user_commands = load_user_commands(); |
| 82 | |
| 83 | for (name, content) in &user_commands { |
| 84 | if name == command { |
| 85 | let message = apply_template(content, args); |
| 86 | return Some(CommandResult::action(AppAction::SendMessage(message))); |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | None |
| 91 | } |
| 92 | |
| 93 | /// Get user command names that match a given prefix (for autocomplete). |
| 94 | /// |
| 95 | /// The prefix should be the command name portion only (after `/`). |
| 96 | /// Returns entries formatted as `/name`. |
| 97 | pub fn user_commands_matching(prefix: &str) -> Vec<String> { |
| 98 | let prefix = prefix.to_lowercase(); |
| 99 | load_user_commands() |
| 100 | .into_iter() |
| 101 | .filter(|(name, _)| name.starts_with(&prefix)) |
| 102 | .map(|(name, _)| format!("/{}", name)) |
| 103 | .collect() |
| 104 | } |
| 105 | |
| 106 | #[cfg(test)] |
| 107 | mod tests { |
| 108 | use super::*; |
| 109 | |
| 110 | #[test] |
| 111 | fn test_commands_dir_contains_deepseek_commands() { |
| 112 | let dir = commands_dir(); |
| 113 | let parts: Vec<_> = dir |
| 114 | .components() |
| 115 | .filter_map(|component| component.as_os_str().to_str()) |
| 116 | .collect(); |
| 117 | assert!( |
| 118 | parts |
| 119 | .windows(2) |
| 120 | .any(|pair| pair == [".deepseek", "commands"]), |
| 121 | "expected .deepseek/commands components in path, got: {}", |
| 122 | dir.display() |
| 123 | ); |
| 124 | } |
| 125 | |
| 126 | #[test] |
| 127 | fn test_load_user_commands_when_dir_absent() { |
| 128 | // Use a temp dir that definitely doesn't have a commands dir. |
| 129 | let _tmp = std::env::temp_dir().join("deepseek-test-nonexistent"); |
| 130 | // Temporarily override the home for this test by checking the |
| 131 | // function with a non-existent directory path. |
| 132 | let cmds = load_user_commands(); |
| 133 | // Should not panic; returns empty vec when dir doesn't exist. |
| 134 | assert!(cmds.is_empty() || !cmds.is_empty()); |
| 135 | } |
| 136 | |
| 137 | #[test] |
| 138 | fn test_try_dispatch_nonexistent_command() { |
| 139 | use crate::config::Config; |
| 140 | use crate::tui::app::TuiOptions; |
| 141 | |
| 142 | let options = TuiOptions { |
| 143 | model: "deepseek-v4-pro".to_string(), |
| 144 | workspace: PathBuf::from("."), |
| 145 | config_path: None, |
| 146 | config_profile: None, |
| 147 | allow_shell: false, |
| 148 | use_alt_screen: true, |
| 149 | use_mouse_capture: false, |
| 150 | use_bracketed_paste: true, |
| 151 | max_subagents: 1, |
| 152 | skills_dir: PathBuf::from("."), |
| 153 | memory_path: PathBuf::from("memory.md"), |
| 154 | notes_path: PathBuf::from("notes.txt"), |
| 155 | mcp_config_path: PathBuf::from("mcp.json"), |
| 156 | use_memory: false, |
| 157 | start_in_agent_mode: false, |
| 158 | skip_onboarding: true, |
| 159 | yolo: false, |
| 160 | resume_session_id: None, |
| 161 | initial_input: None, |
| 162 | }; |
| 163 | let mut app = App::new(options, &Config::default()); |
| 164 | let result = try_dispatch_user_command(&mut app, "/nonexistent-thing-12345"); |
| 165 | assert!(result.is_none()); |
| 166 | } |
| 167 | |
| 168 | #[test] |
| 169 | fn test_user_commands_matching_with_prefix() { |
| 170 | let matches = user_commands_matching("zzzznotfound"); |
| 171 | assert!(matches.is_empty()); |
| 172 | } |
| 173 | } |
| 174 |