Skip to main content

lean_ctx/cli/
addon_cmd.rs

1//! `lean-ctx addon` — manage community addons (MCP extensions) (#858).
2//!
3//! Thin CLI over [`crate::core::addons`]: browse the registry, install an addon
4//! (from the registry or a local `lean-ctx-addon.toml`), and remove it. `add`
5//! and `remove` wire external code into the MCP gateway, so both pass through
6//! the shared confirmation gate (`cli::prompt`).
7
8use std::path::Path;
9
10use crate::core::addons::manifest::AddonManifest;
11use crate::core::addons::store::InstalledStore;
12use crate::core::addons::{install, registry};
13
14pub fn cmd_addon(args: &[String]) {
15    let action = args.first().map_or("list", String::as_str);
16
17    match action {
18        "list" | "ls" => cmd_list(),
19        "search" | "browse" => cmd_search(args.get(1).map_or("", String::as_str)),
20        "info" | "show" => match positional(args) {
21            Some(name) => cmd_info(&name),
22            None => usage_exit("lean-ctx addon info <name>"),
23        },
24        "add" | "install" => match positional(args) {
25            Some(target) => cmd_add(&target, args),
26            None => usage_exit("lean-ctx addon add <name|path-to-lean-ctx-addon.toml>"),
27        },
28        "remove" | "rm" | "uninstall" => match positional(args) {
29            Some(name) => cmd_remove(&name, args),
30            None => usage_exit("lean-ctx addon remove <name>"),
31        },
32        "help" | "--help" | "-h" => print_help(),
33        _ => {
34            eprintln!("Unknown addon action: {action}");
35            print_help();
36            std::process::exit(1);
37        }
38    }
39}
40
41/// First non-flag argument after the action.
42fn positional(args: &[String]) -> Option<String> {
43    args.get(1)
44        .map(|s| s.trim().to_string())
45        .filter(|s| !s.is_empty() && !s.starts_with('-'))
46}
47
48fn usage_exit(usage: &str) -> ! {
49    eprintln!("Usage: {usage}");
50    std::process::exit(1);
51}
52
53fn cmd_list() {
54    let store = InstalledStore::load();
55    let installed = store.list();
56
57    if installed.is_empty() {
58        println!("No addons installed.");
59    } else {
60        println!("Installed addons:\n");
61        for a in &installed {
62            let ver = if a.version.is_empty() {
63                String::new()
64            } else {
65                format!(" v{}", a.version)
66            };
67            println!(
68                "  ✓ {}{ver}  → gateway server `{}` ({})",
69                a.name, a.gateway_server, a.source
70            );
71        }
72    }
73
74    let available = registry::all();
75    if !available.is_empty() {
76        println!("\nRegistry:\n");
77        for m in &available {
78            let installed_flag = if store.get(&m.addon.name).is_some() {
79                " [installed]"
80            } else {
81                ""
82            };
83            let status = if m.is_installable() {
84                ""
85            } else {
86                " · listed (no published endpoint yet)"
87            };
88            println!(
89                "  • {} — {}{status}{installed_flag}",
90                m.addon.name,
91                first_line(&m.addon.description)
92            );
93        }
94    }
95
96    println!(
97        "\nAdd one with `lean-ctx addon add <name>` · build your own with `lean-ctx addon help`."
98    );
99}
100
101fn cmd_search(query: &str) {
102    let hits = registry::search(query);
103    if hits.is_empty() {
104        println!("No addons match `{query}`.");
105        return;
106    }
107    if query.trim().is_empty() {
108        println!("All registry addons:\n");
109    } else {
110        println!("Addons matching `{query}`:\n");
111    }
112    for m in &hits {
113        let status = if m.is_installable() {
114            "installable"
115        } else {
116            "listed"
117        };
118        println!("  {} — {}", m.addon.name, m.display_name());
119        println!("      {}", first_line(&m.addon.description));
120        if m.addon.categories.is_empty() {
121            println!("      {status}");
122        } else {
123            println!(
124                "      categories: {} · {status}",
125                m.addon.categories.join(", ")
126            );
127        }
128    }
129}
130
131fn cmd_info(name: &str) {
132    let store = InstalledStore::load();
133    let Some(manifest) = registry::get(name).or_else(|| {
134        // Allow `info` on a local manifest path too.
135        looks_like_path(name)
136            .then(|| AddonManifest::from_path(Path::new(name)).ok())
137            .flatten()
138    }) else {
139        // Not in the registry and not a manifest path — but it may be a
140        // locally-installed addon recorded in the store.
141        if let Some(installed) = store.get(name) {
142            println!("{}", installed.name);
143            print_field("Version", &installed.version);
144            println!(
145                "  Status:    installed (gateway server `{}`, {})",
146                installed.gateway_server, installed.source
147            );
148            return;
149        }
150        eprintln!(
151            "Addon `{name}` not found. Try `lean-ctx addon search`, or pass a path to a \
152             lean-ctx-addon.toml."
153        );
154        std::process::exit(1);
155    };
156
157    println!("{} ({})", manifest.display_name(), manifest.addon.name);
158    if !manifest.addon.description.is_empty() {
159        println!("  {}", manifest.addon.description);
160    }
161    print_field("Author", &manifest.addon.author);
162    print_field("Version", &manifest.addon.version);
163    print_field("License", &manifest.addon.license);
164    print_field("Homepage", &manifest.addon.homepage);
165    if !manifest.addon.categories.is_empty() {
166        println!("  Categories: {}", manifest.addon.categories.join(", "));
167    }
168
169    if let Some(installed) = store.get(name) {
170        println!(
171            "  Status:    installed (gateway server `{}`, {})",
172            installed.gateway_server, installed.source
173        );
174    } else if manifest.is_installable() {
175        println!(
176            "  Status:    installable — `lean-ctx addon add {}`",
177            manifest.addon.name
178        );
179    } else {
180        println!("  Status:    listed (no published MCP endpoint yet)");
181    }
182
183    if manifest.is_installable() {
184        println!();
185        print_install_preview(&manifest);
186    }
187}
188
189fn cmd_add(target: &str, args: &[String]) {
190    let (manifest, source) = if looks_like_path(target) {
191        match AddonManifest::from_path(Path::new(target)) {
192            Ok(m) => (m, "local".to_string()),
193            Err(e) => {
194                eprintln!("Error: {e}");
195                std::process::exit(1);
196            }
197        }
198    } else {
199        let Some(m) = registry::get(target) else {
200            eprintln!(
201                "Unknown addon `{target}`.\n\
202                 Browse with `lean-ctx addon search`, or pass a path to a \
203                 lean-ctx-addon.toml."
204            );
205            std::process::exit(1);
206        };
207        (m, "registry".to_string())
208    };
209
210    if let Err(e) = manifest.validate() {
211        eprintln!("Error: {e}");
212        std::process::exit(1);
213    }
214
215    if !manifest.is_installable() {
216        eprintln!(
217            "`{name}` is listed but not yet one-click installable (no published MCP endpoint).\n\
218             Follow {home} — once it ships an MCP server, `lean-ctx addon add {name}` will \
219             wire it automatically.",
220            name = manifest.addon.name,
221            home = if manifest.addon.homepage.is_empty() {
222                "its homepage"
223            } else {
224                &manifest.addon.homepage
225            }
226        );
227        std::process::exit(1);
228    }
229
230    println!("About to install `{}`:\n", manifest.addon.name);
231    print_install_preview(&manifest);
232    println!(
233        "\nThis runs/connects to the above MCP server and exposes its tools through lean-ctx."
234    );
235
236    if !super::prompt::confirm(
237        "Install this addon into the MCP gateway?",
238        super::prompt::wants_yes(args),
239    ) {
240        println!("Aborted. Nothing was changed.");
241        return;
242    }
243
244    match install::install(&manifest, &source) {
245        Ok(outcome) => {
246            println!(
247                "\n✓ Installed `{}` → gateway server `{}`.",
248                outcome.name, outcome.gateway_server
249            );
250            if outcome.enabled_gateway {
251                println!("  Enabled the MCP gateway (gateway.enabled = true).");
252            }
253            println!(
254                "  Its tools are reachable via `ctx_tools` (find/call). \
255                 Restart your MCP client to pick them up."
256            );
257        }
258        Err(e) => {
259            eprintln!("Error: {e}");
260            std::process::exit(1);
261        }
262    }
263}
264
265fn cmd_remove(name: &str, args: &[String]) {
266    if InstalledStore::load().get(name).is_none() {
267        eprintln!("Addon `{name}` is not installed.");
268        std::process::exit(1);
269    }
270
271    if !super::prompt::confirm(
272        &format!("Remove addon `{name}` (unwire its MCP server)?"),
273        super::prompt::wants_yes(args),
274    ) {
275        println!("Aborted.");
276        return;
277    }
278
279    match install::remove(name) {
280        Ok(outcome) => {
281            println!(
282                "✓ Removed `{}` (gateway server `{}`).",
283                outcome.name, outcome.gateway_server
284            );
285            if outcome.last_removed {
286                println!(
287                    "  No addons remain. The gateway stays enabled — disable it with \
288                     `lean-ctx config set gateway.enabled false` if you no longer need it."
289                );
290            }
291        }
292        Err(e) => {
293            eprintln!("Error: {e}");
294            std::process::exit(1);
295        }
296    }
297}
298
299fn print_install_preview(manifest: &AddonManifest) {
300    let mcp = &manifest.mcp;
301    println!("  transport: {}", mcp.transport.as_str());
302    match mcp.transport {
303        crate::core::gateway::TransportKind::Stdio => {
304            println!("  command:   {}", mcp.command);
305            if !mcp.args.is_empty() {
306                println!("  args:      {}", mcp.args.join(" "));
307            }
308            if !mcp.env.is_empty() {
309                let keys: Vec<&str> = mcp.env.keys().map(String::as_str).collect();
310                println!("  env:       {}", keys.join(", "));
311            }
312        }
313        crate::core::gateway::TransportKind::Http => {
314            println!("  url:       {}", mcp.url);
315            if !mcp.headers.is_empty() {
316                let keys: Vec<&str> = mcp.headers.keys().map(String::as_str).collect();
317                println!("  headers:   {}", keys.join(", "));
318            }
319        }
320    }
321}
322
323fn print_field(label: &str, value: &str) {
324    if !value.trim().is_empty() {
325        println!(
326            "  {label}:{}{value}",
327            " ".repeat(11usize.saturating_sub(label.len() + 1))
328        );
329    }
330}
331
332fn looks_like_path(target: &str) -> bool {
333    Path::new(target)
334        .extension()
335        .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
336        || target.contains('/')
337        || target.starts_with('.')
338        || Path::new(target).is_file()
339}
340
341fn first_line(s: &str) -> String {
342    let line = s.lines().next().unwrap_or("").trim();
343    if line.chars().count() > 88 {
344        let cut: String = line.chars().take(87).collect();
345        format!("{cut}…")
346    } else {
347        line.to_string()
348    }
349}
350
351fn print_help() {
352    eprintln!(
353        "lean-ctx addon — community extensions (MCP servers) for lean-ctx\n\
354         \n\
355         USAGE:\n    \
356             lean-ctx addon <action> [args]\n\
357         \n\
358         ACTIONS:\n    \
359             list                 List installed addons + the registry\n    \
360             search [query]       Search the registry (empty = list all)\n    \
361             info <name|path>     Show an addon's details + MCP wiring\n    \
362             add <name|path>      Install from the registry or a local\n                         \
363                                  lean-ctx-addon.toml (asks for confirmation)\n    \
364             remove <name>        Uninstall an addon\n    \
365             help                 Show this help\n\
366         \n\
367         FLAGS:\n    \
368             -y, --yes            Skip the confirmation prompt (scripts/CI)\n\
369         \n\
370         BUILD YOUR OWN ADDON:\n    \
371             1. Expose your tool as an MCP server (stdio binary or HTTP endpoint).\n    \
372             2. Add a lean-ctx-addon.toml to your repo:\n\
373         \n        \
374                 [addon]\n        \
375                 name = \"my-addon\"            # slug: [a-z0-9-]\n        \
376                 display_name = \"My Addon\"\n        \
377                 description = \"What it does, in one line.\"\n        \
378                 author = \"you\"\n        \
379                 homepage = \"https://github.com/you/my-addon\"\n        \
380                 license = \"Apache-2.0\"\n        \
381                 categories = [\"workflow\"]\n        \
382                 keywords = [\"...\"]\n\
383         \n        \
384                 [mcp]\n        \
385                 transport = \"stdio\"          # or \"http\"\n        \
386                 command = \"my-addon-mcp\"     # stdio: executable to spawn\n        \
387                 args = [\"serve\"]\n        \
388                 # url = \"https://...\"         # http: streamable endpoint\n\
389         \n    \
390             3. Test it live:  lean-ctx addon add ./lean-ctx-addon.toml\n    \
391             4. Get listed:    open a merge request adding your entry to\n                      \
392                               rust/data/addon_registry.json (see docs/guides/addons.md).\n\
393         \n    \
394             Full guide: docs/guides/addons.md"
395    );
396}