lifeloop-cli 0.3.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Host adapter, asset descriptor, status, and error model.

// ============================================================================
// Adapters and modes
// ============================================================================

/// Host adapters Lifeloop ships asset rendering and merge support for.
///
/// The on-the-wire identifier (`as_str()`) is the same string an
/// `AdapterManifest::adapter_id` would carry for the same harness; issue
/// #6 lands the full manifest registry that consumes these ids.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum HostAdapter {
    Claude,
    Codex,
    Hermes,
    OpenClaw,
}

impl HostAdapter {
    pub const ALL: &'static [Self] = &[Self::Claude, Self::Codex, Self::Hermes, Self::OpenClaw];

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Claude => "claude",
            Self::Codex => "codex",
            Self::Hermes => "hermes",
            Self::OpenClaw => "openclaw",
        }
    }

    /// Recognizes both canonical ids and the historical CCD aliases (e.g.
    /// `claude-code`). Returns `None` for unknown names.
    pub fn from_id(name: &str) -> Option<Self> {
        match name {
            "claude" | "claude-code" => Some(Self::Claude),
            "codex" => Some(Self::Codex),
            "hermes" => Some(Self::Hermes),
            "openclaw" => Some(Self::OpenClaw),
            _ => None,
        }
    }

    /// Default integration mode when the host has no explicit declaration.
    pub fn default_mode(self) -> IntegrationMode {
        match self {
            Self::Claude => IntegrationMode::NativeHook,
            Self::Codex => IntegrationMode::ManualSkill,
            Self::Hermes | Self::OpenClaw => IntegrationMode::ReferenceAdapter,
        }
    }
}

/// Integration modes supported by host asset rendering.
///
/// Mirrors the subset of [`crate::IntegrationMode`] that maps to actual
/// installable assets. `TelemetryOnly` does not produce assets and is
/// therefore not represented here; callers that have a full
/// `IntegrationMode` can convert with [`Self::from_lifecycle_mode`].
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum IntegrationMode {
    ManualSkill,
    LauncherWrapper,
    NativeHook,
    ReferenceAdapter,
}

impl IntegrationMode {
    pub const ALL: &'static [Self] = &[
        Self::ManualSkill,
        Self::LauncherWrapper,
        Self::NativeHook,
        Self::ReferenceAdapter,
    ];

    pub fn as_str(self) -> &'static str {
        match self {
            Self::ManualSkill => "manual_skill",
            Self::LauncherWrapper => "launcher_wrapper",
            Self::NativeHook => "native_hook",
            Self::ReferenceAdapter => "reference_adapter",
        }
    }

    pub fn from_id(value: &str) -> Option<Self> {
        match value {
            "manual_skill" => Some(Self::ManualSkill),
            "launcher_wrapper" => Some(Self::LauncherWrapper),
            "native_hook" => Some(Self::NativeHook),
            "reference_adapter" => Some(Self::ReferenceAdapter),
            _ => None,
        }
    }

    /// Convert from the broader [`crate::IntegrationMode`]. Returns `None`
    /// for `TelemetryOnly`, which has no asset surface.
    pub fn from_lifecycle_mode(mode: crate::IntegrationMode) -> Option<Self> {
        match mode {
            crate::IntegrationMode::ManualSkill => Some(Self::ManualSkill),
            crate::IntegrationMode::LauncherWrapper => Some(Self::LauncherWrapper),
            crate::IntegrationMode::NativeHook => Some(Self::NativeHook),
            crate::IntegrationMode::ReferenceAdapter => Some(Self::ReferenceAdapter),
            crate::IntegrationMode::TelemetryOnly => None,
        }
    }
}

/// Whether `host` supports the requested install mode. Gates `apply` so a
/// caller can refuse before mutating files.
pub fn supports_mode(host: HostAdapter, mode: IntegrationMode) -> bool {
    match host {
        HostAdapter::Claude => mode == IntegrationMode::NativeHook,
        HostAdapter::Codex => matches!(
            mode,
            IntegrationMode::ManualSkill
                | IntegrationMode::LauncherWrapper
                | IntegrationMode::NativeHook
        ),
        HostAdapter::Hermes | HostAdapter::OpenClaw => mode == IntegrationMode::ReferenceAdapter,
    }
}

/// Modes the adapter accepts, in declaration order, for diagnostic
/// messaging. Order is informational.
pub fn supported_modes(host: HostAdapter) -> &'static [IntegrationMode] {
    match host {
        HostAdapter::Claude => &[IntegrationMode::NativeHook],
        HostAdapter::Codex => &[
            IntegrationMode::ManualSkill,
            IntegrationMode::LauncherWrapper,
            IntegrationMode::NativeHook,
        ],
        HostAdapter::Hermes | HostAdapter::OpenClaw => &[IntegrationMode::ReferenceAdapter],
    }
}

// ============================================================================
// Asset descriptor and status
// ============================================================================

/// One file Lifeloop renders for a host adapter. `relative_path` is
/// relative to the repository root the caller is rendering into. `mode`
/// is a Unix permission bitmask when present; callers on non-Unix
/// targets may ignore it, but cross-checking is still meaningful for
/// status reporting.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderedAsset {
    pub relative_path: &'static str,
    pub contents: String,
    pub mode: Option<u32>,
}

/// Status of an installed asset relative to the rendered template.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AssetStatus {
    /// The on-disk asset matches the rendered shape.
    Present,
    /// The asset path does not exist.
    Missing,
    /// The asset exists but its content (or permission mode) differs from
    /// what Lifeloop would render. For merge-aware assets this means the
    /// merge result no longer matches the file.
    Drifted,
    /// The (host, mode) pair is not a supported install combination.
    InvalidMode,
    /// No asset is expected for this (host, mode) pair.
    NotApplicable,
}

/// Action taken for one asset during apply.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileAction {
    /// The asset was created.
    Installed,
    /// The asset existed and was rewritten with new content.
    Updated,
    /// The asset matched the rendered template; no write was needed.
    AlreadyPresent,
}

/// Combine `current` with `next` so a multi-file apply reports the
/// strongest action seen. `Updated` dominates `Installed` dominates
/// `AlreadyPresent`.
pub fn combine_actions(current: FileAction, next: FileAction) -> FileAction {
    match (current, next) {
        (FileAction::Updated, _) | (_, FileAction::Updated) => FileAction::Updated,
        (FileAction::Installed, _) | (_, FileAction::Installed) => FileAction::Installed,
        _ => FileAction::AlreadyPresent,
    }
}

/// Outcome of merging managed entries into an existing settings/hooks
/// file. `existing` is the prior file content (`None` if the file did
/// not exist); `rendered` is the merged content the caller should
/// write.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MergedFile {
    pub existing: Option<String>,
    pub rendered: String,
}

/// Errors returned by merge or render entry points. Callers convert as
/// needed; the variants are pinned so downstream tests can match.
#[derive(Debug)]
pub enum HostAssetError {
    /// The (host, mode) pair is not a supported install combination.
    UnsupportedMode {
        host: HostAdapter,
        mode: IntegrationMode,
    },
    /// The existing file's top-level shape conflicts with the merge
    /// invariants (e.g. `hooks` is an array instead of an object).
    Malformed { reason: String },
    /// The existing file is invalid JSON or TOML.
    Parse { reason: String },
    /// Re-serialization failed (should be unreachable for well-formed
    /// inputs; surfaced rather than panicked).
    Serialize { reason: String },
}

impl std::fmt::Display for HostAssetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::UnsupportedMode { host, mode } => write!(
                f,
                "unsupported mode `{}` for {} (supported: {})",
                mode.as_str(),
                host.as_str(),
                supported_modes(*host)
                    .iter()
                    .map(|m| m.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            ),
            Self::Malformed { reason } => write!(f, "malformed managed file: {reason}"),
            Self::Parse { reason } => write!(f, "parse error: {reason}"),
            Self::Serialize { reason } => write!(f, "serialize error: {reason}"),
        }
    }
}

impl std::error::Error for HostAssetError {}

pub const CLAUDE_SOURCE_SETTINGS: &str = ".ccd-hosts/claude/settings.json";
pub const CLAUDE_TARGET_SETTINGS: &str = ".claude/settings.json";

pub const CODEX_SOURCE_README: &str = ".ccd-hosts/codex/README.md";
pub const CODEX_SOURCE_LAUNCHER: &str = ".ccd-hosts/codex/launcher.sh";
pub const CODEX_SOURCE_CONFIG: &str = ".ccd-hosts/codex/config.toml";
pub const CODEX_SOURCE_HOOKS: &str = ".ccd-hosts/codex/hooks.json";
pub const CODEX_TARGET_LAUNCHER: &str = ".codex/ccd-launch.sh";
pub const CODEX_TARGET_CONFIG: &str = ".codex/config.toml";
pub const CODEX_TARGET_HOOKS: &str = ".codex/hooks.json";

pub const OPENCLAW_SOURCE_ADAPTER: &str = ".ccd-hosts/openclaw/adapter.json";
pub const OPENCLAW_TARGET_ADAPTER: &str = ".openclaw/ccd.json";

pub const HERMES_SOURCE_ADAPTER: &str = ".ccd-hosts/hermes/adapter.json";
pub const HERMES_TARGET_ADAPTER: &str = ".hermes/ccd.json";