Skip to main content

lifeloop/host_assets/
render.rs

1//! Host integration asset rendering.
2
3use serde_json::{Value, json};
4
5use super::merge::merge_codex_hooks_with_profile;
6use super::model::*;
7use super::profiles::*;
8
9// ============================================================================
10// Asset rendering
11// ============================================================================
12
13/// Full set of source-tree assets for a host using the default
14/// [`CCD_COMPAT_PROFILE`]. These are the templates that land under
15/// `.ccd-hosts/<host>/`.
16pub fn render_source_assets(host: HostAdapter) -> Vec<RenderedAsset> {
17    render_source_assets_with_profile(host, &CCD_COMPAT_PROFILE)
18}
19
20/// Full set of source-tree assets for a host rendered with
21/// `profile`'s command prefixes and managed event tables. See
22/// [`render_applied_assets_with_profile`] for the per-profile vs.
23/// per-host scope split.
24pub fn render_source_assets_with_profile(
25    host: HostAdapter,
26    profile: &LifecycleProfile,
27) -> Vec<RenderedAsset> {
28    if profile.validate().is_err() {
29        return Vec::new();
30    }
31    match host {
32        HostAdapter::Claude => vec![RenderedAsset {
33            relative_path: CLAUDE_SOURCE_SETTINGS,
34            contents: claude_settings_json_for(profile),
35            mode: None,
36        }],
37        HostAdapter::Codex => vec![
38            RenderedAsset {
39                relative_path: CODEX_SOURCE_README,
40                contents: codex_guidance_readme(),
41                mode: None,
42            },
43            RenderedAsset {
44                relative_path: CODEX_SOURCE_CONFIG,
45                contents: codex_config_toml(),
46                mode: None,
47            },
48            RenderedAsset {
49                relative_path: CODEX_SOURCE_HOOKS,
50                contents: codex_hooks_json_for(profile),
51                mode: None,
52            },
53            RenderedAsset {
54                relative_path: CODEX_SOURCE_LAUNCHER,
55                contents: codex_launcher_script(),
56                mode: Some(0o755),
57            },
58        ],
59        HostAdapter::Hermes => vec![RenderedAsset {
60            relative_path: HERMES_SOURCE_ADAPTER,
61            contents: hermes_adapter_json(),
62            mode: None,
63        }],
64        HostAdapter::OpenClaw => vec![RenderedAsset {
65            relative_path: OPENCLAW_SOURCE_ADAPTER,
66            contents: openclaw_adapter_json(),
67            mode: None,
68        }],
69    }
70}
71
72/// Subset of [`render_source_assets`] required for the given mode.
73/// Modes that pin only a few of a host's source files (Codex
74/// manual-skill, launcher-wrapper) trim the list so absent files don't
75/// trigger spurious "missing scaffold" errors.
76pub fn render_required_source_assets(
77    host: HostAdapter,
78    mode: IntegrationMode,
79) -> Vec<RenderedAsset> {
80    let assets = render_source_assets(host);
81    let required_paths: &[&str] = match (host, mode) {
82        (HostAdapter::Codex, IntegrationMode::ManualSkill) => &[CODEX_SOURCE_README],
83        (HostAdapter::Codex, IntegrationMode::LauncherWrapper) => {
84            &[CODEX_SOURCE_README, CODEX_SOURCE_LAUNCHER]
85        }
86        (HostAdapter::Codex, IntegrationMode::NativeHook) => {
87            &[CODEX_SOURCE_README, CODEX_SOURCE_CONFIG, CODEX_SOURCE_HOOKS]
88        }
89        _ => return assets,
90    };
91    assets
92        .into_iter()
93        .filter(|asset| required_paths.contains(&asset.relative_path))
94        .collect()
95}
96
97/// Assets to apply into the host's runtime directories (`.claude/`,
98/// `.codex/`, `.hermes/`, `.openclaw/`) using the default
99/// [`CCD_COMPAT_PROFILE`]. Empty when the (host, mode) pair has no
100/// installable files (e.g. Codex manual-skill mode is scaffold-only).
101///
102/// This is a thin wrapper over [`render_applied_assets_with_profile`];
103/// callers that want non-CCD command prefixes pass an explicit
104/// profile.
105pub fn render_applied_assets(host: HostAdapter, mode: IntegrationMode) -> Vec<RenderedAsset> {
106    render_applied_assets_with_profile(host, mode, &CCD_COMPAT_PROFILE)
107}
108
109/// Assets to apply into the host's runtime directories rendered with
110/// `profile`'s command prefixes and managed event tables.
111///
112/// Hermes and OpenClaw reference adapters currently render with a
113/// CCD-flavored adapter JSON regardless of `profile`: those files are
114/// per-host illustrative documentation, not active command surfaces,
115/// and graduating them to per-profile rendering is a follow-up scoped
116/// outside #26.
117pub fn render_applied_assets_with_profile(
118    host: HostAdapter,
119    mode: IntegrationMode,
120    profile: &LifecycleProfile,
121) -> Vec<RenderedAsset> {
122    if profile.validate().is_err() {
123        return Vec::new();
124    }
125    match (host, mode) {
126        (HostAdapter::Claude, IntegrationMode::NativeHook) => vec![RenderedAsset {
127            relative_path: CLAUDE_TARGET_SETTINGS,
128            contents: claude_settings_json_for(profile),
129            mode: None,
130        }],
131        (HostAdapter::Codex, IntegrationMode::LauncherWrapper) => vec![RenderedAsset {
132            relative_path: CODEX_TARGET_LAUNCHER,
133            contents: codex_launcher_script(),
134            mode: Some(0o755),
135        }],
136        (HostAdapter::Codex, IntegrationMode::NativeHook) => vec![
137            RenderedAsset {
138                relative_path: CODEX_TARGET_CONFIG,
139                contents: codex_config_toml(),
140                mode: None,
141            },
142            RenderedAsset {
143                relative_path: CODEX_TARGET_HOOKS,
144                contents: codex_hooks_json_for(profile),
145                mode: None,
146            },
147        ],
148        (HostAdapter::Codex, IntegrationMode::ManualSkill) => Vec::new(),
149        (HostAdapter::Hermes, IntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
150            relative_path: HERMES_TARGET_ADAPTER,
151            contents: hermes_adapter_json(),
152            mode: None,
153        }],
154        (HostAdapter::OpenClaw, IntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
155            relative_path: OPENCLAW_TARGET_ADAPTER,
156            contents: openclaw_adapter_json(),
157            mode: None,
158        }],
159        _ => Vec::new(),
160    }
161}
162
163// ============================================================================
164// Asset content (private renderers)
165// ============================================================================
166
167fn claude_settings_json_for(profile: &LifecycleProfile) -> String {
168    let mut hooks = serde_json::Map::new();
169    for (event, hook_arg, matcher) in profile.claude_managed_events {
170        hooks.insert(
171            (*event).to_string(),
172            json!([{
173                "matcher": matcher,
174                "hooks": [{
175                    "type": "command",
176                    "command": profile.claude_command(hook_arg),
177                }]
178            }]),
179        );
180    }
181    let value = json!({ "hooks": Value::Object(hooks) });
182    serde_json::to_string_pretty(&value).expect("claude settings json")
183}
184
185fn codex_guidance_readme() -> String {
186    r#"<!-- CCD-MANAGED -->
187# Codex Host Guidance
188
189Codex supports native repo-local hooks when `hooks` is enabled in
190`.codex/config.toml` and `.codex/hooks.json` maps lifecycle events into
191the continuity client. Those files are generated alongside this README.
192
193Human-driven Codex drives CCD continuity through the operator loop:
194
195- `/ccd-start`
196- `ccd attach` (one-time, per project)
197- `ccd start` (open or roll forward a spin)
198- `ccd checkpoint` (record summary/handover)
199
200That manual path is tracked as `manual_skill`, not as a product failure.
201"#
202    .to_owned()
203}
204
205fn codex_config_toml() -> String {
206    "[features]\nhooks = true\n".to_owned()
207}
208
209fn codex_hooks_json_for(profile: &LifecycleProfile) -> String {
210    let merged = merge_codex_hooks_with_profile(json!({}), profile)
211        .expect("empty object is a valid Codex hooks base for managed events");
212    serde_json::to_string_pretty(&merged).expect("codex hooks json")
213}
214
215fn codex_launcher_script() -> String {
216    r#"#!/bin/sh
217# CCD-MANAGED
218# Optional Codex launcher/eval harness.
219
220set -e
221
222if [ -n "$CCD_BIN" ] && [ -x "$CCD_BIN" ]; then
223    CCD="$CCD_BIN"
224elif command -v ccd >/dev/null 2>&1; then
225    CCD=ccd
226elif [ -x "$HOME/.ccd/bin/ccd" ]; then
227    CCD="$HOME/.ccd/bin/ccd"
228elif [ -x "$HOME/.cargo/bin/ccd" ]; then
229    CCD="$HOME/.cargo/bin/ccd"
230else
231    CCD=""
232fi
233
234if [ -n "$CCD" ]; then
235    "$CCD" host-hook --output json --path . --host codex --hook on-session-start >/dev/null 2>&1 || true
236fi
237
238exec codex "$@"
239"#
240    .to_owned()
241}
242
243fn openclaw_adapter_json() -> String {
244    serde_json::to_string_pretty(&json!({
245        "host": "openclaw",
246        "integration_mode": "reference_adapter",
247        "commands": {
248            "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",
249            "before_prompt_build": "ccd host-hook --path . --host openclaw --hook before-prompt-build",
250            "on_compaction_notice": "ccd host-hook --path . --host openclaw --hook on-compaction-notice",
251            "on_agent_end": "ccd host-hook --path . --host openclaw --hook on-agent-end",
252            "on_session_end": "ccd host-hook --path . --host openclaw --hook on-session-end"
253        },
254        "notes": [
255            "Inject only the top-level context payload into prompt-build.",
256            "Keep runtime transcript history outside CCD durable state.",
257            "Use separate worktrees for parallel writers."
258        ]
259    }))
260    .expect("openclaw adapter json")
261}
262
263fn hermes_adapter_json() -> String {
264    serde_json::to_string_pretty(&json!({
265        "host": "hermes",
266        "integration_mode": "reference_adapter",
267        "commands": {
268            "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",
269            "before_prompt_build": "ccd host-hook --path . --host hermes --hook before-prompt-build",
270            "on_compaction_notice": "ccd host-hook --path . --host hermes --hook on-compaction-notice",
271            "on_agent_end": "ccd host-hook --path . --host hermes --hook on-agent-end",
272            "on_session_end": "ccd host-hook --path . --host hermes --hook on-session-end",
273            "supervisor_tick": "ccd host-hook --path . --host hermes --hook supervisor-tick"
274        },
275        "notes": [
276            "Honor the top-level session_boundary before unattended continuation.",
277            "Use supervisor_tick when the runtime can refresh lease ownership.",
278            "Treat CCD outputs as control-plane truth rather than prompt folklore."
279        ]
280    }))
281    .expect("hermes adapter json")
282}