1#![allow(dead_code)]
3
4use 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
22pub const VALID_INSTALLABLE_SCOPES: &[&str] = &["user", "project", "local"];
28
29pub const VALID_UPDATE_SCOPES: &[&str] = &["user", "project", "local", "managed"];
31
32#[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#[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#[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
133pub 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#[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 pub reverse_dependents: Option<Vec<String>>,
156}
157
158#[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#[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#[derive(Debug, Clone, Default)]
186pub struct InstalledPluginsV2 {
187 pub plugins: HashMap<String, Vec<PluginInstallationEntry>>,
188}
189
190#[derive(Debug, Clone)]
192pub struct PluginInfo {
193 pub entry: PluginMarketplaceEntry,
194 pub marketplace_install_location: String,
195}
196
197#[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#[derive(Debug, Clone, Default)]
223pub struct SettingsJson {
224 pub enabled_plugins: Option<BTreeMap<String, serde_json::Value>>,
225}
226
227pub fn assert_installable_scope(scope: &str) -> Result<InstallableScope, String> {
233 InstallableScope::try_from(scope)
234}
235
236pub fn is_installable_scope(scope: &str) -> bool {
238 VALID_INSTALLABLE_SCOPES.contains(&scope)
239}
240
241pub 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
253pub(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
262pub(crate) fn is_builtin_plugin_id(plugin: &str) -> bool {
264 const BUILTIN_PLUGINS: &[&str] = &[];
266 BUILTIN_PLUGINS.contains(&plugin)
267}
268
269fn get_settings_for_source(_source: SettingSource) -> Option<SettingsJson> {
275 None
277}
278
279fn update_settings_for_source(
282 _source: SettingSource,
283 _settings: &SettingsJson,
284) -> Result<(), String> {
285 Ok(())
287}
288
289#[derive(Debug, Clone)]
295pub struct LoadedPlugin {
296 pub name: String,
297 pub source: Option<String>,
298 pub manifest: Option<serde_json::Value>,
299}
300
301async fn load_all_plugins() -> (Vec<LoadedPlugin>, Vec<LoadedPlugin>) {
303 (Vec::new(), Vec::new())
305}
306
307fn load_installed_plugins_from_disk() -> InstalledPluginsV2 {
309 InstalledPluginsV2::default()
311}
312
313fn load_installed_plugins_v2() -> InstalledPluginsV2 {
315 load_installed_plugins_from_disk()
316}
317
318fn remove_plugin_installation(_plugin_id: &str, _scope: &str, _project_path: Option<&str>) {
320 log::debug!(
322 "Removing plugin installation: {} scope={} project_path={:?}",
323 _plugin_id,
324 _scope,
325 _project_path
326 );
327}
328
329fn 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 log::debug!(
340 "Updating installation path: {} -> {} version={}",
341 _plugin_id,
342 _new_path,
343 _new_version
344 );
345}
346
347async fn load_known_marketplaces_config() -> HashMap<String, serde_json::Value> {
353 HashMap::new()
355}
356
357async fn get_marketplace(name: &str) -> Option<PluginMarketplace> {
359 log::debug!("Getting marketplace: {}", name);
361 None
362}
363
364async fn get_plugin_by_id(_plugin: &str) -> Option<PluginInfo> {
366 log::debug!("Getting plugin by id: {}", _plugin);
368 None
369}
370
371fn clear_all_caches() {
377 log::debug!("Clearing all caches");
378}
379
380fn clear_plugin_cache(reason: &str) {
382 log::debug!("Clearing plugin cache: {}", reason);
383}
384
385async fn mark_plugin_version_orphaned(_install_path: &str) {
387 log::debug!("Marking plugin version orphaned: {}", _install_path);
388}
389
390async 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 let git_commit_sha = install_plugin_source(source, &temp_path).await?;
413
414 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 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
451fn 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
465fn 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
479async fn install_plugin_source(
482 source: &PluginSource,
483 target: &str,
484) -> Result<Option<String>, String> {
485 match source {
486 PluginSource::Relative(p) => {
487 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 std::process::Command::new("npm")
518 .args(["pack", package])
519 .output()
520 .map_err(|e| format!("npm pack failed: {}", e))?;
521 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 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 let _ = std::fs::remove_file(&dir);
539 Ok(None)
540 }
541 PluginSource::Url { url, .. } => {
542 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 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 Err("Settings plugins cannot be cached".to_string())
574 }
575 }
576}
577
578fn 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
595fn 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
610fn 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 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 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
659fn 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#[derive(Debug, Clone, Default)]
674pub struct CachePluginOptions {
675 pub manifest: Option<serde_json::Value>,
676}
677
678#[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
686async 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 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 let _ = std::fs::remove_dir_all(&cache_path);
712 }
713 Err(_) => { }
714 }
715 }
716
717 if let Some(seed_path) = probe_seed_cache(plugin_id, new_version).await {
719 return Ok(seed_path);
720 }
721
722 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 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 let git_path = format!("{}/.git", cache_path);
740 let _ = std::fs::remove_dir_all(&git_path);
741
742 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 if zip_cache_mode {
759 return create_plugin_zip(&cache_path, &zip_path);
761 }
762
763 Ok(cache_path)
764}
765
766async 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
790fn 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(¤t).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 let _ = std::fs::remove_dir_all(dir_path);
833
834 Ok(zip_path.to_string())
835}
836
837fn 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
845fn get_versioned_zip_cache_path(plugin_id: &str, version: &str) -> String {
848 format!("{}.zip", get_versioned_cache_path(plugin_id, version))
849}
850
851async 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 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
882async 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 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 if let Some(v) = entry_version {
900 return Ok(v.to_string());
901 }
902
903 if let Some(sha) = git_commit_sha {
905 let short_sha = &sha[..sha.len().min(12)];
906 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 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 Ok("unknown".to_string())
924}
925
926fn 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
933fn is_plugin_blocked_by_policy(_plugin_id: &str) -> bool {
939 false
941}
942
943async fn delete_plugin_data_dir(_plugin_id: &str) -> Result<(), String> {
949 log::debug!("Deleting plugin data dir: {}", _plugin_id);
950 Ok(())
951}
952
953fn delete_plugin_options(_plugin_id: &str) {
959 log::debug!("Deleting plugin options: {}", _plugin_id);
960}
961
962fn get_plugin_editable_scopes() -> BTreeSet<String> {
968 BTreeSet::new()
970}
971
972fn find_reverse_dependents(_plugin_id: &str, _all_plugins: &[LoadedPlugin]) -> Vec<String> {
978 Vec::new()
979}
980
981fn 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
996pub(crate) fn format_resolution_error(resolution: &str) -> String {
1002 format!("Failed to resolve plugin: {}", resolution)
1003}
1004
1005async fn install_resolved_plugin(
1007 _plugin_id: &str,
1008 _entry: &PluginMarketplaceEntry,
1009 _scope: InstallableScope,
1010 _marketplace_install_location: Option<&str>,
1011) -> InstallResolutionResult {
1012 InstallResolutionResult::Success {
1018 dep_note: String::new(),
1019 }
1020}
1021
1022pub 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
1042struct 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 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
1090fn 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 if p.name == plugin || p.name == name {
1101 return true;
1102 }
1103
1104 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
1115struct 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 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
1156pub fn get_plugin_installation_from_v2(plugin_id: &str) -> (String, Option<String>) {
1160 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 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 (
1203 installations[0].scope.clone(),
1204 installations[0].project_path.clone(),
1205 )
1206}
1207
1208pub 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 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
1360pub 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 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 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 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 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 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 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 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_plugin_installation(&plugin_id, &scope.to_string(), project_path.as_deref());
1505
1506 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(&plugin_id);
1515 if delete_data_dir {
1516 let _ = delete_plugin_data_dir(&plugin_id).await;
1517 }
1518 }
1519
1520 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
1545pub 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 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 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 let (plugin_id, resolved_scope) = match scope {
1626 Some(explicit_scope) => {
1627 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 if let Some(found) = find_plugin_in_settings(plugin) {
1650 (found.plugin_id, found.scope)
1651 } else if plugin.contains('@') {
1652 (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 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 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 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 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 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
1814pub 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
1829pub 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
1844pub 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
1905pub async fn update_plugin_op(plugin: &str, scope: &str) -> PluginUpdateResult {
1922 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 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 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 let project_path = get_project_path_for_scope(&scope.to_string());
1981
1982 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
2021async 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 match cache_plugin(&entry.source, CachePluginOptions { manifest: None }).await {
2043 Ok(cache_result) => {
2044 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 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 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 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 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 let versioned_path = get_versioned_cache_path(plugin_id, &new_version);
2185
2186 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 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 let old_version_path = installation.install_path.clone();
2226
2227 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 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 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}