1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8use crate::integrations::plugin::PluginId;
9
10#[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#[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#[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 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 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
90fn 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
165#[serde(untagged)]
166pub enum McpServersConfig {
167 File(String),
169 Inline(HashMap<String, McpServerEntry>),
171}
172
173#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(untagged)]
197pub enum LspServersConfig {
198 File(String),
199 Inline(HashMap<String, LspServerEntry>),
200}
201
202#[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
249 pub user_config: Option<HashMap<String, UserConfigOption>>,
250}
251
252pub type PartialPluginManifest = Value;
256
257#[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#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(tag = "type", rename_all = "lowercase")]
286pub enum PluginSource {
287 Local { path: PathBuf },
289 #[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 #[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 #[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 #[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 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}