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
77pub(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
176fn 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
197fn 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 looks_like_path(name)
238 .then(|| AddonManifest::from_path(Path::new(name)).ok())
239 .flatten()
240 }) else {
241 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 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 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 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 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
433pub(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 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 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 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 let server = manifest.to_gateway_server();
515 let mut verified: Option<usize> = None;
516 if !no_verify {
517 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
543pub(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 Ok((manifest, source))
622}