返回 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 dsh_import;
44 mod kimi_import;
45 pub(crate) mod legacy;
46 pub(crate) mod marketplace;
47 #[cfg(test)]
48 mod marketplace_tests;
49 pub(crate) mod render;
50
51 #[cfg(test)]
52 mod tests;
53
54 use legacy::legacy_tools;
55
56 pub struct PluginsCommands;
57
58 impl CommandGroup for PluginsCommands {
59 fn commands(&self) -> &'static [Box<dyn crate::commands::traits::Command>] {
60 cached_command_list!(vec![Box::new(
61 ContextualCommand::from_contract::<PluginsCmd>().expect("plugin registration"),
62 )])
63 }
64 }
65
66 pub(in crate::commands) const PLUGINS_INFO: CommandInfo = CommandInfo {
67 name: "plugin",
68 aliases: &["plugins", "extensions"],
69 usage: "/plugin [list|show|suggest|validate|export|install|import|update|uninstall|trust|enable|disable|revoke|reload|tools|marketplace|dismissals]",
70 description_key: "cmd_plugin_description",
71 };
72
73 pub(in crate::commands) struct PluginsCmd;
74
75 impl RegisterCommand<CommandResult> for PluginsCmd {
76 fn info() -> &'static CommandInfo {
77 &PLUGINS_INFO
78 }
79
80 fn handler() -> CommandHandler<CommandResult> {
81 CommandHandler::Contextual {
82 capabilities: CommandCapabilities::WORKSPACE
83 .union(CommandCapabilities::PRESENTATION)
84 .union(CommandCapabilities::PLUGIN),
85 handler: plugins_contextual,
86 }
87 }
88 }
89
90 fn plugins_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
91 let mut parts = contexts.into_parts();
92 let Some(workspace) = parts.workspace.as_deref() else {
93 return CommandResult::error("Command capability unavailable: workspace");
94 };
95 let Some(presentation) = parts.presentation.as_deref_mut() else {
96 return CommandResult::error("Command capability unavailable: presentation");
97 };
98 let Some(plugin) = parts.plugin.as_deref_mut() else {
99 return CommandResult::error("Command capability unavailable: plugin");
100 };
101 plugins(&workspace.workspace(), presentation, plugin, arg, None)
102 }
103
104 #[cfg(test)]
105 fn plugins_with_kimi_home(app: &mut App, arg: Option<&str>, home: &Path) -> CommandResult {
106 plugins_with_kimi_home_override(app, arg, Some(home))
107 }
108
109 #[cfg(test)]
110 fn plugins_with_kimi_home_override(
111 app: &mut App,
112 arg: Option<&str>,
113 kimi_home: Option<&Path>,
114 ) -> CommandResult {
115 let mut bundle = app.command_contexts();
116 let capabilities = CommandCapabilities::WORKSPACE
117 .union(CommandCapabilities::PRESENTATION)
118 .union(CommandCapabilities::PLUGIN);
119 let mut contexts = bundle.contexts(capabilities).into_parts();
120 let Some(workspace) = contexts.workspace.as_deref() else {
121 return CommandResult::error("Command capability unavailable: workspace");
122 };
123 let Some(presentation) = contexts.presentation.as_deref_mut() else {
124 return CommandResult::error("Command capability unavailable: presentation");
125 };
126 let Some(plugin) = contexts.plugin.as_deref_mut() else {
127 return CommandResult::error("Command capability unavailable: plugin");
128 };
129 plugins(&workspace.workspace(), presentation, plugin, arg, kimi_home)
130 }
131
132 /// Portable `/plugin` dispatch (FEAT-020 Phase 4).
133 ///
134 /// The handler consumes only portable facets; all concrete host access lives
135 /// in the TUI adapter. `kimi_home` is a test-only home override for the Kimi
136 /// managed-import scan.
137 pub(super) fn plugins(
138 workspace: &Path,
139 presentation: &mut dyn CommandPresentationContext,
140 plugin: &mut dyn CommandPluginContext,
141 arg: Option<&str>,
142 kimi_home: Option<&Path>,
143 ) -> CommandResult {
144 let words = arg
145 .unwrap_or_default()
146 .split_whitespace()
147 .collect::<Vec<_>>();
148 match words.as_slice() {
149 [] => CommandResult::action(AppAction::OpenExtensions {
150 tab: crate::tui::views::extensions::ExtensionsTab::Plugins,
151 }),
152 ["list"] => list_bundles_and_legacy_tools(presentation, plugin),
153 ["help"] => CommandResult::message(format!(
154 "{}\n\n/plugin import kimi [list]\n/plugin import kimi approve <name> <content-hash>\n{}",
155 translate(presentation, "cmd_plugin_bundle_usage"),
156 dsh_import::USAGE
157 )),
158 ["marketplace", rest @ ..] => marketplace::dispatch(presentation, plugin, rest),
159 ["import", "kimi", rest @ ..] => {
160 kimi_import::dispatch(presentation, plugin, rest, kimi_home)
161 }
162 ["import", "dsh", rest @ ..] => dsh_import::dispatch(presentation, plugin, rest),
163 ["import", ..] => CommandResult::error(format!(
164 "{}\n{}",
165 kimi_import::usage(presentation),
166 dsh_import::USAGE
167 )),
168 ["show", selector] => show_bundle(presentation, plugin, selector),
169 ["suggest"] | ["recommend"] => CommandResult::error("Usage: /plugin suggest <task>"),
170 ["suggest", task @ ..] | ["recommend", task @ ..] => {
171 suggest_bundles(presentation, plugin, &task.join(" "))
172 }
173 ["validate"] => validate_bundles(presentation, plugin, None),
174 ["validate", selector] => validate_bundles(presentation, plugin, Some(selector)),
175 ["export"] => CommandResult::error("Usage: /plugin export <name> <target-dir>"),
176 ["export", selector, target @ ..] => {
177 export_bundle(workspace, presentation, plugin, selector, &target.join(" "))
178 }
179 ["install"] => CommandResult::error(translate(presentation, "cmd_plugin_bundle_usage")),
180 ["install", rest @ ..] => install_bundle(presentation, plugin, &rest.join(" ")),
181 ["update"] | ["uninstall"] => {
182 CommandResult::error(translate(presentation, "cmd_plugin_bundle_usage"))
183 }
184 ["update", selector] => update_bundle(presentation, plugin, selector),
185 ["uninstall", selector] => uninstall_bundle(presentation, plugin, selector),
186 ["trust", selector] => review_bundle(presentation, plugin, selector),
187 ["trust", selector, token] => {
188 mutate_bundle(presentation, plugin, selector, Mutation::Trust(token))
189 }
190 ["enable", selector] => mutate_bundle(presentation, plugin, selector, Mutation::Enable),
191 ["disable", selector] => mutate_bundle(presentation, plugin, selector, Mutation::Disable),
192 ["revoke", selector] => mutate_bundle(presentation, plugin, selector, Mutation::Revoke),
193 ["reload"] => reload(presentation, plugin),
194 ["dismissals"] => list_dismissals(plugin),
195 ["dismissals", "reset"] => reset_dismissals(plugin, None),
196 ["dismissals", "reset", name] => reset_dismissals(plugin, Some(name)),
197 ["dismissals", ..] => CommandResult::error("Usage: /plugin dismissals [reset [<name>]]"),
198 ["tools"] => legacy_tools(presentation, plugin, None),
199 ["tools", name] => legacy_tools(presentation, plugin, Some(name)),
200 [selector] => {
201 if plugin.detail(selector).is_ok() {
202 show_bundle(presentation, plugin, selector)
203 } else {
204 // Preserve `/plugin <script-tool>` compatibility while making
205 // its distinct execution model explicit in the output.
206 legacy_tools(presentation, plugin, Some(selector))
207 }
208 }
209 _ => CommandResult::error(translate(presentation, "cmd_plugin_bundle_usage")),
210 }
211 }
212
213 /// `/plugin dismissals`: which plugins suggestions skip, and for how long
214 /// (plugin policy rule 9: dismissal is reversible).
215 fn list_dismissals(plugin: &dyn CommandPluginContext) -> CommandResult {
216 let dismissals = match plugin.suggestion_dismissals() {
217 Ok(dismissals) => dismissals,
218 Err(error) => return CommandResult::error(error),
219 };
220 if dismissals.persisted.is_empty() && dismissals.session.is_empty() {
221 return CommandResult::message("No plugins are hidden from suggestions.".to_string());
222 }
223 let mut output = String::from("Plugins hidden from suggestions:\n");
224 if !dismissals.persisted.is_empty() {
225 output.push_str(" Don't suggest again (kept across sessions):\n");
226 for name in &dismissals.persisted {
227 let _ = writeln!(output, " {}", escape_review_text(name));
228 }
229 }
230 if !dismissals.session.is_empty() {
231 output.push_str(" This session only:\n");
232 for name in &dismissals.session {
233 let _ = writeln!(output, " {}", escape_review_text(name));
234 }
235 }
236 output.push_str(
237 "\nReset with /plugin dismissals reset [<name>]. Manual /plugin commands work either way.",
238 );
239 CommandResult::message(output)
240 }
241
242 /// `/plugin dismissals reset [<name>]`: let suggestions offer a plugin again.
243 fn reset_dismissals(plugin: &mut dyn CommandPluginContext, name: Option<&str>) -> CommandResult {
244 match plugin.reset_suggestion_dismissals(name) {
245 Ok(cleared) if cleared.is_empty() => CommandResult::message(match name {
246 Some(name) => format!(
247 "`{}` was not hidden from suggestions.",
248 escape_review_text(name)
249 ),
250 None => "No plugins were hidden from suggestions.".to_string(),
251 }),
252 Ok(cleared) => CommandResult::message(format!(
253 "Suggestions may offer {} again.",
254 cleared
255 .iter()
256 .map(|name| escape_review_text(name))
257 .collect::<Vec<_>>()
258 .join(", ")
259 )),
260 Err(error) => CommandResult::error(error),
261 }
262 }
263
264 /// Translate one stable plugin key through the presentation facet.
265 fn translate(presentation: &mut dyn CommandPresentationContext, key: &str) -> String {
266 presentation.translate(key, &[]).unwrap_or_default()
267 }
268
269 fn reload(
270 presentation: &mut dyn CommandPresentationContext,
271 plugin: &mut dyn CommandPluginContext,
272 ) -> CommandResult {
273 match plugin.reload() {
274 Ok(count) => {
275 let message = presentation
276 .translate(
277 "cmd_plugin_bundle_reloaded",
278 &[("count", &count.to_string())],
279 )
280 .unwrap_or_default();
281 CommandResult::with_message_and_action(message, AppAction::PluginRegistryChanged)
282 }
283 Err(error) => action_error(presentation, &format!("Plugin reload failed: {error}")),
284 }
285 }
286
287 /// Rank installed bundles and locally-added marketplace candidates for a task
288 /// without changing trust, enablement, disk state, or network state.
289 fn suggest_bundles(
290 _presentation: &mut dyn CommandPresentationContext,
291 plugin: &dyn CommandPluginContext,
292 task: &str,
293 ) -> CommandResult {
294 let task = task.trim();
295 if task.chars().count() < 3 {
296 return CommandResult::error("Usage: /plugin suggest <task of at least 3 characters>");
297 }
298 let suggestions = plugin.suggest(task).unwrap_or_default();
299 if suggestions.is_empty() {
300 return CommandResult::message(format!(
301 "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.",
302 escape_review_text(task)
303 ));
304 }
305 let mut output = format!("Suggested plugins for `{}`:\n", escape_review_text(task));
306 output.push_str("─────────────────────────────\n");
307 for suggestion in suggestions {
308 let why = suggestion
309 .why
310 .iter()
311 .map(|term| escape_review_text(term))
312 .collect::<Vec<_>>()
313 .join(", ");
314 let _ = writeln!(
315 output,
316 " {} — {} · {}",
317 escape_review_text(&suggestion.name),
318 suggestion.state_label,
319 escape_review_text(&suggestion.description)
320 );
321 let _ = writeln!(output, " Why: {why}");
322 let _ = writeln!(output, " {}", suggestion.next_step);
323 }
324 output.push_str("\nNothing was installed, trusted, or enabled.");
325 CommandResult::message(output)
326 }
327
328 fn list_bundles_and_legacy_tools(
329 presentation: &mut dyn CommandPresentationContext,
330 plugin: &mut dyn CommandPluginContext,
331 ) -> CommandResult {
332 let summaries = plugin.summaries().unwrap_or_default();
333 let mut output = if summaries.is_empty() {
334 translate(presentation, "cmd_plugin_bundle_none_found")
335 } else {
336 let mut output = presentation
337 .translate(
338 "cmd_plugin_bundle_list_header",
339 &[("count", &summaries.len().to_string())],
340 )
341 .unwrap_or_default();
342 output.push('\n');
343 for summary in &summaries {
344 let _ = writeln!(
345 output,
346 "• {} — {}\n {} · {} · compatibility={} · {}\n {}",
347 escape_review_text(&summary.name),
348 summary.state_label,
349 summary.scope,
350 summary.trust_status,
351 summary.compatibility,
352 summary.inventory,
353 escape_review_text(&summary.id)
354 );
355 }
356 output
357 };
358 append_diagnostics(presentation, &mut output, &plugin.registry_diagnostics());
359 // Diagnostics and the stderr tail carry plugin-controlled text.
360 for line in crate::extension_host::status_report().lines() {
361 output.push('\n');
362 output.push_str(&escape_review_text(line));
363 }
364
365 if let Ok(Some(scan)) = plugin.legacy_scan() {
366 output.push('\n');
367 output.push_str(
368 &presentation
369 .translate(
370 "cmd_plugin_legacy_list_header",
371 &[
372 ("count", &scan.tools.len().to_string()),
373 ("dir", &scan.dir.display().to_string()),
374 ],
375 )
376 .unwrap_or_default(),
377 );
378 output.push('\n');
379 for tool in &scan.tools {
380 let _ = writeln!(
381 output,
382 "• {} — {}\n {}",
383 escape_review_text(&tool.name),
384 escape_review_text(&tool.description),
385 escape_review_path(&tool.path)
386 );
387 }
388 append_diagnostics(presentation, &mut output, &scan.diagnostics);
389 }
390
391 if let Some(nudge) = plugin.reload_nudge() {
392 output.push('\n');
393 output.push_str(&nudge);
394 }
395
396 CommandResult::message(output)
397 }
398
399 fn show_bundle(
400 presentation: &mut dyn CommandPresentationContext,
401 plugin: &dyn CommandPluginContext,
402 selector: &str,
403 ) -> CommandResult {
404 let detail = match plugin.detail(selector) {
405 Ok(detail) => detail,
406 Err(_) => {
407 return CommandResult::error(
408 presentation
409 .translate("cmd_plugin_bundle_not_found", &[("name", selector)])
410 .unwrap_or_default(),
411 );
412 }
413 };
414 let mut output = render::render_bundle_detail(presentation, &detail, true);
415 if let Some(report) = crate::extension_host::owner_report(&detail.id) {
416 append_host_owner_report(presentation, &mut output, &report);
417 }
418 // What the user configured for the plugin is data its code will read, so
419 // it is part of what `/plugin show` puts in front of them (keys only: a
420 // value can be anything, including something they would rather not echo).
421 if let Some(summary) = crate::extension_host::plugin_config_summary(&detail.name) {
422 append_plugin_config_summary(&mut output, &detail.name, &summary);
423 }
424 CommandResult::message(output)
425 }
426
427 /// The `[plugins."<name>".config]` line of `/plugin show`: the configured
428 /// keys, never the values, or why the config is refused. Every part is escaped.
429 fn append_plugin_config_summary(
430 output: &mut String,
431 plugin_name: &str,
432 summary: &Result<Vec<String>, String>,
433 ) {
434 let name = escape_review_text(plugin_name);
435 match summary {
436 Ok(keys) => {
437 let keys = keys
438 .iter()
439 .map(|key| escape_review_text(key))
440 .collect::<Vec<_>>()
441 .join(", ");
442 let _ = write!(
443 output,
444 "\n config from [plugins.\"{name}\".config] in your config.toml (values not shown): {keys}"
445 );
446 }
447 Err(reason) => {
448 let _ = write!(
449 output,
450 "\n config from [plugins.\"{name}\".config] is refused: {}",
451 escape_review_text(reason)
452 );
453 }
454 }
455 }
456
457 fn append_host_owner_report(
458 presentation: &dyn CommandPresentationContext,
459 output: &mut String,
460 report: &crate::extension_host::OwnerReport,
461 ) {
462 use crate::extension_host::registry::OwnerState;
463
464 let (state_key, reason) = match &report.state {
465 Some(OwnerState::Activating) => ("cmd_plugin_owner_activating", None),
466 Some(OwnerState::Active) => ("cmd_plugin_owner_active", None),
467 Some(OwnerState::Failed(reason)) => ("cmd_plugin_owner_failed", Some(reason)),
468 Some(OwnerState::Faulted(reason)) => ("cmd_plugin_owner_faulted", Some(reason)),
469 Some(OwnerState::Revoked) => ("cmd_plugin_owner_revoked", None),
470 None => ("cmd_plugin_owner_inactive", None),
471 };
472 let reason = reason.map(|value| escape_review_text(value));
473 let replacements = reason
474 .as_deref()
475 .map(|value| vec![("reason", value)])
476 .unwrap_or_default();
477 let state = presentation
478 .translate(state_key, &replacements)
479 .unwrap_or_default();
480 let tools = report
481 .tools
482 .iter()
483 .map(|name| escape_review_text(name))
484 .collect::<Vec<_>>();
485 let tool_names = if tools.is_empty() {
486 "—".to_string()
487 } else {
488 tools.join(", ")
489 };
490 output.push('\n');
491 output.push_str(
492 &presentation
493 .translate(
494 "cmd_plugin_owner_report",
495 &[
496 ("state", &state),
497 ("count", &tools.len().to_string()),
498 ("tools", &tool_names),
499 ],
500 )
501 .unwrap_or_default(),
502 );
503 for line in &report.diagnostics {
504 let _ = write!(output, "\n · {}", escape_review_text(line));
505 }
506 }
507
508 /// `/plugin export <name> <target-dir>` — publish a loaded bundle as a
509 /// spec-valid Agent Plugins v1.0.0 directory.
510 fn export_bundle(
511 workspace: &Path,
512 presentation: &mut dyn CommandPresentationContext,
513 plugin: &dyn CommandPluginContext,
514 selector: &str,
515 target: &str,
516 ) -> CommandResult {
517 if plugin.detail(selector).is_err() {
518 return CommandResult::error(
519 presentation
520 .translate("cmd_plugin_bundle_not_found", &[("name", selector)])
521 .unwrap_or_default(),
522 );
523 }
524 let target = target.trim();
525 if target.is_empty() {
526 return CommandResult::error("Usage: /plugin export <name> <target-dir>");
527 }
528 let target = PathBuf::from(target);
529 let target = if target.is_absolute() {
530 target
531 } else {
532 workspace.join(target)
533 };
534 match plugin.export(selector, &target) {
535 Ok(receipt) => {
536 let mut output = format!(
537 "Exported `{}` as an Agent Plugins v1.0.0 bundle:\n {}\n",
538 escape_review_text(&receipt.exported_name),
539 escape_review_path(&receipt.target),
540 );
541 if let Some(display_name) = &receipt.display_name {
542 let _ = writeln!(
543 output,
544 " Published under a slugified name; `{}` is preserved as the display name.",
545 escape_review_text(display_name)
546 );
547 }
548 let _ = writeln!(
549 output,
550 " plugin.json{} · {} file(s) copied{}",
551 if receipt.wrote_mcp_json {
552 " + mcp.json"
553 } else {
554 ""
555 },
556 receipt.files_copied,
557 if receipt.skills_normalized {
558 " · skills moved to the standard skills/ layout"
559 } else {
560 ""
561 }
562 );
563 output.push_str("The installed bundle was not modified.");
564 CommandResult::message(output)
565 }
566 Err(error) => CommandResult::error(format!(
567 "Export of `{}` failed: {}",
568 escape_review_text(selector),
569 escape_review_text(&error)
570 )),
571 }
572 }
573
574 fn review_bundle(
575 presentation: &mut dyn CommandPresentationContext,
576 plugin: &dyn CommandPluginContext,
577 selector: &str,
578 ) -> CommandResult {
579 let detail = match plugin.detail(selector) {
580 Ok(detail) => detail,
581 Err(_) => {
582 return CommandResult::error(
583 presentation
584 .translate("cmd_plugin_bundle_not_found", &[("name", selector)])
585 .unwrap_or_default(),
586 );
587 }
588 };
589 let mut output = render::render_bundle_detail(presentation, &detail, true);
590 let content = output.clone();
591 let command = format!("/plugin trust {} {}", detail.name, review_token(&detail));
592 let _ = writeln!(output, "\n{command}");
593 CommandResult::with_message_and_action(
594 output,
595 AppAction::OpenCommandReview {
596 title: escape_review_text(&detail.name),
597 content,
598 command,
599 },
600 )
601 }
602
603 pub(crate) fn review_token(detail: &PluginDetail) -> String {
604 // This is an explicit user confirmation, not cosmetic display text. Bind
605 // the command to both complete SHA-256 receipts so a same-inventory bundle
606 // cannot collide through the former 48-bit content prefix.
607 format!("{}.{}", detail.content_hash, detail.capability_hash)
608 }
609
610 fn validate_bundles(
611 presentation: &mut dyn CommandPresentationContext,
612 plugin: &dyn CommandPluginContext,
613 selector: Option<&str>,
614 ) -> CommandResult {
615 if plugin.is_empty() && selector.is_none() {
616 return CommandResult::error(translate(presentation, "cmd_plugin_bundle_none_found"));
617 }
618
619 let mut output = String::new();
620 if let Some(selector) = selector {
621 match plugin.detail(selector) {
622 Ok(detail) => {
623 let invalid = detail
624 .diagnostics
625 .iter()
626 .any(|diagnostic| diagnostic.level == PluginDiagnosticLevel::Error);
627 let _ = writeln!(
628 output,
629 "{} — {} — {}",
630 detail.name,
631 if invalid { "invalid" } else { "valid" },
632 detail.inventory_summary
633 );
634 append_diagnostics(presentation, &mut output, &detail.diagnostics);
635 }
636 Err(_) => {
637 return CommandResult::error(
638 presentation
639 .translate("cmd_plugin_bundle_not_found", &[("name", selector)])
640 .unwrap_or_default(),
641 );
642 }
643 }
644 } else {
645 for summary in plugin.summaries().unwrap_or_default() {
646 let _ = writeln!(
647 output,
648 "{} — {} — {}",
649 summary.name, summary.state_label, summary.inventory
650 );
651 }
652 append_diagnostics(presentation, &mut output, &plugin.registry_diagnostics());
653 }
654 if output.is_empty() {
655 output.push_str(if plugin.validation_is_clean() {
656 "valid"
657 } else {
658 "invalid"
659 });
660 }
661 CommandResult::message(output)
662 }
663
664 // ─── /plugin install | update | uninstall (#5182) ──────────────────────────
665
666 fn install_bundle(
667 presentation: &mut dyn CommandPresentationContext,
668 plugin: &mut dyn CommandPluginContext,
669 spec: &str,
670 ) -> CommandResult {
671 match plugin.install(spec, None) {
672 Ok(receipt) => render_install_receipt(presentation, plugin, receipt, None),
673 Err(error) => action_error(presentation, &format!("Plugin install failed: {error}")),
674 }
675 }
676
677 fn install_bundle_with_expected_hash(
678 presentation: &mut dyn CommandPresentationContext,
679 plugin: &mut dyn CommandPluginContext,
680 path: &Path,
681 expected_content_hash: &str,
682 ) -> CommandResult {
683 match plugin.install(
684 path.to_str().unwrap_or_default(),
685 Some(expected_content_hash),
686 ) {
687 Ok(receipt) => {
688 render_install_receipt(presentation, plugin, receipt, Some(expected_content_hash))
689 }
690 Err(error) => action_error(presentation, &format!("Plugin install failed: {error}")),
691 }
692 }
693
694 fn render_install_receipt(
695 presentation: &mut dyn CommandPresentationContext,
696 plugin: &mut dyn CommandPluginContext,
697 receipt: PluginMutationReceipt,
698 expected_content_hash: Option<&str>,
699 ) -> CommandResult {
700 match receipt.outcome {
701 PluginMutationOutcome::Installed => {
702 let name = receipt.name.clone();
703 let installed_path = receipt.path.clone();
704 let path = installed_path
705 .as_deref()
706 .map(|path| path.display().to_string())
707 .unwrap_or_default();
708 if let Some(expected) = expected_content_hash
709 && receipt.content_hash.as_deref() != Some(expected)
710 {
711 return rollback_hash_mismatch(
712 presentation,
713 plugin,
714 &name,
715 installed_path.as_deref(),
716 expected,
717 receipt.content_hash.as_deref(),
718 );
719 }
720 let mut output = format!(
721 "Installed plugin '{name}' to {path}.\n\
722 It is disabled and untrusted. Review its requested authority below, then trust and enable it.\n"
723 );
724 if let Some(review) = review_bundle(presentation, plugin, &name).message {
725 output.push('\n');
726 output.push_str(&review);
727 }
728 CommandResult::with_message_and_action(output, AppAction::PluginRegistryChanged)
729 }
730 PluginMutationOutcome::NeedsApproval(host) => {
731 CommandResult::error(needs_approval_message(&host))
732 }
733 PluginMutationOutcome::NetworkDenied(host) => {
734 CommandResult::error(network_denied_message(&host))
735 }
736 other => CommandResult::error(format!("Unexpected install outcome: {other:?}")),
737 }
738 }
739
740 fn rollback_hash_mismatch(
741 presentation: &mut dyn CommandPresentationContext,
742 plugin: &mut dyn CommandPluginContext,
743 name: &str,
744 installed_path: Option<&Path>,
745 expected: &str,
746 actual: Option<&str>,
747 ) -> CommandResult {
748 let missing_destination = translate(presentation, "plugin_kimi_rollback_destination_missing");
749 // File-level rollback removal crosses the boundary through the plugin
750 // facet (D1); the host adapter owns the `crate::plugins::install::uninstall`
751 // call.
752 let rollback = installed_path
753 .and_then(Path::parent)
754 .ok_or_else(|| anyhow::anyhow!(missing_destination))
755 .and_then(|plugins_dir| {
756 plugin
757 .uninstall_path(name, plugins_dir)
758 .map_err(anyhow::Error::msg)
759 });
760 let actual = actual
761 .map(escape_review_text)
762 .unwrap_or_else(|| translate(presentation, "plugin_kimi_hash_unavailable"));
763 let name = escape_review_text(name);
764 let expected = escape_review_text(expected);
765 match rollback {
766 Ok(()) => CommandResult::error(
767 presentation
768 .translate(
769 "plugin_kimi_mismatch_removed",
770 &[
771 ("name", &name),
772 ("expected", &expected),
773 ("actual", &actual),
774 ],
775 )
776 .unwrap_or_default(),
777 ),
778 Err(error) => {
779 let error_text = escape_review_text(&format!("{error:#}"));
780 let path_text = installed_path
781 .map(escape_review_path)
782 .unwrap_or_else(|| translate(presentation, "plugin_kimi_user_plugin_directory"));
783 CommandResult {
784 message: Some(
785 presentation
786 .translate(
787 "plugin_kimi_mismatch_rollback_failed",
788 &[
789 ("name", &name),
790 ("expected", &expected),
791 ("actual", &actual),
792 ("error", &error_text),
793 ("path", &path_text),
794 ],
795 )
796 .unwrap_or_default(),
797 ),
798 action: Some(AppAction::PluginRegistryChanged),
799 is_error: true,
800 }
801 }
802 }
803 }
804
805 fn update_bundle(
806 presentation: &mut dyn CommandPresentationContext,
807 plugin: &mut dyn CommandPluginContext,
808 selector: &str,
809 ) -> CommandResult {
810 match plugin.update(selector) {
811 Ok(receipt) => match receipt.outcome {
812 PluginMutationOutcome::Updated => {
813 let name = receipt.name.clone();
814 let mut output = format!(
815 "Updated plugin '{name}'. Its content changed, so the previous trust receipt no \
816 longer matches — review and trust it again before enabling.\n"
817 );
818 if let Some(review) = review_bundle(presentation, plugin, &name).message {
819 output.push('\n');
820 output.push_str(&review);
821 }
822 CommandResult::with_message_and_action(output, AppAction::PluginRegistryChanged)
823 }
824 PluginMutationOutcome::NoChange => {
825 CommandResult::message(format!("Plugin '{}' is already up to date.", receipt.name))
826 }
827 PluginMutationOutcome::NeedsApproval(host) => {
828 CommandResult::error(needs_approval_message(&host))
829 }
830 PluginMutationOutcome::NetworkDenied(host) => {
831 CommandResult::error(network_denied_message(&host))
832 }
833 other => CommandResult::error(format!("Unexpected update outcome: {other:?}")),
834 },
835 Err(error) => action_error(presentation, &format!("Plugin update failed: {error}")),
836 }
837 }
838
839 fn uninstall_bundle(
840 presentation: &mut dyn CommandPresentationContext,
841 plugin: &mut dyn CommandPluginContext,
842 selector: &str,
843 ) -> CommandResult {
844 match plugin.uninstall(selector) {
845 Ok(receipt) => CommandResult::with_message_and_action(
846 format!("Uninstalled plugin '{}'.", receipt.name),
847 AppAction::PluginRegistryChanged,
848 ),
849 Err(error) => action_error(presentation, &format!("Plugin uninstall failed: {error}")),
850 }
851 }
852
853 /// Read the active network policy for plugin downloads (host-side, D11).
854 pub(crate) fn plugin_network_policy() -> crate::network_policy::NetworkPolicy {
855 crate::config::Config::load(None, None)
856 .unwrap_or_default()
857 .network
858 .map(|policy| policy.into_runtime())
859 .unwrap_or_default()
860 }
861
862 fn needs_approval_message(host: &str) -> String {
863 format!(
864 "Network policy requires approval for {host}.\n\
865 Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry."
866 )
867 }
868
869 fn network_denied_message(host: &str) -> String {
870 format!(
871 "Network policy denied access to {host}.\n\
872 Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator."
873 )
874 }
875
876 #[derive(Clone, Copy)]
877 enum Mutation<'a> {
878 Trust(&'a str),
879 Enable,
880 Disable,
881 Revoke,
882 }
883
884 fn mutate_bundle(
885 presentation: &mut dyn CommandPresentationContext,
886 plugin: &mut dyn CommandPluginContext,
887 selector: &str,
888 mutation: Mutation<'_>,
889 ) -> CommandResult {
890 if matches!(mutation, Mutation::Enable) {
891 let needs_review = plugin
892 .detail(selector)
893 .map(|detail| !detail.trusted)
894 .unwrap_or(false);
895 if needs_review {
896 // Enabling is the natural entry point. Open the exact capability
897 // review instead of leaving the user at an opaque denial.
898 return review_bundle(presentation, plugin, selector);
899 }
900 }
901
902 let result = match mutation {
903 Mutation::Trust(token) => plugin.trust(selector, token).map(|()| "trusted"),
904 Mutation::Enable => plugin.enable(selector).map(|()| "enabled"),
905 Mutation::Disable => plugin.disable(selector).map(|()| "disabled"),
906 Mutation::Revoke => plugin.revoke_trust(selector).map(|()| "trust-revoked"),
907 };
908 match result {
909 Ok(action) => {
910 let mut message = presentation
911 .translate(
912 "cmd_plugin_bundle_mutation_success",
913 &[("name", selector), ("action", action)],
914 )
915 .unwrap_or_default();
916 if matches!(mutation, Mutation::Enable)
917 && let Ok(detail) = plugin.detail(selector)
918 {
919 let inactive = detail.unsupported_labels;
920 if !inactive.is_empty() {
921 message.push(' ');
922 message.push_str(&format!(
923 "Compatibility: {}. Supported declarative components are active; inactive: {}.",
924 detail.compatibility,
925 inactive.join(", ")
926 ));
927 }
928 }
929 CommandResult::with_message_and_action(message, AppAction::PluginRegistryChanged)
930 }
931 Err(error) => action_error(presentation, &error),
932 }
933 }
934
935 fn action_error(presentation: &mut dyn CommandPresentationContext, error: &str) -> CommandResult {
936 CommandResult::error(
937 presentation
938 .translate("cmd_plugin_action_failed", &[("error", error)])
939 .unwrap_or_default(),
940 )
941 }
942
943 pub(super) fn append_diagnostics(
944 presentation: &mut dyn CommandPresentationContext,
945 output: &mut String,
946 diagnostics: &[codewhale_command_contract::facets::PluginDiagnostic],
947 ) {
948 if diagnostics.is_empty() {
949 return;
950 }
951 if !output.ends_with('\n') {
952 output.push('\n');
953 }
954 output.push_str(
955 &presentation
956 .translate(
957 "cmd_plugin_bundle_diagnostics_header",
958 &[("count", &diagnostics.len().to_string())],
959 )
960 .unwrap_or_default(),
961 );
962 output.push('\n');
963 for diagnostic in diagnostics {
964 let level = match diagnostic.level {
965 PluginDiagnosticLevel::Warning => "warning",
966 PluginDiagnosticLevel::Error => "error",
967 };
968 let path = diagnostic
969 .path
970 .as_deref()
971 .map(|path| format!(" ({})", escape_review_path(path)))
972 .unwrap_or_default();
973 let _ = writeln!(
974 output,
975 "• {level} [{}]: {}{path}",
976 diagnostic.code,
977 escape_review_text(&diagnostic.message)
978 );
979 }
980 }
981
982 pub(super) fn escape_review_path(path: &Path) -> String {
983 render::escape_review_path(path)
984 }
985
986 pub(super) fn escape_review_text(value: &str) -> String {
987 render::escape_review_text(value)
988 }
989
989 lines RUST