| 1 | //! `/plugin marketplace` — the #5311 user journey over the catalog parsers. |
| 2 | //! |
| 3 | //! `add` reads a LOCAL catalog document (no network here, ever), parses it |
| 4 | //! with the strict per-format parsers, and persists the parsed result next to |
| 5 | //! the plugin registry state. `list`/`show` render candidates with their |
| 6 | //! honest install plans and per-entry diagnostics. `install` routes a |
| 7 | //! candidate through the EXISTING reviewed installer — the same code path as |
| 8 | //! `/plugin install`, so installed bundles still enter disabled and untrusted. |
| 9 | //! |
| 10 | //! Catalog-declared tiers and provenance are display-only: nothing in this |
| 11 | //! module grants trust, enables anything, or auto-installs (Codex |
| 12 | //! `INSTALLED_BY_DEFAULT` is visibly ignored). |
| 13 | //! |
| 14 | //! FEAT-020: the marketplace store/parse/install machinery runs host-side in |
| 15 | //! the TUI adapter; the handler consumes portable marketplace values and |
| 16 | //! renders them. |
| 17 | |
| 18 | use std::fmt::Write as _; |
| 19 | use std::path::{Path, PathBuf}; |
| 20 | |
| 21 | use codewhale_command_contract::facets::{ |
| 22 | CommandPluginContext, CommandPresentationContext, PluginMarketplaceCatalog, |
| 23 | PluginMarketplaceInstallPlan, |
| 24 | }; |
| 25 | |
| 26 | use crate::commands::CommandResult; |
| 27 | |
| 28 | const USAGE: &str = "Usage: /plugin marketplace add|list|show|remove|install\n\ |
| 29 | \x20 add <name> <path> read a local catalog file (kimi/claude/codex/codewhale)\n\ |
| 30 | \x20 list show catalogs and their candidates\n\ |
| 31 | \x20 show <name> one catalog in detail\n\ |
| 32 | \x20 remove <name> forget a catalog (installed plugins unaffected)\n\ |
| 33 | \x20 install <catalog> <candidate> install via the reviewed installer"; |
| 34 | |
| 35 | pub(super) fn dispatch( |
| 36 | presentation: &mut dyn CommandPresentationContext, |
| 37 | plugin: &mut dyn CommandPluginContext, |
| 38 | words: &[&str], |
| 39 | ) -> CommandResult { |
| 40 | match words { |
| 41 | [] | ["list"] => list(presentation, plugin), |
| 42 | ["add", name, path] => add(presentation, plugin, name, path), |
| 43 | ["show", name] => show(presentation, plugin, name), |
| 44 | ["remove", name] => remove(presentation, plugin, name), |
| 45 | ["install", catalog, candidate] => install(presentation, plugin, catalog, candidate), |
| 46 | _ => CommandResult::error(USAGE), |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | fn add( |
| 51 | _presentation: &mut dyn CommandPresentationContext, |
| 52 | plugin: &mut dyn CommandPluginContext, |
| 53 | name: &str, |
| 54 | raw_path: &str, |
| 55 | ) -> CommandResult { |
| 56 | let path = PathBuf::from(raw_path.trim()); |
| 57 | let path = if path.is_absolute() { |
| 58 | path |
| 59 | } else { |
| 60 | PathBuf::from(".").join(path) |
| 61 | }; |
| 62 | match plugin.marketplace_add(name, &path) { |
| 63 | Ok(receipt) => { |
| 64 | let summary = render_catalog_summary(name, &receipt.catalog); |
| 65 | CommandResult::message(format!( |
| 66 | "Added marketplace `{}` ({} candidate(s), {} warning(s)).\n{summary}\n\ |
| 67 | Tiers and provenance are display-only. Nothing was installed, trusted, or enabled.", |
| 68 | escape_review_text(name), |
| 69 | receipt.candidate_count, |
| 70 | receipt.warning_count, |
| 71 | )) |
| 72 | } |
| 73 | Err(error) => CommandResult::error(error), |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | fn list( |
| 78 | presentation: &mut dyn CommandPresentationContext, |
| 79 | plugin: &dyn CommandPluginContext, |
| 80 | ) -> CommandResult { |
| 81 | let state = match plugin.marketplace_state() { |
| 82 | Ok(state) => state, |
| 83 | Err(error) => { |
| 84 | return CommandResult::error(format!( |
| 85 | "Marketplace state is fail-closed and will not be rewritten: {error}" |
| 86 | )); |
| 87 | } |
| 88 | }; |
| 89 | if state.official.is_none() && state.stored.is_empty() { |
| 90 | return CommandResult::message(format!( |
| 91 | "No marketplace catalogs are registered.\n{USAGE}\n\ |
| 92 | Reads a LOCAL catalog file; nothing is fetched over the network." |
| 93 | )); |
| 94 | } |
| 95 | let mut output = String::from("Marketplace catalogs:\n"); |
| 96 | if let Some(official) = &state.official { |
| 97 | output.push('\n'); |
| 98 | output.push_str(&render_catalog_summary("official", official)); |
| 99 | output.push_str(" built into this Codewhale release; nothing is downloaded\n"); |
| 100 | output.push_str(&render_candidates(presentation, official, false)); |
| 101 | } |
| 102 | for catalog in &state.stored { |
| 103 | output.push('\n'); |
| 104 | output.push_str(&render_catalog_summary(&catalog.id, catalog)); |
| 105 | output.push_str(&render_candidates(presentation, catalog, false)); |
| 106 | } |
| 107 | output.push_str( |
| 108 | "\nTiers and provenance are display-only. Install with /plugin marketplace install <catalog> <candidate>; \ |
| 109 | installs go through the reviewed installer and start disabled and untrusted.", |
| 110 | ); |
| 111 | CommandResult::message(output) |
| 112 | } |
| 113 | |
| 114 | fn show( |
| 115 | presentation: &mut dyn CommandPresentationContext, |
| 116 | plugin: &dyn CommandPluginContext, |
| 117 | name: &str, |
| 118 | ) -> CommandResult { |
| 119 | let state = match plugin.marketplace_state() { |
| 120 | Ok(state) => state, |
| 121 | Err(error) => { |
| 122 | return CommandResult::error(format!( |
| 123 | "Marketplace state is fail-closed and will not be rewritten: {error}" |
| 124 | )); |
| 125 | } |
| 126 | }; |
| 127 | let catalog = if name == "official" { |
| 128 | state.official.as_ref() |
| 129 | } else { |
| 130 | state.stored.iter().find(|catalog| catalog.id == name) |
| 131 | }; |
| 132 | let Some(catalog) = catalog else { |
| 133 | return CommandResult::error(format!( |
| 134 | "No marketplace named `{}`. Use /plugin marketplace list.", |
| 135 | escape_review_text(name) |
| 136 | )); |
| 137 | }; |
| 138 | let mut output = render_catalog_summary(name, catalog); |
| 139 | output.push_str("\n added from: "); |
| 140 | let _ = writeln!( |
| 141 | output, |
| 142 | "{}", |
| 143 | escape_review_path(Path::new(catalog.source_path.as_deref().unwrap_or(name))) |
| 144 | ); |
| 145 | output.push_str(&render_candidates(presentation, catalog, true)); |
| 146 | CommandResult::message(output) |
| 147 | } |
| 148 | |
| 149 | fn remove( |
| 150 | _presentation: &mut dyn CommandPresentationContext, |
| 151 | plugin: &mut dyn CommandPluginContext, |
| 152 | name: &str, |
| 153 | ) -> CommandResult { |
| 154 | match plugin.marketplace_remove(name) { |
| 155 | Ok(true) => CommandResult::message(format!( |
| 156 | "Removed marketplace `{}`. Installed plugins and their trust state are unaffected.", |
| 157 | escape_review_text(name) |
| 158 | )), |
| 159 | Ok(false) => CommandResult::error(format!( |
| 160 | "No marketplace named `{}`. Use /plugin marketplace list.", |
| 161 | escape_review_text(name) |
| 162 | )), |
| 163 | Err(error) => CommandResult::error(error), |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | fn install( |
| 168 | presentation: &mut dyn CommandPresentationContext, |
| 169 | plugin: &mut dyn CommandPluginContext, |
| 170 | catalog_name: &str, |
| 171 | candidate_name: &str, |
| 172 | ) -> CommandResult { |
| 173 | match plugin.marketplace_install(catalog_name, candidate_name) { |
| 174 | Ok(receipt) => { |
| 175 | use codewhale_command_contract::facets::PluginMutationOutcome; |
| 176 | match receipt.outcome { |
| 177 | PluginMutationOutcome::Installed => { |
| 178 | // Marketplace installs route through the same reviewed |
| 179 | // installer as `/plugin install`: the result is disabled |
| 180 | // and untrusted and drops into the trust review. |
| 181 | let name = receipt.name; |
| 182 | let mut output = format!( |
| 183 | "Installed plugin '{name}' from marketplace `{}`.\n\ |
| 184 | It is disabled and untrusted. Review its requested authority below, then trust and enable it.\n", |
| 185 | escape_review_text(catalog_name) |
| 186 | ); |
| 187 | if let Some(review) = super::review_bundle(presentation, plugin, &name).message |
| 188 | { |
| 189 | output.push('\n'); |
| 190 | output.push_str(&review); |
| 191 | } |
| 192 | CommandResult::with_message_and_action( |
| 193 | output, |
| 194 | crate::tui::app::AppAction::PluginRegistryChanged, |
| 195 | ) |
| 196 | } |
| 197 | PluginMutationOutcome::NeedsApproval(host) => { |
| 198 | CommandResult::error(needs_approval_message(&host)) |
| 199 | } |
| 200 | PluginMutationOutcome::NetworkDenied(host) => { |
| 201 | CommandResult::error(network_denied_message(&host)) |
| 202 | } |
| 203 | _ => CommandResult::message(format!( |
| 204 | "Installed `{}` from marketplace `{}`.", |
| 205 | escape_review_text(candidate_name), |
| 206 | escape_review_text(catalog_name) |
| 207 | )), |
| 208 | } |
| 209 | } |
| 210 | Err(error) => CommandResult::error(error), |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | fn needs_approval_message(host: &str) -> String { |
| 215 | format!( |
| 216 | "Network policy requires approval for {host}.\n\ |
| 217 | Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry." |
| 218 | ) |
| 219 | } |
| 220 | |
| 221 | fn network_denied_message(host: &str) -> String { |
| 222 | format!( |
| 223 | "Network policy denied access to {host}.\n\ |
| 224 | Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator." |
| 225 | ) |
| 226 | } |
| 227 | |
| 228 | fn render_catalog_summary(name: &str, catalog: &PluginMarketplaceCatalog) -> String { |
| 229 | let mut out = String::new(); |
| 230 | let display = catalog |
| 231 | .display_name |
| 232 | .as_deref() |
| 233 | .filter(|d| !d.trim().is_empty()); |
| 234 | let _ = writeln!( |
| 235 | out, |
| 236 | "`{}` — {} format, {} candidate(s), tier={} (display only)", |
| 237 | escape_review_text(name), |
| 238 | catalog.format, |
| 239 | catalog.total_candidates, |
| 240 | catalog.tier |
| 241 | ); |
| 242 | if let Some(display) = display { |
| 243 | let _ = writeln!(out, " display name: {}", escape_review_text(display)); |
| 244 | } |
| 245 | if let Some(description) = catalog |
| 246 | .description |
| 247 | .as_deref() |
| 248 | .filter(|d| !d.trim().is_empty()) |
| 249 | { |
| 250 | let _ = writeln!(out, " {}", escape_review_text(description)); |
| 251 | } |
| 252 | if !catalog.diagnostics.is_empty() { |
| 253 | let _ = writeln!( |
| 254 | out, |
| 255 | " catalog diagnostics: {}", |
| 256 | render_diagnostics_inline(&catalog.diagnostics) |
| 257 | ); |
| 258 | } |
| 259 | out |
| 260 | } |
| 261 | |
| 262 | fn render_candidates( |
| 263 | presentation: &mut dyn CommandPresentationContext, |
| 264 | catalog: &PluginMarketplaceCatalog, |
| 265 | detailed: bool, |
| 266 | ) -> String { |
| 267 | let mut out = String::new(); |
| 268 | for candidate in &catalog.candidates { |
| 269 | let status = if candidate.has_errors { |
| 270 | "unusable" |
| 271 | } else if matches!( |
| 272 | candidate.install_plan, |
| 273 | PluginMarketplaceInstallPlan::AlreadyPresent { .. } |
| 274 | ) { |
| 275 | "name already present" |
| 276 | } else { |
| 277 | "candidate" |
| 278 | }; |
| 279 | let _ = write!( |
| 280 | out, |
| 281 | " • {} [{}] — {}", |
| 282 | escape_review_text(&candidate.name), |
| 283 | status, |
| 284 | candidate |
| 285 | .display_name |
| 286 | .as_deref() |
| 287 | .map(escape_review_text) |
| 288 | .as_deref() |
| 289 | .unwrap_or("no display name") |
| 290 | ); |
| 291 | if let Some(version) = &candidate.version { |
| 292 | let _ = write!(out, " · v{}", escape_review_text(version)); |
| 293 | } |
| 294 | let _ = write!(out, " · tier={}", candidate.tier); |
| 295 | let _ = writeln!(out); |
| 296 | let compatibility = candidate |
| 297 | .compatibility |
| 298 | .clone() |
| 299 | .unwrap_or_else(|| "decided at install review".to_string()); |
| 300 | let _ = writeln!(out, " compatibility: {compatibility}"); |
| 301 | match &candidate.install_plan { |
| 302 | PluginMarketplaceInstallPlan::Supported { |
| 303 | spec: _, |
| 304 | source_kind, |
| 305 | } => { |
| 306 | let source_kind = localized_plan_text(presentation, source_kind); |
| 307 | let _ = writeln!( |
| 308 | out, |
| 309 | " installable via {source_kind}: /plugin marketplace install {} {}", |
| 310 | escape_review_text(&catalog.id), |
| 311 | escape_review_text(&candidate.name) |
| 312 | ); |
| 313 | } |
| 314 | PluginMarketplaceInstallPlan::AlreadyPresent { selector, reason } => { |
| 315 | let _ = writeln!(out, " {}", escape_review_text(reason)); |
| 316 | let _ = writeln!( |
| 317 | out, |
| 318 | " manage: /plugin show {}", |
| 319 | escape_review_text(selector) |
| 320 | ); |
| 321 | } |
| 322 | PluginMarketplaceInstallPlan::Unsupported { reason } => { |
| 323 | let reason = localized_plan_text(presentation, reason); |
| 324 | let _ = writeln!(out, " not installable: {}", escape_review_text(&reason)); |
| 325 | } |
| 326 | } |
| 327 | if detailed { |
| 328 | if let Some(description) = candidate |
| 329 | .description |
| 330 | .as_deref() |
| 331 | .filter(|d| !d.trim().is_empty()) |
| 332 | { |
| 333 | let _ = writeln!(out, " {}", escape_review_text(description)); |
| 334 | } |
| 335 | if let Some(homepage) = &candidate.homepage { |
| 336 | let _ = writeln!(out, " homepage: {}", escape_review_text(homepage)); |
| 337 | } |
| 338 | if let Some(repository) = &candidate.repository { |
| 339 | let _ = writeln!(out, " repository: {}", escape_review_text(repository)); |
| 340 | } |
| 341 | if let Some(author) = &candidate.author { |
| 342 | let _ = writeln!(out, " author: {}", escape_review_text(author)); |
| 343 | } |
| 344 | if let Some(license) = &candidate.license { |
| 345 | let _ = writeln!(out, " license: {}", escape_review_text(license)); |
| 346 | } |
| 347 | if !candidate.keywords.is_empty() { |
| 348 | let _ = writeln!( |
| 349 | out, |
| 350 | " keywords: {}", |
| 351 | escape_review_text(&candidate.keywords.join(", ")) |
| 352 | ); |
| 353 | } |
| 354 | if let Some(when) = &candidate.when { |
| 355 | let _ = writeln!(out, " when: {when}"); |
| 356 | } |
| 357 | } |
| 358 | if !candidate.diagnostics.is_empty() { |
| 359 | let _ = writeln!( |
| 360 | out, |
| 361 | " diagnostics: {}", |
| 362 | render_diagnostics_inline(&candidate.diagnostics) |
| 363 | ); |
| 364 | } |
| 365 | } |
| 366 | out |
| 367 | } |
| 368 | |
| 369 | /// Resolve a marketplace plan code through the presentation facet, falling |
| 370 | /// back to the raw code when unknown (mirrors the legacy localized plan text). |
| 371 | fn localized_plan_text(presentation: &mut dyn CommandPresentationContext, value: &str) -> String { |
| 372 | presentation |
| 373 | .translate(value, &[]) |
| 374 | .unwrap_or_else(|_| value.to_string()) |
| 375 | } |
| 376 | |
| 377 | fn render_diagnostics_inline( |
| 378 | diagnostics: &[codewhale_command_contract::facets::PluginDiagnostic], |
| 379 | ) -> String { |
| 380 | diagnostics |
| 381 | .iter() |
| 382 | .map(|d| { |
| 383 | format!( |
| 384 | "{} {}: {}", |
| 385 | match d.level { |
| 386 | codewhale_command_contract::facets::PluginDiagnosticLevel::Error => "error", |
| 387 | codewhale_command_contract::facets::PluginDiagnosticLevel::Warning => { |
| 388 | "warning" |
| 389 | } |
| 390 | }, |
| 391 | d.code, |
| 392 | escape_review_text(&d.message) |
| 393 | ) |
| 394 | }) |
| 395 | .collect::<Vec<_>>() |
| 396 | .join("; ") |
| 397 | } |
| 398 | |
| 399 | pub(super) fn escape_review_text(value: &str) -> String { |
| 400 | super::escape_review_text(value) |
| 401 | } |
| 402 | |
| 403 | pub(super) fn escape_review_path(path: &Path) -> String { |
| 404 | super::escape_review_path(path) |
| 405 | } |
| 406 |