lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Host integration asset rendering.

use serde_json::{Value, json};

use super::merge::merge_codex_hooks_with_profile;
use super::model::*;
use super::profiles::*;

// ============================================================================
// Asset rendering
// ============================================================================

/// Full set of source-tree assets for a host using the default
/// [`CCD_COMPAT_PROFILE`]. These are the templates that land under
/// `.ccd-hosts/<host>/`.
pub fn render_source_assets(host: HostAdapter) -> Vec<RenderedAsset> {
    render_source_assets_with_profile(host, &CCD_COMPAT_PROFILE)
}

/// Full set of source-tree assets for a host rendered with
/// `profile`'s command prefixes and managed event tables. See
/// [`render_applied_assets_with_profile`] for the per-profile vs.
/// per-host scope split.
pub fn render_source_assets_with_profile(
    host: HostAdapter,
    profile: &LifecycleProfile,
) -> Vec<RenderedAsset> {
    if profile.validate().is_err() {
        return Vec::new();
    }
    match host {
        HostAdapter::Claude => vec![RenderedAsset {
            relative_path: CLAUDE_SOURCE_SETTINGS,
            contents: claude_settings_json_for(profile),
            mode: None,
        }],
        HostAdapter::Codex => vec![
            RenderedAsset {
                relative_path: CODEX_SOURCE_README,
                contents: codex_guidance_readme(),
                mode: None,
            },
            RenderedAsset {
                relative_path: CODEX_SOURCE_CONFIG,
                contents: codex_config_toml(),
                mode: None,
            },
            RenderedAsset {
                relative_path: CODEX_SOURCE_HOOKS,
                contents: codex_hooks_json_for(profile),
                mode: None,
            },
            RenderedAsset {
                relative_path: CODEX_SOURCE_LAUNCHER,
                contents: codex_launcher_script(),
                mode: Some(0o755),
            },
        ],
        HostAdapter::Hermes => vec![RenderedAsset {
            relative_path: HERMES_SOURCE_ADAPTER,
            contents: hermes_adapter_json(),
            mode: None,
        }],
        HostAdapter::OpenClaw => vec![RenderedAsset {
            relative_path: OPENCLAW_SOURCE_ADAPTER,
            contents: openclaw_adapter_json(),
            mode: None,
        }],
    }
}

/// Subset of [`render_source_assets`] required for the given mode.
/// Modes that pin only a few of a host's source files (Codex
/// manual-skill, launcher-wrapper) trim the list so absent files don't
/// trigger spurious "missing scaffold" errors.
pub fn render_required_source_assets(
    host: HostAdapter,
    mode: IntegrationMode,
) -> Vec<RenderedAsset> {
    let assets = render_source_assets(host);
    let required_paths: &[&str] = match (host, mode) {
        (HostAdapter::Codex, IntegrationMode::ManualSkill) => &[CODEX_SOURCE_README],
        (HostAdapter::Codex, IntegrationMode::LauncherWrapper) => {
            &[CODEX_SOURCE_README, CODEX_SOURCE_LAUNCHER]
        }
        (HostAdapter::Codex, IntegrationMode::NativeHook) => {
            &[CODEX_SOURCE_README, CODEX_SOURCE_CONFIG, CODEX_SOURCE_HOOKS]
        }
        _ => return assets,
    };
    assets
        .into_iter()
        .filter(|asset| required_paths.contains(&asset.relative_path))
        .collect()
}

/// Assets to apply into the host's runtime directories (`.claude/`,
/// `.codex/`, `.hermes/`, `.openclaw/`) using the default
/// [`CCD_COMPAT_PROFILE`]. Empty when the (host, mode) pair has no
/// installable files (e.g. Codex manual-skill mode is scaffold-only).
///
/// This is a thin wrapper over [`render_applied_assets_with_profile`];
/// callers that want non-CCD command prefixes pass an explicit
/// profile.
pub fn render_applied_assets(host: HostAdapter, mode: IntegrationMode) -> Vec<RenderedAsset> {
    render_applied_assets_with_profile(host, mode, &CCD_COMPAT_PROFILE)
}

/// Assets to apply into the host's runtime directories rendered with
/// `profile`'s command prefixes and managed event tables.
///
/// Hermes and OpenClaw reference adapters currently render with a
/// CCD-flavored adapter JSON regardless of `profile`: those files are
/// per-host illustrative documentation, not active command surfaces,
/// and graduating them to per-profile rendering is a follow-up scoped
/// outside #26.
pub fn render_applied_assets_with_profile(
    host: HostAdapter,
    mode: IntegrationMode,
    profile: &LifecycleProfile,
) -> Vec<RenderedAsset> {
    if profile.validate().is_err() {
        return Vec::new();
    }
    match (host, mode) {
        (HostAdapter::Claude, IntegrationMode::NativeHook) => vec![RenderedAsset {
            relative_path: CLAUDE_TARGET_SETTINGS,
            contents: claude_settings_json_for(profile),
            mode: None,
        }],
        (HostAdapter::Codex, IntegrationMode::LauncherWrapper) => vec![RenderedAsset {
            relative_path: CODEX_TARGET_LAUNCHER,
            contents: codex_launcher_script(),
            mode: Some(0o755),
        }],
        (HostAdapter::Codex, IntegrationMode::NativeHook) => vec![
            RenderedAsset {
                relative_path: CODEX_TARGET_CONFIG,
                contents: codex_config_toml(),
                mode: None,
            },
            RenderedAsset {
                relative_path: CODEX_TARGET_HOOKS,
                contents: codex_hooks_json_for(profile),
                mode: None,
            },
        ],
        (HostAdapter::Codex, IntegrationMode::ManualSkill) => Vec::new(),
        (HostAdapter::Hermes, IntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
            relative_path: HERMES_TARGET_ADAPTER,
            contents: hermes_adapter_json(),
            mode: None,
        }],
        (HostAdapter::OpenClaw, IntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
            relative_path: OPENCLAW_TARGET_ADAPTER,
            contents: openclaw_adapter_json(),
            mode: None,
        }],
        _ => Vec::new(),
    }
}

// ============================================================================
// Asset content (private renderers)
// ============================================================================

fn claude_settings_json_for(profile: &LifecycleProfile) -> String {
    let mut hooks = serde_json::Map::new();
    for (event, hook_arg, matcher) in profile.claude_managed_events {
        hooks.insert(
            (*event).to_string(),
            json!([{
                "matcher": matcher,
                "hooks": [{
                    "type": "command",
                    "command": profile.claude_command(hook_arg),
                }]
            }]),
        );
    }
    let value = json!({ "hooks": Value::Object(hooks) });
    serde_json::to_string_pretty(&value).expect("claude settings json")
}

fn codex_guidance_readme() -> String {
    format!(
        r#"<!-- CCD-MANAGED -->
# Codex Host Guidance

Codex supports native repo-local hooks when `hooks` is enabled in
`.codex/config.toml` and `.codex/hooks.json` maps lifecycle events into CCD.

CCD installs a minimal native mapping:

- `SessionStart` -> `ccd host-hook --hook on-session-start`
- `UserPromptSubmit` -> `ccd host-hook --hook before-prompt-build`
- `PreCompact` -> `ccd host-hook --hook on-compaction-notice`
- `PostCompact` -> `ccd host-hook --hook on-compaction-notice`
- `Stop` -> `ccd host-hook --hook on-agent-end`

Human-driven Codex can still fall back to the manual CCD startup path:

- `/ccd-start`
- `ccd start --activate --path .`

That fallback is tracked as `manual_skill`, not as a product failure.

If you want the optional zero-ritual launcher/eval harness instead, run:

```bash
ccd host apply --host codex --with-launcher --path .
```

That applies the launcher wrapper at `./{CODEX_TARGET_LAUNCHER}`.
"#
    )
}

fn codex_config_toml() -> String {
    "[features]\nhooks = true\n".to_owned()
}

fn codex_hooks_json_for(profile: &LifecycleProfile) -> String {
    let merged = merge_codex_hooks_with_profile(json!({}), profile)
        .expect("empty object is a valid Codex hooks base for managed events");
    serde_json::to_string_pretty(&merged).expect("codex hooks json")
}

fn codex_launcher_script() -> String {
    r#"#!/bin/sh
# CCD-MANAGED
# Optional Codex launcher/eval harness.

set -e

if [ -n "$CCD_BIN" ] && [ -x "$CCD_BIN" ]; then
    CCD="$CCD_BIN"
elif command -v ccd >/dev/null 2>&1; then
    CCD=ccd
elif [ -x "$HOME/.ccd/bin/ccd" ]; then
    CCD="$HOME/.ccd/bin/ccd"
elif [ -x "$HOME/.cargo/bin/ccd" ]; then
    CCD="$HOME/.cargo/bin/ccd"
else
    CCD=""
fi

if [ -n "$CCD" ]; then
    "$CCD" host-hook --output json --path . --host codex --hook on-session-start >/dev/null 2>&1 || true
fi

exec codex "$@"
"#
    .to_owned()
}

fn openclaw_adapter_json() -> String {
    serde_json::to_string_pretty(&json!({
        "host": "openclaw",
        "integration_mode": "reference_adapter",
        "commands": {
            "session_start": "ccd --output json host-hook --path . --host openclaw --hook on-session-start --mode implement --lifecycle autonomous --owner-kind runtime-worker --actor-id runtime/openclaw-agent-1 --lease-seconds 900 --host-session-id acp-session-42 --host-run-id acp-run-42 --host-task-id req-openclaw-42",
            "before_prompt_build": "ccd host-hook --path . --host openclaw --hook before-prompt-build",
            "on_compaction_notice": "ccd host-hook --path . --host openclaw --hook on-compaction-notice",
            "on_agent_end": "ccd host-hook --path . --host openclaw --hook on-agent-end",
            "on_session_end": "ccd host-hook --path . --host openclaw --hook on-session-end"
        },
        "notes": [
            "Inject only the top-level context payload into prompt-build.",
            "Keep runtime transcript history outside CCD durable state.",
            "Use separate worktrees for parallel writers."
        ]
    }))
    .expect("openclaw adapter json")
}

fn hermes_adapter_json() -> String {
    serde_json::to_string_pretty(&json!({
        "host": "hermes",
        "integration_mode": "reference_adapter",
        "commands": {
            "session_start": "ccd host-hook --output json --path . --host hermes --hook on-session-start --mode implement --lifecycle autonomous --actor-id runtime/hermes-worker-1 --supervisor-id runtime/hermes-supervisor-1 --lease-seconds 900 --host-session-id hermes-channel-42 --host-run-id hermes-run-42 --host-task-id hermes-task-42",
            "before_prompt_build": "ccd host-hook --path . --host hermes --hook before-prompt-build",
            "on_compaction_notice": "ccd host-hook --path . --host hermes --hook on-compaction-notice",
            "on_agent_end": "ccd host-hook --path . --host hermes --hook on-agent-end",
            "on_session_end": "ccd host-hook --path . --host hermes --hook on-session-end",
            "supervisor_tick": "ccd host-hook --path . --host hermes --hook supervisor-tick"
        },
        "notes": [
            "Honor the top-level session_boundary before unattended continuation.",
            "Use supervisor_tick when the runtime can refresh lease ownership.",
            "Treat CCD outputs as control-plane truth rather than prompt folklore."
        ]
    }))
    .expect("hermes adapter json")
}