1use 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#[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#[derive(Debug, Clone)]
51pub struct PluginCommand {
52 pub name: String,
54 pub description: String,
56 pub allowed_tools: Vec<String>,
58 pub argument_hint: Option<String>,
60 pub arg_names: Vec<String>,
62 pub when_to_use: Option<String>,
64 pub version: Option<String>,
66 pub model: Option<String>,
68 pub effort: Option<u8>,
70 pub disable_model_invocation: bool,
72 pub user_invocable: bool,
74 pub content: String,
76 pub source_path: Option<String>,
78 pub plugin_name: String,
80 pub plugin_source: String,
81 pub is_skill: bool,
83 pub content_length: usize,
85}
86
87#[derive(Debug, Clone, Default)]
89pub struct CommandContext {
90 pub args: HashMap<String, String>,
92 pub variables: HashMap<String, String>,
94}
95
96#[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
105pub 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 if let Some(end_pos) = trimmed[3..].find("---") {
116 let frontmatter_str = &trimmed[3..end_pos + 3];
117
118 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
170pub 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
187pub 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
203pub fn parse_effort_value(effort: &Option<String>) -> Option<u8> {
205 match effort {
206 Some(s) => {
207 if let Ok(num) = s.parse::<u8>() {
209 return Some(num);
210 }
211 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
225pub 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 let description = frontmatter
238 .description
239 .clone()
240 .unwrap_or_else(|| extract_description_from_markdown(&body));
241
242 let allowed_tools = parse_allowed_tools(&frontmatter.allowed_tools);
244
245 let arg_names = parse_argument_names(&frontmatter.arguments);
247
248 let effort = parse_effort_value(&frontmatter.effort);
250
251 let user_invocable = frontmatter.user_invocable.unwrap_or(true);
253
254 let command_name = if is_skill {
256 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 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
293fn extract_description_from_markdown(content: &str) -> String {
295 for line in content.lines() {
297 let trimmed = line.trim();
298 if trimmed.is_empty() {
299 continue;
300 }
301 return if trimmed.len() > 200 {
303 format!("{}...", &trimmed[..200])
304 } else {
305 trimmed.to_string()
306 };
307 }
308 "No description".to_string()
309}
310
311pub 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 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 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 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
370pub 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
380pub type CommandHandler = Arc<
382 dyn Fn(HashMap<String, String>, &CommandContext) -> Result<CommandResult, AgentError>
383 + Send
384 + Sync,
385>;
386
387#[derive(Clone)]
389pub struct ExecutablePluginCommand {
390 pub command: PluginCommand,
391 pub handler: Option<CommandHandler>,
392}
393
394impl ExecutablePluginCommand {
395 pub fn execute(
397 &self,
398 args: HashMap<String, String>,
399 context: &CommandContext,
400 ) -> Result<CommandResult, AgentError> {
401 if let Some(handler) = &self.handler {
403 return handler(args, context);
404 }
405
406 let content = substitute_arguments(&self.command.content, &args);
408 Ok(CommandResult {
409 success: true,
410 content,
411 error: None,
412 })
413 }
414
415 pub fn get_prompt(&self, args: &HashMap<String, String>) -> String {
417 substitute_arguments(&self.command.content, args)
418 }
419}
420
421pub struct CommandRegistry {
423 commands: RwLock<HashMap<String, ExecutablePluginCommand>>,
424 by_plugin: RwLock<HashMap<String, Vec<String>>>,
426}
427
428impl CommandRegistry {
429 pub fn new() -> Self {
431 Self {
432 commands: RwLock::new(HashMap::new()),
433 by_plugin: RwLock::new(HashMap::new()),
434 }
435 }
436
437 pub fn global() -> &'static CommandRegistry {
439 static REGISTRY: OnceLock<CommandRegistry> = OnceLock::new();
440 REGISTRY.get_or_init(|| CommandRegistry::new())
441 }
442
443 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 {
455 let mut commands = self.commands.write().unwrap();
456 commands.insert(name.clone(), executable);
457 }
458
459 {
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 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 pub fn get(&self, name: &str) -> Option<ExecutablePluginCommand> {
499 let commands = self.commands.read().unwrap();
500 commands.get(name).cloned()
501 }
502
503 pub fn all_commands(&self) -> Vec<String> {
505 let commands = self.commands.read().unwrap();
506 commands.keys().cloned().collect()
507 }
508
509 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 pub fn contains(&self, name: &str) -> bool {
527 let commands = self.commands.read().unwrap();
528 commands.contains_key(name)
529 }
530
531 pub fn parse_slash_command(input: &str) -> Option<(String, String, HashMap<String, String>)> {
534 let input = input.trim();
535
536 if !input.starts_with('/') {
538 return None;
539 }
540
541 let input = &input[1..]; let colon_pos = input.find(':')?;
545
546 let plugin_name = input[..colon_pos].to_string();
547 let rest = &input[colon_pos + 1..];
548
549 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 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 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 if !current_key.is_empty() {
604 args.insert(current_key, current_value);
605 }
606
607 args
608 }
609
610 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 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 pub fn len(&self) -> usize {
641 let commands = self.commands.read().unwrap();
642 commands.len()
643 }
644
645 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
657pub 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 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 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 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 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
725pub 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
740pub 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
747pub fn get_command(name: &str) -> Option<ExecutablePluginCommand> {
749 let registry = CommandRegistry::global();
750 registry.get(name)
751}
752
753pub fn has_command(name: &str) -> bool {
755 let registry = CommandRegistry::global();
756 registry.contains(name)
757}
758
759pub 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
768pub fn register_command(command: PluginCommand) {
770 let registry = CommandRegistry::global();
771 registry.register(command);
772}
773
774pub 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}