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::revocation::RevocationList;
12use crate::core::addons::store::InstalledStore;
13use crate::core::addons::{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
60/// First non-flag argument after the action.
61fn 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
159/// `addon categories` — browse the registry by category (discovery, P5). Counts
160/// are computed from the live registry, so the list is always accurate.
161fn 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
180/// `addon usage` — per-addon / per-tool call counters from the local meter
181/// (P5). The honest basis for "most-used" discovery and usage-metered billing.
182fn 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        // Allow `info` on a local manifest path too.
220        looks_like_path(name)
221            .then(|| AddonManifest::from_path(Path::new(name)).ok())
222            .flatten()
223    }) else {
224        // Not in the registry and not a manifest path — but it may be a
225        // locally-installed addon recorded in the store.
226        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    println!("About to install `{}`:\n", manifest.addon.name);
316    print_install_preview(&manifest);
317    println!(
318        "\nThis runs/connects to the above MCP server and exposes its tools through lean-ctx."
319    );
320
321    if !super::prompt::confirm(
322        "Install this addon into the MCP gateway?",
323        super::prompt::wants_yes(args),
324    ) {
325        println!("Aborted. Nothing was changed.");
326        return;
327    }
328
329    match install::install(&manifest, &source) {
330        Ok(outcome) => {
331            println!(
332                "\n✓ Installed `{}` → gateway server `{}`.",
333                outcome.name, outcome.gateway_server
334            );
335            if outcome.enabled_gateway {
336                println!("  Enabled the MCP gateway (gateway.enabled = true).");
337            }
338            println!(
339                "  Its tools are reachable via `ctx_tools` (find/call). \
340                 Restart your MCP client to pick them up."
341            );
342        }
343        Err(e) => {
344            eprintln!("Error: {e}");
345            std::process::exit(1);
346        }
347    }
348}
349
350fn cmd_remove(name: &str, args: &[String]) {
351    if InstalledStore::load().get(name).is_none() {
352        eprintln!("Addon `{name}` is not installed.");
353        std::process::exit(1);
354    }
355
356    if !super::prompt::confirm(
357        &format!("Remove addon `{name}` (unwire its MCP server)?"),
358        super::prompt::wants_yes(args),
359    ) {
360        println!("Aborted.");
361        return;
362    }
363
364    match install::remove(name) {
365        Ok(outcome) => {
366            println!(
367                "✓ Removed `{}` (gateway server `{}`).",
368                outcome.name, outcome.gateway_server
369            );
370            if outcome.last_removed {
371                println!(
372                    "  No addons remain. The gateway stays enabled — disable it with \
373                     `lean-ctx config set gateway.enabled false` if you no longer need it."
374                );
375            }
376        }
377        Err(e) => {
378            eprintln!("Error: {e}");
379            std::process::exit(1);
380        }
381    }
382}
383
384/// `addon revoke <name>` — block an addon from running everywhere (install,
385/// catalog, every proxy call). Protective, so it does not prompt.
386fn cmd_revoke(name: &str, args: &[String]) {
387    let reason = flag_value(args, "--reason").unwrap_or_else(|| "manually revoked".to_string());
388    let version = flag_value(args, "--version");
389
390    let mut list = RevocationList::load();
391    list.revoke(name, &reason, version.clone());
392    match list.save() {
393        Ok(()) => {
394            let scope =
395                version.map_or_else(|| "all versions".to_string(), |v| format!("version {v}"));
396            println!("✓ Revoked `{name}` ({scope}): {reason}");
397            println!(
398                "  It will no longer run via the gateway (its tools disappear from `ctx_tools`)."
399            );
400            if InstalledStore::load().get(name).is_some() {
401                println!("  It is still installed — `lean-ctx addon remove {name}` to unwire it.");
402            }
403            crate::core::gateway::catalog::invalidate();
404        }
405        Err(e) => {
406            eprintln!("Error: {e}");
407            std::process::exit(1);
408        }
409    }
410}
411
412/// `addon unrevoke <name>` — lift a revocation (removes protection), so confirm.
413fn cmd_unrevoke(name: &str, args: &[String]) {
414    let mut list = RevocationList::load();
415    if !list.revocations.contains_key(name) {
416        eprintln!("Addon `{name}` is not revoked.");
417        std::process::exit(1);
418    }
419    if !super::prompt::confirm(
420        &format!("Lift the revocation on `{name}` (allow it to run again)?"),
421        super::prompt::wants_yes(args),
422    ) {
423        println!("Aborted.");
424        return;
425    }
426    list.unrevoke(name);
427    match list.save() {
428        Ok(()) => {
429            println!("✓ Lifted revocation on `{name}`.");
430            crate::core::gateway::catalog::invalidate();
431        }
432        Err(e) => {
433            eprintln!("Error: {e}");
434            std::process::exit(1);
435        }
436    }
437}
438
439/// `addon revocations` — list the active local revocations.
440fn cmd_revocations() {
441    let list = RevocationList::load();
442    if list.revocations.is_empty() {
443        println!("No revocations.");
444        return;
445    }
446    println!("Revoked addons:\n");
447    for (name, rev) in &list.revocations {
448        let scope = rev
449            .version
450            .as_deref()
451            .map(|v| format!(" (version {v})"))
452            .unwrap_or_default();
453        println!("  ⛔ {name}{scope} — {}", rev.reason);
454    }
455}
456
457/// `addon verify` — re-check each installed addon's live wiring against the
458/// integrity hash pinned at install (P2). Exits non-zero if any addon drifted.
459fn cmd_verify() {
460    use crate::core::addons::integrity::{self, IntegrityStatus};
461    let findings = integrity::verify_all();
462    if findings.is_empty() {
463        println!("No addons installed.");
464        return;
465    }
466    let mut drift = false;
467    println!("Addon integrity:\n");
468    for f in &findings {
469        let glyph = match f.status {
470            IntegrityStatus::Ok => "✓",
471            IntegrityStatus::Drift => {
472                drift = true;
473                "⛔"
474            }
475            IntegrityStatus::Missing | IntegrityStatus::Unpinned => "•",
476        };
477        println!("  {glyph} {} — {}", f.name, f.status.label());
478    }
479    if drift {
480        eprintln!(
481            "\nOne or more addons no longer match their pinned wiring. Review the \
482             `[[gateway.servers]]` entries, then re-install (`addon add`) or remove them."
483        );
484        std::process::exit(1);
485    }
486}
487
488/// `addon init [name]` — scaffold a ready-to-edit `lean-ctx-addon.toml` in the
489/// current directory. `--http` for an HTTP addon, `--force` to overwrite.
490fn cmd_init(args: &[String]) {
491    use crate::core::addons::scaffold;
492    use crate::core::gateway::TransportKind;
493
494    let transport = if args.iter().any(|a| a == "--http") {
495        TransportKind::Http
496    } else {
497        TransportKind::Stdio
498    };
499    let force = args.iter().any(|a| a == "--force" || a == "-f");
500
501    // Slug: explicit positional, else the current directory name.
502    let slug = positional(args).or_else(|| {
503        std::env::current_dir()
504            .ok()
505            .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
506            .and_then(|n| scaffold::slugify(&n))
507    });
508    let Some(raw) = slug else {
509        eprintln!("Could not derive an addon name. Pass one: `lean-ctx addon init my-addon`.");
510        std::process::exit(1);
511    };
512    let Some(slug) = scaffold::slugify(&raw) else {
513        eprintln!("`{raw}` has no usable slug characters ([a-z0-9-]).");
514        std::process::exit(1);
515    };
516
517    let path = Path::new(scaffold::MANIFEST_FILENAME);
518    if path.exists() && !force {
519        eprintln!(
520            "{} already exists. Re-run with --force to overwrite.",
521            scaffold::MANIFEST_FILENAME
522        );
523        std::process::exit(1);
524    }
525
526    let contents = scaffold::addon_manifest(&slug, transport);
527    if let Err(e) = std::fs::write(path, contents) {
528        eprintln!("Error writing {}: {e}", scaffold::MANIFEST_FILENAME);
529        std::process::exit(1);
530    }
531
532    println!("✓ Wrote {} (addon `{slug}`).", scaffold::MANIFEST_FILENAME);
533    println!("\nNext:");
534    println!("  1. Edit the manifest — fill in description/author/homepage.");
535    println!(
536        "  2. Audit it:    lean-ctx addon audit ./{}",
537        scaffold::MANIFEST_FILENAME
538    );
539    println!(
540        "  3. Test live:   lean-ctx addon add ./{}",
541        scaffold::MANIFEST_FILENAME
542    );
543    println!("  4. Get listed:  see docs/guides/addons.md");
544}
545
546/// `addon registry validate [path]` — run the registry security/quality bar
547/// (#864 + #403) against a registry JSON file, or the bundled + local registry
548/// if no path is given. The dry-run harness an author / CI uses before opening a
549/// merge request. Non-zero exit when problems are found.
550fn cmd_registry(args: &[String]) {
551    let sub = args.get(1).map_or("", String::as_str);
552    if sub != "validate" {
553        eprintln!("Usage: lean-ctx addon registry validate [path-to-registry.json]");
554        std::process::exit(1);
555    }
556
557    let (entries, label) = match args.get(2).map(String::as_str) {
558        Some(path) if !path.starts_with('-') => match load_registry_file(path) {
559            Ok(e) => (e, path.to_string()),
560            Err(e) => {
561                eprintln!("Error: {e}");
562                std::process::exit(1);
563            }
564        },
565        _ => (
566            registry::all(),
567            "installed registry (bundled + local)".to_string(),
568        ),
569    };
570
571    let problems = registry::validate_entries(&entries);
572    if problems.is_empty() {
573        println!(
574            "✓ {label}: {} entr{} pass the security + quality bar.",
575            entries.len(),
576            if entries.len() == 1 { "y" } else { "ies" }
577        );
578        return;
579    }
580    eprintln!("✗ {label}: {} problem(s):\n", problems.len());
581    for p in &problems {
582        eprintln!("  • {p}");
583    }
584    std::process::exit(1);
585}
586
587/// Parse a registry JSON file (`{ "addons": [ … ] }`) into manifests.
588fn load_registry_file(path: &str) -> Result<Vec<AddonManifest>, String> {
589    let raw = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?;
590    #[derive(serde::Deserialize)]
591    struct RegistryFile {
592        #[serde(default)]
593        addons: Vec<AddonManifest>,
594    }
595    serde_json::from_str::<RegistryFile>(&raw)
596        .map(|f| f.addons)
597        .map_err(|e| format!("{path} is not a valid registry file: {e}"))
598}
599
600/// `addon audit <name|path>` — run the publish/list gate (#403): wiring risk +
601/// capability coherence + malware heuristics, then the verified/paid verdict.
602/// Exits non-zero on a `fail` verdict so it is usable in CI / a publish hook.
603fn cmd_audit(target: &str) {
604    let manifest = if looks_like_path(target) {
605        match AddonManifest::from_path(Path::new(target)) {
606            Ok(m) => m,
607            Err(e) => {
608                eprintln!("Error: {e}");
609                std::process::exit(1);
610            }
611        }
612    } else {
613        let Some(m) = registry::get(target) else {
614            eprintln!("Unknown addon `{target}`. Pass a name from the registry or a path.");
615            std::process::exit(1);
616        };
617        m
618    };
619
620    let report = crate::core::addons::audit::audit(&manifest);
621    println!("Audit of `{}`:\n", manifest.addon.name);
622    println!("  verdict:        {}", report.verdict.as_str());
623    println!(
624        "  capabilities:   {}",
625        if manifest.capabilities.is_some() {
626            if report.capability_coherent {
627                "declared + coherent with wiring"
628            } else {
629                "declared but INCOHERENT with wiring"
630            }
631        } else {
632            "not declared"
633        }
634    );
635    println!(
636        "  binary pin:     {}",
637        if manifest.mcp.transport == crate::core::gateway::TransportKind::Http {
638            "n/a (http transport)"
639        } else if report.binary_pinned {
640            "pinned (sha256)"
641        } else {
642            "unpinned"
643        }
644    );
645    println!(
646        "  paid-eligible:  {} (verified/paid tier requires a clean audit, declared + coherent \
647         capabilities, and a pinned binary)",
648        if report.paid_eligible { "yes" } else { "no" }
649    );
650
651    // Track B: when the manifest carries `[pricing]`, show whether it clears the
652    // mandatory paid-listing gate and, if not, exactly what blocks the sale.
653    if let Some(pricing) = &manifest.pricing
654        && pricing.is_paid()
655    {
656        let price = match pricing.model {
657            crate::core::addons::PricingModel::OneTime => {
658                format!(
659                    "{} {} one-time",
660                    pricing.price_cents,
661                    pricing.currency_or_default()
662                )
663            }
664            crate::core::addons::PricingModel::Usage => format!(
665                "{} {}/1k tool calls (usage)",
666                pricing.usage_price_per_1k_cents,
667                pricing.currency_or_default()
668            ),
669        };
670        println!("  pricing:        {price}");
671        let gate = crate::core::addons::paid_listing_gate(&manifest, &report);
672        if gate.eligible {
673            println!("  paid listing:   ELIGIBLE — clears the security gate");
674        } else {
675            println!("  paid listing:   BLOCKED");
676            for blocker in &gate.blockers {
677                println!("                    - {blocker}");
678            }
679        }
680    }
681
682    if report.findings.is_empty() {
683        println!("\n  No findings.");
684    } else {
685        println!("\n  Findings:");
686        for f in &report.findings {
687            println!(
688                "    {} [{}] {} ({})",
689                f.level.glyph(),
690                f.level.as_str(),
691                f.message,
692                f.code
693            );
694        }
695    }
696
697    if report.verdict == crate::core::addons::AuditVerdict::Fail {
698        eprintln!(
699            "\nAudit failed — this addon must not be listed until the blocking findings are resolved."
700        );
701        std::process::exit(1);
702    }
703}
704
705/// Read the value following `flag` in `args` (e.g. `--reason "text"`).
706fn flag_value(args: &[String], flag: &str) -> Option<String> {
707    args.iter()
708        .position(|a| a == flag)
709        .and_then(|i| args.get(i + 1))
710        .map(|s| s.trim().to_string())
711        .filter(|s| !s.is_empty())
712}
713
714fn print_install_preview(manifest: &AddonManifest) {
715    let mcp = &manifest.mcp;
716    println!(
717        "  trust:     {}",
718        crate::core::addons::TrustTier::of(manifest).label()
719    );
720    println!("  transport: {}", mcp.transport.as_str());
721    match mcp.transport {
722        crate::core::gateway::TransportKind::Stdio => {
723            println!("  command:   {}", mcp.command);
724            if !mcp.args.is_empty() {
725                println!("  args:      {}", mcp.args.join(" "));
726            }
727            if !mcp.env.is_empty() {
728                let keys: Vec<&str> = mcp.env.keys().map(String::as_str).collect();
729                println!("  env:       {}", keys.join(", "));
730            }
731            if !mcp.sha256.trim().is_empty() {
732                println!("  binary:    sha256-pinned");
733            }
734        }
735        crate::core::gateway::TransportKind::Http => {
736            println!("  url:       {}", mcp.url);
737            if !mcp.headers.is_empty() {
738                let keys: Vec<&str> = mcp.headers.keys().map(String::as_str).collect();
739                println!("  headers:   {}", keys.join(", "));
740            }
741        }
742    }
743    print_capabilities(manifest);
744    print_security_review(manifest);
745}
746
747/// Show the declared capabilities the user is about to grant (P1). A declared
748/// `[capabilities]` block means the addon runs under a per-addon OS sandbox +
749/// scrubbed environment derived from exactly these permissions; an addon with
750/// no block runs under the legacy `addons.sandbox` mode.
751fn print_capabilities(manifest: &AddonManifest) {
752    match &manifest.capabilities {
753        Some(caps) => {
754            println!(
755                "\n  Capabilities — network/filesystem/env enforced (sandbox + scrub, \
756                 inherited by children); exec declared + audited:"
757            );
758            for line in caps.summary() {
759                println!("    • {line}");
760            }
761        }
762        None => {
763            if manifest.mcp.transport == crate::core::gateway::TransportKind::Stdio {
764                println!(
765                    "\n  Capabilities: none declared — governed by `addons.sandbox` \
766                     (set a [capabilities] block for a per-addon sandbox)."
767                );
768            }
769        }
770    }
771}
772
773/// Static risk review shown before install — disclosure, not a verdict (the
774/// install policy gate enforces; see [`crate::core::addons::policy`]). Sourced
775/// from the full audit (#403) so wiring risk, capability-coherence and malware
776/// heuristics all surface before the user consents.
777fn print_security_review(manifest: &AddonManifest) {
778    let findings = crate::core::addons::audit::audit(manifest).findings;
779    if findings.is_empty() {
780        return;
781    }
782    println!("\n  Security review:");
783    for f in &findings {
784        println!(
785            "    {} [{}] {}",
786            f.level.glyph(),
787            f.level.as_str(),
788            f.message
789        );
790    }
791}
792
793fn print_field(label: &str, value: &str) {
794    if !value.trim().is_empty() {
795        println!(
796            "  {label}:{}{value}",
797            " ".repeat(11usize.saturating_sub(label.len() + 1))
798        );
799    }
800}
801
802fn looks_like_path(target: &str) -> bool {
803    Path::new(target)
804        .extension()
805        .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
806        || target.contains('/')
807        || target.starts_with('.')
808        || Path::new(target).is_file()
809}
810
811fn first_line(s: &str) -> String {
812    let line = s.lines().next().unwrap_or("").trim();
813    if line.chars().count() > 88 {
814        let cut: String = line.chars().take(87).collect();
815        format!("{cut}…")
816    } else {
817        line.to_string()
818    }
819}
820
821fn print_help() {
822    eprintln!(
823        "lean-ctx addon — community extensions (MCP servers) for lean-ctx\n\
824         \n\
825         USAGE:\n    \
826             lean-ctx addon <action> [args]\n\
827         \n\
828         ACTIONS:\n    \
829             list                 List installed addons + the registry\n    \
830             init [name]          Scaffold a lean-ctx-addon.toml here\n                         \
831                                  [--http] [--force]\n    \
832             search [query]       Search the registry (empty = list all)\n    \
833             categories           Browse the registry by category\n    \
834             usage                Per-addon / per-tool call counters\n    \
835             info <name|path>     Show an addon's details + MCP wiring\n    \
836             add <name|path>      Install from the registry or a local\n                         \
837                                  lean-ctx-addon.toml (asks for confirmation)\n    \
838             remove <name>        Uninstall an addon\n    \
839             revoke <name>        Block an addon from running (kill-switch)\n                         \
840                                  [--reason \"…\"] [--version X]\n    \
841             unrevoke <name>      Lift a revocation\n    \
842             revocations          List active revocations\n    \
843             verify               Re-check installed addons against their\n                         \
844                                  pinned wiring (integrity lock)\n    \
845             audit <name|path>    Run the publish/list gate: wiring risk +\n                         \
846                                  capability coherence + malware heuristics\n    \
847             registry validate [path]\n                         \
848                                  Validate a registry file (or the installed\n                         \
849                                  registry) against the security + quality bar\n    \
850             help                 Show this help\n\
851         \n\
852         FLAGS:\n    \
853             -y, --yes            Skip the confirmation prompt (scripts/CI)\n\
854         \n\
855         BUILD YOUR OWN ADDON:\n    \
856             1. Expose your tool as an MCP server (stdio binary or HTTP endpoint).\n    \
857             2. Add a lean-ctx-addon.toml to your repo:\n\
858         \n        \
859                 [addon]\n        \
860                 name = \"my-addon\"            # slug: [a-z0-9-]\n        \
861                 display_name = \"My Addon\"\n        \
862                 description = \"What it does, in one line.\"\n        \
863                 author = \"you\"\n        \
864                 homepage = \"https://github.com/you/my-addon\"\n        \
865                 license = \"Apache-2.0\"\n        \
866                 categories = [\"workflow\"]\n        \
867                 keywords = [\"...\"]\n\
868         \n        \
869                 [mcp]\n        \
870                 transport = \"stdio\"          # or \"http\"\n        \
871                 command = \"my-addon-mcp\"     # stdio: executable to spawn\n        \
872                 args = [\"serve\"]\n        \
873                 # sha256 = \"<shasum -a 256>\"  # stdio: pin the binary (P3)\n        \
874                 # url = \"https://...\"         # http: streamable endpoint\n\
875         \n        \
876                 [capabilities]               # secure-by-default; widen only what you need\n        \
877                 network = \"none\"             # \"full\" to reach the internet\n        \
878                 filesystem = \"read_only\"     # \"read_write\" to write outside tmp\n        \
879                 exec = \"none\"                # or [\"lean-ctx\"] if you spawn subprocesses\n\
880         \n    \
881             3. Test it live:  lean-ctx addon add ./lean-ctx-addon.toml\n    \
882             4. Get listed:    open a merge request adding your entry to\n                      \
883                               rust/data/addon_registry.json (see docs/guides/addons.md).\n\
884         \n    \
885             Full guide: docs/guides/addons.md"
886    );
887}