Skip to main content

vtcode_agent_plugins/
manifest.rs

1use 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                    // Per the Agent Plugins spec, a non-object `extensions`
88                    // value (including null) is reported and ignored — the
89                    // plugin continues loading. Only object values are stored.
90                    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}