lifeloop-cli 0.3.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Lifeloop CLI.
//!
//! Exit codes are stable for shell consumers:
//!
//! | code | meaning                                    |
//! |------|--------------------------------------------|
//! |   0  | success                                    |
//! |   1  | validation error (envelope contents invalid) |
//! |   2  | usage error (bad subcommand or arguments)  |
//! |   3  | input error (stdin unreadable or non-JSON) |

mod cli;

use std::process::ExitCode;

use cli::CliError;

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("{}", err.message());
            ExitCode::from(err.exit_code())
        }
    }
}

fn run() -> Result<(), CliError> {
    let mut raw_args: Vec<String> = std::env::args().skip(1).collect();
    let output = parse_global_output(&mut raw_args)?;
    let mut args = raw_args.into_iter();
    let command = args.next();
    if output.is_some() && command.as_deref() != Some("host-hook") {
        return Err(CliError::Usage(
            "--output is only supported for `host-hook`".into(),
        ));
    }
    match command.as_deref() {
        None | Some("help") | Some("--help") | Some("-h") => {
            print_help();
            Ok(())
        }
        Some("version") | Some("--version") | Some("-V") => {
            println!("{}", env!("CARGO_PKG_VERSION"));
            Ok(())
        }
        Some("events") => {
            let json = serde_json::to_string_pretty(&lifeloop::lifecycle_event_kinds()).map_err(
                |err| CliError::Input(format!("failed to serialize event vocabulary: {err}")),
            )?;
            println!("{json}");
            Ok(())
        }
        Some("envelope") => run_envelope(args),
        Some("event") => cli::event::run(args),
        Some("manifest") => cli::manifest::run(args),
        Some("asset") => cli::asset::run(args),
        Some("telemetry") => cli::telemetry::run(args),
        Some("receipt") => cli::receipt::run(args),
        Some("conformance") => cli::conformance::run(args),
        Some("host-hook") => cli::host_hook::run(args, output.as_deref()),
        Some(command) => Err(CliError::Usage(format!("unknown command: {command}"))),
    }
}

fn parse_global_output(args: &mut Vec<String>) -> Result<Option<String>, CliError> {
    if args.first().map(String::as_str) == Some("--output") {
        if args.len() < 2 {
            return Err(CliError::Usage("flag `--output` requires a value".into()));
        }
        let value = args.remove(1);
        args.remove(0);
        return Ok(Some(value));
    }
    if let Some(first) = args.first()
        && let Some(value) = first.strip_prefix("--output=")
    {
        let value = value.to_string();
        args.remove(0);
        return Ok(Some(value));
    }
    Ok(None)
}

fn run_envelope<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
    let action = args.next().ok_or_else(|| {
        CliError::Usage("envelope requires a subcommand: validate | echo".to_string())
    })?;
    let kind = args.next().ok_or_else(|| {
        CliError::Usage(format!(
            "envelope {action} requires a kind: request | response"
        ))
    })?;
    if args.next().is_some() {
        return Err(CliError::Usage(format!(
            "envelope {action} {kind}: unexpected extra argument"
        )));
    }
    match (action.as_str(), kind.as_str()) {
        ("validate", "request") => validate::<lifeloop::CallbackRequest>("request"),
        ("validate", "response") => validate::<lifeloop::CallbackResponse>("response"),
        ("echo", "request") => echo::<lifeloop::CallbackRequest>(),
        ("echo", "response") => echo::<lifeloop::CallbackResponse>(),
        ("validate" | "echo", other) => Err(CliError::Usage(format!(
            "envelope {action}: unknown kind `{other}` (expected: request | response)"
        ))),
        _ => Err(CliError::Usage(format!(
            "envelope: unknown subcommand `{action}` (expected: validate | echo)"
        ))),
    }
}

trait Validatable: serde::de::DeserializeOwned + serde::Serialize {
    fn validate(&self) -> Result<(), lifeloop::ValidationError>;
}

impl Validatable for lifeloop::CallbackRequest {
    fn validate(&self) -> Result<(), lifeloop::ValidationError> {
        self.validate()
    }
}

impl Validatable for lifeloop::CallbackResponse {
    fn validate(&self) -> Result<(), lifeloop::ValidationError> {
        self.validate()
    }
}

fn validate<T: Validatable>(label: &str) -> Result<(), CliError> {
    let value = parse_stdin::<T>(label)?;
    value
        .validate()
        .map_err(|err| CliError::Validation(format!("{label} failed validation: {err}")))?;
    println!("ok");
    Ok(())
}

fn echo<T: Validatable>() -> Result<(), CliError> {
    let value = parse_stdin::<T>("envelope")?;
    let json = serde_json::to_string_pretty(&value)
        .map_err(|err| CliError::Input(format!("failed to serialize envelope: {err}")))?;
    println!("{json}");
    Ok(())
}

fn parse_stdin<T: serde::de::DeserializeOwned>(label: &str) -> Result<T, CliError> {
    let buf = cli::read_stdin(&format!("{label} envelope"))?;
    serde_json::from_str::<T>(&buf)
        .map_err(|err| CliError::Input(format!("{label} envelope: invalid JSON: {err}")))
}

fn print_help() {
    println!("lifeloop");
    println!();
    println!("Provider-neutral lifecycle abstraction for AI harnesses.");
    println!();
    println!("Commands:");
    println!("  events                       Print the lifecycle event vocabulary as JSON");
    println!("  envelope validate <kind>     Validate a JSON envelope read from stdin");
    println!("                               kind: request | response");
    println!("                               prints `ok` on success; errors go to stderr");
    println!("  envelope echo <kind>         Parse and pretty-print an envelope from stdin");
    println!("                               kind: request | response");
    println!("  event invoke                 Run validate -> negotiate -> callback -> receipt");
    println!("                               flags: --client-cmd <path> [--client-arg <a>]...");
    println!("                                      [--timeout-ms <ms>] [--client-id <id>]");
    println!("                                      [--receipt-id <id>] [--at-epoch-s <n>]");
    println!("                                      [--in-process]");
    println!("  manifest list                List registered adapters as JSON");
    println!("  manifest show <id>           Print one AdapterManifest as JSON");
    println!("  manifest inspect <id>@<ver>  Print a manifest pinned to a version");
    println!("  asset preview --host <h> --mode <m>  Print rendered host assets as JSON");
    println!("                                       flags: [--profile <id>]");
    println!("                                       (read-only; callers write files themselves —");
    println!("                                       see docs/decisions/asset-apply-boundary.md)");
    println!("  telemetry snapshot --host <id>  Read host telemetry, emit PressureObservation");
    println!("  receipt emit                 Synthesize a LifecycleReceipt from a wire envelope");
    println!("  receipt show                 Validate and pretty-print a receipt from stdin");
    println!("  conformance run              Walk tests/conformance/ fixtures and emit JSONL");
    println!("                               flags: [--root <dir>] [--summary]");
    println!("  --output hook-protocol host-hook  Run a synchronous host-hook broker");
    println!("                               flags: --host <h> --hook <name> [--client-cmd <cmd>]");
    println!("  version                      Print the Lifeloop version");
    println!();
    println!("Exit codes:  0=ok  1=validation error  2=usage error  3=input/IO error");
    println!();
    println!("Spec: docs/specs/lifecycle-contract/body.md");
}