1use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16use forge_primitives::plugins::{MarketplaceHealth, PluginComponents};
17
18pub fn plugins_root() -> Option<PathBuf> {
22 let config_dir = match std::env::var_os("CLAUDE_CONFIG_DIR") {
23 Some(dir) if !dir.is_empty() => PathBuf::from(dir),
24 _ => {
25 let home = std::env::var_os("HOME").filter(|s| !s.is_empty()).map(PathBuf::from)?;
26 home.join(".claude")
27 }
28 };
29 Some(config_dir.join("plugins"))
30}
31
32#[derive(Deserialize)]
33struct PluginRegistry {
34 #[serde(default)]
35 plugins: BTreeMap<String, Vec<RegistryEntry>>,
36}
37
38#[derive(Deserialize)]
39struct RegistryEntry {
40 #[serde(rename = "installPath", default)]
41 install_path: Option<String>,
42 #[serde(default)]
43 version: Option<String>,
44 #[serde(default)]
45 auto: Option<bool>,
46 #[serde(default)]
47 scope: Option<String>,
48 #[serde(rename = "installedAt", default)]
49 installed_at: Option<String>,
50 #[serde(rename = "lastUpdated", default)]
51 last_updated: Option<String>,
52 #[serde(rename = "projectPath", default)]
53 project_path: Option<String>,
54}
55
56#[derive(Deserialize)]
57struct MarketplaceManifest {
58 #[serde(default)]
59 plugins: Vec<ManifestPlugin>,
60}
61
62#[derive(Deserialize)]
63struct ManifestPlugin {
64 name: String,
65 #[serde(default)]
66 version: Option<String>,
67 #[serde(default)]
72 source: Option<serde_json::Value>,
73 #[serde(rename = "lspServers", default)]
74 lsp_servers: Option<BTreeMap<String, serde_json::Value>>,
75}
76
77#[derive(Deserialize)]
78struct KnownMarketplaces {
79 #[serde(flatten)]
80 entries: BTreeMap<String, KnownMarketplaceEntry>,
81}
82
83#[derive(Deserialize)]
84struct KnownMarketplaceEntry {
85 #[serde(default)]
86 source: Option<serde_json::Value>,
87 #[serde(rename = "installLocation", default)]
88 install_location: Option<String>,
89}
90
91struct DirComponents {
93 skills: Vec<String>,
94 agents: Vec<String>,
95 commands: Vec<String>,
96 hooks: Vec<String>,
97 mcp: bool,
98}
99
100#[derive(Deserialize, Default)]
110struct PluginManifest {
111 #[serde(default)]
112 skills: Option<serde_json::Value>,
113 #[serde(default)]
114 agents: Option<serde_json::Value>,
115 #[serde(default)]
116 commands: Option<serde_json::Value>,
117 #[serde(default)]
118 hooks: Option<serde_json::Value>,
119 #[serde(rename = "mcpServers", default)]
120 mcp_servers: Option<serde_json::Value>,
121}
122
123fn read_json(path: &Path) -> Option<serde_json::Value> {
129 match std::fs::read_to_string(path) {
130 Ok(contents) => match serde_json::from_str(&contents) {
131 Ok(doc) => Some(doc),
132 Err(error) => {
133 tracing::warn!(
134 target: "forge_agent::userdata::plugins",
135 path = %path.display(),
136 error = %error,
137 "file exists but does not parse; reading as absent",
138 );
139 None
140 }
141 },
142 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
143 Err(error) => {
144 tracing::warn!(
145 target: "forge_agent::userdata::plugins",
146 path = %path.display(),
147 error = %error,
148 "file exists but cannot be read; reading as absent",
149 );
150 None
151 }
152 }
153}
154
155fn is_regular_file(path: &Path) -> Result<bool, std::io::Error> {
159 match std::fs::metadata(path) {
160 Ok(metadata) => Ok(metadata.is_file()),
161 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
162 Err(error) => Err(error),
163 }
164}
165
166fn read_component_dir(dir: &Path, gated: bool) -> Result<Option<DirComponents>, std::io::Error> {
174 std::fs::read_dir(dir)?;
175 if gated && !is_regular_file(&dir.join(".claude-plugin/plugin.json"))? {
176 return Ok(None);
177 }
178 let PluginManifest {
179 skills: declared_skills,
180 agents: declared_agents,
181 commands: declared_commands,
182 hooks: declared_hooks,
183 mcp_servers: declared_mcp,
184 } = read_plugin_manifest(dir).unwrap_or_default();
185 let mut skills = list_subdirs(&dir.join("skills"));
186 if let Some(declared) = declared_skills {
187 for path in declared_paths("skills", &declared) {
188 skills.extend(declared_skill_names(&dir.join(&path)));
189 }
190 skills.sort();
191 skills.dedup();
192 }
193 let agents = md_component_names(dir, "agents", declared_agents, &dir.join("agents"));
197 let commands = md_component_names(dir, "commands", declared_commands, &dir.join("commands"));
198 let hooks = hook_trigger_names(dir, declared_hooks.as_ref());
199 let mcp = mcp_present(dir, declared_mcp.as_ref());
200 Ok(Some(DirComponents { skills, agents, commands, hooks, mcp }))
201}
202
203fn read_plugin_manifest(dir: &Path) -> Option<PluginManifest> {
207 let path = dir.join(".claude-plugin/plugin.json");
208 read_json(&path).and_then(|doc| match serde_json::from_value::<PluginManifest>(doc) {
209 Ok(manifest) => Some(manifest),
210 Err(error) => {
211 tracing::warn!(
212 target: "forge_agent::userdata::plugins",
213 path = %path.display(),
214 error = %error,
215 "plugin manifest declares no component paths forge can read; they read as undeclared",
216 );
217 None
218 }
219 })
220}
221
222fn declared_paths(kind: &str, value: &serde_json::Value) -> Vec<String> {
225 match value {
226 serde_json::Value::String(path) => vec![path.clone()],
227 serde_json::Value::Array(items) => {
228 items.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect()
229 }
230 _ => {
231 tracing::warn!(
232 target: "forge_agent::userdata::plugins",
233 kind = %kind,
234 "the manifest's component declaration is neither a string nor an array; it reads as undeclared",
235 );
236 Vec::new()
237 }
238 }
239}
240
241fn declared_skill_names(path: &Path) -> Vec<String> {
245 if is_regular_file(&path.join("SKILL.md")).unwrap_or(false) {
246 return vec![path.file_name().unwrap_or_default().to_string_lossy().into_owned()];
247 }
248 match std::fs::metadata(path) {
249 Ok(metadata) if metadata.is_dir() => list_subdirs(path),
250 Ok(_) => vec![path.file_stem().unwrap_or_default().to_string_lossy().into_owned()],
251 Err(_) => Vec::new(),
252 }
253}
254
255fn md_component_names(
259 dir: &Path,
260 kind: &str,
261 declared: Option<serde_json::Value>,
262 conventional: &Path,
263) -> Vec<String> {
264 let Some(declared) = declared else { return list_md_files(conventional) };
265 let mut names = Vec::new();
266 for path in declared_paths(kind, &declared) {
267 let path = dir.join(path);
268 match std::fs::metadata(&path) {
269 Ok(metadata) if metadata.is_dir() => names.extend(list_md_files(&path)),
270 Ok(_) => {
271 names.push(path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
272 }
273 Err(_) => {}
274 }
275 }
276 names.sort();
277 names
278}
279
280fn hook_trigger_names(dir: &Path, declared: Option<&serde_json::Value>) -> Vec<String> {
284 let mut files = vec![dir.join("hooks/hooks.json")];
285 if let Some(declared) = declared {
286 match declared {
287 serde_json::Value::String(path) => files.push(dir.join(path)),
288 serde_json::Value::Array(items) => files
289 .extend(items.iter().filter_map(|item| item.as_str()).map(|path| dir.join(path))),
290 serde_json::Value::Object(config) => {
291 return config.keys().cloned().chain(hook_trigger_names(dir, None)).collect();
292 }
293 _ => {
294 tracing::warn!(
295 target: "forge_agent::userdata::plugins",
296 "the manifest's hooks declaration is neither paths nor an inline object; it reads as undeclared",
297 );
298 }
299 }
300 }
301 let mut triggers = Vec::new();
302 for hooks_path in &files {
303 match is_regular_file(hooks_path) {
304 Ok(true) => {
305 let Some(doc) = read_json(hooks_path) else { continue };
306 let Some(hooks) = doc.get("hooks").and_then(|hooks| hooks.as_object()) else {
307 tracing::warn!(
308 target: "forge_agent::userdata::plugins",
309 path = %hooks_path.display(),
310 "hooks.json names no hooks object; its triggers are not shown",
311 );
312 continue;
313 };
314 triggers.extend(hooks.keys().cloned());
315 }
316 Ok(false) => {}
317 Err(error) => {
318 tracing::warn!(
319 target: "forge_agent::userdata::plugins",
320 path = %hooks_path.display(),
321 error = %error,
322 "hooks.json cannot be read; its triggers are not shown",
323 );
324 }
325 }
326 }
327 triggers
328}
329
330fn mcp_present(dir: &Path, declared: Option<&serde_json::Value>) -> bool {
333 let mcp_file = |path: &Path| match is_regular_file(path) {
334 Ok(present) => present,
335 Err(error) => {
336 tracing::warn!(
337 target: "forge_agent::userdata::plugins",
338 path = %path.display(),
339 error = %error,
340 "the plugin's MCP config cannot be read; the row's MCP flag is not set",
341 );
342 false
343 }
344 };
345 let mut mcp = mcp_file(&dir.join(".mcp.json"));
346 if let Some(declared) = declared {
347 match declared {
348 serde_json::Value::Object(_) => mcp = true,
349 serde_json::Value::String(path) => mcp |= mcp_file(&dir.join(path)),
350 serde_json::Value::Array(items) => {
351 for path in items.iter().filter_map(|item| item.as_str()) {
352 mcp |= mcp_file(&dir.join(path));
353 }
354 }
355 _ => {
356 tracing::warn!(
357 target: "forge_agent::userdata::plugins",
358 "the manifest's MCP server declaration is neither paths nor an inline object; it reads as undeclared",
359 );
360 }
361 }
362 }
363 mcp
364}
365
366fn install_dir_reason(error: Option<&std::io::Error>) -> String {
371 match error.map(std::io::Error::kind) {
372 Some(std::io::ErrorKind::NotFound) | None => {
373 "registered install dir is missing on disk".to_owned()
374 }
375 Some(_) => "registered install dir cannot be read".to_owned(),
376 }
377}
378
379fn list_subdirs(dir: &Path) -> Vec<String> {
380 let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
381 let mut names = entries
382 .flatten()
383 .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()))
384 .map(|entry| entry.file_name().to_string_lossy().into_owned())
385 .collect::<Vec<_>>();
386 names.sort();
387 names
388}
389
390fn list_md_files(dir: &Path) -> Vec<String> {
391 let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
392 let mut names = entries
393 .flatten()
394 .filter(|entry| entry.file_type().is_ok_and(|t| t.is_file()))
395 .map(|entry| entry.path())
396 .filter(|path| path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("md")))
397 .map(|path| path.file_stem().unwrap_or_default().to_string_lossy().into_owned())
398 .collect::<Vec<_>>();
399 names.sort();
400 names
401}
402
403struct Manifests {
409 entries: BTreeMap<(String, String), ManifestPlugin>,
410 errors: BTreeMap<String, String>,
411 loaded: std::collections::BTreeSet<String>,
412}
413
414impl Manifests {
415 fn load(marketplaces_root: &Path) -> Self {
416 let mut entries = BTreeMap::new();
417 let mut errors = BTreeMap::new();
418 let mut loaded = std::collections::BTreeSet::new();
419 let marketplaces = match std::fs::read_dir(marketplaces_root) {
420 Ok(marketplaces) => marketplaces,
421 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
422 return Self { entries, errors, loaded };
423 }
424 Err(error) => {
425 tracing::warn!(
426 target: "forge_agent::userdata::plugins",
427 path = %marketplaces_root.display(),
428 error = %error,
429 "marketplaces root cannot be read; every configured marketplace reads as a cache-miss",
430 );
431 return Self { entries, errors, loaded };
432 }
433 };
434 for marketplace in marketplaces {
435 let marketplace = match marketplace {
436 Ok(marketplace) => marketplace,
437 Err(error) => {
438 tracing::warn!(
439 target: "forge_agent::userdata::plugins",
440 path = %marketplaces_root.display(),
441 error = %error,
442 "marketplace entry cannot be read; its catalog is not listed",
443 );
444 continue;
445 }
446 };
447 match marketplace.file_type() {
448 Ok(file_type) if file_type.is_dir() => {}
449 Ok(_) => continue,
450 Err(error) => {
453 tracing::warn!(
454 target: "forge_agent::userdata::plugins",
455 path = %marketplace.path().display(),
456 error = %error,
457 "marketplace entry cannot be read; it reads as a cache-miss",
458 );
459 continue;
460 }
461 }
462 let name = marketplace.file_name().to_string_lossy().into_owned();
463 let dir = marketplace.path();
464 for candidate in
465 [dir.join("marketplace.json"), dir.join(".claude-plugin/marketplace.json")]
466 {
467 match is_regular_file(&candidate) {
468 Ok(true) => {}
469 Ok(false) => continue,
470 Err(error) => {
471 tracing::warn!(
472 target: "forge_agent::userdata::plugins",
473 path = %candidate.display(),
474 error = %error,
475 "marketplace manifest cannot be read; this marketplace reads as a load failure",
476 );
477 errors.insert(
478 name.clone(),
479 format!("marketplace.json cannot be read: {error}"),
480 );
481 break;
482 }
483 }
484 match std::fs::read_to_string(&candidate) {
487 Err(error) => {
488 tracing::warn!(
489 target: "forge_agent::userdata::plugins",
490 path = %candidate.display(),
491 error = %error,
492 "marketplace manifest cannot be read",
493 );
494 errors.insert(
495 name.clone(),
496 format!("marketplace.json cannot be read: {error}"),
497 );
498 break;
499 }
500 Ok(text) => match serde_json::from_str::<MarketplaceManifest>(&text) {
501 Ok(manifest) => {
502 loaded.insert(name.clone());
503 for plugin in manifest.plugins {
504 entries.insert((name.clone(), plugin.name.clone()), plugin);
505 }
506 }
507 Err(error) => {
508 tracing::warn!(
509 target: "forge_agent::userdata::plugins",
510 path = %candidate.display(),
511 error = %error,
512 "marketplace manifest failed to parse",
513 );
514 errors.insert(
518 name.clone(),
519 format!("marketplace.json failed to parse: {error}"),
520 );
521 }
522 },
523 }
524 break;
525 }
526 }
527 Self { entries, errors, loaded }
528 }
529
530 fn plugin(&self, marketplace: &str, name: &str) -> Option<&ManifestPlugin> {
531 self.entries.get(&(marketplace.to_owned(), name.to_owned()))
532 }
533}
534
535#[derive(Debug, Default, PartialEq, Eq)]
541pub struct ExtensionScan {
542 pub installed: Vec<forge_primitives::plugins::InstalledPluginEntry>,
543 pub marketplace: Vec<forge_primitives::plugins::MarketplaceEntry>,
544 pub marketplace_sources: Vec<forge_primitives::plugins::MarketplaceSourceEntry>,
545 pub components: Vec<PluginComponents>,
546 pub marketplace_health: Vec<MarketplaceHealth>,
547}
548
549fn read_registry(plugins_root: &Path) -> PluginRegistry {
555 let path = plugins_root.join("installed_plugins.json");
556 let text = match std::fs::read_to_string(&path) {
557 Ok(text) => text,
558 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
559 return PluginRegistry { plugins: BTreeMap::new() };
560 }
561 Err(error) => {
562 tracing::warn!(
563 target: "forge_agent::userdata::plugins",
564 path = %path.display(),
565 error = %error,
566 "installed_plugins.json cannot be read; reading as an empty registry",
567 );
568 return PluginRegistry { plugins: BTreeMap::new() };
569 }
570 };
571 match serde_json::from_str::<serde_json::Value>(&text)
572 .map_err(|error| error.to_string())
573 .and_then(|doc| {
574 serde_json::from_value::<PluginRegistry>(doc).map_err(|error| error.to_string())
575 }) {
576 Ok(registry) => registry,
577 Err(error) => {
578 tracing::warn!(
579 target: "forge_agent::userdata::plugins",
580 path = %path.display(),
581 error = %error,
582 "installed_plugins.json exists but does not parse; reading as an empty registry",
583 );
584 PluginRegistry { plugins: BTreeMap::new() }
585 }
586 }
587}
588
589pub fn scan_extensions(
598 plugins_root: &Path,
599 marketplaces_root: &Path,
600 config_dir: &Path,
601) -> ExtensionScan {
602 let registry = read_registry(plugins_root);
603 let enabled = enabled_plugins(plugins_root);
604 let manifests = Manifests::load(marketplaces_root);
605 let (marketplace_registry, registry_error) = read_marketplace_registry(marketplaces_root);
606 let mut rows = BTreeMap::<String, PluginComponents>::new();
607
608 for (id, entries) in ®istry.plugins {
611 let Some(entry) = entries.first() else { continue };
612 let marketplace = id.split_once('@').map_or("", |(_, marketplace)| marketplace);
613 let manifest = manifests.plugin(marketplace, plugin_name(id));
614 let (components, reason) = if let Some(path) = entry.install_path.as_deref() {
615 match read_component_dir(Path::new(path), false) {
616 Ok(components) => (components, None),
617 Err(error) => {
618 tracing::warn!(
619 target: "forge_agent::userdata::plugins",
620 plugin = %id,
621 path = %path,
622 error = %error,
623 "registered plugin install dir cannot be read",
624 );
625 (None, Some(install_dir_reason(Some(&error))))
626 }
627 }
628 } else {
629 tracing::warn!(
630 target: "forge_agent::userdata::plugins",
631 plugin = %id,
632 "registered plugin entry carries no installPath",
633 );
634 (None, Some(install_dir_reason(None)))
635 };
636 let Some(components) = components else {
637 rows.insert(
638 id.clone(),
639 PluginComponents {
640 plugin: id.clone(),
641 marketplace: marketplace.to_owned(),
642 version: entry.version.clone(),
643 installed: true,
644 enabled: enabled.is_enabled(id),
645 auto: entries.iter().any(|entry| entry.auto == Some(true)),
646 available_version: manifest.and_then(|plugin| plugin.version.clone()),
647 load_error: reason,
648 ..PluginComponents::default()
649 },
650 );
651 continue;
652 };
653 rows.insert(
654 id.clone(),
655 PluginComponents {
656 plugin: id.clone(),
657 marketplace: marketplace.to_owned(),
658 version: entry.version.clone(),
659 installed: true,
660 enabled: enabled.is_enabled(id),
661 auto: entries.iter().any(|entry| entry.auto == Some(true)),
662 available_version: manifest.and_then(|plugin| plugin.version.clone()),
663 skills: components.skills,
664 agents: components.agents,
665 commands: components.commands,
666 hooks: components.hooks,
667 mcp: components.mcp,
668 lsp_servers: manifest_lsp_servers(manifest),
669 ..PluginComponents::default()
670 },
671 );
672 }
673
674 for ((marketplace, name), plugin) in &manifests.entries {
679 let id = format!("{name}@{marketplace}");
680 if rows.contains_key(&id) {
681 continue;
682 }
683 let source_dir = plugin
684 .source
685 .as_ref()
686 .and_then(serde_json::Value::as_str)
687 .filter(|source| source.starts_with("./"))
688 .map(|source| marketplaces_root.join(marketplace).join(source));
689 let components = source_dir.and_then(|source| match read_component_dir(&source, false) {
690 Ok(components) => components,
691 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
692 Err(error) => {
693 tracing::warn!(
694 target: "forge_agent::userdata::plugins",
695 plugin = %id,
696 path = %source.display(),
697 error = %error,
698 "marketplace clone source cannot be read; its components are not shown",
699 );
700 None
701 }
702 });
703 let (skills, agents, commands, hooks, mcp) = components
704 .map(|components| {
705 (
706 components.skills,
707 components.agents,
708 components.commands,
709 components.hooks,
710 components.mcp,
711 )
712 })
713 .unwrap_or_default();
714 rows.insert(
715 id.clone(),
716 PluginComponents {
717 plugin: id,
718 marketplace: marketplace.clone(),
719 installed: false,
720 enabled: false,
721 available_version: plugin.version.clone(),
722 skills,
723 agents,
724 commands,
725 hooks,
726 mcp,
727 lsp_servers: manifest_lsp_servers(Some(plugin)),
728 ..PluginComponents::default()
729 },
730 );
731 }
732
733 for (marketplace, error) in &manifests.errors {
737 rows.entry(marketplace.clone()).or_insert_with(|| PluginComponents {
738 plugin: marketplace.clone(),
739 marketplace: marketplace.clone(),
740 load_error: Some(error.clone()),
741 ..PluginComponents::default()
742 });
743 }
744
745 let cache = plugins_root.join("cache");
749 let marketplace_dirs = std::fs::read_dir(&cache);
750 if let Err(error) = &marketplace_dirs
751 && error.kind() != std::io::ErrorKind::NotFound
752 {
753 tracing::warn!(
754 target: "forge_agent::userdata::plugins",
755 path = %cache.display(),
756 error = %error,
757 "plugin cache cannot be read; no cache rows are shown",
758 );
759 }
760 let Ok(marketplace_dirs) = marketplace_dirs else {
761 return ExtensionScan {
762 installed: installed_entries(®istry, &enabled),
763 marketplace: manifest_marketplace_entries(&manifests),
764 marketplace_sources: match ®istry_error {
765 Some(_) => manifests
768 .loaded
769 .iter()
770 .chain(manifests.errors.keys())
771 .collect::<std::collections::BTreeSet<_>>()
772 .into_iter()
773 .map(|name| forge_primitives::plugins::MarketplaceSourceEntry {
774 name: name.clone(),
775 source: None,
776 repo: None,
777 install_location: None,
778 })
779 .collect(),
780 None => marketplace_source_entries(&marketplace_registry),
781 },
782 components: rows.into_values().collect(),
783 marketplace_health: marketplace_health(
784 &marketplace_registry,
785 &manifests,
786 config_dir,
787 registry_error.as_deref(),
788 ),
789 };
790 };
791 for marketplace_dir in marketplace_dirs {
792 let marketplace_dir = match marketplace_dir {
793 Ok(entry) => entry,
794 Err(error) => {
795 tracing::warn!(
796 target: "forge_agent::userdata::plugins",
797 path = %cache.display(),
798 error = %error,
799 "cache entry cannot be read; its leftovers are not shown",
800 );
801 continue;
802 }
803 };
804 match marketplace_dir.file_type() {
807 Ok(file_type) if file_type.is_dir() => {}
808 Ok(_) => continue,
809 Err(error) => {
810 tracing::warn!(
811 target: "forge_agent::userdata::plugins",
812 path = %marketplace_dir.path().display(),
813 error = %error,
814 "cache entry cannot be read; its leftovers are not shown",
815 );
816 continue;
817 }
818 }
819 let marketplace = marketplace_dir.file_name().to_string_lossy().into_owned();
820 let plugin_dirs = std::fs::read_dir(marketplace_dir.path());
821 if let Err(error) = &plugin_dirs
822 && error.kind() != std::io::ErrorKind::NotFound
823 {
824 tracing::warn!(
825 target: "forge_agent::userdata::plugins",
826 path = %marketplace_dir.path().display(),
827 error = %error,
828 "marketplace cache dir cannot be read; its leftovers are not shown",
829 );
830 }
831 let Ok(plugin_dirs) = plugin_dirs else { continue };
832 for plugin_dir in plugin_dirs {
833 let plugin_dir = match plugin_dir {
834 Ok(entry) => entry,
835 Err(error) => {
836 tracing::warn!(
837 target: "forge_agent::userdata::plugins",
838 path = %marketplace_dir.path().display(),
839 error = %error,
840 "cache plugin entry cannot be read; it is not listed",
841 );
842 continue;
843 }
844 };
845 let name = plugin_dir.file_name().to_string_lossy().into_owned();
846 let id = format!("{name}@{marketplace}");
847 if rows.contains_key(&id) {
848 continue;
849 }
850 let Some((_, components)) = newest_plugin_version(&plugin_dir.path()) else { continue };
851 rows.insert(
852 id.clone(),
853 PluginComponents {
854 plugin: id,
855 marketplace: marketplace.clone(),
856 installed: false,
857 enabled: false,
858 skills: components.skills,
859 agents: components.agents,
860 commands: components.commands,
861 hooks: components.hooks,
862 mcp: components.mcp,
863 ..PluginComponents::default()
864 },
865 );
866 }
867 }
868
869 ExtensionScan {
870 installed: installed_entries(®istry, &enabled),
871 marketplace: manifest_marketplace_entries(&manifests),
872 marketplace_sources: match ®istry_error {
873 Some(_) => manifests
876 .loaded
877 .iter()
878 .chain(manifests.errors.keys())
879 .collect::<std::collections::BTreeSet<_>>()
880 .into_iter()
881 .map(|name| forge_primitives::plugins::MarketplaceSourceEntry {
882 name: name.clone(),
883 source: None,
884 repo: None,
885 install_location: None,
886 })
887 .collect(),
888 None => marketplace_source_entries(&marketplace_registry),
889 },
890 components: rows.into_values().collect(),
891 marketplace_health: marketplace_health(
892 &marketplace_registry,
893 &manifests,
894 config_dir,
895 registry_error.as_deref(),
896 ),
897 }
898}
899
900fn read_marketplace_registry(marketplaces_root: &Path) -> (KnownMarketplaces, Option<String>) {
907 let registry_path =
908 marketplaces_root.parent().map(|plugins_root| plugins_root.join("known_marketplaces.json"));
909 let Some(path) = registry_path else {
910 return (KnownMarketplaces { entries: BTreeMap::new() }, None);
911 };
912 let text = match std::fs::read_to_string(&path) {
913 Ok(text) => text,
914 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
915 return (KnownMarketplaces { entries: BTreeMap::new() }, None);
916 }
917 Err(error) => {
918 let message = format!("known_marketplaces.json cannot be read: {error}");
919 tracing::warn!(
920 target: "forge_agent::userdata::plugins",
921 path = %path.display(),
922 error = %error,
923 "marketplace registry cannot be read; the configured marketplaces cannot be listed",
924 );
925 return (KnownMarketplaces { entries: BTreeMap::new() }, Some(message));
926 }
927 };
928 match serde_json::from_str::<KnownMarketplaces>(&text) {
929 Ok(registry) => (registry, None),
930 Err(error) => {
931 let message = format!("known_marketplaces.json failed to parse: {error}");
932 tracing::warn!(
933 target: "forge_agent::userdata::plugins",
934 path = %path.display(),
935 error = %error,
936 "marketplace registry does not parse; the configured marketplaces cannot be listed",
937 );
938 (KnownMarketplaces { entries: BTreeMap::new() }, Some(message))
939 }
940 }
941}
942
943fn installed_entries(
948 registry: &PluginRegistry,
949 enabled: &EnabledMap,
950) -> Vec<forge_primitives::plugins::InstalledPluginEntry> {
951 registry
952 .plugins
953 .iter()
954 .flat_map(|(id, entries)| {
955 entries.iter().map(move |entry| forge_primitives::plugins::InstalledPluginEntry {
956 id: id.clone(),
957 version: entry.version.clone(),
958 scope: entry.scope.clone().unwrap_or_else(|| "user".to_owned()),
959 enabled: enabled.is_enabled(id),
960 installed_at: entry.installed_at.clone(),
961 last_updated: entry.last_updated.clone(),
962 project_path: entry.project_path.clone(),
963 capability: forge_primitives::plugins::PluginCapability::Skill,
964 })
965 })
966 .collect()
967}
968
969fn manifest_marketplace_entries(
972 manifests: &Manifests,
973) -> Vec<forge_primitives::plugins::MarketplaceEntry> {
974 manifests
975 .entries
976 .iter()
977 .map(|((marketplace, name), plugin)| forge_primitives::plugins::MarketplaceEntry {
978 plugin_id: format!("{name}@{marketplace}"),
979 name: name.clone(),
980 description: None,
981 marketplace_name: Some(marketplace.clone()),
982 version: plugin.version.clone(),
983 install_count: None,
984 source: None,
985 })
986 .collect()
987}
988
989fn marketplace_source_entries(
992 registry: &KnownMarketplaces,
993) -> Vec<forge_primitives::plugins::MarketplaceSourceEntry> {
994 registry
995 .entries
996 .iter()
997 .map(|(name, entry)| forge_primitives::plugins::MarketplaceSourceEntry {
998 name: name.clone(),
999 source: entry
1000 .source
1001 .as_ref()
1002 .and_then(|source| source.get("source"))
1003 .and_then(serde_json::Value::as_str)
1004 .map(str::to_owned),
1005 repo: entry
1006 .source
1007 .as_ref()
1008 .and_then(|source| source.get("repo"))
1009 .and_then(serde_json::Value::as_str)
1010 .map(str::to_owned),
1011 install_location: entry.install_location.clone(),
1012 })
1013 .collect()
1014}
1015
1016fn plugin_name(id: &str) -> &str {
1017 id.split_once('@').map_or(id, |(name, _)| name)
1018}
1019
1020fn manifest_lsp_servers(plugin: Option<&ManifestPlugin>) -> BTreeMap<String, String> {
1023 plugin
1024 .and_then(|plugin| plugin.lsp_servers.as_ref())
1025 .map(|servers| {
1026 servers
1027 .iter()
1028 .map(|(key, value)| {
1029 let command = value.get("command").and_then(serde_json::Value::as_str);
1030 (key.clone(), command.unwrap_or(key).to_owned())
1031 })
1032 .collect()
1033 })
1034 .unwrap_or_default()
1035}
1036
1037fn enabled_plugins(plugins_root: &Path) -> EnabledMap {
1043 let settings_path = plugins_root.parent().map(|config_dir| config_dir.join("settings.json"));
1044 let map = settings_path
1045 .and_then(|path| read_json(&path))
1046 .and_then(|settings| {
1047 settings.get("enabledPlugins").and_then(|map| map.as_object()).cloned()
1048 })
1049 .unwrap_or_default();
1050 EnabledMap { map }
1051}
1052
1053struct EnabledMap {
1054 map: serde_json::Map<String, serde_json::Value>,
1055}
1056
1057impl EnabledMap {
1058 fn is_enabled(&self, id: &str) -> bool {
1059 self.map.get(id).and_then(serde_json::Value::as_bool) != Some(false)
1060 }
1061}
1062
1063fn newest_plugin_version(plugin_dir: &Path) -> Option<(PathBuf, DirComponents)> {
1070 let entries = match std::fs::read_dir(plugin_dir) {
1071 Ok(entries) => entries,
1072 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
1073 Err(error) => {
1074 tracing::warn!(
1075 target: "forge_agent::userdata::plugins",
1076 path = %plugin_dir.display(),
1077 error = %error,
1078 "cache plugin dir cannot be read; its versions are not shown",
1079 );
1080 return None;
1081 }
1082 };
1083 let mut versions = Vec::new();
1084 for entry in entries {
1085 let entry = match entry {
1086 Ok(entry) => entry,
1087 Err(error) => {
1088 tracing::warn!(
1089 target: "forge_agent::userdata::plugins",
1090 path = %plugin_dir.display(),
1091 error = %error,
1092 "cache dir entry cannot be read; no inventory is shown for the plugin",
1093 );
1094 return None;
1095 }
1096 };
1097 match entry.file_type() {
1098 Ok(file_type) if file_type.is_dir() => versions.push(entry.path()),
1099 Ok(_) => {}
1100 Err(error) => {
1103 tracing::warn!(
1104 target: "forge_agent::userdata::plugins",
1105 path = %entry.path().display(),
1106 error = %error,
1107 "cache entry cannot be read; no inventory is shown for the plugin",
1108 );
1109 return None;
1110 }
1111 }
1112 }
1113 versions.sort_by_key(|path| {
1114 let name = path.file_name().unwrap_or_default().to_string_lossy().into_owned();
1115 version_sort_key(&name)
1116 });
1117 versions.reverse();
1118 for version in versions {
1119 match read_component_dir(&version, true) {
1120 Ok(Some(components)) => return Some((version, components)),
1121 Ok(None) => {}
1122 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1123 Err(error) => {
1124 tracing::warn!(
1125 target: "forge_agent::userdata::plugins",
1126 path = %version.display(),
1127 error = %error,
1128 "cache plugin version dir cannot be read; no inventory is shown for the plugin",
1129 );
1130 return None;
1131 }
1132 }
1133 }
1134 tracing::warn!(
1135 target: "forge_agent::userdata::plugins",
1136 path = %plugin_dir.display(),
1137 "cache plugin dir carries no version dir with a manifest; it lists nothing",
1138 );
1139 None
1140}
1141
1142fn version_sort_key(name: &str) -> Vec<(u8, u64, String)> {
1145 name.split(['.', '-', '_'])
1146 .map(|chunk| match chunk.parse::<u64>() {
1147 Ok(number) => (0_u8, number, String::new()),
1148 Err(_) => (1_u8, 0_u64, chunk.to_owned()),
1149 })
1150 .collect()
1151}
1152
1153fn marketplace_health(
1157 registry: &KnownMarketplaces,
1158 manifests: &Manifests,
1159 config_dir: &Path,
1160 registry_error: Option<&str>,
1161) -> Vec<MarketplaceHealth> {
1162 if let (Some(error), true) = (registry_error, registry.entries.is_empty()) {
1166 let names = manifests
1167 .loaded
1168 .iter()
1169 .chain(manifests.errors.keys())
1170 .collect::<std::collections::BTreeSet<_>>();
1171 return names
1172 .into_iter()
1173 .map(|name| MarketplaceHealth {
1174 name: name.clone(),
1175 load_error: Some((*error).to_owned()),
1176 ..MarketplaceHealth::default()
1177 })
1178 .collect();
1179 }
1180 registry
1181 .entries
1182 .iter()
1183 .map(|(name, entry)| {
1184 let install_location =
1185 entry.install_location.clone().map(PathBuf::from).unwrap_or_default();
1186 let drifted = !install_location.as_os_str().is_empty()
1187 && !install_location.starts_with(config_dir);
1188 let load_error = manifests.errors.get(name).cloned();
1189 let available = if load_error.is_some() {
1190 0
1191 } else {
1192 manifests
1193 .entries
1194 .keys()
1195 .filter(|(manifest_marketplace, _)| manifest_marketplace == name)
1196 .count()
1197 };
1198 MarketplaceHealth {
1199 name: name.clone(),
1200 source: entry
1201 .source
1202 .as_ref()
1203 .and_then(|source| source.get("source"))
1204 .and_then(serde_json::Value::as_str)
1205 .unwrap_or_default()
1206 .to_owned(),
1207 available,
1208 load_error: load_error.or_else(|| {
1209 (!manifests.loaded.contains(name)).then(|| {
1210 if install_location.as_os_str().is_empty() {
1211 "no marketplace clone on disk".to_owned()
1212 } else {
1213 "no marketplace.json found in the clone".to_owned()
1214 }
1215 })
1216 }),
1217 install_location,
1218 drifted,
1219 }
1220 })
1221 .collect()
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226 use super::*;
1227
1228 struct Fixture {
1254 root: tempfile::TempDir,
1255 plugins_root: PathBuf,
1256 marketplaces_root: PathBuf,
1257 config_dir: PathBuf,
1258 }
1259
1260 fn write(path: &Path, contents: &str) {
1261 if let Some(parent) = path.parent() {
1262 std::fs::create_dir_all(parent).expect("create parent");
1263 }
1264 std::fs::write(path, contents).expect("write fixture file");
1265 }
1266
1267 fn skill(path: &Path) {
1268 write(path, "# skill");
1269 }
1270
1271 fn fixture() -> Fixture {
1272 let root = tempfile::tempdir().expect("tempdir");
1273 let home = root.path().join("home");
1274 let config_dir = home.join(".claude");
1275 let plugins_root = config_dir.join("plugins");
1276 let marketplaces_root = plugins_root.join("marketplaces");
1277 let cache = plugins_root.join("cache");
1278 let mkt = marketplaces_root.join("probe-market");
1279
1280 let full = cache.join("probe-market").join("full").join("1.2.3");
1282 write(&full.join(".claude-plugin/plugin.json"), r#"{"name":"full","version":"1.2.3"}"#);
1283 skill(&full.join("skills/a/SKILL.md"));
1284 skill(&full.join("skills/b/SKILL.md"));
1285 write(&full.join("agents/x.md"), "# x");
1286 write(&full.join("agents/y.md"), "# y");
1287 write(&full.join("commands/c1.md"), "# c1");
1288 write(&full.join("commands/c2.md"), "# c2");
1289 write(&full.join("hooks/hooks.json"), r#"{"hooks":{"SessionStart":[],"PreToolUse":[]}}"#);
1290 write(&full.join(".mcp.json"), r#"{"mcpServers":{}}"#);
1291
1292 let off = cache.join("probe-market").join("off").join("3.0.0");
1294 write(&off.join(".claude-plugin/plugin.json"), r#"{"name":"off","version":"3.0.0"}"#);
1295 skill(&off.join("skills/one/SKILL.md"));
1296
1297 let gone = cache.join("probe-market").join("gone").join("1.0.0");
1300 write(&gone.join(".claude-plugin/plugin.json"), r#"{"name":"gone","version":"1.0.0"}"#);
1301 skill(&gone.join("skills/stale/SKILL.md"));
1302
1303 let broken = cache.join("probe-market").join("broken").join("9.9.9");
1305 skill(&broken.join("skills/a/SKILL.md"));
1306
1307 let rootonly = cache.join("probe-market").join("rootonly").join("9.9.9");
1310 write(&rootonly.join("plugin.json"), r#"{"name":"rootonly","version":"9.9.9"}"#);
1311 skill(&rootonly.join("skills/a/SKILL.md"));
1312
1313 let stray = cache.join("probe-market").join("stray").join("1.0.0");
1316 write(&stray.join(".claude-plugin/plugin.json"), r#"{"name":"stray","version":"1.0.0"}"#);
1317 skill(&stray.join("skills/a/SKILL.md"));
1318
1319 let multi = cache.join("probe-market").join("multi");
1323 write(
1324 &multi.join("1.0.0/.claude-plugin/plugin.json"),
1325 r#"{"name":"multi","version":"1.0.0"}"#,
1326 );
1327 skill(&multi.join("1.0.0/skills/old/SKILL.md"));
1328 write(
1329 &multi.join("2.0.0/.claude-plugin/plugin.json"),
1330 r#"{"name":"multi","version":"2.0.0"}"#,
1331 );
1332 skill(&multi.join("2.0.0/skills/new/SKILL.md"));
1333 skill(&multi.join("3.0.0/skills/bare/SKILL.md"));
1336
1337 let lsponly = cache.join("probe-market").join("lsponly").join("1.0.0");
1340 write(&lsponly.join("README.md"), "# lsp only");
1341
1342 let custom = cache.join("probe-market").join("custom").join("1.0.0");
1348 write(
1349 &custom.join(".claude-plugin/plugin.json"),
1350 r#"{"name":"custom","version":"1.0.0",
1351 "skills":"./meta/skills/",
1352 "agents":["./meta/agents/one.md","./meta/agents/two.md"],
1353 "commands":["./meta/commands/c.md"],
1354 "hooks":[],
1355 "mcpServers":{"db":{"command":"db"}}}"#,
1356 );
1357 skill(&custom.join("skills/conv/SKILL.md"));
1358 skill(&custom.join("meta/skills/conv/SKILL.md"));
1359 skill(&custom.join("meta/skills/meta-a/SKILL.md"));
1360 skill(&custom.join("meta/skills/meta-b/SKILL.md"));
1361 write(&custom.join("meta/agents/one.md"), "# one");
1362 write(&custom.join("meta/agents/two.md"), "# two");
1363 write(&custom.join("meta/commands/c.md"), "# c");
1364 write(&custom.join("agents/undeclared.md"), "# undeclared");
1365 write(&custom.join("commands/undeclared.md"), "# undeclared");
1366 write(&custom.join("hooks/hooks.json"), r#"{"hooks":{"SessionStart":[],"Stop":[]}}"#);
1367
1368 let hookpath = cache.join("probe-market").join("hookpath").join("1.0.0");
1370 write(
1371 &hookpath.join(".claude-plugin/plugin.json"),
1372 r#"{"name":"hookpath","version":"1.0.0","hooks":"./extra-hooks.json"}"#,
1373 );
1374 write(&hookpath.join("hooks/hooks.json"), r#"{"hooks":{"Default":[]}}"#);
1375 write(&hookpath.join("extra-hooks.json"), r#"{"hooks":{"Extra":[]}}"#);
1376
1377 let skillarray = cache.join("probe-market").join("skillarray").join("1.0.0");
1380 write(
1381 &skillarray.join(".claude-plugin/plugin.json"),
1382 r#"{"name":"skillarray","version":"1.0.0",
1383 "skills":["./meta/one-skill","./meta/skill-container"]}"#,
1384 );
1385 skill(&skillarray.join("meta/one-skill/SKILL.md"));
1386 skill(&skillarray.join("meta/skill-container/sub-a/SKILL.md"));
1387 skill(&skillarray.join("meta/skill-container/sub-b/SKILL.md"));
1388
1389 let mcppath = cache.join("probe-market").join("mcppath").join("1.0.0");
1392 write(
1393 &mcppath.join(".claude-plugin/plugin.json"),
1394 r#"{"name":"mcppath","version":"1.0.0","mcpServers":"./mcp.json"}"#,
1395 );
1396 write(&mcppath.join("mcp.json"), r#"{"mcpServers":{}}"#);
1397
1398 let tornmanifest = cache.join("probe-market").join("tornmanifest").join("1.0.0");
1401 write(&tornmanifest.join(".claude-plugin/plugin.json"), "{ torn");
1402 skill(&tornmanifest.join("skills/a/SKILL.md"));
1403
1404 let regcustom = cache.join("probe-market").join("regcustom").join("1.0.0");
1408 write(
1409 ®custom.join(".claude-plugin/plugin.json"),
1410 r#"{"name":"regcustom","version":"1.0.0","commands":"./meta/cmds/"}"#,
1411 );
1412 write(®custom.join("meta/cmds/declared.md"), "# declared");
1413 write(®custom.join("commands/undeclared.md"), "# undeclared");
1414
1415 let unreadable = cache.join("probe-market").join("unreadable").join("1.0.0");
1419 write(
1420 &unreadable.join(".claude-plugin/plugin.json"),
1421 r#"{"name":"unreadable","version":"1.0.0"}"#,
1422 );
1423 skill(&unreadable.join("skills/a/SKILL.md"));
1424
1425 let filed = plugins_root.join("stray-file.txt");
1427 write(&filed, "not a plugin dir");
1428
1429 let ghosted = cache.join("probe-market").join("ghosted").join("1.0.0");
1432
1433 write(
1434 &config_dir.join("settings.json"),
1435 r#"{"enabledPlugins":{"off@probe-market":false}}"#,
1436 );
1437 write(
1438 &plugins_root.join("installed_plugins.json"),
1439 r#"{"version":2,"plugins":{
1440 "full@probe-market":[{"scope":"user","installPath":"CACHE_FULL","version":"1.2.3","installedAt":"","lastUpdated":""}],
1441 "off@probe-market":[{"scope":"user","installPath":"CACHE_OFF","version":"3.0.0","installedAt":"","lastUpdated":"","auto":true}],
1442 "lsponly@probe-market":[{"scope":"user","installPath":"CACHE_LSPONLY","version":"1.0.0","installedAt":"","lastUpdated":""}],
1443 "regcustom@probe-market":[{"scope":"user","installPath":"CACHE_REGCUSTOM","version":"1.0.0","installedAt":"","lastUpdated":""}],
1444 "unreadable@probe-market":[{"scope":"user","installPath":"CACHE_UNREADABLE","version":"1.0.0","installedAt":"","lastUpdated":""}],
1445 "nopath@probe-market":[{"scope":"user","version":"1.0.0","installedAt":"","lastUpdated":""}],
1446 "filed@probe-market":[{"scope":"user","installPath":"CACHE_FILED","version":"1.0.0","installedAt":"","lastUpdated":""}],
1447 "ghosted@probe-market":[{"scope":"user","installPath":"CACHE_GHOSTED","version":"1.0.0","installedAt":"","lastUpdated":""}]
1448 }}"#
1449 .replace("CACHE_FULL", &full.to_string_lossy())
1450 .replace("CACHE_OFF", &off.to_string_lossy())
1451 .replace("CACHE_LSPONLY", &lsponly.to_string_lossy())
1452 .replace("CACHE_REGCUSTOM", ®custom.to_string_lossy())
1453 .replace("CACHE_UNREADABLE", &unreadable.to_string_lossy())
1454 .replace("CACHE_FILED", &filed.to_string_lossy())
1455 .replace("CACHE_GHOSTED", &ghosted.join("missing-dir").to_string_lossy())
1456 .as_str(),
1457 );
1458
1459 write(
1465 &mkt.join(".claude-plugin/marketplace.json"),
1466 r#"{"name":"probe-market","plugins":[
1467 {"name":"full","version":"2.0.0","source":"./full-src",
1468 "lspServers":{"rust-analyzer":{"command":"/opt/ra/current/bin/rust-analyzer"}}},
1469 {"name":"gone","version":"6.4.0","source":"./gone-src"},
1470 {"name":"lsponly","version":"1.0.0","source":"./lsponly-src",
1471 "lspServers":{"gopls":{"command":"/opt/go/current/bin/gopls"}}},
1472 {"name":"fileclone","version":"1.0.0","source":"./file-source"},
1473 {"name":"absentclone","version":"1.0.0","source":"./absent-src"}
1474 ]}"#,
1475 );
1476 write(&mkt.join("file-source"), "not a directory");
1477 skill(&mkt.join("full-src/skills/clone-skill/SKILL.md"));
1478 skill(&mkt.join("gone-src/skills/writing-skills/SKILL.md"));
1479 skill(&mkt.join("gone-src/skills/not-a-dir.md"));
1480 write(&mkt.join("gone-src/commands/do.md"), "# do");
1481
1482 write(
1487 &plugins_root.join("known_marketplaces.json"),
1488 format!(
1489 r#"{{"probe-market":{{"source":{{"source":"github","repo":"a/b"}},
1490 "installLocation":{:?},"lastUpdated":""}},
1491 "ghost":{{"source":{{"source":"github","repo":"c/d"}},
1492 "installLocation":{:?},"lastUpdated":""}}}}"#,
1493 mkt.to_string_lossy(),
1494 mkt.to_string_lossy()
1495 )
1496 .as_str(),
1497 );
1498
1499 Fixture { root, plugins_root, marketplaces_root, config_dir }
1500 }
1501
1502 #[derive(Clone, Default)]
1504 struct LogCapture(std::sync::Arc<parking_lot::Mutex<Vec<u8>>>);
1505
1506 impl std::io::Write for LogCapture {
1507 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1508 self.0.lock().extend_from_slice(buf);
1509 Ok(buf.len())
1510 }
1511 fn flush(&mut self) -> std::io::Result<()> {
1512 Ok(())
1513 }
1514 }
1515
1516 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture {
1517 type Writer = Self;
1518 fn make_writer(&'a self) -> Self::Writer {
1519 self.clone()
1520 }
1521 }
1522
1523 fn capture_logs(f: impl FnOnce()) -> String {
1524 let capture = LogCapture::default();
1525 let subscriber = tracing_subscriber::fmt().with_writer(capture.clone()).finish();
1526 tracing::subscriber::with_default(subscriber, f);
1527 let bytes = capture.0.lock().clone();
1528 String::from_utf8_lossy(&bytes).into_owned()
1529 }
1530
1531 fn by_plugin<'a>(rows: &'a [PluginComponents], id: &str) -> &'a PluginComponents {
1532 rows.iter().find(|row| row.plugin == id).unwrap_or_else(|| panic!("no row for {id}"))
1533 }
1534
1535 fn scan_fixture(fixture: &Fixture) -> Vec<PluginComponents> {
1536 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
1537 .components
1538 }
1539
1540 #[test]
1543 fn a_plugin_with_every_component_kind_scans_exact_lists() {
1544 let fixture = fixture();
1545 let rows =
1546 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
1547 .components;
1548
1549 let full = by_plugin(&rows, "full@probe-market");
1550 assert_eq!(full.skills, vec!["a", "b"]);
1551 assert_eq!(full.agents, vec!["x", "y"]);
1552 assert_eq!(full.commands, vec!["c1", "c2"]);
1553 assert_eq!(full.hooks, vec!["SessionStart", "PreToolUse"]);
1554 assert!(full.mcp, ".mcp.json present");
1555 assert_eq!(
1556 full.lsp_servers,
1557 BTreeMap::from([(
1558 "rust-analyzer".to_owned(),
1559 "/opt/ra/current/bin/rust-analyzer".to_owned(),
1560 )])
1561 );
1562 assert!(full.installed);
1563 assert!(full.enabled);
1564 assert!(!full.auto);
1565 assert_eq!(full.version.as_deref(), Some("1.2.3"));
1566 assert_eq!(full.available_version.as_deref(), Some("2.0.0"));
1567 assert_eq!(full.marketplace, "probe-market");
1568 }
1569
1570 #[test]
1574 fn a_cache_leftover_without_the_real_manifest_is_not_a_plugin() {
1575 let fixture = fixture();
1576 let mut rows = Vec::new();
1577 let log = capture_logs(|| rows = scan_fixture(&fixture));
1578 assert!(
1579 rows.iter().any(|row| row.plugin == "stray@probe-market"),
1580 "the cache-leftover branch must run for the exclusions below to mean anything: {rows:?}"
1581 );
1582 assert!(
1583 log.contains("carries no version dir with a manifest"),
1584 "a plugin dir that lists nothing says so rather than vanishing: {log}"
1585 );
1586 assert!(
1587 rows.iter().all(|row| !row.plugin.starts_with("broken")),
1588 "no row may exist for the manifest-less dir: {rows:?}"
1589 );
1590 assert!(
1591 rows.iter().all(|row| !row.plugin.starts_with("rootonly")),
1592 "the root-level plugin.json is not the install layout: {rows:?}"
1593 );
1594 }
1595
1596 #[test]
1599 fn a_vanished_install_path_surfaces_as_a_load_failed_row() {
1600 let fixture = fixture();
1601 let rows =
1602 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
1603 .components;
1604 let ghosted = rows
1605 .iter()
1606 .find(|row| row.plugin == "ghosted@probe-market")
1607 .expect("the registered-but-vanished plugin must still have a row: {rows:?}");
1608 assert!(ghosted.installed);
1609 assert_eq!(
1610 ghosted.load_error.as_deref(),
1611 Some("registered install dir is missing on disk"),
1612 "the vanished dir is the row's stated reason: {ghosted:?}"
1613 );
1614 assert!(ghosted.skills.is_empty());
1615 }
1616
1617 #[test]
1620 fn a_registered_install_without_a_manifest_is_installed_not_failed() {
1621 let fixture = fixture();
1622 let rows =
1623 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
1624 .components;
1625
1626 let lsponly = by_plugin(&rows, "lsponly@probe-market");
1627 assert!(
1628 lsponly.installed,
1629 "the registry branch must emit it, or it degrades to a catalog row: {lsponly:?}"
1630 );
1631 assert_eq!(
1632 lsponly.load_error, None,
1633 "no manifest of its own is not a load failure: {lsponly:?}"
1634 );
1635 assert_eq!(lsponly.skills, Vec::<String>::new(), "it ships no components");
1636 assert_eq!(
1637 lsponly.lsp_servers,
1638 BTreeMap::from([("gopls".to_owned(), "/opt/go/current/bin/gopls".to_owned())]),
1639 "its LSP servers come from the marketplace manifest: {lsponly:?}"
1640 );
1641 }
1642
1643 #[test]
1646 #[cfg(unix)]
1647 fn an_unreadable_install_dir_surfaces_as_a_load_failed_row() {
1648 use std::os::unix::fs::PermissionsExt;
1649 let fixture = fixture();
1650 let dir = fixture.plugins_root.join("cache/probe-market/unreadable/1.0.0");
1651 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o000))
1652 .expect("chmod the install dir away");
1653 assert!(
1654 std::fs::read_dir(&dir).is_err(),
1655 "this test needs a runner where mode 000 denies reads; root bypasses the permission model"
1656 );
1657
1658 let mut rows = Vec::new();
1659 let log = capture_logs(|| rows = scan_fixture(&fixture));
1660 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755))
1661 .expect("restore for tempdir cleanup");
1662
1663 let unreadable = by_plugin(&rows, "unreadable@probe-market");
1664 assert_eq!(
1665 unreadable.load_error.as_deref(),
1666 Some("registered install dir cannot be read"),
1667 "the EACCES dir states the read failure, not the missing one: {unreadable:?}"
1668 );
1669 assert!(unreadable.skills.is_empty(), "nothing may be read out of it: {unreadable:?}");
1670
1671 let dir_text = dir.to_string_lossy().into_owned();
1672 assert!(log.contains("unreadable@probe-market"), "the warn names the plugin: {log}");
1673 assert!(log.contains(&dir_text), "the warn carries the path: {log}");
1674 assert!(log.contains("Permission denied"), "the warn carries the io error: {log}");
1675 }
1676
1677 #[test]
1681 fn a_registry_entry_without_an_install_path_surfaces_as_a_load_failed_row() {
1682 let fixture = fixture();
1683 let mut rows = Vec::new();
1684 let log = capture_logs(|| rows = scan_fixture(&fixture));
1685
1686 let nopath = by_plugin(&rows, "nopath@probe-market");
1687 assert!(nopath.installed, "a failed registry install is still an install: {nopath:?}");
1688 assert_eq!(
1689 nopath.load_error.as_deref(),
1690 Some("registered install dir is missing on disk"),
1691 "an absent installPath reads as no dir on disk: {nopath:?}"
1692 );
1693 assert!(nopath.skills.is_empty(), "nothing may be read out of it: {nopath:?}");
1694 assert!(log.contains("nopath@probe-market"), "the warn names the entry: {log}");
1695 assert!(
1696 log.contains("registered plugin entry carries no installPath"),
1697 "the warn says the entry records no path: {log}"
1698 );
1699 }
1700
1701 #[test]
1704 fn an_install_path_pointing_at_a_file_surfaces_as_a_load_failed_row() {
1705 let fixture = fixture();
1706 let rows =
1707 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
1708 .components;
1709
1710 let filed = by_plugin(&rows, "filed@probe-market");
1711 assert!(filed.installed, "a failed registry install is still an install: {filed:?}");
1712 assert_eq!(
1713 filed.load_error.as_deref(),
1714 Some("registered install dir cannot be read"),
1715 "an installPath that is a file states the read failure: {filed:?}"
1716 );
1717 assert!(filed.skills.is_empty(), "nothing may be read out of it: {filed:?}");
1718 }
1719
1720 #[test]
1724 #[cfg(unix)]
1725 fn an_unreadable_newest_version_dir_stops_the_fall_through() {
1726 use std::os::unix::fs::PermissionsExt;
1727 let fixture = fixture();
1728 let newest = fixture.plugins_root.join("cache/probe-market/multi/2.0.0");
1729
1730 let readable = scan_fixture(&fixture);
1733 let multi = by_plugin(&readable, "multi@probe-market");
1734 assert_eq!(multi.skills, vec!["new"], "the newest version wins: {multi:?}");
1735
1736 std::fs::set_permissions(&newest, std::fs::Permissions::from_mode(0o000))
1737 .expect("chmod the newest version away");
1738 assert!(
1739 std::fs::read_dir(&newest).is_err(),
1740 "this test needs a runner where mode 000 denies reads; root bypasses the permission model"
1741 );
1742 let mut rows = Vec::new();
1743 let log = capture_logs(|| rows = scan_fixture(&fixture));
1744 std::fs::set_permissions(&newest, std::fs::Permissions::from_mode(0o755))
1745 .expect("restore for tempdir cleanup");
1746
1747 assert!(
1748 rows.iter().all(|row| row.plugin != "multi@probe-market"),
1749 "no inventory is shown rather than the older version's: {rows:?}"
1750 );
1751 let version_text = newest.to_string_lossy().into_owned();
1752 assert!(
1753 log.contains("cache plugin version dir cannot be read"),
1754 "the warn says the version dir could not be read: {log}"
1755 );
1756 assert!(log.contains(&version_text), "the warn names the version dir: {log}");
1757 let plugin_dir_text =
1758 fixture.plugins_root.join("cache/probe-market/multi").to_string_lossy().into_owned();
1759 assert!(
1760 !log.lines().any(|line| line.contains("carries no version dir with a manifest")
1761 && line.contains(&plugin_dir_text)),
1762 "a read failure on this plugin is not reported as an absent version dir: {log}"
1763 );
1764 }
1765
1766 #[test]
1770 fn an_unreadable_clone_source_is_warned_and_contributes_nothing() {
1771 let fixture = fixture();
1772 let mut rows = Vec::new();
1773 let log = capture_logs(|| rows = scan_fixture(&fixture));
1774
1775 let fileclone = by_plugin(&rows, "fileclone@probe-market");
1776 assert!(!fileclone.installed, "it is a catalog entry: {fileclone:?}");
1777 assert!(fileclone.skills.is_empty(), "nothing may be read out of it: {fileclone:?}");
1778
1779 let source_text = fixture
1782 .marketplaces_root
1783 .join("probe-market")
1784 .join("./file-source")
1785 .to_string_lossy()
1786 .into_owned();
1787 assert!(log.contains("fileclone@probe-market"), "the warn names the plugin: {log}");
1788 assert!(
1789 log.lines()
1790 .any(|line| line.contains("fileclone@probe-market") && line.contains(&source_text)),
1791 "the clone warn carries the path: {log}"
1792 );
1793 assert!(
1794 log.lines()
1795 .any(|line| line.contains("fileclone@probe-market")
1796 && line.contains("Not a directory")),
1797 "the clone warn carries the io error: {log}"
1798 );
1799 }
1800
1801 #[test]
1805 fn a_missing_clone_source_stays_quiet() {
1806 let fixture = fixture();
1807 let mut rows = Vec::new();
1808 let log = capture_logs(|| rows = scan_fixture(&fixture));
1809
1810 let absent = by_plugin(&rows, "absentclone@probe-market");
1811 assert!(!absent.installed, "it is a catalog entry: {absent:?}");
1812 assert!(absent.skills.is_empty(), "nothing may be read out of it: {absent:?}");
1813 assert!(
1814 !log.contains("absentclone"),
1815 "a missing clone source is not worth a log line: {log}"
1816 );
1817 }
1818
1819 #[test]
1823 #[cfg(unix)]
1824 fn an_unreadable_registry_warns_and_leaves_only_catalog_rows() {
1825 use std::os::unix::fs::PermissionsExt;
1826 let fixture = fixture();
1827 let path = fixture.plugins_root.join("installed_plugins.json");
1828 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))
1829 .expect("chmod the registry away");
1830 assert!(
1831 std::fs::read_to_string(&path).is_err(),
1832 "this test needs a runner where mode 000 denies reads; root bypasses the permission model"
1833 );
1834 let mut rows = Vec::new();
1835 let log = capture_logs(|| rows = scan_fixture(&fixture));
1836 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
1837 .expect("restore for tempdir cleanup");
1838
1839 assert!(
1840 rows.iter().all(|row| !row.installed),
1841 "no registry means no installed rows, only the catalog: {rows:?}"
1842 );
1843 assert!(
1844 rows.iter().any(|row| row.plugin == "stray@probe-market"),
1845 "the catalog still renders, so the assertion above is not vacuous: {rows:?}"
1846 );
1847 assert!(
1848 log.contains("installed_plugins.json cannot be read"),
1849 "the warn says what happened: {log}"
1850 );
1851 assert!(
1852 log.contains(&path.to_string_lossy().into_owned()),
1853 "the warn carries the path: {log}"
1854 );
1855 assert!(log.contains("Permission denied"), "the warn carries the io error: {log}");
1856 }
1857
1858 #[test]
1862 #[cfg(unix)]
1863 fn an_unreadable_marketplaces_root_warns_and_reads_as_a_cache_miss() {
1864 use std::os::unix::fs::PermissionsExt;
1865 let fixture = fixture();
1866 let root = &fixture.marketplaces_root;
1867 std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o000))
1868 .expect("chmod the marketplaces root away");
1869 assert!(
1870 std::fs::read_dir(root).is_err(),
1871 "this test needs a runner where mode 000 denies reads; root bypasses the permission model"
1872 );
1873 let mut scan = ExtensionScan::default();
1874 let log = capture_logs(|| {
1875 scan = scan_extensions(&fixture.plugins_root, root, &fixture.config_dir);
1876 });
1877 std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o755))
1878 .expect("restore for tempdir cleanup");
1879
1880 assert!(
1881 log.contains("marketplaces root cannot be read"),
1882 "the warn says what happened: {log}"
1883 );
1884 let root_text = root.to_string_lossy().into_owned();
1885 assert!(
1886 log.lines().any(|line| line.contains("marketplaces root cannot be read")
1887 && line.contains(&root_text)),
1888 "the root warn carries the path: {log}"
1889 );
1890 assert!(
1891 log.lines().any(|line| line.contains("marketplaces root cannot be read")
1892 && line.contains("Permission denied")),
1893 "the root warn carries the io error: {log}"
1894 );
1895
1896 let probe = scan
1897 .marketplace_health
1898 .iter()
1899 .find(|row| row.name == "probe-market")
1900 .expect("probe row");
1901 assert_eq!(
1902 probe.load_error.as_deref(),
1903 Some("no marketplace.json found in the clone"),
1904 "the intact clone reads as a cache-miss, which is what the warn is for: {probe:?}"
1905 );
1906 }
1907
1908 #[test]
1912 #[cfg(unix)]
1913 fn an_unreadable_cache_warns_and_shows_no_cache_rows() {
1914 use std::os::unix::fs::PermissionsExt;
1915 let fixture = fixture();
1916 let cache = fixture.plugins_root.join("cache");
1917
1918 let readable = scan_fixture(&fixture);
1921 assert!(
1922 readable.iter().any(|row| row.plugin == "stray@probe-market"),
1923 "the cache walk emits rows while the cache is readable: {readable:?}"
1924 );
1925
1926 std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o000))
1927 .expect("chmod the cache away");
1928 assert!(
1929 std::fs::read_dir(&cache).is_err(),
1930 "this test needs a runner where mode 000 denies reads; root bypasses the permission model"
1931 );
1932 let mut rows = Vec::new();
1933 let log = capture_logs(|| rows = scan_fixture(&fixture));
1934 std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o755))
1935 .expect("restore for tempdir cleanup");
1936
1937 assert!(
1938 rows.iter().all(|row| row.plugin != "stray@probe-market"),
1939 "no cache-leftover row is read without the cache: {rows:?}"
1940 );
1941 let full = by_plugin(&rows, "full@probe-market");
1944 assert_eq!(
1945 full.load_error.as_deref(),
1946 Some("registered install dir cannot be read"),
1947 "the registry loop still runs with the cache unreadable: {full:?}"
1948 );
1949 let cache_text = cache.to_string_lossy().into_owned();
1950 assert!(log.contains("plugin cache cannot be read"), "the warn says what happened: {log}");
1951 assert!(
1952 log.lines()
1953 .any(|line| line.contains("plugin cache cannot be read")
1954 && line.contains(&cache_text)),
1955 "the cache warn carries the path: {log}"
1956 );
1957 assert!(
1958 log.lines().any(|line| line.contains("plugin cache cannot be read")
1959 && line.contains("Permission denied")),
1960 "the cache warn carries the io error: {log}"
1961 );
1962 }
1963
1964 #[test]
1968 #[cfg(unix)]
1969 fn an_unreadable_marketplace_cache_dir_warns() {
1970 use std::os::unix::fs::PermissionsExt;
1971 let fixture = fixture();
1972 let dir = fixture.plugins_root.join("cache/probe-market");
1973 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o000))
1974 .expect("chmod the marketplace cache dir away");
1975 assert!(
1976 std::fs::read_dir(&dir).is_err(),
1977 "this test needs a runner where mode 000 denies reads; root bypasses the permission model"
1978 );
1979 let mut rows = Vec::new();
1980 let log = capture_logs(|| rows = scan_fixture(&fixture));
1981 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755))
1982 .expect("restore for tempdir cleanup");
1983
1984 assert!(
1985 rows.iter().all(|row| row.plugin != "stray@probe-market"),
1986 "no leftover under it is read: {rows:?}"
1987 );
1988 let dir_text = dir.to_string_lossy().into_owned();
1989 assert!(
1990 log.contains("marketplace cache dir cannot be read"),
1991 "the unreadable marketplace dir is warned: {log}"
1992 );
1993 assert!(
1994 log.lines().any(|line| line.contains("marketplace cache dir cannot be read")
1995 && line.contains(&dir_text)),
1996 "the warn names that dir: {log}"
1997 );
1998 }
1999
2000 #[test]
2003 fn a_missing_cache_is_silent() {
2004 let fixture = fixture();
2005 std::fs::remove_dir_all(fixture.plugins_root.join("cache")).expect("remove the cache");
2006 let mut rows = Vec::new();
2007 let log = capture_logs(|| rows = scan_fixture(&fixture));
2008
2009 assert!(
2010 !log.contains("plugin cache cannot be read"),
2011 "a fresh install is not worth a log line: {log}"
2012 );
2013 assert!(
2014 rows.iter().any(|row| row.plugin == "gone@probe-market"),
2015 "the manifest half still runs, so the silence is not vacuous: {rows:?}"
2016 );
2017 }
2018
2019 #[test]
2023 #[cfg(unix)]
2024 fn a_version_dir_with_an_unreadable_manifest_dir_stops_the_fall_through() {
2025 use std::os::unix::fs::PermissionsExt;
2026 let fixture = fixture();
2027 let hidden = fixture.plugins_root.join("cache/probe-market/multi/2.0.0/.claude-plugin");
2028 std::fs::set_permissions(&hidden, std::fs::Permissions::from_mode(0o000))
2029 .expect("chmod the manifest dir away");
2030 assert!(
2031 std::fs::metadata(hidden.join("plugin.json")).is_err(),
2032 "this test needs a runner where mode 000 denies a stat; root bypasses the permission model"
2033 );
2034 let mut rows = Vec::new();
2035 let log = capture_logs(|| rows = scan_fixture(&fixture));
2036 std::fs::set_permissions(&hidden, std::fs::Permissions::from_mode(0o755))
2037 .expect("restore for tempdir cleanup");
2038
2039 assert!(
2040 rows.iter().all(|row| row.plugin != "multi@probe-market"),
2041 "no inventory is shown rather than the older version's: {rows:?}"
2042 );
2043 assert!(
2044 log.contains("cache plugin version dir cannot be read"),
2045 "the read failure is warned rather than read as an absent manifest: {log}"
2046 );
2047 }
2048
2049 #[test]
2052 fn a_manifest_less_newest_version_falls_through() {
2053 let fixture = fixture();
2054 let rows = scan_fixture(&fixture);
2055 let multi = by_plugin(&rows, "multi@probe-market");
2056 assert_eq!(
2057 multi.skills,
2058 vec!["new"],
2059 "manifest-less 3.0.0 is skipped for the 2.0.0 below it: {multi:?}"
2060 );
2061 }
2062
2063 #[test]
2066 #[cfg(unix)]
2067 fn an_unreadable_cache_plugin_dir_warns_and_shows_no_row() {
2068 use std::os::unix::fs::PermissionsExt;
2069 let fixture = fixture();
2070 let plugin_dir = fixture.plugins_root.join("cache/probe-market/multi");
2071 std::fs::set_permissions(&plugin_dir, std::fs::Permissions::from_mode(0o000))
2072 .expect("chmod the plugin dir away");
2073 assert!(
2074 std::fs::read_dir(&plugin_dir).is_err(),
2075 "this test needs a runner where mode 000 denies reads; root bypasses the permission model"
2076 );
2077 let mut rows = Vec::new();
2078 let log = capture_logs(|| rows = scan_fixture(&fixture));
2079 std::fs::set_permissions(&plugin_dir, std::fs::Permissions::from_mode(0o755))
2080 .expect("restore for tempdir cleanup");
2081
2082 assert!(
2083 rows.iter().all(|row| row.plugin != "multi@probe-market"),
2084 "no inventory is shown when the plugin dir cannot be read: {rows:?}"
2085 );
2086 assert!(
2087 log.contains("cache plugin dir cannot be read"),
2088 "the warn says what happened: {log}"
2089 );
2090 assert!(
2091 log.contains(&plugin_dir.to_string_lossy().into_owned()),
2092 "the warn names the plugin dir: {log}"
2093 );
2094 assert!(log.contains("Permission denied"), "the warn carries the io error: {log}");
2095 }
2096
2097 #[test]
2100 fn a_missing_registry_is_a_silent_fresh_install() {
2101 let fixture = fixture();
2102 std::fs::remove_file(fixture.plugins_root.join("installed_plugins.json"))
2103 .expect("remove the registry");
2104 let mut rows = Vec::new();
2105 let log = capture_logs(|| rows = scan_fixture(&fixture));
2106
2107 assert!(
2108 rows.iter().all(|row| !row.installed),
2109 "no registry means no installed rows: {rows:?}"
2110 );
2111 assert!(
2112 rows.iter().any(|row| row.plugin == "stray@probe-market"),
2113 "the catalog still renders, so the assertion above is not vacuous: {rows:?}"
2114 );
2115 assert!(
2116 !log.contains("installed_plugins.json"),
2117 "a fresh install is not worth a log line: {log}"
2118 );
2119 }
2120
2121 #[test]
2124 fn an_unparseable_registry_warns_and_leaves_the_scan_intact() {
2125 let fixture = fixture();
2126 let path = fixture.plugins_root.join("installed_plugins.json");
2127 std::fs::write(&path, "{ torn").expect("break the registry");
2128 let mut rows = Vec::new();
2129 let log = capture_logs(|| rows = scan_fixture(&fixture));
2130
2131 assert!(
2132 log.contains("installed_plugins.json exists but does not parse"),
2133 "the warn says what happened: {log}"
2134 );
2135 assert!(
2136 log.contains(&path.to_string_lossy().into_owned()),
2137 "the warn carries the path: {log}"
2138 );
2139 assert!(
2140 rows.iter().any(|row| row.plugin == "full@probe-market"),
2141 "the manifest half of the scan still runs: {rows:?}"
2142 );
2143 }
2144
2145 #[test]
2148 fn a_missing_marketplaces_root_is_silent() {
2149 let fixture = fixture();
2150 std::fs::remove_dir_all(&fixture.marketplaces_root).expect("remove the clones root");
2151 let mut scan = ExtensionScan::default();
2152 let log = capture_logs(|| {
2153 scan = scan_extensions(
2154 &fixture.plugins_root,
2155 &fixture.marketplaces_root,
2156 &fixture.config_dir,
2157 );
2158 });
2159
2160 assert!(
2161 !log.contains("marketplaces root"),
2162 "a fresh install is not worth a log line: {log}"
2163 );
2164 let probe = scan
2165 .marketplace_health
2166 .iter()
2167 .find(|row| row.name == "probe-market")
2168 .expect("the configured marketplace still lists");
2169 assert_eq!(
2170 probe.load_error.as_deref(),
2171 Some("no marketplace.json found in the clone"),
2172 "with no root every clone reads as a cache-miss: {probe:?}"
2173 );
2174 }
2175
2176 #[test]
2180 fn a_two_digit_version_dir_outranks_a_nine() {
2181 let fixture = fixture();
2182 let orphan_cache = fixture.plugins_root.join("cache/probe-market/orphan");
2185 let old_dir = orphan_cache.join("9.9.9");
2186 let new_dir = orphan_cache.join("10.0.0");
2187 skill(&old_dir.join("skills/nine/SKILL.md"));
2188 write(
2189 &old_dir.join(".claude-plugin/plugin.json"),
2190 r#"{"name":"orphan","version":"9.9.9"}"#,
2191 );
2192 skill(&new_dir.join("skills/ten/SKILL.md"));
2193 write(
2194 &new_dir.join(".claude-plugin/plugin.json"),
2195 r#"{"name":"orphan","version":"10.0.0"}"#,
2196 );
2197
2198 let rows =
2199 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
2200 .components;
2201 let orphan =
2202 rows.iter().find(|row| row.plugin == "orphan@probe-market").expect("orphan row");
2203 assert_eq!(
2204 orphan.skills,
2205 vec!["ten"],
2206 "the newest (10.0.0) dir's components win over 9.9.9: {orphan:?}"
2207 );
2208 }
2209
2210 #[test]
2211 fn a_skill_file_is_not_a_skill() {
2212 let fixture = fixture();
2213 let rows =
2214 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
2215 .components;
2216 let gone = by_plugin(&rows, "gone@probe-market");
2217 assert_eq!(gone.skills, vec!["writing-skills"], "file-not-dir excluded; clone scanned");
2218 }
2219
2220 #[test]
2221 fn an_uninstalled_plugin_scans_from_its_marketplace_clone() {
2222 let fixture = fixture();
2223 let rows =
2224 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
2225 .components;
2226 let gone = by_plugin(&rows, "gone@probe-market");
2227 assert!(!gone.installed);
2228 assert!(!gone.enabled);
2229 assert_eq!(gone.version, None);
2230 assert_eq!(gone.available_version.as_deref(), Some("6.4.0"));
2231 assert_eq!(gone.commands, vec!["do"]);
2232 assert!(!gone.mcp);
2233 }
2234
2235 #[test]
2236 fn a_disabled_auto_installed_plugin_carries_both_markers() {
2237 let fixture = fixture();
2238 let rows =
2239 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
2240 .components;
2241 let off = by_plugin(&rows, "off@probe-market");
2242 assert!(off.installed);
2243 assert!(!off.enabled, "disabled in enabledPlugins");
2244 assert!(off.auto, "auto marker comes from the registry file");
2245 assert_eq!(off.skills, vec!["one"]);
2246 assert_eq!(off.available_version, None, "manifest names no off entry");
2247 }
2248
2249 #[test]
2250 fn components_come_from_the_installed_copy_not_the_clone() {
2251 let fixture = fixture();
2252 let rows =
2253 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
2254 .components;
2255 let full = by_plugin(&rows, "full@probe-market");
2256 assert_eq!(
2257 full.skills,
2258 vec!["a", "b"],
2259 "the clone's clone-skill must not leak into the installed inventory"
2260 );
2261 }
2262
2263 #[test]
2267 fn a_declared_skills_container_joins_the_conventional_dir_deduped() {
2268 let fixture = fixture();
2269 let rows = scan_fixture(&fixture);
2270 let custom = by_plugin(&rows, "custom@probe-market");
2271 assert_eq!(
2272 custom.skills,
2273 vec!["conv", "meta-a", "meta-b"],
2274 "the shared skill renders once, not once per side: {custom:?}"
2275 );
2276 }
2277
2278 #[test]
2281 fn declared_agents_and_commands_replace_the_conventional_dirs() {
2282 let fixture = fixture();
2283 let rows = scan_fixture(&fixture);
2284 let custom = by_plugin(&rows, "custom@probe-market");
2285 assert_eq!(custom.agents, vec!["one", "two"], "only the declared agents list: {custom:?}");
2286 assert_eq!(custom.commands, vec!["c"], "only the declared commands list: {custom:?}");
2287 }
2288
2289 #[test]
2293 fn an_empty_hooks_declaration_leaves_the_default_triggers_listed() {
2294 let fixture = fixture();
2295 let rows = scan_fixture(&fixture);
2296 let custom = by_plugin(&rows, "custom@probe-market");
2297 assert_eq!(
2298 custom.hooks,
2299 vec!["SessionStart", "Stop"],
2300 "the default hooks.json still lists despite hooks: []: {custom:?}"
2301 );
2302 }
2303
2304 #[test]
2307 fn an_inline_mcp_declaration_sets_the_flag() {
2308 let fixture = fixture();
2309 let rows = scan_fixture(&fixture);
2310 let custom = by_plugin(&rows, "custom@probe-market");
2311 assert!(custom.mcp, "inline mcpServers is MCP present: {custom:?}");
2312 }
2313
2314 #[test]
2317 fn a_declared_mcp_path_sets_the_flag() {
2318 let fixture = fixture();
2319 let rows = scan_fixture(&fixture);
2320 let mcppath = by_plugin(&rows, "mcppath@probe-market");
2321 assert!(mcppath.mcp, "the declared MCP file is present: {mcppath:?}");
2322 }
2323
2324 #[test]
2326 fn a_declared_hooks_path_merges_with_the_default() {
2327 let fixture = fixture();
2328 let rows = scan_fixture(&fixture);
2329 let hookpath = by_plugin(&rows, "hookpath@probe-market");
2330 assert_eq!(
2331 hookpath.hooks,
2332 vec!["Default", "Extra"],
2333 "both files' triggers list: {hookpath:?}"
2334 );
2335 }
2336
2337 #[test]
2341 fn a_skills_array_element_is_one_skill_or_a_container() {
2342 let fixture = fixture();
2343 let rows = scan_fixture(&fixture);
2344 let skillarray = by_plugin(&rows, "skillarray@probe-market");
2345 assert_eq!(
2346 skillarray.skills,
2347 vec!["one-skill", "sub-a", "sub-b"],
2348 "the SKILL.md element counts once, the container's children each: {skillarray:?}"
2349 );
2350 }
2351
2352 #[test]
2355 fn an_unparseable_plugin_manifest_falls_back_to_the_conventional_scan() {
2356 let fixture = fixture();
2357 let mut rows = Vec::new();
2358 let log = capture_logs(|| rows = scan_fixture(&fixture));
2359 let torn = by_plugin(&rows, "tornmanifest@probe-market");
2360 assert_eq!(
2361 torn.skills,
2362 vec!["a"],
2363 "the conventional skills/ scan stands without a readable manifest: {torn:?}"
2364 );
2365 assert!(
2366 log.contains("does not parse"),
2367 "the torn manifest is warned, not swallowed: {log}"
2368 );
2369 }
2370
2371 #[test]
2374 fn a_registered_string_dir_declaration_shadows_the_conventional_dir() {
2375 let fixture = fixture();
2376 let rows = scan_fixture(&fixture);
2377 let regcustom = by_plugin(&rows, "regcustom@probe-market");
2378 assert_eq!(
2379 regcustom.commands,
2380 vec!["declared"],
2381 "the declared commands dir replaces the conventional one: {regcustom:?}"
2382 );
2383 }
2384
2385 #[test]
2388 fn a_marketplace_inside_the_config_dir_is_healthy() {
2389 let fixture = fixture();
2390 let rows =
2391 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
2392 .marketplace_health;
2393 let probe = rows.iter().find(|row| row.name == "probe-market").expect("probe row");
2394 assert_eq!(probe.available, 5);
2395 assert_eq!(probe.source, "github");
2396 assert_eq!(probe.load_error, None);
2397 assert!(!probe.drifted);
2398 assert_eq!(probe.install_location, fixture.marketplaces_root.join("probe-market"));
2399 }
2400
2401 #[test]
2402 fn an_install_location_outside_the_config_dir_is_drifted() {
2403 let fixture = fixture();
2404 let foreign = fixture.root.path().join("elsewhere").join(".claude");
2406 let rows = scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &foreign)
2407 .marketplace_health;
2408 let probe = rows.iter().find(|row| row.name == "probe-market").expect("probe row");
2409 assert!(probe.drifted, "{probe:?}");
2410 }
2411
2412 #[test]
2413 fn a_marketplace_missing_its_manifest_is_a_load_error() {
2414 let fixture = fixture();
2415 let rows =
2416 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir)
2417 .marketplace_health;
2418 let ghost = rows.iter().find(|row| row.name == "ghost").expect("ghost row");
2419 assert_eq!(ghost.available, 0);
2420 assert!(ghost.load_error.is_some(), "cache-miss reads as a load error: {ghost:?}");
2421 }
2422
2423 #[test]
2427 fn a_syntactically_broken_manifest_reads_as_a_parse_failure() {
2428 let fixture = fixture();
2429 let manifest =
2430 fixture.marketplaces_root.join("probe-market/.claude-plugin/marketplace.json");
2431 std::fs::write(&manifest, "{ not json").expect("break manifest");
2432 let scan =
2433 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir);
2434 let probe = scan
2435 .marketplace_health
2436 .iter()
2437 .find(|row| row.name == "probe-market")
2438 .expect("probe row");
2439 let error = probe.load_error.as_deref().expect("the parse error surfaces");
2440 assert!(
2441 error.contains("failed to parse"),
2442 "a broken manifest names the parse failure: {error}"
2443 );
2444 assert!(
2445 !error.contains("no marketplace.json found"),
2446 "a present manifest must not read as a cache-miss: {error}"
2447 );
2448
2449 let marketplace_row = scan
2452 .components
2453 .iter()
2454 .find(|row| row.plugin == "probe-market")
2455 .expect("the failed marketplace's row is keyed by its name");
2456 assert!(
2457 !marketplace_row.installed,
2458 "a marketplace that cannot load is not an install: {marketplace_row:?}"
2459 );
2460 assert!(
2461 marketplace_row.load_error.as_deref().unwrap_or_default().contains("failed to parse"),
2462 "the row names the parse failure: {marketplace_row:?}"
2463 );
2464 }
2465
2466 #[test]
2472 fn object_shaped_plugin_sources_parse_and_stay_available() {
2473 let fixture = fixture();
2474 write(
2475 &fixture.marketplaces_root.join("official/.claude-plugin/marketplace.json"),
2476 r#"{
2477 "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
2478 "name": "official",
2479 "owner": {"name": "Anthropic"},
2480 "renames": {"old-name": "renamed-plugin"},
2481 "plugins": [
2482 {
2483 "name": "subdir-plugin",
2484 "source": {
2485 "source": "git-subdir",
2486 "url": "https://github.com/example/plugins",
2487 "path": "plugins/subdir-plugin",
2488 "ref": "v1.5.5"
2489 }
2490 },
2491 {
2492 "name": "url-plugin",
2493 "source": {
2494 "source": "url",
2495 "url": "https://github.com/example/single-plugin"
2496 }
2497 },
2498 {"name": "relative-plugin", "source": "./plugins/relative-plugin"},
2499 {"name": "bare-string-plugin", "source": "plugins/bare-string"},
2500 {"name": "renamed-plugin", "version": "2.0.0"}
2501 ]
2502 }"#,
2503 );
2504 skill(
2505 &fixture.marketplaces_root.join("official/plugins/relative-plugin/skills/one/SKILL.md"),
2506 );
2507 skill(&fixture.marketplaces_root.join("official/plugins/bare-string/skills/leak/SKILL.md"));
2510 write(
2511 &fixture.plugins_root.join("known_marketplaces.json"),
2512 format!(
2513 r#"{{"official":{{"source":{{"source":"github","repo":"anthropics/claude-plugins-official"}},
2514 "installLocation":{:?},"lastUpdated":""}}}}"#,
2515 fixture.marketplaces_root.join("official").to_string_lossy()
2516 )
2517 .as_str(),
2518 );
2519
2520 let scan =
2521 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir);
2522
2523 let health =
2524 scan.marketplace_health.iter().find(|row| row.name == "official").expect("health row");
2525 assert_eq!(health.available, 5, "every manifest plugin counts: {health:?}");
2526 assert_eq!(
2527 health.load_error, None,
2528 "object sources must not fail the manifest parse: {health:?}"
2529 );
2530
2531 let subdir = scan
2532 .components
2533 .iter()
2534 .find(|row| row.plugin == "subdir-plugin@official")
2535 .expect("the git-subdir plugin lists");
2536 assert!(!subdir.installed);
2537 assert_eq!(
2538 subdir.skills,
2539 Vec::<String>::new(),
2540 "a remote source carries no local components: {subdir:?}"
2541 );
2542
2543 let relative = scan
2544 .components
2545 .iter()
2546 .find(|row| row.plugin == "relative-plugin@official")
2547 .expect("the relative plugin lists");
2548 assert_eq!(
2549 relative.skills,
2550 vec!["one"],
2551 "a relative source still resolves inside the clone: {relative:?}"
2552 );
2553
2554 let bare = scan
2555 .components
2556 .iter()
2557 .find(|row| row.plugin == "bare-string-plugin@official")
2558 .expect("the bare-string plugin lists");
2559 assert_eq!(
2560 bare.skills,
2561 Vec::<String>::new(),
2562 "a string source without ./ is not clone-relative - the guard keeps its dir unscanned: {bare:?}"
2563 );
2564 }
2565
2566 #[test]
2569 fn a_corrupt_marketplace_registry_names_the_parse_error_on_the_clone_rows() {
2570 let fixture = fixture();
2571 write(&fixture.plugins_root.join("known_marketplaces.json"), "{ torn write");
2572 let scan =
2573 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir);
2574 let probe =
2575 scan.marketplace_health.iter().find(|row| row.name == "probe-market").expect("row");
2576 let error = probe.load_error.as_deref().expect("the registry error surfaces");
2577 assert!(
2578 error.contains("known_marketplaces.json failed to parse"),
2579 "the registry error, not a cache-miss: {error}"
2580 );
2581 assert!(
2582 !scan.marketplace_sources.is_empty(),
2583 "the configured sources still list by clone dir: {:?}",
2584 scan.marketplace_sources
2585 );
2586 }
2587
2588 #[test]
2591 #[cfg(unix)]
2592 fn an_unreadable_marketplace_registry_also_carries_the_error() {
2593 use std::os::unix::fs::PermissionsExt;
2594 let fixture = fixture();
2595 let path = fixture.plugins_root.join("known_marketplaces.json");
2596 std::fs::write(&path, "{ fine").expect("write registry");
2597 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))
2598 .expect("chmod away");
2599 let scan =
2600 scan_extensions(&fixture.plugins_root, &fixture.marketplaces_root, &fixture.config_dir);
2601 let probe =
2602 scan.marketplace_health.iter().find(|row| row.name == "probe-market").expect("row");
2603 let error = probe.load_error.as_deref().expect("the read error surfaces");
2604 assert!(
2605 error.contains("known_marketplaces.json cannot be read"),
2606 "the read class, not absence and not parse: {error}"
2607 );
2608 assert!(
2609 !scan.marketplace_sources.is_empty(),
2610 "the clone rows still list: {:?}",
2611 scan.marketplace_sources
2612 );
2613 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
2614 .expect("restore for tempdir cleanup");
2615 }
2616
2617 #[test]
2620 #[cfg(unix)]
2621 fn an_unreadable_manifest_names_the_read_class() {
2622 use std::os::unix::fs::PermissionsExt;
2623 let fixture = fixture();
2624 let manifest =
2625 fixture.marketplaces_root.join("probe-market/.claude-plugin/marketplace.json");
2626 std::fs::set_permissions(&manifest, std::fs::Permissions::from_mode(0o000))
2627 .expect("chmod away");
2628 let mut scan = ExtensionScan::default();
2629 let log = capture_logs(|| {
2630 scan = scan_extensions(
2631 &fixture.plugins_root,
2632 &fixture.marketplaces_root,
2633 &fixture.config_dir,
2634 );
2635 });
2636 std::fs::set_permissions(&manifest, std::fs::Permissions::from_mode(0o644))
2637 .expect("restore for tempdir cleanup");
2638 let probe = scan
2639 .marketplace_health
2640 .iter()
2641 .find(|row| row.name == "probe-market")
2642 .expect("probe row");
2643 let error = probe.load_error.as_deref().expect("the read error surfaces");
2644 assert!(
2645 error.contains("cannot be read") && !error.contains("failed to parse"),
2646 "the read class is named, not a parse diagnosis: {error}"
2647 );
2648
2649 let manifest_text = manifest.to_string_lossy().into_owned();
2650 assert!(
2651 log.lines().any(|line| line.contains("marketplace manifest cannot be read")
2652 && line.contains(&manifest_text)),
2653 "the manifest warn carries the path and the read class: {log}"
2654 );
2655 }
2656
2657 #[test]
2660 fn a_corrupt_settings_file_reads_all_enabled_with_the_scan_intact() {
2661 let fixture = fixture();
2662 let path = fixture.config_dir.join("settings.json");
2663 write(&path, "{ torn");
2664 let mut scan = ExtensionScan::default();
2665 let log = capture_logs(|| {
2666 scan = scan_extensions(
2667 &fixture.plugins_root,
2668 &fixture.marketplaces_root,
2669 &fixture.config_dir,
2670 );
2671 });
2672 let off =
2673 scan.components.iter().find(|row| row.plugin == "off@probe-market").expect("off row");
2674 assert!(off.installed);
2675 assert!(off.enabled, "absent from the map reads enabled; the corrupt file warned");
2676 assert!(off.auto, "the auto marker comes from the intact registry, not settings");
2677 assert!(log.contains("does not parse"), "the parse failure is warned: {log}");
2678 assert!(
2679 log.contains(&path.to_string_lossy().into_owned()),
2680 "the warn names the settings path: {log}"
2681 );
2682 }
2683
2684 #[test]
2687 #[cfg(unix)]
2688 fn an_unreadable_settings_file_warns_and_reads_all_enabled() {
2689 use std::os::unix::fs::PermissionsExt;
2690 let fixture = fixture();
2691 let path = fixture.config_dir.join("settings.json");
2692 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))
2693 .expect("chmod the settings file away");
2694 assert!(
2695 std::fs::read_to_string(&path).is_err(),
2696 "this test needs a runner where mode 000 denies reads; root bypasses the permission model"
2697 );
2698 let mut scan = ExtensionScan::default();
2699 let log = capture_logs(|| {
2700 scan = scan_extensions(
2701 &fixture.plugins_root,
2702 &fixture.marketplaces_root,
2703 &fixture.config_dir,
2704 );
2705 });
2706 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
2707 .expect("restore for tempdir cleanup");
2708
2709 let off =
2710 scan.components.iter().find(|row| row.plugin == "off@probe-market").expect("off row");
2711 assert!(off.enabled, "an unreadable file reads as all-enabled: {off:?}");
2712 assert!(
2713 log.contains("file exists but cannot be read"),
2714 "the read arm's own phrase is used: {log}"
2715 );
2716 assert!(
2717 !log.lines().any(|line| {
2718 line.contains(&path.to_string_lossy().into_owned())
2719 && line.contains("does not parse")
2720 }),
2721 "a read failure is not reported as a parse failure: {log}"
2722 );
2723 assert!(
2724 log.contains(&path.to_string_lossy().into_owned()),
2725 "the warn names the settings path: {log}"
2726 );
2727 }
2728
2729 #[test]
2731 fn a_missing_settings_file_is_silent() {
2732 let fixture = fixture();
2733 std::fs::remove_file(fixture.config_dir.join("settings.json")).expect("remove settings");
2734 let mut scan = ExtensionScan::default();
2735 let log = capture_logs(|| {
2736 scan = scan_extensions(
2737 &fixture.plugins_root,
2738 &fixture.marketplaces_root,
2739 &fixture.config_dir,
2740 );
2741 });
2742
2743 assert!(!log.contains("settings.json"), "a fresh install is not worth a log line: {log}");
2744 let off =
2745 scan.components.iter().find(|row| row.plugin == "off@probe-market").expect("off row");
2746 assert!(off.enabled, "no settings means enabled: {off:?}");
2747 }
2748
2749 #[test]
2752 fn a_hooks_file_without_a_hooks_object_warns() {
2753 let fixture = fixture();
2754 let hooks = fixture.plugins_root.join("cache/probe-market/full/1.2.3/hooks/hooks.json");
2755 write(&hooks, r#"{"hooks": []}"#);
2756 let mut rows = Vec::new();
2757 let log = capture_logs(|| rows = scan_fixture(&fixture));
2758
2759 let full = by_plugin(&rows, "full@probe-market");
2760 assert!(full.skills.contains(&"a".to_owned()), "its other components still list: {full:?}");
2761 assert!(full.hooks.is_empty(), "no triggers are shown: {full:?}");
2762 assert!(
2763 log.contains("names no hooks object"),
2764 "the absent hooks object is warned, not swallowed: {log}"
2765 );
2766 }
2767
2768 #[test]
2771 fn an_unparseable_hooks_file_names_the_parse_class() {
2772 let fixture = fixture();
2773 let hooks = fixture.plugins_root.join("cache/probe-market/full/1.2.3/hooks/hooks.json");
2774 write(&hooks, "{ torn");
2775 let mut rows = Vec::new();
2776 let log = capture_logs(|| rows = scan_fixture(&fixture));
2777
2778 let full = by_plugin(&rows, "full@probe-market");
2779 assert!(full.skills.contains(&"a".to_owned()), "its other components still list: {full:?}");
2780 assert!(full.hooks.is_empty(), "no triggers are shown: {full:?}");
2781 assert!(log.contains("does not parse"), "the parse class is named: {log}");
2782 assert!(
2783 log.contains(&hooks.to_string_lossy().into_owned()),
2784 "the warn names the hooks path: {log}"
2785 );
2786 }
2787
2788 #[test]
2791 #[cfg(unix)]
2792 fn an_unreadable_hooks_file_names_the_read_class() {
2793 use std::os::unix::fs::PermissionsExt;
2794 let fixture = fixture();
2795 let hooks = fixture.plugins_root.join("cache/probe-market/full/1.2.3/hooks/hooks.json");
2796 std::fs::set_permissions(&hooks, std::fs::Permissions::from_mode(0o000))
2797 .expect("chmod the hooks file away");
2798 assert!(
2799 std::fs::read_to_string(&hooks).is_err(),
2800 "this test needs a runner where mode 000 denies reads; root bypasses the permission model"
2801 );
2802 let mut rows = Vec::new();
2803 let log = capture_logs(|| rows = scan_fixture(&fixture));
2804 std::fs::set_permissions(&hooks, std::fs::Permissions::from_mode(0o644))
2805 .expect("restore for tempdir cleanup");
2806
2807 let full = by_plugin(&rows, "full@probe-market");
2808 assert!(full.skills.contains(&"a".to_owned()), "its other components still list: {full:?}");
2809 assert!(full.hooks.is_empty(), "no triggers are shown: {full:?}");
2810 assert!(
2811 log.contains("file exists but cannot be read"),
2812 "the read arm's own phrase is used: {log}"
2813 );
2814 assert!(
2815 log.contains(&hooks.to_string_lossy().into_owned()),
2816 "the warn names the hooks path, not another file's: {log}"
2817 );
2818 assert!(
2819 !log.lines().any(|line| {
2820 line.contains(&hooks.to_string_lossy().into_owned())
2821 && line.contains("does not parse")
2822 }),
2823 "a read failure is not reported as a parse failure: {log}"
2824 );
2825 }
2826
2827 #[test]
2830 #[cfg(unix)]
2831 fn an_unstatable_hooks_dir_keeps_the_plugin_row() {
2832 use std::os::unix::fs::PermissionsExt;
2833 let fixture = fixture();
2834 let dir = fixture.plugins_root.join("cache/probe-market/full/1.2.3/hooks");
2835 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o000))
2836 .expect("chmod the hooks dir away");
2837 assert!(
2838 std::fs::metadata(dir.join("hooks.json")).is_err(),
2839 "this test needs a runner where mode 000 denies a stat; root bypasses the permission model"
2840 );
2841 let mut rows = Vec::new();
2842 let log = capture_logs(|| rows = scan_fixture(&fixture));
2843 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755))
2844 .expect("restore for tempdir cleanup");
2845
2846 let full = by_plugin(&rows, "full@probe-market");
2847 assert!(full.load_error.is_none(), "the plugin is not failed for its hooks: {full:?}");
2848 assert!(full.skills.contains(&"a".to_owned()), "its other components still list: {full:?}");
2849 assert!(full.hooks.is_empty(), "no triggers are shown: {full:?}");
2850 assert!(log.contains("hooks.json cannot be read"), "the stat failure is warned: {log}");
2851 }
2852
2853 #[test]
2857 #[cfg(unix)]
2858 fn an_unstatable_mcp_file_keeps_the_plugin_row() {
2859 let fixture = fixture();
2860 let mcp = fixture.plugins_root.join("cache/probe-market/full/1.2.3/.mcp.json");
2861 std::fs::remove_file(&mcp).expect("remove the fixture MCP file");
2862 std::os::unix::fs::symlink(&mcp, &mcp).expect("self-referential symlink");
2863 assert!(
2864 std::fs::metadata(&mcp).is_err(),
2865 "this test needs a symlink loop, which fails a stat rather than denying a read"
2866 );
2867 let mut rows = Vec::new();
2868 let log = capture_logs(|| rows = scan_fixture(&fixture));
2869 std::fs::remove_file(&mcp).expect("restore for tempdir cleanup");
2870
2871 let full = by_plugin(&rows, "full@probe-market");
2872 assert!(full.load_error.is_none(), "the plugin is not failed for its MCP file: {full:?}");
2873 assert!(!full.mcp, "no MCP flag is shown: {full:?}");
2874 assert!(full.skills.contains(&"a".to_owned()), "its other components still list: {full:?}");
2875 assert!(
2876 log.contains("the plugin's MCP config cannot be read"),
2877 "the stat failure is warned: {log}"
2878 );
2879 assert!(
2880 log.contains(&mcp.to_string_lossy().into_owned()),
2881 "the warn names the MCP path: {log}"
2882 );
2883 }
2884}