Skip to main content

klasp_agents_codex/
surface.rs

1//! `CodexSurface` — `klasp_core::AgentSurface` impl for Codex.
2//!
3//! Mirrors the install flow of [`klasp_agents_claude::ClaudeCodeSurface`]:
4//! compute paths → render the managed-block bodies → idempotent
5//! merge / replace into the on-disk files → atomic write → report what
6//! changed. The two surfaces differ in their target files (Claude writes
7//! one bash shim and one JSON settings file; Codex writes AGENTS.md *and*
8//! a pair of git hooks) and in their conflict-handling story: Codex has
9//! to coexist with husky / lefthook / pre-commit framework, so the hook
10//! writer skips-with-warning rather than failing the install.
11//!
12//! ## v0.2 W2 scope
13//!
14//! - `install` writes the AGENTS.md managed block (W1 behaviour) **and**
15//!   the `.git/hooks/pre-commit` + `.git/hooks/pre-push` hook files.
16//! - When a foreign hook manager is detected via
17//!   [`git_hooks::detect_conflict`], the hook write is skipped and a
18//!   [`HookWarning`] rides alongside the `InstallReport` (returned via
19//!   the typed [`CodexSurface::install_detailed`] entry-point — the
20//!   plain [`AgentSurface::install`] trait method, which W3 will wire
21//!   into the CLI, discards warnings to keep the cross-crate contract
22//!   `klasp-core` defines unchanged).
23//! - `uninstall` strips the managed block from each managed file and
24//!   removes any file klasp owned end-to-end (round-trip from the
25//!   missing-file install). Sibling content — both other tools' hooks
26//!   and any prose in AGENTS.md — is preserved byte-for-byte.
27//!
28//! ## Windows notes
29//!
30//! `AGENTS.md` is plain text. `.git/hooks/pre-commit` and `pre-push` are
31//! shell scripts that git itself executes through `sh.exe` (Git for
32//! Windows) or whatever the user's git is configured to use; they need
33//! a shebang for portability but no executable bit on NTFS. Behaviour
34//! parity with `klasp_agents_claude` — `apply_mode` is a no-op there too.
35
36use std::fs;
37use std::io::Write;
38use std::path::{Path, PathBuf};
39
40use klasp_core::{AgentSurface, InstallContext, InstallError, InstallReport};
41
42use crate::agents_md::{self, AgentsMdError, DEFAULT_BLOCK_BODY};
43use crate::git_hooks::{self, HookError, HookKind, HookWarning};
44
45/// Codex agent surface. Stateless; the registry stores it as
46/// `Box<dyn AgentSurface>`.
47pub struct CodexSurface;
48
49impl CodexSurface {
50    pub const AGENT_ID: &'static str = "codex";
51
52    /// Filename of the markdown file Codex reads from the repo root.
53    pub const AGENTS_MD: &'static str = "AGENTS.md";
54
55    /// Repo-relative path of the git pre-commit hook.
56    pub const PRE_COMMIT_RELPATH: &'static [&'static str] = &[".git", "hooks", "pre-commit"];
57
58    /// Repo-relative path of the git pre-push hook.
59    pub const PRE_PUSH_RELPATH: &'static [&'static str] = &[".git", "hooks", "pre-push"];
60
61    /// Repo-relative path of the *primary* hook reported via the trait's
62    /// `hook_path` method. Kept as `pre-commit` for parity with the W1
63    /// API; consumers needing both paths should use
64    /// [`Self::all_hook_paths`].
65    pub const HOOK_RELPATH: &'static [&'static str] = Self::PRE_COMMIT_RELPATH;
66
67    /// Both managed hook paths, in install order. W3 callers that want
68    /// to render the full install report (e.g. for `klasp install --dry-run`)
69    /// should iterate this rather than relying on the trait's single
70    /// `hook_path`.
71    pub fn all_hook_paths(repo_root: &Path) -> [(HookKind, PathBuf); 2] {
72        [
73            (HookKind::Commit, hook_path_for(repo_root, HookKind::Commit)),
74            (HookKind::Push, hook_path_for(repo_root, HookKind::Push)),
75        ]
76    }
77
78    /// Detailed install entry-point. Returns the standard
79    /// [`InstallReport`] *and* the list of [`HookWarning`]s collected
80    /// from the hook writer. The trait's [`AgentSurface::install`]
81    /// method calls this and discards the warnings to keep the
82    /// cross-crate trait surface unchanged; W3's CLI plumbing calls this
83    /// method directly so it can render warnings to the user.
84    pub fn install_detailed(
85        &self,
86        ctx: &InstallContext,
87    ) -> Result<CodexInstallReport, InstallError> {
88        // 1. AGENTS.md — same merge contract as W1.
89        let settings_path = self.settings_path(&ctx.repo_root);
90        let agents_existing = read_or_empty(&settings_path)?;
91        let agents_merged = agents_md::install_block(&agents_existing, DEFAULT_BLOCK_BODY)
92            .map_err(|e| agents_md_error(&settings_path, e))?;
93        let agents_unchanged = agents_merged == agents_existing;
94
95        // 2. Hooks — pre-commit and pre-push. Per-hook conflict check;
96        //    on conflict, record a warning and skip the write.
97        let mut hook_plans = Vec::with_capacity(2);
98        let mut warnings = Vec::new();
99        for (kind, path) in Self::all_hook_paths(&ctx.repo_root) {
100            let plan = plan_hook_install(&path, kind, ctx.schema_version)?;
101            if let HookPlanOutcome::Conflict(conflict) = plan.outcome {
102                warnings.push(HookWarning::Skipped {
103                    path: path.clone(),
104                    kind,
105                    conflict,
106                });
107            }
108            hook_plans.push(plan);
109        }
110
111        let all_already_installed = agents_unchanged
112            && hook_plans
113                .iter()
114                .all(|p| matches!(p.outcome, HookPlanOutcome::Unchanged));
115
116        // 3. Dry-run: report shape only, no writes. Preview is the
117        //    AGENTS.md merged body — that's the most user-readable thing
118        //    we can show, and matches W1 behaviour.
119        if ctx.dry_run {
120            return Ok(CodexInstallReport {
121                report: InstallReport {
122                    agent_id: Self::AGENT_ID.to_string(),
123                    hook_path: hook_path_for(&ctx.repo_root, HookKind::Commit),
124                    settings_path,
125                    already_installed: all_already_installed,
126                    paths_written: Vec::new(),
127                    preview: Some(agents_merged),
128                },
129                warnings,
130            });
131        }
132
133        // 4. Apply the plans. Order: AGENTS.md first (cheapest to roll
134        //    back if a hook write fails partway through), then each
135        //    hook with its individual atomic write.
136        let mut paths_written = Vec::new();
137
138        if !agents_unchanged {
139            ensure_parent(&settings_path)?;
140            let mode = current_mode(&settings_path).unwrap_or(0o644);
141            atomic_write(&settings_path, agents_merged.as_bytes(), mode)?;
142            paths_written.push(settings_path.clone());
143        }
144
145        for plan in hook_plans {
146            match plan.outcome {
147                HookPlanOutcome::Write(merged) => {
148                    ensure_parent(&plan.path)?;
149                    // Hook scripts must be executable. Honour the user's
150                    // pre-existing mode if they had one (so we don't
151                    // *demote* a 0o775 hook to 0o755), otherwise fall
152                    // back to the canonical 0o755.
153                    let mode = current_mode(&plan.path).unwrap_or(0o755);
154                    atomic_write(&plan.path, merged.as_bytes(), mode)?;
155                    paths_written.push(plan.path);
156                }
157                HookPlanOutcome::Unchanged | HookPlanOutcome::Conflict(_) => {
158                    // Either already up-to-date or owned by a foreign
159                    // tool — both no-op for the writer.
160                }
161            }
162        }
163
164        Ok(CodexInstallReport {
165            report: InstallReport {
166                agent_id: Self::AGENT_ID.to_string(),
167                hook_path: hook_path_for(&ctx.repo_root, HookKind::Commit),
168                settings_path,
169                already_installed: all_already_installed,
170                paths_written,
171                preview: None,
172            },
173            warnings,
174        })
175    }
176}
177
178/// Result of a [`CodexSurface::install_detailed`] call. Bundles the
179/// standard [`InstallReport`] with the per-hook warnings collected
180/// during install.
181#[derive(Debug)]
182pub struct CodexInstallReport {
183    pub report: InstallReport,
184    pub warnings: Vec<HookWarning>,
185}
186
187impl AgentSurface for CodexSurface {
188    fn agent_id(&self) -> &'static str {
189        Self::AGENT_ID
190    }
191
192    fn detect(&self, repo_root: &Path) -> bool {
193        // Codex looks for `AGENTS.md` at the repo root. We treat its
194        // presence as the auto-detect signal; `klasp install --force`
195        // overrides a `false` return if the user wants to bootstrap the
196        // file from scratch.
197        repo_root.join(Self::AGENTS_MD).is_file()
198    }
199
200    fn hook_path(&self, repo_root: &Path) -> PathBuf {
201        hook_path_for(repo_root, HookKind::Commit)
202    }
203
204    fn settings_path(&self, repo_root: &Path) -> PathBuf {
205        repo_root.join(Self::AGENTS_MD)
206    }
207
208    fn render_hook_script(&self, ctx: &InstallContext) -> String {
209        // Trait contract returns a single string; we pick the
210        // pre-commit body since that's what `hook_path` reports. W3's
211        // CLI dry-run renderer can call `git_hooks::install_block`
212        // directly when it needs the pre-push body too.
213        git_hooks::install_block("", HookKind::Commit, ctx.schema_version).unwrap_or_default()
214    }
215
216    fn install(&self, ctx: &InstallContext) -> Result<InstallReport, InstallError> {
217        // Discard warnings; the `InstallReport` shape is fixed by
218        // `klasp-core` and we may not extend it from here. W3 will
219        // call `install_detailed` from the CLI to surface them.
220        Ok(self.install_detailed(ctx)?.report)
221    }
222
223    fn uninstall(&self, repo_root: &Path, dry_run: bool) -> Result<Vec<PathBuf>, InstallError> {
224        let mut paths = Vec::new();
225
226        // 1. AGENTS.md — strip block, remove the file if klasp was the
227        //    sole content (round-trip from missing-file install).
228        let settings_path = self.settings_path(repo_root);
229        let agents_existing = read_or_empty(&settings_path)?;
230        let agents_stripped = agents_md::uninstall_block(&agents_existing)
231            .map_err(|e| agents_md_error(&settings_path, e))?;
232        if agents_stripped != agents_existing {
233            if !dry_run {
234                if agents_stripped.is_empty() {
235                    fs::remove_file(&settings_path).map_err(|e| InstallError::Io {
236                        path: settings_path.clone(),
237                        source: e,
238                    })?;
239                } else {
240                    let mode = current_mode(&settings_path).unwrap_or(0o644);
241                    atomic_write(&settings_path, agents_stripped.as_bytes(), mode)?;
242                }
243            }
244            paths.push(settings_path);
245        }
246
247        // 2. Each hook — same shape, but if klasp was the only content
248        //    we delete the file (so `git` falls back to its no-hook
249        //    default rather than executing a shebang-only stub).
250        //
251        //    Mangled-marker tolerance: a hook that has the start marker
252        //    without a matching end (or the pair in the wrong order) is
253        //    treated as "user has hand-edited this and we don't know how
254        //    to safely strip" — we leave the file alone rather than
255        //    erroring partway through and leaving the repo half-uninstalled
256        //    (AGENTS.md gone but hooks intact). The user can fix the
257        //    markers and re-run; meanwhile install reports a non-fatal
258        //    skip for that path.
259        for (_, hook_path) in Self::all_hook_paths(repo_root) {
260            if !hook_path.exists() {
261                continue;
262            }
263            let existing = fs::read_to_string(&hook_path).map_err(|e| InstallError::Io {
264                path: hook_path.clone(),
265                source: e,
266            })?;
267            // If klasp doesn't own this file, leave it alone. This is
268            // the symmetric inverse of the install-time conflict skip:
269            // a husky / lefthook / pre-commit-framework hook never
270            // gained a klasp marker, so it has nothing for us to strip.
271            if !existing.contains(git_hooks::MANAGED_START) {
272                continue;
273            }
274            let stripped = match git_hooks::uninstall_block(&existing) {
275                Ok(s) => s,
276                Err(_) => continue,
277            };
278            if stripped == existing {
279                continue;
280            }
281            if !dry_run {
282                if stripped.is_empty() {
283                    fs::remove_file(&hook_path).map_err(|e| InstallError::Io {
284                        path: hook_path.clone(),
285                        source: e,
286                    })?;
287                } else {
288                    let mode = current_mode(&hook_path).unwrap_or(0o755);
289                    atomic_write(&hook_path, stripped.as_bytes(), mode)?;
290                }
291            }
292            paths.push(hook_path);
293        }
294
295        Ok(paths)
296    }
297}
298
299fn hook_path_for(repo_root: &Path, kind: HookKind) -> PathBuf {
300    let segments = match kind {
301        HookKind::Commit => CodexSurface::PRE_COMMIT_RELPATH,
302        HookKind::Push => CodexSurface::PRE_PUSH_RELPATH,
303    };
304    let mut p = repo_root.to_path_buf();
305    for seg in segments {
306        p.push(seg);
307    }
308    p
309}
310
311/// What `install` should do with one hook file.
312enum HookPlanOutcome {
313    /// Existing content already matches what we'd write — no-op.
314    Unchanged,
315    /// Foreign hook manager detected; skip with a warning.
316    Conflict(crate::git_hooks::HookConflict),
317    /// Write `merged` to disk.
318    Write(String),
319}
320
321struct HookPlan {
322    path: PathBuf,
323    outcome: HookPlanOutcome,
324}
325
326fn plan_hook_install(
327    path: &Path,
328    kind: HookKind,
329    schema_version: u32,
330) -> Result<HookPlan, InstallError> {
331    let existing = read_or_empty(path)?;
332
333    // klasp already manages this file → drive the standard managed-block
334    // merge. Conflict detection on a klasp-owned file is meaningless;
335    // checking *first* would force-skip even our own hooks if a tool
336    // marker happened to land in a sibling line, so we route through
337    // marker detection before fingerprint sniffing.
338    let already_klasp = git_hooks::contains_block(&existing).map_err(|e| hook_error(path, e))?;
339    if !already_klasp {
340        if let Some(conflict) = git_hooks::detect_conflict(&existing) {
341            return Ok(HookPlan {
342                path: path.to_path_buf(),
343                outcome: HookPlanOutcome::Conflict(conflict),
344            });
345        }
346    }
347
348    let merged = git_hooks::install_block(&existing, kind, schema_version)
349        .map_err(|e| hook_error(path, e))?;
350
351    if merged == existing {
352        Ok(HookPlan {
353            path: path.to_path_buf(),
354            outcome: HookPlanOutcome::Unchanged,
355        })
356    } else {
357        Ok(HookPlan {
358            path: path.to_path_buf(),
359            outcome: HookPlanOutcome::Write(merged),
360        })
361    }
362}
363
364fn read_or_empty(path: &Path) -> Result<String, InstallError> {
365    if !path.exists() {
366        return Ok(String::new());
367    }
368    fs::read_to_string(path).map_err(|e| InstallError::Io {
369        path: path.to_path_buf(),
370        source: e,
371    })
372}
373
374fn ensure_parent(path: &Path) -> Result<(), InstallError> {
375    let Some(parent) = path.parent() else {
376        return Ok(());
377    };
378    if parent.as_os_str().is_empty() {
379        return Ok(());
380    }
381    fs::create_dir_all(parent).map_err(|e| InstallError::Io {
382        path: parent.to_path_buf(),
383        source: e,
384    })
385}
386
387/// Atomic write via tempfile + rename. `mode` is applied to the *temp*
388/// file before the rename so the published file is never visible at
389/// `NamedTempFile`'s `0o600` default — a concurrent `git commit` between
390/// `persist` and a post-rename `chmod` would otherwise see a hook with
391/// the executable bit cleared and abort with EACCES.
392fn atomic_write(path: &Path, contents: &[u8], mode: u32) -> Result<(), InstallError> {
393    let dir = path.parent().unwrap_or_else(|| Path::new("."));
394    let mut tf = tempfile::NamedTempFile::new_in(dir).map_err(|e| InstallError::Io {
395        path: dir.to_path_buf(),
396        source: e,
397    })?;
398    tf.write_all(contents).map_err(|e| InstallError::Io {
399        path: tf.path().to_path_buf(),
400        source: e,
401    })?;
402    tf.flush().map_err(|e| InstallError::Io {
403        path: tf.path().to_path_buf(),
404        source: e,
405    })?;
406    apply_mode(tf.path(), mode)?;
407    tf.persist(path).map_err(|e| InstallError::Io {
408        path: path.to_path_buf(),
409        source: e.error,
410    })?;
411    Ok(())
412}
413
414#[cfg(unix)]
415fn current_mode(path: &Path) -> Option<u32> {
416    use std::os::unix::fs::PermissionsExt;
417    fs::metadata(path).ok().map(|m| m.permissions().mode())
418}
419
420#[cfg(not(unix))]
421fn current_mode(_path: &Path) -> Option<u32> {
422    None
423}
424
425fn apply_mode(path: &Path, mode: u32) -> Result<(), InstallError> {
426    #[cfg(unix)]
427    {
428        use std::os::unix::fs::PermissionsExt;
429        let perms = std::fs::Permissions::from_mode(mode);
430        fs::set_permissions(path, perms).map_err(|e| InstallError::Io {
431            path: path.to_path_buf(),
432            source: e,
433        })?;
434    }
435    #[cfg(not(unix))]
436    {
437        let _ = (path, mode);
438    }
439    Ok(())
440}
441
442fn agents_md_error(path: &Path, error: AgentsMdError) -> InstallError {
443    InstallError::Surface {
444        agent_id: CodexSurface::AGENT_ID.to_string(),
445        message: format!("{}: {error}", path.display()),
446    }
447}
448
449fn hook_error(path: &Path, error: HookError) -> InstallError {
450    InstallError::Surface {
451        agent_id: CodexSurface::AGENT_ID.to_string(),
452        message: format!("{}: {error}", path.display()),
453    }
454}