Skip to main content

ai_agent/plugin/
loader.rs

1//! Plugin loader - ported from ~/claudecode/openclaudecode/src/utils/plugins/pluginLoader.ts
2//!
3//! This module provides plugin loading functionality for discovering, validating,
4//! and loading plugins from various sources (local directories, git repos, npm packages).
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use crate::plugin::types::{
12    CommandMetadata, LoadedPlugin, PluginComponent, PluginError, PluginManifest,
13};
14
15/// Validates a git URL
16fn validate_git_url(url: &str) -> Result<String, PluginError> {
17    // Check for SSH format (git@host:path)
18    if url.starts_with("git@") {
19        return Ok(url.to_string());
20    }
21
22    // Check for HTTPS/HTTP/FILE protocols
23    if let Ok(parsed) = url::Url::parse(url) {
24        let scheme = parsed.scheme();
25        if ["https", "http", "file"].contains(&scheme) {
26            return Ok(url.to_string());
27        }
28    }
29
30    Err(PluginError::GenericError {
31        source: "plugin_loader".to_string(),
32        plugin: None,
33        error: format!("Invalid git URL: {}", url),
34    })
35}
36
37/// Check if a path exists
38#[allow(dead_code)]
39async fn path_exists(path: &Path) -> bool {
40    path.exists()
41}
42
43/// Validate plugin manifest fields
44#[allow(dead_code)]
45fn validate_manifest(manifest: &PluginManifest) -> Result<(), Vec<String>> {
46    let mut errors = Vec::new();
47
48    // Name is required
49    if manifest.name.is_empty() {
50        errors.push("Plugin name is required".to_string());
51    }
52
53    // Check name format (kebab-case recommended)
54    if manifest.name.contains(' ') {
55        errors.push(format!(
56            "Plugin name '{}' should not contain spaces. Use kebab-case.",
57            manifest.name
58        ));
59    }
60
61    // Validate commands if present
62    if let Some(ref commands) = manifest.commands {
63        // Commands can be either:
64        // 1. A string (single path)
65        // 2. An array of strings (multiple paths)
66        // 3. An object mapping command names to metadata
67        match commands {
68            serde_json::Value::String(_) => {}
69            serde_json::Value::Array(arr) => {
70                for item in arr {
71                    if !item.is_string() {
72                        errors.push("Commands array must contain strings".to_string());
73                    }
74                }
75            }
76            serde_json::Value::Object(obj) => {
77                for (cmd_name, metadata) in obj {
78                    if let serde_json::Value::Object(meta) = metadata {
79                        // Validate metadata fields
80                        if let Some(source) = meta.get("source") {
81                            if !source.is_string() {
82                                errors.push(format!(
83                                    "Command '{}' source must be a string",
84                                    cmd_name
85                                ));
86                            }
87                        }
88                        if let Some(content) = meta.get("content") {
89                            if !content.is_string() {
90                                errors.push(format!(
91                                    "Command '{}' content must be a string",
92                                    cmd_name
93                                ));
94                            }
95                        }
96                    }
97                }
98            }
99            _ => {
100                errors.push("Commands must be a string, array, or object".to_string());
101            }
102        }
103    }
104
105    // Validate skills if present
106    if let Some(ref skills) = manifest.skills {
107        match skills {
108            serde_json::Value::String(_) => {}
109            serde_json::Value::Array(arr) => {
110                for item in arr {
111                    if !item.is_string() {
112                        errors.push("Skills array must contain strings".to_string());
113                    }
114                }
115            }
116            _ => {
117                errors.push("Skills must be a string or array".to_string());
118            }
119        }
120    }
121
122    if errors.is_empty() {
123        Ok(())
124    } else {
125        Err(errors)
126    }
127}
128
129/// Validate manifest against schema (simplified version)
130fn validate_manifest_schema(manifest: &PluginManifest) -> Result<(), String> {
131    // Check required fields from PluginManifestMetadataSchema
132    if manifest.name.is_empty() {
133        return Err("name is required".to_string());
134    }
135
136    // Validate commands format
137    if let Some(ref commands) = manifest.commands {
138        let valid = match commands {
139            serde_json::Value::String(_) => true,
140            serde_json::Value::Array(arr) => arr.iter().all(|v| v.is_string()),
141            serde_json::Value::Object(obj) => obj.values().all(|v| {
142                if let serde_json::Value::Object(meta) = v {
143                    meta.contains_key("source") || meta.contains_key("content")
144                } else {
145                    false
146                }
147            }),
148            _ => false,
149        };
150        if !valid {
151            return Err(
152                "commands must be a string, array, or object with source/content fields"
153                    .to_string(),
154            );
155        }
156    }
157
158    // Validate agents format
159    if let Some(ref agents) = manifest.agents {
160        let valid = match agents {
161            serde_json::Value::String(_) => true,
162            serde_json::Value::Array(arr) => arr.iter().all(|v| v.is_string()),
163            _ => false,
164        };
165        if !valid {
166            return Err("agents must be a string or array".to_string());
167        }
168    }
169
170    // Validate skills format
171    if let Some(ref skills) = manifest.skills {
172        let valid = match skills {
173            serde_json::Value::String(_) => true,
174            serde_json::Value::Array(arr) => arr.iter().all(|v| v.is_string()),
175            _ => false,
176        };
177        if !valid {
178            return Err("skills must be a string or array".to_string());
179        }
180    }
181
182    // Validate hooks format
183    if let Some(ref hooks) = manifest.hooks {
184        if !hooks.is_object() {
185            return Err("hooks must be an object".to_string());
186        }
187    }
188
189    // Validate output_styles format
190    if let Some(ref output_styles) = manifest.output_styles {
191        let valid = match output_styles {
192            serde_json::Value::String(_) => true,
193            serde_json::Value::Array(arr) => arr.iter().all(|v| v.is_string()),
194            _ => false,
195        };
196        if !valid {
197            return Err("output_styles must be a string or array".to_string());
198        }
199    }
200
201    Ok(())
202}
203
204/// Load plugin manifest from a JSON file
205pub fn load_plugin_manifest(manifest_path: &Path) -> Result<PluginManifest, PluginError> {
206    if !manifest_path.exists() {
207        return Err(PluginError::PathNotFound {
208            source: "plugin_loader".to_string(),
209            plugin: None,
210            path: manifest_path.display().to_string(),
211            component: PluginComponent::Commands,
212        });
213    }
214
215    let content =
216        fs::read_to_string(manifest_path).map_err(|e| PluginError::ManifestParseError {
217            source: "plugin_loader".to_string(),
218            plugin: None,
219            manifest_path: manifest_path.display().to_string(),
220            parse_error: e.to_string(),
221        })?;
222
223    let parsed: serde_json::Value =
224        serde_json::from_str(&content).map_err(|e| PluginError::ManifestParseError {
225            source: "plugin_loader".to_string(),
226            plugin: None,
227            manifest_path: manifest_path.display().to_string(),
228            parse_error: e.to_string(),
229        })?;
230
231    // Parse into PluginManifest
232    let manifest: PluginManifest =
233        serde_json::from_value(parsed).map_err(|e| PluginError::ManifestParseError {
234            source: "plugin_loader".to_string(),
235            plugin: None,
236            manifest_path: manifest_path.display().to_string(),
237            parse_error: e.to_string(),
238        })?;
239
240    // Validate schema
241    validate_manifest_schema(&manifest).map_err(|err| PluginError::ManifestValidationError {
242        source: "plugin_loader".to_string(),
243        plugin: Some(manifest.name.clone()),
244        manifest_path: manifest_path.display().to_string(),
245        validation_errors: vec![err],
246    })?;
247
248    Ok(manifest)
249}
250
251/// Load plugin manifest, returning a default if not found
252pub fn load_plugin_manifest_or_default(
253    manifest_path: &Path,
254    plugin_name: &str,
255    source: &str,
256) -> PluginManifest {
257    match load_plugin_manifest(manifest_path) {
258        Ok(manifest) => manifest,
259        Err(_) => PluginManifest {
260            name: plugin_name.to_string(),
261            version: None,
262            description: Some(format!("Plugin from {}", source)),
263            author: None,
264            homepage: None,
265            repository: None,
266            license: None,
267            keywords: None,
268            dependencies: None,
269            commands: None,
270            agents: None,
271            skills: None,
272            hooks: None,
273            output_styles: None,
274            channels: None,
275            mcp_servers: None,
276            lsp_servers: None,
277            settings: None,
278            user_config: None,
279        },
280    }
281}
282
283/// Clone a git repository to a target path
284pub async fn git_clone(
285    git_url: &str,
286    target_path: &Path,
287    branch: Option<&str>,
288    sha: Option<&str>,
289) -> Result<(), PluginError> {
290    let validated_url = validate_git_url(git_url)?;
291
292    // Ensure parent directory exists
293    if let Some(parent) = target_path.parent() {
294        fs::create_dir_all(parent).map_err(|e| PluginError::GenericError {
295            source: "plugin_loader".to_string(),
296            plugin: None,
297            error: format!("Failed to create parent directory: {}", e),
298        })?;
299    }
300
301    // Build git clone arguments
302    let mut args = vec![
303        "clone".to_string(),
304        "--depth".to_string(),
305        "1".to_string(),
306        "--recurse-submodules".to_string(),
307        "--shallow-submodules".to_string(),
308    ];
309
310    // Add branch flag
311    if let Some(branch) = branch {
312        args.push("--branch".to_string());
313        args.push(branch.to_string());
314    }
315
316    // If sha is specified, use --no-checkout
317    if sha.is_some() {
318        args.push("--no-checkout".to_string());
319    }
320
321    args.push(validated_url);
322    args.push(target_path.display().to_string());
323
324    // Run git clone
325    let output =
326        Command::new("git")
327            .args(&args)
328            .output()
329            .map_err(|e| PluginError::GenericError {
330                source: "plugin_loader".to_string(),
331                plugin: None,
332                error: format!("Failed to execute git: {}", e),
333            })?;
334
335    if !output.status.success() {
336        let stderr = String::from_utf8_lossy(&output.stderr);
337        return Err(PluginError::GenericError {
338            source: "plugin_loader".to_string(),
339            plugin: None,
340            error: format!("Git clone failed: {}", stderr),
341        });
342    }
343
344    // If sha is specified, fetch and checkout that specific commit
345    if let Some(sha) = sha {
346        // Try shallow fetch first
347        let fetch_result = Command::new("git")
348            .args(&["fetch", "--depth", "1", "origin", sha])
349            .current_dir(target_path)
350            .output();
351
352        let fetch_success = match fetch_result {
353            Ok(output) => output.status.success(),
354            Err(_) => false,
355        };
356
357        if !fetch_success {
358            // Fall back to unshallow fetch
359            let _ = Command::new("git")
360                .args(&["fetch", "--unshallow"])
361                .current_dir(target_path)
362                .output();
363        }
364
365        // Checkout the specific commit
366        let checkout_output = Command::new("git")
367            .args(&["checkout", sha])
368            .current_dir(target_path)
369            .output()
370            .map_err(|e| PluginError::GenericError {
371                source: "plugin_loader".to_string(),
372                plugin: None,
373                error: format!("Failed to checkout commit: {}", e),
374            })?;
375
376        if !checkout_output.status.success() {
377            let stderr = String::from_utf8_lossy(&checkout_output.stderr);
378            return Err(PluginError::GenericError {
379                source: "plugin_loader".to_string(),
380                plugin: None,
381                error: format!("Failed to checkout commit {}: {}", sha, stderr),
382            });
383        }
384    }
385
386    Ok(())
387}
388
389/// Install a plugin from npm
390pub async fn install_from_npm(
391    package_name: &str,
392    target_path: &Path,
393    version: Option<&str>,
394) -> Result<(), PluginError> {
395    // Build package spec
396    let package_spec = match version {
397        Some(v) => format!("{}@{}", package_name, v),
398        None => package_name.to_string(),
399    };
400
401    // Ensure parent directory exists
402    if let Some(parent) = target_path.parent() {
403        fs::create_dir_all(parent).map_err(|e| PluginError::GenericError {
404            source: "plugin_loader".to_string(),
405            plugin: Some(package_name.to_string()),
406            error: format!("Failed to create parent directory: {}", e),
407        })?;
408    }
409
410    // Run npm install
411    let install_result = Command::new("npm")
412        .args(&[
413            "install",
414            &package_spec,
415            "--prefix",
416            &target_path.display().to_string(),
417        ])
418        .output()
419        .map_err(|e| PluginError::GenericError {
420            source: "plugin_loader".to_string(),
421            plugin: Some(package_name.to_string()),
422            error: format!("Failed to execute npm: {}", e),
423        })?;
424
425    if !install_result.status.success() {
426        let stderr = String::from_utf8_lossy(&install_result.stderr);
427        return Err(PluginError::GenericError {
428            source: "plugin_loader".to_string(),
429            plugin: Some(package_name.to_string()),
430            error: format!("npm install failed: {}", stderr),
431        });
432    }
433
434    // Find the actual package location in node_modules
435    let node_modules_path = target_path.join("node_modules").join(package_name);
436    if !node_modules_path.exists() {
437        return Err(PluginError::GenericError {
438            source: "plugin_loader".to_string(),
439            plugin: Some(package_name.to_string()),
440            error: format!("Package not found in node_modules: {}", package_name),
441        });
442    }
443
444    Ok(())
445}
446
447/// Copy a directory recursively (non-async for simplicity)
448#[allow(dead_code)]
449fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), PluginError> {
450    if !src.exists() {
451        return Err(PluginError::PathNotFound {
452            source: "plugin_loader".to_string(),
453            plugin: None,
454            path: src.display().to_string(),
455            component: PluginComponent::Commands,
456        });
457    }
458
459    // Create destination directory
460    fs::create_dir_all(dest).map_err(|e| PluginError::GenericError {
461        source: "plugin_loader".to_string(),
462        plugin: None,
463        error: format!("Failed to create destination directory: {}", e),
464    })?;
465
466    // Copy entries
467    for entry in fs::read_dir(src).map_err(|e| PluginError::GenericError {
468        source: "plugin_loader".to_string(),
469        plugin: None,
470        error: format!("Failed to read source directory: {}", e),
471    })? {
472        let entry = entry.map_err(|e| PluginError::GenericError {
473            source: "plugin_loader".to_string(),
474            plugin: None,
475            error: format!("Failed to read directory entry: {}", e),
476        })?;
477
478        let src_path = entry.path();
479        let dest_path = dest.join(entry.file_name());
480
481        if src_path.is_dir() {
482            copy_dir_recursive(&src_path, &dest_path)?;
483        } else {
484            fs::copy(&src_path, &dest_path).map_err(|e| PluginError::GenericError {
485                source: "plugin_loader".to_string(),
486                plugin: None,
487                error: format!("Failed to copy file: {}", e),
488            })?;
489        }
490    }
491
492    Ok(())
493}
494
495/// Validate plugin paths from manifest
496#[allow(dead_code)]
497fn validate_plugin_paths(
498    paths: &[String],
499    plugin_path: &Path,
500    _plugin_name: &str,
501    _source: &str,
502    _component: PluginComponent,
503) -> Vec<(String, bool)> {
504    paths
505        .iter()
506        .map(|rel_path| {
507            let full_path = plugin_path.join(rel_path);
508            (rel_path.clone(), full_path.exists())
509        })
510        .collect()
511}
512
513/// Create a LoadedPlugin from a plugin directory path
514pub async fn create_plugin_from_path(
515    plugin_path: &Path,
516    source: &str,
517    enabled: bool,
518    fallback_name: &str,
519) -> Result<LoadedPlugin, PluginError> {
520    // Step 1: Load or create the plugin manifest
521    // Try multiple possible manifest locations
522    let possible_manifest_paths = vec![
523        plugin_path.join(".ai-plugin").join("plugin.json"),
524        plugin_path.join("plugin.json"),
525        plugin_path.join("claude_plugin.json"),
526    ];
527
528    let mut manifest: Option<PluginManifest> = None;
529    for manifest_path in &possible_manifest_paths {
530        if manifest_path.exists() {
531            match load_plugin_manifest(manifest_path) {
532                Ok(m) => {
533                    manifest = Some(m);
534                    break;
535                }
536                Err(e) => {
537                    return Err(e);
538                }
539            }
540        }
541    }
542
543    // If no manifest found, create default
544    let manifest = manifest.unwrap_or_else(|| PluginManifest {
545        name: fallback_name.to_string(),
546        version: None,
547        description: Some(format!("Plugin from {}", source)),
548        author: None,
549        homepage: None,
550        repository: None,
551        license: None,
552        keywords: None,
553        dependencies: None,
554        commands: None,
555        agents: None,
556        skills: None,
557        hooks: None,
558        output_styles: None,
559        channels: None,
560        mcp_servers: None,
561        lsp_servers: None,
562        settings: None,
563        user_config: None,
564    });
565
566    // Step 2: Create the base plugin object
567    let mut plugin = LoadedPlugin {
568        name: manifest.name.clone(),
569        manifest: manifest.clone(),
570        path: plugin_path.display().to_string(),
571        source: source.to_string(),
572        repository: source.to_string(),
573        enabled: Some(enabled),
574        is_builtin: None,
575        sha: None,
576        commands_path: None,
577        commands_paths: None,
578        commands_metadata: None,
579        agents_path: None,
580        agents_paths: None,
581        skills_path: None,
582        skills_paths: None,
583        output_styles_path: None,
584        output_styles_paths: None,
585        hooks_config: None,
586        mcp_servers: None,
587        lsp_servers: None,
588        settings: None,
589    };
590
591    // Step 3: Auto-detect optional directories
592    let commands_dir = plugin_path.join("commands");
593    let agents_dir = plugin_path.join("agents");
594    let skills_dir = plugin_path.join("skills");
595    let output_styles_dir = plugin_path.join("output-styles");
596
597    // Register detected directories
598    if commands_dir.exists() {
599        plugin.commands_path = Some(commands_dir.display().to_string());
600    }
601
602    if agents_dir.exists() {
603        plugin.agents_path = Some(agents_dir.display().to_string());
604    }
605
606    if skills_dir.exists() {
607        plugin.skills_path = Some(skills_dir.display().to_string());
608    }
609
610    if output_styles_dir.exists() {
611        plugin.output_styles_path = Some(output_styles_dir.display().to_string());
612    }
613
614    // Step 3a: Process command paths from manifest
615    if let Some(ref commands) = manifest.commands {
616        let cmd_paths: Vec<String> = match commands {
617            serde_json::Value::String(s) => vec![s.clone()],
618            serde_json::Value::Array(arr) => arr
619                .iter()
620                .filter_map(|v| v.as_str().map(String::from))
621                .collect(),
622            serde_json::Value::Object(obj) => {
623                // Object mapping format - collect source paths
624                let mut paths: Vec<String> = Vec::new();
625                let mut metadata_map: HashMap<String, CommandMetadata> = HashMap::new();
626
627                for (cmd_name, metadata) in obj {
628                    if let serde_json::Value::Object(meta) = metadata {
629                        if let Some(source) = meta.get("source").and_then(|v| v.as_str()) {
630                            paths.push(source.to_string());
631                        }
632
633                        // Build metadata
634                        let meta_obj = CommandMetadata {
635                            source: meta
636                                .get("source")
637                                .and_then(|v| v.as_str().map(String::from)),
638                            content: meta
639                                .get("content")
640                                .and_then(|v| v.as_str().map(String::from)),
641                            description: meta
642                                .get("description")
643                                .and_then(|v| v.as_str().map(String::from)),
644                            argument_hint: meta
645                                .get("argumentHint")
646                                .and_then(|v| v.as_str().map(String::from)),
647                            model: meta.get("model").and_then(|v| v.as_str().map(String::from)),
648                            allowed_tools: meta.get("allowedTools").and_then(|v| {
649                                v.as_array().map(|arr| {
650                                    arr.iter()
651                                        .filter_map(|item| item.as_str().map(String::from))
652                                        .collect()
653                                })
654                            }),
655                        };
656                        metadata_map.insert(cmd_name.clone(), meta_obj);
657                    }
658                }
659
660                plugin.commands_metadata = Some(metadata_map);
661                paths
662            }
663            _ => vec![],
664        };
665
666        // Validate and set command paths
667        if !cmd_paths.is_empty() {
668            let validated: Vec<String> = cmd_paths
669                .iter()
670                .filter(|p| plugin_path.join(p).exists())
671                .map(|p| plugin_path.join(p).display().to_string())
672                .collect();
673
674            if !validated.is_empty() {
675                plugin.commands_paths = Some(validated);
676            }
677        }
678    }
679
680    // Step 4: Process agent paths from manifest
681    if let Some(ref agents) = manifest.agents {
682        let agent_paths: Vec<String> = match agents {
683            serde_json::Value::String(s) => vec![s.clone()],
684            serde_json::Value::Array(arr) => arr
685                .iter()
686                .filter_map(|v| v.as_str().map(String::from))
687                .collect(),
688            _ => vec![],
689        };
690
691        if !agent_paths.is_empty() {
692            let validated: Vec<String> = agent_paths
693                .iter()
694                .filter(|p| plugin_path.join(p).exists())
695                .map(|p| plugin_path.join(p).display().to_string())
696                .collect();
697
698            if !validated.is_empty() {
699                plugin.agents_paths = Some(validated);
700            }
701        }
702    }
703
704    // Step 5: Process skill paths from manifest
705    if let Some(ref skills) = manifest.skills {
706        let skill_paths: Vec<String> = match skills {
707            serde_json::Value::String(s) => vec![s.clone()],
708            serde_json::Value::Array(arr) => arr
709                .iter()
710                .filter_map(|v| v.as_str().map(String::from))
711                .collect(),
712            _ => vec![],
713        };
714
715        if !skill_paths.is_empty() {
716            let validated: Vec<String> = skill_paths
717                .iter()
718                .filter(|p| plugin_path.join(p).exists())
719                .map(|p| plugin_path.join(p).display().to_string())
720                .collect();
721
722            if !validated.is_empty() {
723                plugin.skills_paths = Some(validated);
724            }
725        }
726    }
727
728    // Step 6: Process output styles from manifest
729    if let Some(ref output_styles) = manifest.output_styles {
730        let style_paths: Vec<String> = match output_styles {
731            serde_json::Value::String(s) => vec![s.clone()],
732            serde_json::Value::Array(arr) => arr
733                .iter()
734                .filter_map(|v| v.as_str().map(String::from))
735                .collect(),
736            _ => vec![],
737        };
738
739        if !style_paths.is_empty() {
740            let validated: Vec<String> = style_paths
741                .iter()
742                .filter(|p| plugin_path.join(p).exists())
743                .map(|p| plugin_path.join(p).display().to_string())
744                .collect();
745
746            if !validated.is_empty() {
747                plugin.output_styles_paths = Some(validated);
748            }
749        }
750    }
751
752    // Step 7: Load hooks configuration if present
753    let hooks_path = plugin_path.join("hooks").join("hooks.json");
754    if hooks_path.exists() {
755        match fs::read_to_string(&hooks_path) {
756            Ok(content) => {
757                if let Ok(hooks_config) = serde_json::from_str::<serde_json::Value>(&content) {
758                    plugin.hooks_config = Some(hooks_config);
759                }
760            }
761            Err(_) => {}
762        }
763    }
764
765    Ok(plugin)
766}
767
768/// Load a single plugin from a path
769///
770/// This function handles loading a plugin from:
771/// - A local directory (looking for manifest.json or claude_plugin.json)
772/// - A git repository (clone and load)
773/// - An npm package (install and load)
774///
775/// # Arguments
776/// * `path` - The path to load the plugin from. Can be:
777///   - A local directory path
778///   - A git URL (https://, git@, or file://)
779///   - An npm package name (optionally with version)
780pub async fn load_plugin(path: &Path) -> Result<LoadedPlugin, PluginError> {
781    let path_str = path.display().to_string();
782
783    // Determine the source type and load accordingly
784    let (plugin_path, source, plugin_name) = if path.is_dir() {
785        // Local directory
786        let name = path
787            .file_name()
788            .and_then(|n| n.to_str())
789            .unwrap_or("unknown")
790            .to_string();
791        (path.to_path_buf(), path_str.clone(), name)
792    } else if path_str.starts_with("git@")
793        || path_str.starts_with("https://")
794        || path_str.starts_with("http://")
795        || path_str.starts_with("file://")
796    {
797        // Git repository URL
798        let temp_dir = std::env::temp_dir().join(format!(
799            "plugin_{}",
800            std::time::SystemTime::now()
801                .duration_since(std::time::UNIX_EPOCH)
802                .unwrap()
803                .as_millis()
804        ));
805
806        git_clone(&path_str, &temp_dir, None, None).await?;
807
808        // Extract plugin name from URL
809        let name = path
810            .file_stem()
811            .and_then(|n| n.to_str())
812            .unwrap_or("git-plugin")
813            .to_string();
814
815        (temp_dir, path_str.clone(), name)
816    } else if !path_str.contains('/') && !path_str.contains('\\') {
817        // Could be npm package name
818        let temp_dir = std::env::temp_dir().join(format!(
819            "npm_plugin_{}",
820            std::time::SystemTime::now()
821                .duration_since(std::time::UNIX_EPOCH)
822                .unwrap()
823                .as_millis()
824        ));
825
826        install_from_npm(&path_str, &temp_dir, None).await?;
827
828        (temp_dir, format!("npm:{}", path_str), path_str.clone())
829    } else {
830        return Err(PluginError::GenericError {
831            source: "plugin_loader".to_string(),
832            plugin: None,
833            error: format!("Invalid plugin path: {}", path_str),
834        });
835    };
836
837    // Create the plugin from the loaded path
838    create_plugin_from_path(&plugin_path, &source, true, &plugin_name).await
839}
840
841/// Load plugins from a directory
842///
843/// Scans the specified directory for plugin subdirectories and loads each one.
844///
845/// # Arguments
846/// * `dir` - The directory to scan for plugins
847///
848/// # Returns
849/// A vector of successfully loaded plugins
850pub async fn load_plugins_from_dir(dir: &Path) -> Vec<LoadedPlugin> {
851    let mut plugins = Vec::new();
852
853    if !dir.exists() || !dir.is_dir() {
854        return plugins;
855    }
856
857    // Read directory entries
858    let entries = match fs::read_dir(dir) {
859        Ok(entries) => entries,
860        Err(_) => return plugins,
861    };
862
863    for entry in entries.flatten() {
864        let path = entry.path();
865        if path.is_dir() {
866            match load_plugin(&path).await {
867                Ok(plugin) => plugins.push(plugin),
868                Err(e) => {
869                    // Log error but continue loading other plugins
870                    eprintln!("Failed to load plugin from {}: {:?}", path.display(), e);
871                }
872            }
873        }
874    }
875
876    plugins
877}
878
879/// Load plugins from multiple sources
880///
881/// # Arguments
882/// * `sources` - A slice of paths to load plugins from
883///
884/// # Returns
885/// A vector of successfully loaded plugins
886pub async fn load_plugins_from_sources(sources: &[PathBuf]) -> Vec<LoadedPlugin> {
887    let mut plugins = Vec::new();
888
889    for source in sources {
890        match load_plugin(source).await {
891            Ok(plugin) => plugins.push(plugin),
892            Err(e) => {
893                eprintln!("Failed to load plugin from {}: {:?}", source.display(), e);
894            }
895        }
896    }
897
898    plugins
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904    use std::fs;
905    use tempfile::TempDir;
906
907    #[test]
908    fn test_validate_git_url_https() {
909        let result = validate_git_url("https://github.com/user/repo.git");
910        assert!(result.is_ok());
911    }
912
913    #[test]
914    fn test_validate_git_url_ssh() {
915        let result = validate_git_url("git@github.com:user/repo.git");
916        assert!(result.is_ok());
917    }
918
919    #[test]
920    fn test_validate_git_url_invalid() {
921        let result = validate_git_url("ftp://github.com/user/repo.git");
922        assert!(result.is_err());
923    }
924
925    #[test]
926    fn test_validate_manifest_schema_valid() {
927        let manifest = PluginManifest {
928            name: "test-plugin".to_string(),
929            version: Some("1.0.0".to_string()),
930            description: Some("A test plugin".to_string()),
931            author: None,
932            homepage: None,
933            repository: None,
934            license: None,
935            keywords: None,
936            dependencies: None,
937            commands: None,
938            agents: None,
939            skills: None,
940            hooks: None,
941            output_styles: None,
942            channels: None,
943            mcp_servers: None,
944            lsp_servers: None,
945            settings: None,
946            user_config: None,
947        };
948
949        let result = validate_manifest_schema(&manifest);
950        assert!(result.is_ok());
951    }
952
953    #[test]
954    fn test_validate_manifest_schema_empty_name() {
955        let manifest = PluginManifest {
956            name: "".to_string(),
957            version: Some("1.0.0".to_string()),
958            description: Some("A test plugin".to_string()),
959            author: None,
960            homepage: None,
961            repository: None,
962            license: None,
963            keywords: None,
964            dependencies: None,
965            commands: None,
966            agents: None,
967            skills: None,
968            hooks: None,
969            output_styles: None,
970            channels: None,
971            mcp_servers: None,
972            lsp_servers: None,
973            settings: None,
974            user_config: None,
975        };
976
977        let result = validate_manifest_schema(&manifest);
978        assert!(result.is_err());
979    }
980
981    #[tokio::test]
982    async fn test_load_plugin_manifest_from_file() {
983        // Create a temp directory with a manifest
984        let temp_dir = TempDir::new().unwrap();
985        let manifest_path = temp_dir.path().join("plugin.json");
986
987        let manifest_content = r#"{
988            "name": "test-plugin",
989            "version": "1.0.0",
990            "description": "A test plugin"
991        }"#;
992
993        fs::write(&manifest_path, manifest_content).unwrap();
994
995        let result = load_plugin_manifest(&manifest_path);
996        assert!(result.is_ok());
997        let manifest = result.unwrap();
998        assert_eq!(manifest.name, "test-plugin");
999        assert_eq!(manifest.version, Some("1.0.0".to_string()));
1000    }
1001
1002    #[tokio::test]
1003    async fn test_load_plugin_manifest_not_found() {
1004        let temp_dir = TempDir::new().unwrap();
1005        let manifest_path = temp_dir.path().join("nonexistent.json");
1006
1007        let result = load_plugin_manifest(&manifest_path);
1008        assert!(result.is_err());
1009    }
1010
1011    #[tokio::test]
1012    async fn test_create_plugin_from_path_with_manifest() {
1013        // Create a temp directory with a manifest and commands
1014        let temp_dir = TempDir::new().unwrap();
1015        let plugin_dir = temp_dir.path();
1016
1017        // Create manifest
1018        let manifest_content = r#"{
1019            "name": "my-test-plugin",
1020            "version": "1.0.0",
1021            "description": "A test plugin"
1022        }"#;
1023        fs::write(plugin_dir.join("plugin.json"), manifest_content).unwrap();
1024
1025        // Create commands directory
1026        fs::create_dir(plugin_dir.join("commands")).unwrap();
1027        fs::write(
1028            plugin_dir.join("commands").join("test.md"),
1029            "# Test Command",
1030        )
1031        .unwrap();
1032
1033        let result = create_plugin_from_path(plugin_dir, "test", true, "fallback").await;
1034        assert!(result.is_ok());
1035
1036        let plugin = result.unwrap();
1037        assert_eq!(plugin.name, "my-test-plugin");
1038        assert!(plugin.commands_path.is_some());
1039    }
1040
1041    #[tokio::test]
1042    async fn test_load_plugins_from_dir_empty() {
1043        let temp_dir = TempDir::new().unwrap();
1044        let plugins = load_plugins_from_dir(temp_dir.path()).await;
1045        assert!(plugins.is_empty());
1046    }
1047
1048    #[tokio::test]
1049    async fn test_load_plugins_from_dir_with_plugins() {
1050        // Create a temp directory with plugin subdirectories
1051        let temp_dir = TempDir::new().unwrap();
1052        let plugins_dir = temp_dir.path();
1053
1054        // Create first plugin
1055        let plugin1_dir = plugins_dir.join("plugin1");
1056        fs::create_dir(&plugin1_dir).unwrap();
1057        fs::write(plugin1_dir.join("plugin.json"), r#"{"name": "plugin1"}"#).unwrap();
1058
1059        // Create second plugin
1060        let plugin2_dir = plugins_dir.join("plugin2");
1061        fs::create_dir(&plugin2_dir).unwrap();
1062        fs::write(plugin2_dir.join("plugin.json"), r#"{"name": "plugin2"}"#).unwrap();
1063
1064        let plugins = load_plugins_from_dir(plugins_dir).await;
1065        assert_eq!(plugins.len(), 2);
1066    }
1067}