use serde_json::{Value, json};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum HostAdapter {
Claude,
Codex,
Hermes,
OpenClaw,
}
impl HostAdapter {
pub const ALL: &'static [Self] = &[Self::Claude, Self::Codex, Self::Hermes, Self::OpenClaw];
pub fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
Self::Hermes => "hermes",
Self::OpenClaw => "openclaw",
}
}
pub fn from_id(name: &str) -> Option<Self> {
match name {
"claude" | "claude-code" => Some(Self::Claude),
"codex" => Some(Self::Codex),
"hermes" => Some(Self::Hermes),
"openclaw" => Some(Self::OpenClaw),
_ => None,
}
}
pub fn default_mode(self) -> IntegrationMode {
match self {
Self::Claude => IntegrationMode::NativeHook,
Self::Codex => IntegrationMode::ManualSkill,
Self::Hermes | Self::OpenClaw => IntegrationMode::ReferenceAdapter,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum IntegrationMode {
ManualSkill,
LauncherWrapper,
NativeHook,
ReferenceAdapter,
}
impl IntegrationMode {
pub const ALL: &'static [Self] = &[
Self::ManualSkill,
Self::LauncherWrapper,
Self::NativeHook,
Self::ReferenceAdapter,
];
pub fn as_str(self) -> &'static str {
match self {
Self::ManualSkill => "manual_skill",
Self::LauncherWrapper => "launcher_wrapper",
Self::NativeHook => "native_hook",
Self::ReferenceAdapter => "reference_adapter",
}
}
pub fn from_id(value: &str) -> Option<Self> {
match value {
"manual_skill" => Some(Self::ManualSkill),
"launcher_wrapper" => Some(Self::LauncherWrapper),
"native_hook" => Some(Self::NativeHook),
"reference_adapter" => Some(Self::ReferenceAdapter),
_ => None,
}
}
pub fn from_lifecycle_mode(mode: crate::IntegrationMode) -> Option<Self> {
match mode {
crate::IntegrationMode::ManualSkill => Some(Self::ManualSkill),
crate::IntegrationMode::LauncherWrapper => Some(Self::LauncherWrapper),
crate::IntegrationMode::NativeHook => Some(Self::NativeHook),
crate::IntegrationMode::ReferenceAdapter => Some(Self::ReferenceAdapter),
crate::IntegrationMode::TelemetryOnly => None,
}
}
}
pub fn supports_mode(host: HostAdapter, mode: IntegrationMode) -> bool {
match host {
HostAdapter::Claude => mode == IntegrationMode::NativeHook,
HostAdapter::Codex => matches!(
mode,
IntegrationMode::ManualSkill
| IntegrationMode::LauncherWrapper
| IntegrationMode::NativeHook
),
HostAdapter::Hermes | HostAdapter::OpenClaw => mode == IntegrationMode::ReferenceAdapter,
}
}
pub fn supported_modes(host: HostAdapter) -> &'static [IntegrationMode] {
match host {
HostAdapter::Claude => &[IntegrationMode::NativeHook],
HostAdapter::Codex => &[
IntegrationMode::ManualSkill,
IntegrationMode::LauncherWrapper,
IntegrationMode::NativeHook,
],
HostAdapter::Hermes | HostAdapter::OpenClaw => &[IntegrationMode::ReferenceAdapter],
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderedAsset {
pub relative_path: &'static str,
pub contents: String,
pub mode: Option<u32>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AssetStatus {
Present,
Missing,
Drifted,
InvalidMode,
NotApplicable,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileAction {
Installed,
Updated,
AlreadyPresent,
}
pub fn combine_actions(current: FileAction, next: FileAction) -> FileAction {
match (current, next) {
(FileAction::Updated, _) | (_, FileAction::Updated) => FileAction::Updated,
(FileAction::Installed, _) | (_, FileAction::Installed) => FileAction::Installed,
_ => FileAction::AlreadyPresent,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MergedFile {
pub existing: Option<String>,
pub rendered: String,
}
#[derive(Debug)]
pub enum HostAssetError {
UnsupportedMode {
host: HostAdapter,
mode: IntegrationMode,
},
Malformed { reason: String },
Parse { reason: String },
Serialize { reason: String },
}
impl std::fmt::Display for HostAssetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedMode { host, mode } => write!(
f,
"unsupported mode `{}` for {} (supported: {})",
mode.as_str(),
host.as_str(),
supported_modes(*host)
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(", ")
),
Self::Malformed { reason } => write!(f, "malformed managed file: {reason}"),
Self::Parse { reason } => write!(f, "parse error: {reason}"),
Self::Serialize { reason } => write!(f, "serialize error: {reason}"),
}
}
}
impl std::error::Error for HostAssetError {}
pub const CLAUDE_SOURCE_SETTINGS: &str = ".ccd-hosts/claude/settings.json";
pub const CLAUDE_TARGET_SETTINGS: &str = ".claude/settings.json";
pub const CODEX_SOURCE_README: &str = ".ccd-hosts/codex/README.md";
pub const CODEX_SOURCE_LAUNCHER: &str = ".ccd-hosts/codex/launcher.sh";
pub const CODEX_SOURCE_CONFIG: &str = ".ccd-hosts/codex/config.toml";
pub const CODEX_SOURCE_HOOKS: &str = ".ccd-hosts/codex/hooks.json";
pub const CODEX_TARGET_LAUNCHER: &str = ".codex/ccd-launch.sh";
pub const CODEX_TARGET_CONFIG: &str = ".codex/config.toml";
pub const CODEX_TARGET_HOOKS: &str = ".codex/hooks.json";
pub const OPENCLAW_SOURCE_ADAPTER: &str = ".ccd-hosts/openclaw/adapter.json";
pub const OPENCLAW_TARGET_ADAPTER: &str = ".openclaw/ccd.json";
pub const HERMES_SOURCE_ADAPTER: &str = ".ccd-hosts/hermes/adapter.json";
pub const HERMES_TARGET_ADAPTER: &str = ".hermes/ccd.json";
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct LifecycleProfile {
pub id: &'static str,
pub claude_command_prefix: &'static str,
pub claude_legacy_substrings: &'static [&'static str],
pub claude_managed_events: &'static [(&'static str, &'static str, &'static str)],
pub codex_command_prefix: &'static str,
pub codex_managed_events: &'static [(&'static str, &'static str, &'static str, &'static str)],
}
impl LifecycleProfile {
pub fn claude_command(&self, hook_arg: &str) -> String {
format!("{}{}", self.claude_command_prefix, hook_arg)
}
pub fn codex_command(&self, hook_arg: &str) -> String {
format!("{}{}", self.codex_command_prefix, hook_arg)
}
fn claude_entry_is_managed_or_legacy(&self, entry: &Value) -> bool {
let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
cmd.starts_with(self.claude_command_prefix)
|| self
.claude_legacy_substrings
.iter()
.any(|legacy| cmd.contains(legacy))
}
fn codex_entry_is_managed(&self, entry: &Value) -> bool {
entry
.get("command")
.and_then(Value::as_str)
.map(|cmd| cmd.starts_with(self.codex_command_prefix))
.unwrap_or(false)
}
}
const STANDARD_CLAUDE_MANAGED_EVENTS: &[(&str, &str, &str)] = &[
(
"SessionStart",
"on-session-start",
"startup|resume|clear|compact",
),
("UserPromptSubmit", "before-prompt-build", "*"),
("PreCompact", "on-compaction-notice", "*"),
("Stop", "on-agent-end", "*"),
("SessionEnd", "on-session-end", "*"),
];
const STANDARD_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
(
"SessionStart",
"on-session-start",
"startup|resume|clear",
"Loading CCD session context",
),
(
"UserPromptSubmit",
"before-prompt-build",
"*",
"Refreshing CCD prompt context",
),
(
"PreCompact",
"on-compaction-notice",
"*",
"Recording CCD compaction boundary",
),
(
"PostCompact",
"on-compaction-notice",
"*",
"Recording CCD compacted context boundary",
),
(
"Stop",
"on-agent-end",
"*",
"Checking CCD continuation boundary",
),
];
const LIFELOOP_DIRECT_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
(
"SessionStart",
"on-session-start",
"startup|resume|clear",
"Loading Lifeloop session context",
),
(
"UserPromptSubmit",
"before-prompt-build",
"*",
"Refreshing Lifeloop prompt context",
),
(
"Stop",
"on-agent-end",
"*",
"Checking Lifeloop continuation boundary",
),
];
pub const CCD_COMPAT_PROFILE: LifecycleProfile = LifecycleProfile {
id: "ccd-compat",
claude_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
claude_legacy_substrings: &["ccd-hook.py"],
claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
codex_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ",
codex_managed_events: STANDARD_CODEX_MANAGED_EVENTS,
};
pub const LIFELOOP_DIRECT_PROFILE: LifecycleProfile = LifecycleProfile {
id: "lifeloop-direct",
claude_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
claude_legacy_substrings: &[CCD_COMPAT_PROFILE.claude_command_prefix, "ccd-hook.py"],
claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
codex_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ",
codex_managed_events: LIFELOOP_DIRECT_CODEX_MANAGED_EVENTS,
};
pub const CCD_COMPAT_CLAUDE_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.claude_command_prefix;
pub const CCD_COMPAT_CODEX_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.codex_command_prefix;
pub const CCD_COMPAT_CLAUDE_LEGACY_PYTHON_HOOK: &str = "ccd-hook.py";
pub fn ccd_compat_claude_command(hook_arg: &str) -> String {
CCD_COMPAT_PROFILE.claude_command(hook_arg)
}
pub fn ccd_compat_codex_command(hook_arg: &str) -> String {
CCD_COMPAT_PROFILE.codex_command(hook_arg)
}
pub fn render_source_assets(host: HostAdapter) -> Vec<RenderedAsset> {
render_source_assets_with_profile(host, &CCD_COMPAT_PROFILE)
}
pub fn render_source_assets_with_profile(
host: HostAdapter,
profile: &LifecycleProfile,
) -> Vec<RenderedAsset> {
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,
}],
}
}
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()
}
pub fn render_applied_assets(host: HostAdapter, mode: IntegrationMode) -> Vec<RenderedAsset> {
render_applied_assets_with_profile(host, mode, &CCD_COMPAT_PROFILE)
}
pub fn render_applied_assets_with_profile(
host: HostAdapter,
mode: IntegrationMode,
profile: &LifecycleProfile,
) -> Vec<RenderedAsset> {
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(),
}
}
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")
}
pub fn merge_claude_settings(settings: Value) -> Option<Value> {
merge_claude_settings_with_profile(settings, &CCD_COMPAT_PROFILE)
}
pub fn merge_claude_settings_with_profile(
mut settings: Value,
profile: &LifecycleProfile,
) -> Option<Value> {
let root_obj = settings.as_object_mut()?;
let hooks_entry = root_obj
.entry("hooks")
.or_insert_with(|| Value::Object(Default::default()));
let hooks_obj = hooks_entry.as_object_mut()?;
scrub_retired_task_completed_event(hooks_obj, profile);
for (event, hook_arg, matcher) in profile.claude_managed_events {
let event_entry = hooks_obj
.entry(*event)
.or_insert_with(|| Value::Array(Vec::new()));
let event_array = event_entry.as_array_mut()?;
let matcher_idx = event_array.iter().position(|entry| {
entry
.get("matcher")
.and_then(Value::as_str)
.map(|s| s == *matcher)
.unwrap_or(false)
});
let matcher_entry = match matcher_idx {
Some(idx) => &mut event_array[idx],
None => {
event_array.push(json!({ "matcher": matcher, "hooks": [] }));
event_array.last_mut().unwrap()
}
};
let hooks_inside = matcher_entry
.get_mut("hooks")
.and_then(Value::as_array_mut)?;
hooks_inside.retain(|entry| !profile.claude_entry_is_managed_or_legacy(entry));
hooks_inside.push(json!({
"type": "command",
"command": profile.claude_command(hook_arg),
}));
}
Some(settings)
}
fn scrub_retired_task_completed_event(
hooks_obj: &mut serde_json::Map<String, Value>,
profile: &LifecycleProfile,
) {
let Some(task_completed) = hooks_obj.get_mut("TaskCompleted") else {
return;
};
let Some(event_array) = task_completed.as_array_mut() else {
return;
};
event_array.retain_mut(|entry| {
let Some(hooks_inside) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
return true;
};
hooks_inside.retain(|hook| !profile.claude_entry_is_managed_or_legacy(hook));
!hooks_inside.is_empty()
});
if event_array.is_empty() {
hooks_obj.remove("TaskCompleted");
}
}
pub fn merge_claude_settings_text(
existing: Option<&str>,
force: bool,
) -> Result<Option<MergedFile>, HostAssetError> {
merge_claude_settings_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
}
pub fn merge_claude_settings_text_with_profile(
existing: Option<&str>,
force: bool,
profile: &LifecycleProfile,
) -> Result<Option<MergedFile>, HostAssetError> {
let parsed = match existing {
None => Value::Object(Default::default()),
Some(body) => match serde_json::from_str::<Value>(body) {
Ok(v) => v,
Err(_) if force => Value::Object(Default::default()),
Err(_) => return Ok(None),
},
};
let root = if parsed.is_object() {
parsed
} else if force {
Value::Object(Default::default())
} else {
return Ok(None);
};
let merged = match merge_claude_settings_with_profile(root, profile) {
Some(v) => v,
None if force => {
merge_claude_settings_with_profile(Value::Object(Default::default()), profile)
.expect("empty object is always a valid base")
}
None => return Ok(None),
};
let rendered =
serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
reason: err.to_string(),
})?;
Ok(Some(MergedFile {
existing: existing.map(str::to_owned),
rendered,
}))
}
pub fn merge_codex_hooks(hooks_doc: Value) -> Option<Value> {
merge_codex_hooks_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
}
pub fn merge_codex_hooks_with_profile(
mut hooks_doc: Value,
profile: &LifecycleProfile,
) -> Option<Value> {
let hooks_entry = hooks_doc
.as_object_mut()?
.entry("hooks")
.or_insert_with(|| Value::Object(Default::default()));
let hooks_obj = hooks_entry.as_object_mut()?;
for (event, hook_arg, matcher, status_message) in profile.codex_managed_events {
let event_entry = hooks_obj
.entry(*event)
.or_insert_with(|| Value::Array(Vec::new()));
let event_array = event_entry.as_array_mut()?;
let matcher_idx = event_array.iter().position(|entry| {
entry
.get("matcher")
.and_then(Value::as_str)
.map(|value| value == *matcher)
.unwrap_or(false)
});
let matcher_entry = match matcher_idx {
Some(idx) => &mut event_array[idx],
None => {
event_array.push(json!({ "matcher": matcher, "hooks": [] }));
event_array.last_mut().unwrap()
}
};
let hooks_inside = matcher_entry
.get_mut("hooks")
.and_then(Value::as_array_mut)?;
hooks_inside.retain(|entry| !profile.codex_entry_is_managed(entry));
hooks_inside.push(json!({
"type": "command",
"command": profile.codex_command(hook_arg),
"timeout": 30,
"statusMessage": status_message,
}));
}
Some(hooks_doc)
}
pub fn codex_hooks_contain_managed_lifecycle(hooks_doc: &Value) -> bool {
codex_hooks_contain_managed_lifecycle_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
}
pub fn codex_hooks_contain_managed_lifecycle_with_profile(
hooks_doc: &Value,
profile: &LifecycleProfile,
) -> bool {
profile
.codex_managed_events
.iter()
.all(|(event, hook_arg, _, _)| {
codex_event_contains_managed_hook(hooks_doc, event, hook_arg, profile)
})
}
fn codex_event_contains_managed_hook(
hooks_doc: &Value,
event: &str,
hook_arg: &str,
profile: &LifecycleProfile,
) -> bool {
let expected = profile.codex_command(hook_arg);
hooks_doc
.get("hooks")
.and_then(|hooks| hooks.get(event))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|matcher| matcher.get("hooks").and_then(Value::as_array))
.flatten()
.any(|hook| {
hook.get("command")
.and_then(Value::as_str)
.map(|command| {
command == expected
|| (command.starts_with(profile.codex_command_prefix)
&& command.contains(hook_arg))
})
.unwrap_or(false)
})
}
pub fn merge_codex_hooks_text(
existing: Option<&str>,
force: bool,
) -> Result<Option<MergedFile>, HostAssetError> {
merge_codex_hooks_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
}
pub fn merge_codex_hooks_text_with_profile(
existing: Option<&str>,
_force: bool,
profile: &LifecycleProfile,
) -> Result<Option<MergedFile>, HostAssetError> {
let parsed = match existing {
None => Value::Object(Default::default()),
Some(body) => match serde_json::from_str::<Value>(body) {
Ok(value) => value,
Err(_) => return Ok(None),
},
};
let root = if parsed.is_object() {
parsed
} else {
return Ok(None);
};
let merged = match merge_codex_hooks_with_profile(root, profile) {
Some(value) => value,
None => return Ok(None),
};
let rendered =
serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
reason: err.to_string(),
})?;
Ok(Some(MergedFile {
existing: existing.map(str::to_owned),
rendered,
}))
}
pub fn merge_codex_config_text(existing: Option<&str>) -> Result<MergedFile, HostAssetError> {
let mut root = match existing {
None => toml::Table::new(),
Some(raw) if raw.trim().is_empty() => toml::Table::new(),
Some(raw) => match raw.parse::<toml::Value>() {
Ok(value) => value
.as_table()
.cloned()
.ok_or_else(|| HostAssetError::Malformed {
reason: "codex config.toml must be a TOML table".into(),
})?,
Err(err) => {
return Err(HostAssetError::Parse {
reason: err.to_string(),
});
}
},
};
let features_entry = root
.entry("features".to_owned())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
let features = features_entry
.as_table_mut()
.ok_or_else(|| HostAssetError::Malformed {
reason: "[features] must be a TOML table".into(),
})?;
features.insert("hooks".to_owned(), toml::Value::Boolean(true));
let rendered = toml::to_string_pretty(&root).map_err(|err| HostAssetError::Serialize {
reason: err.to_string(),
})?;
Ok(MergedFile {
existing: existing.map(str::to_owned),
rendered,
})
}
pub fn codex_hooks_feature_is_enabled(config: &toml::Value) -> bool {
config
.get("features")
.and_then(|features| features.get("hooks"))
.and_then(toml::Value::as_bool)
== Some(true)
}
pub fn byte_equal_asset_status(
asset: &RenderedAsset,
existing_content: Option<&str>,
existing_mode: Option<u32>,
) -> AssetStatus {
let Some(content) = existing_content else {
return AssetStatus::Missing;
};
if content != asset.contents {
return AssetStatus::Drifted;
}
if let Some(expected) = asset.mode {
match existing_mode {
Some(actual) if actual == expected => {}
_ => return AssetStatus::Drifted,
}
}
AssetStatus::Present
}
pub fn claude_settings_status(existing: Option<&str>) -> AssetStatus {
claude_settings_status_with_profile(existing, &CCD_COMPAT_PROFILE)
}
pub fn claude_settings_status_with_profile(
existing: Option<&str>,
profile: &LifecycleProfile,
) -> AssetStatus {
let Some(content) = existing else {
return AssetStatus::Missing;
};
match merge_claude_settings_text_with_profile(Some(content), false, profile) {
Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
Ok(_) | Err(_) => AssetStatus::Drifted,
}
}
pub fn codex_hooks_status(existing: Option<&str>) -> AssetStatus {
codex_hooks_status_with_profile(existing, &CCD_COMPAT_PROFILE)
}
pub fn codex_hooks_status_with_profile(
existing: Option<&str>,
profile: &LifecycleProfile,
) -> AssetStatus {
let Some(content) = existing else {
return AssetStatus::Missing;
};
match merge_codex_hooks_text_with_profile(Some(content), false, profile) {
Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
Ok(_) | Err(_) => AssetStatus::Drifted,
}
}
pub fn codex_config_status(existing: Option<&str>) -> AssetStatus {
let Some(content) = existing else {
return AssetStatus::Missing;
};
match content.parse::<toml::Value>() {
Ok(parsed) if codex_hooks_feature_is_enabled(&parsed) => AssetStatus::Present,
Ok(_) | Err(_) => AssetStatus::Drifted,
}
}
pub fn asset_status(
asset: &RenderedAsset,
existing_content: Option<&str>,
existing_mode: Option<u32>,
) -> AssetStatus {
match asset.relative_path {
CLAUDE_TARGET_SETTINGS => claude_settings_status(existing_content),
CODEX_TARGET_CONFIG => codex_config_status(existing_content),
CODEX_TARGET_HOOKS => codex_hooks_status(existing_content),
_ => byte_equal_asset_status(asset, existing_content, existing_mode),
}
}
pub fn aggregate_status<I: IntoIterator<Item = AssetStatus>>(statuses: I) -> AssetStatus {
let mut saw_missing = false;
let mut saw_drift = false;
let mut saw_any = false;
for s in statuses {
saw_any = true;
match s {
AssetStatus::Drifted => saw_drift = true,
AssetStatus::Missing => saw_missing = true,
AssetStatus::InvalidMode => return AssetStatus::InvalidMode,
AssetStatus::Present | AssetStatus::NotApplicable => {}
}
}
if !saw_any {
return AssetStatus::NotApplicable;
}
if saw_drift {
AssetStatus::Drifted
} else if saw_missing {
AssetStatus::Missing
} else {
AssetStatus::Present
}
}