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::{ArtifactReceipt, InstalledStore};
13use crate::core::addons::{artifact_install, 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        "update" | "upgrade" => match positional(args) {
38            Some(name) => cmd_update(&name, args),
39            None => usage_exit("lean-ctx addon update <name>"),
40        },
41        "revoke" => match positional(args) {
42            Some(name) => cmd_revoke(&name, args),
43            None => usage_exit("lean-ctx addon revoke <name> [--reason \"…\"] [--version X]"),
44        },
45        "unrevoke" => match positional(args) {
46            Some(name) => cmd_unrevoke(&name, args),
47            None => usage_exit("lean-ctx addon unrevoke <name>"),
48        },
49        "revocations" => cmd_revocations(),
50        "verify" => cmd_verify(),
51        "audit" => match positional(args) {
52            Some(target) => cmd_audit(&target),
53            None => usage_exit("lean-ctx addon audit <name|path-to-lean-ctx-addon.toml>"),
54        },
55        "publish" => cmd_publish(args),
56        "help" | "--help" | "-h" => print_help(),
57        _ => {
58            eprintln!("Unknown addon action: {action}");
59            print_help();
60            std::process::exit(1);
61        }
62    }
63}
64
65/// First non-flag argument after the action.
66fn positional(args: &[String]) -> Option<String> {
67    args.get(1)
68        .map(|s| s.trim().to_string())
69        .filter(|s| !s.is_empty() && !s.starts_with('-'))
70}
71
72fn usage_exit(usage: &str) -> ! {
73    eprintln!("Usage: {usage}");
74    std::process::exit(1);
75}
76
77fn cmd_list() {
78    let store = InstalledStore::load();
79    let installed = store.list();
80
81    if installed.is_empty() {
82        println!("No addons installed.");
83    } else {
84        println!("Installed addons:\n");
85        for a in &installed {
86            let ver = if a.version.is_empty() {
87                String::new()
88            } else {
89                format!(" v{}", a.version)
90            };
91            if let Some(reason) = crate::core::addons::revocation::blocked_reason(&a.name) {
92                println!(
93                    "  ⛔ {}{ver}  → REVOKED ({reason}) — will not run; remove with `addon remove {}`",
94                    a.name, a.name
95                );
96            } else {
97                println!(
98                    "  ✓ {}{ver}  → gateway server `{}` ({})",
99                    a.name, a.gateway_server, a.source
100                );
101            }
102        }
103    }
104
105    let available = registry::all();
106    if !available.is_empty() {
107        println!("\nRegistry:\n");
108        for m in &available {
109            let installed_flag = if store.get(&m.addon.name).is_some() {
110                " [installed]"
111            } else {
112                ""
113            };
114            let status = if m.is_installable() {
115                ""
116            } else {
117                " · listed (no published endpoint yet)"
118            };
119            let badge = if m.addon.verified { " [verified]" } else { "" };
120            println!(
121                "  • {}{badge} — {}{status}{installed_flag}",
122                m.addon.name,
123                first_line(&m.addon.description)
124            );
125        }
126    }
127
128    println!(
129        "\nAdd one with `lean-ctx addon add <name>` · build your own with `lean-ctx addon help`."
130    );
131}
132
133fn cmd_search(query: &str) {
134    let hits = registry::search(query);
135    if hits.is_empty() {
136        println!("No addons match `{query}`.");
137        return;
138    }
139    if query.trim().is_empty() {
140        println!("All registry addons:\n");
141    } else {
142        println!("Addons matching `{query}`:\n");
143    }
144    for m in &hits {
145        let status = if m.is_installable() {
146            "installable"
147        } else {
148            "listed"
149        };
150        let badge = if m.addon.verified { " [verified]" } else { "" };
151        println!("  {}{badge} — {}", m.addon.name, m.display_name());
152        println!("      {}", first_line(&m.addon.description));
153        if m.addon.categories.is_empty() {
154            println!("      {status}");
155        } else {
156            println!(
157                "      categories: {} · {status}",
158                m.addon.categories.join(", ")
159            );
160        }
161    }
162}
163
164/// `addon categories` — browse the registry by category (discovery, P5). Counts
165/// are computed from the live registry, so the list is always accurate.
166fn cmd_categories() {
167    use std::collections::BTreeMap;
168    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
169    for m in registry::all() {
170        for c in &m.addon.categories {
171            *counts.entry(c.trim().to_string()).or_default() += 1;
172        }
173    }
174    if counts.is_empty() {
175        println!("No categories yet.");
176        return;
177    }
178    println!("Addon categories:\n");
179    for (cat, n) in &counts {
180        println!("  {cat}  ({n})");
181    }
182    println!("\nFilter with `lean-ctx addon search <category>`.");
183}
184
185/// `addon usage` — per-addon / per-tool call counters from the local meter
186/// (P5). The honest basis for "most-used" discovery and usage-metered billing.
187fn cmd_usage() {
188    use crate::core::addons::meter::UsageLedger;
189    let ledger = UsageLedger::load();
190    let ranked = ledger.by_usage();
191    if ranked.is_empty() {
192        println!(
193            "No addon usage recorded yet. (Metering is {}.)",
194            if InstalledStore::load().list().is_empty() {
195                "ready once you install + use an addon"
196            } else {
197                "on; call an addon tool via the gateway to populate it"
198            }
199        );
200        return;
201    }
202    println!("Addon usage (most-used first):\n");
203    for (name, usage) in ranked {
204        let revoked = if crate::core::addons::revocation::blocked_reason(name).is_some() {
205            " ⛔ revoked"
206        } else {
207            ""
208        };
209        println!(
210            "  {name}{revoked} — {} call(s), {} error(s)",
211            usage.calls, usage.errors
212        );
213        let mut tools: Vec<_> = usage.tools.iter().collect();
214        tools.sort_by(|a, b| b.1.calls.cmp(&a.1.calls).then_with(|| a.0.cmp(b.0)));
215        for (tool, ts) in tools.iter().take(5) {
216            println!("      {tool}: {} call(s), {} error(s)", ts.calls, ts.errors);
217        }
218    }
219}
220
221fn cmd_info(name: &str) {
222    let store = InstalledStore::load();
223    let Some(manifest) = registry::get(name).or_else(|| {
224        // Allow `info` on a local manifest path too.
225        looks_like_path(name)
226            .then(|| AddonManifest::from_path(Path::new(name)).ok())
227            .flatten()
228    }) else {
229        // Not in the registry and not a manifest path — but it may be a
230        // locally-installed addon recorded in the store.
231        if let Some(installed) = store.get(name) {
232            println!("{}", installed.name);
233            print_field("Version", &installed.version);
234            println!(
235                "  Status:    installed (gateway server `{}`, {})",
236                installed.gateway_server, installed.source
237            );
238            return;
239        }
240        eprintln!(
241            "Addon `{name}` not found. Try `lean-ctx addon search`, or pass a path to a \
242             lean-ctx-addon.toml."
243        );
244        std::process::exit(1);
245    };
246
247    println!("{} ({})", manifest.display_name(), manifest.addon.name);
248    if !manifest.addon.description.is_empty() {
249        println!("  {}", manifest.addon.description);
250    }
251    print_field("Author", &manifest.addon.author);
252    print_field("Version", &manifest.addon.version);
253    print_field("License", &manifest.addon.license);
254    print_field("Homepage", &manifest.addon.homepage);
255    if !manifest.addon.categories.is_empty() {
256        println!("  Categories: {}", manifest.addon.categories.join(", "));
257    }
258
259    if let Some(installed) = store.get(name) {
260        println!(
261            "  Status:    installed (gateway server `{}`, {})",
262            installed.gateway_server, installed.source
263        );
264    } else if manifest.is_installable() {
265        println!(
266            "  Status:    installable — `lean-ctx addon add {}`",
267            manifest.addon.name
268        );
269    } else {
270        println!("  Status:    listed (no published MCP endpoint yet)");
271    }
272
273    if manifest.is_installable() {
274        println!();
275        print_install_preview(&manifest);
276    }
277}
278
279fn cmd_add(target: &str, args: &[String]) {
280    // Resolution order: local manifest file → hosted ctxpkg pack (`ns/slug`,
281    // GH #726) → bundled registry slug. A bare `ns/slug` that exists on disk
282    // is treated as the local path it names.
283    let is_local_path = Path::new(target)
284        .extension()
285        .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
286        || target.starts_with('.')
287        || target.starts_with('/')
288        || Path::new(target).exists();
289    let mut pack_manifest: Option<crate::core::context_package::PackageManifest> = None;
290    let (manifest, source) = if is_local_path {
291        match AddonManifest::from_path(Path::new(target)) {
292            Ok(m) => (m, "local".to_string()),
293            Err(e) => {
294                eprintln!("Error: {e}");
295                std::process::exit(1);
296            }
297        }
298    } else if let Some(remote_ref) = crate::core::context_package::remote::parse_remote_ref(target)
299    {
300        match fetch_addon_pack(&remote_ref, flag_value(args, "--registry").as_deref()) {
301            Ok((m, s, pm)) => {
302                pack_manifest = Some(pm);
303                (m, s)
304            }
305            Err(e) => {
306                eprintln!("Error: {e}");
307                std::process::exit(1);
308            }
309        }
310    } else {
311        let Some(m) = registry::get(target) else {
312            eprintln!(
313                "Unknown addon `{target}`.\n\
314                 Browse with `lean-ctx addon search`, install a hosted pack with \
315                 `lean-ctx addon add <namespace>/<name>`, or pass a path to a \
316                 lean-ctx-addon.toml."
317            );
318            std::process::exit(1);
319        };
320        (m, "registry".to_string())
321    };
322
323    if let Err(e) = manifest.validate() {
324        eprintln!("Error: {e}");
325        std::process::exit(1);
326    }
327
328    if !manifest.is_installable() {
329        eprintln!(
330            "`{name}` is listed but not yet one-click installable (no published MCP endpoint).\n\
331             Follow {home} — once it ships an MCP server, `lean-ctx addon add {name}` will \
332             wire it automatically.",
333            name = manifest.addon.name,
334            home = if manifest.addon.homepage.is_empty() {
335                "its homepage"
336            } else {
337                &manifest.addon.homepage
338            }
339        );
340        std::process::exit(1);
341    }
342
343    let force = args.iter().any(|a| a == "--force" || a == "-f");
344    let no_verify = args.iter().any(|a| a == "--no-verify");
345    let cfg = crate::core::config::Config::load();
346
347    // Fail fast (#1080): run the full pre-persist gate — policy, kill-switch,
348    // capability coherence — before rendering the preview or spawning a probe,
349    // so a rejected addon surfaces a clear verdict and nothing is touched.
350    // (The health probe later targets the post-artifact wiring instead of
351    // this resolution, so only the verdict matters here.)
352    if let Err(e) = install::preflight(&manifest, &cfg.addons, force) {
353        eprintln!("Error: {e}");
354        std::process::exit(1);
355    }
356
357    println!("About to install `{}`:\n", manifest.addon.name);
358    print_install_preview(&manifest);
359
360    // Depth-1 dependency resolution (GH #727): declared deps are part of the
361    // consent surface — resolve before asking, install after wiring succeeds.
362    let registry_base = crate::core::context_package::remote::registry_base(
363        flag_value(args, "--registry").as_deref(),
364    );
365    let reg_token = crate::core::context_package::remote::publish_token(None);
366    let resolved_deps = match pack_manifest.as_ref() {
367        Some(pm) if pm.dependencies.iter().any(|d| !d.optional) => {
368            match crate::core::context_package::deps::resolve_dependencies(
369                pm,
370                &registry_base,
371                reg_token.as_deref(),
372            ) {
373                Ok(v) => v,
374                Err(e) => {
375                    eprintln!("Error: {e}");
376                    std::process::exit(1);
377                }
378            }
379        }
380        _ => Vec::new(),
381    };
382    if !resolved_deps.is_empty() {
383        println!("\nDeclared dependencies (installed alongside, depth-1):");
384        for d in &resolved_deps {
385            println!("  + {}@{}", d.name, d.version);
386        }
387    }
388
389    println!(
390        "\nThis runs/connects to the above MCP server and exposes its tools through lean-ctx."
391    );
392
393    if !super::prompt::confirm(
394        "Install this addon into the MCP gateway?",
395        super::prompt::wants_yes(args),
396    ) {
397        println!("Aborted. Nothing was changed.");
398        return;
399    }
400
401    match provision_and_wire(manifest, &source, force, no_verify, &cfg) {
402        Ok((outcome, verified)) => {
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            if let Some(pm) = pack_manifest.as_ref().filter(|_| !resolved_deps.is_empty()) {
414                let project_root = super::common::detect_project_root(args);
415                if let Err(e) = super::pack_remote::install_declared_dependencies(
416                    pm,
417                    &registry_base,
418                    reg_token.as_deref(),
419                    &project_root,
420                    false,
421                ) {
422                    eprintln!("Error: dependency install failed: {e}");
423                    eprintln!(
424                        "  The addon itself is wired; re-run `lean-ctx addon add {}` to retry.",
425                        outcome.name
426                    );
427                    std::process::exit(1);
428                }
429            }
430            println!(
431                "  Its tools are reachable via `ctx_tools` (find/call). \
432                 Restart your MCP client to pick them up."
433            );
434        }
435        Err(e) => {
436            eprintln!("Error: {e}");
437            std::process::exit(1);
438        }
439    }
440}
441
442/// The impure provisioning pipeline `add` and `update` share, run after user
443/// consent: managed artifact (GH #725) → bootstrap (#1105) → health probe
444/// (#1076) → wire. On any error nothing is wired. Returns the install outcome
445/// plus the probed tool count (`None` with `--no-verify`).
446fn provision_and_wire(
447    mut manifest: AddonManifest,
448    source: &str,
449    force: bool,
450    no_verify: bool,
451    cfg: &crate::core::config::Config,
452) -> Result<(install::InstallOutcome, Option<usize>), String> {
453    // Managed artifact (GH #725, Phase 1): a prebuilt binary for this platform
454    // takes precedence over [install]/PATH. It lands in the managed bin dir
455    // (never PATH), hash-verified; the gateway command is rewritten to the
456    // absolute path and the SHA-256 auto-pinned as the spawn-time binhash.
457    let mut artifact_receipt: Option<ArtifactReceipt> = None;
458    if let Some(asset) = manifest.artifact_for_current_platform().cloned() {
459        let triple = artifact_install::current_target_triple();
460        println!("\nInstalling prebuilt binary for {triple} (sha256-pinned)…");
461        let path = artifact_install::ensure_addon_binary(
462            &manifest.addon.name,
463            &manifest.addon.version,
464            &asset,
465        )
466        .map_err(|e| format!("artifact install failed: {e}\n  Nothing was wired."))?;
467        println!("  ✓ {}", path.display());
468        artifact_receipt = Some(ArtifactReceipt {
469            platform: triple.to_string(),
470            url: asset.url.clone(),
471            sha256: asset.sha256.clone(),
472            path: path.display().to_string(),
473        });
474        manifest.mcp.command = path.display().to_string();
475        manifest.mcp.sha256 = asset.sha256;
476    } else if manifest.install.is_declared() {
477        // Bootstrap (#1105): provision the upstream package via its pinned
478        // manager *before* probing — the [mcp] command depends on it. The
479        // policy floor (addons.allow_bootstrap) was already enforced in
480        // preflight. Skipped when a managed artifact resolved above (the
481        // artifact IS the binary the bootstrap would have provisioned).
482        println!(
483            "\nInstalling `{}` via {} (pinned {})…",
484            manifest.install.package.trim(),
485            manifest.install.manager.trim(),
486            manifest.install.version.trim()
487        );
488        let outcome = bootstrap::ensure_installed(&manifest.install)
489            .map_err(|e| format!("bootstrap install failed: {e}\n  Nothing was wired."))?;
490        match outcome.status {
491            bootstrap::BootstrapStatus::AlreadyPresent => {
492                println!("  Already installed — skipped.");
493            }
494            bootstrap::BootstrapStatus::Installed => println!("  ✓ Installed."),
495        }
496        if let Some(warning) = outcome.warning {
497            eprintln!("  ⚠ {warning}");
498        }
499    }
500
501    // Health probe (#1076): confirm the server actually speaks MCP *before* we
502    // wire it, so a broken command/args fails now with a clear message instead
503    // of opaquely at first `ctx_tools` use. Skip with `--no-verify`. Probes the
504    // post-artifact wiring, i.e. exactly what the gateway will spawn.
505    let server = manifest.to_gateway_server();
506    let mut verified: Option<usize> = None;
507    if !no_verify {
508        // First spawn may download a package (npx/uvx), so allow extra headroom
509        // over the per-call timeout.
510        let timeout = std::time::Duration::from_secs(cfg.gateway.call_timeout_secs.max(60));
511        print!("Verifying the MCP server responds… ");
512        let _ = std::io::Write::flush(&mut std::io::stdout());
513        match crate::core::addons::health::probe(&server, timeout) {
514            Ok(report) => {
515                println!("ok ({} tool(s)).", report.tool_count);
516                verified = Some(report.tool_count);
517            }
518            Err(e) => {
519                println!("failed.");
520                return Err(format!(
521                    "`{}` did not pass its health check: {e}\n  \
522                     Nothing was installed. Check the command/args (and capabilities), then retry \
523                     — or skip the check with `--no-verify`.",
524                    manifest.addon.name
525                ));
526            }
527        }
528    }
529
530    let outcome = install::install(&manifest, source, force, artifact_receipt)?;
531    Ok((outcome, verified))
532}
533
534/// Resolve `ns/slug[@version]` against the hosted ctxpkg registry and unwrap
535/// the `kind=addon` pack into the addon manifest it embeds (GH #726).
536///
537/// Trust chain before anything is returned: artifact SHA-256 against the
538/// registry index (in `download_verified`), then full pack verification —
539/// integrity hashes, **mandatory** ed25519 signature (packs carrying
540/// executable references get no unsigned path), kind=addon and
541/// kind↔payload coherence. The embedded TOML then walks the exact same
542/// consent/preflight/probe pipeline as every other source.
543fn fetch_addon_pack(
544    remote_ref: &crate::core::context_package::remote::RemoteRef,
545    registry_flag: Option<&str>,
546) -> Result<
547    (
548        AddonManifest,
549        String,
550        crate::core::context_package::PackageManifest,
551    ),
552    String,
553> {
554    use crate::core::context_package::{remote, verify};
555
556    let base = remote::registry_base(registry_flag);
557    let ns = &remote_ref.namespace;
558    let name = &remote_ref.name;
559    let token = remote::publish_token(None);
560
561    println!("Resolving @{ns}/{name} via {base} …");
562    let versions = remote::fetch_versions(&base, ns, name, token.as_deref())?;
563    let info = remote::select_version(&versions, remote_ref.version.as_deref())?;
564    if info.yanked {
565        eprintln!(
566            "WARNING: @{ns}/{name}@{} is YANKED — installing only because the version \
567             was pinned explicitly",
568            info.version
569        );
570    }
571    let bytes = remote::download_verified(&base, ns, name, info, token.as_deref())?;
572    let text = String::from_utf8(bytes).map_err(|_| "package is not valid UTF-8".to_string())?;
573
574    let report = verify::verify_package_text(&text);
575    if !report.valid() {
576        return Err(format!(
577            "pack verification failed — refusing to install:\n  {}",
578            report.errors.join("\n  ")
579        ));
580    }
581    if report.signature != verify::CheckOutcome::Pass {
582        return Err(
583            "pack is unsigned — addon packs reference executables, so a verifying \
584             ed25519 signature is mandatory"
585                .into(),
586        );
587    }
588
589    #[derive(serde::Deserialize)]
590    struct Bundle {
591        manifest: crate::core::context_package::PackageManifest,
592        content: crate::core::context_package::PackageContent,
593    }
594    let bundle: Bundle = serde_json::from_str(&text).map_err(|e| format!("parse package: {e}"))?;
595    let Bundle {
596        manifest: pack_manifest,
597        content,
598    } = bundle;
599
600    if pack_manifest.kind != crate::core::context_package::manifest::PackageKind::Addon {
601        return Err(format!(
602            "@{ns}/{name} is a kind={} package — install it with `lean-ctx pack install \
603             {ns}/{name}` instead",
604            pack_manifest.kind.as_str()
605        ));
606    }
607    verify::validate_kind_coherence(&pack_manifest, &content).map_err(|errs| errs.join("; "))?;
608
609    let payload = content
610        .addon
611        .expect("coherence guarantees content.addon for kind=addon");
612    let manifest = AddonManifest::from_toml(&payload.manifest_toml)?;
613
614    let source = format!("ctxpkg:@{ns}/{name}@{}", info.version);
615    // The pack manifest rides along for depth-1 dependency resolution
616    // (GH #727): declared skills/context deps install with the addon.
617    Ok((manifest, source, pack_manifest))
618}
619
620/// `addon publish [manifest] --namespace <ns>` — build the signed
621/// `kind=addon` pack from a `lean-ctx-addon.toml` and upload it to the
622/// hosted ctxpkg registry (GH #726). `--check` runs every local gate
623/// (schema, audit, signing, self-verification) and stops before the network.
624fn cmd_publish(args: &[String]) {
625    let manifest_path = args
626        .iter()
627        .skip(1)
628        .find(|a| {
629            !a.starts_with('-')
630                && Path::new(a.as_str())
631                    .extension()
632                    .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
633        })
634        .map_or_else(|| "lean-ctx-addon.toml".to_string(), String::clone);
635
636    let Some(namespace) = flag_value(args, "--namespace") else {
637        eprintln!(
638            "Usage: lean-ctx addon publish [lean-ctx-addon.toml] --namespace <ns> \
639             [--check] [--registry <url>] [--token <ctxp_…>]"
640        );
641        eprintln!();
642        eprintln!("The namespace is your ctxpkg.com account handle — the pack publishes");
643        eprintln!("as @<ns>/<addon-name>. `--check` validates and signs locally without");
644        eprintln!("uploading anything.");
645        std::process::exit(1);
646    };
647
648    let plan =
649        match crate::core::addons::publish::build_addon_pack(Path::new(&manifest_path), &namespace)
650        {
651            Ok(p) => p,
652            Err(e) => {
653                eprintln!("Error: {e}");
654                std::process::exit(1);
655            }
656        };
657
658    println!(
659        "Built @{}/{}@{} (kind=addon, {} bytes)",
660        plan.namespace,
661        plan.slug,
662        plan.version,
663        plan.bundle_json.len()
664    );
665    println!("  Audit verdict: {}", plan.audit.verdict.as_str());
666    for f in &plan.audit.findings {
667        println!("    {} {} — {}", f.level.as_str(), f.code, f.message);
668    }
669    if plan.artifact_platforms.is_empty() {
670        println!("  Artifacts: none (installs use the runner/[install] path)");
671    } else {
672        println!("  Artifacts: {}", plan.artifact_platforms.join(", "));
673    }
674    if plan.has_bootstrap {
675        println!("  Bootstrap: [install] fallback for platforms without an artifact");
676    }
677
678    if args.iter().any(|a| a == "--check") {
679        println!("\n--check: all local gates passed — nothing was uploaded.");
680        return;
681    }
682
683    use crate::core::context_package::remote;
684    let base = remote::registry_base(flag_value(args, "--registry").as_deref());
685    let Some(token) = remote::publish_token(flag_value(args, "--token").as_deref()) else {
686        eprintln!("ERROR: no publish token — pass --token or set CTXPKG_TOKEN");
687        eprintln!("Mint one at ctxpkg.com/account (sign in, then Tokens → Mint).");
688        std::process::exit(1);
689    };
690    if token.starts_with("ctxr_") {
691        eprintln!(
692            "ERROR: this is a read-only install token (ctxr_) — publishing needs a ctxp_ token"
693        );
694        std::process::exit(1);
695    }
696
697    println!(
698        "\nPublishing @{}/{}@{} to {base} …",
699        plan.namespace, plan.slug, plan.version
700    );
701    match remote::publish(
702        &base,
703        &token,
704        &plan.namespace,
705        &plan.slug,
706        &plan.version,
707        plan.bundle_json.as_bytes(),
708    ) {
709        Ok(receipt) => {
710            println!("Published: {}", receipt.published);
711            println!("Artifact SHA-256: {}", receipt.artifact_sha256);
712            println!(
713                "Install with: lean-ctx addon add {}/{}",
714                plan.namespace, plan.slug
715            );
716        }
717        Err(e) => {
718            eprintln!("ERROR: {e}");
719            std::process::exit(1);
720        }
721    }
722}
723
724/// `addon update <name>` — re-resolve the registry entry and reinstall when it
725/// changed (GH #725). Managed binaries install side-by-side into a new version
726/// dir; only after the health probe passes is the gateway pointer flipped and
727/// the old version pruned — a failed update leaves the working install intact.
728fn cmd_update(name: &str, args: &[String]) {
729    let Some(entry) = InstalledStore::load().get(name).cloned() else {
730        eprintln!("Addon `{name}` is not installed.");
731        std::process::exit(1);
732    };
733    if entry.source == "local" {
734        eprintln!(
735            "`{name}` was installed from a local manifest — update it by re-running \
736             `lean-ctx addon add <path-to-lean-ctx-addon.toml>`."
737        );
738        std::process::exit(1);
739    }
740    // Re-resolve from where it came: a hosted ctxpkg pack updates against the
741    // registry it was installed from (latest non-yanked version), everything
742    // else against the bundled registry snapshot.
743    let mut pack_manifest: Option<crate::core::context_package::PackageManifest> = None;
744    let (manifest, update_source) = if let Some(spec) = entry.source.strip_prefix("ctxpkg:") {
745        let unpinned = spec.split('@').take(2).collect::<Vec<_>>().join("@");
746        let Some(remote_ref) = crate::core::context_package::remote::parse_remote_ref(&unpinned)
747        else {
748            eprintln!(
749                "`{name}` has a malformed install source `{}`.",
750                entry.source
751            );
752            std::process::exit(1);
753        };
754        match fetch_addon_pack(&remote_ref, flag_value(args, "--registry").as_deref()) {
755            Ok((m, s, pm)) => {
756                pack_manifest = Some(pm);
757                (m, s)
758            }
759            Err(e) => {
760                eprintln!("Error: {e}");
761                std::process::exit(1);
762            }
763        }
764    } else {
765        let Some(m) = registry::get(name) else {
766            eprintln!(
767                "`{name}` is no longer in the registry — remove it or reinstall from a path."
768            );
769            std::process::exit(1);
770        };
771        (m, entry.source.clone())
772    };
773
774    let force = args.iter().any(|a| a == "--force" || a == "-f");
775    let no_verify = args.iter().any(|a| a == "--no-verify");
776
777    // Up-to-date check: same version and (for managed binaries) same artifact
778    // pin ⇒ nothing to do. `--force` reinstalls anyway.
779    let same_version = manifest.addon.version == entry.version;
780    let same_artifact = match (
781        manifest.artifact_for_current_platform(),
782        entry.artifact.as_ref(),
783    ) {
784        (Some(asset), Some(receipt)) => asset.sha256.eq_ignore_ascii_case(&receipt.sha256),
785        (None, None) => true,
786        _ => false,
787    };
788    if same_version && same_artifact && !force {
789        println!(
790            "`{name}` is up to date (v{}).",
791            if entry.version.is_empty() {
792                "unversioned".to_string()
793            } else {
794                entry.version.clone()
795            }
796        );
797        // A skills/context dependency may have bumped even when the addon
798        // itself did not (GH #727) — refresh those without re-wiring.
799        refresh_pack_dependencies(pack_manifest.as_ref(), args);
800        return;
801    }
802
803    let cfg = crate::core::config::Config::load();
804    if let Err(e) = install::preflight(&manifest, &cfg.addons, force) {
805        eprintln!("Error: {e}");
806        std::process::exit(1);
807    }
808
809    println!(
810        "Updating `{name}`: v{} → v{}",
811        entry.version, manifest.addon.version
812    );
813    if !super::prompt::confirm("Proceed with the update?", super::prompt::wants_yes(args)) {
814        println!("Aborted. Nothing was changed.");
815        return;
816    }
817
818    let new_version = manifest.addon.version.clone();
819    match provision_and_wire(manifest, &update_source, force, no_verify, &cfg) {
820        Ok((outcome, verified)) => {
821            // The new version is wired and healthy — now prune superseded
822            // managed binaries (side-by-side rollback safety until here).
823            artifact_install::prune_other_versions(name, &new_version);
824            println!(
825                "\n✓ Updated `{}` to v{new_version} (gateway server `{}`).",
826                outcome.name, outcome.gateway_server
827            );
828            if let Some(n) = verified {
829                println!("  Verified: {n} tool(s) reachable.");
830            }
831            refresh_pack_dependencies(pack_manifest.as_ref(), args);
832            println!("  Restart your MCP client to pick up the new version.");
833        }
834        Err(e) => {
835            eprintln!("Error: {e}\n  The previous install remains wired.");
836            std::process::exit(1);
837        }
838    }
839}
840
841/// Re-resolve and install the declared dependencies of an addon's pack
842/// manifest (GH #727) — used on `addon update`, where a dependency can move
843/// forward independently of the addon binary.
844fn refresh_pack_dependencies(
845    pack_manifest: Option<&crate::core::context_package::PackageManifest>,
846    args: &[String],
847) {
848    let Some(pm) = pack_manifest else { return };
849    if pm.dependencies.iter().all(|d| d.optional) {
850        return;
851    }
852    let base = crate::core::context_package::remote::registry_base(
853        flag_value(args, "--registry").as_deref(),
854    );
855    let token = crate::core::context_package::remote::publish_token(None);
856    let project_root = super::common::detect_project_root(args);
857    println!("Refreshing declared dependencies (depth-1) …");
858    if let Err(e) = super::pack_remote::install_declared_dependencies(
859        pm,
860        &base,
861        token.as_deref(),
862        &project_root,
863        true,
864    ) {
865        eprintln!("Warning: dependency refresh failed: {e}\n  The addon update itself succeeded.");
866    }
867}
868
869fn cmd_remove(name: &str, args: &[String]) {
870    let Some(entry) = InstalledStore::load().get(name).cloned() else {
871        eprintln!("Addon `{name}` is not installed.");
872        std::process::exit(1);
873    };
874
875    if !super::prompt::confirm(
876        &format!("Remove addon `{name}` (unwire its MCP server)?"),
877        super::prompt::wants_yes(args),
878    ) {
879        println!("Aborted.");
880        return;
881    }
882
883    match install::remove(name) {
884        Ok(outcome) => {
885            println!(
886                "✓ Removed `{}` (gateway server `{}`).",
887                outcome.name, outcome.gateway_server
888            );
889            // Uninstall the bootstrapped package (#1105), best-effort — a failed
890            // uninstall must never block the unwire that already succeeded.
891            if let Some(receipt) = entry.install {
892                println!(
893                    "Uninstalling `{}` via {}…",
894                    receipt.package, receipt.manager
895                );
896                match bootstrap::uninstall(&receipt) {
897                    Ok(()) => println!("  ✓ Uninstalled."),
898                    Err(e) => eprintln!(
899                        "  Note: could not uninstall `{}` automatically: {e}\n  \
900                         Remove it manually if you no longer need it.",
901                        receipt.package
902                    ),
903                }
904            }
905            // Delete managed binaries (GH #725), best-effort for the same reason.
906            if entry.artifact.is_some() && artifact_install::remove_managed_binaries(name) {
907                println!("  ✓ Deleted managed binaries.");
908            }
909            if outcome.last_removed {
910                println!(
911                    "  No addons remain. The gateway stays enabled — disable it with \
912                     `lean-ctx config set gateway.enabled false` if you no longer need it."
913                );
914            }
915        }
916        Err(e) => {
917            eprintln!("Error: {e}");
918            std::process::exit(1);
919        }
920    }
921}
922
923/// `addon revoke <name>` — block an addon from running everywhere (install,
924/// catalog, every proxy call). Protective, so it does not prompt.
925fn cmd_revoke(name: &str, args: &[String]) {
926    let reason = flag_value(args, "--reason").unwrap_or_else(|| "manually revoked".to_string());
927    let version = flag_value(args, "--version");
928
929    let mut list = RevocationList::load();
930    list.revoke(name, &reason, version.clone());
931    match list.save() {
932        Ok(()) => {
933            let scope =
934                version.map_or_else(|| "all versions".to_string(), |v| format!("version {v}"));
935            println!("✓ Revoked `{name}` ({scope}): {reason}");
936            println!(
937                "  It will no longer run via the gateway (its tools disappear from `ctx_tools`)."
938            );
939            if InstalledStore::load().get(name).is_some() {
940                println!("  It is still installed — `lean-ctx addon remove {name}` to unwire it.");
941            }
942            crate::core::mcp_catalog::catalog::invalidate();
943        }
944        Err(e) => {
945            eprintln!("Error: {e}");
946            std::process::exit(1);
947        }
948    }
949}
950
951/// `addon unrevoke <name>` — lift a revocation (removes protection), so confirm.
952fn cmd_unrevoke(name: &str, args: &[String]) {
953    let mut list = RevocationList::load();
954    if !list.revocations.contains_key(name) {
955        eprintln!("Addon `{name}` is not revoked.");
956        std::process::exit(1);
957    }
958    if !super::prompt::confirm(
959        &format!("Lift the revocation on `{name}` (allow it to run again)?"),
960        super::prompt::wants_yes(args),
961    ) {
962        println!("Aborted.");
963        return;
964    }
965    list.unrevoke(name);
966    match list.save() {
967        Ok(()) => {
968            println!("✓ Lifted revocation on `{name}`.");
969            crate::core::mcp_catalog::catalog::invalidate();
970        }
971        Err(e) => {
972            eprintln!("Error: {e}");
973            std::process::exit(1);
974        }
975    }
976}
977
978/// `addon revocations` — list the active local revocations.
979fn cmd_revocations() {
980    let list = RevocationList::load();
981    if list.revocations.is_empty() {
982        println!("No revocations.");
983        return;
984    }
985    println!("Revoked addons:\n");
986    for (name, rev) in &list.revocations {
987        let scope = rev
988            .version
989            .as_deref()
990            .map(|v| format!(" (version {v})"))
991            .unwrap_or_default();
992        println!("  ⛔ {name}{scope} — {}", rev.reason);
993    }
994}
995
996/// `addon verify` — re-check each installed addon's live wiring against the
997/// integrity hash pinned at install (P2). Exits non-zero if any addon drifted.
998fn cmd_verify() {
999    use crate::core::addons::integrity::{self, IntegrityStatus};
1000    let findings = integrity::verify_all();
1001    if findings.is_empty() {
1002        println!("No addons installed.");
1003        return;
1004    }
1005    let mut drift = false;
1006    println!("Addon integrity:\n");
1007    for f in &findings {
1008        let glyph = match f.status {
1009            IntegrityStatus::Ok => "✓",
1010            IntegrityStatus::Drift => {
1011                drift = true;
1012                "⛔"
1013            }
1014            IntegrityStatus::Missing | IntegrityStatus::Unpinned => "•",
1015        };
1016        println!("  {glyph} {} — {}", f.name, f.status.label());
1017    }
1018    if drift {
1019        eprintln!(
1020            "\nOne or more addons no longer match their pinned wiring. Review the \
1021             `[[gateway.servers]]` entries, then re-install (`addon add`) or remove them."
1022        );
1023        std::process::exit(1);
1024    }
1025}
1026
1027/// `addon init [name]` — scaffold a ready-to-edit `lean-ctx-addon.toml` in the
1028/// current directory. `--http` for an HTTP addon, `--force` to overwrite.
1029fn cmd_init(args: &[String]) {
1030    use crate::core::addons::scaffold;
1031    use crate::core::mcp_catalog::TransportKind;
1032
1033    let transport = if args.iter().any(|a| a == "--http") {
1034        TransportKind::Http
1035    } else {
1036        TransportKind::Stdio
1037    };
1038    let force = args.iter().any(|a| a == "--force" || a == "-f");
1039
1040    // `--command "npx -y pkg@1.2.3"` (stdio only): wire a real command and let
1041    // the scaffold pick capabilities that actually let it run (GH #1079).
1042    let command: Option<Vec<String>> = (transport == TransportKind::Stdio)
1043        .then(|| flag_value(args, "--command"))
1044        .flatten()
1045        .map(|spec| spec.split_whitespace().map(str::to_string).collect());
1046
1047    // Slug: explicit positional, else the current directory name.
1048    let slug = positional(args).or_else(|| {
1049        std::env::current_dir()
1050            .ok()
1051            .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
1052            .and_then(|n| scaffold::slugify(&n))
1053    });
1054    let Some(raw) = slug else {
1055        eprintln!("Could not derive an addon name. Pass one: `lean-ctx addon init my-addon`.");
1056        std::process::exit(1);
1057    };
1058    let Some(slug) = scaffold::slugify(&raw) else {
1059        eprintln!("`{raw}` has no usable slug characters ([a-z0-9-]).");
1060        std::process::exit(1);
1061    };
1062
1063    let path = Path::new(scaffold::MANIFEST_FILENAME);
1064    if path.exists() && !force {
1065        eprintln!(
1066            "{} already exists. Re-run with --force to overwrite.",
1067            scaffold::MANIFEST_FILENAME
1068        );
1069        std::process::exit(1);
1070    }
1071
1072    let contents = scaffold::addon_manifest(&slug, transport, command.as_deref());
1073    if let Err(e) = std::fs::write(path, contents) {
1074        eprintln!("Error writing {}: {e}", scaffold::MANIFEST_FILENAME);
1075        std::process::exit(1);
1076    }
1077
1078    println!("✓ Wrote {} (addon `{slug}`).", scaffold::MANIFEST_FILENAME);
1079    println!("\nNext:");
1080    println!("  1. Edit the manifest — fill in description/author/homepage.");
1081    println!(
1082        "  2. Audit it:    lean-ctx addon audit ./{}",
1083        scaffold::MANIFEST_FILENAME
1084    );
1085    println!(
1086        "  3. Test live:   lean-ctx addon add ./{}",
1087        scaffold::MANIFEST_FILENAME
1088    );
1089    println!("  4. Get listed:  see docs/guides/addons.md");
1090}
1091
1092/// `addon registry validate [path]` — run the registry security/quality bar
1093/// (#864 + #403) against a registry JSON file, or the bundled + local registry
1094/// if no path is given. The dry-run harness an author / CI uses before opening a
1095/// merge request. Non-zero exit when problems are found.
1096fn cmd_registry(args: &[String]) {
1097    let sub = args.get(1).map_or("", String::as_str);
1098    if sub != "validate" {
1099        eprintln!("Usage: lean-ctx addon registry validate [path-to-registry.json]");
1100        std::process::exit(1);
1101    }
1102
1103    let (entries, label) = match args.get(2).map(String::as_str) {
1104        Some(path) if !path.starts_with('-') => match load_registry_file(path) {
1105            Ok(e) => (e, path.to_string()),
1106            Err(e) => {
1107                eprintln!("Error: {e}");
1108                std::process::exit(1);
1109            }
1110        },
1111        _ => (
1112            registry::all(),
1113            "installed registry (bundled + local)".to_string(),
1114        ),
1115    };
1116
1117    let problems = registry::validate_entries(&entries);
1118    if problems.is_empty() {
1119        println!(
1120            "✓ {label}: {} entr{} pass the security + quality bar.",
1121            entries.len(),
1122            if entries.len() == 1 { "y" } else { "ies" }
1123        );
1124        return;
1125    }
1126    eprintln!("✗ {label}: {} problem(s):\n", problems.len());
1127    for p in &problems {
1128        eprintln!("  • {p}");
1129    }
1130    std::process::exit(1);
1131}
1132
1133/// Parse a registry JSON file (`{ "addons": [ … ] }`) into manifests.
1134fn load_registry_file(path: &str) -> Result<Vec<AddonManifest>, String> {
1135    let raw = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?;
1136    #[derive(serde::Deserialize)]
1137    struct RegistryFile {
1138        #[serde(default)]
1139        addons: Vec<AddonManifest>,
1140    }
1141    serde_json::from_str::<RegistryFile>(&raw)
1142        .map(|f| f.addons)
1143        .map_err(|e| format!("{path} is not a valid registry file: {e}"))
1144}
1145
1146/// `addon audit <name|path>` — run the publish/list gate (#403): wiring risk +
1147/// capability coherence + malware heuristics, then the verified/paid verdict.
1148/// Exits non-zero on a `fail` verdict so it is usable in CI / a publish hook.
1149fn cmd_audit(target: &str) {
1150    let manifest = if looks_like_path(target) {
1151        match AddonManifest::from_path(Path::new(target)) {
1152            Ok(m) => m,
1153            Err(e) => {
1154                eprintln!("Error: {e}");
1155                std::process::exit(1);
1156            }
1157        }
1158    } else {
1159        let Some(m) = registry::get(target) else {
1160            eprintln!("Unknown addon `{target}`. Pass a name from the registry or a path.");
1161            std::process::exit(1);
1162        };
1163        m
1164    };
1165
1166    let report = crate::core::addons::audit::audit(&manifest);
1167    println!("Audit of `{}`:\n", manifest.addon.name);
1168    println!("  verdict:        {}", report.verdict.as_str());
1169    println!(
1170        "  capabilities:   {}",
1171        if manifest.capabilities.is_some() {
1172            if report.capability_coherent {
1173                "declared + coherent with wiring"
1174            } else {
1175                "declared but INCOHERENT with wiring"
1176            }
1177        } else {
1178            "not declared"
1179        }
1180    );
1181    println!(
1182        "  binary pin:     {}",
1183        if manifest.mcp.transport == crate::core::mcp_catalog::TransportKind::Http {
1184            "n/a (http transport)"
1185        } else if report.binary_pinned {
1186            "pinned (sha256)"
1187        } else {
1188            "unpinned"
1189        }
1190    );
1191    println!(
1192        "  paid-eligible:  {} (verified/paid tier requires a clean audit, declared + coherent \
1193         capabilities, and a pinned binary)",
1194        if report.paid_eligible { "yes" } else { "no" }
1195    );
1196
1197    // Track B: when the manifest carries `[pricing]`, show whether it clears the
1198    // mandatory paid-listing gate and, if not, exactly what blocks the sale.
1199    if let Some(pricing) = &manifest.pricing
1200        && pricing.is_paid()
1201    {
1202        let price = match pricing.model {
1203            crate::core::addons::PricingModel::OneTime => {
1204                format!(
1205                    "{} {} one-time",
1206                    pricing.price_cents,
1207                    pricing.currency_or_default()
1208                )
1209            }
1210            crate::core::addons::PricingModel::Usage => format!(
1211                "{} {}/1k tool calls (usage)",
1212                pricing.usage_price_per_1k_cents,
1213                pricing.currency_or_default()
1214            ),
1215        };
1216        println!("  pricing:        {price}");
1217        let gate = crate::core::addons::paid_listing_gate(&manifest, &report);
1218        if gate.eligible {
1219            println!("  paid listing:   ELIGIBLE — clears the security gate");
1220        } else {
1221            println!("  paid listing:   BLOCKED");
1222            for blocker in &gate.blockers {
1223                println!("                    - {blocker}");
1224            }
1225        }
1226    }
1227
1228    if report.findings.is_empty() {
1229        println!("\n  No findings.");
1230    } else {
1231        println!("\n  Findings:");
1232        for f in &report.findings {
1233            println!(
1234                "    {} [{}] {} ({})",
1235                f.level.glyph(),
1236                f.level.as_str(),
1237                f.message,
1238                f.code
1239            );
1240        }
1241    }
1242
1243    if report.verdict == crate::core::addons::AuditVerdict::Fail {
1244        eprintln!(
1245            "\nAudit failed — this addon must not be listed until the blocking findings are resolved."
1246        );
1247        std::process::exit(1);
1248    }
1249}
1250
1251/// Read the value following `flag` in `args` (e.g. `--reason "text"`).
1252fn flag_value(args: &[String], flag: &str) -> Option<String> {
1253    args.iter()
1254        .position(|a| a == flag)
1255        .and_then(|i| args.get(i + 1))
1256        .map(|s| s.trim().to_string())
1257        .filter(|s| !s.is_empty())
1258}
1259
1260fn print_install_preview(manifest: &AddonManifest) {
1261    let mcp = &manifest.mcp;
1262    println!(
1263        "  trust:     {}",
1264        crate::core::addons::TrustTier::of(manifest).label()
1265    );
1266    println!("  transport: {}", mcp.transport.as_str());
1267    match mcp.transport {
1268        crate::core::mcp_catalog::TransportKind::Stdio => {
1269            println!("  command:   {}", mcp.command);
1270            if !mcp.args.is_empty() {
1271                println!("  args:      {}", mcp.args.join(" "));
1272            }
1273            if !mcp.env.is_empty() {
1274                let keys: Vec<&str> = mcp.env.keys().map(String::as_str).collect();
1275                println!("  env:       {}", keys.join(", "));
1276            }
1277            if !mcp.sha256.trim().is_empty() {
1278                println!("  binary:    sha256-pinned");
1279            }
1280            if let Some(asset) = manifest.artifact_for_current_platform() {
1281                println!(
1282                    "  artifact:  {} → managed bin dir (sha256-pinned, never PATH)",
1283                    asset.filename
1284                );
1285            }
1286        }
1287        crate::core::mcp_catalog::TransportKind::Http => {
1288            println!("  url:       {}", mcp.url);
1289            if !mcp.headers.is_empty() {
1290                let keys: Vec<&str> = mcp.headers.keys().map(String::as_str).collect();
1291                println!("  headers:   {}", keys.join(", "));
1292            }
1293        }
1294    }
1295    print_bootstrap(manifest);
1296    print_capabilities(manifest);
1297    print_security_review(manifest);
1298}
1299
1300/// Disclose the bootstrap install a `[install]` block performs on `add` (#1105):
1301/// the exact, shell-free package-manager commands the user is consenting to.
1302fn print_bootstrap(manifest: &AddonManifest) {
1303    let install = &manifest.install;
1304    if !install.is_declared() {
1305        return;
1306    }
1307    // A managed artifact for this platform supersedes the bootstrap (GH #725) —
1308    // say so instead of describing an install that will not run.
1309    if manifest.artifact_for_current_platform().is_some() {
1310        println!(
1311            "\n  Install on add: skipped — the prebuilt artifact above is used \
1312             instead of `{}`.",
1313            install.manager.trim()
1314        );
1315        return;
1316    }
1317    let prog = install
1318        .manager()
1319        .map_or_else(|| install.manager.trim().to_string(), |m| m.as_str().into());
1320    println!("\n  Install on add — runs a pinned package manager before first use:");
1321    println!("    manager:   {}", install.manager.trim());
1322    println!(
1323        "    package:   {} (pinned {})",
1324        install.package.trim(),
1325        install.version.trim()
1326    );
1327    println!("    install:   {prog} {}", install.install_argv().join(" "));
1328    println!(
1329        "    uninstall: {prog} {}   (run on `addon remove`)",
1330        install.uninstall_argv().join(" ")
1331    );
1332    // Pre-flight: tell the user up front whether the manager is even present, so
1333    // a missing toolchain is visible before they consent rather than mid-install.
1334    if let Some(m) = install.manager() {
1335        if m.is_available() {
1336            println!("    requires:  `{prog}` on PATH — ✓ found");
1337        } else {
1338            println!(
1339                "    requires:  `{prog}` on PATH — ✗ NOT found ({})",
1340                m.install_hint()
1341            );
1342        }
1343    }
1344}
1345
1346/// Show the declared capabilities the user is about to grant (P1). A declared
1347/// `[capabilities]` block means the addon runs under a per-addon OS sandbox +
1348/// scrubbed environment derived from exactly these permissions; an addon with
1349/// no block runs under the legacy `addons.sandbox` mode.
1350fn print_capabilities(manifest: &AddonManifest) {
1351    match &manifest.capabilities {
1352        Some(caps) => {
1353            println!(
1354                "\n  Capabilities — network/filesystem/env enforced (sandbox + scrub, \
1355                 inherited by children); exec declared + audited:"
1356            );
1357            for line in caps.summary() {
1358                println!("    • {line}");
1359            }
1360        }
1361        None => {
1362            if manifest.mcp.transport == crate::core::mcp_catalog::TransportKind::Stdio {
1363                println!(
1364                    "\n  Capabilities: none declared — governed by `addons.sandbox` \
1365                     (set a [capabilities] block for a per-addon sandbox)."
1366                );
1367            }
1368        }
1369    }
1370}
1371
1372/// Static risk review shown before install — disclosure, not a verdict (the
1373/// install policy gate enforces; see [`crate::core::addons::policy`]). Sourced
1374/// from the full audit (#403) so wiring risk, capability-coherence and malware
1375/// heuristics all surface before the user consents.
1376fn print_security_review(manifest: &AddonManifest) {
1377    let findings = crate::core::addons::audit::audit(manifest).findings;
1378    if findings.is_empty() {
1379        return;
1380    }
1381    println!("\n  Security review:");
1382    for f in &findings {
1383        println!(
1384            "    {} [{}] {}",
1385            f.level.glyph(),
1386            f.level.as_str(),
1387            f.message
1388        );
1389    }
1390}
1391
1392fn print_field(label: &str, value: &str) {
1393    if !value.trim().is_empty() {
1394        println!(
1395            "  {label}:{}{value}",
1396            " ".repeat(11usize.saturating_sub(label.len() + 1))
1397        );
1398    }
1399}
1400
1401fn looks_like_path(target: &str) -> bool {
1402    Path::new(target)
1403        .extension()
1404        .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
1405        || target.contains('/')
1406        || target.starts_with('.')
1407        || Path::new(target).is_file()
1408}
1409
1410fn first_line(s: &str) -> String {
1411    let line = s.lines().next().unwrap_or("").trim();
1412    if line.chars().count() > 88 {
1413        let cut: String = line.chars().take(87).collect();
1414        format!("{cut}…")
1415    } else {
1416        line.to_string()
1417    }
1418}
1419
1420fn print_help() {
1421    eprintln!(
1422        "lean-ctx addon — community extensions (MCP servers) for lean-ctx\n\
1423         \n\
1424         USAGE:\n    \
1425             lean-ctx addon <action> [args]\n\
1426         \n\
1427         ACTIONS:\n    \
1428             list                 List installed addons + the registry\n    \
1429             init [name]          Scaffold a lean-ctx-addon.toml here\n                         \
1430                                  [--http] [--force]\n                         \
1431                                  [--command \"npx -y pkg@1.2.3\"]\n    \
1432             search [query]       Search the registry (empty = list all)\n    \
1433             categories           Browse the registry by category\n    \
1434             usage                Per-addon / per-tool call counters\n    \
1435             info <name|path>     Show an addon's details + MCP wiring\n    \
1436             add <name|path>      Install from the registry, a hosted pack\n                         \
1437                                  (<namespace>/<name>, ctxpkg.com) or a local\n                         \
1438                                  lean-ctx-addon.toml (asks for confirmation)\n    \
1439             update <name>        Update an addon from where it came (side-by-\n                         \
1440                                  side managed binary, health-gated, auto-prune)\n    \
1441             publish [manifest]   Build + sign the kind=addon pack and upload\n                         \
1442                                  it to ctxpkg.com --namespace <ns> [--check]\n    \
1443             remove <name>        Uninstall an addon\n    \
1444             revoke <name>        Block an addon from running (kill-switch)\n                         \
1445                                  [--reason \"…\"] [--version X]\n    \
1446             unrevoke <name>      Lift a revocation\n    \
1447             revocations          List active revocations\n    \
1448             verify               Re-check installed addons against their\n                         \
1449                                  pinned wiring (integrity lock)\n    \
1450             audit <name|path>    Run the publish/list gate: wiring risk +\n                         \
1451                                  capability coherence + malware heuristics\n    \
1452             registry validate [path]\n                         \
1453                                  Validate a registry file (or the installed\n                         \
1454                                  registry) against the security + quality bar\n    \
1455             help                 Show this help\n\
1456         \n\
1457         FLAGS:\n    \
1458             -y, --yes            Skip the confirmation prompt (scripts/CI)\n    \
1459             --no-verify          add: skip the post-install MCP health probe\n    \
1460             --force, -f          add: install despite an under-declared\n                         \
1461                                  capability warning (init: overwrite)\n\
1462         \n\
1463         BUILD YOUR OWN ADDON:\n    \
1464             1. Expose your tool as an MCP server (stdio binary or HTTP endpoint).\n    \
1465             2. Add a lean-ctx-addon.toml to your repo:\n\
1466         \n        \
1467                 [addon]\n        \
1468                 name = \"my-addon\"            # slug: [a-z0-9-]\n        \
1469                 display_name = \"My Addon\"\n        \
1470                 description = \"What it does, in one line.\"\n        \
1471                 author = \"you\"\n        \
1472                 homepage = \"https://github.com/you/my-addon\"\n        \
1473                 license = \"Apache-2.0\"\n        \
1474                 categories = [\"workflow\"]\n        \
1475                 keywords = [\"...\"]\n\
1476         \n        \
1477                 [mcp]\n        \
1478                 transport = \"stdio\"          # or \"http\"\n        \
1479                 command = \"my-addon-mcp\"     # stdio: executable to spawn\n        \
1480                 args = [\"serve\"]\n        \
1481                 # sha256 = \"<shasum -a 256>\"  # stdio: pin the binary (P3)\n        \
1482                 # url = \"https://...\"         # http: streamable endpoint\n\
1483         \n        \
1484                 [capabilities]               # secure-by-default; widen only what you need\n        \
1485                 network = \"none\"             # \"full\" to reach the internet\n        \
1486                 filesystem = \"read_only\"     # \"read_write\" to write outside tmp\n        \
1487                 exec = \"none\"                # or [\"lean-ctx\"] if you spawn subprocesses\n\
1488         \n    \
1489             3. Test it live:  lean-ctx addon add ./lean-ctx-addon.toml\n    \
1490             4. Publish:       lean-ctx addon publish --namespace <your-handle>\n                      \
1491                               — self-service via ctxpkg.com; users install with\n                      \
1492                               `lean-ctx addon add <your-handle>/my-addon`.\n                      \
1493                               (Curated default catalog: MR against\n                      \
1494                               rust/data/addon_registry.json, docs/guides/addons.md.)\n\
1495         \n    \
1496             Full guide: docs/guides/addons.md"
1497    );
1498}