Skip to main content

lifeloop/
host_assets.rs

1//! Host integration asset rendering and merge behavior.
2//!
3//! Lifeloop owns the file shape, merge safety, and status reporting for
4//! lifecycle integration assets installed into harness host directories
5//! (`.claude/`, `.codex/`, `.hermes/`, `.openclaw/`). The `host apply`
6//! and `host inspect` compatibility commands route here.
7//!
8//! # Boundary (issue #4)
9//!
10//! This module owns:
11//! * rendering source/applied asset content as in-memory data,
12//! * additive-merge logic that preserves user-owned entries,
13//! * asset status reporting (`Present`/`Missing`/`Drifted`/`InvalidMode`/
14//!   `NotApplicable`),
15//! * supported-mode rules for each adapter.
16//!
17//! This module does **not** own:
18//! * the hook protocol command strings themselves (those are issue #3 —
19//!   the strings appear here only as opaque compatibility labels that the
20//!   merge logic must recognize so it can scrub stale entries);
21//! * the full adapter manifest registry (issue #6);
22//! * lifecycle routing (issue #7);
23//! * telemetry parsing (issue #5);
24//! * filesystem IO. Callers handle reads, writes, mode bits, and atomic
25//!   replace. This module is pure: it operates on `serde_json::Value`,
26//!   strings, and byte slices.
27//!
28//! # CCD compatibility
29//!
30//! The command-prefix constants and legacy recognizer patterns
31//! (`CCD_COMPAT_*`) are CCD compatibility labels — Lifeloop's first
32//! client wires its own binary into harness hooks via these prefixes.
33//! They are *not* core Lifeloop semantics: a future non-CCD client
34//! gets its own profile in the same shape. Keeping them in one place
35//! makes the compat surface auditable.
36//!
37//! # Lifecycle integration profiles (issue #26)
38//!
39//! [`LifecycleProfile`] generalizes the CCD-shaped command-prefix /
40//! managed-event surface into a per-client-profile struct. The free
41//! functions exported from this module keep their CCD-compat default
42//! behavior (they delegate to [`CCD_COMPAT_PROFILE`]); paired
43//! `*_with_profile` variants accept any profile so a non-CCD client
44//! (e.g. [`LIFELOOP_DIRECT_PROFILE`], the post-slimdown shape where
45//! the harness invokes the `lifeloop` CLI directly without CCD as
46//! broker) can render and merge its own lifecycle hook assets without
47//! editing core merge logic. This is the bridge contemplated by
48//! `docs/release-gates.md` for the CCD slimdown
49//! (dusk-network/ccd#723) — the slimdown lands by switching active
50//! installs from `CCD_COMPAT_PROFILE` to a non-CCD profile, not by
51//! rewriting the renderer.
52
53use serde_json::{Value, json};
54
55// ============================================================================
56// Adapters and modes
57// ============================================================================
58
59/// Host adapters Lifeloop ships asset rendering and merge support for.
60///
61/// The on-the-wire identifier (`as_str()`) is the same string an
62/// `AdapterManifest::adapter_id` would carry for the same harness; issue
63/// #6 lands the full manifest registry that consumes these ids.
64#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
65pub enum HostAdapter {
66    Claude,
67    Codex,
68    Hermes,
69    OpenClaw,
70}
71
72impl HostAdapter {
73    pub const ALL: &'static [Self] = &[Self::Claude, Self::Codex, Self::Hermes, Self::OpenClaw];
74
75    pub fn as_str(self) -> &'static str {
76        match self {
77            Self::Claude => "claude",
78            Self::Codex => "codex",
79            Self::Hermes => "hermes",
80            Self::OpenClaw => "openclaw",
81        }
82    }
83
84    /// Recognizes both canonical ids and the historical CCD aliases (e.g.
85    /// `claude-code`). Returns `None` for unknown names.
86    pub fn from_id(name: &str) -> Option<Self> {
87        match name {
88            "claude" | "claude-code" => Some(Self::Claude),
89            "codex" => Some(Self::Codex),
90            "hermes" => Some(Self::Hermes),
91            "openclaw" => Some(Self::OpenClaw),
92            _ => None,
93        }
94    }
95
96    /// Default integration mode when the host has no explicit declaration.
97    pub fn default_mode(self) -> IntegrationMode {
98        match self {
99            Self::Claude => IntegrationMode::NativeHook,
100            Self::Codex => IntegrationMode::ManualSkill,
101            Self::Hermes | Self::OpenClaw => IntegrationMode::ReferenceAdapter,
102        }
103    }
104}
105
106/// Integration modes supported by host asset rendering.
107///
108/// Mirrors the subset of [`crate::IntegrationMode`] that maps to actual
109/// installable assets. `TelemetryOnly` does not produce assets and is
110/// therefore not represented here; callers that have a full
111/// `IntegrationMode` can convert with [`Self::from_lifecycle_mode`].
112#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
113pub enum IntegrationMode {
114    ManualSkill,
115    LauncherWrapper,
116    NativeHook,
117    ReferenceAdapter,
118}
119
120impl IntegrationMode {
121    pub const ALL: &'static [Self] = &[
122        Self::ManualSkill,
123        Self::LauncherWrapper,
124        Self::NativeHook,
125        Self::ReferenceAdapter,
126    ];
127
128    pub fn as_str(self) -> &'static str {
129        match self {
130            Self::ManualSkill => "manual_skill",
131            Self::LauncherWrapper => "launcher_wrapper",
132            Self::NativeHook => "native_hook",
133            Self::ReferenceAdapter => "reference_adapter",
134        }
135    }
136
137    pub fn from_id(value: &str) -> Option<Self> {
138        match value {
139            "manual_skill" => Some(Self::ManualSkill),
140            "launcher_wrapper" => Some(Self::LauncherWrapper),
141            "native_hook" => Some(Self::NativeHook),
142            "reference_adapter" => Some(Self::ReferenceAdapter),
143            _ => None,
144        }
145    }
146
147    /// Convert from the broader [`crate::IntegrationMode`]. Returns `None`
148    /// for `TelemetryOnly`, which has no asset surface.
149    pub fn from_lifecycle_mode(mode: crate::IntegrationMode) -> Option<Self> {
150        match mode {
151            crate::IntegrationMode::ManualSkill => Some(Self::ManualSkill),
152            crate::IntegrationMode::LauncherWrapper => Some(Self::LauncherWrapper),
153            crate::IntegrationMode::NativeHook => Some(Self::NativeHook),
154            crate::IntegrationMode::ReferenceAdapter => Some(Self::ReferenceAdapter),
155            crate::IntegrationMode::TelemetryOnly => None,
156        }
157    }
158}
159
160/// Whether `host` supports the requested install mode. Gates `apply` so a
161/// caller can refuse before mutating files.
162pub fn supports_mode(host: HostAdapter, mode: IntegrationMode) -> bool {
163    match host {
164        HostAdapter::Claude => mode == IntegrationMode::NativeHook,
165        HostAdapter::Codex => matches!(
166            mode,
167            IntegrationMode::ManualSkill
168                | IntegrationMode::LauncherWrapper
169                | IntegrationMode::NativeHook
170        ),
171        HostAdapter::Hermes | HostAdapter::OpenClaw => mode == IntegrationMode::ReferenceAdapter,
172    }
173}
174
175/// Modes the adapter accepts, in declaration order, for diagnostic
176/// messaging. Order is informational.
177pub fn supported_modes(host: HostAdapter) -> &'static [IntegrationMode] {
178    match host {
179        HostAdapter::Claude => &[IntegrationMode::NativeHook],
180        HostAdapter::Codex => &[
181            IntegrationMode::ManualSkill,
182            IntegrationMode::LauncherWrapper,
183            IntegrationMode::NativeHook,
184        ],
185        HostAdapter::Hermes | HostAdapter::OpenClaw => &[IntegrationMode::ReferenceAdapter],
186    }
187}
188
189// ============================================================================
190// Asset descriptor and status
191// ============================================================================
192
193/// One file Lifeloop renders for a host adapter. `relative_path` is
194/// relative to the repository root the caller is rendering into. `mode`
195/// is a Unix permission bitmask when present; callers on non-Unix
196/// targets may ignore it, but cross-checking is still meaningful for
197/// status reporting.
198#[derive(Clone, Debug, PartialEq, Eq)]
199pub struct RenderedAsset {
200    pub relative_path: &'static str,
201    pub contents: String,
202    pub mode: Option<u32>,
203}
204
205/// Status of an installed asset relative to the rendered template.
206#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub enum AssetStatus {
208    /// The on-disk asset matches the rendered shape.
209    Present,
210    /// The asset path does not exist.
211    Missing,
212    /// The asset exists but its content (or permission mode) differs from
213    /// what Lifeloop would render. For merge-aware assets this means the
214    /// merge result no longer matches the file.
215    Drifted,
216    /// The (host, mode) pair is not a supported install combination.
217    InvalidMode,
218    /// No asset is expected for this (host, mode) pair.
219    NotApplicable,
220}
221
222/// Action taken for one asset during apply.
223#[derive(Clone, Copy, Debug, Eq, PartialEq)]
224pub enum FileAction {
225    /// The asset was created.
226    Installed,
227    /// The asset existed and was rewritten with new content.
228    Updated,
229    /// The asset matched the rendered template; no write was needed.
230    AlreadyPresent,
231}
232
233/// Combine `current` with `next` so a multi-file apply reports the
234/// strongest action seen. `Updated` dominates `Installed` dominates
235/// `AlreadyPresent`.
236pub fn combine_actions(current: FileAction, next: FileAction) -> FileAction {
237    match (current, next) {
238        (FileAction::Updated, _) | (_, FileAction::Updated) => FileAction::Updated,
239        (FileAction::Installed, _) | (_, FileAction::Installed) => FileAction::Installed,
240        _ => FileAction::AlreadyPresent,
241    }
242}
243
244/// Outcome of merging managed entries into an existing settings/hooks
245/// file. `existing` is the prior file content (`None` if the file did
246/// not exist); `rendered` is the merged content the caller should
247/// write.
248#[derive(Clone, Debug, PartialEq, Eq)]
249pub struct MergedFile {
250    pub existing: Option<String>,
251    pub rendered: String,
252}
253
254/// Errors returned by merge or render entry points. Callers convert as
255/// needed; the variants are pinned so downstream tests can match.
256#[derive(Debug)]
257pub enum HostAssetError {
258    /// The (host, mode) pair is not a supported install combination.
259    UnsupportedMode {
260        host: HostAdapter,
261        mode: IntegrationMode,
262    },
263    /// The existing file's top-level shape conflicts with the merge
264    /// invariants (e.g. `hooks` is an array instead of an object).
265    Malformed { reason: String },
266    /// The existing file is invalid JSON or TOML.
267    Parse { reason: String },
268    /// Re-serialization failed (should be unreachable for well-formed
269    /// inputs; surfaced rather than panicked).
270    Serialize { reason: String },
271}
272
273impl std::fmt::Display for HostAssetError {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        match self {
276            Self::UnsupportedMode { host, mode } => write!(
277                f,
278                "unsupported mode `{}` for {} (supported: {})",
279                mode.as_str(),
280                host.as_str(),
281                supported_modes(*host)
282                    .iter()
283                    .map(|m| m.as_str())
284                    .collect::<Vec<_>>()
285                    .join(", ")
286            ),
287            Self::Malformed { reason } => write!(f, "malformed managed file: {reason}"),
288            Self::Parse { reason } => write!(f, "parse error: {reason}"),
289            Self::Serialize { reason } => write!(f, "serialize error: {reason}"),
290        }
291    }
292}
293
294impl std::error::Error for HostAssetError {}
295
296// ============================================================================
297// Path constants
298// ============================================================================
299
300pub const CLAUDE_SOURCE_SETTINGS: &str = ".ccd-hosts/claude/settings.json";
301pub const CLAUDE_TARGET_SETTINGS: &str = ".claude/settings.json";
302
303pub const CODEX_SOURCE_README: &str = ".ccd-hosts/codex/README.md";
304pub const CODEX_SOURCE_LAUNCHER: &str = ".ccd-hosts/codex/launcher.sh";
305pub const CODEX_SOURCE_CONFIG: &str = ".ccd-hosts/codex/config.toml";
306pub const CODEX_SOURCE_HOOKS: &str = ".ccd-hosts/codex/hooks.json";
307pub const CODEX_TARGET_LAUNCHER: &str = ".codex/ccd-launch.sh";
308pub const CODEX_TARGET_CONFIG: &str = ".codex/config.toml";
309pub const CODEX_TARGET_HOOKS: &str = ".codex/hooks.json";
310
311pub const OPENCLAW_SOURCE_ADAPTER: &str = ".ccd-hosts/openclaw/adapter.json";
312pub const OPENCLAW_TARGET_ADAPTER: &str = ".openclaw/ccd.json";
313
314pub const HERMES_SOURCE_ADAPTER: &str = ".ccd-hosts/hermes/adapter.json";
315pub const HERMES_TARGET_ADAPTER: &str = ".hermes/ccd.json";
316
317// ============================================================================
318// Lifecycle integration profiles
319// ============================================================================
320//
321// A `LifecycleProfile` captures the per-client-profile facts that vary
322// between integration profiles: per-host command prefixes, the legacy
323// substrings the merge logic should scrub for that profile, and the
324// managed event tables Lifeloop installs into each host's hook config
325// for that profile. The renderers and merge logic consult a profile
326// rather than hardcoding any one client's binary or command prefix,
327// so adding a new profile does not require editing core merge logic.
328// See the module rustdoc for the slimdown narrative this enables.
329
330/// Per-client-profile data driving lifecycle integration asset
331/// rendering and merge.
332///
333/// This struct expresses the client-shape of a host integration
334/// profile (e.g. CCD compatibility, lifeloop-direct callback) without
335/// pulling client semantics into core types. It is a pure data
336/// surface: every field is `'static` and the methods are pure
337/// functions of those fields.
338#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
339pub struct LifecycleProfile {
340    /// Stable profile identifier (e.g. `"ccd-compat"`,
341    /// `"lifeloop-direct"`). Used in diagnostics; not part of the
342    /// rendered asset content.
343    pub id: &'static str,
344    /// Command prefix Lifeloop renders into `.claude/settings.json`
345    /// for managed hook entries. The merge logic uses it as a
346    /// managed-entry marker (it scrubs entries whose `command`
347    /// starts with this prefix and rewrites them).
348    pub claude_command_prefix: &'static str,
349    /// Substrings inside `.claude/settings.json` `command` strings
350    /// that the merge logic also treats as managed (legacy/pre-v1
351    /// forms whose shape changed across releases). Always merged
352    /// WITH the prefix scrub, never replacing it. Empty when the
353    /// profile has no legacy shape to scrub.
354    pub claude_legacy_substrings: &'static [&'static str],
355    /// `(claude_event, hook_arg, matcher_pattern)` tuples this
356    /// profile installs into Claude's hook config.
357    pub claude_managed_events: &'static [(&'static str, &'static str, &'static str)],
358    /// Command prefix Lifeloop renders into `.codex/hooks.json` for
359    /// managed hook entries. Merge logic scrubs entries whose
360    /// `command` starts with it.
361    pub codex_command_prefix: &'static str,
362    /// `(codex_event, hook_arg, matcher_pattern, status_message)`
363    /// tuples this profile installs into Codex's hook config.
364    pub codex_managed_events: &'static [(&'static str, &'static str, &'static str, &'static str)],
365}
366
367impl LifecycleProfile {
368    /// Render this profile's `.claude/settings.json` hook command for
369    /// `hook_arg`.
370    pub fn claude_command(&self, hook_arg: &str) -> String {
371        format!("{}{}", self.claude_command_prefix, hook_arg)
372    }
373
374    /// Render this profile's `.codex/hooks.json` hook command for
375    /// `hook_arg`.
376    pub fn codex_command(&self, hook_arg: &str) -> String {
377        format!("{}{}", self.codex_command_prefix, hook_arg)
378    }
379
380    /// True when `entry` is recognized as a managed `.claude/settings.json`
381    /// hook for this profile — either the modern command prefix or any
382    /// of `claude_legacy_substrings`. Used by the merge logic to scrub
383    /// stale managed entries before rewriting them.
384    fn claude_entry_is_managed_or_legacy(&self, entry: &Value) -> bool {
385        let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
386        cmd.starts_with(self.claude_command_prefix)
387            || self
388                .claude_legacy_substrings
389                .iter()
390                .any(|legacy| cmd.contains(legacy))
391    }
392
393    /// True when `entry` is recognized as a managed `.codex/hooks.json`
394    /// hook for this profile.
395    fn codex_entry_is_managed(&self, entry: &Value) -> bool {
396        entry
397            .get("command")
398            .and_then(Value::as_str)
399            .map(|cmd| cmd.starts_with(self.codex_command_prefix))
400            .unwrap_or(false)
401    }
402}
403
404// ----------------------------------------------------------------------------
405// Shared event tables
406// ----------------------------------------------------------------------------
407//
408// These tables describe the lifecycle events Lifeloop installs into a
409// host's hook config. They are shared across profiles because the
410// lifecycle event vocabulary is harness-defined, not client-defined —
411// what varies across profiles is the *command prefix* that wraps each
412// event's hook arg, not the (event, hook arg, matcher) triple. A
413// future profile that needs to skip an event or use a different hook
414// arg can simply ship its own table.
415
416/// (claude_event, hook_arg, matcher_pattern). `TaskCompleted` is
417/// intentionally excluded — only `Stop` fires reliably at end-of-turn
418/// in Claude's hook protocol.
419const STANDARD_CLAUDE_MANAGED_EVENTS: &[(&str, &str, &str)] = &[
420    (
421        "SessionStart",
422        "on-session-start",
423        "startup|resume|clear|compact",
424    ),
425    ("UserPromptSubmit", "before-prompt-build", "*"),
426    ("PreCompact", "on-compaction-notice", "*"),
427    ("Stop", "on-agent-end", "*"),
428    ("SessionEnd", "on-session-end", "*"),
429];
430
431/// (codex_event, hook_arg, matcher_pattern, status_message). Codex's
432/// hook surface does not expose `PreCompact` or `SessionEnd`, so the
433/// table is shorter than the Claude one.
434const STANDARD_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
435    (
436        "SessionStart",
437        "on-session-start",
438        "startup|resume|clear",
439        "Loading CCD session context",
440    ),
441    (
442        "UserPromptSubmit",
443        "before-prompt-build",
444        "*",
445        "Refreshing CCD prompt context",
446    ),
447    (
448        "PreCompact",
449        "on-compaction-notice",
450        "*",
451        "Recording CCD compaction boundary",
452    ),
453    (
454        "PostCompact",
455        "on-compaction-notice",
456        "*",
457        "Recording CCD compacted context boundary",
458    ),
459    (
460        "Stop",
461        "on-agent-end",
462        "*",
463        "Checking CCD continuation boundary",
464    ),
465];
466
467/// (codex_event, hook_arg, matcher_pattern, status_message) for the
468/// post-slimdown lifeloop-direct profile. Status text reads
469/// "Lifeloop ..." rather than "CCD ..." so the operator-facing
470/// messaging matches the binary actually invoked.
471const LIFELOOP_DIRECT_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
472    (
473        "SessionStart",
474        "on-session-start",
475        "startup|resume|clear",
476        "Loading Lifeloop session context",
477    ),
478    (
479        "UserPromptSubmit",
480        "before-prompt-build",
481        "*",
482        "Refreshing Lifeloop prompt context",
483    ),
484    (
485        "Stop",
486        "on-agent-end",
487        "*",
488        "Checking Lifeloop continuation boundary",
489    ),
490];
491
492// ----------------------------------------------------------------------------
493// Built-in profiles
494// ----------------------------------------------------------------------------
495
496/// CCD compatibility profile: the harness invokes `${CCD_BIN:-ccd}
497/// host-hook ...` and CCD acts as the broker that calls back into
498/// Lifeloop. This is Lifeloop's first client and its current
499/// production install shape.
500pub const CCD_COMPAT_PROFILE: LifecycleProfile = LifecycleProfile {
501    id: "ccd-compat",
502    claude_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
503    claude_legacy_substrings: &["ccd-hook.py"],
504    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
505    codex_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ",
506    codex_managed_events: STANDARD_CODEX_MANAGED_EVENTS,
507};
508
509/// Lifeloop-direct callback profile: the harness invokes
510/// `${LIFELOOP_BIN:-lifeloop} host-hook ...` directly, with no CCD
511/// in the loop. This is the post-slimdown shape contemplated by
512/// dusk-network/ccd#723 — landing it as a built-in profile lets a
513/// non-CCD pilot exercise the full host-asset rendering path before
514/// the slimdown work commits to it.
515pub const LIFELOOP_DIRECT_PROFILE: LifecycleProfile = LifecycleProfile {
516    id: "lifeloop-direct",
517    claude_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
518    // The lifeloop-direct profile is the documented successor to the
519    // CCD-compat profile (see `docs/decisions/lifecycle-profiles.md`
520    // and `docs/release-gates.md` on dusk-network/ccd#723). Treating
521    // CCD-compat entries as legacy ensures that an operator who runs
522    // a lifeloop-direct merge over an existing CCD-compat
523    // settings.json gets a single set of managed hooks in the new
524    // shape — not two coexisting sets — which is what "switch
525    // profiles" means at the install layer. The pre-v1 Python-bridge
526    // substring is also recognized for the same reason. The reverse
527    // direction (CCD-compat merge over lifeloop-direct) is
528    // intentionally additive, since CCD has no claim to a successor
529    // profile's shape; that asymmetry is pinned by tests in
530    // `tests/host_assets_profiles.rs`.
531    claude_legacy_substrings: &[CCD_COMPAT_PROFILE.claude_command_prefix, "ccd-hook.py"],
532    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
533    codex_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ",
534    codex_managed_events: LIFELOOP_DIRECT_CODEX_MANAGED_EVENTS,
535};
536
537// ----------------------------------------------------------------------------
538// CCD-compat back-compat aliases
539// ----------------------------------------------------------------------------
540//
541// The constants and helpers below preserve the pre-#26 public API
542// while delegating to `CCD_COMPAT_PROFILE`. Keeping them in place
543// avoids a churn ripple across in-tree callers and downstream
544// consumers (CCD itself imports `CCD_COMPAT_CLAUDE_COMMAND_PREFIX` to
545// produce matching strings during host-hook receipts).
546
547/// Command prefix Lifeloop renders into `.claude/settings.json` for
548/// CCD-managed hook entries. Equal to
549/// [`CCD_COMPAT_PROFILE`]`.claude_command_prefix`.
550pub const CCD_COMPAT_CLAUDE_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.claude_command_prefix;
551
552/// Command prefix Lifeloop renders into `.codex/hooks.json` for
553/// CCD-managed hook entries. Equal to
554/// [`CCD_COMPAT_PROFILE`]`.codex_command_prefix`.
555pub const CCD_COMPAT_CODEX_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.codex_command_prefix;
556
557/// Substring that identifies the pre-v1 Python bridge entries in
558/// `.claude/settings.json`. Merge logic scrubs these even when the
559/// modern command prefix has changed.
560pub const CCD_COMPAT_CLAUDE_LEGACY_PYTHON_HOOK: &str = "ccd-hook.py";
561
562/// Render a CCD-compat `.claude/settings.json` hook command for `hook_arg`.
563pub fn ccd_compat_claude_command(hook_arg: &str) -> String {
564    CCD_COMPAT_PROFILE.claude_command(hook_arg)
565}
566
567/// Render a CCD-compat `.codex/hooks.json` hook command for `hook_arg`.
568pub fn ccd_compat_codex_command(hook_arg: &str) -> String {
569    CCD_COMPAT_PROFILE.codex_command(hook_arg)
570}
571
572// ============================================================================
573// Asset rendering
574// ============================================================================
575
576/// Full set of source-tree assets for a host using the default
577/// [`CCD_COMPAT_PROFILE`]. These are the templates that land under
578/// `.ccd-hosts/<host>/`.
579pub fn render_source_assets(host: HostAdapter) -> Vec<RenderedAsset> {
580    render_source_assets_with_profile(host, &CCD_COMPAT_PROFILE)
581}
582
583/// Full set of source-tree assets for a host rendered with
584/// `profile`'s command prefixes and managed event tables. See
585/// [`render_applied_assets_with_profile`] for the per-profile vs.
586/// per-host scope split.
587pub fn render_source_assets_with_profile(
588    host: HostAdapter,
589    profile: &LifecycleProfile,
590) -> Vec<RenderedAsset> {
591    match host {
592        HostAdapter::Claude => vec![RenderedAsset {
593            relative_path: CLAUDE_SOURCE_SETTINGS,
594            contents: claude_settings_json_for(profile),
595            mode: None,
596        }],
597        HostAdapter::Codex => vec![
598            RenderedAsset {
599                relative_path: CODEX_SOURCE_README,
600                contents: codex_guidance_readme(),
601                mode: None,
602            },
603            RenderedAsset {
604                relative_path: CODEX_SOURCE_CONFIG,
605                contents: codex_config_toml(),
606                mode: None,
607            },
608            RenderedAsset {
609                relative_path: CODEX_SOURCE_HOOKS,
610                contents: codex_hooks_json_for(profile),
611                mode: None,
612            },
613            RenderedAsset {
614                relative_path: CODEX_SOURCE_LAUNCHER,
615                contents: codex_launcher_script(),
616                mode: Some(0o755),
617            },
618        ],
619        HostAdapter::Hermes => vec![RenderedAsset {
620            relative_path: HERMES_SOURCE_ADAPTER,
621            contents: hermes_adapter_json(),
622            mode: None,
623        }],
624        HostAdapter::OpenClaw => vec![RenderedAsset {
625            relative_path: OPENCLAW_SOURCE_ADAPTER,
626            contents: openclaw_adapter_json(),
627            mode: None,
628        }],
629    }
630}
631
632/// Subset of [`render_source_assets`] required for the given mode.
633/// Modes that pin only a few of a host's source files (Codex
634/// manual-skill, launcher-wrapper) trim the list so absent files don't
635/// trigger spurious "missing scaffold" errors.
636pub fn render_required_source_assets(
637    host: HostAdapter,
638    mode: IntegrationMode,
639) -> Vec<RenderedAsset> {
640    let assets = render_source_assets(host);
641    let required_paths: &[&str] = match (host, mode) {
642        (HostAdapter::Codex, IntegrationMode::ManualSkill) => &[CODEX_SOURCE_README],
643        (HostAdapter::Codex, IntegrationMode::LauncherWrapper) => {
644            &[CODEX_SOURCE_README, CODEX_SOURCE_LAUNCHER]
645        }
646        (HostAdapter::Codex, IntegrationMode::NativeHook) => {
647            &[CODEX_SOURCE_README, CODEX_SOURCE_CONFIG, CODEX_SOURCE_HOOKS]
648        }
649        _ => return assets,
650    };
651    assets
652        .into_iter()
653        .filter(|asset| required_paths.contains(&asset.relative_path))
654        .collect()
655}
656
657/// Assets to apply into the host's runtime directories (`.claude/`,
658/// `.codex/`, `.hermes/`, `.openclaw/`) using the default
659/// [`CCD_COMPAT_PROFILE`]. Empty when the (host, mode) pair has no
660/// installable files (e.g. Codex manual-skill mode is scaffold-only).
661///
662/// This is a thin wrapper over [`render_applied_assets_with_profile`];
663/// callers that want non-CCD command prefixes pass an explicit
664/// profile.
665pub fn render_applied_assets(host: HostAdapter, mode: IntegrationMode) -> Vec<RenderedAsset> {
666    render_applied_assets_with_profile(host, mode, &CCD_COMPAT_PROFILE)
667}
668
669/// Assets to apply into the host's runtime directories rendered with
670/// `profile`'s command prefixes and managed event tables.
671///
672/// Hermes and OpenClaw reference adapters currently render with a
673/// CCD-flavored adapter JSON regardless of `profile`: those files are
674/// per-host illustrative documentation, not active command surfaces,
675/// and graduating them to per-profile rendering is a follow-up scoped
676/// outside #26.
677pub fn render_applied_assets_with_profile(
678    host: HostAdapter,
679    mode: IntegrationMode,
680    profile: &LifecycleProfile,
681) -> Vec<RenderedAsset> {
682    match (host, mode) {
683        (HostAdapter::Claude, IntegrationMode::NativeHook) => vec![RenderedAsset {
684            relative_path: CLAUDE_TARGET_SETTINGS,
685            contents: claude_settings_json_for(profile),
686            mode: None,
687        }],
688        (HostAdapter::Codex, IntegrationMode::LauncherWrapper) => vec![RenderedAsset {
689            relative_path: CODEX_TARGET_LAUNCHER,
690            contents: codex_launcher_script(),
691            mode: Some(0o755),
692        }],
693        (HostAdapter::Codex, IntegrationMode::NativeHook) => vec![
694            RenderedAsset {
695                relative_path: CODEX_TARGET_CONFIG,
696                contents: codex_config_toml(),
697                mode: None,
698            },
699            RenderedAsset {
700                relative_path: CODEX_TARGET_HOOKS,
701                contents: codex_hooks_json_for(profile),
702                mode: None,
703            },
704        ],
705        (HostAdapter::Codex, IntegrationMode::ManualSkill) => Vec::new(),
706        (HostAdapter::Hermes, IntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
707            relative_path: HERMES_TARGET_ADAPTER,
708            contents: hermes_adapter_json(),
709            mode: None,
710        }],
711        (HostAdapter::OpenClaw, IntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
712            relative_path: OPENCLAW_TARGET_ADAPTER,
713            contents: openclaw_adapter_json(),
714            mode: None,
715        }],
716        _ => Vec::new(),
717    }
718}
719
720// ============================================================================
721// Asset content (private renderers)
722// ============================================================================
723
724fn claude_settings_json_for(profile: &LifecycleProfile) -> String {
725    let mut hooks = serde_json::Map::new();
726    for (event, hook_arg, matcher) in profile.claude_managed_events {
727        hooks.insert(
728            (*event).to_string(),
729            json!([{
730                "matcher": matcher,
731                "hooks": [{
732                    "type": "command",
733                    "command": profile.claude_command(hook_arg),
734                }]
735            }]),
736        );
737    }
738    let value = json!({ "hooks": Value::Object(hooks) });
739    serde_json::to_string_pretty(&value).expect("claude settings json")
740}
741
742fn codex_guidance_readme() -> String {
743    format!(
744        r#"<!-- CCD-MANAGED -->
745# Codex Host Guidance
746
747Codex supports native repo-local hooks when `hooks` is enabled in
748`.codex/config.toml` and `.codex/hooks.json` maps lifecycle events into CCD.
749
750CCD installs a minimal native mapping:
751
752- `SessionStart` -> `ccd host-hook --hook on-session-start`
753- `UserPromptSubmit` -> `ccd host-hook --hook before-prompt-build`
754- `PreCompact` -> `ccd host-hook --hook on-compaction-notice`
755- `PostCompact` -> `ccd host-hook --hook on-compaction-notice`
756- `Stop` -> `ccd host-hook --hook on-agent-end`
757
758Human-driven Codex can still fall back to the manual CCD startup path:
759
760- `/ccd-start`
761- `ccd start --activate --path .`
762
763That fallback is tracked as `manual_skill`, not as a product failure.
764
765If you want the optional zero-ritual launcher/eval harness instead, run:
766
767```bash
768ccd host apply --host codex --with-launcher --path .
769```
770
771That applies the launcher wrapper at `./{CODEX_TARGET_LAUNCHER}`.
772"#
773    )
774}
775
776fn codex_config_toml() -> String {
777    "[features]\nhooks = true\n".to_owned()
778}
779
780fn codex_hooks_json_for(profile: &LifecycleProfile) -> String {
781    let merged = merge_codex_hooks_with_profile(json!({}), profile)
782        .expect("empty object is a valid Codex hooks base for managed events");
783    serde_json::to_string_pretty(&merged).expect("codex hooks json")
784}
785
786fn codex_launcher_script() -> String {
787    r#"#!/bin/sh
788# CCD-MANAGED
789# Optional Codex launcher/eval harness.
790
791set -e
792
793if [ -n "$CCD_BIN" ] && [ -x "$CCD_BIN" ]; then
794    CCD="$CCD_BIN"
795elif command -v ccd >/dev/null 2>&1; then
796    CCD=ccd
797elif [ -x "$HOME/.ccd/bin/ccd" ]; then
798    CCD="$HOME/.ccd/bin/ccd"
799elif [ -x "$HOME/.cargo/bin/ccd" ]; then
800    CCD="$HOME/.cargo/bin/ccd"
801else
802    CCD=""
803fi
804
805if [ -n "$CCD" ]; then
806    "$CCD" host-hook --output json --path . --host codex --hook on-session-start >/dev/null 2>&1 || true
807fi
808
809exec codex "$@"
810"#
811    .to_owned()
812}
813
814fn openclaw_adapter_json() -> String {
815    serde_json::to_string_pretty(&json!({
816        "host": "openclaw",
817        "integration_mode": "reference_adapter",
818        "commands": {
819            "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",
820            "before_prompt_build": "ccd host-hook --path . --host openclaw --hook before-prompt-build",
821            "on_compaction_notice": "ccd host-hook --path . --host openclaw --hook on-compaction-notice",
822            "on_agent_end": "ccd host-hook --path . --host openclaw --hook on-agent-end",
823            "on_session_end": "ccd host-hook --path . --host openclaw --hook on-session-end"
824        },
825        "notes": [
826            "Inject only the top-level context payload into prompt-build.",
827            "Keep runtime transcript history outside CCD durable state.",
828            "Use separate worktrees for parallel writers."
829        ]
830    }))
831    .expect("openclaw adapter json")
832}
833
834fn hermes_adapter_json() -> String {
835    serde_json::to_string_pretty(&json!({
836        "host": "hermes",
837        "integration_mode": "reference_adapter",
838        "commands": {
839            "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",
840            "before_prompt_build": "ccd host-hook --path . --host hermes --hook before-prompt-build",
841            "on_compaction_notice": "ccd host-hook --path . --host hermes --hook on-compaction-notice",
842            "on_agent_end": "ccd host-hook --path . --host hermes --hook on-agent-end",
843            "on_session_end": "ccd host-hook --path . --host hermes --hook on-session-end",
844            "supervisor_tick": "ccd host-hook --path . --host hermes --hook supervisor-tick"
845        },
846        "notes": [
847            "Honor the top-level session_boundary before unattended continuation.",
848            "Use supervisor_tick when the runtime can refresh lease ownership.",
849            "Treat CCD outputs as control-plane truth rather than prompt folklore."
850        ]
851    }))
852    .expect("hermes adapter json")
853}
854
855// ============================================================================
856// Merge: .claude/settings.json
857// ============================================================================
858
859/// Additive merge of CCD-managed lifecycle hooks into a Claude
860/// `settings.json` value, using the default [`CCD_COMPAT_PROFILE`].
861///
862/// See [`merge_claude_settings_with_profile`] for the algorithm. This
863/// is the back-compat entry point preserved for callers that pre-date
864/// the profile abstraction.
865pub fn merge_claude_settings(settings: Value) -> Option<Value> {
866    merge_claude_settings_with_profile(settings, &CCD_COMPAT_PROFILE)
867}
868
869/// Additive merge of `profile`'s managed lifecycle hooks into a Claude
870/// `settings.json` value.
871///
872/// Algorithm:
873/// 1. Ensure a top-level `hooks` object. Preserve every other top-level
874///    key.
875/// 2. For each managed event in `profile.claude_managed_events`,
876///    ensure a matcher entry exists. Within its `hooks` array, drop
877///    any entry whose `command` is recognized as a stale managed
878///    entry for `profile` (current prefix or any of
879///    `profile.claude_legacy_substrings`), then append the current
880///    managed entry rendered through `profile.claude_command`.
881/// 3. Non-managed entries within the same matcher are preserved in
882///    original order.
883/// 4. The retired `TaskCompleted` event is removed when its only
884///    entries are managed by `profile`; user-owned `TaskCompleted`
885///    hooks are preserved.
886///
887/// Returns `None` when the existing `hooks` shape is incompatible:
888/// `hooks` is not an object, or a managed event's value is not an array,
889/// or a matcher's inner `hooks` is not an array. Callers treat `None` as
890/// malformed input.
891pub fn merge_claude_settings_with_profile(
892    mut settings: Value,
893    profile: &LifecycleProfile,
894) -> Option<Value> {
895    let root_obj = settings.as_object_mut()?;
896    let hooks_entry = root_obj
897        .entry("hooks")
898        .or_insert_with(|| Value::Object(Default::default()));
899    let hooks_obj = hooks_entry.as_object_mut()?;
900    scrub_retired_task_completed_event(hooks_obj, profile);
901
902    for (event, hook_arg, matcher) in profile.claude_managed_events {
903        let event_entry = hooks_obj
904            .entry(*event)
905            .or_insert_with(|| Value::Array(Vec::new()));
906        let event_array = event_entry.as_array_mut()?;
907
908        let matcher_idx = event_array.iter().position(|entry| {
909            entry
910                .get("matcher")
911                .and_then(Value::as_str)
912                .map(|s| s == *matcher)
913                .unwrap_or(false)
914        });
915        let matcher_entry = match matcher_idx {
916            Some(idx) => &mut event_array[idx],
917            None => event_array.push_mut(json!({ "matcher": matcher, "hooks": [] })),
918        };
919
920        let hooks_inside = matcher_entry
921            .get_mut("hooks")
922            .and_then(Value::as_array_mut)?;
923        hooks_inside.retain(|entry| !profile.claude_entry_is_managed_or_legacy(entry));
924        hooks_inside.push(json!({
925            "type": "command",
926            "command": profile.claude_command(hook_arg),
927        }));
928    }
929
930    Some(settings)
931}
932
933fn scrub_retired_task_completed_event(
934    hooks_obj: &mut serde_json::Map<String, Value>,
935    profile: &LifecycleProfile,
936) {
937    let Some(task_completed) = hooks_obj.get_mut("TaskCompleted") else {
938        return;
939    };
940    let Some(event_array) = task_completed.as_array_mut() else {
941        return;
942    };
943
944    event_array.retain_mut(|entry| {
945        let Some(hooks_inside) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
946            return true;
947        };
948        hooks_inside.retain(|hook| !profile.claude_entry_is_managed_or_legacy(hook));
949        !hooks_inside.is_empty()
950    });
951
952    if event_array.is_empty() {
953        hooks_obj.remove("TaskCompleted");
954    }
955}
956
957/// Merge an existing serialized `.claude/settings.json` body into the
958/// CCD-managed shape (default [`CCD_COMPAT_PROFILE`]). See
959/// [`merge_claude_settings_text_with_profile`] for the contract.
960pub fn merge_claude_settings_text(
961    existing: Option<&str>,
962    force: bool,
963) -> Result<Option<MergedFile>, HostAssetError> {
964    merge_claude_settings_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
965}
966
967/// Merge an existing serialized `.claude/settings.json` body into
968/// `profile`'s managed shape. Returns:
969/// * `Ok(Some(merged))` on success;
970/// * `Ok(None)` when the existing body is malformed and `force` was
971///   not requested (caller should warn and skip);
972/// * `Err(_)` for parse errors that `force` cannot recover from
973///   without reset.
974///
975/// When `force` is true, parse errors and shape mismatches fall back
976/// to a fresh merge against an empty object.
977pub fn merge_claude_settings_text_with_profile(
978    existing: Option<&str>,
979    force: bool,
980    profile: &LifecycleProfile,
981) -> Result<Option<MergedFile>, HostAssetError> {
982    let parsed = match existing {
983        None => Value::Object(Default::default()),
984        Some(body) => match serde_json::from_str::<Value>(body) {
985            Ok(v) => v,
986            Err(_) if force => Value::Object(Default::default()),
987            Err(_) => return Ok(None),
988        },
989    };
990
991    let root = if parsed.is_object() {
992        parsed
993    } else if force {
994        Value::Object(Default::default())
995    } else {
996        return Ok(None);
997    };
998
999    let merged = match merge_claude_settings_with_profile(root, profile) {
1000        Some(v) => v,
1001        None if force => {
1002            merge_claude_settings_with_profile(Value::Object(Default::default()), profile)
1003                .expect("empty object is always a valid base")
1004        }
1005        None => return Ok(None),
1006    };
1007    let rendered =
1008        serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
1009            reason: err.to_string(),
1010        })?;
1011    Ok(Some(MergedFile {
1012        existing: existing.map(str::to_owned),
1013        rendered,
1014    }))
1015}
1016
1017// ============================================================================
1018// Merge: .codex/hooks.json
1019// ============================================================================
1020
1021/// Additive merge of CCD-managed lifecycle hooks into a Codex
1022/// `hooks.json` value (default [`CCD_COMPAT_PROFILE`]). See
1023/// [`merge_codex_hooks_with_profile`] for the algorithm.
1024pub fn merge_codex_hooks(hooks_doc: Value) -> Option<Value> {
1025    merge_codex_hooks_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
1026}
1027
1028/// Additive merge of `profile`'s managed lifecycle hooks into a Codex
1029/// `hooks.json` value. See [`merge_claude_settings_with_profile`] for
1030/// the algorithm; the only differences are the managed event table
1031/// and the per-entry `timeout`/`statusMessage` fields Codex carries.
1032pub fn merge_codex_hooks_with_profile(
1033    mut hooks_doc: Value,
1034    profile: &LifecycleProfile,
1035) -> Option<Value> {
1036    let hooks_entry = hooks_doc
1037        .as_object_mut()?
1038        .entry("hooks")
1039        .or_insert_with(|| Value::Object(Default::default()));
1040    let hooks_obj = hooks_entry.as_object_mut()?;
1041
1042    for (event, hook_arg, matcher, status_message) in profile.codex_managed_events {
1043        let event_entry = hooks_obj
1044            .entry(*event)
1045            .or_insert_with(|| Value::Array(Vec::new()));
1046        let event_array = event_entry.as_array_mut()?;
1047
1048        let matcher_idx = event_array.iter().position(|entry| {
1049            entry
1050                .get("matcher")
1051                .and_then(Value::as_str)
1052                .map(|value| value == *matcher)
1053                .unwrap_or(false)
1054        });
1055        let matcher_entry = match matcher_idx {
1056            Some(idx) => &mut event_array[idx],
1057            None => event_array.push_mut(json!({ "matcher": matcher, "hooks": [] })),
1058        };
1059
1060        let hooks_inside = matcher_entry
1061            .get_mut("hooks")
1062            .and_then(Value::as_array_mut)?;
1063        hooks_inside.retain(|entry| !profile.codex_entry_is_managed(entry));
1064        hooks_inside.push(json!({
1065            "type": "command",
1066            "command": profile.codex_command(hook_arg),
1067            "timeout": 30,
1068            "statusMessage": status_message,
1069        }));
1070    }
1071
1072    Some(hooks_doc)
1073}
1074
1075/// True when `hooks_doc` already contains the full CCD-managed Codex
1076/// lifecycle hook set (default [`CCD_COMPAT_PROFILE`]). Used by status
1077/// reporting to detect "Codex hooks installed externally" vs.
1078/// "needs apply".
1079pub fn codex_hooks_contain_managed_lifecycle(hooks_doc: &Value) -> bool {
1080    codex_hooks_contain_managed_lifecycle_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
1081}
1082
1083/// True when `hooks_doc` already contains the full set of `profile`'s
1084/// managed Codex lifecycle hooks.
1085pub fn codex_hooks_contain_managed_lifecycle_with_profile(
1086    hooks_doc: &Value,
1087    profile: &LifecycleProfile,
1088) -> bool {
1089    profile
1090        .codex_managed_events
1091        .iter()
1092        .all(|(event, hook_arg, _, _)| {
1093            codex_event_contains_managed_hook(hooks_doc, event, hook_arg, profile)
1094        })
1095}
1096
1097fn codex_event_contains_managed_hook(
1098    hooks_doc: &Value,
1099    event: &str,
1100    hook_arg: &str,
1101    profile: &LifecycleProfile,
1102) -> bool {
1103    let expected = profile.codex_command(hook_arg);
1104    hooks_doc
1105        .get("hooks")
1106        .and_then(|hooks| hooks.get(event))
1107        .and_then(Value::as_array)
1108        .into_iter()
1109        .flatten()
1110        .filter_map(|matcher| matcher.get("hooks").and_then(Value::as_array))
1111        .flatten()
1112        .any(|hook| {
1113            hook.get("command")
1114                .and_then(Value::as_str)
1115                .map(|command| {
1116                    command == expected
1117                        || (command.starts_with(profile.codex_command_prefix)
1118                            && command.contains(hook_arg))
1119                })
1120                .unwrap_or(false)
1121        })
1122}
1123
1124/// Merge an existing serialized `.codex/hooks.json` body using the
1125/// default [`CCD_COMPAT_PROFILE`]. See
1126/// [`merge_codex_hooks_text_with_profile`] for the contract.
1127pub fn merge_codex_hooks_text(
1128    existing: Option<&str>,
1129    force: bool,
1130) -> Result<Option<MergedFile>, HostAssetError> {
1131    merge_codex_hooks_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
1132}
1133
1134/// Merge an existing serialized `.codex/hooks.json` body using
1135/// `profile`. Returns `Ok(None)` when the existing body is invalid
1136/// JSON or has an incompatible top-level shape; `_force` is reserved
1137/// for symmetry with [`merge_claude_settings_text_with_profile`] and
1138/// currently behaves the same regardless of value (the previous CCD
1139/// implementation never branched on it).
1140pub fn merge_codex_hooks_text_with_profile(
1141    existing: Option<&str>,
1142    _force: bool,
1143    profile: &LifecycleProfile,
1144) -> Result<Option<MergedFile>, HostAssetError> {
1145    let parsed = match existing {
1146        None => Value::Object(Default::default()),
1147        Some(body) => match serde_json::from_str::<Value>(body) {
1148            Ok(value) => value,
1149            Err(_) => return Ok(None),
1150        },
1151    };
1152
1153    let root = if parsed.is_object() {
1154        parsed
1155    } else {
1156        return Ok(None);
1157    };
1158
1159    let merged = match merge_codex_hooks_with_profile(root, profile) {
1160        Some(value) => value,
1161        None => return Ok(None),
1162    };
1163    let rendered =
1164        serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
1165            reason: err.to_string(),
1166        })?;
1167    Ok(Some(MergedFile {
1168        existing: existing.map(str::to_owned),
1169        rendered,
1170    }))
1171}
1172
1173// ============================================================================
1174// Merge: .codex/config.toml
1175// ============================================================================
1176
1177/// Merge `[features].hooks = true` into an existing Codex
1178/// `config.toml`, preserving every other key.
1179pub fn merge_codex_config_text(existing: Option<&str>) -> Result<MergedFile, HostAssetError> {
1180    let mut root = match existing {
1181        None => toml::Table::new(),
1182        Some(raw) if raw.trim().is_empty() => toml::Table::new(),
1183        Some(raw) => match raw.parse::<toml::Value>() {
1184            Ok(value) => value
1185                .as_table()
1186                .cloned()
1187                .ok_or_else(|| HostAssetError::Malformed {
1188                    reason: "codex config.toml must be a TOML table".into(),
1189                })?,
1190            Err(err) => {
1191                return Err(HostAssetError::Parse {
1192                    reason: err.to_string(),
1193                });
1194            }
1195        },
1196    };
1197
1198    let features_entry = root
1199        .entry("features".to_owned())
1200        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1201    let features = features_entry
1202        .as_table_mut()
1203        .ok_or_else(|| HostAssetError::Malformed {
1204            reason: "[features] must be a TOML table".into(),
1205        })?;
1206    features.insert("hooks".to_owned(), toml::Value::Boolean(true));
1207
1208    let rendered = toml::to_string_pretty(&root).map_err(|err| HostAssetError::Serialize {
1209        reason: err.to_string(),
1210    })?;
1211    Ok(MergedFile {
1212        existing: existing.map(str::to_owned),
1213        rendered,
1214    })
1215}
1216
1217/// True when the parsed TOML enables `[features].hooks`.
1218pub fn codex_hooks_feature_is_enabled(config: &toml::Value) -> bool {
1219    config
1220        .get("features")
1221        .and_then(|features| features.get("hooks"))
1222        .and_then(toml::Value::as_bool)
1223        == Some(true)
1224}
1225
1226// ============================================================================
1227// Asset status
1228// ============================================================================
1229
1230/// Status of an installed asset that uses byte-equal comparison (every
1231/// asset *except* the merge-aware Claude settings, Codex hooks, and
1232/// Codex config files).
1233///
1234/// `existing_content` and `existing_mode` describe the on-disk state:
1235/// `None` means the file does not exist. The caller is responsible for
1236/// reading them.
1237pub fn byte_equal_asset_status(
1238    asset: &RenderedAsset,
1239    existing_content: Option<&str>,
1240    existing_mode: Option<u32>,
1241) -> AssetStatus {
1242    let Some(content) = existing_content else {
1243        return AssetStatus::Missing;
1244    };
1245    if content != asset.contents {
1246        return AssetStatus::Drifted;
1247    }
1248    if let Some(expected) = asset.mode {
1249        match existing_mode {
1250            Some(actual) if actual == expected => {}
1251            _ => return AssetStatus::Drifted,
1252        }
1253    }
1254    AssetStatus::Present
1255}
1256
1257/// Status of `.claude/settings.json` against the rendered template
1258/// for the default [`CCD_COMPAT_PROFILE`]. `existing` is the file
1259/// body (`None` means missing). The merge is re-run and compared
1260/// byte-for-byte to detect drift.
1261pub fn claude_settings_status(existing: Option<&str>) -> AssetStatus {
1262    claude_settings_status_with_profile(existing, &CCD_COMPAT_PROFILE)
1263}
1264
1265/// Status of `.claude/settings.json` against the rendered template
1266/// for `profile`.
1267pub fn claude_settings_status_with_profile(
1268    existing: Option<&str>,
1269    profile: &LifecycleProfile,
1270) -> AssetStatus {
1271    let Some(content) = existing else {
1272        return AssetStatus::Missing;
1273    };
1274    match merge_claude_settings_text_with_profile(Some(content), false, profile) {
1275        Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
1276        Ok(_) | Err(_) => AssetStatus::Drifted,
1277    }
1278}
1279
1280/// Status of `.codex/hooks.json` against the rendered template for
1281/// the default [`CCD_COMPAT_PROFILE`].
1282pub fn codex_hooks_status(existing: Option<&str>) -> AssetStatus {
1283    codex_hooks_status_with_profile(existing, &CCD_COMPAT_PROFILE)
1284}
1285
1286/// Status of `.codex/hooks.json` against the rendered template for
1287/// `profile`.
1288pub fn codex_hooks_status_with_profile(
1289    existing: Option<&str>,
1290    profile: &LifecycleProfile,
1291) -> AssetStatus {
1292    let Some(content) = existing else {
1293        return AssetStatus::Missing;
1294    };
1295    match merge_codex_hooks_text_with_profile(Some(content), false, profile) {
1296        Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
1297        Ok(_) | Err(_) => AssetStatus::Drifted,
1298    }
1299}
1300
1301/// Status of `.codex/config.toml`. Returns `Present` only when the
1302/// `[features].hooks = true` flag is set; the rest of the file
1303/// is ignored because users are free to add their own config.
1304pub fn codex_config_status(existing: Option<&str>) -> AssetStatus {
1305    let Some(content) = existing else {
1306        return AssetStatus::Missing;
1307    };
1308    match content.parse::<toml::Value>() {
1309        Ok(parsed) if codex_hooks_feature_is_enabled(&parsed) => AssetStatus::Present,
1310        Ok(_) | Err(_) => AssetStatus::Drifted,
1311    }
1312}
1313
1314/// Compute status for one rendered asset by dispatching to the
1315/// appropriate per-path comparison. Falls back to byte-equal
1316/// comparison for any path that doesn't match a merge-aware target.
1317pub fn asset_status(
1318    asset: &RenderedAsset,
1319    existing_content: Option<&str>,
1320    existing_mode: Option<u32>,
1321) -> AssetStatus {
1322    match asset.relative_path {
1323        CLAUDE_TARGET_SETTINGS => claude_settings_status(existing_content),
1324        CODEX_TARGET_CONFIG => codex_config_status(existing_content),
1325        CODEX_TARGET_HOOKS => codex_hooks_status(existing_content),
1326        _ => byte_equal_asset_status(asset, existing_content, existing_mode),
1327    }
1328}
1329
1330/// Combine per-asset statuses into a single bundle status. `Drifted`
1331/// dominates `Missing` dominates `Present`; an empty input is
1332/// `NotApplicable`.
1333pub fn aggregate_status<I: IntoIterator<Item = AssetStatus>>(statuses: I) -> AssetStatus {
1334    let mut saw_missing = false;
1335    let mut saw_drift = false;
1336    let mut saw_any = false;
1337    for s in statuses {
1338        saw_any = true;
1339        match s {
1340            AssetStatus::Drifted => saw_drift = true,
1341            AssetStatus::Missing => saw_missing = true,
1342            AssetStatus::InvalidMode => return AssetStatus::InvalidMode,
1343            AssetStatus::Present | AssetStatus::NotApplicable => {}
1344        }
1345    }
1346    if !saw_any {
1347        return AssetStatus::NotApplicable;
1348    }
1349    if saw_drift {
1350        AssetStatus::Drifted
1351    } else if saw_missing {
1352        AssetStatus::Missing
1353    } else {
1354        AssetStatus::Present
1355    }
1356}