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    format!(
187        r#"<!-- CCD-MANAGED -->
188# Codex Host Guidance
189
190Codex supports native repo-local hooks when `hooks` is enabled in
191`.codex/config.toml` and `.codex/hooks.json` maps lifecycle events into CCD.
192
193CCD installs a minimal native mapping:
194
195- `SessionStart` -> `ccd host-hook --hook on-session-start`
196- `UserPromptSubmit` -> `ccd host-hook --hook before-prompt-build`
197- `PreCompact` -> `ccd host-hook --hook on-compaction-notice`
198- `PostCompact` -> `ccd host-hook --hook on-compaction-notice`
199- `Stop` -> `ccd host-hook --hook on-agent-end`
200
201Human-driven Codex can still fall back to the manual CCD startup path:
202
203- `/ccd-start`
204- `ccd start --activate --path .`
205
206That fallback is tracked as `manual_skill`, not as a product failure.
207
208If you want the optional zero-ritual launcher/eval harness instead, run:
209
210```bash
211ccd host apply --host codex --with-launcher --path .
212```
213
214That applies the launcher wrapper at `./{CODEX_TARGET_LAUNCHER}`.
215"#
216    )
217}
218
219fn codex_config_toml() -> String {
220    "[features]\nhooks = true\n".to_owned()
221}
222
223fn codex_hooks_json_for(profile: &LifecycleProfile) -> String {
224    let merged = merge_codex_hooks_with_profile(json!({}), profile)
225        .expect("empty object is a valid Codex hooks base for managed events");
226    serde_json::to_string_pretty(&merged).expect("codex hooks json")
227}
228
229fn codex_launcher_script() -> String {
230    r#"#!/bin/sh
231# CCD-MANAGED
232# Optional Codex launcher/eval harness.
233
234set -e
235
236if [ -n "$CCD_BIN" ] && [ -x "$CCD_BIN" ]; then
237    CCD="$CCD_BIN"
238elif command -v ccd >/dev/null 2>&1; then
239    CCD=ccd
240elif [ -x "$HOME/.ccd/bin/ccd" ]; then
241    CCD="$HOME/.ccd/bin/ccd"
242elif [ -x "$HOME/.cargo/bin/ccd" ]; then
243    CCD="$HOME/.cargo/bin/ccd"
244else
245    CCD=""
246fi
247
248if [ -n "$CCD" ]; then
249    "$CCD" host-hook --output json --path . --host codex --hook on-session-start >/dev/null 2>&1 || true
250fi
251
252exec codex "$@"
253"#
254    .to_owned()
255}
256
257fn openclaw_adapter_json() -> String {
258    serde_json::to_string_pretty(&json!({
259        "host": "openclaw",
260        "integration_mode": "reference_adapter",
261        "commands": {
262            "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",
263            "before_prompt_build": "ccd host-hook --path . --host openclaw --hook before-prompt-build",
264            "on_compaction_notice": "ccd host-hook --path . --host openclaw --hook on-compaction-notice",
265            "on_agent_end": "ccd host-hook --path . --host openclaw --hook on-agent-end",
266            "on_session_end": "ccd host-hook --path . --host openclaw --hook on-session-end"
267        },
268        "notes": [
269            "Inject only the top-level context payload into prompt-build.",
270            "Keep runtime transcript history outside CCD durable state.",
271            "Use separate worktrees for parallel writers."
272        ]
273    }))
274    .expect("openclaw adapter json")
275}
276
277fn hermes_adapter_json() -> String {
278    serde_json::to_string_pretty(&json!({
279        "host": "hermes",
280        "integration_mode": "reference_adapter",
281        "commands": {
282            "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",
283            "before_prompt_build": "ccd host-hook --path . --host hermes --hook before-prompt-build",
284            "on_compaction_notice": "ccd host-hook --path . --host hermes --hook on-compaction-notice",
285            "on_agent_end": "ccd host-hook --path . --host hermes --hook on-agent-end",
286            "on_session_end": "ccd host-hook --path . --host hermes --hook on-session-end",
287            "supervisor_tick": "ccd host-hook --path . --host hermes --hook supervisor-tick"
288        },
289        "notes": [
290            "Honor the top-level session_boundary before unattended continuation.",
291            "Use supervisor_tick when the runtime can refresh lease ownership.",
292            "Treat CCD outputs as control-plane truth rather than prompt folklore."
293        ]
294    }))
295    .expect("hermes adapter json")
296}