Skip to main content

byob_plugins/
discover.rs

1//! Finding plugins on disk: folders with a `byob-plugin.json`, their `skills/*/SKILL.md`,
2//! `agents/*.md` and `commands/*.md`, and checks that declared scripts exist. Nothing here is
3//! fatal: a broken plugin becomes a [`LoadError`] and the rest still load.
4
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::LoadError;
10use crate::agents::{AgentDef, load_agents_dir};
11use crate::commands::{Command, load_commands_dir};
12use crate::manifest::{MANIFEST, Manifest, OLD_MANIFEST, ParseOptions, ToolKind, parse_manifest_str};
13use crate::skills::{Skill, load_skills_dir, read_capped};
14
15/// Biggest manifest we read.
16const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
17
18/// A loaded plugin.
19#[derive(Debug, Clone, PartialEq)]
20pub struct Plugin {
21    pub manifest: Manifest,
22    pub dir: PathBuf,
23    /// Ships with the app (may use `native` tools).
24    pub builtin: bool,
25    pub skills: Vec<Skill>,
26    /// Subagent definitions from its `agents/*.md` (Claude Code format).
27    pub agents: Vec<AgentDef>,
28    /// Slash commands from its `commands/*.md` (Claude Code format).
29    pub commands: Vec<Command>,
30    /// Non-fatal problems for the plugin's card (a bad skill, a missing script). The parts
31    /// that work are still used.
32    pub problems: Vec<String>,
33}
34
35impl Plugin {
36    pub fn id(&self) -> &str {
37        &self.manifest.id
38    }
39
40    /// The manifest's `platforms` include this system (or it has none).
41    pub fn supported_here(&self) -> bool {
42        self.manifest.platforms.as_ref().is_none_or(|p| p.iter().any(|x| x == current_platform()))
43    }
44
45    /// Names of the app's own tools this (built-in) plugin turns on.
46    pub fn native_tools(&self) -> Vec<&str> {
47        if !self.builtin {
48            return Vec::new();
49        }
50        self.manifest
51            .tools
52            .iter()
53            .filter_map(|t| match &t.kind {
54                ToolKind::Native(n) => Some(n.as_str()),
55                ToolKind::Script(_) => None,
56            })
57            .collect()
58    }
59}
60
61/// This system as Node's `process.platform` names it (what manifests use).
62pub fn current_platform() -> &'static str {
63    match std::env::consts::OS {
64        "macos" => "darwin",
65        "windows" => "win32",
66        other => other,
67    }
68}
69
70/// The manifest file in `dir`, if any (`byob-plugin.json`).
71pub fn manifest_path(dir: &Path) -> Option<PathBuf> {
72    let file = dir.join(MANIFEST);
73    if file.is_file() {
74        return Some(file);
75    }
76    if dir.join(OLD_MANIFEST).is_file() {
77        log::warn!("{}: rename {OLD_MANIFEST} to {MANIFEST}; the old name isn't read any more", dir.display());
78    }
79    None
80}
81
82/// Load the plugin in `dir`. `None` when there's no manifest there.
83pub fn load_plugin(dir: &Path, builtin: bool) -> Option<Result<Plugin, LoadError>> {
84    let file = manifest_path(dir)?;
85    let file_name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| MANIFEST.into());
86    let err = |message: String| LoadError { path: dir.to_path_buf(), message };
87    let text = match read_capped(&file, MAX_MANIFEST_BYTES) {
88        Ok(t) => t,
89        Err(e) => return Some(Err(err(e))),
90    };
91    let manifest = match parse_manifest_str(&text, ParseOptions { builtin, file: Some(&file_name) }) {
92        Ok(m) => m,
93        Err(e) => return Some(Err(err(e.to_string()))),
94    };
95    let mut problems = Vec::new();
96    for t in &manifest.tools {
97        if let ToolKind::Script(run) = &t.kind
98            && !dir.join(run).is_file()
99        {
100            problems.push(format!("{file_name}: {run} is missing"));
101        }
102    }
103    let (skills, skill_errors) = load_skills_dir(&dir.join("skills"));
104    problems.extend(skill_errors.into_iter().map(|e| e.message));
105    let (agents, agent_errors) = load_agents_dir(&dir.join("agents"));
106    problems.extend(agent_errors.into_iter().map(|e| e.to_string()));
107    let (commands, command_errors) = load_commands_dir(&dir.join("commands"));
108    problems.extend(command_errors.into_iter().map(|e| e.message));
109    Some(Ok(Plugin { manifest, dir: dir.to_path_buf(), builtin, skills, agents, commands, problems }))
110}
111
112/// Where to look for plugins.
113#[derive(Debug, Clone, Default)]
114pub struct PluginSources {
115    /// Folders whose subfolders are built-in plugins.
116    pub builtin_roots: Vec<PathBuf>,
117    /// Folders whose subfolders are installed plugins, e.g. `<config>/plugins`.
118    pub roots: Vec<PathBuf>,
119    /// Plugin folders linked one by one (a plugin under development).
120    pub linked: Vec<PathBuf>,
121}
122
123/// Everything found.
124#[derive(Debug, Clone, Default)]
125pub struct Discovery {
126    /// In load order: built-in, installed, linked. Ids are unique.
127    pub plugins: Vec<Plugin>,
128    /// Plugins that couldn't load at all.
129    pub errors: Vec<LoadError>,
130}
131
132impl Discovery {
133    /// Plugins that apply to this system.
134    pub fn active(&self) -> impl Iterator<Item = &Plugin> {
135        self.plugins.iter().filter(|p| p.supported_here())
136    }
137
138    /// Skills of active plugins; the first plugin to define a name wins.
139    pub fn skills(&self) -> Vec<Skill> {
140        let mut seen = BTreeMap::new();
141        for s in self.active().flat_map(|p| &p.skills) {
142            seen.entry(s.name.clone()).or_insert_with(|| s.clone());
143        }
144        seen.into_values().collect()
145    }
146
147    /// Subagents of active plugins; the first plugin to define a name wins.
148    pub fn agents(&self) -> Vec<AgentDef> {
149        crate::agents::merge_agents(self.active().map(|p| p.agents.clone()))
150    }
151
152    /// The `prompt` lines of active plugins, one per line.
153    pub fn prompt(&self) -> String {
154        self.active().filter_map(|p| p.manifest.prompt.as_deref()).collect::<Vec<_>>().join("\n")
155    }
156}
157
158/// Non-hidden subfolders of `dir`, sorted. Missing or unreadable → empty.
159pub(crate) fn subdirs(dir: &Path) -> Vec<PathBuf> {
160    let Ok(rd) = fs::read_dir(dir) else { return Vec::new() };
161    let mut out: Vec<PathBuf> = rd.flatten().filter(|e| !e.file_name().to_string_lossy().starts_with('.')).map(|e| e.path()).filter(|p| p.is_dir()).collect();
162    out.sort();
163    out
164}
165
166/// Scan every source. Folders without a manifest are skipped silently.
167pub fn discover(sources: &PluginSources) -> Discovery {
168    let mut d = Discovery::default();
169    let mut ids: BTreeMap<String, PathBuf> = BTreeMap::new();
170    let candidates = sources
171        .builtin_roots
172        .iter()
173        .flat_map(|r| subdirs(r).into_iter().map(|p| (p, true)))
174        .chain(sources.roots.iter().flat_map(|r| subdirs(r).into_iter().map(|p| (p, false))))
175        .chain(sources.linked.iter().map(|p| (p.clone(), false)));
176    for (dir, builtin) in candidates {
177        match load_plugin(&dir, builtin) {
178            None => {}
179            Some(Err(e)) => d.errors.push(e),
180            Some(Ok(p)) => {
181                if let Some(first) = ids.get(p.id()) {
182                    d.errors.push(LoadError { path: dir, message: format!("id \"{}\" is already used by {}", p.id(), first.display()) });
183                } else {
184                    ids.insert(p.id().to_string(), dir);
185                    d.plugins.push(p);
186                }
187            }
188        }
189    }
190    d
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn platform_names_match_node() {
199        let p = current_platform();
200        assert!(!p.is_empty() && p != "macos" && p != "windows");
201    }
202
203    #[test]
204    fn plugins_bring_agents() {
205        let root = tempfile::tempdir().unwrap();
206        let dir = root.path().join("helpers");
207        fs::create_dir_all(dir.join("agents")).unwrap();
208        fs::write(dir.join(MANIFEST), r#"{"id":"helpers","name":"Helpers","version":"1.0.0"}"#).unwrap();
209        fs::write(dir.join("agents/explorer.md"), "---\nname: explorer\ndescription: Finds things\ntools: Read, Glob\n---\nExplore.").unwrap();
210        fs::write(dir.join("agents/broken.md"), "---\nname: broken\n---\n").unwrap();
211        let d = discover(&PluginSources { roots: vec![root.path().to_path_buf()], ..Default::default() });
212        assert!(d.errors.is_empty(), "{:?}", d.errors);
213        let agents = d.agents();
214        assert_eq!(agents.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["explorer"]);
215        assert_eq!(d.plugins[0].problems.len(), 1, "the broken agent is a problem, not an error");
216    }
217
218    #[test]
219    fn missing_roots_are_empty() {
220        let d = discover(&PluginSources { roots: vec![PathBuf::from("/definitely/not/here")], ..Default::default() });
221        assert!(d.plugins.is_empty() && d.errors.is_empty());
222    }
223}