fix: respect integration config dirs

refs #121
This commit is contained in:
Ogulcan Celik 2026-05-16 17:29:37 +03:00
parent 72770305d3
commit 170d567487
4 changed files with 149 additions and 17 deletions

View File

@ -4,6 +4,7 @@
### Fixed
- GitHub Copilot is now correctly detected when its process name is `copilot`.
- Integration installs now respect `PI_CODING_AGENT_DIR`, `CLAUDE_CONFIG_DIR`, and `CODEX_HOME` when choosing Pi, Claude Code, and Codex config paths.
- Split pane resize hit areas no longer overlap the first content column or row, making text selection work from the start of right and bottom panes.
## [0.5.9] - 2026-05-15

View File

@ -55,6 +55,14 @@ this writes the bundled pi extension to:
~/.pi/agent/extensions/herdr-agent-state.ts
```
if `PI_CODING_AGENT_DIR` is set, herdr uses that agent directory instead and writes to:
```text
$PI_CODING_AGENT_DIR/extensions/herdr-agent-state.ts
```
`~` is expanded in `PI_CODING_AGENT_DIR`.
pi is the cleanest integration. it already has an authoritative hook model, so herdr can get direct state reports over the socket api without guessing as much from the terminal.
bundled source: [`src/integration/assets/pi/herdr-agent-state.ts`](./src/integration/assets/pi/herdr-agent-state.ts)
@ -65,11 +73,7 @@ uninstall:
herdr integration uninstall pi
```
this removes:
```text
~/.pi/agent/extensions/herdr-agent-state.ts
```
this removes the same extension path herdr would install, using `PI_CODING_AGENT_DIR` when it is set.
## claude code
@ -84,6 +88,8 @@ this:
- writes the hook script to `~/.claude/hooks/herdr-agent-state.sh`
- updates `~/.claude/settings.json`
if `CLAUDE_CONFIG_DIR` is set, herdr uses that directory instead, for example `$CLAUDE_CONFIG_DIR/hooks/herdr-agent-state.sh` and `$CLAUDE_CONFIG_DIR/settings.json`. `~` is expanded in `CLAUDE_CONFIG_DIR`.
bundled source: [`src/integration/assets/claude/herdr-agent-state.sh`](./src/integration/assets/claude/herdr-agent-state.sh)
current hook mapping:
@ -109,10 +115,7 @@ uninstall:
herdr integration uninstall claude
```
this:
- removes `~/.claude/hooks/herdr-agent-state.sh`
- removes herdr-owned hook entries from `~/.claude/settings.json`
this removes the same hook path and settings entries herdr would install, using `CLAUDE_CONFIG_DIR` when it is set.
## codex
@ -129,6 +132,8 @@ this:
- ensures `hooks = true` under `[features]` in `~/.codex/config.toml`
- migrates the deprecated top-level `[features] codex_hooks = true` setting to `hooks = true`
if `CODEX_HOME` is set, herdr uses that directory instead, for example `$CODEX_HOME/herdr-agent-state.sh`, `$CODEX_HOME/hooks.json`, and `$CODEX_HOME/config.toml`. `~` is expanded in `CODEX_HOME`.
bundled source: [`src/integration/assets/codex/herdr-agent-state.sh`](./src/integration/assets/codex/herdr-agent-state.sh)
current hook mapping:
@ -153,9 +158,9 @@ herdr integration uninstall codex
this:
- removes `~/.codex/herdr-agent-state.sh`
- removes herdr-owned hook entries from `~/.codex/hooks.json`
- intentionally leaves `~/.codex/config.toml` alone
- removes the same hook path herdr would install, using `CODEX_HOME` when it is set
- removes herdr-owned hook entries from the matching `hooks.json`
- intentionally leaves the matching `config.toml` alone
that last point is deliberate: herdr does **not** try to guess whether `hooks = true` is still needed for some other codex hook setup.

View File

@ -13,12 +13,15 @@ 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 PI_INTEGRATION_VERSION: u32 = 1;
const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR";
const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh");
const CLAUDE_INTEGRATION_VERSION: u32 = 1;
const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR";
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 = 2;
const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
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;
@ -997,15 +1000,53 @@ fn make_executable(path: &Path) -> io::Result<()> {
}
fn pi_extension_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".pi/agent/extensions"))
Ok(
config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".pi", "agent"])?
.join("extensions"),
)
}
fn claude_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".claude"))
config_dir_from_env_or_home(CLAUDE_CONFIG_DIR_ENV_VAR, &[".claude"])
}
fn codex_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".codex"))
config_dir_from_env_or_home(CODEX_HOME_ENV_VAR, &[".codex"])
}
fn config_dir_from_env_or_home(
env_var: &str,
home_relative_segments: &[&str],
) -> io::Result<PathBuf> {
if let Some(value) = std::env::var_os(env_var).filter(|value| !value.is_empty()) {
return expand_tilde_path(PathBuf::from(value));
}
let mut path = home_dir()?;
for segment in home_relative_segments {
path.push(segment);
}
Ok(path)
}
fn expand_tilde_path(path: PathBuf) -> io::Result<PathBuf> {
let Some(raw) = path.to_str() else {
return Ok(path);
};
if raw == "~" {
return home_dir();
}
if let Some(rest) = raw
.strip_prefix("~/")
.or_else(|| raw.strip_prefix("~\\"))
.or_else(|| raw.strip_prefix('~'))
{
return Ok(home_dir()?.join(rest));
}
Ok(path)
}
fn opencode_dir() -> io::Result<PathBuf> {
@ -1028,7 +1069,14 @@ pub(crate) fn integration_env_lock() -> MutexGuard<'static, ()> {
mod tests {
use super::*;
fn clear_integration_path_env() {
std::env::remove_var(PI_CODING_AGENT_DIR_ENV_VAR);
std::env::remove_var(CLAUDE_CONFIG_DIR_ENV_VAR);
std::env::remove_var(CODEX_HOME_ENV_VAR);
}
fn unique_base() -> PathBuf {
clear_integration_path_env();
std::env::temp_dir().join(format!(
"herdr-integration-install-test-{}-{}",
std::process::id(),
@ -1058,6 +1106,42 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_pi_uses_pi_coding_agent_dir_env() {
let _lock = integration_env_lock();
let base = unique_base();
let agent_dir = base.join("custom-pi-agent");
let ext_dir = agent_dir.join("extensions");
fs::create_dir_all(&ext_dir).unwrap();
std::env::set_var(PI_CODING_AGENT_DIR_ENV_VAR, &agent_dir);
let path = install_pi().unwrap();
assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME));
clear_integration_path_env();
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_pi_expands_tilde_in_pi_coding_agent_dir_env() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let ext_dir = home.join("custom-pi-agent/extensions");
fs::create_dir_all(&ext_dir).unwrap();
std::env::set_var("HOME", &home);
std::env::set_var(PI_CODING_AGENT_DIR_ENV_VAR, "~/custom-pi-agent");
let path = install_pi().unwrap();
assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME));
std::env::remove_var("HOME");
clear_integration_path_env();
let _ = fs::remove_dir_all(base);
}
#[test]
fn uninstall_pi_removes_embedded_extension_when_present() {
let _lock = integration_env_lock();
@ -1208,6 +1292,26 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_claude_uses_claude_config_dir_env() {
let _lock = integration_env_lock();
let base = unique_base();
let claude_dir = base.join("custom-claude");
fs::create_dir_all(&claude_dir).unwrap();
std::env::set_var(CLAUDE_CONFIG_DIR_ENV_VAR, &claude_dir);
let installed = install_claude().unwrap();
assert_eq!(installed.settings_path, claude_dir.join("settings.json"));
assert_eq!(
installed.hook_path,
claude_dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)
);
clear_integration_path_env();
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_claude_is_idempotent_for_hook_entries() {
let _lock = integration_env_lock();
@ -1377,6 +1481,25 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_codex_uses_codex_home_env() {
let _lock = integration_env_lock();
let base = unique_base();
let codex_dir = base.join("custom-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(CODEX_HOME_ENV_VAR, &codex_dir);
let installed = install_codex().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"));
clear_integration_path_env();
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() {
let _lock = integration_env_lock();

View File

@ -575,10 +575,13 @@ mod tests {
let root_pane = ws.tabs[0].root_pane;
ws.tabs[0]
.pane_cwds
.insert(root_pane, PathBuf::from("/tmp/pion"));
.insert(root_pane, PathBuf::from("/herdr-test/pion"));
assert_eq!(ws.display_name(), "pion");
assert_eq!(ws.resolved_identity_cwd(), Some(PathBuf::from("/tmp/pion")));
assert_eq!(
ws.resolved_identity_cwd(),
Some(PathBuf::from("/herdr-test/pion"))
);
}
#[test]