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 crate::core::gateway::{GatewayServer, TransportKind};
15
16/// `[addon]` — human + catalog metadata.
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18#[serde(default)]
19pub struct AddonMeta {
20    /// Stable slug (`[a-z0-9-]`); becomes the gateway server name.
21    pub name: String,
22    /// Human-friendly name for UIs (falls back to `name`).
23    pub display_name: String,
24    /// Author-declared version (free-form; may be empty for listed-only entries).
25    pub version: String,
26    /// One-line description shown in `addon list` / the website.
27    pub description: String,
28    /// Maintainer / org.
29    pub author: String,
30    /// Project homepage or repository URL.
31    pub homepage: String,
32    /// SPDX license id (e.g. `Apache-2.0`).
33    pub license: String,
34    /// Coarse buckets for browsing (e.g. `plans`, `workflow`, `search`).
35    pub categories: Vec<String>,
36    /// Free-form search keywords.
37    pub keywords: Vec<String>,
38    /// Minimum lean-ctx version the addon targets (informational).
39    pub min_lean_ctx: String,
40}
41
42/// `[mcp]` — how lean-ctx launches/connects to the addon's MCP server.
43///
44/// Mirrors [`GatewayServer`]'s transport fields so installation is a direct
45/// translation. Absent (default) → the entry is listed-only, not installable.
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47#[serde(default)]
48pub struct AddonMcp {
49    /// `stdio` (spawn `command`) or `http` (connect to `url`).
50    pub transport: TransportKind,
51    /// Executable to spawn (stdio transport).
52    pub command: String,
53    /// Arguments passed to `command`.
54    pub args: Vec<String>,
55    /// Extra environment variables for the child process.
56    pub env: BTreeMap<String, String>,
57    /// Streamable-HTTP endpoint (http transport).
58    pub url: String,
59    /// Extra request headers (e.g. auth) for the http transport.
60    pub headers: BTreeMap<String, String>,
61}
62
63/// A full addon manifest / registry entry.
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct AddonManifest {
66    pub addon: AddonMeta,
67    #[serde(default)]
68    pub mcp: AddonMcp,
69}
70
71impl AddonManifest {
72    /// Parse a manifest from TOML text (author's `lean-ctx-addon.toml`).
73    pub fn from_toml(text: &str) -> Result<Self, String> {
74        toml::from_str(text).map_err(|e| format!("invalid addon manifest: {e}"))
75    }
76
77    /// Read + parse + validate a manifest file from disk.
78    pub fn from_path(path: &Path) -> Result<Self, String> {
79        let raw = std::fs::read_to_string(path)
80            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
81        let manifest = Self::from_toml(&raw)?;
82        manifest.validate()?;
83        Ok(manifest)
84    }
85
86    /// Human name for display (falls back to the slug).
87    pub fn display_name(&self) -> &str {
88        if self.addon.display_name.trim().is_empty() {
89            &self.addon.name
90        } else {
91            &self.addon.display_name
92        }
93    }
94
95    /// Validate required metadata. Does **not** require a runnable `[mcp]`
96    /// block — that is [`Self::is_installable`].
97    pub fn validate(&self) -> Result<(), String> {
98        let name = self.addon.name.trim();
99        if name.is_empty() {
100            return Err("addon manifest is missing `addon.name`".into());
101        }
102        if !is_slug(name) {
103            return Err(format!(
104                "addon name `{name}` must be a slug (lowercase letters, digits and dashes, \
105                 no leading/trailing dash)"
106            ));
107        }
108        Ok(())
109    }
110
111    /// The gateway server entry this addon installs.
112    pub fn to_gateway_server(&self) -> GatewayServer {
113        GatewayServer {
114            name: self.addon.name.clone(),
115            transport: self.mcp.transport,
116            enabled: true,
117            command: self.mcp.command.clone(),
118            args: self.mcp.args.clone(),
119            env: self.mcp.env.clone(),
120            url: self.mcp.url.clone(),
121            headers: self.mcp.headers.clone(),
122        }
123    }
124
125    /// True when the addon declares a runnable MCP endpoint (one-click
126    /// installable). A registry entry without a valid `[mcp]` block is *listed*
127    /// only and reports `false` here.
128    pub fn is_installable(&self) -> bool {
129        self.to_gateway_server().resolve().is_ok()
130    }
131}
132
133fn is_slug(s: &str) -> bool {
134    !s.is_empty()
135        && !s.starts_with('-')
136        && !s.ends_with('-')
137        && s.chars()
138            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn stdio_manifest() -> AddonManifest {
146        AddonManifest::from_toml(
147            r#"
148[addon]
149name = "demo"
150display_name = "Demo Addon"
151version = "1.2.3"
152description = "A demo"
153author = "tester"
154categories = ["search"]
155keywords = ["alpha", "beta"]
156
157[mcp]
158transport = "stdio"
159command = "demo-mcp"
160args = ["serve"]
161"#,
162        )
163        .expect("parse")
164    }
165
166    #[test]
167    fn parses_full_stdio_manifest() {
168        let m = stdio_manifest();
169        assert_eq!(m.addon.name, "demo");
170        assert_eq!(m.display_name(), "Demo Addon");
171        assert_eq!(m.mcp.transport, TransportKind::Stdio);
172        assert_eq!(m.mcp.command, "demo-mcp");
173        assert!(m.is_installable());
174        let srv = m.to_gateway_server();
175        assert_eq!(srv.name, "demo");
176        assert_eq!(srv.args, vec!["serve".to_string()]);
177        assert!(srv.enabled);
178    }
179
180    #[test]
181    fn listed_only_entry_is_not_installable() {
182        let m = AddonManifest::from_toml(
183            r#"
184[addon]
185name = "listed"
186description = "no mcp block"
187homepage = "https://example.com"
188"#,
189        )
190        .expect("parse");
191        assert!(m.validate().is_ok());
192        assert!(!m.is_installable(), "no [mcp] block → listed only");
193    }
194
195    #[test]
196    fn http_manifest_is_installable() {
197        let m = AddonManifest::from_toml(
198            r#"
199[addon]
200name = "remote"
201
202[mcp]
203transport = "http"
204url = "https://example.com/mcp"
205"#,
206        )
207        .expect("parse");
208        assert!(m.is_installable());
209        assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
210    }
211
212    #[test]
213    fn display_name_falls_back_to_slug() {
214        let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
215        assert_eq!(m.display_name(), "slug-only");
216    }
217
218    #[test]
219    fn rejects_missing_and_bad_names() {
220        assert!(AddonManifest::default().validate().is_err());
221        let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
222        assert!(bad.validate().is_err());
223        let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
224        assert!(bad2.validate().is_err());
225    }
226
227    #[test]
228    fn slug_validation() {
229        assert!(is_slug("lmd"));
230        assert!(is_slug("my-addon-2"));
231        assert!(!is_slug("Bad"));
232        assert!(!is_slug("-x"));
233        assert!(!is_slug("x-"));
234        assert!(!is_slug("under_score"));
235        assert!(!is_slug(""));
236    }
237}