1use 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
68fn 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
167fn 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
188fn 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 looks_like_path(name)
229 .then(|| AddonManifest::from_path(Path::new(name)).ok())
230 .flatten()
231 }) else {
232 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 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 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 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 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
422fn 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 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 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 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 let server = manifest.to_gateway_server();
504 let mut verified: Option<usize> = None;
505 if !no_verify {
506 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
532fn 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 Ok((manifest, source))
611}
612
613fn 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
717fn 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 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 let root_ref = addon_self_ref(&update_source);
770
771 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 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 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 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 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 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
906fn 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
934fn 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
961fn 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
979fn 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
1010fn 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 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 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
1075fn 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
1116fn 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
1129fn 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 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
1234pub(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
1283fn print_bootstrap(manifest: &AddonManifest) {
1286 let install = &manifest.install;
1287 if !install.is_declared() {
1288 return;
1289 }
1290 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 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
1329fn 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
1355fn 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}