Skip to main content

par_term/mux_hook_installer/
codex.rs

1//! codex arm: codex reads claude-format hooks from `~/.codex/hooks.json`,
2//! gated by a `[features] hooks = true` flag in `~/.codex/config.toml`
3//! (herdr's proven model). Installing merges a matcher-less SessionStart
4//! entry into hooks.json with the engine in [`super`] and enables the flag
5//! with a comment-preserving line edit; uninstall removes the entries and
6//! the script but leaves the flag — without hook entries it enables nothing.
7
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12use super::{
13    home_dir, hook_asset_path as asset_path_for, hook_command_with_action, merge_session_start,
14    remove_marked_file, save_settings, unmerge_session_start, write_hook_asset,
15};
16
17const CODEX_HOOK_ASSET_POSIX: &str = include_str!("../../mux_hooks/par-mux-codex-session-hook.sh");
18const CODEX_HOOK_ASSET_WINDOWS: &str =
19    include_str!("../../mux_hooks/par-mux-codex-session-hook.ps1");
20
21/// The asset file name for the current platform — the `.ps1` port on
22/// Windows, the `.sh` reporter elsewhere. Runtime-selected (not cfg-split)
23/// so both arms compile on every platform and the selection is assertable
24/// in tests instead of invisible to the non-Windows build.
25fn codex_hook_install_name() -> &'static str {
26    if cfg!(windows) {
27        "par-mux-codex-session-hook.ps1"
28    } else {
29        "par-mux-codex-session-hook.sh"
30    }
31}
32
33/// The hook asset contents for the current platform.
34fn codex_hook_asset() -> &'static str {
35    if cfg!(windows) {
36        CODEX_HOOK_ASSET_WINDOWS
37    } else {
38        CODEX_HOOK_ASSET_POSIX
39    }
40}
41
42/// Marker identifying the installed codex script as ours.
43pub const CODEX_HOOK_MARKER: &str = "PAR_MUX_INTEGRATION_ID=codex";
44
45const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
46const CODEX_HOOKS_FILE_NAME: &str = "hooks.json";
47const CODEX_CONFIG_FILE_NAME: &str = "config.toml";
48
49/// Outcome of a codex hook install.
50#[derive(Debug)]
51pub struct CodexHookInstall {
52    /// Where the hook script asset was written.
53    pub hook_path: PathBuf,
54    /// The claude-format hooks file that was merged into.
55    pub hooks_path: PathBuf,
56    /// The TOML config whose `[features] hooks` flag was enabled.
57    pub config_path: PathBuf,
58    /// Whether hooks.json changed (false = our entry was already present).
59    pub hooks_changed: bool,
60    /// Whether config.toml changed (false = the flag was already enabled).
61    pub config_changed: bool,
62}
63
64/// Outcome of a codex hook uninstall.
65#[derive(Debug)]
66pub struct CodexHookUninstall {
67    pub hook_path: PathBuf,
68    pub hooks_path: PathBuf,
69    /// The flag in this file is deliberately left behind — see the module doc.
70    pub config_path: PathBuf,
71    pub hooks_changed: bool,
72    pub hook_removed: bool,
73}
74
75/// Resolve the codex config home (`$CODEX_HOME`, which codex honors, else
76/// `~/.codex`).
77pub fn codex_config_dir() -> io::Result<PathBuf> {
78    if let Some(dir) = std::env::var_os(CODEX_HOME_ENV_VAR).filter(|value| !value.is_empty()) {
79        return Ok(PathBuf::from(dir));
80    }
81    Ok(home_dir()?.join(".codex"))
82}
83
84/// Where the hook script asset lives: par-term's own config directory, the
85/// same tree the other hook scripts are written to.
86fn hook_asset_path() -> PathBuf {
87    asset_path_for(codex_hook_install_name())
88}
89
90/// Install the codex session hook: write the script asset, merge the
91/// SessionStart entry into hooks.json, and enable `[features] hooks`.
92pub fn install_codex_hook() -> io::Result<CodexHookInstall> {
93    install_codex_hook_into(&codex_config_dir()?, &hook_asset_path())
94}
95
96/// Install with explicit paths (test/install seam).
97pub fn install_codex_hook_into(codex_dir: &Path, hook_path: &Path) -> io::Result<CodexHookInstall> {
98    if !codex_dir.is_dir() {
99        return Err(io::Error::other(format!(
100            "codex config directory not found at {}. install codex first",
101            codex_dir.display()
102        )));
103    }
104    let hooks_path = codex_dir.join(CODEX_HOOKS_FILE_NAME);
105    let config_path = codex_dir.join(CODEX_CONFIG_FILE_NAME);
106
107    let hooks_content = read_or_default(&hooks_path, "{}\n")?;
108    let config_content = read_or_default(&config_path, "")?;
109
110    // Merge and verify BOTH files before writing anything: a malformed user
111    // config must fail with every file — and the asset — untouched.
112    let command = codex_hook_command(hook_path);
113    let merged = merge_session_start(&hooks_content, &hooks_path, &command, None)?;
114    let config_updated = ensure_features_hooks_true(&config_content, &config_path)?;
115
116    write_hook_asset(hook_path, codex_hook_asset())?;
117
118    if let Some(updated) = &merged {
119        save_settings(&hooks_path, updated)?;
120    }
121    if let Some(updated) = &config_updated {
122        save_settings(&config_path, updated)?;
123    }
124
125    Ok(CodexHookInstall {
126        hook_path: hook_path.to_path_buf(),
127        hooks_path,
128        config_path,
129        hooks_changed: merged.is_some(),
130        config_changed: config_updated.is_some(),
131    })
132}
133
134/// Uninstall the codex session hook: remove our entries from hooks.json and
135/// delete the script asset when it is ours. The `[features] hooks` flag in
136/// config.toml is left behind: without hook entries it enables nothing, and
137/// removing a flag the user may have set themselves is not ours to decide.
138pub fn uninstall_codex_hook() -> io::Result<CodexHookUninstall> {
139    uninstall_codex_hook_into(&codex_config_dir()?, &hook_asset_path())
140}
141
142/// Uninstall with explicit paths (test/install seam).
143pub fn uninstall_codex_hook_into(
144    codex_dir: &Path,
145    hook_path: &Path,
146) -> io::Result<CodexHookUninstall> {
147    let hooks_path = codex_dir.join(CODEX_HOOKS_FILE_NAME);
148    let config_path = codex_dir.join(CODEX_CONFIG_FILE_NAME);
149
150    let mut hooks_changed = false;
151    if hooks_path.is_file() {
152        let content = fs::read_to_string(&hooks_path)?;
153        let command = codex_hook_command(hook_path);
154        if let Some(updated) = unmerge_session_start(&content, &hooks_path, &command)? {
155            save_settings(&hooks_path, &updated)?;
156            hooks_changed = true;
157        }
158    }
159
160    let hook_removed = remove_marked_file(hook_path, CODEX_HOOK_MARKER)?;
161
162    Ok(CodexHookUninstall {
163        hook_path: hook_path.to_path_buf(),
164        hooks_path,
165        config_path,
166        hooks_changed,
167        hook_removed,
168    })
169}
170
171/// The hooks.json command: `sh <script> session` on POSIX, herdr's
172/// powershell -File form on Windows — both always quote the path (the
173/// grok-arm heritage).
174fn codex_hook_command(hook_path: &Path) -> String {
175    hook_command_with_action(hook_path, "session")
176}
177
178fn read_or_default(path: &Path, default: &str) -> io::Result<String> {
179    match fs::read_to_string(path) {
180        Ok(content) => Ok(content),
181        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(default.to_string()),
182        Err(err) => Err(err),
183    }
184}
185
186/// Enable `[features] hooks = true` in codex config.toml text. `Ok(None)` =
187/// already enabled, byte-exact no-op. The edit is a line splice that never
188/// moves untouched bytes, and the result is verified by a real TOML parse
189/// before the caller is allowed to write it.
190fn ensure_features_hooks_true(content: &str, config_path: &Path) -> io::Result<Option<String>> {
191    // Parse before touching: a config that is not valid TOML is a clear
192    // error with nothing written, mirroring the jsonc arms.
193    let parsed = toml::from_str::<toml::Table>(content).map_err(|err| {
194        io::Error::other(format!("failed to parse {}: {err}", config_path.display()))
195    })?;
196    if features_hooks_enabled(&parsed) {
197        return Ok(None);
198    }
199
200    let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
201    let trailing_newline = content.ends_with('\n');
202    let mut features_header_index = None;
203    let mut hooks_line_index = None;
204    let mut in_features = false;
205
206    for (index, line) in lines.iter().enumerate() {
207        if let Some(header) = toml_table_header(line) {
208            in_features = header == "[features]";
209            if in_features && features_header_index.is_none() {
210                features_header_index = Some(index);
211            }
212            continue;
213        }
214        if in_features && hooks_line_index.is_none() && is_toml_key(line, "hooks") {
215            hooks_line_index = Some(index);
216        }
217    }
218
219    let updated = if let Some(hooks_index) = hooks_line_index {
220        let line = &lines[hooks_index];
221        let comment = toml_comment_start(line).map(str::to_string);
222        lines[hooks_index] = match comment {
223            Some(comment) => format!("hooks = true {comment}"),
224            None => "hooks = true".to_string(),
225        };
226        join_toml_lines(lines, trailing_newline)
227    } else if let Some(header_index) = features_header_index {
228        lines.insert(header_index + 1, "hooks = true".to_string());
229        join_toml_lines(lines, trailing_newline)
230    } else {
231        let mut result = content.trim_end_matches('\n').to_string();
232        if !result.is_empty() {
233            result.push('\n');
234            result.push('\n');
235        }
236        result.push_str("[features]\nhooks = true\n");
237        result
238    };
239
240    // Fail closed: the edited text must parse back with the flag enabled
241    // before the caller is allowed to write it. A dotted
242    // `features = { hooks = false }` conflicts with the appended table and
243    // fails here — correctly, since a line edit cannot flip that form.
244    let reparsed = toml::from_str::<toml::Table>(&updated).map_err(|err| {
245        io::Error::other(format!(
246            "failed to safely update codex config at {}: {err}",
247            config_path.display()
248        ))
249    })?;
250    if !features_hooks_enabled(&reparsed) {
251        return Err(io::Error::other(format!(
252            "failed to safely update codex config at {}",
253            config_path.display()
254        )));
255    }
256    Ok(Some(updated))
257}
258
259fn features_hooks_enabled(config: &toml::Table) -> bool {
260    config
261        .get("features")
262        .and_then(|features| features.get("hooks"))
263        .and_then(|hooks| hooks.as_bool())
264        == Some(true)
265}
266
267fn join_toml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
268    let mut result = lines.join("\n");
269    if trailing_newline || result.is_empty() {
270        result.push('\n');
271    }
272    result
273}
274
275/// The table header of a TOML line (`[features]`, `[[hooks]]`), or None for
276/// anything else — including a `[features]` inside brackets-quoted keys, which
277/// codex config does not use. Ported from herdr's codex integration.
278fn toml_table_header(line: &str) -> Option<&str> {
279    let trimmed = line.trim_start();
280    if trimmed.starts_with('#') || !trimmed.starts_with('[') {
281        return None;
282    }
283
284    let header_end = if trimmed.starts_with("[[") {
285        trimmed.find("]]").map(|index| index + 2)?
286    } else {
287        trimmed.find(']').map(|index| index + 1)?
288    };
289    let header = &trimmed[..header_end];
290    let rest = trimmed[header_end..].trim_start();
291    if !rest.is_empty() && !rest.starts_with('#') {
292        return None;
293    }
294
295    Some(header)
296}
297
298/// Whether a line assigns to `key` at table-body level (`key = ...`).
299/// `codex_hooks` and `hooks_extra` do not match `hooks`. Ported from herdr.
300fn is_toml_key(line: &str, key: &str) -> bool {
301    let trimmed = line.trim();
302    if trimmed.starts_with('#') || !trimmed.starts_with(key) {
303        return false;
304    }
305
306    trimmed[key.len()..].trim_start().starts_with('=')
307}
308
309/// The ` # comment` suffix of a TOML value, honoring quotes so a `#` inside a
310/// string is not mistaken for one. Ported from herdr.
311fn toml_comment_start(value: &str) -> Option<&str> {
312    let mut quote = None;
313    let mut escaped = false;
314    for (index, ch) in value.char_indices() {
315        if let Some(quote_char) = quote {
316            if escaped {
317                escaped = false;
318                continue;
319            }
320            if ch == '\\' {
321                escaped = true;
322                continue;
323            }
324            if ch == quote_char {
325                quote = None;
326            }
327            continue;
328        }
329
330        match ch {
331            '"' | '\'' => quote = Some(ch),
332            '#' if index == 0 || value[..index].ends_with(char::is_whitespace) => {
333                return Some(&value[index..]);
334            }
335            _ => {}
336        }
337    }
338
339    None
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use serde_json::Value as JsonValue;
346    use std::fs;
347    use std::path::Path;
348
349    use super::super::parse_serde;
350
351    fn temp_root() -> tempfile::TempDir {
352        tempfile::tempdir().expect("temp dir")
353    }
354
355    /// A realistic codex config.toml: comments, nested tables — including a
356    /// profile-scoped features table that must not be mistaken for the
357    /// top-level one — and no hooks flag yet.
358    const REAL_WORLD_CONFIG: &str = r#"# codex configuration
359model = "gpt-5-codex"
360notify = ["notify-send", "--app-name", "codex"]
361
362[profiles.work.features]
363hooks = false
364
365[features]
366# experimental toggles
367web_search = true
368
369[mcp_servers.github]
370command = "gh"
371args = ["mcp", "serve"]
372"#;
373
374    /// A hooks.json a user might already have: their own SessionStart hook
375    /// and an unrelated event.
376    const REAL_WORLD_HOOKS: &str = r#"{
377  "hooks": {
378    "SessionStart": [
379      {
380        "hooks": [
381          {
382            "type": "command",
383            "command": "/usr/local/bin/codex-motd"
384          }
385        ]
386      }
387    ],
388    "Stop": [
389      {
390        "hooks": [
391          {
392            "type": "command",
393            "command": "/usr/local/bin/codex-done"
394          }
395        ]
396      }
397    ]
398  }
399}
400"#;
401
402    fn installed_command(hook_path: &Path) -> String {
403        codex_hook_command(hook_path)
404    }
405
406    fn session_start_commands(content: &str) -> Vec<String> {
407        let value: JsonValue = parse_serde(content, Path::new("<test>")).expect("parses");
408        value
409            .get("hooks")
410            .and_then(|hooks| hooks.get("SessionStart"))
411            .and_then(JsonValue::as_array)
412            .expect("SessionStart array")
413            .iter()
414            .flat_map(|group| {
415                group
416                    .get("hooks")
417                    .and_then(JsonValue::as_array)
418                    .map(|entries| {
419                        entries
420                            .iter()
421                            .filter_map(|entry| entry.get("command").and_then(JsonValue::as_str))
422                            .collect::<Vec<_>>()
423                    })
424                    .unwrap_or_default()
425            })
426            .map(String::from)
427            .collect()
428    }
429
430    fn features_hooks(config: &str) -> Option<bool> {
431        toml::from_str::<toml::Table>(config)
432            .expect("parses")
433            .get("features")
434            .and_then(|f| f.get("hooks"))
435            .and_then(|v| v.as_bool())
436    }
437
438    #[test]
439    fn install_preserves_user_toml_entries_comments_and_formatting() {
440        let root = temp_root();
441        let codex_dir = root.path().join("codex");
442        fs::create_dir_all(&codex_dir).unwrap();
443        fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
444        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
445        let hook = root.path().join(codex_hook_install_name());
446
447        let result = install_codex_hook_into(&codex_dir, &hook).unwrap();
448
449        assert!(result.hooks_changed && result.config_changed);
450
451        // Every line of the user's TOML survives verbatim.
452        let config = fs::read_to_string(&result.config_path).unwrap();
453        for line in REAL_WORLD_CONFIG.lines() {
454            assert!(
455                config.contains(line),
456                "user toml line must survive untouched: {line}"
457            );
458        }
459        assert_eq!(features_hooks(&config), Some(true));
460        assert_eq!(
461            toml::from_str::<toml::Table>(&config)
462                .unwrap()
463                .get("profiles")
464                .and_then(|p| p.get("work"))
465                .and_then(|w| w.get("features"))
466                .and_then(|f| f.get("hooks"))
467                .and_then(|v| v.as_bool()),
468            Some(false),
469            "the profile-scoped features table is not ours to touch"
470        );
471
472        // Every line of the user's hooks.json survives, ours joins theirs.
473        let hooks = fs::read_to_string(&result.hooks_path).unwrap();
474        for line in REAL_WORLD_HOOKS.lines() {
475            assert!(
476                hooks.contains(line),
477                "user hooks line must survive untouched: {line}"
478            );
479        }
480        let mut commands = session_start_commands(&hooks);
481        commands.sort();
482        let mut expected = vec![
483            "/usr/local/bin/codex-motd".to_string(),
484            installed_command(&hook),
485        ];
486        expected.sort();
487        assert_eq!(commands, expected);
488
489        // The entry is matcher-less (codex has no claude matcher space).
490        let value: JsonValue = parse_serde(&hooks, Path::new("<test>")).unwrap();
491        let ours = &value["hooks"]["SessionStart"]
492            .as_array()
493            .unwrap()
494            .iter()
495            .find(|group| {
496                group
497                    .get("hooks")
498                    .and_then(JsonValue::as_array)
499                    .is_some_and(|entries| {
500                        entries.iter().any(|entry| {
501                            entry.get("command").and_then(JsonValue::as_str)
502                                == Some(installed_command(&hook).as_str())
503                        })
504                    })
505            })
506            .expect("our group");
507        assert!(ours.get("matcher").is_none());
508
509        let asset = fs::read_to_string(&hook).unwrap();
510        assert_eq!(asset, codex_hook_asset());
511        assert!(asset.contains(CODEX_HOOK_MARKER));
512        assert!(asset.contains("CODEX_THREAD_ID"));
513        let resume_argv = if cfg!(windows) {
514            r#"@("codex", "resume", "$sessionId")"#
515        } else {
516            r#""codex", "resume", session_id"#
517        };
518        assert!(asset.contains(resume_argv));
519        assert!(!asset.contains("HERDR_"), "fully env-renamed port");
520        #[cfg(unix)]
521        {
522            use std::os::unix::fs::PermissionsExt;
523            let mode = fs::metadata(&hook).unwrap().permissions().mode() & 0o777;
524            assert_eq!(mode, 0o755, "the hook must be executable");
525        }
526    }
527
528    #[test]
529    fn a_disabled_flag_is_flipped_and_its_comment_preserved() {
530        let root = temp_root();
531        let codex_dir = root.path().join("codex");
532        fs::create_dir_all(&codex_dir).unwrap();
533        fs::write(
534            codex_dir.join("config.toml"),
535            "[features]\nhooks = false # keep me\n",
536        )
537        .unwrap();
538        let hook = root.path().join(codex_hook_install_name());
539
540        install_codex_hook_into(&codex_dir, &hook).unwrap();
541
542        let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
543        assert_eq!(config, "[features]\nhooks = true # keep me\n");
544    }
545
546    #[test]
547    fn a_config_without_features_gets_the_table_appended() {
548        let root = temp_root();
549        let codex_dir = root.path().join("codex");
550        fs::create_dir_all(&codex_dir).unwrap();
551        fs::write(codex_dir.join("config.toml"), "model = \"gpt-5\"\n").unwrap();
552        let hook = root.path().join(codex_hook_install_name());
553
554        install_codex_hook_into(&codex_dir, &hook).unwrap();
555
556        let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
557        assert!(config.starts_with("model = \"gpt-5\"\n\n[features]\nhooks = true\n"));
558        assert_eq!(features_hooks(&config), Some(true));
559    }
560
561    #[test]
562    fn a_missing_config_and_hooks_file_are_created() {
563        let root = temp_root();
564        let codex_dir = root.path().join("codex");
565        fs::create_dir_all(&codex_dir).unwrap();
566        let hook = root.path().join(codex_hook_install_name());
567
568        let result = install_codex_hook_into(&codex_dir, &hook).unwrap();
569
570        assert!(result.hooks_changed && result.config_changed);
571        let config = fs::read_to_string(&result.config_path).unwrap();
572        assert_eq!(config, "[features]\nhooks = true\n");
573        let commands = session_start_commands(&fs::read_to_string(&result.hooks_path).unwrap());
574        assert_eq!(commands, vec![installed_command(&hook)]);
575    }
576
577    #[test]
578    fn reinstall_is_a_byte_exact_noop() {
579        let root = temp_root();
580        let codex_dir = root.path().join("codex");
581        fs::create_dir_all(&codex_dir).unwrap();
582        fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
583        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
584        let hook = root.path().join(codex_hook_install_name());
585
586        install_codex_hook_into(&codex_dir, &hook).unwrap();
587        let config_once = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
588        let hooks_once = fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
589
590        let second = install_codex_hook_into(&codex_dir, &hook).unwrap();
591        assert!(
592            !second.hooks_changed && !second.config_changed,
593            "reinstall is a no-op on both files"
594        );
595        assert_eq!(
596            fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
597            config_once
598        );
599        assert_eq!(
600            fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
601            hooks_once
602        );
603    }
604
605    #[test]
606    fn a_dotted_features_table_fails_closed_and_writes_nothing() {
607        let root = temp_root();
608        let codex_dir = root.path().join("codex");
609        fs::create_dir_all(&codex_dir).unwrap();
610        // A line edit cannot flip a dotted/inline features table without
611        // creating a duplicate definition — refuse instead.
612        fs::write(
613            codex_dir.join("config.toml"),
614            "model = \"gpt-5\"\nfeatures = { hooks = false }\n",
615        )
616        .unwrap();
617        let original_hooks = "{}\n";
618        fs::write(codex_dir.join("hooks.json"), original_hooks).unwrap();
619        let hook = root.path().join(codex_hook_install_name());
620
621        let err = install_codex_hook_into(&codex_dir, &hook)
622            .unwrap_err()
623            .to_string();
624
625        assert!(
626            err.contains("failed to safely update codex config"),
627            "error names the file and the refusal: {err}"
628        );
629        assert_eq!(
630            fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
631            "model = \"gpt-5\"\nfeatures = { hooks = false }\n"
632        );
633        assert_eq!(
634            fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
635            original_hooks
636        );
637        assert!(!hook.exists(), "not even the asset is written");
638    }
639
640    #[test]
641    fn malformed_toml_fails_and_writes_nothing() {
642        let root = temp_root();
643        let codex_dir = root.path().join("codex");
644        fs::create_dir_all(&codex_dir).unwrap();
645        let original_config = "model = \n";
646        fs::write(codex_dir.join("config.toml"), original_config).unwrap();
647        let hook = root.path().join(codex_hook_install_name());
648
649        let err = install_codex_hook_into(&codex_dir, &hook)
650            .unwrap_err()
651            .to_string();
652
653        assert!(
654            err.contains("failed to parse"),
655            "error names the file: {err}"
656        );
657        assert_eq!(
658            fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
659            original_config
660        );
661        assert!(!hook.exists());
662    }
663
664    #[test]
665    fn malformed_hooks_json_fails_and_writes_nothing() {
666        let root = temp_root();
667        let codex_dir = root.path().join("codex");
668        fs::create_dir_all(&codex_dir).unwrap();
669        fs::write(codex_dir.join("config.toml"), "model = \"gpt-5\"\n").unwrap();
670        let original_hooks = "{oops";
671        fs::write(codex_dir.join("hooks.json"), original_hooks).unwrap();
672        let hook = root.path().join(codex_hook_install_name());
673
674        let err = install_codex_hook_into(&codex_dir, &hook)
675            .unwrap_err()
676            .to_string();
677
678        assert!(
679            err.contains("failed to parse"),
680            "error names the file: {err}"
681        );
682        assert_eq!(
683            fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
684            original_hooks
685        );
686        assert_eq!(
687            fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
688            "model = \"gpt-5\"\n",
689            "the verified-but-unwritten config edit never reaches disk"
690        );
691        assert!(!hook.exists());
692    }
693
694    #[test]
695    fn missing_codex_directory_is_an_actionable_error() {
696        let root = temp_root();
697        let hook = root.path().join(codex_hook_install_name());
698
699        let err = install_codex_hook_into(&root.path().join("never"), &hook)
700            .unwrap_err()
701            .to_string();
702
703        assert!(
704            err.contains("codex config directory not found at"),
705            "error must name the path: {err}"
706        );
707        assert!(
708            err.contains("install codex first"),
709            "error must say what to do: {err}"
710        );
711    }
712
713    #[test]
714    fn uninstall_restores_hooks_json_and_leaves_the_flag() {
715        let root = temp_root();
716        let codex_dir = root.path().join("codex");
717        fs::create_dir_all(&codex_dir).unwrap();
718        fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
719        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
720        let hook = root.path().join(codex_hook_install_name());
721        install_codex_hook_into(&codex_dir, &hook).unwrap();
722
723        let result = uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
724
725        assert!(result.hooks_changed && result.hook_removed);
726        // hooks.json is byte-identical to before we ever touched it.
727        assert_eq!(
728            fs::read_to_string(&result.hooks_path).unwrap(),
729            REAL_WORLD_HOOKS
730        );
731        // The flag stays: without hook entries it enables nothing, and it may
732        // predate our install.
733        assert_eq!(
734            features_hooks(&fs::read_to_string(&result.config_path).unwrap()),
735            Some(true)
736        );
737        assert!(!hook.exists());
738    }
739
740    #[test]
741    fn uninstall_removes_our_entry_from_a_mixed_group_and_keeps_the_users() {
742        let root = temp_root();
743        let codex_dir = root.path().join("codex");
744        fs::create_dir_all(&codex_dir).unwrap();
745        let command = installed_command(&root.path().join(codex_hook_install_name()));
746        // A group the user built that carries BOTH their command and ours.
747        let mixed = format!(
748            r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"/usr/local/bin/codex-motd"}},{{"type":"command","command":{command}}}]}}]}}}}"#,
749            command = serde_json::to_string(&command).unwrap()
750        );
751        fs::write(codex_dir.join("hooks.json"), mixed).unwrap();
752        let hook = root.path().join(codex_hook_install_name());
753
754        uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
755
756        let after = fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
757        assert_eq!(
758            session_start_commands(&after),
759            vec!["/usr/local/bin/codex-motd".to_string()],
760            "the user's entry survives, ours is gone"
761        );
762    }
763
764    #[test]
765    fn uninstall_noops_when_absent_and_leaves_foreign_assets_alone() {
766        let root = temp_root();
767        let codex_dir = root.path().join("codex");
768        fs::create_dir_all(&codex_dir).unwrap();
769        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
770        // A same-named asset that is NOT ours.
771        let hook = root.path().join(codex_hook_install_name());
772        fs::write(&hook, "// user's own script, no marker\n").unwrap();
773
774        let result = uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
775
776        assert!(!result.hooks_changed);
777        assert!(
778            !result.hook_removed,
779            "a file without our marker is not ours"
780        );
781        assert_eq!(
782            fs::read_to_string(&result.hooks_path).unwrap(),
783            REAL_WORLD_HOOKS
784        );
785        assert_eq!(
786            fs::read_to_string(&hook).unwrap(),
787            "// user's own script, no marker\n"
788        );
789    }
790
791    #[test]
792    fn install_preserves_both_files_modes() {
793        let root = temp_root();
794        let codex_dir = root.path().join("codex");
795        fs::create_dir_all(&codex_dir).unwrap();
796        fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
797        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
798        #[cfg(unix)]
799        {
800            use std::os::unix::fs::PermissionsExt;
801            fs::set_permissions(
802                codex_dir.join("config.toml"),
803                fs::Permissions::from_mode(0o600),
804            )
805            .unwrap();
806            fs::set_permissions(
807                codex_dir.join("hooks.json"),
808                fs::Permissions::from_mode(0o600),
809            )
810            .unwrap();
811            let hook = root.path().join(codex_hook_install_name());
812
813            install_codex_hook_into(&codex_dir, &hook).unwrap();
814
815            let config_mode = fs::metadata(codex_dir.join("config.toml"))
816                .unwrap()
817                .permissions()
818                .mode()
819                & 0o777;
820            let hooks_mode = fs::metadata(codex_dir.join("hooks.json"))
821                .unwrap()
822                .permissions()
823                .mode()
824                & 0o777;
825            assert_eq!(config_mode, 0o600, "the atomic rewrite preserves the mode");
826            assert_eq!(hooks_mode, 0o600, "the atomic rewrite preserves the mode");
827        }
828    }
829
830    #[test]
831    fn the_windows_ps1_asset_is_a_marked_reporter_port() {
832        // The uninstall marker and the reporter contract, checked against the
833        // asset contents directly so the .ps1 variant cannot drift from the
834        // .sh reporter shape it ports (the file is never installed on this
835        // platform, so nothing else would catch it).
836        assert!(CODEX_HOOK_ASSET_WINDOWS.contains(CODEX_HOOK_MARKER));
837        assert!(
838            CODEX_HOOK_ASSET_WINDOWS.contains("pane.report_agent_session"),
839            "speaks the same control-socket method"
840        );
841        assert!(CODEX_HOOK_ASSET_WINDOWS.contains("par-mux:codex"));
842        assert!(CODEX_HOOK_ASSET_WINDOWS.contains("session_resume_argv"));
843        // The codex-specific guards survive the port.
844        assert!(CODEX_HOOK_ASSET_WINDOWS.contains("CODEX_THREAD_ID"));
845        assert!(CODEX_HOOK_ASSET_WINDOWS.contains("SessionStart"));
846    }
847}