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 secret_env: BTreeMap::new(),
245 binary_sha256: self.mcp.sha256.clone(),
246 url: self.mcp.url.clone(),
247 headers: self.mcp.headers.clone(),
248 secret_headers: BTreeMap::new(),
249 capabilities: self.capabilities.clone(),
250 integration: self.integration_kind(),
253 }
254 }
255
256 pub fn is_installable(&self) -> bool {
260 self.to_gateway_server().resolve().is_ok()
261 }
262}
263
264fn is_slug(s: &str) -> bool {
265 !s.is_empty()
266 && !s.starts_with('-')
267 && !s.ends_with('-')
268 && s.chars()
269 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 fn stdio_manifest() -> AddonManifest {
277 AddonManifest::from_toml(
278 r#"
279[addon]
280name = "demo"
281display_name = "Demo Addon"
282version = "1.2.3"
283description = "A demo"
284author = "tester"
285categories = ["search"]
286keywords = ["alpha", "beta"]
287
288[mcp]
289transport = "stdio"
290command = "demo-mcp"
291args = ["serve"]
292"#,
293 )
294 .expect("parse")
295 }
296
297 #[test]
298 fn parses_full_stdio_manifest() {
299 let m = stdio_manifest();
300 assert_eq!(m.addon.name, "demo");
301 assert_eq!(m.display_name(), "Demo Addon");
302 assert_eq!(m.mcp.transport, TransportKind::Stdio);
303 assert_eq!(m.mcp.command, "demo-mcp");
304 assert!(m.is_installable());
305 let srv = m.to_gateway_server();
306 assert_eq!(srv.name, "demo");
307 assert_eq!(srv.args, vec!["serve".to_string()]);
308 assert!(srv.enabled);
309 }
310
311 #[test]
312 fn listed_only_entry_is_not_installable() {
313 let m = AddonManifest::from_toml(
314 r#"
315[addon]
316name = "listed"
317description = "no mcp block"
318homepage = "https://example.com"
319"#,
320 )
321 .expect("parse");
322 assert!(m.validate().is_ok());
323 assert!(!m.is_installable(), "no [mcp] block → listed only");
324 }
325
326 #[test]
327 fn http_manifest_is_installable() {
328 let m = AddonManifest::from_toml(
329 r#"
330[addon]
331name = "remote"
332
333[mcp]
334transport = "http"
335url = "https://example.com/mcp"
336"#,
337 )
338 .expect("parse");
339 assert!(m.is_installable());
340 assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
341 }
342
343 #[test]
344 fn display_name_falls_back_to_slug() {
345 let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
346 assert_eq!(m.display_name(), "slug-only");
347 }
348
349 #[test]
350 fn capabilities_block_parses_and_threads_to_gateway() {
351 let m = AddonManifest::from_toml(
352 r#"
353[addon]
354name = "caps"
355
356[mcp]
357transport = "stdio"
358command = "caps-mcp"
359
360[capabilities]
361network = "full"
362filesystem = "read_write"
363env = ["GITHUB_TOKEN"]
364"#,
365 )
366 .expect("parse");
367 let caps = m.capabilities.as_ref().expect("capabilities present");
368 assert!(caps.network_allowed());
369 assert!(caps.filesystem_writable());
370 assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
371 assert_eq!(m.to_gateway_server().capabilities, m.capabilities);
373 }
374
375 #[test]
376 fn absent_capabilities_is_none() {
377 let m = stdio_manifest();
378 assert!(m.capabilities.is_none(), "no [capabilities] → legacy path");
379 assert!(m.to_gateway_server().capabilities.is_none());
380 }
381
382 #[test]
383 fn invalid_capability_env_name_fails_validation() {
384 let m = AddonManifest::from_toml(
385 "[addon]\nname = \"bad\"\n[capabilities]\nenv = [\"bad name\"]\n",
386 )
387 .expect("parse");
388 assert!(m.validate().is_err());
389 }
390
391 #[test]
392 fn rejects_missing_and_bad_names() {
393 assert!(AddonManifest::default().validate().is_err());
394 let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
395 assert!(bad.validate().is_err());
396 let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
397 assert!(bad2.validate().is_err());
398 }
399
400 #[test]
401 fn install_block_parses_validates_and_records_receipt() {
402 let m = AddonManifest::from_toml(
403 r#"
404[addon]
405name = "boot"
406
407[mcp]
408transport = "stdio"
409command = "boot"
410args = ["serve"]
411
412[install]
413manager = "uv"
414package = "boot-ai[mcp]"
415version = "1.4.2"
416bin = "boot"
417"#,
418 )
419 .expect("parse");
420 assert!(m.install.is_declared());
421 assert!(m.validate().is_ok());
422 assert!(m.is_installable(), "an installed-binary command resolves");
423 let receipt = m.install.to_receipt();
424 assert_eq!(receipt.manager, "uv");
425 assert_eq!(receipt.bin, "boot");
426 assert_eq!(
427 m.install.install_argv(),
428 ["tool", "install", "boot-ai[mcp]==1.4.2"]
429 );
430 }
431
432 #[test]
433 fn install_block_with_bad_pin_fails_manifest_validation() {
434 let m = AddonManifest::from_toml(
435 "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
436 [install]\nmanager = \"uv\"\npackage = \"boot\"\nversion = \"latest\"\n",
437 )
438 .expect("parse");
439 assert!(m.validate().is_err(), "floating version is rejected");
440 }
441
442 #[test]
443 fn absent_install_block_is_default() {
444 let m = stdio_manifest();
445 assert!(!m.install.is_declared(), "no [install] → no bootstrap");
446 }
447
448 #[test]
449 fn slug_validation() {
450 assert!(is_slug("lmd"));
451 assert!(is_slug("my-addon-2"));
452 assert!(!is_slug("Bad"));
453 assert!(!is_slug("-x"));
454 assert!(!is_slug("x-"));
455 assert!(!is_slug("under_score"));
456 assert!(!is_slug(""));
457 }
458
459 fn artifacts_manifest() -> AddonManifest {
462 AddonManifest::from_toml(
463 r#"
464[addon]
465name = "lean-md"
466version = "0.2.0"
467
468[mcp]
469transport = "stdio"
470command = "lean-md"
471args = ["mcp"]
472
473[artifacts.aarch64-apple-darwin]
474filename = "lean-md-aarch64-apple-darwin"
475url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-aarch64-apple-darwin"
476sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
477
478[artifacts.x86_64-unknown-linux-gnu]
479filename = "lean-md-x86_64-unknown-linux-gnu"
480url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-x86_64-unknown-linux-gnu"
481sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
482"#,
483 )
484 .expect("parse")
485 }
486
487 #[test]
488 fn artifacts_block_parses_and_validates() {
489 let m = artifacts_manifest();
490 assert!(m.validate().is_ok());
491 assert_eq!(m.artifacts.len(), 2);
492 let asset = &m.artifacts["aarch64-apple-darwin"];
493 assert_eq!(asset.filename, "lean-md-aarch64-apple-darwin");
494 assert_eq!(asset.sha256, "a".repeat(64));
495 }
496
497 #[test]
498 fn unpinned_artifact_fails_validation() {
499 let mut m = artifacts_manifest();
500 m.artifacts.get_mut("aarch64-apple-darwin").unwrap().sha256 = String::new();
501 let err = m.validate().unwrap_err();
502 assert!(err.contains("sha256"), "got: {err}");
503 }
504
505 #[test]
506 fn artifact_missing_url_fails_validation() {
507 let mut m = artifacts_manifest();
508 m.artifacts.get_mut("aarch64-apple-darwin").unwrap().url = String::new();
509 let err = m.validate().unwrap_err();
510 assert!(err.contains("url"), "got: {err}");
511 }
512
513 #[test]
514 fn artifact_for_current_platform_resolves_by_triple() {
515 let m = artifacts_manifest();
516 let triple = super::super::artifact_install::current_target_triple();
517 assert_eq!(
518 m.artifact_for_current_platform().is_some(),
519 m.artifacts.contains_key(triple)
520 );
521 }
522
523 #[test]
526 fn absent_artifacts_is_empty_and_not_serialized() {
527 let m = stdio_manifest();
528 assert!(m.artifacts.is_empty());
529 let toml = toml::to_string(&m).expect("serialize");
530 assert!(!toml.contains("[artifacts"), "got: {toml}");
531 }
532
533 const DEP_MANIFEST: &str = r#"
536[addon]
537name = "demo"
538version = "0.2.0"
539
540[mcp]
541command = "demo-bin"
542
543[mcp.env]
544LEAN_MD_SKILLS_DIR = "{pack_dir:@dasTholo/lean-md-skills}"
545
546[[dependencies]]
547name = "@dasTholo/lean-md-skills"
548version_req = "^0.2"
549"#;
550
551 #[test]
552 fn dependencies_parse_and_validate() {
553 let m = AddonManifest::from_toml(DEP_MANIFEST).expect("parses");
554 assert_eq!(m.dependencies.len(), 1);
555 assert_eq!(m.dependencies[0].name, "@dasTholo/lean-md-skills");
556 assert_eq!(m.dependencies[0].version_req, "^0.2");
557 assert!(!m.dependencies[0].optional);
558 m.validate().expect("valid");
559 }
560
561 #[test]
562 fn unscoped_dependency_name_is_rejected() {
563 let toml = DEP_MANIFEST.replace("@dasTholo/lean-md-skills", "lean-md-skills");
564 let err = AddonManifest::from_toml(&toml)
565 .expect("parses")
566 .validate()
567 .expect_err("unscoped");
568 assert!(err.contains("scoped `@ns/name`"), "{err}");
569 }
570
571 #[test]
572 fn bad_semver_range_is_rejected() {
573 let toml =
574 DEP_MANIFEST.replace(r#"version_req = "^0.2""#, r#"version_req = "not-a-range""#);
575 let err = AddonManifest::from_toml(&toml)
576 .expect("parses")
577 .validate()
578 .expect_err("bad range");
579 assert!(err.contains("invalid version range"), "{err}");
580 }
581
582 #[test]
583 fn optional_dependency_behind_a_placeholder_is_rejected() {
584 let toml = format!("{DEP_MANIFEST}optional = true\n");
585 let err = AddonManifest::from_toml(&toml)
586 .expect("parses")
587 .validate()
588 .expect_err("optional + placeholder");
589 assert!(err.contains("optional dependency"), "{err}");
590 }
591
592 #[test]
593 fn placeholder_naming_an_undeclared_pack_is_rejected() {
594 let toml = DEP_MANIFEST.replace(
595 "{pack_dir:@dasTholo/lean-md-skills}",
596 "{pack_dir:@dasTholo/other}",
597 );
598 let err = AddonManifest::from_toml(&toml)
599 .expect("parses")
600 .validate()
601 .expect_err("undeclared pack");
602 assert!(err.contains("not declared in [[dependencies]]"), "{err}");
603 }
604
605 #[test]
606 fn unknown_placeholder_scheme_is_rejected() {
607 let toml = DEP_MANIFEST.replace("pack_dir:", "bin_dir:");
608 let err = AddonManifest::from_toml(&toml)
609 .expect("parses")
610 .validate()
611 .expect_err("unknown scheme");
612 assert!(err.contains("unknown placeholder"), "{err}");
613 }
614
615 #[test]
630 fn expand_pack_env_maps_declared_dependency_to_pack_dir() {
631 use crate::core::context_package::deps::ResolvedDep;
632 use crate::core::context_package::remote::parse_remote_ref;
633
634 let m = AddonManifest::from_toml(DEP_MANIFEST).expect("parses");
635 m.validate().expect("valid");
636
637 let resolved: Vec<ResolvedDep> = m
639 .dependencies
640 .iter()
641 .map(|d| {
642 let r = parse_remote_ref(&d.name).expect("scoped");
643 ResolvedDep {
644 name: d.name.clone(),
645 namespace: r.namespace,
646 slug: r.name,
647 version: "0.2.0".into(),
648 artifact_sha256: "a".repeat(64),
649 }
650 })
651 .collect();
652
653 let out = crate::core::addons::pack_env::expand_pack_env(
654 &m.mcp.env,
655 &resolved,
656 std::path::Path::new("/store"),
657 )
658 .expect("expands against manifest.dependencies");
659 let expected = std::path::Path::new("/store")
663 .join("skills")
664 .join("@dasTholo__lean-md-skills")
665 .join("0.2.0")
666 .display()
667 .to_string();
668 assert_eq!(out["LEAN_MD_SKILLS_DIR"], expected);
669 }
670}