Skip to main content

telos_agent/integrations/plugin/
manifest.rs

1//! Plugin manifest types — serde-compatible schema for plugin.json.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8use crate::integrations::plugin::PluginId;
9
10// --- Metadata types ---
11
12/// Author or maintainer of a plugin or marketplace.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct PluginAuthor {
16    pub name: String,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub email: Option<String>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub url: Option<String>,
21}
22
23/// One user-configurable option declared by a plugin.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct UserConfigOption {
27    #[serde(rename = "type")]
28    pub type_: ConfigOptionType,
29    pub title: String,
30    pub description: String,
31    #[serde(default)]
32    pub required: bool,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub default: Option<Value>,
35    #[serde(default)]
36    pub sensitive: bool,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub min: Option<f64>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub max: Option<f64>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum ConfigOptionType {
46    String,
47    Number,
48    Boolean,
49    Directory,
50    File,
51}
52
53/// A dependency reference. Bare "name" resolves against the declaring plugin's
54/// marketplace. "name@marketplace" is fully qualified.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(untagged)]
57pub enum DependencyRef {
58    Bare(String),
59    #[serde(rename_all = "camelCase")]
60    Qualified {
61        name: String,
62        marketplace: String,
63    },
64}
65
66impl DependencyRef {
67    /// Resolve this reference into a concrete PluginId.
68    ///
69    /// Bare names use `default_marketplace`; qualified names use their own.
70    pub fn resolve(&self, default_marketplace: &str) -> PluginId {
71        match self {
72            DependencyRef::Bare(name) => {
73                PluginId { name: name.clone(), marketplace: default_marketplace.to_string() }
74            }
75            DependencyRef::Qualified { name, marketplace } => {
76                PluginId { name: name.clone(), marketplace: marketplace.clone() }
77            }
78        }
79    }
80
81    /// Display the dependency as a string.
82    pub fn display(&self) -> String {
83        match self {
84            DependencyRef::Bare(name) => name.clone(),
85            DependencyRef::Qualified { name, marketplace } => format!("{name}@{marketplace}"),
86        }
87    }
88}
89
90// --- Policy configuration ---
91
92fn default_policy_timeout_ms() -> u64 {
93    30_000
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct CommandPolicyDef {
99    pub command: String,
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub args: Vec<String>,
102    #[serde(default = "default_policy_timeout_ms")]
103    pub timeout: u64,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct SessionPolicyDef {
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub mode: Option<crate::SessionMode>,
111    #[serde(flatten)]
112    pub command: CommandPolicyDef,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct ToolPolicyDef {
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub matcher: Option<String>,
120    #[serde(flatten)]
121    pub command: CommandPolicyDef,
122}
123
124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct PoliciesConfig {
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub session_start: Vec<SessionPolicyDef>,
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub model_response: Vec<CommandPolicyDef>,
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub tool_before_invoke: Vec<ToolPolicyDef>,
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub tool_after_invoke: Vec<ToolPolicyDef>,
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub turn_before_finish: Vec<CommandPolicyDef>,
137}
138
139// --- MCP configuration ---
140
141/// MCP server configuration (mirrors crate::integrations::mcp::McpServerConfig but serde-friendly).
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct McpServerEntry {
145    pub command: String,
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub args: Vec<String>,
148    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
149    pub env: HashMap<String, String>,
150    #[serde(default = "default_true")]
151    pub auto_connect: bool,
152    #[serde(default = "default_mcp_timeout_ms")]
153    pub timeout_ms: u64,
154}
155
156fn default_true() -> bool {
157    true
158}
159fn default_mcp_timeout_ms() -> u64 {
160    60_000
161}
162
163/// MCP servers declared in plugin.json — either inline or a path to .mcp.json.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165#[serde(untagged)]
166pub enum McpServersConfig {
167    /// Path to a .mcp.json file relative to plugin root (starts with "./").
168    File(String),
169    /// Inline server definitions.
170    Inline(HashMap<String, McpServerEntry>),
171}
172
173// --- LSP configuration ---
174
175/// Individual LSP server configuration.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct LspServerEntry {
179    pub command: String,
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub args: Vec<String>,
182    /// File extension to language ID mapping (e.g. ".ts" → "typescript").
183    pub extension_to_language: HashMap<String, String>,
184    #[serde(default = "default_transport")]
185    pub transport: String,
186    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
187    pub env: HashMap<String, String>,
188}
189
190fn default_transport() -> String {
191    "stdio".into()
192}
193
194/// LSP servers declared in plugin.json — either inline or a path.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(untagged)]
197pub enum LspServersConfig {
198    File(String),
199    Inline(HashMap<String, LspServerEntry>),
200}
201
202// --- The full manifest ---
203
204/// Parsed plugin.json — the plugin's self-describing manifest.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct PluginManifest {
208    #[serde(default)]
209    pub name: String,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub version: Option<String>,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub description: Option<String>,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub author: Option<PluginAuthor>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub homepage: Option<String>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub repository: Option<String>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub license: Option<String>,
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub keywords: Vec<String>,
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub dependencies: Vec<DependencyRef>,
226
227    // Components — all optional, paths relative to plugin root
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub tools: Option<Vec<String>>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub policies: Option<PoliciesConfig>,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub skills: Option<Vec<String>>,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub agents: Option<Vec<String>>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub mcp_servers: Option<McpServersConfig>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub lsp_servers: Option<LspServersConfig>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub prompt_sections: Option<Vec<String>>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub output_styles: Option<Vec<String>>,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub settings: Option<HashMap<String, Value>>,
246
247    // User configuration prompts
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub user_config: Option<HashMap<String, UserConfigOption>>,
250}
251
252/// A partial manifest — marketplace entries can override fields.
253///
254/// This is a subset of PluginManifest with all optional fields.
255pub type PartialPluginManifest = Value;
256
257/// An entry in a marketplace — describes a plugin and where to get it.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct MarketplaceEntry {
261    pub name: String,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub description: Option<String>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub version: Option<String>,
266    pub source: PluginSource,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub category: Option<String>,
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub tags: Vec<String>,
271    #[serde(default = "default_strict")]
272    pub strict: bool,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub manifest_override: Option<PartialPluginManifest>,
275}
276
277fn default_strict() -> bool {
278    true
279}
280
281// --- Plugin source types ---
282
283/// Where to fetch a plugin from.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(tag = "type", rename_all = "lowercase")]
286pub enum PluginSource {
287    /// Local directory containing plugin.json.
288    Local { path: PathBuf },
289    /// GitHub repository: "owner/repo".
290    #[serde(rename_all = "camelCase")]
291    GitHub {
292        repo: String,
293        #[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
294        ref_: Option<String>,
295        #[serde(skip_serializing_if = "Option::is_none")]
296        sha: Option<String>,
297        #[serde(skip_serializing_if = "Option::is_none")]
298        path: Option<String>,
299    },
300    /// Arbitrary git URL.
301    #[serde(rename_all = "camelCase")]
302    Git {
303        url: String,
304        #[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
305        ref_: Option<String>,
306        #[serde(skip_serializing_if = "Option::is_none")]
307        sha: Option<String>,
308        #[serde(skip_serializing_if = "Option::is_none")]
309        path: Option<String>,
310    },
311    /// npm package.
312    #[serde(rename_all = "camelCase")]
313    Npm {
314        package: String,
315        #[serde(skip_serializing_if = "Option::is_none")]
316        version: Option<String>,
317        #[serde(skip_serializing_if = "Option::is_none")]
318        registry: Option<String>,
319    },
320    /// pip/PyPI package.
321    #[serde(rename_all = "camelCase")]
322    Pip {
323        package: String,
324        #[serde(skip_serializing_if = "Option::is_none")]
325        version: Option<String>,
326        #[serde(skip_serializing_if = "Option::is_none")]
327        registry: Option<String>,
328    },
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use serde_json::json;
335
336    #[test]
337    fn parse_minimal_manifest() {
338        let json = json!({
339            "name": "my-plugin",
340            "version": "1.0.0",
341            "description": "A test plugin"
342        });
343        let manifest: PluginManifest = serde_json::from_value(json).unwrap();
344        assert_eq!(manifest.name, "my-plugin");
345        assert_eq!(manifest.version.as_deref(), Some("1.0.0"));
346        assert!(manifest.tools.is_none());
347        assert!(manifest.policies.is_none());
348        assert!(manifest.dependencies.is_empty());
349    }
350
351    #[test]
352    fn parse_full_manifest() {
353        let json = json!({
354            "name": "full-plugin",
355            "version": "2.1.0",
356            "description": "Has everything",
357            "author": {
358                "name": "Alice",
359                "email": "alice@example.com",
360                "url": "https://example.com"
361            },
362            "homepage": "https://plugin.example.com",
363            "repository": "https://github.com/alice/full-plugin",
364            "license": "MIT",
365            "keywords": ["testing", "example"],
366            "dependencies": [
367                "required-dep",
368                {"name": "other", "marketplace": "community"}
369            ],
370            "tools": ["./tools/my_tool.json"],
371            "policies": {
372                "toolBeforeInvoke": [{
373                    "matcher": "Bash*",
374                    "command": "python3",
375                    "args": ["validate.py"]
376                }]
377            },
378            "skills": ["./skills/my-skill.md"],
379            "agents": ["./agents/auditor.md"],
380            "mcpServers": {
381                "my-server": {
382                    "command": "node",
383                    "args": ["server.js"],
384                    "autoConnect": true,
385                    "timeoutMs": 30000
386                }
387            },
388            "promptSections": ["./prompt/context.md"],
389            "outputStyles": ["./styles/theme.json"],
390            "userConfig": {
391                "API_KEY": {
392                    "type": "string",
393                    "title": "API Key",
394                    "description": "Your API key",
395                    "required": true,
396                    "sensitive": true
397                }
398            }
399        });
400
401        let manifest: PluginManifest = serde_json::from_value(json).unwrap();
402        assert_eq!(manifest.name, "full-plugin");
403        assert_eq!(manifest.version.unwrap(), "2.1.0");
404        assert!(manifest.author.is_some());
405        let author = manifest.author.unwrap();
406        assert_eq!(author.name, "Alice");
407        assert_eq!(author.email.unwrap(), "alice@example.com");
408        assert_eq!(manifest.keywords.len(), 2);
409        assert_eq!(manifest.dependencies.len(), 2);
410
411        // Check dependency resolution
412        let dep1 = &manifest.dependencies[0];
413        let resolved1 = dep1.resolve("telos-official");
414        assert_eq!(resolved1.to_string(), "required-dep@telos-official");
415
416        let dep2 = &manifest.dependencies[1];
417        let resolved2 = dep2.resolve("telos-official");
418        assert_eq!(resolved2.to_string(), "other@community");
419
420        assert!(manifest.tools.is_some());
421        assert!(manifest.policies.is_some());
422        assert!(manifest.skills.is_some());
423        assert!(manifest.agents.is_some());
424        assert!(manifest.mcp_servers.is_some());
425        assert!(manifest.prompt_sections.is_some());
426        assert!(manifest.output_styles.is_some());
427        assert!(manifest.user_config.is_some());
428    }
429
430    #[test]
431    fn parse_dependency_ref_bare() {
432        let json = json!("my-dep");
433        let dep: DependencyRef = serde_json::from_value(json).unwrap();
434        assert_eq!(dep.display(), "my-dep");
435        let id = dep.resolve("my-marketplace");
436        assert_eq!(id.to_string(), "my-dep@my-marketplace");
437    }
438
439    #[test]
440    fn parse_dependency_ref_qualified() {
441        let json = json!({"name": "dep", "marketplace": "other-mkt"});
442        let dep: DependencyRef = serde_json::from_value(json).unwrap();
443        assert_eq!(dep.display(), "dep@other-mkt");
444        let id = dep.resolve("my-marketplace");
445        assert_eq!(id.to_string(), "dep@other-mkt");
446    }
447
448    #[test]
449    fn parse_plugin_source_github() {
450        let json = json!({
451            "type": "github",
452            "repo": "owner/repo",
453            "ref": "main"
454        });
455        let source: PluginSource = serde_json::from_value(json).unwrap();
456        match source {
457            PluginSource::GitHub { repo, ref_, .. } => {
458                assert_eq!(repo, "owner/repo");
459                assert_eq!(ref_.as_deref(), Some("main"));
460            }
461            _ => panic!("expected GitHub source"),
462        }
463    }
464
465    #[test]
466    fn parse_plugin_source_local() {
467        let json = json!({
468            "type": "local",
469            "path": "/tmp/my-plugin"
470        });
471        let source: PluginSource = serde_json::from_value(json).unwrap();
472        match source {
473            PluginSource::Local { path } => {
474                assert_eq!(path, std::path::PathBuf::from("/tmp/my-plugin"));
475            }
476            _ => panic!("expected Local source"),
477        }
478    }
479
480    #[test]
481    fn parse_mcp_servers_inline() {
482        let json = json!({
483            "my-server": {
484                "command": "node",
485                "args": ["server.js"],
486                "autoConnect": true
487            }
488        });
489        let config: McpServersConfig = serde_json::from_value(json).unwrap();
490        match config {
491            McpServersConfig::Inline(servers) => {
492                assert_eq!(servers.len(), 1);
493                assert_eq!(servers.get("my-server").unwrap().command, "node");
494            }
495            McpServersConfig::File(_) => panic!("expected inline"),
496        }
497    }
498
499    #[test]
500    fn parse_mcp_servers_file() {
501        let json = json!("./.mcp.json");
502        let config: McpServersConfig = serde_json::from_value(json).unwrap();
503        match config {
504            McpServersConfig::File(path) => assert_eq!(path, "./.mcp.json"),
505            McpServersConfig::Inline(_) => panic!("expected file path"),
506        }
507    }
508}