aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-14 16:32:07 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-14 16:32:07 +0300
commitbdac7143f6da89e397fb07d68f59ab698b2514f9 (patch)
tree7776d448ca15d91e8bb7d8d779dd2519ed080fdd /src/integration
parentd1ff8075261a34e2257be185debe3ed68e87f693 (diff)
fix: update codex hooks feature flag
fixes #108
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/assets/codex/herdr-agent-state.sh2
-rw-r--r--src/integration/mod.rs119
2 files changed, 98 insertions, 23 deletions
diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh
index c6ee287c..0b769d99 100644
--- a/src/integration/assets/codex/herdr-agent-state.sh
+++ b/src/integration/assets/codex/herdr-agent-state.sh
@@ -2,7 +2,7 @@
# installed by herdr
# safe to edit. this hook only activates inside herdr-managed panes.
# HERDR_INTEGRATION_ID=codex
-# HERDR_INTEGRATION_VERSION=1
+# HERDR_INTEGRATION_VERSION=2
set -eu
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 1e5c2d68..fe92f66c 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -18,7 +18,7 @@ const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh
const CLAUDE_INTEGRATION_VERSION: u32 = 1;
const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh");
-const CODEX_INTEGRATION_VERSION: u32 = 1;
+const CODEX_INTEGRATION_VERSION: u32 = 2;
const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
const OPENCODE_INTEGRATION_VERSION: u32 = 1;
@@ -891,37 +891,85 @@ fn remove_file_if_exists(path: &Path) -> io::Result<bool> {
fn build_codex_config_with_hooks(content: &str) -> String {
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let trailing_newline = content.ends_with('\n');
+ let mut in_top_level_features = false;
+ let mut features_header_index = None;
+ let mut hooks_index = None;
+ let mut deprecated_hooks_indexes = Vec::new();
+
+ for (index, line) in lines.iter().enumerate() {
+ if let Some(header) = toml_table_header(line) {
+ in_top_level_features = header == "[features]";
+ if in_top_level_features && features_header_index.is_none() {
+ features_header_index = Some(index);
+ }
+ continue;
+ }
- if let Some(index) = lines
- .iter()
- .position(|line| is_toml_key(line, "codex_hooks"))
- {
- lines[index] = "codex_hooks = true".to_string();
- let mut result = lines.join("\n");
- if trailing_newline || result.is_empty() {
- result.push('\n');
+ if !in_top_level_features {
+ continue;
}
- return result;
+
+ if is_toml_key(line, "codex_hooks") {
+ deprecated_hooks_indexes.push(index);
+ } else if is_toml_key(line, "hooks") {
+ hooks_index = Some(index);
+ }
+ }
+
+ if let Some(index) = hooks_index {
+ lines[index] = "hooks = true".to_string();
+ }
+
+ for index in deprecated_hooks_indexes.into_iter().rev() {
+ lines.remove(index);
}
- if let Some(index) = lines.iter().position(|line| line.trim() == "[features]") {
- lines.insert(index + 1, "codex_hooks = true".to_string());
- let mut result = lines.join("\n");
- if trailing_newline || result.is_empty() {
+ if hooks_index.is_none() {
+ if let Some(index) = features_header_index {
+ lines.insert(index + 1, "hooks = true".to_string());
+ return join_toml_lines(lines, trailing_newline);
+ }
+
+ let mut result = content.trim_end_matches('\n').to_string();
+ if !result.is_empty() {
+ result.push('\n');
result.push('\n');
}
+ result.push_str("[features]\nhooks = true\n");
return result;
}
- let mut result = content.trim_end_matches('\n').to_string();
- if !result.is_empty() {
- result.push('\n');
+ join_toml_lines(lines, trailing_newline)
+}
+
+fn join_toml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
+ let mut result = lines.join("\n");
+ if trailing_newline || result.is_empty() {
result.push('\n');
}
- result.push_str("[features]\ncodex_hooks = true\n");
result
}
+fn toml_table_header(line: &str) -> Option<&str> {
+ let trimmed = line.trim_start();
+ if trimmed.starts_with('#') || !trimmed.starts_with('[') {
+ return None;
+ }
+
+ let header_end = if trimmed.starts_with("[[") {
+ trimmed.find("]]").map(|index| index + 2)?
+ } else {
+ trimmed.find(']').map(|index| index + 1)?
+ };
+ let header = &trimmed[..header_end];
+ let rest = trimmed[header_end..].trim_start();
+ if !rest.is_empty() && !rest.starts_with('#') {
+ return None;
+ }
+
+ Some(header)
+}
+
fn is_toml_key(line: &str, key: &str) -> bool {
let trimmed = line.trim();
if trimmed.starts_with('#') || !trimmed.starts_with(key) {
@@ -1322,7 +1370,8 @@ mod tests {
.contains(" idle"));
assert!(config.contains("model = \"gpt-5.4\""));
assert!(config.contains("[features]"));
- assert!(config.contains("codex_hooks = true"));
+ assert!(config.contains("hooks = true"));
+ assert!(!config.contains("codex_hooks"));
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
@@ -1357,7 +1406,8 @@ mod tests {
);
assert_eq!(hooks["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
assert_eq!(hooks["hooks"]["Stop"].as_array().unwrap().len(), 1);
- assert_eq!(config.matches("codex_hooks = true").count(), 1);
+ assert_eq!(config.matches("hooks = true").count(), 1);
+ assert!(!config.contains("codex_hooks"));
assert!(config.contains("other = true"));
std::env::remove_var("HOME");
@@ -1365,6 +1415,31 @@ mod tests {
}
#[test]
+ fn install_codex_only_migrates_top_level_feature_flags() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let codex_dir = home.join(".codex");
+ fs::create_dir_all(&codex_dir).unwrap();
+ fs::write(
+ codex_dir.join("config.toml"),
+ "profile = \"work\"\n\n[profiles.work.features]\nhooks = false\ncodex_hooks = false\n\n[features]\ncodex_hooks = true\nother = true\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_codex().unwrap();
+
+ let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
+
+ assert!(config.contains("[profiles.work.features]\nhooks = false\ncodex_hooks = false"));
+ assert!(config.contains("[features]\nhooks = true\nother = true"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() {
let _lock = integration_env_lock();
let base = unique_base();
@@ -1385,7 +1460,7 @@ mod tests {
.unwrap();
fs::write(
codex_dir.join("config.toml"),
- "[features]\ncodex_hooks = true\nother = true\n",
+ "[features]\nhooks = true\nother = true\n",
)
.unwrap();
std::env::set_var("HOME", &home);
@@ -1412,7 +1487,7 @@ mod tests {
hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
"echo keep"
);
- assert!(config.contains("codex_hooks = true"));
+ assert!(config.contains("hooks = true"));
assert!(config.contains("other = true"));
std::env::remove_var("HOME");