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}
104
105impl AddonManifest {
106    /// Parse a manifest from TOML text (author's `lean-ctx-addon.toml`).
107    pub fn from_toml(text: &str) -> Result<Self, String> {
108        toml::from_str(text).map_err(|e| format!("invalid addon manifest: {e}"))
109    }
110
111    /// Read + parse + validate a manifest file from disk.
112    pub fn from_path(path: &Path) -> Result<Self, String> {
113        let raw = std::fs::read_to_string(path)
114            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
115        let manifest = Self::from_toml(&raw)?;
116        manifest.validate()?;
117        Ok(manifest)
118    }
119
120    /// Resolve the typed-integration adapter kind for this addon: the explicit
121    /// `addon.integration` if set, otherwise derived from `addon.categories`.
122    /// Returns the canonical adapter slug (or empty for none).
123    pub fn integration_kind(&self) -> String {
124        use crate::core::gateway::adapters::IntegrationKind;
125        let explicit = self.addon.integration.trim();
126        let kind = if explicit.is_empty() {
127            IntegrationKind::from_categories(&self.addon.categories)
128        } else {
129            IntegrationKind::parse(explicit)
130        };
131        kind.as_str().to_string()
132    }
133
134    /// Human name for display (falls back to the slug).
135    pub fn display_name(&self) -> &str {
136        if self.addon.display_name.trim().is_empty() {
137            &self.addon.name
138        } else {
139            &self.addon.display_name
140        }
141    }
142
143    /// Validate required metadata. Does **not** require a runnable `[mcp]`
144    /// block — that is [`Self::is_installable`].
145    pub fn validate(&self) -> Result<(), String> {
146        let name = self.addon.name.trim();
147        if name.is_empty() {
148            return Err("addon manifest is missing `addon.name`".into());
149        }
150        if !is_slug(name) {
151            return Err(format!(
152                "addon name `{name}` must be a slug (lowercase letters, digits and dashes, \
153                 no leading/trailing dash)"
154            ));
155        }
156        if let Some(caps) = &self.capabilities {
157            caps.validate()?;
158        }
159        self.install.validate()?;
160        Ok(())
161    }
162
163    /// The gateway server entry this addon installs.
164    pub fn to_gateway_server(&self) -> GatewayServer {
165        GatewayServer {
166            name: self.addon.name.clone(),
167            transport: self.mcp.transport,
168            enabled: true,
169            command: self.mcp.command.clone(),
170            args: self.mcp.args.clone(),
171            env: self.mcp.env.clone(),
172            binary_sha256: self.mcp.sha256.clone(),
173            url: self.mcp.url.clone(),
174            headers: self.mcp.headers.clone(),
175            capabilities: self.capabilities.clone(),
176            // L4 routing: resolved from the explicit manifest field or derived
177            // from the addon's categories (#1096). Empty = generic L1-L3 only.
178            integration: self.integration_kind(),
179        }
180    }
181
182    /// True when the addon declares a runnable MCP endpoint (one-click
183    /// installable). A registry entry without a valid `[mcp]` block is *listed*
184    /// only and reports `false` here.
185    pub fn is_installable(&self) -> bool {
186        self.to_gateway_server().resolve().is_ok()
187    }
188}
189
190fn is_slug(s: &str) -> bool {
191    !s.is_empty()
192        && !s.starts_with('-')
193        && !s.ends_with('-')
194        && s.chars()
195            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn stdio_manifest() -> AddonManifest {
203        AddonManifest::from_toml(
204            r#"
205[addon]
206name = "demo"
207display_name = "Demo Addon"
208version = "1.2.3"
209description = "A demo"
210author = "tester"
211categories = ["search"]
212keywords = ["alpha", "beta"]
213
214[mcp]
215transport = "stdio"
216command = "demo-mcp"
217args = ["serve"]
218"#,
219        )
220        .expect("parse")
221    }
222
223    #[test]
224    fn parses_full_stdio_manifest() {
225        let m = stdio_manifest();
226        assert_eq!(m.addon.name, "demo");
227        assert_eq!(m.display_name(), "Demo Addon");
228        assert_eq!(m.mcp.transport, TransportKind::Stdio);
229        assert_eq!(m.mcp.command, "demo-mcp");
230        assert!(m.is_installable());
231        let srv = m.to_gateway_server();
232        assert_eq!(srv.name, "demo");
233        assert_eq!(srv.args, vec!["serve".to_string()]);
234        assert!(srv.enabled);
235    }
236
237    #[test]
238    fn listed_only_entry_is_not_installable() {
239        let m = AddonManifest::from_toml(
240            r#"
241[addon]
242name = "listed"
243description = "no mcp block"
244homepage = "https://example.com"
245"#,
246        )
247        .expect("parse");
248        assert!(m.validate().is_ok());
249        assert!(!m.is_installable(), "no [mcp] block → listed only");
250    }
251
252    #[test]
253    fn http_manifest_is_installable() {
254        let m = AddonManifest::from_toml(
255            r#"
256[addon]
257name = "remote"
258
259[mcp]
260transport = "http"
261url = "https://example.com/mcp"
262"#,
263        )
264        .expect("parse");
265        assert!(m.is_installable());
266        assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
267    }
268
269    #[test]
270    fn display_name_falls_back_to_slug() {
271        let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
272        assert_eq!(m.display_name(), "slug-only");
273    }
274
275    #[test]
276    fn capabilities_block_parses_and_threads_to_gateway() {
277        let m = AddonManifest::from_toml(
278            r#"
279[addon]
280name = "caps"
281
282[mcp]
283transport = "stdio"
284command = "caps-mcp"
285
286[capabilities]
287network = "full"
288filesystem = "read_write"
289env = ["GITHUB_TOKEN"]
290"#,
291        )
292        .expect("parse");
293        let caps = m.capabilities.as_ref().expect("capabilities present");
294        assert!(caps.network_allowed());
295        assert!(caps.filesystem_writable());
296        assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
297        // Flows into the gateway server entry that actually runs.
298        assert_eq!(m.to_gateway_server().capabilities, m.capabilities);
299    }
300
301    #[test]
302    fn absent_capabilities_is_none() {
303        let m = stdio_manifest();
304        assert!(m.capabilities.is_none(), "no [capabilities] → legacy path");
305        assert!(m.to_gateway_server().capabilities.is_none());
306    }
307
308    #[test]
309    fn invalid_capability_env_name_fails_validation() {
310        let m = AddonManifest::from_toml(
311            "[addon]\nname = \"bad\"\n[capabilities]\nenv = [\"bad name\"]\n",
312        )
313        .expect("parse");
314        assert!(m.validate().is_err());
315    }
316
317    #[test]
318    fn rejects_missing_and_bad_names() {
319        assert!(AddonManifest::default().validate().is_err());
320        let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
321        assert!(bad.validate().is_err());
322        let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
323        assert!(bad2.validate().is_err());
324    }
325
326    #[test]
327    fn install_block_parses_validates_and_records_receipt() {
328        let m = AddonManifest::from_toml(
329            r#"
330[addon]
331name = "boot"
332
333[mcp]
334transport = "stdio"
335command = "boot"
336args = ["serve"]
337
338[install]
339manager = "uv"
340package = "boot-ai[mcp]"
341version = "1.4.2"
342bin = "boot"
343"#,
344        )
345        .expect("parse");
346        assert!(m.install.is_declared());
347        assert!(m.validate().is_ok());
348        assert!(m.is_installable(), "an installed-binary command resolves");
349        let receipt = m.install.to_receipt();
350        assert_eq!(receipt.manager, "uv");
351        assert_eq!(receipt.bin, "boot");
352        assert_eq!(
353            m.install.install_argv(),
354            ["tool", "install", "boot-ai[mcp]==1.4.2"]
355        );
356    }
357
358    #[test]
359    fn install_block_with_bad_pin_fails_manifest_validation() {
360        let m = AddonManifest::from_toml(
361            "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
362             [install]\nmanager = \"uv\"\npackage = \"boot\"\nversion = \"latest\"\n",
363        )
364        .expect("parse");
365        assert!(m.validate().is_err(), "floating version is rejected");
366    }
367
368    #[test]
369    fn absent_install_block_is_default() {
370        let m = stdio_manifest();
371        assert!(!m.install.is_declared(), "no [install] → no bootstrap");
372    }
373
374    #[test]
375    fn slug_validation() {
376        assert!(is_slug("lmd"));
377        assert!(is_slug("my-addon-2"));
378        assert!(!is_slug("Bad"));
379        assert!(!is_slug("-x"));
380        assert!(!is_slug("x-"));
381        assert!(!is_slug("under_score"));
382        assert!(!is_slug(""));
383    }
384}