lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! CLI subcommand modules for the host lifecycle surface (issue #9).
//!
//! Each submodule owns one subcommand family. `main.rs` performs
//! hand-rolled dispatch and delegates here. Modules are kept binary-only
//! by living under `src/cli/` and being declared `mod cli` from
//! `src/main.rs` — no public types are added to the lifeloop crate root.

pub mod asset;
pub mod conformance;
pub mod event;
pub mod manifest;
pub mod receipt;
pub mod telemetry;

use std::io::Read;

pub const MAX_STDIN_BYTES: u64 = 16 * 1024 * 1024;

/// Stable CLI error type re-used by every subcommand. Mirrors the
/// existing `CliError` in `main.rs` so all subcommands share one exit
/// code table without each module redeclaring it.
#[derive(Debug)]
pub enum CliError {
    Usage(String),
    Validation(String),
    Input(String),
}

impl CliError {
    pub fn exit_code(&self) -> u8 {
        match self {
            CliError::Validation(_) => 1,
            CliError::Usage(_) => 2,
            CliError::Input(_) => 3,
        }
    }

    pub fn message(&self) -> &str {
        match self {
            CliError::Usage(m) | CliError::Validation(m) | CliError::Input(m) => m,
        }
    }
}

/// Read stdin as UTF-8 with a fixed upper bound. Standard `Input`
/// failure mode.
pub fn read_stdin(label: &str) -> Result<String, CliError> {
    read_text_bounded(std::io::stdin().lock(), label)
}

pub fn read_text_bounded<R: Read>(reader: R, label: &str) -> Result<String, CliError> {
    let mut buf = Vec::new();
    reader
        .take(MAX_STDIN_BYTES + 1)
        .read_to_end(&mut buf)
        .map_err(|err| CliError::Input(format!("failed to read {label} from stdin: {err}")))?;
    if buf.len() as u64 > MAX_STDIN_BYTES {
        return Err(CliError::Input(format!(
            "{label}: stdin exceeds {MAX_STDIN_BYTES} bytes"
        )));
    }
    let buf = String::from_utf8(buf)
        .map_err(|err| CliError::Input(format!("{label}: stdin is not UTF-8: {err}")))?;
    if buf.trim().is_empty() {
        return Err(CliError::Input(format!("{label}: stdin is empty")));
    }
    Ok(buf)
}

/// Parse stdin as JSON into a typed value.
pub fn parse_stdin_json<T: serde::de::DeserializeOwned>(label: &str) -> Result<T, CliError> {
    let buf = read_stdin(label)?;
    serde_json::from_str::<T>(&buf)
        .map_err(|err| CliError::Input(format!("{label}: invalid JSON: {err}")))
}

/// Print a serializable value as pretty JSON to stdout. Translates
/// serde errors into `Input` errors (consistent with how stdin parse
/// errors are classified).
pub fn print_json<T: serde::Serialize>(value: &T) -> Result<(), CliError> {
    let json = serde_json::to_string_pretty(value)
        .map_err(|err| CliError::Input(format!("failed to serialize output: {err}")))?;
    println!("{json}");
    Ok(())
}