返回 CodeWhale
recommend.rs
根目录 / crates / tui / src / plugins / recommend.rs
1 //! Plugin suggestions for a user task.
2 //!
3 //! Ranks installed bundles and locally-added marketplace candidates. A
4 //! suggestion is never an install, trust, enable, or network side effect.
5 //!
6 //! The proactive toast and the `<recommended_plugins>` fragment are driven
7 //! by the declared-keyword matcher (`match_plugin_for_draft`), not by the
8 //! score below: there is no host score gate on what the model sees. Scoring
9 //! only ranks the user-invoked `/plugin suggest` list.
10
11 use std::collections::{BTreeMap, BTreeSet};
12
13 use crate::skills::install::{RegistryDocument, RegistryEntry};
14 use crate::skills::recommend::recommend_remote_skills;
15
16 use super::marketplace::types::MarketplaceCandidate;
17 use super::registry::PluginRegistry;
18 use super::types::LoadedPlugin;
19
20 const DEFAULT_LIMIT: usize = 3;
21
22 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
23 pub struct RecommendOptions {
24 pub limit: usize,
25 pub min_score: usize,
26 pub include_active: bool,
27 }
28
29 impl Default for RecommendOptions {
30 fn default() -> Self {
31 Self {
32 limit: DEFAULT_LIMIT,
33 min_score: 0,
34 include_active: true,
35 }
36 }
37 }
38
39 #[derive(Debug, Clone, PartialEq, Eq)]
40 pub enum PluginMatchSource {
41 Installed { id: String },
42 Marketplace { catalog_id: String },
43 }
44
45 #[derive(Debug, Clone, PartialEq, Eq)]
46 pub enum PluginNextStep {
47 AlreadyActive,
48 Trust,
49 Enable,
50 Inspect,
51 MarketplaceInstall {
52 catalog_id: String,
53 },
54 /// `/plugin install` only when a catalog entry carries a real source spec.
55 SourceInstall {
56 spec: String,
57 },
58 }
59
60 #[derive(Debug, Clone, PartialEq, Eq)]
61 pub struct PluginTaskRecommendation {
62 pub name: String,
63 pub source: PluginMatchSource,
64 pub matched_terms: Vec<String>,
65 pub score: usize,
66 pub next_step: PluginNextStep,
67 }
68
69 impl PluginTaskRecommendation {
70 #[must_use]
71 pub fn command(&self) -> String {
72 match &self.next_step {
73 PluginNextStep::AlreadyActive | PluginNextStep::Inspect => {
74 format!("/plugin show {}", self.name)
75 }
76 PluginNextStep::Trust => format!("/plugin trust {}", self.name),
77 PluginNextStep::Enable => format!("/plugin enable {}", self.name),
78 PluginNextStep::MarketplaceInstall { catalog_id } => {
79 format!("/plugin marketplace install {catalog_id} {}", self.name)
80 }
81 PluginNextStep::SourceInstall { spec } => format!("/plugin install {spec}"),
82 }
83 }
84 }
85
86 const RECOMMENDED_PLUGINS_INTRO: &str =
87 "Here is a list of plugins that are available but not installed.";
88 const MAX_RECOMMENDED_PLUGINS: usize = 8;
89
90 /// One matcher-driven candidate for the live composer CTA or the
91 /// append-only `<recommended_plugins>` user fragment.
92 #[derive(Debug, Clone, PartialEq, Eq)]
93 pub struct PluginKeywordMatch {
94 pub name: String,
95 pub matched_term: Option<String>,
96 pub id: String,
97 pub next_step: PluginNextStep,
98 keywords: Vec<String>,
99 domains: Vec<String>,
100 }
101
102 impl PluginKeywordMatch {
103 #[must_use]
104 pub fn command(&self) -> String {
105 PluginTaskRecommendation {
106 name: self.name.clone(),
107 source: match &self.next_step {
108 PluginNextStep::MarketplaceInstall { catalog_id } => {
109 PluginMatchSource::Marketplace {
110 catalog_id: catalog_id.clone(),
111 }
112 }
113 _ => PluginMatchSource::Installed {
114 id: self.id.clone(),
115 },
116 },
117 matched_terms: Vec::new(),
118 score: 0,
119 next_step: self.next_step.clone(),
120 }
121 .command()
122 }
123 }
124
125 /// Load the bundled first-party catalog and user-added catalogs from the
126 /// shared store. Browsing never fetches or installs plugin content.
127 #[must_use]
128 pub fn load_marketplace_candidates(
129 state_path: Option<&std::path::Path>,
130 ) -> Vec<MarketplaceCandidate> {
131 let Some(store) = crate::plugins::marketplace::store::MarketplaceStore::open(state_path) else {
132 return Vec::new();
133 };
134 let Ok(state) = store.load() else {
135 return Vec::new();
136 };
137 state
138 .catalogs()
139 .values()
140 .flat_map(|catalog| catalog.catalog.candidates.iter().cloned())
141 .collect()
142 }
143
144 /// Keyword candidates that can still be reviewed: installed-but-idle plugins
145 /// and uninstalled catalog entries. Already-active plugins are omitted.
146 #[must_use]
147 pub fn idle_and_catalog_keyword_matches(
148 registry: &PluginRegistry,
149 marketplace: &[MarketplaceCandidate],
150 ) -> Vec<PluginKeywordMatch> {
151 let installed = registry.list();
152 let installed_names = installed
153 .iter()
154 .map(|plugin| plugin.name().to_ascii_lowercase())
155 .collect::<BTreeSet<_>>();
156 let mut out = Vec::new();
157 for plugin in &installed {
158 if plugin.active() {
159 continue;
160 }
161 let next_step = if !plugin.trusted() {
162 PluginNextStep::Trust
163 } else if !plugin.enabled {
164 PluginNextStep::Enable
165 } else {
166 continue;
167 };
168 let mut keywords = plugin.manifest.plugin.keywords.clone();
169 keywords.push(plugin.name().to_string());
170 out.push(PluginKeywordMatch {
171 matched_term: None,
172 name: plugin.name().to_string(),
173 id: plugin.id.as_str().to_string(),
174 next_step,
175 keywords,
176 domains: plugin.inventory.network_hosts.clone(),
177 });
178 }
179 for candidate in marketplace {
180 if candidate.has_errors() {
181 continue;
182 }
183 // Only plugins are plugin suggestions (#6290 rework): skill entries
184 // are installable, but this pool feeds the composer toast and the
185 // `<recommended_plugins>` fragment, so a skill must not be dressed as
186 // one. This replaces #6274's name suppression, which existed only
187 // because the catalog mixed the two kinds.
188 if candidate.kind != crate::plugins::marketplace::types::MarketplaceEntryKind::Plugin {
189 continue;
190 }
191 if installed_names.contains(&candidate.name.to_ascii_lowercase()) {
192 continue;
193 }
194 let mut keywords = candidate.keywords.clone();
195 keywords.push(candidate.name.clone());
196 if let Some(display) = &candidate.display_name {
197 keywords.push(display.clone());
198 }
199 keywords.extend(candidate.categories.iter().cloned());
200 let mut domains = Vec::new();
201 if let Some(homepage) = &candidate.homepage {
202 domains.push(homepage.clone());
203 }
204 let next_step = match &candidate.install_plan {
205 crate::plugins::marketplace::types::MarketplaceInstallPlan::Supported {
206 spec, ..
207 } if candidate.catalog_id.as_str().is_empty() => {
208 PluginNextStep::SourceInstall { spec: spec.clone() }
209 }
210 _ => PluginNextStep::MarketplaceInstall {
211 catalog_id: candidate.catalog_id.as_str().to_string(),
212 },
213 };
214 out.push(PluginKeywordMatch {
215 matched_term: None,
216 name: candidate.name.clone(),
217 id: candidate.id.as_str().to_string(),
218 next_step,
219 keywords,
220 domains,
221 });
222 }
223 out
224 }
225
226 /// One matcher-driven hit for a live draft. Already-active plugins never
227 /// match. `/plugin install` is returned only when a catalog entry carries a
228 /// real install spec.
229 #[must_use]
230 pub fn match_plugin_for_draft(
231 draft: &str,
232 registry: &PluginRegistry,
233 marketplace: &[MarketplaceCandidate],
234 dismissed: &BTreeSet<String>,
235 ) -> Option<PluginKeywordMatch> {
236 let mut candidates = idle_and_catalog_keyword_matches(registry, marketplace);
237 candidates.retain(|candidate| !dismissed.contains(&candidate.name.to_ascii_lowercase()));
238 match_plugin_for_draft_among(draft, &candidates)
239 }
240
241 #[must_use]
242 pub fn match_plugin_for_draft_among(
243 draft: &str,
244 candidates: &[PluginKeywordMatch],
245 ) -> Option<PluginKeywordMatch> {
246 let keyword_candidates = candidates
247 .iter()
248 .map(|candidate| crate::plugins::matcher::KeywordCandidate {
249 name: candidate.name.as_str(),
250 domains: &candidate.domains,
251 keywords: &candidate.keywords,
252 })
253 .collect::<Vec<_>>();
254 let (idx, term) = crate::plugins::matcher::match_plugin_keyword(draft, &keyword_candidates)?;
255 let mut matched = candidates.get(idx)?.clone();
256 matched.matched_term = Some(term);
257 Some(matched)
258 }
259
260 /// Per-Engine gate for the append-only `<recommended_plugins>` fragment.
261 ///
262 /// A plugin id is suggested at most once per Engine lifetime, and dismissals
263 /// are honored through `Settings`.
264 ///
265 /// Skill-name suppression (#6274) is gone with the #6290 rework: it existed
266 /// only because skill entries were catalogued as plugins and then had to be
267 /// suppressed by name — a snapshot-based check that missed mid-session
268 /// changes and never applied to the composer toast. Entry kinds now keep
269 /// skills out of the plugin pool entirely (see `MarketplaceEntryKind`).
270 #[derive(Debug, Default)]
271 pub struct RecommendedPluginGate {
272 shown: BTreeSet<String>,
273 }
274
275 impl RecommendedPluginGate {
276 /// True when this plugin may be suggested now: not already suggested in
277 /// this Engine's lifetime. First admission records the plugin id.
278 fn admits(&mut self, id: &str) -> bool {
279 self.shown.insert(id.to_string())
280 }
281 }
282
283 /// Append-only user-turn fragment. Never part of the pinned system prefix.
284 /// Bounded, omitted when nothing matches.
285 #[must_use]
286 pub fn recommended_plugins_user_fragment(
287 draft: &str,
288 registry: &PluginRegistry,
289 marketplace: &[MarketplaceCandidate],
290 gate: &mut RecommendedPluginGate,
291 ) -> Option<String> {
292 // Called once when composing a user turn, never from the render loop.
293 // Read the shared preference so headless and long-lived Engines also
294 // honor dismissals recorded by a TUI after Engine startup.
295 let settings = crate::settings::Settings::load_read_only().unwrap_or_default();
296 let matched = match_plugin_for_draft(
297 draft,
298 registry,
299 marketplace,
300 &settings.dismissed_plugin_suggestions,
301 )?;
302 // Once per Engine lifetime per plugin id. Skill exclusion happens a
303 // layer down: skill-kind entries never enter the plugin pool (#6290).
304 if !gate.admits(&matched.id) {
305 return None;
306 }
307 let mut listed = vec![matched];
308 listed.truncate(MAX_RECOMMENDED_PLUGINS);
309 let body = listed
310 .iter()
311 .map(|plugin| format!("- {} ({})", plugin.name, plugin.id))
312 .collect::<Vec<_>>()
313 .join("\n");
314 Some(format!(
315 "<recommended_plugins>\n{RECOMMENDED_PLUGINS_INTRO}\n\n{body}\n</recommended_plugins>"
316 ))
317 }
318
319 /// Resolve a model-requested plugin name against installed and catalog
320 /// entries. Fails closed (None) when the name is unknown.
321 #[must_use]
322 pub fn lookup_reviewable_plugin(
323 name: &str,
324 registry: &PluginRegistry,
325 marketplace: &[MarketplaceCandidate],
326 ) -> Option<PluginKeywordMatch> {
327 let needle = name.trim();
328 if needle.is_empty() {
329 return None;
330 }
331 idle_and_catalog_keyword_matches(registry, marketplace)
332 .into_iter()
333 .find(|candidate| {
334 candidate.name.eq_ignore_ascii_case(needle) || candidate.id.eq_ignore_ascii_case(needle)
335 })
336 }
337
338 #[must_use]
339 pub fn recommend_plugins_for_task(
340 task: &str,
341 registry: &PluginRegistry,
342 marketplace: &[MarketplaceCandidate],
343 options: RecommendOptions,
344 ) -> Vec<PluginTaskRecommendation> {
345 let installed = registry.list();
346 let installed_names = installed
347 .iter()
348 .map(|plugin| plugin.name().to_ascii_lowercase())
349 .collect::<BTreeSet<_>>();
350 let mut entries = Vec::new();
351 for plugin in &installed {
352 entries.push(index_entry_from_installed(plugin));
353 }
354 for candidate in marketplace {
355 if candidate.has_errors() {
356 continue;
357 }
358 if candidate.kind != crate::plugins::marketplace::types::MarketplaceEntryKind::Plugin {
359 continue;
360 }
361 if installed_names.contains(&candidate.name.to_ascii_lowercase()) {
362 continue;
363 }
364 entries.push(index_entry_from_marketplace(candidate));
365 }
366 recommend_from_entries(task, &entries, &installed, options)
367 }
368
369 fn index_entry_from_installed(plugin: &LoadedPlugin) -> (String, RegistryEntry) {
370 let mut keywords = plugin.manifest.plugin.keywords.clone();
371 keywords.push(plugin.name().to_string());
372 let mut description_parts = plugin
373 .manifest
374 .plugin
375 .description
376 .iter()
377 .cloned()
378 .collect::<Vec<_>>();
379 for skill in &plugin.skill_snapshots {
380 description_parts.push(skill.name.clone());
381 description_parts.push(skill.description.clone());
382 keywords.push(skill.name.clone());
383 keywords.extend(skill.aliases.iter().cloned());
384 }
385 (
386 format!("installed:{}", plugin.name()),
387 RegistryEntry {
388 source: plugin.id.as_str().to_string(),
389 description: (!description_parts.is_empty()).then(|| description_parts.join(" ")),
390 keywords,
391 domains: plugin.inventory.network_hosts.clone(),
392 },
393 )
394 }
395
396 fn index_entry_from_marketplace(candidate: &MarketplaceCandidate) -> (String, RegistryEntry) {
397 let mut keywords = candidate.keywords.clone();
398 keywords.push(candidate.name.clone());
399 if let Some(display) = &candidate.display_name {
400 keywords.push(display.clone());
401 }
402 keywords.extend(candidate.categories.iter().cloned());
403 (
404 format!(
405 "marketplace:{}:{}",
406 candidate.catalog_id.as_str(),
407 candidate.name
408 ),
409 RegistryEntry {
410 source: format!(
411 "marketplace:{}:{}",
412 candidate.catalog_id.as_str(),
413 candidate.name
414 ),
415 description: candidate.description.clone(),
416 keywords,
417 domains: Vec::new(),
418 },
419 )
420 }
421
422 fn recommend_from_entries(
423 task: &str,
424 entries: &[(String, RegistryEntry)],
425 installed: &[&LoadedPlugin],
426 options: RecommendOptions,
427 ) -> Vec<PluginTaskRecommendation> {
428 if options.limit == 0 {
429 return Vec::new();
430 }
431 let index = RegistryDocument {
432 skills: entries.iter().cloned().collect::<BTreeMap<_, _>>(),
433 };
434 let ranked = recommend_remote_skills(task, &index, options.limit.saturating_mul(2));
435 let mut out = Vec::new();
436 let mut seen_names = BTreeSet::new();
437 for recommendation in ranked {
438 if recommendation.score() < options.min_score {
439 continue;
440 }
441 let (source, name, next_step) =
442 match recommendation.entry.source.strip_prefix("marketplace:") {
443 Some(rest) => {
444 let Some((catalog_id, name)) = rest.split_once(':') else {
445 continue;
446 };
447 (
448 PluginMatchSource::Marketplace {
449 catalog_id: catalog_id.to_string(),
450 },
451 name.to_string(),
452 PluginNextStep::MarketplaceInstall {
453 catalog_id: catalog_id.to_string(),
454 },
455 )
456 }
457 None => {
458 let Some(plugin) = installed
459 .iter()
460 .find(|plugin| plugin.id.as_str() == recommendation.entry.source)
461 else {
462 continue;
463 };
464 let next_step = if plugin.active() {
465 PluginNextStep::AlreadyActive
466 } else if !plugin.trusted() {
467 PluginNextStep::Trust
468 } else if !plugin.enabled {
469 PluginNextStep::Enable
470 } else {
471 PluginNextStep::Inspect
472 };
473 (
474 PluginMatchSource::Installed {
475 id: plugin.id.as_str().to_string(),
476 },
477 plugin.name().to_string(),
478 next_step,
479 )
480 }
481 };
482 if !options.include_active && next_step == PluginNextStep::AlreadyActive {
483 continue;
484 }
485 let name_key = name.to_ascii_lowercase();
486 if !seen_names.insert(name_key) {
487 continue;
488 }
489 out.push(PluginTaskRecommendation {
490 name,
491 source,
492 matched_terms: recommendation.matched_terms.clone(),
493 score: recommendation.score(),
494 next_step,
495 });
496 if out.len() >= options.limit {
497 break;
498 }
499 }
500 out
501 }
502
503 #[cfg(test)]
504 mod tests {
505 use super::*;
506 use crate::plugins::marketplace::types::{
507 CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
508 MarketplaceCatalogId, MarketplaceEntryKind, MarketplaceInstallPlan, MarketplaceSourceSpec,
509 };
510 use crate::test_support::{EnvVarGuard, lock_test_env};
511 use std::fs;
512 use tempfile::TempDir;
513
514 fn write_keyword_bundle(
515 root: &std::path::Path,
516 name: &str,
517 description: &str,
518 keywords: &[&str],
519 ) {
520 let bundle = root.join(".codewhale/plugins").join(name);
521 fs::create_dir_all(&bundle).unwrap();
522 let keyword_list = keywords
523 .iter()
524 .map(|keyword| format!("\"{keyword}\""))
525 .collect::<Vec<_>>()
526 .join(", ");
527 fs::write(
528 bundle.join("plugin.toml"),
529 format!(
530 "schema_version = 1\n[plugin]\nname = \"{name}\"\nversion = \"1.0.0\"\ndescription = \"{description}\"\nkeywords = [{keyword_list}]\n"
531 ),
532 )
533 .unwrap();
534 }
535
536 fn marketplace_candidate(catalog: &str, name: &str, keywords: &[&str]) -> MarketplaceCandidate {
537 MarketplaceCandidate {
538 id: MarketplaceCandidateId::new(&MarketplaceCatalogId::new(catalog), name),
539 catalog_id: MarketplaceCatalogId::new(catalog),
540 kind: MarketplaceEntryKind::Plugin,
541 name: name.to_string(),
542 display_name: Some(format!("{name} plugin")),
543 icon: None,
544 description: Some(format!("{name} integration")),
545 version: None,
546 author: None,
547 homepage: None,
548 repository: None,
549 license: None,
550 keywords: keywords.iter().map(|value| (*value).to_string()).collect(),
551 categories: Vec::new(),
552 source: MarketplaceSourceSpec::GitHub {
553 owner: "example".to_string(),
554 repo: name.to_string(),
555 git_ref: None,
556 sha: None,
557 },
558 install_plan: MarketplaceInstallPlan::Supported {
559 spec: format!("github:example/{name}"),
560 source_kind: "github".to_string(),
561 },
562 declared_components: None,
563 compatibility: None,
564 provenance: CatalogProvenance {
565 tier: CatalogTier::Community,
566 publisher: None,
567 source_url: None,
568 },
569 when: None,
570 diagnostics: Vec::new(),
571 }
572 }
573
574 #[test]
575 fn keyword_match_ranks_installed_supabase_plugin() {
576 let _lock = lock_test_env();
577 let root = TempDir::new().unwrap();
578 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
579 write_keyword_bundle(
580 root.path(),
581 "supabase",
582 "Hosted Postgres and auth",
583 &["supabase", "postgres"],
584 );
585 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
586 .registry_for_workspace(root.path());
587
588 let recs = recommend_plugins_for_task(
589 "add supabase auth to this app",
590 &registry,
591 &[],
592 RecommendOptions::default(),
593 );
594 assert_eq!(recs.len(), 1);
595 assert_eq!(recs[0].name, "supabase");
596 assert!(recs[0].score > 0);
597 assert_eq!(recs[0].next_step, PluginNextStep::Trust);
598 assert_eq!(recs[0].command(), "/plugin trust supabase");
599 }
600
601 #[test]
602 fn marketplace_fills_in_a_missing_plugin() {
603 let _lock = lock_test_env();
604 let root = TempDir::new().unwrap();
605 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
606 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
607 .registry_for_workspace(root.path());
608 let catalog = [marketplace_candidate("official", "supabase", &["supabase"])];
609
610 let recs = recommend_plugins_for_task(
611 "wire up supabase row level security",
612 &registry,
613 &catalog,
614 RecommendOptions::default(),
615 );
616 assert_eq!(recs.len(), 1);
617 assert_eq!(recs[0].name, "supabase");
618 assert_eq!(
619 recs[0].next_step,
620 PluginNextStep::MarketplaceInstall {
621 catalog_id: "official".to_string()
622 }
623 );
624 assert_eq!(
625 recs[0].command(),
626 "/plugin marketplace install official supabase"
627 );
628 }
629
630 #[test]
631 fn already_active_plugins_are_skipped_when_active_excluded() {
632 let _lock = lock_test_env();
633 let root = TempDir::new().unwrap();
634 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
635 write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
636 let mut registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
637 .registry_for_workspace(root.path())
638 .as_ref()
639 .clone();
640 registry.trust("supabase").unwrap();
641 registry.enable("supabase").unwrap();
642
643 let recs = recommend_plugins_for_task(
644 "add supabase auth",
645 &registry,
646 &[],
647 RecommendOptions {
648 include_active: false,
649 ..RecommendOptions::default()
650 },
651 );
652 assert!(recs.is_empty(), "{recs:?}");
653 }
654
655 #[test]
656 fn recommended_plugins_fragment_present_for_matching_idle_plugin() {
657 let _lock = lock_test_env();
658 let root = TempDir::new().unwrap();
659 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
660 write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
661 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
662 .registry_for_workspace(root.path());
663
664 let fragment = recommended_plugins_user_fragment(
665 "add supabase auth to login",
666 &registry,
667 &[],
668 &mut RecommendedPluginGate::default(),
669 )
670 .expect("idle plugin should produce a fragment");
671 assert!(fragment.starts_with("<recommended_plugins>"));
672 assert!(fragment.contains("- supabase ("));
673 assert!(fragment.contains("</recommended_plugins>"));
674 assert!(
675 recommended_plugins_user_fragment(
676 "fix the failing test",
677 &registry,
678 &[],
679 &mut RecommendedPluginGate::default(),
680 )
681 .is_none()
682 );
683 }
684
685 #[test]
686 fn recommended_plugins_fragment_suggests_a_plugin_once_per_gate() {
687 let _lock = lock_test_env();
688 let root = TempDir::new().unwrap();
689 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
690 write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
691 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
692 .registry_for_workspace(root.path());
693
694 let mut gate = RecommendedPluginGate::default();
695 let first = recommended_plugins_user_fragment(
696 "add supabase auth to login",
697 &registry,
698 &[],
699 &mut gate,
700 )
701 .expect("first matching turn suggests the plugin");
702 assert!(first.contains("- supabase ("));
703 assert!(
704 recommended_plugins_user_fragment(
705 "add supabase auth to the signup flow",
706 &registry,
707 &[],
708 &mut gate,
709 )
710 .is_none(),
711 "a plugin id is suggested at most once per Engine lifetime (#6274)"
712 );
713 }
714
715 /// A skill entry in a catalog is installable but is never a plugin
716 /// suggestion — the structural replacement for #6274's name suppression.
717 #[test]
718 fn skill_entries_never_enter_the_plugin_suggestion_pool() {
719 let _lock = lock_test_env();
720 let root = TempDir::new().unwrap();
721 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
722 let registry = crate::plugins::PluginRegistry::empty(root.path());
723 let mut skill = marketplace_candidate("cw2", "test", &["test"]);
724 skill.kind = MarketplaceEntryKind::Skill;
725 let slice = std::slice::from_ref(&skill);
726 assert!(
727 idle_and_catalog_keyword_matches(&registry, slice).is_empty(),
728 "a skill entry must not be a plugin candidate"
729 );
730 assert!(
731 recommended_plugins_user_fragment(
732 "run the test suite",
733 &registry,
734 slice,
735 &mut RecommendedPluginGate::default(),
736 )
737 .is_none(),
738 "a skill entry must not produce a <recommended_plugins> fragment"
739 );
740
741 // Control: the same entry as a plugin still matches, so the
742 // exclusion is the kind and not a broken fixture.
743 skill.kind = MarketplaceEntryKind::Plugin;
744 assert!(
745 recommended_plugins_user_fragment(
746 "run the test suite",
747 &registry,
748 std::slice::from_ref(&skill),
749 &mut RecommendedPluginGate::default(),
750 )
751 .is_some(),
752 "the same entry as a plugin still matches"
753 );
754 }
755
756 #[test]
757 fn matcher_driven_cta_skips_already_active_plugins() {
758 let _lock = lock_test_env();
759 let root = TempDir::new().unwrap();
760 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
761 write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
762 let mut registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
763 .registry_for_workspace(root.path())
764 .as_ref()
765 .clone();
766 registry.trust("supabase").unwrap();
767 registry.enable("supabase").unwrap();
768
769 assert!(
770 match_plugin_for_draft("add supabase auth", &registry, &[], &BTreeSet::new()).is_none(),
771 "active plugins must not produce a live CTA"
772 );
773 }
774 }
775
775 lines RUST