Skip to main content

forge_primitives/
plugins.rs

1//! Plugin inventory + CLI-action wire shapes. `SessionUpdate`
2//! variants carry these directly; loaders (`refresh_inventory`,
3//! the `claude plugin` shell-out, etc.) live in
4//! `forge_agent::userdata::plugins`.
5
6use std::collections::BTreeMap;
7use std::path::PathBuf;
8
9use serde_json::Value;
10
11/// A plugin's `claude plugin details` projection: the always-on token
12/// cost every session pays for the plugin being installed.
13#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14pub struct PluginDetails {
15    pub token_cost_always_on: u64,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum PluginCapability {
21    Skill,
22    Mcp,
23}
24
25impl PluginCapability {
26    pub const fn label(self) -> &'static str {
27        match self {
28            Self::Skill => "SKILL",
29            Self::Mcp => "MCP",
30        }
31    }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub struct InstalledPluginEntry {
36    pub id: String,
37    pub version: Option<String>,
38    pub scope: String,
39    pub enabled: bool,
40    pub installed_at: Option<String>,
41    pub last_updated: Option<String>,
42    pub project_path: Option<String>,
43    pub capability: PluginCapability,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47pub struct MarketplaceEntry {
48    pub plugin_id: String,
49    pub name: String,
50    pub description: Option<String>,
51    pub marketplace_name: Option<String>,
52    pub version: Option<String>,
53    pub install_count: Option<u64>,
54    pub source: Option<Value>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
58pub struct MarketplaceSourceEntry {
59    pub name: String,
60    pub source: Option<String>,
61    pub repo: Option<String>,
62    /// On-disk clone or directory the CLI maintains for this
63    /// marketplace; a git-backed one here is what makes a rollback
64    /// possible.
65    pub install_location: Option<String>,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69pub struct PluginsInventorySnapshot {
70    pub installed: Vec<InstalledPluginEntry>,
71    pub marketplace: Vec<MarketplaceEntry>,
72    pub marketplaces: Vec<MarketplaceSourceEntry>,
73    /// Per-plugin component inventory read off disk by the catalog
74    /// scan (`forge_agent::userdata::plugins::components`); empty when
75    /// the producer does not scan.
76    pub components: Vec<PluginComponents>,
77    /// Marketplace load health for the Extensions page; empty when the
78    /// producer does not scan.
79    pub marketplace_health: Vec<MarketplaceHealth>,
80    /// Token cost for the plugins the producer fetched details for,
81    /// stamped with the version the cost was fetched FOR - an update
82    /// landing mid-fetch must not pin the old cost under the new
83    /// version's key. The producer decides the fetch set.
84    pub token_costs: BTreeMap<String, (String, PluginDetails)>,
85}
86
87/// Per-plugin component inventory read straight off disk (the plugin
88/// cache plus marketplace manifests). Produced by the scan in
89/// `forge_agent::userdata::plugins::components`; one entry per
90/// registered plugin, per plugin a manifest or the cache names, and one
91/// per marketplace whose manifest failed to load.
92#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93pub struct PluginComponents {
94    /// Full installed id (`name@marketplace`).
95    pub plugin: String,
96    pub marketplace: String,
97    /// Version of the installed copy, from the plugin registry.
98    pub version: Option<String>,
99    pub installed: bool,
100    /// Every registry entry for the plugin is enabled. False for an
101    /// uninstalled plugin.
102    pub enabled: bool,
103    /// The registry marks the install as an auto-installed dependency.
104    pub auto: bool,
105    /// Latest version the marketplace manifest declares.
106    pub available_version: Option<String>,
107    pub skills: Vec<String>,
108    pub agents: Vec<String>,
109    pub commands: Vec<String>,
110    /// Hook trigger events (`SessionStart`, ...), from hooks.json.
111    pub hooks: Vec<String>,
112    /// The plugin ships `.mcp.json`.
113    pub mcp: bool,
114    /// LSP servers the marketplace manifest declares: server key to
115    /// the binary command it launches.
116    pub lsp_servers: BTreeMap<String, String>,
117    /// Why the row could not load: a registered install whose dir is
118    /// gone, unreadable, or absent from the registry entry, or a
119    /// marketplace whose manifest failed to load.
120    pub load_error: Option<String>,
121}
122
123/// One marketplace's on-disk health for the Extensions page.
124#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
125pub struct MarketplaceHealth {
126    pub name: String,
127    pub source: String,
128    /// Plugin count in the marketplace's manifest; 0 when it cannot
129    /// load.
130    pub available: usize,
131    /// Why the marketplace could not load: no clone recorded on disk,
132    /// no manifest found in the clone, a manifest that cannot be read
133    /// or cannot be parsed, or the registry error carried onto every
134    /// clone row.
135    pub load_error: Option<String>,
136    pub install_location: PathBuf,
137    /// The registry's installLocation sits outside the config dir.
138    pub drifted: bool,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
142pub struct PluginsCliActionSuccess {
143    pub snapshot: PluginsInventorySnapshot,
144    pub message: String,
145    pub claude_path: PathBuf,
146}
147
148/// Lifecycle of one row in a plugin update run or update check.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum PluginRunRowStatus {
152    /// Queued for the run, not started yet.
153    Queued,
154    /// The `claude plugin update` call is in flight.
155    Updating,
156    /// Installed version moved.
157    Updated,
158    /// No change to install.
159    AlreadyCurrent,
160    /// The update call failed.
161    Failed,
162    /// Auto-update did not touch this entry.
163    Skipped,
164    /// Check-only: the marketplace reports a different version.
165    UpdateAvailable,
166}
167
168/// One row of a section-level update run or check report, carried to
169/// the pane through `SessionUpdate`.
170#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
171pub struct PluginUpdateRunRow {
172    pub plugin_id: String,
173    pub scope: String,
174    /// Working directory this row's `claude plugin update` runs in;
175    /// project/local scope entries update from their own project.
176    pub cwd_raw: String,
177    pub marketplace: String,
178    pub status: PluginRunRowStatus,
179    pub installed_version: Option<String>,
180    pub available_version: Option<String>,
181    /// The failure text for `Failed`, or the reason for `Skipped`.
182    pub detail: Option<String>,
183}
184
185impl PluginUpdateRunRow {
186    /// A row queued from an installed entry.
187    pub fn queued(
188        plugin_id: String,
189        scope: String,
190        cwd_raw: String,
191        installed_version: Option<String>,
192    ) -> Self {
193        Self {
194            marketplace: plugin_marketplace(&plugin_id).to_owned(),
195            plugin_id,
196            scope,
197            cwd_raw,
198            status: PluginRunRowStatus::Queued,
199            installed_version,
200            available_version: None,
201            detail: None,
202        }
203    }
204}
205
206/// A section-level update run or check report: the rows and whether it
207/// has finished. Progress updates replace the whole run each time so
208/// the pane never reconciles partial state.
209#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
210pub struct PluginUpdateRun {
211    pub trigger: PluginUpdateTrigger,
212    pub finished: bool,
213    pub rows: Vec<PluginUpdateRunRow>,
214}
215
216impl PluginUpdateRun {
217    pub fn summary(&self) -> String {
218        let mut updated = 0;
219        let mut failed = 0;
220        let mut current = 0;
221        let mut available = 0;
222        for row in &self.rows {
223            match row.status {
224                PluginRunRowStatus::Updated => updated += 1,
225                PluginRunRowStatus::Failed => failed += 1,
226                PluginRunRowStatus::AlreadyCurrent => current += 1,
227                PluginRunRowStatus::UpdateAvailable => available += 1,
228                _ => {}
229            }
230        }
231        if updated > 0 || failed > 0 {
232            format!("{updated} updated, {failed} failed, {current} current")
233        } else if available > 0 {
234            format!("{available} update(s) available")
235        } else {
236            "all current".to_owned()
237        }
238    }
239
240    /// Rows that landed successfully - the "done" side of the
241    /// Extensions page's batch counter.
242    pub fn finished_count(&self) -> usize {
243        self.rows
244            .iter()
245            .filter(|row| {
246                matches!(
247                    row.status,
248                    PluginRunRowStatus::Updated | PluginRunRowStatus::AlreadyCurrent
249                )
250            })
251            .count()
252    }
253}
254
255/// The CLI marker printed when an update finds nothing to do.
256const ALREADY_CURRENT_MARKER: &str = "is already at the latest version";
257
258/// Classify one update call from the CLI's output and the observed
259/// version change. The CLI exits 0 on some failures, so neither
260/// signal alone decides: the marker means current, a version change
261/// means updated, and anything else - including a silent exit 0 - is
262/// a failure whose detail is the output tail. Failures that exit
263/// non-zero never reach here: the runner reports them as errors.
264pub fn classify_update_row(
265    plugin_id: &str,
266    scope: &str,
267    version_before: Option<&str>,
268    version_after: Option<&str>,
269    output: &str,
270) -> PluginUpdateRunRow {
271    let mut row = PluginUpdateRunRow::queued(
272        plugin_id.to_owned(),
273        scope.to_owned(),
274        String::new(),
275        version_before.map(str::to_owned),
276    );
277    row.installed_version = version_after.map(str::to_owned);
278    row.status = if output.contains(ALREADY_CURRENT_MARKER) {
279        PluginRunRowStatus::AlreadyCurrent
280    } else if version_before != version_after {
281        PluginRunRowStatus::Updated
282    } else {
283        row.detail = Some(output_tail(output));
284        PluginRunRowStatus::Failed
285    };
286    row
287}
288
289/// The last line of CLI output worth showing, trimmed. Long failure
290/// prose clips so a report row stays one line.
291fn output_tail(output: &str) -> String {
292    let line = output.lines().rev().find(|line| !line.trim().is_empty());
293    let line = line.unwrap_or_default().trim();
294    if line.chars().count() > 120 {
295        let cut: String = line.chars().take(117).collect();
296        format!("{cut}...")
297    } else {
298        line.to_owned()
299    }
300}
301
302/// The marketplace part of an installed id (`name@marketplace`), empty
303/// when the id carries none.
304pub fn plugin_marketplace(id: &str) -> &str {
305    id.split_once('@').map_or("", |(_, marketplace)| marketplace)
306}
307
308/// An installed entry whose marketplace copy reports a different
309/// version - the "check for updates" result, reported without
310/// applying anything.
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct PluginUpdateAvailability {
313    pub plugin_id: String,
314    pub scope: String,
315    pub marketplace: String,
316    pub installed_version: Option<String>,
317    pub available_version: Option<String>,
318}
319
320/// Diff the installed list against the marketplace catalog. One row
321/// per installed entry; an entry with no marketplace copy of a
322/// different version is absent.
323pub fn update_availability(
324    installed: &[InstalledPluginEntry],
325    marketplace: &[MarketplaceEntry],
326) -> Vec<PluginUpdateAvailability> {
327    installed
328        .iter()
329        .filter_map(|entry| {
330            let available = marketplace
331                .iter()
332                .find(|candidate| candidate.plugin_id == entry.id)
333                .and_then(|candidate| candidate.version.clone())?;
334            if Some(&available) == entry.version.as_ref() {
335                return None;
336            }
337            Some(PluginUpdateAvailability {
338                marketplace: plugin_marketplace(&entry.id).to_owned(),
339                plugin_id: entry.id.clone(),
340                scope: entry.scope.clone(),
341                installed_version: entry.version.clone(),
342                available_version: Some(available),
343            })
344        })
345        .collect()
346}
347
348/// What forge remembers after a plugin moved. `marketplace_ref_before`
349/// is the marketplace clone's HEAD before the update - the ref a
350/// rollback checks out. `cwd_raw` is where the update ran, so a
351/// rollback of a project/local entry works on the same install.
352#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
353pub struct PluginUpdateRecord {
354    pub plugin_id: String,
355    pub marketplace: String,
356    pub scope: String,
357    #[serde(default)]
358    pub cwd_raw: String,
359    pub from_version: Option<String>,
360    pub to_version: Option<String>,
361    pub marketplace_ref_before: Option<String>,
362    pub updated_at: String,
363    pub trigger: PluginUpdateTrigger,
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
367#[serde(rename_all = "snake_case")]
368pub enum PluginUpdateTrigger {
369    Manual,
370    Auto,
371}
372
373/// One row of the Extensions page's shared grammar: a plugin, one of
374/// its components, or an MCP server. Flattened from a component scan
375/// by [`extension_rows`].
376#[derive(Debug, Clone, PartialEq, Eq)]
377pub struct ExtensionRow {
378    /// Stable row id: the plugin id for plugin rows,
379    /// `<kind>:<plugin-name>:<component>` for components, and
380    /// `hooks:<plugin-name>` for a plugin's hook set, which is one row
381    /// rather than one per trigger.
382    pub id: String,
383    pub kind: ExtensionKind,
384    pub name: String,
385    /// The owning plugin for components, the marketplace for plugins.
386    pub source: String,
387    pub version: Option<String>,
388    pub available_version: Option<String>,
389    pub state: RowState,
390    /// Extra row context: hook trigger events, failure reasons.
391    pub detail: Option<String>,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum ExtensionKind {
396    Plugin,
397    Skill,
398    Agent,
399    Command,
400    Hook,
401    Lsp,
402    Mcp,
403}
404
405/// The row's health, as the Extensions page renders it.
406#[derive(Debug, Clone, PartialEq, Eq)]
407pub enum RowState {
408    Current,
409    UpdateAvailable,
410    AvailableNotInstalled,
411    Disabled,
412    /// A health failure and its reason.
413    LoadFailed(String),
414    /// Installed as an auto-dependency; the payload is the reason
415    /// shown on the row (the CLI exposes no consumer name).
416    AutoDependency(String),
417    /// Applied but not live until a restart consumes it.
418    RestartRequired,
419}
420
421/// Flatten component scans into Extension page rows: one row per
422/// plugin followed by its component rows, in scan order.
423pub fn extension_rows<'a>(
424    components: impl IntoIterator<Item = &'a PluginComponents>,
425) -> Vec<ExtensionRow> {
426    let mut rows = Vec::new();
427    for components in components {
428        let plugin_name =
429            components.plugin.split_once('@').map_or(components.plugin.as_str(), |(name, _)| name);
430        let state = plugin_row_state(components);
431        rows.push(ExtensionRow {
432            id: components.plugin.clone(),
433            kind: ExtensionKind::Plugin,
434            name: plugin_name.to_owned(),
435            source: components.marketplace.clone(),
436            version: components.version.clone(),
437            available_version: components.available_version.clone(),
438            state: state.clone(),
439            detail: match &state {
440                // The auto-dependency reason renders from the state
441                // payload; stuffing it here would double the label.
442                RowState::AutoDependency(_) => None,
443                // The auto marker would otherwise vanish whenever an
444                // update is available; the update badge outranks, the
445                // marker rides the detail.
446                _ if components.auto
447                    && components.installed
448                    && state == RowState::UpdateAvailable =>
449                {
450                    Some("auto-installed".to_owned())
451                }
452                _ => None,
453            },
454        });
455        let inherited = match state {
456            // Auto-dependency provenance describes the plugin's
457            // install, not its components.
458            RowState::AutoDependency(_) => RowState::Current,
459            other => other,
460        };
461        for skills in &components.skills {
462            rows.push(component_row(
463                ExtensionKind::Skill,
464                &components.plugin,
465                plugin_name,
466                components,
467                skills,
468                inherited.clone(),
469            ));
470        }
471        for agent in &components.agents {
472            rows.push(component_row(
473                ExtensionKind::Agent,
474                &components.plugin,
475                plugin_name,
476                components,
477                agent,
478                inherited.clone(),
479            ));
480        }
481        for command in &components.commands {
482            rows.push(component_row(
483                ExtensionKind::Command,
484                &components.plugin,
485                plugin_name,
486                components,
487                command,
488                inherited.clone(),
489            ));
490        }
491        if !components.hooks.is_empty() {
492            rows.push(ExtensionRow {
493                id: format!("hooks:{plugin_name}"),
494                kind: ExtensionKind::Hook,
495                name: plugin_name.to_owned(),
496                source: components.plugin.clone(),
497                version: components.version.clone(),
498                available_version: components.available_version.clone(),
499                state: inherited.clone(),
500                detail: Some(components.hooks.join(", ")),
501            });
502        }
503        for server in components.lsp_servers.keys() {
504            rows.push(component_row(
505                ExtensionKind::Lsp,
506                &components.plugin,
507                plugin_name,
508                components,
509                server,
510                inherited.clone(),
511            ));
512        }
513    }
514    rows
515}
516
517fn component_row(
518    kind: ExtensionKind,
519    plugin_id: &str,
520    plugin_name: &str,
521    components: &PluginComponents,
522    name: &str,
523    state: RowState,
524) -> ExtensionRow {
525    let kind_label = match kind {
526        ExtensionKind::Skill => "skill",
527        ExtensionKind::Agent => "agent",
528        ExtensionKind::Command => "command",
529        ExtensionKind::Lsp => "lsp",
530        _ => "component",
531    };
532    ExtensionRow {
533        id: format!("{kind_label}:{plugin_name}:{name}"),
534        kind,
535        name: name.to_owned(),
536        source: plugin_id.to_owned(),
537        version: components.version.clone(),
538        available_version: components.available_version.clone(),
539        state,
540        detail: None,
541    }
542}
543
544/// The plugin row's state from a scan result. Priority: a load
545/// failure, then not installed, then disabled, then an update, then
546/// the auto-dependency marker.
547fn plugin_row_state(components: &PluginComponents) -> RowState {
548    if let Some(reason) = &components.load_error {
549        return RowState::LoadFailed(reason.clone());
550    }
551    if !components.installed {
552        return RowState::AvailableNotInstalled;
553    }
554    if !components.enabled {
555        return RowState::Disabled;
556    }
557    let update_available = components
558        .available_version
559        .as_deref()
560        .is_some_and(|available| Some(available) != components.version.as_deref());
561    if update_available {
562        return RowState::UpdateAvailable;
563    }
564    if components.auto {
565        return RowState::AutoDependency("auto-installed".to_owned());
566    }
567    RowState::Current
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    fn installed_entry(id: &str, version: Option<&str>) -> InstalledPluginEntry {
575        InstalledPluginEntry {
576            id: id.to_owned(),
577            version: version.map(str::to_owned),
578            scope: "user".to_owned(),
579            enabled: true,
580            installed_at: None,
581            last_updated: None,
582            project_path: None,
583            capability: PluginCapability::Skill,
584        }
585    }
586
587    fn marketplace_entry(plugin_id: &str, version: Option<&str>) -> MarketplaceEntry {
588        MarketplaceEntry {
589            plugin_id: plugin_id.to_owned(),
590            name: "hello".to_owned(),
591            description: None,
592            marketplace_name: None,
593            version: version.map(str::to_owned),
594            install_count: None,
595            source: None,
596        }
597    }
598
599    #[test]
600    fn marketplace_parses_from_installed_id() {
601        assert_eq!(plugin_marketplace("pensive@claude-night-market"), "claude-night-market");
602        assert_eq!(plugin_marketplace("skills-dir-plugin"), "");
603    }
604
605    #[test]
606    fn update_outcome_classifies_from_output_and_versions() {
607        let updated = classify_update_row(
608            "hello@probe-market",
609            "user",
610            Some("0.2.0"),
611            Some("0.3.0"),
612            "Plugin \"hello\" updated from 0.2.0 to 0.3.0 for scope user.",
613        );
614        assert_eq!(updated.status, PluginRunRowStatus::Updated);
615        assert_eq!(updated.marketplace, "probe-market");
616        assert_eq!(updated.installed_version.as_deref(), Some("0.3.0"));
617
618        let current = classify_update_row(
619            "hello@probe-market",
620            "user",
621            Some("0.2.0"),
622            Some("0.2.0"),
623            "hello is already at the latest version (0.2.0).",
624        );
625        assert_eq!(current.status, PluginRunRowStatus::AlreadyCurrent);
626
627        // Probed CLI behaviour: a missing plugin exits 0 with failure
628        // prose and no version change. That reads as failed, never as
629        // current.
630        let exit_zero_failure = classify_update_row(
631            "hello@probe-market",
632            "user",
633            Some("0.2.0"),
634            Some("0.2.0"),
635            "✘ Failed to update plugin \"hello\": Plugin \"hello\" not found",
636        );
637        assert_eq!(exit_zero_failure.status, PluginRunRowStatus::Failed);
638        assert!(
639            exit_zero_failure.detail.as_deref().is_some_and(|detail| detail.contains("not found")),
640            "the failure prose is the row detail"
641        );
642
643        // An empty success output with an unchanged version is
644        // undecidable and reads as failed; the runner no longer
645        // produces it (it always hands over the CLI's output).
646        let silent_failure =
647            classify_update_row("hello@probe-market", "user", Some("0.2.0"), Some("0.2.0"), "");
648        assert_eq!(silent_failure.status, PluginRunRowStatus::Failed);
649    }
650
651    #[test]
652    fn run_summary_counts_rows_by_status() {
653        let run = PluginUpdateRun {
654            trigger: PluginUpdateTrigger::Manual,
655            finished: true,
656            rows: vec![
657                queued_row_with_status("a@probe-market", PluginRunRowStatus::Updated),
658                queued_row_with_status("b@probe-market", PluginRunRowStatus::Failed),
659                queued_row_with_status("c@probe-market", PluginRunRowStatus::AlreadyCurrent),
660            ],
661        };
662        assert_eq!(run.summary(), "1 updated, 1 failed, 1 current");
663    }
664
665    fn queued_row_with_status(id: &str, status: PluginRunRowStatus) -> PluginUpdateRunRow {
666        let mut row = PluginUpdateRunRow::queued(
667            id.to_owned(),
668            "user".to_owned(),
669            String::new(),
670            Some("1.0.0".to_owned()),
671        );
672        row.status = status;
673        row
674    }
675
676    #[test]
677    fn update_availability_reports_version_divergence_only() {
678        let installed = vec![
679            installed_entry("hello@probe-market", Some("0.2.0")),
680            installed_entry("stale@probe-market", Some("1.0.0")),
681            installed_entry("absent@probe-market", Some("1.0.0")),
682        ];
683        let marketplace = vec![
684            marketplace_entry("hello@probe-market", Some("0.2.0")),
685            marketplace_entry("stale@probe-market", Some("1.1.0")),
686        ];
687
688        let rows = update_availability(&installed, &marketplace);
689        assert_eq!(rows.len(), 1);
690        assert_eq!(rows[0].plugin_id, "stale@probe-market");
691        assert_eq!(rows[0].installed_version.as_deref(), Some("1.0.0"));
692        assert_eq!(rows[0].available_version.as_deref(), Some("1.1.0"));
693        assert_eq!(rows[0].marketplace, "probe-market");
694    }
695
696    fn scan_plugin(id: &str) -> PluginComponents {
697        PluginComponents {
698            plugin: id.to_owned(),
699            marketplace: "probe-market".to_owned(),
700            installed: true,
701            enabled: true,
702            ..PluginComponents::default()
703        }
704    }
705
706    #[test]
707    fn a_plugin_flattens_into_component_rows_with_exact_ids() {
708        let mut superpowers = scan_plugin("superpowers@probe-market");
709        superpowers.version = Some("6.3.0".to_owned());
710        superpowers.skills = vec!["brainstorming".to_owned(), "writing-plans".to_owned()];
711        superpowers.agents = vec!["code-reviewer".to_owned()];
712        superpowers.commands = vec!["review".to_owned()];
713        superpowers.hooks = vec!["SessionStart".to_owned()];
714        superpowers.lsp_servers.insert("rust-analyzer".to_owned(), "rust-analyzer".to_owned());
715
716        let rows = extension_rows(&[superpowers]);
717        let ids: Vec<&str> = rows.iter().map(|row| row.id.as_str()).collect();
718        assert_eq!(
719            ids,
720            vec![
721                "superpowers@probe-market",
722                "skill:superpowers:brainstorming",
723                "skill:superpowers:writing-plans",
724                "agent:superpowers:code-reviewer",
725                "command:superpowers:review",
726                "hooks:superpowers",
727                "lsp:superpowers:rust-analyzer",
728            ],
729            "plugin row first, then its components; got {ids:?}"
730        );
731        let hook = &rows[5];
732        assert_eq!(hook.kind, ExtensionKind::Hook);
733        assert_eq!(hook.detail.as_deref(), Some("SessionStart"));
734        assert_eq!(hook.source, "superpowers@probe-market");
735    }
736
737    #[test]
738    fn an_uninstalled_plugins_skills_render_available_not_installed() {
739        let mut gone = scan_plugin("gone@probe-market");
740        gone.installed = false;
741        gone.enabled = false;
742        gone.version = None;
743        gone.available_version = Some("6.4.0".to_owned());
744        gone.skills = vec!["writing-skills".to_owned()];
745
746        let rows = extension_rows(&[gone]);
747        assert_eq!(rows[0].state, RowState::AvailableNotInstalled);
748        assert_eq!(rows[1].id, "skill:gone:writing-skills");
749        assert_eq!(rows[1].state, RowState::AvailableNotInstalled);
750        assert_eq!(rows[1].available_version.as_deref(), Some("6.4.0"));
751    }
752
753    #[test]
754    fn an_auto_installed_plugin_wears_the_marker_on_its_plugin_row_only() {
755        let mut leyline = scan_plugin("leyline@probe-market");
756        leyline.auto = true;
757        leyline.skills = vec!["utility".to_owned()];
758
759        let rows = extension_rows(&[leyline]);
760        assert_eq!(
761            rows[0].state,
762            RowState::AutoDependency("auto-installed".to_owned()),
763            "the plugin row carries the marker"
764        );
765        assert_eq!(rows[1].state, RowState::Current, "components do not inherit it");
766    }
767
768    #[test]
769    fn a_disabled_plugin_marks_every_row_disabled_and_an_update_outranks_current() {
770        let mut off = scan_plugin("off@probe-market");
771        off.enabled = false;
772        off.version = Some("1.0.0".to_owned());
773        off.skills = vec!["one".to_owned()];
774        let mut stale = scan_plugin("stale@probe-market");
775        stale.version = Some("1.0.0".to_owned());
776        stale.available_version = Some("2.0.0".to_owned());
777        stale.commands = vec!["do".to_owned()];
778
779        let rows = extension_rows(&[off, stale]);
780        assert_eq!(rows[0].state, RowState::Disabled);
781        assert_eq!(rows[1].state, RowState::Disabled);
782        assert_eq!(rows[2].state, RowState::UpdateAvailable);
783        assert_eq!(rows[3].state, RowState::UpdateAvailable);
784    }
785}