返回 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 use std::collections::BTreeMap;
21 use std::fmt::Write as _;
22
23 use crate::commands::CommandResult;
24 use crate::commands::traits::{
25 Command, CommandGroup, CommandInfo, FunctionCommand, RegisterCommand,
26 };
27 use crate::localization::{MessageId, tr};
28 use crate::plugins::types::{LoadedPlugin, PluginDiagnosticLevel};
29 use crate::tui::app::{App, AppAction};
30
31 mod legacy;
32 mod render;
33
34 #[cfg(test)]
35 mod tests;
36
37 use legacy::{legacy_tools, scan_legacy_tools};
38 use render::{
39 append_diagnostics, escape_review_path, escape_review_text, render_bundle_detail, review_token,
40 };
41
42 pub struct PluginsCommands;
43
44 impl CommandGroup for PluginsCommands {
45 fn commands(&self) -> &'static [Box<dyn Command>] {
46 cached_command_list!(vec![Box::new(FunctionCommand::new(
47 PluginsCmd::info(),
48 PluginsCmd::execute,
49 ))])
50 }
51 }
52
53 pub(in crate::commands) const PLUGINS_INFO: CommandInfo = CommandInfo {
54 name: "plugin",
55 aliases: &["plugins"],
56 usage: "/plugin [list|show|suggest|validate|install|update|uninstall|trust|enable|disable|revoke|reload|tools]",
57 description_id: MessageId::CmdPluginDescription,
58 };
59
60 pub(in crate::commands) struct PluginsCmd;
61
62 impl RegisterCommand for PluginsCmd {
63 fn info() -> &'static CommandInfo {
64 &PLUGINS_INFO
65 }
66
67 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
68 plugins(app, arg)
69 }
70 }
71
72 fn plugins(app: &mut App, arg: Option<&str>) -> CommandResult {
73 let words = arg
74 .unwrap_or_default()
75 .split_whitespace()
76 .collect::<Vec<_>>();
77 match words.as_slice() {
78 [] | ["list"] => list_bundles_and_legacy_tools(app),
79 ["help"] => CommandResult::message(tr(app.ui_locale, MessageId::CmdPluginBundleUsage)),
80 ["show", selector] => show_bundle(app, selector),
81 ["suggest"] | ["recommend"] => CommandResult::error("Usage: /plugin suggest <task>"),
82 ["suggest", task @ ..] | ["recommend", task @ ..] => suggest_bundles(app, &task.join(" ")),
83 ["validate"] => validate_bundles(app, None),
84 ["validate", selector] => validate_bundles(app, Some(selector)),
85 ["install"] => CommandResult::error(tr(app.ui_locale, MessageId::CmdPluginBundleUsage)),
86 ["install", rest @ ..] => install_bundle(app, &rest.join(" ")),
87 ["update"] | ["uninstall"] => {
88 CommandResult::error(tr(app.ui_locale, MessageId::CmdPluginBundleUsage))
89 }
90 ["update", selector] => update_bundle(app, selector),
91 ["uninstall", selector] => uninstall_bundle(app, selector),
92 ["trust", selector] => review_bundle(app, selector),
93 ["trust", selector, token] => mutate_bundle(app, selector, Mutation::Trust(token)),
94 ["enable", selector] => mutate_bundle(app, selector, Mutation::Enable),
95 ["disable", selector] => mutate_bundle(app, selector, Mutation::Disable),
96 ["revoke", selector] => mutate_bundle(app, selector, Mutation::Revoke),
97 ["reload"] => {
98 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&app.workspace);
99 app.refresh_skill_cache();
100 let count = app.plugin_registry.len();
101 CommandResult::with_message_and_action(
102 tr(app.ui_locale, MessageId::CmdPluginBundleReloaded)
103 .replace("{count}", &count.to_string())
104 .replace("{workspace}", &app.workspace.display().to_string()),
105 AppAction::PluginRegistryChanged,
106 )
107 }
108 ["tools"] => legacy_tools(app, None),
109 ["tools", name] => legacy_tools(app, Some(name)),
110 [selector] => {
111 if app.plugin_registry.get(selector).is_some() {
112 show_bundle(app, selector)
113 } else {
114 // Preserve `/plugin <script-tool>` compatibility while making
115 // its distinct execution model explicit in the output.
116 legacy_tools(app, Some(selector))
117 }
118 }
119 _ => CommandResult::error(tr(app.ui_locale, MessageId::CmdPluginBundleUsage)),
120 }
121 }
122
123 /// Rank already installed bundle metadata for a task without changing trust,
124 /// enablement, disk state, or network state. A full remote plugin marketplace
125 /// needs separately curated publisher/provenance policy; the existing plugin
126 /// registry is intentionally local-only for this release.
127 fn suggest_bundles(app: &App, task: &str) -> CommandResult {
128 let task = task.trim();
129 if task.chars().count() < 3 {
130 return CommandResult::error("Usage: /plugin suggest <task of at least 3 characters>");
131 }
132
133 let mut skills = BTreeMap::new();
134 for plugin in app.plugin_registry.list() {
135 let mut description_parts = plugin
136 .manifest
137 .plugin
138 .description
139 .iter()
140 .cloned()
141 .collect::<Vec<_>>();
142 let mut keywords = Vec::new();
143 for skill in &plugin.skill_snapshots {
144 description_parts.push(skill.name.clone());
145 description_parts.push(skill.description.clone());
146 keywords.push(skill.name.clone());
147 keywords.extend(skill.aliases.iter().cloned());
148 }
149 skills.insert(
150 plugin.name().to_string(),
151 crate::skills::RegistryEntry {
152 source: plugin.id.as_str().to_string(),
153 description: (!description_parts.is_empty()).then(|| description_parts.join(" ")),
154 keywords,
155 domains: plugin.inventory.network_hosts.clone(),
156 },
157 );
158 }
159
160 let index = crate::skills::RegistryDocument { skills };
161 let recommendations = crate::skills::recommend::recommend_remote_skills(task, &index, 3);
162 if recommendations.is_empty() {
163 return CommandResult::message(format!(
164 "No installed plugin bundles matched `{}`.\n\nInstall a reviewed bundle with /plugin install <source>. Nothing was installed, trusted, or enabled.",
165 escape_review_text(task)
166 ));
167 }
168
169 let mut output = format!(
170 "Suggested installed plugins for `{}`:\n",
171 escape_review_text(task)
172 );
173 output.push_str("─────────────────────────────\n");
174 for recommendation in recommendations {
175 let Some(plugin) = app.plugin_registry.get(&recommendation.entry.source) else {
176 continue;
177 };
178 let description = plugin
179 .manifest
180 .plugin
181 .description
182 .as_deref()
183 .filter(|description| !description.trim().is_empty())
184 .unwrap_or("No description provided.");
185 let why = recommendation
186 .matched_terms
187 .iter()
188 .map(|term| escape_review_text(term))
189 .collect::<Vec<_>>()
190 .join(", ");
191 let next_step = if plugin.active() {
192 format!("Already active: /plugin show {}", plugin.name())
193 } else if !plugin.trusted() {
194 format!("Review before enabling: /plugin trust {}", plugin.name())
195 } else if !plugin.enabled {
196 format!(
197 "Enable if that review still applies: /plugin enable {}",
198 plugin.name()
199 )
200 } else {
201 format!("Inspect its inactive state: /plugin show {}", plugin.name())
202 };
203 let _ = writeln!(
204 output,
205 " {} — {} · {}",
206 escape_review_text(plugin.name()),
207 plugin.state_label(),
208 escape_review_text(description)
209 );
210 let _ = writeln!(output, " Why: {why}");
211 let _ = writeln!(output, " {next_step}");
212 }
213 output.push_str("\nNothing was installed, trusted, or enabled.");
214 CommandResult::message(output)
215 }
216
217 fn list_bundles_and_legacy_tools(app: &App) -> CommandResult {
218 let mut output = {
219 let registry = app.plugin_registry.as_ref();
220 let plugins = registry.list();
221 let mut output = if plugins.is_empty() {
222 tr(app.ui_locale, MessageId::CmdPluginBundleNoneFound).into_owned()
223 } else {
224 let mut output = tr(app.ui_locale, MessageId::CmdPluginBundleListHeader)
225 .replace("{count}", &plugins.len().to_string());
226 output.push('\n');
227 for plugin in plugins {
228 let _ = writeln!(
229 output,
230 "• {} — {}\n {} · {} · {}\n {}",
231 escape_review_text(plugin.name()),
232 plugin.state_label(),
233 plugin.scope,
234 plugin.trust_status.as_str(),
235 plugin.inventory.summary(),
236 escape_review_text(plugin.id.as_str())
237 );
238 }
239 output
240 };
241 append_diagnostics(app, &mut output, registry.diagnostics());
242 output
243 };
244
245 if let Some((dir, tools)) = scan_legacy_tools(app) {
246 output.push('\n');
247 output.push_str(
248 &tr(app.ui_locale, MessageId::CmdPluginLegacyListHeader)
249 .replace("{count}", &tools.len().to_string())
250 .replace("{dir}", &dir.display().to_string()),
251 );
252 output.push('\n');
253 for (path, metadata) in tools {
254 let _ = writeln!(
255 output,
256 "• {} — {}\n {}",
257 escape_review_text(&metadata.name),
258 escape_review_text(&metadata.description),
259 escape_review_path(&path)
260 );
261 }
262 }
263
264 CommandResult::message(output)
265 }
266
267 fn show_bundle(app: &App, selector: &str) -> CommandResult {
268 let Some(plugin) = app.plugin_registry.get(selector).cloned() else {
269 return CommandResult::error(
270 tr(app.ui_locale, MessageId::CmdPluginBundleNotFound).replace("{name}", selector),
271 );
272 };
273 CommandResult::message(render_bundle_detail(app, &plugin, true))
274 }
275
276 fn review_bundle(app: &App, selector: &str) -> CommandResult {
277 let Some(plugin) = app.plugin_registry.get(selector).cloned() else {
278 return CommandResult::error(
279 tr(app.ui_locale, MessageId::CmdPluginBundleNotFound).replace("{name}", selector),
280 );
281 };
282 let mut output = render_bundle_detail(app, &plugin, true);
283 let _ = writeln!(
284 output,
285 "\n/plugin trust {} {}",
286 plugin.name(),
287 review_token(&plugin)
288 );
289 CommandResult::message(output)
290 }
291
292 fn validate_bundles(app: &App, selector: Option<&str>) -> CommandResult {
293 let (plugins, diagnostics, clean) = {
294 let registry = app.plugin_registry.as_ref();
295 let plugins: Vec<LoadedPlugin> = match selector {
296 Some(selector) => registry.get(selector).cloned().into_iter().collect(),
297 None => registry.list().into_iter().cloned().collect(),
298 };
299 (
300 plugins,
301 registry.diagnostics().to_vec(),
302 registry.validation_is_clean(),
303 )
304 };
305 if app.plugin_registry.is_empty() && selector.is_none() {
306 return CommandResult::error(tr(app.ui_locale, MessageId::CmdPluginBundleNoneFound));
307 };
308 if selector.is_some() && plugins.is_empty() {
309 return CommandResult::error(
310 tr(app.ui_locale, MessageId::CmdPluginBundleNotFound)
311 .replace("{name}", selector.unwrap_or_default()),
312 );
313 }
314
315 let mut output = String::new();
316 for plugin in &plugins {
317 let _ = writeln!(
318 output,
319 "{} — {} — {}",
320 plugin.name(),
321 if plugin
322 .diagnostics
323 .iter()
324 .any(|diagnostic| diagnostic.level == PluginDiagnosticLevel::Error)
325 {
326 "invalid"
327 } else {
328 "valid"
329 },
330 plugin.inventory.summary()
331 );
332 append_diagnostics(app, &mut output, &plugin.diagnostics);
333 }
334 append_diagnostics(app, &mut output, &diagnostics);
335 if output.is_empty() {
336 output.push_str(if clean { "valid" } else { "invalid" });
337 }
338 CommandResult::message(output)
339 }
340
341 // ─── /plugin install | update | uninstall (#5182) ──────────────────────────
342 //
343 // The fetch/place on-ramp. All writes go through `plugins::mutation`; after a
344 // successful install or update the command rediscovers and drops the user
345 // into the existing trust review (`review_bundle`) — installed or replaced
346 // bits are always disabled and untrusted until the hash-bound trust flow runs.
347
348 fn install_bundle(app: &mut App, spec: &str) -> CommandResult {
349 use crate::plugins::mutation::{
350 PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
351 };
352
353 let source = match crate::plugins::install::PluginInstallSource::parse(spec) {
354 Ok(source) => source,
355 Err(error) => {
356 return CommandResult::error(format!(
357 "Invalid plugin install source `{spec}`: {error:#}\n\
358 Expected a local path, github:owner/repo, or an HTTPS tarball URL."
359 ));
360 }
361 };
362 let network = plugin_network_policy();
363 let registry = std::sync::Arc::make_mut(&mut app.plugin_registry);
364 let outcome = run_async(async move {
365 let ctx = PluginMutationContext {
366 network: &network,
367 max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
368 };
369 crate::plugins::mutation::execute(PluginMutationRequest::Install { source }, &ctx, registry)
370 .await
371 });
372
373 match outcome {
374 Ok(receipt) => match receipt.outcome {
375 PluginMutationOutcome::Installed => {
376 let name = receipt.name.clone();
377 let path = receipt
378 .path
379 .as_deref()
380 .map(|path| path.display().to_string())
381 .unwrap_or_default();
382 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&app.workspace);
383 app.refresh_skill_cache();
384 let mut output = format!(
385 "Installed plugin '{name}' to {path}.\n\
386 It is disabled and untrusted. Review its requested authority below, then trust and enable it.\n"
387 );
388 if let Some(review) = review_bundle(app, &name).message {
389 output.push('\n');
390 output.push_str(&review);
391 }
392 CommandResult::with_message_and_action(output, AppAction::PluginRegistryChanged)
393 }
394 PluginMutationOutcome::NeedsApproval(host) => {
395 CommandResult::error(needs_approval_message(&host))
396 }
397 PluginMutationOutcome::NetworkDenied(host) => {
398 CommandResult::error(network_denied_message(&host))
399 }
400 other => CommandResult::error(format!("Unexpected install outcome: {other:?}")),
401 },
402 Err(error) => action_error(app, &format!("Plugin install failed: {error:#}")),
403 }
404 }
405
406 fn update_bundle(app: &mut App, selector: &str) -> CommandResult {
407 use crate::plugins::mutation::{
408 PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
409 };
410
411 let network = plugin_network_policy();
412 let selector_owned = selector.to_string();
413 let registry = std::sync::Arc::make_mut(&mut app.plugin_registry);
414 let outcome = run_async(async move {
415 let ctx = PluginMutationContext {
416 network: &network,
417 max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
418 };
419 crate::plugins::mutation::execute(
420 PluginMutationRequest::Update {
421 selector: selector_owned,
422 },
423 &ctx,
424 registry,
425 )
426 .await
427 });
428
429 match outcome {
430 Ok(receipt) => match receipt.outcome {
431 PluginMutationOutcome::Updated => {
432 let name = receipt.name.clone();
433 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&app.workspace);
434 app.refresh_skill_cache();
435 let mut output = format!(
436 "Updated plugin '{name}'. Its content changed, so the previous trust receipt no \
437 longer matches — review and trust it again before enabling.\n"
438 );
439 if let Some(review) = review_bundle(app, &name).message {
440 output.push('\n');
441 output.push_str(&review);
442 }
443 CommandResult::with_message_and_action(output, AppAction::PluginRegistryChanged)
444 }
445 PluginMutationOutcome::NoChange => {
446 CommandResult::message(format!("Plugin '{}' is already up to date.", receipt.name))
447 }
448 PluginMutationOutcome::NeedsApproval(host) => {
449 CommandResult::error(needs_approval_message(&host))
450 }
451 PluginMutationOutcome::NetworkDenied(host) => {
452 CommandResult::error(network_denied_message(&host))
453 }
454 other => CommandResult::error(format!("Unexpected update outcome: {other:?}")),
455 },
456 Err(error) => action_error(app, &format!("Plugin update failed: {error:#}")),
457 }
458 }
459
460 fn uninstall_bundle(app: &mut App, selector: &str) -> CommandResult {
461 use crate::plugins::mutation::{
462 PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
463 };
464
465 let network = plugin_network_policy();
466 let selector_owned = selector.to_string();
467 let registry = std::sync::Arc::make_mut(&mut app.plugin_registry);
468 let outcome = run_async(async move {
469 let ctx = PluginMutationContext {
470 network: &network,
471 max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
472 };
473 crate::plugins::mutation::execute(
474 PluginMutationRequest::Uninstall {
475 selector: selector_owned,
476 },
477 &ctx,
478 registry,
479 )
480 .await
481 });
482
483 match outcome {
484 Ok(receipt) => {
485 debug_assert!(matches!(
486 receipt.outcome,
487 PluginMutationOutcome::Uninstalled
488 ));
489 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&app.workspace);
490 app.refresh_skill_cache();
491 app.active_skill = None;
492 app.active_skill_provenance = None;
493 CommandResult::with_message_and_action(
494 format!("Uninstalled plugin '{}'.", receipt.name),
495 AppAction::PluginRegistryChanged,
496 )
497 }
498 Err(error) => action_error(app, &format!("Plugin uninstall failed: {error:#}")),
499 }
500 }
501
502 /// Read the active network policy for plugin downloads. Mirrors the skill
503 /// installer's on-demand `Config::load` (`App` carries no `Config` field);
504 /// a parse failure falls back to the prompt-default policy so the download
505 /// stays gated rather than crashing.
506 fn plugin_network_policy() -> crate::network_policy::NetworkPolicy {
507 crate::config::Config::load(None, None)
508 .unwrap_or_default()
509 .network
510 .map(|policy| policy.into_runtime())
511 .unwrap_or_default()
512 }
513
514 fn run_async<F, T>(future: F) -> T
515 where
516 F: std::future::Future<Output = T>,
517 {
518 // Same bridge as the skill commands: the TUI thread is part of the
519 // multi-threaded runtime, so `block_in_place` + `block_on` brings the
520 // sync slash-command handler back into the async ecosystem.
521 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
522 }
523
524 fn needs_approval_message(host: &str) -> String {
525 format!(
526 "Network policy requires approval for {host}.\n\
527 Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry."
528 )
529 }
530
531 fn network_denied_message(host: &str) -> String {
532 format!(
533 "Network policy denied access to {host}.\n\
534 Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator."
535 )
536 }
537
538 #[derive(Clone, Copy)]
539 enum Mutation<'a> {
540 Trust(&'a str),
541 Enable,
542 Disable,
543 Revoke,
544 }
545
546 fn mutate_bundle(app: &mut App, selector: &str, mutation: Mutation<'_>) -> CommandResult {
547 if matches!(mutation, Mutation::Enable) {
548 let needs_review = app
549 .plugin_registry
550 .get(selector)
551 .is_some_and(|plugin| !plugin.trusted());
552 if needs_review {
553 // Enabling is the natural entry point. Open the exact capability
554 // review instead of leaving the user at an opaque denial.
555 return review_bundle(app, selector);
556 }
557 }
558 if let Mutation::Trust(token) = mutation {
559 let Some(expected) = app.plugin_registry.get(selector).map(review_token) else {
560 return CommandResult::error(
561 tr(app.ui_locale, MessageId::CmdPluginBundleNotFound).replace("{name}", selector),
562 );
563 };
564 if token != expected {
565 return action_error(
566 app,
567 "Review token does not match this bundle content and capability set; run `/plugin trust <name>` again",
568 );
569 }
570 }
571
572 let result = match mutation {
573 Mutation::Trust(_) => std::sync::Arc::make_mut(&mut app.plugin_registry)
574 .trust(selector)
575 .map(|()| "trusted"),
576 Mutation::Enable => std::sync::Arc::make_mut(&mut app.plugin_registry)
577 .enable(selector)
578 .map(|()| "enabled"),
579 Mutation::Disable => std::sync::Arc::make_mut(&mut app.plugin_registry)
580 .disable(selector)
581 .map(|()| "disabled"),
582 Mutation::Revoke => std::sync::Arc::make_mut(&mut app.plugin_registry)
583 .revoke_trust(selector)
584 .map(|()| "trust-revoked"),
585 };
586 match result {
587 Ok(action) => {
588 app.refresh_skill_cache();
589 if matches!(mutation, Mutation::Disable | Mutation::Revoke) {
590 app.active_skill = None;
591 app.active_skill_provenance = None;
592 }
593 CommandResult::with_message_and_action(
594 tr(app.ui_locale, MessageId::CmdPluginBundleMutationSuccess)
595 .replace("{name}", selector)
596 .replace("{action}", action),
597 AppAction::PluginRegistryChanged,
598 )
599 }
600 Err(error) => action_error(app, &error),
601 }
602 }
603
604 fn action_error(app: &App, error: &str) -> CommandResult {
605 CommandResult::error(
606 tr(app.ui_locale, MessageId::CmdPluginActionFailed).replace("{error}", error),
607 )
608 }
609
609 lines RUST