Skip to main content

ai_agent/services/plugins/
plugin_operations.rs

1// Source: ~/claudecode/openclaudecode/src/services/plugins/pluginOperations.ts
2#![allow(dead_code)]
3
4//! Core plugin operations (install, uninstall, enable, disable, update)
5//!
6//! This module provides pure library functions that can be used by both:
7//! - CLI commands (`claude plugin install/uninstall/enable/disable/update`)
8//! - Interactive UI (ManagePlugins.tsx)
9//!
10//! Functions in this module:
11//! - Do NOT call process::exit()
12//! - Do NOT write to console
13//! - Return result objects indicating success/failure with messages
14//! - Can throw errors for unexpected failures
15
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
17use std::path::{Path, PathBuf};
18
19use crate::utils::plugins::loader::parse_plugin_identifier;
20use crate::utils::plugins::types::{PluginMarketplace, PluginMarketplaceEntry, PluginSource};
21
22// ============================================================================
23// Constants and Types
24// ============================================================================
25
26/// Valid installable scopes (excludes 'managed' which can only be installed from managed-settings.json)
27pub const VALID_INSTALLABLE_SCOPES: &[&str] = &["user", "project", "local"];
28
29/// Valid scopes for update operations (includes 'managed' since managed plugins can be updated)
30pub const VALID_UPDATE_SCOPES: &[&str] = &["user", "project", "local", "managed"];
31
32/// Installation scope type
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum InstallableScope {
35    User,
36    Project,
37    Local,
38}
39
40impl std::fmt::Display for InstallableScope {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::User => write!(f, "user"),
44            Self::Project => write!(f, "project"),
45            Self::Local => write!(f, "local"),
46        }
47    }
48}
49
50impl TryFrom<&str> for InstallableScope {
51    type Error = String;
52
53    fn try_from(value: &str) -> Result<Self, Self::Error> {
54        match value {
55            "user" => Ok(Self::User),
56            "project" => Ok(Self::Project),
57            "local" => Ok(Self::Local),
58            _ => Err(format!(
59                "Invalid scope \"{}\". Must be one of: {}",
60                value,
61                VALID_INSTALLABLE_SCOPES.join(", ")
62            )),
63        }
64    }
65}
66
67/// Plugin scope (broader, includes 'managed')
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum PluginScope {
70    User,
71    Project,
72    Local,
73    Managed,
74}
75
76impl std::fmt::Display for PluginScope {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            Self::User => write!(f, "user"),
80            Self::Project => write!(f, "project"),
81            Self::Local => write!(f, "local"),
82            Self::Managed => write!(f, "managed"),
83        }
84    }
85}
86
87impl TryFrom<&str> for PluginScope {
88    type Error = String;
89
90    fn try_from(value: &str) -> Result<Self, Self::Error> {
91        match value {
92            "user" => Ok(Self::User),
93            "project" => Ok(Self::Project),
94            "local" => Ok(Self::Local),
95            "managed" => Ok(Self::Managed),
96            _ => Err(format!("Invalid plugin scope: {}", value)),
97        }
98    }
99}
100
101impl From<InstallableScope> for PluginScope {
102    fn from(scope: InstallableScope) -> Self {
103        match scope {
104            InstallableScope::User => Self::User,
105            InstallableScope::Project => Self::Project,
106            InstallableScope::Local => Self::Local,
107        }
108    }
109}
110
111/// Setting source mapping
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum SettingSource {
114    UserSettings,
115    ProjectSettings,
116    LocalSettings,
117    PolicySettings,
118    FlagSettings,
119}
120
121impl std::fmt::Display for SettingSource {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        match self {
124            Self::UserSettings => write!(f, "userSettings"),
125            Self::ProjectSettings => write!(f, "projectSettings"),
126            Self::LocalSettings => write!(f, "localSettings"),
127            Self::PolicySettings => write!(f, "policySettings"),
128            Self::FlagSettings => write!(f, "flagSettings"),
129        }
130    }
131}
132
133/// Convert installable scope to setting source
134pub fn scope_to_setting_source(scope: InstallableScope) -> SettingSource {
135    match scope {
136        InstallableScope::User => SettingSource::UserSettings,
137        InstallableScope::Project => SettingSource::ProjectSettings,
138        InstallableScope::Local => SettingSource::LocalSettings,
139    }
140}
141
142// ============================================================================
143// Result Types
144// ============================================================================
145
146/// Result of a plugin operation
147#[derive(Debug, Clone)]
148pub struct PluginOperationResult {
149    pub success: bool,
150    pub message: String,
151    pub plugin_id: Option<String>,
152    pub plugin_name: Option<String>,
153    pub scope: Option<String>,
154    /// Plugins that declare this plugin as a dependency (warning on uninstall/disable)
155    pub reverse_dependents: Option<Vec<String>>,
156}
157
158/// Result of a plugin update operation
159#[derive(Debug, Clone)]
160pub struct PluginUpdateResult {
161    pub success: bool,
162    pub message: String,
163    pub plugin_id: Option<String>,
164    pub new_version: Option<String>,
165    pub old_version: Option<String>,
166    pub already_up_to_date: Option<bool>,
167    pub scope: Option<String>,
168}
169
170// ============================================================================
171// Installed Plugins Data Structures
172// ============================================================================
173
174/// Installation entry in installed_plugins_v2.json
175#[derive(Debug, Clone)]
176pub struct PluginInstallationEntry {
177    pub scope: String,
178    pub project_path: Option<String>,
179    pub install_path: String,
180    pub version: Option<String>,
181    pub git_commit_sha: Option<String>,
182}
183
184/// Installed plugins data (V2 format)
185#[derive(Debug, Clone, Default)]
186pub struct InstalledPluginsV2 {
187    pub plugins: HashMap<String, Vec<PluginInstallationEntry>>,
188}
189
190/// Plugin info from marketplace lookup
191#[derive(Debug, Clone)]
192pub struct PluginInfo {
193    pub entry: PluginMarketplaceEntry,
194    pub marketplace_install_location: String,
195}
196
197/// Resolution result for install
198#[derive(Debug)]
199pub enum InstallResolutionResult {
200    Success {
201        dep_note: String,
202    },
203    LocalSourceNoLocation {
204        plugin_name: String,
205    },
206    SettingsWriteFailed {
207        message: String,
208    },
209    ResolutionFailed {
210        resolution: String,
211    },
212    BlockedByPolicy {
213        plugin_name: String,
214    },
215    DependencyBlockedByPolicy {
216        plugin_name: String,
217        blocked_dependency: String,
218    },
219}
220
221/// Settings JSON structure
222#[derive(Debug, Clone, Default)]
223pub struct SettingsJson {
224    pub enabled_plugins: Option<BTreeMap<String, serde_json::Value>>,
225}
226
227// ============================================================================
228// Helper Functions
229// ============================================================================
230
231/// Assert that a scope is a valid installable scope at runtime
232pub fn assert_installable_scope(scope: &str) -> Result<InstallableScope, String> {
233    InstallableScope::try_from(scope)
234}
235
236/// Type guard to check if a scope is an installable scope (not 'managed')
237pub fn is_installable_scope(scope: &str) -> bool {
238    VALID_INSTALLABLE_SCOPES.contains(&scope)
239}
240
241/// Get the project path for scopes that are project-specific.
242/// Returns the original cwd for 'project' and 'local' scopes, None otherwise.
243pub fn get_project_path_for_scope(scope: &str) -> Option<String> {
244    if scope == "project" || scope == "local" {
245        std::env::current_dir()
246            .ok()
247            .map(|p| p.to_string_lossy().to_string())
248    } else {
249        None
250    }
251}
252
253/// Pluralize helper
254pub(crate) fn plural(count: usize, singular: &str) -> String {
255    if count == 1 {
256        singular.to_string()
257    } else {
258        format!("{}s", singular)
259    }
260}
261
262/// Check if a plugin is a built-in plugin ID
263pub(crate) fn is_builtin_plugin_id(plugin: &str) -> bool {
264    // Built-in plugins would be defined here. For now, check against known list.
265    const BUILTIN_PLUGINS: &[&str] = &[];
266    BUILTIN_PLUGINS.contains(&plugin)
267}
268
269// ============================================================================
270// Settings Operations (stubs - would integrate with actual settings system)
271// ============================================================================
272
273/// Get settings for a specific source
274fn get_settings_for_source(_source: SettingSource) -> Option<SettingsJson> {
275    // In production, this would load from the actual settings file
276    None
277}
278
279/// Update settings for a specific source
280/// Returns error message if the update failed
281fn update_settings_for_source(
282    _source: SettingSource,
283    _settings: &SettingsJson,
284) -> Result<(), String> {
285    // In production, this would write to the actual settings file
286    Ok(())
287}
288
289// ============================================================================
290// Plugin Loader Operations (stubs)
291// ============================================================================
292
293/// Loaded plugin structure
294#[derive(Debug, Clone)]
295pub struct LoadedPlugin {
296    pub name: String,
297    pub source: Option<String>,
298    pub manifest: Option<serde_json::Value>,
299}
300
301/// Load all plugins (enabled and disabled)
302async fn load_all_plugins() -> (Vec<LoadedPlugin>, Vec<LoadedPlugin>) {
303    // In production, this would load plugins from disk/cache
304    (Vec::new(), Vec::new())
305}
306
307/// Load installed plugins from disk
308fn load_installed_plugins_from_disk() -> InstalledPluginsV2 {
309    // In production, this would load installed_plugins_v2.json
310    InstalledPluginsV2::default()
311}
312
313/// Load installed plugins V2
314fn load_installed_plugins_v2() -> InstalledPluginsV2 {
315    load_installed_plugins_from_disk()
316}
317
318/// Remove a plugin installation from disk
319fn remove_plugin_installation(_plugin_id: &str, _scope: &str, _project_path: Option<&str>) {
320    // In production, this would update installed_plugins_v2.json
321    log::debug!(
322        "Removing plugin installation: {} scope={} project_path={:?}",
323        _plugin_id,
324        _scope,
325        _project_path
326    );
327}
328
329/// Update installation path on disk
330fn update_installation_path_on_disk(
331    _plugin_id: &str,
332    _scope: &str,
333    _project_path: Option<&str>,
334    _new_path: &str,
335    _new_version: &str,
336    _git_commit_sha: Option<&str>,
337) {
338    // In production, this would update installed_plugins_v2.json
339    log::debug!(
340        "Updating installation path: {} -> {} version={}",
341        _plugin_id,
342        _new_path,
343        _new_version
344    );
345}
346
347// ============================================================================
348// Marketplace Operations (stubs)
349// ============================================================================
350
351/// Load known marketplaces config
352async fn load_known_marketplaces_config() -> HashMap<String, serde_json::Value> {
353    // In production, this would load known_marketplaces.json
354    HashMap::new()
355}
356
357/// Get a marketplace by name
358async fn get_marketplace(name: &str) -> Option<PluginMarketplace> {
359    // In production, this would load the marketplace from cache/disk
360    log::debug!("Getting marketplace: {}", name);
361    None
362}
363
364/// Get a plugin by ID from marketplace
365async fn get_plugin_by_id(_plugin: &str) -> Option<PluginInfo> {
366    // In production, this would search all marketplaces for the plugin
367    log::debug!("Getting plugin by id: {}", _plugin);
368    None
369}
370
371// ============================================================================
372// Cache Operations (stubs)
373// ============================================================================
374
375/// Clear all caches
376fn clear_all_caches() {
377    log::debug!("Clearing all caches");
378}
379
380/// Clear plugin cache with optional reason
381fn clear_plugin_cache(reason: &str) {
382    log::debug!("Clearing plugin cache: {}", reason);
383}
384
385/// Mark a plugin version as orphaned
386async fn mark_plugin_version_orphaned(_install_path: &str) {
387    log::debug!("Marking plugin version orphaned: {}", _install_path);
388}
389
390/// Cache a plugin (download to temp)
391async fn cache_plugin(
392    source: &PluginSource,
393    options: CachePluginOptions,
394) -> Result<CachePluginResult, String> {
395    use crate::utils::plugins::plugin_directories::get_plugins_directory;
396    use std::time::SystemTime;
397
398    let cache_path = format!("{}/cache", get_plugins_directory());
399    std::fs::create_dir_all(&cache_path).map_err(|e| format!("Failed to create cache dir: {}", e))?;
400
401    let temp_name = format!(
402        "temp_{}_{}",
403        plugin_source_prefix(source),
404        SystemTime::now()
405            .duration_since(std::time::UNIX_EPOCH)
406            .unwrap_or_default()
407            .as_millis()
408    );
409    let temp_path = format!("{}/{}", cache_path, temp_name);
410
411    // Install source into temp path
412    let git_commit_sha = install_plugin_source(source, &temp_path).await?;
413
414    // Load manifest from .claude-plugin/plugin.json or plugin.json
415    let manifest_path = format!("{}/.claude-plugin/plugin.json", temp_path);
416    let legacy_manifest_path = format!("{}/plugin.json", temp_path);
417    let manifest = if Path::new(&manifest_path).exists() {
418        load_plugin_manifest(&manifest_path, &temp_name, "cached").await?
419    } else if Path::new(&legacy_manifest_path).exists() {
420        load_plugin_manifest(&legacy_manifest_path, &temp_name, "cached").await?
421    } else {
422        options.manifest.clone().unwrap_or_else(|| {
423            serde_json::json!({
424                "name": temp_name,
425                "description": format!("Plugin cached from {}", plugin_source_type(source)),
426            })
427        })
428    };
429
430    let final_name = manifest["name"]
431        .as_str()
432        .map(|n| n.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-"))
433        .unwrap_or_else(|| temp_name.clone());
434    let final_path = format!("{}/{}", cache_path, final_name);
435
436    // Remove old cached version if exists
437    if Path::new(&final_path).exists() {
438        let _ = std::fs::remove_dir_all(&final_path);
439    }
440
441    std::fs::rename(&temp_path, &final_path)
442        .map_err(|e| format!("Failed to move cached plugin: {}", e))?;
443
444    Ok(CachePluginResult {
445        path: final_path,
446        manifest,
447        git_commit_sha,
448    })
449}
450
451/// Generate a temp name prefix from a plugin source.
452fn plugin_source_prefix(source: &PluginSource) -> &str {
453    match source {
454        PluginSource::Relative(p) => p.as_str(),
455        PluginSource::Npm { package, .. } => package.as_str(),
456        PluginSource::Pip { package, .. } => package.as_str(),
457        PluginSource::Github { repo, .. } => repo.as_str(),
458        PluginSource::GitSubdir { repo, .. } => repo.as_str(),
459        PluginSource::Git { url, .. } => url.as_str(),
460        PluginSource::Url { url, .. } => url.as_str(),
461        PluginSource::Settings { .. } => "settings",
462    }
463}
464
465/// Return the source type string for a plugin source.
466fn plugin_source_type(source: &PluginSource) -> &str {
467    match source {
468        PluginSource::Relative(_) => "local path",
469        PluginSource::Npm { .. } => "npm",
470        PluginSource::Pip { .. } => "pip",
471        PluginSource::Github { .. } => "github",
472        PluginSource::GitSubdir { .. } => "git-subdir",
473        PluginSource::Git { .. } => "git",
474        PluginSource::Url { .. } => "url",
475        PluginSource::Settings { .. } => "settings",
476    }
477}
478
479/// Install a plugin source into a target directory.
480/// Returns optional git commit SHA for git-based sources.
481async fn install_plugin_source(
482    source: &PluginSource,
483    target: &str,
484) -> Result<Option<String>, String> {
485    match source {
486        PluginSource::Relative(p) => {
487            // Copy local directory
488            let src = Path::new(p);
489            if !src.exists() {
490                return Err(format!("Local plugin path does not exist: {}", p));
491            }
492            copy_directory(src, Path::new(target))?;
493            Ok(None)
494        }
495        PluginSource::Git { url, ref_, .. }
496        | PluginSource::Github {
497            repo: url,
498            ref_,
499            ..
500        } => {
501            run_git_clone(url, target, ref_)?;
502            let sha = get_git_head_sha(target)?;
503            Ok(Some(sha))
504        }
505        PluginSource::GitSubdir {
506            repo: url,
507            ref_,
508            subdir,
509            ..
510        } => {
511            run_git_sparse_clone(url, target, ref_, subdir)?;
512            let sha = get_git_head_sha(target)?;
513            Ok(Some(sha))
514        }
515        PluginSource::Npm { package, .. } => {
516            // Install from npm: run `npm pack` + extract
517            std::process::Command::new("npm")
518                .args(["pack", package])
519                .output()
520                .map_err(|e| format!("npm pack failed: {}", e))?;
521            // Find the .tgz and extract
522            let dir = std::fs::read_dir(".")
523                .map_err(|e| format!("Failed to read cwd: {}", e))?
524                .filter_map(|e| e.ok())
525                .find(|e| e.path().extension().map_or(false, |ext| ext == "tgz"))
526                .map(|e| e.path())
527                .ok_or_else(|| "npm pack did not produce a .tgz file".to_string())?;
528            // Extract using tar
529            let output = std::process::Command::new("tar")
530                .args(["-xzf", dir.to_str().unwrap_or("package.tgz")])
531                .current_dir(target)
532                .output()
533                .map_err(|e| format!("tar extraction failed: {}", e))?;
534            if !output.status.success() {
535                return Err(format!("tar extraction failed"));
536            }
537            // Remove the .tgz
538            let _ = std::fs::remove_file(&dir);
539            Ok(None)
540        }
541        PluginSource::Url { url, .. } => {
542            // Download from URL - expected to be a .zip or .tgz
543            let output = std::process::Command::new("curl")
544                .args(["-fsSL", "-o", "/tmp/plugin_download.tgz", url])
545                .output()
546                .map_err(|e| format!("curl failed: {}", e))?;
547            if !output.status.success() {
548                return Err(format!("Failed to download plugin from {}", url));
549            }
550            std::fs::create_dir_all(target)
551                .map_err(|e| format!("Failed to create target dir: {}", e))?;
552            let extract_output = std::process::Command::new("tar")
553                .args(["-xzf", "/tmp/plugin_download.tgz"])
554                .current_dir(target)
555                .output()
556                .map_err(|e| format!("Failed to extract: {}", e))?;
557            if !extract_output.status.success() {
558                // Try zip as fallback
559                let zip_output = std::process::Command::new("unzip")
560                    .args(["-o", "/tmp/plugin_download.tgz", "-d", target])
561                    .output()
562                    .map_err(|e| format!("Failed to unzip: {}", e))?;
563                if !zip_output.status.success() {
564                    return Err("Failed to extract downloaded plugin (tried tar and zip)".to_string());
565                }
566            }
567            let _ = std::fs::remove_file("/tmp/plugin_download.tgz");
568            Ok(None)
569        }
570        PluginSource::Pip { .. } => Err("Python package plugins are not yet supported".to_string()),
571        PluginSource::Settings { .. } => {
572            // Settings source has no filesystem to install
573            Err("Settings plugins cannot be cached".to_string())
574        }
575    }
576}
577
578/// Copy a directory recursively
579fn copy_directory(src: &Path, dst: &Path) -> Result<(), String> {
580    std::fs::create_dir_all(dst).map_err(|e| format!("Failed to create dir: {}", e))?;
581    for entry in std::fs::read_dir(src).map_err(|e| format!("Failed to read dir: {}", e))? {
582        let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
583        let src_entry = entry.path();
584        let dst_entry = dst.join(entry.file_name());
585        if src_entry.is_dir() {
586            copy_directory(&src_entry, &dst_entry)?;
587        } else {
588            std::fs::copy(&src_entry, &dst_entry)
589                .map_err(|e| format!("Failed to copy {}: {}", src_entry.display(), e))?;
590        }
591    }
592    Ok(())
593}
594
595/// Run git clone into target directory
596fn run_git_clone(url: &str, target: &str, ref_: &Option<String>) -> Result<(), String> {
597    let mut cmd = std::process::Command::new("git");
598    cmd.args(["clone", url, target]);
599    if let Some(r) = ref_ {
600        cmd.args(["--branch", r]);
601    }
602    let output = cmd.output().map_err(|e| format!("git clone failed: {}", e))?;
603    if !output.status.success() {
604        let stderr = String::from_utf8_lossy(&output.stderr);
605        return Err(format!("git clone failed: {}", stderr));
606    }
607    Ok(())
608}
609
610/// Run git sparse clone into target directory
611fn run_git_sparse_clone(url: &str, target: &str, ref_: &Option<String>, subdir: &str) -> Result<(), String> {
612    std::process::Command::new("git")
613        .args([
614            "clone", "--no-checkout", "--filter=blob:none",
615            url, target,
616        ])
617        .output()
618        .map_err(|e| format!("git clone failed: {}", e))?;
619
620    if let Some(r) = ref_ {
621        std::process::Command::new("git")
622            .args(["checkout", r])
623            .current_dir(target)
624            .output()
625            .map_err(|e| format!("git checkout failed: {}", e))?;
626    }
627
628    // Use sparse checkout to extract only the subdir
629    let normalized_subdir = subdir.strip_prefix("./").unwrap_or(subdir);
630    std::process::Command::new("git")
631        .args(["sparse-checkout", "init"])
632        .current_dir(target)
633        .output()
634        .map_err(|e| format!("git sparse-checkout init failed: {}", e))?;
635
636    std::process::Command::new("git")
637        .args(["sparse-checkout", "set", normalized_subdir])
638        .current_dir(target)
639        .output()
640        .map_err(|e| format!("git sparse-checkout set failed: {}", e))?;
641
642    // Move subdir contents to target root
643    let subdir_path = Path::new(target).join(normalized_subdir);
644    if subdir_path.exists() {
645        for entry in std::fs::read_dir(&subdir_path)
646            .map_err(|e| format!("Failed to read subdir: {}", e))?
647        {
648            let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
649            let dst = Path::new(target).join(entry.file_name());
650            std::fs::rename(entry.path(), &dst)
651                .map_err(|e| format!("Failed to move file: {}", e))?;
652        }
653        let _ = std::fs::remove_dir_all(&subdir_path);
654    }
655
656    Ok(())
657}
658
659/// Get the git HEAD commit SHA from a directory
660fn get_git_head_sha(dir: &str) -> Result<String, String> {
661    let output = std::process::Command::new("git")
662        .args(["rev-parse", "HEAD"])
663        .current_dir(dir)
664        .output()
665        .map_err(|e| format!("git rev-parse failed: {}", e))?;
666    if !output.status.success() {
667        return Err("Failed to get git SHA".to_string());
668    }
669    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
670}
671
672/// Cache plugin options
673#[derive(Debug, Clone, Default)]
674pub struct CachePluginOptions {
675    pub manifest: Option<serde_json::Value>,
676}
677
678/// Cache plugin result
679#[derive(Debug, Clone)]
680pub struct CachePluginResult {
681    pub path: String,
682    pub manifest: serde_json::Value,
683    pub git_commit_sha: Option<String>,
684}
685
686/// Copy plugin to versioned cache directory
687async fn copy_plugin_to_versioned_cache(
688    source_path: &str,
689    plugin_id: &str,
690    new_version: &str,
691    entry: &PluginMarketplaceEntry,
692) -> Result<String, String> {
693    use std::path::Path;
694
695    let zip_cache_mode = crate::utils::plugins::zip_cache::is_plugin_zip_cache_enabled();
696    let cache_path = get_versioned_cache_path(plugin_id, new_version);
697    let zip_path = get_versioned_zip_cache_path(plugin_id, new_version);
698
699    // If cache already exists, return it
700    if zip_cache_mode {
701        if Path::new(&zip_path).exists() {
702            return Ok(zip_path);
703        }
704    } else if Path::new(&cache_path).exists() {
705        match std::fs::read_dir(&cache_path) {
706            Ok(entries) => {
707                if entries.count() > 0 {
708                    return Ok(cache_path);
709                }
710                // Empty dir - remove it so we can recreate
711                let _ = std::fs::remove_dir_all(&cache_path);
712            }
713            Err(_) => { /* can't read dir, will try to create */ }
714        }
715    }
716
717    // Seed cache hit — return seed path in place (read-only, no copy)
718    if let Some(seed_path) = probe_seed_cache(plugin_id, new_version).await {
719        return Ok(seed_path);
720    }
721
722    // Create parent directories
723    if let Some(parent) = Path::new(&cache_path).parent() {
724        std::fs::create_dir_all(parent)
725            .map_err(|e| format!("Failed to create cache parent dir: {}", e))?;
726    }
727
728    // Copy source directory to cache
729    let src_path = Path::new(source_path);
730    if !src_path.exists() {
731        return Err(format!(
732            "Plugin source directory not found: {}",
733            source_path
734        ));
735    }
736    copy_directory(src_path, Path::new(&cache_path))?;
737
738    // Remove .git directory from cache if present
739    let git_path = format!("{}/.git", cache_path);
740    let _ = std::fs::remove_dir_all(&git_path);
741
742    // Validate that cache has content
743    match std::fs::read_dir(&cache_path) {
744        Ok(entries) => {
745            if entries.count() == 0 {
746                return Err(format!(
747                    "Failed to copy plugin {} to versioned cache: destination is empty after copy",
748                    plugin_id
749                ));
750            }
751        }
752        Err(_) => {
753            return Err(format!("Failed to read cache directory after copy: {}", cache_path));
754        }
755    }
756
757    // Zip cache mode: convert directory to ZIP and remove the directory
758    if zip_cache_mode {
759        // Use zip crate to create archive
760        return create_plugin_zip(&cache_path, &zip_path);
761    }
762
763    Ok(cache_path)
764}
765
766/// Probe seed directories for a populated cache at this plugin version.
767async fn probe_seed_cache(plugin_id: &str, version: &str) -> Option<String> {
768    let seed_dirs = crate::utils::plugins::plugin_directories::get_plugin_seed_dirs();
769    for seed_dir in &seed_dirs {
770        let (name, marketplace) = crate::utils::plugins::loader::parse_plugin_identifier(plugin_id);
771        let marketplace = marketplace.unwrap_or_else(|| "unknown".to_string());
772        let name = name.unwrap_or_else(|| plugin_id.to_string());
773        let seed_path = seed_dir
774            .join("cache")
775            .join(&marketplace)
776            .join(&name)
777            .join(version);
778        match std::fs::read_dir(&seed_path) {
779            Ok(entries) => {
780                if entries.count() > 0 {
781                    return Some(seed_path.to_string_lossy().to_string());
782                }
783            }
784            Err(_) => continue,
785        }
786    }
787    None
788}
789
790/// Create a ZIP archive of the plugin directory.
791fn create_plugin_zip(dir_path: &str, zip_path: &str) -> Result<String, String> {
792    use std::io::Write;
793
794    let dir = Path::new(dir_path);
795    if let Some(parent) = Path::new(zip_path).parent() {
796        std::fs::create_dir_all(parent)
797            .map_err(|e| format!("Failed to create zip parent dir: {}", e))?;
798    }
799
800    let file = std::fs::File::create(zip_path)
801        .map_err(|e| format!("Failed to create zip file: {}", e))?;
802
803    let mut encoder = zip::ZipWriter::new(file);
804
805    let mut queue = vec![dir.to_path_buf()];
806
807    while let Some(current) = queue.pop() {
808        for entry in std::fs::read_dir(&current).map_err(|e| format!("Failed to read dir {}: {}", current.display(), e))? {
809            let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
810            let path = entry.path();
811            let stripped = path.strip_prefix(dir).unwrap_or(&path);
812
813            if path.is_dir() {
814                queue.push(path);
815            } else if path.is_file() {
816                let options: zip::write::FileOptions<()> = zip::write::FileOptions::default()
817                    .compression_method(zip::CompressionMethod::Deflated);
818                encoder.start_file(stripped.to_string_lossy(), options)
819                    .map_err(|e| format!("Failed to add file to zip: {}", e))?;
820                let data = std::fs::read(&path)
821                    .map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?;
822                encoder.write_all(&data)
823                    .map_err(|e| format!("Failed to write file to zip: {}", e))?;
824            }
825        }
826    }
827
828    encoder.finish()
829        .map_err(|e| format!("Failed to finish zip: {}", e))?;
830
831    // Remove the directory after successful zip creation
832    let _ = std::fs::remove_dir_all(dir_path);
833
834    Ok(zip_path.to_string())
835}
836
837/// Get versioned cache path for a plugin.
838/// Format: ~/.claude/plugins/cache/{marketplace}/{plugin}/{version}/
839fn get_versioned_cache_path(plugin_id: &str, version: &str) -> String {
840    use crate::utils::plugins::plugin_directories::get_plugins_directory;
841    let plugins_dir = get_plugins_directory();
842    format!("{}/cache/{}/{}", plugins_dir, plugin_id, version)
843}
844
845/// Get versioned ZIP cache path for a plugin.
846/// This is the zip cache variant of getVersionedCachePath.
847fn get_versioned_zip_cache_path(plugin_id: &str, version: &str) -> String {
848    format!("{}.zip", get_versioned_cache_path(plugin_id, version))
849}
850
851/// Load plugin manifest from path
852async fn load_plugin_manifest(
853    manifest_path: &str,
854    name: &str,
855    source: &str,
856) -> Result<serde_json::Value, String> {
857    let content = match tokio::fs::read_to_string(manifest_path).await {
858        Ok(c) => c,
859        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
860            return Ok(serde_json::json!({
861                "name": name,
862                "description": format!("Plugin from {}", source),
863            }));
864        }
865        Err(e) => return Err(format!("Failed to read manifest: {}", e)),
866    };
867
868    let parsed: serde_json::Value = serde_json::from_str(&content)
869        .map_err(|e| format!("Plugin {} has a corrupt manifest file at {}.\n\nJSON parse error: {}", name, manifest_path, e))?;
870
871    // Basic validation: must have a "name" field that is a string
872    if parsed.get("name").and_then(|v| v.as_str()).is_none() {
873        return Err(format!(
874            "Plugin {} has an invalid manifest file at {}.\n\nValidation errors: missing 'name' field",
875            name, manifest_path
876        ));
877    }
878
879    Ok(parsed)
880}
881
882/// Calculate plugin version
883async fn calculate_plugin_version(
884    plugin_id: &str,
885    source: &PluginSource,
886    manifest: Option<serde_json::Value>,
887    source_path: &str,
888    entry_version: Option<&str>,
889    git_commit_sha: Option<&str>,
890) -> Result<String, String> {
891    // 1. Use explicit version from plugin.json if available
892    if let Some(ref m) = manifest {
893        if let Some(version) = m.get("version").and_then(|v| v.as_str()) {
894            return Ok(version.to_string());
895        }
896    }
897
898    // 2. Use provided version (typically from marketplace entry)
899    if let Some(v) = entry_version {
900        return Ok(v.to_string());
901    }
902
903    // 3. Use pre-resolved git SHA
904    if let Some(sha) = git_commit_sha {
905        let short_sha = &sha[..sha.len().min(12)];
906        // For git-subdir sources, encode the subdir path in the version
907        if let PluginSource::GitSubdir { subdir, .. } = source {
908            let normalized = subdir.replace('\\', "/");
909            let norm_path = normalized.strip_prefix("./").unwrap_or(&normalized).trim_end_matches('/').to_string();
910            let path_hash = sha256_hash_subdir(&norm_path);
911            return Ok(format!("{}-{}", short_sha, path_hash));
912        }
913        return Ok(short_sha.to_string());
914    }
915
916    // 4. Try to get git SHA from source path
917    if let Ok(sha) = get_git_head_sha(source_path) {
918        let short_sha = &sha[..sha.len().min(12)];
919        return Ok(short_sha.to_string());
920    }
921
922    // 5. Return 'unknown' as last resort
923    Ok("unknown".to_string())
924}
925
926/// Hash a subdir path for version calculation (SHA-256, first 8 hex chars).
927fn sha256_hash_subdir(path: &str) -> String {
928    use sha2::{Digest, Sha256};
929    let hash = Sha256::digest(path.as_bytes());
930    hex::encode(&hash[..]).chars().take(8).collect()
931}
932
933// ============================================================================
934// Plugin Policy (stubs)
935// ============================================================================
936
937/// Check if a plugin is blocked by org policy
938fn is_plugin_blocked_by_policy(_plugin_id: &str) -> bool {
939    // In production, this would check managed-settings.json
940    false
941}
942
943// ============================================================================
944// Plugin Directories (stubs)
945// ============================================================================
946
947/// Delete plugin data directory
948async fn delete_plugin_data_dir(_plugin_id: &str) -> Result<(), String> {
949    log::debug!("Deleting plugin data dir: {}", _plugin_id);
950    Ok(())
951}
952
953// ============================================================================
954// Plugin Options Storage (stubs)
955// ============================================================================
956
957/// Delete plugin options
958fn delete_plugin_options(_plugin_id: &str) {
959    log::debug!("Deleting plugin options: {}", _plugin_id);
960}
961
962// ============================================================================
963// Plugin Editable Scopes (stubs)
964// ============================================================================
965
966/// Get plugin editable scopes - returns set of enabled plugin IDs
967fn get_plugin_editable_scopes() -> BTreeSet<String> {
968    // In production, this would check all editable settings scopes
969    BTreeSet::new()
970}
971
972// ============================================================================
973// Dependency Resolution (stubs)
974// ============================================================================
975
976/// Find reverse dependents of a plugin
977fn find_reverse_dependents(_plugin_id: &str, _all_plugins: &[LoadedPlugin]) -> Vec<String> {
978    Vec::new()
979}
980
981/// Format reverse dependents suffix for warning messages
982fn format_reverse_dependents_suffix(reverse_dependents: Option<&[String]>) -> String {
983    if let Some(deps) = reverse_dependents {
984        if !deps.is_empty() {
985            return format!(
986                ". Warning: {} depend{} on this plugin: {}",
987                plural(deps.len(), "plugin"),
988                if deps.len() == 1 { "s" } else { "" },
989                deps.join(", ")
990            );
991        }
992    }
993    String::new()
994}
995
996// ============================================================================
997// Plugin Installation Helpers
998// ============================================================================
999
1000/// Format resolution error message
1001pub(crate) fn format_resolution_error(resolution: &str) -> String {
1002    format!("Failed to resolve plugin: {}", resolution)
1003}
1004
1005/// Install a resolved plugin
1006async fn install_resolved_plugin(
1007    _plugin_id: &str,
1008    _entry: &PluginMarketplaceEntry,
1009    _scope: InstallableScope,
1010    _marketplace_install_location: Option<&str>,
1011) -> InstallResolutionResult {
1012    // In production, this would:
1013    // 1. Check org policy
1014    // 2. Write settings (enable the plugin)
1015    // 3. Cache the plugin
1016    // 4. Record version hint
1017    InstallResolutionResult::Success {
1018        dep_note: String::new(),
1019    }
1020}
1021
1022// ============================================================================
1023// Core Operation Helpers
1024// ============================================================================
1025
1026/// Is this plugin enabled (value === true) in .claude/settings.json?
1027///
1028/// Distinct from V2 installed_plugins.json scope: that file tracks where a
1029/// plugin was *installed from*, but the same plugin can also be enabled at
1030/// project scope via settings. The uninstall UI needs to check THIS, because
1031/// a user-scope install with a project-scope enablement means "uninstall"
1032/// would succeed at removing the user install while leaving the project
1033/// enablement active -- the plugin keeps running.
1034pub fn is_plugin_enabled_at_project_scope(plugin_id: &str) -> bool {
1035    get_settings_for_source(SettingSource::ProjectSettings)
1036        .and_then(|s| s.enabled_plugins)
1037        .and_then(|ep| ep.get(plugin_id).cloned())
1038        .and_then(|v| v.as_bool())
1039        .unwrap_or(false)
1040}
1041
1042/// Search all editable settings scopes for a plugin ID matching the given input.
1043///
1044/// If `plugin` contains `@`, it's treated as a full plugin_id and returned if
1045/// found in any scope. If `plugin` is a bare name, searches for any key
1046/// starting with `{plugin}@` in any scope.
1047///
1048/// Returns the most specific scope where the plugin is mentioned (regardless
1049/// of enabled/disabled state) plus the resolved full plugin_id.
1050///
1051/// Precedence: local > project > user (most specific wins).
1052struct PluginInSettingsResult {
1053    plugin_id: String,
1054    scope: InstallableScope,
1055}
1056
1057fn find_plugin_in_settings(plugin: &str) -> Option<PluginInSettingsResult> {
1058    let has_marketplace = plugin.contains('@');
1059    // Most specific first -- first match wins
1060    let search_order = [
1061        InstallableScope::Local,
1062        InstallableScope::Project,
1063        InstallableScope::User,
1064    ];
1065
1066    for scope in search_order {
1067        let source = scope_to_setting_source(scope);
1068        let settings = get_settings_for_source(source)?;
1069        let enabled_plugins = settings.enabled_plugins?;
1070
1071        for key in enabled_plugins.keys() {
1072            if has_marketplace {
1073                if key == plugin {
1074                    return Some(PluginInSettingsResult {
1075                        plugin_id: key.clone(),
1076                        scope,
1077                    });
1078                }
1079            } else if key.starts_with(&format!("{}@", plugin)) {
1080                return Some(PluginInSettingsResult {
1081                    plugin_id: key.clone(),
1082                    scope,
1083                });
1084            }
1085        }
1086    }
1087    None
1088}
1089
1090/// Helper function to find a plugin from loaded plugins
1091fn find_plugin_by_identifier<'a>(
1092    plugin: &str,
1093    plugins: &'a [LoadedPlugin],
1094) -> Option<&'a LoadedPlugin> {
1095    let (name, marketplace) = parse_plugin_identifier(plugin);
1096    let name = name.as_deref().unwrap_or(plugin);
1097
1098    plugins.iter().find(|p| {
1099        // Check exact name match
1100        if p.name == plugin || p.name == name {
1101            return true;
1102        }
1103
1104        // If marketplace specified, check if it matches the source
1105        if let Some(ref mp) = marketplace {
1106            if let Some(ref source) = p.source {
1107                return p.name == name && source.contains(&format!("@{}", mp));
1108            }
1109        }
1110
1111        false
1112    })
1113}
1114
1115/// Resolve a plugin ID from V2 installed plugins data for a plugin that may
1116/// have been delisted from its marketplace. Returns None if the plugin is not
1117/// found in V2 data.
1118struct ResolvedDelistedPlugin {
1119    plugin_id: String,
1120    plugin_name: String,
1121}
1122
1123fn resolve_delisted_plugin_id(plugin: &str) -> Option<ResolvedDelistedPlugin> {
1124    let (name, _) = parse_plugin_identifier(plugin);
1125    let plugin_name = name.as_deref().unwrap_or(plugin);
1126    let installed_data = load_installed_plugins_v2();
1127
1128    // Try exact match first, then search by name
1129    if installed_data
1130        .plugins
1131        .get(plugin)
1132        .map_or(false, |v| !v.is_empty())
1133    {
1134        return Some(ResolvedDelistedPlugin {
1135            plugin_id: plugin.to_string(),
1136            plugin_name: plugin_name.to_string(),
1137        });
1138    }
1139
1140    let matching_key = installed_data.plugins.keys().find(|key| {
1141        let (key_name, _) = parse_plugin_identifier(key);
1142        let key_name = key_name.as_deref().unwrap_or(key);
1143        key_name == plugin_name
1144            && installed_data
1145                .plugins
1146                .get(key.as_str())
1147                .map_or(false, |v| !v.is_empty())
1148    });
1149
1150    matching_key.map(|key| ResolvedDelistedPlugin {
1151        plugin_id: key.clone(),
1152        plugin_name: plugin_name.to_string(),
1153    })
1154}
1155
1156/// Get the most relevant installation for a plugin from V2 data.
1157/// For project/local scoped plugins, prioritizes installations matching the current project.
1158/// Priority order: local (matching project) > project (matching project) > user > first available
1159pub fn get_plugin_installation_from_v2(plugin_id: &str) -> (String, Option<String>) {
1160    // Returns (scope, project_path)
1161    let installed_data = load_installed_plugins_v2();
1162    let installations = installed_data.plugins.get(plugin_id);
1163
1164    let installations = match installations {
1165        Some(insts) if !insts.is_empty() => insts,
1166        _ => return ("user".to_string(), None),
1167    };
1168
1169    let current_project_path = std::env::current_dir()
1170        .ok()
1171        .map(|p| p.to_string_lossy().to_string());
1172
1173    // Find installations by priority: local > project > user > managed
1174    if let Some(local_install) = installations
1175        .iter()
1176        .find(|inst| inst.scope == "local" && inst.project_path == current_project_path)
1177    {
1178        return (
1179            local_install.scope.clone(),
1180            local_install.project_path.clone(),
1181        );
1182    }
1183
1184    if let Some(project_install) = installations
1185        .iter()
1186        .find(|inst| inst.scope == "project" && inst.project_path == current_project_path)
1187    {
1188        return (
1189            project_install.scope.clone(),
1190            project_install.project_path.clone(),
1191        );
1192    }
1193
1194    if let Some(user_install) = installations.iter().find(|inst| inst.scope == "user") {
1195        return (
1196            user_install.scope.clone(),
1197            user_install.project_path.clone(),
1198        );
1199    }
1200
1201    // Fall back to first installation (could be managed)
1202    (
1203        installations[0].scope.clone(),
1204        installations[0].project_path.clone(),
1205    )
1206}
1207
1208// ============================================================================
1209// Core Operations
1210// ============================================================================
1211
1212/// Install a plugin (settings-first).
1213///
1214/// Order of operations:
1215///   1. Search materialized marketplaces for the plugin
1216///   2. Write settings (THE ACTION -- declares intent)
1217///   3. Cache plugin + record version hint (materialization)
1218///
1219/// Marketplace reconciliation is NOT this function's responsibility -- startup
1220/// reconcile handles declared-but-not-materialized marketplaces. If the
1221/// marketplace isn't found, "not found" is the correct error.
1222///
1223/// # Arguments
1224/// * `plugin` - Plugin identifier (name or plugin@marketplace)
1225/// * `scope` - Installation scope: user, project, or local (defaults to 'user')
1226///
1227/// # Returns
1228/// Result indicating success/failure
1229pub async fn install_plugin_op(plugin: &str, scope: InstallableScope) -> PluginOperationResult {
1230    let (plugin_name, marketplace_name) = parse_plugin_identifier(plugin);
1231    let plugin_name = plugin_name.unwrap_or_else(|| plugin.to_string());
1232
1233    // Search materialized marketplaces for the plugin
1234    let mut found_plugin: Option<PluginMarketplaceEntry> = None;
1235    let mut found_marketplace: Option<String> = None;
1236    let mut marketplace_install_location: Option<String> = None;
1237
1238    if let Some(ref mp_name) = marketplace_name {
1239        if let Some(plugin_info) = get_plugin_by_id(&format!("{}@{}", plugin_name, mp_name)).await {
1240            found_plugin = Some(plugin_info.entry);
1241            found_marketplace = Some(mp_name.clone());
1242            marketplace_install_location = Some(plugin_info.marketplace_install_location);
1243        }
1244    } else {
1245        let marketplaces = load_known_marketplaces_config().await;
1246        for (mkt_name, mkt_config) in &marketplaces {
1247            if let Ok(Some(marketplace)) =
1248                tokio::time::timeout(std::time::Duration::from_secs(5), get_marketplace(mkt_name))
1249                    .await
1250            {
1251                if let Some(plugin_entry) =
1252                    marketplace.plugins.iter().find(|p| p.name == plugin_name)
1253                {
1254                    found_plugin = Some(plugin_entry.clone());
1255                    found_marketplace = Some(mkt_name.clone());
1256                    marketplace_install_location = mkt_config
1257                        .get("installLocation")
1258                        .and_then(|v| v.as_str())
1259                        .map(String::from);
1260                    break;
1261                }
1262            }
1263        }
1264    }
1265
1266    let (entry, marketplace) = match (found_plugin, found_marketplace) {
1267        (Some(entry), Some(marketplace)) => (entry, marketplace),
1268        _ => {
1269            let location = marketplace_name
1270                .map(|m| format!("marketplace \"{}\"", m))
1271                .unwrap_or_else(|| "any configured marketplace".to_string());
1272            return PluginOperationResult {
1273                success: false,
1274                message: format!("Plugin \"{}\" not found in {}", plugin_name, location),
1275                plugin_id: None,
1276                plugin_name: None,
1277                scope: None,
1278                reverse_dependents: None,
1279            };
1280        }
1281    };
1282
1283    let plugin_id = format!("{}@{}", entry.name, marketplace);
1284
1285    let result = install_resolved_plugin(
1286        &plugin_id,
1287        &entry,
1288        scope,
1289        marketplace_install_location.as_deref(),
1290    )
1291    .await;
1292
1293    match result {
1294        InstallResolutionResult::Success { dep_note } => PluginOperationResult {
1295            success: true,
1296            message: format!(
1297                "Successfully installed plugin: {} (scope: {}){}",
1298                plugin_id, scope, dep_note
1299            ),
1300            plugin_id: Some(plugin_id),
1301            plugin_name: Some(entry.name.clone()),
1302            scope: Some(scope.to_string()),
1303            reverse_dependents: None,
1304        },
1305        InstallResolutionResult::LocalSourceNoLocation { plugin_name } => PluginOperationResult {
1306            success: false,
1307            message: format!(
1308                "Cannot install local plugin \"{}\" without marketplace install location",
1309                plugin_name
1310            ),
1311            plugin_id: None,
1312            plugin_name: None,
1313            scope: None,
1314            reverse_dependents: None,
1315        },
1316        InstallResolutionResult::SettingsWriteFailed { message } => PluginOperationResult {
1317            success: false,
1318            message: format!("Failed to update settings: {}", message),
1319            plugin_id: None,
1320            plugin_name: None,
1321            scope: None,
1322            reverse_dependents: None,
1323        },
1324        InstallResolutionResult::ResolutionFailed { resolution } => PluginOperationResult {
1325            success: false,
1326            message: format_resolution_error(&resolution),
1327            plugin_id: None,
1328            plugin_name: None,
1329            scope: None,
1330            reverse_dependents: None,
1331        },
1332        InstallResolutionResult::BlockedByPolicy { plugin_name } => PluginOperationResult {
1333            success: false,
1334            message: format!(
1335                "Plugin \"{}\" is blocked by your organization's policy and cannot be installed",
1336                plugin_name
1337            ),
1338            plugin_id: None,
1339            plugin_name: None,
1340            scope: None,
1341            reverse_dependents: None,
1342        },
1343        InstallResolutionResult::DependencyBlockedByPolicy {
1344            plugin_name,
1345            blocked_dependency,
1346        } => PluginOperationResult {
1347            success: false,
1348            message: format!(
1349                "Plugin \"{}\" depends on \"{}\", which is blocked by your organization's policy",
1350                plugin_name, blocked_dependency
1351            ),
1352            plugin_id: None,
1353            plugin_name: None,
1354            scope: None,
1355            reverse_dependents: None,
1356        },
1357    }
1358}
1359
1360/// Uninstall a plugin
1361///
1362/// # Arguments
1363/// * `plugin` - Plugin name or plugin@marketplace identifier
1364/// * `scope` - Uninstall from scope: user, project, or local (defaults to 'user')
1365/// * `delete_data_dir` - Whether to delete the plugin's data directory
1366///
1367/// # Returns
1368/// Result indicating success/failure
1369pub async fn uninstall_plugin_op(
1370    plugin: &str,
1371    scope: InstallableScope,
1372    delete_data_dir: bool,
1373) -> PluginOperationResult {
1374    let (enabled, disabled) = load_all_plugins().await;
1375    let all_plugins: Vec<LoadedPlugin> = enabled.into_iter().chain(disabled.into_iter()).collect();
1376
1377    // Find the plugin
1378    let found_plugin = find_plugin_by_identifier(plugin, &all_plugins);
1379
1380    let setting_source = scope_to_setting_source(scope);
1381    let settings = get_settings_for_source(setting_source);
1382
1383    let (plugin_id, plugin_name) = if let Some(found) = found_plugin {
1384        // Find the matching settings key for this plugin
1385        let plugin_id = settings
1386            .as_ref()
1387            .and_then(|s| s.enabled_plugins.as_ref())
1388            .and_then(|ep| {
1389                ep.keys().find(|k| {
1390                    **k == plugin || **k == found.name || k.starts_with(&format!("{}@", found.name))
1391                })
1392            })
1393            .cloned()
1394            .unwrap_or_else(|| {
1395                if plugin.contains('@') {
1396                    plugin.to_string()
1397                } else {
1398                    found.name.clone()
1399                }
1400            });
1401        (plugin_id, found.name.clone())
1402    } else {
1403        // Plugin not found via marketplace lookup -- may have been delisted
1404        match resolve_delisted_plugin_id(plugin) {
1405            Some(resolved) => (resolved.plugin_id, resolved.plugin_name),
1406            None => {
1407                return PluginOperationResult {
1408                    success: false,
1409                    message: format!("Plugin \"{}\" not found in installed plugins", plugin),
1410                    plugin_id: None,
1411                    plugin_name: None,
1412                    scope: None,
1413                    reverse_dependents: None,
1414                };
1415            }
1416        }
1417    };
1418    let plugin_name_clone = plugin_name.clone();
1419
1420    // Check if the plugin is installed in this scope (in V2 file)
1421    let project_path = get_project_path_for_scope(&scope.to_string());
1422    let installed_data = load_installed_plugins_v2();
1423    let installations = installed_data.plugins.get(&plugin_id);
1424
1425    let scope_installation = installations.and_then(|insts| {
1426        insts
1427            .iter()
1428            .find(|i| i.scope == scope.to_string() && i.project_path == project_path)
1429    });
1430
1431    let scope_installation = match scope_installation {
1432        Some(inst) => inst,
1433        None => {
1434            // Try to find where the plugin is actually installed
1435            let (actual_scope, _) = get_plugin_installation_from_v2(&plugin_id);
1436            if actual_scope != scope.to_string() && installations.map_or(false, |i| !i.is_empty()) {
1437                // Project scope is special
1438                if actual_scope == "project" {
1439                    return PluginOperationResult {
1440                        success: false,
1441                        message: format!(
1442                            "Plugin \"{}\" is enabled at project scope (.claude/settings.json, shared with your team). To disable just for you: claude plugin disable {} --scope local",
1443                            plugin, plugin
1444                        ),
1445                        plugin_id: Some(plugin_id.to_string()),
1446                        plugin_name: Some(plugin_name.clone()),
1447                        scope: Some(scope.to_string()),
1448                        reverse_dependents: None,
1449                    };
1450                }
1451                return PluginOperationResult {
1452                    success: false,
1453                    message: format!(
1454                        "Plugin \"{}\" is installed in {} scope, not {}. Use --scope {} to uninstall.",
1455                        plugin, actual_scope, scope, actual_scope
1456                    ),
1457                    plugin_id: Some(plugin_id.to_string()),
1458                    plugin_name: Some(plugin_name.clone()),
1459                    scope: Some(scope.to_string()),
1460                    reverse_dependents: None,
1461                };
1462            }
1463            return PluginOperationResult {
1464                success: false,
1465                message: format!(
1466                    "Plugin \"{}\" is not installed in {} scope. Use --scope to specify the correct scope.",
1467                    plugin, scope
1468                ),
1469                plugin_id: Some(plugin_id.to_string()),
1470                plugin_name: Some(plugin_name.clone()),
1471                scope: Some(scope.to_string()),
1472                reverse_dependents: None,
1473            };
1474        }
1475    };
1476
1477    let install_path = scope_installation.install_path.clone();
1478
1479    // Remove the plugin from the appropriate settings file
1480    let mut new_enabled_plugins: BTreeMap<String, Option<serde_json::Value>> = settings
1481        .as_ref()
1482        .and_then(|s| s.enabled_plugins.clone())
1483        .unwrap_or_default()
1484        .into_iter()
1485        .map(|(k, v)| (k, Some(v)))
1486        .collect();
1487    new_enabled_plugins.insert(plugin_id.to_string(), None);
1488
1489    let _ = update_settings_for_source(
1490        setting_source,
1491        &SettingsJson {
1492            enabled_plugins: Some(
1493                new_enabled_plugins
1494                    .into_iter()
1495                    .filter_map(|(k, v)| v.map(|val| (k, val)))
1496                    .collect(),
1497            ),
1498        },
1499    );
1500
1501    clear_all_caches();
1502
1503    // Remove from installed_plugins_v2.json for this scope
1504    remove_plugin_installation(&plugin_id, &scope.to_string(), project_path.as_deref());
1505
1506    // Check if this is the last scope installation
1507    let updated_data = load_installed_plugins_v2();
1508    let remaining_installations = updated_data.plugins.get(&plugin_id);
1509    let is_last_scope = remaining_installations.map_or(true, |i| i.is_empty());
1510
1511    if is_last_scope {
1512        mark_plugin_version_orphaned(&install_path).await;
1513        // Delete plugin options and data dir
1514        delete_plugin_options(&plugin_id);
1515        if delete_data_dir {
1516            let _ = delete_plugin_data_dir(&plugin_id).await;
1517        }
1518    }
1519
1520    // Warn (don't block) if other enabled plugins depend on this one
1521    let reverse_dependents = find_reverse_dependents(&plugin_id, &all_plugins);
1522    let dep_warn = format_reverse_dependents_suffix(if reverse_dependents.is_empty() {
1523        None
1524    } else {
1525        Some(&reverse_dependents)
1526    });
1527
1528    PluginOperationResult {
1529        success: true,
1530        message: format!(
1531            "Successfully uninstalled plugin: {} (scope: {}){}",
1532            plugin_name, scope, dep_warn
1533        ),
1534        plugin_id: Some(plugin_id.to_string()),
1535        plugin_name: Some(plugin_name),
1536        scope: Some(scope.to_string()),
1537        reverse_dependents: if reverse_dependents.is_empty() {
1538            None
1539        } else {
1540            Some(reverse_dependents)
1541        },
1542    }
1543}
1544
1545/// Set plugin enabled/disabled status (settings-first).
1546///
1547/// Resolves the plugin ID and scope from settings -- does NOT pre-gate on
1548/// installed_plugins.json. Settings declares intent; if the plugin isn't
1549/// cached yet, the next load will cache it.
1550///
1551/// # Arguments
1552/// * `plugin` - Plugin name or plugin@marketplace identifier
1553/// * `enabled` - true to enable, false to disable
1554/// * `scope` - Optional scope. If not provided, auto-detects the most specific
1555///   scope where the plugin is mentioned in settings.
1556///
1557/// # Returns
1558/// Result indicating success/failure
1559pub async fn set_plugin_enabled_op(
1560    plugin: &str,
1561    enabled: bool,
1562    scope: Option<InstallableScope>,
1563) -> PluginOperationResult {
1564    let operation = if enabled { "enable" } else { "disable" };
1565
1566    // Built-in plugins: always use user-scope settings, bypass the normal
1567    // scope-resolution + installed_plugins lookup (they're not installed).
1568    if is_builtin_plugin_id(plugin) {
1569        let current_settings = get_settings_for_source(SettingSource::UserSettings);
1570        let mut enabled_plugins = current_settings
1571            .and_then(|s| s.enabled_plugins)
1572            .unwrap_or_default();
1573        enabled_plugins.insert(plugin.to_string(), serde_json::Value::Bool(enabled));
1574
1575        match update_settings_for_source(
1576            SettingSource::UserSettings,
1577            &SettingsJson {
1578                enabled_plugins: Some(enabled_plugins),
1579            },
1580        ) {
1581            Ok(()) => {
1582                clear_all_caches();
1583                let (_, plugin_name) = parse_plugin_identifier(plugin);
1584                return PluginOperationResult {
1585                    success: true,
1586                    message: format!(
1587                        "Successfully {}d built-in plugin: {}",
1588                        operation,
1589                        plugin_name.as_deref().unwrap_or(plugin)
1590                    ),
1591                    plugin_id: Some(plugin.to_string()),
1592                    plugin_name: plugin_name,
1593                    scope: Some("user".to_string()),
1594                    reverse_dependents: None,
1595                };
1596            }
1597            Err(error) => {
1598                return PluginOperationResult {
1599                    success: false,
1600                    message: format!("Failed to {} built-in plugin: {}", operation, error),
1601                    plugin_id: None,
1602                    plugin_name: None,
1603                    scope: None,
1604                    reverse_dependents: None,
1605                };
1606            }
1607        }
1608    }
1609
1610    // Validate scope if provided
1611    if let Some(s) = scope {
1612        if let Err(e) = assert_installable_scope(&s.to_string()) {
1613            return PluginOperationResult {
1614                success: false,
1615                message: e,
1616                plugin_id: None,
1617                plugin_name: None,
1618                scope: None,
1619                reverse_dependents: None,
1620            };
1621        }
1622    }
1623
1624    // Resolve pluginId and scope from settings
1625    let (plugin_id, resolved_scope) = match scope {
1626        Some(explicit_scope) => {
1627            // Explicit scope: use it. Resolve pluginId from settings if possible,
1628            // otherwise require a full plugin@marketplace identifier.
1629            if let Some(found) = find_plugin_in_settings(plugin) {
1630                (found.plugin_id, explicit_scope)
1631            } else if plugin.contains('@') {
1632                (plugin.to_string(), explicit_scope)
1633            } else {
1634                return PluginOperationResult {
1635                    success: false,
1636                    message: format!(
1637                        "Plugin \"{}\" not found in settings. Use plugin@marketplace format.",
1638                        plugin
1639                    ),
1640                    plugin_id: None,
1641                    plugin_name: None,
1642                    scope: None,
1643                    reverse_dependents: None,
1644                };
1645            }
1646        }
1647        None => {
1648            // Auto-detect scope from settings
1649            if let Some(found) = find_plugin_in_settings(plugin) {
1650                (found.plugin_id, found.scope)
1651            } else if plugin.contains('@') {
1652                // Not in any settings scope, but full pluginId given -- default to user scope
1653                (plugin.to_string(), InstallableScope::User)
1654            } else {
1655                return PluginOperationResult {
1656                    success: false,
1657                    message: format!(
1658                        "Plugin \"{}\" not found in any editable settings scope. Use plugin@marketplace format.",
1659                        plugin
1660                    ),
1661                    plugin_id: None,
1662                    plugin_name: None,
1663                    scope: None,
1664                    reverse_dependents: None,
1665                };
1666            }
1667        }
1668    };
1669
1670    // Policy guard: org-blocked plugins cannot be enabled at any scope
1671    if enabled && is_plugin_blocked_by_policy(&plugin_id) {
1672        return PluginOperationResult {
1673            success: false,
1674            message: format!(
1675                "Plugin \"{}\" is blocked by your organization's policy and cannot be enabled",
1676                plugin_id
1677            ),
1678            plugin_id: Some(plugin_id),
1679            plugin_name: None,
1680            scope: Some(resolved_scope.to_string()),
1681            reverse_dependents: None,
1682        };
1683    }
1684
1685    let setting_source = scope_to_setting_source(resolved_scope);
1686    let scope_settings_value = get_settings_for_source(setting_source)
1687        .and_then(|s| s.enabled_plugins)
1688        .and_then(|ep| ep.get(&plugin_id).cloned())
1689        .and_then(|v| v.as_bool());
1690
1691    // Cross-scope hint: explicit scope given but plugin is elsewhere
1692    let scope_precedence = |s: InstallableScope| -> usize {
1693        match s {
1694            InstallableScope::User => 0,
1695            InstallableScope::Project => 1,
1696            InstallableScope::Local => 2,
1697        }
1698    };
1699
1700    let is_override = scope
1701        .zip(find_plugin_in_settings(plugin))
1702        .map(|(s, found)| scope_precedence(s) > scope_precedence(found.scope))
1703        .unwrap_or(false);
1704
1705    if scope.is_some()
1706        && scope_settings_value.is_none()
1707        && find_plugin_in_settings(plugin)
1708            .as_ref()
1709            .is_some_and(|found| {
1710                let found_scope = found.scope;
1711                scope
1712                    .map(|s| s != found_scope && !is_override)
1713                    .unwrap_or(false)
1714            })
1715    {
1716        let found = find_plugin_in_settings(plugin).unwrap();
1717        return PluginOperationResult {
1718            success: false,
1719            message: format!(
1720                "Plugin \"{}\" is installed at {} scope, not {}. Use --scope {} or omit --scope to auto-detect.",
1721                plugin, found.scope, resolved_scope, found.scope
1722            ),
1723            plugin_id: Some(plugin_id),
1724            plugin_name: None,
1725            scope: Some(resolved_scope.to_string()),
1726            reverse_dependents: None,
1727        };
1728    }
1729
1730    // Check current state (for idempotency messaging)
1731    let is_currently_enabled = if scope.is_some() && !is_override {
1732        scope_settings_value.unwrap_or(false)
1733    } else {
1734        get_plugin_editable_scopes().contains(&plugin_id)
1735    };
1736
1737    if enabled == is_currently_enabled {
1738        let scope_suffix = scope
1739            .map(|s| format!(" at {} scope", s))
1740            .unwrap_or_default();
1741        return PluginOperationResult {
1742            success: false,
1743            message: format!(
1744                "Plugin \"{}\" is already {}{}",
1745                plugin,
1746                if enabled { "enabled" } else { "disabled" },
1747                scope_suffix
1748            ),
1749            plugin_id: Some(plugin_id),
1750            plugin_name: None,
1751            scope: Some(resolved_scope.to_string()),
1752            reverse_dependents: None,
1753        };
1754    }
1755
1756    // On disable: capture reverse dependents from the pre-disable snapshot
1757    let mut reverse_dependents: Option<Vec<String>> = None;
1758    if !enabled {
1759        let (loaded_enabled, disabled) = load_all_plugins().await;
1760        let all: Vec<LoadedPlugin> = loaded_enabled
1761            .into_iter()
1762            .chain(disabled.into_iter())
1763            .collect();
1764        let rdeps = find_reverse_dependents(&plugin_id, &all);
1765        if !rdeps.is_empty() {
1766            reverse_dependents = Some(rdeps);
1767        }
1768    }
1769
1770    // Write settings
1771    let current_settings = get_settings_for_source(setting_source);
1772    let mut enabled_plugins = current_settings
1773        .and_then(|s| s.enabled_plugins)
1774        .unwrap_or_default();
1775    enabled_plugins.insert(plugin_id.clone(), serde_json::Value::Bool(enabled));
1776
1777    if let Err(error) = update_settings_for_source(
1778        setting_source,
1779        &SettingsJson {
1780            enabled_plugins: Some(enabled_plugins),
1781        },
1782    ) {
1783        return PluginOperationResult {
1784            success: false,
1785            message: format!("Failed to {} plugin: {}", operation, error),
1786            plugin_id: Some(plugin_id),
1787            plugin_name: None,
1788            scope: Some(resolved_scope.to_string()),
1789            reverse_dependents: None,
1790        };
1791    }
1792
1793    clear_all_caches();
1794
1795    let (_, plugin_name) = parse_plugin_identifier(&plugin_id);
1796    let dep_warn = format_reverse_dependents_suffix(reverse_dependents.as_deref());
1797
1798    PluginOperationResult {
1799        success: true,
1800        message: format!(
1801            "Successfully {}d plugin: {} (scope: {}){}",
1802            operation,
1803            plugin_name.as_deref().unwrap_or(&plugin_id),
1804            resolved_scope,
1805            dep_warn
1806        ),
1807        plugin_id: Some(plugin_id),
1808        plugin_name,
1809        scope: Some(resolved_scope.to_string()),
1810        reverse_dependents,
1811    }
1812}
1813
1814/// Enable a plugin
1815///
1816/// # Arguments
1817/// * `plugin` - Plugin name or plugin@marketplace identifier
1818/// * `scope` - Optional scope. If not provided, finds the most specific scope for the current project.
1819///
1820/// # Returns
1821/// Result indicating success/failure
1822pub async fn enable_plugin_op(
1823    plugin: &str,
1824    scope: Option<InstallableScope>,
1825) -> PluginOperationResult {
1826    set_plugin_enabled_op(plugin, true, scope).await
1827}
1828
1829/// Disable a plugin
1830///
1831/// # Arguments
1832/// * `plugin` - Plugin name or plugin@marketplace identifier
1833/// * `scope` - Optional scope. If not provided, finds the most specific scope for the current project.
1834///
1835/// # Returns
1836/// Result indicating success/failure
1837pub async fn disable_plugin_op(
1838    plugin: &str,
1839    scope: Option<InstallableScope>,
1840) -> PluginOperationResult {
1841    set_plugin_enabled_op(plugin, false, scope).await
1842}
1843
1844/// Disable all enabled plugins
1845///
1846/// # Returns
1847/// Result indicating success/failure with count of disabled plugins
1848pub async fn disable_all_plugins_op() -> PluginOperationResult {
1849    let enabled_plugins = get_plugin_editable_scopes();
1850
1851    if enabled_plugins.is_empty() {
1852        return PluginOperationResult {
1853            success: true,
1854            message: "No enabled plugins to disable".to_string(),
1855            plugin_id: None,
1856            plugin_name: None,
1857            scope: None,
1858            reverse_dependents: None,
1859        };
1860    }
1861
1862    let mut disabled: Vec<String> = Vec::new();
1863    let mut errors: Vec<String> = Vec::new();
1864
1865    for plugin_id in enabled_plugins {
1866        let result = set_plugin_enabled_op(&plugin_id, false, None).await;
1867        if result.success {
1868            disabled.push(plugin_id);
1869        } else {
1870            errors.push(format!("{}: {}", plugin_id, result.message));
1871        }
1872    }
1873
1874    if !errors.is_empty() {
1875        return PluginOperationResult {
1876            success: false,
1877            message: format!(
1878                "Disabled {} {}, {} failed:\n{}",
1879                disabled.len(),
1880                plural(disabled.len(), "plugin"),
1881                errors.len(),
1882                errors.join("\n")
1883            ),
1884            plugin_id: None,
1885            plugin_name: None,
1886            scope: None,
1887            reverse_dependents: None,
1888        };
1889    }
1890
1891    PluginOperationResult {
1892        success: true,
1893        message: format!(
1894            "Disabled {} {}",
1895            disabled.len(),
1896            plural(disabled.len(), "plugin")
1897        ),
1898        plugin_id: None,
1899        plugin_name: None,
1900        scope: None,
1901        reverse_dependents: None,
1902    }
1903}
1904
1905/// Update a plugin to the latest version.
1906///
1907/// This function performs a NON-INPLACE update:
1908/// 1. Gets the plugin info from the marketplace
1909/// 2. For remote plugins: downloads to temp dir and calculates version
1910/// 3. For local plugins: calculates version from marketplace source
1911/// 4. If version differs from currently installed, copies to new versioned cache directory
1912/// 5. Updates installation in V2 file (memory stays unchanged until restart)
1913/// 6. Cleans up old version if no longer referenced by any installation
1914///
1915/// # Arguments
1916/// * `plugin` - Plugin name or plugin@marketplace identifier
1917/// * `scope` - Scope to update. Unlike install/uninstall/enable/disable, managed scope IS allowed.
1918///
1919/// # Returns
1920/// Result indicating success/failure with version info
1921pub async fn update_plugin_op(plugin: &str, scope: &str) -> PluginUpdateResult {
1922    // Parse the plugin identifier to get the full plugin ID
1923    let (plugin_name, marketplace_name) = parse_plugin_identifier(plugin);
1924    let plugin_name = plugin_name.unwrap_or_else(|| plugin.to_string());
1925    let plugin_id = marketplace_name
1926        .map(|m| format!("{}@{}", plugin_name, m))
1927        .unwrap_or_else(|| plugin.to_string());
1928
1929    let scope = match PluginScope::try_from(scope) {
1930        Ok(s) => s,
1931        Err(e) => {
1932            return PluginUpdateResult {
1933                success: false,
1934                message: e,
1935                plugin_id: Some(plugin_id),
1936                new_version: None,
1937                old_version: None,
1938                already_up_to_date: None,
1939                scope: None,
1940            };
1941        }
1942    };
1943
1944    // Get plugin info from marketplace
1945    let plugin_info = match get_plugin_by_id(&plugin_id).await {
1946        Some(info) => info,
1947        None => {
1948            return PluginUpdateResult {
1949                success: false,
1950                message: format!("Plugin \"{}\" not found", plugin_name),
1951                plugin_id: Some(plugin_id),
1952                new_version: None,
1953                old_version: None,
1954                already_up_to_date: None,
1955                scope: Some(scope.to_string()),
1956            };
1957        }
1958    };
1959
1960    let entry = plugin_info.entry;
1961    let marketplace_install_location = plugin_info.marketplace_install_location;
1962
1963    // Get installations from disk
1964    let disk_data = load_installed_plugins_from_disk();
1965    let installations = disk_data.plugins.get(&plugin_id);
1966
1967    if installations.is_none() || installations.map_or(true, |i| i.is_empty()) {
1968        return PluginUpdateResult {
1969            success: false,
1970            message: format!("Plugin \"{}\" is not installed", plugin_name),
1971            plugin_id: Some(plugin_id),
1972            new_version: None,
1973            old_version: None,
1974            already_up_to_date: None,
1975            scope: Some(scope.to_string()),
1976        };
1977    }
1978
1979    // Determine project_path based on scope
1980    let project_path = get_project_path_for_scope(&scope.to_string());
1981
1982    // Find the installation for this scope
1983    let installations = installations.unwrap();
1984    let installation = installations
1985        .iter()
1986        .find(|inst| inst.scope == scope.to_string() && inst.project_path == project_path);
1987
1988    let installation = match installation {
1989        Some(inst) => inst,
1990        None => {
1991            let scope_desc = project_path
1992                .map(|p| format!("{} ({})", scope, p))
1993                .unwrap_or_else(|| scope.to_string());
1994            return PluginUpdateResult {
1995                success: false,
1996                message: format!(
1997                    "Plugin \"{}\" is not installed at scope {}",
1998                    plugin_name, scope_desc
1999                ),
2000                plugin_id: Some(plugin_id),
2001                new_version: None,
2002                old_version: None,
2003                already_up_to_date: None,
2004                scope: Some(scope.to_string()),
2005            };
2006        }
2007    };
2008
2009    perform_plugin_update(
2010        &plugin_id,
2011        &plugin_name,
2012        &entry,
2013        &marketplace_install_location,
2014        installation,
2015        scope,
2016        project_path,
2017    )
2018    .await
2019}
2020
2021/// Perform the actual plugin update: fetch source, calculate version, copy to cache, update disk.
2022/// This is the core update execution extracted from update_plugin_op.
2023async fn perform_plugin_update(
2024    plugin_id: &str,
2025    plugin_name: &str,
2026    entry: &PluginMarketplaceEntry,
2027    marketplace_install_location: &str,
2028    installation: &PluginInstallationEntry,
2029    scope: PluginScope,
2030    project_path: Option<String>,
2031) -> PluginUpdateResult {
2032    let old_version = installation.version.clone();
2033
2034    let (source_path, new_version, should_cleanup_source, git_commit_sha) = match &entry.source {
2035        PluginSource::Npm { .. }
2036        | PluginSource::Pip { .. }
2037        | PluginSource::Github { .. }
2038        | PluginSource::GitSubdir { .. }
2039        | PluginSource::Git { .. }
2040        | PluginSource::Url { .. } => {
2041            // Remote plugin: download to temp directory first
2042            match cache_plugin(&entry.source, CachePluginOptions { manifest: None }).await {
2043                Ok(cache_result) => {
2044                    // Calculate version from downloaded plugin
2045                    let new_version = match calculate_plugin_version(
2046                        plugin_id,
2047                        &entry.source,
2048                        Some(cache_result.manifest.clone()),
2049                        &cache_result.path,
2050                        entry.version.as_deref(),
2051                        cache_result.git_commit_sha.as_deref(),
2052                    )
2053                    .await
2054                    {
2055                        Ok(v) => v,
2056                        Err(e) => {
2057                            return PluginUpdateResult {
2058                                success: false,
2059                                message: format!("Failed to calculate version: {}", e),
2060                                plugin_id: Some(plugin_id.to_string()),
2061                                new_version: None,
2062                                old_version: old_version.clone(),
2063                                already_up_to_date: None,
2064                                scope: Some(scope.to_string()),
2065                            };
2066                        }
2067                    };
2068                    (
2069                        cache_result.path,
2070                        new_version,
2071                        true,
2072                        cache_result.git_commit_sha,
2073                    )
2074                }
2075                Err(e) => {
2076                    return PluginUpdateResult {
2077                        success: false,
2078                        message: format!("Failed to cache plugin: {}", e),
2079                        plugin_id: Some(plugin_id.to_string()),
2080                        new_version: None,
2081                        old_version: old_version.clone(),
2082                        already_up_to_date: None,
2083                        scope: Some(scope.to_string()),
2084                    };
2085                }
2086            }
2087        }
2088        PluginSource::Relative(_) => {
2089            // Local plugin: use path from marketplace
2090            let marketplace_path = PathBuf::from(marketplace_install_location);
2091            let marketplace_dir = if marketplace_path.is_dir() {
2092                marketplace_path
2093            } else {
2094                marketplace_path
2095                    .parent()
2096                    .unwrap_or(&marketplace_path)
2097                    .to_path_buf()
2098            };
2099            let source_path =
2100                marketplace_dir.join(if let PluginSource::Relative(rel) = &entry.source {
2101                    rel
2102                } else {
2103                    ""
2104                });
2105
2106            // Verify source_path exists
2107            if !source_path.exists() {
2108                return PluginUpdateResult {
2109                    success: false,
2110                    message: format!("Plugin source not found at {}", source_path.display()),
2111                    plugin_id: Some(plugin_id.to_string()),
2112                    new_version: None,
2113                    old_version: old_version.clone(),
2114                    already_up_to_date: None,
2115                    scope: Some(scope.to_string()),
2116                };
2117            }
2118
2119            // Try to load manifest from plugin directory
2120            let plugin_manifest = load_plugin_manifest(
2121                &source_path
2122                    .join(".claude-plugin")
2123                    .join("plugin.json")
2124                    .to_string_lossy(),
2125                &entry.name,
2126                if let PluginSource::Relative(rel) = &entry.source {
2127                    rel
2128                } else {
2129                    ""
2130                },
2131            )
2132            .await
2133            .ok();
2134
2135            // Calculate version from plugin source path
2136            let new_version = match calculate_plugin_version(
2137                plugin_id,
2138                &entry.source,
2139                plugin_manifest,
2140                &source_path.to_string_lossy(),
2141                entry.version.as_deref(),
2142                None,
2143            )
2144            .await
2145            {
2146                Ok(v) => v,
2147                Err(e) => {
2148                    return PluginUpdateResult {
2149                        success: false,
2150                        message: format!("Failed to calculate version: {}", e),
2151                        plugin_id: Some(plugin_id.to_string()),
2152                        new_version: None,
2153                        old_version: old_version.clone(),
2154                        already_up_to_date: None,
2155                        scope: Some(scope.to_string()),
2156                    };
2157                }
2158            };
2159
2160            (
2161                source_path.to_string_lossy().to_string(),
2162                new_version,
2163                false,
2164                None,
2165            )
2166        }
2167        PluginSource::Settings { .. } => {
2168            return PluginUpdateResult {
2169                success: false,
2170                message: format!(
2171                    "Cannot update plugin \"{}\" with settings source",
2172                    plugin_name
2173                ),
2174                plugin_id: Some(plugin_id.to_string()),
2175                new_version: None,
2176                old_version: old_version.clone(),
2177                already_up_to_date: None,
2178                scope: Some(scope.to_string()),
2179            };
2180        }
2181    };
2182
2183    // Check if this version already exists in cache
2184    let versioned_path = get_versioned_cache_path(plugin_id, &new_version);
2185
2186    // Check if installation is already at the new version
2187    let zip_path = get_versioned_zip_cache_path(plugin_id, &new_version);
2188    let is_up_to_date = old_version.as_deref() == Some(&new_version)
2189        || installation.install_path == versioned_path
2190        || installation.install_path == zip_path;
2191
2192    if is_up_to_date {
2193        return PluginUpdateResult {
2194            success: true,
2195            message: format!(
2196                "{} is already at the latest version ({}).",
2197                plugin_name, new_version
2198            ),
2199            plugin_id: Some(plugin_id.to_string()),
2200            new_version: Some(new_version),
2201            old_version,
2202            already_up_to_date: Some(true),
2203            scope: Some(scope.to_string()),
2204        };
2205    }
2206
2207    // Copy to versioned cache
2208    let versioned_path =
2209        match copy_plugin_to_versioned_cache(&source_path, plugin_id, &new_version, entry).await {
2210            Ok(path) => path,
2211            Err(e) => {
2212                return PluginUpdateResult {
2213                    success: false,
2214                    message: format!("Failed to copy plugin to cache: {}", e),
2215                    plugin_id: Some(plugin_id.to_string()),
2216                    new_version: Some(new_version),
2217                    old_version,
2218                    already_up_to_date: None,
2219                    scope: Some(scope.to_string()),
2220                };
2221            }
2222        };
2223
2224    // Store old version path for potential cleanup
2225    let old_version_path = installation.install_path.clone();
2226
2227    // Update disk JSON file for this installation
2228    update_installation_path_on_disk(
2229        plugin_id,
2230        &scope.to_string(),
2231        project_path.as_deref(),
2232        &versioned_path,
2233        &new_version,
2234        git_commit_sha.as_deref(),
2235    );
2236
2237    // Check if old version is still referenced
2238    let updated_disk_data = load_installed_plugins_from_disk();
2239    let is_old_version_still_referenced =
2240        updated_disk_data
2241            .plugins
2242            .values()
2243            .any(|plugin_installations| {
2244                plugin_installations
2245                    .iter()
2246                    .any(|inst| inst.install_path == old_version_path)
2247            });
2248
2249    if !is_old_version_still_referenced && !old_version_path.is_empty() {
2250        mark_plugin_version_orphaned(&old_version_path).await;
2251    }
2252
2253    let scope_desc = project_path
2254        .map(|p| format!("{} ({})", scope, p))
2255        .unwrap_or_else(|| scope.to_string());
2256    let message = format!(
2257        "Plugin \"{}\" updated from {} to {} for scope {}. Restart to apply changes.",
2258        plugin_name,
2259        old_version
2260            .as_ref()
2261            .cloned()
2262            .unwrap_or_else(|| "unknown".to_string()),
2263        new_version,
2264        scope_desc
2265    );
2266
2267    // Clean up temp source if it was a remote download
2268    if should_cleanup_source && source_path != get_versioned_cache_path(plugin_id, &new_version) {
2269        let _ = std::fs::remove_dir_all(&source_path);
2270    }
2271
2272    PluginUpdateResult {
2273        success: true,
2274        message,
2275        plugin_id: Some(plugin_id.to_string()),
2276        new_version: Some(new_version),
2277        old_version,
2278        already_up_to_date: None,
2279        scope: Some(scope.to_string()),
2280    }
2281}