1use std::path::Path;
9
10use crate::core::addons::manifest::AddonManifest;
11use crate::core::addons::revocation::RevocationList;
12use crate::core::addons::store::InstalledStore;
13use crate::core::addons::{bootstrap, install, registry};
14
15pub fn cmd_addon(args: &[String]) {
16 let action = args.first().map_or("list", String::as_str);
17
18 match action {
19 "list" | "ls" => cmd_list(),
20 "init" | "new" => cmd_init(args),
21 "registry" => cmd_registry(args),
22 "categories" | "cats" => cmd_categories(),
23 "usage" | "stats" => cmd_usage(),
24 "search" | "browse" => cmd_search(args.get(1).map_or("", String::as_str)),
25 "info" | "show" => match positional(args) {
26 Some(name) => cmd_info(&name),
27 None => usage_exit("lean-ctx addon info <name>"),
28 },
29 "add" | "install" => match positional(args) {
30 Some(target) => cmd_add(&target, args),
31 None => usage_exit("lean-ctx addon add <name|path-to-lean-ctx-addon.toml>"),
32 },
33 "remove" | "rm" | "uninstall" => match positional(args) {
34 Some(name) => cmd_remove(&name, args),
35 None => usage_exit("lean-ctx addon remove <name>"),
36 },
37 "revoke" => match positional(args) {
38 Some(name) => cmd_revoke(&name, args),
39 None => usage_exit("lean-ctx addon revoke <name> [--reason \"…\"] [--version X]"),
40 },
41 "unrevoke" => match positional(args) {
42 Some(name) => cmd_unrevoke(&name, args),
43 None => usage_exit("lean-ctx addon unrevoke <name>"),
44 },
45 "revocations" => cmd_revocations(),
46 "verify" => cmd_verify(),
47 "audit" => match positional(args) {
48 Some(target) => cmd_audit(&target),
49 None => usage_exit("lean-ctx addon audit <name|path-to-lean-ctx-addon.toml>"),
50 },
51 "help" | "--help" | "-h" => print_help(),
52 _ => {
53 eprintln!("Unknown addon action: {action}");
54 print_help();
55 std::process::exit(1);
56 }
57 }
58}
59
60fn positional(args: &[String]) -> Option<String> {
62 args.get(1)
63 .map(|s| s.trim().to_string())
64 .filter(|s| !s.is_empty() && !s.starts_with('-'))
65}
66
67fn usage_exit(usage: &str) -> ! {
68 eprintln!("Usage: {usage}");
69 std::process::exit(1);
70}
71
72fn cmd_list() {
73 let store = InstalledStore::load();
74 let installed = store.list();
75
76 if installed.is_empty() {
77 println!("No addons installed.");
78 } else {
79 println!("Installed addons:\n");
80 for a in &installed {
81 let ver = if a.version.is_empty() {
82 String::new()
83 } else {
84 format!(" v{}", a.version)
85 };
86 if let Some(reason) = crate::core::addons::revocation::blocked_reason(&a.name) {
87 println!(
88 " ⛔ {}{ver} → REVOKED ({reason}) — will not run; remove with `addon remove {}`",
89 a.name, a.name
90 );
91 } else {
92 println!(
93 " ✓ {}{ver} → gateway server `{}` ({})",
94 a.name, a.gateway_server, a.source
95 );
96 }
97 }
98 }
99
100 let available = registry::all();
101 if !available.is_empty() {
102 println!("\nRegistry:\n");
103 for m in &available {
104 let installed_flag = if store.get(&m.addon.name).is_some() {
105 " [installed]"
106 } else {
107 ""
108 };
109 let status = if m.is_installable() {
110 ""
111 } else {
112 " · listed (no published endpoint yet)"
113 };
114 let badge = if m.addon.verified { " [verified]" } else { "" };
115 println!(
116 " • {}{badge} — {}{status}{installed_flag}",
117 m.addon.name,
118 first_line(&m.addon.description)
119 );
120 }
121 }
122
123 println!(
124 "\nAdd one with `lean-ctx addon add <name>` · build your own with `lean-ctx addon help`."
125 );
126}
127
128fn cmd_search(query: &str) {
129 let hits = registry::search(query);
130 if hits.is_empty() {
131 println!("No addons match `{query}`.");
132 return;
133 }
134 if query.trim().is_empty() {
135 println!("All registry addons:\n");
136 } else {
137 println!("Addons matching `{query}`:\n");
138 }
139 for m in &hits {
140 let status = if m.is_installable() {
141 "installable"
142 } else {
143 "listed"
144 };
145 let badge = if m.addon.verified { " [verified]" } else { "" };
146 println!(" {}{badge} — {}", m.addon.name, m.display_name());
147 println!(" {}", first_line(&m.addon.description));
148 if m.addon.categories.is_empty() {
149 println!(" {status}");
150 } else {
151 println!(
152 " categories: {} · {status}",
153 m.addon.categories.join(", ")
154 );
155 }
156 }
157}
158
159fn cmd_categories() {
162 use std::collections::BTreeMap;
163 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
164 for m in registry::all() {
165 for c in &m.addon.categories {
166 *counts.entry(c.trim().to_string()).or_default() += 1;
167 }
168 }
169 if counts.is_empty() {
170 println!("No categories yet.");
171 return;
172 }
173 println!("Addon categories:\n");
174 for (cat, n) in &counts {
175 println!(" {cat} ({n})");
176 }
177 println!("\nFilter with `lean-ctx addon search <category>`.");
178}
179
180fn cmd_usage() {
183 use crate::core::addons::meter::UsageLedger;
184 let ledger = UsageLedger::load();
185 let ranked = ledger.by_usage();
186 if ranked.is_empty() {
187 println!(
188 "No addon usage recorded yet. (Metering is {}.)",
189 if InstalledStore::load().list().is_empty() {
190 "ready once you install + use an addon"
191 } else {
192 "on; call an addon tool via the gateway to populate it"
193 }
194 );
195 return;
196 }
197 println!("Addon usage (most-used first):\n");
198 for (name, usage) in ranked {
199 let revoked = if crate::core::addons::revocation::blocked_reason(name).is_some() {
200 " ⛔ revoked"
201 } else {
202 ""
203 };
204 println!(
205 " {name}{revoked} — {} call(s), {} error(s)",
206 usage.calls, usage.errors
207 );
208 let mut tools: Vec<_> = usage.tools.iter().collect();
209 tools.sort_by(|a, b| b.1.calls.cmp(&a.1.calls).then_with(|| a.0.cmp(b.0)));
210 for (tool, ts) in tools.iter().take(5) {
211 println!(" {tool}: {} call(s), {} error(s)", ts.calls, ts.errors);
212 }
213 }
214}
215
216fn cmd_info(name: &str) {
217 let store = InstalledStore::load();
218 let Some(manifest) = registry::get(name).or_else(|| {
219 looks_like_path(name)
221 .then(|| AddonManifest::from_path(Path::new(name)).ok())
222 .flatten()
223 }) else {
224 if let Some(installed) = store.get(name) {
227 println!("{}", installed.name);
228 print_field("Version", &installed.version);
229 println!(
230 " Status: installed (gateway server `{}`, {})",
231 installed.gateway_server, installed.source
232 );
233 return;
234 }
235 eprintln!(
236 "Addon `{name}` not found. Try `lean-ctx addon search`, or pass a path to a \
237 lean-ctx-addon.toml."
238 );
239 std::process::exit(1);
240 };
241
242 println!("{} ({})", manifest.display_name(), manifest.addon.name);
243 if !manifest.addon.description.is_empty() {
244 println!(" {}", manifest.addon.description);
245 }
246 print_field("Author", &manifest.addon.author);
247 print_field("Version", &manifest.addon.version);
248 print_field("License", &manifest.addon.license);
249 print_field("Homepage", &manifest.addon.homepage);
250 if !manifest.addon.categories.is_empty() {
251 println!(" Categories: {}", manifest.addon.categories.join(", "));
252 }
253
254 if let Some(installed) = store.get(name) {
255 println!(
256 " Status: installed (gateway server `{}`, {})",
257 installed.gateway_server, installed.source
258 );
259 } else if manifest.is_installable() {
260 println!(
261 " Status: installable — `lean-ctx addon add {}`",
262 manifest.addon.name
263 );
264 } else {
265 println!(" Status: listed (no published MCP endpoint yet)");
266 }
267
268 if manifest.is_installable() {
269 println!();
270 print_install_preview(&manifest);
271 }
272}
273
274fn cmd_add(target: &str, args: &[String]) {
275 let (manifest, source) = if looks_like_path(target) {
276 match AddonManifest::from_path(Path::new(target)) {
277 Ok(m) => (m, "local".to_string()),
278 Err(e) => {
279 eprintln!("Error: {e}");
280 std::process::exit(1);
281 }
282 }
283 } else {
284 let Some(m) = registry::get(target) else {
285 eprintln!(
286 "Unknown addon `{target}`.\n\
287 Browse with `lean-ctx addon search`, or pass a path to a \
288 lean-ctx-addon.toml."
289 );
290 std::process::exit(1);
291 };
292 (m, "registry".to_string())
293 };
294
295 if let Err(e) = manifest.validate() {
296 eprintln!("Error: {e}");
297 std::process::exit(1);
298 }
299
300 if !manifest.is_installable() {
301 eprintln!(
302 "`{name}` is listed but not yet one-click installable (no published MCP endpoint).\n\
303 Follow {home} — once it ships an MCP server, `lean-ctx addon add {name}` will \
304 wire it automatically.",
305 name = manifest.addon.name,
306 home = if manifest.addon.homepage.is_empty() {
307 "its homepage"
308 } else {
309 &manifest.addon.homepage
310 }
311 );
312 std::process::exit(1);
313 }
314
315 let force = args.iter().any(|a| a == "--force" || a == "-f");
316 let no_verify = args.iter().any(|a| a == "--no-verify");
317 let cfg = crate::core::config::Config::load();
318
319 let server = match install::preflight(&manifest, &cfg.addons, force) {
323 Ok(s) => s,
324 Err(e) => {
325 eprintln!("Error: {e}");
326 std::process::exit(1);
327 }
328 };
329
330 println!("About to install `{}`:\n", manifest.addon.name);
331 print_install_preview(&manifest);
332 println!(
333 "\nThis runs/connects to the above MCP server and exposes its tools through lean-ctx."
334 );
335
336 if !super::prompt::confirm(
337 "Install this addon into the MCP gateway?",
338 super::prompt::wants_yes(args),
339 ) {
340 println!("Aborted. Nothing was changed.");
341 return;
342 }
343
344 if manifest.install.is_declared() {
348 println!(
349 "\nInstalling `{}` via {} (pinned {})…",
350 manifest.install.package.trim(),
351 manifest.install.manager.trim(),
352 manifest.install.version.trim()
353 );
354 match bootstrap::ensure_installed(&manifest.install) {
355 Ok(outcome) => {
356 match outcome.status {
357 bootstrap::BootstrapStatus::AlreadyPresent => {
358 println!(" Already installed — skipped.");
359 }
360 bootstrap::BootstrapStatus::Installed => println!(" ✓ Installed."),
361 }
362 if let Some(warning) = outcome.warning {
363 eprintln!(" ⚠ {warning}");
364 }
365 }
366 Err(e) => {
367 eprintln!("Error: bootstrap install failed: {e}\n Nothing was wired.");
368 std::process::exit(1);
369 }
370 }
371 }
372
373 let mut verified: Option<usize> = None;
377 if !no_verify {
378 let timeout = std::time::Duration::from_secs(cfg.gateway.call_timeout_secs.max(60));
381 print!("Verifying the MCP server responds… ");
382 let _ = std::io::Write::flush(&mut std::io::stdout());
383 match crate::core::addons::health::probe(&server, timeout) {
384 Ok(report) => {
385 println!("ok ({} tool(s)).", report.tool_count);
386 verified = Some(report.tool_count);
387 }
388 Err(e) => {
389 println!("failed.");
390 eprintln!(
391 "Error: `{}` did not pass its health check: {e}\n \
392 Nothing was installed. Check the command/args (and capabilities), then retry \
393 — or skip the check with `--no-verify`.",
394 manifest.addon.name
395 );
396 std::process::exit(1);
397 }
398 }
399 }
400
401 match install::install(&manifest, &source, force) {
402 Ok(outcome) => {
403 println!(
404 "\n✓ Installed `{}` → gateway server `{}`.",
405 outcome.name, outcome.gateway_server
406 );
407 if outcome.enabled_gateway {
408 println!(" Enabled the MCP gateway (gateway.enabled = true).");
409 }
410 if let Some(n) = verified {
411 println!(" Verified: {n} tool(s) reachable.");
412 }
413 println!(
414 " Its tools are reachable via `ctx_tools` (find/call). \
415 Restart your MCP client to pick them up."
416 );
417 }
418 Err(e) => {
419 eprintln!("Error: {e}");
420 std::process::exit(1);
421 }
422 }
423}
424
425fn cmd_remove(name: &str, args: &[String]) {
426 let Some(entry) = InstalledStore::load().get(name).cloned() else {
427 eprintln!("Addon `{name}` is not installed.");
428 std::process::exit(1);
429 };
430
431 if !super::prompt::confirm(
432 &format!("Remove addon `{name}` (unwire its MCP server)?"),
433 super::prompt::wants_yes(args),
434 ) {
435 println!("Aborted.");
436 return;
437 }
438
439 match install::remove(name) {
440 Ok(outcome) => {
441 println!(
442 "✓ Removed `{}` (gateway server `{}`).",
443 outcome.name, outcome.gateway_server
444 );
445 if let Some(receipt) = entry.install {
448 println!(
449 "Uninstalling `{}` via {}…",
450 receipt.package, receipt.manager
451 );
452 match bootstrap::uninstall(&receipt) {
453 Ok(()) => println!(" ✓ Uninstalled."),
454 Err(e) => eprintln!(
455 " Note: could not uninstall `{}` automatically: {e}\n \
456 Remove it manually if you no longer need it.",
457 receipt.package
458 ),
459 }
460 }
461 if outcome.last_removed {
462 println!(
463 " No addons remain. The gateway stays enabled — disable it with \
464 `lean-ctx config set gateway.enabled false` if you no longer need it."
465 );
466 }
467 }
468 Err(e) => {
469 eprintln!("Error: {e}");
470 std::process::exit(1);
471 }
472 }
473}
474
475fn cmd_revoke(name: &str, args: &[String]) {
478 let reason = flag_value(args, "--reason").unwrap_or_else(|| "manually revoked".to_string());
479 let version = flag_value(args, "--version");
480
481 let mut list = RevocationList::load();
482 list.revoke(name, &reason, version.clone());
483 match list.save() {
484 Ok(()) => {
485 let scope =
486 version.map_or_else(|| "all versions".to_string(), |v| format!("version {v}"));
487 println!("✓ Revoked `{name}` ({scope}): {reason}");
488 println!(
489 " It will no longer run via the gateway (its tools disappear from `ctx_tools`)."
490 );
491 if InstalledStore::load().get(name).is_some() {
492 println!(" It is still installed — `lean-ctx addon remove {name}` to unwire it.");
493 }
494 crate::core::gateway::catalog::invalidate();
495 }
496 Err(e) => {
497 eprintln!("Error: {e}");
498 std::process::exit(1);
499 }
500 }
501}
502
503fn cmd_unrevoke(name: &str, args: &[String]) {
505 let mut list = RevocationList::load();
506 if !list.revocations.contains_key(name) {
507 eprintln!("Addon `{name}` is not revoked.");
508 std::process::exit(1);
509 }
510 if !super::prompt::confirm(
511 &format!("Lift the revocation on `{name}` (allow it to run again)?"),
512 super::prompt::wants_yes(args),
513 ) {
514 println!("Aborted.");
515 return;
516 }
517 list.unrevoke(name);
518 match list.save() {
519 Ok(()) => {
520 println!("✓ Lifted revocation on `{name}`.");
521 crate::core::gateway::catalog::invalidate();
522 }
523 Err(e) => {
524 eprintln!("Error: {e}");
525 std::process::exit(1);
526 }
527 }
528}
529
530fn cmd_revocations() {
532 let list = RevocationList::load();
533 if list.revocations.is_empty() {
534 println!("No revocations.");
535 return;
536 }
537 println!("Revoked addons:\n");
538 for (name, rev) in &list.revocations {
539 let scope = rev
540 .version
541 .as_deref()
542 .map(|v| format!(" (version {v})"))
543 .unwrap_or_default();
544 println!(" ⛔ {name}{scope} — {}", rev.reason);
545 }
546}
547
548fn cmd_verify() {
551 use crate::core::addons::integrity::{self, IntegrityStatus};
552 let findings = integrity::verify_all();
553 if findings.is_empty() {
554 println!("No addons installed.");
555 return;
556 }
557 let mut drift = false;
558 println!("Addon integrity:\n");
559 for f in &findings {
560 let glyph = match f.status {
561 IntegrityStatus::Ok => "✓",
562 IntegrityStatus::Drift => {
563 drift = true;
564 "⛔"
565 }
566 IntegrityStatus::Missing | IntegrityStatus::Unpinned => "•",
567 };
568 println!(" {glyph} {} — {}", f.name, f.status.label());
569 }
570 if drift {
571 eprintln!(
572 "\nOne or more addons no longer match their pinned wiring. Review the \
573 `[[gateway.servers]]` entries, then re-install (`addon add`) or remove them."
574 );
575 std::process::exit(1);
576 }
577}
578
579fn cmd_init(args: &[String]) {
582 use crate::core::addons::scaffold;
583 use crate::core::gateway::TransportKind;
584
585 let transport = if args.iter().any(|a| a == "--http") {
586 TransportKind::Http
587 } else {
588 TransportKind::Stdio
589 };
590 let force = args.iter().any(|a| a == "--force" || a == "-f");
591
592 let command: Option<Vec<String>> = (transport == TransportKind::Stdio)
595 .then(|| flag_value(args, "--command"))
596 .flatten()
597 .map(|spec| spec.split_whitespace().map(str::to_string).collect());
598
599 let slug = positional(args).or_else(|| {
601 std::env::current_dir()
602 .ok()
603 .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
604 .and_then(|n| scaffold::slugify(&n))
605 });
606 let Some(raw) = slug else {
607 eprintln!("Could not derive an addon name. Pass one: `lean-ctx addon init my-addon`.");
608 std::process::exit(1);
609 };
610 let Some(slug) = scaffold::slugify(&raw) else {
611 eprintln!("`{raw}` has no usable slug characters ([a-z0-9-]).");
612 std::process::exit(1);
613 };
614
615 let path = Path::new(scaffold::MANIFEST_FILENAME);
616 if path.exists() && !force {
617 eprintln!(
618 "{} already exists. Re-run with --force to overwrite.",
619 scaffold::MANIFEST_FILENAME
620 );
621 std::process::exit(1);
622 }
623
624 let contents = scaffold::addon_manifest(&slug, transport, command.as_deref());
625 if let Err(e) = std::fs::write(path, contents) {
626 eprintln!("Error writing {}: {e}", scaffold::MANIFEST_FILENAME);
627 std::process::exit(1);
628 }
629
630 println!("✓ Wrote {} (addon `{slug}`).", scaffold::MANIFEST_FILENAME);
631 println!("\nNext:");
632 println!(" 1. Edit the manifest — fill in description/author/homepage.");
633 println!(
634 " 2. Audit it: lean-ctx addon audit ./{}",
635 scaffold::MANIFEST_FILENAME
636 );
637 println!(
638 " 3. Test live: lean-ctx addon add ./{}",
639 scaffold::MANIFEST_FILENAME
640 );
641 println!(" 4. Get listed: see docs/guides/addons.md");
642}
643
644fn cmd_registry(args: &[String]) {
649 let sub = args.get(1).map_or("", String::as_str);
650 if sub != "validate" {
651 eprintln!("Usage: lean-ctx addon registry validate [path-to-registry.json]");
652 std::process::exit(1);
653 }
654
655 let (entries, label) = match args.get(2).map(String::as_str) {
656 Some(path) if !path.starts_with('-') => match load_registry_file(path) {
657 Ok(e) => (e, path.to_string()),
658 Err(e) => {
659 eprintln!("Error: {e}");
660 std::process::exit(1);
661 }
662 },
663 _ => (
664 registry::all(),
665 "installed registry (bundled + local)".to_string(),
666 ),
667 };
668
669 let problems = registry::validate_entries(&entries);
670 if problems.is_empty() {
671 println!(
672 "✓ {label}: {} entr{} pass the security + quality bar.",
673 entries.len(),
674 if entries.len() == 1 { "y" } else { "ies" }
675 );
676 return;
677 }
678 eprintln!("✗ {label}: {} problem(s):\n", problems.len());
679 for p in &problems {
680 eprintln!(" • {p}");
681 }
682 std::process::exit(1);
683}
684
685fn load_registry_file(path: &str) -> Result<Vec<AddonManifest>, String> {
687 let raw = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?;
688 #[derive(serde::Deserialize)]
689 struct RegistryFile {
690 #[serde(default)]
691 addons: Vec<AddonManifest>,
692 }
693 serde_json::from_str::<RegistryFile>(&raw)
694 .map(|f| f.addons)
695 .map_err(|e| format!("{path} is not a valid registry file: {e}"))
696}
697
698fn cmd_audit(target: &str) {
702 let manifest = if looks_like_path(target) {
703 match AddonManifest::from_path(Path::new(target)) {
704 Ok(m) => m,
705 Err(e) => {
706 eprintln!("Error: {e}");
707 std::process::exit(1);
708 }
709 }
710 } else {
711 let Some(m) = registry::get(target) else {
712 eprintln!("Unknown addon `{target}`. Pass a name from the registry or a path.");
713 std::process::exit(1);
714 };
715 m
716 };
717
718 let report = crate::core::addons::audit::audit(&manifest);
719 println!("Audit of `{}`:\n", manifest.addon.name);
720 println!(" verdict: {}", report.verdict.as_str());
721 println!(
722 " capabilities: {}",
723 if manifest.capabilities.is_some() {
724 if report.capability_coherent {
725 "declared + coherent with wiring"
726 } else {
727 "declared but INCOHERENT with wiring"
728 }
729 } else {
730 "not declared"
731 }
732 );
733 println!(
734 " binary pin: {}",
735 if manifest.mcp.transport == crate::core::gateway::TransportKind::Http {
736 "n/a (http transport)"
737 } else if report.binary_pinned {
738 "pinned (sha256)"
739 } else {
740 "unpinned"
741 }
742 );
743 println!(
744 " paid-eligible: {} (verified/paid tier requires a clean audit, declared + coherent \
745 capabilities, and a pinned binary)",
746 if report.paid_eligible { "yes" } else { "no" }
747 );
748
749 if let Some(pricing) = &manifest.pricing
752 && pricing.is_paid()
753 {
754 let price = match pricing.model {
755 crate::core::addons::PricingModel::OneTime => {
756 format!(
757 "{} {} one-time",
758 pricing.price_cents,
759 pricing.currency_or_default()
760 )
761 }
762 crate::core::addons::PricingModel::Usage => format!(
763 "{} {}/1k tool calls (usage)",
764 pricing.usage_price_per_1k_cents,
765 pricing.currency_or_default()
766 ),
767 };
768 println!(" pricing: {price}");
769 let gate = crate::core::addons::paid_listing_gate(&manifest, &report);
770 if gate.eligible {
771 println!(" paid listing: ELIGIBLE — clears the security gate");
772 } else {
773 println!(" paid listing: BLOCKED");
774 for blocker in &gate.blockers {
775 println!(" - {blocker}");
776 }
777 }
778 }
779
780 if report.findings.is_empty() {
781 println!("\n No findings.");
782 } else {
783 println!("\n Findings:");
784 for f in &report.findings {
785 println!(
786 " {} [{}] {} ({})",
787 f.level.glyph(),
788 f.level.as_str(),
789 f.message,
790 f.code
791 );
792 }
793 }
794
795 if report.verdict == crate::core::addons::AuditVerdict::Fail {
796 eprintln!(
797 "\nAudit failed — this addon must not be listed until the blocking findings are resolved."
798 );
799 std::process::exit(1);
800 }
801}
802
803fn flag_value(args: &[String], flag: &str) -> Option<String> {
805 args.iter()
806 .position(|a| a == flag)
807 .and_then(|i| args.get(i + 1))
808 .map(|s| s.trim().to_string())
809 .filter(|s| !s.is_empty())
810}
811
812fn print_install_preview(manifest: &AddonManifest) {
813 let mcp = &manifest.mcp;
814 println!(
815 " trust: {}",
816 crate::core::addons::TrustTier::of(manifest).label()
817 );
818 println!(" transport: {}", mcp.transport.as_str());
819 match mcp.transport {
820 crate::core::gateway::TransportKind::Stdio => {
821 println!(" command: {}", mcp.command);
822 if !mcp.args.is_empty() {
823 println!(" args: {}", mcp.args.join(" "));
824 }
825 if !mcp.env.is_empty() {
826 let keys: Vec<&str> = mcp.env.keys().map(String::as_str).collect();
827 println!(" env: {}", keys.join(", "));
828 }
829 if !mcp.sha256.trim().is_empty() {
830 println!(" binary: sha256-pinned");
831 }
832 }
833 crate::core::gateway::TransportKind::Http => {
834 println!(" url: {}", mcp.url);
835 if !mcp.headers.is_empty() {
836 let keys: Vec<&str> = mcp.headers.keys().map(String::as_str).collect();
837 println!(" headers: {}", keys.join(", "));
838 }
839 }
840 }
841 print_bootstrap(manifest);
842 print_capabilities(manifest);
843 print_security_review(manifest);
844}
845
846fn print_bootstrap(manifest: &AddonManifest) {
849 let install = &manifest.install;
850 if !install.is_declared() {
851 return;
852 }
853 let prog = install
854 .manager()
855 .map_or_else(|| install.manager.trim().to_string(), |m| m.as_str().into());
856 println!("\n Install on add — runs a pinned package manager before first use:");
857 println!(" manager: {}", install.manager.trim());
858 println!(
859 " package: {} (pinned {})",
860 install.package.trim(),
861 install.version.trim()
862 );
863 println!(" install: {prog} {}", install.install_argv().join(" "));
864 println!(
865 " uninstall: {prog} {} (run on `addon remove`)",
866 install.uninstall_argv().join(" ")
867 );
868 if let Some(m) = install.manager() {
871 if m.is_available() {
872 println!(" requires: `{prog}` on PATH — ✓ found");
873 } else {
874 println!(
875 " requires: `{prog}` on PATH — ✗ NOT found ({})",
876 m.install_hint()
877 );
878 }
879 }
880}
881
882fn print_capabilities(manifest: &AddonManifest) {
887 match &manifest.capabilities {
888 Some(caps) => {
889 println!(
890 "\n Capabilities — network/filesystem/env enforced (sandbox + scrub, \
891 inherited by children); exec declared + audited:"
892 );
893 for line in caps.summary() {
894 println!(" • {line}");
895 }
896 }
897 None => {
898 if manifest.mcp.transport == crate::core::gateway::TransportKind::Stdio {
899 println!(
900 "\n Capabilities: none declared — governed by `addons.sandbox` \
901 (set a [capabilities] block for a per-addon sandbox)."
902 );
903 }
904 }
905 }
906}
907
908fn print_security_review(manifest: &AddonManifest) {
913 let findings = crate::core::addons::audit::audit(manifest).findings;
914 if findings.is_empty() {
915 return;
916 }
917 println!("\n Security review:");
918 for f in &findings {
919 println!(
920 " {} [{}] {}",
921 f.level.glyph(),
922 f.level.as_str(),
923 f.message
924 );
925 }
926}
927
928fn print_field(label: &str, value: &str) {
929 if !value.trim().is_empty() {
930 println!(
931 " {label}:{}{value}",
932 " ".repeat(11usize.saturating_sub(label.len() + 1))
933 );
934 }
935}
936
937fn looks_like_path(target: &str) -> bool {
938 Path::new(target)
939 .extension()
940 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
941 || target.contains('/')
942 || target.starts_with('.')
943 || Path::new(target).is_file()
944}
945
946fn first_line(s: &str) -> String {
947 let line = s.lines().next().unwrap_or("").trim();
948 if line.chars().count() > 88 {
949 let cut: String = line.chars().take(87).collect();
950 format!("{cut}…")
951 } else {
952 line.to_string()
953 }
954}
955
956fn print_help() {
957 eprintln!(
958 "lean-ctx addon — community extensions (MCP servers) for lean-ctx\n\
959 \n\
960 USAGE:\n \
961 lean-ctx addon <action> [args]\n\
962 \n\
963 ACTIONS:\n \
964 list List installed addons + the registry\n \
965 init [name] Scaffold a lean-ctx-addon.toml here\n \
966 [--http] [--force]\n \
967 [--command \"npx -y pkg@1.2.3\"]\n \
968 search [query] Search the registry (empty = list all)\n \
969 categories Browse the registry by category\n \
970 usage Per-addon / per-tool call counters\n \
971 info <name|path> Show an addon's details + MCP wiring\n \
972 add <name|path> Install from the registry or a local\n \
973 lean-ctx-addon.toml (asks for confirmation)\n \
974 remove <name> Uninstall an addon\n \
975 revoke <name> Block an addon from running (kill-switch)\n \
976 [--reason \"…\"] [--version X]\n \
977 unrevoke <name> Lift a revocation\n \
978 revocations List active revocations\n \
979 verify Re-check installed addons against their\n \
980 pinned wiring (integrity lock)\n \
981 audit <name|path> Run the publish/list gate: wiring risk +\n \
982 capability coherence + malware heuristics\n \
983 registry validate [path]\n \
984 Validate a registry file (or the installed\n \
985 registry) against the security + quality bar\n \
986 help Show this help\n\
987 \n\
988 FLAGS:\n \
989 -y, --yes Skip the confirmation prompt (scripts/CI)\n \
990 --no-verify add: skip the post-install MCP health probe\n \
991 --force, -f add: install despite an under-declared\n \
992 capability warning (init: overwrite)\n\
993 \n\
994 BUILD YOUR OWN ADDON:\n \
995 1. Expose your tool as an MCP server (stdio binary or HTTP endpoint).\n \
996 2. Add a lean-ctx-addon.toml to your repo:\n\
997 \n \
998 [addon]\n \
999 name = \"my-addon\" # slug: [a-z0-9-]\n \
1000 display_name = \"My Addon\"\n \
1001 description = \"What it does, in one line.\"\n \
1002 author = \"you\"\n \
1003 homepage = \"https://github.com/you/my-addon\"\n \
1004 license = \"Apache-2.0\"\n \
1005 categories = [\"workflow\"]\n \
1006 keywords = [\"...\"]\n\
1007 \n \
1008 [mcp]\n \
1009 transport = \"stdio\" # or \"http\"\n \
1010 command = \"my-addon-mcp\" # stdio: executable to spawn\n \
1011 args = [\"serve\"]\n \
1012 # sha256 = \"<shasum -a 256>\" # stdio: pin the binary (P3)\n \
1013 # url = \"https://...\" # http: streamable endpoint\n\
1014 \n \
1015 [capabilities] # secure-by-default; widen only what you need\n \
1016 network = \"none\" # \"full\" to reach the internet\n \
1017 filesystem = \"read_only\" # \"read_write\" to write outside tmp\n \
1018 exec = \"none\" # or [\"lean-ctx\"] if you spawn subprocesses\n\
1019 \n \
1020 3. Test it live: lean-ctx addon add ./lean-ctx-addon.toml\n \
1021 4. Get listed: open a merge request adding your entry to\n \
1022 rust/data/addon_registry.json (see docs/guides/addons.md).\n\
1023 \n \
1024 Full guide: docs/guides/addons.md"
1025 );
1026}