Skip to main content

agent_engine/skills/
registry.rs

1//! Slash command registry: built-ins + dynamically registered skills.
2
3use 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/// Editor kind for a plugin-declared settings field, normalised from the
34/// manifest into a form the settings UI can consume directly.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub enum PluginSettingsEditor {
37    Text {
38        numeric: bool,
39    },
40    Cycler {
41        options: Vec<String>,
42    },
43    Picker,
44    /// Editor body rendered by the plugin via the
45    /// `settings.editor.*` JSON-RPC contract.
46    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/// A plugin-declared category contributed to the `/settings` modal.
59///
60/// The owning plugin is recorded in `plugin` so that the settings UI can
61/// scope reads/writes to that plugin's config namespace and dispatch
62/// custom-editor JSON-RPC events to the right extension.
63#[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/// Resolution outcome for a typed slash command.
72#[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
82pub enum Resolution {
83    /// A built-in command (dispatched via existing handle_command).
84    Builtin,
85    /// A single unambiguous skill.
86    Skill(Arc<LoadedSkill>),
87    /// A plugin command from plugin.json `commands`.
88    PluginCommand(Arc<RegisteredPluginCommand>),
89    /// Multiple skills share this unqualified name; user must qualify.
90    Ambiguous(Vec<String>), // list of plugin-qualified names
91    /// No such command.
92    Unknown,
93}
94
95struct Inner {
96    skills: HashMap<String, Vec<Arc<LoadedSkill>>>, // unqualified name -> all matches
97    qualified: HashMap<String, Arc<LoadedSkill>>,   // "plugin:skill" -> single
98    plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>>, // "plugin:cmd" -> single
99    plugin_help_entries: Vec<HelpEntry>,
100    /// Plugin-declared settings categories, in plugin discovery order.
101    /// Path B Phase 4. The settings UI snapshots this on each open.
102    plugin_settings_categories: Vec<PluginSettingsCategory>,
103}
104
105/// Local frontend commands; reserved so plugins cannot hijack explicit file consent.
106pub const ATTACHMENT_COMMANDS: &[&str] = &["attach", "attachments", "detach"];
107
108/// Parse the entire `/attach` argument as one literal path, not shell words.
109/// Only a matching pair of surrounding quotes is removed; no expansion,
110/// unescaping, globbing, URL handling, or automatic path discovery occurs.
111pub fn attachment_path_argument(arg: &str) -> Result<&std::path::Path, String> {
112    let arg = arg.trim();
113    let path = if let Some(quote) = arg.chars().next().filter(|c| matches!(c, '\'' | '"')) {
114        if arg.len() < 2 || !arg.ends_with(quote) {
115            return Err("/attach: unmatched surrounding quote".into());
116        }
117        &arg[1..arg.len() - 1]
118    } else {
119        arg
120    };
121    if path.is_empty() {
122        return Err("usage: /attach PATH (the entire argument is one path)".into());
123    }
124    Ok(std::path::Path::new(path))
125}
126
127pub const ATTACHMENT_DISCLOSURE: &str =
128    "Attachment bytes are included in provider requests and stored in private session storage after submission.";
129
130pub struct CommandRegistry {
131    builtins: Vec<&'static str>,
132    inner: RwLock<Inner>,
133}
134
135impl CommandRegistry {
136    pub fn new(builtins: &[&'static str], skills: Vec<LoadedSkill>) -> Self {
137        Self::new_with_plugins(builtins, skills, vec![])
138    }
139
140    pub fn new_with_plugins(
141        builtins: &[&'static str],
142        skills: Vec<LoadedSkill>,
143        plugins: Vec<Plugin>,
144    ) -> Self {
145        let mut builtins = builtins.to_vec();
146        for &command in ATTACHMENT_COMMANDS {
147            if !builtins.contains(&command) {
148                builtins.push(command);
149            }
150        }
151        let r = CommandRegistry {
152            builtins,
153            inner: RwLock::new(Inner {
154                skills: HashMap::new(),
155                qualified: HashMap::new(),
156                plugin_commands: HashMap::new(),
157                plugin_help_entries: Vec::new(),
158                plugin_settings_categories: Vec::new(),
159            }),
160        };
161        r.rebuild_with_plugins(skills, plugins);
162        r
163    }
164
165    /// Atomically replace the skill set. Built-ins are unchanged.
166    pub fn rebuild_with(&self, skills: Vec<LoadedSkill>) {
167        self.rebuild_with_plugins(skills, vec![]);
168    }
169
170    /// Atomically replace the skill and plugin-command set. Built-ins are unchanged.
171    pub fn rebuild_with_plugins(&self, skills: Vec<LoadedSkill>, plugins: Vec<Plugin>) {
172        let builtins_set: std::collections::HashSet<&str> = self.builtins.iter().copied().collect();
173        let mut new_skills: HashMap<String, Vec<Arc<LoadedSkill>>> = HashMap::new();
174        let mut new_qualified: HashMap<String, Arc<LoadedSkill>> = HashMap::new();
175        let mut new_plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>> = HashMap::new();
176        let mut new_plugin_help_entries: Vec<HelpEntry> = Vec::new();
177        let mut new_plugin_settings_categories: Vec<PluginSettingsCategory> = Vec::new();
178        for plugin in plugins {
179            if let Some(manifest) = plugin.manifest {
180                new_plugin_help_entries.extend(manifest.help_entries.iter().cloned().map(
181                    |mut entry| {
182                        entry.source = Some(manifest.name.clone());
183                        entry
184                    },
185                ));
186                if let Some(ref settings) = manifest.settings {
187                    for cat in &settings.categories {
188                        let fields = cat
189                            .fields
190                            .iter()
191                            .map(|f| PluginSettingsField {
192                                key: f.key.clone(),
193                                label: f.label.clone(),
194                                editor: match f.editor {
195                                    crate::skills::manifest::ManifestEditorKind::Text => {
196                                        PluginSettingsEditor::Text { numeric: f.numeric }
197                                    }
198                                    crate::skills::manifest::ManifestEditorKind::Cycler => {
199                                        PluginSettingsEditor::Cycler {
200                                            options: f.options.clone(),
201                                        }
202                                    }
203                                    crate::skills::manifest::ManifestEditorKind::Picker => {
204                                        PluginSettingsEditor::Picker
205                                    }
206                                    crate::skills::manifest::ManifestEditorKind::Custom => {
207                                        PluginSettingsEditor::Custom
208                                    }
209                                },
210                                help: f.help.clone(),
211                                default: f.default.clone(),
212                            })
213                            .collect();
214                        new_plugin_settings_categories.push(PluginSettingsCategory {
215                            plugin: manifest.name.clone(),
216                            id: cat.id.clone(),
217                            label: cat.label.clone(),
218                            fields,
219                        });
220                    }
221                }
222                for cmd in manifest.commands {
223                    let (name, description, backend) = match cmd {
224                        crate::skills::manifest::ManifestCommand::Shell(cmd) => (
225                            cmd.name,
226                            cmd.description,
227                            RegisteredPluginCommandBackend::Shell {
228                                command: cmd.command,
229                                args: cmd.args,
230                            },
231                        ),
232                        crate::skills::manifest::ManifestCommand::ExtensionTool(cmd) => (
233                            cmd.name,
234                            cmd.description,
235                            RegisteredPluginCommandBackend::ExtensionTool {
236                                tool: cmd.tool,
237                                input: cmd.input,
238                            },
239                        ),
240                        crate::skills::manifest::ManifestCommand::SkillPrompt(cmd) => (
241                            cmd.name,
242                            cmd.description,
243                            RegisteredPluginCommandBackend::SkillPrompt {
244                                skill: cmd.skill,
245                                prompt: cmd.prompt,
246                            },
247                        ),
248                        crate::skills::manifest::ManifestCommand::Interactive(cmd) => {
249                            if !cmd.interactive {
250                                continue;
251                            }
252                            (
253                                cmd.name,
254                                cmd.description,
255                                RegisteredPluginCommandBackend::Interactive {
256                                    plugin_extension_id: manifest
257                                        .extension
258                                        .as_ref()
259                                        .map(|_| plugin.name.clone())
260                                        .unwrap_or_else(|| plugin.name.clone()),
261                                },
262                            )
263                        }
264                    };
265                    let q = format!("{}:{}", manifest.name, name);
266                    // Block plugins from registering commands that match builtin names
267                    if builtins_set.contains(name.as_str()) {
268                        tracing::warn!(
269                            "plugin '{}' command '{}' shadows builtin; skipping",
270                            manifest.name,
271                            name,
272                        );
273                        continue;
274                    }
275                    new_plugin_commands.insert(
276                        q,
277                        Arc::new(RegisteredPluginCommand {
278                            plugin: manifest.name.clone(),
279                            name,
280                            description,
281                            backend,
282                            plugin_root: plugin.root.clone(),
283                        }),
284                    );
285                }
286            }
287        }
288        for s in skills {
289            let arc = Arc::new(s);
290            // Unqualified entry
291            if builtins_set.contains(arc.name.as_str()) {
292                tracing::warn!(
293                    "skill '{}' shadowed by built-in; reachable only via qualified form '{}:{}'",
294                    arc.name,
295                    arc.plugin.as_deref().unwrap_or("?"),
296                    arc.name
297                );
298            } else {
299                new_skills
300                    .entry(arc.name.clone())
301                    .or_default()
302                    .push(arc.clone());
303            }
304            // Qualified entry
305            if let Some(ref p) = arc.plugin {
306                let q = format!("{}:{}", p, arc.name);
307                new_qualified.insert(q, arc.clone());
308            }
309        }
310
311        let mut w = self.inner.write().unwrap();
312        w.skills = new_skills;
313        w.qualified = new_qualified;
314        w.plugin_commands = new_plugin_commands;
315        w.plugin_help_entries = new_plugin_help_entries;
316        w.plugin_settings_categories = new_plugin_settings_categories;
317    }
318
319    pub fn resolve(&self, cmd: &str) -> Resolution {
320        let r = self.inner.read().unwrap();
321        if cmd.contains(':') {
322            if let Some(c) = r.plugin_commands.get(cmd) {
323                return Resolution::PluginCommand(c.clone());
324            }
325            return match r.qualified.get(cmd) {
326                Some(s) => Resolution::Skill(s.clone()),
327                None => Resolution::Unknown,
328            };
329        }
330        if self.builtins.contains(&cmd) {
331            return Resolution::Builtin;
332        }
333        match r.skills.get(cmd) {
334            Some(v) if v.len() == 1 => Resolution::Skill(v[0].clone()),
335            Some(v) => Resolution::Ambiguous(
336                v.iter()
337                    .map(|s| format!("{}:{}", s.plugin.as_deref().unwrap_or("?"), s.name))
338                    .collect(),
339            ),
340            None => {
341                // Fall back to an unambiguous plugin command claiming
342                // this bare name. `find_plugin_command_unqualified`
343                // returns Some only when exactly one plugin claims it;
344                // ambiguous (>=2) and missing both fall through to
345                // Unknown so the user must qualify.
346                drop(r);
347                if let Some(c) = self.find_plugin_command_unqualified(cmd) {
348                    return Resolution::PluginCommand(c);
349                }
350                Resolution::Unknown
351            }
352        }
353    }
354
355    /// Look up a plugin command by its unqualified name. Returns the
356    /// only match, or None if zero or multiple plugins claim the name.
357    /// Used when a builtin command has the same identifier as a
358    /// plugin-owned command that also has extension subcommands
359    /// builtin and a plugin command name) and we need to bypass the
360    /// builtin check to reach the plugin.
361    pub fn find_plugin_command_unqualified(
362        &self,
363        name: &str,
364    ) -> Option<Arc<RegisteredPluginCommand>> {
365        let r = self.inner.read().unwrap();
366        let mut matches = r.plugin_commands.values().filter(|c| c.name == name);
367        let first = matches.next()?.clone();
368        if matches.next().is_some() {
369            return None; // ambiguous
370        }
371        Some(first)
372    }
373
374    /// All commands for autocomplete/help: builtins + unique unqualified skill names, sorted.
375    pub fn all_commands(&self) -> Vec<String> {
376        let r = self.inner.read().unwrap();
377        let mut v: Vec<String> = self.builtins.iter().map(|s| s.to_string()).collect();
378        v.extend(r.skills.keys().cloned());
379        // Plugin commands: group by unqualified name. If exactly one
380        // plugin claims a name, surface it bare so users can type
381        // `/crush` instead of `/crush:crush`. If two or more plugins
382        // share the name, surface every qualified key so the user can
383        // disambiguate. Mirrors `resolve()`'s bare-resolution logic.
384        let mut by_name: HashMap<&str, Vec<&str>> = HashMap::new();
385        for (qkey, c) in r.plugin_commands.iter() {
386            by_name
387                .entry(c.name.as_str())
388                .or_default()
389                .push(qkey.as_str());
390        }
391        for (name, qkeys) in by_name {
392            if qkeys.len() == 1 {
393                v.push(name.to_string());
394            } else {
395                v.extend(qkeys.iter().map(|s| s.to_string()));
396            }
397        }
398        v.sort();
399        v.dedup();
400        v
401    }
402
403
404    pub fn plugins(&self) -> Vec<PluginSummary> {
405        let r = self.inner.read().unwrap();
406        let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
407        let mut seen: std::collections::HashSet<(String, String)> =
408            std::collections::HashSet::new();
409        for c in r.plugin_commands.values() {
410            let key = (c.plugin.clone(), c.name.clone());
411            if seen.insert(key) {
412                *counts.entry(c.plugin.clone()).or_insert(0) += 0;
413            }
414        }
415        for s in r.qualified.values() {
416            if let Some(ref p) = s.plugin {
417                let key = (p.clone(), s.name.clone());
418                if seen.insert(key) {
419                    *counts.entry(p.clone()).or_insert(0) += 1;
420                }
421            }
422        }
423        counts
424            .into_iter()
425            .map(|(name, skill_count)| PluginSummary { name, skill_count })
426            .collect()
427    }
428
429    pub fn plugin_help_entries(&self) -> Vec<HelpEntry> {
430        self.inner.read().unwrap().plugin_help_entries.clone()
431    }
432
433    /// Snapshot of every plugin-declared settings category, preserving
434    /// plugin discovery order. The settings UI calls this on each open
435    /// to merge plugin categories with the built-in ones.
436    pub fn plugin_settings_categories(&self) -> Vec<PluginSettingsCategory> {
437        self.inner
438            .read()
439            .unwrap()
440            .plugin_settings_categories
441            .clone()
442    }
443
444    pub fn all_skills(&self) -> Vec<Arc<LoadedSkill>> {
445        let r = self.inner.read().unwrap();
446        let mut seen: std::collections::HashSet<(Option<String>, String)> =
447            std::collections::HashSet::new();
448        let mut out = Vec::new();
449        for list in r.skills.values() {
450            for s in list {
451                let key = (s.plugin.clone(), s.name.clone());
452                if seen.insert(key) {
453                    out.push(s.clone());
454                }
455            }
456        }
457        for s in r.qualified.values() {
458            let key = (s.plugin.clone(), s.name.clone());
459            if seen.insert(key) {
460                out.push(s.clone());
461            }
462        }
463        out
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::skills::manifest::{ManifestCommand, ManifestShellCommand};
471    use std::path::PathBuf;
472
473    #[test]
474    fn attachment_commands_are_reserved_and_deduplicated() {
475        let r = CommandRegistry::new(&["attach"], vec![mk("attach", Some("p"))]);
476        for &command in ATTACHMENT_COMMANDS {
477            assert!(matches!(r.resolve(command), Resolution::Builtin));
478            assert_eq!(
479                r.all_commands()
480                    .iter()
481                    .filter(|c| c.as_str() == command)
482                    .count(),
483                1
484            );
485        }
486        assert!(matches!(r.resolve("p:attach"), Resolution::Skill(_)));
487    }
488
489    #[test]
490    fn attachment_argument_is_one_literal_path() {
491        for arg in ["some file.txt", "  \"some file.txt\"  ", "'some file.txt'"] {
492            assert_eq!(
493                attachment_path_argument(arg).unwrap(),
494                std::path::Path::new("some file.txt")
495            );
496        }
497        assert_eq!(
498            attachment_path_argument("~/file* $HOME.txt").unwrap(),
499            std::path::Path::new("~/file* $HOME.txt")
500        );
501        for arg in [
502            "",
503            "  ",
504            "\"\"",
505            "''",
506            "\"",
507            "'",
508            "\"unclosed",
509            "'unclosed\"",
510        ] {
511            assert!(attachment_path_argument(arg).is_err(), "{arg:?}");
512        }
513    }
514
515    fn mk_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
516        Plugin {
517            name: plugin.to_string(),
518            root,
519            marketplace: None,
520            version: None,
521            description: None,
522            extension: None,
523            manifest: Some(crate::skills::manifest::PluginManifest {
524                name: plugin.to_string(),
525                version: None,
526                description: None,
527                keybinds: vec![],
528                compatibility: None,
529                commands: vec![ManifestCommand::Shell(ManifestShellCommand {
530                    name: name.to_string(),
531                    description: Some("desc".to_string()),
532                    command: "printf".to_string(),
533                    args: vec!["hi".to_string()],
534                })],
535                extension: None,
536                help_entries: vec![],
537                settings: None,
538            }),
539        }
540    }
541
542    fn mk_interactive_cmd(plugin: &str, name: &str, root: PathBuf) -> Plugin {
543        Plugin {
544            name: plugin.to_string(),
545            root,
546            marketplace: None,
547            version: None,
548            description: None,
549            extension: None,
550            manifest: Some(crate::skills::manifest::PluginManifest {
551                name: plugin.to_string(),
552                version: None,
553                description: None,
554                keybinds: vec![],
555                compatibility: None,
556                commands: vec![ManifestCommand::Interactive(
557                    crate::skills::manifest::ManifestInteractiveCommand {
558                        name: name.to_string(),
559                        description: Some("interactive desc".to_string()),
560                        interactive: true,
561                        subcommands: vec!["help".to_string()],
562                    },
563                )],
564                extension: None,
565                help_entries: vec![],
566                settings: None,
567            }),
568        }
569    }
570
571    #[test]
572    fn registers_interactive_plugin_command_backend() {
573        let reg = CommandRegistry::new_with_plugins(
574            &[],
575            vec![],
576            vec![mk_interactive_cmd(
577                "demo-plugin",
578                "demo",
579                PathBuf::from("/tmp/demo"),
580            )],
581        );
582
583        match reg.resolve("demo-plugin:demo") {
584            Resolution::PluginCommand(cmd) => match &cmd.backend {
585                RegisteredPluginCommandBackend::Interactive {
586                    plugin_extension_id,
587                } => {
588                    assert_eq!(plugin_extension_id, "demo-plugin");
589                    assert_eq!(cmd.name, "demo");
590                }
591                other => panic!("expected interactive backend, got {other:?}"),
592            },
593            _ => panic!("expected plugin command resolution"),
594        }
595    }
596
597    fn mk(name: &str, plugin: Option<&str>) -> LoadedSkill {
598        LoadedSkill::new_inline(
599            name,
600            "",
601            "",
602            plugin,
603            PathBuf::from("/"),
604            PathBuf::from("/SKILL.md"),
605        )
606    }
607
608    #[test]
609    fn plugin_help_entries_are_tagged_with_manifest_name() {
610        let root = PathBuf::from("/tmp/plugin-root");
611        let mut plugin = mk_cmd("acme-tools", "sync", root);
612        plugin
613            .manifest
614            .as_mut()
615            .unwrap()
616            .help_entries
617            .push(HelpEntry {
618                id: "acme-sync".to_string(),
619                command: "/acme:sync".to_string(),
620                title: "Acme Sync".to_string(),
621                summary: "Sync Acme workspace state.".to_string(),
622                category: "Plugin".to_string(),
623                topic: crate::help::HelpTopicKind::Command,
624                protected: false,
625                common: false,
626                aliases: vec![],
627                keywords: vec![],
628                lines: vec![],
629                usage: Some("/acme:sync [workspace]".to_string()),
630                examples: vec![crate::help::HelpExample {
631                    command: "/acme:sync docs".to_string(),
632                    description: "Sync docs.".to_string(),
633                }],
634                related: vec![],
635                source: None,
636            });
637
638        let registry = CommandRegistry::new_with_plugins(&[], vec![], vec![plugin]);
639        let entries = registry.plugin_help_entries();
640
641        assert_eq!(entries.len(), 1);
642        assert_eq!(entries[0].source.as_deref(), Some("acme-tools"));
643        assert_eq!(entries[0].usage.as_deref(), Some("/acme:sync [workspace]"));
644        assert_eq!(entries[0].examples[0].command, "/acme:sync docs");
645    }
646
647    #[test]
648    fn resolve_builtin() {
649        let r = CommandRegistry::new(&["clear"], vec![]);
650        assert!(matches!(r.resolve("clear"), Resolution::Builtin));
651    }
652
653    #[test]
654    fn resolve_unknown() {
655        let r = CommandRegistry::new(&["clear"], vec![]);
656        assert!(matches!(r.resolve("xyz"), Resolution::Unknown));
657    }
658
659    #[test]
660    fn resolve_unique_skill() {
661        let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
662        match r.resolve("search") {
663            Resolution::Skill(s) => assert_eq!(s.name, "search"),
664            _ => panic!(),
665        }
666    }
667
668    #[test]
669    fn resolve_ambiguous() {
670        let r = CommandRegistry::new(
671            &[],
672            vec![mk("search", Some("p1")), mk("search", Some("p2"))],
673        );
674        match r.resolve("search") {
675            Resolution::Ambiguous(v) => {
676                assert_eq!(v.len(), 2);
677                assert!(v.iter().any(|s| s == "p1:search"));
678                assert!(v.iter().any(|s| s == "p2:search"));
679            }
680            _ => panic!(),
681        }
682    }
683
684    #[test]
685    fn resolve_qualified() {
686        let r = CommandRegistry::new(
687            &[],
688            vec![mk("search", Some("p1")), mk("search", Some("p2"))],
689        );
690        match r.resolve("p1:search") {
691            Resolution::Skill(s) => assert_eq!(s.plugin.as_deref(), Some("p1")),
692            _ => panic!(),
693        }
694    }
695
696    #[test]
697    fn builtin_shadows_skill_unqualified() {
698        // Skill named "clear" should not win over the built-in.
699        let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
700        assert!(matches!(r.resolve("clear"), Resolution::Builtin));
701        // Qualified form still works.
702        match r.resolve("p:clear") {
703            Resolution::Skill(s) => assert_eq!(s.name, "clear"),
704            _ => panic!(),
705        }
706    }
707
708    #[test]
709    fn all_commands_sorted_and_deduped() {
710        let r = CommandRegistry::new(
711            &["clear", "model"],
712            vec![mk("search", Some("p")), mk("help-me", None)],
713        );
714        let cmds = r.all_commands();
715        assert_eq!(
716            cmds,
717            vec![
718                "attach",
719                "attachments",
720                "clear",
721                "detach",
722                "help-me",
723                "model",
724                "search"
725            ]
726        );
727    }
728
729    #[test]
730    fn all_skills_dedups_plugin_skill() {
731        let r = CommandRegistry::new(&[], vec![mk("search", Some("p"))]);
732        let all = r.all_skills();
733        assert_eq!(all.len(), 1);
734        assert_eq!(all[0].name, "search");
735        assert_eq!(all[0].plugin.as_deref(), Some("p"));
736    }
737
738    #[test]
739    fn all_skills_includes_shadowed_skill() {
740        let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
741        let all = r.all_skills();
742        assert_eq!(all.len(), 1);
743        assert_eq!(all[0].name, "clear");
744        assert_eq!(all[0].plugin.as_deref(), Some("p"));
745    }
746
747    #[test]
748    fn resolve_qualified_unknown_returns_unknown() {
749        let r = CommandRegistry::new(&[], vec![mk("search", Some("p1"))]);
750        assert!(matches!(r.resolve("p1:nosuch"), Resolution::Unknown));
751        assert!(matches!(r.resolve("nosuch:search"), Resolution::Unknown));
752    }
753
754    #[test]
755    fn rebuild_replaces_skills() {
756        let r = CommandRegistry::new(&["clear"], vec![mk("old", None)]);
757        assert!(matches!(r.resolve("old"), Resolution::Skill(_)));
758        assert!(matches!(r.resolve("new"), Resolution::Unknown));
759        r.rebuild_with(vec![mk("new", None)]);
760        assert!(matches!(r.resolve("old"), Resolution::Unknown));
761        assert!(matches!(r.resolve("new"), Resolution::Skill(_)));
762    }
763
764    #[test]
765    fn rebuild_visible_through_shared_arc() {
766        let r = std::sync::Arc::new(CommandRegistry::new(&[], vec![mk("a", None)]));
767        let r2 = r.clone();
768        r.rebuild_with(vec![mk("b", None)]);
769        assert!(matches!(r2.resolve("b"), Resolution::Skill(_)));
770        assert!(matches!(r2.resolve("a"), Resolution::Unknown));
771    }
772
773    #[test]
774    fn resolve_qualified_plugin_command() {
775        let r = CommandRegistry::new_with_plugins(
776            &[],
777            vec![],
778            vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
779        );
780        match r.resolve("p:hello") {
781            Resolution::PluginCommand(cmd) => {
782                assert_eq!(cmd.plugin, "p");
783                assert_eq!(cmd.name, "hello");
784                assert!(matches!(
785                    &cmd.backend,
786                    RegisteredPluginCommandBackend::Shell { command, .. } if command == "printf"
787                ));
788                assert_eq!(cmd.plugin_root, PathBuf::from("/tmp/p"));
789            }
790            _ => panic!(),
791        }
792    }
793
794    #[test]
795    fn all_commands_includes_unambiguous_plugin_commands_bare() {
796        // Updated from `all_commands_includes_qualified_plugin_commands`:
797        // a sole plugin command is now surfaced bare ("hello"), not
798        // qualified ("p:hello"). Ambiguous names stay qualified — see
799        // `all_commands_ambiguous_plugin_commands_stay_qualified`.
800        let r = CommandRegistry::new_with_plugins(
801            &["help"],
802            vec![],
803            vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
804        );
805        let cmds = r.all_commands();
806        assert!(cmds.contains(&"help".to_string()));
807        assert!(cmds.contains(&"hello".to_string()));
808        assert!(!cmds.contains(&"p:hello".to_string()));
809    }
810
811    #[test]
812    fn resolve_bare_unique_plugin_command() {
813        let r = CommandRegistry::new_with_plugins(
814            &[],
815            vec![],
816            vec![mk_cmd("crush", "crush", PathBuf::from("/tmp/crush"))],
817        );
818        match r.resolve("crush") {
819            Resolution::PluginCommand(cmd) => {
820                assert_eq!(cmd.plugin, "crush");
821                assert_eq!(cmd.name, "crush");
822            }
823            _ => panic!("expected PluginCommand"),
824        }
825        // Qualified form still works (no regression).
826        match r.resolve("crush:crush") {
827            Resolution::PluginCommand(cmd) => assert_eq!(cmd.plugin, "crush"),
828            _ => panic!("expected PluginCommand"),
829        }
830    }
831
832    #[test]
833    fn resolve_bare_ambiguous_plugin_command_unknown() {
834        // Two plugins both expose "foo". Bare resolution must NOT pick
835        // one — `find_plugin_command_unqualified` returns None for >1
836        // match, so we fall through to Unknown. Qualified forms still
837        // resolve.
838        let r = CommandRegistry::new_with_plugins(
839            &[],
840            vec![],
841            vec![
842                mk_cmd("a", "foo", PathBuf::from("/tmp/a")),
843                mk_cmd("b", "foo", PathBuf::from("/tmp/b")),
844            ],
845        );
846        assert!(matches!(r.resolve("foo"), Resolution::Unknown));
847        assert!(matches!(r.resolve("a:foo"), Resolution::PluginCommand(_)));
848        assert!(matches!(r.resolve("b:foo"), Resolution::PluginCommand(_)));
849    }
850
851    #[test]
852    fn all_commands_ambiguous_plugin_commands_stay_qualified() {
853        let r = CommandRegistry::new_with_plugins(
854            &[],
855            vec![],
856            vec![
857                mk_cmd("a", "foo", PathBuf::from("/tmp/a")),
858                mk_cmd("b", "foo", PathBuf::from("/tmp/b")),
859            ],
860        );
861        let cmds = r.all_commands();
862        assert!(cmds.contains(&"a:foo".to_string()));
863        assert!(cmds.contains(&"b:foo".to_string()));
864        assert!(!cmds.contains(&"foo".to_string()));
865    }
866
867    #[test]
868    fn plugins_summary_groups_by_plugin_name() {
869        let r = CommandRegistry::new(
870            &[],
871            vec![
872                mk("a", Some("p1")),
873                mk("b", Some("p1")),
874                mk("c", Some("p2")),
875                mk("loose", None),
876            ],
877        );
878        let mut plugins = r.plugins();
879        plugins.sort_by(|a, b| a.name.cmp(&b.name));
880        assert_eq!(plugins.len(), 2);
881        assert_eq!(plugins[0].name, "p1");
882        assert_eq!(plugins[0].skill_count, 2);
883        assert_eq!(plugins[1].name, "p2");
884        assert_eq!(plugins[1].skill_count, 1);
885    }
886
887    fn mk_plugin_with_settings(plugin: &str, root: PathBuf) -> Plugin {
888        use crate::skills::manifest::{
889            ManifestEditorKind, ManifestSettings, ManifestSettingsCategory, ManifestSettingsField,
890        };
891        Plugin {
892            name: plugin.to_string(),
893            root,
894            marketplace: None,
895            version: None,
896            description: None,
897            extension: None,
898            manifest: Some(crate::skills::manifest::PluginManifest {
899                name: plugin.to_string(),
900                version: None,
901                description: None,
902                keybinds: vec![],
903                compatibility: None,
904                commands: vec![],
905                extension: None,
906                help_entries: vec![],
907                settings: Some(ManifestSettings {
908                    categories: vec![ManifestSettingsCategory {
909                        id: "demo".to_string(),
910                        label: "Demo".to_string(),
911                        fields: vec![
912                            ManifestSettingsField {
913                                key: "backend".to_string(),
914                                label: "Backend".to_string(),
915                                editor: ManifestEditorKind::Cycler,
916                                options: vec!["a".to_string(), "b".to_string()],
917                                help: None,
918                                default: None,
919                                numeric: false,
920                            },
921                            ManifestSettingsField {
922                                key: "endpoint".to_string(),
923                                label: "Endpoint".to_string(),
924                                editor: ManifestEditorKind::Text,
925                                options: vec![],
926                                help: Some("URL".to_string()),
927                                default: None,
928                                numeric: false,
929                            },
930                        ],
931                    }],
932                }),
933            }),
934        }
935    }
936
937    #[test]
938    fn plugin_settings_categories_exposed_after_rebuild() {
939        let r = CommandRegistry::new_with_plugins(
940            &[],
941            vec![],
942            vec![mk_plugin_with_settings(
943                "demo-plugin",
944                PathBuf::from("/tmp/demo"),
945            )],
946        );
947        let cats = r.plugin_settings_categories();
948        assert_eq!(cats.len(), 1, "expected one plugin settings category");
949        let cat = &cats[0];
950        assert_eq!(cat.plugin, "demo-plugin");
951        assert_eq!(cat.id, "demo");
952        assert_eq!(cat.label, "Demo");
953        assert_eq!(cat.fields.len(), 2);
954
955        match &cat.fields[0].editor {
956            PluginSettingsEditor::Cycler { options } => {
957                assert_eq!(options, &vec!["a".to_string(), "b".to_string()]);
958            }
959            other => panic!("expected cycler, got {other:?}"),
960        }
961        assert!(matches!(
962            cat.fields[1].editor,
963            PluginSettingsEditor::Text { numeric: false }
964        ));
965        assert_eq!(cat.fields[1].help.as_deref(), Some("URL"));
966    }
967
968    #[test]
969    fn plugin_settings_categories_empty_without_settings_block() {
970        let r = CommandRegistry::new_with_plugins(
971            &[],
972            vec![],
973            vec![mk_cmd("p", "hello", PathBuf::from("/tmp/p"))],
974        );
975        assert!(r.plugin_settings_categories().is_empty());
976    }
977
978    #[test]
979    fn plugin_settings_categories_replaced_on_rebuild() {
980        let r = CommandRegistry::new_with_plugins(
981            &[],
982            vec![],
983            vec![mk_plugin_with_settings(
984                "demo-plugin",
985                PathBuf::from("/tmp/demo"),
986            )],
987        );
988        assert_eq!(r.plugin_settings_categories().len(), 1);
989        r.rebuild_with_plugins(vec![], vec![]);
990        assert!(r.plugin_settings_categories().is_empty());
991    }
992
993    #[test]
994    fn plugin_settings_categories_does_not_hardcode_capture() {
995        // Acceptance: declarative cycler/text fields are represented in
996        // core data without plugin-specific knowledge.
997        let r = CommandRegistry::new_with_plugins(
998            &[],
999            vec![],
1000            vec![mk_plugin_with_settings(
1001                "totally-unrelated",
1002                PathBuf::from("/tmp/x"),
1003            )],
1004        );
1005        let cats = r.plugin_settings_categories();
1006        assert_eq!(cats[0].plugin, "totally-unrelated");
1007        assert_eq!(cats[0].id, "demo");
1008        assert!(cats[0]
1009            .fields
1010            .iter()
1011            .any(|f| matches!(f.editor, PluginSettingsEditor::Cycler { .. })));
1012        assert!(cats[0]
1013            .fields
1014            .iter()
1015            .any(|f| matches!(f.editor, PluginSettingsEditor::Text { .. })));
1016    }
1017}