Skip to main content

ai_agent/utils/plugins/
loader.rs

1//! Plugin loader - cache-only loading functions
2//!
3//! Ported from ~/claudecode/openclaudecode/src/utils/plugins/marketplaceManager.ts
4//!
5//! This module provides cache-only loading functions for marketplaces and plugins.
6//! These functions are used for startup paths that should never block on network.
7
8use std::fs;
9use std::path::PathBuf;
10
11use super::types::{KnownMarketplacesFile, PluginMarketplace, PluginMarketplaceEntry};
12use crate::plugin::types::{LoadedPlugin, PluginError, PluginLoadResult};
13use crate::utils::config::get_global_config_path;
14
15/// Get the path to the known marketplaces config file
16fn get_known_marketplaces_file() -> PathBuf {
17    get_global_config_path().join("known_marketplaces.json")
18}
19
20/// Read a cached marketplace from disk
21async fn read_cached_marketplace(install_location: &str) -> Option<PluginMarketplace> {
22    let marketplace_path = PathBuf::from(install_location)
23        .join(".ai-plugin")
24        .join("marketplace.json");
25
26    if !marketplace_path.exists() {
27        return None;
28    }
29
30    match fs::read_to_string(&marketplace_path) {
31        Ok(content) => match serde_json::from_str::<PluginMarketplace>(&content) {
32            Ok(marketplace) => Some(marketplace),
33            Err(e) => {
34                eprintln!(
35                    "Failed to parse marketplace at {}: {}",
36                    marketplace_path.display(),
37                    e
38                );
39                None
40            }
41        },
42        Err(e) => {
43            eprintln!(
44                "Failed to read marketplace at {}: {}",
45                marketplace_path.display(),
46                e
47            );
48            None
49        }
50    }
51}
52
53/// Load known marketplaces config from cache
54async fn load_known_marketplaces_config() -> Option<KnownMarketplacesFile> {
55    let config_file = get_known_marketplaces_file();
56
57    if !config_file.exists() {
58        return None;
59    }
60
61    match fs::read_to_string(&config_file) {
62        Ok(content) => match serde_json::from_str::<KnownMarketplacesFile>(&content) {
63            Ok(config) => Some(config),
64            Err(e) => {
65                eprintln!("Failed to parse known marketplaces: {}", e);
66                None
67            }
68        },
69        Err(e) => {
70            eprintln!("Failed to read known marketplaces file: {}", e);
71            None
72        }
73    }
74}
75
76/// Parse plugin identifier into name and marketplace
77///
78/// # Arguments
79/// * `plugin_id` - Plugin ID in format "name@marketplace"
80///
81/// # Returns
82/// Tuple of (name, marketplace) or (None, None) if invalid
83pub fn parse_plugin_identifier(plugin_id: &str) -> (Option<String>, Option<String>) {
84    if let Some(at_pos) = plugin_id.rfind('@') {
85        let name = plugin_id[..at_pos].to_string();
86        let marketplace = plugin_id[at_pos + 1..].to_string();
87        if !name.is_empty() && !marketplace.is_empty() {
88            return (Some(name), Some(marketplace));
89        }
90    }
91    (None, None)
92}
93
94/// Get a marketplace by name from cache only (no network)
95///
96/// Use this for startup paths that should never block on network.
97///
98/// # Arguments
99/// * `name` - Marketplace name
100///
101/// # Returns
102/// The marketplace or null if not found/cache missing
103pub async fn get_marketplace_cache_only(name: &str) -> Option<PluginMarketplace> {
104    let config_file = get_known_marketplaces_file();
105
106    if !config_file.exists() {
107        return None;
108    }
109
110    match fs::read_to_string(&config_file) {
111        Ok(content) => {
112            match serde_json::from_str::<KnownMarketplacesFile>(&content) {
113                Ok(config) => {
114                    if let Some(entry) = config.get(name) {
115                        // Try to read the marketplace from the install location
116                        if let Some(marketplace) =
117                            read_cached_marketplace(&entry.install_location).await
118                        {
119                            return Some(marketplace);
120                        }
121                    }
122                    None
123                }
124                Err(e) => {
125                    eprintln!("Failed to parse known marketplaces config: {}", e);
126                    None
127                }
128            }
129        }
130        Err(_) => None,
131    }
132}
133
134/// Get a plugin by ID from cache only (no network)
135///
136/// # Arguments
137/// * `plugin_id` - Plugin ID in format "name@marketplace"
138///
139/// # Returns
140/// The plugin entry and marketplace install location, or null if not found/cache missing
141pub async fn get_plugin_by_id_cache_only(
142    plugin_id: &str,
143) -> Option<(PluginMarketplaceEntry, String)> {
144    let (plugin_name, marketplace_name) = parse_plugin_identifier(plugin_id);
145    let plugin_name = plugin_name?;
146    let marketplace_name = marketplace_name?;
147
148    let config_file = get_known_marketplaces_file();
149
150    if !config_file.exists() {
151        return None;
152    }
153
154    match fs::read_to_string(&config_file) {
155        Ok(content) => {
156            match serde_json::from_str::<KnownMarketplacesFile>(&content) {
157                Ok(config) => {
158                    // Get marketplace config
159                    let marketplace_config = config.get(&marketplace_name)?;
160
161                    // Get the marketplace itself
162                    let marketplace = get_marketplace_cache_only(&marketplace_name).await?;
163
164                    // Find the plugin in the marketplace
165                    marketplace
166                        .plugins
167                        .into_iter()
168                        .find(|p| p.name == plugin_name)
169                        .map(|entry| (entry, marketplace_config.install_location.clone()))
170                }
171                Err(_) => None,
172            }
173        }
174        Err(_) => None,
175    }
176}
177
178/// Get all known marketplace names
179pub async fn get_known_marketplace_names() -> Vec<String> {
180    match load_known_marketplaces_config().await {
181        Some(config) => config.keys().cloned().collect(),
182        None => vec![],
183    }
184}
185
186/// Load all plugins from cache (no network).
187///
188/// This is the main entry point for loading plugins at startup.
189/// Returns enabled/disabled plugins and any errors encountered.
190pub async fn load_all_plugins() -> Result<PluginLoadResult, Box<dyn std::error::Error + Send + Sync>>
191{
192    // Stub: return empty result - full implementation would load
193    // from known_marketplaces config and resolve each plugin
194    Ok(PluginLoadResult {
195        enabled: vec![],
196        disabled: vec![],
197        errors: vec![],
198    })
199}
200
201/// Load all plugins from cache only (strictly no network).
202///
203/// Same as load_all_plugins but guaranteed never to hit the network.
204pub async fn load_all_plugins_cache_only()
205-> Result<PluginLoadResult, Box<dyn std::error::Error + Send + Sync>> {
206    load_all_plugins().await
207}
208
209/// Get the plugin cache root directory.
210pub fn get_plugin_cache_path() -> String {
211    get_global_config_path()
212        .join("plugins")
213        .to_string_lossy()
214        .to_string()
215}
216
217/// Get a versioned cache path for a specific plugin version.
218pub fn get_versioned_cache_path(plugin_id: &str, version: &str) -> String {
219    let (name, marketplace) = parse_plugin_identifier(plugin_id);
220    let marketplace = marketplace.unwrap_or_else(|| "unknown".to_string());
221    let name = name.unwrap_or_else(|| plugin_id.to_string());
222    get_global_config_path()
223        .join("plugins")
224        .join(&marketplace)
225        .join(&name)
226        .join(version)
227        .to_string_lossy()
228        .to_string()
229}
230
231/// Get a versioned zip cache path for a specific plugin version.
232pub fn get_versioned_zip_cache_path(plugin_id: &str, version: &str) -> String {
233    format!("{}.zip", get_versioned_cache_path(plugin_id, version))
234}
235
236/// Cache a plugin and return the cached path.
237pub async fn cache_plugin(
238    _source: &super::schemas::PluginSource,
239    _entry: &PluginMarketplaceEntry,
240) -> Result<CachePluginResult, Box<dyn std::error::Error + Send + Sync>> {
241    // Stub: in production would clone/fetch plugin source
242    Err("cache_plugin not fully implemented - stub".into())
243}
244
245/// Result of caching a plugin.
246pub struct CachePluginResult {
247    pub path: String,
248    pub manifest: serde_json::Value,
249    pub git_commit_sha: Option<String>,
250}
251
252/// Clear the plugin cache for a specific marketplace, or all if None.
253pub fn clear_plugin_cache(_marketplace: Option<&str>) {
254    // Stub: would clear in-memory caches
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_parse_plugin_identifier_basic() {
263        let (name, marketplace) = parse_plugin_identifier("my-plugin@my-marketplace");
264        assert_eq!(name, Some("my-plugin".to_string()));
265        assert_eq!(marketplace, Some("my-marketplace".to_string()));
266    }
267
268    #[test]
269    fn test_parse_plugin_identifier_invalid() {
270        let (name, marketplace) = parse_plugin_identifier("invalid");
271        assert_eq!(name, None);
272        assert_eq!(marketplace, None);
273    }
274
275    #[test]
276    fn test_parse_plugin_identifier_empty_marketplace() {
277        let (name, marketplace) = parse_plugin_identifier("my-plugin@");
278        assert_eq!(name, None);
279        assert_eq!(marketplace, None);
280    }
281}