Skip to main content

zeroclaw_plugins/
host.rs

1//! Plugin host: discovery, loading, lifecycle management.
2
3use super::error::PluginError;
4use super::signature::{self, SignatureMode, VerificationResult};
5use super::{PluginCapability, PluginInfo, PluginManifest};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9/// Subdirectory inside a skill-capable plugin that holds individual skills.
10const SKILLS_SUBDIR: &str = "skills";
11
12/// Manages the lifecycle of WASM plugins.
13pub struct PluginHost {
14    plugins_dir: PathBuf,
15    loaded: HashMap<String, LoadedPlugin>,
16    signature_mode: SignatureMode,
17    trusted_publisher_keys: Vec<String>,
18}
19
20struct LoadedPlugin {
21    manifest: PluginManifest,
22    plugin_dir: PathBuf,
23    /// Resolved path to the WASM file. `None` for skill-only plugins.
24    wasm_path: Option<PathBuf>,
25    #[allow(dead_code)]
26    verification: VerificationResult,
27}
28
29impl PluginHost {
30    /// Create a new plugin host with the given plugins directory.
31    pub fn new(workspace_dir: &Path) -> Result<Self, PluginError> {
32        Self::with_security(workspace_dir, SignatureMode::Disabled, Vec::new())
33    }
34
35    /// Create a new plugin host with signature verification settings.
36    pub fn with_security(
37        workspace_dir: &Path,
38        signature_mode: SignatureMode,
39        trusted_publisher_keys: Vec<String>,
40    ) -> Result<Self, PluginError> {
41        let plugins_dir = workspace_dir.join("plugins");
42        if !plugins_dir.exists() {
43            std::fs::create_dir_all(&plugins_dir)?;
44        }
45
46        let mut host = Self {
47            plugins_dir,
48            loaded: HashMap::new(),
49            signature_mode,
50            trusted_publisher_keys,
51        };
52
53        host.discover()?;
54        Ok(host)
55    }
56
57    /// Parse the signature mode string from config into a `SignatureMode`.
58    pub fn parse_signature_mode(mode: &str) -> SignatureMode {
59        match mode.to_lowercase().as_str() {
60            "strict" => SignatureMode::Strict,
61            "permissive" => SignatureMode::Permissive,
62            _ => SignatureMode::Disabled,
63        }
64    }
65
66    /// Discover plugins in the plugins directory.
67    fn discover(&mut self) -> Result<(), PluginError> {
68        if !self.plugins_dir.exists() {
69            return Ok(());
70        }
71
72        let entries = std::fs::read_dir(&self.plugins_dir)?;
73        for entry in entries.flatten() {
74            let path = entry.path();
75            if path.is_dir() {
76                let manifest_path = path.join("manifest.toml");
77                if manifest_path.exists()
78                    && let Ok(manifest) = self.load_manifest(&manifest_path)
79                {
80                    if let Err(e) = validate_manifest_shape(&manifest, &path) {
81                        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"plugin": path.display().to_string(), "error": format!("{}", e)})), "skipping plugin due to invalid manifest shape");
82                        continue;
83                    }
84
85                    // Verify plugin signature
86                    let manifest_toml = std::fs::read_to_string(&manifest_path).unwrap_or_default();
87                    match self.verify_plugin_signature(&manifest.name, &manifest_toml, &manifest) {
88                        Ok(verification) => {
89                            let wasm_path = manifest.wasm_path.as_deref().map(|p| path.join(p));
90                            self.loaded.insert(
91                                manifest.name.clone(),
92                                LoadedPlugin {
93                                    manifest,
94                                    plugin_dir: path.clone(),
95                                    wasm_path,
96                                    verification,
97                                },
98                            );
99                        }
100                        Err(e) => {
101                            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"plugin": path.display().to_string(), "error": format!("{}", e)})), "skipping plugin due to signature verification failure");
102                        }
103                    }
104                }
105            }
106        }
107
108        Ok(())
109    }
110
111    fn load_manifest(&self, path: &Path) -> Result<PluginManifest, PluginError> {
112        let content = std::fs::read_to_string(path)?;
113        let manifest: PluginManifest = toml::from_str(&content)?;
114        Ok(manifest)
115    }
116
117    /// Verify a plugin's signature against configured policy.
118    fn verify_plugin_signature(
119        &self,
120        name: &str,
121        manifest_toml: &str,
122        manifest: &PluginManifest,
123    ) -> Result<VerificationResult, PluginError> {
124        signature::enforce_signature_policy(
125            name,
126            manifest_toml,
127            manifest.signature.as_deref(),
128            manifest.publisher_key.as_deref(),
129            &self.trusted_publisher_keys,
130            self.signature_mode,
131        )
132    }
133
134    /// List all discovered plugins.
135    pub fn list_plugins(&self) -> Vec<PluginInfo> {
136        self.loaded.values().map(plugin_info_from_loaded).collect()
137    }
138
139    /// Get info about a specific plugin.
140    pub fn get_plugin(&self, name: &str) -> Option<PluginInfo> {
141        self.loaded.get(name).map(plugin_info_from_loaded)
142    }
143
144    /// Install a plugin from a directory path.
145    pub fn install(&mut self, source: &str) -> Result<(), PluginError> {
146        let source_path = PathBuf::from(source);
147        let manifest_path = if source_path.is_dir() {
148            source_path.join("manifest.toml")
149        } else {
150            source_path.clone()
151        };
152
153        if !manifest_path.exists() {
154            return Err(PluginError::NotFound(format!(
155                "manifest.toml not found at {}",
156                manifest_path.display()
157            )));
158        }
159
160        let manifest = self.load_manifest(&manifest_path)?;
161        let source_dir = manifest_path
162            .parent()
163            .ok_or_else(|| PluginError::InvalidManifest("no parent directory".into()))?;
164
165        validate_manifest_shape(&manifest, source_dir)?;
166
167        let wasm_source = manifest.wasm_path.as_deref().map(|p| source_dir.join(p));
168        if let Some(ref wasm_source) = wasm_source
169            && !wasm_source.exists()
170        {
171            return Err(PluginError::NotFound(format!(
172                "WASM file not found: {}",
173                wasm_source.display()
174            )));
175        }
176
177        if self.loaded.contains_key(&manifest.name) {
178            return Err(PluginError::AlreadyLoaded(manifest.name));
179        }
180
181        // Verify plugin signature before installing
182        let manifest_toml = std::fs::read_to_string(&manifest_path)?;
183        let verification =
184            self.verify_plugin_signature(&manifest.name, &manifest_toml, &manifest)?;
185
186        // Copy plugin to plugins directory
187        let dest_dir = self.plugins_dir.join(&manifest.name);
188        std::fs::create_dir_all(&dest_dir)?;
189
190        // Copy manifest
191        std::fs::copy(&manifest_path, dest_dir.join("manifest.toml"))?;
192
193        // Copy WASM file (if any)
194        let wasm_dest = if let (Some(rel), Some(src)) = (manifest.wasm_path.as_deref(), wasm_source)
195        {
196            let dest = dest_dir.join(rel);
197            if let Some(parent) = dest.parent() {
198                std::fs::create_dir_all(parent)?;
199            }
200            std::fs::copy(&src, &dest)?;
201            Some(dest)
202        } else {
203            None
204        };
205
206        // Copy skills/ subtree for skill-capable plugins.
207        if manifest.capabilities.contains(&PluginCapability::Skill) {
208            let src_skills = source_dir.join(SKILLS_SUBDIR);
209            let dest_skills = dest_dir.join(SKILLS_SUBDIR);
210            if src_skills.is_dir() {
211                copy_dir_recursive(&src_skills, &dest_skills)?;
212            }
213        }
214
215        self.loaded.insert(
216            manifest.name.clone(),
217            LoadedPlugin {
218                manifest,
219                plugin_dir: dest_dir,
220                wasm_path: wasm_dest,
221                verification,
222            },
223        );
224
225        Ok(())
226    }
227
228    /// Remove a plugin by name.
229    pub fn remove(&mut self, name: &str) -> Result<(), PluginError> {
230        if self.loaded.remove(name).is_none() {
231            return Err(PluginError::NotFound(name.to_string()));
232        }
233
234        let plugin_dir = self.plugins_dir.join(name);
235        if plugin_dir.exists() {
236            std::fs::remove_dir_all(plugin_dir)?;
237        }
238
239        Ok(())
240    }
241
242    /// Get tool-capable plugins.
243    pub fn tool_plugins(&self) -> Vec<&PluginManifest> {
244        self.loaded
245            .values()
246            .filter(|p| p.manifest.capabilities.contains(&PluginCapability::Tool))
247            .map(|p| &p.manifest)
248            .collect()
249    }
250
251    /// Get tool-capable plugins with their resolved WASM file paths.
252    /// Returns `(manifest, resolved_wasm_path)` tuples for building `WasmTool`s.
253    /// Tool plugins without a `wasm_path` are skipped.
254    pub fn tool_plugin_details(&self) -> Vec<(&PluginManifest, &Path)> {
255        self.loaded
256            .values()
257            .filter(|p| p.manifest.capabilities.contains(&PluginCapability::Tool))
258            .filter_map(|p| p.wasm_path.as_deref().map(|wp| (&p.manifest, wp)))
259            .collect()
260    }
261
262    /// Get channel-capable plugins.
263    pub fn channel_plugins(&self) -> Vec<&PluginManifest> {
264        self.loaded
265            .values()
266            .filter(|p| p.manifest.capabilities.contains(&PluginCapability::Channel))
267            .map(|p| &p.manifest)
268            .collect()
269    }
270
271    /// Get skill-capable plugins.
272    pub fn skill_plugins(&self) -> Vec<&PluginManifest> {
273        self.loaded
274            .values()
275            .filter(|p| p.manifest.capabilities.contains(&PluginCapability::Skill))
276            .map(|p| &p.manifest)
277            .collect()
278    }
279
280    /// Get skill-capable plugins paired with the absolute path to their `skills/`
281    /// directory. Plugins without an existing `skills/` subdirectory are skipped.
282    ///
283    /// Callers (typically the runtime skill loader) should pass each `skills_dir`
284    /// to `load_skills_from_directory` and then re-namespace the resulting skill
285    /// names as `plugin:<plugin>/<skill>` to avoid collisions with user skills.
286    pub fn skill_plugin_details(&self) -> Vec<(&PluginManifest, PathBuf)> {
287        self.loaded
288            .values()
289            .filter(|p| p.manifest.capabilities.contains(&PluginCapability::Skill))
290            .filter_map(|p| {
291                let skills_dir = p.plugin_dir.join(SKILLS_SUBDIR);
292                if skills_dir.is_dir() {
293                    Some((&p.manifest, skills_dir))
294                } else {
295                    None
296                }
297            })
298            .collect()
299    }
300
301    /// Returns the plugins directory path.
302    pub fn plugins_dir(&self) -> &Path {
303        &self.plugins_dir
304    }
305}
306
307fn plugin_info_from_loaded(p: &LoadedPlugin) -> PluginInfo {
308    let loaded = match &p.wasm_path {
309        Some(path) => path.exists(),
310        // Skill-only plugins are "loaded" if their skills/ subtree exists.
311        None => p.plugin_dir.join(SKILLS_SUBDIR).is_dir(),
312    };
313    PluginInfo {
314        name: p.manifest.name.clone(),
315        version: p.manifest.version.clone(),
316        description: p.manifest.description.clone(),
317        capabilities: p.manifest.capabilities.clone(),
318        permissions: p.manifest.permissions.clone(),
319        wasm_path: p.wasm_path.clone(),
320        loaded,
321    }
322}
323
324/// Validate manifest shape: `wasm_path` is required unless the plugin's only
325/// capability is `Skill`, and `Skill` plugins must include a `skills/` directory
326/// where every subdirectory holds a `SKILL.md` with the agentskills.io required
327/// frontmatter fields (`name`, `description`).
328fn validate_manifest_shape(
329    manifest: &PluginManifest,
330    plugin_dir: &Path,
331) -> Result<(), PluginError> {
332    if manifest.capabilities.is_empty() {
333        return Err(PluginError::InvalidManifest(format!(
334            "plugin '{}' declares no capabilities",
335            manifest.name
336        )));
337    }
338
339    let is_skill_only =
340        manifest.capabilities.len() == 1 && manifest.capabilities[0] == PluginCapability::Skill;
341
342    if !is_skill_only && manifest.wasm_path.is_none() {
343        return Err(PluginError::InvalidManifest(format!(
344            "plugin '{}' is missing required `wasm_path` for non-skill capabilities",
345            manifest.name
346        )));
347    }
348
349    if manifest.capabilities.contains(&PluginCapability::Skill) {
350        validate_skill_bundle(&manifest.name, plugin_dir)?;
351    }
352
353    Ok(())
354}
355
356/// Validate a skill bundle: `<plugin_dir>/skills/` must exist, contain at least
357/// one subdirectory, and each subdirectory must hold a `SKILL.md` whose YAML
358/// frontmatter declares the agentskills.io-required `name` and `description`.
359fn validate_skill_bundle(plugin_name: &str, plugin_dir: &Path) -> Result<(), PluginError> {
360    let skills_dir = plugin_dir.join(SKILLS_SUBDIR);
361    if !skills_dir.is_dir() {
362        return Err(PluginError::InvalidManifest(format!(
363            "skill plugin '{}' is missing `skills/` directory at {}",
364            plugin_name,
365            skills_dir.display()
366        )));
367    }
368
369    let mut found_any = false;
370    for entry in std::fs::read_dir(&skills_dir)? {
371        let entry = entry?;
372        let path = entry.path();
373        if !path.is_dir() {
374            continue;
375        }
376        found_any = true;
377        let skill_md = path.join("SKILL.md");
378        if !skill_md.is_file() {
379            return Err(PluginError::InvalidManifest(format!(
380                "skill plugin '{}' subdirectory '{}' is missing SKILL.md",
381                plugin_name,
382                path.file_name().and_then(|n| n.to_str()).unwrap_or("?")
383            )));
384        }
385        validate_skill_md_frontmatter(plugin_name, &skill_md)?;
386    }
387
388    if !found_any {
389        return Err(PluginError::InvalidManifest(format!(
390            "skill plugin '{}' has empty `skills/` directory",
391            plugin_name
392        )));
393    }
394
395    Ok(())
396}
397
398fn validate_skill_md_frontmatter(plugin_name: &str, skill_md: &Path) -> Result<(), PluginError> {
399    let content = std::fs::read_to_string(skill_md)?;
400    let normalized = content.replace("\r\n", "\n");
401    let rest = normalized.strip_prefix("---\n").ok_or_else(|| {
402        PluginError::InvalidManifest(format!(
403            "skill plugin '{}': {} is missing YAML frontmatter",
404            plugin_name,
405            skill_md.display()
406        ))
407    })?;
408    let frontmatter = if let Some(idx) = rest.find("\n---\n") {
409        &rest[..idx]
410    } else if let Some(stripped) = rest.strip_suffix("\n---") {
411        stripped
412    } else {
413        return Err(PluginError::InvalidManifest(format!(
414            "skill plugin '{}': {} has unterminated frontmatter",
415            plugin_name,
416            skill_md.display()
417        )));
418    };
419
420    let mut has_name = false;
421    let mut has_description = false;
422    for line in frontmatter.lines() {
423        let trimmed = line.trim_start();
424        if let Some((key, value)) = trimmed.split_once(':') {
425            let key = key.trim();
426            let value = value.trim();
427            // Treat block-scalar markers as a non-empty value once a continuation
428            // line is present; the simple check below is sufficient because the
429            // runtime loader parses the actual content.
430            let has_value = !value.is_empty();
431            match key {
432                "name" if has_value => has_name = true,
433                "description" if has_value => has_description = true,
434                _ => {}
435            }
436        }
437    }
438
439    if !has_name || !has_description {
440        return Err(PluginError::InvalidManifest(format!(
441            "skill plugin '{}': {} frontmatter must declare `name` and `description`",
442            plugin_name,
443            skill_md.display()
444        )));
445    }
446
447    Ok(())
448}
449
450fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), PluginError> {
451    std::fs::create_dir_all(dst)?;
452    for entry in std::fs::read_dir(src)? {
453        let entry = entry?;
454        let from = entry.path();
455        let to = dst.join(entry.file_name());
456        let ft = entry.file_type()?;
457        if ft.is_dir() {
458            copy_dir_recursive(&from, &to)?;
459        } else if ft.is_file() {
460            std::fs::copy(&from, &to)?;
461        }
462        // Symlinks intentionally skipped to match the runtime skill auditor.
463    }
464    Ok(())
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use tempfile::tempdir;
471
472    #[test]
473    fn test_empty_plugin_dir() {
474        let dir = tempdir().unwrap();
475        let host = PluginHost::new(dir.path()).unwrap();
476        assert!(host.list_plugins().is_empty());
477    }
478
479    #[test]
480    fn test_discover_with_manifest() {
481        let dir = tempdir().unwrap();
482        let plugin_dir = dir.path().join("plugins").join("test-plugin");
483        std::fs::create_dir_all(&plugin_dir).unwrap();
484
485        std::fs::write(
486            plugin_dir.join("manifest.toml"),
487            r#"
488name = "test-plugin"
489version = "0.1.0"
490description = "A test plugin"
491wasm_path = "plugin.wasm"
492capabilities = ["tool"]
493permissions = []
494"#,
495        )
496        .unwrap();
497
498        let host = PluginHost::new(dir.path()).unwrap();
499        let plugins = host.list_plugins();
500        assert_eq!(plugins.len(), 1);
501        assert_eq!(plugins[0].name, "test-plugin");
502    }
503
504    #[test]
505    fn test_tool_plugins_filter() {
506        let dir = tempdir().unwrap();
507        let plugins_base = dir.path().join("plugins");
508
509        // Tool plugin
510        let tool_dir = plugins_base.join("my-tool");
511        std::fs::create_dir_all(&tool_dir).unwrap();
512        std::fs::write(
513            tool_dir.join("manifest.toml"),
514            r#"
515name = "my-tool"
516version = "0.1.0"
517wasm_path = "tool.wasm"
518capabilities = ["tool"]
519"#,
520        )
521        .unwrap();
522
523        // Channel plugin
524        let chan_dir = plugins_base.join("my-channel");
525        std::fs::create_dir_all(&chan_dir).unwrap();
526        std::fs::write(
527            chan_dir.join("manifest.toml"),
528            r#"
529name = "my-channel"
530version = "0.1.0"
531wasm_path = "channel.wasm"
532capabilities = ["channel"]
533"#,
534        )
535        .unwrap();
536
537        let host = PluginHost::new(dir.path()).unwrap();
538        assert_eq!(host.list_plugins().len(), 2);
539        assert_eq!(host.tool_plugins().len(), 1);
540        assert_eq!(host.channel_plugins().len(), 1);
541        assert_eq!(host.tool_plugins()[0].name, "my-tool");
542    }
543
544    #[test]
545    fn test_get_plugin() {
546        let dir = tempdir().unwrap();
547        let plugin_dir = dir.path().join("plugins").join("lookup-test");
548        std::fs::create_dir_all(&plugin_dir).unwrap();
549        std::fs::write(
550            plugin_dir.join("manifest.toml"),
551            r#"
552name = "lookup-test"
553version = "1.0.0"
554description = "Lookup test"
555wasm_path = "plugin.wasm"
556capabilities = ["tool"]
557"#,
558        )
559        .unwrap();
560
561        let host = PluginHost::new(dir.path()).unwrap();
562        assert!(host.get_plugin("lookup-test").is_some());
563        assert!(host.get_plugin("nonexistent").is_none());
564    }
565
566    #[test]
567    fn test_remove_plugin() {
568        let dir = tempdir().unwrap();
569        let plugin_dir = dir.path().join("plugins").join("removable");
570        std::fs::create_dir_all(&plugin_dir).unwrap();
571        std::fs::write(
572            plugin_dir.join("manifest.toml"),
573            r#"
574name = "removable"
575version = "0.1.0"
576wasm_path = "plugin.wasm"
577capabilities = ["tool"]
578"#,
579        )
580        .unwrap();
581
582        let mut host = PluginHost::new(dir.path()).unwrap();
583        assert_eq!(host.list_plugins().len(), 1);
584
585        host.remove("removable").unwrap();
586        assert!(host.list_plugins().is_empty());
587        assert!(!plugin_dir.exists());
588    }
589
590    #[test]
591    fn test_remove_nonexistent_returns_error() {
592        let dir = tempdir().unwrap();
593        let mut host = PluginHost::new(dir.path()).unwrap();
594        assert!(host.remove("ghost").is_err());
595    }
596
597    fn write_skill_md(path: &Path, name: &str, description: &str) {
598        std::fs::write(
599            path,
600            format!(
601                "---\nname: {name}\ndescription: {description}\n---\n\nBody content for {name}.\n"
602            ),
603        )
604        .unwrap();
605    }
606
607    fn write_skill_bundle_plugin(plugins_base: &Path, plugin_name: &str, skill_names: &[&str]) {
608        let plugin_dir = plugins_base.join(plugin_name);
609        std::fs::create_dir_all(&plugin_dir).unwrap();
610        std::fs::write(
611            plugin_dir.join("manifest.toml"),
612            format!("name = \"{plugin_name}\"\nversion = \"0.1.0\"\ncapabilities = [\"skill\"]\n"),
613        )
614        .unwrap();
615        let skills_dir = plugin_dir.join("skills");
616        std::fs::create_dir_all(&skills_dir).unwrap();
617        for skill in skill_names {
618            let sd = skills_dir.join(skill);
619            std::fs::create_dir_all(&sd).unwrap();
620            write_skill_md(
621                &sd.join("SKILL.md"),
622                skill,
623                &format!("Description for {skill}"),
624            );
625        }
626    }
627
628    #[test]
629    fn test_skill_only_plugin_discovers_without_wasm_path() {
630        let dir = tempdir().unwrap();
631        let plugins_base = dir.path().join("plugins");
632        write_skill_bundle_plugin(
633            &plugins_base,
634            "my-toolkit",
635            &["design-review", "code-review"],
636        );
637
638        let host = PluginHost::new(dir.path()).unwrap();
639        let plugins = host.list_plugins();
640        assert_eq!(plugins.len(), 1);
641        assert_eq!(plugins[0].name, "my-toolkit");
642        assert!(plugins[0].wasm_path.is_none());
643        assert!(plugins[0].loaded);
644
645        let skill_plugins = host.skill_plugins();
646        assert_eq!(skill_plugins.len(), 1);
647
648        let details = host.skill_plugin_details();
649        assert_eq!(details.len(), 1);
650        assert_eq!(details[0].0.name, "my-toolkit");
651        assert!(details[0].1.ends_with("skills"));
652    }
653
654    #[test]
655    fn test_non_skill_plugin_without_wasm_path_is_rejected() {
656        let dir = tempdir().unwrap();
657        let plugin_dir = dir.path().join("plugins").join("broken");
658        std::fs::create_dir_all(&plugin_dir).unwrap();
659        std::fs::write(
660            plugin_dir.join("manifest.toml"),
661            "name = \"broken\"\nversion = \"0.1.0\"\ncapabilities = [\"tool\"]\n",
662        )
663        .unwrap();
664
665        let host = PluginHost::new(dir.path()).unwrap();
666        // Discovery skips invalid manifests rather than failing.
667        assert!(host.list_plugins().is_empty());
668    }
669
670    #[test]
671    fn test_skill_plugin_missing_skills_dir_is_rejected() {
672        let dir = tempdir().unwrap();
673        let plugin_dir = dir.path().join("plugins").join("empty-skills");
674        std::fs::create_dir_all(&plugin_dir).unwrap();
675        std::fs::write(
676            plugin_dir.join("manifest.toml"),
677            "name = \"empty-skills\"\nversion = \"0.1.0\"\ncapabilities = [\"skill\"]\n",
678        )
679        .unwrap();
680
681        let host = PluginHost::new(dir.path()).unwrap();
682        assert!(host.list_plugins().is_empty());
683    }
684
685    #[test]
686    fn test_skill_plugin_rejects_skill_without_required_frontmatter() {
687        let dir = tempdir().unwrap();
688        let plugin_dir = dir.path().join("plugins").join("bad-frontmatter");
689        std::fs::create_dir_all(&plugin_dir).unwrap();
690        std::fs::write(
691            plugin_dir.join("manifest.toml"),
692            "name = \"bad-frontmatter\"\nversion = \"0.1.0\"\ncapabilities = [\"skill\"]\n",
693        )
694        .unwrap();
695        let skill_dir = plugin_dir.join("skills").join("oops");
696        std::fs::create_dir_all(&skill_dir).unwrap();
697        // Missing description field
698        std::fs::write(skill_dir.join("SKILL.md"), "---\nname: oops\n---\n\nbody\n").unwrap();
699
700        let host = PluginHost::new(dir.path()).unwrap();
701        assert!(host.list_plugins().is_empty());
702    }
703
704    #[test]
705    fn test_skill_plugin_rejects_skill_without_skill_md() {
706        let dir = tempdir().unwrap();
707        let plugin_dir = dir.path().join("plugins").join("missing-md");
708        std::fs::create_dir_all(&plugin_dir).unwrap();
709        std::fs::write(
710            plugin_dir.join("manifest.toml"),
711            "name = \"missing-md\"\nversion = \"0.1.0\"\ncapabilities = [\"skill\"]\n",
712        )
713        .unwrap();
714        let skill_dir = plugin_dir.join("skills").join("orphan");
715        std::fs::create_dir_all(&skill_dir).unwrap();
716        std::fs::write(skill_dir.join("notes.md"), "no SKILL.md here").unwrap();
717
718        let host = PluginHost::new(dir.path()).unwrap();
719        assert!(host.list_plugins().is_empty());
720    }
721
722    #[test]
723    fn test_skill_plugin_does_not_appear_in_tool_or_channel_lists() {
724        let dir = tempdir().unwrap();
725        let plugins_base = dir.path().join("plugins");
726        write_skill_bundle_plugin(&plugins_base, "skill-bundle", &["one"]);
727
728        let host = PluginHost::new(dir.path()).unwrap();
729        assert!(host.tool_plugins().is_empty());
730        assert!(host.tool_plugin_details().is_empty());
731        assert!(host.channel_plugins().is_empty());
732        assert_eq!(host.skill_plugins().len(), 1);
733    }
734}