Skip to main content

agent_engine/skills/
plugin_index.rs

1//! Plugin index schema support.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
6pub struct PluginIndex {
7    pub schema_version: u32,
8    #[serde(default)]
9    pub generated_at: Option<String>,
10    pub plugins: Vec<PluginIndexEntry>,
11}
12
13#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
14pub struct PluginIndexEntry {
15    pub id: String,
16    pub name: String,
17    pub version: String,
18    pub description: String,
19    pub repository: String,
20    #[serde(default)]
21    pub subdir: Option<String>,
22    #[serde(default)]
23    pub license: Option<String>,
24    #[serde(default)]
25    pub categories: Vec<String>,
26    #[serde(default)]
27    pub keywords: Vec<String>,
28    pub checksum: PluginIndexChecksum,
29    pub compatibility: PluginIndexCompatibility,
30    pub capabilities: PluginIndexCapabilities,
31    #[serde(default)]
32    pub trust: Option<PluginIndexTrust>,
33}
34
35#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
36pub struct PluginIndexChecksum {
37    pub algorithm: String,
38    pub value: String,
39}
40
41#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
42pub struct PluginIndexCompatibility {
43    #[serde(default)]
44    pub synaps: Option<String>,
45    #[serde(default)]
46    pub extension_protocol: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
50pub struct PluginIndexCapabilities {
51    #[serde(default)]
52    pub skills: Vec<String>,
53    pub has_extension: bool,
54    #[serde(default)]
55    pub permissions: Vec<String>,
56    #[serde(default)]
57    pub hooks: Vec<String>,
58    #[serde(default)]
59    pub commands: Vec<String>,
60    #[serde(default)]
61    pub providers: Vec<PluginIndexProviderCapability>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct PluginIndexProviderCapability {
66    pub id: String,
67    #[serde(default)]
68    pub display_name: Option<String>,
69    #[serde(default)]
70    pub models: Vec<String>,
71}
72
73#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
74pub struct PluginIndexTrust {
75    #[serde(default)]
76    pub publisher: Option<String>,
77    #[serde(default)]
78    pub homepage: Option<String>,
79}
80
81pub fn validate_plugin_index(index: &PluginIndex) -> Result<(), String> {
82    if index.schema_version != 1 {
83        return Err(format!(
84            "plugin index schema_version must be 1, got {}",
85            index.schema_version
86        ));
87    }
88    for (idx, plugin) in index.plugins.iter().enumerate() {
89        if plugin.id.is_empty()
90            || !plugin
91                .id
92                .chars()
93                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
94        {
95            return Err(format!("plugins[{idx}].id must be lower-kebab-case"));
96        }
97        if plugin.name.trim().is_empty() {
98            return Err(format!("plugins[{idx}].name is required"));
99        }
100        if !is_semver(&plugin.version) {
101            return Err(format!("plugins[{idx}].version must be semver"));
102        }
103        if plugin.description.trim().is_empty() {
104            return Err(format!("plugins[{idx}].description is required"));
105        }
106        if !(plugin.repository.starts_with("https://") || plugin.repository.starts_with("file://"))
107        {
108            return Err(format!(
109                "plugins[{idx}].repository must be https:// or file://"
110            ));
111        }
112        if plugin.checksum.algorithm != "sha256" {
113            return Err(format!("plugins[{idx}].checksum.algorithm must be sha256"));
114        }
115        if plugin.checksum.value.len() != 64
116            || !plugin
117                .checksum
118                .value
119                .chars()
120                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
121        {
122            return Err(format!(
123                "plugins[{idx}].checksum.value must be 64 lowercase hex characters"
124            ));
125        }
126        if let Some(trust) = &plugin.trust {
127            if let Some(homepage) = &trust.homepage {
128                if !homepage.starts_with("https://") {
129                    return Err(format!("plugins[{idx}].trust.homepage must be https://"));
130                }
131            }
132        }
133        for (provider_idx, provider) in plugin.capabilities.providers.iter().enumerate() {
134            if provider.id.trim().is_empty() {
135                return Err(format!(
136                    "plugins[{idx}].capabilities.providers[{provider_idx}].id is required"
137                ));
138            }
139            if provider.id.contains(':') {
140                return Err(format!(
141                    "plugins[{idx}].capabilities.providers[{provider_idx}].id must not contain ':'"
142                ));
143            }
144            for (model_idx, model) in provider.models.iter().enumerate() {
145                if model.trim().is_empty() || model.contains(':') {
146                    return Err(format!("plugins[{idx}].capabilities.providers[{provider_idx}].models[{model_idx}] must be non-empty and must not contain ':'"));
147                }
148            }
149        }
150    }
151    Ok(())
152}
153
154fn is_semver(value: &str) -> bool {
155    let mut parts = value.splitn(2, '-');
156    let core = parts.next().unwrap_or_default();
157    let nums: Vec<&str> = core.split('.').collect();
158    nums.len() == 3
159        && nums
160            .iter()
161            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn sample_index_json() -> &'static str {
169        r#"{
170          "schema_version": 1,
171          "generated_at": "2026-05-01T12:00:00Z",
172          "plugins": [{
173            "id": "session-memory",
174            "name": "session-memory",
175            "version": "0.1.0",
176            "description": "Extracts local session notes.",
177            "repository": "https://github.com/example/synaps-skills.git",
178            "subdir": "session-memory-plugin",
179            "checksum": {"algorithm": "sha256", "value": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
180            "compatibility": {"synaps": ">=0.1.0", "extension_protocol": "1"},
181            "capabilities": {
182              "skills": ["session-memory"],
183              "has_extension": true,
184              "permissions": ["session.lifecycle"],
185              "hooks": ["on_session_end"],
186              "commands": []
187            },
188            "trust": {"publisher": "Maha Media", "homepage": "https://example.com"}
189          }]
190        }"#
191    }
192
193    #[test]
194    fn parses_and_validates_v1_plugin_index() {
195        let index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
196        validate_plugin_index(&index).unwrap();
197        assert_eq!(index.plugins[0].id, "session-memory");
198        assert!(index.plugins[0].capabilities.has_extension);
199        assert_eq!(
200            index.plugins[0].capabilities.permissions,
201            vec!["session.lifecycle"]
202        );
203    }
204
205    #[test]
206    fn rejects_unsupported_schema_version() {
207        let mut index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
208        index.schema_version = 2;
209        assert!(validate_plugin_index(&index)
210            .unwrap_err()
211            .contains("schema_version"));
212    }
213
214    #[test]
215    fn rejects_bad_checksum_algorithm() {
216        let mut index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
217        index.plugins[0].checksum.algorithm = "md5".into();
218        assert!(validate_plugin_index(&index)
219            .unwrap_err()
220            .contains("checksum.algorithm"));
221    }
222
223    #[test]
224    fn rejects_bad_checksum_shape() {
225        let mut index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
226        index.plugins[0].checksum.value = "abc123".into();
227        assert!(validate_plugin_index(&index)
228            .unwrap_err()
229            .contains("checksum.value"));
230    }
231}