返回 CodeWhale
marketplace.rs
根目录 / crates / tui / src / commands / groups / plugins / marketplace.rs
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 PluginMutationOutcome::NoChange => CommandResult::message(format!(
204 "`{}` is built into Codewhale, so there is nothing to install. \
205 Review it with /plugin show {}.",
206 escape_review_text(candidate_name),
207 escape_review_text(&receipt.name)
208 )),
209 _ => CommandResult::message(format!(
210 "Installed `{}` from marketplace `{}`.",
211 escape_review_text(candidate_name),
212 escape_review_text(catalog_name)
213 )),
214 }
215 }
216 Err(error) => CommandResult::error(error),
217 }
218 }
219
220 fn needs_approval_message(host: &str) -> String {
221 format!(
222 "Network policy requires approval for {host}.\n\
223 Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry."
224 )
225 }
226
227 fn network_denied_message(host: &str) -> String {
228 format!(
229 "Network policy denied access to {host}.\n\
230 Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator."
231 )
232 }
233
234 fn render_catalog_summary(name: &str, catalog: &PluginMarketplaceCatalog) -> String {
235 let mut out = String::new();
236 let display = catalog
237 .display_name
238 .as_deref()
239 .filter(|d| !d.trim().is_empty());
240 let _ = writeln!(
241 out,
242 "`{}` — {} format, {} candidate(s), tier={} (display only)",
243 escape_review_text(name),
244 catalog.format,
245 catalog.total_candidates,
246 catalog.tier
247 );
248 if let Some(display) = display {
249 let _ = writeln!(out, " display name: {}", escape_review_text(display));
250 }
251 if let Some(description) = catalog
252 .description
253 .as_deref()
254 .filter(|d| !d.trim().is_empty())
255 {
256 let _ = writeln!(out, " {}", escape_review_text(description));
257 }
258 if !catalog.diagnostics.is_empty() {
259 let _ = writeln!(
260 out,
261 " catalog diagnostics: {}",
262 render_diagnostics_inline(&catalog.diagnostics)
263 );
264 }
265 out
266 }
267
268 fn render_candidates(
269 presentation: &mut dyn CommandPresentationContext,
270 catalog: &PluginMarketplaceCatalog,
271 detailed: bool,
272 ) -> String {
273 let mut out = String::new();
274 for candidate in &catalog.candidates {
275 let status = if candidate.has_errors {
276 "unusable"
277 } else if matches!(
278 candidate.install_plan,
279 PluginMarketplaceInstallPlan::AlreadyPresent { .. }
280 ) {
281 "name already present"
282 } else {
283 "candidate"
284 };
285 let _ = write!(
286 out,
287 " • {} [{}] — {}",
288 escape_review_text(&candidate.name),
289 status,
290 candidate
291 .display_name
292 .as_deref()
293 .map(escape_review_text)
294 .as_deref()
295 .unwrap_or("no display name")
296 );
297 if let Some(version) = &candidate.version {
298 let _ = write!(out, " · v{}", escape_review_text(version));
299 }
300 let _ = write!(out, " · tier={}", candidate.tier);
301 let _ = writeln!(out);
302 let compatibility = candidate
303 .compatibility
304 .clone()
305 .unwrap_or_else(|| "decided at install review".to_string());
306 let _ = writeln!(out, " compatibility: {compatibility}");
307 match &candidate.install_plan {
308 PluginMarketplaceInstallPlan::Supported {
309 spec: _,
310 source_kind,
311 } => {
312 let source_kind = localized_plan_text(presentation, source_kind);
313 let _ = writeln!(
314 out,
315 " installable via {source_kind}: /plugin marketplace install {} {}",
316 escape_review_text(&catalog.id),
317 escape_review_text(&candidate.name)
318 );
319 }
320 PluginMarketplaceInstallPlan::AlreadyPresent { selector, reason } => {
321 let _ = writeln!(out, " {}", escape_review_text(reason));
322 let _ = writeln!(
323 out,
324 " manage: /plugin show {}",
325 escape_review_text(selector)
326 );
327 }
328 PluginMarketplaceInstallPlan::Unsupported { reason } => {
329 let reason = localized_plan_text(presentation, reason);
330 let _ = writeln!(out, " not installable: {}", escape_review_text(&reason));
331 }
332 }
333 if detailed {
334 if let Some(description) = candidate
335 .description
336 .as_deref()
337 .filter(|d| !d.trim().is_empty())
338 {
339 let _ = writeln!(out, " {}", escape_review_text(description));
340 }
341 if let Some(homepage) = &candidate.homepage {
342 let _ = writeln!(out, " homepage: {}", escape_review_text(homepage));
343 }
344 if let Some(repository) = &candidate.repository {
345 let _ = writeln!(out, " repository: {}", escape_review_text(repository));
346 }
347 if let Some(author) = &candidate.author {
348 let _ = writeln!(out, " author: {}", escape_review_text(author));
349 }
350 if let Some(license) = &candidate.license {
351 let _ = writeln!(out, " license: {}", escape_review_text(license));
352 }
353 if !candidate.keywords.is_empty() {
354 let _ = writeln!(
355 out,
356 " keywords: {}",
357 escape_review_text(&candidate.keywords.join(", "))
358 );
359 }
360 if let Some(when) = &candidate.when {
361 let _ = writeln!(out, " when: {when}");
362 }
363 }
364 if !candidate.diagnostics.is_empty() {
365 let _ = writeln!(
366 out,
367 " diagnostics: {}",
368 render_diagnostics_inline(&candidate.diagnostics)
369 );
370 }
371 }
372 out
373 }
374
375 /// Resolve a marketplace plan code through the presentation facet, falling
376 /// back to the raw code when unknown (mirrors the legacy localized plan text).
377 fn localized_plan_text(presentation: &mut dyn CommandPresentationContext, value: &str) -> String {
378 presentation
379 .translate(value, &[])
380 .unwrap_or_else(|_| value.to_string())
381 }
382
383 fn render_diagnostics_inline(
384 diagnostics: &[codewhale_command_contract::facets::PluginDiagnostic],
385 ) -> String {
386 diagnostics
387 .iter()
388 .map(|d| {
389 format!(
390 "{} {}: {}",
391 match d.level {
392 codewhale_command_contract::facets::PluginDiagnosticLevel::Error => "error",
393 codewhale_command_contract::facets::PluginDiagnosticLevel::Warning => {
394 "warning"
395 }
396 },
397 d.code,
398 escape_review_text(&d.message)
399 )
400 })
401 .collect::<Vec<_>>()
402 .join("; ")
403 }
404
405 pub(super) fn escape_review_text(value: &str) -> String {
406 super::escape_review_text(value)
407 }
408
409 pub(super) fn escape_review_path(path: &Path) -> String {
410 super::escape_review_path(path)
411 }
412
412 lines RUST