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