use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::ExitCode;
use anyhow::{bail, Context, Result};
use serde::Serialize;
use serde_json::json;
use super::attach;
use crate::output::CommandReport;
use crate::paths::{state::StateLayout, write};
use crate::profile;
use crate::repo::marker;
mod claude_protocol;
mod settings_merge;
pub(crate) use claude_protocol::{
build_hook_protocol_payload, claude_event_for_hook, HookProtocolInputs,
};
const CLAUDE_SOURCE_SETTINGS: &str = ".ccd-hosts/claude/settings.json";
const CLAUDE_TARGET_SETTINGS: &str = ".claude/settings.json";
const CODEX_SOURCE_README: &str = ".ccd-hosts/codex/README.md";
const CODEX_SOURCE_LAUNCHER: &str = ".ccd-hosts/codex/launcher.sh";
const CODEX_TARGET_LAUNCHER: &str = ".codex/ccd-launch.sh";
const OPENCLAW_SOURCE_ADAPTER: &str = ".ccd-hosts/openclaw/adapter.json";
const OPENCLAW_TARGET_ADAPTER: &str = ".openclaw/ccd.json";
const HERMES_SOURCE_ADAPTER: &str = ".ccd-hosts/hermes/adapter.json";
const HERMES_TARGET_ADAPTER: &str = ".hermes/ccd.json";
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ManagedHost {
Claude,
Codex,
Hermes,
OpenClaw,
}
impl ManagedHost {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
Self::Hermes => "hermes",
Self::OpenClaw => "openclaw",
}
}
pub(crate) fn all() -> [Self; 4] {
[Self::Claude, Self::Codex, Self::Hermes, Self::OpenClaw]
}
pub(crate) fn from_str_lower(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(crate) fn default_mode(self) -> HostIntegrationMode {
match self {
Self::Claude => HostIntegrationMode::NativeHook,
Self::Codex => HostIntegrationMode::ManualSkill,
Self::Hermes | Self::OpenClaw => HostIntegrationMode::ReferenceAdapter,
}
}
fn from_config_key(key: &str) -> Option<Self> {
Self::from_str_lower(key)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum HostIntegrationMode {
ManualSkill,
LauncherWrapper,
NativeHook,
ReferenceAdapter,
}
impl HostIntegrationMode {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::ManualSkill => "manual_skill",
Self::LauncherWrapper => "launcher_wrapper",
Self::NativeHook => "native_hook",
Self::ReferenceAdapter => "reference_adapter",
}
}
fn from_config(value: Option<&str>, host: ManagedHost) -> Self {
match value {
Some("manual_skill") => Self::ManualSkill,
Some("launcher_wrapper") => Self::LauncherWrapper,
Some("native_hook") => Self::NativeHook,
Some("reference_adapter") => Self::ReferenceAdapter,
_ => host.default_mode(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum FileAction {
Installed,
Updated,
AlreadyPresent,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum IntegrationAssetStatus {
Present,
Missing,
Drifted,
InvalidMode,
NotApplicable,
}
#[derive(Clone, Debug)]
struct RenderedAsset {
relative_path: &'static str,
contents: String,
mode: Option<u32>,
}
#[derive(Serialize)]
pub struct InitReport {
command: &'static str,
ok: bool,
path: String,
profile: String,
locality_id: String,
attach: attach::AttachReport,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
host_scaffold: Vec<HostMutationView>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
next_steps: Vec<String>,
}
impl CommandReport for InitReport {
fn exit_code(&self) -> ExitCode {
ExitCode::SUCCESS
}
fn render_text(&self) {
self.attach.render_text();
if self.host_scaffold.is_empty() {
return;
}
println!();
println!("Host scaffolding:");
for item in &self.host_scaffold {
println!(
"- {} ({}) -> {}",
item.host,
item.mode,
match item.action {
FileAction::Installed => "installed",
FileAction::Updated => "updated",
FileAction::AlreadyPresent => "already present",
}
);
}
if self.next_steps.is_empty() {
return;
}
println!();
println!("Next steps:");
for (index, step) in self.next_steps.iter().enumerate() {
println!("{}. {step}", index + 1);
}
}
}
#[derive(Serialize)]
pub struct InstallReport {
command: &'static str,
ok: bool,
path: String,
profile: String,
host: String,
mode: String,
action: FileAction,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
applied_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
next_steps: Vec<String>,
}
impl CommandReport for InstallReport {
fn exit_code(&self) -> ExitCode {
ExitCode::SUCCESS
}
fn render_text(&self) {
let action = match self.action {
FileAction::Installed => "Installed",
FileAction::Updated => "Updated",
FileAction::AlreadyPresent => "Validated",
};
println!(
"{action} {} runtime integration in {} mode.",
self.host, self.mode
);
for path in &self.applied_paths {
println!("Applied: {path}");
}
if self.next_steps.is_empty() {
return;
}
println!();
println!("Next steps:");
for (index, step) in self.next_steps.iter().enumerate() {
println!("{}. {step}", index + 1);
}
}
}
#[derive(Clone, Serialize)]
pub struct HostIntegrationStatus {
pub host: String,
pub mode: String,
pub scaffold_status: IntegrationAssetStatus,
pub install_status: IntegrationAssetStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applied_paths: Vec<String>,
}
#[derive(Serialize)]
struct HostMutationView {
host: String,
mode: String,
action: FileAction,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
source_paths: Vec<String>,
}
pub(crate) fn detect_applied_hosts(repo_root: &Path) -> Vec<ManagedHost> {
let mut found = Vec::new();
if repo_root.join(".claude").is_dir() {
found.push(ManagedHost::Claude);
}
if repo_root.join(".codex").is_dir() {
found.push(ManagedHost::Codex);
}
if repo_root.join(".hermes").is_dir() {
found.push(ManagedHost::Hermes);
}
if repo_root.join(".openclaw").is_dir() {
found.push(ManagedHost::OpenClaw);
}
found
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
struct ApplyResult {
host: ManagedHost,
mode: HostIntegrationMode,
action: FileAction,
applied_paths: Vec<String>,
launcher_applied: bool,
}
pub fn init(
repo_root: &Path,
explicit_profile: Option<&str>,
explicit_locality_id: Option<&str>,
display_name: Option<&str>,
force: bool,
) -> Result<InitReport> {
let attach_report = attach::run(
repo_root,
explicit_profile,
explicit_locality_id,
display_name,
)?;
let profile_name = profile::resolve(Some(attach_report.profile_name()))?;
let layout = StateLayout::resolve(repo_root, profile_name)?;
let mut host_scaffold = Vec::new();
for host in ManagedHost::all() {
let mode = host.default_mode();
let result = scaffold_host(
repo_root,
&layout,
attach_report.locality_id(),
host,
mode,
force,
)?;
host_scaffold.push(result);
}
let marker = marker::load(repo_root)?.ok_or_else(|| {
anyhow::anyhow!(
"no CCD marker found after attach at {}; this indicates an internal inconsistency",
repo_root.display()
)
})?;
let applied_hosts: Vec<ManagedHost> = if let Some(list) = marker.hosts_applied.clone() {
list.iter()
.filter_map(|name| ManagedHost::from_str_lower(name))
.collect()
} else {
let detected = detect_applied_hosts(repo_root);
if !detected.is_empty() {
let names: Vec<String> = detected.iter().map(|h| h.as_str().to_owned()).collect();
let new_marker = marker.with_hosts_applied(Some(names))?;
marker::write(repo_root, &new_marker)?;
}
detected
};
let mut apply_results: Vec<ApplyResult> = Vec::new();
for host in &applied_hosts {
let result = apply_host_integration(
repo_root,
&layout,
attach_report.locality_id(),
*host,
force,
false,
)?;
apply_results.push(result);
}
let legacy_migration = migrate_legacy_claude_hook(repo_root)?;
let mut next_steps: Vec<String> = Vec::new();
if let Some(codex_result) = apply_results
.iter()
.find(|r| r.host == ManagedHost::Codex && r.mode == HostIntegrationMode::ManualSkill)
{
let _ = codex_result; next_steps.push(
"Use `ccd host apply --host codex --with-launcher` if you want the Codex launcher/eval harness."
.to_owned(),
);
}
match legacy_migration {
LegacyHookMigration::NoLegacyFile => {}
LegacyHookMigration::DeletedSentinelFile { path } => {
next_steps.push(format!("Removed legacy CCD-managed Claude hook at {path}."));
}
LegacyHookMigration::PreservedUserFile { warning, .. } => {
next_steps.push(warning);
}
}
Ok(InitReport {
command: "init",
ok: true,
path: repo_root.display().to_string(),
profile: attach_report.profile_name().to_owned(),
locality_id: attach_report.locality_id().to_owned(),
attach: attach_report,
host_scaffold,
next_steps,
})
}
fn apply_host_integration(
repo_root: &Path,
layout: &StateLayout,
locality_id: &str,
host: ManagedHost,
force: bool,
with_launcher: bool,
) -> Result<ApplyResult> {
let mode = effective_install_mode(layout, locality_id, host, with_launcher)?;
ensure_supported_mode(host, mode)?;
let source_assets = render_source_assets(host);
ensure_assets_present(repo_root, &source_assets, "scaffolded source")?;
let applied_assets = render_applied_assets(host, mode);
let mut action = FileAction::AlreadyPresent;
let mut applied_paths = Vec::new();
for asset in &applied_assets {
let file_action =
if host == ManagedHost::Claude && asset.relative_path == CLAUDE_TARGET_SETTINGS {
merge_or_write_claude_settings(repo_root, asset, force)?
} else {
sync_asset(repo_root, asset, force)?
};
action = combine_actions(action, file_action);
applied_paths.push(repo_root.join(asset.relative_path).display().to_string());
}
upsert_host_expectation(layout, locality_id, host, mode)?;
Ok(ApplyResult {
host,
mode,
action,
applied_paths,
launcher_applied: false,
})
}
fn merge_or_write_claude_settings(
repo_root: &Path,
asset: &RenderedAsset,
force: bool,
) -> Result<FileAction> {
let target = repo_root.join(asset.relative_path);
let merge_output = settings_merge::merge_settings_file(&target, force)?;
match merge_output {
Some(rendered) => {
let prior = std::fs::read_to_string(&target).ok();
if prior.as_deref() == Some(rendered.as_str()) {
return Ok(FileAction::AlreadyPresent);
}
std::fs::create_dir_all(target.parent().unwrap())
.with_context(|| format!("creating dir for {}", target.display()))?;
std::fs::write(&target, &rendered)
.with_context(|| format!("writing {}", target.display()))?;
Ok(if prior.is_some() {
FileAction::Updated
} else {
FileAction::Installed
})
}
None => {
anyhow::bail!(
"`{}` is malformed; re-run with `--force` to overwrite or repair manually",
target.display()
)
}
}
}
pub fn apply(
repo_root: &Path,
explicit_profile: Option<&str>,
explicit_host: Option<ManagedHost>,
force: bool,
with_launcher: bool,
) -> Result<InstallReport> {
let profile_name = profile::resolve(explicit_profile)?;
let layout = StateLayout::resolve(repo_root, profile_name.clone())?;
let marker = marker::load(repo_root)?.ok_or_else(|| {
anyhow::anyhow!("this checkout is not attached to a CCD profile; run `ccd attach` first")
})?;
let scaffolded: Vec<ManagedHost> = ManagedHost::all()
.iter()
.copied()
.filter(|host| {
let source_dir = repo_root.join(".ccd-hosts").join(host.as_str());
source_dir.is_dir()
})
.collect();
let targets: Vec<ManagedHost> = match explicit_host {
Some(host) => {
if !scaffolded.contains(&host) {
anyhow::bail!(
"host `{}` has no scaffolded sources under .ccd-hosts/; run `ccd init` first",
host.as_str()
);
}
vec![host]
}
None => {
if scaffolded.is_empty() {
anyhow::bail!("no host sources found under .ccd-hosts/; run `ccd init` first");
}
if scaffolded.len() > 1 && with_launcher {
anyhow::bail!(
"--with-launcher requires --host when multiple hosts are scaffolded; currently {:?}",
scaffolded.iter().map(|h| h.as_str()).collect::<Vec<_>>()
);
}
scaffolded.clone()
}
};
let mut action = FileAction::AlreadyPresent;
let mut applied_paths: Vec<String> = Vec::new();
let mut host_names: Vec<String> = Vec::new();
let mut mode_names: Vec<String> = Vec::new();
let mut next_steps: Vec<String> = Vec::new();
let mut claude_targeted = false;
for host in &targets {
let result = apply_host_integration(
repo_root,
&layout,
&marker.locality_id,
*host,
force,
with_launcher,
)?;
if *host == ManagedHost::Claude {
claude_targeted = true;
}
if *host == ManagedHost::Codex && result.mode == HostIntegrationMode::ManualSkill {
next_steps.push(
"Use `$ccd-start` or `ccd start --activate --path .` as the supported human-driven Codex baseline.".to_owned(),
);
}
if *host == ManagedHost::Codex && result.mode == HostIntegrationMode::LauncherWrapper {
next_steps.push(format!(
"Use `./{CODEX_TARGET_LAUNCHER}` when you want the optional Codex launcher/eval harness."
));
}
host_names.push(result.host.as_str().to_owned());
mode_names.push(result.mode.as_str().to_owned());
applied_paths.extend(result.applied_paths.iter().cloned());
action = combine_actions(action, result.action);
}
if claude_targeted {
match migrate_legacy_claude_hook(repo_root)? {
LegacyHookMigration::NoLegacyFile => {}
LegacyHookMigration::DeletedSentinelFile { path } => {
next_steps.push(format!("Removed legacy CCD-managed Claude hook at {path}."));
}
LegacyHookMigration::PreservedUserFile { warning, .. } => {
next_steps.push(warning);
}
}
}
Ok(InstallReport {
command: "host apply",
ok: true,
path: repo_root.display().to_string(),
profile: profile_name.to_string(),
host: host_names.join(","),
mode: mode_names.join(","),
action,
applied_paths,
next_steps,
})
}
pub fn inspect(
repo_root: &Path,
layout: &StateLayout,
locality_id: &str,
) -> Result<Vec<HostIntegrationStatus>> {
let mut statuses = Vec::new();
for (host, mode) in expected_integrations(layout, locality_id)? {
let source_assets = render_source_assets(host);
let applied_assets = render_applied_assets(host, mode);
let install_status = if supports_mode(host, mode) {
inspect_assets(repo_root, &applied_assets)
} else {
IntegrationAssetStatus::InvalidMode
};
statuses.push(HostIntegrationStatus {
host: host.as_str().to_owned(),
mode: mode.as_str().to_owned(),
scaffold_status: inspect_assets(repo_root, &source_assets),
install_status,
source_paths: source_assets
.iter()
.map(|asset| repo_root.join(asset.relative_path).display().to_string())
.collect(),
applied_paths: applied_assets
.iter()
.map(|asset| repo_root.join(asset.relative_path).display().to_string())
.collect(),
});
}
Ok(statuses)
}
fn supports_mode(host: ManagedHost, mode: HostIntegrationMode) -> bool {
match host {
ManagedHost::Claude => mode == HostIntegrationMode::NativeHook,
ManagedHost::Codex => {
matches!(
mode,
HostIntegrationMode::ManualSkill | HostIntegrationMode::LauncherWrapper
)
}
ManagedHost::Hermes | ManagedHost::OpenClaw => {
mode == HostIntegrationMode::ReferenceAdapter
}
}
}
fn supported_modes_label(host: ManagedHost) -> &'static str {
match host {
ManagedHost::Claude => "`native_hook`",
ManagedHost::Codex => "`manual_skill`, `launcher_wrapper`",
ManagedHost::Hermes | ManagedHost::OpenClaw => "`reference_adapter`",
}
}
fn ensure_supported_mode(host: ManagedHost, mode: HostIntegrationMode) -> Result<()> {
if supports_mode(host, mode) {
return Ok(());
}
bail!(
"unsupported mode `{}` for {}; supported modes: {}",
mode.as_str(),
host.as_str(),
supported_modes_label(host)
);
}
fn effective_install_mode(
layout: &StateLayout,
locality_id: &str,
host: ManagedHost,
with_launcher: bool,
) -> Result<HostIntegrationMode> {
if host == ManagedHost::Codex {
if with_launcher {
return Ok(HostIntegrationMode::LauncherWrapper);
}
let existing_mode = expected_integrations(layout, locality_id)?
.into_iter()
.find_map(|(expected_host, mode)| (expected_host == host).then_some(mode));
return Ok(match existing_mode {
Some(HostIntegrationMode::LauncherWrapper) => HostIntegrationMode::LauncherWrapper,
_ => HostIntegrationMode::ManualSkill,
});
}
Ok(expected_integrations(layout, locality_id)?
.into_iter()
.find_map(|(expected_host, mode)| (expected_host == host).then_some(mode))
.unwrap_or_else(|| host.default_mode()))
}
fn scaffold_host(
repo_root: &Path,
_layout: &StateLayout,
_locality_id: &str,
host: ManagedHost,
mode: HostIntegrationMode,
force: bool,
) -> Result<HostMutationView> {
let assets = render_source_assets(host);
let mut action = FileAction::AlreadyPresent;
let mut source_paths = Vec::new();
for asset in &assets {
let file_action = sync_asset(repo_root, asset, force)?;
action = combine_actions(action, file_action);
source_paths.push(repo_root.join(asset.relative_path).display().to_string());
}
Ok(HostMutationView {
host: host.as_str().to_owned(),
mode: mode.as_str().to_owned(),
action,
source_paths,
})
}
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,
}
}
fn inspect_assets(repo_root: &Path, assets: &[RenderedAsset]) -> IntegrationAssetStatus {
if assets.is_empty() {
return IntegrationAssetStatus::NotApplicable;
}
let mut saw_missing = false;
let mut saw_drift = false;
for asset in assets {
match asset_status(repo_root, asset) {
IntegrationAssetStatus::Present => {}
IntegrationAssetStatus::Missing => saw_missing = true,
IntegrationAssetStatus::Drifted => saw_drift = true,
IntegrationAssetStatus::InvalidMode => unreachable!(),
IntegrationAssetStatus::NotApplicable => {}
}
}
if saw_drift {
IntegrationAssetStatus::Drifted
} else if saw_missing {
IntegrationAssetStatus::Missing
} else {
IntegrationAssetStatus::Present
}
}
fn asset_status(repo_root: &Path, asset: &RenderedAsset) -> IntegrationAssetStatus {
let path = repo_root.join(asset.relative_path);
if asset.relative_path == CLAUDE_TARGET_SETTINGS {
return claude_settings_status(&path);
}
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return IntegrationAssetStatus::Missing
}
Err(_) => return IntegrationAssetStatus::Drifted,
};
if contents != asset.contents {
return IntegrationAssetStatus::Drifted;
}
if let Some(expected_mode) = asset.mode {
let current_mode = match fs::metadata(&path) {
Ok(metadata) => metadata.permissions().mode() & 0o777,
Err(_) => return IntegrationAssetStatus::Drifted,
};
if current_mode != expected_mode {
return IntegrationAssetStatus::Drifted;
}
}
IntegrationAssetStatus::Present
}
fn claude_settings_status(path: &Path) -> IntegrationAssetStatus {
let current = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return IntegrationAssetStatus::Missing
}
Err(_) => return IntegrationAssetStatus::Drifted,
};
match settings_merge::merge_settings_file(path, false) {
Ok(Some(rendered)) if rendered == current => IntegrationAssetStatus::Present,
Ok(Some(_)) | Ok(None) | Err(_) => IntegrationAssetStatus::Drifted,
}
}
fn ensure_assets_present(repo_root: &Path, assets: &[RenderedAsset], label: &str) -> Result<()> {
for asset in assets {
match asset_status(repo_root, asset) {
IntegrationAssetStatus::Present => {}
IntegrationAssetStatus::Missing => bail!(
"{} is missing at {}; run `ccd init --path {}` first",
label,
repo_root.join(asset.relative_path).display(),
repo_root.display()
),
IntegrationAssetStatus::Drifted => bail!(
"{} at {} has drifted from the CCD-managed content; review it or re-run with `--force`",
label,
repo_root.join(asset.relative_path).display()
),
IntegrationAssetStatus::InvalidMode => unreachable!(),
IntegrationAssetStatus::NotApplicable => {}
}
}
Ok(())
}
fn sync_asset(repo_root: &Path, asset: &RenderedAsset, force: bool) -> Result<FileAction> {
let path = repo_root.join(asset.relative_path);
let current = fs::read_to_string(&path);
match current {
Ok(existing) => {
let mode_matches = asset.mode.is_none_or(|expected| {
fs::metadata(&path)
.map(|metadata| metadata.permissions().mode() & 0o777 == expected)
.unwrap_or(false)
});
if existing == asset.contents && mode_matches {
return Ok(FileAction::AlreadyPresent);
}
if !force {
bail!(
"{} already exists and differs from the CCD-managed content; re-run with `--force` to replace it",
path.display()
);
}
write::replace_text(&path, &asset.contents, asset.mode)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(FileAction::Updated)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
write::create_text(&path, &asset.contents, asset.mode)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(FileAction::Installed)
}
Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
}
}
#[derive(Debug)]
pub(crate) enum LegacyHookMigration {
NoLegacyFile,
DeletedSentinelFile { path: String },
PreservedUserFile { warning: String },
}
pub(crate) fn migrate_legacy_claude_hook(repo_root: &Path) -> Result<LegacyHookMigration> {
let hook_path = repo_root.join(".claude/hooks/ccd-hook.py");
if !hook_path.exists() {
return Ok(LegacyHookMigration::NoLegacyFile);
}
let contents = std::fs::read_to_string(&hook_path)
.with_context(|| format!("reading {}", hook_path.display()))?;
let first_non_shebang = contents
.lines()
.find(|line| !line.starts_with("#!"))
.unwrap_or("");
let is_ccd_managed = first_non_shebang.trim_start().starts_with("# CCD-MANAGED");
if !is_ccd_managed {
let warning = format!(
"a non-CCD ccd-hook.py was found at {}; remove it manually if it is obsolete.",
hook_path.display()
);
return Ok(LegacyHookMigration::PreservedUserFile { warning });
}
std::fs::remove_file(&hook_path)
.with_context(|| format!("removing legacy hook {}", hook_path.display()))?;
let hooks_dir = hook_path.parent().expect("hook has parent dir");
if std::fs::read_dir(hooks_dir)
.map(|mut iter| iter.next().is_none())
.unwrap_or(false)
{
let _ = std::fs::remove_dir(hooks_dir); }
Ok(LegacyHookMigration::DeletedSentinelFile {
path: hook_path.display().to_string(),
})
}
fn expected_integrations(
layout: &StateLayout,
locality_id: &str,
) -> Result<Vec<(ManagedHost, HostIntegrationMode)>> {
let Some(config) = layout.load_repo_overlay_config(locality_id)? else {
return Ok(Vec::new());
};
let mut expected = std::collections::BTreeMap::new();
for (key, host_config) in config.hosts {
let Some(host) = ManagedHost::from_config_key(&key) else {
continue;
};
let mode = HostIntegrationMode::from_config(host_config.mode.as_deref(), host);
let is_canonical_key = key == host.as_str();
match expected.entry(host) {
std::collections::btree_map::Entry::Vacant(entry) => {
entry.insert((mode, is_canonical_key));
}
std::collections::btree_map::Entry::Occupied(mut entry) => {
if is_canonical_key || !entry.get().1 {
entry.insert((mode, is_canonical_key));
}
}
}
}
Ok(expected
.into_iter()
.map(|(host, (mode, _))| (host, mode))
.collect())
}
fn upsert_host_expectation(
layout: &StateLayout,
locality_id: &str,
host: ManagedHost,
mode: HostIntegrationMode,
) -> Result<()> {
let config_path = layout.repo_overlay_config_path(locality_id)?;
let mut root = load_repo_overlay_config_table(&config_path)?;
let hosts = root
.entry("hosts".to_owned())
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.ok_or_else(|| anyhow::anyhow!("{}: [hosts] must be a table", config_path.display()))?;
let mut host_table = toml::Table::new();
host_table.insert(
"mode".to_owned(),
toml::Value::String(mode.as_str().to_owned()),
);
hosts.insert(host.as_str().to_owned(), toml::Value::Table(host_table));
let rendered = toml::to_string_pretty(&root)
.with_context(|| format!("failed to serialize {}", config_path.display()))?;
write::replace_text(&config_path, &rendered, None)
.with_context(|| format!("failed to write {}", config_path.display()))?;
Ok(())
}
fn load_repo_overlay_config_table(path: &Path) -> Result<toml::Table> {
let raw = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(toml::Table::new());
}
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
if raw.trim().is_empty() {
return Ok(toml::Table::new());
}
let value: toml::Value =
toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?;
value
.as_table()
.cloned()
.ok_or_else(|| anyhow::anyhow!("{} must be a TOML table", path.display()))
}
fn render_source_assets(host: ManagedHost) -> Vec<RenderedAsset> {
match host {
ManagedHost::Claude => vec![RenderedAsset {
relative_path: CLAUDE_SOURCE_SETTINGS,
contents: claude_settings_json(),
mode: None,
}],
ManagedHost::Codex => vec![
RenderedAsset {
relative_path: CODEX_SOURCE_README,
contents: codex_guidance_readme(),
mode: None,
},
RenderedAsset {
relative_path: CODEX_SOURCE_LAUNCHER,
contents: codex_launcher_script(),
mode: Some(0o755),
},
],
ManagedHost::Hermes => vec![RenderedAsset {
relative_path: HERMES_SOURCE_ADAPTER,
contents: hermes_adapter_json(),
mode: None,
}],
ManagedHost::OpenClaw => vec![RenderedAsset {
relative_path: OPENCLAW_SOURCE_ADAPTER,
contents: openclaw_adapter_json(),
mode: None,
}],
}
}
fn render_applied_assets(host: ManagedHost, mode: HostIntegrationMode) -> Vec<RenderedAsset> {
match (host, mode) {
(ManagedHost::Claude, HostIntegrationMode::NativeHook) => vec![RenderedAsset {
relative_path: CLAUDE_TARGET_SETTINGS,
contents: claude_settings_json(),
mode: None,
}],
(ManagedHost::Codex, HostIntegrationMode::LauncherWrapper) => vec![RenderedAsset {
relative_path: CODEX_TARGET_LAUNCHER,
contents: codex_launcher_script(),
mode: Some(0o755),
}],
(ManagedHost::Codex, HostIntegrationMode::ManualSkill) => Vec::new(),
(ManagedHost::Hermes, HostIntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
relative_path: HERMES_TARGET_ADAPTER,
contents: hermes_adapter_json(),
mode: None,
}],
(ManagedHost::OpenClaw, HostIntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
relative_path: OPENCLAW_TARGET_ADAPTER,
contents: openclaw_adapter_json(),
mode: None,
}],
_ => Vec::new(),
}
}
pub(crate) const CCD_CLAUDE_COMMAND_PREFIX: &str =
"\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ";
fn ccd_claude_command(hook_name: &str) -> String {
format!("{CCD_CLAUDE_COMMAND_PREFIX}{hook_name}")
}
fn claude_settings_json() -> String {
serde_json::to_string_pretty(&json!({
"hooks": {
"SessionStart": [{
"matcher": "startup|resume|clear|compact",
"hooks": [{
"type": "command",
"command": ccd_claude_command("on-session-start"),
}]
}],
"UserPromptSubmit": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": ccd_claude_command("before-prompt-build"),
}]
}],
"PreCompact": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": ccd_claude_command("on-compaction-notice"),
}]
}],
"Stop": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": ccd_claude_command("on-agent-end"),
}]
}],
"SessionEnd": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": ccd_claude_command("on-session-end"),
}]
}]
}
}))
.expect("claude settings json")
}
fn codex_guidance_readme() -> String {
format!(
r#"<!-- CCD-MANAGED -->
# Codex Host Guidance
Human-driven Codex is allowed to use the manual CCD startup path:
- `/ccd-start`
- `ccd start --activate --path .`
That is the supported baseline for terminal Codex today. It 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_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 host-hook --output json --path . --host openclaw --hook on-session-start --mode implement --lifecycle autonomous --actor-id runtime/openclaw-agent-1 --lease-seconds 900 --host-session-id acp-session-42 --host-run-id acp-run-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")
}
#[cfg(test)]
mod legacy_hook_tests {
use super::*;
use tempfile::TempDir;
fn write(path: &Path, body: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
}
#[test]
fn no_legacy_file_returns_no_op() {
let tmp = TempDir::new().unwrap();
match migrate_legacy_claude_hook(tmp.path()).unwrap() {
LegacyHookMigration::NoLegacyFile => {}
_ => panic!("expected NoLegacyFile"),
}
}
#[test]
fn sentinel_file_is_deleted_and_empty_dir_removed() {
let tmp = TempDir::new().unwrap();
let hook = tmp.path().join(".claude/hooks/ccd-hook.py");
write(
&hook,
"#!/usr/bin/env python3\n# CCD-MANAGED\nprint('hi')\n",
);
let result = migrate_legacy_claude_hook(tmp.path()).unwrap();
assert!(matches!(
result,
LegacyHookMigration::DeletedSentinelFile { .. }
));
assert!(!hook.exists());
assert!(
!hook.parent().unwrap().exists(),
"empty hooks dir should be removed"
);
}
#[test]
fn user_file_without_sentinel_is_preserved() {
let tmp = TempDir::new().unwrap();
let hook = tmp.path().join(".claude/hooks/ccd-hook.py");
write(&hook, "#!/usr/bin/env python3\nprint('user script')\n");
let result = migrate_legacy_claude_hook(tmp.path()).unwrap();
match result {
LegacyHookMigration::PreservedUserFile { warning, .. } => {
assert!(warning.contains("non-CCD"), "warning: {warning}");
}
_ => panic!("expected PreservedUserFile"),
}
assert!(hook.exists(), "user hook must be preserved");
}
#[test]
fn sentinel_recognized_after_multiple_shebang_prefixed_lines() {
let tmp = TempDir::new().unwrap();
let hook = tmp.path().join(".claude/hooks/ccd-hook.py");
write(
&hook,
"#!/usr/bin/env python3\n#!coding:utf-8\n# CCD-MANAGED\nimport json\n",
);
let result = migrate_legacy_claude_hook(tmp.path()).unwrap();
assert!(
matches!(result, LegacyHookMigration::DeletedSentinelFile { .. }),
"got: {:?}",
result
);
assert!(!hook.exists());
}
#[test]
fn sentinel_deletion_preserves_dir_when_sibling_exists() {
let tmp = TempDir::new().unwrap();
let hooks_dir = tmp.path().join(".claude/hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
let hook = hooks_dir.join("ccd-hook.py");
let sibling = hooks_dir.join("helper.sh");
std::fs::write(
&hook,
"#!/usr/bin/env python3\n# CCD-MANAGED\nprint('hi')\n",
)
.unwrap();
std::fs::write(&sibling, "#!/bin/bash\necho hi\n").unwrap();
let result = migrate_legacy_claude_hook(tmp.path()).unwrap();
assert!(matches!(
result,
LegacyHookMigration::DeletedSentinelFile { .. }
));
assert!(!hook.exists(), "hook itself must be deleted");
assert!(hooks_dir.exists(), "non-empty hooks dir must be preserved");
assert!(sibling.exists(), "sibling file must not be touched");
}
}