herdr/src/integration/mod.rs

1151 lines
36 KiB
Rust

use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use portable_pty::CommandBuilder;
use serde_json::{json, Map, Value};
use crate::layout::PaneId;
pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID";
const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts";
const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts");
const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh");
const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh");
const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
#[derive(Debug)]
pub(crate) struct ClaudeInstallPaths {
pub hook_path: PathBuf,
pub settings_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct CodexInstallPaths {
pub hook_path: PathBuf,
pub hooks_path: PathBuf,
pub config_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct OpenCodeInstallPaths {
pub plugin_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct PiUninstallResult {
pub extension_path: PathBuf,
pub removed_extension: bool,
}
#[derive(Debug)]
pub(crate) struct ClaudeUninstallResult {
pub hook_path: PathBuf,
pub settings_path: PathBuf,
pub removed_hook_file: bool,
pub updated_settings: bool,
}
#[derive(Debug)]
pub(crate) struct CodexUninstallResult {
pub hook_path: PathBuf,
pub hooks_path: PathBuf,
pub config_path: PathBuf,
pub removed_hook_file: bool,
pub updated_hooks: bool,
}
#[derive(Debug)]
pub(crate) struct OpenCodeUninstallResult {
pub plugin_path: PathBuf,
pub removed_plugin: bool,
}
pub(crate) fn apply_pane_env(cmd: &mut CommandBuilder, pane_id: PaneId) {
cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path());
cmd.env(HERDR_PANE_ID_ENV_VAR, format!("p_{}", pane_id.raw()));
}
pub(crate) fn install_pi() -> io::Result<PathBuf> {
let dir = pi_extension_dir()?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"pi extension directory not found at {}. install pi and create the extensions directory first",
dir.display()
)));
}
let path = dir.join(PI_EXTENSION_INSTALL_NAME);
fs::write(&path, PI_EXTENSION_ASSET)?;
Ok(path)
}
pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
let dir = claude_dir()?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"claude directory not found at {}. install claude code first",
dir.display()
)));
}
let hooks_dir = dir.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
fs::write(&hook_path, CLAUDE_HOOK_ASSET)?;
make_executable(&hook_path)?;
let settings_path = dir.join("settings.json");
let mut settings = if settings_path.is_file() {
serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| {
io::Error::other(format!(
"failed to parse {}: {err}",
settings_path.display()
))
})?
} else {
json!({})
};
let hooks = ensure_hooks_object(
&mut settings,
&settings_path,
"claude settings",
"claude settings hooks",
)?;
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
ensure_command_hook(
hooks,
"UserPromptSubmit",
format!("bash {quoted_hook_path} working"),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"PreToolUse",
format!("bash {quoted_hook_path} working"),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"PermissionRequest",
format!("bash {quoted_hook_path} blocked"),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"PostToolUse",
format!("bash {quoted_hook_path} working"),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"PostToolUseFailure",
format!("bash {quoted_hook_path} working"),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"SubagentStop",
format!("bash {quoted_hook_path} working"),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"Stop",
format!("bash {quoted_hook_path} idle"),
10,
Some("*"),
)?;
ensure_command_hook(
hooks,
"SessionEnd",
format!("bash {quoted_hook_path} release"),
10,
Some("*"),
)?;
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
Ok(ClaudeInstallPaths {
hook_path,
settings_path,
})
}
pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
let dir = codex_dir()?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"codex config directory not found at {}. install codex first",
dir.display()
)));
}
let hook_path = dir.join(CODEX_HOOK_INSTALL_NAME);
fs::write(&hook_path, CODEX_HOOK_ASSET)?;
make_executable(&hook_path)?;
let hooks_path = dir.join("hooks.json");
let mut hooks_file = if hooks_path.is_file() {
serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?).map_err(|err| {
io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
})?
} else {
json!({})
};
let hooks = ensure_hooks_object(
&mut hooks_file,
&hooks_path,
"codex hooks file",
"codex hooks file hooks",
)?;
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
ensure_command_hook(
hooks,
"SessionStart",
format!("bash {quoted_hook_path} idle"),
10,
None,
)?;
ensure_command_hook(
hooks,
"UserPromptSubmit",
format!("bash {quoted_hook_path} working"),
10,
None,
)?;
ensure_command_hook(
hooks,
"PreToolUse",
format!("bash {quoted_hook_path} working"),
10,
None,
)?;
ensure_command_hook(
hooks,
"Stop",
format!("bash {quoted_hook_path} idle"),
10,
None,
)?;
fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
let config_path = dir.join("config.toml");
let existing_config = if config_path.is_file() {
fs::read_to_string(&config_path)?
} else {
String::new()
};
let new_config = build_codex_config_with_hooks(&existing_config);
if new_config != existing_config {
fs::write(&config_path, new_config)?;
}
Ok(CodexInstallPaths {
hook_path,
hooks_path,
config_path,
})
}
pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
let dir = opencode_dir()?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"opencode config directory not found at {}. install opencode first",
dir.display()
)));
}
let plugins_dir = dir.join("plugins");
fs::create_dir_all(&plugins_dir)?;
let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME);
fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET)?;
Ok(OpenCodeInstallPaths { plugin_path })
}
pub(crate) fn uninstall_pi() -> io::Result<PiUninstallResult> {
let extension_path = pi_extension_dir()?.join(PI_EXTENSION_INSTALL_NAME);
let removed_extension = remove_file_if_exists(&extension_path)?;
Ok(PiUninstallResult {
extension_path,
removed_extension,
})
}
pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
let hook_path = claude_dir()?.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME);
let settings_path = claude_dir()?.join("settings.json");
let mut updated_settings = false;
if settings_path.is_file() {
let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?)
.map_err(|err| {
io::Error::other(format!(
"failed to parse {}: {err}",
settings_path.display()
))
})?;
if let Some(hooks) = hooks_object_if_present(
&mut settings,
&settings_path,
"claude settings",
"claude settings hooks",
)? {
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
updated_settings |= remove_command_hook(
hooks,
"UserPromptSubmit",
&format!("bash {quoted_hook_path} working"),
)?;
updated_settings |= remove_command_hook(
hooks,
"PreToolUse",
&format!("bash {quoted_hook_path} working"),
)?;
updated_settings |= remove_command_hook(
hooks,
"PermissionRequest",
&format!("bash {quoted_hook_path} blocked"),
)?;
updated_settings |= remove_command_hook(
hooks,
"PostToolUse",
&format!("bash {quoted_hook_path} working"),
)?;
updated_settings |= remove_command_hook(
hooks,
"PostToolUseFailure",
&format!("bash {quoted_hook_path} working"),
)?;
updated_settings |= remove_command_hook(
hooks,
"SubagentStop",
&format!("bash {quoted_hook_path} working"),
)?;
updated_settings |=
remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
updated_settings |= remove_command_hook(
hooks,
"SessionEnd",
&format!("bash {quoted_hook_path} release"),
)?;
}
if updated_settings {
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
}
}
let removed_hook_file = remove_file_if_exists(&hook_path)?;
Ok(ClaudeUninstallResult {
hook_path,
settings_path,
removed_hook_file,
updated_settings,
})
}
pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
let codex_dir = codex_dir()?;
let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
let hooks_path = codex_dir.join("hooks.json");
let config_path = codex_dir.join("config.toml");
let mut updated_hooks = false;
if hooks_path.is_file() {
let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?)
.map_err(|err| {
io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
})?;
if let Some(hooks) = hooks_object_if_present(
&mut hooks_file,
&hooks_path,
"codex hooks file",
"codex hooks file hooks",
)? {
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
updated_hooks |= remove_command_hook(
hooks,
"SessionStart",
&format!("bash {quoted_hook_path} idle"),
)?;
updated_hooks |= remove_command_hook(
hooks,
"UserPromptSubmit",
&format!("bash {quoted_hook_path} working"),
)?;
updated_hooks |= remove_command_hook(
hooks,
"PreToolUse",
&format!("bash {quoted_hook_path} working"),
)?;
updated_hooks |=
remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
}
if updated_hooks {
fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
}
}
let removed_hook_file = remove_file_if_exists(&hook_path)?;
Ok(CodexUninstallResult {
hook_path,
hooks_path,
config_path,
removed_hook_file,
updated_hooks,
})
}
pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
let plugin_path = opencode_dir()?
.join("plugins")
.join(OPENCODE_PLUGIN_INSTALL_NAME);
let removed_plugin = remove_file_if_exists(&plugin_path)?;
Ok(OpenCodeUninstallResult {
plugin_path,
removed_plugin,
})
}
fn ensure_hooks_object<'a>(
settings: &'a mut Value,
settings_path: &Path,
root_description: &str,
hooks_description: &str,
) -> io::Result<&'a mut Map<String, Value>> {
let root = settings.as_object_mut().ok_or_else(|| {
io::Error::other(format!(
"{root_description} at {} must be a JSON object",
settings_path.display()
))
})?;
let hooks = root.entry("hooks").or_insert_with(|| json!({}));
hooks.as_object_mut().ok_or_else(|| {
io::Error::other(format!(
"{hooks_description} at {} must be a JSON object",
settings_path.display()
))
})
}
fn hooks_object_if_present<'a>(
settings: &'a mut Value,
settings_path: &Path,
root_description: &str,
hooks_description: &str,
) -> io::Result<Option<&'a mut Map<String, Value>>> {
let root = settings.as_object_mut().ok_or_else(|| {
io::Error::other(format!(
"{root_description} at {} must be a JSON object",
settings_path.display()
))
})?;
let Some(hooks) = root.get_mut("hooks") else {
return Ok(None);
};
hooks.as_object_mut().map(Some).ok_or_else(|| {
io::Error::other(format!(
"{hooks_description} at {} must be a JSON object",
settings_path.display()
))
})
}
fn ensure_command_hook(
hooks: &mut Map<String, Value>,
event: &str,
command: String,
timeout: u64,
matcher: Option<&str>,
) -> io::Result<()> {
let entries = hooks
.entry(event.to_string())
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
.ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?;
let already_installed = entries.iter().any(|entry| {
entry
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|hook_entries| {
hook_entries.iter().any(|hook| {
hook.get("type").and_then(Value::as_str) == Some("command")
&& hook.get("command").and_then(Value::as_str) == Some(command.as_str())
})
})
});
if already_installed {
return Ok(());
}
let mut entry = Map::new();
if let Some(matcher) = matcher {
entry.insert("matcher".to_string(), Value::String(matcher.to_string()));
}
entry.insert(
"hooks".to_string(),
json!([
{
"type": "command",
"command": command,
"timeout": timeout,
}
]),
);
entries.push(Value::Object(entry));
Ok(())
}
fn remove_command_hook(
hooks: &mut Map<String, Value>,
event: &str,
command: &str,
) -> io::Result<bool> {
let Some(entries_value) = hooks.get_mut(event) else {
return Ok(false);
};
let entries = entries_value
.as_array_mut()
.ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?;
let mut removed = false;
entries.retain_mut(|entry| {
let Some(entry_object) = entry.as_object_mut() else {
return true;
};
let Some(hook_entries) = entry_object.get_mut("hooks") else {
return true;
};
let Some(hook_entries) = hook_entries.as_array_mut() else {
return true;
};
let before = hook_entries.len();
hook_entries.retain(|hook| !is_matching_command_hook(hook, command));
if hook_entries.len() != before {
removed = true;
}
!hook_entries.is_empty()
});
let remove_event = entries.is_empty();
if remove_event {
hooks.remove(event);
}
Ok(removed)
}
fn is_matching_command_hook(hook: &Value, command: &str) -> bool {
hook.get("type").and_then(Value::as_str) == Some("command")
&& hook.get("command").and_then(Value::as_str) == Some(command)
}
fn remove_file_if_exists(path: &Path) -> io::Result<bool> {
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
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');
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');
}
return result;
}
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() {
result.push('\n');
}
return result;
}
let mut result = content.trim_end_matches('\n').to_string();
if !result.is_empty() {
result.push('\n');
result.push('\n');
}
result.push_str("[features]\ncodex_hooks = true\n");
result
}
fn is_toml_key(line: &str, key: &str) -> bool {
let trimmed = line.trim();
if trimmed.starts_with('#') || !trimmed.starts_with(key) {
return false;
}
trimmed[key.len()..].trim_start().starts_with('=')
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn make_executable(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)?;
}
Ok(())
}
fn pi_extension_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".pi/agent/extensions"))
}
fn claude_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".claude"))
}
fn codex_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".codex"))
}
fn opencode_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".config/opencode"))
}
fn home_dir() -> io::Result<PathBuf> {
std::env::var("HOME")
.map(PathBuf::from)
.map_err(|_| io::Error::other("HOME is not set; cannot locate home directory"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn unique_base() -> PathBuf {
std::env::temp_dir().join(format!(
"herdr-integration-install-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
#[test]
fn install_pi_writes_embedded_asset_to_pi_extensions_dir() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
let ext_dir = home.join(".pi/agent/extensions");
fs::create_dir_all(&ext_dir).unwrap();
std::env::set_var("HOME", &home);
let path = install_pi().unwrap();
let content = fs::read_to_string(&path).unwrap();
assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME));
assert_eq!(content, PI_EXTENSION_ASSET);
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn uninstall_pi_removes_embedded_extension_when_present() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
let ext_dir = home.join(".pi/agent/extensions");
fs::create_dir_all(&ext_dir).unwrap();
fs::write(ext_dir.join(PI_EXTENSION_INSTALL_NAME), PI_EXTENSION_ASSET).unwrap();
std::env::set_var("HOME", &home);
let result = uninstall_pi().unwrap();
assert_eq!(
result.extension_path,
ext_dir.join(PI_EXTENSION_INSTALL_NAME)
);
assert!(result.removed_extension);
assert!(!result.extension_path.exists());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_pi_errors_when_extension_dir_missing() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
fs::create_dir_all(&home).unwrap();
std::env::set_var("HOME", &home);
let err = install_pi().unwrap_err().to_string();
assert!(err.contains("pi extension directory not found"));
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_claude_writes_hook_and_updates_settings() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
let claude_dir = home.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(
claude_dir.join("settings.json"),
r#"{"permissions":{"allow":["Read"]},"hooks":{}}"#,
)
.unwrap();
std::env::set_var("HOME", &home);
let installed = install_claude().unwrap();
let hook_content = fs::read_to_string(&installed.hook_path).unwrap();
let settings: Value =
serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap();
assert_eq!(
installed.hook_path,
claude_dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)
);
assert_eq!(hook_content, CLAUDE_HOOK_ASSET);
assert!(settings["permissions"]["allow"].is_array());
assert_eq!(settings["hooks"]["UserPromptSubmit"][0]["matcher"], "*");
assert!(
settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" working")
);
assert!(settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" working"));
assert!(
settings["hooks"]["PermissionRequest"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" blocked")
);
assert!(settings["hooks"]["PostToolUse"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" working"));
assert!(
settings["hooks"]["PostToolUseFailure"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" working")
);
assert!(settings["hooks"]["SubagentStop"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" working"));
assert!(settings["hooks"]["Stop"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" idle"));
assert!(settings["hooks"]["SessionEnd"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" release"));
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_claude_is_idempotent_for_hook_entries() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
let claude_dir = home.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
std::env::set_var("HOME", &home);
install_claude().unwrap();
install_claude().unwrap();
let settings: Value =
serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
.unwrap();
assert_eq!(
settings["hooks"]["UserPromptSubmit"]
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
assert_eq!(
settings["hooks"]["PermissionRequest"]
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(
settings["hooks"]["PostToolUse"].as_array().unwrap().len(),
1
);
assert_eq!(
settings["hooks"]["PostToolUseFailure"]
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(
settings["hooks"]["SubagentStop"].as_array().unwrap().len(),
1
);
assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1);
assert_eq!(settings["hooks"]["SessionEnd"].as_array().unwrap().len(), 1);
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn uninstall_claude_removes_herdr_hooks_and_preserves_others() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
let claude_dir = home.join(".claude");
let hooks_dir = claude_dir.join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
fs::write(&hook_path, CLAUDE_HOOK_ASSET).unwrap();
fs::write(
claude_dir.join("settings.json"),
format!(
r#"{{"hooks":{{"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#,
hook_path.display(),
hook_path.display(),
hook_path.display(),
hook_path.display(),
hook_path.display(),
hook_path.display(),
hook_path.display(),
),
)
.unwrap();
std::env::set_var("HOME", &home);
let result = uninstall_claude().unwrap();
let settings: Value =
serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
.unwrap();
assert!(result.removed_hook_file);
assert!(result.updated_settings);
assert!(!result.hook_path.exists());
assert_eq!(
settings["hooks"]["UserPromptSubmit"][0]["hooks"]
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(
settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
"echo keep"
);
assert!(settings["hooks"].get("PermissionRequest").is_none());
assert!(settings["hooks"].get("PostToolUse").is_none());
assert!(settings["hooks"].get("PostToolUseFailure").is_none());
assert!(settings["hooks"].get("SubagentStop").is_none());
assert!(settings["hooks"].get("Stop").is_none());
assert!(settings["hooks"].get("SessionEnd").is_none());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_claude_errors_when_claude_dir_missing() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
fs::create_dir_all(&home).unwrap();
std::env::set_var("HOME", &home);
let err = install_claude().unwrap_err().to_string();
assert!(err.contains("claude directory not found"));
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_codex_writes_hook_and_updates_hooks_and_config() {
let _lock = 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"), "model = \"gpt-5.4\"\n").unwrap();
std::env::set_var("HOME", &home);
let installed = install_codex().unwrap();
let hook_content = fs::read_to_string(&installed.hook_path).unwrap();
let hooks: Value =
serde_json::from_str(&fs::read_to_string(&installed.hooks_path).unwrap()).unwrap();
let config = fs::read_to_string(&installed.config_path).unwrap();
assert_eq!(installed.hook_path, codex_dir.join(CODEX_HOOK_INSTALL_NAME));
assert_eq!(installed.hooks_path, codex_dir.join("hooks.json"));
assert_eq!(installed.config_path, codex_dir.join("config.toml"));
assert_eq!(hook_content, CODEX_HOOK_ASSET);
assert!(hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" idle"));
assert!(hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" working"));
assert!(hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" working"));
assert!(hooks["hooks"]["Stop"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(" idle"));
assert!(config.contains("model = \"gpt-5.4\""));
assert!(config.contains("[features]"));
assert!(config.contains("codex_hooks = true"));
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() {
let _lock = 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"),
"[features]\ncodex_hooks = false\nother = true\n",
)
.unwrap();
std::env::set_var("HOME", &home);
install_codex().unwrap();
install_codex().unwrap();
let hooks: Value =
serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
.unwrap();
let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
assert_eq!(hooks["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
assert_eq!(
hooks["hooks"]["UserPromptSubmit"].as_array().unwrap().len(),
1
);
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!(config.contains("other = 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 = env_lock();
let base = unique_base();
let home = base.join("home");
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).unwrap();
let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
fs::write(&hook_path, CODEX_HOOK_ASSET).unwrap();
fs::write(
codex_dir.join("hooks.json"),
format!(
r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"UserPromptSubmit":[{{"hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"Stop":[{{"hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}]}}}}"#,
hook_path.display(),
hook_path.display(),
hook_path.display(),
),
)
.unwrap();
fs::write(
codex_dir.join("config.toml"),
"[features]\ncodex_hooks = true\nother = true\n",
)
.unwrap();
std::env::set_var("HOME", &home);
let result = uninstall_codex().unwrap();
let hooks: Value =
serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
.unwrap();
let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
assert!(result.removed_hook_file);
assert!(result.updated_hooks);
assert!(!result.hook_path.exists());
assert!(hooks["hooks"].get("SessionStart").is_none());
assert!(hooks["hooks"].get("Stop").is_none());
assert_eq!(
hooks["hooks"]["UserPromptSubmit"][0]["hooks"]
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(
hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
"echo keep"
);
assert!(config.contains("codex_hooks = true"));
assert!(config.contains("other = true"));
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_codex_errors_when_config_dir_missing() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
fs::create_dir_all(&home).unwrap();
std::env::set_var("HOME", &home);
let err = install_codex().unwrap_err().to_string();
assert!(err.contains("codex config directory not found"));
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_opencode_writes_plugin_to_plugins_dir() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
let opencode_dir = home.join(".config/opencode");
fs::create_dir_all(&opencode_dir).unwrap();
std::env::set_var("HOME", &home);
let installed = install_opencode().unwrap();
let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap();
assert_eq!(
installed.plugin_path,
opencode_dir
.join("plugins")
.join(OPENCODE_PLUGIN_INSTALL_NAME)
);
assert_eq!(plugin_content, OPENCODE_PLUGIN_ASSET);
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn uninstall_opencode_removes_plugin_when_present() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
let opencode_dir = home.join(".config/opencode/plugins");
fs::create_dir_all(&opencode_dir).unwrap();
fs::write(
opencode_dir.join(OPENCODE_PLUGIN_INSTALL_NAME),
OPENCODE_PLUGIN_ASSET,
)
.unwrap();
std::env::set_var("HOME", &home);
let result = uninstall_opencode().unwrap();
assert!(result.removed_plugin);
assert!(!result.plugin_path.exists());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_opencode_errors_when_config_dir_missing() {
let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
fs::create_dir_all(&home).unwrap();
std::env::set_var("HOME", &home);
let err = install_opencode().unwrap_err().to_string();
assert!(err.contains("opencode config directory not found"));
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
}