lifeloop-cli 0.3.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! CLI integration tests for `lifeloop asset preview` (issues #9, #23).
//!
//! `asset apply` was removed in `lifeloop.v0.2` per the apply-boundary
//! decision (`docs/decisions/asset-apply-boundary.md`). The test
//! `asset_apply_is_rejected_with_pointer_to_preview` pins that surface.

use std::process::{Command, Stdio};

fn lifeloop_bin() -> std::path::PathBuf {
    std::path::PathBuf::from(env!("CARGO_BIN_EXE_lifeloop"))
}

fn run(args: &[&str]) -> (i32, String, String) {
    let out = Command::new(lifeloop_bin())
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .expect("spawn lifeloop");
    (
        out.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
    )
}

#[test]
fn asset_preview_claude_native_hook_lists_settings_target() {
    let (code, stdout, stderr) = run(&[
        "asset",
        "preview",
        "--host",
        "claude",
        "--mode",
        "native_hook",
    ]);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).expect("rows JSON");
    let rows = v.as_array().unwrap();
    assert_eq!(rows.len(), 1);
    assert_eq!(
        rows[0].get("relative_path").and_then(|s| s.as_str()),
        Some(".claude/settings.json")
    );
}

#[test]
fn asset_preview_codex_manual_skill_yields_no_applied_assets() {
    // Codex manual_skill has no installable files (scaffold-only).
    let (code, stdout, stderr) = run(&[
        "asset",
        "preview",
        "--host",
        "codex",
        "--mode",
        "manual_skill",
    ]);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(v.as_array().unwrap().len(), 0);
}

#[test]
fn asset_preview_accepts_ccd_renewal_profile() {
    let (code, stdout, stderr) = run(&[
        "asset",
        "preview",
        "--host",
        "codex",
        "--mode",
        "native_hook",
        "--profile",
        "ccd-renewal",
    ]);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).expect("rows JSON");
    let rows = v.as_array().unwrap();
    let hooks = rows
        .iter()
        .find(|row| row.get("relative_path").and_then(|s| s.as_str()) == Some(".codex/hooks.json"))
        .expect("hooks row");
    let contents = hooks["contents"].as_str().expect("contents");
    assert!(contents.contains("lifeloop"));
    assert!(contents.contains("--client-cmd"));
    assert!(contents.contains("CCD_BIN"));
}

#[test]
fn asset_unknown_profile_is_validation_error() {
    let (code, _stdout, stderr) = run(&[
        "asset",
        "preview",
        "--host",
        "codex",
        "--mode",
        "native_hook",
        "--profile",
        "nope",
    ]);
    assert_eq!(code, 1);
    assert!(stderr.contains("unknown profile"));
}

#[test]
fn asset_apply_is_rejected_with_pointer_to_preview() {
    // `asset apply` was removed in lifeloop.v0.2 per the apply-boundary
    // decision. The CLI must reject the old name with a usage error
    // (exit 2) and point operators at `asset preview`. See
    // `docs/decisions/asset-apply-boundary.md` and
    // `docs/tombstones/asset-apply-cli.md`.
    let (code, _stdout, stderr) = run(&[
        "asset",
        "apply",
        "--host",
        "claude",
        "--mode",
        "native_hook",
    ]);
    assert_eq!(code, 2);
    assert!(
        stderr.contains("asset preview"),
        "rejection should redirect callers to `asset preview`; stderr=`{stderr}`"
    );
}

#[test]
fn asset_unknown_host_is_validation_error() {
    let (code, _stdout, stderr) = run(&[
        "asset",
        "preview",
        "--host",
        "nope",
        "--mode",
        "native_hook",
    ]);
    assert_eq!(code, 1);
    assert!(stderr.contains("unknown host"));
}

#[test]
fn asset_unknown_mode_is_validation_error() {
    let (code, _stdout, stderr) = run(&[
        "asset",
        "preview",
        "--host",
        "claude",
        "--mode",
        "weird_mode",
    ]);
    assert_eq!(code, 1);
    assert!(stderr.contains("unknown mode"));
}

#[test]
fn asset_unsupported_combination_is_validation_error() {
    // Claude only supports native_hook.
    let (code, _stdout, stderr) = run(&[
        "asset",
        "preview",
        "--host",
        "claude",
        "--mode",
        "manual_skill",
    ]);
    assert_eq!(code, 1);
    assert!(stderr.contains("does not support"));
}

#[test]
fn asset_missing_host_flag_is_usage_error() {
    let (code, _stdout, stderr) = run(&["asset", "preview", "--mode", "native_hook"]);
    assert_eq!(code, 2);
    assert!(stderr.contains("--host"));
}