vtcode_agent_plugins/
manifest.rs1use std::collections::HashMap;
2
3use crate::errors::PluginError;
4
5const SUPPORTED_SCHEMAS: &[&str] = &["https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"];
6
7#[derive(Debug, Clone)]
8pub struct PluginAuthor {
9 pub name: Option<String>,
10 pub email: Option<String>,
11 pub url: Option<String>,
12}
13
14#[derive(Debug, Clone)]
15pub struct PluginManifest {
16 pub schema: String,
17 pub name: String,
18 pub version: Option<String>,
19 pub description: Option<String>,
20 pub author: Option<PluginAuthor>,
21 pub homepage: Option<String>,
22 pub repository: Option<String>,
23 pub license: Option<String>,
24 pub keywords: Option<Vec<String>>,
25 pub extensions: Option<HashMap<String, serde_json::Value>>,
26 pub unknown_fields: Vec<String>,
27}
28
29impl PluginManifest {
30 pub fn parse(content: &str) -> Result<(Self, Vec<String>), PluginError> {
31 let value: serde_json::Value = serde_json::from_str(content).map_err(PluginError::Json)?;
32 let map = value
33 .as_object()
34 .ok_or_else(|| PluginError::InvalidManifest("manifest must be a JSON object".into()))?;
35
36 let mut unknown = Vec::new();
37 let mut manifest = PluginManifest {
38 schema: String::new(),
39 name: String::new(),
40 version: None,
41 description: None,
42 author: None,
43 homepage: None,
44 repository: None,
45 license: None,
46 keywords: None,
47 extensions: None,
48 unknown_fields: Vec::new(),
49 };
50
51 for (key, val) in map {
52 match key.as_str() {
53 "$schema" => {
54 let s = val
55 .as_str()
56 .ok_or_else(|| PluginError::InvalidManifest("$schema must be a string".into()))?;
57 manifest.schema = s.to_string();
58 }
59 "name" => {
60 manifest.name = val
61 .as_str()
62 .ok_or_else(|| PluginError::InvalidManifest("name must be a string".into()))?
63 .to_string();
64 }
65 "version" => {
66 manifest.version = Some(expect_string(val, "version")?);
67 }
68 "description" => {
69 manifest.description = Some(expect_string(val, "description")?);
70 }
71 "author" => {
72 manifest.author = Some(parse_author(val)?);
73 }
74 "homepage" => {
75 manifest.homepage = Some(expect_string(val, "homepage")?);
76 }
77 "repository" => {
78 manifest.repository = Some(expect_string(val, "repository")?);
79 }
80 "license" => {
81 manifest.license = Some(expect_string(val, "license")?);
82 }
83 "keywords" => {
84 manifest.keywords = Some(expect_string_array(val, "keywords")?);
85 }
86 "extensions" => {
87 match val.as_object() {
91 Some(obj) => {
92 manifest.extensions = Some(obj.into_iter().map(|(k, v)| (k.clone(), v.clone())).collect());
93 }
94 None => {
95 tracing::warn!(field = "extensions", "extensions is not an object; ignoring");
96 }
97 }
98 }
99 _ => unknown.push(key.clone()),
100 }
101 }
102
103 if manifest.schema.is_empty() {
104 return Err(PluginError::InvalidManifest("missing required field: $schema".into()));
105 }
106 if !SUPPORTED_SCHEMAS.contains(&manifest.schema.as_str()) {
107 return Err(PluginError::InvalidManifest(format!(
108 "unsupported $schema '{}' (expected {})",
109 manifest.schema,
110 SUPPORTED_SCHEMAS.join(" or ")
111 )));
112 }
113 if manifest.name.is_empty() {
114 return Err(PluginError::InvalidManifest("missing required field: name".into()));
115 }
116
117 Self::validate_name(&manifest.name).map_err(PluginError::InvalidName)?;
118
119 Ok((manifest, unknown))
120 }
121
122 pub fn validate_name(name: &str) -> Result<(), String> {
123 if name.is_empty() || name.len() > 64 {
124 return Err("name must be 1-64 characters".into());
125 }
126 if !name
127 .chars()
128 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.')
129 {
130 return Err("name must contain only a-z, 0-9, -, .".into());
131 }
132 if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
133 || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
134 {
135 return Err("name must start and end with an alphanumeric character".into());
136 }
137 if name.contains("--") || name.contains("..") {
138 return Err("name must not contain consecutive hyphens or periods".into());
139 }
140 Ok(())
141 }
142}
143
144fn expect_string(value: &serde_json::Value, field: &str) -> Result<String, PluginError> {
145 value
146 .as_str()
147 .map(String::from)
148 .ok_or_else(|| PluginError::InvalidManifest(format!("{field} must be a string")))
149}
150
151fn expect_string_array(value: &serde_json::Value, field: &str) -> Result<Vec<String>, PluginError> {
152 let arr = value
153 .as_array()
154 .ok_or_else(|| PluginError::InvalidManifest(format!("{field} must be an array")))?;
155 arr.iter()
156 .map(|v| {
157 v.as_str()
158 .map(String::from)
159 .ok_or_else(|| PluginError::InvalidManifest(format!("{field} must contain only strings")))
160 })
161 .collect()
162}
163
164fn parse_author(value: &serde_json::Value) -> Result<PluginAuthor, PluginError> {
165 let obj = value
166 .as_object()
167 .ok_or_else(|| PluginError::InvalidManifest("author must be an object".into()))?;
168
169 let mut author = PluginAuthor { name: None, email: None, url: None };
170
171 for (key, val) in obj {
172 match key.as_str() {
173 "name" | "email" | "url" => {
174 let v = val
175 .as_str()
176 .ok_or_else(|| PluginError::InvalidManifest(format!("author.{} must be a string", key)))?;
177 if key == "name" {
178 author.name = Some(v.to_string());
179 } else if key == "email" {
180 author.email = Some(v.to_string());
181 } else {
182 author.url = Some(v.to_string());
183 }
184 }
185 _ => {
186 return Err(PluginError::InvalidManifest(format!("author contains unsupported field: {}", key)));
187 }
188 }
189 }
190
191 Ok(author)
192}