1use std::collections::HashMap;
4use std::sync::{Arc, RwLock};
5use crate::skills::{LoadedSkill, Plugin};
6use crate::help::HelpEntry;
7
8#[derive(Debug, Clone)]
9pub struct PluginSummary {
10 pub name: String,
11 pub skill_count: usize,
12}
13
14#[derive(Clone, Debug)]
15pub enum RegisteredPluginCommandBackend {
16 Shell { command: String, args: Vec<String> },
17 ExtensionTool { tool: String, input: serde_json::Value },
18 SkillPrompt { skill: String, prompt: String },
19 Interactive { plugin_extension_id: String },
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum PluginSettingsEditor {
26 Text { numeric: bool },
27 Cycler { options: Vec<String> },
28 Picker,
29 Custom,
32}
33
34#[derive(Clone, Debug)]
35pub struct PluginSettingsField {
36 pub key: String,
37 pub label: String,
38 pub editor: PluginSettingsEditor,
39 pub help: Option<String>,
40 pub default: Option<serde_json::Value>,
41}
42
43#[derive(Clone, Debug)]
49pub struct PluginSettingsCategory {
50 pub plugin: String,
51 pub id: String,
52 pub label: String,
53 pub fields: Vec<PluginSettingsField>,
54}
55
56#[derive(Clone, Debug)]
58pub struct RegisteredPluginCommand {
59 pub plugin: String,
60 pub name: String,
61 pub description: Option<String>,
62 pub backend: RegisteredPluginCommandBackend,
63 pub plugin_root: std::path::PathBuf,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct LifecycleClaim {
80 pub plugin: String,
82 pub command: String,
84 pub settings_category: Option<String>,
87 pub display_name: String,
89 pub importance: i32,
92}
93
94pub enum Resolution {
95 Builtin,
97 Skill(Arc<LoadedSkill>),
99 PluginCommand(Arc<RegisteredPluginCommand>),
101 Ambiguous(Vec<String>), Unknown,
105}
106
107struct Inner {
108 skills: HashMap<String, Vec<Arc<LoadedSkill>>>, qualified: HashMap<String, Arc<LoadedSkill>>, plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>>, plugin_help_entries: Vec<HelpEntry>,
112 plugin_settings_categories: Vec<PluginSettingsCategory>,
115 lifecycle_claims: HashMap<String, LifecycleClaim>,
119 lifecycle_claim_collisions: Vec<(String, String, String)>,
123}
124
125pub struct CommandRegistry {
126 builtins: Vec<&'static str>,
127 inner: RwLock<Inner>,
128}
129
130impl CommandRegistry {
131 pub fn new(builtins: &[&'static str], skills: Vec<LoadedSkill>) -> Self {
132 Self::new_with_plugins(builtins, skills, vec![])
133 }
134
135 pub fn new_with_plugins(builtins: &[&'static str], skills: Vec<LoadedSkill>, plugins: Vec<Plugin>) -> Self {
136 let r = CommandRegistry {
137 builtins: builtins.to_vec(),
138 inner: RwLock::new(Inner {
139 skills: HashMap::new(),
140 qualified: HashMap::new(),
141 plugin_commands: HashMap::new(),
142 plugin_help_entries: Vec::new(),
143 plugin_settings_categories: Vec::new(),
144 lifecycle_claims: HashMap::new(),
145 lifecycle_claim_collisions: Vec::new(),
146 }),
147 };
148 r.rebuild_with_plugins(skills, plugins);
149 r
150 }
151
152 pub fn rebuild_with(&self, skills: Vec<LoadedSkill>) {
154 self.rebuild_with_plugins(skills, vec![]);
155 }
156
157 pub fn rebuild_with_plugins(&self, skills: Vec<LoadedSkill>, plugins: Vec<Plugin>) {
159 let builtins_set: std::collections::HashSet<&str> =
160 self.builtins.iter().copied().collect();
161 let mut new_skills: HashMap<String, Vec<Arc<LoadedSkill>>> = HashMap::new();
162 let mut new_qualified: HashMap<String, Arc<LoadedSkill>> = HashMap::new();
163 let mut new_plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>> = HashMap::new();
164 let mut new_plugin_help_entries: Vec<HelpEntry> = Vec::new();
165 let mut new_plugin_settings_categories: Vec<PluginSettingsCategory> = Vec::new();
166 let mut new_lifecycle_claims: HashMap<String, LifecycleClaim> = HashMap::new();
167 let mut new_lifecycle_collisions: Vec<(String, String, String)> = Vec::new();
168 for plugin in plugins {
169 if let Some(manifest) = plugin.manifest {
170 new_plugin_help_entries.extend(manifest.help_entries.iter().cloned().map(|mut entry| {
171 entry.source = Some(manifest.name.clone());
172 entry
173 }));
174 if let Some(ref provides) = manifest.provides {
179 if let Some(ref sidecar) = provides.sidecar {
180 if let Some(ref lc) = sidecar.lifecycle {
181 let claim = LifecycleClaim {
182 plugin: manifest.name.clone(),
183 command: lc.command.clone(),
184 settings_category: lc.settings_category.clone(),
185 display_name: lc.effective_display_name().to_string(),
186 importance: lc.importance,
187 };
188 if builtins_set.contains(claim.command.as_str()) {
190 tracing::warn!(
191 "plugin '{}' attempted to claim builtin command '{}'; rejected",
192 claim.plugin, claim.command,
193 );
194 } else if let Some(existing) = new_lifecycle_claims.get(&claim.command) {
195 new_lifecycle_collisions.push((
196 claim.plugin.clone(),
197 claim.command.clone(),
198 existing.plugin.clone(),
199 ));
200 tracing::warn!(
201 "lifecycle command '{}' claimed by both '{}' and '{}'; first-loaded wins",
202 claim.command, existing.plugin, claim.plugin,
203 );
204 } else {
205 new_lifecycle_claims.insert(claim.command.clone(), claim);
206 }
207 }
208 }
209 }
210 if let Some(ref settings) = manifest.settings {
211 for cat in &settings.categories {
212 let fields = cat
213 .fields
214 .iter()
215 .map(|f| PluginSettingsField {
216 key: f.key.clone(),
217 label: f.label.clone(),
218 editor: match f.editor {
219 crate::skills::manifest::ManifestEditorKind::Text => {
220 PluginSettingsEditor::Text { numeric: f.numeric }
221 }
222 crate::skills::manifest::ManifestEditorKind::Cycler => {
223 PluginSettingsEditor::Cycler {
224 options: f.options.clone(),
225 }
226 }
227 crate::skills::manifest::ManifestEditorKind::Picker => {
228 PluginSettingsEditor::Picker
229 }
230 crate::skills::manifest::ManifestEditorKind::Custom => {
231 PluginSettingsEditor::Custom
232 }
233 },
234 help: f.help.clone(),
235 default: f.default.clone(),
236 })
237 .collect();
238 new_plugin_settings_categories.push(PluginSettingsCategory {
239 plugin: manifest.name.clone(),
240 id: cat.id.clone(),
241 label: cat.label.clone(),
242 fields,
243 });
244 }
245 }
246 for cmd in manifest.commands {
247 let (name, description, backend) = match cmd {
248 crate::skills::manifest::ManifestCommand::Shell(cmd) => (
249 cmd.name,
250 cmd.description,
251 RegisteredPluginCommandBackend::Shell { command: cmd.command, args: cmd.args },
252 ),
253 crate::skills::manifest::ManifestCommand::ExtensionTool(cmd) => (
254 cmd.name,
255 cmd.description,
256 RegisteredPluginCommandBackend::ExtensionTool { tool: cmd.tool, input: cmd.input },
257 ),
258 crate::skills::manifest::ManifestCommand::SkillPrompt(cmd) => (
259 cmd.name,
260 cmd.description,
261 RegisteredPluginCommandBackend::SkillPrompt { skill: cmd.skill, prompt: cmd.prompt },
262 ),
263 crate::skills::manifest::ManifestCommand::Interactive(cmd) => {
264 if !cmd.interactive {
265 continue;
266 }
267 (
268 cmd.name,
269 cmd.description,
270 RegisteredPluginCommandBackend::Interactive {
271 plugin_extension_id: manifest
272 .extension
273 .as_ref()
274 .map(|_| plugin.name.clone())
275 .unwrap_or_else(|| plugin.name.clone()),
276 },
277 )
278 },
279 };
280 let q = format!("{}:{}", manifest.name, name);
281 if builtins_set.contains(name.as_str()) {
283 tracing::warn!(
284 "plugin '{}' command '{}' shadows builtin; skipping",
285 manifest.name, name,
286 );
287 continue;
288 }
289 new_plugin_commands.insert(q, Arc::new(RegisteredPluginCommand {
290 plugin: manifest.name.clone(),
291 name,
292 description,
293 backend,
294 plugin_root: plugin.root.clone(),
295 }));
296 }
297 }
298 }
299 for s in skills {
300 let arc = Arc::new(s);
301 if builtins_set.contains(arc.name.as_str()) {
303 tracing::warn!(
304 "skill '{}' shadowed by built-in; reachable only via qualified form '{}:{}'",
305 arc.name,
306 arc.plugin.as_deref().unwrap_or("?"),
307 arc.name
308 );
309 } else {
310 new_skills.entry(arc.name.clone()).or_default().push(arc.clone());
311 }
312 if let Some(ref p) = arc.plugin {
314 let q = format!("{}:{}", p, arc.name);
315 new_qualified.insert(q, arc.clone());
316 }
317 }
318 for claim in new_lifecycle_claims.values() {
323 let Some(ref cat_id) = claim.settings_category else {
324 continue;
325 };
326 let pos = new_plugin_settings_categories
327 .iter()
328 .position(|c| c.plugin == claim.plugin && &c.id == cat_id);
329 match pos {
330 Some(idx) => {
331 let injected = PluginSettingsField {
332 key: "_lifecycle_toggle_key".to_string(),
333 label: "Toggle key".to_string(),
334 editor: PluginSettingsEditor::Cycler {
335 options: ["F8", "F2", "F12", "C-V", "C-G"]
336 .iter()
337 .map(|s| s.to_string())
338 .collect(),
339 },
340 help: Some("Keybind that toggles this sidecar.".to_string()),
341 default: None,
342 };
343 new_plugin_settings_categories[idx]
344 .fields
345 .insert(0, injected);
346 }
347 None => {
348 tracing::warn!(
349 "lifecycle claim for plugin '{}' references settings_category '{}' but no such category was declared; skipping toggle-key injection",
350 claim.plugin,
351 cat_id,
352 );
353 }
354 }
355 }
356
357 let mut w = self.inner.write().unwrap();
358 w.skills = new_skills;
359 w.qualified = new_qualified;
360 w.plugin_commands = new_plugin_commands;
361 w.plugin_help_entries = new_plugin_help_entries;
362 w.plugin_settings_categories = new_plugin_settings_categories;
363 w.lifecycle_claims = new_lifecycle_claims;
364 w.lifecycle_claim_collisions = new_lifecycle_collisions;
365 }
366
367 pub fn resolve(&self, cmd: &str) -> Resolution {
368 let r = self.inner.read().unwrap();
369 if cmd.contains(':') {
370 if let Some(c) = r.plugin_commands.get(cmd) {
371 return Resolution::PluginCommand(c.clone());
372 }
373 return match r.qualified.get(cmd) {
374 Some(s) => Resolution::Skill(s.clone()),
375 None => Resolution::Unknown,
376 };
377 }
378 if self.builtins.contains(&cmd) {
379 return Resolution::Builtin;
380 }
381 match r.skills.get(cmd) {
382 Some(v) if v.len() == 1 => Resolution::Skill(v[0].clone()),
383 Some(v) => Resolution::Ambiguous(
384 v.iter()
385 .map(|s| format!("{}:{}", s.plugin.as_deref().unwrap_or("?"), s.name))
386 .collect(),
387 ),
388 None => Resolution::Unknown,
389 }
390 }
391
392 pub fn find_plugin_command_unqualified(&self, name: &str) -> Option<Arc<RegisteredPluginCommand>> {
399 let r = self.inner.read().unwrap();
400 let mut matches = r.plugin_commands.values().filter(|c| c.name == name);
401 let first = matches.next()?.clone();
402 if matches.next().is_some() {
403 return None; }
405 Some(first)
406 }
407
408 pub fn all_commands(&self) -> Vec<String> {
410 let r = self.inner.read().unwrap();
411 let mut v: Vec<String> = self.builtins.iter().map(|s| s.to_string()).collect();
412 v.extend(r.skills.keys().cloned());
413 v.extend(r.plugin_commands.keys().cloned());
414 v.extend(r.lifecycle_claims.keys().cloned());
417 v.sort();
418 v.dedup();
419 v
420 }
421
422 pub fn lifecycle_for_command(&self, cmd: &str) -> Option<LifecycleClaim> {
426 let r = self.inner.read().unwrap();
427 r.lifecycle_claims.get(cmd).cloned()
428 }
429
430 pub fn lifecycle_claims(&self) -> Vec<LifecycleClaim> {
434 self.inner.read().unwrap().lifecycle_claims.values().cloned().collect()
435 }
436
437 pub fn lifecycle_claim_collisions(&self) -> Vec<(String, String, String)> {
441 self.inner.read().unwrap().lifecycle_claim_collisions.clone()
442 }
443
444 pub fn plugins(&self) -> Vec<PluginSummary> {
445 let r = self.inner.read().unwrap();
446 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
447 let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
448 for c in r.plugin_commands.values() {
449 let key = (c.plugin.clone(), c.name.clone());
450 if seen.insert(key) {
451 *counts.entry(c.plugin.clone()).or_insert(0) += 0;
452 }
453 }
454 for s in r.qualified.values() {
455 if let Some(ref p) = s.plugin {
456 let key = (p.clone(), s.name.clone());
457 if seen.insert(key) {
458 *counts.entry(p.clone()).or_insert(0) += 1;
459 }
460 }
461 }
462 counts.into_iter()
463 .map(|(name, skill_count)| PluginSummary { name, skill_count })
464 .collect()
465 }
466
467 pub fn plugin_help_entries(&self) -> Vec<HelpEntry> {
468 self.inner.read().unwrap().plugin_help_entries.clone()
469 }
470
471 pub fn plugin_settings_categories(&self) -> Vec<PluginSettingsCategory> {
475 self.inner.read().unwrap().plugin_settings_categories.clone()
476 }
477
478 pub fn all_skills(&self) -> Vec<Arc<LoadedSkill>> {
479 let r = self.inner.read().unwrap();
480 let mut seen: std::collections::HashSet<(Option<String>, String)> =
481 std::collections::HashSet::new();
482 let mut out = Vec::new();
483 for list in r.skills.values() {
484 for s in list {
485 let key = (s.plugin.clone(), s.name.clone());
486 if seen.insert(key) { out.push(s.clone()); }
487 }
488 }
489 for s in r.qualified.values() {
490 let key = (s.plugin.clone(), s.name.clone());
491 if seen.insert(key) { out.push(s.clone()); }
492 }
493 out
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use std::path::PathBuf;
501 use crate::skills::manifest::{ManifestCommand, ManifestShellCommand};
502
503 fn mk_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
504 Plugin {
505 name: plugin.to_string(),
506 root,
507 marketplace: None,
508 version: None,
509 description: None,
510 extension: None,
511 manifest: Some(crate::skills::manifest::PluginManifest {
512 name: plugin.to_string(),
513 version: None,
514 description: None,
515 keybinds: vec![],
516 compatibility: None,
517 commands: vec![ManifestCommand::Shell(ManifestShellCommand {
518 name: name.to_string(),
519 description: Some("desc".to_string()),
520 command: "printf".to_string(),
521 args: vec!["hi".to_string()],
522 })],
523 extension: None,
524 help_entries: vec![],
525 provides: None,
526 settings: None,
527 }),
528 }
529 }
530
531
532 fn mk_interactive_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
533 Plugin {
534 name: plugin.to_string(),
535 root,
536 marketplace: None,
537 version: None,
538 description: None,
539 extension: None,
540 manifest: Some(crate::skills::manifest::PluginManifest {
541 name: plugin.to_string(),
542 version: None,
543 description: None,
544 keybinds: vec![],
545 compatibility: None,
546 commands: vec![ManifestCommand::Interactive(crate::skills::manifest::ManifestInteractiveCommand {
547 name: name.to_string(),
548 description: Some("interactive desc".to_string()),
549 interactive: true,
550 subcommands: vec!["help".to_string()],
551 })],
552 extension: None,
553 help_entries: vec![],
554 provides: None,
555 settings: None,
556 }),
557 }
558 }
559
560 #[test]
561 fn registers_interactive_plugin_command_backend() {
562 let reg = CommandRegistry::new_with_plugins(
563 &[],
564 vec![],
565 vec![mk_interactive_cmd("demo-plugin", "demo", PathBuf::from("/tmp/demo"))],
566 );
567
568 match reg.resolve("demo-plugin:demo") {
569 Resolution::PluginCommand(cmd) => match &cmd.backend {
570 RegisteredPluginCommandBackend::Interactive { plugin_extension_id } => {
571 assert_eq!(plugin_extension_id, "demo-plugin");
572 assert_eq!(cmd.name, "demo");
573 }
574 other => panic!("expected interactive backend, got {other:?}"),
575 },
576 _ => panic!("expected plugin command resolution"),
577 }
578 }
579
580 fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
581 LoadedSkill {
582 name: name.to_string(),
583 description: String::new(),
584 body: String::new(),
585 plugin: plugin.map(str::to_string),
586 base_dir: PathBuf::from("/"),
587 source_path: PathBuf::from("/SKILL.md"),
588 }
589 }
590
591
592 #[test]
593 fn plugin_help_entries_are_tagged_with_manifest_name() {
594 let root = PathBuf::from("/tmp/plugin-root");
595 let mut plugin = mk_cmd("acme-tools", "sync", root);
596 plugin.manifest.as_mut().unwrap().help_entries.push(HelpEntry {
597 id: "acme-sync".to_string(),
598 command: "/acme:sync".to_string(),
599 title: "Acme Sync".to_string(),
600 summary: "Sync Acme workspace state.".to_string(),
601 category: "Plugin".to_string(),
602 topic: crate::help::HelpTopicKind::Command,
603 protected: false,
604 common: false,
605 aliases: vec![],
606 keywords: vec![],
607 lines: vec![],
608 usage: Some("/acme:sync [workspace]".to_string()),
609 examples: vec![crate::help::HelpExample {
610 command: "/acme:sync docs".to_string(),
611 description: "Sync docs.".to_string(),
612 }],
613 related: vec![],
614 source: None,
615 });
616
617 let registry = CommandRegistry::new_with_plugins(&[], vec![], vec![plugin]);
618 let entries = registry.plugin_help_entries();
619
620 assert_eq!(entries.len(), 1);
621 assert_eq!(entries[0].source.as_deref(), Some("acme-tools"));
622 assert_eq!(entries[0].usage.as_deref(), Some("/acme:sync [workspace]"));
623 assert_eq!(entries[0].examples[0].command, "/acme:sync docs");
624 }
625
626 #[test]
627 fn resolve_builtin() {
628 let r = CommandRegistry::new(&["clear"], vec![]);
629 assert!(matches!(r.resolve("clear"), Resolution::Builtin));
630 }
631
632 #[test]
633 fn resolve_unknown() {
634 let r = CommandRegistry::new(&["clear"], vec![]);
635 assert!(matches!(r.resolve("xyz"), Resolution::Unknown));
636 }
637
638 #[test]
639 fn resolve_unique_skill() {
640 let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
641 match r.resolve("search") {
642 Resolution::Skill(s) => assert_eq!(s.name, "search"),
643 _ => panic!(),
644 }
645 }
646
647 #[test]
648 fn resolve_ambiguous() {
649 let r = CommandRegistry::new(&[], vec![
650 mk("search", Some("p1")),
651 mk("search", Some("p2")),
652 ]);
653 match r.resolve("search") {
654 Resolution::Ambiguous(v) => {
655 assert_eq!(v.len(), 2);
656 assert!(v.iter().any(|s| s == "p1:search"));
657 assert!(v.iter().any(|s| s == "p2:search"));
658 }
659 _ => panic!(),
660 }
661 }
662
663 #[test]
664 fn resolve_qualified() {
665 let r = CommandRegistry::new(&[], vec![
666 mk("search", Some("p1")),
667 mk("search", Some("p2")),
668 ]);
669 match r.resolve("p1:search") {
670 Resolution::Skill(s) => assert_eq!(s.plugin.as_deref(), Some("p1")),
671 _ => panic!(),
672 }
673 }
674
675 #[test]
676 fn builtin_shadows_skill_unqualified() {
677 let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
679 assert!(matches!(r.resolve("clear"), Resolution::Builtin));
680 match r.resolve("p:clear") {
682 Resolution::Skill(s) => assert_eq!(s.name, "clear"),
683 _ => panic!(),
684 }
685 }
686
687 #[test]
688 fn all_commands_sorted_and_deduped() {
689 let r = CommandRegistry::new(&["clear", "model"], vec![
690 mk("search", Some("p")),
691 mk("help-me", None),
692 ]);
693 let cmds = r.all_commands();
694 assert_eq!(cmds, vec!["clear", "help-me", "model", "search"]);
695 }
696
697 #[test]
698 fn all_skills_dedups_plugin_skill() {
699 let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
700 let all = r.all_skills();
701 assert_eq!(all.len(), 1);
702 assert_eq!(all[0].name, "search");
703 assert_eq!(all[0].plugin.as_deref(), Some("p"));
704 }
705
706 #[test]
707 fn all_skills_includes_shadowed_skill() {
708 let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
709 let all = r.all_skills();
710 assert_eq!(all.len(), 1);
711 assert_eq!(all[0].name, "clear");
712 assert_eq!(all[0].plugin.as_deref(), Some("p"));
713 }
714
715 #[test]
716 fn resolve_qualified_unknown_returns_unknown() {
717 let r = CommandRegistry::new(&[], vec![mk("search", Some("p1"))]);
718 assert!(matches!(r.resolve("p1:nosuch"), Resolution::Unknown));
719 assert!(matches!(r.resolve("nosuch:search"), Resolution::Unknown));
720 }
721
722 #[test]
723 fn rebuild_replaces_skills() {
724 let r = CommandRegistry::new(&["clear"], vec![mk("old", None)]);
725 assert!(matches!(r.resolve("old"), Resolution::Skill(_)));
726 assert!(matches!(r.resolve("new"), Resolution::Unknown));
727 r.rebuild_with(vec![mk("new", None)]);
728 assert!(matches!(r.resolve("old"), Resolution::Unknown));
729 assert!(matches!(r.resolve("new"), Resolution::Skill(_)));
730 }
731
732 #[test]
733 fn rebuild_visible_through_shared_arc() {
734 let r = std::sync::Arc::new(CommandRegistry::new(&[], vec![mk("a", None)]));
735 let r2 = r.clone();
736 r.rebuild_with(vec![mk("b", None)]);
737 assert!(matches!(r2.resolve("b"), Resolution::Skill(_)));
738 assert!(matches!(r2.resolve("a"), Resolution::Unknown));
739 }
740
741 #[test]
742 fn resolve_qualified_plugin_command() {
743 let r = CommandRegistry::new_with_plugins(&[], vec![], vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))]);
744 match r.resolve("p:hello") {
745 Resolution::PluginCommand(cmd) => {
746 assert_eq!(cmd.plugin, "p");
747 assert_eq!(cmd.name, "hello");
748 assert!(matches!(
749 &cmd.backend,
750 RegisteredPluginCommandBackend::Shell { command, .. } if command == "printf"
751 ));
752 assert_eq!(cmd.plugin_root, PathBuf::from("/tmp/p"));
753 }
754 _ => panic!(),
755 }
756 }
757
758 #[test]
759 fn all_commands_includes_qualified_plugin_commands() {
760 let r = CommandRegistry::new_with_plugins(&["help"], vec![], vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))]);
761 let cmds = r.all_commands();
762 assert!(cmds.contains(&"help".to_string()));
763 assert!(cmds.contains(&"p:hello".to_string()));
764 }
765
766 #[test]
767 fn plugins_summary_groups_by_plugin_name() {
768 let r = CommandRegistry::new(&[], vec![
769 mk("a", Some("p1")),
770 mk("b", Some("p1")),
771 mk("c", Some("p2")),
772 mk("loose", None),
773 ]);
774 let mut plugins = r.plugins();
775 plugins.sort_by(|a, b| a.name.cmp(&b.name));
776 assert_eq!(plugins.len(), 2);
777 assert_eq!(plugins[0].name, "p1");
778 assert_eq!(plugins[0].skill_count, 2);
779 assert_eq!(plugins[1].name, "p2");
780 assert_eq!(plugins[1].skill_count, 1);
781 }
782
783 fn mk_plugin_with_settings(plugin: &str, root: PathBuf) -> Plugin {
784 use crate::skills::manifest::{
785 ManifestEditorKind, ManifestSettings, ManifestSettingsCategory,
786 ManifestSettingsField,
787 };
788 Plugin {
789 name: plugin.to_string(),
790 root,
791 marketplace: None,
792 version: None,
793 description: None,
794 extension: None,
795 manifest: Some(crate::skills::manifest::PluginManifest {
796 name: plugin.to_string(),
797 version: None,
798 description: None,
799 keybinds: vec![],
800 compatibility: None,
801 commands: vec![],
802 extension: None,
803 help_entries: vec![],
804 provides: None,
805 settings: Some(ManifestSettings {
806 categories: vec![ManifestSettingsCategory {
807 id: "demo".to_string(),
808 label: "Demo".to_string(),
809 fields: vec![
810 ManifestSettingsField {
811 key: "backend".to_string(),
812 label: "Backend".to_string(),
813 editor: ManifestEditorKind::Cycler,
814 options: vec!["a".to_string(), "b".to_string()],
815 help: None,
816 default: None,
817 numeric: false,
818 },
819 ManifestSettingsField {
820 key: "endpoint".to_string(),
821 label: "Endpoint".to_string(),
822 editor: ManifestEditorKind::Text,
823 options: vec![],
824 help: Some("URL".to_string()),
825 default: None,
826 numeric: false,
827 },
828 ],
829 }],
830 }),
831 }),
832 }
833 }
834
835 #[test]
836 fn plugin_settings_categories_exposed_after_rebuild() {
837 let r = CommandRegistry::new_with_plugins(
838 &[],
839 vec![],
840 vec![mk_plugin_with_settings("demo-plugin", PathBuf::from("/tmp/demo"))],
841 );
842 let cats = r.plugin_settings_categories();
843 assert_eq!(cats.len(), 1, "expected one plugin settings category");
844 let cat = &cats[0];
845 assert_eq!(cat.plugin, "demo-plugin");
846 assert_eq!(cat.id, "demo");
847 assert_eq!(cat.label, "Demo");
848 assert_eq!(cat.fields.len(), 2);
849
850 match &cat.fields[0].editor {
851 PluginSettingsEditor::Cycler { options } => {
852 assert_eq!(options, &vec!["a".to_string(), "b".to_string()]);
853 }
854 other => panic!("expected cycler, got {other:?}"),
855 }
856 assert!(matches!(
857 cat.fields[1].editor,
858 PluginSettingsEditor::Text { numeric: false }
859 ));
860 assert_eq!(cat.fields[1].help.as_deref(), Some("URL"));
861 }
862
863 #[test]
864 fn plugin_settings_categories_empty_without_settings_block() {
865 let r = CommandRegistry::new_with_plugins(
866 &[],
867 vec![],
868 vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
869 );
870 assert!(r.plugin_settings_categories().is_empty());
871 }
872
873 #[test]
874 fn plugin_settings_categories_replaced_on_rebuild() {
875 let r = CommandRegistry::new_with_plugins(
876 &[],
877 vec![],
878 vec![mk_plugin_with_settings("demo-plugin", PathBuf::from("/tmp/demo"))],
879 );
880 assert_eq!(r.plugin_settings_categories().len(), 1);
881 r.rebuild_with_plugins(vec![], vec![]);
882 assert!(r.plugin_settings_categories().is_empty());
883 }
884
885 #[test]
886 fn plugin_settings_categories_does_not_hardcode_capture() {
887 let r = CommandRegistry::new_with_plugins(
890 &[],
891 vec![],
892 vec![mk_plugin_with_settings("totally-unrelated", PathBuf::from("/tmp/x"))],
893 );
894 let cats = r.plugin_settings_categories();
895 assert_eq!(cats[0].plugin, "totally-unrelated");
896 assert_eq!(cats[0].id, "demo");
897 assert!(cats[0].fields.iter().any(|f| matches!(
898 f.editor,
899 PluginSettingsEditor::Cycler { .. }
900 )));
901 assert!(cats[0].fields.iter().any(|f| matches!(
902 f.editor,
903 PluginSettingsEditor::Text { .. }
904 )));
905 }
906
907 fn mk_plugin_with_lifecycle(
910 plugin: &str,
911 command: &str,
912 display: Option<&str>,
913 importance: i32,
914 settings_category: Option<&str>,
915 ) -> Plugin {
916 use crate::skills::manifest::{
917 PluginManifest, PluginProvides, SidecarLifecycle, SidecarManifest,
918 };
919 Plugin {
920 name: plugin.to_string(),
921 root: PathBuf::from(format!("/tmp/{plugin}")),
922 marketplace: None,
923 version: None,
924 description: None,
925 extension: None,
926 manifest: Some(PluginManifest {
927 name: plugin.to_string(),
928 version: None,
929 description: None,
930 keybinds: vec![],
931 compatibility: None,
932 commands: vec![],
933 extension: None,
934 help_entries: vec![],
935 provides: Some(PluginProvides {
936 sidecar: Some(SidecarManifest {
937 command: "bin/run".to_string(),
938 setup: None,
939 protocol_version: 1,
940 model: None,
941 lifecycle: Some(SidecarLifecycle {
942 command: command.to_string(),
943 settings_category: settings_category.map(str::to_string),
944 display_name: display.map(str::to_string),
945 importance,
946 }),
947 }),
948 }),
949 settings: None,
950 }),
951 }
952 }
953
954 #[test]
955 fn lifecycle_claim_registers_under_command_word() {
956 let reg = CommandRegistry::new_with_plugins(
957 &[],
958 vec![],
959 vec![mk_plugin_with_lifecycle(
960 "sample-sidecar",
961 "capture",
962 Some("Sample"),
963 50,
964 Some("capture"),
965 )],
966 );
967 let claim = reg
968 .lifecycle_for_command("capture")
969 .expect("sample lifecycle claim should be registered");
970 assert_eq!(claim.plugin, "sample-sidecar");
971 assert_eq!(claim.command, "capture");
972 assert_eq!(claim.display_name, "Sample");
973 assert_eq!(claim.importance, 50);
974 assert_eq!(claim.settings_category.as_deref(), Some("capture"));
975 }
976
977 #[test]
978 fn lifecycle_claim_display_name_falls_back_to_command() {
979 let reg = CommandRegistry::new_with_plugins(
980 &[],
981 vec![],
982 vec![mk_plugin_with_lifecycle("p", "ocr", None, 0, None)],
983 );
984 let claim = reg.lifecycle_for_command("ocr").unwrap();
985 assert_eq!(claim.display_name, "ocr");
986 }
987
988 #[test]
989 fn lifecycle_claim_surfaces_in_all_commands() {
990 let reg = CommandRegistry::new_with_plugins(
991 &[],
992 vec![],
993 vec![mk_plugin_with_lifecycle("sample-sidecar", "capture", None, 0, None)],
994 );
995 assert!(reg.all_commands().contains(&"capture".to_string()));
996 }
997
998 #[test]
999 fn lifecycle_claim_collision_first_loaded_wins() {
1000 let reg = CommandRegistry::new_with_plugins(
1003 &[],
1004 vec![],
1005 vec![
1006 mk_plugin_with_lifecycle("alpha-sidecar", "capture", Some("Alpha"), 10, None),
1007 mk_plugin_with_lifecycle("beta-sidecar", "capture", Some("Beta"), 90, None),
1008 ],
1009 );
1010 let claim = reg.lifecycle_for_command("capture").unwrap();
1011 assert_eq!(claim.plugin, "alpha-sidecar");
1012 let collisions = reg.lifecycle_claim_collisions();
1013 assert_eq!(collisions.len(), 1);
1014 assert_eq!(collisions[0], (
1015 "beta-sidecar".to_string(),
1016 "capture".to_string(),
1017 "alpha-sidecar".to_string(),
1018 ));
1019 }
1020
1021 #[test]
1022 fn lifecycle_claims_returns_all_unique_command_words() {
1023 let reg = CommandRegistry::new_with_plugins(
1024 &[],
1025 vec![],
1026 vec![
1027 mk_plugin_with_lifecycle("sample-sidecar", "capture", None, 50, None),
1028 mk_plugin_with_lifecycle("ocr-plugin", "ocr", None, 30, None),
1029 ],
1030 );
1031 let claims = reg.lifecycle_claims();
1032 let mut names: Vec<_> = claims.iter().map(|c| c.command.as_str()).collect();
1033 names.sort();
1034 assert_eq!(names, vec!["capture", "ocr"]);
1035 }
1036
1037 #[test]
1038 fn lifecycle_for_command_returns_none_when_no_claim() {
1039 let reg = CommandRegistry::new_with_plugins(&[], vec![], vec![]);
1040 assert!(reg.lifecycle_for_command("capture").is_none());
1041 }
1042
1043 #[test]
1044 fn rebuild_replaces_lifecycle_claims_atomically() {
1045 let reg = CommandRegistry::new_with_plugins(
1046 &[],
1047 vec![],
1048 vec![mk_plugin_with_lifecycle("sample-sidecar", "capture", None, 0, None)],
1049 );
1050 assert!(reg.lifecycle_for_command("capture").is_some());
1051 reg.rebuild_with_plugins(vec![], vec![]);
1053 assert!(reg.lifecycle_for_command("capture").is_none());
1054 assert!(reg.lifecycle_claim_collisions().is_empty());
1055 }
1056
1057 fn mk_plugin_lifecycle_plus_settings(
1060 plugin: &str,
1061 command: &str,
1062 lifecycle_settings_category: Option<&str>,
1063 category_ids: &[&str],
1064 ) -> Plugin {
1065 use crate::skills::manifest::{
1066 ManifestEditorKind, ManifestSettings, ManifestSettingsCategory,
1067 ManifestSettingsField, PluginManifest, PluginProvides, SidecarLifecycle,
1068 SidecarManifest,
1069 };
1070 Plugin {
1071 name: plugin.to_string(),
1072 root: PathBuf::from(format!("/tmp/{plugin}")),
1073 marketplace: None,
1074 version: None,
1075 description: None,
1076 extension: None,
1077 manifest: Some(PluginManifest {
1078 name: plugin.to_string(),
1079 version: None,
1080 description: None,
1081 keybinds: vec![],
1082 compatibility: None,
1083 commands: vec![],
1084 extension: None,
1085 help_entries: vec![],
1086 provides: Some(PluginProvides {
1087 sidecar: Some(SidecarManifest {
1088 command: "bin/run".to_string(),
1089 setup: None,
1090 protocol_version: 1,
1091 model: None,
1092 lifecycle: Some(SidecarLifecycle {
1093 command: command.to_string(),
1094 settings_category: lifecycle_settings_category.map(str::to_string),
1095 display_name: None,
1096 importance: 0,
1097 }),
1098 }),
1099 }),
1100 settings: Some(ManifestSettings {
1101 categories: category_ids
1102 .iter()
1103 .map(|id| ManifestSettingsCategory {
1104 id: id.to_string(),
1105 label: id.to_string(),
1106 fields: vec![ManifestSettingsField {
1107 key: "existing".to_string(),
1108 label: "Existing".to_string(),
1109 editor: ManifestEditorKind::Text,
1110 options: vec![],
1111 help: None,
1112 default: None,
1113 numeric: false,
1114 }],
1115 })
1116 .collect(),
1117 }),
1118 }),
1119 }
1120 }
1121
1122 #[test]
1123 fn lifecycle_injects_virtual_toggle_key_into_matching_category() {
1124 let reg = CommandRegistry::new_with_plugins(
1125 &[],
1126 vec![],
1127 vec![mk_plugin_lifecycle_plus_settings(
1128 "sample-sidecar",
1129 "capture",
1130 Some("capture"),
1131 &["capture"],
1132 )],
1133 );
1134 let cats = reg.plugin_settings_categories();
1135 let capture = cats
1136 .iter()
1137 .find(|c| c.id == "capture" && c.plugin == "sample-sidecar")
1138 .expect("sample category present");
1139 assert!(!capture.fields.is_empty());
1140 let first = &capture.fields[0];
1141 assert_eq!(first.key, "_lifecycle_toggle_key");
1142 assert_eq!(first.label, "Toggle key");
1143 match &first.editor {
1144 PluginSettingsEditor::Cycler { options } => {
1145 assert_eq!(
1146 options,
1147 &vec![
1148 "F8".to_string(),
1149 "F2".to_string(),
1150 "F12".to_string(),
1151 "C-V".to_string(),
1152 "C-G".to_string(),
1153 ]
1154 );
1155 }
1156 other => panic!("expected cycler, got {other:?}"),
1157 }
1158 assert_eq!(capture.fields[1].key, "existing");
1159 }
1160
1161 #[test]
1162 fn lifecycle_no_injection_when_settings_category_is_none() {
1163 let reg = CommandRegistry::new_with_plugins(
1164 &[],
1165 vec![],
1166 vec![mk_plugin_lifecycle_plus_settings("p", "ocr", None, &["capture"])],
1167 );
1168 let cats = reg.plugin_settings_categories();
1169 let capture = cats.iter().find(|c| c.id == "capture").expect("category");
1170 assert!(capture.fields.iter().all(|f| f.key != "_lifecycle_toggle_key"));
1171 }
1172
1173 #[test]
1174 fn lifecycle_no_injection_when_category_does_not_exist() {
1175 let reg = CommandRegistry::new_with_plugins(
1176 &[],
1177 vec![],
1178 vec![mk_plugin_lifecycle_plus_settings(
1179 "p",
1180 "capture",
1181 Some("nonexistent"),
1182 &["capture"],
1183 )],
1184 );
1185 let cats = reg.plugin_settings_categories();
1186 for c in &cats {
1187 assert!(c.fields.iter().all(|f| f.key != "_lifecycle_toggle_key"));
1188 }
1189 }
1190
1191 #[test]
1192 fn lifecycle_two_plugins_each_get_injection_in_their_own_category() {
1193 let reg = CommandRegistry::new_with_plugins(
1194 &[],
1195 vec![],
1196 vec![
1197 mk_plugin_lifecycle_plus_settings(
1198 "sidecar-plugin",
1199 "capture",
1200 Some("capture"),
1201 &["capture"],
1202 ),
1203 mk_plugin_lifecycle_plus_settings(
1204 "ocr-plugin",
1205 "ocr",
1206 Some("ocr"),
1207 &["ocr"],
1208 ),
1209 ],
1210 );
1211 let cats = reg.plugin_settings_categories();
1212 let capture = cats.iter().find(|c| c.plugin == "sidecar-plugin").unwrap();
1213 let ocr = cats.iter().find(|c| c.plugin == "ocr-plugin").unwrap();
1214 assert_eq!(capture.fields[0].key, "_lifecycle_toggle_key");
1215 assert_eq!(ocr.fields[0].key, "_lifecycle_toggle_key");
1216 }
1217}