diff --git a/rust/src/cli/addon_cmd/authoring.rs b/rust/src/cli/addon_cmd/authoring.rs index 9a5b28d91f..8635262e48 100644 --- a/rust/src/cli/addon_cmd/authoring.rs +++ b/rust/src/cli/addon_cmd/authoring.rs @@ -2,7 +2,7 @@ use super::{AddonManifest, Path, flag_value, looks_like_path, positional, regist /// `addon init [name]` — scaffold a ready-to-edit `lean-ctx-addon.toml` in the /// current directory. `--http` for an HTTP addon, `--force` to overwrite. -pub(super) fn cmd_init(args: &[String]) { +pub(super) fn cmd_init(args: &[String]) -> i32 { use crate::core::addons::scaffold; use crate::core::mcp_catalog::TransportKind; @@ -29,11 +29,11 @@ pub(super) fn cmd_init(args: &[String]) { }); let Some(raw) = slug else { eprintln!("Could not derive an addon name. Pass one: `lean-ctx addon init my-addon`."); - std::process::exit(1); + return 1; }; let Some(slug) = scaffold::slugify(&raw) else { eprintln!("`{raw}` has no usable slug characters ([a-z0-9-])."); - std::process::exit(1); + return 1; }; let path = Path::new(scaffold::MANIFEST_FILENAME); @@ -42,13 +42,13 @@ pub(super) fn cmd_init(args: &[String]) { "{} already exists. Re-run with --force to overwrite.", scaffold::MANIFEST_FILENAME ); - std::process::exit(1); + return 1; } let contents = scaffold::addon_manifest(&slug, transport, command.as_deref()); if let Err(e) = std::fs::write(path, contents) { eprintln!("Error writing {}: {e}", scaffold::MANIFEST_FILENAME); - std::process::exit(1); + return 1; } println!("✓ Wrote {} (addon `{slug}`).", scaffold::MANIFEST_FILENAME); @@ -63,17 +63,18 @@ pub(super) fn cmd_init(args: &[String]) { scaffold::MANIFEST_FILENAME ); println!(" 4. Get listed: see docs/guides/addons.md"); + 0 } /// `addon registry validate [path]` — run the registry security/quality bar /// (#864 + #403) against a registry JSON file, or the bundled + local registry /// if no path is given. The dry-run harness an author / CI uses before opening a /// merge request. Non-zero exit when problems are found. -pub(super) fn cmd_registry(args: &[String]) { +pub(super) fn cmd_registry(args: &[String]) -> i32 { let sub = args.get(1).map_or("", String::as_str); if sub != "validate" { eprintln!("Usage: lean-ctx addon registry validate [path-to-registry.json]"); - std::process::exit(1); + return 1; } let (entries, label) = match args.get(2).map(String::as_str) { @@ -81,7 +82,7 @@ pub(super) fn cmd_registry(args: &[String]) { Ok(e) => (e, path.to_string()), Err(e) => { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } }, _ => ( @@ -97,13 +98,13 @@ pub(super) fn cmd_registry(args: &[String]) { entries.len(), if entries.len() == 1 { "y" } else { "ies" } ); - return; + return 0; } eprintln!("✗ {label}: {} problem(s):\n", problems.len()); for p in &problems { eprintln!(" • {p}"); } - std::process::exit(1); + 1 } /// Parse a registry JSON file (`{ "addons": [ … ] }`) into manifests. @@ -122,19 +123,19 @@ fn load_registry_file(path: &str) -> Result, String> { /// `addon audit ` — run the publish/list gate (#403): wiring risk + /// capability coherence + malware heuristics, then the verified/paid verdict. /// Exits non-zero on a `fail` verdict so it is usable in CI / a publish hook. -pub(super) fn cmd_audit(target: &str) { +pub(super) fn cmd_audit(target: &str) -> i32 { let manifest = if looks_like_path(target) { match AddonManifest::from_path(Path::new(target)) { Ok(m) => m, Err(e) => { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } } } else { let Some(m) = registry::get(target) else { eprintln!("Unknown addon `{target}`. Pass a name from the registry or a path."); - std::process::exit(1); + return 1; }; m }; @@ -220,6 +221,7 @@ pub(super) fn cmd_audit(target: &str) { eprintln!( "\nAudit failed — this addon must not be listed until the blocking findings are resolved." ); - std::process::exit(1); + return 1; } + 0 } diff --git a/rust/src/cli/addon_cmd/commands.rs b/rust/src/cli/addon_cmd/commands.rs index c1d48d9b90..701b6df30f 100644 --- a/rust/src/cli/addon_cmd/commands.rs +++ b/rust/src/cli/addon_cmd/commands.rs @@ -6,16 +6,28 @@ use super::{ registry, resolve_declared_deps, }; -pub fn cmd_addon(args: &[String]) { +pub fn cmd_addon(args: &[String]) -> i32 { let action = args.first().map_or("list", String::as_str); match action { - "list" | "ls" => cmd_list(), + "list" | "ls" => { + cmd_list(); + 0 + } "init" | "new" => cmd_init(args), "registry" => cmd_registry(args), - "categories" | "cats" => cmd_categories(), - "usage" | "stats" => cmd_usage(), - "search" | "browse" => cmd_search(args.get(1).map_or("", String::as_str)), + "categories" | "cats" => { + cmd_categories(); + 0 + } + "usage" | "stats" => { + cmd_usage(); + 0 + } + "search" | "browse" => { + cmd_search(args.get(1).map_or("", String::as_str)); + 0 + } "info" | "show" => match positional(args) { Some(name) => cmd_info(&name), None => usage_exit("lean-ctx addon info "), @@ -40,18 +52,24 @@ pub fn cmd_addon(args: &[String]) { Some(name) => cmd_unrevoke(&name, args), None => usage_exit("lean-ctx addon unrevoke "), }, - "revocations" => cmd_revocations(), + "revocations" => { + cmd_revocations(); + 0 + } "verify" => cmd_verify(), "audit" => match positional(args) { Some(target) => cmd_audit(&target), None => usage_exit("lean-ctx addon audit "), }, "publish" => cmd_publish(args), - "help" | "--help" | "-h" => print_help(), + "help" | "--help" | "-h" => { + print_help(); + 0 + } _ => { eprintln!("Unknown addon action: {action}"); print_help(); - std::process::exit(1); + 1 } } } @@ -63,9 +81,9 @@ pub(super) fn positional(args: &[String]) -> Option { .filter(|s| !s.is_empty() && !s.starts_with('-')) } -fn usage_exit(usage: &str) -> ! { +fn usage_exit(usage: &str) -> i32 { eprintln!("Usage: {usage}"); - std::process::exit(1); + 1 } fn cmd_list() { @@ -212,7 +230,7 @@ fn cmd_usage() { } } -fn cmd_info(name: &str) { +fn cmd_info(name: &str) -> i32 { let store = InstalledStore::load(); let Some(manifest) = registry::get(name).or_else(|| { // Allow `info` on a local manifest path too. @@ -229,13 +247,13 @@ fn cmd_info(name: &str) { " Status: installed (gateway server `{}`, {})", installed.gateway_server, installed.source ); - return; + return 0; } eprintln!( "Addon `{name}` not found. Try `lean-ctx addon search`, or pass a path to a \ lean-ctx-addon.toml." ); - std::process::exit(1); + return 1; }; println!("{} ({})", manifest.display_name(), manifest.addon.name); @@ -268,9 +286,10 @@ fn cmd_info(name: &str) { println!(); print_install_preview(&manifest); } + 0 } -fn cmd_add(target: &str, args: &[String]) { +fn cmd_add(target: &str, args: &[String]) -> i32 { // Resolution order: local manifest file → hosted ctxpkg pack (`ns/slug`, // GH #726) → bundled registry slug. A bare `ns/slug` that exists on disk // is treated as the local path it names. @@ -285,7 +304,7 @@ fn cmd_add(target: &str, args: &[String]) { Ok(m) => (m, "local".to_string()), Err(e) => { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } } } else if let Some(remote_ref) = crate::core::context_package::remote::parse_remote_ref(target) @@ -294,7 +313,7 @@ fn cmd_add(target: &str, args: &[String]) { Ok((m, s)) => (m, s), Err(e) => { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } } } else { @@ -305,14 +324,14 @@ fn cmd_add(target: &str, args: &[String]) { `lean-ctx addon add /`, or pass a path to a \ lean-ctx-addon.toml." ); - std::process::exit(1); + return 1; }; (m, "registry".to_string()) }; if let Err(e) = manifest.validate() { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } if !manifest.is_installable() { @@ -327,7 +346,7 @@ fn cmd_add(target: &str, args: &[String]) { &manifest.addon.homepage } ); - std::process::exit(1); + return 1; } let force = args.iter().any(|a| a == "--force" || a == "-f"); @@ -341,7 +360,7 @@ fn cmd_add(target: &str, args: &[String]) { // this resolution, so only the verdict matters here.) if let Err(e) = install::preflight(&manifest, &cfg.addons, force) { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } println!("About to install `{}`:\n", manifest.addon.name); @@ -373,7 +392,7 @@ fn cmd_add(target: &str, args: &[String]) { super::prompt::wants_yes(args), ) { println!("Aborted. Nothing was changed."); - return; + return 0; } // The slice wired into `[mcp.env]` must be the versions the install step @@ -405,9 +424,10 @@ fn cmd_add(target: &str, args: &[String]) { } Err(e) => { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } } + 0 } /// The impure provisioning pipeline `add` and `update` share, run after user diff --git a/rust/src/cli/addon_cmd/management.rs b/rust/src/cli/addon_cmd/management.rs index 3c0ed25c55..bcd72f9e7e 100644 --- a/rust/src/cli/addon_cmd/management.rs +++ b/rust/src/cli/addon_cmd/management.rs @@ -8,7 +8,7 @@ use super::{ /// `kind=addon` pack from a `lean-ctx-addon.toml` and upload it to the /// hosted ctxpkg registry (GH #726). `--check` runs every local gate /// (schema, audit, signing, self-verification) and stops before the network. -pub(super) fn cmd_publish(args: &[String]) { +pub(super) fn cmd_publish(args: &[String]) -> i32 { let manifest_path = args .iter() .skip(1) @@ -29,7 +29,7 @@ pub(super) fn cmd_publish(args: &[String]) { eprintln!("The namespace is your ctxpkg.com account handle — the pack publishes"); eprintln!("as @/. `--check` validates and signs locally without"); eprintln!("uploading anything."); - std::process::exit(1); + return 1; }; let plan = @@ -38,7 +38,7 @@ pub(super) fn cmd_publish(args: &[String]) { Ok(p) => p, Err(e) => { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } }; @@ -64,7 +64,7 @@ pub(super) fn cmd_publish(args: &[String]) { if args.iter().any(|a| a == "--check") { println!("\n--check: all local gates passed — nothing was uploaded."); - return; + return 0; } use crate::core::context_package::remote; @@ -72,13 +72,13 @@ pub(super) fn cmd_publish(args: &[String]) { let Some(token) = remote::publish_token(flag_value(args, "--token").as_deref()) else { eprintln!("ERROR: no publish token — pass --token or set CTXPKG_TOKEN"); eprintln!("Mint one at ctxpkg.com/account (sign in, then Tokens → Mint)."); - std::process::exit(1); + return 1; }; if token.starts_with("ctxr_") { eprintln!( "ERROR: this is a read-only install token (ctxr_) — publishing needs a ctxp_ token" ); - std::process::exit(1); + return 1; } println!( @@ -103,26 +103,27 @@ pub(super) fn cmd_publish(args: &[String]) { } Err(e) => { eprintln!("ERROR: {e}"); - std::process::exit(1); + return 1; } } + 0 } /// `addon update ` — re-resolve the registry entry and reinstall when it /// changed (GH #725). Managed binaries install side-by-side into a new version /// dir; only after the health probe passes is the gateway pointer flipped and /// the old version pruned — a failed update leaves the working install intact. -pub(super) fn cmd_update(name: &str, args: &[String]) { +pub(super) fn cmd_update(name: &str, args: &[String]) -> i32 { let Some(entry) = InstalledStore::load().get(name).cloned() else { eprintln!("Addon `{name}` is not installed."); - std::process::exit(1); + return 1; }; if entry.source == "local" { eprintln!( "`{name}` was installed from a local manifest — update it by re-running \ `lean-ctx addon add `." ); - std::process::exit(1); + return 1; } // Re-resolve from where it came: a hosted ctxpkg pack updates against the // registry it was installed from (latest non-yanked version), everything @@ -135,13 +136,13 @@ pub(super) fn cmd_update(name: &str, args: &[String]) { "`{name}` has a malformed install source `{}`.", entry.source ); - std::process::exit(1); + return 1; }; match fetch_addon_pack(&remote_ref, flag_value(args, "--registry").as_deref()) { Ok((m, s)) => (m, s), Err(e) => { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } } } else { @@ -149,7 +150,7 @@ pub(super) fn cmd_update(name: &str, args: &[String]) { eprintln!( "`{name}` is no longer in the registry — remove it or reinstall from a path." ); - std::process::exit(1); + return 1; }; (m, entry.source.clone()) }; @@ -185,13 +186,13 @@ pub(super) fn cmd_update(name: &str, args: &[String]) { // A skills/context dependency may have bumped even when the addon // itself did not (GH #727) — refresh those without re-wiring. refresh_pack_dependencies(&manifest.dependencies, root_ref.as_deref(), args); - return; + return 0; } let cfg = crate::core::config::Config::load(); if let Err(e) = install::preflight(&manifest, &cfg.addons, force) { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } println!( @@ -200,7 +201,7 @@ pub(super) fn cmd_update(name: &str, args: &[String]) { ); if !super::prompt::confirm("Proceed with the update?", super::prompt::wants_yes(args)) { println!("Aborted. Nothing was changed."); - return; + return 0; } let preview_deps = resolve_declared_deps(&manifest.dependencies, root_ref.as_deref(), args); @@ -238,15 +239,16 @@ pub(super) fn cmd_update(name: &str, args: &[String]) { } Err(e) => { eprintln!("Error: {e}\n The previous install remains wired."); - std::process::exit(1); + return 1; } } + 0 } -pub(super) fn cmd_remove(name: &str, args: &[String]) { +pub(super) fn cmd_remove(name: &str, args: &[String]) -> i32 { let Some(entry) = InstalledStore::load().get(name).cloned() else { eprintln!("Addon `{name}` is not installed."); - std::process::exit(1); + return 1; }; if !super::prompt::confirm( @@ -254,7 +256,7 @@ pub(super) fn cmd_remove(name: &str, args: &[String]) { super::prompt::wants_yes(args), ) { println!("Aborted."); - return; + return 0; } match install::remove(name) { @@ -292,14 +294,15 @@ pub(super) fn cmd_remove(name: &str, args: &[String]) { } Err(e) => { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } } + 0 } /// `addon revoke ` — block an addon from running everywhere (install, /// catalog, every proxy call). Protective, so it does not prompt. -pub(super) fn cmd_revoke(name: &str, args: &[String]) { +pub(super) fn cmd_revoke(name: &str, args: &[String]) -> i32 { let reason = flag_value(args, "--reason").unwrap_or_else(|| "manually revoked".to_string()); let version = flag_value(args, "--version"); @@ -320,24 +323,25 @@ pub(super) fn cmd_revoke(name: &str, args: &[String]) { } Err(e) => { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } } + 0 } /// `addon unrevoke ` — lift a revocation (removes protection), so confirm. -pub(super) fn cmd_unrevoke(name: &str, args: &[String]) { +pub(super) fn cmd_unrevoke(name: &str, args: &[String]) -> i32 { let mut list = RevocationList::load(); if !list.revocations.contains_key(name) { eprintln!("Addon `{name}` is not revoked."); - std::process::exit(1); + return 1; } if !super::prompt::confirm( &format!("Lift the revocation on `{name}` (allow it to run again)?"), super::prompt::wants_yes(args), ) { println!("Aborted."); - return; + return 0; } list.unrevoke(name); match list.save() { @@ -347,9 +351,10 @@ pub(super) fn cmd_unrevoke(name: &str, args: &[String]) { } Err(e) => { eprintln!("Error: {e}"); - std::process::exit(1); + return 1; } } + 0 } /// `addon revocations` — list the active local revocations. @@ -372,12 +377,12 @@ pub(super) fn cmd_revocations() { /// `addon verify` — re-check each installed addon's live wiring against the /// integrity hash pinned at install (P2). Exits non-zero if any addon drifted. -pub(super) fn cmd_verify() { +pub(super) fn cmd_verify() -> i32 { use crate::core::addons::integrity::{self, IntegrityStatus}; let findings = integrity::verify_all(); if findings.is_empty() { println!("No addons installed."); - return; + return 0; } let mut drift = false; println!("Addon integrity:\n"); @@ -397,6 +402,7 @@ pub(super) fn cmd_verify() { "\nOne or more addons no longer match their pinned wiring. Review the \ `[[gateway.servers]]` entries, then re-install (`addon add`) or remove them." ); - std::process::exit(1); + return 1; } + 0 } diff --git a/rust/src/cli/dispatch/mod.rs b/rust/src/cli/dispatch/mod.rs index 4fb0eca6b7..c870ed50ed 100644 --- a/rust/src/cli/dispatch/mod.rs +++ b/rust/src/cli/dispatch/mod.rs @@ -116,7 +116,10 @@ pub fn run() { return; } "addon" | "addons" => { - crate::cli::addon_cmd::cmd_addon(&rest); + let code = crate::cli::addon_cmd::cmd_addon(&rest); + if code != 0 { + std::process::exit(code); + } return; } "embeddings" => {