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 => {
389 drop(r);
395 if let Some(c) = self.find_plugin_command_unqualified(cmd) {
396 return Resolution::PluginCommand(c);
397 }
398 Resolution::Unknown
399 }
400 }
401 }
402
403 pub fn find_plugin_command_unqualified(&self, name: &str) -> Option<Arc<RegisteredPluginCommand>> {
410 let r = self.inner.read().unwrap();
411 let mut matches = r.plugin_commands.values().filter(|c| c.name == name);
412 let first = matches.next()?.clone();
413 if matches.next().is_some() {
414 return None; }
416 Some(first)
417 }
418
419 pub fn all_commands(&self) -> Vec<String> {
421 let r = self.inner.read().unwrap();
422 let mut v: Vec<String> = self.builtins.iter().map(|s| s.to_string()).collect();
423 v.extend(r.skills.keys().cloned());
424 let mut by_name: HashMap<&str, Vec<&str>> = HashMap::new();
430 for (qkey, c) in r.plugin_commands.iter() {
431 by_name.entry(c.name.as_str()).or_default().push(qkey.as_str());
432 }
433 for (name, qkeys) in by_name {
434 if qkeys.len() == 1 {
435 v.push(name.to_string());
436 } else {
437 v.extend(qkeys.iter().map(|s| s.to_string()));
438 }
439 }
440 v.extend(r.lifecycle_claims.keys().cloned());
443 v.sort();
444 v.dedup();
445 v
446 }
447
448 pub fn lifecycle_for_command(&self, cmd: &str) -> Option<LifecycleClaim> {
452 let r = self.inner.read().unwrap();
453 r.lifecycle_claims.get(cmd).cloned()
454 }
455
456 pub fn lifecycle_claims(&self) -> Vec<LifecycleClaim> {
460 self.inner.read().unwrap().lifecycle_claims.values().cloned().collect()
461 }
462
463 pub fn lifecycle_claim_collisions(&self) -> Vec<(String, String, String)> {
467 self.inner.read().unwrap().lifecycle_claim_collisions.clone()
468 }
469
470 pub fn plugins(&self) -> Vec<PluginSummary> {
471 let r = self.inner.read().unwrap();
472 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
473 let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
474 for c in r.plugin_commands.values() {
475 let key = (c.plugin.clone(), c.name.clone());
476 if seen.insert(key) {
477 *counts.entry(c.plugin.clone()).or_insert(0) += 0;
478 }
479 }
480 for s in r.qualified.values() {
481 if let Some(ref p) = s.plugin {
482 let key = (p.clone(), s.name.clone());
483 if seen.insert(key) {
484 *counts.entry(p.clone()).or_insert(0) += 1;
485 }
486 }
487 }
488 counts.into_iter()
489 .map(|(name, skill_count)| PluginSummary { name, skill_count })
490 .collect()
491 }
492
493 pub fn plugin_help_entries(&self) -> Vec<HelpEntry> {
494 self.inner.read().unwrap().plugin_help_entries.clone()
495 }
496
497 pub fn plugin_settings_categories(&self) -> Vec<PluginSettingsCategory> {
501 self.inner.read().unwrap().plugin_settings_categories.clone()
502 }
503
504 pub fn all_skills(&self) -> Vec<Arc<LoadedSkill>> {
505 let r = self.inner.read().unwrap();
506 let mut seen: std::collections::HashSet<(Option<String>, String)> =
507 std::collections::HashSet::new();
508 let mut out = Vec::new();
509 for list in r.skills.values() {
510 for s in list {
511 let key = (s.plugin.clone(), s.name.clone());
512 if seen.insert(key) { out.push(s.clone()); }
513 }
514 }
515 for s in r.qualified.values() {
516 let key = (s.plugin.clone(), s.name.clone());
517 if seen.insert(key) { out.push(s.clone()); }
518 }
519 out
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use std::path::PathBuf;
527 use crate::skills::manifest::{ManifestCommand, ManifestShellCommand};
528
529 fn mk_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
530 Plugin {
531 name: plugin.to_string(),
532 root,
533 marketplace: None,
534 version: None,
535 description: None,
536 extension: None,
537 manifest: Some(crate::skills::manifest::PluginManifest {
538 name: plugin.to_string(),
539 version: None,
540 description: None,
541 keybinds: vec![],
542 compatibility: None,
543 commands: vec![ManifestCommand::Shell(ManifestShellCommand {
544 name: name.to_string(),
545 description: Some("desc".to_string()),
546 command: "printf".to_string(),
547 args: vec!["hi".to_string()],
548 })],
549 extension: None,
550 help_entries: vec![],
551 provides: None,
552 settings: None,
553 }),
554 }
555 }
556
557
558 fn mk_interactive_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
559 Plugin {
560 name: plugin.to_string(),
561 root,
562 marketplace: None,
563 version: None,
564 description: None,
565 extension: None,
566 manifest: Some(crate::skills::manifest::PluginManifest {
567 name: plugin.to_string(),
568 version: None,
569 description: None,
570 keybinds: vec![],
571 compatibility: None,
572 commands: vec![ManifestCommand::Interactive(crate::skills::manifest::ManifestInteractiveCommand {
573 name: name.to_string(),
574 description: Some("interactive desc".to_string()),
575 interactive: true,
576 subcommands: vec!["help".to_string()],
577 })],
578 extension: None,
579 help_entries: vec![],
580 provides: None,
581 settings: None,
582 }),
583 }
584 }
585
586 #[test]
587 fn registers_interactive_plugin_command_backend() {
588 let reg = CommandRegistry::new_with_plugins(
589 &[],
590 vec![],
591 vec![mk_interactive_cmd("demo-plugin", "demo", PathBuf::from("/tmp/demo"))],
592 );
593
594 match reg.resolve("demo-plugin:demo") {
595 Resolution::PluginCommand(cmd) => match &cmd.backend {
596 RegisteredPluginCommandBackend::Interactive { plugin_extension_id } => {
597 assert_eq!(plugin_extension_id, "demo-plugin");
598 assert_eq!(cmd.name, "demo");
599 }
600 other => panic!("expected interactive backend, got {other:?}"),
601 },
602 _ => panic!("expected plugin command resolution"),
603 }
604 }
605
606 fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
607 LoadedSkill {
608 name: name.to_string(),
609 description: String::new(),
610 body: String::new(),
611 plugin: plugin.map(str::to_string),
612 base_dir: PathBuf::from("/"),
613 source_path: PathBuf::from("/SKILL.md"),
614 }
615 }
616
617
618 #[test]
619 fn plugin_help_entries_are_tagged_with_manifest_name() {
620 let root = PathBuf::from("/tmp/plugin-root");
621 let mut plugin = mk_cmd("acme-tools", "sync", root);
622 plugin.manifest.as_mut().unwrap().help_entries.push(HelpEntry {
623 id: "acme-sync".to_string(),
624 command: "/acme:sync".to_string(),
625 title: "Acme Sync".to_string(),
626 summary: "Sync Acme workspace state.".to_string(),
627 category: "Plugin".to_string(),
628 topic: crate::help::HelpTopicKind::Command,
629 protected: false,
630 common: false,
631 aliases: vec![],
632 keywords: vec![],
633 lines: vec![],
634 usage: Some("/acme:sync [workspace]".to_string()),
635 examples: vec![crate::help::HelpExample {
636 command: "/acme:sync docs".to_string(),
637 description: "Sync docs.".to_string(),
638 }],
639 related: vec![],
640 source: None,
641 });
642
643 let registry = CommandRegistry::new_with_plugins(&[], vec![], vec![plugin]);
644 let entries = registry.plugin_help_entries();
645
646 assert_eq!(entries.len(), 1);
647 assert_eq!(entries[0].source.as_deref(), Some("acme-tools"));
648 assert_eq!(entries[0].usage.as_deref(), Some("/acme:sync [workspace]"));
649 assert_eq!(entries[0].examples[0].command, "/acme:sync docs");
650 }
651
652 #[test]
653 fn resolve_builtin() {
654 let r = CommandRegistry::new(&["clear"], vec![]);
655 assert!(matches!(r.resolve("clear"), Resolution::Builtin));
656 }
657
658 #[test]
659 fn resolve_unknown() {
660 let r = CommandRegistry::new(&["clear"], vec![]);
661 assert!(matches!(r.resolve("xyz"), Resolution::Unknown));
662 }
663
664 #[test]
665 fn resolve_unique_skill() {
666 let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
667 match r.resolve("search") {
668 Resolution::Skill(s) => assert_eq!(s.name, "search"),
669 _ => panic!(),
670 }
671 }
672
673 #[test]
674 fn resolve_ambiguous() {
675 let r = CommandRegistry::new(&[], vec![
676 mk("search", Some("p1")),
677 mk("search", Some("p2")),
678 ]);
679 match r.resolve("search") {
680 Resolution::Ambiguous(v) => {
681 assert_eq!(v.len(), 2);
682 assert!(v.iter().any(|s| s == "p1:search"));
683 assert!(v.iter().any(|s| s == "p2:search"));
684 }
685 _ => panic!(),
686 }
687 }
688
689 #[test]
690 fn resolve_qualified() {
691 let r = CommandRegistry::new(&[], vec![
692 mk("search", Some("p1")),
693 mk("search", Some("p2")),
694 ]);
695 match r.resolve("p1:search") {
696 Resolution::Skill(s) => assert_eq!(s.plugin.as_deref(), Some("p1")),
697 _ => panic!(),
698 }
699 }
700
701 #[test]
702 fn builtin_shadows_skill_unqualified() {
703 let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
705 assert!(matches!(r.resolve("clear"), Resolution::Builtin));
706 match r.resolve("p:clear") {
708 Resolution::Skill(s) => assert_eq!(s.name, "clear"),
709 _ => panic!(),
710 }
711 }
712
713 #[test]
714 fn all_commands_sorted_and_deduped() {
715 let r = CommandRegistry::new(&["clear", "model"], vec![
716 mk("search", Some("p")),
717 mk("help-me", None),
718 ]);
719 let cmds = r.all_commands();
720 assert_eq!(cmds, vec!["clear", "help-me", "model", "search"]);
721 }
722
723 #[test]
724 fn all_skills_dedups_plugin_skill() {
725 let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
726 let all = r.all_skills();
727 assert_eq!(all.len(), 1);
728 assert_eq!(all[0].name, "search");
729 assert_eq!(all[0].plugin.as_deref(), Some("p"));
730 }
731
732 #[test]
733 fn all_skills_includes_shadowed_skill() {
734 let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
735 let all = r.all_skills();
736 assert_eq!(all.len(), 1);
737 assert_eq!(all[0].name, "clear");
738 assert_eq!(all[0].plugin.as_deref(), Some("p"));
739 }
740
741 #[test]
742 fn resolve_qualified_unknown_returns_unknown() {
743 let r = CommandRegistry::new(&[], vec![mk("search", Some("p1"))]);
744 assert!(matches!(r.resolve("p1:nosuch"), Resolution::Unknown));
745 assert!(matches!(r.resolve("nosuch:search"), Resolution::Unknown));
746 }
747
748 #[test]
749 fn rebuild_replaces_skills() {
750 let r = CommandRegistry::new(&["clear"], vec![mk("old", None)]);
751 assert!(matches!(r.resolve("old"), Resolution::Skill(_)));
752 assert!(matches!(r.resolve("new"), Resolution::Unknown));
753 r.rebuild_with(vec![mk("new", None)]);
754 assert!(matches!(r.resolve("old"), Resolution::Unknown));
755 assert!(matches!(r.resolve("new"), Resolution::Skill(_)));
756 }
757
758 #[test]
759 fn rebuild_visible_through_shared_arc() {
760 let r = std::sync::Arc::new(CommandRegistry::new(&[], vec![mk("a", None)]));
761 let r2 = r.clone();
762 r.rebuild_with(vec![mk("b", None)]);
763 assert!(matches!(r2.resolve("b"), Resolution::Skill(_)));
764 assert!(matches!(r2.resolve("a"), Resolution::Unknown));
765 }
766
767 #[test]
768 fn resolve_qualified_plugin_command() {
769 let r = CommandRegistry::new_with_plugins(&[], vec![], vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))]);
770 match r.resolve("p:hello") {
771 Resolution::PluginCommand(cmd) => {
772 assert_eq!(cmd.plugin, "p");
773 assert_eq!(cmd.name, "hello");
774 assert!(matches!(
775 &cmd.backend,
776 RegisteredPluginCommandBackend::Shell { command, .. } if command == "printf"
777 ));
778 assert_eq!(cmd.plugin_root, PathBuf::from("/tmp/p"));
779 }
780 _ => panic!(),
781 }
782 }
783
784 #[test]
785 fn all_commands_includes_unambiguous_plugin_commands_bare() {
786 let r = CommandRegistry::new_with_plugins(&["help"], vec![], vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))]);
791 let cmds = r.all_commands();
792 assert!(cmds.contains(&"help".to_string()));
793 assert!(cmds.contains(&"hello".to_string()));
794 assert!(!cmds.contains(&"p:hello".to_string()));
795 }
796
797 #[test]
798 fn resolve_bare_unique_plugin_command() {
799 let r = CommandRegistry::new_with_plugins(
800 &[],
801 vec![],
802 vec![mk_cmd("crush", "crush", PathBuf::from("/tmp/crush"))],
803 );
804 match r.resolve("crush") {
805 Resolution::PluginCommand(cmd) => {
806 assert_eq!(cmd.plugin, "crush");
807 assert_eq!(cmd.name, "crush");
808 }
809 _ => panic!("expected PluginCommand"),
810 }
811 match r.resolve("crush:crush") {
813 Resolution::PluginCommand(cmd) => assert_eq!(cmd.plugin, "crush"),
814 _ => panic!("expected PluginCommand"),
815 }
816 }
817
818 #[test]
819 fn resolve_bare_ambiguous_plugin_command_unknown() {
820 let r = CommandRegistry::new_with_plugins(
825 &[],
826 vec![],
827 vec![
828 mk_cmd("a", "foo", PathBuf::from("/tmp/a")),
829 mk_cmd("b", "foo", PathBuf::from("/tmp/b")),
830 ],
831 );
832 assert!(matches!(r.resolve("foo"), Resolution::Unknown));
833 assert!(matches!(r.resolve("a:foo"), Resolution::PluginCommand(_)));
834 assert!(matches!(r.resolve("b:foo"), Resolution::PluginCommand(_)));
835 }
836
837 #[test]
838 fn all_commands_ambiguous_plugin_commands_stay_qualified() {
839 let r = CommandRegistry::new_with_plugins(
840 &[],
841 vec![],
842 vec![
843 mk_cmd("a", "foo", PathBuf::from("/tmp/a")),
844 mk_cmd("b", "foo", PathBuf::from("/tmp/b")),
845 ],
846 );
847 let cmds = r.all_commands();
848 assert!(cmds.contains(&"a:foo".to_string()));
849 assert!(cmds.contains(&"b:foo".to_string()));
850 assert!(!cmds.contains(&"foo".to_string()));
851 }
852
853 #[test]
854 fn plugins_summary_groups_by_plugin_name() {
855 let r = CommandRegistry::new(&[], vec![
856 mk("a", Some("p1")),
857 mk("b", Some("p1")),
858 mk("c", Some("p2")),
859 mk("loose", None),
860 ]);
861 let mut plugins = r.plugins();
862 plugins.sort_by(|a, b| a.name.cmp(&b.name));
863 assert_eq!(plugins.len(), 2);
864 assert_eq!(plugins[0].name, "p1");
865 assert_eq!(plugins[0].skill_count, 2);
866 assert_eq!(plugins[1].name, "p2");
867 assert_eq!(plugins[1].skill_count, 1);
868 }
869
870 fn mk_plugin_with_settings(plugin: &str, root: PathBuf) -> Plugin {
871 use crate::skills::manifest::{
872 ManifestEditorKind, ManifestSettings, ManifestSettingsCategory,
873 ManifestSettingsField,
874 };
875 Plugin {
876 name: plugin.to_string(),
877 root,
878 marketplace: None,
879 version: None,
880 description: None,
881 extension: None,
882 manifest: Some(crate::skills::manifest::PluginManifest {
883 name: plugin.to_string(),
884 version: None,
885 description: None,
886 keybinds: vec![],
887 compatibility: None,
888 commands: vec![],
889 extension: None,
890 help_entries: vec![],
891 provides: None,
892 settings: Some(ManifestSettings {
893 categories: vec![ManifestSettingsCategory {
894 id: "demo".to_string(),
895 label: "Demo".to_string(),
896 fields: vec![
897 ManifestSettingsField {
898 key: "backend".to_string(),
899 label: "Backend".to_string(),
900 editor: ManifestEditorKind::Cycler,
901 options: vec!["a".to_string(), "b".to_string()],
902 help: None,
903 default: None,
904 numeric: false,
905 },
906 ManifestSettingsField {
907 key: "endpoint".to_string(),
908 label: "Endpoint".to_string(),
909 editor: ManifestEditorKind::Text,
910 options: vec![],
911 help: Some("URL".to_string()),
912 default: None,
913 numeric: false,
914 },
915 ],
916 }],
917 }),
918 }),
919 }
920 }
921
922 #[test]
923 fn plugin_settings_categories_exposed_after_rebuild() {
924 let r = CommandRegistry::new_with_plugins(
925 &[],
926 vec![],
927 vec![mk_plugin_with_settings("demo-plugin", PathBuf::from("/tmp/demo"))],
928 );
929 let cats = r.plugin_settings_categories();
930 assert_eq!(cats.len(), 1, "expected one plugin settings category");
931 let cat = &cats[0];
932 assert_eq!(cat.plugin, "demo-plugin");
933 assert_eq!(cat.id, "demo");
934 assert_eq!(cat.label, "Demo");
935 assert_eq!(cat.fields.len(), 2);
936
937 match &cat.fields[0].editor {
938 PluginSettingsEditor::Cycler { options } => {
939 assert_eq!(options, &vec!["a".to_string(), "b".to_string()]);
940 }
941 other => panic!("expected cycler, got {other:?}"),
942 }
943 assert!(matches!(
944 cat.fields[1].editor,
945 PluginSettingsEditor::Text { numeric: false }
946 ));
947 assert_eq!(cat.fields[1].help.as_deref(), Some("URL"));
948 }
949
950 #[test]
951 fn plugin_settings_categories_empty_without_settings_block() {
952 let r = CommandRegistry::new_with_plugins(
953 &[],
954 vec![],
955 vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
956 );
957 assert!(r.plugin_settings_categories().is_empty());
958 }
959
960 #[test]
961 fn plugin_settings_categories_replaced_on_rebuild() {
962 let r = CommandRegistry::new_with_plugins(
963 &[],
964 vec![],
965 vec![mk_plugin_with_settings("demo-plugin", PathBuf::from("/tmp/demo"))],
966 );
967 assert_eq!(r.plugin_settings_categories().len(), 1);
968 r.rebuild_with_plugins(vec![], vec![]);
969 assert!(r.plugin_settings_categories().is_empty());
970 }
971
972 #[test]
973 fn plugin_settings_categories_does_not_hardcode_capture() {
974 let r = CommandRegistry::new_with_plugins(
977 &[],
978 vec![],
979 vec![mk_plugin_with_settings("totally-unrelated", PathBuf::from("/tmp/x"))],
980 );
981 let cats = r.plugin_settings_categories();
982 assert_eq!(cats[0].plugin, "totally-unrelated");
983 assert_eq!(cats[0].id, "demo");
984 assert!(cats[0].fields.iter().any(|f| matches!(
985 f.editor,
986 PluginSettingsEditor::Cycler { .. }
987 )));
988 assert!(cats[0].fields.iter().any(|f| matches!(
989 f.editor,
990 PluginSettingsEditor::Text { .. }
991 )));
992 }
993
994 fn mk_plugin_with_lifecycle(
997 plugin: &str,
998 command: &str,
999 display: Option<&str>,
1000 importance: i32,
1001 settings_category: Option<&str>,
1002 ) -> Plugin {
1003 use crate::skills::manifest::{
1004 PluginManifest, PluginProvides, SidecarLifecycle, SidecarManifest,
1005 };
1006 Plugin {
1007 name: plugin.to_string(),
1008 root: PathBuf::from(format!("/tmp/{plugin}")),
1009 marketplace: None,
1010 version: None,
1011 description: None,
1012 extension: None,
1013 manifest: Some(PluginManifest {
1014 name: plugin.to_string(),
1015 version: None,
1016 description: None,
1017 keybinds: vec![],
1018 compatibility: None,
1019 commands: vec![],
1020 extension: None,
1021 help_entries: vec![],
1022 provides: Some(PluginProvides {
1023 sidecar: Some(SidecarManifest {
1024 command: "bin/run".to_string(),
1025 setup: None,
1026 protocol_version: 1,
1027 model: None,
1028 lifecycle: Some(SidecarLifecycle {
1029 command: command.to_string(),
1030 settings_category: settings_category.map(str::to_string),
1031 display_name: display.map(str::to_string),
1032 importance,
1033 }),
1034 }),
1035 }),
1036 settings: None,
1037 }),
1038 }
1039 }
1040
1041 #[test]
1042 fn lifecycle_claim_registers_under_command_word() {
1043 let reg = CommandRegistry::new_with_plugins(
1044 &[],
1045 vec![],
1046 vec![mk_plugin_with_lifecycle(
1047 "sample-sidecar",
1048 "capture",
1049 Some("Sample"),
1050 50,
1051 Some("capture"),
1052 )],
1053 );
1054 let claim = reg
1055 .lifecycle_for_command("capture")
1056 .expect("sample lifecycle claim should be registered");
1057 assert_eq!(claim.plugin, "sample-sidecar");
1058 assert_eq!(claim.command, "capture");
1059 assert_eq!(claim.display_name, "Sample");
1060 assert_eq!(claim.importance, 50);
1061 assert_eq!(claim.settings_category.as_deref(), Some("capture"));
1062 }
1063
1064 #[test]
1065 fn lifecycle_claim_display_name_falls_back_to_command() {
1066 let reg = CommandRegistry::new_with_plugins(
1067 &[],
1068 vec![],
1069 vec![mk_plugin_with_lifecycle("p", "ocr", None, 0, None)],
1070 );
1071 let claim = reg.lifecycle_for_command("ocr").unwrap();
1072 assert_eq!(claim.display_name, "ocr");
1073 }
1074
1075 #[test]
1076 fn lifecycle_claim_surfaces_in_all_commands() {
1077 let reg = CommandRegistry::new_with_plugins(
1078 &[],
1079 vec![],
1080 vec![mk_plugin_with_lifecycle("sample-sidecar", "capture", None, 0, None)],
1081 );
1082 assert!(reg.all_commands().contains(&"capture".to_string()));
1083 }
1084
1085 #[test]
1086 fn lifecycle_claim_collision_first_loaded_wins() {
1087 let reg = CommandRegistry::new_with_plugins(
1090 &[],
1091 vec![],
1092 vec![
1093 mk_plugin_with_lifecycle("alpha-sidecar", "capture", Some("Alpha"), 10, None),
1094 mk_plugin_with_lifecycle("beta-sidecar", "capture", Some("Beta"), 90, None),
1095 ],
1096 );
1097 let claim = reg.lifecycle_for_command("capture").unwrap();
1098 assert_eq!(claim.plugin, "alpha-sidecar");
1099 let collisions = reg.lifecycle_claim_collisions();
1100 assert_eq!(collisions.len(), 1);
1101 assert_eq!(collisions[0], (
1102 "beta-sidecar".to_string(),
1103 "capture".to_string(),
1104 "alpha-sidecar".to_string(),
1105 ));
1106 }
1107
1108 #[test]
1109 fn lifecycle_claims_returns_all_unique_command_words() {
1110 let reg = CommandRegistry::new_with_plugins(
1111 &[],
1112 vec![],
1113 vec![
1114 mk_plugin_with_lifecycle("sample-sidecar", "capture", None, 50, None),
1115 mk_plugin_with_lifecycle("ocr-plugin", "ocr", None, 30, None),
1116 ],
1117 );
1118 let claims = reg.lifecycle_claims();
1119 let mut names: Vec<_> = claims.iter().map(|c| c.command.as_str()).collect();
1120 names.sort();
1121 assert_eq!(names, vec!["capture", "ocr"]);
1122 }
1123
1124 #[test]
1125 fn lifecycle_for_command_returns_none_when_no_claim() {
1126 let reg = CommandRegistry::new_with_plugins(&[], vec![], vec![]);
1127 assert!(reg.lifecycle_for_command("capture").is_none());
1128 }
1129
1130 #[test]
1131 fn rebuild_replaces_lifecycle_claims_atomically() {
1132 let reg = CommandRegistry::new_with_plugins(
1133 &[],
1134 vec![],
1135 vec![mk_plugin_with_lifecycle("sample-sidecar", "capture", None, 0, None)],
1136 );
1137 assert!(reg.lifecycle_for_command("capture").is_some());
1138 reg.rebuild_with_plugins(vec![], vec![]);
1140 assert!(reg.lifecycle_for_command("capture").is_none());
1141 assert!(reg.lifecycle_claim_collisions().is_empty());
1142 }
1143
1144 fn mk_plugin_lifecycle_plus_settings(
1147 plugin: &str,
1148 command: &str,
1149 lifecycle_settings_category: Option<&str>,
1150 category_ids: &[&str],
1151 ) -> Plugin {
1152 use crate::skills::manifest::{
1153 ManifestEditorKind, ManifestSettings, ManifestSettingsCategory,
1154 ManifestSettingsField, PluginManifest, PluginProvides, SidecarLifecycle,
1155 SidecarManifest,
1156 };
1157 Plugin {
1158 name: plugin.to_string(),
1159 root: PathBuf::from(format!("/tmp/{plugin}")),
1160 marketplace: None,
1161 version: None,
1162 description: None,
1163 extension: None,
1164 manifest: Some(PluginManifest {
1165 name: plugin.to_string(),
1166 version: None,
1167 description: None,
1168 keybinds: vec![],
1169 compatibility: None,
1170 commands: vec![],
1171 extension: None,
1172 help_entries: vec![],
1173 provides: Some(PluginProvides {
1174 sidecar: Some(SidecarManifest {
1175 command: "bin/run".to_string(),
1176 setup: None,
1177 protocol_version: 1,
1178 model: None,
1179 lifecycle: Some(SidecarLifecycle {
1180 command: command.to_string(),
1181 settings_category: lifecycle_settings_category.map(str::to_string),
1182 display_name: None,
1183 importance: 0,
1184 }),
1185 }),
1186 }),
1187 settings: Some(ManifestSettings {
1188 categories: category_ids
1189 .iter()
1190 .map(|id| ManifestSettingsCategory {
1191 id: id.to_string(),
1192 label: id.to_string(),
1193 fields: vec![ManifestSettingsField {
1194 key: "existing".to_string(),
1195 label: "Existing".to_string(),
1196 editor: ManifestEditorKind::Text,
1197 options: vec![],
1198 help: None,
1199 default: None,
1200 numeric: false,
1201 }],
1202 })
1203 .collect(),
1204 }),
1205 }),
1206 }
1207 }
1208
1209 #[test]
1210 fn lifecycle_injects_virtual_toggle_key_into_matching_category() {
1211 let reg = CommandRegistry::new_with_plugins(
1212 &[],
1213 vec![],
1214 vec![mk_plugin_lifecycle_plus_settings(
1215 "sample-sidecar",
1216 "capture",
1217 Some("capture"),
1218 &["capture"],
1219 )],
1220 );
1221 let cats = reg.plugin_settings_categories();
1222 let capture = cats
1223 .iter()
1224 .find(|c| c.id == "capture" && c.plugin == "sample-sidecar")
1225 .expect("sample category present");
1226 assert!(!capture.fields.is_empty());
1227 let first = &capture.fields[0];
1228 assert_eq!(first.key, "_lifecycle_toggle_key");
1229 assert_eq!(first.label, "Toggle key");
1230 match &first.editor {
1231 PluginSettingsEditor::Cycler { options } => {
1232 assert_eq!(
1233 options,
1234 &vec![
1235 "F8".to_string(),
1236 "F2".to_string(),
1237 "F12".to_string(),
1238 "C-V".to_string(),
1239 "C-G".to_string(),
1240 ]
1241 );
1242 }
1243 other => panic!("expected cycler, got {other:?}"),
1244 }
1245 assert_eq!(capture.fields[1].key, "existing");
1246 }
1247
1248 #[test]
1249 fn lifecycle_no_injection_when_settings_category_is_none() {
1250 let reg = CommandRegistry::new_with_plugins(
1251 &[],
1252 vec![],
1253 vec![mk_plugin_lifecycle_plus_settings("p", "ocr", None, &["capture"])],
1254 );
1255 let cats = reg.plugin_settings_categories();
1256 let capture = cats.iter().find(|c| c.id == "capture").expect("category");
1257 assert!(capture.fields.iter().all(|f| f.key != "_lifecycle_toggle_key"));
1258 }
1259
1260 #[test]
1261 fn lifecycle_no_injection_when_category_does_not_exist() {
1262 let reg = CommandRegistry::new_with_plugins(
1263 &[],
1264 vec![],
1265 vec![mk_plugin_lifecycle_plus_settings(
1266 "p",
1267 "capture",
1268 Some("nonexistent"),
1269 &["capture"],
1270 )],
1271 );
1272 let cats = reg.plugin_settings_categories();
1273 for c in &cats {
1274 assert!(c.fields.iter().all(|f| f.key != "_lifecycle_toggle_key"));
1275 }
1276 }
1277
1278 #[test]
1279 fn lifecycle_two_plugins_each_get_injection_in_their_own_category() {
1280 let reg = CommandRegistry::new_with_plugins(
1281 &[],
1282 vec![],
1283 vec![
1284 mk_plugin_lifecycle_plus_settings(
1285 "sidecar-plugin",
1286 "capture",
1287 Some("capture"),
1288 &["capture"],
1289 ),
1290 mk_plugin_lifecycle_plus_settings(
1291 "ocr-plugin",
1292 "ocr",
1293 Some("ocr"),
1294 &["ocr"],
1295 ),
1296 ],
1297 );
1298 let cats = reg.plugin_settings_categories();
1299 let capture = cats.iter().find(|c| c.plugin == "sidecar-plugin").unwrap();
1300 let ocr = cats.iter().find(|c| c.plugin == "ocr-plugin").unwrap();
1301 assert_eq!(capture.fields[0].key, "_lifecycle_toggle_key");
1302 assert_eq!(ocr.fields[0].key, "_lifecycle_toggle_key");
1303 }
1304}