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    has_command, home_dir, hook_asset_path as asset_path_for, hook_command_with_action,
14    merge_hook_event, remove_marked_file, save_settings, unmerge_hook_entries, 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_hook_event(&hooks_content, &hooks_path, &command, None, "SessionStart")?;
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) =
155            unmerge_hook_entries(&content, &hooks_path, &[("SessionStart", command.as_str())])?
156        {
157            save_settings(&hooks_path, &updated)?;
158            hooks_changed = true;
159        }
160    }
161
162    let hook_removed = remove_marked_file(hook_path, CODEX_HOOK_MARKER)?;
163
164    Ok(CodexHookUninstall {
165        hook_path: hook_path.to_path_buf(),
166        hooks_path,
167        config_path,
168        hooks_changed,
169        hook_removed,
170    })
171}
172
173/// The hooks.json command: `sh <script> session` on POSIX, herdr's
174/// powershell -File form on Windows — both always quote the path (the
175/// grok-arm heritage).
176fn codex_hook_command(hook_path: &Path) -> String {
177    hook_command_with_action(hook_path, "session")
178}
179
180fn read_or_default(path: &Path, default: &str) -> io::Result<String> {
181    match fs::read_to_string(path) {
182        Ok(content) => Ok(content),
183        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(default.to_string()),
184        Err(err) => Err(err),
185    }
186}
187
188/// Enable `[features] hooks = true` in codex config.toml text. `Ok(None)` =
189/// already enabled, byte-exact no-op. The edit is a line splice that never
190/// moves untouched bytes, and the result is verified by a real TOML parse
191/// before the caller is allowed to write it.
192fn ensure_features_hooks_true(content: &str, config_path: &Path) -> io::Result<Option<String>> {
193    // Parse before touching: a config that is not valid TOML is a clear
194    // error with nothing written, mirroring the jsonc arms.
195    let parsed = toml::from_str::<toml::Table>(content).map_err(|err| {
196        io::Error::other(format!("failed to parse {}: {err}", config_path.display()))
197    })?;
198    if features_hooks_enabled(&parsed) {
199        return Ok(None);
200    }
201
202    let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
203    let trailing_newline = content.ends_with('\n');
204    let mut features_header_index = None;
205    let mut hooks_line_index = None;
206    let mut in_features = false;
207
208    for (index, line) in lines.iter().enumerate() {
209        if let Some(header) = toml_table_header(line) {
210            in_features = header == "[features]";
211            if in_features && features_header_index.is_none() {
212                features_header_index = Some(index);
213            }
214            continue;
215        }
216        if in_features && hooks_line_index.is_none() && is_toml_key(line, "hooks") {
217            hooks_line_index = Some(index);
218        }
219    }
220
221    let updated = if let Some(hooks_index) = hooks_line_index {
222        let line = &lines[hooks_index];
223        let comment = toml_comment_start(line).map(str::to_string);
224        lines[hooks_index] = match comment {
225            Some(comment) => format!("hooks = true {comment}"),
226            None => "hooks = true".to_string(),
227        };
228        join_toml_lines(lines, trailing_newline)
229    } else if let Some(header_index) = features_header_index {
230        lines.insert(header_index + 1, "hooks = true".to_string());
231        join_toml_lines(lines, trailing_newline)
232    } else {
233        let mut result = content.trim_end_matches('\n').to_string();
234        if !result.is_empty() {
235            result.push('\n');
236            result.push('\n');
237        }
238        result.push_str("[features]\nhooks = true\n");
239        result
240    };
241
242    // Fail closed: the edited text must parse back with the flag enabled
243    // before the caller is allowed to write it. A dotted
244    // `features = { hooks = false }` conflicts with the appended table and
245    // fails here — correctly, since a line edit cannot flip that form.
246    let reparsed = toml::from_str::<toml::Table>(&updated).map_err(|err| {
247        io::Error::other(format!(
248            "failed to safely update codex config at {}: {err}",
249            config_path.display()
250        ))
251    })?;
252    if !features_hooks_enabled(&reparsed) {
253        return Err(io::Error::other(format!(
254            "failed to safely update codex config at {}",
255            config_path.display()
256        )));
257    }
258    Ok(Some(updated))
259}
260
261fn features_hooks_enabled(config: &toml::Table) -> bool {
262    config
263        .get("features")
264        .and_then(|features| features.get("hooks"))
265        .and_then(|hooks| hooks.as_bool())
266        == Some(true)
267}
268
269fn join_toml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
270    let mut result = lines.join("\n");
271    if trailing_newline || result.is_empty() {
272        result.push('\n');
273    }
274    result
275}
276
277/// The table header of a TOML line (`[features]`, `[[hooks]]`), or None for
278/// anything else — including a `[features]` inside brackets-quoted keys, which
279/// codex config does not use. Ported from herdr's codex integration.
280fn toml_table_header(line: &str) -> Option<&str> {
281    let trimmed = line.trim_start();
282    if trimmed.starts_with('#') || !trimmed.starts_with('[') {
283        return None;
284    }
285
286    let header_end = if trimmed.starts_with("[[") {
287        trimmed.find("]]").map(|index| index + 2)?
288    } else {
289        trimmed.find(']').map(|index| index + 1)?
290    };
291    let header = &trimmed[..header_end];
292    let rest = trimmed[header_end..].trim_start();
293    if !rest.is_empty() && !rest.starts_with('#') {
294        return None;
295    }
296
297    Some(header)
298}
299
300/// Whether a line assigns to `key` at table-body level (`key = ...`).
301/// `codex_hooks` and `hooks_extra` do not match `hooks`. Ported from herdr.
302fn is_toml_key(line: &str, key: &str) -> bool {
303    let trimmed = line.trim();
304    if trimmed.starts_with('#') || !trimmed.starts_with(key) {
305        return false;
306    }
307
308    trimmed[key.len()..].trim_start().starts_with('=')
309}
310
311/// The ` # comment` suffix of a TOML value, honoring quotes so a `#` inside a
312/// string is not mistaken for one. Ported from herdr.
313fn toml_comment_start(value: &str) -> Option<&str> {
314    let mut quote = None;
315    let mut escaped = false;
316    for (index, ch) in value.char_indices() {
317        if let Some(quote_char) = quote {
318            if escaped {
319                escaped = false;
320                continue;
321            }
322            if ch == '\\' {
323                escaped = true;
324                continue;
325            }
326            if ch == quote_char {
327                quote = None;
328            }
329            continue;
330        }
331
332        match ch {
333            '"' | '\'' => quote = Some(ch),
334            '#' if index == 0 || value[..index].ends_with(char::is_whitespace) => {
335                return Some(&value[index..]);
336            }
337            _ => {}
338        }
339    }
340
341    None
342}
343
344/// Startup self-heal: rewrite the script asset when hooks.json still carries
345/// our entry but the script is gone — the hooks directory has repeatedly
346/// been deleted out from under live registrations while the entries in the
347/// agent's own config survived.
348pub fn heal_codex_hook_asset() {
349    let Ok(codex_dir) = codex_config_dir() else {
350        return; // no home to resolve against — nothing to heal
351    };
352    if let Err(err) = heal_codex_hook_asset_into(&codex_dir, &hook_asset_path()) {
353        log::warn!("codex mux-hook heal skipped: {err}");
354    }
355}
356
357/// Heal with explicit paths (test seam). `Ok(())` in every skip case:
358/// script present, agent not installed, or our entry not registered. A
359/// malformed hooks.json surfaces as an error with nothing written. Neither
360/// hooks.json nor config.toml is ever touched.
361pub(crate) fn heal_codex_hook_asset_into(codex_dir: &Path, hook_path: &Path) -> io::Result<()> {
362    if hook_path.exists() {
363        return Ok(());
364    }
365    let hooks_path = codex_dir.join(CODEX_HOOKS_FILE_NAME);
366    let Ok(content) = fs::read_to_string(&hooks_path) else {
367        return Ok(()); // agent not installed — nothing registers the script
368    };
369    let command = codex_hook_command(hook_path);
370    if has_command(&content, &hooks_path, &command, "SessionStart")? {
371        write_hook_asset(hook_path, codex_hook_asset())?;
372        // The deleter is unidentified; this line dates any recurrence in the debug log.
373        log::info!("mux hook heal: restored {}", hook_path.display());
374    }
375    Ok(())
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use serde_json::Value as JsonValue;
382    use std::fs;
383    use std::path::Path;
384
385    use super::super::parse_serde;
386
387    fn temp_root() -> tempfile::TempDir {
388        tempfile::tempdir().expect("temp dir")
389    }
390
391    #[test]
392    fn heal_rewrites_a_deleted_script_and_leaves_agent_files_untouched() {
393        let root = temp_root();
394        let codex_dir = root.path().join(".codex");
395        fs::create_dir_all(&codex_dir).unwrap();
396        fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
397        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
398        let hook = root.path().join("par-mux-codex-session-hook.sh");
399        install_codex_hook_into(&codex_dir, &hook).unwrap();
400        let hooks_after_install = fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
401        let config_after_install = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
402        fs::remove_file(&hook).unwrap();
403
404        heal_codex_hook_asset_into(&codex_dir, &hook).unwrap();
405
406        assert_eq!(fs::read_to_string(&hook).unwrap(), codex_hook_asset());
407        assert_eq!(
408            fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
409            hooks_after_install
410        );
411        assert_eq!(
412            fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
413            config_after_install
414        );
415    }
416
417    #[test]
418    fn heal_does_not_create_a_script_without_registration() {
419        let root = temp_root();
420        let codex_dir = root.path().join(".codex");
421        fs::create_dir_all(&codex_dir).unwrap();
422        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
423        let hook = root.path().join("par-mux-codex-session-hook.sh");
424
425        heal_codex_hook_asset_into(&codex_dir, &hook).unwrap();
426
427        assert!(!hook.exists());
428    }
429
430    /// A realistic codex config.toml: comments, nested tables — including a
431    /// profile-scoped features table that must not be mistaken for the
432    /// top-level one — and no hooks flag yet.
433    const REAL_WORLD_CONFIG: &str = r#"# codex configuration
434model = "gpt-5-codex"
435notify = ["notify-send", "--app-name", "codex"]
436
437[profiles.work.features]
438hooks = false
439
440[features]
441# experimental toggles
442web_search = true
443
444[mcp_servers.github]
445command = "gh"
446args = ["mcp", "serve"]
447"#;
448
449    /// A hooks.json a user might already have: their own SessionStart hook
450    /// and an unrelated event.
451    const REAL_WORLD_HOOKS: &str = r#"{
452  "hooks": {
453    "SessionStart": [
454      {
455        "hooks": [
456          {
457            "type": "command",
458            "command": "/usr/local/bin/codex-motd"
459          }
460        ]
461      }
462    ],
463    "Stop": [
464      {
465        "hooks": [
466          {
467            "type": "command",
468            "command": "/usr/local/bin/codex-done"
469          }
470        ]
471      }
472    ]
473  }
474}
475"#;
476
477    fn installed_command(hook_path: &Path) -> String {
478        codex_hook_command(hook_path)
479    }
480
481    fn session_start_commands(content: &str) -> Vec<String> {
482        let value: JsonValue = parse_serde(content, Path::new("<test>")).expect("parses");
483        value
484            .get("hooks")
485            .and_then(|hooks| hooks.get("SessionStart"))
486            .and_then(JsonValue::as_array)
487            .expect("SessionStart array")
488            .iter()
489            .flat_map(|group| {
490                group
491                    .get("hooks")
492                    .and_then(JsonValue::as_array)
493                    .map(|entries| {
494                        entries
495                            .iter()
496                            .filter_map(|entry| entry.get("command").and_then(JsonValue::as_str))
497                            .collect::<Vec<_>>()
498                    })
499                    .unwrap_or_default()
500            })
501            .map(String::from)
502            .collect()
503    }
504
505    fn features_hooks(config: &str) -> Option<bool> {
506        toml::from_str::<toml::Table>(config)
507            .expect("parses")
508            .get("features")
509            .and_then(|f| f.get("hooks"))
510            .and_then(|v| v.as_bool())
511    }
512
513    #[test]
514    fn install_preserves_user_toml_entries_comments_and_formatting() {
515        let root = temp_root();
516        let codex_dir = root.path().join("codex");
517        fs::create_dir_all(&codex_dir).unwrap();
518        fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
519        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
520        let hook = root.path().join(codex_hook_install_name());
521
522        let result = install_codex_hook_into(&codex_dir, &hook).unwrap();
523
524        assert!(result.hooks_changed && result.config_changed);
525
526        // Every line of the user's TOML survives verbatim.
527        let config = fs::read_to_string(&result.config_path).unwrap();
528        for line in REAL_WORLD_CONFIG.lines() {
529            assert!(
530                config.contains(line),
531                "user toml line must survive untouched: {line}"
532            );
533        }
534        assert_eq!(features_hooks(&config), Some(true));
535        assert_eq!(
536            toml::from_str::<toml::Table>(&config)
537                .unwrap()
538                .get("profiles")
539                .and_then(|p| p.get("work"))
540                .and_then(|w| w.get("features"))
541                .and_then(|f| f.get("hooks"))
542                .and_then(|v| v.as_bool()),
543            Some(false),
544            "the profile-scoped features table is not ours to touch"
545        );
546
547        // Every line of the user's hooks.json survives, ours joins theirs.
548        let hooks = fs::read_to_string(&result.hooks_path).unwrap();
549        for line in REAL_WORLD_HOOKS.lines() {
550            assert!(
551                hooks.contains(line),
552                "user hooks line must survive untouched: {line}"
553            );
554        }
555        let mut commands = session_start_commands(&hooks);
556        commands.sort();
557        let mut expected = vec![
558            "/usr/local/bin/codex-motd".to_string(),
559            installed_command(&hook),
560        ];
561        expected.sort();
562        assert_eq!(commands, expected);
563
564        // The entry is matcher-less (codex has no claude matcher space).
565        let value: JsonValue = parse_serde(&hooks, Path::new("<test>")).unwrap();
566        let ours = &value["hooks"]["SessionStart"]
567            .as_array()
568            .unwrap()
569            .iter()
570            .find(|group| {
571                group
572                    .get("hooks")
573                    .and_then(JsonValue::as_array)
574                    .is_some_and(|entries| {
575                        entries.iter().any(|entry| {
576                            entry.get("command").and_then(JsonValue::as_str)
577                                == Some(installed_command(&hook).as_str())
578                        })
579                    })
580            })
581            .expect("our group");
582        assert!(ours.get("matcher").is_none());
583
584        let asset = fs::read_to_string(&hook).unwrap();
585        assert_eq!(asset, codex_hook_asset());
586        assert!(asset.contains(CODEX_HOOK_MARKER));
587        assert!(asset.contains("CODEX_THREAD_ID"));
588        let resume_argv = if cfg!(windows) {
589            r#"@("codex", "resume", "$sessionId")"#
590        } else {
591            r#""codex", "resume", session_id"#
592        };
593        assert!(asset.contains(resume_argv));
594        assert!(!asset.contains("HERDR_"), "fully env-renamed port");
595        #[cfg(unix)]
596        {
597            use std::os::unix::fs::PermissionsExt;
598            let mode = fs::metadata(&hook).unwrap().permissions().mode() & 0o777;
599            assert_eq!(mode, 0o755, "the hook must be executable");
600        }
601    }
602
603    #[test]
604    fn a_disabled_flag_is_flipped_and_its_comment_preserved() {
605        let root = temp_root();
606        let codex_dir = root.path().join("codex");
607        fs::create_dir_all(&codex_dir).unwrap();
608        fs::write(
609            codex_dir.join("config.toml"),
610            "[features]\nhooks = false # keep me\n",
611        )
612        .unwrap();
613        let hook = root.path().join(codex_hook_install_name());
614
615        install_codex_hook_into(&codex_dir, &hook).unwrap();
616
617        let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
618        assert_eq!(config, "[features]\nhooks = true # keep me\n");
619    }
620
621    #[test]
622    fn a_config_without_features_gets_the_table_appended() {
623        let root = temp_root();
624        let codex_dir = root.path().join("codex");
625        fs::create_dir_all(&codex_dir).unwrap();
626        fs::write(codex_dir.join("config.toml"), "model = \"gpt-5\"\n").unwrap();
627        let hook = root.path().join(codex_hook_install_name());
628
629        install_codex_hook_into(&codex_dir, &hook).unwrap();
630
631        let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
632        assert!(config.starts_with("model = \"gpt-5\"\n\n[features]\nhooks = true\n"));
633        assert_eq!(features_hooks(&config), Some(true));
634    }
635
636    #[test]
637    fn a_missing_config_and_hooks_file_are_created() {
638        let root = temp_root();
639        let codex_dir = root.path().join("codex");
640        fs::create_dir_all(&codex_dir).unwrap();
641        let hook = root.path().join(codex_hook_install_name());
642
643        let result = install_codex_hook_into(&codex_dir, &hook).unwrap();
644
645        assert!(result.hooks_changed && result.config_changed);
646        let config = fs::read_to_string(&result.config_path).unwrap();
647        assert_eq!(config, "[features]\nhooks = true\n");
648        let commands = session_start_commands(&fs::read_to_string(&result.hooks_path).unwrap());
649        assert_eq!(commands, vec![installed_command(&hook)]);
650    }
651
652    #[test]
653    fn reinstall_is_a_byte_exact_noop() {
654        let root = temp_root();
655        let codex_dir = root.path().join("codex");
656        fs::create_dir_all(&codex_dir).unwrap();
657        fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
658        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
659        let hook = root.path().join(codex_hook_install_name());
660
661        install_codex_hook_into(&codex_dir, &hook).unwrap();
662        let config_once = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
663        let hooks_once = fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
664
665        let second = install_codex_hook_into(&codex_dir, &hook).unwrap();
666        assert!(
667            !second.hooks_changed && !second.config_changed,
668            "reinstall is a no-op on both files"
669        );
670        assert_eq!(
671            fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
672            config_once
673        );
674        assert_eq!(
675            fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
676            hooks_once
677        );
678    }
679
680    #[test]
681    fn a_dotted_features_table_fails_closed_and_writes_nothing() {
682        let root = temp_root();
683        let codex_dir = root.path().join("codex");
684        fs::create_dir_all(&codex_dir).unwrap();
685        // A line edit cannot flip a dotted/inline features table without
686        // creating a duplicate definition — refuse instead.
687        fs::write(
688            codex_dir.join("config.toml"),
689            "model = \"gpt-5\"\nfeatures = { hooks = false }\n",
690        )
691        .unwrap();
692        let original_hooks = "{}\n";
693        fs::write(codex_dir.join("hooks.json"), original_hooks).unwrap();
694        let hook = root.path().join(codex_hook_install_name());
695
696        let err = install_codex_hook_into(&codex_dir, &hook)
697            .unwrap_err()
698            .to_string();
699
700        assert!(
701            err.contains("failed to safely update codex config"),
702            "error names the file and the refusal: {err}"
703        );
704        assert_eq!(
705            fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
706            "model = \"gpt-5\"\nfeatures = { hooks = false }\n"
707        );
708        assert_eq!(
709            fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
710            original_hooks
711        );
712        assert!(!hook.exists(), "not even the asset is written");
713    }
714
715    #[test]
716    fn malformed_toml_fails_and_writes_nothing() {
717        let root = temp_root();
718        let codex_dir = root.path().join("codex");
719        fs::create_dir_all(&codex_dir).unwrap();
720        let original_config = "model = \n";
721        fs::write(codex_dir.join("config.toml"), original_config).unwrap();
722        let hook = root.path().join(codex_hook_install_name());
723
724        let err = install_codex_hook_into(&codex_dir, &hook)
725            .unwrap_err()
726            .to_string();
727
728        assert!(
729            err.contains("failed to parse"),
730            "error names the file: {err}"
731        );
732        assert_eq!(
733            fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
734            original_config
735        );
736        assert!(!hook.exists());
737    }
738
739    #[test]
740    fn malformed_hooks_json_fails_and_writes_nothing() {
741        let root = temp_root();
742        let codex_dir = root.path().join("codex");
743        fs::create_dir_all(&codex_dir).unwrap();
744        fs::write(codex_dir.join("config.toml"), "model = \"gpt-5\"\n").unwrap();
745        let original_hooks = "{oops";
746        fs::write(codex_dir.join("hooks.json"), original_hooks).unwrap();
747        let hook = root.path().join(codex_hook_install_name());
748
749        let err = install_codex_hook_into(&codex_dir, &hook)
750            .unwrap_err()
751            .to_string();
752
753        assert!(
754            err.contains("failed to parse"),
755            "error names the file: {err}"
756        );
757        assert_eq!(
758            fs::read_to_string(codex_dir.join("hooks.json")).unwrap(),
759            original_hooks
760        );
761        assert_eq!(
762            fs::read_to_string(codex_dir.join("config.toml")).unwrap(),
763            "model = \"gpt-5\"\n",
764            "the verified-but-unwritten config edit never reaches disk"
765        );
766        assert!(!hook.exists());
767    }
768
769    #[test]
770    fn missing_codex_directory_is_an_actionable_error() {
771        let root = temp_root();
772        let hook = root.path().join(codex_hook_install_name());
773
774        let err = install_codex_hook_into(&root.path().join("never"), &hook)
775            .unwrap_err()
776            .to_string();
777
778        assert!(
779            err.contains("codex config directory not found at"),
780            "error must name the path: {err}"
781        );
782        assert!(
783            err.contains("install codex first"),
784            "error must say what to do: {err}"
785        );
786    }
787
788    #[test]
789    fn uninstall_restores_hooks_json_and_leaves_the_flag() {
790        let root = temp_root();
791        let codex_dir = root.path().join("codex");
792        fs::create_dir_all(&codex_dir).unwrap();
793        fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
794        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
795        let hook = root.path().join(codex_hook_install_name());
796        install_codex_hook_into(&codex_dir, &hook).unwrap();
797
798        let result = uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
799
800        assert!(result.hooks_changed && result.hook_removed);
801        // hooks.json is byte-identical to before we ever touched it.
802        assert_eq!(
803            fs::read_to_string(&result.hooks_path).unwrap(),
804            REAL_WORLD_HOOKS
805        );
806        // The flag stays: without hook entries it enables nothing, and it may
807        // predate our install.
808        assert_eq!(
809            features_hooks(&fs::read_to_string(&result.config_path).unwrap()),
810            Some(true)
811        );
812        assert!(!hook.exists());
813    }
814
815    #[test]
816    fn uninstall_removes_our_entry_from_a_mixed_group_and_keeps_the_users() {
817        let root = temp_root();
818        let codex_dir = root.path().join("codex");
819        fs::create_dir_all(&codex_dir).unwrap();
820        let command = installed_command(&root.path().join(codex_hook_install_name()));
821        // A group the user built that carries BOTH their command and ours.
822        let mixed = format!(
823            r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"/usr/local/bin/codex-motd"}},{{"type":"command","command":{command}}}]}}]}}}}"#,
824            command = serde_json::to_string(&command).unwrap()
825        );
826        fs::write(codex_dir.join("hooks.json"), mixed).unwrap();
827        let hook = root.path().join(codex_hook_install_name());
828
829        uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
830
831        let after = fs::read_to_string(codex_dir.join("hooks.json")).unwrap();
832        assert_eq!(
833            session_start_commands(&after),
834            vec!["/usr/local/bin/codex-motd".to_string()],
835            "the user's entry survives, ours is gone"
836        );
837    }
838
839    #[test]
840    fn uninstall_noops_when_absent_and_leaves_foreign_assets_alone() {
841        let root = temp_root();
842        let codex_dir = root.path().join("codex");
843        fs::create_dir_all(&codex_dir).unwrap();
844        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
845        // A same-named asset that is NOT ours.
846        let hook = root.path().join(codex_hook_install_name());
847        fs::write(&hook, "// user's own script, no marker\n").unwrap();
848
849        let result = uninstall_codex_hook_into(&codex_dir, &hook).unwrap();
850
851        assert!(!result.hooks_changed);
852        assert!(
853            !result.hook_removed,
854            "a file without our marker is not ours"
855        );
856        assert_eq!(
857            fs::read_to_string(&result.hooks_path).unwrap(),
858            REAL_WORLD_HOOKS
859        );
860        assert_eq!(
861            fs::read_to_string(&hook).unwrap(),
862            "// user's own script, no marker\n"
863        );
864    }
865
866    #[test]
867    fn install_preserves_both_files_modes() {
868        let root = temp_root();
869        let codex_dir = root.path().join("codex");
870        fs::create_dir_all(&codex_dir).unwrap();
871        fs::write(codex_dir.join("config.toml"), REAL_WORLD_CONFIG).unwrap();
872        fs::write(codex_dir.join("hooks.json"), REAL_WORLD_HOOKS).unwrap();
873        #[cfg(unix)]
874        {
875            use std::os::unix::fs::PermissionsExt;
876            fs::set_permissions(
877                codex_dir.join("config.toml"),
878                fs::Permissions::from_mode(0o600),
879            )
880            .unwrap();
881            fs::set_permissions(
882                codex_dir.join("hooks.json"),
883                fs::Permissions::from_mode(0o600),
884            )
885            .unwrap();
886            let hook = root.path().join(codex_hook_install_name());
887
888            install_codex_hook_into(&codex_dir, &hook).unwrap();
889
890            let config_mode = fs::metadata(codex_dir.join("config.toml"))
891                .unwrap()
892                .permissions()
893                .mode()
894                & 0o777;
895            let hooks_mode = fs::metadata(codex_dir.join("hooks.json"))
896                .unwrap()
897                .permissions()
898                .mode()
899                & 0o777;
900            assert_eq!(config_mode, 0o600, "the atomic rewrite preserves the mode");
901            assert_eq!(hooks_mode, 0o600, "the atomic rewrite preserves the mode");
902        }
903    }
904
905    #[test]
906    fn the_windows_ps1_asset_is_a_marked_reporter_port() {
907        // The uninstall marker and the reporter contract, checked against the
908        // asset contents directly so the .ps1 variant cannot drift from the
909        // .sh reporter shape it ports (the file is never installed on this
910        // platform, so nothing else would catch it).
911        assert!(CODEX_HOOK_ASSET_WINDOWS.contains(CODEX_HOOK_MARKER));
912        assert!(
913            CODEX_HOOK_ASSET_WINDOWS.contains("pane.report_agent_session"),
914            "speaks the same control-socket method"
915        );
916        assert!(CODEX_HOOK_ASSET_WINDOWS.contains("par-mux:codex"));
917        assert!(CODEX_HOOK_ASSET_WINDOWS.contains("session_resume_argv"));
918        // The codex-specific guards survive the port.
919        assert!(CODEX_HOOK_ASSET_WINDOWS.contains("CODEX_THREAD_ID"));
920        assert!(CODEX_HOOK_ASSET_WINDOWS.contains("SessionStart"));
921    }
922}