use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use klasp_core::{AgentSurface, InstallContext, InstallError, InstallReport};
use crate::agents_md::{self, AgentsMdError, DEFAULT_BLOCK_BODY};
use crate::git_hooks::{self, HookError, HookKind, HookWarning};
pub struct CodexSurface;
impl CodexSurface {
pub const AGENT_ID: &'static str = "codex";
pub const AGENTS_MD: &'static str = "AGENTS.md";
pub const PRE_COMMIT_RELPATH: &'static [&'static str] = &[".git", "hooks", "pre-commit"];
pub const PRE_PUSH_RELPATH: &'static [&'static str] = &[".git", "hooks", "pre-push"];
pub const HOOK_RELPATH: &'static [&'static str] = Self::PRE_COMMIT_RELPATH;
pub fn all_hook_paths(repo_root: &Path) -> [(HookKind, PathBuf); 2] {
[
(HookKind::Commit, hook_path_for(repo_root, HookKind::Commit)),
(HookKind::Push, hook_path_for(repo_root, HookKind::Push)),
]
}
pub fn install_detailed(
&self,
ctx: &InstallContext,
) -> Result<CodexInstallReport, InstallError> {
let settings_path = self.settings_path(&ctx.repo_root);
let agents_existing = read_or_empty(&settings_path)?;
let agents_merged = agents_md::install_block(&agents_existing, DEFAULT_BLOCK_BODY)
.map_err(|e| agents_md_error(&settings_path, e))?;
let agents_unchanged = agents_merged == agents_existing;
let mut hook_plans = Vec::with_capacity(2);
let mut warnings = Vec::new();
for (kind, path) in Self::all_hook_paths(&ctx.repo_root) {
let plan = plan_hook_install(&path, kind, ctx.schema_version)?;
if let HookPlanOutcome::Conflict(conflict) = plan.outcome {
warnings.push(HookWarning::Skipped {
path: path.clone(),
kind,
conflict,
});
}
hook_plans.push(plan);
}
let all_already_installed = agents_unchanged
&& hook_plans
.iter()
.all(|p| matches!(p.outcome, HookPlanOutcome::Unchanged));
if ctx.dry_run {
return Ok(CodexInstallReport {
report: InstallReport {
agent_id: Self::AGENT_ID.to_string(),
hook_path: hook_path_for(&ctx.repo_root, HookKind::Commit),
settings_path,
already_installed: all_already_installed,
paths_written: Vec::new(),
preview: Some(agents_merged),
},
warnings,
});
}
let mut paths_written = Vec::new();
if !agents_unchanged {
ensure_parent(&settings_path)?;
let mode = current_mode(&settings_path).unwrap_or(0o644);
atomic_write(&settings_path, agents_merged.as_bytes(), mode)?;
paths_written.push(settings_path.clone());
}
for plan in hook_plans {
match plan.outcome {
HookPlanOutcome::Write(merged) => {
ensure_parent(&plan.path)?;
let mode = current_mode(&plan.path).unwrap_or(0o755);
atomic_write(&plan.path, merged.as_bytes(), mode)?;
paths_written.push(plan.path);
}
HookPlanOutcome::Unchanged | HookPlanOutcome::Conflict(_) => {
}
}
}
Ok(CodexInstallReport {
report: InstallReport {
agent_id: Self::AGENT_ID.to_string(),
hook_path: hook_path_for(&ctx.repo_root, HookKind::Commit),
settings_path,
already_installed: all_already_installed,
paths_written,
preview: None,
},
warnings,
})
}
}
#[derive(Debug)]
pub struct CodexInstallReport {
pub report: InstallReport,
pub warnings: Vec<HookWarning>,
}
impl AgentSurface for CodexSurface {
fn agent_id(&self) -> &'static str {
Self::AGENT_ID
}
fn detect(&self, repo_root: &Path) -> bool {
repo_root.join(Self::AGENTS_MD).is_file()
}
fn hook_path(&self, repo_root: &Path) -> PathBuf {
hook_path_for(repo_root, HookKind::Commit)
}
fn settings_path(&self, repo_root: &Path) -> PathBuf {
repo_root.join(Self::AGENTS_MD)
}
fn render_hook_script(&self, ctx: &InstallContext) -> String {
git_hooks::install_block("", HookKind::Commit, ctx.schema_version).unwrap_or_default()
}
fn install(&self, ctx: &InstallContext) -> Result<InstallReport, InstallError> {
Ok(self.install_detailed(ctx)?.report)
}
fn uninstall(&self, repo_root: &Path, dry_run: bool) -> Result<Vec<PathBuf>, InstallError> {
let mut paths = Vec::new();
let settings_path = self.settings_path(repo_root);
let agents_existing = read_or_empty(&settings_path)?;
let agents_stripped = agents_md::uninstall_block(&agents_existing)
.map_err(|e| agents_md_error(&settings_path, e))?;
if agents_stripped != agents_existing {
if !dry_run {
if agents_stripped.is_empty() {
fs::remove_file(&settings_path).map_err(|e| InstallError::Io {
path: settings_path.clone(),
source: e,
})?;
} else {
let mode = current_mode(&settings_path).unwrap_or(0o644);
atomic_write(&settings_path, agents_stripped.as_bytes(), mode)?;
}
}
paths.push(settings_path);
}
for (_, hook_path) in Self::all_hook_paths(repo_root) {
if !hook_path.exists() {
continue;
}
let existing = fs::read_to_string(&hook_path).map_err(|e| InstallError::Io {
path: hook_path.clone(),
source: e,
})?;
if !existing.contains(git_hooks::MANAGED_START) {
continue;
}
let stripped = match git_hooks::uninstall_block(&existing) {
Ok(s) => s,
Err(_) => continue,
};
if stripped == existing {
continue;
}
if !dry_run {
if stripped.is_empty() {
fs::remove_file(&hook_path).map_err(|e| InstallError::Io {
path: hook_path.clone(),
source: e,
})?;
} else {
let mode = current_mode(&hook_path).unwrap_or(0o755);
atomic_write(&hook_path, stripped.as_bytes(), mode)?;
}
}
paths.push(hook_path);
}
Ok(paths)
}
}
fn hook_path_for(repo_root: &Path, kind: HookKind) -> PathBuf {
let segments = match kind {
HookKind::Commit => CodexSurface::PRE_COMMIT_RELPATH,
HookKind::Push => CodexSurface::PRE_PUSH_RELPATH,
};
let mut p = repo_root.to_path_buf();
for seg in segments {
p.push(seg);
}
p
}
enum HookPlanOutcome {
Unchanged,
Conflict(crate::git_hooks::HookConflict),
Write(String),
}
struct HookPlan {
path: PathBuf,
outcome: HookPlanOutcome,
}
fn plan_hook_install(
path: &Path,
kind: HookKind,
schema_version: u32,
) -> Result<HookPlan, InstallError> {
let existing = read_or_empty(path)?;
let already_klasp = git_hooks::contains_block(&existing).map_err(|e| hook_error(path, e))?;
if !already_klasp {
if let Some(conflict) = git_hooks::detect_conflict(&existing) {
return Ok(HookPlan {
path: path.to_path_buf(),
outcome: HookPlanOutcome::Conflict(conflict),
});
}
}
let merged = git_hooks::install_block(&existing, kind, schema_version)
.map_err(|e| hook_error(path, e))?;
if merged == existing {
Ok(HookPlan {
path: path.to_path_buf(),
outcome: HookPlanOutcome::Unchanged,
})
} else {
Ok(HookPlan {
path: path.to_path_buf(),
outcome: HookPlanOutcome::Write(merged),
})
}
}
fn read_or_empty(path: &Path) -> Result<String, InstallError> {
if !path.exists() {
return Ok(String::new());
}
fs::read_to_string(path).map_err(|e| InstallError::Io {
path: path.to_path_buf(),
source: e,
})
}
fn ensure_parent(path: &Path) -> Result<(), InstallError> {
let Some(parent) = path.parent() else {
return Ok(());
};
if parent.as_os_str().is_empty() {
return Ok(());
}
fs::create_dir_all(parent).map_err(|e| InstallError::Io {
path: parent.to_path_buf(),
source: e,
})
}
fn atomic_write(path: &Path, contents: &[u8], mode: u32) -> Result<(), InstallError> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut tf = tempfile::NamedTempFile::new_in(dir).map_err(|e| InstallError::Io {
path: dir.to_path_buf(),
source: e,
})?;
tf.write_all(contents).map_err(|e| InstallError::Io {
path: tf.path().to_path_buf(),
source: e,
})?;
tf.flush().map_err(|e| InstallError::Io {
path: tf.path().to_path_buf(),
source: e,
})?;
apply_mode(tf.path(), mode)?;
tf.persist(path).map_err(|e| InstallError::Io {
path: path.to_path_buf(),
source: e.error,
})?;
Ok(())
}
#[cfg(unix)]
fn current_mode(path: &Path) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
fs::metadata(path).ok().map(|m| m.permissions().mode())
}
#[cfg(not(unix))]
fn current_mode(_path: &Path) -> Option<u32> {
None
}
fn apply_mode(path: &Path, mode: u32) -> Result<(), InstallError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(mode);
fs::set_permissions(path, perms).map_err(|e| InstallError::Io {
path: path.to_path_buf(),
source: e,
})?;
}
#[cfg(not(unix))]
{
let _ = (path, mode);
}
Ok(())
}
fn agents_md_error(path: &Path, error: AgentsMdError) -> InstallError {
InstallError::Surface {
agent_id: CodexSurface::AGENT_ID.to_string(),
message: format!("{}: {error}", path.display()),
}
}
fn hook_error(path: &Path, error: HookError) -> InstallError {
InstallError::Surface {
agent_id: CodexSurface::AGENT_ID.to_string(),
message: format!("{}: {error}", path.display()),
}
}