1use serde_json::{Value, json};
54
55#[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 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 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#[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 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
160pub 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
175pub 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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub enum AssetStatus {
208 Present,
210 Missing,
212 Drifted,
216 InvalidMode,
218 NotApplicable,
220}
221
222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
224pub enum FileAction {
225 Installed,
227 Updated,
229 AlreadyPresent,
231}
232
233pub 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#[derive(Clone, Debug, PartialEq, Eq)]
249pub struct MergedFile {
250 pub existing: Option<String>,
251 pub rendered: String,
252}
253
254#[derive(Debug)]
257pub enum HostAssetError {
258 UnsupportedMode {
260 host: HostAdapter,
261 mode: IntegrationMode,
262 },
263 Malformed { reason: String },
266 Parse { reason: String },
268 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
296pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
339pub struct LifecycleProfile {
340 pub id: &'static str,
344 pub claude_command_prefix: &'static str,
349 pub claude_legacy_substrings: &'static [&'static str],
355 pub claude_managed_events: &'static [(&'static str, &'static str, &'static str)],
358 pub codex_command_prefix: &'static str,
362 pub codex_managed_events: &'static [(&'static str, &'static str, &'static str, &'static str)],
365}
366
367impl LifecycleProfile {
368 pub fn claude_command(&self, hook_arg: &str) -> String {
371 format!("{}{}", self.claude_command_prefix, hook_arg)
372 }
373
374 pub fn codex_command(&self, hook_arg: &str) -> String {
377 format!("{}{}", self.codex_command_prefix, hook_arg)
378 }
379
380 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 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
404const 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
431const 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
467const 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
492pub 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
509pub 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 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
537pub const CCD_COMPAT_CLAUDE_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.claude_command_prefix;
551
552pub const CCD_COMPAT_CODEX_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.codex_command_prefix;
556
557pub const CCD_COMPAT_CLAUDE_LEGACY_PYTHON_HOOK: &str = "ccd-hook.py";
561
562pub fn ccd_compat_claude_command(hook_arg: &str) -> String {
564 CCD_COMPAT_PROFILE.claude_command(hook_arg)
565}
566
567pub fn ccd_compat_codex_command(hook_arg: &str) -> String {
569 CCD_COMPAT_PROFILE.codex_command(hook_arg)
570}
571
572pub fn render_source_assets(host: HostAdapter) -> Vec<RenderedAsset> {
580 render_source_assets_with_profile(host, &CCD_COMPAT_PROFILE)
581}
582
583pub 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
632pub 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
657pub fn render_applied_assets(host: HostAdapter, mode: IntegrationMode) -> Vec<RenderedAsset> {
666 render_applied_assets_with_profile(host, mode, &CCD_COMPAT_PROFILE)
667}
668
669pub 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
720fn 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
855pub fn merge_claude_settings(settings: Value) -> Option<Value> {
866 merge_claude_settings_with_profile(settings, &CCD_COMPAT_PROFILE)
867}
868
869pub 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 => {
918 event_array.push(json!({ "matcher": matcher, "hooks": [] }));
919 event_array.last_mut().unwrap()
920 }
921 };
922
923 let hooks_inside = matcher_entry
924 .get_mut("hooks")
925 .and_then(Value::as_array_mut)?;
926 hooks_inside.retain(|entry| !profile.claude_entry_is_managed_or_legacy(entry));
927 hooks_inside.push(json!({
928 "type": "command",
929 "command": profile.claude_command(hook_arg),
930 }));
931 }
932
933 Some(settings)
934}
935
936fn scrub_retired_task_completed_event(
937 hooks_obj: &mut serde_json::Map<String, Value>,
938 profile: &LifecycleProfile,
939) {
940 let Some(task_completed) = hooks_obj.get_mut("TaskCompleted") else {
941 return;
942 };
943 let Some(event_array) = task_completed.as_array_mut() else {
944 return;
945 };
946
947 event_array.retain_mut(|entry| {
948 let Some(hooks_inside) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
949 return true;
950 };
951 hooks_inside.retain(|hook| !profile.claude_entry_is_managed_or_legacy(hook));
952 !hooks_inside.is_empty()
953 });
954
955 if event_array.is_empty() {
956 hooks_obj.remove("TaskCompleted");
957 }
958}
959
960pub fn merge_claude_settings_text(
964 existing: Option<&str>,
965 force: bool,
966) -> Result<Option<MergedFile>, HostAssetError> {
967 merge_claude_settings_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
968}
969
970pub fn merge_claude_settings_text_with_profile(
981 existing: Option<&str>,
982 force: bool,
983 profile: &LifecycleProfile,
984) -> Result<Option<MergedFile>, HostAssetError> {
985 let parsed = match existing {
986 None => Value::Object(Default::default()),
987 Some(body) => match serde_json::from_str::<Value>(body) {
988 Ok(v) => v,
989 Err(_) if force => Value::Object(Default::default()),
990 Err(_) => return Ok(None),
991 },
992 };
993
994 let root = if parsed.is_object() {
995 parsed
996 } else if force {
997 Value::Object(Default::default())
998 } else {
999 return Ok(None);
1000 };
1001
1002 let merged = match merge_claude_settings_with_profile(root, profile) {
1003 Some(v) => v,
1004 None if force => {
1005 merge_claude_settings_with_profile(Value::Object(Default::default()), profile)
1006 .expect("empty object is always a valid base")
1007 }
1008 None => return Ok(None),
1009 };
1010 let rendered =
1011 serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
1012 reason: err.to_string(),
1013 })?;
1014 Ok(Some(MergedFile {
1015 existing: existing.map(str::to_owned),
1016 rendered,
1017 }))
1018}
1019
1020pub fn merge_codex_hooks(hooks_doc: Value) -> Option<Value> {
1028 merge_codex_hooks_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
1029}
1030
1031pub fn merge_codex_hooks_with_profile(
1036 mut hooks_doc: Value,
1037 profile: &LifecycleProfile,
1038) -> Option<Value> {
1039 let hooks_entry = hooks_doc
1040 .as_object_mut()?
1041 .entry("hooks")
1042 .or_insert_with(|| Value::Object(Default::default()));
1043 let hooks_obj = hooks_entry.as_object_mut()?;
1044
1045 for (event, hook_arg, matcher, status_message) in profile.codex_managed_events {
1046 let event_entry = hooks_obj
1047 .entry(*event)
1048 .or_insert_with(|| Value::Array(Vec::new()));
1049 let event_array = event_entry.as_array_mut()?;
1050
1051 let matcher_idx = event_array.iter().position(|entry| {
1052 entry
1053 .get("matcher")
1054 .and_then(Value::as_str)
1055 .map(|value| value == *matcher)
1056 .unwrap_or(false)
1057 });
1058 let matcher_entry = match matcher_idx {
1059 Some(idx) => &mut event_array[idx],
1060 None => {
1061 event_array.push(json!({ "matcher": matcher, "hooks": [] }));
1062 event_array.last_mut().unwrap()
1063 }
1064 };
1065
1066 let hooks_inside = matcher_entry
1067 .get_mut("hooks")
1068 .and_then(Value::as_array_mut)?;
1069 hooks_inside.retain(|entry| !profile.codex_entry_is_managed(entry));
1070 hooks_inside.push(json!({
1071 "type": "command",
1072 "command": profile.codex_command(hook_arg),
1073 "timeout": 30,
1074 "statusMessage": status_message,
1075 }));
1076 }
1077
1078 Some(hooks_doc)
1079}
1080
1081pub fn codex_hooks_contain_managed_lifecycle(hooks_doc: &Value) -> bool {
1086 codex_hooks_contain_managed_lifecycle_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
1087}
1088
1089pub fn codex_hooks_contain_managed_lifecycle_with_profile(
1092 hooks_doc: &Value,
1093 profile: &LifecycleProfile,
1094) -> bool {
1095 profile
1096 .codex_managed_events
1097 .iter()
1098 .all(|(event, hook_arg, _, _)| {
1099 codex_event_contains_managed_hook(hooks_doc, event, hook_arg, profile)
1100 })
1101}
1102
1103fn codex_event_contains_managed_hook(
1104 hooks_doc: &Value,
1105 event: &str,
1106 hook_arg: &str,
1107 profile: &LifecycleProfile,
1108) -> bool {
1109 let expected = profile.codex_command(hook_arg);
1110 hooks_doc
1111 .get("hooks")
1112 .and_then(|hooks| hooks.get(event))
1113 .and_then(Value::as_array)
1114 .into_iter()
1115 .flatten()
1116 .filter_map(|matcher| matcher.get("hooks").and_then(Value::as_array))
1117 .flatten()
1118 .any(|hook| {
1119 hook.get("command")
1120 .and_then(Value::as_str)
1121 .map(|command| {
1122 command == expected
1123 || (command.starts_with(profile.codex_command_prefix)
1124 && command.contains(hook_arg))
1125 })
1126 .unwrap_or(false)
1127 })
1128}
1129
1130pub fn merge_codex_hooks_text(
1134 existing: Option<&str>,
1135 force: bool,
1136) -> Result<Option<MergedFile>, HostAssetError> {
1137 merge_codex_hooks_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
1138}
1139
1140pub fn merge_codex_hooks_text_with_profile(
1147 existing: Option<&str>,
1148 _force: bool,
1149 profile: &LifecycleProfile,
1150) -> Result<Option<MergedFile>, HostAssetError> {
1151 let parsed = match existing {
1152 None => Value::Object(Default::default()),
1153 Some(body) => match serde_json::from_str::<Value>(body) {
1154 Ok(value) => value,
1155 Err(_) => return Ok(None),
1156 },
1157 };
1158
1159 let root = if parsed.is_object() {
1160 parsed
1161 } else {
1162 return Ok(None);
1163 };
1164
1165 let merged = match merge_codex_hooks_with_profile(root, profile) {
1166 Some(value) => value,
1167 None => return Ok(None),
1168 };
1169 let rendered =
1170 serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
1171 reason: err.to_string(),
1172 })?;
1173 Ok(Some(MergedFile {
1174 existing: existing.map(str::to_owned),
1175 rendered,
1176 }))
1177}
1178
1179pub fn merge_codex_config_text(existing: Option<&str>) -> Result<MergedFile, HostAssetError> {
1186 let mut root = match existing {
1187 None => toml::Table::new(),
1188 Some(raw) if raw.trim().is_empty() => toml::Table::new(),
1189 Some(raw) => match raw.parse::<toml::Value>() {
1190 Ok(value) => value
1191 .as_table()
1192 .cloned()
1193 .ok_or_else(|| HostAssetError::Malformed {
1194 reason: "codex config.toml must be a TOML table".into(),
1195 })?,
1196 Err(err) => {
1197 return Err(HostAssetError::Parse {
1198 reason: err.to_string(),
1199 });
1200 }
1201 },
1202 };
1203
1204 let features_entry = root
1205 .entry("features".to_owned())
1206 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1207 let features = features_entry
1208 .as_table_mut()
1209 .ok_or_else(|| HostAssetError::Malformed {
1210 reason: "[features] must be a TOML table".into(),
1211 })?;
1212 features.insert("hooks".to_owned(), toml::Value::Boolean(true));
1213
1214 let rendered = toml::to_string_pretty(&root).map_err(|err| HostAssetError::Serialize {
1215 reason: err.to_string(),
1216 })?;
1217 Ok(MergedFile {
1218 existing: existing.map(str::to_owned),
1219 rendered,
1220 })
1221}
1222
1223pub fn codex_hooks_feature_is_enabled(config: &toml::Value) -> bool {
1225 config
1226 .get("features")
1227 .and_then(|features| features.get("hooks"))
1228 .and_then(toml::Value::as_bool)
1229 == Some(true)
1230}
1231
1232pub fn byte_equal_asset_status(
1244 asset: &RenderedAsset,
1245 existing_content: Option<&str>,
1246 existing_mode: Option<u32>,
1247) -> AssetStatus {
1248 let Some(content) = existing_content else {
1249 return AssetStatus::Missing;
1250 };
1251 if content != asset.contents {
1252 return AssetStatus::Drifted;
1253 }
1254 if let Some(expected) = asset.mode {
1255 match existing_mode {
1256 Some(actual) if actual == expected => {}
1257 _ => return AssetStatus::Drifted,
1258 }
1259 }
1260 AssetStatus::Present
1261}
1262
1263pub fn claude_settings_status(existing: Option<&str>) -> AssetStatus {
1268 claude_settings_status_with_profile(existing, &CCD_COMPAT_PROFILE)
1269}
1270
1271pub fn claude_settings_status_with_profile(
1274 existing: Option<&str>,
1275 profile: &LifecycleProfile,
1276) -> AssetStatus {
1277 let Some(content) = existing else {
1278 return AssetStatus::Missing;
1279 };
1280 match merge_claude_settings_text_with_profile(Some(content), false, profile) {
1281 Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
1282 Ok(_) | Err(_) => AssetStatus::Drifted,
1283 }
1284}
1285
1286pub fn codex_hooks_status(existing: Option<&str>) -> AssetStatus {
1289 codex_hooks_status_with_profile(existing, &CCD_COMPAT_PROFILE)
1290}
1291
1292pub fn codex_hooks_status_with_profile(
1295 existing: Option<&str>,
1296 profile: &LifecycleProfile,
1297) -> AssetStatus {
1298 let Some(content) = existing else {
1299 return AssetStatus::Missing;
1300 };
1301 match merge_codex_hooks_text_with_profile(Some(content), false, profile) {
1302 Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
1303 Ok(_) | Err(_) => AssetStatus::Drifted,
1304 }
1305}
1306
1307pub fn codex_config_status(existing: Option<&str>) -> AssetStatus {
1311 let Some(content) = existing else {
1312 return AssetStatus::Missing;
1313 };
1314 match content.parse::<toml::Value>() {
1315 Ok(parsed) if codex_hooks_feature_is_enabled(&parsed) => AssetStatus::Present,
1316 Ok(_) | Err(_) => AssetStatus::Drifted,
1317 }
1318}
1319
1320pub fn asset_status(
1324 asset: &RenderedAsset,
1325 existing_content: Option<&str>,
1326 existing_mode: Option<u32>,
1327) -> AssetStatus {
1328 match asset.relative_path {
1329 CLAUDE_TARGET_SETTINGS => claude_settings_status(existing_content),
1330 CODEX_TARGET_CONFIG => codex_config_status(existing_content),
1331 CODEX_TARGET_HOOKS => codex_hooks_status(existing_content),
1332 _ => byte_equal_asset_status(asset, existing_content, existing_mode),
1333 }
1334}
1335
1336pub fn aggregate_status<I: IntoIterator<Item = AssetStatus>>(statuses: I) -> AssetStatus {
1340 let mut saw_missing = false;
1341 let mut saw_drift = false;
1342 let mut saw_any = false;
1343 for s in statuses {
1344 saw_any = true;
1345 match s {
1346 AssetStatus::Drifted => saw_drift = true,
1347 AssetStatus::Missing => saw_missing = true,
1348 AssetStatus::InvalidMode => return AssetStatus::InvalidMode,
1349 AssetStatus::Present | AssetStatus::NotApplicable => {}
1350 }
1351 }
1352 if !saw_any {
1353 return AssetStatus::NotApplicable;
1354 }
1355 if saw_drift {
1356 AssetStatus::Drifted
1357 } else if saw_missing {
1358 AssetStatus::Missing
1359 } else {
1360 AssetStatus::Present
1361 }
1362}