Skip to main content

lean_ctx/cli/addon_cmd/
commands.rs

1use super::{
2    AddonManifest, ArtifactReceipt, InstalledStore, Path, addon_self_ref, artifact_install,
3    bootstrap, cmd_audit, cmd_init, cmd_publish, cmd_registry, cmd_remove, cmd_revocations,
4    cmd_revoke, cmd_unrevoke, cmd_update, cmd_verify, first_line, flag_value, install,
5    install_declared_deps, looks_like_path, print_field, print_help, print_install_preview,
6    registry, resolve_declared_deps,
7};
8
9pub fn cmd_addon(args: &[String]) -> i32 {
10    let action = args.first().map_or("list", String::as_str);
11
12    match action {
13        "list" | "ls" => {
14            cmd_list();
15            0
16        }
17        "init" | "new" => cmd_init(args),
18        "registry" => cmd_registry(args),
19        "categories" | "cats" => {
20            cmd_categories();
21            0
22        }
23        "usage" | "stats" => {
24            cmd_usage();
25            0
26        }
27        "search" | "browse" => {
28            cmd_search(args.get(1).map_or("", String::as_str));
29            0
30        }
31        "info" | "show" => match positional(args) {
32            Some(name) => cmd_info(&name),
33            None => usage_exit("lean-ctx addon info <name>"),
34        },
35        "add" | "install" => match positional(args) {
36            Some(target) => cmd_add(&target, args),
37            None => usage_exit("lean-ctx addon add <name|path-to-lean-ctx-addon.toml>"),
38        },
39        "remove" | "rm" | "uninstall" => match positional(args) {
40            Some(name) => cmd_remove(&name, args),
41            None => usage_exit("lean-ctx addon remove <name>"),
42        },
43        "update" | "upgrade" => match positional(args) {
44            Some(name) => cmd_update(&name, args),
45            None => usage_exit("lean-ctx addon update <name>"),
46        },
47        "revoke" => match positional(args) {
48            Some(name) => cmd_revoke(&name, args),
49            None => usage_exit("lean-ctx addon revoke <name> [--reason \"…\"] [--version X]"),
50        },
51        "unrevoke" => match positional(args) {
52            Some(name) => cmd_unrevoke(&name, args),
53            None => usage_exit("lean-ctx addon unrevoke <name>"),
54        },
55        "revocations" => {
56            cmd_revocations();
57            0
58        }
59        "verify" => cmd_verify(),
60        "audit" => match positional(args) {
61            Some(target) => cmd_audit(&target),
62            None => usage_exit("lean-ctx addon audit <name|path-to-lean-ctx-addon.toml>"),
63        },
64        "publish" => cmd_publish(args),
65        "help" | "--help" | "-h" => {
66            print_help();
67            0
68        }
69        _ => {
70            eprintln!("Unknown addon action: {action}");
71            print_help();
72            1
73        }
74    }
75}
76
77/// First non-flag argument after the action.
78pub(super) fn positional(args: &[String]) -> Option<String> {
79    args.get(1)
80        .map(|s| s.trim().to_string())
81        .filter(|s| !s.is_empty() && !s.starts_with('-'))
82}
83
84fn usage_exit(usage: &str) -> i32 {
85    eprintln!("Usage: {usage}");
86    1
87}
88
89fn cmd_list() {
90    let store = InstalledStore::load();
91    let installed = store.list();
92
93    if installed.is_empty() {
94        println!("No addons installed.");
95    } else {
96        println!("Installed addons:\n");
97        for a in &installed {
98            let ver = if a.version.is_empty() {
99                String::new()
100            } else {
101                format!(" v{}", a.version)
102            };
103            if let Some(reason) = crate::core::addons::revocation::blocked_reason(&a.name) {
104                println!(
105                    "  ⛔ {}{ver}  → REVOKED ({reason}) — will not run; remove with `addon remove {}`",
106                    a.name, a.name
107                );
108            } else {
109                println!(
110                    "  ✓ {}{ver}  → gateway server `{}` ({})",
111                    a.name, a.gateway_server, a.source
112                );
113            }
114        }
115    }
116
117    let available = registry::all();
118    if !available.is_empty() {
119        println!("\nRegistry:\n");
120        for m in &available {
121            let installed_flag = if store.get(&m.addon.name).is_some() {
122                " [installed]"
123            } else {
124                ""
125            };
126            let status = if m.is_installable() {
127                ""
128            } else {
129                " · listed (no published endpoint yet)"
130            };
131            let badge = if m.addon.verified { " [verified]" } else { "" };
132            println!(
133                "  • {}{badge} — {}{status}{installed_flag}",
134                m.addon.name,
135                first_line(&m.addon.description)
136            );
137        }
138    }
139
140    println!(
141        "\nAdd one with `lean-ctx addon add <name>` · build your own with `lean-ctx addon help`."
142    );
143}
144
145fn cmd_search(query: &str) {
146    let hits = registry::search(query);
147    if hits.is_empty() {
148        println!("No addons match `{query}`.");
149        return;
150    }
151    if query.trim().is_empty() {
152        println!("All registry addons:\n");
153    } else {
154        println!("Addons matching `{query}`:\n");
155    }
156    for m in &hits {
157        let status = if m.is_installable() {
158            "installable"
159        } else {
160            "listed"
161        };
162        let badge = if m.addon.verified { " [verified]" } else { "" };
163        println!("  {}{badge} — {}", m.addon.name, m.display_name());
164        println!("      {}", first_line(&m.addon.description));
165        if m.addon.categories.is_empty() {
166            println!("      {status}");
167        } else {
168            println!(
169                "      categories: {} · {status}",
170                m.addon.categories.join(", ")
171            );
172        }
173    }
174}
175
176/// `addon categories` — browse the registry by category (discovery, P5). Counts
177/// are computed from the live registry, so the list is always accurate.
178fn cmd_categories() {
179    use std::collections::BTreeMap;
180    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
181    for m in registry::all() {
182        for c in &m.addon.categories {
183            *counts.entry(c.trim().to_string()).or_default() += 1;
184        }
185    }
186    if counts.is_empty() {
187        println!("No categories yet.");
188        return;
189    }
190    println!("Addon categories:\n");
191    for (cat, n) in &counts {
192        println!("  {cat}  ({n})");
193    }
194    println!("\nFilter with `lean-ctx addon search <category>`.");
195}
196
197/// `addon usage` — per-addon / per-tool call counters from the local meter
198/// (P5). The honest basis for "most-used" discovery and usage-metered billing.
199fn cmd_usage() {
200    use crate::core::addons::meter::UsageLedger;
201    let ledger = UsageLedger::load();
202    let ranked = ledger.by_usage();
203    if ranked.is_empty() {
204        println!(
205            "No addon usage recorded yet. (Metering is {}.)",
206            if InstalledStore::load().list().is_empty() {
207                "ready once you install + use an addon"
208            } else {
209                "on; call an addon tool via the gateway to populate it"
210            }
211        );
212        return;
213    }
214    println!("Addon usage (most-used first):\n");
215    for (name, usage) in ranked {
216        let revoked = if crate::core::addons::revocation::blocked_reason(name).is_some() {
217            " ⛔ revoked"
218        } else {
219            ""
220        };
221        println!(
222            "  {name}{revoked} — {} call(s), {} error(s)",
223            usage.calls, usage.errors
224        );
225        let mut tools: Vec<_> = usage.tools.iter().collect();
226        tools.sort_by(|a, b| b.1.calls.cmp(&a.1.calls).then_with(|| a.0.cmp(b.0)));
227        for (tool, ts) in tools.iter().take(5) {
228            println!("      {tool}: {} call(s), {} error(s)", ts.calls, ts.errors);
229        }
230    }
231}
232
233fn cmd_info(name: &str) -> i32 {
234    let store = InstalledStore::load();
235    let Some(manifest) = registry::get(name).or_else(|| {
236        // Allow `info` on a local manifest path too.
237        looks_like_path(name)
238            .then(|| AddonManifest::from_path(Path::new(name)).ok())
239            .flatten()
240    }) else {
241        // Not in the registry and not a manifest path — but it may be a
242        // locally-installed addon recorded in the store.
243        if let Some(installed) = store.get(name) {
244            println!("{}", installed.name);
245            print_field("Version", &installed.version);
246            println!(
247                "  Status:    installed (gateway server `{}`, {})",
248                installed.gateway_server, installed.source
249            );
250            return 0;
251        }
252        eprintln!(
253            "Addon `{name}` not found. Try `lean-ctx addon search`, or pass a path to a \
254             lean-ctx-addon.toml."
255        );
256        return 1;
257    };
258
259    println!("{} ({})", manifest.display_name(), manifest.addon.name);
260    if !manifest.addon.description.is_empty() {
261        println!("  {}", manifest.addon.description);
262    }
263    print_field("Author", &manifest.addon.author);
264    print_field("Version", &manifest.addon.version);
265    print_field("License", &manifest.addon.license);
266    print_field("Homepage", &manifest.addon.homepage);
267    if !manifest.addon.categories.is_empty() {
268        println!("  Categories: {}", manifest.addon.categories.join(", "));
269    }
270
271    if let Some(installed) = store.get(name) {
272        println!(
273            "  Status:    installed (gateway server `{}`, {})",
274            installed.gateway_server, installed.source
275        );
276    } else if manifest.is_installable() {
277        println!(
278            "  Status:    installable — `lean-ctx addon add {}`",
279            manifest.addon.name
280        );
281    } else {
282        println!("  Status:    listed (no published MCP endpoint yet)");
283    }
284
285    if manifest.is_installable() {
286        println!();
287        print_install_preview(&manifest);
288    }
289    0
290}
291
292fn cmd_add(target: &str, args: &[String]) -> i32 {
293    // Resolution order: local manifest file → hosted ctxpkg pack (`ns/slug`,
294    // GH #726) → bundled registry slug. A bare `ns/slug` that exists on disk
295    // is treated as the local path it names.
296    let is_local_path = Path::new(target)
297        .extension()
298        .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
299        || target.starts_with('.')
300        || target.starts_with('/')
301        || Path::new(target).exists();
302    let (manifest, source) = if is_local_path {
303        match AddonManifest::from_path(Path::new(target)) {
304            Ok(m) => (m, "local".to_string()),
305            Err(e) => {
306                eprintln!("Error: {e}");
307                return 1;
308            }
309        }
310    } else if let Some(remote_ref) = crate::core::context_package::remote::parse_remote_ref(target)
311    {
312        match fetch_addon_pack(&remote_ref, flag_value(args, "--registry").as_deref()) {
313            Ok((m, s)) => (m, s),
314            Err(e) => {
315                eprintln!("Error: {e}");
316                return 1;
317            }
318        }
319    } else {
320        let Some(m) = registry::get(target) else {
321            eprintln!(
322                "Unknown addon `{target}`.\n\
323                 Browse with `lean-ctx addon search`, install a hosted pack with \
324                 `lean-ctx addon add <namespace>/<name>`, or pass a path to a \
325                 lean-ctx-addon.toml."
326            );
327            return 1;
328        };
329        (m, "registry".to_string())
330    };
331
332    if let Err(e) = manifest.validate() {
333        eprintln!("Error: {e}");
334        return 1;
335    }
336
337    if !manifest.is_installable() {
338        eprintln!(
339            "`{name}` is listed but not yet one-click installable (no published MCP endpoint).\n\
340             Follow {home} — once it ships an MCP server, `lean-ctx addon add {name}` will \
341             wire it automatically.",
342            name = manifest.addon.name,
343            home = if manifest.addon.homepage.is_empty() {
344                "its homepage"
345            } else {
346                &manifest.addon.homepage
347            }
348        );
349        return 1;
350    }
351
352    let force = args.iter().any(|a| a == "--force" || a == "-f");
353    let no_verify = args.iter().any(|a| a == "--no-verify");
354    let cfg = crate::core::config::Config::load();
355
356    // Fail fast (#1080): run the full pre-persist gate — policy, kill-switch,
357    // capability coherence — before rendering the preview or spawning a probe,
358    // so a rejected addon surfaces a clear verdict and nothing is touched.
359    // (The health probe later targets the post-artifact wiring instead of
360    // this resolution, so only the verdict matters here.)
361    if let Err(e) = install::preflight(&manifest, &cfg.addons, force) {
362        eprintln!("Error: {e}");
363        return 1;
364    }
365
366    println!("About to install `{}`:\n", manifest.addon.name);
367    print_install_preview(&manifest);
368
369    // Depth-1 dependency resolution (GH #727): declared deps are part of the
370    // consent surface — preview before asking, install before wiring. The
371    // dependency list lives in the addon manifest itself, so a local
372    // `lean-ctx-addon.toml` install resolves them the same as a hosted pack
373    // (Finding A).
374    // Self-dependency root: the addon's own scoped `@ns/slug` when the source
375    // names a namespace (hosted pack), else `None` (a local manifest cannot
376    // name itself) — never the bare `addon.name` slug (GH #727, Finding A).
377    let root_ref = addon_self_ref(&source);
378    let preview_deps = resolve_declared_deps(&manifest.dependencies, root_ref.as_deref(), args);
379    if !preview_deps.is_empty() {
380        println!("\nDeclared dependencies (installed alongside, depth-1):");
381        for d in &preview_deps {
382            println!("  + {}@{}", d.name, d.version);
383        }
384    }
385
386    println!(
387        "\nThis runs/connects to the above MCP server and exposes its tools through lean-ctx."
388    );
389
390    if !super::prompt::confirm(
391        "Install this addon into the MCP gateway?",
392        super::prompt::wants_yes(args),
393    ) {
394        println!("Aborted. Nothing was changed.");
395        return 0;
396    }
397
398    // The slice wired into `[mcp.env]` must be the versions the install step
399    // actually landed (lockfile honoured), never the preview's highest-match
400    // resolution — otherwise `{pack_dir:}` could point at a directory that does
401    // not exist (Finding B).
402    let installed_deps = if preview_deps.is_empty() {
403        Vec::new()
404    } else {
405        install_declared_deps(&manifest.dependencies, root_ref.as_deref(), args)
406    };
407
408    match provision_and_wire(manifest, &source, force, no_verify, &cfg, &installed_deps) {
409        Ok((outcome, verified)) => {
410            println!(
411                "\n✓ Installed `{}` → gateway server `{}`.",
412                outcome.name, outcome.gateway_server
413            );
414            if outcome.enabled_gateway {
415                println!("  Enabled the MCP gateway (gateway.enabled = true).");
416            }
417            if let Some(n) = verified {
418                println!("  Verified: {n} tool(s) reachable.");
419            }
420            println!(
421                "  Its tools are reachable via `ctx_tools` (find/call). \
422                 Restart your MCP client to pick them up."
423            );
424        }
425        Err(e) => {
426            eprintln!("Error: {e}");
427            return 1;
428        }
429    }
430    0
431}
432
433/// The impure provisioning pipeline `add` and `update` share, run after user
434/// consent: pack-env expansion (#727) → managed artifact (GH #725) → bootstrap
435/// (#1105) → health probe (#1076) → wire. On any error nothing is wired.
436/// Returns the install outcome plus the probed tool count (`None` with
437/// `--no-verify`).
438pub(super) fn provision_and_wire(
439    mut manifest: AddonManifest,
440    source: &str,
441    force: bool,
442    no_verify: bool,
443    cfg: &crate::core::config::Config,
444    resolved_deps: &[crate::core::context_package::deps::ResolvedDep],
445) -> Result<(install::InstallOutcome, Option<usize>), String> {
446    // Pack-dir delivery (GH #727): expand `{pack_dir:@ns/name}` in [mcp.env]
447    // against the resolved dependency versions. The caller installed those
448    // dependencies already, so every path burned into the wiring exists. The
449    // parameter *is* the ordering guarantee — this cannot be called before the
450    // deps are resolved.
451    if !manifest.mcp.env.is_empty() {
452        let store_root = crate::core::context_package::LocalRegistry::open()?
453            .root()
454            .to_path_buf();
455        manifest.mcp.env = crate::core::addons::pack_env::expand_pack_env(
456            &manifest.mcp.env,
457            resolved_deps,
458            &store_root,
459        )?;
460    }
461
462    // Managed artifact (GH #725, Phase 1): a prebuilt binary for this platform
463    // takes precedence over [install]/PATH. It lands in the managed bin dir
464    // (never PATH), hash-verified; the gateway command is rewritten to the
465    // absolute path and the SHA-256 auto-pinned as the spawn-time binhash.
466    let mut artifact_receipt: Option<ArtifactReceipt> = None;
467    if let Some(asset) = manifest.artifact_for_current_platform().cloned() {
468        let triple = artifact_install::current_target_triple();
469        println!("\nInstalling prebuilt binary for {triple} (sha256-pinned)…");
470        let path = artifact_install::ensure_addon_binary(
471            &manifest.addon.name,
472            &manifest.addon.version,
473            &asset,
474        )
475        .map_err(|e| format!("artifact install failed: {e}\n  Nothing was wired."))?;
476        println!("  ✓ {}", path.display());
477        artifact_receipt = Some(ArtifactReceipt {
478            platform: triple.to_string(),
479            url: asset.url.clone(),
480            sha256: asset.sha256.clone(),
481            path: path.display().to_string(),
482        });
483        manifest.mcp.command = path.display().to_string();
484        manifest.mcp.sha256 = asset.sha256;
485    } else if manifest.install.is_declared() {
486        // Bootstrap (#1105): provision the upstream package via its pinned
487        // manager *before* probing — the [mcp] command depends on it. The
488        // policy floor (addons.allow_bootstrap) was already enforced in
489        // preflight. Skipped when a managed artifact resolved above (the
490        // artifact IS the binary the bootstrap would have provisioned).
491        println!(
492            "\nInstalling `{}` via {} (pinned {})…",
493            manifest.install.package.trim(),
494            manifest.install.manager.trim(),
495            manifest.install.version.trim()
496        );
497        let outcome = bootstrap::ensure_installed(&manifest.install)
498            .map_err(|e| format!("bootstrap install failed: {e}\n  Nothing was wired."))?;
499        match outcome.status {
500            bootstrap::BootstrapStatus::AlreadyPresent => {
501                println!("  Already installed — skipped.");
502            }
503            bootstrap::BootstrapStatus::Installed => println!("  ✓ Installed."),
504        }
505        if let Some(warning) = outcome.warning {
506            eprintln!("  ⚠ {warning}");
507        }
508    }
509
510    // Health probe (#1076): confirm the server actually speaks MCP *before* we
511    // wire it, so a broken command/args fails now with a clear message instead
512    // of opaquely at first `ctx_tools` use. Skip with `--no-verify`. Probes the
513    // post-artifact wiring, i.e. exactly what the gateway will spawn.
514    let server = manifest.to_gateway_server();
515    let mut verified: Option<usize> = None;
516    if !no_verify {
517        // First spawn may download a package (npx/uvx), so allow extra headroom
518        // over the per-call timeout.
519        let timeout = std::time::Duration::from_secs(cfg.gateway.call_timeout_secs.max(60));
520        print!("Verifying the MCP server responds… ");
521        let _ = std::io::Write::flush(&mut std::io::stdout());
522        match crate::core::addons::health::probe(&server, timeout) {
523            Ok(report) => {
524                println!("ok ({} tool(s)).", report.tool_count);
525                verified = Some(report.tool_count);
526            }
527            Err(e) => {
528                println!("failed.");
529                return Err(format!(
530                    "`{}` did not pass its health check: {e}\n  \
531                     Nothing was installed. Check the command/args (and capabilities), then retry \
532                     — or skip the check with `--no-verify`.",
533                    manifest.addon.name
534                ));
535            }
536        }
537    }
538
539    let outcome = install::install(&manifest, source, force, artifact_receipt)?;
540    Ok((outcome, verified))
541}
542
543/// Resolve `ns/slug[@version]` against the hosted ctxpkg registry and unwrap
544/// the `kind=addon` pack into the addon manifest it embeds (GH #726).
545///
546/// Trust chain before anything is returned: artifact SHA-256 against the
547/// registry index (in `download_verified`), then full pack verification —
548/// integrity hashes, **mandatory** ed25519 signature (packs carrying
549/// executable references get no unsigned path), kind=addon and
550/// kind↔payload coherence. The embedded TOML then walks the exact same
551/// consent/preflight/probe pipeline as every other source.
552pub(super) fn fetch_addon_pack(
553    remote_ref: &crate::core::context_package::remote::RemoteRef,
554    registry_flag: Option<&str>,
555) -> Result<(AddonManifest, String), String> {
556    use crate::core::context_package::{remote, verify};
557
558    let base = remote::registry_base(registry_flag);
559    let ns = &remote_ref.namespace;
560    let name = &remote_ref.name;
561    let token = remote::publish_token(None);
562
563    println!("Resolving @{ns}/{name} via {base} …");
564    let versions = remote::fetch_versions(&base, ns, name, token.as_deref())?;
565    let info = remote::select_version(&versions, remote_ref.version.as_deref())?;
566    if info.yanked {
567        eprintln!(
568            "WARNING: @{ns}/{name}@{} is YANKED — installing only because the version \
569             was pinned explicitly",
570            info.version
571        );
572    }
573    let bytes = remote::download_verified(&base, ns, name, info, token.as_deref())?;
574    let text = String::from_utf8(bytes).map_err(|_| "package is not valid UTF-8".to_string())?;
575
576    let report = verify::verify_package_text(&text);
577    if !report.valid() {
578        return Err(format!(
579            "pack verification failed — refusing to install:\n  {}",
580            report.errors.join("\n  ")
581        ));
582    }
583    if report.signature != verify::CheckOutcome::Pass {
584        return Err(
585            "pack is unsigned — addon packs reference executables, so a verifying \
586             ed25519 signature is mandatory"
587                .into(),
588        );
589    }
590
591    #[derive(serde::Deserialize)]
592    struct Bundle {
593        manifest: crate::core::context_package::PackageManifest,
594        content: crate::core::context_package::PackageContent,
595    }
596    let bundle: Bundle = serde_json::from_str(&text).map_err(|e| format!("parse package: {e}"))?;
597    let Bundle {
598        manifest: pack_manifest,
599        content,
600    } = bundle;
601
602    if pack_manifest.kind != crate::core::context_package::manifest::PackageKind::Addon {
603        return Err(format!(
604            "@{ns}/{name} is a kind={} package — install it with `lean-ctx pack install \
605             {ns}/{name}` instead",
606            pack_manifest.kind.as_str()
607        ));
608    }
609    verify::validate_kind_coherence(&pack_manifest, &content).map_err(|errs| errs.join("; "))?;
610
611    let payload = content
612        .addon
613        .expect("coherence guarantees content.addon for kind=addon");
614    let manifest = AddonManifest::from_toml(&payload.manifest_toml)?;
615
616    let source = format!("ctxpkg:@{ns}/{name}@{}", info.version);
617    // Depth-1 dependencies (GH #727) travel inside the addon manifest itself
618    // (`manifest.dependencies`, parsed from the pack's embedded TOML above), so
619    // they resolve the same on every source path — the hosted `pack_manifest`
620    // no longer needs to ride along.
621    Ok((manifest, source))
622}