Skip to main content

forge_agent/userdata/plugins/
components.rs

1//! Per-plugin component + marketplace-manifest scan: the fast path
2//! behind the Extensions page. Pure filesystem reads under the config
3//! dir (`$CLAUDE_CONFIG_DIR`/`~/.claude`) and its `plugins/` subtree,
4//! `settings.json` included; no CLI call. The plugin registry
5//! (`installed_plugins.json`) supplies installed-ness, version and the
6//! auto-dependency marker; `settings.json`'s `enabledPlugins` the
7//! enabled state; the marketplace manifests the latest version and LSP
8//! server names; each plugin's own manifest its declared component
9//! paths.
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16use forge_primitives::plugins::{MarketplaceHealth, PluginComponents};
17
18/// The plugins subtree of the config dir the `claude` CLI itself
19/// resolves: `$CLAUDE_CONFIG_DIR`, else `$HOME/.claude`. Both env reads
20/// are launch-directory-independent; `None` when neither resolves.
21pub 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    /// A clone-relative string (`./plugins/foo`) or a remote
68    /// descriptor object (`url`, `git-subdir`); the official
69    /// marketplace ships both. Only the string form names local
70    /// components.
71    #[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
91/// One plugin dir's component inventory.
92struct DirComponents {
93    skills: Vec<String>,
94    agents: Vec<String>,
95    commands: Vec<String>,
96    hooks: Vec<String>,
97    mcp: bool,
98}
99
100/// The component-path fields of `.claude-plugin/plugin.json`. The
101/// `claude` CLI resolves each as a string path or an array of paths,
102/// hooks and MCP servers additionally as inline objects.
103///
104/// Declared paths join the plugin dir unvalidated: an absolute path
105/// replaces it, `..` walks out, symlinks are followed. Accepted under
106/// the trust model (one trusted user driving their own machine); the
107/// cost is that a bogus declaration counts a foreign directory's
108/// entries as the plugin's own.
109#[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
123/// Read and parse a JSON file. All three outcomes return `None`, so the
124/// caller cannot tell them apart from the value; the warn carries the
125/// difference: a missing file is the expected fresh-install case and
126/// stays silent, while an unreadable or unparseable one warns with the
127/// reason.
128fn 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
155/// Whether `path` is a regular file. `Path::is_file` folds every stat
156/// error into "not there", which reads a permission or io failure as an
157/// absent file; this reports it instead.
158fn 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
166/// Read the component parts of one plugin dir. `Err` is an io error
167/// that stopped the dir being read - its own `read_dir`, or for a
168/// `gated` dir the stat of its `.claude-plugin/plugin.json`; `Ok(None)`
169/// when a gated dir carries no such manifest. Registered installs read
170/// ungated because the registry's installPath is authoritative; clone
171/// sources read ungated because a marketplace source dir is a plugin by
172/// declaration.
173fn 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    // A hooks or MCP file that cannot be stat'd costs the plugin that
194    // component, not its row: failing the whole row would hide whatever
195    // the sibling reads did return.
196    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
203/// The plugin's own manifest, read for its declared component paths.
204/// Missing reads as undeclared in silence; unreadable or unparseable
205/// warns and reads as undeclared too.
206fn 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
222/// The paths one manifest component key declares: a string is one path,
223/// an array a list; another shape warns and reads as undeclared.
224fn 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
241/// The skill names one declared path contributes: a dir with its own
242/// SKILL.md is one skill, another dir a container of skills, a file its
243/// stem.
244fn 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
255/// Command or agent names: the manifest's declaration replaces the
256/// conventional dir, whose undeclared entries then count nothing; each
257/// declared path is a dir of .md files or one file.
258fn 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
280/// Hook trigger names over the default hooks/hooks.json plus whatever
281/// the manifest declares; a declaration adds sources, it never
282/// suppresses the default.
283fn 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
330/// Whether the plugin carries MCP config: the default `.mcp.json`, plus
331/// whatever the manifest declares, an inline object counting as present.
332fn 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
366/// The reason shown for a registered install whose dir is unusable. A
367/// vanished dir and an unreadable one point the reader at different
368/// remedies, so the row says which; an entry recording no path reads
369/// as the dir being missing.
370fn 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
403/// Every marketplace manifest readable under the clones root, keyed by
404/// (marketplace name, plugin name), plus per-marketplace load errors,
405/// read or parse, and which clones produced a readable manifest - the
406/// distinction a zero-plugin manifest needs to avoid reading as a
407/// cache-miss.
408struct 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                // FileType is readdir's d_type; only the stat fallback
451                // fails, and a skipped clone then reads as a cache-miss.
452                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                // Read failure and parse failure are different classes:
485                // a permission-denied manifest is not "failed to parse".
486                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                            // A present-but-broken manifest must never
515                            // read as a cache-miss: that sends the user
516                            // to Repair against a healthy clone.
517                            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/// One combined disk scan: everything a refresh needs without a CLI
536/// spawn - registry-derived installed entries, manifest-derived
537/// available entries and marketplace sources, per-plugin component
538/// inventories, and per-marketplace health, over one shared manifest
539/// load so a failed manifest surfaces the same error on both halves.
540#[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
549/// Read the plugin registry. A missing file is the fresh install and
550/// reads as empty in silence; an unreadable or unparseable one warns
551/// with the reason and reads as empty too; the page renders
552/// identically either way, so the warn is the only place the
553/// difference shows.
554fn 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
589/// Scan the plugin cache and marketplace manifests into per-plugin
590/// component inventories, one entry per plugin the registry, a
591/// manifest or the cache knows about. Installed plugins scan their
592/// registry `installPath`; uninstalled ones their marketplace clone
593/// source. A registry entry with no readable install dir - gone,
594/// unreadable, or absent from the entry itself - emits a LoadFailed
595/// row carrying its reason rather than disappearing, and so does a
596/// marketplace whose manifest could not load.
597pub 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    // Registry installs are authoritative: the row scans the exact
609    // installPath the CLI recorded.
610    for (id, entries) in &registry.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    // Uninstalled plugins: components come from the clone source when
675    // the manifest declares a relative one, and are empty otherwise -
676    // an object source names a remote, and a missing clone dir yields
677    // nothing. The cache walk below covers what neither list names.
678    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    // A marketplace whose manifest does not load would silently drop
734    // every uninstalled plugin it declares; one LoadFailed row per
735    // failed manifest keeps the gap visible on the page.
736    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    // Cache leftovers neither the registry nor a manifest names: a real
746    // plugin dir with no entry on either side. The newest version dir
747    // carrying a manifest wins.
748    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(&registry, &enabled),
763            marketplace: manifest_marketplace_entries(&manifests),
764            marketplace_sources: match &registry_error {
765                // An unreadable or unparseable registry cannot list the
766                // sources; the clone dirs still name them.
767                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        // Mirrors the manifest walk: only a dir is a marketplace, and a
805        // stat that fails is not read as one.
806        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(&registry, &enabled),
871        marketplace: manifest_marketplace_entries(&manifests),
872        marketplace_sources: match &registry_error {
873            // An unreadable or unparseable registry cannot list the
874            // sources; the clone dirs still name them.
875            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
900/// The CLI's marketplace registry (`known_marketplaces.json` beside
901/// the clones), read once for both the source entries and the health.
902/// A corrupt OR unreadable registry reads empty with its error carried
903/// back - the health rows must name it, not render the tab as an
904/// absence. Missing is the expected fresh-install case and stays
905/// silent.
906fn 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
943/// Installed entries derived from the registry and settings, the disk
944/// mirror of `claude plugin list --json`. One entry per registry
945/// record: a plugin installed in user AND project scope keeps both,
946/// like the CLI list does.
947fn 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
969/// Available entries derived from the manifests, the disk mirror of
970/// `claude plugin list --available --json`.
971fn 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
989/// Configured marketplace sources derived from the registry, the disk
990/// mirror of `claude plugin marketplace list --json`.
991fn 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
1020/// The manifest's LSP servers as server key -> binary command; the
1021/// command is what the PATH check tests, the key the fallback.
1022fn 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
1037/// `enabledPlugins` from `settings.json` beside the plugins root; the
1038/// CLI's own enable state. An id absent from the map is enabled. An
1039/// unreadable or unparseable settings file reads as all-enabled WITH a
1040/// warning - the page must not silently flip every disabled plugin to
1041/// Current.
1042fn 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
1063/// The newest version dir of a cache plugin, with its inventory. A dir
1064/// that cannot be read - its own `read_dir`, or the stat of its
1065/// manifest - stops the search: an older version's inventory shown as
1066/// this plugin's own would be confidently wrong. Two arms fall through
1067/// instead: a vanished dir, and a dir carrying no manifest, which is
1068/// not a version dir at all.
1069fn 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            // FileType is readdir's d_type, so only the stat fallback
1101            // fails; an unreadable version dir stops the search.
1102            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
1142/// A version directory name's numeric-aware sort key: numeric chunks
1143/// order before and inside text chunks, so "10.0.0" outranks "9.9.9".
1144fn 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
1153/// Per-marketplace health over the shared manifest load. A manifest
1154/// the load could not read or parse surfaces its recorded error; one
1155/// that loads but shows no plugins is healthy at zero.
1156fn marketplace_health(
1157    registry: &KnownMarketplaces,
1158    manifests: &Manifests,
1159    config_dir: &Path,
1160    registry_error: Option<&str>,
1161) -> Vec<MarketplaceHealth> {
1162    // An unreadable or unparseable known_marketplaces.json leaves the
1163    // registry empty; the clone dirs still exist, so each carries the
1164    // load error rather than the tab reading as an absence.
1165    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    /// Fixture layout, all under one tempdir. A version dir carries its
1229    /// manifest at `.claude-plugin/plugin.json`.
1230    ///
1231    /// home/.claude/
1232    ///   settings.json                (enabledPlugins: off -> false)
1233    ///   plugins/installed_plugins.json  (off carries auto; nopath omits
1234    ///                                   installPath; filed points at
1235    ///                                   stray-file.txt)
1236    ///   plugins/cache/probe-market/
1237    ///     full/1.2.3/                every component kind
1238    ///     off/3.0.0/                 one skill
1239    ///     gone/1.0.0/                stale leftover, not installed
1240    ///     lsponly/1.0.0/             registered, no manifest -> LSP comes
1241    ///                                from the marketplace manifest
1242    ///     broken/9.9.9/              manifest-less cache leftover -> excluded
1243    ///     rootonly/9.9.9/            root-level manifest -> excluded
1244    ///     stray/1.0.0/               valid leftover -> available row
1245    ///     multi/1.0.0, 2.0.0, 3.0.0  three versions; 3.0.0 has none
1246    ///     unreadable/1.0.0/          registered, mode 000 -> LoadFailed
1247    ///     ghosted/1.0.0/missing-dir  registered, dir gone -> LoadFailed
1248    ///   plugins/marketplaces/probe-market/
1249    ///     .claude-plugin/marketplace.json
1250    ///     full-src/, gone-src/       clone sources for components
1251    ///     file-source                clone source that is a file
1252    ///   plugins/known_marketplaces.json
1253    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        // full: every component kind, installed and enabled.
1281        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        // off: installed but disabled, registry marks auto.
1293        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        // gone: stale cache copy; nothing in the registry. The
1298        // marketplace clone source carries its real components.
1299        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        // broken: a cache leftover carrying no manifest at all.
1304        let broken = cache.join("probe-market").join("broken").join("9.9.9");
1305        skill(&broken.join("skills/a/SKILL.md"));
1306
1307        // rootonly: a cache leftover whose manifest sits at the dir
1308        // root, which is not the install layout.
1309        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        // stray: proves the cache-leftover walk emits rows, so the
1314        // exclusions in that test are not vacuous.
1315        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        // multi: a cache leftover with three version dirs. Tests chmod
1320        // 2.0.0, its .claude-plugin, and the plugin dir itself; 3.0.0
1321        // carries no manifest at all.
1322        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        // 3.0.0 sorts newest and carries no manifest, so the walk skips
1334        // it for the 2.0.0 below - the fall-through its doc names.
1335        skill(&multi.join("3.0.0/skills/bare/SKILL.md"));
1336
1337        // lsponly: registered with a real dir that ships nothing but a
1338        // README - the rust-analyzer-lsp/gopls-lsp shape.
1339        let lsponly = cache.join("probe-market").join("lsponly").join("1.0.0");
1340        write(&lsponly.join("README.md"), "# lsp only");
1341
1342        // custom: manifest declares a ui-ux-pro-max-shaped skills
1343        // container plus array agents/commands and inline MCP; its
1344        // conventional dirs carry UNDECLARED extras that must not count,
1345        // hooks is [] beside a populated hooks/hooks.json, and one skill
1346        // sits on both sides to pin the union dedup.
1347        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        // hookpath: a declared hooks file merges with the default one.
1369        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        // skillarray: an element carrying SKILL.md is one skill; one
1378        // without is a container of skills.
1379        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        // mcppath: the manifest's MCP declaration is a path, not an
1390        // inline object.
1391        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        // tornmanifest: a cache leftover whose manifest does not parse
1399        // reads as undeclared and scans conventionally.
1400        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        // regcustom: a registered install whose manifest declares the
1405        // commands dir as a string, shadowing an undeclared conventional
1406        // command.
1407        let regcustom = cache.join("probe-market").join("regcustom").join("1.0.0");
1408        write(
1409            &regcustom.join(".claude-plugin/plugin.json"),
1410            r#"{"name":"regcustom","version":"1.0.0","commands":"./meta/cmds/"}"#,
1411        );
1412        write(&regcustom.join("meta/cmds/declared.md"), "# declared");
1413        write(&regcustom.join("commands/undeclared.md"), "# undeclared");
1414
1415        // unreadable: registered with a real dir whose permissions one
1416        // test strips; mode 000 still passes is_dir, so only a
1417        // readability probe catches it.
1418        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        // filed: a registry entry whose installPath is a regular file.
1426        let filed = plugins_root.join("stray-file.txt");
1427        write(&filed, "not a plugin dir");
1428
1429        // ghosted: registered but its install dir does not exist -
1430        // the honest-scan LoadFailed row.
1431        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", &regcustom.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        // Marketplace manifest: full is at 2.0.0 with an LSP server;
1460        // gone is available at 6.4.0 with a relative clone source;
1461        // lsponly declares only an LSP server, like the real -lsp
1462        // plugins do; fileclone's source is a file, and absentclone's
1463        // source directory is never created.
1464        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        // ghost: the cache-miss health case. Health matches clones to
1483        // registry entries by name rather than by installLocation, so
1484        // this row reads as missing even though its installLocation
1485        // points at the real probe-market clone.
1486        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    /// Buffer tracing output so an emitted record can be read back.
1503    #[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    // -- scan_components -------------------------------------------------
1541
1542    #[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    /// A cache leftover without the manifest at the real path is not a
1571    /// plugin, whether it carries no manifest anywhere or one at the
1572    /// dir root, which is not the install layout.
1573    #[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    /// A registry entry whose install dir is gone must surface as a
1597    /// LoadFailed row, not vanish - the honest-scan contract.
1598    #[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    /// An install with no manifest of its own is a valid plugin
1618    /// (rust-analyzer-lsp and gopls-lsp on the real estate).
1619    #[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    /// A registered dir that exists but cannot be read is a load
1644    /// failure, not an empty healthy install.
1645    #[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    /// A registry entry with no installPath never reaches the
1678    /// filesystem: it is an installed row carrying the missing-dir
1679    /// reason, with a warn of its own.
1680    #[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    /// An installPath that is a regular file, not a directory, carries
1702    /// the read-failure reason rather than the missing-dir one.
1703    #[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    /// A cache leftover whose newest version dir cannot be read shows
1721    /// no inventory at all: falling through would present an older
1722    /// version's components as this plugin's own.
1723    #[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        // The readable scan is the control: the newest version's skill
1731        // is the inventory shown.
1732        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    /// A clone source that cannot be read only stays quiet when it is
1767    /// missing; any other io error warns with the plugin, the path and
1768    /// the error, and contributes no components.
1769    #[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        // The manifest declares `./file-source`; the scan logs the path
1780        // as joined, without normalising it.
1781        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    /// A clone source that is simply not there is the ordinary case: the
1802    /// row still lists as a catalog entry with nothing in it, and no log
1803    /// line is written for it.
1804    #[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    /// An unreadable registry warns and leaves catalog rows only; the
1820    /// page renders identically either way, so the warn is the only
1821    /// signal.
1822    #[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    /// An unreadable marketplaces root warns, and the intact clones then
1859    /// read as cache-misses - the reason the warn exists, since the page
1860    /// would otherwise offer Repair against a healthy clone.
1861    #[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    /// An unreadable plugin cache warns and its own rows do not
1909    /// appear; the registry rows survive as read failures, since their
1910    /// install paths sit under it.
1911    #[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        // The readable scan is the control: the cache walk does emit
1919        // rows, which is what the assertion below denies.
1920        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        // full's own installPath is under the cache this test chmods,
1942        // so the registry loop still emits it, as a failure.
1943        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    /// A marketplace cache dir that cannot be read warns; the registry
1965    /// loop still resolves the install paths inside it, as load-failed
1966    /// rows, and only the leftover walk under it stops.
1967    #[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    /// A missing cache is the fresh install: no warn, and the manifest
2001    /// half of the scan still runs.
2002    #[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    /// A version dir whose manifest cannot be stat'd is a read failure,
2020    /// not an absent manifest: it must not fall through to an older
2021    /// version's inventory.
2022    #[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    /// A newest version dir carrying no manifest is not a version dir:
2050    /// the walk falls through to the one below it.
2051    #[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    /// A cache plugin dir that cannot be read warns and shows no
2064    /// inventory for that plugin at all.
2065    #[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    /// A missing registry is the fresh install: no warn at all, and the
2098    /// catalog still renders.
2099    #[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    /// A registry that does not parse warns with its path, and the
2122    /// manifest half of the scan is unaffected.
2123    #[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    /// A missing marketplaces root is the fresh-install case too: silent,
2146    /// with the configured marketplaces still listed as cache-misses.
2147    #[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    /// Numeric-aware version ordering: "10.0.0" outranks "9.9.9" when
2177    /// a cache plugin has both dirs; a plain string sort would pick
2178    /// "9.9.9".
2179    #[test]
2180    fn a_two_digit_version_dir_outranks_a_nine() {
2181        let fixture = fixture();
2182        // An unregistered cache plugin (no registry entry, no manifest
2183        // entry) so the cache-leftover branch resolves it.
2184        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    /// A manifest-declared skills directory is a container of skills
2264    /// (the ui-ux-pro-max shape), scanned alongside the conventional
2265    /// `skills/`; a skill present on both sides counts once.
2266    #[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    /// Declared agents and commands REPLACE the conventional dirs: an
2279    /// undeclared sibling .md in the conventional dir must not count.
2280    #[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    /// An empty hooks declaration suppresses nothing: the default
2290    /// hooks/hooks.json triggers survive (the conserve/herald/oracle
2291    /// shape), named exactly.
2292    #[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    /// An inline mcpServers declaration is MCP config present, without
2305    /// a .mcp.json on disk.
2306    #[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    /// A manifest MCP declaration that is a path sets the flag when
2315    /// that file exists.
2316    #[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    /// A declared hooks file merges with the default hooks/hooks.json.
2325    #[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    /// A skills array element carrying SKILL.md is one skill; an element
2338    /// without one is a container of skills (the leyline/pensive arrays
2339    /// vs the ui-ux string).
2340    #[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    /// A plugin manifest that does not parse reads as undeclared: the
2353    /// conventional scan stands and the parse failure is warned.
2354    #[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    /// A registered install whose manifest declares a component dir as
2372    /// a string shadows the conventional dir of that kind.
2373    #[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    // -- scan_marketplaces -----------------------------------------------
2386
2387    #[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        // A foreign config dir: every registry location sits outside it.
2405        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    /// A manifest that EXISTS with a syntax error must read as a
2424    /// parse failure, never as the cache-miss reason that would send
2425    /// the user to Repair against a healthy clone.
2426    #[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        // The same failure reaches the Installed tab: a marketplace
2450        // that cannot load renders there as a load-failed row.
2451        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    /// The official marketplace's manifest declares per-plugin sources
2467    /// as OBJECTS (`url`, `git-subdir` remotes), not just relative
2468    /// strings. A string-typed source field fails the whole parse and
2469    /// the largest marketplace's catalog disappears behind one error.
2470    /// The fixture mirrors the real manifest's shapes.
2471    #[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        // A string source WITHOUT the ./ prefix is not clone-relative:
2508        // this dir exists, and the guard is what keeps it unscanned.
2509        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    /// A corrupt known_marketplaces.json must not render the tab as an
2567    /// absence: each clone's health row carries the load error.
2568    #[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    /// An UNREADABLE registry is the sibling class: it warns and the
2589    /// clone rows carry the error, exactly like the parse class.
2590    #[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    /// A manifest that exists but cannot be READ is its own class -
2618    /// never "failed to parse", which would misdiagnose healthy JSON.
2619    #[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    /// A corrupt settings.json must not silently flip every disabled
2658    /// plugin to enabled; the scan still completes and warns.
2659    #[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    /// The read sibling: an unreadable settings.json warns with the read
2685    /// class and reads as all-enabled.
2686    #[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    /// No settings file is the fresh install: all-enabled, nothing logged.
2730    #[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    /// A hooks.json that parses but names no hooks object warns for
2750    /// itself, and costs the plugin only its triggers.
2751    #[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    /// A hooks.json that does not parse warns as a parse failure, and
2769    /// costs the plugin only its triggers.
2770    #[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    /// An unreadable hooks.json names the read class, never the parse
2789    /// one, and costs the plugin only its triggers.
2790    #[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    /// A hooks DIR that cannot be stat'd costs the plugin its triggers,
2828    /// not its row: failing the row would hide its other components.
2829    #[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    /// A .mcp.json that cannot be stat'd costs the plugin its MCP flag,
2854    /// not its row. It sits directly in the plugin dir, so the only way
2855    /// to deny a stat without denying the dir is a symlink loop.
2856    #[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}