Skip to main content

agent_engine/skills/
registry.rs

1//! Slash command registry: built-ins + dynamically registered skills.
2
3use 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/// Editor kind for a plugin-declared settings field, normalised from the
23/// manifest into a form the settings UI can consume directly.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum PluginSettingsEditor {
26    Text { numeric: bool },
27    Cycler { options: Vec<String> },
28    Picker,
29    /// Editor body rendered by the plugin via the
30    /// `settings.editor.*` JSON-RPC contract.
31    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/// A plugin-declared category contributed to the `/settings` modal.
44///
45/// The owning plugin is recorded in `plugin` so that the settings UI can
46/// scope reads/writes to that plugin's config namespace and dispatch
47/// custom-editor JSON-RPC events to the right extension.
48#[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/// Resolution outcome for a typed slash command.
57#[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/// A plugin's claim on a top-level lifecycle command namespace, derived
67/// from `provides.sidecar.lifecycle` in plugin.json. Phase 8 slice 8A.
68///
69/// When a plugin declares a lifecycle claim, the command registry
70/// auto-registers `/<command> toggle` and `/<command> status` (and any
71/// future lifecycle verbs) so the plugin's own UX namespace —
72/// e.g. a plugin-owned command — drives sidecar lifecycle instead
73/// of the modality-laden `/sidecar` builtin.
74///
75/// Multiple plugins may register lifecycle claims; in Phase 8 slice 8A
76/// the host still hosts at most one active sidecar so all claims share
77/// a single backing `App.sidecar` slot. Slice 8B widens this.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct LifecycleClaim {
80    /// Plugin (manifest) name that owns this claim.
81    pub plugin: String,
82    /// Top-level command word the plugin claims.
83    pub command: String,
84    /// Optional settings-category id that the host should inject
85    /// per-plugin lifecycle keys (e.g. toggle keybind) into.
86    pub settings_category: Option<String>,
87    /// Human-readable label for pills, error messages, status lines.
88    pub display_name: String,
89    /// Sort key for pill ordering (higher = earlier). Range `-100..=100`,
90    /// already clamped during manifest deserialisation.
91    pub importance: i32,
92}
93
94pub enum Resolution {
95    /// A built-in command (dispatched via existing handle_command).
96    Builtin,
97    /// A single unambiguous skill.
98    Skill(Arc<LoadedSkill>),
99    /// A plugin command from plugin.json `commands`.
100    PluginCommand(Arc<RegisteredPluginCommand>),
101    /// Multiple skills share this unqualified name; user must qualify.
102    Ambiguous(Vec<String>), // list of plugin-qualified names
103    /// No such command.
104    Unknown,
105}
106
107struct Inner {
108    skills: HashMap<String, Vec<Arc<LoadedSkill>>>, // unqualified name -> all matches
109    qualified: HashMap<String, Arc<LoadedSkill>>,   // "plugin:skill" -> single
110    plugin_commands: HashMap<String, Arc<RegisteredPluginCommand>>, // "plugin:cmd" -> single
111    plugin_help_entries: Vec<HelpEntry>,
112    /// Plugin-declared settings categories, in plugin discovery order.
113    /// Path B Phase 4. The settings UI snapshots this on each open.
114    plugin_settings_categories: Vec<PluginSettingsCategory>,
115    /// Plugin-declared lifecycle claims (Phase 8 slice 8A). Indexed by
116    /// the claimed command word. At most one claim per
117    /// command word; first-loaded plugin wins on collision.
118    lifecycle_claims: HashMap<String, LifecycleClaim>,
119    /// Plugins that lost a lifecycle-claim collision, recorded so the
120    /// `/extensions` view can surface a warning. Each entry is
121    /// `(losing_plugin, command, winning_plugin)`.
122    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    /// Atomically replace the skill set. Built-ins are unchanged.
153    pub fn rebuild_with(&self, skills: Vec<LoadedSkill>) {
154        self.rebuild_with_plugins(skills, vec![]);
155    }
156
157    /// Atomically replace the skill and plugin-command set. Built-ins are unchanged.
158    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                // Phase 8 slice 8A: harvest lifecycle claims from
175                // provides.sidecar.lifecycle. First-loaded wins on
176                // collision; the loser is recorded for surfacing in
177                // /extensions and the chat log.
178                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                            // Block plugins from hijacking built-in commands
189                            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                    // Block plugins from registering commands that match builtin names
282                    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            // Unqualified entry
302            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            // Qualified entry
313            if let Some(ref p) = arc.plugin {
314                let q = format!("{}:{}", p, arc.name);
315                new_qualified.insert(q, arc.clone());
316            }
317        }
318        // Phase 8 slice 8A.3: for each lifecycle claim with a
319        // settings_category, inject a synthetic `_lifecycle_toggle_key`
320        // field at the front of the matching plugin-declared category.
321        // No-op (with a warn) if the named category doesn't exist.
322        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                // Fall back to an unambiguous plugin command claiming
390                // this bare name. `find_plugin_command_unqualified`
391                // returns Some only when exactly one plugin claims it;
392                // ambiguous (>=2) and missing both fall through to
393                // Unknown so the user must qualify.
394                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    /// Look up a plugin command by its unqualified name. Returns the
404    /// only match, or None if zero or multiple plugins claim the name.
405    /// Used when a builtin command has the same identifier as a
406    /// plugin-owned command that also has extension subcommands
407    /// builtin and a plugin command name) and we need to bypass the
408    /// builtin check to reach the plugin.
409    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; // ambiguous
415        }
416        Some(first)
417    }
418
419    /// All commands for autocomplete/help: builtins + unique unqualified skill names, sorted.
420    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        // Plugin commands: group by unqualified name. If exactly one
425        // plugin claims a name, surface it bare so users can type
426        // `/crush` instead of `/crush:crush`. If two or more plugins
427        // share the name, surface every qualified key so the user can
428        // disambiguate. Mirrors `resolve()`'s bare-resolution logic.
429        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        // Phase 8 slice 8A: plugin-claimed lifecycle commands surface
441        // as top-level commands too.
442        v.extend(r.lifecycle_claims.keys().cloned());
443        v.sort();
444        v.dedup();
445        v
446    }
447
448    /// Look up the lifecycle claim for a top-level command word, if any.
449    /// Phase 8 slice 8A. Returns the claim regardless of arg/subcommand —
450    /// callers (the dispatcher) decide what to do with `toggle` / `status`.
451    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    /// Snapshot of every claimed lifecycle command, in arbitrary order.
457    /// Used by `/extensions` to render the active claims and by the
458    /// pill renderer to enumerate sidecars.
459    pub fn lifecycle_claims(&self) -> Vec<LifecycleClaim> {
460        self.inner.read().unwrap().lifecycle_claims.values().cloned().collect()
461    }
462
463    /// Plugins that lost a lifecycle-claim collision during the most
464    /// recent rebuild. Each entry is `(losing_plugin, command,
465    /// winning_plugin)`. Surfaced in `/extensions`.
466    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    /// Snapshot of every plugin-declared settings category, preserving
498    /// plugin discovery order. The settings UI calls this on each open
499    /// to merge plugin categories with the built-in ones.
500    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        // Skill named "clear" should not win over the built-in.
704        let r = CommandRegistry::new(&["clear"], vec![mk("clear", Some("p"))]);
705        assert!(matches!(r.resolve("clear"), Resolution::Builtin));
706        // Qualified form still works.
707        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        // Updated from `all_commands_includes_qualified_plugin_commands`:
787        // a sole plugin command is now surfaced bare ("hello"), not
788        // qualified ("p:hello"). Ambiguous names stay qualified — see
789        // `all_commands_ambiguous_plugin_commands_stay_qualified`.
790        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        // Qualified form still works (no regression).
812        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        // Two plugins both expose "foo". Bare resolution must NOT pick
821        // one — `find_plugin_command_unqualified` returns None for >1
822        // match, so we fall through to Unknown. Qualified forms still
823        // resolve.
824        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        // Acceptance: declarative cycler/text fields are represented in
975        // core data without plugin-specific knowledge.
976        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    // ---- Phase 8 slice 8A: lifecycle claims ----
995
996    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        // Two plugins both claim "capture"; first in the discovery
1088        // order (the vec we pass) should win.
1089        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        // Rebuild without the plugin: the claim must vanish.
1139        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    // ---- Phase 8 slice 8A.3: virtual toggle-key field injection ----
1145
1146    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}