Skip to main content

ai_agent/plugin/
commands.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/commands.ts
2//! Plugin commands - ported from ~/claudecode/openclaudecode/src/utils/plugins/loadPluginCommands.ts
3//!
4//! This module provides the plugin command registry and execution functionality.
5
6use crate::error::AgentError;
7use crate::plugin::types::LoadedPlugin;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12use std::sync::{Arc, OnceLock, RwLock};
13
14/// Frontmatter data parsed from command markdown files
15#[derive(Debug, Clone, Default, Deserialize, Serialize)]
16pub struct CommandFrontmatter {
17    #[serde(default)]
18    pub description: Option<String>,
19    #[serde(default)]
20    pub name: Option<String>,
21    #[serde(default)]
22    #[serde(rename = "allowed-tools")]
23    pub allowed_tools: Option<serde_json::Value>,
24    #[serde(default)]
25    #[serde(rename = "argument-hint")]
26    pub argument_hint: Option<String>,
27    #[serde(default)]
28    #[serde(rename = "arguments")]
29    pub arguments: Option<serde_json::Value>,
30    #[serde(default)]
31    #[serde(rename = "when_to_use")]
32    pub when_to_use: Option<String>,
33    #[serde(default)]
34    pub version: Option<String>,
35    #[serde(default)]
36    pub model: Option<String>,
37    #[serde(default)]
38    pub effort: Option<String>,
39    #[serde(default)]
40    #[serde(rename = "disable-model-invocation")]
41    pub disable_model_invocation: Option<bool>,
42    #[serde(default)]
43    #[serde(rename = "user-invocable")]
44    pub user_invocable: Option<bool>,
45    #[serde(default)]
46    pub shell: Option<serde_json::Value>,
47}
48
49/// A plugin command definition
50#[derive(Debug, Clone)]
51pub struct PluginCommand {
52    /// Unique command name (e.g., "my-plugin:my-command")
53    pub name: String,
54    /// Human-readable description
55    pub description: String,
56    /// Tools allowed when executing this command
57    pub allowed_tools: Vec<String>,
58    /// Hint for command arguments
59    pub argument_hint: Option<String>,
60    /// Argument names parsed from frontmatter
61    pub arg_names: Vec<String>,
62    /// When to use this command
63    pub when_to_use: Option<String>,
64    /// Command version
65    pub version: Option<String>,
66    /// Model to use for this command
67    pub model: Option<String>,
68    /// Effort level
69    pub effort: Option<u8>,
70    /// Whether to disable model invocation
71    pub disable_model_invocation: bool,
72    /// Whether the command can be invoked by user
73    pub user_invocable: bool,
74    /// The command content/prompt
75    pub content: String,
76    /// Path to the command file
77    pub source_path: Option<String>,
78    /// Plugin info
79    pub plugin_name: String,
80    pub plugin_source: String,
81    /// Whether this is a skill (loaded from skills directory)
82    pub is_skill: bool,
83    /// Content length for optimization
84    pub content_length: usize,
85}
86
87/// Command execution context
88#[derive(Debug, Clone, Default)]
89pub struct CommandContext {
90    /// Arguments to substitute into the command
91    pub args: HashMap<String, String>,
92    /// Additional context variables
93    pub variables: HashMap<String, String>,
94}
95
96/// Result of command execution
97#[derive(Debug, Clone, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct CommandResult {
100    pub success: bool,
101    pub content: String,
102    pub error: Option<String>,
103}
104
105/// Parse simple YAML-like frontmatter from markdown content
106pub fn parse_frontmatter(content: &str) -> (CommandFrontmatter, String) {
107    let mut frontmatter = CommandFrontmatter::default();
108    let trimmed = content.trim();
109
110    if !trimmed.starts_with("---") {
111        return (frontmatter, content.to_string());
112    }
113
114    // Find the closing ---
115    if let Some(end_pos) = trimmed[3..].find("---") {
116        let frontmatter_str = &trimmed[3..end_pos + 3];
117
118        // Parse key: value pairs
119        for line in frontmatter_str.lines() {
120            let line = line.trim();
121            if line.is_empty() || line.starts_with('#') {
122                continue;
123            }
124
125            if let Some(colon_pos) = line.find(':') {
126                let key = line[..colon_pos].trim().to_lowercase();
127                let value = line[colon_pos + 1..].trim().to_string();
128
129                match key.as_str() {
130                    "description" => frontmatter.description = Some(value),
131                    "name" => frontmatter.name = Some(value),
132                    "allowed-tools" => {
133                        if value.is_empty() {
134                            frontmatter.allowed_tools = Some(serde_json::json!([]));
135                        } else {
136                            let tools: Vec<String> = value
137                                .split(',')
138                                .map(|s| s.trim().to_string())
139                                .filter(|s| !s.is_empty())
140                                .collect();
141                            frontmatter.allowed_tools = Some(serde_json::json!(tools));
142                        }
143                    }
144                    "argument-hint" => frontmatter.argument_hint = Some(value),
145                    "arguments" => frontmatter.arguments = Some(serde_json::json!(value)),
146                    "when_to_use" => frontmatter.when_to_use = Some(value),
147                    "version" => frontmatter.version = Some(value),
148                    "model" => frontmatter.model = Some(value),
149                    "effort" => frontmatter.effort = Some(value),
150                    "disable-model-invocation" => {
151                        frontmatter.disable_model_invocation =
152                            Some(value.parse::<bool>().ok().unwrap_or(false));
153                    }
154                    "user-invocable" => {
155                        frontmatter.user_invocable =
156                            Some(value.parse::<bool>().ok().unwrap_or(true));
157                    }
158                    _ => {}
159                }
160            }
161        }
162
163        let body = trimmed[end_pos + 6..].trim_start().to_string();
164        return (frontmatter, body);
165    }
166
167    (frontmatter, content.to_string())
168}
169
170/// Parse argument names from arguments field
171pub fn parse_argument_names(arguments: &Option<serde_json::Value>) -> Vec<String> {
172    match arguments {
173        Some(serde_json::Value::String(s)) => s
174            .split(',')
175            .map(|s| s.trim().to_string())
176            .filter(|s| !s.is_empty())
177            .collect(),
178        Some(serde_json::Value::Array(arr)) => arr
179            .iter()
180            .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
181            .filter(|s| !s.is_empty())
182            .collect(),
183        _ => Vec::new(),
184    }
185}
186
187/// Parse allowed tools from frontmatter
188pub fn parse_allowed_tools(allowed_tools: &Option<serde_json::Value>) -> Vec<String> {
189    match allowed_tools {
190        Some(serde_json::Value::Array(arr)) => arr
191            .iter()
192            .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
193            .collect(),
194        Some(serde_json::Value::String(s)) => s
195            .split(',')
196            .map(|s| s.trim().to_string())
197            .filter(|s| !s.is_empty())
198            .collect(),
199        _ => Vec::new(),
200    }
201}
202
203/// Parse effort value from string
204pub fn parse_effort_value(effort: &Option<String>) -> Option<u8> {
205    match effort {
206        Some(s) => {
207            // Try parsing as number first
208            if let Ok(num) = s.parse::<u8>() {
209                return Some(num);
210            }
211            // Try keyword mappings
212            match s.to_lowercase().as_str() {
213                "minimal" => Some(1),
214                "low" => Some(2),
215                "medium" => Some(3),
216                "high" => Some(5),
217                "maximum" => Some(8),
218                _ => None,
219            }
220        }
221        None => None,
222    }
223}
224
225/// Load a single plugin command from a markdown file
226pub fn load_command_from_file(
227    file_path: &Path,
228    plugin_name: &str,
229    plugin_source: &str,
230    is_skill: bool,
231) -> Result<PluginCommand, AgentError> {
232    let content = fs::read_to_string(file_path).map_err(|e| AgentError::Io(e))?;
233
234    let (frontmatter, body) = parse_frontmatter(&content);
235
236    // Extract description
237    let description = frontmatter
238        .description
239        .clone()
240        .unwrap_or_else(|| extract_description_from_markdown(&body));
241
242    // Parse allowed tools
243    let allowed_tools = parse_allowed_tools(&frontmatter.allowed_tools);
244
245    // Parse argument names
246    let arg_names = parse_argument_names(&frontmatter.arguments);
247
248    // Parse effort
249    let effort = parse_effort_value(&frontmatter.effort);
250
251    // Determine user invocable (default true)
252    let user_invocable = frontmatter.user_invocable.unwrap_or(true);
253
254    // Get command name from file path
255    let command_name = if is_skill {
256        // For skills, use the parent directory name
257        file_path
258            .parent()
259            .and_then(|p| p.file_name())
260            .and_then(|n| n.to_str())
261            .map(|n| format!("{}:{}", plugin_name, n))
262            .unwrap_or_else(|| format!("{}:unknown", plugin_name))
263    } else {
264        // For regular commands, use filename without extension
265        file_path
266            .file_stem()
267            .and_then(|n| n.to_str())
268            .map(|n| format!("{}:{}", plugin_name, n))
269            .unwrap_or_else(|| format!("{}:unknown", plugin_name))
270    };
271
272    Ok(PluginCommand {
273        name: command_name,
274        description,
275        allowed_tools,
276        argument_hint: frontmatter.argument_hint.clone(),
277        arg_names,
278        when_to_use: frontmatter.when_to_use.clone(),
279        version: frontmatter.version.clone(),
280        model: frontmatter.model.clone(),
281        effort,
282        disable_model_invocation: frontmatter.disable_model_invocation.unwrap_or(false),
283        user_invocable,
284        content: body.clone(),
285        source_path: Some(file_path.to_string_lossy().to_string()),
286        plugin_name: plugin_name.to_string(),
287        plugin_source: plugin_source.to_string(),
288        is_skill,
289        content_length: body.len(),
290    })
291}
292
293/// Extract description from markdown content
294fn extract_description_from_markdown(content: &str) -> String {
295    // Try to get the first paragraph or heading
296    for line in content.lines() {
297        let trimmed = line.trim();
298        if trimmed.is_empty() {
299            continue;
300        }
301        // Return first non-empty line, truncated
302        return if trimmed.len() > 200 {
303            format!("{}...", &trimmed[..200])
304        } else {
305            trimmed.to_string()
306        };
307    }
308    "No description".to_string()
309}
310
311/// Load commands from a directory
312pub fn load_commands_from_directory(
313    dir_path: &Path,
314    plugin_name: &str,
315    plugin_source: &str,
316    is_skill_mode: bool,
317) -> Result<Vec<PluginCommand>, AgentError> {
318    if !dir_path.exists() {
319        return Ok(Vec::new());
320    }
321
322    let mut commands = Vec::new();
323
324    let entries = fs::read_dir(dir_path).map_err(|e| AgentError::Io(e))?;
325
326    for entry in entries {
327        let entry = entry.map_err(|e| AgentError::Io(e))?;
328        let path = entry.path();
329
330        if path.is_dir() {
331            // For skill mode, check if directory contains SKILL.md
332            if is_skill_mode {
333                let skill_file = path.join("SKILL.md");
334                if skill_file.exists() {
335                    if let Ok(cmd) =
336                        load_command_from_file(&skill_file, plugin_name, plugin_source, true)
337                    {
338                        commands.push(cmd);
339                    }
340                }
341            } else {
342                // Recursively load from subdirectories
343                match load_commands_from_directory(&path, plugin_name, plugin_source, false) {
344                    Ok(sub_commands) => commands.extend(sub_commands),
345                    Err(e) => {
346                        log::warn!("Failed to load commands from {:?}: {}", path, e);
347                    }
348                }
349            }
350        } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
351            // Skip SKILL.md files in non-skill mode (they're loaded as skills)
352            if !is_skill_mode
353                && path
354                    .file_name()
355                    .and_then(|s| s.to_str())
356                    .map_or(false, |s| s.to_lowercase() == "skill.md")
357            {
358                continue;
359            }
360
361            if let Ok(cmd) = load_command_from_file(&path, plugin_name, plugin_source, false) {
362                commands.push(cmd);
363            }
364        }
365    }
366
367    Ok(commands)
368}
369
370/// Substitute arguments in command content
371pub fn substitute_arguments(content: &str, args: &HashMap<String, String>) -> String {
372    let mut result = content.to_string();
373    for (key, value) in args {
374        let placeholder = format!("${{{}}}", key);
375        result = result.replace(&placeholder, value);
376    }
377    result
378}
379
380/// Command handler function type
381pub type CommandHandler = Arc<
382    dyn Fn(HashMap<String, String>, &CommandContext) -> Result<CommandResult, AgentError>
383        + Send
384        + Sync,
385>;
386
387/// Plugin command with handler
388#[derive(Clone)]
389pub struct ExecutablePluginCommand {
390    pub command: PluginCommand,
391    pub handler: Option<CommandHandler>,
392}
393
394impl ExecutablePluginCommand {
395    /// Execute the command with given arguments
396    pub fn execute(
397        &self,
398        args: HashMap<String, String>,
399        context: &CommandContext,
400    ) -> Result<CommandResult, AgentError> {
401        // If there's a custom handler, use it
402        if let Some(handler) = &self.handler {
403            return handler(args, context);
404        }
405
406        // Otherwise, return the command content as a prompt
407        let content = substitute_arguments(&self.command.content, &args);
408        Ok(CommandResult {
409            success: true,
410            content,
411            error: None,
412        })
413    }
414
415    /// Get the prompt for this command
416    pub fn get_prompt(&self, args: &HashMap<String, String>) -> String {
417        substitute_arguments(&self.command.content, args)
418    }
419}
420
421/// Global command registry
422pub struct CommandRegistry {
423    commands: RwLock<HashMap<String, ExecutablePluginCommand>>,
424    /// Map of plugin names to their commands (for quick lookup by plugin)
425    by_plugin: RwLock<HashMap<String, Vec<String>>>,
426}
427
428impl CommandRegistry {
429    /// Create a new command registry
430    pub fn new() -> Self {
431        Self {
432            commands: RwLock::new(HashMap::new()),
433            by_plugin: RwLock::new(HashMap::new()),
434        }
435    }
436
437    /// Get global command registry instance
438    pub fn global() -> &'static CommandRegistry {
439        static REGISTRY: OnceLock<CommandRegistry> = OnceLock::new();
440        REGISTRY.get_or_init(|| CommandRegistry::new())
441    }
442
443    /// Register a command
444    pub fn register(&self, command: PluginCommand) {
445        let name = command.name.clone();
446        let plugin_name = command.plugin_name.clone();
447
448        let executable = ExecutablePluginCommand {
449            command,
450            handler: None,
451        };
452
453        // Add to commands map
454        {
455            let mut commands = self.commands.write().unwrap();
456            commands.insert(name.clone(), executable);
457        }
458
459        // Add to by_plugin map
460        {
461            let mut by_plugin = self.by_plugin.write().unwrap();
462            by_plugin
463                .entry(plugin_name.clone())
464                .or_insert_with(Vec::new)
465                .push(name.clone());
466        }
467
468        log::debug!("Registered plugin command: {}", name);
469    }
470
471    /// Register a command with a custom handler
472    pub fn register_with_handler(&self, command: PluginCommand, handler: CommandHandler) {
473        let name = command.name.clone();
474        let plugin_name = command.plugin_name.clone();
475
476        let executable = ExecutablePluginCommand {
477            command,
478            handler: Some(handler),
479        };
480
481        {
482            let mut commands = self.commands.write().unwrap();
483            commands.insert(name.clone(), executable);
484        }
485
486        {
487            let mut by_plugin = self.by_plugin.write().unwrap();
488            by_plugin
489                .entry(plugin_name)
490                .or_insert_with(Vec::new)
491                .push(name.clone());
492        }
493
494        log::debug!("Registered plugin command with handler: {}", name);
495    }
496
497    /// Get a command by name
498    pub fn get(&self, name: &str) -> Option<ExecutablePluginCommand> {
499        let commands = self.commands.read().unwrap();
500        commands.get(name).cloned()
501    }
502
503    /// Get all registered command names
504    pub fn all_commands(&self) -> Vec<String> {
505        let commands = self.commands.read().unwrap();
506        commands.keys().cloned().collect()
507    }
508
509    /// Get commands for a specific plugin
510    pub fn get_by_plugin(&self, plugin_name: &str) -> Vec<ExecutablePluginCommand> {
511        let commands = self.commands.read().unwrap();
512        let by_plugin = self.by_plugin.read().unwrap();
513
514        by_plugin
515            .get(plugin_name)
516            .map(|names| {
517                names
518                    .iter()
519                    .filter_map(|n| commands.get(n).cloned())
520                    .collect()
521            })
522            .unwrap_or_default()
523    }
524
525    /// Check if a command exists
526    pub fn contains(&self, name: &str) -> bool {
527        let commands = self.commands.read().unwrap();
528        commands.contains_key(name)
529    }
530
531    /// Parse a slash command and extract plugin name, command name, and arguments
532    /// Format: /plugin-name:command arg1=value1 arg2=value2
533    pub fn parse_slash_command(input: &str) -> Option<(String, String, HashMap<String, String>)> {
534        let input = input.trim();
535
536        // Must start with /
537        if !input.starts_with('/') {
538            return None;
539        }
540
541        let input = &input[1..]; // Remove leading /
542
543        // Find the : separator between plugin and command
544        let colon_pos = input.find(':')?;
545
546        let plugin_name = input[..colon_pos].to_string();
547        let rest = &input[colon_pos + 1..];
548
549        // Parse command name and arguments
550        let (command_name, args) = if let Some(space_pos) = rest.find(' ') {
551            let cmd_name = rest[..space_pos].to_string();
552            let args_str = &rest[space_pos + 1..];
553            let args = Self::parse_arguments(args_str);
554            (cmd_name, args)
555        } else {
556            (rest.to_string(), HashMap::new())
557        };
558
559        Some((plugin_name, command_name, args))
560    }
561
562    /// Parse command arguments from string
563    /// Format: arg1=value1 arg2="value with spaces" arg3='quoted'
564    fn parse_arguments(args_str: &str) -> HashMap<String, String> {
565        let mut args = HashMap::new();
566        let mut current_key = String::new();
567        let mut current_value = String::new();
568        let mut in_key = true;
569        let mut in_quotes = false;
570        let mut quote_char = '\0';
571
572        for ch in args_str.chars() {
573            if in_key {
574                if ch == '=' {
575                    in_key = false;
576                } else if !ch.is_whitespace() {
577                    current_key.push(ch);
578                }
579            } else {
580                if in_quotes {
581                    if ch == quote_char {
582                        in_quotes = false;
583                    } else {
584                        current_value.push(ch);
585                    }
586                } else if ch == '"' || ch == '\'' {
587                    in_quotes = true;
588                    quote_char = ch;
589                } else if ch.is_whitespace() && !current_key.is_empty() && !current_value.is_empty()
590                {
591                    // End of argument
592                    args.insert(current_key.clone(), current_value.clone());
593                    current_key.clear();
594                    current_value.clear();
595                    in_key = true;
596                } else {
597                    current_value.push(ch);
598                }
599            }
600        }
601
602        // Add last argument if present
603        if !current_key.is_empty() {
604            args.insert(current_key, current_value);
605        }
606
607        args
608    }
609
610    /// Execute a slash command
611    pub fn execute_slash_command(
612        &self,
613        input: &str,
614        context: &CommandContext,
615    ) -> Result<CommandResult, AgentError> {
616        let (plugin_name, command_name, args) =
617            Self::parse_slash_command(input).ok_or_else(|| {
618                AgentError::Command(format!("Invalid slash command format: {}", input))
619            })?;
620
621        let full_name = format!("{}:{}", plugin_name, command_name);
622
623        let cmd = self
624            .get(&full_name)
625            .ok_or_else(|| AgentError::Command(format!("Command not found: {}", full_name)))?;
626
627        cmd.execute(args, context)
628    }
629
630    /// Clear all registered commands
631    pub fn clear(&self) {
632        let mut commands = self.commands.write().unwrap();
633        commands.clear();
634
635        let mut by_plugin = self.by_plugin.write().unwrap();
636        by_plugin.clear();
637    }
638
639    /// Get the number of registered commands
640    pub fn len(&self) -> usize {
641        let commands = self.commands.read().unwrap();
642        commands.len()
643    }
644
645    /// Check if registry is empty
646    pub fn is_empty(&self) -> bool {
647        self.len() == 0
648    }
649}
650
651impl Default for CommandRegistry {
652    fn default() -> Self {
653        Self::new()
654    }
655}
656
657/// Load commands from a plugin
658pub fn load_plugin_commands(plugin: &LoadedPlugin) -> Result<Vec<PluginCommand>, AgentError> {
659    let mut commands = Vec::new();
660    let plugin_name = &plugin.name;
661    let plugin_source = &plugin.source;
662
663    // Load from commands_path
664    if let Some(commands_path) = &plugin.commands_path {
665        let path = Path::new(commands_path);
666        match load_commands_from_directory(path, plugin_name, plugin_source, false) {
667            Ok(cmds) => commands.extend(cmds),
668            Err(e) => {
669                log::warn!("Failed to load commands from {}: {}", commands_path, e);
670            }
671        }
672    }
673
674    // Load from additional commands_paths
675    if let Some(commands_paths) = &plugin.commands_paths {
676        for command_path in commands_paths {
677            let path = Path::new(command_path);
678            if path.is_dir() {
679                match load_commands_from_directory(path, plugin_name, plugin_source, false) {
680                    Ok(cmds) => commands.extend(cmds),
681                    Err(e) => {
682                        log::warn!("Failed to load commands from {}: {}", command_path, e);
683                    }
684                }
685            } else if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
686                match load_command_from_file(path, plugin_name, plugin_source, false) {
687                    Ok(cmd) => commands.push(cmd),
688                    Err(e) => {
689                        log::warn!("Failed to load command from {}: {}", command_path, e);
690                    }
691                }
692            }
693        }
694    }
695
696    // Load skills from skills_path
697    if let Some(skills_path) = &plugin.skills_path {
698        let path = Path::new(skills_path);
699        match load_commands_from_directory(path, plugin_name, plugin_source, true) {
700            Ok(cmds) => commands.extend(cmds),
701            Err(e) => {
702                log::warn!("Failed to load skills from {}: {}", skills_path, e);
703            }
704        }
705    }
706
707    // Load from additional skills_paths
708    if let Some(skills_paths) = &plugin.skills_paths {
709        for skill_path in skills_paths {
710            let path = Path::new(skill_path);
711            if path.is_dir() {
712                match load_commands_from_directory(path, plugin_name, plugin_source, true) {
713                    Ok(cmds) => commands.extend(cmds),
714                    Err(e) => {
715                        log::warn!("Failed to load skills from {}: {}", skill_path, e);
716                    }
717                }
718            }
719        }
720    }
721
722    Ok(commands)
723}
724
725/// Register all commands from a plugin
726pub fn register_plugin_commands(plugin: &LoadedPlugin) -> Result<usize, AgentError> {
727    let commands = load_plugin_commands(plugin)?;
728    let registry = CommandRegistry::global();
729
730    let count = commands.len();
731    for command in commands {
732        registry.register(command);
733    }
734
735    log::info!("Registered {} commands from plugin {}", count, plugin.name);
736
737    Ok(count)
738}
739
740/// Get all registered plugin commands
741pub fn get_all_plugin_commands() -> Vec<ExecutablePluginCommand> {
742    let registry = CommandRegistry::global();
743    let names = registry.all_commands();
744    names.iter().filter_map(|n| registry.get(n)).collect()
745}
746
747/// Get a command by name (matches TypeScript getCommand)
748pub fn get_command(name: &str) -> Option<ExecutablePluginCommand> {
749    let registry = CommandRegistry::global();
750    registry.get(name)
751}
752
753/// Check if a command exists (matches TypeScript hasCommand)
754pub fn has_command(name: &str) -> bool {
755    let registry = CommandRegistry::global();
756    registry.contains(name)
757}
758
759/// Get all skill tool commands (matches TypeScript getSkillToolCommands)
760/// Returns commands that are marked as skills
761pub fn get_skill_tool_commands() -> Vec<ExecutablePluginCommand> {
762    get_all_plugin_commands()
763        .into_iter()
764        .filter(|cmd| cmd.command.is_skill)
765        .collect()
766}
767
768/// Register a command directly (convenience function)
769pub fn register_command(command: PluginCommand) {
770    let registry = CommandRegistry::global();
771    registry.register(command);
772}
773
774/// Clear all registered commands (useful for testing)
775pub fn clear_commands() {
776    let registry = CommandRegistry::global();
777    registry.clear();
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    #[test]
785    fn test_parse_frontmatter() {
786        let content = r#"---
787description: Test command
788allowed-tools: Bash,Read
789argument-hint: <name>
790---
791
792This is the command content.
793"#;
794        let (fm, body) = parse_frontmatter(content);
795        assert_eq!(fm.description, Some("Test command".to_string()));
796        assert_eq!(fm.argument_hint, Some("<name>".to_string()));
797        assert_eq!(body, "This is the command content.");
798    }
799
800    #[test]
801    fn test_parse_argument_names() {
802        let args = Some(serde_json::json!("arg1, arg2, arg3"));
803        let names = parse_argument_names(&args);
804        assert_eq!(names, vec!["arg1", "arg2", "arg3"]);
805    }
806
807    #[test]
808    fn test_parse_allowed_tools() {
809        let tools = Some(serde_json::json!(["Bash", "Read"]));
810        let parsed = parse_allowed_tools(&tools);
811        assert_eq!(parsed, vec!["Bash", "Read"]);
812    }
813
814    #[test]
815    fn test_parse_effort_value() {
816        assert_eq!(parse_effort_value(&Some("3".to_string())), Some(3));
817        assert_eq!(parse_effort_value(&Some("medium".to_string())), Some(3));
818        assert_eq!(parse_effort_value(&Some("high".to_string())), Some(5));
819        assert_eq!(parse_effort_value(&None), None);
820        assert_eq!(parse_effort_value(&Some("invalid".to_string())), None);
821    }
822
823    #[test]
824    fn test_parse_slash_command() {
825        let (plugin, cmd, args) =
826            CommandRegistry::parse_slash_command("/my-plugin:hello arg1=value1").unwrap();
827        assert_eq!(plugin, "my-plugin");
828        assert_eq!(cmd, "hello");
829        assert_eq!(args.get("arg1"), Some(&"value1".to_string()));
830    }
831
832    #[test]
833    fn test_parse_slash_command_with_quoted_args() {
834        let (plugin, cmd, args) =
835            CommandRegistry::parse_slash_command("/my-plugin:hello name=\"John Doe\"").unwrap();
836        assert_eq!(plugin, "my-plugin");
837        assert_eq!(cmd, "hello");
838        assert_eq!(args.get("name"), Some(&"John Doe".to_string()));
839    }
840
841    #[test]
842    fn test_substitute_arguments() {
843        let content = "Hello ${name}, your score is ${score}";
844        let mut args = HashMap::new();
845        args.insert("name".to_string(), "Alice".to_string());
846        args.insert("score".to_string(), "100".to_string());
847
848        let result = substitute_arguments(content, &args);
849        assert_eq!(result, "Hello Alice, your score is 100");
850    }
851
852    #[test]
853    fn test_command_registry_register_and_get() {
854        let registry = CommandRegistry::new();
855        registry.clear();
856
857        let command = PluginCommand {
858            name: "test:cmd".to_string(),
859            description: "Test command".to_string(),
860            allowed_tools: vec!["Bash".to_string()],
861            argument_hint: None,
862            arg_names: vec![],
863            when_to_use: None,
864            version: None,
865            model: None,
866            effort: None,
867            disable_model_invocation: false,
868            user_invocable: true,
869            content: "Test content".to_string(),
870            source_path: None,
871            plugin_name: "test".to_string(),
872            plugin_source: "test".to_string(),
873            is_skill: false,
874            content_length: 12,
875        };
876
877        registry.register(command);
878
879        let retrieved = registry.get("test:cmd");
880        assert!(retrieved.is_some());
881        assert_eq!(retrieved.unwrap().command.name, "test:cmd");
882    }
883
884    #[test]
885    fn test_command_registry_execute() {
886        let registry = CommandRegistry::new();
887        registry.clear();
888
889        let command = PluginCommand {
890            name: "test:hello".to_string(),
891            description: "Test command".to_string(),
892            allowed_tools: vec![],
893            argument_hint: None,
894            arg_names: vec!["name".to_string()],
895            when_to_use: None,
896            version: None,
897            model: None,
898            effort: None,
899            disable_model_invocation: false,
900            user_invocable: true,
901            content: "Hello ${name}".to_string(),
902            source_path: None,
903            plugin_name: "test".to_string(),
904            plugin_source: "test".to_string(),
905            is_skill: false,
906            content_length: 10,
907        };
908
909        registry.register(command);
910
911        let result =
912            registry.execute_slash_command("/test:hello name=World", &CommandContext::default());
913        assert!(result.is_ok());
914        let result = result.unwrap();
915        assert!(result.success);
916        assert_eq!(result.content, "Hello World");
917    }
918
919    #[test]
920    fn test_command_registry_by_plugin() {
921        let registry = CommandRegistry::new();
922        registry.clear();
923
924        let cmd1 = PluginCommand {
925            name: "my-plugin:cmd1".to_string(),
926            description: "Command 1".to_string(),
927            allowed_tools: vec![],
928            argument_hint: None,
929            arg_names: vec![],
930            when_to_use: None,
931            version: None,
932            model: None,
933            effort: None,
934            disable_model_invocation: false,
935            user_invocable: true,
936            content: "Content 1".to_string(),
937            source_path: None,
938            plugin_name: "my-plugin".to_string(),
939            plugin_source: "my-plugin".to_string(),
940            is_skill: false,
941            content_length: 9,
942        };
943
944        let cmd2 = PluginCommand {
945            name: "my-plugin:cmd2".to_string(),
946            description: "Command 2".to_string(),
947            allowed_tools: vec![],
948            argument_hint: None,
949            arg_names: vec![],
950            when_to_use: None,
951            version: None,
952            model: None,
953            effort: None,
954            disable_model_invocation: false,
955            user_invocable: true,
956            content: "Content 2".to_string(),
957            source_path: None,
958            plugin_name: "my-plugin".to_string(),
959            plugin_source: "my-plugin".to_string(),
960            is_skill: false,
961            content_length: 9,
962        };
963
964        registry.register(cmd1);
965        registry.register(cmd2);
966
967        let by_plugin = registry.get_by_plugin("my-plugin");
968        assert_eq!(by_plugin.len(), 2);
969    }
970}