1use crate::help::HelpEntry;
4use crate::skills::{LoadedSkill, Plugin};
5use std::collections::HashMap;
6use std::sync::{Arc, RwLock};
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 {
17 command: String,
18 args: Vec<String>,
19 },
20 ExtensionTool {
21 tool: String,
22 input: serde_json::Value,
23 },
24 SkillPrompt {
25 skill: String,
26 prompt: String,
27 },
28 Interactive {
29 plugin_extension_id: String,
30 },
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
36pub enum PluginSettingsEditor {
37 Text {
38 numeric: bool,
39 },
40 Cycler {
41 options: Vec<String>,
42 },
43 Picker,
44 Custom,
47}
48
49#[derive(Clone, Debug)]
50pub struct PluginSettingsField {
51 pub key: String,
52 pub label: String,
53 pub editor: PluginSettingsEditor,
54 pub help: Option<String>,
55 pub default: Option<serde_json::Value>,
56}
57
58#[derive(Clone, Debug)]
64pub struct PluginSettingsCategory {
65 pub plugin: String,
66 pub id: String,
67 pub label: String,
68 pub fields: Vec<PluginSettingsField>,
69}
70
71#[derive(Clone, Debug)]
73pub struct RegisteredPluginCommand {
74 pub plugin: String,
75 pub name: String,
76 pub description: Option<String>,
77 pub backend: RegisteredPluginCommandBackend,
78 pub plugin_root: std::path::PathBuf,
79}
80
81#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct LifecycleClaim {
95 pub plugin: String,
97 pub command: String,
99 pub settings_category: Option<String>,
102 pub display_name: String,
104 pub importance: i32,
107}
108
109pub enum Resolution {
110 Builtin,
112 Skill(Arc<LoadedSkill>),
114 PluginCommand(Arc<RegisteredPluginCommand>),
116 Ambiguous(Vec<String>), Unknown,
120}
121
122struct Inner {
123 skills: HashMap<String, Vec<Arc<LoadedSkill>>>, qualified: HashMap<String, Arc<LoadedSkill>>, plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>>, plugin_help_entries: Vec<HelpEntry>,
127 plugin_settings_categories: Vec<PluginSettingsCategory>,
130 lifecycle_claims: HashMap<String, LifecycleClaim>,
134 lifecycle_claim_collisions: Vec<(String, String, String)>,
138}
139
140pub struct CommandRegistry {
141 builtins: Vec<&'static str>,
142 inner: RwLock<Inner>,
143}
144
145impl CommandRegistry {
146 pub fn new(builtins: &[&'static str], skills: Vec<LoadedSkill>) -> Self {
147 Self::new_with_plugins(builtins, skills, vec![])
148 }
149
150 pub fn new_with_plugins(
151 builtins: &[&'static str],
152 skills: Vec<LoadedSkill>,
153 plugins: Vec<Plugin>,
154 ) -> Self {
155 let r = CommandRegistry {
156 builtins: builtins.to_vec(),
157 inner: RwLock::new(Inner {
158 skills: HashMap::new(),
159 qualified: HashMap::new(),
160 plugin_commands: HashMap::new(),
161 plugin_help_entries: Vec::new(),
162 plugin_settings_categories: Vec::new(),
163 lifecycle_claims: HashMap::new(),
164 lifecycle_claim_collisions: Vec::new(),
165 }),
166 };
167 r.rebuild_with_plugins(skills, plugins);
168 r
169 }
170
171 pub fn rebuild_with(&self, skills: Vec<LoadedSkill>) {
173 self.rebuild_with_plugins(skills, vec![]);
174 }
175
176 pub fn rebuild_with_plugins(&self, skills: Vec<LoadedSkill>, plugins: Vec<Plugin>) {
178 let builtins_set: std::collections::HashSet<&str> = self.builtins.iter().copied().collect();
179 let mut new_skills: HashMap<String, Vec<Arc<LoadedSkill>>> = HashMap::new();
180 let mut new_qualified: HashMap<String, Arc<LoadedSkill>> = HashMap::new();
181 let mut new_plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>> = HashMap::new();
182 let mut new_plugin_help_entries: Vec<HelpEntry> = Vec::new();
183 let mut new_plugin_settings_categories: Vec<PluginSettingsCategory> = Vec::new();
184 let mut new_lifecycle_claims: HashMap<String, LifecycleClaim> = HashMap::new();
185 let mut new_lifecycle_collisions: Vec<(String, String, String)> = Vec::new();
186 for plugin in plugins {
187 if let Some(manifest) = plugin.manifest {
188 new_plugin_help_entries.extend(manifest.help_entries.iter().cloned().map(
189 |mut entry| {
190 entry.source = Some(manifest.name.clone());
191 entry
192 },
193 ));
194 if let Some(ref provides) = manifest.provides {
199 if let Some(ref sidecar) = provides.sidecar {
200 if let Some(ref lc) = sidecar.lifecycle {
201 let claim = LifecycleClaim {
202 plugin: manifest.name.clone(),
203 command: lc.command.clone(),
204 settings_category: lc.settings_category.clone(),
205 display_name: lc.effective_display_name().to_string(),
206 importance: lc.importance,
207 };
208 if builtins_set.contains(claim.command.as_str()) {
210 tracing::warn!(
211 "plugin '{}' attempted to claim builtin command '{}'; rejected",
212 claim.plugin,
213 claim.command,
214 );
215 } else if let Some(existing) = new_lifecycle_claims.get(&claim.command)
216 {
217 new_lifecycle_collisions.push((
218 claim.plugin.clone(),
219 claim.command.clone(),
220 existing.plugin.clone(),
221 ));
222 tracing::warn!(
223 "lifecycle command '{}' claimed by both '{}' and '{}'; first-loaded wins",
224 claim.command, existing.plugin, claim.plugin,
225 );
226 } else {
227 new_lifecycle_claims.insert(claim.command.clone(), claim);
228 }
229 }
230 }
231 }
232 if let Some(ref settings) = manifest.settings {
233 for cat in &settings.categories {
234 let fields = cat
235 .fields
236 .iter()
237 .map(|f| PluginSettingsField {
238 key: f.key.clone(),
239 label: f.label.clone(),
240 editor: match f.editor {
241 crate::skills::manifest::ManifestEditorKind::Text => {
242 PluginSettingsEditor::Text { numeric: f.numeric }
243 }
244 crate::skills::manifest::ManifestEditorKind::Cycler => {
245 PluginSettingsEditor::Cycler {
246 options: f.options.clone(),
247 }
248 }
249 crate::skills::manifest::ManifestEditorKind::Picker => {
250 PluginSettingsEditor::Picker
251 }
252 crate::skills::manifest::ManifestEditorKind::Custom => {
253 PluginSettingsEditor::Custom
254 }
255 },
256 help: f.help.clone(),
257 default: f.default.clone(),
258 })
259 .collect();
260 new_plugin_settings_categories.push(PluginSettingsCategory {
261 plugin: manifest.name.clone(),
262 id: cat.id.clone(),
263 label: cat.label.clone(),
264 fields,
265 });
266 }
267 }
268 for cmd in manifest.commands {
269 let (name, description, backend) = match cmd {
270 crate::skills::manifest::ManifestCommand::Shell(cmd) => (
271 cmd.name,
272 cmd.description,
273 RegisteredPluginCommandBackend::Shell {
274 command: cmd.command,
275 args: cmd.args,
276 },
277 ),
278 crate::skills::manifest::ManifestCommand::ExtensionTool(cmd) => (
279 cmd.name,
280 cmd.description,
281 RegisteredPluginCommandBackend::ExtensionTool {
282 tool: cmd.tool,
283 input: cmd.input,
284 },
285 ),
286 crate::skills::manifest::ManifestCommand::SkillPrompt(cmd) => (
287 cmd.name,
288 cmd.description,
289 RegisteredPluginCommandBackend::SkillPrompt {
290 skill: cmd.skill,
291 prompt: cmd.prompt,
292 },
293 ),
294 crate::skills::manifest::ManifestCommand::Interactive(cmd) => {
295 if !cmd.interactive {
296 continue;
297 }
298 (
299 cmd.name,
300 cmd.description,
301 RegisteredPluginCommandBackend::Interactive {
302 plugin_extension_id: manifest
303 .extension
304 .as_ref()
305 .map(|_| plugin.name.clone())
306 .unwrap_or_else(|| plugin.name.clone()),
307 },
308 )
309 }
310 };
311 let q = format!("{}:{}", manifest.name, name);
312 if builtins_set.contains(name.as_str()) {
314 tracing::warn!(
315 "plugin '{}' command '{}' shadows builtin; skipping",
316 manifest.name,
317 name,
318 );
319 continue;
320 }
321 new_plugin_commands.insert(
322 q,
323 Arc::new(RegisteredPluginCommand {
324 plugin: manifest.name.clone(),
325 name,
326 description,
327 backend,
328 plugin_root: plugin.root.clone(),
329 }),
330 );
331 }
332 }
333 }
334 for s in skills {
335 let arc = Arc::new(s);
336 if builtins_set.contains(arc.name.as_str()) {
338 tracing::warn!(
339 "skill '{}' shadowed by built-in; reachable only via qualified form '{}:{}'",
340 arc.name,
341 arc.plugin.as_deref().unwrap_or("?"),
342 arc.name
343 );
344 } else {
345 new_skills
346 .entry(arc.name.clone())
347 .or_default()
348 .push(arc.clone());
349 }
350 if let Some(ref p) = arc.plugin {
352 let q = format!("{}:{}", p, arc.name);
353 new_qualified.insert(q, arc.clone());
354 }
355 }
356 for claim in new_lifecycle_claims.values() {
361 let Some(ref cat_id) = claim.settings_category else {
362 continue;
363 };
364 let pos = new_plugin_settings_categories
365 .iter()
366 .position(|c| c.plugin == claim.plugin && &c.id == cat_id);
367 match pos {
368 Some(idx) => {
369 let injected = PluginSettingsField {
370 key: "_lifecycle_toggle_key".to_string(),
371 label: "Toggle key".to_string(),
372 editor: PluginSettingsEditor::Cycler {
373 options: ["F8", "F2", "F12", "C-V", "C-G"]
374 .iter()
375 .map(|s| s.to_string())
376 .collect(),
377 },
378 help: Some("Keybind that toggles this sidecar.".to_string()),
379 default: None,
380 };
381 new_plugin_settings_categories[idx]
382 .fields
383 .insert(0, injected);
384 }
385 None => {
386 tracing::warn!(
387 "lifecycle claim for plugin '{}' references settings_category '{}' but no such category was declared; skipping toggle-key injection",
388 claim.plugin,
389 cat_id,
390 );
391 }
392 }
393 }
394
395 let mut w = self.inner.write().unwrap();
396 w.skills = new_skills;
397 w.qualified = new_qualified;
398 w.plugin_commands = new_plugin_commands;
399 w.plugin_help_entries = new_plugin_help_entries;
400 w.plugin_settings_categories = new_plugin_settings_categories;
401 w.lifecycle_claims = new_lifecycle_claims;
402 w.lifecycle_claim_collisions = new_lifecycle_collisions;
403 }
404
405 pub fn resolve(&self, cmd: &str) -> Resolution {
406 let r = self.inner.read().unwrap();
407 if cmd.contains(':') {
408 if let Some(c) = r.plugin_commands.get(cmd) {
409 return Resolution::PluginCommand(c.clone());
410 }
411 return match r.qualified.get(cmd) {
412 Some(s) => Resolution::Skill(s.clone()),
413 None => Resolution::Unknown,
414 };
415 }
416 if self.builtins.contains(&cmd) {
417 return Resolution::Builtin;
418 }
419 match r.skills.get(cmd) {
420 Some(v) if v.len() == 1 => Resolution::Skill(v[0].clone()),
421 Some(v) => Resolution::Ambiguous(
422 v.iter()
423 .map(|s| format!("{}:{}", s.plugin.as_deref().unwrap_or("?"), s.name))
424 .collect(),
425 ),
426 None => {
427 drop(r);
433 if let Some(c) = self.find_plugin_command_unqualified(cmd) {
434 return Resolution::PluginCommand(c);
435 }
436 Resolution::Unknown
437 }
438 }
439 }
440
441 pub fn find_plugin_command_unqualified(
448 &self,
449 name: &str,
450 ) -> Option<Arc<RegisteredPluginCommand>> {
451 let r = self.inner.read().unwrap();
452 let mut matches = r.plugin_commands.values().filter(|c| c.name == name);
453 let first = matches.next()?.clone();
454 if matches.next().is_some() {
455 return None; }
457 Some(first)
458 }
459
460 pub fn all_commands(&self) -> Vec<String> {
462 let r = self.inner.read().unwrap();
463 let mut v: Vec<String> = self.builtins.iter().map(|s| s.to_string()).collect();
464 v.extend(r.skills.keys().cloned());
465 let mut by_name: HashMap<&str, Vec<&str>> = HashMap::new();
471 for (qkey, c) in r.plugin_commands.iter() {
472 by_name
473 .entry(c.name.as_str())
474 .or_default()
475 .push(qkey.as_str());
476 }
477 for (name, qkeys) in by_name {
478 if qkeys.len() == 1 {
479 v.push(name.to_string());
480 } else {
481 v.extend(qkeys.iter().map(|s| s.to_string()));
482 }
483 }
484 v.extend(r.lifecycle_claims.keys().cloned());
487 v.sort();
488 v.dedup();
489 v
490 }
491
492 pub fn lifecycle_for_command(&self, cmd: &str) -> Option<LifecycleClaim> {
496 let r = self.inner.read().unwrap();
497 r.lifecycle_claims.get(cmd).cloned()
498 }
499
500 pub fn lifecycle_claims(&self) -> Vec<LifecycleClaim> {
504 self.inner
505 .read()
506 .unwrap()
507 .lifecycle_claims
508 .values()
509 .cloned()
510 .collect()
511 }
512
513 pub fn lifecycle_claim_collisions(&self) -> Vec<(String, String, String)> {
517 self.inner
518 .read()
519 .unwrap()
520 .lifecycle_claim_collisions
521 .clone()
522 }
523
524 pub fn plugins(&self) -> Vec<PluginSummary> {
525 let r = self.inner.read().unwrap();
526 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
527 let mut seen: std::collections::HashSet<(String, String)> =
528 std::collections::HashSet::new();
529 for c in r.plugin_commands.values() {
530 let key = (c.plugin.clone(), c.name.clone());
531 if seen.insert(key) {
532 *counts.entry(c.plugin.clone()).or_insert(0) += 0;
533 }
534 }
535 for s in r.qualified.values() {
536 if let Some(ref p) = s.plugin {
537 let key = (p.clone(), s.name.clone());
538 if seen.insert(key) {
539 *counts.entry(p.clone()).or_insert(0) += 1;
540 }
541 }
542 }
543 counts
544 .into_iter()
545 .map(|(name, skill_count)| PluginSummary { name, skill_count })
546 .collect()
547 }
548
549 pub fn plugin_help_entries(&self) -> Vec<HelpEntry> {
550 self.inner.read().unwrap().plugin_help_entries.clone()
551 }
552
553 pub fn plugin_settings_categories(&self) -> Vec<PluginSettingsCategory> {
557 self.inner
558 .read()
559 .unwrap()
560 .plugin_settings_categories
561 .clone()
562 }
563
564 pub fn all_skills(&self) -> Vec<Arc<LoadedSkill>> {
565 let r = self.inner.read().unwrap();
566 let mut seen: std::collections::HashSet<(Option<String>, String)> =
567 std::collections::HashSet::new();
568 let mut out = Vec::new();
569 for list in r.skills.values() {
570 for s in list {
571 let key = (s.plugin.clone(), s.name.clone());
572 if seen.insert(key) {
573 out.push(s.clone());
574 }
575 }
576 }
577 for s in r.qualified.values() {
578 let key = (s.plugin.clone(), s.name.clone());
579 if seen.insert(key) {
580 out.push(s.clone());
581 }
582 }
583 out
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use crate::skills::manifest::{ManifestCommand, ManifestShellCommand};
591 use std::path::PathBuf;
592
593 fn mk_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
594 Plugin {
595 name: plugin.to_string(),
596 root,
597 marketplace: None,
598 version: None,
599 description: None,
600 extension: None,
601 manifest: Some(crate::skills::manifest::PluginManifest {
602 name: plugin.to_string(),
603 version: None,
604 description: None,
605 keybinds: vec![],
606 compatibility: None,
607 commands: vec![ManifestCommand::Shell(ManifestShellCommand {
608 name: name.to_string(),
609 description: Some("desc".to_string()),
610 command: "printf".to_string(),
611 args: vec!["hi".to_string()],
612 })],
613 extension: None,
614 help_entries: vec![],
615 provides: None,
616 settings: None,
617 }),
618 }
619 }
620
621 fn mk_interactive_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
622 Plugin {
623 name: plugin.to_string(),
624 root,
625 marketplace: None,
626 version: None,
627 description: None,
628 extension: None,
629 manifest: Some(crate::skills::manifest::PluginManifest {
630 name: plugin.to_string(),
631 version: None,
632 description: None,
633 keybinds: vec![],
634 compatibility: None,
635 commands: vec![ManifestCommand::Interactive(
636 crate::skills::manifest::ManifestInteractiveCommand {
637 name: name.to_string(),
638 description: Some("interactive desc".to_string()),
639 interactive: true,
640 subcommands: vec!["help".to_string()],
641 },
642 )],
643 extension: None,
644 help_entries: vec![],
645 provides: None,
646 settings: None,
647 }),
648 }
649 }
650
651 #[test]
652 fn registers_interactive_plugin_command_backend() {
653 let reg = CommandRegistry::new_with_plugins(
654 &[],
655 vec![],
656 vec![mk_interactive_cmd(
657 "demo-plugin",
658 "demo",
659 PathBuf::from("/tmp/demo"),
660 )],
661 );
662
663 match reg.resolve("demo-plugin:demo") {
664 Resolution::PluginCommand(cmd) => match &cmd.backend {
665 RegisteredPluginCommandBackend::Interactive {
666 plugin_extension_id,
667 } => {
668 assert_eq!(plugin_extension_id, "demo-plugin");
669 assert_eq!(cmd.name, "demo");
670 }
671 other => panic!("expected interactive backend, got {other:?}"),
672 },
673 _ => panic!("expected plugin command resolution"),
674 }
675 }
676
677 fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
678 LoadedSkill::new_inline(
679 name,
680 "",
681 "",
682 plugin,
683 PathBuf::from("/"),
684 PathBuf::from("/SKILL.md"),
685 )
686 }
687
688 #[test]
689 fn plugin_help_entries_are_tagged_with_manifest_name() {
690 let root = PathBuf::from("/tmp/plugin-root");
691 let mut plugin = mk_cmd("acme-tools", "sync", root);
692 plugin
693 .manifest
694 .as_mut()
695 .unwrap()
696 .help_entries
697 .push(HelpEntry {
698 id: "acme-sync".to_string(),
699 command: "/acme:sync".to_string(),
700 title: "Acme Sync".to_string(),
701 summary: "Sync Acme workspace state.".to_string(),
702 category: "Plugin".to_string(),
703 topic: crate::help::HelpTopicKind::Command,
704 protected: false,
705 common: false,
706 aliases: vec![],
707 keywords: vec![],
708 lines: vec![],
709 usage: Some("/acme:sync [workspace]".to_string()),
710 examples: vec![crate::help::HelpExample {
711 command: "/acme:sync docs".to_string(),
712 description: "Sync docs.".to_string(),
713 }],
714 related: vec![],
715 source: None,
716 });
717
718 let registry = CommandRegistry::new_with_plugins(&[], vec![], vec![plugin]);
719 let entries = registry.plugin_help_entries();
720
721 assert_eq!(entries.len(), 1);
722 assert_eq!(entries[0].source.as_deref(), Some("acme-tools"));
723 assert_eq!(entries[0].usage.as_deref(), Some("/acme:sync [workspace]"));
724 assert_eq!(entries[0].examples[0].command, "/acme:sync docs");
725 }
726
727 #[test]
728 fn resolve_builtin() {
729 let r = CommandRegistry::new(&["clear"], vec![]);
730 assert!(matches!(r.resolve("clear"), Resolution::Builtin));
731 }
732
733 #[test]
734 fn resolve_unknown() {
735 let r = CommandRegistry::new(&["clear"], vec![]);
736 assert!(matches!(r.resolve("xyz"), Resolution::Unknown));
737 }
738
739 #[test]
740 fn resolve_unique_skill() {
741 let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
742 match r.resolve("search") {
743 Resolution::Skill(s) => assert_eq!(s.name, "search"),
744 _ => panic!(),
745 }
746 }
747
748 #[test]
749 fn resolve_ambiguous() {
750 let r = CommandRegistry::new(
751 &[],
752 vec![mk("search", Some("p1")), mk("search", Some("p2"))],
753 );
754 match r.resolve("search") {
755 Resolution::Ambiguous(v) => {
756 assert_eq!(v.len(), 2);
757 assert!(v.iter().any(|s| s == "p1:search"));
758 assert!(v.iter().any(|s| s == "p2:search"));
759 }
760 _ => panic!(),
761 }
762 }
763
764 #[test]
765 fn resolve_qualified() {
766 let r = CommandRegistry::new(
767 &[],
768 vec![mk("search", Some("p1")), mk("search", Some("p2"))],
769 );
770 match r.resolve("p1:search") {
771 Resolution::Skill(s) => assert_eq!(s.plugin.as_deref(), Some("p1")),
772 _ => panic!(),
773 }
774 }
775
776 #[test]
777 fn builtin_shadows_skill_unqualified() {
778 let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
780 assert!(matches!(r.resolve("clear"), Resolution::Builtin));
781 match r.resolve("p:clear") {
783 Resolution::Skill(s) => assert_eq!(s.name, "clear"),
784 _ => panic!(),
785 }
786 }
787
788 #[test]
789 fn all_commands_sorted_and_deduped() {
790 let r = CommandRegistry::new(
791 &["clear", "model"],
792 vec![mk("search", Some("p")), mk("help-me", None)],
793 );
794 let cmds = r.all_commands();
795 assert_eq!(cmds, vec!["clear", "help-me", "model", "search"]);
796 }
797
798 #[test]
799 fn all_skills_dedups_plugin_skill() {
800 let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
801 let all = r.all_skills();
802 assert_eq!(all.len(), 1);
803 assert_eq!(all[0].name, "search");
804 assert_eq!(all[0].plugin.as_deref(), Some("p"));
805 }
806
807 #[test]
808 fn all_skills_includes_shadowed_skill() {
809 let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
810 let all = r.all_skills();
811 assert_eq!(all.len(), 1);
812 assert_eq!(all[0].name, "clear");
813 assert_eq!(all[0].plugin.as_deref(), Some("p"));
814 }
815
816 #[test]
817 fn resolve_qualified_unknown_returns_unknown() {
818 let r = CommandRegistry::new(&[], vec![mk("search", Some("p1"))]);
819 assert!(matches!(r.resolve("p1:nosuch"), Resolution::Unknown));
820 assert!(matches!(r.resolve("nosuch:search"), Resolution::Unknown));
821 }
822
823 #[test]
824 fn rebuild_replaces_skills() {
825 let r = CommandRegistry::new(&["clear"], vec![mk("old", None)]);
826 assert!(matches!(r.resolve("old"), Resolution::Skill(_)));
827 assert!(matches!(r.resolve("new"), Resolution::Unknown));
828 r.rebuild_with(vec![mk("new", None)]);
829 assert!(matches!(r.resolve("old"), Resolution::Unknown));
830 assert!(matches!(r.resolve("new"), Resolution::Skill(_)));
831 }
832
833 #[test]
834 fn rebuild_visible_through_shared_arc() {
835 let r = std::sync::Arc::new(CommandRegistry::new(&[], vec![mk("a", None)]));
836 let r2 = r.clone();
837 r.rebuild_with(vec![mk("b", None)]);
838 assert!(matches!(r2.resolve("b"), Resolution::Skill(_)));
839 assert!(matches!(r2.resolve("a"), Resolution::Unknown));
840 }
841
842 #[test]
843 fn resolve_qualified_plugin_command() {
844 let r = CommandRegistry::new_with_plugins(
845 &[],
846 vec![],
847 vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
848 );
849 match r.resolve("p:hello") {
850 Resolution::PluginCommand(cmd) => {
851 assert_eq!(cmd.plugin, "p");
852 assert_eq!(cmd.name, "hello");
853 assert!(matches!(
854 &cmd.backend,
855 RegisteredPluginCommandBackend::Shell { command, .. } if command == "printf"
856 ));
857 assert_eq!(cmd.plugin_root, PathBuf::from("/tmp/p"));
858 }
859 _ => panic!(),
860 }
861 }
862
863 #[test]
864 fn all_commands_includes_unambiguous_plugin_commands_bare() {
865 let r = CommandRegistry::new_with_plugins(
870 &["help"],
871 vec![],
872 vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
873 );
874 let cmds = r.all_commands();
875 assert!(cmds.contains(&"help".to_string()));
876 assert!(cmds.contains(&"hello".to_string()));
877 assert!(!cmds.contains(&"p:hello".to_string()));
878 }
879
880 #[test]
881 fn resolve_bare_unique_plugin_command() {
882 let r = CommandRegistry::new_with_plugins(
883 &[],
884 vec![],
885 vec![mk_cmd("crush", "crush", PathBuf::from("/tmp/crush"))],
886 );
887 match r.resolve("crush") {
888 Resolution::PluginCommand(cmd) => {
889 assert_eq!(cmd.plugin, "crush");
890 assert_eq!(cmd.name, "crush");
891 }
892 _ => panic!("expected PluginCommand"),
893 }
894 match r.resolve("crush:crush") {
896 Resolution::PluginCommand(cmd) => assert_eq!(cmd.plugin, "crush"),
897 _ => panic!("expected PluginCommand"),
898 }
899 }
900
901 #[test]
902 fn resolve_bare_ambiguous_plugin_command_unknown() {
903 let r = CommandRegistry::new_with_plugins(
908 &[],
909 vec![],
910 vec![
911 mk_cmd("a", "foo", PathBuf::from("/tmp/a")),
912 mk_cmd("b", "foo", PathBuf::from("/tmp/b")),
913 ],
914 );
915 assert!(matches!(r.resolve("foo"), Resolution::Unknown));
916 assert!(matches!(r.resolve("a:foo"), Resolution::PluginCommand(_)));
917 assert!(matches!(r.resolve("b:foo"), Resolution::PluginCommand(_)));
918 }
919
920 #[test]
921 fn all_commands_ambiguous_plugin_commands_stay_qualified() {
922 let r = CommandRegistry::new_with_plugins(
923 &[],
924 vec![],
925 vec![
926 mk_cmd("a", "foo", PathBuf::from("/tmp/a")),
927 mk_cmd("b", "foo", PathBuf::from("/tmp/b")),
928 ],
929 );
930 let cmds = r.all_commands();
931 assert!(cmds.contains(&"a:foo".to_string()));
932 assert!(cmds.contains(&"b:foo".to_string()));
933 assert!(!cmds.contains(&"foo".to_string()));
934 }
935
936 #[test]
937 fn plugins_summary_groups_by_plugin_name() {
938 let r = CommandRegistry::new(
939 &[],
940 vec![
941 mk("a", Some("p1")),
942 mk("b", Some("p1")),
943 mk("c", Some("p2")),
944 mk("loose", None),
945 ],
946 );
947 let mut plugins = r.plugins();
948 plugins.sort_by(|a, b| a.name.cmp(&b.name));
949 assert_eq!(plugins.len(), 2);
950 assert_eq!(plugins[0].name, "p1");
951 assert_eq!(plugins[0].skill_count, 2);
952 assert_eq!(plugins[1].name, "p2");
953 assert_eq!(plugins[1].skill_count, 1);
954 }
955
956 fn mk_plugin_with_settings(plugin: &str, root: PathBuf) -> Plugin {
957 use crate::skills::manifest::{
958 ManifestEditorKind, ManifestSettings, ManifestSettingsCategory, ManifestSettingsField,
959 };
960 Plugin {
961 name: plugin.to_string(),
962 root,
963 marketplace: None,
964 version: None,
965 description: None,
966 extension: None,
967 manifest: Some(crate::skills::manifest::PluginManifest {
968 name: plugin.to_string(),
969 version: None,
970 description: None,
971 keybinds: vec![],
972 compatibility: None,
973 commands: vec![],
974 extension: None,
975 help_entries: vec![],
976 provides: None,
977 settings: Some(ManifestSettings {
978 categories: vec![ManifestSettingsCategory {
979 id: "demo".to_string(),
980 label: "Demo".to_string(),
981 fields: vec![
982 ManifestSettingsField {
983 key: "backend".to_string(),
984 label: "Backend".to_string(),
985 editor: ManifestEditorKind::Cycler,
986 options: vec!["a".to_string(), "b".to_string()],
987 help: None,
988 default: None,
989 numeric: false,
990 },
991 ManifestSettingsField {
992 key: "endpoint".to_string(),
993 label: "Endpoint".to_string(),
994 editor: ManifestEditorKind::Text,
995 options: vec![],
996 help: Some("URL".to_string()),
997 default: None,
998 numeric: false,
999 },
1000 ],
1001 }],
1002 }),
1003 }),
1004 }
1005 }
1006
1007 #[test]
1008 fn plugin_settings_categories_exposed_after_rebuild() {
1009 let r = CommandRegistry::new_with_plugins(
1010 &[],
1011 vec![],
1012 vec![mk_plugin_with_settings(
1013 "demo-plugin",
1014 PathBuf::from("/tmp/demo"),
1015 )],
1016 );
1017 let cats = r.plugin_settings_categories();
1018 assert_eq!(cats.len(), 1, "expected one plugin settings category");
1019 let cat = &cats[0];
1020 assert_eq!(cat.plugin, "demo-plugin");
1021 assert_eq!(cat.id, "demo");
1022 assert_eq!(cat.label, "Demo");
1023 assert_eq!(cat.fields.len(), 2);
1024
1025 match &cat.fields[0].editor {
1026 PluginSettingsEditor::Cycler { options } => {
1027 assert_eq!(options, &vec!["a".to_string(), "b".to_string()]);
1028 }
1029 other => panic!("expected cycler, got {other:?}"),
1030 }
1031 assert!(matches!(
1032 cat.fields[1].editor,
1033 PluginSettingsEditor::Text { numeric: false }
1034 ));
1035 assert_eq!(cat.fields[1].help.as_deref(), Some("URL"));
1036 }
1037
1038 #[test]
1039 fn plugin_settings_categories_empty_without_settings_block() {
1040 let r = CommandRegistry::new_with_plugins(
1041 &[],
1042 vec![],
1043 vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
1044 );
1045 assert!(r.plugin_settings_categories().is_empty());
1046 }
1047
1048 #[test]
1049 fn plugin_settings_categories_replaced_on_rebuild() {
1050 let r = CommandRegistry::new_with_plugins(
1051 &[],
1052 vec![],
1053 vec![mk_plugin_with_settings(
1054 "demo-plugin",
1055 PathBuf::from("/tmp/demo"),
1056 )],
1057 );
1058 assert_eq!(r.plugin_settings_categories().len(), 1);
1059 r.rebuild_with_plugins(vec![], vec![]);
1060 assert!(r.plugin_settings_categories().is_empty());
1061 }
1062
1063 #[test]
1064 fn plugin_settings_categories_does_not_hardcode_capture() {
1065 let r = CommandRegistry::new_with_plugins(
1068 &[],
1069 vec![],
1070 vec![mk_plugin_with_settings(
1071 "totally-unrelated",
1072 PathBuf::from("/tmp/x"),
1073 )],
1074 );
1075 let cats = r.plugin_settings_categories();
1076 assert_eq!(cats[0].plugin, "totally-unrelated");
1077 assert_eq!(cats[0].id, "demo");
1078 assert!(cats[0]
1079 .fields
1080 .iter()
1081 .any(|f| matches!(f.editor, PluginSettingsEditor::Cycler { .. })));
1082 assert!(cats[0]
1083 .fields
1084 .iter()
1085 .any(|f| matches!(f.editor, PluginSettingsEditor::Text { .. })));
1086 }
1087
1088 fn mk_plugin_with_lifecycle(
1091 plugin: &str,
1092 command: &str,
1093 display: Option<&str>,
1094 importance: i32,
1095 settings_category: Option<&str>,
1096 ) -> Plugin {
1097 use crate::skills::manifest::{
1098 PluginManifest, PluginProvides, SidecarLifecycle, SidecarManifest,
1099 };
1100 Plugin {
1101 name: plugin.to_string(),
1102 root: PathBuf::from(format!("/tmp/{plugin}")),
1103 marketplace: None,
1104 version: None,
1105 description: None,
1106 extension: None,
1107 manifest: Some(PluginManifest {
1108 name: plugin.to_string(),
1109 version: None,
1110 description: None,
1111 keybinds: vec![],
1112 compatibility: None,
1113 commands: vec![],
1114 extension: None,
1115 help_entries: vec![],
1116 provides: Some(PluginProvides {
1117 sidecar: Some(SidecarManifest {
1118 command: "bin/run".to_string(),
1119 setup: None,
1120 protocol_version: 1,
1121 model: None,
1122 lifecycle: Some(SidecarLifecycle {
1123 command: command.to_string(),
1124 settings_category: settings_category.map(str::to_string),
1125 display_name: display.map(str::to_string),
1126 importance,
1127 }),
1128 }),
1129 }),
1130 settings: None,
1131 }),
1132 }
1133 }
1134
1135 #[test]
1136 fn lifecycle_claim_registers_under_command_word() {
1137 let reg = CommandRegistry::new_with_plugins(
1138 &[],
1139 vec![],
1140 vec![mk_plugin_with_lifecycle(
1141 "sample-sidecar",
1142 "capture",
1143 Some("Sample"),
1144 50,
1145 Some("capture"),
1146 )],
1147 );
1148 let claim = reg
1149 .lifecycle_for_command("capture")
1150 .expect("sample lifecycle claim should be registered");
1151 assert_eq!(claim.plugin, "sample-sidecar");
1152 assert_eq!(claim.command, "capture");
1153 assert_eq!(claim.display_name, "Sample");
1154 assert_eq!(claim.importance, 50);
1155 assert_eq!(claim.settings_category.as_deref(), Some("capture"));
1156 }
1157
1158 #[test]
1159 fn lifecycle_claim_display_name_falls_back_to_command() {
1160 let reg = CommandRegistry::new_with_plugins(
1161 &[],
1162 vec![],
1163 vec![mk_plugin_with_lifecycle("p", "ocr", None, 0, None)],
1164 );
1165 let claim = reg.lifecycle_for_command("ocr").unwrap();
1166 assert_eq!(claim.display_name, "ocr");
1167 }
1168
1169 #[test]
1170 fn lifecycle_claim_surfaces_in_all_commands() {
1171 let reg = CommandRegistry::new_with_plugins(
1172 &[],
1173 vec![],
1174 vec![mk_plugin_with_lifecycle(
1175 "sample-sidecar",
1176 "capture",
1177 None,
1178 0,
1179 None,
1180 )],
1181 );
1182 assert!(reg.all_commands().contains(&"capture".to_string()));
1183 }
1184
1185 #[test]
1186 fn lifecycle_claim_collision_first_loaded_wins() {
1187 let reg = CommandRegistry::new_with_plugins(
1190 &[],
1191 vec![],
1192 vec![
1193 mk_plugin_with_lifecycle("alpha-sidecar", "capture", Some("Alpha"), 10, None),
1194 mk_plugin_with_lifecycle("beta-sidecar", "capture", Some("Beta"), 90, None),
1195 ],
1196 );
1197 let claim = reg.lifecycle_for_command("capture").unwrap();
1198 assert_eq!(claim.plugin, "alpha-sidecar");
1199 let collisions = reg.lifecycle_claim_collisions();
1200 assert_eq!(collisions.len(), 1);
1201 assert_eq!(
1202 collisions[0],
1203 (
1204 "beta-sidecar".to_string(),
1205 "capture".to_string(),
1206 "alpha-sidecar".to_string(),
1207 )
1208 );
1209 }
1210
1211 #[test]
1212 fn lifecycle_claims_returns_all_unique_command_words() {
1213 let reg = CommandRegistry::new_with_plugins(
1214 &[],
1215 vec![],
1216 vec![
1217 mk_plugin_with_lifecycle("sample-sidecar", "capture", None, 50, None),
1218 mk_plugin_with_lifecycle("ocr-plugin", "ocr", None, 30, None),
1219 ],
1220 );
1221 let claims = reg.lifecycle_claims();
1222 let mut names: Vec<_> = claims.iter().map(|c| c.command.as_str()).collect();
1223 names.sort();
1224 assert_eq!(names, vec!["capture", "ocr"]);
1225 }
1226
1227 #[test]
1228 fn lifecycle_for_command_returns_none_when_no_claim() {
1229 let reg = CommandRegistry::new_with_plugins(&[], vec![], vec![]);
1230 assert!(reg.lifecycle_for_command("capture").is_none());
1231 }
1232
1233 #[test]
1234 fn rebuild_replaces_lifecycle_claims_atomically() {
1235 let reg = CommandRegistry::new_with_plugins(
1236 &[],
1237 vec![],
1238 vec![mk_plugin_with_lifecycle(
1239 "sample-sidecar",
1240 "capture",
1241 None,
1242 0,
1243 None,
1244 )],
1245 );
1246 assert!(reg.lifecycle_for_command("capture").is_some());
1247 reg.rebuild_with_plugins(vec![], vec![]);
1249 assert!(reg.lifecycle_for_command("capture").is_none());
1250 assert!(reg.lifecycle_claim_collisions().is_empty());
1251 }
1252
1253 fn mk_plugin_lifecycle_plus_settings(
1256 plugin: &str,
1257 command: &str,
1258 lifecycle_settings_category: Option<&str>,
1259 category_ids: &[&str],
1260 ) -> Plugin {
1261 use crate::skills::manifest::{
1262 ManifestEditorKind, ManifestSettings, ManifestSettingsCategory, ManifestSettingsField,
1263 PluginManifest, PluginProvides, SidecarLifecycle, SidecarManifest,
1264 };
1265 Plugin {
1266 name: plugin.to_string(),
1267 root: PathBuf::from(format!("/tmp/{plugin}")),
1268 marketplace: None,
1269 version: None,
1270 description: None,
1271 extension: None,
1272 manifest: Some(PluginManifest {
1273 name: plugin.to_string(),
1274 version: None,
1275 description: None,
1276 keybinds: vec![],
1277 compatibility: None,
1278 commands: vec![],
1279 extension: None,
1280 help_entries: vec![],
1281 provides: Some(PluginProvides {
1282 sidecar: Some(SidecarManifest {
1283 command: "bin/run".to_string(),
1284 setup: None,
1285 protocol_version: 1,
1286 model: None,
1287 lifecycle: Some(SidecarLifecycle {
1288 command: command.to_string(),
1289 settings_category: lifecycle_settings_category.map(str::to_string),
1290 display_name: None,
1291 importance: 0,
1292 }),
1293 }),
1294 }),
1295 settings: Some(ManifestSettings {
1296 categories: category_ids
1297 .iter()
1298 .map(|id| ManifestSettingsCategory {
1299 id: id.to_string(),
1300 label: id.to_string(),
1301 fields: vec![ManifestSettingsField {
1302 key: "existing".to_string(),
1303 label: "Existing".to_string(),
1304 editor: ManifestEditorKind::Text,
1305 options: vec![],
1306 help: None,
1307 default: None,
1308 numeric: false,
1309 }],
1310 })
1311 .collect(),
1312 }),
1313 }),
1314 }
1315 }
1316
1317 #[test]
1318 fn lifecycle_injects_virtual_toggle_key_into_matching_category() {
1319 let reg = CommandRegistry::new_with_plugins(
1320 &[],
1321 vec![],
1322 vec![mk_plugin_lifecycle_plus_settings(
1323 "sample-sidecar",
1324 "capture",
1325 Some("capture"),
1326 &["capture"],
1327 )],
1328 );
1329 let cats = reg.plugin_settings_categories();
1330 let capture = cats
1331 .iter()
1332 .find(|c| c.id == "capture" && c.plugin == "sample-sidecar")
1333 .expect("sample category present");
1334 assert!(!capture.fields.is_empty());
1335 let first = &capture.fields[0];
1336 assert_eq!(first.key, "_lifecycle_toggle_key");
1337 assert_eq!(first.label, "Toggle key");
1338 match &first.editor {
1339 PluginSettingsEditor::Cycler { options } => {
1340 assert_eq!(
1341 options,
1342 &vec![
1343 "F8".to_string(),
1344 "F2".to_string(),
1345 "F12".to_string(),
1346 "C-V".to_string(),
1347 "C-G".to_string(),
1348 ]
1349 );
1350 }
1351 other => panic!("expected cycler, got {other:?}"),
1352 }
1353 assert_eq!(capture.fields[1].key, "existing");
1354 }
1355
1356 #[test]
1357 fn lifecycle_no_injection_when_settings_category_is_none() {
1358 let reg = CommandRegistry::new_with_plugins(
1359 &[],
1360 vec![],
1361 vec![mk_plugin_lifecycle_plus_settings(
1362 "p",
1363 "ocr",
1364 None,
1365 &["capture"],
1366 )],
1367 );
1368 let cats = reg.plugin_settings_categories();
1369 let capture = cats.iter().find(|c| c.id == "capture").expect("category");
1370 assert!(capture
1371 .fields
1372 .iter()
1373 .all(|f| f.key != "_lifecycle_toggle_key"));
1374 }
1375
1376 #[test]
1377 fn lifecycle_no_injection_when_category_does_not_exist() {
1378 let reg = CommandRegistry::new_with_plugins(
1379 &[],
1380 vec![],
1381 vec![mk_plugin_lifecycle_plus_settings(
1382 "p",
1383 "capture",
1384 Some("nonexistent"),
1385 &["capture"],
1386 )],
1387 );
1388 let cats = reg.plugin_settings_categories();
1389 for c in &cats {
1390 assert!(c.fields.iter().all(|f| f.key != "_lifecycle_toggle_key"));
1391 }
1392 }
1393
1394 #[test]
1395 fn lifecycle_two_plugins_each_get_injection_in_their_own_category() {
1396 let reg = CommandRegistry::new_with_plugins(
1397 &[],
1398 vec![],
1399 vec![
1400 mk_plugin_lifecycle_plus_settings(
1401 "sidecar-plugin",
1402 "capture",
1403 Some("capture"),
1404 &["capture"],
1405 ),
1406 mk_plugin_lifecycle_plus_settings("ocr-plugin", "ocr", Some("ocr"), &["ocr"]),
1407 ],
1408 );
1409 let cats = reg.plugin_settings_categories();
1410 let capture = cats.iter().find(|c| c.plugin == "sidecar-plugin").unwrap();
1411 let ocr = cats.iter().find(|c| c.plugin == "ocr-plugin").unwrap();
1412 assert_eq!(capture.fields[0].key, "_lifecycle_toggle_key");
1413 assert_eq!(ocr.fields[0].key, "_lifecycle_toggle_key");
1414 }
1415}