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}