lean_ctx/cli/
addon_cmd.rs1use 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
41fn 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 looks_like_path(name)
136 .then(|| AddonManifest::from_path(Path::new(name)).ok())
137 .flatten()
138 }) else {
139 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}