Skip to main content

ai_agent/services/plugins/
plugin_installation_manager.rs

1// Source: ~/claudecode/openclaudecode/src/services/plugins/PluginInstallationManager.ts
2#![allow(dead_code)]
3
4//! Background plugin and marketplace installation manager
5//!
6//! This module handles automatic installation of plugins and marketplaces
7//! from trusted sources (repository and user settings) without blocking startup.
8
9use std::collections::HashMap;
10use std::sync::{Arc, RwLock};
11
12/// Progress event types for marketplace installation
13#[derive(Debug, Clone)]
14pub enum MarketplaceProgressEvent {
15    Installing { name: String },
16    Installed { name: String },
17    Failed { name: String, error: String },
18}
19
20impl MarketplaceProgressEvent {
21    pub fn event_type(&self) -> &'static str {
22        match self {
23            Self::Installing { .. } => "installing",
24            Self::Installed { .. } => "installed",
25            Self::Failed { .. } => "failed",
26        }
27    }
28
29    pub fn name(&self) -> &str {
30        match self {
31            Self::Installing { name } | Self::Installed { name } | Self::Failed { name, .. } => {
32                name
33            }
34        }
35    }
36}
37
38/// Marketplace installation status
39#[derive(Debug, Clone)]
40pub struct MarketplaceStatus {
41    pub name: String,
42    pub status: MarketplaceStatusKind,
43    pub error: Option<String>,
44}
45
46/// Status kind for a marketplace
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum MarketplaceStatusKind {
49    Pending,
50    Installing,
51    Installed,
52    Failed,
53}
54
55/// Plugin installation status
56#[derive(Debug, Clone, Default)]
57pub struct PluginInstallationStatus {
58    pub marketplaces: Vec<MarketplaceStatus>,
59    pub plugins: Vec<PluginStatusEntry>,
60}
61
62/// Plugin status entry
63#[derive(Debug, Clone, Default)]
64pub struct PluginStatusEntry {
65    pub name: String,
66    pub status: String,
67}
68
69/// Plugin state within AppState
70#[derive(Debug, Clone, Default)]
71pub struct PluginsState {
72    pub installation_status: PluginInstallationStatus,
73    pub needs_refresh: bool,
74}
75
76/// Application state (simplified - plugins-related portion)
77#[derive(Debug, Clone, Default)]
78pub struct AppState {
79    pub plugins: PluginsState,
80}
81
82/// Function type for updating app state (functional update pattern)
83pub type SetAppState = Arc<dyn Fn(&AppState) -> AppState + Send + Sync>;
84
85/// Reconciliation progress callback type
86pub type OnProgressCallback = Box<dyn Fn(MarketplaceProgressEvent) + Send>;
87
88/// Result of marketplace reconciliation
89#[derive(Debug, Clone, Default)]
90pub struct ReconcileMarketplacesResult {
91    pub installed: Vec<String>,
92    pub updated: Vec<String>,
93    pub failed: Vec<(String, String)>, // (name, error)
94    pub up_to_date: Vec<String>,
95}
96
97/// Marketplace diff result
98#[derive(Debug, Clone, Default)]
99pub struct MarketplaceDiff {
100    /// Marketplaces that are declared but missing from disk
101    pub missing: Vec<String>,
102    /// Marketplaces whose source has changed
103    pub source_changed: Vec<SourceChangedEntry>,
104}
105
106/// Entry for a marketplace with a changed source
107#[derive(Debug, Clone)]
108pub struct SourceChangedEntry {
109    pub name: String,
110    pub old_source: String,
111    pub new_source: String,
112}
113
114/// Declared marketplace entry
115#[derive(Debug, Clone)]
116pub struct DeclaredMarketplace {
117    pub name: String,
118    pub source: String,
119}
120
121/// Installed marketplace config
122#[derive(Debug, Clone)]
123pub struct InstalledMarketplaceConfig {
124    pub name: String,
125    pub install_location: String,
126    pub source: String,
127}
128
129/// Analytics metrics for background install
130#[derive(Debug, Clone)]
131pub struct MarketplaceBackgroundInstallMetrics {
132    pub installed_count: usize,
133    pub updated_count: usize,
134    pub failed_count: usize,
135    pub up_to_date_count: usize,
136}
137
138/// Update marketplace installation status in app state
139fn update_marketplace_status(
140    set_app_state: &SetAppState,
141    name: &str,
142    status: MarketplaceStatusKind,
143    error: Option<&str>,
144) {
145    let name = name.to_string();
146    let error = error.map(String::from);
147
148    set_app_state(&AppState {
149        plugins: PluginsState {
150            installation_status: PluginInstallationStatus {
151                marketplaces: Vec::new(), // Will be populated by actual implementation
152                plugins: Vec::new(),
153            },
154            needs_refresh: false,
155        },
156    });
157
158    log::debug!(
159        "Marketplace status update: {} -> {:?} (error: {:?})",
160        name,
161        status,
162        error
163    );
164}
165
166/// Get declared marketplaces from settings/config
167fn get_declared_marketplaces() -> Vec<DeclaredMarketplace> {
168    // In production, this would read from settings files
169    // For now, return empty - actual implementation would parse
170    // managed-settings.json, user settings, project settings
171    Vec::new()
172}
173
174/// Load known marketplaces config from disk (cache)
175async fn load_known_marketplaces_config() -> HashMap<String, InstalledMarketplaceConfig> {
176    // In production, this would load from known_marketplaces.json cache
177    HashMap::new()
178}
179
180/// Compute diff between declared and materialized marketplaces
181fn diff_marketplaces(
182    declared: &[DeclaredMarketplace],
183    materialized: &HashMap<String, InstalledMarketplaceConfig>,
184) -> MarketplaceDiff {
185    let mut missing = Vec::new();
186    let mut source_changed = Vec::new();
187
188    for declared_mkt in declared {
189        if let Some(installed) = materialized.get(&declared_mkt.name) {
190            // Check if source changed
191            if installed.source != declared_mkt.source {
192                source_changed.push(SourceChangedEntry {
193                    name: declared_mkt.name.clone(),
194                    old_source: installed.source.clone(),
195                    new_source: declared_mkt.source.clone(),
196                });
197            }
198        } else {
199            // Marketplace not materialized
200            missing.push(declared_mkt.name.clone());
201        }
202    }
203
204    MarketplaceDiff {
205        missing,
206        source_changed,
207    }
208}
209
210/// Reconcile marketplaces - install/update/verify declared marketplaces
211///
212/// This is the core reconciliation function that ensures all declared
213/// marketplaces are installed and up to date.
214async fn reconcile_marketplaces(
215    _on_progress: Option<OnProgressCallback>,
216) -> ReconcileMarketplacesResult {
217    // In production, this would:
218    // 1. Clone/update remote marketplaces
219    // 2. Verify marketplace integrity
220    // 3. Call on_progress callbacks during installation
221    // 4. Return detailed results
222    ReconcileMarketplacesResult::default()
223}
224
225/// Clear the marketplaces cache
226fn clear_marketplaces_cache() {
227    // In production, this would clear the in-memory cache of loaded marketplaces
228    log::debug!("Clearing marketplaces cache");
229}
230
231/// Clear the plugin cache with an optional reason
232fn clear_plugin_cache(reason: &str) {
233    log::debug!("Clearing plugin cache: {}", reason);
234}
235
236/// Refresh active plugins - clears caches and reloads plugins
237async fn refresh_active_plugins(
238    _set_app_state: &SetAppState,
239) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
240    // In production, this would:
241    // 1. Clear all plugin caches
242    // 2. Reload plugins from disk
243    // 3. Bump pluginReconnectKey so MCP connections are re-established
244    // 4. Update app state with loaded plugins
245    log::debug!("Refreshing active plugins");
246    Ok(())
247}
248
249/// Log an analytics event
250fn log_event(event_name: &str, metrics: &MarketplaceBackgroundInstallMetrics) {
251    log::debug!(
252        "Analytics event: {} installed={} updated={} failed={} up_to_date={}",
253        event_name,
254        metrics.installed_count,
255        metrics.updated_count,
256        metrics.failed_count,
257        metrics.up_to_date_count
258    );
259}
260
261/// Log a diagnostic message (no PII)
262fn log_for_diagnostics_no_pii(
263    level: &str,
264    event: &str,
265    metrics: &MarketplaceBackgroundInstallMetrics,
266) {
267    log::debug!(
268        "[{}] {} installed={} updated={} failed={} up_to_date={}",
269        level,
270        event,
271        metrics.installed_count,
272        metrics.updated_count,
273        metrics.failed_count,
274        metrics.up_to_date_count
275    );
276}
277
278/// Log for debugging
279fn log_for_debugging(msg: &str) {
280    log::debug!("{}", msg);
281}
282
283/// Log an error
284fn log_error(error: &dyn std::error::Error) {
285    log::error!("{}", error);
286}
287
288/// Pluralize helper
289fn plural(count: usize, singular: &str) -> String {
290    if count == 1 {
291        singular.to_string()
292    } else {
293        format!("{}s", singular)
294    }
295}
296
297/// Perform background plugin startup checks and installations.
298///
299/// This is a thin wrapper around reconcile_marketplaces() that maps on_progress
300/// events to AppState updates for the REPL UI. After marketplaces are
301/// reconciled:
302/// - New installs -> auto-refresh plugins (fixes "plugin-not-found" errors
303///   from the initial cache-only load on fresh homespace/cleared cache)
304/// - Updates only -> set needs_refresh, show notification for /reload-plugins
305pub async fn perform_background_plugin_installations(set_app_state: &SetAppState) {
306    log_for_debugging("perform_background_plugin_installations called");
307
308    // Compute diff upfront for initial UI status (pending spinners)
309    let declared = get_declared_marketplaces();
310    let materialized = load_known_marketplaces_config().await;
311    let diff = diff_marketplaces(&declared, &materialized);
312
313    let pending_names: Vec<String> = diff
314        .missing
315        .iter()
316        .chain(diff.source_changed.iter().map(|c| &c.name))
317        .cloned()
318        .collect();
319
320    // Initialize AppState with pending status. No per-plugin pending status --
321    // plugin load is fast (cache hit or local copy); marketplace clone is the
322    // slow part worth showing progress for.
323    let pending_statuses: Vec<MarketplaceStatus> = pending_names
324        .iter()
325        .map(|name| MarketplaceStatus {
326            name: name.clone(),
327            status: MarketplaceStatusKind::Pending,
328            error: None,
329        })
330        .collect();
331
332    {
333        let new_state = AppState {
334            plugins: PluginsState {
335                installation_status: PluginInstallationStatus {
336                    marketplaces: pending_statuses,
337                    plugins: Vec::new(),
338                },
339                needs_refresh: false,
340            },
341        };
342        set_app_state(&new_state);
343    }
344
345    if pending_names.is_empty() {
346        return;
347    }
348
349    log_for_debugging(&format!(
350        "Installing {} marketplace(s) in background",
351        pending_names.len()
352    ));
353
354    let result = reconcile_marketplaces(Some(Box::new(move |event| {
355        let on_progress = move |ev: MarketplaceProgressEvent| match ev {
356            MarketplaceProgressEvent::Installing { name } => {
357                log::debug!("Installing marketplace: {}", name);
358            }
359            MarketplaceProgressEvent::Installed { name } => {
360                log::debug!("Installed marketplace: {}", name);
361            }
362            MarketplaceProgressEvent::Failed { name, error } => {
363                log::error!("Failed to install marketplace {}: {}", name, error);
364            }
365        };
366        on_progress(event);
367    })))
368    .await;
369
370    let metrics = MarketplaceBackgroundInstallMetrics {
371        installed_count: result.installed.len(),
372        updated_count: result.updated.len(),
373        failed_count: result.failed.len(),
374        up_to_date_count: result.up_to_date.len(),
375    };
376
377    log_event("tengu_marketplace_background_install", &metrics);
378    log_for_diagnostics_no_pii("info", "tengu_marketplace_background_install", &metrics);
379
380    if !result.installed.is_empty() {
381        // New marketplaces were installed -- auto-refresh plugins. This fixes
382        // "Plugin not found in marketplace" errors from the initial cache-only
383        // load (e.g., fresh homespace where marketplace cache was empty).
384        // refresh_active_plugins clears all caches, reloads plugins, and bumps
385        // plugin_reconnect_key so MCP connections are re-established.
386        clear_marketplaces_cache();
387        log_for_debugging(&format!(
388            "Auto-refreshing plugins after {} new marketplace(s) installed",
389            result.installed.len()
390        ));
391
392        if let Err(refresh_error) = refresh_active_plugins(set_app_state).await {
393            // If auto-refresh fails, fall back to needs_refresh notification so
394            // the user can manually run /reload-plugins to recover.
395            log_error(refresh_error.as_ref());
396            log_for_debugging(&format!(
397                "Auto-refresh failed, falling back to needs_refresh: {}",
398                refresh_error
399            ));
400            clear_plugin_cache("perform_background_plugin_installations: auto-refresh failed");
401
402            let new_state = AppState {
403                plugins: PluginsState {
404                    installation_status: PluginInstallationStatus::default(),
405                    needs_refresh: true,
406                },
407            };
408            set_app_state(&new_state);
409        }
410    } else if !result.updated.is_empty() {
411        // Existing marketplaces updated -- notify user to run /reload-plugins.
412        // Updates are less urgent and the user should choose when to apply them.
413        clear_marketplaces_cache();
414        clear_plugin_cache("perform_background_plugin_installations: marketplaces reconciled");
415
416        let new_state = AppState {
417            plugins: PluginsState {
418                installation_status: PluginInstallationStatus::default(),
419                needs_refresh: true,
420            },
421        };
422        set_app_state(&new_state);
423    }
424}
425
426/// Builder for PluginInstallationManager
427pub struct PluginInstallationManagerBuilder {
428    set_app_state: Option<SetAppState>,
429}
430
431impl PluginInstallationManagerBuilder {
432    pub fn new() -> Self {
433        Self {
434            set_app_state: None,
435        }
436    }
437
438    pub fn with_set_app_state(mut self, set_app_state: SetAppState) -> Self {
439        self.set_app_state = Some(set_app_state);
440        self
441    }
442
443    pub fn build(self) -> PluginInstallationManager {
444        PluginInstallationManager {
445            set_app_state: self.set_app_state,
446        }
447    }
448}
449
450impl Default for PluginInstallationManagerBuilder {
451    fn default() -> Self {
452        Self::new()
453    }
454}
455
456/// Manager for background plugin and marketplace installations
457pub struct PluginInstallationManager {
458    set_app_state: Option<SetAppState>,
459}
460
461impl PluginInstallationManager {
462    pub fn new() -> Self {
463        Self {
464            set_app_state: None,
465        }
466    }
467
468    pub fn with_set_app_state(mut self, set_app_state: SetAppState) -> Self {
469        self.set_app_state = Some(set_app_state);
470        self
471    }
472
473    /// Perform background plugin installations if set_app_state is configured
474    pub async fn perform_installations(&self) {
475        if let Some(ref set_app_state) = self.set_app_state {
476            perform_background_plugin_installations(set_app_state).await;
477        }
478    }
479
480    /// Get the current app state (read-only snapshot)
481    pub fn get_app_state(&self) -> Option<AppState> {
482        // In a real implementation, this would read from the shared state
483        None
484    }
485}
486
487impl Default for PluginInstallationManager {
488    fn default() -> Self {
489        Self::new()
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn test_marketplace_progress_event_types() {
499        let installing = MarketplaceProgressEvent::Installing {
500            name: "test".to_string(),
501        };
502        assert_eq!(installing.event_type(), "installing");
503        assert_eq!(installing.name(), "test");
504
505        let installed = MarketplaceProgressEvent::Installed {
506            name: "test".to_string(),
507        };
508        assert_eq!(installed.event_type(), "installed");
509
510        let failed = MarketplaceProgressEvent::Failed {
511            name: "test".to_string(),
512            error: "some error".to_string(),
513        };
514        assert_eq!(failed.event_type(), "failed");
515    }
516
517    #[test]
518    fn test_marketplace_status_kind() {
519        assert_eq!(
520            MarketplaceStatusKind::Pending,
521            MarketplaceStatusKind::Pending
522        );
523        assert_eq!(
524            MarketplaceStatusKind::Installing,
525            MarketplaceStatusKind::Installing
526        );
527        assert_eq!(
528            MarketplaceStatusKind::Installed,
529            MarketplaceStatusKind::Installed
530        );
531        assert_eq!(MarketplaceStatusKind::Failed, MarketplaceStatusKind::Failed);
532    }
533
534    #[test]
535    fn test_diff_marketplaces_empty() {
536        let declared: Vec<DeclaredMarketplace> = Vec::new();
537        let materialized: HashMap<String, InstalledMarketplaceConfig> = HashMap::new();
538        let diff = diff_marketplaces(&declared, &materialized);
539        assert!(diff.missing.is_empty());
540        assert!(diff.source_changed.is_empty());
541    }
542
543    #[test]
544    fn test_diff_marketplaces_missing() {
545        let declared = vec![DeclaredMarketplace {
546            name: "test-marketplace".to_string(),
547            source: "https://example.com".to_string(),
548        }];
549        let materialized: HashMap<String, InstalledMarketplaceConfig> = HashMap::new();
550        let diff = diff_marketplaces(&declared, &materialized);
551        assert_eq!(diff.missing, vec!["test-marketplace".to_string()]);
552        assert!(diff.source_changed.is_empty());
553    }
554
555    #[test]
556    fn test_diff_marketplaces_source_changed() {
557        let declared = vec![DeclaredMarketplace {
558            name: "test-marketplace".to_string(),
559            source: "https://new-source.com".to_string(),
560        }];
561        let mut materialized = HashMap::new();
562        materialized.insert(
563            "test-marketplace".to_string(),
564            InstalledMarketplaceConfig {
565                name: "test-marketplace".to_string(),
566                install_location: "/path/to/marketplace".to_string(),
567                source: "https://old-source.com".to_string(),
568            },
569        );
570        let diff = diff_marketplaces(&declared, &materialized);
571        assert!(diff.missing.is_empty());
572        assert_eq!(diff.source_changed.len(), 1);
573        assert_eq!(diff.source_changed[0].name, "test-marketplace");
574        assert_eq!(diff.source_changed[0].old_source, "https://old-source.com");
575        assert_eq!(diff.source_changed[0].new_source, "https://new-source.com");
576    }
577
578    #[test]
579    fn test_plural() {
580        assert_eq!(plural(0, "plugin"), "plugins");
581        assert_eq!(plural(1, "plugin"), "plugin");
582        assert_eq!(plural(2, "plugin"), "plugins");
583    }
584
585    #[test]
586    fn test_reconcile_result_default() {
587        let result = ReconcileMarketplacesResult::default();
588        assert!(result.installed.is_empty());
589        assert!(result.updated.is_empty());
590        assert!(result.failed.is_empty());
591        assert!(result.up_to_date.is_empty());
592    }
593
594    #[test]
595    fn test_plugin_installation_manager_default() {
596        let manager = PluginInstallationManager::default();
597        assert!(manager.set_app_state.is_none());
598    }
599
600    #[test]
601    fn test_plugin_installation_manager_builder() {
602        let manager = PluginInstallationManagerBuilder::new().build();
603        assert!(manager.set_app_state.is_none());
604    }
605
606    #[test]
607    fn test_marketplace_status_default() {
608        let status = MarketplaceStatus {
609            name: "test".to_string(),
610            status: MarketplaceStatusKind::Pending,
611            error: None,
612        };
613        assert_eq!(status.name, "test");
614        assert_eq!(status.status, MarketplaceStatusKind::Pending);
615        assert!(status.error.is_none());
616    }
617}