use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::LoadError;
use crate::agents::{AgentDef, load_agents_dir, parse_agent};
use crate::commands::{Command, load_commands_dir, parse_command};
use crate::manifest::{MANIFEST, Manifest, OLD_MANIFEST, ParseOptions, ToolKind, parse_manifest_str};
use crate::skills::{Skill, load_skills_dir, parse_skill, read_capped};
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, PartialEq)]
pub struct Plugin {
pub manifest: Manifest,
pub dir: PathBuf,
pub builtin: bool,
pub skills: Vec<Skill>,
pub agents: Vec<AgentDef>,
pub commands: Vec<Command>,
pub problems: Vec<String>,
pub embedded: Option<Arc<BTreeMap<String, String>>>,
}
pub const MAX_PLUGIN_FILE_BYTES: u64 = 256 * 1024;
pub const BUNDLED_DIR: &str = "(built into byob)";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Embedded {
pub id: String,
pub files: BTreeMap<String, String>,
}
impl Plugin {
pub fn id(&self) -> &str {
&self.manifest.id
}
pub fn read(&self, rel: &str) -> Result<String, String> {
let rel = rel.trim().trim_start_matches("./");
if rel.is_empty() || !crate::manifest::path_inside(rel) {
return Err(format!("{rel:?} must be a path inside the plugin folder"));
}
match &self.embedded {
Some(files) => files.get(rel).cloned().ok_or_else(|| format!("{rel} isn't a file of plugin {}", self.id())),
None => read_capped(&self.dir.join(rel), MAX_PLUGIN_FILE_BYTES),
}
}
pub fn supported_here(&self) -> bool {
self.manifest.platforms.as_ref().is_none_or(|p| p.iter().any(|x| x == current_platform()))
}
pub fn native_tools(&self) -> Vec<&str> {
self.manifest
.tools
.iter()
.filter_map(|t| match &t.kind {
ToolKind::Native(n) => Some(n.as_str()),
ToolKind::Script(_) | ToolKind::Backend => None,
})
.collect()
}
}
pub fn current_platform() -> &'static str {
match std::env::consts::OS {
"macos" => "darwin",
"windows" => "win32",
other => other,
}
}
pub fn manifest_path(dir: &Path) -> Option<PathBuf> {
let file = dir.join(MANIFEST);
if file.is_file() {
return Some(file);
}
if dir.join(OLD_MANIFEST).is_file() {
log::warn!("{}: rename {OLD_MANIFEST} to {MANIFEST}; the old name isn't read any more", dir.display());
}
None
}
pub fn load_plugin(dir: &Path, builtin: bool) -> Option<Result<Plugin, LoadError>> {
let file = manifest_path(dir)?;
let file_name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| MANIFEST.into());
let err = |message: String| LoadError { path: dir.to_path_buf(), message };
let text = match read_capped(&file, MAX_MANIFEST_BYTES) {
Ok(t) => t,
Err(e) => return Some(Err(err(e))),
};
let manifest = match parse_manifest_str(&text, ParseOptions { builtin, file: Some(&file_name) }) {
Ok(m) => m,
Err(e) => return Some(Err(err(e.to_string()))),
};
let mut problems = Vec::new();
for t in &manifest.tools {
if let ToolKind::Script(run) = &t.kind
&& !dir.join(run).is_file()
{
problems.push(format!("{file_name}: {run} is missing"));
}
}
if let Some(crate::manifest::BackendDecl { run: crate::manifest::BackendRun::Python(script), .. }) = &manifest.backend
&& !dir.join(script).is_file()
{
problems.push(format!("{file_name}: backend {script} is missing"));
}
let (skills, skill_errors) = load_skills_dir(&dir.join("skills"));
problems.extend(skill_errors.into_iter().map(|e| e.message));
let (agents, agent_errors) = load_agents_dir(&dir.join("agents"));
problems.extend(agent_errors.into_iter().map(|e| e.to_string()));
let (commands, command_errors) = load_commands_dir(&dir.join("commands"));
problems.extend(command_errors.into_iter().map(|e| e.message));
Some(Ok(Plugin { manifest, dir: dir.to_path_buf(), builtin, skills, agents, commands, problems, embedded: None }))
}
pub fn load_embedded(e: &Embedded, materialize_to: Option<&Path>) -> Result<Plugin, LoadError> {
let mut dir = Path::new(BUNDLED_DIR).join(&e.id);
let err = |message: String| LoadError { path: dir.clone(), message };
let text = e.files.get(MANIFEST).ok_or_else(|| err(format!("{MANIFEST} is missing")))?;
let manifest = parse_manifest_str(text, ParseOptions { builtin: true, file: Some(MANIFEST) }).map_err(|x| err(x.to_string()))?;
if manifest.id != e.id {
return Err(err(format!("{MANIFEST}: id {:?} isn't the folder's name {:?}", manifest.id, e.id)));
}
let mut problems = Vec::new();
let scripts: Vec<&str> = manifest
.tools
.iter()
.filter_map(|t| match &t.kind {
ToolKind::Script(run) => Some(run.as_str()),
_ => None,
})
.collect();
let process_backend = manifest.backend.as_ref().is_some_and(|b| !matches!(b.run, crate::manifest::BackendRun::Native(_)));
let runs = !scripts.is_empty() || process_backend;
let on_disk = match materialize_to {
Some(root) if runs => match crate::materialize::materialize(root, &e.id, env!("CARGO_PKG_VERSION"), &e.files) {
Ok(d) => Some(d),
Err(x) => {
problems.push(format!("its files couldn't be written to disk, so its code can't run: {x}"));
None
}
},
_ => None,
};
match on_disk {
Some(d) => {
for run in &scripts {
if !d.join(run).is_file() {
problems.push(format!("{MANIFEST}: {run} is missing"));
}
}
if let Some(crate::manifest::BackendDecl { run: crate::manifest::BackendRun::Python(script), .. }) = &manifest.backend
&& !d.join(script).is_file()
{
problems.push(format!("{MANIFEST}: backend {script} is missing"));
}
dir = d;
}
None if materialize_to.is_none() => {
for run in &scripts {
problems.push(format!("{MANIFEST}: {run}: scripts of built-in plugins don't run here; fork the plugin to use them"));
}
if process_backend {
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"));
}
}
None => {}
}
let (mut skills, mut agents, mut commands) = (Vec::new(), Vec::new(), Vec::new());
for (path, text) in &e.files {
let parts: Vec<&str> = path.split('/').collect();
let result = match parts.as_slice() {
["skills", name, "SKILL.md"] => parse_skill(text, &dir.join("skills").join(name)).map(|s| skills.push(s)),
["agents", file] if file.ends_with(".md") => parse_agent(text, &dir.join("agents").join(file)).map(|a| agents.push(a)),
["commands", file] if file.ends_with(".md") => parse_command(text, &dir.join("commands").join(file)).map(|c| commands.push(c)),
_ => Ok(()),
};
if let Err(m) = result {
problems.push(m);
}
}
Ok(Plugin { manifest, dir, builtin: true, skills, agents, commands, problems, embedded: Some(Arc::new(e.files.clone())) })
}
pub fn discover_embedded(list: &[Embedded], materialize_to: Option<&Path>) -> Discovery {
let mut d = Discovery::default();
for e in list {
match load_embedded(e, materialize_to) {
Err(x) => d.errors.push(x),
Ok(p) if d.plugins.iter().any(|q| q.id() == p.id()) => {
d.errors.push(LoadError { path: p.dir.clone(), message: format!("id \"{}\" is already used", p.id()) });
}
Ok(p) => d.plugins.push(p),
}
}
d
}
#[derive(Debug, Clone, Default)]
pub struct PluginSources {
pub builtin_roots: Vec<PathBuf>,
pub roots: Vec<PathBuf>,
pub linked: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct Discovery {
pub plugins: Vec<Plugin>,
pub errors: Vec<LoadError>,
}
impl Discovery {
pub fn active(&self) -> impl Iterator<Item = &Plugin> {
self.plugins.iter().filter(|p| p.supported_here())
}
pub fn skills(&self) -> Vec<Skill> {
let mut seen = BTreeMap::new();
for s in self.active().flat_map(|p| &p.skills) {
seen.entry(s.name.clone()).or_insert_with(|| s.clone());
}
seen.into_values().collect()
}
pub fn agents(&self) -> Vec<AgentDef> {
crate::agents::merge_agents(self.active().map(|p| p.agents.clone()))
}
pub fn prompt(&self) -> String {
self.active().filter_map(|p| p.manifest.prompt.as_deref()).collect::<Vec<_>>().join("\n")
}
}
pub(crate) fn subdirs(dir: &Path) -> Vec<PathBuf> {
let Ok(rd) = fs::read_dir(dir) else { return Vec::new() };
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();
out.sort();
out
}
pub fn discover(sources: &PluginSources) -> Discovery {
let mut d = Discovery::default();
let mut ids: BTreeMap<String, PathBuf> = BTreeMap::new();
let candidates = sources
.builtin_roots
.iter()
.flat_map(|r| subdirs(r).into_iter().map(|p| (p, true)))
.chain(sources.roots.iter().flat_map(|r| subdirs(r).into_iter().map(|p| (p, false))))
.chain(sources.linked.iter().map(|p| (p.clone(), false)));
for (dir, builtin) in candidates {
match load_plugin(&dir, builtin) {
None => {}
Some(Err(e)) => d.errors.push(e),
Some(Ok(p)) => {
if let Some(first) = ids.get(p.id()) {
d.errors.push(LoadError { path: dir, message: format!("id \"{}\" is already used by {}", p.id(), first.display()) });
} else {
ids.insert(p.id().to_string(), dir);
d.plugins.push(p);
}
}
}
}
d
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn platform_names_match_node() {
let p = current_platform();
assert!(!p.is_empty() && p != "macos" && p != "windows");
}
#[test]
fn plugins_bring_agents() {
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("helpers");
fs::create_dir_all(dir.join("agents")).unwrap();
fs::write(dir.join(MANIFEST), r#"{"id":"helpers","name":"Helpers","version":"1.0.0"}"#).unwrap();
fs::write(dir.join("agents/explorer.md"), "---\nname: explorer\ndescription: Finds things\ntools: Read, Glob\n---\nExplore.").unwrap();
fs::write(dir.join("agents/broken.md"), "---\nname: broken\n---\n").unwrap();
let d = discover(&PluginSources { roots: vec![root.path().to_path_buf()], ..Default::default() });
assert!(d.errors.is_empty(), "{:?}", d.errors);
let agents = d.agents();
assert_eq!(agents.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["explorer"]);
assert_eq!(d.plugins[0].problems.len(), 1, "the broken agent is a problem, not an error");
}
#[test]
fn missing_roots_are_empty() {
let d = discover(&PluginSources { roots: vec![PathBuf::from("/definitely/not/here")], ..Default::default() });
assert!(d.plugins.is_empty() && d.errors.is_empty());
}
}