1use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use super::bootstrap::AddonInstall;
15use super::capabilities::AddonCapabilities;
16use crate::core::mcp_catalog::{GatewayServer, TransportKind};
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(default)]
21pub struct AddonMeta {
22 pub name: String,
24 pub display_name: String,
26 pub version: String,
28 pub description: String,
30 pub author: String,
32 pub homepage: String,
34 pub license: String,
36 pub categories: Vec<String>,
38 pub integration: String,
45 pub keywords: Vec<String>,
47 pub min_lean_ctx: String,
49 pub verified: bool,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61#[serde(default)]
62pub struct AddonMcp {
63 pub transport: TransportKind,
65 pub command: String,
67 pub args: Vec<String>,
69 pub env: BTreeMap<String, String>,
71 pub sha256: String,
75 pub url: String,
77 pub headers: BTreeMap<String, String>,
79}
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct AddonManifest {
84 pub addon: AddonMeta,
85 #[serde(default)]
86 pub mcp: AddonMcp,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub capabilities: Option<AddonCapabilities>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub pricing: Option<super::commerce::AddonPricing>,
97 #[serde(default, skip_serializing_if = "AddonInstall::is_absent")]
102 pub install: AddonInstall,
103 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
110 pub artifacts: BTreeMap<String, super::artifact_install::ArtifactAsset>,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub dependencies: Vec<crate::core::context_package::manifest::PackageDependency>,
118}
119
120impl AddonManifest {
121 pub fn from_toml(text: &str) -> Result<Self, String> {
123 toml::from_str(text).map_err(|e| format!("invalid addon manifest: {e}"))
124 }
125
126 pub fn from_path(path: &Path) -> Result<Self, String> {
128 let raw = std::fs::read_to_string(path)
129 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
130 let manifest = Self::from_toml(&raw)?;
131 manifest.validate()?;
132 Ok(manifest)
133 }
134
135 pub fn integration_kind(&self) -> String {
139 use crate::core::mcp_catalog::adapters::IntegrationKind;
140 let explicit = self.addon.integration.trim();
141 let kind = if explicit.is_empty() {
142 IntegrationKind::from_categories(&self.addon.categories)
143 } else {
144 IntegrationKind::parse(explicit)
145 };
146 kind.as_str().to_string()
147 }
148
149 pub fn display_name(&self) -> &str {
151 if self.addon.display_name.trim().is_empty() {
152 &self.addon.name
153 } else {
154 &self.addon.display_name
155 }
156 }
157
158 pub fn validate(&self) -> Result<(), String> {
161 let name = self.addon.name.trim();
162 if name.is_empty() {
163 return Err("addon manifest is missing `addon.name`".into());
164 }
165 if !is_slug(name) {
166 return Err(format!(
167 "addon name `{name}` must be a slug (lowercase letters, digits and dashes, \
168 no leading/trailing dash)"
169 ));
170 }
171 if let Some(caps) = &self.capabilities {
172 caps.validate()?;
173 }
174 self.install.validate()?;
175 for (triple, asset) in &self.artifacts {
176 if asset.filename.trim().is_empty() {
177 return Err(format!(
178 "addon `{name}` artifact for `{triple}` is missing `filename`"
179 ));
180 }
181 if asset.url.trim().is_empty() {
182 return Err(format!(
183 "addon `{name}` artifact for `{triple}` is missing `url`"
184 ));
185 }
186 if asset.sha256.trim().is_empty() {
187 return Err(format!(
188 "addon `{name}` artifact for `{triple}` is missing `sha256` — a managed \
189 binary must be pinned"
190 ));
191 }
192 }
193 for dep in &self.dependencies {
194 if crate::core::context_package::remote::parse_remote_ref(&dep.name).is_none() {
195 return Err(format!(
196 "addon `{name}` dependency `{}` must be a scoped `@ns/name` reference",
197 dep.name
198 ));
199 }
200 crate::core::context_package::deps::parse_version_req(&dep.version_req)
201 .map_err(|e| format!("addon `{name}` dependency `{}`: {e}", dep.name))?;
202 }
203
204 for (key, value) in &self.mcp.env {
208 let refs = super::pack_env::referenced_packs(value)
209 .map_err(|e| format!("addon `{name}` [mcp.env] `{key}`: {e}"))?;
210 for pack in refs {
211 let Some(dep) = self.dependencies.iter().find(|d| d.name == pack) else {
212 return Err(format!(
213 "addon `{name}` [mcp.env] `{key}`: `{{pack_dir:{pack}}}` names a pack that is \
214 not declared in [[dependencies]]"
215 ));
216 };
217 if dep.optional {
218 return Err(format!(
219 "addon `{name}` [mcp.env] `{key}`: `{{pack_dir:{pack}}}` refers to an optional \
220 dependency — optional dependencies are never resolved, so the placeholder \
221 could never expand"
222 ));
223 }
224 }
225 }
226 Ok(())
227 }
228
229 pub fn artifact_for_current_platform(&self) -> Option<&super::artifact_install::ArtifactAsset> {
231 self.artifacts
232 .get(super::artifact_install::current_target_triple())
233 }
234
235 pub fn to_gateway_server(&self) -> GatewayServer {
237 GatewayServer {
238 name: self.addon.name.clone(),
239 transport: self.mcp.transport,
240 enabled: true,
241 command: self.mcp.command.clone(),
242 args: self.mcp.args.clone(),
243 env: self.mcp.env.clone(),
244 binary_sha256: self.mcp.sha256.clone(),
245 url: self.mcp.url.clone(),
246 headers: self.mcp.headers.clone(),
247 capabilities: self.capabilities.clone(),
248 integration: self.integration_kind(),
251 }
252 }
253
254 pub fn is_installable(&self) -> bool {
258 self.to_gateway_server().resolve().is_ok()
259 }
260}
261
262fn is_slug(s: &str) -> bool {
263 !s.is_empty()
264 && !s.starts_with('-')
265 && !s.ends_with('-')
266 && s.chars()
267 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn stdio_manifest() -> AddonManifest {
275 AddonManifest::from_toml(
276 r#"
277[addon]
278name = "demo"
279display_name = "Demo Addon"
280version = "1.2.3"
281description = "A demo"
282author = "tester"
283categories = ["search"]
284keywords = ["alpha", "beta"]
285
286[mcp]
287transport = "stdio"
288command = "demo-mcp"
289args = ["serve"]
290"#,
291 )
292 .expect("parse")
293 }
294
295 #[test]
296 fn parses_full_stdio_manifest() {
297 let m = stdio_manifest();
298 assert_eq!(m.addon.name, "demo");
299 assert_eq!(m.display_name(), "Demo Addon");
300 assert_eq!(m.mcp.transport, TransportKind::Stdio);
301 assert_eq!(m.mcp.command, "demo-mcp");
302 assert!(m.is_installable());
303 let srv = m.to_gateway_server();
304 assert_eq!(srv.name, "demo");
305 assert_eq!(srv.args, vec!["serve".to_string()]);
306 assert!(srv.enabled);
307 }
308
309 #[test]
310 fn listed_only_entry_is_not_installable() {
311 let m = AddonManifest::from_toml(
312 r#"
313[addon]
314name = "listed"
315description = "no mcp block"
316homepage = "https://example.com"
317"#,
318 )
319 .expect("parse");
320 assert!(m.validate().is_ok());
321 assert!(!m.is_installable(), "no [mcp] block → listed only");
322 }
323
324 #[test]
325 fn http_manifest_is_installable() {
326 let m = AddonManifest::from_toml(
327 r#"
328[addon]
329name = "remote"
330
331[mcp]
332transport = "http"
333url = "https://example.com/mcp"
334"#,
335 )
336 .expect("parse");
337 assert!(m.is_installable());
338 assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
339 }
340
341 #[test]
342 fn display_name_falls_back_to_slug() {
343 let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
344 assert_eq!(m.display_name(), "slug-only");
345 }
346
347 #[test]
348 fn capabilities_block_parses_and_threads_to_gateway() {
349 let m = AddonManifest::from_toml(
350 r#"
351[addon]
352name = "caps"
353
354[mcp]
355transport = "stdio"
356command = "caps-mcp"
357
358[capabilities]
359network = "full"
360filesystem = "read_write"
361env = ["GITHUB_TOKEN"]
362"#,
363 )
364 .expect("parse");
365 let caps = m.capabilities.as_ref().expect("capabilities present");
366 assert!(caps.network_allowed());
367 assert!(caps.filesystem_writable());
368 assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
369 assert_eq!(m.to_gateway_server().capabilities, m.capabilities);
371 }
372
373 #[test]
374 fn absent_capabilities_is_none() {
375 let m = stdio_manifest();
376 assert!(m.capabilities.is_none(), "no [capabilities] → legacy path");
377 assert!(m.to_gateway_server().capabilities.is_none());
378 }
379
380 #[test]
381 fn invalid_capability_env_name_fails_validation() {
382 let m = AddonManifest::from_toml(
383 "[addon]\nname = \"bad\"\n[capabilities]\nenv = [\"bad name\"]\n",
384 )
385 .expect("parse");
386 assert!(m.validate().is_err());
387 }
388
389 #[test]
390 fn rejects_missing_and_bad_names() {
391 assert!(AddonManifest::default().validate().is_err());
392 let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
393 assert!(bad.validate().is_err());
394 let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
395 assert!(bad2.validate().is_err());
396 }
397
398 #[test]
399 fn install_block_parses_validates_and_records_receipt() {
400 let m = AddonManifest::from_toml(
401 r#"
402[addon]
403name = "boot"
404
405[mcp]
406transport = "stdio"
407command = "boot"
408args = ["serve"]
409
410[install]
411manager = "uv"
412package = "boot-ai[mcp]"
413version = "1.4.2"
414bin = "boot"
415"#,
416 )
417 .expect("parse");
418 assert!(m.install.is_declared());
419 assert!(m.validate().is_ok());
420 assert!(m.is_installable(), "an installed-binary command resolves");
421 let receipt = m.install.to_receipt();
422 assert_eq!(receipt.manager, "uv");
423 assert_eq!(receipt.bin, "boot");
424 assert_eq!(
425 m.install.install_argv(),
426 ["tool", "install", "boot-ai[mcp]==1.4.2"]
427 );
428 }
429
430 #[test]
431 fn install_block_with_bad_pin_fails_manifest_validation() {
432 let m = AddonManifest::from_toml(
433 "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
434 [install]\nmanager = \"uv\"\npackage = \"boot\"\nversion = \"latest\"\n",
435 )
436 .expect("parse");
437 assert!(m.validate().is_err(), "floating version is rejected");
438 }
439
440 #[test]
441 fn absent_install_block_is_default() {
442 let m = stdio_manifest();
443 assert!(!m.install.is_declared(), "no [install] → no bootstrap");
444 }
445
446 #[test]
447 fn slug_validation() {
448 assert!(is_slug("lmd"));
449 assert!(is_slug("my-addon-2"));
450 assert!(!is_slug("Bad"));
451 assert!(!is_slug("-x"));
452 assert!(!is_slug("x-"));
453 assert!(!is_slug("under_score"));
454 assert!(!is_slug(""));
455 }
456
457 fn artifacts_manifest() -> AddonManifest {
460 AddonManifest::from_toml(
461 r#"
462[addon]
463name = "lean-md"
464version = "0.2.0"
465
466[mcp]
467transport = "stdio"
468command = "lean-md"
469args = ["mcp"]
470
471[artifacts.aarch64-apple-darwin]
472filename = "lean-md-aarch64-apple-darwin"
473url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-aarch64-apple-darwin"
474sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
475
476[artifacts.x86_64-unknown-linux-gnu]
477filename = "lean-md-x86_64-unknown-linux-gnu"
478url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-x86_64-unknown-linux-gnu"
479sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
480"#,
481 )
482 .expect("parse")
483 }
484
485 #[test]
486 fn artifacts_block_parses_and_validates() {
487 let m = artifacts_manifest();
488 assert!(m.validate().is_ok());
489 assert_eq!(m.artifacts.len(), 2);
490 let asset = &m.artifacts["aarch64-apple-darwin"];
491 assert_eq!(asset.filename, "lean-md-aarch64-apple-darwin");
492 assert_eq!(asset.sha256, "a".repeat(64));
493 }
494
495 #[test]
496 fn unpinned_artifact_fails_validation() {
497 let mut m = artifacts_manifest();
498 m.artifacts.get_mut("aarch64-apple-darwin").unwrap().sha256 = String::new();
499 let err = m.validate().unwrap_err();
500 assert!(err.contains("sha256"), "got: {err}");
501 }
502
503 #[test]
504 fn artifact_missing_url_fails_validation() {
505 let mut m = artifacts_manifest();
506 m.artifacts.get_mut("aarch64-apple-darwin").unwrap().url = String::new();
507 let err = m.validate().unwrap_err();
508 assert!(err.contains("url"), "got: {err}");
509 }
510
511 #[test]
512 fn artifact_for_current_platform_resolves_by_triple() {
513 let m = artifacts_manifest();
514 let triple = super::super::artifact_install::current_target_triple();
515 assert_eq!(
516 m.artifact_for_current_platform().is_some(),
517 m.artifacts.contains_key(triple)
518 );
519 }
520
521 #[test]
524 fn absent_artifacts_is_empty_and_not_serialized() {
525 let m = stdio_manifest();
526 assert!(m.artifacts.is_empty());
527 let toml = toml::to_string(&m).expect("serialize");
528 assert!(!toml.contains("[artifacts"), "got: {toml}");
529 }
530
531 const DEP_MANIFEST: &str = r#"
534[addon]
535name = "demo"
536version = "0.2.0"
537
538[mcp]
539command = "demo-bin"
540
541[mcp.env]
542LEAN_MD_SKILLS_DIR = "{pack_dir:@dasTholo/lean-md-skills}"
543
544[[dependencies]]
545name = "@dasTholo/lean-md-skills"
546version_req = "^0.2"
547"#;
548
549 #[test]
550 fn dependencies_parse_and_validate() {
551 let m = AddonManifest::from_toml(DEP_MANIFEST).expect("parses");
552 assert_eq!(m.dependencies.len(), 1);
553 assert_eq!(m.dependencies[0].name, "@dasTholo/lean-md-skills");
554 assert_eq!(m.dependencies[0].version_req, "^0.2");
555 assert!(!m.dependencies[0].optional);
556 m.validate().expect("valid");
557 }
558
559 #[test]
560 fn unscoped_dependency_name_is_rejected() {
561 let toml = DEP_MANIFEST.replace("@dasTholo/lean-md-skills", "lean-md-skills");
562 let err = AddonManifest::from_toml(&toml)
563 .expect("parses")
564 .validate()
565 .expect_err("unscoped");
566 assert!(err.contains("scoped `@ns/name`"), "{err}");
567 }
568
569 #[test]
570 fn bad_semver_range_is_rejected() {
571 let toml =
572 DEP_MANIFEST.replace(r#"version_req = "^0.2""#, r#"version_req = "not-a-range""#);
573 let err = AddonManifest::from_toml(&toml)
574 .expect("parses")
575 .validate()
576 .expect_err("bad range");
577 assert!(err.contains("invalid version range"), "{err}");
578 }
579
580 #[test]
581 fn optional_dependency_behind_a_placeholder_is_rejected() {
582 let toml = format!("{DEP_MANIFEST}optional = true\n");
583 let err = AddonManifest::from_toml(&toml)
584 .expect("parses")
585 .validate()
586 .expect_err("optional + placeholder");
587 assert!(err.contains("optional dependency"), "{err}");
588 }
589
590 #[test]
591 fn placeholder_naming_an_undeclared_pack_is_rejected() {
592 let toml = DEP_MANIFEST.replace(
593 "{pack_dir:@dasTholo/lean-md-skills}",
594 "{pack_dir:@dasTholo/other}",
595 );
596 let err = AddonManifest::from_toml(&toml)
597 .expect("parses")
598 .validate()
599 .expect_err("undeclared pack");
600 assert!(err.contains("not declared in [[dependencies]]"), "{err}");
601 }
602
603 #[test]
604 fn unknown_placeholder_scheme_is_rejected() {
605 let toml = DEP_MANIFEST.replace("pack_dir:", "bin_dir:");
606 let err = AddonManifest::from_toml(&toml)
607 .expect("parses")
608 .validate()
609 .expect_err("unknown scheme");
610 assert!(err.contains("unknown placeholder"), "{err}");
611 }
612
613 #[test]
628 fn expand_pack_env_maps_declared_dependency_to_pack_dir() {
629 use crate::core::context_package::deps::ResolvedDep;
630 use crate::core::context_package::remote::parse_remote_ref;
631
632 let m = AddonManifest::from_toml(DEP_MANIFEST).expect("parses");
633 m.validate().expect("valid");
634
635 let resolved: Vec<ResolvedDep> = m
637 .dependencies
638 .iter()
639 .map(|d| {
640 let r = parse_remote_ref(&d.name).expect("scoped");
641 ResolvedDep {
642 name: d.name.clone(),
643 namespace: r.namespace,
644 slug: r.name,
645 version: "0.2.0".into(),
646 artifact_sha256: "a".repeat(64),
647 }
648 })
649 .collect();
650
651 let out = crate::core::addons::pack_env::expand_pack_env(
652 &m.mcp.env,
653 &resolved,
654 std::path::Path::new("/store"),
655 )
656 .expect("expands against manifest.dependencies");
657 let expected = std::path::Path::new("/store")
661 .join("skills")
662 .join("@dasTholo__lean-md-skills")
663 .join("0.2.0")
664 .display()
665 .to_string();
666 assert_eq!(out["LEAN_MD_SKILLS_DIR"], expected);
667 }
668}