1use std::collections::HashMap;
2use std::path::Path;
3
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use toml::Value;
7
8use crate::error::PluginError;
9use crate::hermes::{
10 looks_like_hermes_plugin, parse_hermes_manifest,
11 synthesize_manifest as synthesize_hermes_manifest,
12};
13use crate::skill::manifest::{SkillManifest, parse_skill_manifest};
14use crate::types::{PluginKind, TrustLevel};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PluginManifest {
18 pub plugin: PluginMetadata,
19 #[serde(default)]
20 pub exec: Option<PluginExecConfig>,
21 #[serde(default)]
22 pub script: Option<PluginScriptConfig>,
23 #[serde(default)]
24 pub tools: Vec<PluginToolDefinition>,
25 #[serde(default)]
26 pub capabilities: PluginCapabilities,
27 #[serde(default)]
28 pub trust: Option<PluginTrustConfig>,
29 #[serde(default)]
30 pub integrity: Option<PluginIntegrityConfig>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PluginMetadata {
35 pub name: String,
36 pub version: String,
37 pub description: String,
38 pub kind: PluginKind,
39 #[serde(default)]
40 pub author: String,
41 #[serde(default)]
42 pub license: String,
43 #[serde(default)]
44 pub homepage: Option<String>,
45 #[serde(default)]
46 pub min_edgecrab_version: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct PluginExecConfig {
51 pub command: String,
52 #[serde(default)]
53 pub args: Vec<String>,
54 #[serde(default)]
55 pub cwd: Option<String>,
56 #[serde(default)]
57 pub env: HashMap<String, String>,
58 #[serde(default = "default_startup_timeout_secs")]
59 pub startup_timeout_secs: u64,
60 #[serde(default = "default_call_timeout_secs")]
61 pub call_timeout_secs: u64,
62 #[serde(default)]
63 pub restart_policy: PluginRestartPolicy,
64 #[serde(default = "default_restart_max_attempts")]
65 pub restart_max_attempts: u32,
66 #[serde(default = "default_idle_timeout_secs")]
67 pub idle_timeout_secs: u64,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct PluginScriptConfig {
72 pub file: String,
73 #[serde(default = "default_max_operations")]
74 pub max_operations: u64,
75 #[serde(default = "default_max_call_depth")]
76 pub max_call_depth: usize,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct PluginToolDefinition {
81 pub name: String,
82 pub description: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, Default)]
86pub struct PluginCapabilities {
87 #[serde(default)]
88 pub host: Vec<String>,
89 #[serde(default)]
90 pub secrets: Vec<String>,
91 #[serde(default)]
92 pub allowed_hosts: Vec<String>,
93 #[serde(default)]
94 pub allowed_paths: Vec<String>,
95 #[serde(default)]
96 pub required_host_toolsets: Vec<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PluginTrustConfig {
101 #[serde(default)]
102 pub level: TrustLevel,
103 #[serde(default)]
104 pub source: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PluginIntegrityConfig {
109 pub checksum: String,
110}
111
112pub const INSTALL_METADATA_FILE: &str = ".edgecrab-install.json";
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct InstallMetadata {
116 pub trust_level: TrustLevel,
117 pub source: String,
118 pub checksum: String,
119}
120
121#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
122#[serde(rename_all = "kebab-case")]
123pub enum PluginRestartPolicy {
124 Never,
125 #[default]
126 Once,
127 Always,
128}
129
130fn default_startup_timeout_secs() -> u64 {
131 10
132}
133
134fn default_call_timeout_secs() -> u64 {
135 60
136}
137
138fn default_idle_timeout_secs() -> u64 {
139 300
140}
141
142fn default_restart_max_attempts() -> u32 {
143 3
144}
145
146fn default_max_operations() -> u64 {
147 100_000
148}
149
150fn default_max_call_depth() -> usize {
151 50
152}
153
154pub fn parse_plugin_manifest(path: &Path) -> Result<PluginManifest, PluginError> {
155 let content = std::fs::read_to_string(path)?;
156 parse_plugin_manifest_str(path, &content)
157}
158
159pub fn parse_plugin_manifest_str(
160 path: &Path,
161 content: &str,
162) -> Result<PluginManifest, PluginError> {
163 let manifest: PluginManifest = toml::from_str(content)?;
164 validate_plugin_manifest(path, &manifest)?;
165 Ok(manifest)
166}
167
168pub fn ensure_installable_manifest(dir: &Path) -> Result<std::path::PathBuf, PluginError> {
169 let manifest_path = dir.join("plugin.toml");
170 if manifest_path.is_file() {
171 return Ok(manifest_path);
172 }
173
174 let manifest = if looks_like_hermes_plugin(dir) {
175 let hermes_manifest = parse_hermes_manifest(dir)?;
176 synthesize_hermes_manifest(dir, &hermes_manifest)
177 } else {
178 let skill_path = dir.join("SKILL.md");
179 if !skill_path.is_file() {
180 return Err(PluginError::MissingManifest {
181 path: manifest_path,
182 });
183 }
184 let skill_manifest = parse_skill_manifest(&skill_path)?;
185 synthesize_skill_manifest(&skill_manifest)
186 };
187
188 write_plugin_manifest(&manifest_path, &manifest)?;
189 Ok(manifest_path)
190}
191
192fn validate_plugin_manifest(path: &Path, manifest: &PluginManifest) -> Result<(), PluginError> {
193 let name_re = Regex::new(r"^[a-z0-9][a-z0-9._-]*$").expect("valid regex");
194 if manifest.plugin.name.trim().is_empty() {
195 return invalid_manifest(path, "plugin.name is required");
196 }
197 if manifest.plugin.name.len() > 64 || !name_re.is_match(&manifest.plugin.name) {
198 return invalid_manifest(
199 path,
200 "plugin.name must match [a-z0-9][a-z0-9._-]* and be <= 64 chars",
201 );
202 }
203
204 if manifest.plugin.version.trim().is_empty() {
205 return invalid_manifest(path, "plugin.version is required");
206 }
207 if manifest.plugin.description.trim().is_empty() || manifest.plugin.description.len() > 256 {
208 return invalid_manifest(path, "plugin.description must be 1..=256 characters");
209 }
210 if let Some(homepage) = manifest.plugin.homepage.as_deref() {
211 if !homepage.starts_with("https://") {
212 return invalid_manifest(path, "plugin.homepage must use https");
213 }
214 }
215
216 if let Some(trust) = manifest.trust.as_ref() {
217 if matches!(trust.level, TrustLevel::Official | TrustLevel::Trusted) {
218 let installer_stamped = trust
219 .source
220 .as_deref()
221 .is_some_and(|source| !source.trim().is_empty())
222 && manifest
223 .integrity
224 .as_ref()
225 .is_some_and(|integrity| !integrity.checksum.trim().is_empty());
226 if !installer_stamped {
227 return invalid_manifest(
228 path,
229 "plugin.toml cannot self-assign official or trusted trust level",
230 );
231 }
232 }
233 }
234
235 for tool in &manifest.tools {
236 if tool.name.trim().is_empty() {
237 return invalid_manifest(path, "tool names must be non-empty");
238 }
239 if !name_re.is_match(&tool.name) {
240 return invalid_manifest(path, "tool names must match [a-z0-9][a-z0-9._-]*");
241 }
242 }
243
244 match manifest.plugin.kind {
245 PluginKind::Skill => {}
246 PluginKind::ToolServer => {
247 let Some(exec) = manifest.exec.as_ref() else {
248 return invalid_manifest(path, "tool-server plugins require [exec]");
249 };
250 if exec.command.trim().is_empty() {
251 return invalid_manifest(path, "exec.command is required");
252 }
253 if !(1..=60).contains(&exec.startup_timeout_secs) {
254 return invalid_manifest(path, "exec.startup_timeout_secs must be 1..=60");
255 }
256 if !(1..=300).contains(&exec.call_timeout_secs) {
257 return invalid_manifest(path, "exec.call_timeout_secs must be 1..=300");
258 }
259 }
260 PluginKind::Script => {
261 let Some(script) = manifest.script.as_ref() else {
262 return invalid_manifest(path, "script plugins require [script]");
263 };
264 if script.file.trim().is_empty() {
265 return invalid_manifest(path, "script.file is required");
266 }
267 }
268 PluginKind::Hermes => {
269 let Some(exec) = manifest.exec.as_ref() else {
270 return invalid_manifest(path, "Hermes compatibility plugins require [exec]");
271 };
272 if exec.command.trim().is_empty() {
273 return invalid_manifest(path, "exec.command is required");
274 }
275 }
276 }
277
278 if !matches!(manifest.plugin.kind, PluginKind::Skill | PluginKind::Hermes)
279 && manifest.tools.is_empty()
280 {
281 return invalid_manifest(
282 path,
283 "runtime plugins must declare at least one [[tools]] entry",
284 );
285 }
286
287 Ok(())
288}
289
290pub fn write_install_metadata(
291 path: &Path,
292 trust_level: TrustLevel,
293 source: &str,
294 checksum: &str,
295) -> Result<(), PluginError> {
296 let content = std::fs::read_to_string(path)?;
297 let mut value: Value = toml::from_str(&content)?;
298 let root = value
299 .as_table_mut()
300 .ok_or_else(|| PluginError::InvalidManifest {
301 path: path.to_path_buf(),
302 message: "plugin manifest must be a TOML table".into(),
303 })?;
304
305 let trust = root
306 .entry("trust")
307 .or_insert_with(|| Value::Table(Default::default()))
308 .as_table_mut()
309 .ok_or_else(|| PluginError::InvalidManifest {
310 path: path.to_path_buf(),
311 message: "[trust] must be a TOML table".into(),
312 })?;
313 trust.insert(
314 "level".into(),
315 Value::String(
316 match trust_level {
317 TrustLevel::Official => "official",
318 TrustLevel::Trusted => "trusted",
319 TrustLevel::Community => "community",
320 TrustLevel::AgentCreated => "agent-created",
321 TrustLevel::Unverified => "unverified",
322 }
323 .into(),
324 ),
325 );
326 trust.insert("source".into(), Value::String(source.to_string()));
327
328 let integrity = root
329 .entry("integrity")
330 .or_insert_with(|| Value::Table(Default::default()))
331 .as_table_mut()
332 .ok_or_else(|| PluginError::InvalidManifest {
333 path: path.to_path_buf(),
334 message: "[integrity] must be a TOML table".into(),
335 })?;
336 integrity.insert("checksum".into(), Value::String(checksum.to_string()));
337
338 let rendered =
339 toml::to_string_pretty(&value).map_err(|error| PluginError::InvalidManifest {
340 path: path.to_path_buf(),
341 message: error.to_string(),
342 })?;
343 std::fs::write(path, rendered)?;
344 Ok(())
345}
346
347pub fn write_bundle_install_metadata(
348 dir: &Path,
349 trust_level: TrustLevel,
350 source: &str,
351 checksum: &str,
352) -> Result<(), PluginError> {
353 let metadata = InstallMetadata {
354 trust_level,
355 source: source.to_string(),
356 checksum: checksum.to_string(),
357 };
358 let path = dir.join(INSTALL_METADATA_FILE);
359 std::fs::write(
360 path,
361 serde_json::to_vec_pretty(&metadata).map_err(PluginError::Json)?,
362 )?;
363 Ok(())
364}
365
366pub fn read_bundle_install_metadata(dir: &Path) -> Option<InstallMetadata> {
367 let path = dir.join(INSTALL_METADATA_FILE);
368 let content = std::fs::read(path).ok()?;
369 serde_json::from_slice(&content).ok()
370}
371
372fn synthesize_skill_manifest(skill: &SkillManifest) -> PluginManifest {
373 PluginManifest {
374 plugin: PluginMetadata {
375 name: skill.name.clone(),
376 version: skill.version.clone().unwrap_or_else(|| "0.1.0".into()),
377 description: synthesize_skill_description(skill),
378 kind: PluginKind::Skill,
379 author: skill.author.clone().unwrap_or_default(),
380 license: skill.license.clone().unwrap_or_default(),
381 homepage: None,
382 min_edgecrab_version: None,
383 },
384 exec: None,
385 script: None,
386 tools: Vec::new(),
387 capabilities: PluginCapabilities::default(),
388 trust: None,
389 integrity: None,
390 }
391}
392
393fn synthesize_skill_description(skill: &SkillManifest) -> String {
394 let fallback = format!("Skill plugin '{}'", skill.name);
395 let description = if skill.description.trim().is_empty() {
396 fallback.as_str()
397 } else {
398 skill.description.trim()
399 };
400 description.chars().take(256).collect()
401}
402
403fn write_plugin_manifest(path: &Path, manifest: &PluginManifest) -> Result<(), PluginError> {
404 let rendered =
405 toml::to_string_pretty(manifest).map_err(|error| PluginError::InvalidManifest {
406 path: path.to_path_buf(),
407 message: error.to_string(),
408 })?;
409 std::fs::write(path, rendered)?;
410 Ok(())
411}
412
413fn invalid_manifest<T>(path: &Path, message: &str) -> Result<T, PluginError> {
414 Err(PluginError::InvalidManifest {
415 path: path.to_path_buf(),
416 message: message.into(),
417 })
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use tempfile::TempDir;
424
425 #[test]
426 fn rejects_invalid_name() {
427 let err = parse_plugin_manifest_str(
428 Path::new("/tmp/plugin.toml"),
429 r#"
430[plugin]
431name = "Bad Name"
432version = "1.0.0"
433description = "Demo"
434kind = "skill"
435"#,
436 )
437 .expect_err("invalid name rejected");
438
439 assert!(err.to_string().contains("plugin.name"));
440 }
441
442 #[test]
443 fn rejects_self_assigned_trusted_level() {
444 let err = parse_plugin_manifest_str(
445 Path::new("/tmp/plugin.toml"),
446 r#"
447[plugin]
448name = "demo"
449version = "1.0.0"
450description = "Demo"
451kind = "skill"
452
453[trust]
454level = "trusted"
455"#,
456 )
457 .expect_err("trusted self-assignment rejected");
458
459 assert!(err.to_string().contains("trust level"));
460 }
461
462 #[test]
463 fn accepts_installer_stamped_trusted_level_with_source_and_checksum() {
464 let manifest = parse_plugin_manifest_str(
465 Path::new("/tmp/plugin.toml"),
466 r#"
467[plugin]
468name = "demo"
469version = "1.0.0"
470description = "Demo"
471kind = "skill"
472
473[trust]
474level = "trusted"
475source = "hub:official/demo"
476
477[integrity]
478checksum = "sha256:abc123"
479"#,
480 )
481 .expect("installer-stamped trust should parse");
482
483 assert_eq!(
484 manifest.trust.as_ref().map(|trust| trust.level),
485 Some(TrustLevel::Trusted)
486 );
487 }
488
489 #[test]
490 fn synthesizes_install_manifest_for_local_hermes_bundle() {
491 let temp = TempDir::new().expect("tempdir");
492 std::fs::write(
493 temp.path().join("plugin.yaml"),
494 r#"
495name: calculator
496version: "1.0.0"
497description: Calculator plugin
498provides_tools:
499 - calculate
500"#,
501 )
502 .expect("write manifest");
503 std::fs::write(
504 temp.path().join("__init__.py"),
505 "def register(ctx):\n pass\n",
506 )
507 .expect("write init");
508
509 let manifest_path = ensure_installable_manifest(temp.path()).expect("manifest path");
510 let manifest = parse_plugin_manifest(&manifest_path).expect("parsed manifest");
511
512 assert_eq!(manifest.plugin.kind, PluginKind::Hermes);
513 assert_eq!(manifest.plugin.name, "calculator");
514 }
515
516 #[test]
517 fn synthesizes_install_manifest_for_local_skill_bundle() {
518 let temp = TempDir::new().expect("tempdir");
519 std::fs::write(
520 temp.path().join("SKILL.md"),
521 r#"---
522name: github-issues
523description: Manage GitHub issues with a long but valid description.
524version: 1.1.0
525---
526
527# GitHub Issues
528
529Body.
530"#,
531 )
532 .expect("write skill");
533
534 let manifest_path = ensure_installable_manifest(temp.path()).expect("manifest path");
535 let manifest = parse_plugin_manifest(&manifest_path).expect("parsed manifest");
536
537 assert_eq!(manifest.plugin.kind, PluginKind::Skill);
538 assert_eq!(manifest.plugin.name, "github-issues");
539 assert_eq!(manifest.plugin.version, "1.1.0");
540 }
541}