1use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::LoadError;
10use crate::agents::{AgentDef, load_agents_dir};
11use crate::manifest::{MANIFEST, MANIFEST_ALIAS, Manifest, ParseOptions, ToolKind, parse_manifest_str};
12use crate::skills::{Skill, load_skills_dir, read_capped};
13
14const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
16
17#[derive(Debug, Clone, PartialEq)]
19pub struct Plugin {
20 pub manifest: Manifest,
21 pub dir: PathBuf,
22 pub builtin: bool,
24 pub skills: Vec<Skill>,
25 pub agents: Vec<AgentDef>,
27 pub problems: Vec<String>,
30}
31
32impl Plugin {
33 pub fn id(&self) -> &str {
34 &self.manifest.id
35 }
36
37 pub fn supported_here(&self) -> bool {
39 self.manifest.platforms.as_ref().is_none_or(|p| p.iter().any(|x| x == current_platform()))
40 }
41
42 pub fn native_tools(&self) -> Vec<&str> {
44 if !self.builtin {
45 return Vec::new();
46 }
47 self.manifest
48 .tools
49 .iter()
50 .filter_map(|t| match &t.kind {
51 ToolKind::Native(n) => Some(n.as_str()),
52 ToolKind::Script(_) => None,
53 })
54 .collect()
55 }
56}
57
58pub fn current_platform() -> &'static str {
60 match std::env::consts::OS {
61 "macos" => "darwin",
62 "windows" => "win32",
63 other => other,
64 }
65}
66
67pub fn manifest_path(dir: &Path) -> Option<PathBuf> {
69 [MANIFEST, MANIFEST_ALIAS].iter().map(|f| dir.join(f)).find(|p| p.is_file())
70}
71
72pub fn load_plugin(dir: &Path, builtin: bool) -> Option<Result<Plugin, LoadError>> {
74 let file = manifest_path(dir)?;
75 let file_name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| MANIFEST.into());
76 let err = |message: String| LoadError { path: dir.to_path_buf(), message };
77 let text = match read_capped(&file, MAX_MANIFEST_BYTES) {
78 Ok(t) => t,
79 Err(e) => return Some(Err(err(e))),
80 };
81 let manifest = match parse_manifest_str(&text, ParseOptions { builtin, file: Some(&file_name) }) {
82 Ok(m) => m,
83 Err(e) => return Some(Err(err(e.to_string()))),
84 };
85 let mut problems = Vec::new();
86 for t in &manifest.tools {
87 if let ToolKind::Script(run) = &t.kind
88 && !dir.join(run).is_file()
89 {
90 problems.push(format!("{file_name}: {run} is missing"));
91 }
92 }
93 let (skills, skill_errors) = load_skills_dir(&dir.join("skills"));
94 problems.extend(skill_errors.into_iter().map(|e| e.message));
95 let (agents, agent_errors) = load_agents_dir(&dir.join("agents"));
96 problems.extend(agent_errors.into_iter().map(|e| e.to_string()));
97 Some(Ok(Plugin { manifest, dir: dir.to_path_buf(), builtin, skills, agents, problems }))
98}
99
100#[derive(Debug, Clone, Default)]
102pub struct PluginSources {
103 pub builtin_roots: Vec<PathBuf>,
105 pub roots: Vec<PathBuf>,
107 pub linked: Vec<PathBuf>,
109}
110
111#[derive(Debug, Clone, Default)]
113pub struct Discovery {
114 pub plugins: Vec<Plugin>,
116 pub errors: Vec<LoadError>,
118}
119
120impl Discovery {
121 pub fn active(&self) -> impl Iterator<Item = &Plugin> {
123 self.plugins.iter().filter(|p| p.supported_here())
124 }
125
126 pub fn skills(&self) -> Vec<Skill> {
128 let mut seen = BTreeMap::new();
129 for s in self.active().flat_map(|p| &p.skills) {
130 seen.entry(s.name.clone()).or_insert_with(|| s.clone());
131 }
132 seen.into_values().collect()
133 }
134
135 pub fn agents(&self) -> Vec<AgentDef> {
137 crate::agents::merge_agents(self.active().map(|p| p.agents.clone()))
138 }
139
140 pub fn prompt(&self) -> String {
142 self.active().filter_map(|p| p.manifest.prompt.as_deref()).collect::<Vec<_>>().join("\n")
143 }
144}
145
146pub(crate) fn subdirs(dir: &Path) -> Vec<PathBuf> {
148 let Ok(rd) = fs::read_dir(dir) else { return Vec::new() };
149 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();
150 out.sort();
151 out
152}
153
154pub fn discover(sources: &PluginSources) -> Discovery {
156 let mut d = Discovery::default();
157 let mut ids: BTreeMap<String, PathBuf> = BTreeMap::new();
158 let candidates = sources
159 .builtin_roots
160 .iter()
161 .flat_map(|r| subdirs(r).into_iter().map(|p| (p, true)))
162 .chain(sources.roots.iter().flat_map(|r| subdirs(r).into_iter().map(|p| (p, false))))
163 .chain(sources.linked.iter().map(|p| (p.clone(), false)));
164 for (dir, builtin) in candidates {
165 match load_plugin(&dir, builtin) {
166 None => {}
167 Some(Err(e)) => d.errors.push(e),
168 Some(Ok(p)) => {
169 if let Some(first) = ids.get(p.id()) {
170 d.errors.push(LoadError { path: dir, message: format!("id \"{}\" is already used by {}", p.id(), first.display()) });
171 } else {
172 ids.insert(p.id().to_string(), dir);
173 d.plugins.push(p);
174 }
175 }
176 }
177 }
178 d
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn platform_names_match_node() {
187 let p = current_platform();
188 assert!(!p.is_empty() && p != "macos" && p != "windows");
189 }
190
191 #[test]
192 fn plugins_bring_agents() {
193 let root = tempfile::tempdir().unwrap();
194 let dir = root.path().join("helpers");
195 fs::create_dir_all(dir.join("agents")).unwrap();
196 fs::write(dir.join(MANIFEST), r#"{"id":"helpers","name":"Helpers","version":"1.0.0"}"#).unwrap();
197 fs::write(dir.join("agents/explorer.md"), "---\nname: explorer\ndescription: Finds things\ntools: Read, Glob\n---\nExplore.").unwrap();
198 fs::write(dir.join("agents/broken.md"), "---\nname: broken\n---\n").unwrap();
199 let d = discover(&PluginSources { roots: vec![root.path().to_path_buf()], ..Default::default() });
200 assert!(d.errors.is_empty(), "{:?}", d.errors);
201 let agents = d.agents();
202 assert_eq!(agents.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["explorer"]);
203 assert_eq!(d.plugins[0].problems.len(), 1, "the broken agent is a problem, not an error");
204 }
205
206 #[test]
207 fn missing_roots_are_empty() {
208 let d = discover(&PluginSources { roots: vec![PathBuf::from("/definitely/not/here")], ..Default::default() });
209 assert!(d.plugins.is_empty() && d.errors.is_empty());
210 }
211}