Skip to main content

ai_agent/utils/plugins/
load_plugin_commands.rs

1// Source: ~/claudecode/openclaudecode/src/utils/plugins/loadPluginCommands.ts
2#![allow(dead_code)]
3
4use std::collections::HashSet;
5use std::path::Path;
6use std::sync::Mutex;
7
8use once_cell::sync::Lazy;
9
10use super::frontmatter_parser::parse_frontmatter;
11use super::loader::load_all_plugins_cache_only;
12use super::plugin_options_storage::{
13    load_plugin_options, substitute_plugin_variables, substitute_user_config_in_content,
14};
15use super::walk_plugin_markdown::{WalkPluginMarkdownOpts, walk_plugin_markdown};
16use crate::plugin::types::PluginManifest;
17
18static PLUGIN_COMMAND_CACHE: Lazy<Mutex<Option<Vec<Command>>>> = Lazy::new(|| Mutex::new(None));
19
20/// Command definition loaded from a plugin.
21#[derive(Clone, Debug)]
22pub struct Command {
23    pub command_type: String,
24    pub name: String,
25    pub description: String,
26    pub content: String,
27    pub source: String,
28    pub plugin: Option<String>,
29    pub is_hidden: bool,
30    pub allowed_tools: Vec<String>,
31}
32
33/// Load plugin commands from all enabled plugins.
34pub async fn load_plugin_commands() -> Result<Vec<Command>, Box<dyn std::error::Error + Send + Sync>>
35{
36    {
37        let cache = PLUGIN_COMMAND_CACHE.lock().unwrap();
38        if let Some(ref commands) = *cache {
39            return Ok(commands.clone());
40        }
41    }
42
43    let plugin_result = load_all_plugins_cache_only().await?;
44    let mut all_commands = Vec::new();
45
46    for plugin in &plugin_result.enabled {
47        let mut loaded_paths = HashSet::new();
48
49        // Load commands from default commands directory
50        if let Some(ref commands_path) = plugin.commands_path {
51            match load_commands_from_directory(
52                Path::new(commands_path),
53                &plugin.name,
54                &plugin.source,
55                &plugin.manifest,
56                &plugin.path,
57                &mut loaded_paths,
58                false,
59            )
60            .await
61            {
62                Ok(commands) => {
63                    log::debug!(
64                        "Loaded {} commands from plugin {} default directory",
65                        commands.len(),
66                        plugin.name
67                    );
68                    all_commands.extend(commands);
69                }
70                Err(e) => log::debug!(
71                    "Failed to load commands from plugin {} default directory: {}",
72                    plugin.name,
73                    e
74                ),
75            }
76        }
77
78        // Load commands from additional paths
79        if let Some(ref commands_paths) = plugin.commands_paths {
80            for command_path in commands_paths {
81                if let Ok(commands) = load_commands_from_path(
82                    command_path,
83                    &plugin.name,
84                    &plugin.source,
85                    &plugin.manifest,
86                    &plugin.path,
87                    &mut loaded_paths,
88                )
89                .await
90                {
91                    all_commands.extend(commands);
92                }
93            }
94        }
95    }
96
97    log::debug!("Total plugin commands loaded: {}", all_commands.len());
98
99    {
100        let mut cache = PLUGIN_COMMAND_CACHE.lock().unwrap();
101        *cache = Some(all_commands.clone());
102    }
103
104    Ok(all_commands)
105}
106
107async fn load_commands_from_directory(
108    commands_path: &Path,
109    plugin_name: &str,
110    source_name: &str,
111    plugin_manifest: &PluginManifest,
112    plugin_path: &str,
113    loaded_paths: &mut HashSet<String>,
114    is_skill_mode: bool,
115) -> Result<Vec<Command>, Box<dyn std::error::Error + Send + Sync>> {
116    use std::sync::Arc;
117    use tokio::sync::Mutex;
118
119    let commands: Arc<Mutex<Vec<Command>>> = Arc::new(Mutex::new(Vec::new()));
120
121    walk_plugin_markdown(
122        commands_path,
123        |full_path, _namespace| {
124            let plugin_name = plugin_name.to_string();
125            let source_name = source_name.to_string();
126            let manifest = plugin_manifest.clone();
127            let plugin_path = plugin_path.to_string();
128            let base_dir = commands_path.to_path_buf();
129            let commands = Arc::clone(&commands);
130
131            Box::pin(async move {
132                let path_str = full_path.clone();
133
134                if let Ok(content) = tokio::fs::read_to_string(&full_path).await {
135                    let command_name =
136                        get_command_name_from_file(Path::new(&full_path), &base_dir, &plugin_name);
137
138                    if let Ok(Some(command)) = create_plugin_command(
139                        &command_name,
140                        &content,
141                        &path_str,
142                        &source_name,
143                        &manifest,
144                        &plugin_path,
145                        false,
146                        is_skill_mode,
147                    )
148                    .await
149                    {
150                        commands.lock().await.push(command);
151                    }
152                }
153            })
154        },
155        WalkPluginMarkdownOpts {
156            stop_at_skill_dir: Some(false),
157            log_label: Some("commands".to_string()),
158        },
159    )
160    .await
161    .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
162
163    Ok(Arc::try_unwrap(commands).unwrap().into_inner())
164}
165
166fn get_command_name_from_file(file_path: &Path, base_dir: &Path, plugin_name: &str) -> String {
167    let is_skill = is_skill_file(file_path);
168
169    let command_base_name = if is_skill {
170        file_path
171            .parent()
172            .and_then(|p| p.file_name())
173            .map(|n| n.to_string_lossy().to_string())
174            .unwrap_or_default()
175    } else {
176        file_path
177            .file_stem()
178            .map(|s| s.to_string_lossy().to_string())
179            .unwrap_or_default()
180    };
181
182    let relative_path = file_path
183        .parent()
184        .and_then(|p| p.strip_prefix(base_dir).ok())
185        .map(|p| p.to_string_lossy().to_string())
186        .unwrap_or_default();
187
188    let namespace = if relative_path.is_empty() {
189        String::new()
190    } else {
191        relative_path.replace('/', ":")
192    };
193
194    if namespace.is_empty() {
195        format!("{}:{}", plugin_name, command_base_name)
196    } else {
197        format!("{}:{}:{}", plugin_name, namespace, command_base_name)
198    }
199}
200
201fn is_skill_file(file_path: &Path) -> bool {
202    file_path
203        .file_name()
204        .map(|n| n.to_string_lossy().to_string())
205        .map(|n| n.to_lowercase() == "skill.md")
206        .unwrap_or(false)
207}
208
209async fn create_plugin_command(
210    command_name: &str,
211    content: &str,
212    file_path: &str,
213    source_name: &str,
214    plugin_manifest: &PluginManifest,
215    plugin_path: &str,
216    is_skill: bool,
217    is_skill_mode: bool,
218) -> Result<Option<Command>, Box<dyn std::error::Error + Send + Sync>> {
219    let (frontmatter, markdown_content) = parse_frontmatter(content, file_path);
220
221    let description = frontmatter
222        .get("description")
223        .and_then(|v| v.as_str())
224        .unwrap_or(if is_skill {
225            "Plugin skill"
226        } else {
227            "Plugin command"
228        })
229        .to_string();
230
231    let mut final_content = if is_skill_mode {
232        if let Some(parent) = std::path::Path::new(file_path).parent() {
233            format!(
234                "Base directory for this skill: {}\n\n{}",
235                parent.display(),
236                markdown_content
237            )
238        } else {
239            markdown_content.to_string()
240        }
241    } else {
242        markdown_content.to_string()
243    };
244
245    // Substitute plugin variables
246    final_content = substitute_plugin_variables(&final_content, plugin_path, source_name);
247
248    // Substitute user config
249    if plugin_manifest.user_config.is_some() {
250        let options = load_plugin_options(source_name);
251        final_content = substitute_user_config_in_content(
252            &final_content,
253            &options,
254            plugin_manifest.user_config.as_ref().unwrap(),
255        );
256    }
257
258    Ok(Some(Command {
259        command_type: "prompt".to_string(),
260        name: command_name.to_string(),
261        description,
262        content: final_content,
263        source: "plugin".to_string(),
264        plugin: Some(source_name.to_string()),
265        is_hidden: false,
266        allowed_tools: Vec::new(),
267    }))
268}
269
270async fn load_commands_from_path(
271    command_path: &str,
272    plugin_name: &str,
273    source_name: &str,
274    plugin_manifest: &PluginManifest,
275    plugin_path: &str,
276    loaded_paths: &mut HashSet<String>,
277) -> Result<Vec<Command>, Box<dyn std::error::Error + Send + Sync>> {
278    let metadata = tokio::fs::metadata(command_path)
279        .await
280        .map_err(|e| format!("Failed to stat {}: {}", command_path, e))?;
281
282    if metadata.is_dir() {
283        load_commands_from_directory(
284            Path::new(command_path),
285            plugin_name,
286            source_name,
287            plugin_manifest,
288            plugin_path,
289            loaded_paths,
290            false,
291        )
292        .await
293    } else if metadata.is_file()
294        && Path::new(command_path)
295            .extension()
296            .map(|e| e.to_string_lossy() == "md")
297            .unwrap_or(false)
298    {
299        if loaded_paths.contains(command_path) {
300            return Ok(Vec::new());
301        }
302        loaded_paths.insert(command_path.to_string());
303
304        let content = tokio::fs::read_to_string(command_path)
305            .await
306            .map_err(|e| format!("Failed to read {}: {}", command_path, e))?;
307        let path_str = command_path.to_string();
308        let command_name = format!(
309            "{}:{}",
310            plugin_name,
311            Path::new(command_path)
312                .file_stem()
313                .map(|s| s.to_string_lossy().to_string())
314                .unwrap_or_default()
315        );
316
317        match create_plugin_command(
318            &command_name,
319            &content,
320            &path_str,
321            source_name,
322            plugin_manifest,
323            plugin_path,
324            false,
325            false,
326        )
327        .await
328        {
329            Ok(Some(cmd)) => Ok(vec![cmd]),
330            _ => Ok(Vec::new()),
331        }
332    } else {
333        Ok(Vec::new())
334    }
335}
336
337/// Clear the plugin command cache.
338pub fn clear_plugin_command_cache() {
339    let mut cache = PLUGIN_COMMAND_CACHE.lock().unwrap();
340    *cache = None;
341}