vtcode_agent_plugins/
discovery.rs1use std::path::{Path, PathBuf};
2
3use crate::errors::PluginError;
4use crate::manifest::PluginManifest;
5use crate::mcp::McpConfig;
6use vtcode_skills::types::SkillManifest;
7
8#[derive(Debug, Clone)]
9pub struct DiscoveredSkill {
10 pub name: String,
11 pub dir_name: String,
12 pub skill_md_path: PathBuf,
13 pub manifest: SkillManifest,
14}
15
16#[derive(Debug, Clone)]
17pub struct LoadedPlugin {
18 pub manifest: PluginManifest,
19 pub root: PathBuf,
20 pub skills: Vec<DiscoveredSkill>,
21 pub mcp: Option<McpConfig>,
22}
23
24impl LoadedPlugin {
25 pub fn load_from_dir(plugin_root: &Path) -> Result<Self, PluginError> {
28 let absolute = std::path::absolute(plugin_root).map_err(PluginError::Io)?;
31 let root = vtcode_commons::canonicalize(&absolute).unwrap_or(absolute);
32 let manifest = Self::parse_manifest_dir(&root)?;
33 let skills = Self::discover_skills_dir(&root)?;
34 let mcp = Self::discover_mcp_dir(&root)?;
35
36 let mcp = match mcp {
39 Some(ref mcp_config)
40 if !mcp_config.schema.is_empty()
41 && !manifest.schema.is_empty()
42 && extract_schema_version(&manifest.schema) != extract_schema_version(&mcp_config.schema) =>
43 {
44 tracing::warn!(
45 manifest_version = ?extract_schema_version(&manifest.schema),
46 mcp_version = ?extract_schema_version(&mcp_config.schema),
47 "mcp.json schema version does not match plugin.json; disabling MCP for plugin"
48 );
49 None
50 }
51 other => other,
52 };
53
54 Ok(LoadedPlugin { manifest, root, skills, mcp })
55 }
56
57 pub fn parse_manifest_dir(plugin_root: &Path) -> Result<PluginManifest, PluginError> {
59 let manifest_path = plugin_root.join("plugin.json");
60 let _ = ensure_package_path_within(plugin_root, &manifest_path)?;
62 let content = std::fs::read_to_string(&manifest_path)?;
63 let (manifest, unknown_fields) = PluginManifest::parse(&content)?;
64 for field in &unknown_fields {
65 tracing::warn!(field = field, "unknown top-level field in plugin.json");
66 }
67 Ok(manifest)
68 }
69
70 pub fn discover_skills_dir(plugin_root: &Path) -> Result<Vec<DiscoveredSkill>, PluginError> {
72 discover_skills(plugin_root)
73 }
74
75 pub fn discover_mcp_dir(plugin_root: &Path) -> Result<Option<McpConfig>, PluginError> {
77 discover_mcp(plugin_root)
78 }
79}
80
81fn extract_schema_version(schema: &str) -> Option<&str> {
82 schema
83 .strip_prefix("https://agent-plugins.org/schemas/")
84 .and_then(|rest| rest.split('/').next())
85}
86
87fn discover_skills(plugin_root: &Path) -> Result<Vec<DiscoveredSkill>, PluginError> {
88 let skills_dir = plugin_root.join("skills");
89 match std::fs::symlink_metadata(&skills_dir) {
92 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
93 Err(e) => return Err(PluginError::Io(e)),
94 Ok(_) => {}
95 }
96 if ensure_package_path_within(plugin_root, &skills_dir).is_err() {
99 tracing::warn!(
100 plugin_root = %plugin_root.display(),
101 "skills location escapes plugin root; disabling skills for plugin"
102 );
103 return Ok(Vec::new());
104 }
105 if !skills_dir.is_dir() {
106 tracing::warn!(
107 plugin_root = %plugin_root.display(),
108 "skills location is not a directory; disabling skills for plugin"
109 );
110 return Ok(Vec::new());
111 }
112
113 let mut skills = Vec::new();
114 for entry in std::fs::read_dir(&skills_dir)? {
115 let entry = entry?;
116 let path = entry.path();
117 if !path.is_dir() {
118 continue;
119 }
120 if ensure_package_path_within(plugin_root, &path).is_err() {
122 tracing::warn!(
123 plugin_root = %plugin_root.display(),
124 skill_dir = %path.display(),
125 "skill directory escapes plugin root; skipping"
126 );
127 continue;
128 }
129 let skill_md = path.join("SKILL.md");
130 if ensure_package_path_within(plugin_root, &skill_md).is_err() {
132 tracing::warn!(
133 plugin_root = %plugin_root.display(),
134 skill_md = %skill_md.display(),
135 "SKILL.md escapes plugin root; skipping skill"
136 );
137 continue;
138 }
139 if !skill_md.is_file() {
140 continue;
141 }
142
143 let dir_name = path
144 .file_name()
145 .and_then(|s| s.to_string_lossy().into_owned().into())
146 .unwrap_or_default();
147
148 match load_one_skill(&skill_md, &dir_name) {
149 Ok(skill) => {
150 if skills.iter().any(|s: &DiscoveredSkill| s.name == skill.name) {
153 tracing::warn!(
154 plugin_root = %plugin_root.display(),
155 skill_name = %skill.name,
156 skill_dir = %dir_name,
157 "duplicate skill name in plugin; keeping first occurrence"
158 );
159 continue;
160 }
161 skills.push(skill);
162 }
163 Err(e) => {
166 tracing::warn!(
167 plugin_root = %plugin_root.display(),
168 skill_dir = %dir_name,
169 error = %e,
170 "skipping invalid plugin skill"
171 );
172 }
173 }
174 }
175
176 Ok(skills)
177}
178
179fn load_one_skill(skill_md: &Path, dir_name: &str) -> Result<DiscoveredSkill, PluginError> {
180 let content = std::fs::read_to_string(skill_md)?;
181 let (skill_manifest, _instructions) =
182 vtcode_skills::manifest::parse_skill_content(&content).map_err(|e| PluginError::InvalidSkill(e.to_string()))?;
183
184 if skill_manifest.name != dir_name {
188 tracing::warn!(
189 skill_name = %skill_manifest.name,
190 dir_name = %dir_name,
191 "skill name does not match directory; loading by manifest name"
192 );
193 }
194
195 skill_manifest
196 .validate()
197 .map_err(|e| PluginError::InvalidSkill(e.to_string()))?;
198
199 Ok(DiscoveredSkill {
200 name: skill_manifest.name.clone(),
201 dir_name: dir_name.to_string(),
202 skill_md_path: skill_md.to_path_buf(),
203 manifest: skill_manifest,
204 })
205}
206
207fn ensure_package_path_within(plugin_root: &Path, candidate: &Path) -> Result<PathBuf, PluginError> {
208 let canonical_root = vtcode_commons::canonicalize(plugin_root)
209 .map_err(|e| PluginError::PathEscape(format!("plugin root could not be resolved: {e}")))?;
210 let resolved = crate::expansion::canonicalize_allow_missing(candidate)
214 .map_err(|e| PluginError::PathEscape(format!("path could not be resolved: {} ({e})", candidate.display())))?;
215 if resolved.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
216 return Err(PluginError::PathEscape(format!("path escapes plugin root: {}", candidate.display())));
217 }
218 if !resolved.starts_with(&canonical_root) {
219 return Err(PluginError::PathEscape(format!("path escapes plugin root: {}", candidate.display())));
220 }
221 Ok(resolved)
222}
223
224fn discover_mcp(plugin_root: &Path) -> Result<Option<McpConfig>, PluginError> {
225 let mcp_path = plugin_root.join("mcp.json");
226 match std::fs::symlink_metadata(&mcp_path) {
227 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
228 Err(e) => return Err(PluginError::Io(e)),
229 Ok(_) => {}
230 }
231 if ensure_package_path_within(plugin_root, &mcp_path).is_err() {
233 tracing::warn!(
234 plugin_root = %plugin_root.display(),
235 "mcp.json escapes plugin root; disabling MCP for plugin"
236 );
237 return Ok(None);
238 }
239 if !mcp_path.is_file() {
240 tracing::warn!(
241 plugin_root = %plugin_root.display(),
242 "mcp.json is not a regular file; disabling MCP for plugin"
243 );
244 return Ok(None);
245 }
246
247 let content = std::fs::read_to_string(&mcp_path)?;
248 let config = McpConfig::parse(&content)?;
249 if !config.unknown_fields.is_empty() {
250 tracing::warn!(
251 fields = ?config.unknown_fields,
252 "unknown top-level fields in plugin mcp.json; disabling MCP for this plugin"
253 );
254 return Ok(None);
255 }
256 Ok(Some(config))
257}