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