use crate::config::Config;
use crate::util::{dim, ok, warn};
use anyhow::{Context, Result};
use std::fs;
pub fn step_configure_codex(config: &Config) -> Result<()> {
let codex_dir = dirs::home_dir().unwrap().join(".codex");
let config_path = codex_dir.join("config.toml");
if !codex_dir.exists() && !config_path.exists() {
println!("{} ~/.codex not found — skipping Codex setup", warn());
return Ok(());
}
step_configure_codex_toml(config, &codex_dir, &config_path)?;
step_configure_codex_hooks(config, &codex_dir)?;
Ok(())
}
fn step_configure_codex_toml(
config: &Config,
codex_dir: &std::path::Path,
config_path: &std::path::Path,
) -> Result<()> {
let _ = config; let raw = fs::read_to_string(config_path).unwrap_or_default();
let mut cfg_val: toml::Value = if raw.trim().is_empty() {
toml::Value::Table(Default::default())
} else {
toml::from_str(&raw).context("parsing ~/.codex/config.toml")?
};
let table = cfg_val
.as_table_mut()
.context("Codex config root must be a table")?;
let mut changed = false;
let features = table
.entry("features".to_string())
.or_insert(toml::Value::Table(Default::default()));
let features_table = features
.as_table_mut()
.context("~/.codex/config.toml [features] must be a table")?;
if features_table.get("codex_hooks").and_then(|v| v.as_bool()) != Some(true) {
features_table.insert("codex_hooks".to_string(), toml::Value::Boolean(true));
changed = true;
}
let current_notify = table.get("notify").and_then(toml_array_to_strings);
if let Some(existing) = current_notify {
if existing.iter().any(|p| p.contains("capture-codex.py")) {
let forward_idx = existing.iter().position(|p| p == "--forward");
let forward_val = forward_idx
.and_then(|i| existing.get(i + 1))
.cloned()
.unwrap_or_default();
if forward_val.is_empty() {
table.remove("notify");
} else {
if let Ok(other_cmd) = serde_json::from_str::<Vec<String>>(&forward_val) {
table.insert("notify".to_string(), string_array_to_toml(&other_cmd));
} else {
table.remove("notify");
}
}
changed = true;
}
}
if changed {
fs::create_dir_all(codex_dir)?;
fs::write(config_path, toml::to_string_pretty(&cfg_val)?)?;
println!(
"{} Codex config.toml updated (codex_hooks=true, notify removed) in {}",
ok(),
config_path.display()
);
} else {
println!("{} Codex config.toml already up-to-date", dim());
}
Ok(())
}
fn step_configure_codex_hooks(config: &Config, codex_dir: &std::path::Path) -> Result<()> {
let hooks_path = codex_dir.join("hooks.json");
let capture_script = config.scripts_root().join("capture-codex.py");
let capture_cmd = format!("python3 {}", capture_script.display());
let raw = fs::read_to_string(&hooks_path).unwrap_or_default();
let mut root: serde_json::Value = if raw.trim().is_empty() {
serde_json::json!({ "hooks": {} })
} else {
serde_json::from_str(&raw).unwrap_or(serde_json::json!({ "hooks": {} }))
};
let hooks_val = root
.as_object_mut()
.unwrap()
.entry("hooks")
.or_insert(serde_json::json!({}));
if hooks_val.is_array() {
println!(
"{} Codex hooks.json: migrating old flat-array format to event-keyed format",
warn()
);
*hooks_val = serde_json::json!({});
}
let hooks_map = hooks_val.as_object_mut().unwrap();
let events: &[(&str, u64)] = &[
("UserPromptSubmit", 10),
("Stop", 30),
];
let mut changed = false;
for (event, timeout_secs) in events {
let event_arr = hooks_map
.entry(*event)
.or_insert(serde_json::json!([]))
.as_array_mut()
.unwrap();
let found = event_arr.iter().any(|group| {
group
.get("hooks")
.and_then(|h| h.as_array())
.map(|hs| {
hs.iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.map(|c| c.contains("capture-codex.py"))
.unwrap_or(false)
})
})
.unwrap_or(false)
});
if !found {
event_arr.push(serde_json::json!({
"hooks": [{
"type": "command",
"command": capture_cmd,
"timeout": timeout_secs
}]
}));
changed = true;
} else {
for group in event_arr.iter_mut() {
if let Some(hs) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) {
for h in hs.iter_mut() {
if let Some(cmd_val) = h.get_mut("command") {
if cmd_val
.as_str()
.map(|c| c.contains("capture-codex.py"))
.unwrap_or(false)
&& cmd_val.as_str() != Some(&capture_cmd)
{
*cmd_val = serde_json::Value::String(capture_cmd.clone());
changed = true;
}
}
}
}
}
}
}
if changed {
if let Some(parent) = hooks_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&hooks_path, serde_json::to_string_pretty(&root)?)?;
println!(
"{} Codex hooks.json configured (UserPromptSubmit + Stop) in {}",
ok(),
hooks_path.display()
);
} else {
println!(
"{} Codex hooks.json already up-to-date in {}",
dim(),
hooks_path.display()
);
}
Ok(())
}
fn toml_array_to_strings(v: &toml::Value) -> Option<Vec<String>> {
let arr = v.as_array()?;
arr.iter()
.map(|x| x.as_str().map(|s| s.to_string()))
.collect()
}
fn string_array_to_toml(parts: &[String]) -> toml::Value {
toml::Value::Array(
parts
.iter()
.map(|p| toml::Value::String(p.clone()))
.collect(),
)
}