Skip to main content

lean_ctx/core/addons/
install.rs

1//! Install / remove logic: wire an addon's MCP server into the global gateway
2//! and record it in the installed store.
3//!
4//! Pure state mutation — any interactive confirmation belongs in the CLI layer
5//! (so this stays unit-testable). Installation goes through
6//! [`Config::update_global`], the canonical safe-persistence entry point: it
7//! reads only the global config (no project-local merge) and refuses to clobber
8//! an unparseable file.
9
10use super::manifest::AddonManifest;
11use super::store::{InstalledAddon, InstalledStore};
12use crate::core::config::Config;
13
14/// Result of a successful [`install`].
15pub struct InstallOutcome {
16    pub name: String,
17    pub gateway_server: String,
18    /// `true` when installation flipped `gateway.enabled` from off to on.
19    pub enabled_gateway: bool,
20}
21
22/// Wire `manifest` into the global gateway and record it in the store.
23///
24/// `source` is recorded for `addon list` (`"registry"` or `"local"`). Replaces
25/// any existing gateway server / store entry with the same name (idempotent
26/// re-install). Returns an error if the addon has no runnable MCP endpoint.
27pub fn install(manifest: &AddonManifest, source: &str) -> Result<InstallOutcome, String> {
28    manifest.validate()?;
29    let server = manifest.to_gateway_server();
30    server.resolve().map_err(|e| {
31        format!(
32            "addon `{}` has no runnable MCP endpoint: {e}",
33            manifest.addon.name
34        )
35    })?;
36
37    let name = manifest.addon.name.clone();
38    let server_name = server.name.clone();
39    let mut enabled_gateway = false;
40
41    Config::update_global(|cfg| {
42        if !cfg.gateway.enabled {
43            cfg.gateway.enabled = true;
44            enabled_gateway = true;
45        }
46        cfg.gateway.servers.retain(|s| s.name != server_name);
47        cfg.gateway.servers.push(server.clone());
48    })
49    .map_err(|e| e.to_string())?;
50
51    let mut store = InstalledStore::load();
52    store.upsert(InstalledAddon {
53        name: name.clone(),
54        version: manifest.addon.version.clone(),
55        source: source.to_string(),
56        gateway_server: server_name.clone(),
57    });
58    store.save()?;
59
60    crate::core::gateway::catalog::invalidate();
61
62    Ok(InstallOutcome {
63        name,
64        gateway_server: server_name,
65        enabled_gateway,
66    })
67}
68
69/// Result of a successful [`remove`].
70pub struct RemoveOutcome {
71    pub name: String,
72    pub gateway_server: String,
73    /// `true` when no addons remain installed afterwards.
74    pub last_removed: bool,
75}
76
77/// Unwire an installed addon: drop its gateway server and store entry.
78pub fn remove(name: &str) -> Result<RemoveOutcome, String> {
79    let mut store = InstalledStore::load();
80    let entry = store
81        .get(name)
82        .cloned()
83        .ok_or_else(|| format!("addon `{name}` is not installed"))?;
84    let server_name = entry.gateway_server.clone();
85
86    Config::update_global(|cfg| {
87        cfg.gateway.servers.retain(|s| s.name != server_name);
88    })
89    .map_err(|e| e.to_string())?;
90
91    store.remove(name);
92    let last_removed = store.addons.is_empty();
93    store.save()?;
94
95    crate::core::gateway::catalog::invalidate();
96
97    Ok(RemoveOutcome {
98        name: name.to_string(),
99        gateway_server: server_name,
100        last_removed,
101    })
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::core::data_dir::isolated_data_dir;
108
109    fn manifest(name: &str) -> AddonManifest {
110        AddonManifest::from_toml(&format!(
111            "[addon]\nname = \"{name}\"\nversion = \"0.1.0\"\n\
112             [mcp]\ntransport = \"stdio\"\ncommand = \"{name}-mcp\"\n"
113        ))
114        .expect("parse")
115    }
116
117    #[test]
118    fn install_then_remove_round_trip() {
119        let _iso = isolated_data_dir();
120
121        let out = install(&manifest("demo"), "registry").expect("install");
122        assert_eq!(out.gateway_server, "demo");
123        assert!(out.enabled_gateway, "gateway was off, install enables it");
124
125        // Config now carries the server + gateway enabled.
126        let cfg = Config::load();
127        assert!(cfg.gateway.enabled);
128        assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
129
130        // Store records it.
131        assert!(InstalledStore::load().get("demo").is_some());
132
133        // Re-install is idempotent (no duplicate server).
134        let out2 = install(&manifest("demo"), "registry").expect("reinstall");
135        assert!(!out2.enabled_gateway, "already enabled");
136        let cfg = Config::load();
137        assert_eq!(
138            cfg.gateway
139                .servers
140                .iter()
141                .filter(|s| s.name == "demo")
142                .count(),
143            1
144        );
145
146        // Remove unwinds both config + store.
147        let rm = remove("demo").expect("remove");
148        assert!(rm.last_removed);
149        let cfg = Config::load();
150        assert!(!cfg.gateway.servers.iter().any(|s| s.name == "demo"));
151        assert!(InstalledStore::load().get("demo").is_none());
152    }
153
154    #[test]
155    fn remove_unknown_is_error() {
156        let _iso = isolated_data_dir();
157        assert!(remove("nope").is_err());
158    }
159
160    #[test]
161    fn listed_only_manifest_refuses_install() {
162        let _iso = isolated_data_dir();
163        let listed = AddonManifest::from_toml("[addon]\nname = \"listed\"\n").expect("parse");
164        assert!(install(&listed, "registry").is_err());
165    }
166}