lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! `lifeloop asset preview --host <id> --mode <mode>`.
//!
//! `preview` is read-only and side-effect-free: it lists the assets
//! Lifeloop would render for the (host, mode) pair as JSON
//! `{ relative_path, mode, contents }` rows. Callers (CCD, RLM, etc.)
//! consume the JSON output and write files themselves.
//!
//! # Boundary (issue #23)
//!
//! Lifeloop does not own filesystem writes for lifecycle integration
//! assets. The decision lives in
//! [`docs/decisions/asset-apply-boundary.md`]. The pre-decision
//! `asset apply` subcommand was removed because it was identical to
//! `asset preview` (no IO, JSON dump) and its name implied mutation it
//! never performed. The tombstone for the old name lives at
//! [`docs/tombstones/asset-apply-cli.md`].
//!
//! Following the issue's "expose, don't extend" rule, the actual
//! rendering logic lives in `host_assets.rs` and is reused here
//! verbatim.

use lifeloop::host_assets::{HostAdapter, IntegrationMode, render_applied_assets, supports_mode};
use serde::Serialize;

use super::{CliError, print_json};

#[derive(Serialize)]
struct AssetRow<'a> {
    relative_path: &'a str,
    /// Unix mode bits, when applicable (e.g. 0o755 on launcher
    /// scripts). Serialized as a decimal integer for portability;
    /// callers that want octal can re-format client-side.
    mode: Option<u32>,
    contents: &'a str,
}

pub fn run<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
    let action = args
        .next()
        .ok_or_else(|| CliError::Usage("asset requires a subcommand: preview".to_string()))?;
    match action.as_str() {
        "preview" => run_preview(args),
        "apply" => Err(CliError::Usage(
            "asset: `apply` was removed in lifeloop.v0.2; use `asset preview` (it never wrote to \
             the filesystem). See docs/decisions/asset-apply-boundary.md."
                .to_string(),
        )),
        other => Err(CliError::Usage(format!(
            "asset: unknown subcommand `{other}` (expected: preview)"
        ))),
    }
}

fn run_preview<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
    let mut host_id: Option<String> = None;
    let mut mode_id: Option<String> = None;
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--host" => {
                host_id = Some(require_value(&arg, args.next())?);
            }
            "--mode" => {
                mode_id = Some(require_value(&arg, args.next())?);
            }
            other => {
                return Err(CliError::Usage(format!("asset: unknown flag `{other}`")));
            }
        }
    }
    let host_id =
        host_id.ok_or_else(|| CliError::Usage("asset: --host <id> is required".into()))?;
    let mode_id =
        mode_id.ok_or_else(|| CliError::Usage("asset: --mode <mode> is required".into()))?;

    let host = HostAdapter::from_id(&host_id)
        .ok_or_else(|| CliError::Validation(format!("asset: unknown host `{host_id}`")))?;
    let mode = IntegrationMode::from_id(&mode_id)
        .ok_or_else(|| CliError::Validation(format!("asset: unknown mode `{mode_id}`")))?;
    if !supports_mode(host, mode) {
        return Err(CliError::Validation(format!(
            "asset: host `{}` does not support mode `{}`",
            host.as_str(),
            mode.as_str()
        )));
    }

    let assets = render_applied_assets(host, mode);
    let rows: Vec<AssetRow<'_>> = assets
        .iter()
        .map(|a| AssetRow {
            relative_path: a.relative_path,
            mode: a.mode,
            contents: &a.contents,
        })
        .collect();
    print_json(&rows)
}

fn require_value(flag: &str, value: Option<String>) -> Result<String, CliError> {
    value.ok_or_else(|| CliError::Usage(format!("flag `{flag}` requires a value")))
}