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