Skip to main content

lean_ctx/core/addons/
manifest.rs

1//! The `lean-ctx-addon.toml` manifest — the contract an addon author writes.
2//!
3//! The same shape is reused as a registry entry (see [`super::registry`]) so a
4//! curated catalog and a hand-written manifest deserialize into one type. An
5//! addon declares metadata (`[addon]`) and how lean-ctx runs its MCP server
6//! (`[mcp]`). A registry entry without a runnable `[mcp]` block is *listed*
7//! only (a directory entry that links to its homepage) — never installable
8//! with fabricated wiring.
9
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use super::bootstrap::AddonInstall;
15use super::capabilities::AddonCapabilities;
16use crate::core::gateway::{GatewayServer, TransportKind};
17
18/// `[addon]` — human + catalog metadata.
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(default)]
21pub struct AddonMeta {
22    /// Stable slug (`[a-z0-9-]`); becomes the gateway server name.
23    pub name: String,
24    /// Human-friendly name for UIs (falls back to `name`).
25    pub display_name: String,
26    /// Author-declared version (free-form; may be empty for listed-only entries).
27    pub version: String,
28    /// One-line description shown in `addon list` / the website.
29    pub description: String,
30    /// Maintainer / org.
31    pub author: String,
32    /// Project homepage or repository URL.
33    pub homepage: String,
34    /// SPDX license id (e.g. `Apache-2.0`).
35    pub license: String,
36    /// Coarse buckets for browsing (e.g. `plans`, `workflow`, `search`).
37    pub categories: Vec<String>,
38    /// Typed-integration adapter for the gateway output pipeline (#1096, L4).
39    /// Empty = derive from [`Self::categories`]. An explicit value forces a
40    /// specific adapter: `codebase-pack` | `code-graph` | `code-symbols` |
41    /// `memory` | `compression` | `none`. Recorded into the installed
42    /// `[[gateway.servers]]` entry so the proxy can route output without a
43    /// catalog lookup on the hot path.
44    pub integration: String,
45    /// Free-form search keywords.
46    pub keywords: Vec<String>,
47    /// Minimum lean-ctx version the addon targets (informational).
48    pub min_lean_ctx: String,
49    /// Trust tier. `true` **only** for entries audited and vouched by
50    /// maintainers in the curated registry; community submissions stay `false`.
51    /// Author-set in a local manifest is meaningless — trust is conferred by the
52    /// registry the entry ships in, not by the entry claiming it.
53    pub verified: bool,
54}
55
56/// `[mcp]` — how lean-ctx launches/connects to the addon's MCP server.
57///
58/// Mirrors [`GatewayServer`]'s transport fields so installation is a direct
59/// translation. Absent (default) → the entry is listed-only, not installable.
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61#[serde(default)]
62pub struct AddonMcp {
63    /// `stdio` (spawn `command`) or `http` (connect to `url`).
64    pub transport: TransportKind,
65    /// Executable to spawn (stdio transport).
66    pub command: String,
67    /// Arguments passed to `command`.
68    pub args: Vec<String>,
69    /// Extra environment variables for the child process.
70    pub env: BTreeMap<String, String>,
71    /// Optional SHA-256 pin of the stdio `command` binary (P3 supply-chain). The
72    /// value `sha256sum`/`shasum -a 256` prints; the gateway refuses to spawn a
73    /// binary whose hash does not match. Empty = unpinned.
74    pub sha256: String,
75    /// Streamable-HTTP endpoint (http transport).
76    pub url: String,
77    /// Extra request headers (e.g. auth) for the http transport.
78    pub headers: BTreeMap<String, String>,
79}
80
81/// A full addon manifest / registry entry.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct AddonManifest {
84    pub addon: AddonMeta,
85    #[serde(default)]
86    pub mcp: AddonMcp,
87    /// `[capabilities]` — declared permissions (network/filesystem/env). Absent
88    /// (`None`) keeps the legacy `addons.sandbox` behaviour; present opts the
89    /// addon into the per-addon, secure-by-default capability model (P1).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub capabilities: Option<AddonCapabilities>,
92    /// `[pricing]` — optional commerce metadata for a sellable addon (Track B).
93    /// Absent (`None`) ⇒ free. A paid entry must clear
94    /// [`super::commerce::paid_listing_gate`] before it may be listed/sold.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub pricing: Option<super::commerce::AddonPricing>,
97    /// `[install]` — optional bootstrap: provision the addon's upstream package
98    /// via a pinned package manager on `add` (#1105, Phase 2). Absent (empty) ⇒
99    /// the `[mcp]` command is expected to be runnable already (an installed
100    /// binary or an ephemeral `npx`/`uvx` runner).
101    #[serde(default, skip_serializing_if = "AddonInstall::is_absent")]
102    pub install: AddonInstall,
103    /// `[artifacts]` — optional prebuilt binaries keyed by Rust target triple
104    /// (GH #724/#725, Phase 1). When the current platform has an entry, `add`
105    /// downloads it into the managed bin dir (never `PATH`), pins its SHA-256
106    /// as the spawn-time binhash, and rewrites the gateway command to the
107    /// absolute managed path. Resolution order: `artifacts` → `[install]`
108    /// bootstrap → `[mcp] command` on `PATH`.
109    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
110    pub artifacts: BTreeMap<String, super::artifact_install::ArtifactAsset>,
111}
112
113impl AddonManifest {
114    /// Parse a manifest from TOML text (author's `lean-ctx-addon.toml`).
115    pub fn from_toml(text: &str) -> Result<Self, String> {
116        toml::from_str(text).map_err(|e| format!("invalid addon manifest: {e}"))
117    }
118
119    /// Read + parse + validate a manifest file from disk.
120    pub fn from_path(path: &Path) -> Result<Self, String> {
121        let raw = std::fs::read_to_string(path)
122            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
123        let manifest = Self::from_toml(&raw)?;
124        manifest.validate()?;
125        Ok(manifest)
126    }
127
128    /// Resolve the typed-integration adapter kind for this addon: the explicit
129    /// `addon.integration` if set, otherwise derived from `addon.categories`.
130    /// Returns the canonical adapter slug (or empty for none).
131    pub fn integration_kind(&self) -> String {
132        use crate::core::gateway::adapters::IntegrationKind;
133        let explicit = self.addon.integration.trim();
134        let kind = if explicit.is_empty() {
135            IntegrationKind::from_categories(&self.addon.categories)
136        } else {
137            IntegrationKind::parse(explicit)
138        };
139        kind.as_str().to_string()
140    }
141
142    /// Human name for display (falls back to the slug).
143    pub fn display_name(&self) -> &str {
144        if self.addon.display_name.trim().is_empty() {
145            &self.addon.name
146        } else {
147            &self.addon.display_name
148        }
149    }
150
151    /// Validate required metadata. Does **not** require a runnable `[mcp]`
152    /// block — that is [`Self::is_installable`].
153    pub fn validate(&self) -> Result<(), String> {
154        let name = self.addon.name.trim();
155        if name.is_empty() {
156            return Err("addon manifest is missing `addon.name`".into());
157        }
158        if !is_slug(name) {
159            return Err(format!(
160                "addon name `{name}` must be a slug (lowercase letters, digits and dashes, \
161                 no leading/trailing dash)"
162            ));
163        }
164        if let Some(caps) = &self.capabilities {
165            caps.validate()?;
166        }
167        self.install.validate()?;
168        for (triple, asset) in &self.artifacts {
169            if asset.filename.trim().is_empty() {
170                return Err(format!(
171                    "addon `{name}` artifact for `{triple}` is missing `filename`"
172                ));
173            }
174            if asset.url.trim().is_empty() {
175                return Err(format!(
176                    "addon `{name}` artifact for `{triple}` is missing `url`"
177                ));
178            }
179            if asset.sha256.trim().is_empty() {
180                return Err(format!(
181                    "addon `{name}` artifact for `{triple}` is missing `sha256` — a managed \
182                     binary must be pinned"
183                ));
184            }
185        }
186        Ok(())
187    }
188
189    /// The prebuilt artifact for the running platform, if this addon ships one.
190    pub fn artifact_for_current_platform(&self) -> Option<&super::artifact_install::ArtifactAsset> {
191        self.artifacts
192            .get(super::artifact_install::current_target_triple())
193    }
194
195    /// The gateway server entry this addon installs.
196    pub fn to_gateway_server(&self) -> GatewayServer {
197        GatewayServer {
198            name: self.addon.name.clone(),
199            transport: self.mcp.transport,
200            enabled: true,
201            command: self.mcp.command.clone(),
202            args: self.mcp.args.clone(),
203            env: self.mcp.env.clone(),
204            binary_sha256: self.mcp.sha256.clone(),
205            url: self.mcp.url.clone(),
206            headers: self.mcp.headers.clone(),
207            capabilities: self.capabilities.clone(),
208            // L4 routing: resolved from the explicit manifest field or derived
209            // from the addon's categories (#1096). Empty = generic L1-L3 only.
210            integration: self.integration_kind(),
211        }
212    }
213
214    /// True when the addon declares a runnable MCP endpoint (one-click
215    /// installable). A registry entry without a valid `[mcp]` block is *listed*
216    /// only and reports `false` here.
217    pub fn is_installable(&self) -> bool {
218        self.to_gateway_server().resolve().is_ok()
219    }
220}
221
222fn is_slug(s: &str) -> bool {
223    !s.is_empty()
224        && !s.starts_with('-')
225        && !s.ends_with('-')
226        && s.chars()
227            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn stdio_manifest() -> AddonManifest {
235        AddonManifest::from_toml(
236            r#"
237[addon]
238name = "demo"
239display_name = "Demo Addon"
240version = "1.2.3"
241description = "A demo"
242author = "tester"
243categories = ["search"]
244keywords = ["alpha", "beta"]
245
246[mcp]
247transport = "stdio"
248command = "demo-mcp"
249args = ["serve"]
250"#,
251        )
252        .expect("parse")
253    }
254
255    #[test]
256    fn parses_full_stdio_manifest() {
257        let m = stdio_manifest();
258        assert_eq!(m.addon.name, "demo");
259        assert_eq!(m.display_name(), "Demo Addon");
260        assert_eq!(m.mcp.transport, TransportKind::Stdio);
261        assert_eq!(m.mcp.command, "demo-mcp");
262        assert!(m.is_installable());
263        let srv = m.to_gateway_server();
264        assert_eq!(srv.name, "demo");
265        assert_eq!(srv.args, vec!["serve".to_string()]);
266        assert!(srv.enabled);
267    }
268
269    #[test]
270    fn listed_only_entry_is_not_installable() {
271        let m = AddonManifest::from_toml(
272            r#"
273[addon]
274name = "listed"
275description = "no mcp block"
276homepage = "https://example.com"
277"#,
278        )
279        .expect("parse");
280        assert!(m.validate().is_ok());
281        assert!(!m.is_installable(), "no [mcp] block → listed only");
282    }
283
284    #[test]
285    fn http_manifest_is_installable() {
286        let m = AddonManifest::from_toml(
287            r#"
288[addon]
289name = "remote"
290
291[mcp]
292transport = "http"
293url = "https://example.com/mcp"
294"#,
295        )
296        .expect("parse");
297        assert!(m.is_installable());
298        assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
299    }
300
301    #[test]
302    fn display_name_falls_back_to_slug() {
303        let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
304        assert_eq!(m.display_name(), "slug-only");
305    }
306
307    #[test]
308    fn capabilities_block_parses_and_threads_to_gateway() {
309        let m = AddonManifest::from_toml(
310            r#"
311[addon]
312name = "caps"
313
314[mcp]
315transport = "stdio"
316command = "caps-mcp"
317
318[capabilities]
319network = "full"
320filesystem = "read_write"
321env = ["GITHUB_TOKEN"]
322"#,
323        )
324        .expect("parse");
325        let caps = m.capabilities.as_ref().expect("capabilities present");
326        assert!(caps.network_allowed());
327        assert!(caps.filesystem_writable());
328        assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
329        // Flows into the gateway server entry that actually runs.
330        assert_eq!(m.to_gateway_server().capabilities, m.capabilities);
331    }
332
333    #[test]
334    fn absent_capabilities_is_none() {
335        let m = stdio_manifest();
336        assert!(m.capabilities.is_none(), "no [capabilities] → legacy path");
337        assert!(m.to_gateway_server().capabilities.is_none());
338    }
339
340    #[test]
341    fn invalid_capability_env_name_fails_validation() {
342        let m = AddonManifest::from_toml(
343            "[addon]\nname = \"bad\"\n[capabilities]\nenv = [\"bad name\"]\n",
344        )
345        .expect("parse");
346        assert!(m.validate().is_err());
347    }
348
349    #[test]
350    fn rejects_missing_and_bad_names() {
351        assert!(AddonManifest::default().validate().is_err());
352        let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
353        assert!(bad.validate().is_err());
354        let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
355        assert!(bad2.validate().is_err());
356    }
357
358    #[test]
359    fn install_block_parses_validates_and_records_receipt() {
360        let m = AddonManifest::from_toml(
361            r#"
362[addon]
363name = "boot"
364
365[mcp]
366transport = "stdio"
367command = "boot"
368args = ["serve"]
369
370[install]
371manager = "uv"
372package = "boot-ai[mcp]"
373version = "1.4.2"
374bin = "boot"
375"#,
376        )
377        .expect("parse");
378        assert!(m.install.is_declared());
379        assert!(m.validate().is_ok());
380        assert!(m.is_installable(), "an installed-binary command resolves");
381        let receipt = m.install.to_receipt();
382        assert_eq!(receipt.manager, "uv");
383        assert_eq!(receipt.bin, "boot");
384        assert_eq!(
385            m.install.install_argv(),
386            ["tool", "install", "boot-ai[mcp]==1.4.2"]
387        );
388    }
389
390    #[test]
391    fn install_block_with_bad_pin_fails_manifest_validation() {
392        let m = AddonManifest::from_toml(
393            "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
394             [install]\nmanager = \"uv\"\npackage = \"boot\"\nversion = \"latest\"\n",
395        )
396        .expect("parse");
397        assert!(m.validate().is_err(), "floating version is rejected");
398    }
399
400    #[test]
401    fn absent_install_block_is_default() {
402        let m = stdio_manifest();
403        assert!(!m.install.is_declared(), "no [install] → no bootstrap");
404    }
405
406    #[test]
407    fn slug_validation() {
408        assert!(is_slug("lmd"));
409        assert!(is_slug("my-addon-2"));
410        assert!(!is_slug("Bad"));
411        assert!(!is_slug("-x"));
412        assert!(!is_slug("x-"));
413        assert!(!is_slug("under_score"));
414        assert!(!is_slug(""));
415    }
416
417    // ── [artifacts] — managed prebuilt binaries (GH #724/#725) ──
418
419    fn artifacts_manifest() -> AddonManifest {
420        AddonManifest::from_toml(
421            r#"
422[addon]
423name = "lean-md"
424version = "0.2.0"
425
426[mcp]
427transport = "stdio"
428command = "lean-md"
429args = ["mcp"]
430
431[artifacts.aarch64-apple-darwin]
432filename = "lean-md-aarch64-apple-darwin"
433url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-aarch64-apple-darwin"
434sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
435
436[artifacts.x86_64-unknown-linux-gnu]
437filename = "lean-md-x86_64-unknown-linux-gnu"
438url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-x86_64-unknown-linux-gnu"
439sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
440"#,
441        )
442        .expect("parse")
443    }
444
445    #[test]
446    fn artifacts_block_parses_and_validates() {
447        let m = artifacts_manifest();
448        assert!(m.validate().is_ok());
449        assert_eq!(m.artifacts.len(), 2);
450        let asset = &m.artifacts["aarch64-apple-darwin"];
451        assert_eq!(asset.filename, "lean-md-aarch64-apple-darwin");
452        assert_eq!(asset.sha256, "a".repeat(64));
453    }
454
455    #[test]
456    fn unpinned_artifact_fails_validation() {
457        let mut m = artifacts_manifest();
458        m.artifacts.get_mut("aarch64-apple-darwin").unwrap().sha256 = String::new();
459        let err = m.validate().unwrap_err();
460        assert!(err.contains("sha256"), "got: {err}");
461    }
462
463    #[test]
464    fn artifact_missing_url_fails_validation() {
465        let mut m = artifacts_manifest();
466        m.artifacts.get_mut("aarch64-apple-darwin").unwrap().url = String::new();
467        let err = m.validate().unwrap_err();
468        assert!(err.contains("url"), "got: {err}");
469    }
470
471    #[test]
472    fn artifact_for_current_platform_resolves_by_triple() {
473        let m = artifacts_manifest();
474        let triple = super::super::artifact_install::current_target_triple();
475        assert_eq!(
476            m.artifact_for_current_platform().is_some(),
477            m.artifacts.contains_key(triple)
478        );
479    }
480
481    /// Manifests without `[artifacts]` (all pre-#725 entries) parse, validate
482    /// and serialize exactly as before — the field is additive-only.
483    #[test]
484    fn absent_artifacts_is_empty_and_not_serialized() {
485        let m = stdio_manifest();
486        assert!(m.artifacts.is_empty());
487        let toml = toml::to_string(&m).expect("serialize");
488        assert!(!toml.contains("[artifacts"), "got: {toml}");
489    }
490}