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 => 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
957pub 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
967pub 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
1017pub fn merge_codex_hooks(hooks_doc: Value) -> Option<Value> {
1025 merge_codex_hooks_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
1026}
1027
1028pub 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
1075pub 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
1083pub 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
1124pub 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
1134pub 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
1173pub 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
1217pub 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
1226pub 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
1257pub fn claude_settings_status(existing: Option<&str>) -> AssetStatus {
1262 claude_settings_status_with_profile(existing, &CCD_COMPAT_PROFILE)
1263}
1264
1265pub 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
1280pub fn codex_hooks_status(existing: Option<&str>) -> AssetStatus {
1283 codex_hooks_status_with_profile(existing, &CCD_COMPAT_PROFILE)
1284}
1285
1286pub 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
1301pub 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
1314pub 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
1330pub 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}