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;
const CLAUDE_SOURCE_SETTINGS: &str = ".ccd-hosts/claude/settings.json";
const CLAUDE_SOURCE_HOOK: &str = ".ccd-hosts/claude/hooks/ccd-hook.py";
const CLAUDE_TARGET_SETTINGS: &str = ".claude/settings.json";
const CLAUDE_TARGET_HOOK: &str = ".claude/hooks/ccd-hook.py";
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 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> {
match key {
"claude" | "claude-code" => Some(Self::Claude),
"codex" => Some(Self::Codex),
"hermes" => Some(Self::Hermes),
"openclaw" => Some(Self::OpenClaw),
_ => None,
}
}
}
#[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} {} host 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 fn init(
repo_root: &Path,
explicit_profile: Option<&str>,
explicit_locality_id: Option<&str>,
display_name: Option<&str>,
hosts: &[ManagedHost],
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 dedup_hosts(hosts) {
let mode = host.default_mode();
let result = scaffold_host(
repo_root,
&layout,
attach_report.locality_id(),
host,
mode,
force,
)?;
host_scaffold.push(result);
}
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,
next_steps: host_scaffold
.iter()
.map(|item| {
format!(
"Run `ccd host install {} --path {}` to apply the repo-local {} integration.",
item.host,
repo_root.display(),
item.host
)
})
.collect(),
host_scaffold,
})
}
pub fn install(
repo_root: &Path,
explicit_profile: Option<&str>,
host: 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 mode = effective_install_mode(&layout, &marker.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 = 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, &marker.locality_id, host, mode)?;
let mut next_steps = Vec::new();
if host == ManagedHost::Codex && 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 && mode == HostIntegrationMode::LauncherWrapper {
next_steps.push(format!(
"Use `./{CODEX_TARGET_LAUNCHER}` when you want the optional Codex launcher/eval harness."
));
}
Ok(InstallReport {
command: "host install",
ok: true,
path: repo_root.display().to_string(),
profile: profile_name.to_string(),
host: host.as_str().to_owned(),
mode: mode.as_str().to_owned(),
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 dedup_hosts(hosts: &[ManagedHost]) -> Vec<ManagedHost> {
let mut unique = hosts.to_vec();
unique.sort();
unique.dedup();
unique
}
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());
}
upsert_host_expectation(layout, locality_id, host, mode)?;
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);
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 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 --host {} --path {}` first",
label,
repo_root.join(asset.relative_path).display(),
asset_owner(asset),
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 asset_owner(asset: &RenderedAsset) -> &'static str {
if asset.relative_path.contains("/claude/") {
"claude"
} else if asset.relative_path.contains("/codex/") {
"codex"
} else if asset.relative_path.contains("/hermes/") {
"hermes"
} else {
"openclaw"
}
}
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())),
}
}
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,
},
RenderedAsset {
relative_path: CLAUDE_SOURCE_HOOK,
contents: claude_hook_script(),
mode: Some(0o755),
},
],
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,
},
RenderedAsset {
relative_path: CLAUDE_TARGET_HOOK,
contents: claude_hook_script(),
mode: Some(0o755),
},
],
(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(),
}
}
fn claude_settings_json() -> String {
serde_json::to_string_pretty(&json!({
"hooks": {
"SessionStart": [{
"matcher": "startup|resume|clear|compact",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ccd-hook.py on-session-start"
}]
}],
"UserPromptSubmit": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ccd-hook.py before-prompt-build"
}]
}],
"PreCompact": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ccd-hook.py on-compaction-notice"
}]
}],
"TaskCompleted": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ccd-hook.py on-agent-end"
}]
}],
"Stop": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ccd-hook.py on-agent-end"
}]
}],
"SessionEnd": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ccd-hook.py on-session-end"
}]
}]
}
}))
.expect("claude settings json")
}
fn claude_hook_script() -> String {
r#"#!/usr/bin/env python3
# CCD-MANAGED
import json
import os
import shutil
import subprocess
import sys
def resolve_ccd():
explicit = os.environ.get("CCD_BIN")
if explicit and os.path.isfile(explicit) and os.access(explicit, os.X_OK):
return explicit
discovered = shutil.which("ccd")
if discovered:
return discovered
for candidate in (
os.path.expanduser("~/.ccd/bin/ccd"),
os.path.expanduser("~/.cargo/bin/ccd"),
):
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def run_ccd(host_hook):
ccd = resolve_ccd()
if not ccd:
print("ccd is unavailable for Claude native hooks", file=sys.stderr)
return None
repo_root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
command = [
ccd,
"--output",
"json",
"host-hook",
"--path",
repo_root,
"--host",
"claude",
"--hook",
host_hook,
]
profile = os.environ.get("CCD_PROFILE")
if profile:
command.extend(["--profile", profile])
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
stderr = result.stderr.strip()
if stderr:
print(stderr, file=sys.stderr)
return None
try:
return json.loads(result.stdout or "{}")
except json.JSONDecodeError as exc:
print(f"failed to parse ccd host-hook output: {exc}", file=sys.stderr)
return None
def render_context(report):
context = report.get("context") or {}
return json.dumps(context, indent=2, sort_keys=True)
def emit_additional_context(event_name, report, block=False):
payload = {
"hookSpecificOutput": {
"hookEventName": event_name,
"additionalContext": render_context(report),
}
}
if block:
action = (report.get("session_boundary") or {}).get("action")
payload["decision"] = "block"
payload["reason"] = (
f"CCD session boundary is `{action}`; resolve continuity or wrap-up "
"guidance before continuing."
)
print(json.dumps(payload))
def main():
if len(sys.argv) != 2:
print("usage: ccd-hook.py <host-hook>", file=sys.stderr)
return 0
host_hook = sys.argv[1]
report = run_ccd(host_hook)
if not report:
return 0
if host_hook == "on-session-start":
emit_additional_context("SessionStart", report)
return 0
if host_hook == "before-prompt-build":
action = (report.get("session_boundary") or {}).get("action")
emit_additional_context("UserPromptSubmit", report, block=action in {"stop", "refresh"})
return 0
return 0
if __name__ == "__main__":
raise SystemExit(main())
"#
.to_owned()
}
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 install 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 host 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 host can refresh lease ownership.",
"Treat CCD outputs as control-plane truth rather than prompt folklore."
]
}))
.expect("hermes adapter json")
}