1use std::collections::BTreeMap;
7use std::path::PathBuf;
8
9use serde_json::Value;
10
11#[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 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 pub components: Vec<PluginComponents>,
77 pub marketplace_health: Vec<MarketplaceHealth>,
80 pub token_costs: BTreeMap<String, (String, PluginDetails)>,
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93pub struct PluginComponents {
94 pub plugin: String,
96 pub marketplace: String,
97 pub version: Option<String>,
99 pub installed: bool,
100 pub enabled: bool,
103 pub auto: bool,
105 pub available_version: Option<String>,
107 pub skills: Vec<String>,
108 pub agents: Vec<String>,
109 pub commands: Vec<String>,
110 pub hooks: Vec<String>,
112 pub mcp: bool,
114 pub lsp_servers: BTreeMap<String, String>,
117 pub load_error: Option<String>,
121}
122
123#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
125pub struct MarketplaceHealth {
126 pub name: String,
127 pub source: String,
128 pub available: usize,
131 pub load_error: Option<String>,
136 pub install_location: PathBuf,
137 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum PluginRunRowStatus {
152 Queued,
154 Updating,
156 Updated,
158 AlreadyCurrent,
160 Failed,
162 Skipped,
164 UpdateAvailable,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
171pub struct PluginUpdateRunRow {
172 pub plugin_id: String,
173 pub scope: String,
174 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 pub detail: Option<String>,
183}
184
185impl PluginUpdateRunRow {
186 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#[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 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
255const ALREADY_CURRENT_MARKER: &str = "is already at the latest version";
257
258pub 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
289fn 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
302pub fn plugin_marketplace(id: &str) -> &str {
305 id.split_once('@').map_or("", |(_, marketplace)| marketplace)
306}
307
308#[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
320pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
377pub struct ExtensionRow {
378 pub id: String,
383 pub kind: ExtensionKind,
384 pub name: String,
385 pub source: String,
387 pub version: Option<String>,
388 pub available_version: Option<String>,
389 pub state: RowState,
390 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#[derive(Debug, Clone, PartialEq, Eq)]
407pub enum RowState {
408 Current,
409 UpdateAvailable,
410 AvailableNotInstalled,
411 Disabled,
412 LoadFailed(String),
414 AutoDependency(String),
417 RestartRequired,
419}
420
421pub 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 RowState::AutoDependency(_) => None,
443 _ 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 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
544fn 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 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 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}