lifeloop-cli 0.1.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::io::Read;
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 args = std::env::args().skip(1);
    match args.next().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(command) => Err(CliError::Usage(format!("unknown command: {command}"))),
    }
}

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 mut buf = String::new();
    std::io::stdin().read_to_string(&mut buf).map_err(|err| {
        CliError::Input(format!("failed to read {label} envelope from stdin: {err}"))
    })?;
    if buf.trim().is_empty() {
        return Err(CliError::Input(format!("{label} envelope: stdin is empty")));
    }
    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!("                                       (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!("  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");
}