klasp-agents-codex 0.2.3

Codex agent surface for klasp — writes the AGENTS.md managed-block that documents the gate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! Git-hook managed-block writer + conflict detection for `CodexSurface`.
//!
//! Codex has no programmatic gate equivalent (see [`docs/design.md`] §1),
//! so enforcement runs through real `.git/hooks/pre-commit` and
//! `.git/hooks/pre-push` scripts. This module is the analogue of
//! [`crate::agents_md`] for those shell files. The block is anchored by
//! the [`MANAGED_START`] / [`MANAGED_END`] marker pair so re-installing
//! never disturbs sibling content. [`detect_conflict`] sniffs each hook
//! for husky / lefthook / pre-commit-framework fingerprints; on a match,
//! callers must skip writing and surface a [`HookConflict`] in their
//! report (never fail, never clobber). The idempotency contract matches
//! [`crate::agents_md`]: round-trip canonicalises trailing-newline state
//! to a single `\n`; bytes outside the block are byte-for-byte preserved.

use thiserror::Error;

/// Opening marker for klasp's managed block in a git-hook file. Stable
/// across schema bumps; `install_block` greps for this exact substring to
/// decide whether the file already has a klasp section.
pub const MANAGED_START: &str = "# >>> klasp managed start <<<";

/// Closing marker for klasp's managed block in a git-hook file.
pub const MANAGED_END: &str = "# >>> klasp managed end <<<";

/// Shebang klasp uses when it has to fresh-create a hook. We pick the
/// portable `/usr/bin/env sh` form — git itself ships hook samples with
/// the same shebang, so users running this on minimal Alpine / BSD images
/// get a working interpreter without a hard-coded `/bin/bash` dependency.
pub const SHEBANG: &str = "#!/usr/bin/env sh";

/// Which git-hook this block targets. Drives the `--trigger` arg passed
/// through to `klasp gate`, and the on-disk filename
/// (`.git/hooks/pre-commit` vs `.git/hooks/pre-push`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookKind {
    /// `.git/hooks/pre-commit` — fires on `git commit`.
    Commit,
    /// `.git/hooks/pre-push` — fires on `git push`.
    Push,
}

impl HookKind {
    /// Filename within `.git/hooks/`.
    pub const fn filename(self) -> &'static str {
        match self {
            HookKind::Commit => "pre-commit",
            HookKind::Push => "pre-push",
        }
    }

    /// Value passed to `klasp gate --trigger ...`. Matches the trigger
    /// vocabulary documented in [`klasp_core::trigger`].
    pub const fn trigger_arg(self) -> &'static str {
        match self {
            HookKind::Commit => "commit",
            HookKind::Push => "push",
        }
    }
}

/// A foreign hook manager klasp recognises and refuses to overwrite.
///
/// Detected via public, version-stable fingerprints in the hook file — see
/// [`detect_conflict`] for the exact substrings. The variants are intended
/// for surfacing in [`HookWarning::Skipped`]; the `tool` accessor returns a
/// short canonical name (`"husky"`, `"lefthook"`, `"pre-commit"`) that
/// downstream UIs can render verbatim.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookConflict {
    /// husky — `_/husky.sh` shim is `source`d at the top of the hook.
    Husky,
    /// lefthook — `lefthook` invocation + the explicit
    /// `# DON'T REMOVE THIS LINE (lefthook)` marker.
    Lefthook,
    /// pre-commit framework — `# File generated by pre-commit:
    /// https://pre-commit.com` banner.
    PreCommit,
}

impl HookConflict {
    /// Short canonical tool name, suitable for log output.
    pub const fn tool(self) -> &'static str {
        match self {
            HookConflict::Husky => "husky",
            HookConflict::Lefthook => "lefthook",
            HookConflict::PreCommit => "pre-commit",
        }
    }
}

/// Structured warning surfaced by `CodexSurface::install_detailed` when
/// the hook write was skipped (or otherwise non-fatally adjusted). Never
/// raised as an error — the install completes; the warning rides
/// alongside the report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookWarning {
    /// The hook file is owned by another tool ([`HookConflict`]). klasp
    /// did not modify it. The user should integrate the gate manually,
    /// e.g. by adding `klasp gate --agent codex --trigger commit` to
    /// their pre-commit framework's hook list.
    Skipped {
        path: std::path::PathBuf,
        kind: HookKind,
        conflict: HookConflict,
    },
}

#[derive(Debug, Error)]
pub enum HookError {
    /// The hook file contains an unmatched marker pair (start without
    /// end, end without start, or duplicate markers). We refuse to coerce
    /// the file because the safe action — overwriting from the first
    /// marker to EOF — could nuke hand-written content the user intended
    /// to keep.
    #[error(
        "git hook: managed-block markers are malformed \
         (expected exactly one `{MANAGED_START}` followed by one `{MANAGED_END}`). \
         Fix the file by hand or remove both markers and re-run install."
    )]
    MalformedMarkers,
}

/// Render the inner body of klasp's managed block for the given hook
/// kind. Pure: no env, no IO. The result is the lines *between* the
/// markers — [`render_managed_block`] adds the markers themselves.
pub fn render_managed_body(kind: HookKind, schema_version: u32) -> String {
    // Notes on the script itself:
    // - `KLASP_GATE_SCHEMA` is exported as part of the same command via
    //   `env`-style prefix so the value is in the child's environment
    //   regardless of whether the user's parent shell exports it. See
    //   [`klasp_core::GateProtocol::read_schema_from_env`] for the
    //   schema-mismatch detection path this feeds into.
    // - `exec` replaces the shell with `klasp gate` so the hook's exit
    //   status *is* the gate's exit status — git interprets non-zero as
    //   "block this commit/push".
    // - `"$@"` propagates whatever args git passed to the hook (commit
    //   message file, refs, etc.) untouched.
    format!(
        "# Managed by klasp install. Re-run `klasp install` to regenerate.\n\
         KLASP_GATE_SCHEMA={ver} exec klasp gate --agent codex --trigger {trigger} \"$@\"\n",
        ver = schema_version,
        trigger = kind.trigger_arg(),
    )
}

/// Render the full managed block (markers + body) for the given hook.
///
/// Pure: no IO. The output starts with [`MANAGED_START`] on its own line,
/// ends with [`MANAGED_END`] on its own line, with the body sandwiched.
pub fn render_managed_block(kind: HookKind, schema_version: u32) -> String {
    let body = render_managed_body(kind, schema_version);
    let trimmed = body.trim_end_matches('\n');
    format!("{MANAGED_START}\n{trimmed}\n{MANAGED_END}\n")
}

/// Splice klasp's managed block into `existing`, returning the new file
/// body.
///
/// Behaviour matrix:
///
/// | Input shape                        | Output shape                                          |
/// |------------------------------------|-------------------------------------------------------|
/// | empty / all-whitespace             | `<shebang>\n\n<block>` (fresh-create)                 |
/// | starts with a shebang, no block    | `<existing>\n\n<block>` (append after user content)   |
/// | no shebang, no block               | `<shebang>\n\n<existing>\n\n<block>`                  |
/// | contains a managed block           | block contents replaced in-place                      |
///
/// Idempotent: when the existing block already matches the rendered block
/// byte-for-byte and no shebang prepending was needed, the input is
/// returned unchanged.
pub fn install_block(
    existing: &str,
    kind: HookKind,
    schema_version: u32,
) -> Result<String, HookError> {
    let block = render_managed_block(kind, schema_version);

    if let Some(span) = find_block(existing)? {
        // Replace in-place. Preserve everything outside [start, end).
        let mut out = String::with_capacity(existing.len() + block.len());
        out.push_str(&existing[..span.start]);
        out.push_str(&block);
        out.push_str(&existing[span.end..]);
        return Ok(out);
    }

    // No existing block. Decide on shebang prelude + appropriate spacing.
    let trimmed = existing.trim();
    if trimmed.is_empty() {
        // Fresh-create: shebang line + blank line + block. The blank
        // line keeps the marker visually separate from the shebang and
        // matches the structure `uninstall_block` reverses below.
        let mut out = String::with_capacity(SHEBANG.len() + block.len() + 2);
        out.push_str(SHEBANG);
        out.push_str("\n\n");
        out.push_str(&block);
        return Ok(out);
    }

    // Existing user content. If the file already has a shebang, just
    // append the block after the user content with a one-line spacer.
    // Otherwise prepend our own shebang first — without one, git won't
    // execute the hook on systems that don't have an inherited
    // interpreter for the file mode.
    let mut out = String::with_capacity(existing.len() + SHEBANG.len() + block.len() + 4);
    if has_shebang(existing) {
        out.push_str(existing.trim_end_matches('\n'));
        out.push_str("\n\n");
        out.push_str(&block);
    } else {
        out.push_str(SHEBANG);
        out.push_str("\n\n");
        out.push_str(existing.trim_end_matches('\n'));
        out.push_str("\n\n");
        out.push_str(&block);
    }
    Ok(out)
}

/// Inverse of [`install_block`]: remove klasp's managed block and the
/// blank-line separator install inserted around it.
///
/// Idempotent: a file with no managed block is returned unchanged. A file
/// where klasp was the only meaningful content (shebang + block, no user
/// commands) is returned as the empty string — the caller can use that
/// signal to remove the file altogether and round-trip the missing-file
/// install path.
///
/// The trailing-newline normalisation matches [`crate::agents_md`]: when
/// install *appended* the block to user content, uninstall restores the
/// canonical `<content>\n` shape. The pre-install file ending in exactly
/// one `\n` round-trips byte-for-byte; an input without a trailing
/// newline gains one. This is the same tolerated normalisation the
/// AGENTS.md writer documents.
pub fn uninstall_block(existing: &str) -> Result<String, HookError> {
    let Some(span) = find_block(existing)? else {
        return Ok(existing.to_string());
    };

    let before = &existing[..span.start];
    let after = &existing[span.end..];

    // Three shapes possible after stripping the block:
    //
    // 1. `before` is empty (block was at byte 0): collapse to `after`.
    //    The fresh-create path *never* hits this — install always
    //    prepends a shebang. The path is reachable only via a
    //    user-fabricated input whose first byte is the start marker.
    //
    // 2. `before` is just our shebang + whitespace, `after` is empty:
    //    this is the round-trip from the missing-file install. Collapse
    //    to empty so the caller can `rm` the file.
    //
    // 3. `before` has real content: strip the trailing `\n\n` install
    //    inserted as a separator, restore canonical `<content>\n`. If
    //    `after` is non-empty, leave it in place verbatim.
    let mut out = String::with_capacity(before.len() + after.len() + 1);
    if before.is_empty() {
        out.push_str(after);
    } else if after.is_empty() && is_only_shebang_or_whitespace(before) {
        // Shebang-only prefix means klasp was the sole content. Collapse
        // to empty so the caller deletes the file.
    } else if after.is_empty() {
        out.push_str(before.trim_end_matches('\n'));
        out.push('\n');
    } else {
        out.push_str(before);
        out.push_str(after);
    }
    Ok(out)
}

/// `true` when `existing` already contains a (well-formed) klasp managed
/// block — used by callers to decide whether install is a no-op.
pub fn contains_block(existing: &str) -> Result<bool, HookError> {
    Ok(find_block(existing)?.is_some())
}

/// Inspect a hook file's contents for a recognised foreign hook manager.
///
/// Returns `Some(HookConflict)` when the file is one we know not to
/// touch. Returns `None` for files we either own (klasp markers present)
/// or for plain user-authored hooks (which we'll merge into).
///
/// Matches are deliberately conservative — we only fingerprint markers
/// that the foreign tool itself drops in:
///
/// - **husky** ≥ v8 sources `_/husky.sh` (or its successor)
///   `_/h` / `_/husky` shim. Older v4-v7 husky used `husky.sh` directly.
///   We match either form.
/// - **lefthook** drops a `# DON'T REMOVE THIS LINE (lefthook)` sentinel
///   in the hooks it generates. The sentinel is searched in addition to
///   the bare `lefthook` invocation to avoid false positives on hooks
///   that merely *call* `lefthook` from a wrapper.
/// - **pre-commit** stamps each generated hook with the
///   `# File generated by pre-commit: https://pre-commit.com` banner.
pub fn detect_conflict(existing: &str) -> Option<HookConflict> {
    // Husky's dotted-source line is the only place these substrings
    // legitimately appear; the leading `/` and trailing `"` together
    // anchor on `. "$(dirname -- "$0")/_/<shim>"` and refuse plausible
    // false-positive contexts (a user comment, a here-doc, a `cd` path).
    if existing.contains("/_/husky.sh\"") || existing.contains("/_/h\"") {
        return Some(HookConflict::Husky);
    }
    // Lefthook: anti-deletion sentinel + `lefthook` invocation token.
    if existing.contains("DON'T REMOVE THIS LINE (lefthook)") && existing.contains("lefthook") {
        return Some(HookConflict::Lefthook);
    }
    if existing.contains("File generated by pre-commit: https://pre-commit.com") {
        return Some(HookConflict::PreCommit);
    }
    None
}

/// Byte span of the managed block within `existing`, including both
/// markers and the trailing `\n` after the end marker. Mirrors the
/// equivalent helper in [`crate::agents_md`].
struct Span {
    start: usize,
    end: usize,
}

fn find_block(existing: &str) -> Result<Option<Span>, HookError> {
    let (Some(start), Some(end_marker_start)) =
        (existing.find(MANAGED_START), existing.find(MANAGED_END))
    else {
        return if existing.contains(MANAGED_START) || existing.contains(MANAGED_END) {
            Err(HookError::MalformedMarkers)
        } else {
            Ok(None)
        };
    };

    if existing.rfind(MANAGED_START) != Some(start)
        || existing.rfind(MANAGED_END) != Some(end_marker_start)
        || end_marker_start < start
    {
        return Err(HookError::MalformedMarkers);
    }

    let after_marker = end_marker_start + MANAGED_END.len();
    let end = if existing.as_bytes().get(after_marker) == Some(&b'\n') {
        after_marker + 1
    } else {
        after_marker
    };
    Ok(Some(Span { start, end }))
}

fn has_shebang(s: &str) -> bool {
    s.starts_with("#!")
}

/// Returns `true` when `s` consists of only a shebang line plus
/// whitespace (and nothing else). Used by [`uninstall_block`] to detect
/// the round-trip-from-missing-file case where klasp owns the entire
/// file.
fn is_only_shebang_or_whitespace(s: &str) -> bool {
    let trimmed = s.trim();
    trimmed.is_empty() || (trimmed.starts_with("#!") && !trimmed.contains('\n'))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn render_block_wraps_body_in_markers() {
        let s = render_managed_block(HookKind::Commit, 1);
        assert!(s.starts_with(MANAGED_START));
        assert!(s.contains("KLASP_GATE_SCHEMA=1"));
        assert!(s.contains("--trigger commit"));
        assert!(s.contains("--agent codex"));
        assert!(s.trim_end().ends_with(MANAGED_END));
    }

    #[test]
    fn render_block_uses_push_trigger_for_pre_push() {
        let s = render_managed_block(HookKind::Push, 1);
        assert!(s.contains("--trigger push"));
        assert!(!s.contains("--trigger commit"));
    }

    #[test]
    fn render_block_parameterises_schema_version() {
        let s = render_managed_block(HookKind::Commit, 7);
        assert!(s.contains("KLASP_GATE_SCHEMA=7"));
    }

    #[test]
    fn install_into_empty_emits_shebang_and_block() {
        let out = install_block("", HookKind::Commit, 1).unwrap();
        assert!(out.starts_with(SHEBANG));
        assert!(out.contains(MANAGED_START));
        assert!(out.trim_end().ends_with(MANAGED_END));
    }

    #[test]
    fn install_into_user_hook_with_shebang_appends() {
        let pre = "#!/bin/bash\n\necho 'user lint'\n";
        let out = install_block(pre, HookKind::Commit, 1).unwrap();
        assert!(out.starts_with(pre));
        // Block lives after the user content.
        assert!(out.contains("echo 'user lint'"));
        let after_pre = &out[pre.len()..];
        assert!(after_pre.starts_with('\n'));
        assert!(after_pre[1..].starts_with(MANAGED_START));
    }

    #[test]
    fn install_into_user_hook_without_shebang_prepends_one() {
        let pre = "echo lint\n";
        let out = install_block(pre, HookKind::Commit, 1).unwrap();
        assert!(out.starts_with(SHEBANG));
        assert!(out.contains("echo lint"));
        assert!(out.contains(MANAGED_START));
    }

    #[test]
    fn install_replaces_existing_block_in_place() {
        let stale = render_managed_block(HookKind::Commit, 0);
        let pre = format!("#!/bin/bash\n\n{stale}\nset -e\n");
        let out = install_block(&pre, HookKind::Commit, 1).unwrap();
        assert!(out.contains("KLASP_GATE_SCHEMA=1"));
        assert!(!out.contains("KLASP_GATE_SCHEMA=0"));
        assert!(out.starts_with("#!/bin/bash\n\n"));
        assert!(out.ends_with("set -e\n"));
    }

    #[test]
    fn install_is_idempotent() {
        let pre = "#!/bin/bash\n\necho 'user lint'\n";
        let once = install_block(pre, HookKind::Commit, 1).unwrap();
        let twice = install_block(&once, HookKind::Commit, 1).unwrap();
        assert_eq!(once, twice);
    }

    #[test]
    fn install_uninstall_round_trip_on_user_hook_restores_input() {
        let pre = "#!/bin/bash\n\necho 'user lint'\n";
        let installed = install_block(pre, HookKind::Commit, 1).unwrap();
        let restored = uninstall_block(&installed).unwrap();
        assert_eq!(restored, pre);
    }

    #[test]
    fn install_uninstall_round_trip_on_empty_collapses_to_empty() {
        let installed = install_block("", HookKind::Commit, 1).unwrap();
        let restored = uninstall_block(&installed).unwrap();
        assert_eq!(restored, "", "fresh-create round-trip must empty out");
    }

    #[test]
    fn uninstall_is_noop_when_no_block_present() {
        let pre = "#!/bin/bash\n\necho lint\n";
        assert_eq!(uninstall_block(pre).unwrap(), pre);
    }

    #[test]
    fn malformed_markers_rejected() {
        let pre = format!("#!/bin/bash\n{MANAGED_START}\nbody\n");
        let err = install_block(&pre, HookKind::Commit, 1).expect_err("must fail");
        assert!(matches!(err, HookError::MalformedMarkers));
    }

    #[test]
    fn duplicate_start_marker_rejected() {
        let pre = format!(
            "#!/bin/bash\n{MANAGED_START}\nbody\n{MANAGED_END}\n{MANAGED_START}\nbody2\n{MANAGED_END}\n"
        );
        let err = install_block(&pre, HookKind::Commit, 1).expect_err("must fail");
        assert!(matches!(err, HookError::MalformedMarkers));
    }

    #[test]
    fn detect_conflict_avoids_false_positive_on_lone_lefthook_word() {
        // A user hook that mentions "lefthook" in a comment but doesn't
        // have the sentinel must not match. Positive cases for husky /
        // lefthook / pre-commit framework live in the integration test
        // suite under `tests/git_hooks_install.rs`, driven from
        // captured `.git/hooks/pre-commit` fixtures.
        let pre = "#!/bin/sh\n# we used to use lefthook, removed it\necho lint\n";
        assert_eq!(detect_conflict(pre), None);
        assert_eq!(detect_conflict(""), None);
    }

    #[test]
    fn hook_kind_constants_are_canonical() {
        assert_eq!(HookKind::Commit.filename(), "pre-commit");
        assert_eq!(HookKind::Push.filename(), "pre-push");
        assert_eq!(HookKind::Commit.trigger_arg(), "commit");
        assert_eq!(HookKind::Push.trigger_arg(), "push");
        assert_eq!(HookConflict::Husky.tool(), "husky");
        assert_eq!(HookConflict::Lefthook.tool(), "lefthook");
        assert_eq!(HookConflict::PreCommit.tool(), "pre-commit");
    }
}