返回 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 _ => 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
406 lines RUST