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