返回 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 send-time toast is driven by the declared-keyword matcher
7 //! (`match_plugin_for_draft`), not by the score below. Scoring only ranks the
8 //! user-invoked `/plugin suggest` list. Nothing here writes to the model's
9 //! request: the former `<recommended_plugins>` user-turn block is gone
10 //! (0.10.1 plugin offering policy, rule 2).
11
12 use std::collections::{BTreeMap, BTreeSet};
13
14 use crate::skills::install::{RegistryDocument, RegistryEntry};
15 use crate::skills::recommend::recommend_remote_skills;
16
17 use super::marketplace::types::MarketplaceCandidate;
18 use super::registry::PluginRegistry;
19 use super::types::LoadedPlugin;
20
21 const DEFAULT_LIMIT: usize = 3;
22
23 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
24 pub struct RecommendOptions {
25 pub limit: usize,
26 pub min_score: usize,
27 pub include_active: bool,
28 }
29
30 impl Default for RecommendOptions {
31 fn default() -> Self {
32 Self {
33 limit: DEFAULT_LIMIT,
34 min_score: 0,
35 include_active: true,
36 }
37 }
38 }
39
40 #[derive(Debug, Clone, PartialEq, Eq)]
41 pub enum PluginMatchSource {
42 Installed { id: String },
43 Marketplace { catalog_id: String },
44 }
45
46 #[derive(Debug, Clone, PartialEq, Eq)]
47 pub enum PluginNextStep {
48 AlreadyActive,
49 Trust,
50 Enable,
51 Inspect,
52 MarketplaceInstall {
53 catalog_id: String,
54 },
55 /// `/plugin install` only when a catalog entry carries a real source spec.
56 SourceInstall {
57 spec: String,
58 },
59 }
60
61 #[derive(Debug, Clone, PartialEq, Eq)]
62 pub struct PluginTaskRecommendation {
63 pub name: String,
64 pub source: PluginMatchSource,
65 pub matched_terms: Vec<String>,
66 pub score: usize,
67 pub next_step: PluginNextStep,
68 }
69
70 impl PluginTaskRecommendation {
71 #[must_use]
72 pub fn command(&self) -> String {
73 match &self.next_step {
74 PluginNextStep::AlreadyActive | PluginNextStep::Inspect => {
75 format!("/plugin show {}", self.name)
76 }
77 PluginNextStep::Trust => format!("/plugin trust {}", self.name),
78 PluginNextStep::Enable => format!("/plugin enable {}", self.name),
79 PluginNextStep::MarketplaceInstall { catalog_id } => {
80 format!("/plugin marketplace install {catalog_id} {}", self.name)
81 }
82 PluginNextStep::SourceInstall { spec } => format!("/plugin install {spec}"),
83 }
84 }
85 }
86
87 /// One matcher-driven candidate for the send-time toast or a model-requested
88 /// review.
89 #[derive(Debug, Clone, PartialEq, Eq)]
90 pub struct PluginKeywordMatch {
91 pub name: String,
92 pub matched_term: Option<String>,
93 pub id: String,
94 pub next_step: PluginNextStep,
95 keywords: Vec<String>,
96 domains: Vec<String>,
97 }
98
99 impl PluginKeywordMatch {
100 #[must_use]
101 pub fn command(&self) -> String {
102 PluginTaskRecommendation {
103 name: self.name.clone(),
104 source: match &self.next_step {
105 PluginNextStep::MarketplaceInstall { catalog_id } => {
106 PluginMatchSource::Marketplace {
107 catalog_id: catalog_id.clone(),
108 }
109 }
110 _ => PluginMatchSource::Installed {
111 id: self.id.clone(),
112 },
113 },
114 matched_terms: Vec::new(),
115 score: 0,
116 next_step: self.next_step.clone(),
117 }
118 .command()
119 }
120 }
121
122 /// Load the bundled first-party catalog and user-added catalogs from the
123 /// shared store. Browsing never fetches or installs plugin content.
124 #[must_use]
125 pub fn load_marketplace_candidates(
126 state_path: Option<&std::path::Path>,
127 ) -> Vec<MarketplaceCandidate> {
128 let Some(store) = crate::plugins::marketplace::store::MarketplaceStore::open(state_path) else {
129 return Vec::new();
130 };
131 let Ok(state) = store.load() else {
132 return Vec::new();
133 };
134 state
135 .catalogs()
136 .values()
137 .flat_map(|catalog| catalog.catalog.candidates.iter().cloned())
138 .collect()
139 }
140
141 /// Keyword candidates that can still be reviewed: installed-but-idle plugins
142 /// and uninstalled catalog entries. Already-active plugins are omitted, and so
143 /// are:
144 ///
145 /// - bundled (`PluginScope::Builtin`) plugins, which are never advertised and
146 /// appear passively in `/plugin list` and Extensions only (policy rule 5);
147 /// - plugins that cannot run on this machine: an installed bundle whose
148 /// `when` gate is not met, or a catalog entry whose `when.os` excludes the
149 /// current OS (policy rule 7).
150 #[must_use]
151 pub fn idle_and_catalog_keyword_matches(
152 registry: &PluginRegistry,
153 marketplace: &[MarketplaceCandidate],
154 ) -> Vec<PluginKeywordMatch> {
155 idle_and_catalog_keyword_matches_for_os(registry, marketplace, std::env::consts::OS)
156 }
157
158 /// True when a catalog entry's `when.os` (if any) admits `os`. Binary gates
159 /// are left to install review: the binary may arrive with the plugin.
160 fn catalog_os_allows(when: Option<&super::manifest::PluginWhen>, os: &str) -> bool {
161 when.and_then(|when| when.os.as_ref())
162 .is_none_or(|os_list| os_list.iter().any(|entry| entry.eq_ignore_ascii_case(os)))
163 }
164
165 fn idle_and_catalog_keyword_matches_for_os(
166 registry: &PluginRegistry,
167 marketplace: &[MarketplaceCandidate],
168 os: &str,
169 ) -> Vec<PluginKeywordMatch> {
170 let installed = registry.list();
171 let installed_names = installed
172 .iter()
173 .map(|plugin| plugin.name().to_ascii_lowercase())
174 .collect::<BTreeSet<_>>();
175 let mut out = Vec::new();
176 for plugin in &installed {
177 if plugin.active()
178 || plugin.scope == super::types::PluginScope::Builtin
179 || !plugin.applicable
180 {
181 continue;
182 }
183 let next_step = if !plugin.trusted() {
184 PluginNextStep::Trust
185 } else if !plugin.enabled {
186 PluginNextStep::Enable
187 } else {
188 continue;
189 };
190 let mut keywords = plugin.manifest.plugin.keywords.clone();
191 keywords.push(plugin.name().to_string());
192 out.push(PluginKeywordMatch {
193 matched_term: None,
194 name: plugin.name().to_string(),
195 id: plugin.id.as_str().to_string(),
196 next_step,
197 keywords,
198 domains: plugin.inventory.network_hosts.clone(),
199 });
200 }
201 for candidate in marketplace {
202 if candidate.has_errors() {
203 continue;
204 }
205 // Only plugins are plugin suggestions (#6290 rework): skill entries
206 // are installable, but this pool feeds the composer toast, so a skill
207 // must not be dressed as one. This replaces #6274's name suppression, which existed only
208 // because the catalog mixed the two kinds.
209 if candidate.kind != crate::plugins::marketplace::types::MarketplaceEntryKind::Plugin {
210 continue;
211 }
212 if installed_names.contains(&candidate.name.to_ascii_lowercase())
213 || !catalog_os_allows(candidate.when.as_ref(), os)
214 {
215 continue;
216 }
217 let mut keywords = candidate.keywords.clone();
218 keywords.push(candidate.name.clone());
219 if let Some(display) = &candidate.display_name {
220 keywords.push(display.clone());
221 }
222 // Categories are deliberately *not* matchable, for the same reason a
223 // code-hosting homepage is not (see `matcher::effective_keywords`): a
224 // category names the bucket a catalog files the plugin under, not what
225 // the plugin is. The bundled catalog buckets read `development`,
226 // `productivity`, `design`, `testing`, `security` — ordinary English
227 // words, shared by up to 121 entries each — so folding them in made a
228 // normal sentence open an unsolicited install prompt. Scored
229 // `/plugin suggest` still weighs them (`index_entry_from_marketplace`);
230 // that path is user-invoked and ranked, not a proactive interruption.
231 let mut domains = Vec::new();
232 if let Some(homepage) = &candidate.homepage {
233 domains.push(homepage.clone());
234 }
235 let next_step = match &candidate.install_plan {
236 crate::plugins::marketplace::types::MarketplaceInstallPlan::Supported {
237 spec, ..
238 } if candidate.catalog_id.as_str().is_empty() => {
239 PluginNextStep::SourceInstall { spec: spec.clone() }
240 }
241 _ => PluginNextStep::MarketplaceInstall {
242 catalog_id: candidate.catalog_id.as_str().to_string(),
243 },
244 };
245 out.push(PluginKeywordMatch {
246 matched_term: None,
247 name: candidate.name.clone(),
248 id: candidate.id.as_str().to_string(),
249 next_step,
250 keywords,
251 domains,
252 });
253 }
254 out
255 }
256
257 /// One matcher-driven hit for a live draft. Already-active plugins never
258 /// match. `/plugin install` is returned only when a catalog entry carries a
259 /// real install spec.
260 #[must_use]
261 pub fn match_plugin_for_draft(
262 draft: &str,
263 registry: &PluginRegistry,
264 marketplace: &[MarketplaceCandidate],
265 dismissed: &BTreeSet<String>,
266 ) -> Option<PluginKeywordMatch> {
267 let mut candidates = idle_and_catalog_keyword_matches(registry, marketplace);
268 candidates.retain(|candidate| !dismissed.contains(&candidate.name.to_ascii_lowercase()));
269 match_plugin_for_draft_among(draft, &candidates)
270 }
271
272 #[must_use]
273 pub fn match_plugin_for_draft_among(
274 draft: &str,
275 candidates: &[PluginKeywordMatch],
276 ) -> Option<PluginKeywordMatch> {
277 let keyword_candidates = candidates
278 .iter()
279 .map(|candidate| crate::plugins::matcher::KeywordCandidate {
280 name: candidate.name.as_str(),
281 domains: &candidate.domains,
282 keywords: &candidate.keywords,
283 })
284 .collect::<Vec<_>>();
285 let (idx, term) = crate::plugins::matcher::match_plugin_keyword(draft, &keyword_candidates)?;
286 let mut matched = candidates.get(idx)?.clone();
287 matched.matched_term = Some(term);
288 Some(matched)
289 }
290
291 /// Resolve a model-requested plugin name against installed and catalog
292 /// entries. Fails closed (None) when the name is unknown.
293 #[must_use]
294 pub fn lookup_reviewable_plugin(
295 name: &str,
296 registry: &PluginRegistry,
297 marketplace: &[MarketplaceCandidate],
298 ) -> Option<PluginKeywordMatch> {
299 let needle = name.trim();
300 if needle.is_empty() {
301 return None;
302 }
303 idle_and_catalog_keyword_matches(registry, marketplace)
304 .into_iter()
305 .find(|candidate| {
306 candidate.name.eq_ignore_ascii_case(needle) || candidate.id.eq_ignore_ascii_case(needle)
307 })
308 }
309
310 #[must_use]
311 pub fn recommend_plugins_for_task(
312 task: &str,
313 registry: &PluginRegistry,
314 marketplace: &[MarketplaceCandidate],
315 options: RecommendOptions,
316 ) -> Vec<PluginTaskRecommendation> {
317 let installed = registry.list();
318 let installed_names = installed
319 .iter()
320 .map(|plugin| plugin.name().to_ascii_lowercase())
321 .collect::<BTreeSet<_>>();
322 let mut entries = Vec::new();
323 for plugin in &installed {
324 entries.push(index_entry_from_installed(plugin));
325 }
326 for candidate in marketplace {
327 if candidate.has_errors() {
328 continue;
329 }
330 if candidate.kind != crate::plugins::marketplace::types::MarketplaceEntryKind::Plugin {
331 continue;
332 }
333 if installed_names.contains(&candidate.name.to_ascii_lowercase()) {
334 continue;
335 }
336 entries.push(index_entry_from_marketplace(candidate));
337 }
338 recommend_from_entries(task, &entries, &installed, options)
339 }
340
341 fn index_entry_from_installed(plugin: &LoadedPlugin) -> (String, RegistryEntry) {
342 let mut keywords = plugin.manifest.plugin.keywords.clone();
343 keywords.push(plugin.name().to_string());
344 let mut description_parts = plugin
345 .manifest
346 .plugin
347 .description
348 .iter()
349 .cloned()
350 .collect::<Vec<_>>();
351 for skill in &plugin.skill_snapshots {
352 description_parts.push(skill.name.clone());
353 description_parts.push(skill.description.clone());
354 keywords.push(skill.name.clone());
355 keywords.extend(skill.aliases.iter().cloned());
356 }
357 (
358 format!("installed:{}", plugin.name()),
359 RegistryEntry {
360 source: plugin.id.as_str().to_string(),
361 description: (!description_parts.is_empty()).then(|| description_parts.join(" ")),
362 keywords,
363 domains: plugin.inventory.network_hosts.clone(),
364 },
365 )
366 }
367
368 fn index_entry_from_marketplace(candidate: &MarketplaceCandidate) -> (String, RegistryEntry) {
369 let mut keywords = candidate.keywords.clone();
370 keywords.push(candidate.name.clone());
371 if let Some(display) = &candidate.display_name {
372 keywords.push(display.clone());
373 }
374 keywords.extend(candidate.categories.iter().cloned());
375 (
376 format!(
377 "marketplace:{}:{}",
378 candidate.catalog_id.as_str(),
379 candidate.name
380 ),
381 RegistryEntry {
382 source: format!(
383 "marketplace:{}:{}",
384 candidate.catalog_id.as_str(),
385 candidate.name
386 ),
387 description: candidate.description.clone(),
388 keywords,
389 domains: Vec::new(),
390 },
391 )
392 }
393
394 fn recommend_from_entries(
395 task: &str,
396 entries: &[(String, RegistryEntry)],
397 installed: &[&LoadedPlugin],
398 options: RecommendOptions,
399 ) -> Vec<PluginTaskRecommendation> {
400 if options.limit == 0 {
401 return Vec::new();
402 }
403 let index = RegistryDocument {
404 skills: entries.iter().cloned().collect::<BTreeMap<_, _>>(),
405 };
406 let ranked = recommend_remote_skills(task, &index, options.limit.saturating_mul(2));
407 let mut out = Vec::new();
408 let mut seen_names = BTreeSet::new();
409 for recommendation in ranked {
410 if recommendation.score() < options.min_score {
411 continue;
412 }
413 let (source, name, next_step) =
414 match recommendation.entry.source.strip_prefix("marketplace:") {
415 Some(rest) => {
416 let Some((catalog_id, name)) = rest.split_once(':') else {
417 continue;
418 };
419 (
420 PluginMatchSource::Marketplace {
421 catalog_id: catalog_id.to_string(),
422 },
423 name.to_string(),
424 PluginNextStep::MarketplaceInstall {
425 catalog_id: catalog_id.to_string(),
426 },
427 )
428 }
429 None => {
430 let Some(plugin) = installed
431 .iter()
432 .find(|plugin| plugin.id.as_str() == recommendation.entry.source)
433 else {
434 continue;
435 };
436 let next_step = if plugin.active() {
437 PluginNextStep::AlreadyActive
438 } else if !plugin.trusted() {
439 PluginNextStep::Trust
440 } else if !plugin.enabled {
441 PluginNextStep::Enable
442 } else {
443 PluginNextStep::Inspect
444 };
445 (
446 PluginMatchSource::Installed {
447 id: plugin.id.as_str().to_string(),
448 },
449 plugin.name().to_string(),
450 next_step,
451 )
452 }
453 };
454 if !options.include_active && next_step == PluginNextStep::AlreadyActive {
455 continue;
456 }
457 let name_key = name.to_ascii_lowercase();
458 if !seen_names.insert(name_key) {
459 continue;
460 }
461 out.push(PluginTaskRecommendation {
462 name,
463 source,
464 matched_terms: recommendation.matched_terms.clone(),
465 score: recommendation.score(),
466 next_step,
467 });
468 if out.len() >= options.limit {
469 break;
470 }
471 }
472 out
473 }
474
475 #[cfg(test)]
476 mod tests {
477 use super::*;
478 use crate::plugins::marketplace::types::{
479 CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
480 MarketplaceCatalogId, MarketplaceEntryKind, MarketplaceInstallPlan, MarketplaceSourceSpec,
481 };
482 use crate::test_support::{EnvVarGuard, lock_test_env};
483 use std::fs;
484 use tempfile::TempDir;
485
486 fn write_keyword_bundle(
487 root: &std::path::Path,
488 name: &str,
489 description: &str,
490 keywords: &[&str],
491 ) {
492 let bundle = root.join(".codewhale/plugins").join(name);
493 fs::create_dir_all(&bundle).unwrap();
494 let keyword_list = keywords
495 .iter()
496 .map(|keyword| format!("\"{keyword}\""))
497 .collect::<Vec<_>>()
498 .join(", ");
499 fs::write(
500 bundle.join("plugin.toml"),
501 format!(
502 "schema_version = 1\n[plugin]\nname = \"{name}\"\nversion = \"1.0.0\"\ndescription = \"{description}\"\nkeywords = [{keyword_list}]\n"
503 ),
504 )
505 .unwrap();
506 }
507
508 fn marketplace_candidate(catalog: &str, name: &str, keywords: &[&str]) -> MarketplaceCandidate {
509 MarketplaceCandidate {
510 id: MarketplaceCandidateId::new(&MarketplaceCatalogId::new(catalog), name),
511 catalog_id: MarketplaceCatalogId::new(catalog),
512 kind: MarketplaceEntryKind::Plugin,
513 name: name.to_string(),
514 display_name: Some(format!("{name} plugin")),
515 icon: None,
516 description: Some(format!("{name} integration")),
517 version: None,
518 author: None,
519 homepage: None,
520 repository: None,
521 license: None,
522 keywords: keywords.iter().map(|value| (*value).to_string()).collect(),
523 categories: Vec::new(),
524 source: MarketplaceSourceSpec::GitHub {
525 owner: "example".to_string(),
526 repo: name.to_string(),
527 git_ref: None,
528 sha: None,
529 },
530 install_plan: MarketplaceInstallPlan::Supported {
531 spec: format!("github:example/{name}"),
532 source_kind: "github".to_string(),
533 },
534 declared_components: None,
535 compatibility: None,
536 provenance: CatalogProvenance {
537 tier: CatalogTier::Community,
538 publisher: None,
539 source_url: None,
540 },
541 when: None,
542 diagnostics: Vec::new(),
543 }
544 }
545
546 #[test]
547 fn keyword_match_ranks_installed_supabase_plugin() {
548 let _lock = lock_test_env();
549 let root = TempDir::new().unwrap();
550 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
551 write_keyword_bundle(
552 root.path(),
553 "supabase",
554 "Hosted Postgres and auth",
555 &["supabase", "postgres"],
556 );
557 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
558 .registry_for_workspace(root.path());
559
560 let recs = recommend_plugins_for_task(
561 "add supabase auth to this app",
562 &registry,
563 &[],
564 RecommendOptions::default(),
565 );
566 assert_eq!(recs.len(), 1);
567 assert_eq!(recs[0].name, "supabase");
568 assert!(recs[0].score > 0);
569 assert_eq!(recs[0].next_step, PluginNextStep::Trust);
570 assert_eq!(recs[0].command(), "/plugin trust supabase");
571 }
572
573 #[test]
574 fn marketplace_fills_in_a_missing_plugin() {
575 let _lock = lock_test_env();
576 let root = TempDir::new().unwrap();
577 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
578 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
579 .registry_for_workspace(root.path());
580 let catalog = [marketplace_candidate("official", "supabase", &["supabase"])];
581
582 let recs = recommend_plugins_for_task(
583 "wire up supabase row level security",
584 &registry,
585 &catalog,
586 RecommendOptions::default(),
587 );
588 assert_eq!(recs.len(), 1);
589 assert_eq!(recs[0].name, "supabase");
590 assert_eq!(
591 recs[0].next_step,
592 PluginNextStep::MarketplaceInstall {
593 catalog_id: "official".to_string()
594 }
595 );
596 assert_eq!(
597 recs[0].command(),
598 "/plugin marketplace install official supabase"
599 );
600 }
601
602 #[test]
603 fn already_active_plugins_are_skipped_when_active_excluded() {
604 let _lock = lock_test_env();
605 let root = TempDir::new().unwrap();
606 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
607 write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
608 let mut registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
609 .registry_for_workspace(root.path())
610 .as_ref()
611 .clone();
612 registry.trust("supabase").unwrap();
613 registry.enable("supabase").unwrap();
614
615 let recs = recommend_plugins_for_task(
616 "add supabase auth",
617 &registry,
618 &[],
619 RecommendOptions {
620 include_active: false,
621 ..RecommendOptions::default()
622 },
623 );
624 assert!(recs.is_empty(), "{recs:?}");
625 }
626
627 /// Policy rule 5: a bundled plugin is never advertised, however well its
628 /// keywords match; it stays visible in `/plugin list` and Extensions.
629 #[test]
630 fn builtin_plugins_are_never_suggested() {
631 let root = TempDir::new().unwrap();
632 let config = crate::plugins::discovery::DiscoveryConfig {
633 workspace: root.path().join("project"),
634 user_plugins_dir: root.path().join("user"),
635 workspace_plugins_dir: root.path().join("workspace"),
636 builtin_plugin_dirs: vec![root.path().join("builtin")],
637 state_path: root.path().join("state.json"),
638 };
639 let bundle = root.path().join("builtin/computer-use");
640 fs::create_dir_all(&bundle).unwrap();
641 fs::write(
642 bundle.join("plugin.toml"),
643 "schema_version = 1\n[plugin]\nname = \"computer-use\"\nversion = \"1.0.0\"\nkeywords = [\"accessibility\", \"screenshot\", \"desktop control\"]\n",
644 )
645 .unwrap();
646 let registry = crate::plugins::discovery::discover_with_config(&config);
647 let plugin = registry.get("computer-use").expect("builtin discovered");
648 assert_eq!(plugin.scope, crate::plugins::types::PluginScope::Builtin);
649 assert!(!plugin.active(), "fixture must be idle to prove the skip");
650
651 let catalog = [marketplace_candidate(
652 "official",
653 "computer-use",
654 &["desktop control"],
655 )];
656 assert!(idle_and_catalog_keyword_matches(&registry, &catalog).is_empty());
657 for draft in [
658 "improve accessibility",
659 "take a screenshot",
660 "fix the accessibility of the login form",
661 "use desktop control to click the button",
662 ] {
663 assert_eq!(
664 match_plugin_for_draft(draft, &registry, &catalog, &BTreeSet::new()),
665 None,
666 "{draft}"
667 );
668 }
669 assert!(lookup_reviewable_plugin("computer-use", &registry, &catalog).is_none());
670 }
671
672 /// Policy rule 6: generic words never trigger an offer, even for a
673 /// non-bundled plugin that declares them.
674 #[test]
675 fn generic_words_do_not_suggest_an_installed_plugin() {
676 let _lock = lock_test_env();
677 let root = TempDir::new().unwrap();
678 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
679 write_keyword_bundle(
680 root.path(),
681 "chromewhale",
682 "Codewhale in your own Chrome",
683 &["chrome", "browser", "extension", "side-panel", "web"],
684 );
685 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
686 .registry_for_workspace(root.path());
687 let catalog = [marketplace_candidate(
688 "official",
689 "screen-tools",
690 &["accessibility", "screenshot", "automation"],
691 )];
692 for draft in [
693 "improve accessibility",
694 "take a screenshot",
695 "open chrome and check the web page",
696 "write a browser extension",
697 "add automation to the docs site",
698 ] {
699 assert_eq!(
700 match_plugin_for_draft(draft, &registry, &catalog, &BTreeSet::new()),
701 None,
702 "{draft}"
703 );
704 }
705 // Specific terms still work.
706 assert_eq!(
707 match_plugin_for_draft("open the side-panel", &registry, &catalog, &BTreeSet::new())
708 .map(|matched| matched.name),
709 Some("chromewhale".to_string())
710 );
711 }
712
713 /// Policy rule 7: only offer what can run here.
714 #[test]
715 fn plugins_for_another_os_are_not_suggested() {
716 let _lock = lock_test_env();
717 let root = TempDir::new().unwrap();
718 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
719 let registry = crate::plugins::PluginRegistry::empty(root.path());
720 let mut mac_only = marketplace_candidate("official", "mac-control", &["mac control"]);
721 mac_only.when = Some(crate::plugins::manifest::PluginWhen {
722 os: Some(vec!["macos".to_string()]),
723 binaries: None,
724 });
725 let catalog = std::slice::from_ref(&mac_only);
726 assert!(idle_and_catalog_keyword_matches_for_os(&registry, catalog, "linux").is_empty());
727 assert!(idle_and_catalog_keyword_matches_for_os(&registry, catalog, "windows").is_empty());
728 assert_eq!(
729 idle_and_catalog_keyword_matches_for_os(&registry, catalog, "macos").len(),
730 1,
731 "control: the same entry is offered on macOS"
732 );
733
734 // An installed bundle whose `when` gate fails here is not offered.
735 let bundle = root.path().join(".codewhale/plugins/elsewhere");
736 fs::create_dir_all(&bundle).unwrap();
737 let other_os = if cfg!(target_os = "windows") {
738 "linux"
739 } else {
740 "windows"
741 };
742 fs::write(
743 bundle.join("plugin.toml"),
744 format!(
745 "schema_version = 1\n[plugin]\nname = \"elsewhere\"\nversion = \"1.0.0\"\nkeywords = [\"elsewhere\"]\n[when]\nos = [\"{other_os}\"]\n"
746 ),
747 )
748 .unwrap();
749 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
750 .registry_for_workspace(root.path());
751 let plugin = registry.get("elsewhere").expect("bundle discovered");
752 assert!(!plugin.applicable);
753 assert_eq!(
754 match_plugin_for_draft("run elsewhere", &registry, &[], &BTreeSet::new()),
755 None
756 );
757 }
758
759 /// A skill entry in a catalog is installable but is never a plugin
760 /// suggestion — the structural replacement for #6274's name suppression.
761 #[test]
762 fn skill_entries_never_enter_the_plugin_suggestion_pool() {
763 let _lock = lock_test_env();
764 let root = TempDir::new().unwrap();
765 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
766 let registry = crate::plugins::PluginRegistry::empty(root.path());
767 let mut skill = marketplace_candidate("cw2", "test", &["test"]);
768 skill.kind = MarketplaceEntryKind::Skill;
769 let slice = std::slice::from_ref(&skill);
770 assert!(
771 idle_and_catalog_keyword_matches(&registry, slice).is_empty(),
772 "a skill entry must not be a plugin candidate"
773 );
774
775 // Control: the same entry as a plugin is a candidate, so the
776 // exclusion is the kind and not a broken fixture.
777 skill.kind = MarketplaceEntryKind::Plugin;
778 assert_eq!(
779 idle_and_catalog_keyword_matches(&registry, std::slice::from_ref(&skill)).len(),
780 1,
781 "the same entry as a plugin is a candidate"
782 );
783 }
784
785 /// A catalog category names the bucket an entry is filed under, not what
786 /// the plugin *is* — the same class of thing as a code-hosting homepage,
787 /// which the matcher already refuses. Folding categories into the
788 /// matchable keyword set made ordinary English in a draft ("productivity",
789 /// "development") open an unsolicited install prompt.
790 #[test]
791 fn catalog_categories_never_fire_a_plugin_suggestion() {
792 let _lock = lock_test_env();
793 let root = TempDir::new().unwrap();
794 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
795 let registry = crate::plugins::PluginRegistry::empty(root.path());
796 let mut candidate = marketplace_candidate("anthropic", "receipts", &["expense"]);
797 candidate.display_name = None;
798 candidate.categories = vec!["productivity".to_string(), "development".to_string()];
799 let slice = std::slice::from_ref(&candidate);
800 let candidates = idle_and_catalog_keyword_matches(&registry, slice);
801 assert_eq!(candidates.len(), 1, "fixture must supply one candidate");
802
803 for draft in [
804 "some notes on productivity today",
805 "walk me through the development workflow",
806 ] {
807 assert!(
808 match_plugin_for_draft_among(draft, &candidates).is_none(),
809 "a category must not fire an install prompt: {draft}"
810 );
811 }
812
813 // Control: the declared keyword and the entry name still match, so the
814 // exclusion is the category and not a dead fixture.
815 for (draft, term) in [
816 ("track this expense", "expense"),
817 ("open receipts", "receipts"),
818 ] {
819 let matched = match_plugin_for_draft_among(draft, &candidates)
820 .unwrap_or_else(|| panic!("declared term must still match: {draft}"));
821 assert_eq!(matched.name, "receipts");
822 assert_eq!(matched.matched_term.as_deref(), Some(term));
823 }
824 }
825
826 #[test]
827 fn matcher_driven_cta_skips_already_active_plugins() {
828 let _lock = lock_test_env();
829 let root = TempDir::new().unwrap();
830 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
831 write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
832 let mut registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
833 .registry_for_workspace(root.path())
834 .as_ref()
835 .clone();
836 registry.trust("supabase").unwrap();
837 registry.enable("supabase").unwrap();
838
839 assert!(
840 match_plugin_for_draft("add supabase auth", &registry, &[], &BTreeSet::new()).is_none(),
841 "active plugins must not produce a live CTA"
842 );
843 }
844 }
845
845 lines RUST