返回 CodeWhale
mod.rs
根目录 / crates / tui / src / commands / groups / plugins / mod.rs
1 //! Codewhale bundle lifecycle and legacy executable plugin-tool inventory.
2 //!
3 //! `/plugin` owns declarative bundles (`plugin.toml`). Script tools under
4 //! `[tools].plugin_dir` remain supported, but are labeled as legacy executable
5 //! tools and never share bundle trust state.
6 //!
7 //! # Module map
8 //!
9 //! This file is the command surface: registration, the `/plugin` verb
10 //! dispatch, and the bundle lifecycle verbs (list/show/trust/validate/
11 //! install/update/uninstall/enable/disable/revoke). Two seams live next
12 //! door:
13 //!
14 //! * [`render`] — every string the user reads: bundle detail, the
15 //! capability review body, diagnostics, and the escaping that keeps
16 //! manifest-controlled text from forging review output.
17 //! * [`legacy`] — the separate `[tools].plugin_dir` executable inventory,
18 //! which shares no trust state with declarative bundles.
19 //!
20 //! FEAT-020 converts this group to the portable command contract: every
21 //! production handler consumes workspace, presentation, and plugin facets —
22 //! never concrete `App`, `PluginRegistry`, or `Config`. Production registration
23 //! uses `ContextualCommand::from_contract`; a test-only shell builds the same
24 //! capability bundle for focused parity tests. `CommandResult` and `AppAction`
25 //! remain temporary TUI-owned references until FEAT-037.
26
27 use std::fmt::Write as _;
28 use std::path::{Path, PathBuf};
29
30 use codewhale_command_contract::facets::{
31 CommandPluginContext, CommandPresentationContext, PluginDetail, PluginDiagnosticLevel,
32 PluginMutationOutcome, PluginMutationReceipt,
33 };
34 use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler};
35 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
36
37 use crate::commands::CommandResult;
38 use crate::commands::traits::{CommandGroup, ContextualCommand};
39 #[cfg(test)]
40 use crate::tui::app::App;
41 use crate::tui::app::AppAction;
42
43 pub(crate) mod kimi_import;
44 pub(crate) mod legacy;
45 pub(crate) mod marketplace;
46 #[cfg(test)]
47 mod marketplace_tests;
48 pub(crate) mod render;
49
50 #[cfg(test)]
51 mod tests;
52
53 use legacy::legacy_tools;
54
55 pub struct PluginsCommands;
56
57 impl CommandGroup for PluginsCommands {
58 fn commands(&self) -> &'static [Box<dyn crate::commands::traits::Command>] {
59 cached_command_list!(vec![Box::new(
60 ContextualCommand::from_contract::<PluginsCmd>().expect("plugin registration"),
61 )])
62 }
63 }
64
65 pub(in crate::commands) const PLUGINS_INFO: CommandInfo = CommandInfo {
66 name: "plugin",
67 aliases: &["plugins", "extensions"],
68 usage: "/plugin [list|show|suggest|validate|export|install|import|update|uninstall|trust|enable|disable|revoke|reload|tools|marketplace]",
69 description_key: "cmd_plugin_description",
70 };
71
72 pub(in crate::commands) struct PluginsCmd;
73
74 impl RegisterCommand<CommandResult> for PluginsCmd {
75 fn info() -> &'static CommandInfo {
76 &PLUGINS_INFO
77 }
78
79 fn handler() -> CommandHandler<CommandResult> {
80 CommandHandler::Contextual {
81 capabilities: CommandCapabilities::WORKSPACE
82 .union(CommandCapabilities::PRESENTATION)
83 .union(CommandCapabilities::PLUGIN),
84 handler: plugins_contextual,
85 }
86 }
87 }
88
89 fn plugins_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
90 let mut parts = contexts.into_parts();
91 let Some(workspace) = parts.workspace.as_deref() else {
92 return CommandResult::error("Command capability unavailable: workspace");
93 };
94 let Some(presentation) = parts.presentation.as_deref_mut() else {
95 return CommandResult::error("Command capability unavailable: presentation");
96 };
97 let Some(plugin) = parts.plugin.as_deref_mut() else {
98 return CommandResult::error("Command capability unavailable: plugin");
99 };
100 plugins(&workspace.workspace(), presentation, plugin, arg, None)
101 }
102
103 #[cfg(test)]
104 fn plugins_with_kimi_home(app: &mut App, arg: Option<&str>, home: &Path) -> CommandResult {
105 plugins_with_kimi_home_override(app, arg, Some(home))
106 }
107
108 #[cfg(test)]
109 fn plugins_with_kimi_home_override(
110 app: &mut App,
111 arg: Option<&str>,
112 kimi_home: Option<&Path>,
113 ) -> CommandResult {
114 let mut bundle = app.command_contexts();
115 let capabilities = CommandCapabilities::WORKSPACE
116 .union(CommandCapabilities::PRESENTATION)
117 .union(CommandCapabilities::PLUGIN);
118 let mut contexts = bundle.contexts(capabilities).into_parts();
119 let Some(workspace) = contexts.workspace.as_deref() else {
120 return CommandResult::error("Command capability unavailable: workspace");
121 };
122 let Some(presentation) = contexts.presentation.as_deref_mut() else {
123 return CommandResult::error("Command capability unavailable: presentation");
124 };
125 let Some(plugin) = contexts.plugin.as_deref_mut() else {
126 return CommandResult::error("Command capability unavailable: plugin");
127 };
128 plugins(&workspace.workspace(), presentation, plugin, arg, kimi_home)
129 }
130
131 /// Portable `/plugin` dispatch (FEAT-020 Phase 4).
132 ///
133 /// The handler consumes only portable facets; all concrete host access lives
134 /// in the TUI adapter. `kimi_home` is a test-only home override for the Kimi
135 /// managed-import scan.
136 pub(super) fn plugins(
137 workspace: &Path,
138 presentation: &mut dyn CommandPresentationContext,
139 plugin: &mut dyn CommandPluginContext,
140 arg: Option<&str>,
141 kimi_home: Option<&Path>,
142 ) -> CommandResult {
143 let words = arg
144 .unwrap_or_default()
145 .split_whitespace()
146 .collect::<Vec<_>>();
147 match words.as_slice() {
148 [] => CommandResult::action(AppAction::OpenExtensions {
149 tab: crate::tui::views::extensions::ExtensionsTab::Plugins,
150 }),
151 ["list"] => list_bundles_and_legacy_tools(presentation, plugin),
152 ["help"] => CommandResult::message(format!(
153 "{}\n\n/plugin import kimi [list]\n/plugin import kimi approve <name> <content-hash>",
154 translate(presentation, "cmd_plugin_bundle_usage")
155 )),
156 ["marketplace", rest @ ..] => marketplace::dispatch(presentation, plugin, rest),
157 ["import", "kimi", rest @ ..] => {
158 kimi_import::dispatch(presentation, plugin, rest, kimi_home)
159 }
160 ["import", ..] => CommandResult::error(kimi_import::usage(presentation)),
161 ["show", selector] => show_bundle(presentation, plugin, selector),
162 ["suggest"] | ["recommend"] => CommandResult::error("Usage: /plugin suggest <task>"),
163 ["suggest", task @ ..] | ["recommend", task @ ..] => {
164 suggest_bundles(presentation, plugin, &task.join(" "))
165 }
166 ["validate"] => validate_bundles(presentation, plugin, None),
167 ["validate", selector] => validate_bundles(presentation, plugin, Some(selector)),
168 ["export"] => CommandResult::error("Usage: /plugin export <name> <target-dir>"),
169 ["export", selector, target @ ..] => {
170 export_bundle(workspace, presentation, plugin, selector, &target.join(" "))
171 }
172 ["install"] => CommandResult::error(translate(presentation, "cmd_plugin_bundle_usage")),
173 ["install", rest @ ..] => install_bundle(presentation, plugin, &rest.join(" ")),
174 ["update"] | ["uninstall"] => {
175 CommandResult::error(translate(presentation, "cmd_plugin_bundle_usage"))
176 }
177 ["update", selector] => update_bundle(presentation, plugin, selector),
178 ["uninstall", selector] => uninstall_bundle(presentation, plugin, selector),
179 ["trust", selector] => review_bundle(presentation, plugin, selector),
180 ["trust", selector, token] => {
181 mutate_bundle(presentation, plugin, selector, Mutation::Trust(token))
182 }
183 ["enable", selector] => mutate_bundle(presentation, plugin, selector, Mutation::Enable),
184 ["disable", selector] => mutate_bundle(presentation, plugin, selector, Mutation::Disable),
185 ["revoke", selector] => mutate_bundle(presentation, plugin, selector, Mutation::Revoke),
186 ["reload"] => reload(presentation, plugin),
187 ["tools"] => legacy_tools(presentation, plugin, None),
188 ["tools", name] => legacy_tools(presentation, plugin, Some(name)),
189 [selector] => {
190 if plugin.detail(selector).is_ok() {
191 show_bundle(presentation, plugin, selector)
192 } else {
193 // Preserve `/plugin <script-tool>` compatibility while making
194 // its distinct execution model explicit in the output.
195 legacy_tools(presentation, plugin, Some(selector))
196 }
197 }
198 _ => CommandResult::error(translate(presentation, "cmd_plugin_bundle_usage")),
199 }
200 }
201
202 /// Translate one stable plugin key through the presentation facet.
203 fn translate(presentation: &mut dyn CommandPresentationContext, key: &str) -> String {
204 presentation.translate(key, &[]).unwrap_or_default()
205 }
206
207 fn reload(
208 presentation: &mut dyn CommandPresentationContext,
209 plugin: &mut dyn CommandPluginContext,
210 ) -> CommandResult {
211 match plugin.reload() {
212 Ok(count) => {
213 let message = presentation
214 .translate(
215 "cmd_plugin_bundle_reloaded",
216 &[("count", &count.to_string())],
217 )
218 .unwrap_or_default();
219 CommandResult::with_message_and_action(message, AppAction::PluginRegistryChanged)
220 }
221 Err(error) => action_error(presentation, &format!("Plugin reload failed: {error}")),
222 }
223 }
224
225 /// Rank installed bundles and locally-added marketplace candidates for a task
226 /// without changing trust, enablement, disk state, or network state.
227 fn suggest_bundles(
228 _presentation: &mut dyn CommandPresentationContext,
229 plugin: &dyn CommandPluginContext,
230 task: &str,
231 ) -> CommandResult {
232 let task = task.trim();
233 if task.chars().count() < 3 {
234 return CommandResult::error("Usage: /plugin suggest <task of at least 3 characters>");
235 }
236 let suggestions = plugin.suggest(task).unwrap_or_default();
237 if suggestions.is_empty() {
238 return CommandResult::message(format!(
239 "No installed or catalog plugin matched `{}`.\n\nInstall a reviewed bundle with /plugin install <source>, or add a catalog with /plugin marketplace add. Nothing was installed, trusted, or enabled.",
240 escape_review_text(task)
241 ));
242 }
243 let mut output = format!("Suggested plugins for `{}`:\n", escape_review_text(task));
244 output.push_str("─────────────────────────────\n");
245 for suggestion in suggestions {
246 let why = suggestion
247 .why
248 .iter()
249 .map(|term| escape_review_text(term))
250 .collect::<Vec<_>>()
251 .join(", ");
252 let _ = writeln!(
253 output,
254 " {} — {} · {}",
255 escape_review_text(&suggestion.name),
256 suggestion.state_label,
257 escape_review_text(&suggestion.description)
258 );
259 let _ = writeln!(output, " Why: {why}");
260 let _ = writeln!(output, " {}", suggestion.next_step);
261 }
262 output.push_str("\nNothing was installed, trusted, or enabled.");
263 CommandResult::message(output)
264 }
265
266 fn list_bundles_and_legacy_tools(
267 presentation: &mut dyn CommandPresentationContext,
268 plugin: &mut dyn CommandPluginContext,
269 ) -> CommandResult {
270 let summaries = plugin.summaries().unwrap_or_default();
271 let mut output = if summaries.is_empty() {
272 translate(presentation, "cmd_plugin_bundle_none_found")
273 } else {
274 let mut output = presentation
275 .translate(
276 "cmd_plugin_bundle_list_header",
277 &[("count", &summaries.len().to_string())],
278 )
279 .unwrap_or_default();
280 output.push('\n');
281 for summary in &summaries {
282 let _ = writeln!(
283 output,
284 "• {} — {}\n {} · {} · compatibility={} · {}\n {}",
285 escape_review_text(&summary.name),
286 summary.state_label,
287 summary.scope,
288 summary.trust_status,
289 summary.compatibility,
290 summary.inventory,
291 escape_review_text(&summary.id)
292 );
293 }
294 output
295 };
296 append_diagnostics(presentation, &mut output, &plugin.registry_diagnostics());
297
298 if let Ok(Some(scan)) = plugin.legacy_scan() {
299 output.push('\n');
300 output.push_str(
301 &presentation
302 .translate(
303 "cmd_plugin_legacy_list_header",
304 &[
305 ("count", &scan.tools.len().to_string()),
306 ("dir", &scan.dir.display().to_string()),
307 ],
308 )
309 .unwrap_or_default(),
310 );
311 output.push('\n');
312 for tool in &scan.tools {
313 let _ = writeln!(
314 output,
315 "• {} — {}\n {}",
316 escape_review_text(&tool.name),
317 escape_review_text(&tool.description),
318 escape_review_path(&tool.path)
319 );
320 }
321 }
322
323 if let Some(nudge) = plugin.reload_nudge() {
324 output.push('\n');
325 output.push_str(&nudge);
326 }
327
328 CommandResult::message(output)
329 }
330
331 fn show_bundle(
332 presentation: &mut dyn CommandPresentationContext,
333 plugin: &dyn CommandPluginContext,
334 selector: &str,
335 ) -> CommandResult {
336 let detail = match plugin.detail(selector) {
337 Ok(detail) => detail,
338 Err(_) => {
339 return CommandResult::error(
340 presentation
341 .translate("cmd_plugin_bundle_not_found", &[("name", selector)])
342 .unwrap_or_default(),
343 );
344 }
345 };
346 CommandResult::message(render::render_bundle_detail(presentation, &detail, true))
347 }
348
349 /// `/plugin export <name> <target-dir>` — publish a loaded bundle as a
350 /// spec-valid Agent Plugins v1.0.0 directory.
351 fn export_bundle(
352 workspace: &Path,
353 presentation: &mut dyn CommandPresentationContext,
354 plugin: &dyn CommandPluginContext,
355 selector: &str,
356 target: &str,
357 ) -> CommandResult {
358 if plugin.detail(selector).is_err() {
359 return CommandResult::error(
360 presentation
361 .translate("cmd_plugin_bundle_not_found", &[("name", selector)])
362 .unwrap_or_default(),
363 );
364 }
365 let target = target.trim();
366 if target.is_empty() {
367 return CommandResult::error("Usage: /plugin export <name> <target-dir>");
368 }
369 let target = PathBuf::from(target);
370 let target = if target.is_absolute() {
371 target
372 } else {
373 workspace.join(target)
374 };
375 match plugin.export(selector, &target) {
376 Ok(receipt) => {
377 let mut output = format!(
378 "Exported `{}` as an Agent Plugins v1.0.0 bundle:\n {}\n",
379 escape_review_text(&receipt.exported_name),
380 escape_review_path(&receipt.target),
381 );
382 if let Some(display_name) = &receipt.display_name {
383 let _ = writeln!(
384 output,
385 " Published under a slugified name; `{}` is preserved as the display name.",
386 escape_review_text(display_name)
387 );
388 }
389 let _ = writeln!(
390 output,
391 " plugin.json{} · {} file(s) copied{}",
392 if receipt.wrote_mcp_json {
393 " + mcp.json"
394 } else {
395 ""
396 },
397 receipt.files_copied,
398 if receipt.skills_normalized {
399 " · skills moved to the standard skills/ layout"
400 } else {
401 ""
402 }
403 );
404 output.push_str("The installed bundle was not modified.");
405 CommandResult::message(output)
406 }
407 Err(error) => CommandResult::error(format!(
408 "Export of `{}` failed: {}",
409 escape_review_text(selector),
410 escape_review_text(&error)
411 )),
412 }
413 }
414
415 fn review_bundle(
416 presentation: &mut dyn CommandPresentationContext,
417 plugin: &dyn CommandPluginContext,
418 selector: &str,
419 ) -> CommandResult {
420 let detail = match plugin.detail(selector) {
421 Ok(detail) => detail,
422 Err(_) => {
423 return CommandResult::error(
424 presentation
425 .translate("cmd_plugin_bundle_not_found", &[("name", selector)])
426 .unwrap_or_default(),
427 );
428 }
429 };
430 let mut output = render::render_bundle_detail(presentation, &detail, true);
431 let content = output.clone();
432 let command = format!("/plugin trust {} {}", detail.name, review_token(&detail));
433 let _ = writeln!(output, "\n{command}");
434 CommandResult::with_message_and_action(
435 output,
436 AppAction::OpenCommandReview {
437 title: escape_review_text(&detail.name),
438 content,
439 command,
440 },
441 )
442 }
443
444 pub(crate) fn review_token(detail: &PluginDetail) -> String {
445 // This is an explicit user confirmation, not cosmetic display text. Bind
446 // the command to both complete SHA-256 receipts so a same-inventory bundle
447 // cannot collide through the former 48-bit content prefix.
448 format!("{}.{}", detail.content_hash, detail.capability_hash)
449 }
450
451 fn validate_bundles(
452 presentation: &mut dyn CommandPresentationContext,
453 plugin: &dyn CommandPluginContext,
454 selector: Option<&str>,
455 ) -> CommandResult {
456 if plugin.is_empty() && selector.is_none() {
457 return CommandResult::error(translate(presentation, "cmd_plugin_bundle_none_found"));
458 }
459
460 let mut output = String::new();
461 if let Some(selector) = selector {
462 match plugin.detail(selector) {
463 Ok(detail) => {
464 let invalid = detail
465 .diagnostics
466 .iter()
467 .any(|diagnostic| diagnostic.level == PluginDiagnosticLevel::Error);
468 let _ = writeln!(
469 output,
470 "{} — {} — {}",
471 detail.name,
472 if invalid { "invalid" } else { "valid" },
473 detail.inventory_summary
474 );
475 append_diagnostics(presentation, &mut output, &detail.diagnostics);
476 }
477 Err(_) => {
478 return CommandResult::error(
479 presentation
480 .translate("cmd_plugin_bundle_not_found", &[("name", selector)])
481 .unwrap_or_default(),
482 );
483 }
484 }
485 } else {
486 for summary in plugin.summaries().unwrap_or_default() {
487 let _ = writeln!(
488 output,
489 "{} — {} — {}",
490 summary.name, summary.state_label, summary.inventory
491 );
492 }
493 append_diagnostics(presentation, &mut output, &plugin.registry_diagnostics());
494 }
495 if output.is_empty() {
496 output.push_str(if plugin.validation_is_clean() {
497 "valid"
498 } else {
499 "invalid"
500 });
501 }
502 CommandResult::message(output)
503 }
504
505 // ─── /plugin install | update | uninstall (#5182) ──────────────────────────
506
507 fn install_bundle(
508 presentation: &mut dyn CommandPresentationContext,
509 plugin: &mut dyn CommandPluginContext,
510 spec: &str,
511 ) -> CommandResult {
512 match plugin.install(spec, None) {
513 Ok(receipt) => render_install_receipt(presentation, plugin, receipt, None),
514 Err(error) => action_error(presentation, &format!("Plugin install failed: {error}")),
515 }
516 }
517
518 fn install_bundle_with_expected_hash(
519 presentation: &mut dyn CommandPresentationContext,
520 plugin: &mut dyn CommandPluginContext,
521 path: &Path,
522 expected_content_hash: &str,
523 ) -> CommandResult {
524 match plugin.install(
525 path.to_str().unwrap_or_default(),
526 Some(expected_content_hash),
527 ) {
528 Ok(receipt) => {
529 render_install_receipt(presentation, plugin, receipt, Some(expected_content_hash))
530 }
531 Err(error) => action_error(presentation, &format!("Plugin install failed: {error}")),
532 }
533 }
534
535 fn render_install_receipt(
536 presentation: &mut dyn CommandPresentationContext,
537 plugin: &mut dyn CommandPluginContext,
538 receipt: PluginMutationReceipt,
539 expected_content_hash: Option<&str>,
540 ) -> CommandResult {
541 match receipt.outcome {
542 PluginMutationOutcome::Installed => {
543 let name = receipt.name.clone();
544 let installed_path = receipt.path.clone();
545 let path = installed_path
546 .as_deref()
547 .map(|path| path.display().to_string())
548 .unwrap_or_default();
549 if let Some(expected) = expected_content_hash
550 && receipt.content_hash.as_deref() != Some(expected)
551 {
552 return rollback_hash_mismatch(
553 presentation,
554 plugin,
555 &name,
556 installed_path.as_deref(),
557 expected,
558 receipt.content_hash.as_deref(),
559 );
560 }
561 let mut output = format!(
562 "Installed plugin '{name}' to {path}.\n\
563 It is disabled and untrusted. Review its requested authority below, then trust and enable it.\n"
564 );
565 if let Some(review) = review_bundle(presentation, plugin, &name).message {
566 output.push('\n');
567 output.push_str(&review);
568 }
569 CommandResult::with_message_and_action(output, AppAction::PluginRegistryChanged)
570 }
571 PluginMutationOutcome::NeedsApproval(host) => {
572 CommandResult::error(needs_approval_message(&host))
573 }
574 PluginMutationOutcome::NetworkDenied(host) => {
575 CommandResult::error(network_denied_message(&host))
576 }
577 other => CommandResult::error(format!("Unexpected install outcome: {other:?}")),
578 }
579 }
580
581 fn rollback_hash_mismatch(
582 presentation: &mut dyn CommandPresentationContext,
583 plugin: &mut dyn CommandPluginContext,
584 name: &str,
585 installed_path: Option<&Path>,
586 expected: &str,
587 actual: Option<&str>,
588 ) -> CommandResult {
589 let missing_destination = translate(presentation, "plugin_kimi_rollback_destination_missing");
590 // File-level rollback removal crosses the boundary through the plugin
591 // facet (D1); the host adapter owns the `crate::plugins::install::uninstall`
592 // call.
593 let rollback = installed_path
594 .and_then(Path::parent)
595 .ok_or_else(|| anyhow::anyhow!(missing_destination))
596 .and_then(|plugins_dir| {
597 plugin
598 .uninstall_path(name, plugins_dir)
599 .map_err(anyhow::Error::msg)
600 });
601 let actual = actual
602 .map(escape_review_text)
603 .unwrap_or_else(|| translate(presentation, "plugin_kimi_hash_unavailable"));
604 let name = escape_review_text(name);
605 let expected = escape_review_text(expected);
606 match rollback {
607 Ok(()) => CommandResult::error(
608 presentation
609 .translate(
610 "plugin_kimi_mismatch_removed",
611 &[
612 ("name", &name),
613 ("expected", &expected),
614 ("actual", &actual),
615 ],
616 )
617 .unwrap_or_default(),
618 ),
619 Err(error) => {
620 let error_text = escape_review_text(&format!("{error:#}"));
621 let path_text = installed_path
622 .map(escape_review_path)
623 .unwrap_or_else(|| translate(presentation, "plugin_kimi_user_plugin_directory"));
624 CommandResult {
625 message: Some(
626 presentation
627 .translate(
628 "plugin_kimi_mismatch_rollback_failed",
629 &[
630 ("name", &name),
631 ("expected", &expected),
632 ("actual", &actual),
633 ("error", &error_text),
634 ("path", &path_text),
635 ],
636 )
637 .unwrap_or_default(),
638 ),
639 action: Some(AppAction::PluginRegistryChanged),
640 is_error: true,
641 }
642 }
643 }
644 }
645
646 fn update_bundle(
647 presentation: &mut dyn CommandPresentationContext,
648 plugin: &mut dyn CommandPluginContext,
649 selector: &str,
650 ) -> CommandResult {
651 match plugin.update(selector) {
652 Ok(receipt) => match receipt.outcome {
653 PluginMutationOutcome::Updated => {
654 let name = receipt.name.clone();
655 let mut output = format!(
656 "Updated plugin '{name}'. Its content changed, so the previous trust receipt no \
657 longer matches — review and trust it again before enabling.\n"
658 );
659 if let Some(review) = review_bundle(presentation, plugin, &name).message {
660 output.push('\n');
661 output.push_str(&review);
662 }
663 CommandResult::with_message_and_action(output, AppAction::PluginRegistryChanged)
664 }
665 PluginMutationOutcome::NoChange => {
666 CommandResult::message(format!("Plugin '{}' is already up to date.", receipt.name))
667 }
668 PluginMutationOutcome::NeedsApproval(host) => {
669 CommandResult::error(needs_approval_message(&host))
670 }
671 PluginMutationOutcome::NetworkDenied(host) => {
672 CommandResult::error(network_denied_message(&host))
673 }
674 other => CommandResult::error(format!("Unexpected update outcome: {other:?}")),
675 },
676 Err(error) => action_error(presentation, &format!("Plugin update failed: {error}")),
677 }
678 }
679
680 fn uninstall_bundle(
681 presentation: &mut dyn CommandPresentationContext,
682 plugin: &mut dyn CommandPluginContext,
683 selector: &str,
684 ) -> CommandResult {
685 match plugin.uninstall(selector) {
686 Ok(receipt) => CommandResult::with_message_and_action(
687 format!("Uninstalled plugin '{}'.", receipt.name),
688 AppAction::PluginRegistryChanged,
689 ),
690 Err(error) => action_error(presentation, &format!("Plugin uninstall failed: {error}")),
691 }
692 }
693
694 /// Read the active network policy for plugin downloads (host-side, D11).
695 pub(crate) fn plugin_network_policy() -> crate::network_policy::NetworkPolicy {
696 crate::config::Config::load(None, None)
697 .unwrap_or_default()
698 .network
699 .map(|policy| policy.into_runtime())
700 .unwrap_or_default()
701 }
702
703 fn needs_approval_message(host: &str) -> String {
704 format!(
705 "Network policy requires approval for {host}.\n\
706 Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry."
707 )
708 }
709
710 fn network_denied_message(host: &str) -> String {
711 format!(
712 "Network policy denied access to {host}.\n\
713 Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator."
714 )
715 }
716
717 #[derive(Clone, Copy)]
718 enum Mutation<'a> {
719 Trust(&'a str),
720 Enable,
721 Disable,
722 Revoke,
723 }
724
725 fn mutate_bundle(
726 presentation: &mut dyn CommandPresentationContext,
727 plugin: &mut dyn CommandPluginContext,
728 selector: &str,
729 mutation: Mutation<'_>,
730 ) -> CommandResult {
731 if matches!(mutation, Mutation::Enable) {
732 let needs_review = plugin
733 .detail(selector)
734 .map(|detail| !detail.trusted)
735 .unwrap_or(false);
736 if needs_review {
737 // Enabling is the natural entry point. Open the exact capability
738 // review instead of leaving the user at an opaque denial.
739 return review_bundle(presentation, plugin, selector);
740 }
741 }
742
743 let result = match mutation {
744 Mutation::Trust(token) => plugin.trust(selector, token).map(|()| "trusted"),
745 Mutation::Enable => plugin.enable(selector).map(|()| "enabled"),
746 Mutation::Disable => plugin.disable(selector).map(|()| "disabled"),
747 Mutation::Revoke => plugin.revoke_trust(selector).map(|()| "trust-revoked"),
748 };
749 match result {
750 Ok(action) => {
751 let mut message = presentation
752 .translate(
753 "cmd_plugin_bundle_mutation_success",
754 &[("name", selector), ("action", action)],
755 )
756 .unwrap_or_default();
757 if matches!(mutation, Mutation::Enable)
758 && let Ok(detail) = plugin.detail(selector)
759 {
760 let inactive = detail.unsupported_labels;
761 if !inactive.is_empty() {
762 message.push(' ');
763 message.push_str(&format!(
764 "Compatibility: {}. Supported declarative components are active; inactive: {}.",
765 detail.compatibility,
766 inactive.join(", ")
767 ));
768 }
769 }
770 CommandResult::with_message_and_action(message, AppAction::PluginRegistryChanged)
771 }
772 Err(error) => action_error(presentation, &error),
773 }
774 }
775
776 fn action_error(presentation: &mut dyn CommandPresentationContext, error: &str) -> CommandResult {
777 CommandResult::error(
778 presentation
779 .translate("cmd_plugin_action_failed", &[("error", error)])
780 .unwrap_or_default(),
781 )
782 }
783
784 pub(super) fn append_diagnostics(
785 presentation: &mut dyn CommandPresentationContext,
786 output: &mut String,
787 diagnostics: &[codewhale_command_contract::facets::PluginDiagnostic],
788 ) {
789 if diagnostics.is_empty() {
790 return;
791 }
792 if !output.ends_with('\n') {
793 output.push('\n');
794 }
795 output.push_str(
796 &presentation
797 .translate(
798 "cmd_plugin_bundle_diagnostics_header",
799 &[("count", &diagnostics.len().to_string())],
800 )
801 .unwrap_or_default(),
802 );
803 output.push('\n');
804 for diagnostic in diagnostics {
805 let level = match diagnostic.level {
806 PluginDiagnosticLevel::Warning => "warning",
807 PluginDiagnosticLevel::Error => "error",
808 };
809 let path = diagnostic
810 .path
811 .as_deref()
812 .map(|path| format!(" ({})", escape_review_path(path)))
813 .unwrap_or_default();
814 let _ = writeln!(
815 output,
816 "• {level} [{}]: {}{path}",
817 diagnostic.code,
818 escape_review_text(&diagnostic.message)
819 );
820 }
821 }
822
823 pub(super) fn escape_review_path(path: &Path) -> String {
824 render::escape_review_path(path)
825 }
826
827 pub(super) fn escape_review_text(value: &str) -> String {
828 render::escape_review_text(value)
829 }
830
830 lines RUST