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};
8use std::sync::Arc;
9
10use crate::LoadError;
11use crate::agents::{AgentDef, load_agents_dir, parse_agent};
12use crate::commands::{Command, load_commands_dir, parse_command};
13use crate::manifest::{MANIFEST, Manifest, OLD_MANIFEST, ParseOptions, ToolKind, parse_manifest_str};
14use crate::skills::{Skill, load_skills_dir, parse_skill, read_capped};
15
16/// Biggest manifest we read.
17const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
18
19/// A loaded plugin.
20#[derive(Debug, Clone, PartialEq)]
21pub struct Plugin {
22    pub manifest: Manifest,
23    pub dir: PathBuf,
24    /// Ships with the app (may use `native` tools).
25    pub builtin: bool,
26    pub skills: Vec<Skill>,
27    /// Subagent definitions from its `agents/*.md` (Claude Code format).
28    pub agents: Vec<AgentDef>,
29    /// Slash commands from its `commands/*.md` (Claude Code format).
30    pub commands: Vec<Command>,
31    /// Non-fatal problems for the plugin's card (a bad skill, a missing script). The parts
32    /// that work are still used.
33    pub problems: Vec<String>,
34    /// Its files when they are in the binary (a bundled plugin), by path; `None`: on disk
35    /// in `dir`.
36    pub embedded: Option<Arc<BTreeMap<String, String>>>,
37}
38
39/// Biggest plugin file [`Plugin::read`] returns.
40pub const MAX_PLUGIN_FILE_BYTES: u64 = 256 * 1024;
41
42/// Where bundled plugins say they are (for display; nothing is on disk there).
43pub const BUNDLED_DIR: &str = "(built into byob)";
44
45/// A plugin whose files are in the binary: its id and its files by path (`/`-separated).
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct Embedded {
48    pub id: String,
49    pub files: BTreeMap<String, String>,
50}
51
52impl Plugin {
53    pub fn id(&self) -> &str {
54        &self.manifest.id
55    }
56
57    /// The text of its file `rel` (a path inside the plugin, checked): from the binary for
58    /// a bundled plugin, else from its folder; capped at [`MAX_PLUGIN_FILE_BYTES`].
59    pub fn read(&self, rel: &str) -> Result<String, String> {
60        let rel = rel.trim().trim_start_matches("./");
61        if rel.is_empty() || !crate::manifest::path_inside(rel) {
62            return Err(format!("{rel:?} must be a path inside the plugin folder"));
63        }
64        match &self.embedded {
65            Some(files) => files.get(rel).cloned().ok_or_else(|| format!("{rel} isn't a file of plugin {}", self.id())),
66            None => read_capped(&self.dir.join(rel), MAX_PLUGIN_FILE_BYTES),
67        }
68    }
69
70    /// The manifest's `platforms` include this system (or it has none).
71    pub fn supported_here(&self) -> bool {
72        self.manifest.platforms.as_ref().is_none_or(|p| p.iter().any(|x| x == current_platform()))
73    }
74
75    /// Names of the app's own (in-tree) tools this plugin turns on.
76    pub fn native_tools(&self) -> Vec<&str> {
77        self.manifest
78            .tools
79            .iter()
80            .filter_map(|t| match &t.kind {
81                ToolKind::Native(n) => Some(n.as_str()),
82                ToolKind::Script(_) | ToolKind::Backend => None,
83            })
84            .collect()
85    }
86}
87
88/// This system as Node's `process.platform` names it (what manifests use).
89pub fn current_platform() -> &'static str {
90    match std::env::consts::OS {
91        "macos" => "darwin",
92        "windows" => "win32",
93        other => other,
94    }
95}
96
97/// The manifest file in `dir`, if any (`byob-plugin.json`).
98pub fn manifest_path(dir: &Path) -> Option<PathBuf> {
99    let file = dir.join(MANIFEST);
100    if file.is_file() {
101        return Some(file);
102    }
103    if dir.join(OLD_MANIFEST).is_file() {
104        log::warn!("{}: rename {OLD_MANIFEST} to {MANIFEST}; the old name isn't read any more", dir.display());
105    }
106    None
107}
108
109/// Load the plugin in `dir`. `None` when there's no manifest there.
110pub fn load_plugin(dir: &Path, builtin: bool) -> Option<Result<Plugin, LoadError>> {
111    let file = manifest_path(dir)?;
112    let file_name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| MANIFEST.into());
113    let err = |message: String| LoadError { path: dir.to_path_buf(), message };
114    let text = match read_capped(&file, MAX_MANIFEST_BYTES) {
115        Ok(t) => t,
116        Err(e) => return Some(Err(err(e))),
117    };
118    let manifest = match parse_manifest_str(&text, ParseOptions { builtin, file: Some(&file_name) }) {
119        Ok(m) => m,
120        Err(e) => return Some(Err(err(e.to_string()))),
121    };
122    let mut problems = Vec::new();
123    for t in &manifest.tools {
124        if let ToolKind::Script(run) = &t.kind
125            && !dir.join(run).is_file()
126        {
127            problems.push(format!("{file_name}: {run} is missing"));
128        }
129    }
130    if let Some(crate::manifest::BackendDecl { run: crate::manifest::BackendRun::Python(script), .. }) = &manifest.backend
131        && !dir.join(script).is_file()
132    {
133        problems.push(format!("{file_name}: backend {script} is missing"));
134    }
135    let (skills, skill_errors) = load_skills_dir(&dir.join("skills"));
136    problems.extend(skill_errors.into_iter().map(|e| e.message));
137    let (agents, agent_errors) = load_agents_dir(&dir.join("agents"));
138    problems.extend(agent_errors.into_iter().map(|e| e.to_string()));
139    let (commands, command_errors) = load_commands_dir(&dir.join("commands"));
140    problems.extend(command_errors.into_iter().map(|e| e.message));
141    Some(Ok(Plugin { manifest, dir: dir.to_path_buf(), builtin, skills, agents, commands, problems, embedded: None }))
142}
143
144/// Load a plugin whose files are in the binary (see [`Embedded`]). Its `dir` is
145/// `(built into byob)/<id>`, for display only, unless it runs processes (a backend or script
146/// tools) and `materialize_to` is given: then its files are written to a versioned,
147/// read-only folder there ([`crate::materialize`]) and `dir` is that folder. Without one,
148/// scripts and backends are problems (fork the plugin to give them files).
149pub fn load_embedded(e: &Embedded, materialize_to: Option<&Path>) -> Result<Plugin, LoadError> {
150    let mut dir = Path::new(BUNDLED_DIR).join(&e.id);
151    let err = |message: String| LoadError { path: dir.clone(), message };
152    let text = e.files.get(MANIFEST).ok_or_else(|| err(format!("{MANIFEST} is missing")))?;
153    let manifest = parse_manifest_str(text, ParseOptions { builtin: true, file: Some(MANIFEST) }).map_err(|x| err(x.to_string()))?;
154    if manifest.id != e.id {
155        return Err(err(format!("{MANIFEST}: id {:?} isn't the folder's name {:?}", manifest.id, e.id)));
156    }
157    let mut problems = Vec::new();
158    let scripts: Vec<&str> = manifest
159        .tools
160        .iter()
161        .filter_map(|t| match &t.kind {
162            ToolKind::Script(run) => Some(run.as_str()),
163            _ => None,
164        })
165        .collect();
166    // a native backend is in-tree code: it needs no files on disk
167    let process_backend = manifest.backend.as_ref().is_some_and(|b| !matches!(b.run, crate::manifest::BackendRun::Native(_)));
168    let runs = !scripts.is_empty() || process_backend;
169    let on_disk = match materialize_to {
170        Some(root) if runs => match crate::materialize::materialize(root, &e.id, env!("CARGO_PKG_VERSION"), &e.files) {
171            Ok(d) => Some(d),
172            Err(x) => {
173                problems.push(format!("its files couldn't be written to disk, so its code can't run: {x}"));
174                None
175            }
176        },
177        _ => None,
178    };
179    match on_disk {
180        Some(d) => {
181            for run in &scripts {
182                if !d.join(run).is_file() {
183                    problems.push(format!("{MANIFEST}: {run} is missing"));
184                }
185            }
186            if let Some(crate::manifest::BackendDecl { run: crate::manifest::BackendRun::Python(script), .. }) = &manifest.backend
187                && !d.join(script).is_file()
188            {
189                problems.push(format!("{MANIFEST}: backend {script} is missing"));
190            }
191            dir = d;
192        }
193        None if materialize_to.is_none() => {
194            for run in &scripts {
195                problems.push(format!("{MANIFEST}: {run}: scripts of built-in plugins don't run here; fork the plugin to use them"));
196            }
197            if process_backend {
198                problems.push(format!("{MANIFEST}: this byob has nowhere to put built-in plugins' code, so the backend can't run; fork the plugin to use one"));
199            }
200        }
201        None => {}
202    }
203    let (mut skills, mut agents, mut commands) = (Vec::new(), Vec::new(), Vec::new());
204    for (path, text) in &e.files {
205        let parts: Vec<&str> = path.split('/').collect();
206        let result = match parts.as_slice() {
207            ["skills", name, "SKILL.md"] => parse_skill(text, &dir.join("skills").join(name)).map(|s| skills.push(s)),
208            ["agents", file] if file.ends_with(".md") => parse_agent(text, &dir.join("agents").join(file)).map(|a| agents.push(a)),
209            ["commands", file] if file.ends_with(".md") => parse_command(text, &dir.join("commands").join(file)).map(|c| commands.push(c)),
210            _ => Ok(()),
211        };
212        if let Err(m) = result {
213            problems.push(m);
214        }
215    }
216    Ok(Plugin { manifest, dir, builtin: true, skills, agents, commands, problems, embedded: Some(Arc::new(e.files.clone())) })
217}
218
219/// Load every embedded plugin; ids are unique. Those that run code are written out under
220/// `materialize_to` (see [`load_embedded`]).
221pub fn discover_embedded(list: &[Embedded], materialize_to: Option<&Path>) -> Discovery {
222    let mut d = Discovery::default();
223    for e in list {
224        match load_embedded(e, materialize_to) {
225            Err(x) => d.errors.push(x),
226            Ok(p) if d.plugins.iter().any(|q| q.id() == p.id()) => {
227                d.errors.push(LoadError { path: p.dir.clone(), message: format!("id \"{}\" is already used", p.id()) });
228            }
229            Ok(p) => d.plugins.push(p),
230        }
231    }
232    d
233}
234
235/// Where to look for plugins.
236#[derive(Debug, Clone, Default)]
237pub struct PluginSources {
238    /// Folders whose subfolders are built-in plugins.
239    pub builtin_roots: Vec<PathBuf>,
240    /// Folders whose subfolders are installed plugins, e.g. `<config>/plugins`.
241    pub roots: Vec<PathBuf>,
242    /// Plugin folders linked one by one (a plugin under development).
243    pub linked: Vec<PathBuf>,
244}
245
246/// Everything found.
247#[derive(Debug, Clone, Default)]
248pub struct Discovery {
249    /// In load order: built-in, installed, linked. Ids are unique.
250    pub plugins: Vec<Plugin>,
251    /// Plugins that couldn't load at all.
252    pub errors: Vec<LoadError>,
253}
254
255impl Discovery {
256    /// Plugins that apply to this system.
257    pub fn active(&self) -> impl Iterator<Item = &Plugin> {
258        self.plugins.iter().filter(|p| p.supported_here())
259    }
260
261    /// Skills of active plugins; the first plugin to define a name wins.
262    pub fn skills(&self) -> Vec<Skill> {
263        let mut seen = BTreeMap::new();
264        for s in self.active().flat_map(|p| &p.skills) {
265            seen.entry(s.name.clone()).or_insert_with(|| s.clone());
266        }
267        seen.into_values().collect()
268    }
269
270    /// Subagents of active plugins; the first plugin to define a name wins.
271    pub fn agents(&self) -> Vec<AgentDef> {
272        crate::agents::merge_agents(self.active().map(|p| p.agents.clone()))
273    }
274
275    /// The `prompt` lines of active plugins, one per line.
276    pub fn prompt(&self) -> String {
277        self.active().filter_map(|p| p.manifest.prompt.as_deref()).collect::<Vec<_>>().join("\n")
278    }
279}
280
281/// Non-hidden subfolders of `dir`, sorted. Missing or unreadable → empty.
282pub(crate) fn subdirs(dir: &Path) -> Vec<PathBuf> {
283    let Ok(rd) = fs::read_dir(dir) else { return Vec::new() };
284    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();
285    out.sort();
286    out
287}
288
289/// Scan every source. Folders without a manifest are skipped silently.
290pub fn discover(sources: &PluginSources) -> Discovery {
291    let mut d = Discovery::default();
292    let mut ids: BTreeMap<String, PathBuf> = BTreeMap::new();
293    let candidates = sources
294        .builtin_roots
295        .iter()
296        .flat_map(|r| subdirs(r).into_iter().map(|p| (p, true)))
297        .chain(sources.roots.iter().flat_map(|r| subdirs(r).into_iter().map(|p| (p, false))))
298        .chain(sources.linked.iter().map(|p| (p.clone(), false)));
299    for (dir, builtin) in candidates {
300        match load_plugin(&dir, builtin) {
301            None => {}
302            Some(Err(e)) => d.errors.push(e),
303            Some(Ok(p)) => {
304                if let Some(first) = ids.get(p.id()) {
305                    d.errors.push(LoadError { path: dir, message: format!("id \"{}\" is already used by {}", p.id(), first.display()) });
306                } else {
307                    ids.insert(p.id().to_string(), dir);
308                    d.plugins.push(p);
309                }
310            }
311        }
312    }
313    d
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn platform_names_match_node() {
322        let p = current_platform();
323        assert!(!p.is_empty() && p != "macos" && p != "windows");
324    }
325
326    #[test]
327    fn plugins_bring_agents() {
328        let root = tempfile::tempdir().unwrap();
329        let dir = root.path().join("helpers");
330        fs::create_dir_all(dir.join("agents")).unwrap();
331        fs::write(dir.join(MANIFEST), r#"{"id":"helpers","name":"Helpers","version":"1.0.0"}"#).unwrap();
332        fs::write(dir.join("agents/explorer.md"), "---\nname: explorer\ndescription: Finds things\ntools: Read, Glob\n---\nExplore.").unwrap();
333        fs::write(dir.join("agents/broken.md"), "---\nname: broken\n---\n").unwrap();
334        let d = discover(&PluginSources { roots: vec![root.path().to_path_buf()], ..Default::default() });
335        assert!(d.errors.is_empty(), "{:?}", d.errors);
336        let agents = d.agents();
337        assert_eq!(agents.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["explorer"]);
338        assert_eq!(d.plugins[0].problems.len(), 1, "the broken agent is a problem, not an error");
339    }
340
341    #[test]
342    fn missing_roots_are_empty() {
343        let d = discover(&PluginSources { roots: vec![PathBuf::from("/definitely/not/here")], ..Default::default() });
344        assert!(d.plugins.is_empty() && d.errors.is_empty());
345    }
346}